diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b7e49e..49dda43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - run: npm run catalog:check - run: npm run typecheck - run: npm run lint - - run: npm run test:extractor + - run: npm test - run: npm run build cli: diff --git a/docs/design.md b/docs/design.md index 5731543..57ae140 100644 --- a/docs/design.md +++ b/docs/design.md @@ -116,10 +116,10 @@ Inter loaded from `rsms.me/inter` (self-hosted); `ss03` stylistic set enabled si ### Shape ``` ---radius-card 12px List rows, modals, stat cards ---radius-control 8px Buttons, inputs, list-row icons ---radius-tile 6px Icon tiles, list-row check ---radius-pill 9999px Primary CTAs (1Password pattern) +--radius-card 6px List rows, modals, stat cards, doctor cards +--radius-control 6px Buttons, inputs, sidebar items +--radius-tile 4px Avatars, icon tiles, chips +--radius-pill 9999px Badges + primary pill CTAs only ``` Pill primary buttons + sharp cards is a deliberate tension — primary actions look "elevated" by their roundedness; everything else stays grounded. diff --git a/package.json b/package.json index c14dfe2..c6a2aaa 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "prebuild": "npm run gen:catalog", "prebuild:renderer": "npm run gen:catalog", "catalog:check": "node scripts/check-catalog-stale.mjs", + "test": "npm run build:main && npm run test:unit", + "test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs", "test:extractor": "node scripts/test-extract-base-url.mjs", "package": "npm run build && electron-builder" }, @@ -47,5 +49,6 @@ }, "allowScripts": { "electron@35.7.5": true - } + }, + "packageManager": "pnpm@9.3.0+sha512.ee7b93e0c2bd11409c6424f92b866f31d3ea1bef5fbe47d3c7500cdc3c9668833d2e55681ad66df5b640c61fa9dc25d546efa54d76d7f8bf54b13614ac293631" } diff --git a/scripts/test-doctor.mjs b/scripts/test-doctor.mjs new file mode 100644 index 0000000..e0e0650 --- /dev/null +++ b/scripts/test-doctor.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * Unit tests for src/shared/doctor.ts (via dist build). + */ +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const doctorJs = join(root, 'dist/shared/doctor.js') + +if (!existsSync(doctorJs)) { + console.error('dist/shared/doctor.js missing — run npm run build:main first') + process.exit(1) +} + +const require = createRequire(import.meta.url) +const { analyzeLibrary, catalogBinaryName } = require(doctorJs) + +let failed = 0 +function assert(cond, msg) { + if (cond) console.log(` ✓ ${msg}`) + else { + failed++ + console.error(` ✗ ${msg}`) + } +} + +function nodeEntry() { + return { + id: 'node', + catalogId: 'node', + kind: 'runtime', + name: 'Node.js', + status: 'installed', + version: '26.0.0', + path: '/opt/homebrew/bin/node', + source: 'Homebrew', + packageManager: 'homebrew', + homebrew: 'formula', + primary: true, + installs: [ + { + path: '/opt/homebrew/bin/node', + realPath: '/opt/homebrew/Cellar/node/26.0.0/bin/node', + version: '26.0.0', + source: 'Homebrew', + packageManager: 'homebrew', + homebrew: 'formula', + primary: true, + }, + ], + } +} + +// --- catalogBinaryName --- +assert(catalogBinaryName('claude-code') === 'claude', 'claude-code → claude') +assert(catalogBinaryName('opencode') === 'opencode', 'opencode → opencode') +assert(catalogBinaryName('python') === 'python3', 'python → python3') + +// --- single install + node present: healthy --- +{ + const report = analyzeLibrary([ + { + id: 'claude-code', + catalogId: 'claude-code', + kind: 'harness', + name: 'Claude Code', + status: 'installed', + version: '2.1.226', + path: '/opt/homebrew/bin/claude', + source: 'Homebrew Cask', + packageManager: 'homebrew', + homebrew: 'cask', + primary: true, + installs: [ + { + path: '/opt/homebrew/bin/claude', + realPath: '/opt/homebrew/Caskroom/claude-code/2.1.226/claude', + version: '2.1.226', + source: 'Homebrew Cask', + packageManager: 'homebrew', + homebrew: 'cask', + primary: true, + }, + ], + }, + nodeEntry(), + ]) + assert(report.summary.error === 0, 'single install: no errors') + assert(report.summary.warn === 0, 'single install: no warns') + assert(report.findings.some((f) => f.severity === 'ok'), 'single install: healthy finding') +} + +// --- multi-install harness version skew → warn + fix actions --- +{ + const report = analyzeLibrary([ + nodeEntry(), + { + id: 'claude-code#1', + catalogId: 'claude-code', + kind: 'harness', + name: 'Claude Code', + status: 'installed', + version: '2.1.211', + path: '/opt/homebrew/bin/claude', + source: 'Homebrew Cask', + packageManager: 'homebrew', + homebrew: 'cask', + primary: true, + installs: [ + { + path: '/opt/homebrew/bin/claude', + realPath: '/a', + version: '2.1.211', + source: 'Homebrew Cask', + packageManager: 'homebrew', + homebrew: 'cask', + primary: true, + }, + { + path: '/usr/local/bin/claude', + realPath: '/b', + version: '2.0.76', + source: 'npm', + packageManager: 'npm', + homebrew: null, + primary: false, + }, + ], + }, + ]) + assert(report.summary.error === 0, 'version skew: not an error') + assert(report.summary.warn >= 1, 'version skew: at least one warn') + const shadow = report.findings.find((f) => f.id === 'shadow:claude-code') + assert(!!shadow, 'version skew: shadow finding exists') + const actions = (shadow?.resolutions ?? []).filter((r) => r.action) + assert(actions.some((r) => r.action?.type === 'upgrade'), 'has upgrade action') + assert(actions.some((r) => r.action?.type === 'reconfigure'), 'has reconfigure action') + assert(actions.some((r) => r.action?.type === 'uninstall'), 'has uninstall action for npm dup') +} + +// --- node multi-install is info, not warn --- +{ + const report = analyzeLibrary([ + { + id: 'node#1', + catalogId: 'node', + kind: 'runtime', + name: 'Node.js', + status: 'installed', + version: '26.7.0', + path: '/opt/homebrew/bin/node', + source: 'Homebrew', + packageManager: 'homebrew', + homebrew: 'formula', + primary: true, + installs: [ + { + path: '/opt/homebrew/bin/node', + realPath: '/a', + version: '26.7.0', + source: 'Homebrew', + packageManager: 'homebrew', + homebrew: 'formula', + primary: true, + }, + { + path: '/Users/x/.asdf/shims/node', + realPath: '/b', + version: '22.9.0', + source: 'asdf', + packageManager: 'asdf', + homebrew: null, + primary: false, + }, + ], + }, + ]) + const shadow = report.findings.find((f) => f.id === 'shadow:node') + assert(shadow?.severity === 'info', 'node multi-version is info') + assert(report.summary.error === 0, 'node multi: no errors') + assert(report.summary.warn === 0, 'node multi: no warns (node present)') +} + +// --- missing node → warn --- +{ + const report = analyzeLibrary([]) + assert(report.findings.some((f) => f.id === 'missing:node' && f.severity === 'warn'), 'missing node warns') +} + +if (failed > 0) { + console.error(`\n${failed} doctor assertion(s) failed`) + process.exit(1) +} +console.log('\nall doctor cases passed') diff --git a/scripts/test-homebrew.mjs b/scripts/test-homebrew.mjs new file mode 100644 index 0000000..31ba4c6 --- /dev/null +++ b/scripts/test-homebrew.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Unit tests for Homebrew channel detection (via dist build). + */ +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const libraryJs = join(root, 'dist/main/library.js') + +if (!existsSync(libraryJs)) { + console.error('dist/main/library.js missing — run npm run build:main first') + process.exit(1) +} + +const require = createRequire(import.meta.url) +const { detectHomebrew, sourceLabel, detectPackageManager } = require(libraryJs) + +let failed = 0 +function assert(cond, msg) { + if (cond) console.log(` ✓ ${msg}`) + else { + failed++ + console.error(` ✗ ${msg}`) + } +} + +const prefix = '/opt/homebrew' + +assert( + detectHomebrew('/opt/homebrew/bin/claude', '/opt/homebrew/Caskroom/claude-code/2.1.211/claude', prefix) === 'cask', + 'cask detection', +) +assert( + detectHomebrew('/opt/homebrew/bin/node', '/opt/homebrew/Cellar/node/26.7.0/bin/node', prefix) === 'formula', + 'formula detection', +) +assert( + detectHomebrew( + '/opt/homebrew/bin/opencode', + '/opt/homebrew/lib/node_modules/opencode-ai/bin/opencode.exe', + prefix, + ) === 'node', + 'homebrew node_modules → node channel', +) +assert( + detectHomebrew('/Users/x/.asdf/shims/claude', '/Users/x/.asdf/shims/claude', prefix) === null, + 'asdf is not homebrew', +) +assert( + detectHomebrew('/Users/x/.bun/bin/bun', '/Users/x/.bun/bin/bun', prefix) === null, + 'bun is not homebrew', +) + +assert( + sourceLabel('/opt/homebrew/bin/claude', '/opt/homebrew/Caskroom/claude-code/x/claude', 'cask') === 'Homebrew Cask', + 'source label cask', +) +assert( + sourceLabel('/opt/homebrew/bin/opencode', '/opt/homebrew/lib/node_modules/opencode-ai/bin/x', 'node') === + 'npm · Homebrew Node', + 'source label npm under brew node', +) + +assert( + detectPackageManager('/opt/homebrew/bin/claude', '/opt/homebrew/Caskroom/x', 'cask') === 'homebrew', + 'pm homebrew for cask', +) +assert( + detectPackageManager('/opt/homebrew/bin/opencode', '/opt/homebrew/lib/node_modules/x', 'node') === 'npm', + 'pm npm for brew-node globals', +) +assert( + detectPackageManager('/Users/x/.bun/bin/bun', '/Users/x/.bun/bin/bun', null) === 'bun', + 'pm bun', +) + +if (failed > 0) { + console.error(`\n${failed} homebrew assertion(s) failed`) + process.exit(1) +} +console.log('\nall homebrew detection cases passed') diff --git a/scripts/test-secrets.mjs b/scripts/test-secrets.mjs new file mode 100644 index 0000000..e8c9800 --- /dev/null +++ b/scripts/test-secrets.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * Unit tests for vault secret id helpers. + */ +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const secretsJs = join(root, 'dist/shared/secrets.js') + +if (!existsSync(secretsJs)) { + console.error('dist/shared/secrets.js missing — run npm run build:main first') + process.exit(1) +} + +const require = createRequire(import.meta.url) +const { secretIdForProvider, providerIdFromSecretId } = require(secretsJs) + +let failed = 0 +function assert(cond, msg) { + if (cond) console.log(` ✓ ${msg}`) + else { + failed++ + console.error(` ✗ ${msg}`) + } +} + +assert(secretIdForProvider('anthropic') === 'provider:anthropic:api_key', 'secret id format') +assert(secretIdForProvider('openai') === 'provider:openai:api_key', 'openai secret id') +assert(providerIdFromSecretId('provider:anthropic:api_key') === 'anthropic', 'parse anthropic') +assert(providerIdFromSecretId('provider:openai:api_key') === 'openai', 'parse openai') +assert(providerIdFromSecretId('random') === null, 'reject non-canonical') +assert(providerIdFromSecretId(secretIdForProvider('groq')) === 'groq', 'round-trip') + +if (failed > 0) { + console.error(`\n${failed} secrets assertion(s) failed`) + process.exit(1) +} +console.log('\nall secrets cases passed') diff --git a/scripts/test-versions.mjs b/scripts/test-versions.mjs new file mode 100644 index 0000000..968ee37 --- /dev/null +++ b/scripts/test-versions.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +/** + * Unit tests for version compare + changelog parsing (via dist build). + */ +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const versionsJs = join(root, 'dist/main/installer/versions.js') + +if (!existsSync(versionsJs)) { + console.error('dist/main/installer/versions.js missing — run npm run build:main first') + process.exit(1) +} + +const require = createRequire(import.meta.url) +const { + compareVersions, + parseChangelogMarkdown, + changelogBetween, +} = require(versionsJs) + +let failed = 0 +function assert(cond, msg) { + if (cond) console.log(` ✓ ${msg}`) + else { + failed++ + console.error(` ✗ ${msg}`) + } +} + +// compareVersions +assert(compareVersions('2.1.211', '2.1.226') < 0, '2.1.211 < 2.1.226') +assert(compareVersions('2.1.226', '2.1.211') > 0, '2.1.226 > 2.1.211') +assert(compareVersions('2.1.211', '2.1.211') === 0, 'equal versions') +assert(compareVersions('v2.0.0', '2.0.0') === 0, 'strips v prefix') + +// parse changelog +const md = `# Changelog + +## 2.1.226 + +- Bug fixes + +## 2.1.225 + +- Feature A +- Feature B + +## 2.1.211 + +- Old stuff +` +const map = parseChangelogMarkdown(md) +assert(map.has('2.1.226'), 'parsed 2.1.226') +assert(map.has('2.1.225'), 'parsed 2.1.225') +assert(map.get('2.1.225')?.includes('Feature A'), 'body preserved') + +// changelog between current → latest (exclusive of from) +const range = changelogBetween(map, '2.1.211', '2.1.226') +assert(range.some((e) => e.version === '2.1.226'), 'includes latest') +assert(range.some((e) => e.version === '2.1.225'), 'includes middle') +assert(!range.some((e) => e.version === '2.1.211'), 'excludes current/from') +assert(compareVersions(range[0].version, range[range.length - 1].version) >= 0, 'newest first') + +if (failed > 0) { + console.error(`\n${failed} versions assertion(s) failed`) + process.exit(1) +} +console.log('\nall versions cases passed') diff --git a/src/main/installer/index.ts b/src/main/installer/index.ts index 9a529aa..99e69e6 100644 --- a/src/main/installer/index.ts +++ b/src/main/installer/index.ts @@ -108,25 +108,55 @@ export interface InstallProgress { tool?: ToolInstallSpec } +export interface InstallOptions { + /** Pin a specific version when the method supports it (npm package@version). */ + version?: string + /** Prefer a specific install method type. */ + prefer?: ToolInstallMethod['type'] + /** Force reinstall / upgrade. */ + force?: boolean +} + export async function installHarness( spec: ToolInstallSpec, onProgress?: (p: InstallProgress) => void, + opts: InstallOptions = {}, ): Promise { - const method = spec.installMethods[0] + const method = + (opts.prefer ? spec.installMethods.find((m) => m.type === opts.prefer) : undefined) + ?? spec.installMethods[0] if (!method) throw new Error(`No install method defined for ${spec.id}`) onProgress?.({ phase: 'resolving', message: `Resolving ${method.type} install for ${spec.name}…`, tool: spec }) if (method.type === 'npm') { - onProgress?.({ phase: 'spawning', message: `npm install -g ${method.package}`, tool: spec }) + const pkg = opts.version ? `${method.package}@${opts.version}` : method.package + const args = ['install', '-g', pkg] + if (opts.force) args.push('--force') + onProgress?.({ phase: 'spawning', message: `npm install -g ${pkg}`, tool: spec }) const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm' - const result = await run(npmBin, ['install', '-g', method.package], { timeout: 5 * 60 * 1000 }) + const result = await run(npmBin, args, { timeout: 5 * 60 * 1000 }) if (result.code !== 0) { onProgress?.({ phase: 'error', message: result.stderr.trim() || `npm exited ${result.code}`, tool: spec }) throw new Error(`npm install failed: ${result.stderr.trim() || result.code}`) } } else if (method.type === 'brew') { - onProgress?.({ phase: 'spawning', message: `brew install ${method.formula}`, tool: spec }) - const result = await run('brew', ['install', method.formula], { timeout: 5 * 60 * 1000 }) + const token = method.formula + const caskProbe = await run('brew', ['info', '--json=v2', '--cask', token], { timeout: 15_000 }).catch(() => null) + const isCask = Boolean(caskProbe && caskProbe.code === 0 && /"token"\s*:/.test(caskProbe.stdout)) + let brewArgs: string[] + if (opts.force) { + brewArgs = isCask ? ['reinstall', '--cask', token] : ['reinstall', token] + } else { + const listArgs = isCask ? ['list', '--cask', token] : ['list', token] + const listed = await run('brew', listArgs, { timeout: 15_000 }).catch(() => null) + if (listed && listed.code === 0) { + brewArgs = isCask ? ['upgrade', '--cask', token] : ['upgrade', token] + } else { + brewArgs = isCask ? ['install', '--cask', token] : ['install', token] + } + } + onProgress?.({ phase: 'spawning', message: `brew ${brewArgs.join(' ')}`, tool: spec }) + const result = await run('brew', brewArgs, { timeout: 5 * 60 * 1000 }) if (result.code !== 0) { onProgress?.({ phase: 'error', message: result.stderr.trim() || `brew exited ${result.code}`, tool: spec }) throw new Error(`brew install failed: ${result.stderr.trim() || result.code}`) @@ -139,6 +169,46 @@ export async function installHarness( return discoverInstalled(spec) } +export interface UninstallOptions { + prefer?: ToolInstallMethod['type'] +} + +export async function uninstallHarness( + spec: ToolInstallSpec, + opts: UninstallOptions = {}, +): Promise<{ ok: boolean; message: string }> { + const methods = opts.prefer + ? spec.installMethods.filter((m) => m.type === opts.prefer) + : spec.installMethods + + const errors: string[] = [] + for (const method of methods) { + try { + if (method.type === 'npm') { + const npmBin = process.platform === 'win32' ? 'npm.cmd' : 'npm' + const result = await run(npmBin, ['uninstall', '-g', method.package], { timeout: 3 * 60 * 1000 }) + if (result.code === 0) { + return { ok: true, message: `Uninstalled ${method.package} via npm` } + } + errors.push(result.stderr.trim() || `npm uninstall exited ${result.code}`) + } else if (method.type === 'brew') { + const token = method.formula + const caskProbe = await run('brew', ['info', '--json=v2', '--cask', token], { timeout: 15_000 }).catch(() => null) + const isCask = Boolean(caskProbe && caskProbe.code === 0 && /"token"\s*:/.test(caskProbe.stdout)) + const args = isCask ? ['uninstall', '--cask', token] : ['uninstall', token] + const result = await run('brew', args, { timeout: 3 * 60 * 1000 }) + if (result.code === 0) { + return { ok: true, message: `Uninstalled ${token} via brew` } + } + errors.push(result.stderr.trim() || `brew uninstall exited ${result.code}`) + } + } catch (err) { + errors.push(err instanceof Error ? err.message : String(err)) + } + } + return { ok: false, message: errors.join('; ') || 'No uninstall method succeeded' } +} + export async function discoverAll(specs: ToolInstallSpec[]): Promise { return Promise.all(specs.map(discoverInstalled)) } diff --git a/src/main/installer/versions.ts b/src/main/installer/versions.ts new file mode 100644 index 0000000..829f9fa --- /dev/null +++ b/src/main/installer/versions.ts @@ -0,0 +1,264 @@ +/** + * Fetch available versions + changelog snippets for harness packages. + * Prefers npm registry (full history) and GitHub CHANGELOG.md for notes. + */ +import { spawn } from 'node:child_process' +import { findHarnessCatalog } from '../providers/harnesses' + +const TIMEOUT_MS = 15_000 + +export interface VersionInfo { + version: string + publishedAt?: string + latest?: boolean +} + +export interface ChangelogEntry { + version: string + body: string +} + +export interface VersionCheckResult { + ok: boolean + error?: string + harnessId: string + packageName: string | null + current: string | null + latest: string | null + outdated: boolean + versions: VersionInfo[] + /** Changelog sections between current → latest (or selected range). */ + changelog: ChangelogEntry[] + /** Compare URL when a GitHub repo is known. */ + compareUrl: string | null + homepage: string | null +} + +const GITHUB_CHANGELOG: Record = { + 'claude-code': 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md', + // opencode / codex may not have public changelogs in the same place +} + +const GITHUB_REPO: Record = { + 'claude-code': 'anthropics/claude-code', + opencode: 'anomalyco/opencode', + codex: 'openai/codex', +} + +function run(cmd: string, args: string[], timeout = TIMEOUT_MS): Promise<{ stdout: string; code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }) + const out: Buffer[] = [] + const timer = setTimeout(() => { + child.kill('SIGTERM') + reject(new Error('timeout')) + }, timeout) + child.stdout.on('data', (c: Buffer) => out.push(c)) + child.stderr.on('data', () => { /* ignore */ }) + child.on('error', reject) + child.on('close', (code) => { + clearTimeout(timer) + resolve({ stdout: Buffer.concat(out).toString('utf8'), code: code ?? -1 }) + }) + }) +} + +function npmPackageFor(harnessId: string): string | null { + const spec = findHarnessCatalog(harnessId) + const m = spec?.installMethods.find((x) => x.type === 'npm') + return m && m.type === 'npm' ? m.package : null +} + +function parseSemver(v: string): number[] | null { + const m = v.trim().replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/) + if (!m) return null + return [Number(m[1]), Number(m[2]), Number(m[3])] +} + +export function compareVersions(a: string, b: string): number { + const pa = parseSemver(a) + const pb = parseSemver(b) + if (!pa || !pb) return a.localeCompare(b) + for (let i = 0; i < 3; i++) { + if (pa[i] !== pb[i]) return pa[i] - pb[i] + } + return 0 +} + +/** Parse GitHub-style CHANGELOG.md into version → body map. */ +export function parseChangelogMarkdown(md: string): Map { + const map = new Map() + const parts = md.split(/^##\s+/m).slice(1) + for (const part of parts) { + const nl = part.indexOf('\n') + const header = (nl === -1 ? part : part.slice(0, nl)).trim() + const body = (nl === -1 ? '' : part.slice(nl + 1)).trim() + const ver = header.replace(/^v/, '').split(/\s+/)[0] + if (/^\d+\.\d+/.test(ver)) map.set(ver, body) + } + return map +} + +/** Changelog entries for versions (from, to] ordered newest-first. */ +export function changelogBetween( + map: Map, + from: string | null, + to: string | null, +): ChangelogEntry[] { + const entries = [...map.entries()] + .map(([version, body]) => ({ version, body })) + .sort((a, b) => compareVersions(b.version, a.version)) + + if (!to && !from) return entries.slice(0, 5) + + const out: ChangelogEntry[] = [] + for (const e of entries) { + if (to && compareVersions(e.version, to) > 0) continue + if (from && compareVersions(e.version, from) <= 0) break + out.push(e) + if (out.length >= 12) break + } + return out +} + +async function fetchNpmMeta(pkg: string): Promise<{ + latest: string + versions: VersionInfo[] + homepage: string | null +} | null> { + try { + const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg).replace('%40', '@')}`, { + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { Accept: 'application/json' }, + }) + if (!res.ok) return null + const data = (await res.json()) as { + 'dist-tags'?: { latest?: string } + versions?: Record + time?: Record + homepage?: string + } + const latest = data['dist-tags']?.latest ?? null + if (!latest || !data.versions) return null + const times = data.time ?? {} + const versions = Object.keys(data.versions) + .filter((v) => parseSemver(v)) + .sort((a, b) => compareVersions(b, a)) + .slice(0, 40) + .map((version) => ({ + version, + publishedAt: times[version], + latest: version === latest, + })) + return { latest, versions, homepage: data.homepage ?? null } + } catch { + return null + } +} + +async function fetchChangelogMd(url: string): Promise> { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }) + if (!res.ok) return new Map() + return parseChangelogMarkdown(await res.text()) + } catch { + return new Map() + } +} + +async function brewCaskLatest(token: string): Promise { + try { + const r = await run('brew', ['info', '--json=v2', '--cask', token], 12_000) + if (r.code !== 0) return null + const j = JSON.parse(r.stdout) as { casks?: Array<{ version?: string }> } + return j.casks?.[0]?.version ?? null + } catch { + return null + } +} + +export async function checkHarnessVersions( + harnessId: string, + currentVersion: string | null, +): Promise { + const catalogId = harnessId.includes('#') ? harnessId.split('#')[0] : harnessId + const pkg = npmPackageFor(catalogId) + const base: VersionCheckResult = { + ok: false, + harnessId: catalogId, + packageName: pkg, + current: currentVersion, + latest: null, + outdated: false, + versions: [], + changelog: [], + compareUrl: null, + homepage: null, + } + + if (!pkg) { + // Try brew-only latest + if (catalogId === 'claude-code') { + const latest = await brewCaskLatest('claude-code') + if (latest) { + return { + ...base, + ok: true, + latest, + outdated: currentVersion ? compareVersions(currentVersion, latest) < 0 : true, + versions: [{ version: latest, latest: true }], + homepage: 'https://claude.com/product/claude-code', + } + } + } + return { ...base, error: 'No version source for this harness' } + } + + const meta = await fetchNpmMeta(pkg) + if (!meta) return { ...base, error: `Could not fetch npm metadata for ${pkg}` } + + const changelogUrl = GITHUB_CHANGELOG[catalogId] + const map = changelogUrl ? await fetchChangelogMd(changelogUrl) : new Map() + const changelog = changelogBetween(map, currentVersion, meta.latest) + + const repo = GITHUB_REPO[catalogId] + const compareUrl = + repo && currentVersion && meta.latest && currentVersion !== meta.latest + ? `https://github.com/${repo}/compare/v${currentVersion}...v${meta.latest}` + : repo + ? `https://github.com/${repo}` + : null + + // Prefer brew cask latest when higher/different for claude-code display note + let latest = meta.latest + if (catalogId === 'claude-code') { + const brewLatest = await brewCaskLatest('claude-code') + if (brewLatest && compareVersions(brewLatest, latest) > 0) latest = brewLatest + } + + return { + ok: true, + harnessId: catalogId, + packageName: pkg, + current: currentVersion, + latest, + outdated: currentVersion ? compareVersions(currentVersion, latest) < 0 : Boolean(latest), + versions: meta.versions, + changelog, + compareUrl, + homepage: meta.homepage, + } +} + +/** Changelog for an arbitrary version range (for UI version selector). */ +export async function changelogForRange( + harnessId: string, + from: string | null, + to: string | null, +): Promise { + const catalogId = harnessId.includes('#') ? harnessId.split('#')[0] : harnessId + const url = GITHUB_CHANGELOG[catalogId] + if (!url) return [] + const map = await fetchChangelogMd(url) + return changelogBetween(map, from, to) +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index c617c63..c2da46f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,10 +4,13 @@ import { createSafeStorageBackend } from './secrets/safestorage' import { maskSecret } from './secrets/backend' import type { SecretBackend } from './secrets/backend' import { runProbe } from './probes' -import { discoverAll, installHarness } from './installer' +import { libraryDiscover } from './library' +import { discoverAll, installHarness, uninstallHarness } from './installer' +import { changelogForRange, checkHarnessVersions } from './installer/versions' import { HARNESS_CATALOG, findHarness } from './providers/harnesses' import { PROVIDER_CATALOG, findProvider } from './providers/catalog' import { applyWiring } from './wiring' +import { modelPresetsFor, resetHarnessConfig, setHarnessModel } from './wiring/configure' import { GATEWAY_CATALOG } from './gateways' import { selectAnthropicEndpoint, selectOpenAIEndpoint, unresolvedPlaceholders } from './gateways/resolve' import { CHANNELS } from '../shared/channels' @@ -45,7 +48,9 @@ interface GatewayApplyRequest { gatewayId: string | null providerId: string baseUrl: string - apiKey: string + /** Plaintext key — prefer secretId so the renderer never holds the secret. */ + apiKey?: string + secretId?: string harnessIds: string[] label?: string } @@ -102,21 +107,7 @@ export function registerIpcHandlers(): void { ipcMain.handle(CHANNELS.harnessList, () => HARNESS_CATALOG) - ipcMain.handle(CHANNELS.libraryList, async () => { - const installed = await discoverAll(HARNESS_CATALOG) - return HARNESS_CATALOG.map((entry) => { - const found = installed.find((i) => i.spec.id === entry.id) - const status = found?.path - ? 'installed' - : (entry.status === 'installed' ? 'available' : entry.status) - return { - ...entry, - status, - exec: found?.path ?? null, - version: found?.version ?? null, - } - }) - }) + ipcMain.handle(CHANNELS.libraryList, () => libraryDiscover()) ipcMain.handle(CHANNELS.harnessDiscover, async () => { const installed = await discoverAll(HARNESS_CATALOG) @@ -126,52 +117,114 @@ export function registerIpcHandlers(): void { }, {}) }) - ipcMain.handle(CHANNELS.harnessInstall, async (_evt, id: string) => { - const spec = findHarness(id) + ipcMain.handle(CHANNELS.harnessInstall, async (_evt, req: string | { id: string; version?: string; prefer?: 'npm' | 'brew'; force?: boolean }) => { + const id = typeof req === 'string' ? req : req.id + const catalogId = id.includes('#') ? id.split('#')[0] : id + const spec = findHarness(catalogId) if (!spec) return { ok: false as const, error: `Unknown harness "${id}"` } try { - const result = await installHarness(spec) + const opts = typeof req === 'string' ? {} : { version: req.version, prefer: req.prefer, force: req.force } + const result = await installHarness(spec, undefined, opts) return { ok: true as const, tool: result } } catch (err) { return { ok: false as const, error: errMsg(err) } } }) + ipcMain.handle(CHANNELS.harnessUninstall, async (_evt, req: { id: string; prefer?: 'npm' | 'brew' }) => { + const catalogId = req.id.includes('#') ? req.id.split('#')[0] : req.id + const spec = findHarness(catalogId) + if (!spec) return { ok: false as const, error: `Unknown harness "${req.id}"` } + try { + return await uninstallHarness(spec, { prefer: req.prefer }) + } catch (err) { + return { ok: false as const, message: errMsg(err) } + } + }) + + ipcMain.handle(CHANNELS.harnessVersions, async (_evt, req: { id: string; current?: string | null; from?: string | null; to?: string | null }) => { + const catalogId = req.id.includes('#') ? req.id.split('#')[0] : req.id + try { + const check = await checkHarnessVersions(catalogId, req.current ?? null) + if (req.from || req.to) { + check.changelog = await changelogForRange(catalogId, req.from ?? null, req.to ?? req.current ?? null) + } + return check + } catch (err) { + return { + ok: false as const, + error: errMsg(err), + harnessId: catalogId, + packageName: null, + current: req.current ?? null, + latest: null, + outdated: false, + versions: [], + changelog: [], + compareUrl: null, + homepage: null, + } + } + }) + ipcMain.handle(CHANNELS.harnessConfigShow, async (_evt, id: string) => { - const spec = findHarness(id) + const catalogId = id.includes('#') ? id.split('#')[0] : id + const spec = findHarness(catalogId) if (!spec) return { ok: false as const, error: `Unknown harness "${id}"`, harnessId: id, exists: false } const cfg = - id === 'claude-code' + catalogId === 'claude-code' ? { path: claudeCodeSettingsPath(), editor: 'jsonEnv' as const } - : id === 'codex' + : catalogId === 'codex' ? { path: codexConfigPath(), editor: 'toml' as const } - : id === 'opencode' + : catalogId === 'opencode' ? { path: openCodeConfigPath(), editor: 'jsonProvider' as const } : null if (!cfg) return { ok: false as const, harnessId: id, exists: false, error: 'No config editor for this harness' } let excerpt: string | undefined let exists = false + let activeModel: string | null = null try { const blob = await readFile(cfg.path, 'utf8') exists = true excerpt = blob.length > 1200 ? blob.slice(0, 1200) + '\n…' : blob + if (cfg.editor === 'jsonEnv' || cfg.editor === 'jsonProvider') { + try { + const j = JSON.parse(blob) as { model?: string } + activeModel = typeof j.model === 'string' ? j.model : null + } catch { /* ignore */ } + } else if (cfg.editor === 'toml') { + const m = blob.match(/^model\s*=\s*"?([^"\n]+)"?/m) + activeModel = m?.[1]?.trim() ?? null + } } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err } return { ok: true as const, - harnessId: id, + harnessId: catalogId, path: cfg.path, exists, excerpt, + activeModel, + modelPresets: modelPresetsFor(catalogId), notes: [ - cfg.editor === 'jsonEnv' ? 'Hoist writes the `env` block; your other settings are preserved.' : '', - cfg.editor === 'toml' ? 'Hoist writes a `[model_providers.]` block; surrounding TOML is preserved.' : '', - cfg.editor === 'jsonProvider' ? 'Hoist writes a `provider.` block; existing providers are preserved.' : '', + cfg.editor === 'jsonEnv' ? 'Hoist writes the `env` block and optional `model`; your other settings are preserved.' : '', + cfg.editor === 'toml' ? 'Hoist writes a `[model_providers.]` block and `model =`; surrounding TOML is preserved.' : '', + cfg.editor === 'jsonProvider' ? 'Hoist writes a `provider.` block and `model`; existing providers are preserved.' : '', ].filter(Boolean), } }) + ipcMain.handle(CHANNELS.harnessConfigSet, async (_evt, req: { harnessId: string; model?: string | null }) => { + const harnessId = req.harnessId.includes('#') ? req.harnessId.split('#')[0] : req.harnessId + return setHarnessModel({ harnessId, model: req.model }) + }) + + ipcMain.handle(CHANNELS.harnessConfigReset, async (_evt, req: { harnessId: string; clearModel?: boolean }) => { + const harnessId = req.harnessId.includes('#') ? req.harnessId.split('#')[0] : req.harnessId + return resetHarnessConfig({ harnessId, clearModel: req.clearModel }) + }) + ipcMain.handle(CHANNELS.providerList, () => PROVIDER_CATALOG) ipcMain.handle(CHANNELS.gatewayList, () => @@ -190,6 +243,15 @@ export function registerIpcHandlers(): void { if (!provider) { return { ok: false as const, error: `Unknown provider "${req.providerId}"` } } + + let apiKey = req.apiKey + if (!apiKey && req.secretId) { + apiKey = (await getBackend().get(req.secretId)) ?? undefined + } + if (!apiKey) { + return { ok: false as const, error: 'No API key available. Save a key in Provider keys first, or pass apiKey.' } + } + const gateway = req.gatewayId ? GATEWAY_CATALOG.find((g) => g.id === req.gatewayId) ?? null : null const effectiveBaseUrl = (() => { @@ -204,12 +266,16 @@ export function registerIpcHandlers(): void { .map((id) => findHarness(id)) .filter((h): h is NonNullable => !!h) + if (harnesses.length === 0) { + return { ok: false as const, error: 'Select at least one harness to wire.' } + } + const wiring: { harnessId: string; harnessName: string; ok: boolean; error?: string; path?: string; note?: string; envHint?: Record }[] = [] for (const harness of harnesses) { try { const results = await applyWiring({ - apiKey: req.apiKey, + apiKey, baseUrl: effectiveBaseUrl, harness, provider, @@ -230,7 +296,13 @@ export function registerIpcHandlers(): void { } } - return { ok: true as const, wiring, effectiveBaseUrl } + const anyOk = wiring.some((w) => w.ok) + return { + ok: anyOk, + error: anyOk ? undefined : 'Wiring failed for all selected harnesses.', + wiring, + effectiveBaseUrl, + } }) ipcMain.handle(CHANNELS.probeRun, async (_evt, req: ProbeRequest) => { diff --git a/src/main/library.ts b/src/main/library.ts new file mode 100644 index 0000000..33cf840 --- /dev/null +++ b/src/main/library.ts @@ -0,0 +1,629 @@ +import { spawn } from 'node:child_process' +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { homedir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import which from 'which' +import { HARNESS_CATALOG } from './providers/harnesses' +import { RUNTIME_CATALOG, type RuntimeCatalogEntry } from './providers/runtimes' + +/** + * Live Library discovery. + * + * - Harnesses (claude / opencode / codex) via `which -a` + config probes. + * - Runtimes & package managers (node, bun, npm, python, go, rust, …). + * - Multiple installs of the same tool, de-duped by realpath. + * - Detects whether a JS harness was installed via npm, bun, pnpm, or Homebrew. + */ + +const TIMEOUT_MS = 8000 + +export type LibraryKind = 'harness' | 'runtime' | 'package-manager' + +/** How tightly this install is owned by Homebrew. */ +export type HomebrewChannel = + | 'formula' // $(brew --prefix)/Cellar/… + | 'cask' // $(brew --prefix)/Caskroom/… + | 'node' // npm -g into Homebrew's node prefix + | null + +export interface LibraryInstall { + path: string + realPath: string + version: string | null + source: string + /** npm | bun | pnpm | yarn | homebrew | asdf | … when detectable. */ + packageManager: string | null + /** Non-null when this install lives under the Homebrew prefix. */ + homebrew: HomebrewChannel + primary: boolean +} + +export interface LibraryEntry { + id: string + catalogId: string + kind: LibraryKind + name: string + avatar: string + status: 'installed' | 'installing' | 'available' | 'failed' | 'deprecated' + exec: string | null + version: string | null + path: string | null + source: string | null + /** How this binary was installed / which PM owns it. */ + packageManager: string | null + homebrew: HomebrewChannel + primary: boolean + installs: LibraryInstall[] + config: { + activeModel: string | null + provider: string | null + authStatus: string | null + installDir: string | null + models: string[] + } + desc: string +} + +function run( + cmd: string, + args: string[], + opts: { timeout?: number } = {}, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const out: Buffer[] = [] + const err: Buffer[] = [] + let timer: NodeJS.Timeout | undefined + if (opts.timeout) { + timer = setTimeout(() => { + child.kill('SIGTERM') + reject(new Error('timeout')) + }, opts.timeout) + } + child.stdout.on('data', (c: Buffer) => out.push(c)) + child.stderr.on('data', (c: Buffer) => err.push(c)) + child.on('error', reject) + child.on('close', (code) => { + if (timer) clearTimeout(timer) + resolve({ + stdout: Buffer.concat(out).toString('utf8'), + stderr: Buffer.concat(err).toString('utf8'), + code: code ?? -1, + }) + }) + }) +} + +function isBrokenProbeOutput(raw: string): boolean { + const s = raw.toLowerCase() + return ( + s.includes('no version is set') || + s.includes('unable to locate a java runtime') || + s.includes('command not found') || + s.includes('not found') || + s.includes('error:') || + s.includes('please run `asdf') + ) +} + +function cleanVersion(raw: string | null, name: string): string | null { + if (!raw) return null + let trimmed = raw.trim().split('\n')[0].trim() + if (!trimmed || isBrokenProbeOutput(trimmed)) return null + const suffix = `(${name})` + if (trimmed.endsWith(suffix)) trimmed = trimmed.slice(0, -suffix.length).trim() + trimmed = trimmed.replace(/^codex-cli\s+/i, '') + trimmed = trimmed.replace(/^v(?=\d)/, '') + trimmed = trimmed.replace(/^Python\s+/i, '') + trimmed = trimmed.replace(/^deno\s+/i, '') + trimmed = trimmed.replace(/\s+\(stable.*$/i, '') // deno 2.9.1 (stable, …) + trimmed = trimmed.replace(/^go\s+version\s+go/i, '') + trimmed = trimmed.replace(/^pip\s+/i, '') + trimmed = trimmed.replace(/^ruby\s+/i, '') + // Prefer leading semver-ish token (handles ruby 2.6.10p210 (…), deno extras) + const lead = trimmed.match(/^(\d+\.\d+\.\d+\S*|\d+\.\d+\S*)/) + if (lead && (/\s/.test(trimmed) || /p\d+/.test(lead[1]))) { + // keep patch-level ruby builds like 2.6.10p210, drop trailing junk + if (/\(/.test(trimmed)) trimmed = lead[1] + } + // pip long line → version only + const pipVer = trimmed.match(/^(\d+\.\d+(?:\.\d+)?)/) + if (pipVer && /from\s+\//i.test(trimmed)) trimmed = pipVer[1] + // openjdk version "21.0.1" … + const jver = trimmed.match(/version\s+"([^"]+)"/i) + if (jver) trimmed = jver[1] + return trimmed || null +} + +function resolveReal(path: string): string { + try { + return realpathSync(path) + } catch { + return path + } +} + +/** Cached `brew --prefix` (null when brew is missing). */ +let brewPrefixCache: string | null | undefined + +async function getBrewPrefix(): Promise { + if (brewPrefixCache !== undefined) return brewPrefixCache + try { + const r = await run('brew', ['--prefix'], { timeout: 3000 }) + if (r.code === 0) { + const p = r.stdout.trim() + brewPrefixCache = p || null + return brewPrefixCache + } + } catch { + // no brew + } + // Fallback to common prefixes when brew isn't on PATH but files exist + for (const candidate of ['/opt/homebrew', '/usr/local']) { + if (existsSync(join(candidate, 'Cellar')) || existsSync(join(candidate, 'Caskroom'))) { + brewPrefixCache = candidate + return brewPrefixCache + } + } + brewPrefixCache = null + return null +} + +/** + * Classify Homebrew ownership. + * - formula: Cellar/… (brew install foo) + * - cask: Caskroom/… (brew install --cask foo) + * - node: under brew prefix node_modules (npm i -g using Homebrew Node) + */ +export function detectHomebrew( + path: string, + realPath: string, + brewPrefix: string | null, +): HomebrewChannel { + const hay = `${path}\n${realPath}` + if (hay.includes('/Caskroom/')) return 'cask' + if (hay.includes('/Cellar/')) return 'formula' + if (brewPrefix) { + const under = + realPath === brewPrefix || + realPath.startsWith(`${brewPrefix}/`) || + path === brewPrefix || + path.startsWith(`${brewPrefix}/`) + if (under) { + if (hay.includes('node_modules') || hay.includes('npm-cli') || hay.includes('npx-cli')) { + return 'node' + } + // brew's opt/ symlinks resolve into Cellar already; leftover prefix bins + if (hay.includes(`${brewPrefix}/opt/`)) return 'formula' + } + } + return null +} + +export function sourceLabel( + path: string, + realPath: string, + homebrew: HomebrewChannel, +): string { + if (homebrew === 'formula') return 'Homebrew' + if (homebrew === 'cask') return 'Homebrew Cask' + if (homebrew === 'node') return 'npm · Homebrew Node' + const p = `${path} ${realPath}`.toLowerCase() + if (p.includes('.asdf')) return 'asdf' + if (p.includes('.nvm') || p.includes('/nvm/')) return 'nvm' + if (p.includes('.fnm') || p.includes('/fnm/')) return 'fnm' + if (p.includes('.volta')) return 'Volta' + if (p.includes('.bun/')) return 'Bun' + if (p.includes('.opencode')) return 'OpenCode installer' + if (p.includes('node_modules')) return 'npm' + if (p.includes('.local/bin')) return 'local' + if (p.includes('/usr/bin/') || p.includes('xcode.app')) return 'System' + if (p.includes('/opt/homebrew/bin') || p.includes('/usr/local/bin')) return 'PATH' + return basename(dirname(path)) || 'PATH' +} + +/** + * Infer which package manager (or channel) owns this binary install. + */ +export function detectPackageManager( + path: string, + realPath: string, + homebrew: HomebrewChannel, +): string | null { + if (homebrew === 'formula' || homebrew === 'cask') return 'homebrew' + if (homebrew === 'node') return 'npm' + const p = `${path} ${realPath}`.toLowerCase() + if (p.includes('.bun/') || p.includes('/bun/bin') || /\/bun$/.test(realPath)) return 'bun' + if (p.includes('/pnpm') || p.endsWith('pnpm')) return 'pnpm' + if (p.includes('/yarn') || p.endsWith('yarn')) return 'yarn' + if (p.includes('.asdf')) return 'asdf' + if (p.includes('.nvm')) return 'nvm' + if (p.includes('node_modules') || p.includes('npm-cli') || p.includes('npx-cli')) return 'npm' + if (p.includes('.opencode')) return 'opencode' + if (p.includes('pip') || p.includes('site-packages')) return 'pip' + if (p.includes('cargo') || p.includes('.rustup') || p.includes('.cargo')) return 'cargo' + return null +} + +async function findAllBinaries(binary: string): Promise { + try { + const r = await run('which', ['-a', binary], { timeout: 3000 }) + if (r.code === 0) { + const hits = r.stdout + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('which:')) + if (hits.length > 0) return [...new Set(hits)] + } + } catch { + // fall through + } + try { + const p = await which(binary) + return p ? [p] : [] + } catch { + return [] + } +} + +async function tryReadJson(path: string): Promise { + try { + if (!existsSync(path)) return null + return JSON.parse(readFileSync(path, 'utf8')) + } catch { + return null + } +} + +async function readClaudeState(binaryPath: string): Promise { + const env = process.env + const fromEnv = { + activeModel: env.COPILOT_MODEL || env.ANTHROPIC_MODEL || null, + provider: env.COPILOT_PROVIDER_TYPE || env.ANTHROPIC_BASE_URL || null, + } + let authStatus: string | null = null + try { + const r = await run(binaryPath, ['auth', 'status'], { timeout: TIMEOUT_MS }) + if (r.code === 0) { + try { + const j = JSON.parse(r.stdout) as { + loggedIn?: boolean + subscriptionType?: string + email?: string + } + authStatus = j.loggedIn + ? `${j.subscriptionType ?? 'logged in'} (${j.email ?? '—'})` + : 'not logged in' + } catch { + authStatus = r.stdout.split('\n')[0].trim() || null + } + } else { + authStatus = 'auth unavailable' + } + } catch { + authStatus = null + } + const settings = (await tryReadJson(join(homedir(), '.claude', 'settings.json'))) as + | { model?: string; activeModel?: string; installDir?: string } + | null + let activeModel = fromEnv.activeModel + if (!activeModel && settings?.model) activeModel = settings.model + else if (!activeModel && settings?.activeModel) activeModel = settings.activeModel + return { + activeModel, + provider: fromEnv.provider, + authStatus, + installDir: typeof settings?.installDir === 'string' ? settings.installDir : null, + models: [], + } +} + +async function readOpencodeState(binary: string): Promise { + let models: string[] = [] + try { + const r = await run(binary, ['models'], { timeout: TIMEOUT_MS }) + if (r.code === 0) { + models = r.stdout + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('Available')) + .slice(0, 24) + } + } catch { + // ignore + } + const cfg = (await tryReadJson(join(homedir(), '.config', 'opencode', 'opencode.json'))) as + | { provider?: Record }> } + | null + let activeModel: string | null = null + let providerName: string | null = null + let firstProviderName: string | null = null + if (cfg?.provider) { + for (const [id, p] of Object.entries(cfg.provider)) { + if (firstProviderName === null && p.name) firstProviderName = p.name + for (const modelId of Object.keys(p.models || {})) { + if (activeModel === null) { + activeModel = modelId + providerName = p.name ?? id + } + } + } + } + return { + activeModel, + provider: providerName ?? firstProviderName, + authStatus: 'opencode.config', + installDir: null, + models, + } +} + +async function readCodexState(): Promise { + const cfg = (await tryReadJson(join(homedir(), '.codex', 'config.json'))) as + | { model?: string; provider?: string; providers?: Record } + | null + const parsedToml: { model?: string; provider?: string } = {} + try { + const raw = readFileSync(join(homedir(), '.codex', 'config.toml'), 'utf8') + for (const key of ['model', 'provider'] as const) { + const m = raw.match(new RegExp(`^${key}\\s*=\\s*"?([^"\\n]+)"?`, 'm')) + if (m) parsedToml[key] = m[1].trim() + } + } catch { + // ignore + } + const activeModel = parsedToml.model ?? cfg?.model ?? null + const provider = parsedToml.provider ?? cfg?.provider ?? null + const modelsAll: string[] = [] + if (cfg?.providers) { + for (const p of Object.values(cfg.providers)) { + if (p && Array.isArray(p.models)) { + for (const m of p.models) if (typeof m === 'string') modelsAll.push(m) + } + } + } + return { + activeModel, + provider, + authStatus: existsSync(join(homedir(), '.codex', 'auth.json')) ? 'auth.json present' : 'codex.config', + installDir: join(homedir(), '.codex'), + models: activeModel ? [activeModel, ...modelsAll.filter((m) => m !== activeModel)] : modelsAll, + } +} + +async function probeVersion( + path: string, + displayName: string, + versionArgs: string[], +): Promise { + try { + const r = await run(path, versionArgs, { timeout: TIMEOUT_MS }) + // java -version writes to stderr + const raw = (r.stdout || r.stderr || '').split('\n')[0] || null + if (r.code === 0 || raw) return cleanVersion(raw, displayName) + } catch { + // ignore + } + return null +} + +async function collectInstalls( + binaries: string[], + displayName: string, + versionArgs: string[] = ['--version'], +): Promise { + const brewPrefix = await getBrewPrefix() + const pathHits: string[] = [] + for (const b of binaries) { + const hits = await findAllBinaries(b) + for (const h of hits) pathHits.push(h) + } + + const installs: LibraryInstall[] = [] + const seenReal = new Set() + for (const p of pathHits) { + const realPath = resolveReal(p) + if (seenReal.has(realPath)) continue + seenReal.add(realPath) + const version = await probeVersion(p, displayName, versionArgs) + // Drop asdf/nvm shims (and macOS Java stubs) that don't resolve to a real tool. + if (!version) continue + const homebrew = detectHomebrew(p, realPath, brewPrefix) + installs.push({ + path: p, + realPath, + version, + homebrew, + source: sourceLabel(p, realPath, homebrew), + packageManager: detectPackageManager(p, realPath, homebrew), + primary: installs.length === 0, + }) + } + // Recompute primary after filtering + installs.forEach((inst, i) => { inst.primary = i === 0 }) + return installs +} + +function emptyConfig(authStatus: string): LibraryEntry['config'] { + return { + activeModel: null, + provider: null, + authStatus, + installDir: null, + models: [], + } +} + +function entriesFromInstalls( + base: { + catalogId: string + kind: LibraryKind + name: string + avatar: string + desc: string + }, + installs: LibraryInstall[], + config: LibraryEntry['config'], +): LibraryEntry[] { + if (installs.length === 0) { + return [{ + id: base.catalogId, + catalogId: base.catalogId, + kind: base.kind, + name: base.name, + avatar: base.avatar, + status: 'available', + exec: null, + version: null, + path: null, + source: null, + packageManager: null, + homebrew: null, + primary: true, + installs: [], + config, + desc: base.desc, + }] + } + + const multi = installs.length > 1 + return installs.map((inst, i) => ({ + id: multi ? `${base.catalogId}#${i + 1}` : base.catalogId, + catalogId: base.catalogId, + kind: base.kind, + name: base.name, + avatar: base.avatar, + status: 'installed' as const, + exec: inst.path, + version: inst.version, + path: inst.path, + source: inst.source, + packageManager: inst.packageManager, + homebrew: inst.homebrew, + primary: inst.primary, + installs, + config, + desc: base.desc, + })) +} + +async function discoverHarnesses(): Promise { + const out: LibraryEntry[] = [] + for (const entry of HARNESS_CATALOG) { + const binary = + entry.installMethods[0]?.type === 'npm' + ? (entry.installMethods[0] as { binary?: string }).binary ?? entry.id + : entry.id + + const installs = await collectInstalls([binary], entry.name, ['--version']) + + let config = emptyConfig(installs.length ? 'binary not configured' : 'binary not on PATH') + if (installs.length > 0) { + try { + const primaryPath = installs[0].path + if (entry.id === 'claude-code') config = await readClaudeState(primaryPath) + else if (entry.id === 'opencode') config = await readOpencodeState(primaryPath) + else if (entry.id === 'codex') config = await readCodexState() + } catch { + // keep defaults + } + } + + out.push(...entriesFromInstalls( + { + catalogId: entry.id, + kind: 'harness', + name: entry.name, + avatar: entry.avatar, + desc: entry.description, + }, + installs, + config, + )) + } + return out +} + +async function discoverRuntimes(): Promise { + const out: LibraryEntry[] = [] + for (const entry of RUNTIME_CATALOG) { + const installs = await collectInstalls( + entry.binaries, + entry.name, + entry.versionArgs ?? ['--version'], + ) + + // For package-manager entries, the tool IS the package manager. + // Homebrew formula/cask → channel homebrew; Homebrew's bundled npm → still npm. + const enriched = installs.map((inst) => ({ + ...inst, + packageManager: + entry.kind === 'package-manager' + ? (inst.homebrew === 'formula' || inst.homebrew === 'cask' + ? 'homebrew' + : (inst.packageManager ?? entry.id)) + : inst.packageManager, + })) + + const config = emptyConfig( + enriched.length > 0 + ? (enriched[0].packageManager ? `via ${enriched[0].packageManager}` : enriched[0].source) + : 'binary not on PATH', + ) + + out.push(...entriesFromInstalls( + { + catalogId: entry.id, + kind: entry.kind, + name: entry.name, + avatar: entry.avatar, + desc: entry.description, + }, + enriched, + config, + )) + } + return out +} + +/** + * Detect which JS package managers are present and which is PATH-primary. + * Surfaced on harness rows as a hint, and as its own library entries. + */ +export async function detectJsPackageManagers(): Promise<{ + installed: string[] + primary: string | null +}> { + const candidates = ['bun', 'pnpm', 'yarn', 'npm'] as const + const installed: string[] = [] + let primary: string | null = null + for (const pm of candidates) { + const hits = await findAllBinaries(pm) + if (hits.length > 0) { + installed.push(pm) + if (!primary) primary = pm + } + } + // Prefer explicit ordering for "primary" when multiple are first-on-PATH + // equal — use whichever appears first when resolving a dummy; already PATH order + // via which -a first hit of each, then pick by common preference bun > pnpm > yarn > npm + // only if we want preference over PATH. Stick to PATH order of first found above. + return { installed, primary } +} + +export async function libraryDiscover(): Promise { + const [harnesses, runtimes] = await Promise.all([ + discoverHarnesses(), + discoverRuntimes(), + ]) + + // Annotate harness installs with a clearer packageManager when missing. + // (detectPackageManager already covers most cases via path.) + return [...harnesses, ...runtimes] +} + +// Re-export for tests / IPC helpers +export type { RuntimeCatalogEntry } diff --git a/src/main/probes/index.ts b/src/main/probes/index.ts index bd4008f..ad83e10 100644 --- a/src/main/probes/index.ts +++ b/src/main/probes/index.ts @@ -1,9 +1,12 @@ import { probeAnthropic } from './anthropic' +import { probeOpenAI } from './openai' import type { ProbeResult } from './types' +import { PROVIDER_CATALOG } from '../providers/catalog.generated' export type { ProbeResult } from './types' export type { AnthropicProbeOptions } from './anthropic' export { probeAnthropic } from './anthropic' +export { probeOpenAI } from './openai' export interface ProbeContext { providerId: string @@ -11,16 +14,42 @@ export interface ProbeContext { baseUrl?: string } +/** Providers that share the OpenAI-compatible /models probe. */ +const OPENAI_COMPAT = new Set([ + 'openai', + 'groq', + 'openrouter', + 'together', + 'fireworks', + 'deepseek', + 'mistral', + 'xai', + 'perplexity', + 'custom-openai', +]) + export async function runProbe(ctx: ProbeContext): Promise { - switch (ctx.providerId) { - case 'anthropic': - return probeAnthropic({ apiKey: ctx.apiKey, baseUrl: ctx.baseUrl }) - default: - return { - valid: false, - status: 'error', - detail: `No probe implemented for provider "${ctx.providerId}".`, - checkedAt: new Date().toISOString(), - } + const provider = PROVIDER_CATALOG.find((p) => p.id === ctx.providerId) + const kind = provider?.probeKind + + if (ctx.providerId === 'anthropic' || kind === 'anthropicModels') { + return probeAnthropic({ + apiKey: ctx.apiKey, + baseUrl: ctx.baseUrl ?? provider?.defaultBaseUrl, + }) + } + + if (kind === 'openaiModels' || OPENAI_COMPAT.has(ctx.providerId)) { + return probeOpenAI({ + apiKey: ctx.apiKey, + baseUrl: ctx.baseUrl ?? provider?.defaultBaseUrl, + }) + } + + return { + valid: false, + status: 'error', + detail: `No probe implemented for provider "${ctx.providerId}".`, + checkedAt: new Date().toISOString(), } } diff --git a/src/main/probes/openai.ts b/src/main/probes/openai.ts new file mode 100644 index 0000000..60a6348 --- /dev/null +++ b/src/main/probes/openai.ts @@ -0,0 +1,56 @@ +import type { ProbeResult } from './types' + +const OPENAI_BASE = 'https://api.openai.com/v1' +const TIMEOUT_MS = 5000 + +export interface OpenAIProbeOptions { + apiKey: string + baseUrl?: string +} + +export async function probeOpenAI(opts: OpenAIProbeOptions): Promise { + const { apiKey, baseUrl = OPENAI_BASE } = opts + const checkedAt = new Date().toISOString() + + if (!apiKey) { + return { valid: false, status: 'invalid', detail: 'No API key supplied.', checkedAt } + } + + const root = baseUrl.replace(/\/$/, '') + const url = root.endsWith('/v1') ? `${root}/models` : `${root}/v1/models` + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) + try { + const res = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + signal: controller.signal, + }) + if (res.ok) { + return { valid: true, status: 'ok', detail: 'Key validated against /models.', checkedAt } + } + if (res.status === 401 || res.status === 403) { + return { valid: false, status: 'invalid', detail: `Authentication failed (${res.status}).`, checkedAt } + } + if (res.status === 429) { + return { + valid: true, + status: 'quota_exceeded', + detail: 'Rate limited.', + checkedAt, + } + } + return { valid: false, status: 'error', detail: `Unexpected HTTP ${res.status}.`, checkedAt } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('abort')) { + return { valid: false, status: 'error', detail: `Timed out after ${TIMEOUT_MS}ms.`, checkedAt } + } + return { valid: false, status: 'error', detail: message, checkedAt } + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/providers/harnesses.ts b/src/main/providers/harnesses.ts index 09accb7..47a8b48 100644 --- a/src/main/providers/harnesses.ts +++ b/src/main/providers/harnesses.ts @@ -26,6 +26,7 @@ export const HARNESS_CATALOG: HarnessCatalogEntry[] = [ ], status: 'installed', installMethods: [ + { type: 'brew', formula: 'claude-code' }, { type: 'npm', package: '@anthropic-ai/claude-code', binary: 'claude' }, ], }, @@ -39,6 +40,7 @@ export const HARNESS_CATALOG: HarnessCatalogEntry[] = [ status: 'installed', installMethods: [ { type: 'npm', package: 'opencode-ai', binary: 'opencode' }, + { type: 'brew', formula: 'opencode' }, ], }, { @@ -50,6 +52,7 @@ export const HARNESS_CATALOG: HarnessCatalogEntry[] = [ features: ['GPT-5.1 · v0.46.0'], status: 'installed', installMethods: [ + { type: 'brew', formula: 'codex' }, { type: 'npm', package: '@openai/codex', binary: 'codex' }, ], }, diff --git a/src/main/providers/runtimes.ts b/src/main/providers/runtimes.ts new file mode 100644 index 0000000..e5e91eb --- /dev/null +++ b/src/main/providers/runtimes.ts @@ -0,0 +1,147 @@ +/** + * Language runtimes and package managers Hoist surfaces in the Library. + * Discovery probes each `binaries` entry with `which -a` + version flags. + */ + +export type RuntimeKind = 'runtime' | 'package-manager' + +export interface RuntimeCatalogEntry { + id: string + name: string + avatar: string + kind: RuntimeKind + description: string + /** PATH binary names to probe, in preference order. */ + binaries: string[] + /** Args that print a version line (default --version). */ + versionArgs?: string[] + /** Optional family for grouping related tools (node ↔ npm). */ + family?: string +} + +export const RUNTIME_CATALOG: RuntimeCatalogEntry[] = [ + { + id: 'node', + name: 'Node.js', + avatar: 'N', + kind: 'runtime', + family: 'javascript', + description: 'JavaScript runtime. Hosts most agent CLIs when installed via npm.', + binaries: ['node'], + }, + { + id: 'npm', + name: 'npm', + avatar: 'npm', + kind: 'package-manager', + family: 'javascript', + description: 'Node package manager. Default installer for many agent harnesses.', + binaries: ['npm'], + }, + { + id: 'bun', + name: 'Bun', + avatar: 'B', + kind: 'package-manager', + family: 'javascript', + description: 'Fast all-in-one JS toolkit. Runtime + package manager + bundler.', + binaries: ['bun'], + }, + { + id: 'pnpm', + name: 'pnpm', + avatar: 'pn', + kind: 'package-manager', + family: 'javascript', + description: 'Efficient Node package manager with a content-addressable store.', + binaries: ['pnpm'], + }, + { + id: 'yarn', + name: 'Yarn', + avatar: 'Y', + kind: 'package-manager', + family: 'javascript', + description: 'Node package manager (Classic / Berry).', + binaries: ['yarn'], + }, + { + id: 'deno', + name: 'Deno', + avatar: 'D', + kind: 'runtime', + family: 'javascript', + description: 'Secure TypeScript-first runtime with built-in tooling.', + binaries: ['deno'], + }, + { + id: 'python', + name: 'Python', + avatar: 'Py', + kind: 'runtime', + family: 'python', + description: 'Python interpreter. Used by Aider and many data/ML agent tools.', + binaries: ['python3', 'python'], + versionArgs: ['--version'], + }, + { + id: 'pip', + name: 'pip', + avatar: 'pip', + kind: 'package-manager', + family: 'python', + description: 'Python package installer.', + binaries: ['pip3', 'pip'], + }, + { + id: 'go', + name: 'Go', + avatar: 'Go', + kind: 'runtime', + family: 'go', + description: 'Go toolchain (compiler + runtime).', + binaries: ['go'], + versionArgs: ['version'], + }, + { + id: 'rust', + name: 'Rust', + avatar: 'Rs', + kind: 'runtime', + family: 'rust', + description: 'Rust compiler (rustc). Pair with Cargo for packages.', + binaries: ['rustc'], + }, + { + id: 'cargo', + name: 'Cargo', + avatar: 'Cr', + kind: 'package-manager', + family: 'rust', + description: 'Rust package manager and build tool.', + binaries: ['cargo'], + }, + { + id: 'ruby', + name: 'Ruby', + avatar: 'Rb', + kind: 'runtime', + family: 'ruby', + description: 'Ruby interpreter.', + binaries: ['ruby'], + }, + { + id: 'java', + name: 'Java', + avatar: 'Jv', + kind: 'runtime', + family: 'jvm', + description: 'Java runtime (JRE/JDK).', + binaries: ['java'], + versionArgs: ['-version'], + }, +] + +export function findRuntime(id: string): RuntimeCatalogEntry | undefined { + return RUNTIME_CATALOG.find((r) => r.id === id) +} diff --git a/src/main/wiring/configure.ts b/src/main/wiring/configure.ts new file mode 100644 index 0000000..bdef1f7 --- /dev/null +++ b/src/main/wiring/configure.ts @@ -0,0 +1,168 @@ +/** + * Per-harness config helpers: set active model, reset Hoist-managed wiring. + */ +import { readFile } from 'node:fs/promises' +import { exists, readJsonOrNull, writeJsonAtomic, writeTextAtomic } from '../fsutil' +import { + clearClaudeCodeHoistEnv, + claudeCodeSettingsPath, + type ClaudeCodeConfig, +} from './claudeCode' +import { clearOpenCodeProvider, openCodeConfigPath } from './openCode' +import { codexConfigPath } from './codex' + +export const CLAUDE_MODEL_PRESETS = [ + 'claude-opus-4-20250514', + 'claude-sonnet-4-20250514', + 'claude-haiku-4-5-20251001', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229', +] as const + +export const CODEX_MODEL_PRESETS = [ + 'gpt-5', + 'gpt-4.1', + 'gpt-4o', + 'o3', + 'o4-mini', +] as const + +export interface HarnessConfigSetRequest { + harnessId: string + /** Active model id (Claude settings.model, OpenCode model, Codex model=). */ + model?: string | null +} + +export interface HarnessConfigResetRequest { + harnessId: string + /** Also clear the active model field. Default true. */ + clearModel?: boolean +} + +export interface HarnessConfigMutationResult { + ok: boolean + error?: string + path?: string + note?: string +} + +export async function setHarnessModel(req: HarnessConfigSetRequest): Promise { + const model = (req.model ?? '').trim() + if (!model) return { ok: false, error: 'Model is required.' } + + try { + if (req.harnessId === 'claude-code') { + const path = claudeCodeSettingsPath() + const existing = ((await readJsonOrNull(path)) as ClaudeCodeConfig | null) ?? {} + await writeJsonAtomic(path, { ...existing, model }) + return { ok: true, path, note: 'Wrote model to ~/.claude/settings.json' } + } + + if (req.harnessId === 'opencode') { + const path = openCodeConfigPath() + const existing = ((await readJsonOrNull(path)) as Record | null) ?? {} + // OpenCode accepts "provider/model" or bare model ids depending on setup. + await writeJsonAtomic(path, { ...existing, model }) + return { ok: true, path, note: 'Wrote model to opencode.json' } + } + + if (req.harnessId === 'codex') { + const path = codexConfigPath() + const text = (await exists(path)) ? await readFile(path, 'utf8') : '' + const next = upsertCodexModelLine(text, model) + await writeTextAtomic(path, next) + return { ok: true, path, note: 'Wrote model = "…" in ~/.codex/config.toml' } + } + + return { ok: false, error: `No model editor for harness "${req.harnessId}"` } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} + +export async function resetHarnessConfig(req: HarnessConfigResetRequest): Promise { + const clearModel = req.clearModel !== false + try { + if (req.harnessId === 'claude-code') { + await clearClaudeCodeHoistEnv() + const path = claudeCodeSettingsPath() + if (clearModel) { + const existing = ((await readJsonOrNull(path)) as ClaudeCodeConfig | null) ?? {} + if ('model' in existing) { + const next = { ...existing } + delete next.model + await writeJsonAtomic(path, next) + } + } + return { + ok: true, + path, + note: clearModel + ? 'Cleared Hoist env keys and model from ~/.claude/settings.json' + : 'Cleared Hoist env keys from ~/.claude/settings.json', + } + } + + if (req.harnessId === 'opencode') { + const path = openCodeConfigPath() + // Remove hoist-* providers and optional model field + const existing = ((await readJsonOrNull(path)) as Record | null) ?? {} + const providers = { ...((existing.provider as Record | undefined) ?? {}) } + let changed = false + for (const key of Object.keys(providers)) { + if (key.startsWith('hoist-')) { + delete providers[key] + changed = true + } + } + if (changed) existing.provider = providers + if (clearModel && 'model' in existing) { + delete existing.model + changed = true + } + if (changed) await writeJsonAtomic(path, existing) + // Also clear known default hoist provider ids + await clearOpenCodeProvider('hoist-anthropic') + await clearOpenCodeProvider('hoist-openai') + return { ok: true, path, note: 'Removed Hoist provider blocks from opencode.json' } + } + + if (req.harnessId === 'codex') { + const path = codexConfigPath() + if (!(await exists(path))) return { ok: true, path, note: 'No Codex config to reset.' } + const text = await readFile(path, 'utf8') + const next = stripHoistCodexBlock(text) + if (next !== text) await writeTextAtomic(path, next) + return { ok: true, path, note: 'Removed # hoist-managed block from config.toml' } + } + + return { ok: false, error: `No reset handler for harness "${req.harnessId}"` } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} + +function upsertCodexModelLine(text: string, modelId: string): string { + const line = `model = "${modelId}"` + if (/^model\s*=/m.test(text)) { + return text.replace(/^model\s*=\s*.*$/m, line) + } + const trimmed = text.replace(/\s*$/, '') + return `${trimmed}\n\n# hoist-managed model\n${line}\n` +} + +function stripHoistCodexBlock(text: string): string { + // Drop from "# hoist-managed" through next blank-line-separated section or EOF + return text + .replace(/\n*# hoist-managed[\s\S]*?(?=\n#(?! hoist)|\n\[|\s*$)/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + (text.endsWith('\n') ? '\n' : '') +} + +/** Suggested models for the configure UI. */ +export function modelPresetsFor(harnessId: string): string[] { + if (harnessId === 'claude-code') return [...CLAUDE_MODEL_PRESETS] + if (harnessId === 'codex') return [...CODEX_MODEL_PRESETS] + return [] +} diff --git a/src/preload/api.ts b/src/preload/api.ts index 5b91677..56cc08f 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -11,8 +11,12 @@ export interface HoistAPI { harness: { list: () => Promise discover: () => Promise> - install: (id: string) => Promise + install: (req: string | { id: string; version?: string; prefer?: 'npm' | 'brew'; force?: boolean }) => Promise + uninstall: (req: { id: string; prefer?: 'npm' | 'brew' }) => Promise<{ ok: boolean; message: string }> + versions: (req: { id: string; current?: string | null; from?: string | null; to?: string | null }) => Promise configShow: (harnessId: string) => Promise + configSet: (req: { harnessId: string; model?: string | null }) => Promise + configReset: (req: { harnessId: string; clearModel?: boolean }) => Promise } provider: { list: () => Promise @@ -35,16 +39,42 @@ export interface HoistAPI { } } +export type LibraryKind = 'harness' | 'runtime' | 'package-manager' +export type HomebrewChannel = 'formula' | 'cask' | 'node' | null + +export interface LibraryInstall { + path: string + realPath: string + version: string | null + source: string + packageManager: string | null + homebrew: HomebrewChannel + primary: boolean +} + export interface LibraryEntry { id: string + catalogId: string + kind: LibraryKind name: string avatar: string desc: string - models: string[] - features: string[] status: 'installed' | 'installing' | 'available' | 'failed' | 'deprecated' exec: string | null version: string | null + path: string | null + source: string | null + packageManager: string | null + homebrew: HomebrewChannel + primary: boolean + installs: LibraryInstall[] + config: { + activeModel: string | null + provider: string | null + authStatus: string | null + installDir: string | null + models: string[] + } } export interface ClipboardReadResponse { @@ -106,11 +136,47 @@ export interface HarnessConfigView { exists: boolean /** Excerpt of the current config relevant to hoist's wiring. */ excerpt?: string + /** Active model currently written in the harness config file. */ + activeModel?: string | null + /** Suggested model ids for the configure UI. */ + modelPresets?: string[] /** Computed env vars hoist will write. */ envHint?: Record notes?: string[] } +export interface HarnessConfigMutationResult { + ok: boolean + error?: string + path?: string + note?: string +} + +export interface HarnessVersionInfo { + version: string + publishedAt?: string + latest?: boolean +} + +export interface HarnessChangelogEntry { + version: string + body: string +} + +export interface HarnessVersionCheck { + ok: boolean + error?: string + harnessId: string + packageName: string | null + current: string | null + latest: string | null + outdated: boolean + versions: HarnessVersionInfo[] + changelog: HarnessChangelogEntry[] + compareUrl: string | null + homepage: string | null +} + export interface ProviderSummary { id: string label: string @@ -141,8 +207,10 @@ export interface GatewayApplyRequest { providerId: string /** Resolved gateway base URL (placeholders filled). */ baseUrl: string - /** Resolved API key to inject into harnesses. */ - apiKey: string + /** Plaintext key — prefer secretId so the renderer never holds the secret. */ + apiKey?: string + /** Vault secret id (e.g. provider:anthropic:api_key). Resolved in main. */ + secretId?: string /** Harness ids to apply to (e.g. ["claude-code","codex","opencode"]). */ harnessIds: string[] /** Display label for the config record. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 93d9503..95b2626 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,8 +13,12 @@ const api: HoistAPI = { harness: { list: () => ipcRenderer.invoke(CHANNELS.harnessList), discover: () => ipcRenderer.invoke(CHANNELS.harnessDiscover), - install: (id) => ipcRenderer.invoke(CHANNELS.harnessInstall, id), + install: (req) => ipcRenderer.invoke(CHANNELS.harnessInstall, req), + uninstall: (req) => ipcRenderer.invoke(CHANNELS.harnessUninstall, req), + versions: (req) => ipcRenderer.invoke(CHANNELS.harnessVersions, req), configShow: (id) => ipcRenderer.invoke(CHANNELS.harnessConfigShow, id), + configSet: (req) => ipcRenderer.invoke(CHANNELS.harnessConfigSet, req), + configReset: (req) => ipcRenderer.invoke(CHANNELS.harnessConfigReset, req), }, provider: { list: () => ipcRenderer.invoke(CHANNELS.providerList), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9353431..76de595 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Zap, Search, @@ -15,8 +15,29 @@ import { SquareTerminal, Circle, CircleHelp, + PanelLeftClose, + PanelLeftOpen, + Stethoscope, + Copy, + AlertTriangle, + Activity, } from 'lucide-react' -import type { HoistAPI, LibraryEntry } from '../preload/api' +import type { + GatewaySummary, + HarnessWiringResult, + HoistAPI, + LibraryEntry, + ProbeResult, + ProviderSummary, + VaultEntry, +} from '../preload/api' +import { + analyzeLibrary, + catalogBinaryName, + type DoctorAction, + type DoctorFinding, +} from '../shared/doctor' +import { providerIdFromSecretId, secretIdForProvider } from '../shared/secrets' declare global { interface Window { @@ -24,9 +45,18 @@ declare global { } } -type SurfaceId = 'library' | 'harnesses' | 'keys' | 'gateway' | 'status' -type ScopeId = 'all' | 'anthropic' | 'openai' -type LibraryFilter = 'all' | 'installed' | 'available' | 'updates' +type SurfaceId = 'library' | 'harnesses' | 'keys' | 'gateway' | 'status' | 'doctor' +type LibraryFilter = 'all' | 'harnesses' | 'runtimes' | 'package-managers' | 'installed' | 'available' + +interface KeyRow { + secretId: string + providerId: string + name: string + env: string + preview: string + updatedAt?: string + probe?: ProbeResult +} interface SidebarSection { id: SurfaceId @@ -43,77 +73,512 @@ interface SidebarGroup { type HarnessStatus = 'installed' | 'installing' | 'available' | 'failed' | 'deprecated' -interface HarnessCatalogEntry { - id: string - name: string - avatar: string - version: string | null - status: HarnessStatus - desc: string - models: string[] - features: string[] - exec: string | null - meta: { - binary: string - installed: string - lastUsed: string +const WIDTH_KEYS = { + sidebar: 'hoist.width.sidebar', + detail: 'hoist.width.detail', +} as const + +function readStoredWidth(key: string, fallback: number, min: number, max: number): number { + try { + const raw = localStorage.getItem(key) + if (!raw) return fallback + const n = Number(raw) + if (!Number.isFinite(n)) return fallback + return Math.min(max, Math.max(min, Math.round(n))) + } catch { + return fallback } } +function useResizableWidth(key: string, initial: number, min: number, max: number) { + const [width, setWidth] = useState(() => readStoredWidth(key, initial, min, max)) + const widthRef = useRef(width) + widthRef.current = width + const [dragging, setDragging] = useState(false) + + useEffect(() => { + try { localStorage.setItem(key, String(width)) } catch { /* ignore */ } + }, [key, width]) + + /** dir: +1 grows when pointer moves right; -1 grows when pointer moves left */ + const beginResize = useCallback((dir: 1 | -1) => (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const startX = e.clientX + const startW = widthRef.current + setDragging(true) + document.body.classList.add('is-col-resizing') + + const onMove = (ev: MouseEvent) => { + const next = Math.min(max, Math.max(min, Math.round(startW + dir * (ev.clientX - startX)))) + setWidth(next) + } + const onUp = () => { + setDragging(false) + document.body.classList.remove('is-col-resizing') + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, [min, max]) + + return { width, dragging, beginResize } +} + export function App() { const [surface, setSurface] = useState('library') const [paletteOpen, setPaletteOpen] = useState(false) const [library, setLibrary] = useState([]) + const [selectedLibraryId, setSelectedLibraryId] = useState('claude-code') + const [selectedHarnessId, setSelectedHarnessId] = useState('claude-code') + const [keys, setKeys] = useState([]) + const [providers, setProviders] = useState([]) + const [selectedKeyId, setSelectedKeyId] = useState(null) + const [keysBusy, setKeysBusy] = useState(false) + const [harnessBusy, setHarnessBusy] = useState(false) + const [addKeyOpen, setAddKeyOpen] = useState(false) + const [toast, setToast] = useState(null) + const [gateways, setGateways] = useState([]) + const [selectedGatewayId, setSelectedGatewayId] = useState('truefoundry') + const [gatewayBusy, setGatewayBusy] = useState(false) + const [lastWiring, setLastWiring] = useState(null) + + const sidebar = useResizableWidth(WIDTH_KEYS.sidebar, 232, 180, 420) + const detail = useResizableWidth(WIDTH_KEYS.detail, 400, 320, 720) + const [navExpanded, setNavExpanded] = useState(() => { + try { + const v = localStorage.getItem('hoist.nav.expanded') + return v === null ? true : v === '1' + } catch { + return true + } + }) useEffect(() => { - let alive = true - window.hoist.library - .list() - .then((entries) => { if (alive) setLibrary(entries) }) - .catch(() => { /* monotonic guard */ }) - return () => { alive = false } + try { localStorage.setItem('hoist.nav.expanded', navExpanded ? '1' : '0') } catch { /* ignore */ } + }, [navExpanded]) + + const showToast = useCallback((msg: string) => { + setToast(msg) + window.setTimeout(() => setToast(null), 2500) + }, []) + + const refreshLibrary = useCallback(async () => { + try { + const entries = await window.hoist.library.list() + setLibrary(entries) + setSelectedLibraryId((cur) => ( + entries.some((e) => e.id === cur) ? cur : (entries[0]?.id ?? cur) + )) + setSelectedHarnessId((cur) => { + const harnesses = entries.filter((e) => e.kind === 'harness') + return harnesses.some((e) => e.id === cur || e.catalogId === cur) + ? cur + : (harnesses.find((e) => e.primary)?.id ?? harnesses[0]?.id ?? cur) + }) + } catch { + // ignore + } + }, []) + + const refreshKeys = useCallback(async () => { + try { + const [vaultRes, providerList] = await Promise.all([ + window.hoist.vault.list(), + window.hoist.provider.list(), + ]) + setProviders(providerList) + if (!vaultRes.ok) { + setKeys([]) + return + } + const byId = new Map(providerList.map((p) => [p.id, p])) + const rows: KeyRow[] = vaultRes.entries.map((e: VaultEntry) => { + const pid = providerIdFromSecretId(e.id) ?? e.id + const prov = byId.get(pid) + return { + secretId: e.id, + providerId: pid, + name: e.label || prov?.label || pid, + env: prov?.envKeys?.[0] ?? '—', + preview: e.preview ?? '••••', + updatedAt: e.updatedAt, + } + }) + setKeys(rows) + setSelectedKeyId((cur) => { + if (cur && rows.some((r) => r.secretId === cur)) return cur + return rows[0]?.secretId ?? null + }) + } catch { + setKeys([]) + } + }, []) + + const refreshGateways = useCallback(async () => { + try { + const list = await window.hoist.gateway.list() + setGateways(list) + setSelectedGatewayId((cur) => ( + list.some((g) => g.id === cur) ? cur : (list[0]?.id ?? cur) + )) + } catch { + setGateways([]) + } }, []) + useEffect(() => { + void refreshLibrary() + void refreshKeys() + void refreshGateways() + }, [refreshLibrary, refreshKeys, refreshGateways]) + useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault() setPaletteOpen(true) } + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') { + e.preventDefault() + setNavExpanded((v) => !v) + } if (e.key === 'Escape') setPaletteOpen(false) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, []) - const isRail = surface === 'library' + const selectedLibrary = library.find((h) => h.id === selectedLibraryId) ?? library[0] + const harnessEntries = library.filter((e) => e.kind === 'harness') + const selectedHarness = + harnessEntries.find((h) => h.id === selectedHarnessId || h.catalogId === selectedHarnessId) + ?? harnessEntries.find((h) => h.primary) + ?? harnessEntries[0] + const selectedKey = keys.find((k) => k.secretId === selectedKeyId) ?? keys[0] + + const installHarness = useCallback(async (catalogId: string) => { + setHarnessBusy(true) + try { + const res = await window.hoist.harness.install(catalogId) + if (!res.ok) { + showToast(res.error ?? 'Install failed') + } else { + showToast(`Installed ${catalogId}`) + } + await refreshLibrary() + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + } finally { + setHarnessBusy(false) + } + }, [refreshLibrary, showToast]) + + const probeKey = useCallback(async (row: KeyRow) => { + setKeysBusy(true) + try { + const res = await window.hoist.probe.run({ + providerId: row.providerId, + secretId: row.secretId, + }) + if (!res.ok || !res.result) { + showToast(res.error ?? 'Probe failed') + return + } + setKeys((prev) => prev.map((k) => ( + k.secretId === row.secretId ? { ...k, probe: res.result } : k + ))) + showToast(res.result.valid ? `${row.name} valid` : `${row.name}: ${res.result.detail ?? res.result.status}`) + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + } finally { + setKeysBusy(false) + } + }, [showToast]) + + const deleteKey = useCallback(async (secretId: string) => { + setKeysBusy(true) + try { + const res = await window.hoist.vault.delete(secretId) + if (!res.ok) showToast(res.error ?? 'Delete failed') + else showToast('Key deleted') + await refreshKeys() + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + } finally { + setKeysBusy(false) + } + }, [refreshKeys, showToast]) + + const copyKey = useCallback(async (secretId: string) => { + try { + const res = await window.hoist.vault.copy(secretId) + if (!res.ok) showToast(res.error ?? 'Copy failed') + else showToast(`Copied · clears in ${(res.clearedInMs ?? 30000) / 1000}s`) + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + } + }, [showToast]) + + const saveKey = useCallback(async (providerId: string, value: string, label: string) => { + const id = secretIdForProvider(providerId) + const res = await window.hoist.vault.set({ id, value, label }) + if (!res.ok) throw new Error(res.error ?? 'Failed to save key') + await refreshKeys() + setSelectedKeyId(id) + // Best-effort probe after save + void window.hoist.probe.run({ providerId, secretId: id }).then((pr) => { + if (pr.ok && pr.result) { + setKeys((prev) => prev.map((k) => ( + k.secretId === id ? { ...k, probe: pr.result } : k + ))) + } + }) + showToast(`Saved ${label}`) + }, [refreshKeys, showToast]) + + const runDoctorAction = useCallback(async (action: DoctorAction) => { + try { + if (action.type === 'navigate') { + setSurface(action.surface) + return + } + if (action.type === 'reconfigure') { + const hit = library.find((e) => e.catalogId === action.harnessId && e.primary) + ?? library.find((e) => e.catalogId === action.harnessId) + if (hit) setSelectedLibraryId(hit.id) + setSurface('library') + showToast(`Configure ${action.harnessId} in Library → Lifecycle / Configure`) + return + } + if (action.type === 'uninstall') { + setHarnessBusy(true) + const res = await window.hoist.harness.uninstall({ + id: action.harnessId, + prefer: action.prefer, + }) + showToast(res.message) + await refreshLibrary() + return + } + if (action.type === 'install' || action.type === 'upgrade') { + setHarnessBusy(true) + const res = await window.hoist.harness.install({ + id: action.harnessId, + prefer: action.prefer, + force: action.type === 'upgrade' || action.force, + version: action.type === 'install' ? action.version : undefined, + }) + if (!res.ok) showToast(res.error ?? 'Install failed') + else showToast(action.type === 'upgrade' ? `Upgraded ${action.harnessId}` : `Installed ${action.harnessId}`) + await refreshLibrary() + return + } + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + } finally { + setHarnessBusy(false) + } + }, [library, refreshLibrary, showToast]) + + const applyGateway = useCallback(async (opts: { + gatewayId: string + baseUrl: string + providerId: string + secretId: string + harnessIds: string[] + }) => { + setGatewayBusy(true) + setLastWiring(null) + try { + const res = await window.hoist.gateway.apply({ + gatewayId: opts.gatewayId, + baseUrl: opts.baseUrl, + providerId: opts.providerId, + secretId: opts.secretId, + harnessIds: opts.harnessIds, + }) + if (res.wiring) setLastWiring(res.wiring) + if (!res.ok) { + showToast(res.error ?? 'Gateway apply failed') + } else { + const okN = res.wiring?.filter((w) => w.ok).length ?? 0 + showToast(`Wired ${okN} harness${okN === 1 ? '' : 'es'}${res.effectiveBaseUrl ? ` → ${res.effectiveBaseUrl}` : ''}`) + } + return res + } catch (err) { + showToast(err instanceof Error ? err.message : String(err)) + return null + } finally { + setGatewayBusy(false) + } + }, [showToast]) + + const doctorReport = analyzeLibrary(library) + const doctorIssues = doctorReport.summary.error + doctorReport.summary.warn + const installedHarnessCount = harnessEntries.filter((h) => h.status === 'installed' && h.primary).length + const selectedGateway = gateways.find((g) => g.id === selectedGatewayId) ?? gateways[0] + const statusCounts = { + library: library.length, + harnesses: installedHarnessCount || harnessEntries.length, + keys: keys.length, + gateway: gateways.length, + status: 2, + doctor: doctorIssues, + } + + const shellStyle = { + ['--sidebar-width' as string]: `${sidebar.width}px`, + ['--detail-width' as string]: `${detail.width}px`, + } return ( -
- setPaletteOpen(true)} surface={surface} /> -
- {isRail ? ( - - ) : ( - + setPaletteOpen(true)} + surface={surface} + onAddKey={() => { + setSurface('keys') + setAddKeyOpen(true) + }} + /> +
+ setNavExpanded((v) => !v)} + /> + {navExpanded && ( +
+ {addKeyOpen && ( + k.providerId))} + onClose={() => setAddKeyOpen(false)} + onSave={async (providerId, value, label) => { + await saveKey(providerId, value, label) + setAddKeyOpen(false) + }} + /> + )} + {toast &&
{toast}
} {paletteOpen && ( setPaletteOpen(false)} @@ -127,11 +592,55 @@ export function App() { ) } -function TopBar({ onOpenPalette, surface }: { onOpenPalette: () => void; surface: SurfaceId }) { +function harnessesPrimary(entries: LibraryEntry[]): LibraryEntry[] { + // One row per catalog family — prefer primary installed, else first. + const map = new Map() + for (const e of entries) { + const prev = map.get(e.catalogId) + if (!prev) { + map.set(e.catalogId, e) + continue + } + if (e.primary && e.status === 'installed') map.set(e.catalogId, e) + else if (e.status === 'installed' && prev.status !== 'installed') map.set(e.catalogId, e) + } + return [...map.values()] +} + +function relativeTime(iso?: string): string { + if (!iso) return '—' + const t = Date.parse(iso) + if (!Number.isFinite(t)) return '—' + const sec = Math.round((Date.now() - t) / 1000) + if (sec < 60) return 'just now' + if (sec < 3600) return `${Math.floor(sec / 60)}m ago` + if (sec < 86400) return `${Math.floor(sec / 3600)}h ago` + return `${Math.floor(sec / 86400)}d ago` +} + +function probeBadge(probe?: ProbeResult): { cls: string; label: string } { + if (!probe) return { cls: '', label: 'not probed' } + if (probe.status === 'ok' && probe.valid) return { cls: 'badge-ok', label: 'valid' } + if (probe.status === 'invalid') return { cls: 'badge-bad', label: 'invalid' } + if (probe.status === 'quota_exceeded') return { cls: 'badge-warn', label: 'quota' } + if (probe.status === 'expired') return { cls: 'badge-warn', label: 'expired' } + return { cls: 'badge-bad', label: probe.status } +} + +function TopBar({ + onOpenPalette, + surface, + onAddKey, +}: { + onOpenPalette: () => void + surface: SurfaceId + onAddKey: () => void +}) { const sectionLabel = surface === 'library' ? 'Library' : surface === 'harnesses' ? 'Harnesses' : surface === 'keys' ? 'Provider keys' : surface === 'gateway' ? 'Gateway' + : surface === 'doctor' ? 'Doctor' : 'Watchtower' return (
@@ -141,94 +650,112 @@ function TopBar({ onOpenPalette, surface }: { onOpenPalette: () => void; surface {sectionLabel}
-
- - + +
) } -interface RailProps { +interface NavSidebarProps { surface: SurfaceId onSurface: (s: SurfaceId) => void statusCounts: Record + expanded: boolean + onToggleExpand: () => void } -function Rail({ surface, onSurface, statusCounts }: RailProps) { - const railItems: SidebarSection[] = [ - { id: 'library', label: 'Harnesses', icon: , count: statusCounts.library, active: surface === 'library' }, - { id: 'keys', label: 'Provider keys', icon: , count: statusCounts.keys, active: surface === 'keys' }, - { id: 'gateway', label: 'Gateway', icon: , count: statusCounts.gateway, active: surface === 'gateway' }, - ] - return ( - - ) -} - -interface SidebarProps extends RailProps {} - -function Sidebar(props: SidebarProps) { - const { surface, onSurface, statusCounts } = props +function NavSidebar({ surface, onSurface, statusCounts, expanded, onToggleExpand }: NavSidebarProps) { const groups: SidebarGroup[] = [ { label: 'Vault', items: [ - { id: 'library', label: 'Library', icon: , count: statusCounts.library, active: surface === 'library' }, - { id: 'harnesses', label: 'Harnesses', icon: , count: statusCounts.harnesses, active: surface === 'harnesses' }, - { id: 'keys', label: 'Provider keys', icon: , count: statusCounts.keys, active: surface === 'keys' }, - { id: 'gateway', label: 'Gateway', icon: , count: statusCounts.gateway, active: surface === 'gateway' }, + { id: 'library', label: 'Library', icon: , count: statusCounts.library, active: surface === 'library' }, + { id: 'harnesses', label: 'Harnesses', icon: , count: statusCounts.harnesses, active: surface === 'harnesses' }, + { id: 'keys', label: 'Provider keys', icon: , count: statusCounts.keys, active: surface === 'keys' }, + { id: 'gateway', label: 'Gateway', icon: , count: statusCounts.gateway, active: surface === 'gateway' }, ], }, { label: 'Health', items: [ - { id: 'status', label: 'Watchtower', icon: , count: statusCounts.status, active: surface === 'status' }, + { id: 'status', label: 'Watchtower', icon: , count: statusCounts.status, active: surface === 'status' }, + { id: 'doctor', label: 'Doctor', icon: , count: statusCounts.doctor, active: surface === 'doctor' }, ], }, ] - return ( - + ) + } + + return ( +
+ } + /> +
+ {harnesses.length === 0 ? ( +
Scanning harnesses…
+ ) : ( +
+ {harnesses.map((tool) => { + const installed = tool.status === 'installed' + return ( + + ) + })} +
+ )} +
+ + ) } -function ScopePicker({ value, onChange }: { value: ScopeId; onChange: (v: ScopeId) => void }) { - const opts: { id: ScopeId; label: string }[] = [ +function KeysSurface({ + keys, + providers, + selectedId, + onSelect, + onAdd, + busy, +}: { + keys: KeyRow[] + providers: ProviderSummary[] + selectedId: string | null + onSelect: (id: string) => void + onAdd: () => void + busy: boolean +}) { + const [scope, setScope] = useState('all') + const [search, setSearch] = useState('') + const scopes = [ { id: 'all', label: 'All providers' }, - { id: 'anthropic', label: 'Anthropic' }, - { id: 'openai', label: 'OpenAI' }, + ...providers.filter((p) => p.featured).map((p) => ({ id: p.id, label: p.label })), ] + const visible = keys.filter((e) => { + if (scope !== 'all' && e.providerId !== scope) return false + if (search) { + const q = search.toLowerCase() + if (!`${e.name} ${e.providerId} ${e.env}`.toLowerCase().includes(q)) return false + } + return true + }) + return ( -
- -
- {opts.map((o) => ( - - ))} + } + /> +
+ + setSearch(e.target.value)} + />
+
+ {visible.length === 0 ? ( +
+

{keys.length === 0 ? 'No keys in the vault yet.' : 'No keys match this filter.'}

+ +
+ ) : ( +
+ {visible.map((e) => { + const pb = probeBadge(e.probe) + return ( + + ) + })} +
+ )} +
+ + ) +} + +function providerGlyph(id: string): React.ReactNode { + switch (id) { + case 'anthropic': return A + case 'openai': return + case 'vertex': return V + case 'bedrock': return B + case 'groq': return G + case 'google': return G + default: return + } +} + +function ScopePicker({ + value, + onChange, + options, +}: { + value: string + onChange: (v: string) => void + options: { id: string; label: string }[] +}) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + const onPointer = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + window.addEventListener('mousedown', onPointer) + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('mousedown', onPointer) + window.removeEventListener('keydown', onKey) + } + }, [open]) + + return ( +
+ + {open && ( +
+ {options.map((o) => ( + + ))} +
+ )}
) } -function NewItemCatalogue({ onClose }: { onClose: () => void }) { - const tiles = [ - { id: 'anthropic', title: 'Anthropic API key', desc: 'sk-ant-…', icon: A, accent: true }, - { id: 'openai', title: 'OpenAI API key', desc: 'sk-…', icon: }, - { id: 'azure', title: 'Azure OpenAI', desc: 'endpoint + deployment + key', icon: Az }, - { id: 'vertex', title: 'Google Vertex AI', desc: 'project + region + ADC', icon: V }, - { id: 'bedrock', title: 'AWS Bedrock', desc: 'profile + region', icon: B }, - { id: 'custom-openai', title: 'Custom OpenAI endpoint', desc: 'OpenAI-compatible URL', icon: }, - ] +function AddKeyModal({ + providers, + existingIds, + onClose, + onSave, +}: { + providers: ProviderSummary[] + existingIds: Set + onClose: () => void + onSave: (providerId: string, value: string, label: string) => Promise +}) { + const [step, setStep] = useState<'pick' | 'enter'>('pick') + const [picked, setPicked] = useState(null) + const [query, setQuery] = useState('') + const [value, setValue] = useState('') + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + + const list = providers + .filter((p) => p.envKeys && p.envKeys.length > 0) + .filter((p) => { + if (!query) return true + const q = query.toLowerCase() + return p.label.toLowerCase().includes(q) || p.id.includes(q) + }) + .sort((a, b) => Number(!!b.featured) - Number(!!a.featured) || a.label.localeCompare(b.label)) + + const submit = async () => { + if (!picked || !value.trim()) return + setSaving(true) + setError(null) + try { + await onSave(picked.id, value.trim(), picked.label) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setSaving(false) + } + } + return (
e.stopPropagation()}>
-

What would you like to add?

- +

{step === 'pick' ? 'What would you like to add?' : `Add ${picked?.label} key`}

+
- -
- {tiles.map((t) => ( - + {step === 'pick' ? ( + <> + setQuery(e.target.value)} + autoFocus + /> +
+ {list.map((p) => ( + + ))} +
+ + ) : ( +
+

+ Stored as {secretIdForProvider(picked!.id)}. + Env: {picked!.envKeys[0]} +

+ + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void submit() + }} + /> + {error &&

{error}

} +
+ + +
+
+ )} +
+
+
+ ) +} + +function fillPlaceholders(template: string, values: Record): string { + return template.replace(/<([a-zA-Z0-9_-]+)>/g, (_, key: string) => { + const v = values[key] + return v && v.trim() ? v.trim() : `<${key}>` + }) +} + +function GatewayApplyPanel({ + gateway, + keys, + providers, + harnesses, + busy, + lastWiring, + onApply, +}: { + gateway: GatewaySummary + keys: KeyRow[] + providers: ProviderSummary[] + harnesses: LibraryEntry[] + busy: boolean + lastWiring: HarnessWiringResult[] | null + onApply?: (opts: { + gatewayId: string + baseUrl: string + providerId: string + secretId: string + harnessIds: string[] + }) => void +}) { + const placeholders = gateway.placeholders ?? [] + const [phValues, setPhValues] = useState>({}) + const [baseUrlOverride, setBaseUrlOverride] = useState(gateway.baseUrl) + const [secretId, setSecretId] = useState(keys[0]?.secretId ?? '') + const [providerId, setProviderId] = useState( + keys[0]?.providerId + ?? gateway.nativeProviders.find((p) => p === 'anthropic' || p === 'openai') + ?? gateway.nativeProviders[0] + ?? 'anthropic', + ) + const [harnessIds, setHarnessIds] = useState(() => + harnesses.filter((h) => h.status === 'installed').map((h) => h.catalogId), + ) + + // Reset form when gateway changes + useEffect(() => { + setPhValues({}) + setBaseUrlOverride(gateway.baseUrl) + const preferred = keys.find((k) => gateway.nativeProviders.includes(k.providerId)) ?? keys[0] + if (preferred) { + setSecretId(preferred.secretId) + setProviderId(preferred.providerId) + } + setHarnessIds(harnesses.filter((h) => h.status === 'installed').map((h) => h.catalogId)) + }, [gateway.id]) + + const resolvedUrl = placeholders.length > 0 + ? fillPlaceholders(gateway.baseUrl, phValues) + : baseUrlOverride + + const unresolved = (resolvedUrl.match(/<[^>]+>/g) ?? []) + const canApply = unresolved.length === 0 && !!secretId && harnessIds.length > 0 && !busy + + const toggleHarness = (id: string) => { + setHarnessIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) + } + + const providerChoices = (() => { + const fromKeys = keys.map((k) => ({ id: k.providerId, label: k.name, secretId: k.secretId })) + // Ensure native providers appear even without keys (user must add key first) + const seen = new Set(fromKeys.map((p) => p.id)) + for (const pid of gateway.nativeProviders) { + if (seen.has(pid)) continue + const prov = providers.find((p) => p.id === pid) + fromKeys.push({ id: pid, label: prov?.label ?? pid, secretId: '' }) + } + return fromKeys + })() + + return ( + <> +
+
{gateway.label}
+ {gateway.auth.header}: {gateway.auth.scheme} {`$${gateway.auth.envVar}`}} + /> + {gateway.modelIdFormat}} /> + + {gateway.notes &&

{gateway.notes}

} + {gateway.docUrl && ( + + Docs ↗ + + )} +
+ +
+
Wire harnesses
+ + {placeholders.length > 0 ? ( +
+ {placeholders.map((ph) => ( + ))} +
Resolved URL
+
{resolvedUrl}
+ ) : ( + + )} + + + + + +
Harnesses
+
+ {harnesses.length === 0 && No harness catalog loaded.} + {harnesses.map((h) => ( + + ))}
+ + {unresolved.length > 0 && ( +

+ Fill placeholders: {unresolved.join(', ')} +

+ )} + +
-
+ + {lastWiring && lastWiring.length > 0 && ( +
+
Last apply
+
    + {lastWiring.map((w, i) => ( +
  • + {w.harnessName}{' '} + {w.ok ? (w.note || w.path || 'updated') : (w.error || 'failed')} +
  • + ))} +
+
+ )} + ) } -function GatewaySurface() { - const gateways = [ - { id: 'corporate', label: 'Corporate AI gateway', url: 'https://gateway..com', placeholder: true, native: 'anthropic, openai', env: 'GATEWAY_API_KEY' }, - { id: 'truefoundry', label: 'TrueFoundry AI Gateway', url: 'https://gateway.truefoundry.ai', placeholder: false, native: 'anthropic, openai, bedrock, vertex, azure-foundry', env: 'TFY_API_KEY' }, - { id: 'litellm', label: 'LiteLLM Proxy', url: 'http://localhost:4000', placeholder: false, native: 'anthropic, openai, azure, vertex, bedrock', env: 'LITELLM_API_KEY' }, - { id: 'cloudflare', label: 'Cloudflare AI Gateway', url: 'https://gateway.ai.cloudflare.com/v1/', placeholder: true, native: 'openai, anthropic, workers-ai', env: 'CF_API_TOKEN' }, - { id: 'vercel', label: 'Vercel AI Gateway', url: 'https://api.vercel.com/v1/ai', placeholder: false, native: 'openai, anthropic, google', env: 'VERCEL_API_KEY' }, - { id: 'openrouter', label: 'OpenRouter', url: 'https://openrouter.ai/api/v1', placeholder: false, native: 'openai, anthropic, google, meta, mistral', env: 'OPENROUTER_API_KEY' }, - { id: 'together', label: 'Together AI', url: 'https://api.together.xyz/v1', placeholder: false, native: 'openai-compat', env: 'TOGETHER_API_KEY' }, - { id: 'opencode', label: 'OpenCode Zen', url: 'https://opencode.ai/zen/v1', placeholder: false, native: 'anthropic, openai, google', env: 'OPENCODE_ZEN_API_KEY' }, - { id: 'zenlayer', label: 'ZenLayer AI Gateway', url: 'https://gateway.theturbo.ai', placeholder: false, native: 'openai, anthropic, google', env: 'ZENLAYER_API_KEY' }, - { id: 'claude-code-compatible', label: 'Claude Code-compatible (custom)', url: '(custom)', placeholder: true, native: 'anthropic', env: 'ANTHROPIC_API_KEY' }, - { id: 'custom-openai', label: 'Custom OpenAI-compatible endpoint', url: '(custom)', placeholder: true, native: 'openai-compat', env: 'PROVIDER_API_KEY' }, - ] - const [selected, setSelected] = useState('truefoundry') +function GatewaySurface({ + gateways, + selectedId, + onSelect, + onRefresh, +}: { + gateways: GatewaySummary[] + selectedId: string | null + onSelect: (id: string) => void + onRefresh: () => void +}) { const [filter, setFilter] = useState('') const filtered = gateways.filter((g) => - !filter || g.label.toLowerCase().includes(filter.toLowerCase()) || g.id.includes(filter.toLowerCase()), + !filter + || g.label.toLowerCase().includes(filter.toLowerCase()) + || g.id.includes(filter.toLowerCase()) + || g.baseUrl.toLowerCase().includes(filter.toLowerCase()), ) return (
Apply wiring →} + subtitle="Point harnesses at a hosted gateway or custom OpenAI/Anthropic-compatible URL." + primaryAction={ + + } />
-
- {filtered.map((g) => ( - - ))} -
+ {filtered.length === 0 ? ( +
+ {gateways.length === 0 ? 'Loading gateway catalog…' : 'No gateways match this filter.'} +
+ ) : ( +
+ {filtered.map((g) => { + const needsFill = (g.placeholders?.length ?? 0) > 0 + return ( + + ) + })} +
+ )}
) } -function StatusSurface() { - const stats = [ - { key: 'stored', value: 11, label: 'Keys stored', badge: 'ok', sub: 'across 4 providers' }, - { key: 'valid', value: 9, label: 'Valid right now', badge: 'ok', sub: 'last probe < 1h' }, - { key: 'invalid', value: 1, label: 'Invalid', badge: 'bad', sub: 'Bedrock · never probed' }, - { key: 'expiring', value: 1, label: 'Expiring in 30d', badge: 'warn', sub: 'OpenAI key · set 2025-12' }, - { key: 'reused', value: 0, label: 'Reused', badge: 'ok', sub: 'across providers' }, - { key: 'harnesses', value: 2, label: 'Harnesses outdated', badge: 'warn', sub: 'Codex · latest 0.144.3' }, +/** Lightweight SVG donut — no chart library. */ +function DonutChart({ + segments, + size = 140, + thickness = 18, + center, +}: { + segments: { value: number; color: string; label: string }[] + size?: number + thickness?: number + center?: React.ReactNode +}) { + const total = segments.reduce((s, x) => s + x.value, 0) || 1 + const r = (size - thickness) / 2 + const c = 2 * Math.PI * r + let offset = 0 + return ( +
+ + + {segments.map((seg) => { + if (seg.value <= 0) return null + const len = (seg.value / total) * c + const el = ( + + ) + offset += len + return el + })} + + {center &&
{center}
} +
+ ) +} + +function BarChart({ + rows, +}: { + rows: { label: string; value: number; max: number; color: string; hint?: string }[] +}) { + return ( +
+ {rows.map((row) => { + const pct = row.max > 0 ? Math.max(2, Math.round((row.value / row.max) * 100)) : 0 + return ( +
+
{row.label}
+
+
+
+
+ {row.value}{row.hint ? ` · ${row.hint}` : ''} +
+
+ ) + })} +
+ ) +} + +function SparkBars({ + points, + color = 'var(--accent)', +}: { + points: number[] + color?: string +}) { + const max = Math.max(1, ...points) + return ( +
+ {points.map((p, i) => ( + + ))} +
+ ) +} + +function StatusSurface({ + keys, + library, + doctorSummary, + onReprobeAll, + busy, +}: { + keys: KeyRow[] + library: LibraryEntry[] + doctorSummary: { error: number; warn: number; info: number; ok: number } + onReprobeAll: () => void + busy: boolean +}) { + const valid = keys.filter((k) => k.probe?.valid && k.probe.status === 'ok').length + const invalid = keys.filter((k) => k.probe && (!k.probe.valid || k.probe.status === 'invalid')).length + const unprobed = keys.length - valid - invalid + const quota = keys.filter((k) => k.probe?.status === 'quota_exceeded').length + + const harnesses = library.filter((e) => e.kind === 'harness') + const harnessPrimary = harnessesPrimary(harnesses) + const harnessInstalled = harnessPrimary.filter((h) => h.status === 'installed').length + const harnessMulti = harnessPrimary.filter((h) => h.installs.length > 1).length + const harnessMissing = harnessPrimary.filter((h) => h.status !== 'installed').length + + const runtimes = library.filter((e) => e.kind === 'runtime' && e.primary && e.status === 'installed') + const pms = library.filter((e) => e.kind === 'package-manager' && e.primary && e.status === 'installed') + + // Channel mix across installed primaries + const channelCounts: Record = {} + for (const e of library.filter((x) => x.primary && x.status === 'installed')) { + const ch = e.homebrew === 'formula' ? 'Homebrew' + : e.homebrew === 'cask' ? 'Cask' + : e.homebrew === 'node' ? 'npm·HB' + : e.source === 'asdf' ? 'asdf' + : e.source === 'Bun' || e.packageManager === 'bun' ? 'Bun' + : e.source === 'System' ? 'System' + : (e.packageManager || e.source || 'other') + channelCounts[ch] = (channelCounts[ch] || 0) + 1 + } + const channelRows = Object.entries(channelCounts) + .sort((a, b) => b[1] - a[1]) + .map(([label, value]) => ({ + label, + value, + max: Math.max(...Object.values(channelCounts), 1), + color: label.startsWith('Homebrew') || label === 'Cask' ? 'var(--status-ok)' + : label === 'asdf' ? 'var(--status-warn)' + : label === 'Bun' ? '#f472b6' + : label === 'System' ? 'var(--text-subtle)' + : 'var(--accent)', + })) + + // Synthetic 14-day activity from probe timestamps + key updates (bucketed) + const days = 14 + const activity = Array.from({ length: days }, () => 0) + const now = Date.now() + for (const k of keys) { + const ts = k.probe?.checkedAt ? Date.parse(k.probe.checkedAt) : (k.updatedAt ? Date.parse(k.updatedAt) : NaN) + if (!Number.isFinite(ts)) continue + const dayAgo = Math.floor((now - ts) / 86400000) + if (dayAgo >= 0 && dayAgo < days) activity[days - 1 - dayAgo] += 1 + } + // Ensure some visual baseline when empty + const activityPoints = activity.every((n) => n === 0) + ? activity.map((_, i) => (i === days - 1 ? Math.max(keys.length, 1) : 0)) + : activity + + const keyDonut = [ + { value: valid, color: 'var(--status-ok)', label: 'valid' }, + { value: invalid, color: 'var(--status-bad)', label: 'invalid' }, + { value: quota, color: 'var(--status-warn)', label: 'quota' }, + { value: Math.max(0, unprobed - quota), color: 'var(--surface-4)', label: 'unprobed' }, ] + + const harnessDonut = [ + { value: harnessInstalled - harnessMulti, color: 'var(--status-ok)', label: 'installed' }, + { value: harnessMulti, color: 'var(--status-warn)', label: 'multi' }, + { value: harnessMissing, color: 'var(--surface-4)', label: 'missing' }, + ] + + type StatBadge = 'ok' | 'bad' | 'warn' + const doctorBadge: StatBadge = doctorSummary.error ? 'bad' : doctorSummary.warn ? 'warn' : 'ok' + const harnessBadge: StatBadge = harnessMulti ? 'warn' : 'ok' + const stats: { key: string; value: number; label: string; badge: StatBadge; sub: string }[] = [ + { key: 'stored', value: keys.length, label: 'Keys stored', badge: 'ok', sub: `${providersLabel(keys)} providers` }, + { key: 'valid', value: valid, label: 'Valid', badge: 'ok', sub: unprobed ? `${unprobed} not probed` : 'all probed' }, + { key: 'invalid', value: invalid, label: 'Invalid', badge: 'bad', sub: invalid ? 'needs rotation' : 'none' }, + { key: 'doctor', value: doctorSummary.error + doctorSummary.warn, label: 'Doctor issues', badge: doctorBadge, sub: `${doctorSummary.error} err · ${doctorSummary.warn} warn` }, + { key: 'harnesses', value: harnessInstalled, label: 'Harnesses live', badge: harnessBadge, sub: harnessMulti ? `${harnessMulti} multi-install` : `${harnessMissing} available` }, + { key: 'runtimes', value: runtimes.length, label: 'Runtimes', badge: 'ok', sub: `${pms.length} package managers` }, + ] + return (
Re-probe all} + subtitle="Live health of vault keys, harness installs, and install channels." + primaryAction={ + + } /> -
+
{stats.map((s) => ( - +
{s.sub}
+
))}
+ +
+
+
+

Key health

+ {keys.length} total +
+
+ + {keys.length ? Math.round((valid / Math.max(keys.length, 1)) * 100) : 0}% + valid +
+ } + /> +
    + {keyDonut.map((s) => ( +
  • + + {s.label} + {s.value} +
  • + ))} +
+
+
+ +
+
+

Harness coverage

+ {harnessPrimary.length} catalog +
+
+ + {harnessInstalled}/{harnessPrimary.length || 0} + live +
+ } + /> +
    + {harnessDonut.map((s) => ( +
  • + + {s.label} + {s.value} +
  • + ))} +
+
+
+ +
+
+

Install channels

+ where binaries come from +
+
+ {channelRows.length === 0 ? ( +

No installs discovered yet.

+ ) : ( + + )} +
+
+ +
+
+

Vault activity

+ last 14 days +
+
+ +
+ −14d + today +
+

+ Counts key saves and successful probes bucketed by day. +

+
+
+
+
+ + ) +} + +function providersLabel(keys: KeyRow[]): number { + return new Set(keys.map((k) => k.providerId)).size +} + +function doctorSeverityBadge(severity: DoctorFinding['severity']): string { + switch (severity) { + case 'error': return 'badge-bad' + case 'warn': return 'badge-warn' + case 'info': return 'badge-info-faded' + case 'ok': return 'badge-ok' + } +} + +function DoctorSurface({ + report, + onOpenLibrary, + onAction, + onRescan, + busy, +}: { + report: ReturnType + onOpenLibrary: (catalogId: string) => void + onAction: (action: DoctorAction) => void + onRescan: () => void + busy?: boolean +}) { + const [openId, setOpenId] = useState( + report.findings.find((f) => f.severity === 'warn' || f.severity === 'error')?.id + ?? report.findings[0]?.id + ?? null, + ) + const [copied, setCopied] = useState(null) + const [runningId, setRunningId] = useState(null) + + const copy = async (text: string, id: string) => { + try { + await navigator.clipboard.writeText(text) + setCopied(id) + setTimeout(() => setCopied(null), 1500) + } catch { + // ignore + } + } + + const runFix = async (action: DoctorAction, id: string) => { + setRunningId(id) + try { + onAction(action) + } finally { + setTimeout(() => setRunningId(null), 400) + } + } + + return ( +
+ + {busy ? 'Scanning…' : 'Re-scan'} + + } + /> +
+
+
+ + {report.summary.error} + errors +
+
+ + {report.summary.warn} + warnings +
+
+ {report.summary.info} + info +
+
+ + {report.summary.ok} + clear +
+
+ +
+ {report.findings.map((f) => { + const open = openId === f.id + const fixActions = f.resolutions.filter((r) => r.action) + return ( +
+ + {open && ( +
+

{f.detail}

+ + {f.installs && f.installs.length > 0 && ( +
+
Installs on PATH
+
    + {f.installs.map((inst) => ( +
  • +
    + {inst.source} + {inst.primary && PATH} + {inst.homebrew && ( + + {inst.homebrew === 'cask' ? 'Homebrew Cask' : inst.homebrew === 'node' ? 'Homebrew Node' : 'Homebrew'} + + )} + {!inst.homebrew && ( + not Homebrew + )} + {inst.version && ( + {inst.version} + )} +
    +
    {inst.path}
    +
  • + ))} +
+
+ )} + + {fixActions.length > 0 && ( +
+
One-click fixes
+
+ {fixActions.map((r, i) => { + const cid = `${f.id}:fix:${i}` + const isPrimary = r.primary + const label = + r.action?.type === 'upgrade' ? 'Upgrade' + : r.action?.type === 'uninstall' ? 'Uninstall duplicate' + : r.action?.type === 'reconfigure' ? 'Reconfigure' + : r.action?.type === 'install' ? 'Install' + : r.label + return ( + + ) + })} +
+
+ )} + +
+
Details & commands
+ {f.resolutions.map((r, i) => { + const cid = `${f.id}:res:${i}` + return ( +
+
+
{r.label}
+ {r.action && ( + + )} +
+ {r.note &&
{r.note}
} + {r.command && ( +
+
{r.command}
+ +
+ )} +
+ ) + })} +
+ + {f.catalogId && ( +
+ + {f.catalogId === 'claude-code' || f.catalogId === 'opencode' || f.catalogId === 'codex' ? ( + + ) : null} +
+ )} +
+ )} +
+ ) + })} +
) } -function DetailRail({ surface, library }: { surface: SurfaceId; library: LibraryEntry[] }) { - if (surface === 'library') return +function DetailRail({ + surface, + selectedLibrary, + selectedHarness, + selectedKey, + selectedGateway, + recentKeys = [], + keys = [], + providers = [], + library = [], + doctorReport, + harnessOptions = [], + lastWiring = null, + harnessBusy, + keysBusy, + gatewayBusy, + onRedetectHarness, + onInstallHarness, + onProbeKey, + onCopyKey, + onDeleteKey, + onApplyGateway, + onNavigate, + onOpenLibrary, + onReprobeAll, + onToast, +}: { + surface: SurfaceId + selectedLibrary: LibraryEntry | undefined + selectedHarness?: LibraryEntry + selectedKey?: KeyRow + selectedGateway?: GatewaySummary + recentKeys?: KeyRow[] + keys?: KeyRow[] + providers?: ProviderSummary[] + library?: LibraryEntry[] + doctorReport?: ReturnType + harnessOptions?: LibraryEntry[] + lastWiring?: HarnessWiringResult[] | null + harnessBusy?: boolean + keysBusy?: boolean + gatewayBusy?: boolean + onRedetectHarness?: () => void + onInstallHarness?: (catalogId: string) => void + onProbeKey?: (row: KeyRow) => void + onCopyKey?: (secretId: string) => void + onDeleteKey?: (secretId: string) => void + onApplyGateway?: (opts: { + gatewayId: string + baseUrl: string + providerId: string + secretId: string + harnessIds: string[] + }) => void + onNavigate?: (s: SurfaceId) => void + onOpenLibrary?: (catalogId: string) => void + onReprobeAll?: () => void + onToast?: (msg: string) => void +}) { + if (surface === 'library') { + return ( + onRedetectHarness?.()} + onToast={onToast} + /> + ) + } + if (surface === 'doctor') { + return ( + + ) + } return ( + ) +} + +function DoctorDetailRail({ + report, + library, + onOpenLibrary, + onNavigate, +}: { + report?: ReturnType + library: LibraryEntry[] + onOpenLibrary?: (catalogId: string) => void + onNavigate?: (s: SurfaceId) => void +}) { + const [copied, setCopied] = useState(null) + const summary = report?.summary ?? { error: 0, warn: 0, info: 0, ok: 0 } + const top = (report?.findings ?? []) + .filter((f) => f.severity === 'error' || f.severity === 'warn' || f.severity === 'info') + .slice(0, 5) + + const pathWinners = library + .filter((e) => e.kind === 'harness' && e.primary && e.status === 'installed') + .map((e) => ({ + name: e.name, + bin: catalogBinaryName(e.catalogId), + path: e.path, + version: e.version, + source: e.source, + multi: e.installs.length, + catalogId: e.catalogId, + })) + + const copy = async (cmd: string, id: string) => { + try { + await navigator.clipboard.writeText(cmd) + setCopied(id) + setTimeout(() => setCopied(null), 1200) + } catch { /* ignore */ } + } + + const multiCmd = `which -a ${pathWinners.map((w) => w.bin).join(' ') || 'claude opencode codex'} node npm bun` + + return ( + + ) +} + +function WatchtowerDetailRail({ + keys, + library, + doctorReport, + keysBusy, + onReprobeAll, + onNavigate, + onOpenLibrary, +}: { + keys: KeyRow[] + library: LibraryEntry[] + doctorReport?: ReturnType + keysBusy?: boolean + onReprobeAll?: () => void + onNavigate?: (s: SurfaceId) => void + onOpenLibrary?: (catalogId: string) => void +}) { + const valid = keys.filter((k) => k.probe?.valid && k.probe.status === 'ok').length + const invalid = keys.filter((k) => k.probe && (!k.probe.valid || k.probe.status === 'invalid')).length + const unprobed = keys.length - valid - invalid + const summary = doctorReport?.summary ?? { error: 0, warn: 0, info: 0, ok: 0 } + + const winners = library + .filter((e) => e.kind === 'harness' && e.primary) + .map((e) => ({ + catalogId: e.catalogId, + name: e.name, + bin: catalogBinaryName(e.catalogId), + path: e.path, + version: e.version, + status: e.status, + multi: e.installs.length, + source: e.source, + })) + + const probed = [...keys] + .filter((k) => k.probe) + .sort((a, b) => Date.parse(b.probe!.checkedAt) - Date.parse(a.probe!.checkedAt)) + .slice(0, 6) + + return ( + ) @@ -903,6 +3425,7 @@ function CommandPalette({ onClose, onSelect }: { onClose: () => void; onSelect: { id: 'surface-keys', label: 'Open Provider keys', hint: 'New item catalogue', kind: 'Navigate' }, { id: 'surface-gateway', label: 'Open Gateway', hint: '11 gateways · 18 providers', kind: 'Navigate' }, { id: 'surface-status', label: 'Open Watchtower', hint: 'Key health · last probe', kind: 'Navigate' }, + { id: 'surface-doctor', label: 'Open Doctor', hint: 'PATH conflicts · install channels', kind: 'Navigate' }, { id: 'open-claude-settings',label: 'Reveal ~/.claude/settings.json', hint: 'Reveal in Finder', kind: 'Reveal' }, { id: 'open-opencode', label: 'Reveal ~/.config/opencode/', hint: 'Reveal in Finder', kind: 'Reveal' }, { id: 'open-codex', label: 'Reveal ~/.codex/', hint: 'Reveal in Finder', kind: 'Reveal' }, diff --git a/src/renderer/styles/components.css b/src/renderer/styles/components.css index cfe103d..9d655e6 100644 --- a/src/renderer/styles/components.css +++ b/src/renderer/styles/components.css @@ -4,19 +4,55 @@ * (`.btn`, `.card`, etc.) to keep the JSX clean. */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Optical vertical centering for control chrome */ +.btn, +.badge, +.input, +.kbd, +.hoist-scope-trigger, +.hoist-library-filter, +.hoist-sidebar-item-count, +.hoist-sidebar-item, +.hoist-rail-item, +.hoist-list-row, +.hoist-library-row { + line-height: 1; +} + +.btn svg, +.badge svg, +.hoist-scope-trigger svg, +.hoist-sidebar-item svg, +.hoist-rail-item svg, +.hoist-list-row-icon svg, +.hoist-sidebar-collapse svg, +.hoist-palette-trigger svg { + display: block; + flex-shrink: 0; +} + .btn { display: inline-flex; align-items: center; justify-content: center; - gap: 8px; + gap: 6px; height: 32px; padding: 0 14px; border-radius: var(--radius-control); border: 1px solid var(--border); background: var(--surface-2); color: var(--text); + font-family: inherit; font-size: 13px; font-weight: 500; + line-height: 1; + white-space: nowrap; cursor: pointer; transition: background 80ms ease, border-color 80ms ease, color 80ms ease; } @@ -34,7 +70,7 @@ .btn-pill { border-radius: var(--radius-pill); - padding: 0 20px; + padding: 0 16px; font-weight: 600; } @@ -48,8 +84,18 @@ .btn-danger { color: var(--status-bad); } .btn-danger:hover { background: var(--status-bad-soft); border-color: var(--status-bad); } -.btn-sm { height: 26px; padding: 0 10px; font-size: 12px; } -.btn-lg { height: 38px; padding: 0 20px; font-size: 14px; } +.btn-sm { + height: 28px; + padding: 0 10px; + font-size: 12px; + gap: 5px; +} +.btn-sm.btn-pill { padding: 0 12px; } +.btn-lg { + height: 36px; + padding: 0 18px; + font-size: 14px; +} .card { background: var(--surface-1); @@ -61,6 +107,8 @@ .card-pad-lg { padding: 24px; } .input { + display: inline-flex; + align-items: center; height: 32px; width: 100%; padding: 0 12px; @@ -71,34 +119,52 @@ font-family: inherit; font-size: 13px; font-feature-settings: inherit; + line-height: 1; outline: none; } .input::placeholder { color: var(--text-subtle); } .input:hover { border-color: var(--border-strong); } .input:focus { border-color: var(--accent); background: var(--surface-1); } -.input-lg { height: 38px; padding: 0 14px; font-size: 14px; } +.input-lg { height: 36px; padding: 0 14px; font-size: 14px; } .muted { color: var(--text-muted); } .subtle { color: var(--text-subtle); } -.mono { font-family: var(--font-mono); font-size: 12px; } -.kbd { font-family: var(--font-mono); font-size: 11px; - background: var(--surface-2); border: 1px solid var(--border); - padding: 2px 5px; border-radius: var(--radius-tile); color: var(--text-muted); } +.mono { font-family: var(--font-mono); font-size: 12px; line-height: 1.2; } +.kbd { + display: inline-flex; + align-items: center; + justify-content: center; + height: 18px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1; + background: var(--surface-2); + border: 1px solid var(--border); + padding: 0 5px; + border-radius: var(--radius-tile); + color: var(--text-muted); +} -/* Badge — filled treatment matches the design's harness-state pills. - Add \ variants for muted backgrounds. */ +/* Badge — fixed height + line-height:1 keeps label optically centered */ .badge { display: inline-flex; align-items: center; + justify-content: center; gap: 4px; - height: 18px; - padding: 0 7px; + height: 20px; + min-height: 20px; + padding: 0 8px; font-size: 11px; font-weight: 600; + line-height: 1; + letter-spacing: 0.01em; + white-space: nowrap; border-radius: var(--radius-pill); background: var(--surface-3); color: var(--text-muted); + vertical-align: middle; + flex: 0 0 auto; } .badge-ok { background: var(--status-ok); color: var(--text-on-accent); } .badge-ok-faded { background: var(--status-ok-soft); color: var(--status-ok); } @@ -119,4 +185,5 @@ display: inline-flex; align-items: center; justify-content: center; color: currentColor; } +.icon svg { display: block; } .icon-lg { width: 20px; height: 20px; } \ No newline at end of file diff --git a/src/renderer/styles/layout.css b/src/renderer/styles/layout.css index 1180dde..9afd06b 100644 --- a/src/renderer/styles/layout.css +++ b/src/renderer/styles/layout.css @@ -14,16 +14,50 @@ .hoist-body { display: grid; - grid-template-columns: 232px 1fr 320px; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--detail-width); min-height: 0; + position: relative; } -@media (max-width: 1100px) { - .hoist-body { grid-template-columns: 200px 1fr 280px; } +.hoist-body.is-rail { + grid-template-columns: var(--rail-width) minmax(0, 1fr) var(--detail-width); +} + +/* Drag handles between resizable columns */ +.hoist-resize { + position: absolute; + top: 0; + bottom: 0; + width: 6px; + margin-left: -3px; + z-index: 20; + cursor: col-resize; + touch-action: none; + background: transparent; + border: none; + padding: 0; } - -@media (max-width: 900px) { - .hoist-body { grid-template-columns: 56px 1fr 280px; } +.hoist-resize::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 2px; + width: 2px; + background: transparent; + transition: background 120ms ease; +} +.hoist-resize:hover::after, +.hoist-resize.is-active::after { + background: var(--accent); +} +.hoist-resize-sidebar { left: var(--sidebar-width); } +.hoist-body.is-rail .hoist-resize-sidebar { display: none; } +.hoist-resize-detail { left: calc(100% - var(--detail-width)); } +body.is-col-resizing, +body.is-col-resizing * { + cursor: col-resize !important; + user-select: none !important; } /* ─── topbar ─────────────────────────────────────────────────────── */ @@ -91,11 +125,22 @@ border-right: 1px solid var(--border); padding: 8px 0; min-height: 0; + min-width: 0; + overflow: hidden; +} + +.hoist-sidebar-top { + display: flex; + align-items: flex-start; + gap: 4px; + padding: 0 8px 0 4px; } .hoist-sidebar-account { display: flex; align-items: center; gap: 10px; - margin: 4px 12px 12px; + flex: 1; + min-width: 0; + margin: 4px 0 12px 8px; padding: 8px 10px; background: transparent; border: 1px solid transparent; @@ -105,6 +150,26 @@ text-align: left; } .hoist-sidebar-account:hover { background: var(--surface-2); } + +.hoist-sidebar-collapse { + flex: 0 0 auto; + width: 32px; + height: 32px; + margin-top: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-control); + color: var(--text-subtle); + cursor: pointer; +} +.hoist-sidebar-collapse:hover { + background: var(--surface-2); + color: var(--text); + border-color: var(--border); +} .hoist-account-mark { width: 28px; height: 28px; border-radius: var(--radius-control); background: var(--accent); color: var(--text-on-accent); @@ -126,11 +191,19 @@ padding: 6px 8px; } .hoist-sidebar-item { - display: flex; align-items: center; gap: 10px; - width: 100%; padding: 6px 8px; - background: transparent; border: none; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + min-height: 32px; + height: 32px; + padding: 0 8px; + background: transparent; + border: none; color: var(--text-muted); + font-family: inherit; font-size: 13px; + line-height: 1; border-radius: var(--radius-control); cursor: pointer; text-align: left; @@ -141,13 +214,39 @@ color: var(--accent); font-weight: 500; } -.hoist-sidebar-item-icon { width: 18px; text-align: center; font-size: 14px; } -.hoist-sidebar-item-label { flex: 1; } +.hoist-sidebar-item-icon { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + font-size: 14px; + line-height: 1; +} +.hoist-sidebar-item-icon svg { display: block; } +.hoist-sidebar-item-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; +} .hoist-sidebar-item-count { - font-size: 11px; font-weight: 500; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 18px; + padding: 0 6px; + font-size: 11px; + font-weight: 600; + line-height: 1; background: var(--surface-3); color: var(--text-muted); - padding: 1px 7px; border-radius: var(--radius-pill); + border-radius: var(--radius-pill); + flex: 0 0 auto; } .hoist-sidebar-item.is-active .hoist-sidebar-item-count { background: var(--accent); color: var(--text-on-accent); @@ -203,10 +302,19 @@ } .hoist-pane-header { - display: flex; align-items: flex-start; justify-content: space-between; + display: flex; + align-items: center; + justify-content: space-between; gap: 24px; padding: 24px 32px 16px; } +.hoist-pane-actions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + padding-top: 0; +} .hoist-pane-title { margin: 0; font-size: 22px; font-weight: 700; @@ -215,13 +323,19 @@ .hoist-pane-sub { margin: 4px 0 0; font-size: 13px; } -.hoist-pane-actions { padding-top: 4px; } + .hoist-pane-toolbar { - display: flex; gap: 8px; padding: 0 32px 12px; + display: flex; + gap: 8px; + padding: 0 32px 12px; align-items: center; + min-height: 32px; +} +.hoist-search { + max-width: 320px; + height: 32px; } -.hoist-search { max-width: 320px; } .hoist-pane-body { flex: 1; min-height: 0; @@ -241,9 +355,9 @@ .hoist-list-row { display: grid; - grid-template-columns: 36px 1fr auto; + grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; - gap: 12px; + column-gap: 12px; padding: 12px 14px; background: transparent; border: none; @@ -251,49 +365,90 @@ color: var(--text); text-align: left; cursor: pointer; + font-family: inherit; } .hoist-list-row:last-child { border-bottom: none; } .hoist-list-row:hover { background: var(--surface-2); } .hoist-list-row.is-selected { background: var(--accent-soft); - border-left: 2px solid var(--accent); - padding-left: 12px; + box-shadow: inset 2px 0 0 var(--accent); } .hoist-list-row.is-selected .hoist-list-row-title { color: var(--accent); font-weight: 600; } .hoist-list-row-icon { - width: 32px; height: 32px; - display: flex; align-items: center; justify-content: center; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; background: var(--surface-3); border-radius: var(--radius-tile); color: var(--text); + flex: 0 0 auto; + line-height: 1; } +.hoist-list-row-icon svg { display: block; } .hoist-list-row.is-selected .hoist-list-row-icon { background: var(--accent); color: var(--text-on-accent); } -.hoist-list-row-body { min-width: 0; } +.hoist-list-row-body { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; +} .hoist-list-row-title { - display: flex; align-items: center; gap: 8px; - font-size: 13px; font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 13px; + font-weight: 600; + line-height: 1.2; +} +.hoist-list-row-title > :not(.badge) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .hoist-list-row-title .badge { font-weight: 500; } .hoist-list-row-sub { - display: flex; align-items: center; gap: 8px; - font-size: 12px; margin-top: 3px; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-size: 12px; + line-height: 1.2; } -.hoist-dot-sep { opacity: 0.4; } +.hoist-dot-sep { opacity: 0.4; line-height: 1; } .hoist-list-row-meta { - display: flex; align-items: center; gap: 8px; + display: flex; + align-items: center; + gap: 8px; font-size: 12px; + flex: 0 0 auto; + line-height: 1; +} +.hoist-last-probe { + font-size: 11px; + line-height: 1; + white-space: nowrap; } -.hoist-last-probe { font-size: 11px; } .hoist-list-row-check { - width: 22px; height: 22px; - display: flex; align-items: center; justify-content: center; - background: var(--accent); color: var(--text-on-accent); + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent); + color: var(--text-on-accent); border-radius: 50%; + flex: 0 0 auto; } +.hoist-list-row-check svg { display: block; } /* ─── scope picker ─────────────────────────────────────────────── */ @@ -301,17 +456,38 @@ position: relative; } .hoist-scope-trigger { - display: flex; align-items: center; gap: 6px; - height: 32px; padding: 0 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-control); color: var(--text); + font-family: inherit; font-size: 13px; + font-weight: 500; + line-height: 1; + white-space: nowrap; cursor: pointer; } .hoist-scope-trigger:hover { background: var(--surface-3); } -.hoist-scope-icon { display: inline-flex; align-items: center; color: var(--text-subtle); } +.hoist-scope-picker.is-open .hoist-scope-trigger { + border-color: var(--accent); + background: var(--surface-3); +} +.hoist-scope-icon { + display: block; + color: var(--text-subtle); + transition: transform 120ms ease; + flex: 0 0 auto; +} +.hoist-scope-picker.is-open .hoist-scope-icon { + transform: rotate(180deg); + color: var(--accent); +} .hoist-scope-menu { position: absolute; top: 100%; left: 0; margin-top: 4px; min-width: 200px; @@ -320,18 +496,40 @@ border-radius: var(--radius-card); padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - z-index: 10; + z-index: 30; } .hoist-scope-item { - display: flex; align-items: center; gap: 8px; - width: 100%; padding: 6px 8px; - background: transparent; border: none; - color: var(--text); border-radius: var(--radius-tile); - font-size: 13px; text-align: left; cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 32px; + height: 32px; + padding: 0 8px; + background: transparent; + border: none; + color: var(--text); + border-radius: var(--radius-tile); + font-family: inherit; + font-size: 13px; + line-height: 1; + text-align: left; + cursor: pointer; } .hoist-scope-item:hover { background: var(--surface-3); } .hoist-scope-item.is-active { color: var(--accent); } -.hoist-scope-check { color: var(--accent); display: inline-flex; align-items: center; width: 14px; } +.hoist-scope-check-slot { + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} +.hoist-scope-check { + color: var(--accent); + display: block; +} /* ─── right rail ───────────────────────────────────────────────── */ @@ -374,24 +572,53 @@ gap: 12px; } .hoist-stat-card { - display: flex; flex-direction: column; + display: flex; + flex-direction: column; align-items: flex-start; - gap: 6px; - padding: 16px 18px; - background: var(--surface-1); + gap: 10px; + padding: 18px 18px 16px; + background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-card); + color: var(--text); /* buttons default to UA black — force light text */ + font-family: inherit; text-align: left; cursor: pointer; } -.hoist-stat-card:hover { background: var(--surface-2); } +.hoist-stat-card:hover { + background: var(--surface-3); + border-color: var(--border-strong); +} .hoist-stat-value { - font-size: 32px; font-weight: 700; + font-size: 36px; + font-weight: 700; letter-spacing: var(--tracking-display); line-height: 1; - margin-top: 4px; + color: var(--text); +} +.hoist-stat-card.is-ok .hoist-stat-value { color: var(--status-ok); } +.hoist-stat-card.is-bad .hoist-stat-value { color: var(--status-bad); } +.hoist-stat-card.is-warn .hoist-stat-value { color: var(--status-warn); } +.hoist-stat-card .badge { + /* Soft pills read better on elevated cards than solid fills */ +} +.hoist-stat-card .badge-ok { + background: var(--status-ok-soft); + color: #3dd68c; +} +.hoist-stat-card .badge-bad { + background: var(--status-bad-soft); + color: #ff7b72; +} +.hoist-stat-card .badge-warn { + background: var(--status-warn-soft); + color: #f0a000; +} +.hoist-stat-sub { + font-size: 12px; + line-height: 1.35; + color: var(--text-muted); } -.hoist-stat-sub { font-size: 12px; } /* ─── new-item catalogue modal ─────────────────────────────────── */ @@ -520,9 +747,7 @@ } /* ─── Rail (Library surface) ─────────────────────────────────────── */ -.hoist-body.is-rail { - grid-template-columns: var(--rail-width) 1fr var(--sidebar-width); -} +/* is-rail grid columns set above with .hoist-body.is-rail */ .hoist-rail.hoist-rail { display: flex; @@ -583,15 +808,17 @@ .hoist-rail-item { width: 32px; height: 32px; - display: flex; + display: inline-flex; align-items: center; justify-content: center; + padding: 0; background: transparent; border: 1px solid transparent; border-radius: var(--radius-tile); color: var(--text-muted); cursor: pointer; position: relative; + line-height: 1; } .hoist-rail-item:hover { @@ -608,7 +835,11 @@ display: inline-flex; align-items: center; justify-content: center; + width: 16px; + height: 16px; + line-height: 1; } +.hoist-rail-item-icon svg { display: block; } .hoist-rail-footer { padding: 8px 0 12px; @@ -618,45 +849,50 @@ align-items: center; } -/* ─── Library surface (Library detail) ─────────────────────────── */ +/* ─── Library surface — list fills center; detail lives in right rail ─ */ .hoist-library { - display: grid; - grid-template-columns: 280px 1fr; - grid-template-rows: auto auto 1fr; - grid-template-areas: - "header header" - "toolbar toolbar" - "list main"; + display: flex; + flex-direction: column; height: 100%; min-height: 0; padding: 0; } -.hoist-library .hoist-pane-header { - grid-area: header; -} -.hoist-library .hoist-pane-toolbar { - grid-area: toolbar; +.hoist-library .hoist-pane-body { + flex: 1; + min-height: 0; + padding: 4px 32px 32px; + overflow: hidden; + display: flex; + flex-direction: column; } .hoist-library-filters { - display: flex; - gap: 4px; - padding: 2px; + display: inline-flex; + align-items: center; + gap: 2px; + height: 32px; + padding: 3px; background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius-control); } .hoist-library-filter { - height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + height: 26px; padding: 0 10px; background: transparent; border: none; border-radius: var(--radius-tile); color: var(--text-muted); + font-family: inherit; font-size: 12px; font-weight: 500; + line-height: 1; + white-space: nowrap; cursor: pointer; } .hoist-library-filter:hover { @@ -669,177 +905,220 @@ } .hoist-library .hoist-list { - grid-area: list; - margin: 0 0 0 32px; - width: 380px; + margin: 0; + width: 100%; + flex: 1 1 auto; + min-height: 0; background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius-card); - overflow: hidden; - height: fit-content; - max-height: calc(100vh - 220px); + overflow-x: hidden; overflow-y: auto; } .hoist-library-row { + /* avatar | name | ver ...... | badge + . | subtitle .......... | . */ display: grid; - grid-template-columns: 36px 1fr; + grid-template-columns: 28px minmax(0, auto) minmax(0, 1fr) auto; + grid-template-rows: 28px; align-items: center; - column-gap: 12px; + column-gap: 10px; row-gap: 4px; - padding: 12px 14px; + padding: 10px 14px; + font-family: inherit; background: transparent; border: none; border-bottom: 1px solid var(--border); color: var(--text); text-align: left; cursor: pointer; + width: 100%; + line-height: 1; +} +.hoist-library-row.has-sub { + grid-template-rows: 28px 14px; } .hoist-library-row:last-child { border-bottom: none; } .hoist-library-row:hover { background: var(--surface-2); } .hoist-library-row.is-selected { background: var(--accent-soft); - border-left: 2px solid var(--accent); - padding-left: 12px; + box-shadow: inset 2px 0 0 var(--accent); } .hoist-library-avatar { - grid-row: 1 / 3; - width: 32px; - height: 32px; + grid-column: 1; + grid-row: 1; + width: 28px; + height: 28px; border-radius: var(--radius-tile); background: var(--surface-3); - display: flex; + display: inline-flex; align-items: center; justify-content: center; font-weight: 700; - font-size: 12px; + font-size: 10px; + letter-spacing: 0.04em; + line-height: 1; color: var(--text); + user-select: none; } .hoist-library-row.is-selected .hoist-library-avatar { background: var(--accent); color: var(--text-on-accent); } -.hoist-library-row-body { +.hoist-library-row-name { grid-column: 2; - grid-row: 1 / 3; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; - align-items: flex-start; -} - -.hoist-library-row-title { - display: flex; - align-items: baseline; - gap: 8px; + grid-row: 1; font-size: 13px; font-weight: 600; - min-width: 0; - width: 100%; -} -.hoist-library-row-title > span:first-child { + line-height: 28px; + height: 28px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - flex: 0 1 auto; min-width: 0; } +.hoist-library-row-dup { + font-weight: 500; + color: var(--text-subtle); +} .hoist-library-ver { + grid-column: 3; + grid-row: 1; font-size: 11px; + line-height: 28px; + height: 28px; color: var(--text-subtle); font-family: var(--font-mono); font-weight: 400; - flex: 0 0 auto; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + justify-self: start; } -.hoist-library-main { - grid-area: main; - margin: 0 24px 0 24px; - padding: 24px 32px; - overflow-y: auto; - max-width: 920px; +.hoist-library-row > .badge { + grid-column: 4; + grid-row: 1; + justify-self: end; + align-self: center; } -.hoist-library-main-name { - display: flex; - align-items: center; - gap: 16px; - margin-bottom: 16px; - flex-wrap: wrap; +.hoist-library-row-sub { + grid-column: 2 / 4; + grid-row: 2; + font-size: 11px; + line-height: 14px; + height: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } -.hoist-library-main-avatar { - width: 48px; - height: 48px; +/* Detail content for selected harness lives in the right rail */ +.hoist-rail-hero { + display: flex; + flex-direction: column; + gap: 12px; +} +.hoist-rail-hero-top { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + align-items: start; /* logo top-aligned, not vertically centered */ + column-gap: 12px; + min-width: 0; +} +.hoist-rail-hero-avatar { + width: 40px; + height: 40px; border-radius: var(--radius-tile); background: var(--accent); color: var(--text-on-accent); - display: flex; + display: inline-flex; align-items: center; justify-content: center; font-weight: 700; - font-size: 18px; - flex: 0 0 auto; + font-size: 13px; + letter-spacing: 0.04em; + line-height: 1; + align-self: start; + justify-self: start; } - -.hoist-library-main-meta { - flex: 1; +.hoist-rail-hero-meta { min-width: 0; display: flex; flex-direction: column; - gap: 6px; + align-items: flex-start; + gap: 8px; + padding-top: 0; } - -.hoist-library-main-title { +.hoist-rail-hero-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: 100%; + min-width: 0; +} +.hoist-rail-hero-title { margin: 0; - font-size: 22px; + font-size: 18px; font-weight: 700; letter-spacing: var(--tracking-display); + line-height: 1.15; + min-width: 0; + flex: 1 1 auto; } - -.hoist-library-main-models { +.hoist-rail-hero-sub { + font-size: 12px; + color: var(--text-subtle); + line-height: 1.2; +} +.hoist-rail-hero-badges { display: flex; + align-items: center; + gap: 6px; flex-wrap: wrap; - gap: 4px 8px; - margin-top: 4px; } -.hoist-library-main-model { - font-size: 11px; - font-family: var(--font-mono); +.hoist-rail-hero-desc { + margin: 0; + font-size: 12px; + line-height: 1.5; color: var(--text-muted); - background: var(--surface-2); - padding: 2px 8px; - border-radius: var(--radius-tile); - white-space: nowrap; -} - -.hoist-library-main-model { - font-size: 11px; - font-family: var(--font-mono); - color: var(--text-subtle); } - -.hoist-library-main-actions { +.hoist-rail-hero-actions { display: flex; - gap: 8px; + align-items: center; + gap: 4px; flex: 0 0 auto; + margin-top: -2px; /* align with title cap height */ } - -.hoist-library-main-desc { - font-size: 13px; - line-height: 1.55; - color: var(--text-muted); - margin: 0 0 16px; +.hoist-rail-hero-live { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; } - -.hoist-library-main-features { - border-top: 1px solid var(--border); - padding-top: 16px; +.hoist-rail-hero-live-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-subtle); + line-height: 1; +} +.hoist-rail-hero-live-value { + font-size: 12px; + color: var(--text); + line-height: 1.3; + overflow-wrap: anywhere; + word-break: break-word; } .hoist-library-main-section-label { @@ -896,25 +1175,110 @@ .hoist-rail-kvrow { display: flex; - justify-content: space-between; - align-items: baseline; + flex-direction: column; + align-items: stretch; + gap: 3px; font-size: 12px; - gap: 12px; + min-width: 0; +} + +.hoist-rail-install-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.hoist-rail-install-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 10px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-control); + min-width: 0; +} +.hoist-rail-install-item.is-current { + border-color: var(--accent); + background: var(--accent-soft); +} +.hoist-rail-install-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} +.hoist-rail-install-source { + font-size: 12px; + font-weight: 600; + color: var(--text); + line-height: 1; +} +.hoist-rail-install-ver { + font-size: 11px; + color: var(--text-subtle); + margin-left: auto; +} +.hoist-rail-install-path, +.hoist-rail-install-real { + font-size: 11px; + line-height: 1.35; + overflow-wrap: anywhere; + word-break: break-word; + color: var(--text-muted); +} +.hoist-rail-install-real { color: var(--text-subtle); } + +.hoist-rail-model-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; + max-height: min(320px, 40vh); + overflow-y: auto; +} +.hoist-rail-model-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + background: var(--surface-2); + border-radius: var(--radius-tile); } +.hoist-rail-model-glyph { + color: var(--accent); + flex: 0 0 auto; +} +.hoist-rail-model-name { + word-break: break-all; + flex: 1 1 auto; +} + .hoist-rail-kvkey { font-size: 11px; + font-weight: 500; color: var(--text-subtle); + line-height: 1.2; } .hoist-rail-kvval { font-family: var(--font-mono); - font-size: 11px; + font-size: 12px; color: var(--text); - text-align: right; - word-break: break-all; + text-align: left; + overflow-wrap: anywhere; + word-break: break-word; + line-height: 1.35; min-width: 0; - flex: 0 1 60%; } .hoist-terminal { @@ -929,3 +1293,701 @@ overflow-x: auto; white-space: pre; } + +/* ─── Doctor ───────────────────────────────────────────────────── */ + +.hoist-doctor-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} +.hoist-doctor-pill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: var(--radius-pill); + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + line-height: 1; +} +.hoist-doctor-pill-n { + font-weight: 700; + font-variant-numeric: tabular-nums; + color: var(--text); +} +.hoist-doctor-pill.is-error.is-hot { + background: var(--status-bad-soft); + border-color: rgba(207, 34, 46, 0.35); + color: #ff7b72; +} +.hoist-doctor-pill.is-error.is-hot .hoist-doctor-pill-n { color: #ff7b72; } +.hoist-doctor-pill.is-warn.is-hot { + background: var(--status-warn-soft); + border-color: rgba(240, 160, 0, 0.35); + color: #f0a000; +} +.hoist-doctor-pill.is-warn.is-hot .hoist-doctor-pill-n { color: #f0a000; } +.hoist-doctor-pill.is-ok .hoist-doctor-pill-n { color: var(--status-ok); } + +.hoist-doctor-list { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 880px; +} +.hoist-doctor-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-card); + overflow: hidden; +} +.hoist-doctor-card.is-error { border-color: rgba(207, 34, 46, 0.35); } +.hoist-doctor-card.is-warn { border-color: rgba(240, 160, 0, 0.28); } +.hoist-doctor-card.is-ok { border-color: rgba(45, 164, 78, 0.28); } + +.hoist-doctor-card-head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 14px; + background: transparent; + border: none; + color: var(--text); + text-align: left; + cursor: pointer; + font-family: inherit; +} +.hoist-doctor-card-head:hover { background: var(--surface-3); } +.hoist-doctor-card-title { + font-size: 13px; + font-weight: 600; + line-height: 1.3; + min-width: 0; +} +.hoist-doctor-card-caret { + color: var(--text-subtle); + transition: transform 120ms ease; +} +.hoist-doctor-card-body { + padding: 0 14px 14px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 14px; +} +.hoist-doctor-card-detail { + margin: 12px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--text-muted); +} +.hoist-doctor-section-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-subtle); + margin-bottom: 8px; +} +.hoist-doctor-resolutions { + display: flex; + flex-direction: column; + gap: 12px; +} +.hoist-doctor-res-label { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} +.hoist-doctor-res-note { + font-size: 12px; + line-height: 1.4; + margin-bottom: 6px; +} +.hoist-doctor-cmd { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; +} +.hoist-doctor-cmd .hoist-terminal { + margin: 0; + min-width: 0; +} +.hoist-doctor-card-actions { + display: flex; + gap: 8px; +} + +/* ─── Watchtower charts ────────────────────────────────────────── */ + +.hoist-watchtower { + display: flex; + flex-direction: column; + gap: 20px; +} + +.hoist-chart-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} +@media (max-width: 1100px) { + .hoist-chart-grid { grid-template-columns: 1fr; } +} + +.hoist-chart-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-card); + padding: 14px 16px 16px; + min-width: 0; +} +.hoist-chart-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +.hoist-chart-title { + margin: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: var(--tracking-body); +} +.hoist-chart-body { min-width: 0; } +.hoist-chart-body-row { + display: flex; + align-items: center; + gap: 20px; +} +.hoist-chart-footnote { + margin: 10px 0 0; + font-size: 11px; + line-height: 1.4; +} + +.hoist-donut { + position: relative; + flex: 0 0 auto; +} +.hoist-donut-center { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} +.hoist-donut-label { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + line-height: 1; +} +.hoist-donut-label strong { + font-size: 18px; + font-weight: 700; + letter-spacing: var(--tracking-display); + color: var(--text); +} +.hoist-donut-label span { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-subtle); +} + +.hoist-legend { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1 1 auto; + min-width: 0; +} +.hoist-legend li { + display: grid; + grid-template-columns: 10px 1fr auto; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); +} +.hoist-legend-swatch { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.hoist-barchart { + display: flex; + flex-direction: column; + gap: 10px; +} +.hoist-barchart-row { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; +} +.hoist-barchart-label { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.hoist-barchart-track { + height: 8px; + background: var(--surface-3); + border-radius: 999px; + overflow: hidden; + min-width: 0; +} +.hoist-barchart-fill { + height: 100%; + border-radius: 999px; + min-width: 2px; + transition: width 200ms ease; +} +.hoist-barchart-value { + font-size: 11px; + color: var(--text-subtle); + white-space: nowrap; +} + +.hoist-spark { + display: flex; + align-items: flex-end; + gap: 4px; + height: 72px; + padding: 4px 0; +} +.hoist-spark-bar { + flex: 1 1 0; + min-width: 4px; + border-radius: 2px 2px 0 0; + opacity: 0.9; +} +.hoist-spark-axis { + display: flex; + justify-content: space-between; + font-size: 10px; + margin-top: 6px; +} + +.hoist-empty { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding: 24px 8px; +} +.hoist-toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 200; + padding: 10px 16px; + background: var(--surface-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-control); + color: var(--text); + font-size: 13px; + font-weight: 500; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} +.hoist-addkey-form { + display: flex; + flex-direction: column; + gap: 10px; + max-width: 480px; +} +.hoist-addkey-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} +.hoist-form-error { + margin: 0; + color: #ff7b72; + font-size: 12px; +} + +/* ─── PATH priority panel ──────────────────────────────────────── */ + +.hoist-path-lead { + margin: 0 0 10px; + font-size: 12px; + line-height: 1.45; + color: var(--text-muted); +} +.hoist-path-empty { + margin: 0 0 8px; + font-size: 12px; +} +.hoist-path-list { + list-style: none; + margin: 0 0 10px; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; + counter-reset: none; +} +.hoist-path-item { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + gap: 8px; + align-items: start; + padding: 8px 8px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-control); + min-width: 0; +} +.hoist-path-item.is-winner { + border-color: rgba(45, 164, 78, 0.45); + background: var(--status-ok-soft); +} +.hoist-path-item.is-current:not(.is-winner) { + border-color: var(--accent); +} +.hoist-path-rank { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-tile); + background: var(--surface-3); + color: var(--text-subtle); + font-size: 11px; + font-weight: 700; + line-height: 1; +} +.hoist-path-item.is-winner .hoist-path-rank { + background: var(--status-ok); + color: var(--text-on-accent); +} +.hoist-path-body { min-width: 0; } +.hoist-path-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 3px; +} +.hoist-path-source { + font-size: 12px; + font-weight: 600; + color: var(--text); +} +.hoist-path-ver { + font-size: 11px; + color: var(--text-subtle); + margin-left: auto; +} +.hoist-path-bin, +.hoist-path-real { + font-size: 11px; + line-height: 1.35; + overflow-wrap: anywhere; + word-break: break-word; + color: var(--text-muted); +} +.hoist-path-real { color: var(--text-subtle); } + +/* ─── Gateway apply panel ──────────────────────────────────────── */ + +.hoist-gateway-fields { + display: flex; + flex-direction: column; + gap: 8px; +} +.hoist-gateway-field { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} +.hoist-gateway-resolved { + font-size: 11px; + line-height: 1.4; + color: var(--text-muted); + word-break: break-all; + padding: 6px 8px; + background: var(--surface-recessed); + border-radius: var(--radius-tile); + border: 1px solid var(--border); +} +.hoist-gateway-harnesses { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} +.hoist-gateway-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text); + cursor: pointer; +} +.hoist-gateway-check input { + accent-color: var(--accent); +} +.hoist-doc-link { + display: inline-block; + margin-top: 8px; + font-size: 12px; + color: var(--accent); + text-decoration: none; +} +.hoist-doc-link:hover { text-decoration: underline; } + +/* select styled like input */ +select.input { + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, var(--text-subtle) 50%), + linear-gradient(135deg, var(--text-subtle) 50%, transparent 50%); + background-position: calc(100% - 14px) calc(50% - 2px), calc(100% - 10px) calc(50% - 2px); + background-size: 4px 4px, 4px 4px; + background-repeat: no-repeat; + padding-right: 28px; + color: var(--text); +} + +/* ─── Doctor / Watchtower detail rails ─────────────────────────── */ + +.hoist-winner-list { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.hoist-winner-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + background: transparent; + border: none; + padding: 0; + color: var(--text); + cursor: pointer; + text-align: left; + font-family: inherit; +} +.hoist-winner-row:hover .hoist-winner-name { color: var(--accent); } +.hoist-winner-name { + font-size: 13px; + font-weight: 600; + min-width: 0; +} +.hoist-winner-ver { + font-size: 11px; + color: var(--text-subtle); +} +.hoist-winner-path { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + word-break: break-all; + line-height: 1.35; +} +.hoist-winner-meta { + font-size: 11px; + margin-top: 2px; +} + +.hoist-finding-mini { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} +.hoist-finding-mini li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + align-items: start; +} +.hoist-finding-mini-title { + font-size: 12px; + line-height: 1.35; + color: var(--text-muted); + min-width: 0; +} + +.hoist-rail-actions { + display: flex; + flex-direction: column; + gap: 6px; +} + +.hoist-sev-ok { color: var(--status-ok); font-weight: 600; } +.hoist-sev-warn { color: #f0a000; font-weight: 600; } +.hoist-sev-bad { color: #ff7b72; font-weight: 600; } + +/* ─── Harness configure panel ──────────────────────────────────── */ + +.hoist-config-panel { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 4px; +} +.hoist-config-custom { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + margin-top: 4px; +} +.hoist-model-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} +.hoist-model-chip { + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-muted); + border-radius: var(--radius-tile); + padding: 4px 8px; + font-size: 11px; + font-family: var(--font-mono); + cursor: pointer; + line-height: 1.2; +} +.hoist-model-chip:hover { border-color: var(--accent); color: var(--text); } +.hoist-model-chip.is-active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent); +} +.hoist-config-notes { + margin: 4px 0 0; + padding-left: 16px; + font-size: 11px; + line-height: 1.4; +} +.hoist-config-excerpt { + margin-top: 6px; + font-size: 11px; + color: var(--text-subtle); +} +.hoist-config-excerpt summary { + cursor: pointer; + margin-bottom: 6px; +} +.hoist-config-excerpt .hoist-terminal { + max-height: 180px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +/* ─── Harness lifecycle ────────────────────────────────────────── */ + +.hoist-lifecycle { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 4px; +} +.hoist-lifecycle-methods { + display: flex; + gap: 12px; + flex-wrap: wrap; +} +.hoist-lifecycle-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.hoist-changelog { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} +.hoist-changelog-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 280px; + overflow-y: auto; +} +.hoist-changelog-item { + border: 1px solid var(--border); + border-radius: var(--radius-tile); + background: var(--surface-2); + padding: 6px 8px; +} +.hoist-changelog-item summary { + cursor: pointer; + font-size: 12px; + font-weight: 600; + color: var(--text); +} +.hoist-changelog-body { + margin: 6px 0 0; + padding: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 11px; + line-height: 1.45; + color: var(--text-muted); + font-family: var(--font-sans); + max-height: 160px; + overflow-y: auto; +} + +.hoist-doctor-fixbar { + display: flex; + flex-direction: column; + gap: 8px; +} +.hoist-doctor-fixbar-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.hoist-doctor-res-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.hoist-doctor-res.has-action { + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface-1); +} diff --git a/src/renderer/styles/tokens.css b/src/renderer/styles/tokens.css index 0f29750..3d98d5f 100644 --- a/src/renderer/styles/tokens.css +++ b/src/renderer/styles/tokens.css @@ -16,7 +16,8 @@ /* Rail + sidebar widths */ --rail-width: 56px; /* icon-only nav rail (Library, default) */ - --sidebar-width: 232px; /* expanded sidebar w/ labels (extended state) */ + --sidebar-width: 232px; /* expanded left sidebar (resizable) */ + --detail-width: 400px; /* right inspector rail (resizable) */ /* Hairlines & dividers */ --border: #34343a; @@ -57,11 +58,11 @@ --tracking-display: -0.04em; --tracking-body: -0.011em; - /* Shape */ - --radius-card: 12px; - --radius-control: 8px; - --radius-pill: 9999px; - --radius-tile: 6px; + /* Shape — small, consistent radii (sharp Knox-like chrome) */ + --radius-card: 6px; /* lists, panels, modals, doctor cards */ + --radius-control: 6px; /* buttons, inputs, sidebar items */ + --radius-tile: 4px; /* avatars, chips, icon buttons */ + --radius-pill: 9999px; /* badges + primary pill CTAs only */ /* Spacing */ --space-1: 4px; diff --git a/src/shared/channels.ts b/src/shared/channels.ts index 1bd2fc8..4b2d7ae 100644 --- a/src/shared/channels.ts +++ b/src/shared/channels.ts @@ -11,6 +11,10 @@ export const CHANNELS = { gatewayList: 'gateway:list', gatewayApply: 'gateway:apply', harnessConfigShow: 'harness:configShow', + harnessConfigSet: 'harness:configSet', + harnessConfigReset: 'harness:configReset', + harnessVersions: 'harness:versions', + harnessUninstall: 'harness:uninstall', clipboardRead: 'clipboard:read', libraryList: 'library:list', } as const diff --git a/src/shared/doctor.ts b/src/shared/doctor.ts new file mode 100644 index 0000000..cdfe676 --- /dev/null +++ b/src/shared/doctor.ts @@ -0,0 +1,377 @@ +/** + * Doctor — conflict analysis over Library discovery results. + * + * Pure functions so the renderer (and tests) can run without IPC. + */ + +export type DoctorSeverity = 'error' | 'warn' | 'info' | 'ok' + +/** Machine-executable fix the Doctor UI can run (not just copy). */ +export type DoctorAction = + | { type: 'uninstall'; harnessId: string; prefer?: 'npm' | 'brew' } + | { type: 'install'; harnessId: string; prefer?: 'npm' | 'brew'; force?: boolean; version?: string } + | { type: 'upgrade'; harnessId: string; prefer?: 'npm' | 'brew' } + | { type: 'reconfigure'; harnessId: string } + | { type: 'navigate'; surface: 'library' | 'harnesses' | 'keys' | 'gateway' | 'doctor' | 'status' } + +export interface DoctorResolution { + label: string + /** Shell snippet the user can copy/run. */ + command?: string + note?: string + /** If set, Doctor shows a Fix button that runs this action. */ + action?: DoctorAction + /** Highlight as the recommended fix. */ + primary?: boolean +} + +export interface DoctorFinding { + id: string + severity: DoctorSeverity + category: 'path-shadow' | 'channel-mix' | 'version-skew' | 'package-manager' | 'missing' | 'healthy' + title: string + detail: string + catalogId?: string + kind?: string + installs?: Array<{ + path: string + version: string | null + source: string + primary: boolean + homebrew: string | null + packageManager: string | null + }> + resolutions: DoctorResolution[] +} + +export interface DoctorReport { + findings: DoctorFinding[] + summary: { error: number; warn: number; info: number; ok: number } +} + +type LibLike = { + id: string + catalogId: string + kind: string + name: string + status: string + version: string | null + path: string | null + source: string | null + packageManager: string | null + homebrew: 'formula' | 'cask' | 'node' | null + primary: boolean + installs: Array<{ + path: string + realPath: string + version: string | null + source: string + packageManager: string | null + homebrew: 'formula' | 'cask' | 'node' | null + primary: boolean + }> +} + +function uniqVersions(installs: LibLike['installs']): string[] { + return [...new Set(installs.map((i) => i.version).filter((v): v is string => Boolean(v)))] +} + +function uniqChannels(installs: LibLike['installs']): string[] { + return [...new Set(installs.map((i) => { + if (i.homebrew === 'formula') return 'Homebrew formula' + if (i.homebrew === 'cask') return 'Homebrew Cask' + if (i.homebrew === 'node') return 'npm · Homebrew Node' + return i.source || i.packageManager || 'other' + }))] +} + +function shellQuote(path: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(path)) return path + return `'${path.replace(/'/g, `'\\''`)}'` +} + +/** Binary name used on PATH for a catalog id. */ +export function catalogBinaryName(catalogId: string): string { + switch (catalogId) { + case 'claude-code': return 'claude' + case 'codex': return 'codex' + case 'opencode': return 'opencode' + case 'python': return 'python3' + case 'pip': return 'pip3' + case 'rust': return 'rustc' + default: return catalogId + } +} + +function isHarnessId(catalogId: string): boolean { + return catalogId === 'claude-code' || catalogId === 'opencode' || catalogId === 'codex' +} + +function resolutionsForShadow(name: string, catalogId: string, installs: LibLike['installs']): DoctorResolution[] { + const primary = installs.find((i) => i.primary) ?? installs[0] + const others = installs.filter((i) => i !== primary) + const res: DoctorResolution[] = [] + const harness = isHarnessId(catalogId) + + res.push({ + label: `Keep PATH primary (${primary.source}${primary.version ? ` ${primary.version}` : ''})`, + note: `Bare \`${catalogBinaryName(catalogId)}\` resolves to ${primary.path}`, + primary: true, + action: harness ? { type: 'reconfigure', harnessId: catalogId } : undefined, + }) + + if (harness) { + res.push({ + label: 'Upgrade PATH winner to latest', + note: `Reinstall/upgrade the primary ${name} install via ${primary.homebrew ? 'Homebrew' : primary.packageManager === 'npm' || primary.homebrew === 'node' ? 'npm' : 'Homebrew'}.`, + action: { + type: 'upgrade', + harnessId: catalogId, + prefer: primary.homebrew ? 'brew' : 'npm', + }, + primary: true, + }) + res.push({ + label: 'Reconfigure models & wiring', + note: 'Open Library configure for model selection and Hoist env reset.', + action: { type: 'reconfigure', harnessId: catalogId }, + }) + } + + for (const o of others) { + if (o.homebrew === 'cask') { + res.push({ + label: `Remove Homebrew Cask copy (${o.version ?? 'unknown'})`, + command: `brew uninstall --cask ${catalogId === 'claude-code' ? 'claude-code' : catalogId}`, + action: harness ? { type: 'uninstall', harnessId: catalogId, prefer: 'brew' } : undefined, + }) + } else if (o.homebrew === 'formula') { + const formula = catalogId === 'node' ? 'node' : catalogId === 'python' ? 'python@3.14' : catalogId + res.push({ + label: `Remove Homebrew formula copy (${o.version ?? 'unknown'})`, + command: `brew uninstall ${formula}`, + action: harness ? { type: 'uninstall', harnessId: catalogId, prefer: 'brew' } : undefined, + }) + } else if (o.source === 'asdf' || o.packageManager === 'asdf') { + res.push({ + label: `Remove asdf shim / version (${o.version ?? 'unknown'})`, + command: o.version + ? `asdf uninstall ${catalogBinaryName(catalogId)} ${o.version}` + : `asdf list ${catalogBinaryName(catalogId)}`, + note: 'Then run `asdf reshim` and open a new shell. Hoist cannot drive asdf directly.', + }) + } else if (o.packageManager === 'npm' || o.homebrew === 'node') { + res.push({ + label: 'Remove npm global copy', + command: `npm uninstall -g ${catalogId === 'claude-code' ? '@anthropic-ai/claude-code' : catalogId === 'opencode' ? 'opencode-ai' : catalogId === 'codex' ? '@openai/codex' : catalogId}`, + action: harness ? { type: 'uninstall', harnessId: catalogId, prefer: 'npm' } : undefined, + }) + } else if (o.packageManager === 'bun') { + res.push({ + label: 'Remove Bun global copy', + command: `bun remove -g ${catalogId}`, + }) + } else { + res.push({ + label: 'Inspect non-primary binary', + command: `ls -la ${shellQuote(o.path)}`, + note: `Consider removing ${o.path} from PATH or uninstalling via ${o.source}.`, + }) + } + } + + res.push({ + label: 'Refresh shell command cache', + command: 'hash -r', + note: 'Or open a new terminal tab so PATH order is re-read.', + }) + + return res +} + +/** + * Build a doctor report from discovered library entries. + * Uses primary rows only when grouping by catalogId (installs[] already lists siblings). + */ +export function analyzeLibrary(entries: LibLike[]): DoctorReport { + const findings: DoctorFinding[] = [] + + // One representative per catalog family (prefer primary installed row) + const byCatalog = new Map() + for (const e of entries) { + const prev = byCatalog.get(e.catalogId) + if (!prev) { + byCatalog.set(e.catalogId, e) + continue + } + // Prefer installed primary + if (e.status === 'installed' && e.primary) byCatalog.set(e.catalogId, e) + else if (e.status === 'installed' && prev.status !== 'installed') byCatalog.set(e.catalogId, e) + } + + for (const e of byCatalog.values()) { + if (e.status !== 'installed' || e.installs.length === 0) continue + + const installs = e.installs + const versions = uniqVersions(installs) + const channels = uniqChannels(installs) + + if (installs.length > 1) { + const versionSkew = versions.length > 1 + const primary = installs.find((i) => i.primary) ?? installs[0] + // Multi-install is normal (asdf + brew, old + new). Surface as info/warn, not error. + // Warn only when a harness has version skew (shell may not run the version you think). + const severity: DoctorSeverity = + versionSkew && e.kind === 'harness' ? 'warn' : 'info' + findings.push({ + id: `shadow:${e.catalogId}`, + severity, + category: versionSkew ? 'version-skew' : 'path-shadow', + title: versionSkew + ? `${e.name}: PATH picks ${primary.version ?? 'unknown'} (${installs.length} installs)` + : `${e.name}: ${installs.length} installs on PATH`, + detail: versionSkew + ? `When you run the bare command, PATH resolves to ${primary.path} (${primary.version ?? 'unknown'}, ${primary.source}). Other versions present: ${versions.filter((v) => v !== primary.version).join(', ')}. This is informational unless you expected a different binary.` + : `Multiple PATH hits resolve to different files. Winner: ${primary.path}.`, + catalogId: e.catalogId, + kind: e.kind, + installs: installs.map((i) => ({ + path: i.path, + version: i.version, + source: i.source, + primary: i.primary, + homebrew: i.homebrew, + packageManager: i.packageManager, + })), + resolutions: resolutionsForShadow(e.name, e.catalogId, installs), + }) + + if (channels.length > 1) { + findings.push({ + id: `channel:${e.catalogId}`, + severity: 'info', + category: 'channel-mix', + title: `${e.name} is installed via ${channels.length} channels`, + detail: `Channels: ${channels.join(', ')}. Common and fine if intentional (e.g. brew for default, asdf for project pins). Only clean up if upgrades feel inconsistent.`, + catalogId: e.catalogId, + kind: e.kind, + installs: installs.map((i) => ({ + path: i.path, + version: i.version, + source: i.source, + primary: i.primary, + homebrew: i.homebrew, + packageManager: i.packageManager, + })), + resolutions: [ + { + label: 'See PATH order', + command: `which -a ${catalogBinaryName(e.catalogId)}`, + }, + ...(isHarnessId(e.catalogId) + ? [ + { + label: 'Reconfigure in Library', + action: { type: 'reconfigure' as const, harnessId: e.catalogId }, + primary: true, + }, + { + label: 'Upgrade PATH winner', + action: { + type: 'upgrade' as const, + harnessId: e.catalogId, + prefer: (installs.find((i) => i.primary)?.homebrew ? 'brew' : 'npm') as 'brew' | 'npm', + }, + }, + ] + : [ + { + label: 'Optional: standardize on one channel', + note: 'Keep the PATH winner; remove the other only if it confuses you.', + }, + ]), + ], + }) + } + } + } + + // JS package manager proliferation + const pms = [...byCatalog.values()].filter( + (e) => e.kind === 'package-manager' && ['npm', 'bun', 'pnpm', 'yarn'].includes(e.catalogId) && e.status === 'installed', + ) + if (pms.length > 1) { + const primaryPm = pms.find((p) => p.primary) ?? pms[0] + // Determine PATH order among JS PMs by which has primary install first - use first in list order from discovery + const names = pms.map((p) => `${p.name}${p.version ? ` ${p.version}` : ''}${p.homebrew ? ` (${p.homebrew === 'node' ? 'Homebrew Node' : 'Homebrew'})` : p.source ? ` (${p.source})` : ''}`) + findings.push({ + id: 'pm:multiple', + severity: 'info', + category: 'package-manager', + title: `${pms.length} JavaScript package managers installed`, + detail: `Detected ${names.join(', ')}. Projects may resolve different tools depending on lockfiles and PATH. PATH-facing primary among discovered PMs is roughly ${primaryPm.name}.`, + resolutions: [ + { + label: 'Prefer one PM per project', + note: 'Commit a single lockfile (package-lock.json, bun.lockb, pnpm-lock.yaml, or yarn.lock).', + }, + { + label: 'See which PM wins on PATH', + command: 'which -a bun pnpm yarn npm', + }, + { + label: 'Check this repo’s lockfile', + command: 'ls package-lock.json bun.lockb pnpm-lock.yaml yarn.lock 2>/dev/null', + }, + ], + }) + } + + // Missing recommended tools — only when truly absent + for (const id of ['node'] as const) { + const e = byCatalog.get(id) + if (!e || e.status !== 'installed') { + findings.push({ + id: `missing:${id}`, + severity: 'warn', + category: 'missing', + title: 'Node.js is not available', + detail: 'Most agent harnesses expect a working Node toolchain for global CLIs.', + catalogId: id, + resolutions: [ + { label: 'Install via Homebrew', command: 'brew install node' }, + { label: 'Or use asdf', command: 'asdf plugin add nodejs && asdf install nodejs latest' }, + ], + }) + } + } + + // Healthy / calm summary — multi-install info alone is not "unhealthy" + const problems = findings.filter((f) => f.severity === 'error' || f.severity === 'warn') + if (problems.length === 0) { + findings.unshift({ + id: 'healthy', + severity: 'ok', + category: 'healthy', + title: problems.length === 0 && findings.length > 0 + ? 'No blocking issues' + : 'No install conflicts detected', + detail: findings.some((f) => f.severity === 'info') + ? 'You have multiple installs on PATH (common with Homebrew + asdf). PATH order decides the winner — check the PATH priority panel on each harness.' + : 'Harnesses and runtimes look clean — single installs per tool, or intentional multi-version setups.', + resolutions: [ + { label: 'Inspect PATH winners', command: 'which -a claude opencode codex node npm bun' }, + { label: 'Re-run discovery anytime', note: 'Open Library → Refresh, or revisit Doctor after installing tools.' }, + ], + }) + } + + // Sort: error, warn, info, ok + const order: Record = { error: 0, warn: 1, info: 2, ok: 3 } + findings.sort((a, b) => order[a.severity] - order[b.severity]) + + const summary = { error: 0, warn: 0, info: 0, ok: 0 } + for (const f of findings) summary[f.severity] += 1 + + return { findings, summary } +} diff --git a/src/shared/secrets.ts b/src/shared/secrets.ts new file mode 100644 index 0000000..9055be5 --- /dev/null +++ b/src/shared/secrets.ts @@ -0,0 +1,10 @@ +/** Canonical vault secret id for a provider API key (shared with CLI convention). */ +export function secretIdForProvider(providerId: string): string { + return `provider:${providerId}:api_key` +} + +/** Extract provider id from a vault secret id, if it matches the convention. */ +export function providerIdFromSecretId(secretId: string): string | null { + const m = /^provider:([^:]+):api_key$/.exec(secretId) + return m ? m[1] : null +}