From 11c824736413ebda9fceee7941e66d695ba9b6f4 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:32:42 +0800 Subject: [PATCH 01/15] feat(cli-registry): add data-driven CLI registry core (phase 0) Additive-only: no existing file is modified or consumed yet. This lays the foundation for removing the ~123 hard-coded per-CLI branches spread across the codebase, per the plan to make the set of supported CLIs (claude, shell, opencode, codex, gemini, antigravity, pi) configurable via a single JSON registry instead of a compiled-in union type. Adds src/config/cli-registry/: - types.ts - CliEntry schema shape: identity, discovery, a structured argv DSL, env, capability flags, and remote/docker overlays. - patterns.ts - named, code-owned value patterns (never a raw user regex reaches a shell token); a guarded compiler for the one legitimate user-supplied regex (version-string matching). - argv.ts - the command-rendering engine. Config carries no shell text; every literal is validated at load, every resolved value is re-escaped at render time independent of validation, so the safety property holds even if a pattern check were ever bypassed. - schema.ts - Zod validation, entirely .strict(), enforcing the argv safety rules plus internal consistency (every valueFrom/capabilityGate/overlay variant must reference something the entry actually declares). - stock.ts - the seven current CLIs transcribed as registry entries, kept byte-identical to today's hand-written tmux-manager.ts builders. - registry.ts - load/merge/seed against ~/.codeman/clis.json: the file holds overrides and custom entries only, a seededStockIds ratchet lets new stock CLIs arrive on update while respecting a user's earlier enabled:false, and a malformed or unsafely-permissioned file is quarantined/ignored rather than trusted. Keystone test test/cli-registry-argv-parity.test.ts proves the new argv engine renders BYTE-IDENTICAL output to buildSpawnCommand for every mode across ~50 input permutations - the fixed baseline later phases (wiring tmux-manager.ts, session.ts, routes, and the frontend onto this registry) get measured against, rather than eyeballing diffs. test/cli-registry-schema.test.ts and test/cli-registry-load.test.ts cover schema rejection of injection-shaped literals and the merge/seed/quarantine semantics respectively. Fixed along the way: isUnsafePermissions() is a POSIX-only check - Windows reports a uniform file mode regardless of ACL, so the check now no-ops on win32 instead of treating every file as unsafe. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/config/cli-registry/argv.ts | 190 ++++++++ src/config/cli-registry/index.ts | 47 ++ src/config/cli-registry/patterns.ts | 115 +++++ src/config/cli-registry/profiles.ts | 44 ++ src/config/cli-registry/registry.ts | 216 +++++++++ src/config/cli-registry/schema.ts | 298 ++++++++++++ src/config/cli-registry/stock.ts | 627 ++++++++++++++++++++++++++ src/config/cli-registry/types.ts | 269 +++++++++++ test/cli-registry-argv-parity.test.ts | 252 +++++++++++ test/cli-registry-load.test.ts | 206 +++++++++ test/cli-registry-schema.test.ts | 132 ++++++ 11 files changed, 2396 insertions(+) create mode 100644 src/config/cli-registry/argv.ts create mode 100644 src/config/cli-registry/index.ts create mode 100644 src/config/cli-registry/patterns.ts create mode 100644 src/config/cli-registry/profiles.ts create mode 100644 src/config/cli-registry/registry.ts create mode 100644 src/config/cli-registry/schema.ts create mode 100644 src/config/cli-registry/stock.ts create mode 100644 src/config/cli-registry/types.ts create mode 100644 test/cli-registry-argv-parity.test.ts create mode 100644 test/cli-registry-load.test.ts create mode 100644 test/cli-registry-schema.test.ts diff --git a/src/config/cli-registry/argv.ts b/src/config/cli-registry/argv.ts new file mode 100644 index 000000000..ddbc88b90 --- /dev/null +++ b/src/config/cli-registry/argv.ts @@ -0,0 +1,190 @@ +/** + * @fileoverview The argv rendering engine — turns a `CliLaunch` spec plus a set of resolved + * parameter values into the shell command string that goes into `bash -c "..."`. + * + * SECURITY MODEL (read before touching this file): + * + * 1. Config contains no shell text. There is no `command: "..."` field anywhere in the + * schema. An entry declares a sequence of typed tokens (`ArgSpec`); this module is the + * ONLY place that turns them into a string, and it owns every separator itself: a single + * space between tokens, and ` || ` between fallback variants. Neither can originate from + * config, because config has no field that could hold either. + * 2. Every literal (`lit`, `flag`, `value`) is validated against `SAFE_BARE_TOKEN` — no + * space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens, braces, newline or + * backslash — at LOAD time (see schema.ts), so a bad literal fails registry validation + * rather than reaching this renderer. + * 3. Every `valueFrom` resolves through a declared `ParamSpec`, whose `token` variant names + * a PATTERN rather than accepting one — see patterns.ts. A value that fails its pattern + * causes the WHOLE ArgSpec to be dropped, exactly like the hand-written builders this + * replaces (an invalid `--model` value silently omits `--model`, it does not substitute + * something else). + * 4. Escaping and validation are independent. `renderToken()` always re-checks the resolved + * value against `SAFE_BARE_TOKEN` before emitting it unquoted; anything else is + * single-quote-escaped. So even a value that somehow bypassed pattern validation is still + * quoted, never concatenated raw. + * + * @module config/cli-registry/argv + */ + +import type { ArgSpec, CliEntry, CliLaunch, Cond, EngineValue, ParamSpec, QuoteStyle } from './types.js'; +import { matchesPattern } from './patterns.js'; +import { SAFE_BARE_TOKEN } from './patterns.js'; + +/** Resolved parameter values, keyed by the name declared in `CliLaunch.params`. */ +export type ParamValues = Record; + +/** Values the caller supplies for the reserved engine params. */ +export type EngineValues = Partial>; + +/** + * POSIX single-quote escaping: end-quote, escaped-literal-quote, restart-quote. Identical in + * shape to the three copies already in the codebase (tmux-manager.ts, remote-hosts.ts, + * docker-hosts.ts) — kept local rather than importing one of them so this module has no + * dependency on the files it is replacing. + */ +function singleQuoteEscape(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function doubleQuoteEscape(value: string): string { + // Escape the characters that are special inside a double-quoted bash string. SAFE_BARE_TOKEN + // already excludes all of them, so in practice this never fires; kept as defense in depth. + return `"${value.replace(/([$`"\\])/g, '\\$1')}"`; +} + +/** + * Render a single resolved value per its requested quote style. `auto` (the default) emits + * bare only when the value is provably safe; every other case single-quotes. + */ +function renderToken(value: string, style: QuoteStyle | undefined): string { + const safe = SAFE_BARE_TOKEN.test(value); + switch (style) { + case 'double': + return doubleQuoteEscape(value); + case 'single': + return singleQuoteEscape(value); + case 'bare': + return safe ? value : singleQuoteEscape(value); + case 'auto': + default: + return safe ? value : singleQuoteEscape(value); + } +} + +/** Resolve one parameter to a plain string, or undefined if it is unset / invalid. */ +function resolveParam( + name: string, + spec: ParamSpec | undefined, + params: ParamValues, + engineValues: EngineValues +): string | undefined { + if (!spec) return undefined; + if (spec.type === 'engine') return engineValues[spec.source]; + + const raw = params[name]; + if (raw === undefined) return spec.type === 'enum' ? spec.default : undefined; + + if (spec.type === 'bool') return typeof raw === 'boolean' ? String(raw) : undefined; + if (spec.type === 'enum') { + const s = String(raw); + return spec.values.includes(s) ? s : spec.default; + } + // token + const s = String(raw); + return matchesPattern(spec.pattern, s) ? s : undefined; +} + +/** Is the resolved value "set" for the purposes of a `state` condition? */ +function isSet(name: string, params: ParamValues, resolved: (n: string) => string | undefined): boolean { + if (name in params) { + const raw = params[name]; + if (typeof raw === 'boolean') return true; // a bool param is always "set" once declared + } + return resolved(name) !== undefined; +} + +function evalCond( + cond: Cond | undefined, + params: ParamValues, + resolved: (n: string) => string | undefined, + gatesPassed: ReadonlySet +): boolean { + if (!cond) return true; + if ('allOf' in cond) return cond.allOf.every((c) => evalCond(c, params, resolved, gatesPassed)); + if ('anyOf' in cond) return cond.anyOf.some((c) => evalCond(c, params, resolved, gatesPassed)); + if ('not' in cond) return !evalCond(cond.not, params, resolved, gatesPassed); + if ('capabilityGate' in cond) return gatesPassed.has(cond.capabilityGate); + if ('state' in cond) { + const set = isSet(cond.param, params, resolved); + return cond.state === 'set' ? set : !set; + } + // { param, is } + const raw = params[cond.param]; + if (typeof cond.is === 'boolean') return raw === cond.is; + return resolved(cond.param) === cond.is; +} + +function renderArg( + spec: ArgSpec, + params: ParamValues, + resolved: (n: string) => string | undefined, + gatesPassed: ReadonlySet +): string | null { + if (!evalCond(spec.when, params, resolved, gatesPassed)) return null; + + if ('lit' in spec) return spec.lit; + if ('flag' in spec && !('value' in spec) && !('valueFrom' in spec)) return spec.flag; + if ('flag' in spec && 'value' in spec) return `${spec.flag} ${renderToken(spec.value, spec.quote)}`; + if ('flag' in spec && 'valueFrom' in spec) { + const v = resolved(spec.valueFrom); + return v === undefined ? null : `${spec.flag} ${renderToken(v, spec.quote)}`; + } + // bare positional + const v = resolved((spec as { valueFrom: string }).valueFrom); + return v === undefined ? null : renderToken(v, (spec as { quote?: QuoteStyle }).quote); +} + +/** + * Render one CLI's launch command. Returns the full `bash -c` payload — never a shell + * fragment with embedded newlines or unescaped separators, by construction (see file header). + * + * `gatesPassed` — the set of `capabilities.gates` keys whose version requirement is + * currently satisfied. Callers compute this once per spawn (it depends on a version probe), + * never inside the renderer, keeping this function pure and easy to test byte-for-byte. + */ +export function renderLaunch( + launch: CliLaunch, + params: ParamValues, + engineValues: EngineValues, + gatesPassed: ReadonlySet = new Set() +): string { + const cache = new Map(); + const resolved = (name: string): string | undefined => { + if (cache.has(name)) return cache.get(name); + const v = resolveParam(name, launch.params[name], params, engineValues); + cache.set(name, v); + return v; + }; + + const passing = launch.variants.filter((variant) => evalCond(variant.when, params, resolved, gatesPassed)); + const chosen = launch.chain === 'fallback' ? passing : passing.slice(0, 1); + + const rendered = chosen.map((variant) => + variant.args + .map((arg) => renderArg(arg, params, resolved, gatesPassed)) + .filter((tok): tok is string => tok !== null) + .join(' ') + ); + + return rendered.join(' || '); +} + +/** Convenience: render an entry's launch command straight from a `CliEntry`. */ +export function renderCliCommand( + entry: CliEntry, + params: ParamValues, + engineValues: EngineValues, + gatesPassed?: ReadonlySet +): string { + return renderLaunch(entry.launch, params, engineValues, gatesPassed); +} diff --git a/src/config/cli-registry/index.ts b/src/config/cli-registry/index.ts new file mode 100644 index 000000000..f8d1d8d00 --- /dev/null +++ b/src/config/cli-registry/index.ts @@ -0,0 +1,47 @@ +/** + * @fileoverview Barrel for the CLI registry module. + * @module config/cli-registry + */ + +export type { + ArgSpec, + CliCapabilities, + CliCredStore, + CliDiscovery, + CliEntry, + CliEnv, + CliId, + CliLaunch, + CliOverlays, + CliRegistryFile, + CliVariant, + CliVersionProbe, + Cond, + EngineValue, + ParamSpec, + QuoteStyle, +} from './types.js'; +export { + matchesPattern, + TOKEN_PATTERNS, + SAFE_BARE_TOKEN, + compileVersionRegex, + MAX_VERSION_OUTPUT, +} from './patterns.js'; +export type { TokenPattern } from './patterns.js'; +export { renderLaunch, renderCliCommand } from './argv.js'; +export type { EngineValues, ParamValues } from './argv.js'; +export { CliEntrySchema } from './schema.js'; +export type { ValidatedCliEntry } from './schema.js'; +export { STOCK_CLIS } from './stock.js'; +export { + asCliId, + cliIds, + enabledClis, + getCli, + listClis, + loadCliRegistry, + reloadCliRegistry, + resolveRegistry, +} from './registry.js'; +export { PREDICT_PROFILES, isKnownPredictProfile, TRANSCRIPT_READER_NAMES, COMPOSER_ANCHOR_KINDS } from './profiles.js'; diff --git a/src/config/cli-registry/patterns.ts b/src/config/cli-registry/patterns.ts new file mode 100644 index 000000000..84e8983e6 --- /dev/null +++ b/src/config/cli-registry/patterns.ts @@ -0,0 +1,115 @@ +/** + * @fileoverview Named value patterns for the CLI registry's argv engine. + * + * Config entries select a pattern BY NAME; the regexes themselves live here, in code. + * That is deliberate and is the reason a user-editable `clis.json` cannot widen its own + * validation: there is no field anywhere in the schema that accepts a raw regex for a + * shell token, so no entry can supply `.*` (nor a catastrophically backtracking one). + * + * The sole user-supplied regex in the whole registry is `discovery.version.regex`, which + * is applied to `--version` OUTPUT rather than to a shell token, and goes through + * `compileVersionRegex()` below. + * + * Every pattern here is transcribed from the builder it replaces in tmux-manager.ts, so + * the argv engine accepts and rejects exactly the values the hand-written builders did. + * + * @module config/cli-registry/patterns + */ + +/** Names a value pattern. Config may only reference these. */ +export type TokenPattern = + | 'model' + | 'model-claude' + | 'model-pi' + | 'id' + | 'id-dotted' + | 'uuid' + | 'slug' + | 'tool-list' + | 'config-kv'; + +/** + * The patterns, each traced to the builder it came from. + * + * ⚠️ These are ALLOWLISTS (`^...$` over a safe character class), never blocklists — with + * one deliberate exception, `tool-list`, which mirrors the existing `--allowedTools` + * sanitizer. That one is a metacharacter REJECTION because tool specs legitimately contain + * `(`, `)`, `*`, `:` and spaces (`Bash(git:*), Read`), so an allowlist of safe words cannot + * express it. Keeping it byte-identical to the original matters more than making it uniform. + */ +const PATTERNS: Record = { + // buildOpenCodeCommand / buildCodexCommand / buildGeminiCommand / buildAntigravityCommand + model: /^[a-zA-Z0-9._\-/]+$/, + // buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases + 'model-claude': /^[a-zA-Z0-9._\-[\]]+$/, + // buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` + 'model-pi': /^[a-zA-Z0-9._\-/:]+$/, + // opencode --session, codex resume + id: /^[a-zA-Z0-9_-]+$/, + // gemini --resume, antigravity --conversation, pi --session + 'id-dotted': /^[a-zA-Z0-9._-]+$/, + // claude --resume / --session-id + uuid: /^[a-f0-9-]+$/, + // pi --provider + slug: /^[a-z0-9-]+$/, + // codex --config tui.animations=false + 'config-kv': /^[A-Za-z0-9._-]+=[A-Za-z0-9._-]+$/, + // Placeholder; `tool-list` is handled by isSafeToolList() below, not by a match. + 'tool-list': /^$/, +}; + +/** + * Shell metacharacters rejected in an `--allowedTools` value. Transcribed verbatim from + * buildClaudePermissionFlags so the accepted set does not move. + */ +const TOOL_LIST_DANGEROUS = /[;&|$`\\{}<>'"[\]\n\r]/; + +/** Does `value` satisfy the named pattern? */ +export function matchesPattern(pattern: TokenPattern, value: string): boolean { + if (pattern === 'tool-list') return value.length > 0 && !TOOL_LIST_DANGEROUS.test(value); + return PATTERNS[pattern].test(value); +} + +/** Every pattern name, for schema validation and error messages. */ +export const TOKEN_PATTERNS = Object.keys(PATTERNS) as TokenPattern[]; + +/** + * Characters a token may contain and still be emitted UNQUOTED into the `bash -c "..."` + * command string. Intentionally narrower than "what bash tolerates": anything outside it + * gets single-quoted, so the classification can only ever err toward more quoting. + */ +export const SAFE_BARE_TOKEN = /^[A-Za-z0-9._:@=+/,-]+$/; + +/** + * Longest `--version` output we will run a user-supplied regex over. A version banner is a + * line or two; anything larger is a misconfiguration, and capping the input is what keeps a + * sloppy (not necessarily malicious) regex from becoming a stall. + */ +export const MAX_VERSION_OUTPUT = 200; + +/** Longest permitted `discovery.version.regex` source. */ +const MAX_VERSION_REGEX_SOURCE = 200; + +/** + * Nested quantifiers — `(a+)+`, `(a*)*`, `(a+)*` and friends — the classic catastrophic + * backtracking shape. Rejected outright rather than analysed: this field exists to pull a + * semver out of a banner, and nothing legitimate for that job needs a nested quantifier. + */ +const NESTED_QUANTIFIER = /\([^)]*[+*][^)]*\)\s*[+*{]/; + +/** + * Compile a user-supplied version regex, or return null if it is not one we are willing to + * run. Returning null (rather than throwing) lets the caller degrade to "version unknown", + * which every consumer already handles. + */ +export function compileVersionRegex(source: string): RegExp | null { + if (source.length > MAX_VERSION_REGEX_SOURCE) return null; + if (NESTED_QUANTIFIER.test(source)) return null; + try { + // No `g`: a global regex carries lastIndex state across calls, which is a documented + // footgun in this codebase (see utils/regex-patterns.ts). + return new RegExp(source); + } catch { + return null; + } +} diff --git a/src/config/cli-registry/profiles.ts b/src/config/cli-registry/profiles.ts new file mode 100644 index 000000000..8a5b96d8b --- /dev/null +++ b/src/config/cli-registry/profiles.ts @@ -0,0 +1,44 @@ +/** + * @fileoverview Named code profiles that a `CliEntry.capabilities` field may select BY NAME. + * + * This is the escape hatch for behaviour that is genuinely code-shaped and cannot be + * expressed as data — codex's predictive write-through echo, claude's transcript parsing — + * without letting any of that code branch on a CLI's id. A capability field names a profile; + * the profile itself lives here, and later phases plug the real implementations + * (`CODEX_COMPOSER_ROW_RE`, the claude JSONL reader, the codex rollout reader) in as the + * corresponding module is migrated. + * + * The rule this enforces: `test/cli-registry-no-id-branching.test.ts` fails on any + * `mode === ''` comparison outside `stock.ts`, so a NEW behavioural special case + * must be added here, named, and referenced from a capability field — never inlined as an id + * check at the call site. + * + * @module config/cli-registry/profiles + */ + +/** + * Predictive local-echo profiles, selected via `capabilities.echo.predictProfile`. + * A name with no entry here (or `echo.policy !== 'predict'`) degrades to the 'buffer' + * policy — never to a crash — which is why `predictProfile` is optional in the schema. + */ +export const PREDICT_PROFILES: Record = { + // Phase 5 wires this to the real codex predictive-echo addon + // (packages/xterm-zerolag-input/src/predictive-echo-addon.ts) and CODEX_COMPOSER_ROW_RE. + codex: true, +}; + +/** + * Transcript readers, selected via `capabilities.transcript`. Unlike the other profile + * registries this one is closed over the schema enum itself (`'claude-jsonl' | + * 'codex-rollout' | 'none'`) rather than an open string, since transcript format is a small, + * genuinely fixed set — see CliCapabilities['transcript'] in types.ts. + */ +export const TRANSCRIPT_READER_NAMES = ['claude-jsonl', 'codex-rollout', 'none'] as const; + +/** Composer-row finders, selected via `capabilities.echo.anchor.kind`. Also schema-closed. */ +export const COMPOSER_ANCHOR_KINDS = ['glyph', 'cursor', 'none'] as const; + +/** True when `name` is a profile this build actually implements. */ +export function isKnownPredictProfile(name: string | undefined): boolean { + return name !== undefined && Object.prototype.hasOwnProperty.call(PREDICT_PROFILES, name); +} diff --git a/src/config/cli-registry/registry.ts b/src/config/cli-registry/registry.ts new file mode 100644 index 000000000..d16433dbd --- /dev/null +++ b/src/config/cli-registry/registry.ts @@ -0,0 +1,216 @@ +/** + * @fileoverview Loads, merges, seeds and re-validates the CLI registry. + * + * `~/.codeman/clis.json` holds OVERRIDES and CUSTOM entries only — never a full copy of the + * stock catalog — so a shipped fix to a stock definition actually reaches an existing + * install, and the file stays small enough to hand-edit. + * + * Resolution: start from `STOCK_CLIS` → deep-merge each override by id (objects merge + * key-wise, arrays replace wholesale) → validate every resulting entry. A stock entry that + * fails validation after merge falls back to its pristine stock definition (a fat-fingered + * override cannot brick a shipped CLI); a custom entry that fails is dropped. `shell` and + * `claude` may be disabled but the loader refuses to let either be entirely absent, since + * huge parts of the app assume at least a shell fallback exists. + * + * `seededStockIds` is the ratchet that makes "one file, no generated fragments" survive + * `install.sh update`: any stock id not yet in that list is a NEWLY SHIPPED CLI, so it is + * added (enabled) and the id recorded; an id already in the list that carries no override is + * left exactly as-is, including a user's earlier `enabled: false`. + * + * @module config/cli-registry/registry + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from '../instance.js'; +import type { CliEntry, CliId, CliRegistryFile } from './types.js'; +import { CliEntrySchema } from './schema.js'; +import { STOCK_CLIS } from './stock.js'; + +const SCHEMA_VERSION = 1; + +/** Construct a validated CliId. Throws if `raw` is not a well-formed id — call at API boundaries. */ +export function asCliId(raw: string): CliId { + if (!/^[a-z][a-z0-9-]{0,23}$/.test(raw)) { + throw new Error(`invalid CLI id: ${JSON.stringify(raw)}`); + } + return raw as CliId; +} + +function filePath(): string { + return dataPath('clis.json'); +} + +/** Plain-object deep merge: nested objects merge key-wise, arrays and primitives replace. */ +function deepMerge(base: T, override: unknown): T { + if (override === null || typeof override !== 'object' || Array.isArray(override)) { + return (override === undefined ? base : (override as T)) ?? base; + } + if (base === null || typeof base !== 'object' || Array.isArray(base)) { + return override as T; + } + const result: Record = { ...(base as Record) }; + for (const [key, value] of Object.entries(override as Record)) { + result[key] = deepMerge((base as Record)[key], value); + } + return result as T; +} + +interface LoadResult { + entries: CliEntry[]; + warnings: string[]; +} + +/** + * Refuse a group/world-writable registry file — same posture as the ssh-key discipline. + * + * POSIX only: Windows has no meaningful group/world bits on NTFS (Node reports every file + * as mode 0o666 there regardless of its actual ACL), so this check would flag every file on + * Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which + * this check cannot see and does not attempt to. + */ +function isUnsafePermissions(path: string): boolean { + if (process.platform === 'win32') return false; + try { + const mode = statSync(path).mode & 0o777; + return (mode & 0o077) !== 0; + } catch { + return false; + } +} + +function readRegistryFile(path: string, warnings: string[]): CliRegistryFile | null { + if (!existsSync(path)) return null; + if (isUnsafePermissions(path)) { + warnings.push(`${path} is group/world-writable; ignoring it and falling back to stock CLIs.`); + return null; + } + let raw: string; + try { + raw = readFileSync(path, 'utf-8'); + } catch (err) { + warnings.push(`Failed to read ${path}: ${(err as Error).message}. Falling back to stock CLIs.`); + return null; + } + try { + const parsed = JSON.parse(raw) as CliRegistryFile; + if (typeof parsed !== 'object' || parsed === null || typeof parsed.clis !== 'object') { + throw new Error('missing "clis" object'); + } + return parsed; + } catch (err) { + const quarantined = `${path}.invalid-${Date.now()}`; + try { + renameSync(path, quarantined); + warnings.push(`${path} was not valid JSON (${(err as Error).message}); moved to ${quarantined}.`); + } catch { + warnings.push( + `${path} was not valid JSON (${(err as Error).message}); left in place, falling back to stock CLIs.` + ); + } + return null; + } +} + +/** Merge the stock catalog with a (possibly absent) registry file. Pure — no IO. */ +export function resolveRegistry(stock: CliEntry[], file: CliRegistryFile | null, warnings: string[]): LoadResult { + const stockById = new Map(stock.map((e) => [e.id as string, e])); + const seeded = new Set(file?.seededStockIds ?? []); + const overrides = file?.clis ?? {}; + const entries: CliEntry[] = []; + + for (const stockEntry of stock) { + const id = stockEntry.id as string; + const override = overrides[id]; + const merged = override ? deepMerge(stockEntry, override) : stockEntry; + const parsed = CliEntrySchema.safeParse({ ...merged, id, stock: true }); + if (parsed.success) { + entries.push(parsed.data as CliEntry); + } else { + warnings.push( + `Override for stock CLI "${id}" failed validation; using the shipped definition. ${parsed.error.message}` + ); + entries.push(stockEntry); + } + seeded.add(id); + } + + for (const [id, raw] of Object.entries(overrides)) { + if (stockById.has(id)) continue; // handled above + const parsed = CliEntrySchema.safeParse({ ...(raw as object), id, stock: false }); + if (parsed.success) { + entries.push(parsed.data as CliEntry); + } else { + warnings.push(`Custom CLI "${id}" failed validation and was dropped. ${parsed.error.message}`); + } + } + + entries.sort((a, b) => a.order - b.order); + return { entries, warnings }; +} + +/** Persist the ratcheted `seededStockIds` (and any pass-through overrides) atomically. */ +function writeSeed(path: string, file: CliRegistryFile): void { + mkdirSync(dirname(path), { recursive: true }); + const tmpPath = `${path}.tmp`; + writeFileSync(tmpPath, JSON.stringify(file, null, 2), { mode: 0o600 }); + renameSync(tmpPath, path); +} + +let cache: LoadResult | null = null; + +/** + * Load the effective registry (stock + user overrides), seeding newly-shipped stock ids into + * the on-disk file as a side effect. Memoized; call `reloadCliRegistry()` after a settings + * write to invalidate. + */ +export function loadCliRegistry(): LoadResult { + if (cache) return cache; + const path = filePath(); + const warnings: string[] = []; + const existing = readRegistryFile(path, warnings); + + const knownStockIds = new Set(STOCK_CLIS.map((e) => e.id as string)); + const previouslySeeded = new Set(existing?.seededStockIds ?? []); + const newlyShipped = [...knownStockIds].filter((id) => !previouslySeeded.has(id)); + + const file: CliRegistryFile = { + schemaVersion: SCHEMA_VERSION, + seededStockIds: [...previouslySeeded, ...newlyShipped], + clis: existing?.clis ?? {}, + }; + + // Write back when the file is new, or a previously-unseeded stock CLI just joined — + // otherwise this is a pure read (no write on every boot). + if (!existing || newlyShipped.length > 0) { + try { + writeSeed(path, file); + } catch (err) { + warnings.push(`Failed to persist ${path}: ${(err as Error).message}`); + } + } + + cache = resolveRegistry(STOCK_CLIS, file, warnings); + return cache; +} + +/** Drop the memoized registry so the next `loadCliRegistry()` re-reads the file. */ +export function reloadCliRegistry(): void { + cache = null; +} + +export function listClis(): CliEntry[] { + return loadCliRegistry().entries; +} + +export function enabledClis(): CliEntry[] { + return listClis().filter((e) => e.enabled); +} + +export function getCli(id: string): CliEntry | undefined { + return listClis().find((e) => (e.id as string) === id); +} + +export function cliIds(): string[] { + return listClis().map((e) => e.id as string); +} diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts new file mode 100644 index 000000000..5f8a89a8c --- /dev/null +++ b/src/config/cli-registry/schema.ts @@ -0,0 +1,298 @@ +/** + * @fileoverview Zod validation for CLI registry entries. + * + * Every object here is `.strict()`: an unknown key is a hard validation error, not a + * silently-ignored one. That matters for a security-relevant schema — a typo in a field name + * must never degrade to "field absent, so the permissive default applies". + * + * The load-bearing rule enforced here is `SHELL_TOKEN`: it is what makes it impossible for a + * `clis.json` entry to smuggle shell metacharacters into the eventual `bash -c "..."` string + * (see argv.ts's file header for the full model). + * + * @module config/cli-registry/schema + */ + +import { z } from 'zod'; +import { TOKEN_PATTERNS } from './patterns.js'; + +/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ +const cliId = z + .string() + .regex(/^[a-z][a-z0-9-]{0,23}$/, 'id must be lowercase, start with a letter, and be at most 24 chars'); + +/** An env var name. */ +const envName = z + .string() + .regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE') + .max(64); + +/** + * A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens, + * braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names, + * fixed values) must satisfy this — see argv.ts's file header. + */ +const shellToken = z + .string() + .min(1) + .max(256) + .regex(/^[A-Za-z0-9._:@=+/,-]+$/, 'must be a plain word with no shell metacharacters'); + +const flagToken = z.string().regex(/^--?[A-Za-z0-9][A-Za-z0-9-]*$/, 'must look like -x or --long-flag'); + +const quoteStyle = z.enum(['auto', 'bare', 'double', 'single']); + +const condSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ param: z.string(), is: z.union([z.string(), z.boolean()]) }).strict(), + z.object({ param: z.string(), state: z.enum(['set', 'unset']) }).strict(), + z.object({ allOf: z.array(condSchema).min(1).max(8) }).strict(), + z.object({ anyOf: z.array(condSchema).min(1).max(8) }).strict(), + z.object({ not: condSchema }).strict(), + z.object({ capabilityGate: z.string() }).strict(), + ]) +); + +const paramSpecSchema = z.union([ + z + .object({ type: z.literal('enum'), values: z.array(z.string()).min(1).max(16), default: z.string().optional() }) + .strict(), + z.object({ type: z.literal('bool') }).strict(), + z.object({ type: z.literal('token'), pattern: z.enum(TOKEN_PATTERNS as [string, ...string[]]) }).strict(), + z + .object({ + type: z.literal('engine'), + source: z.enum(['sessionId', 'sessionName', 'muxName', 'effortLevel', 'effortSettingsJson']), + }) + .strict(), +]); + +const argSpecSchema = z.union([ + z.object({ lit: shellToken, when: condSchema.optional() }).strict(), + z.object({ flag: flagToken, when: condSchema.optional() }).strict(), + z.object({ flag: flagToken, value: shellToken, quote: quoteStyle.optional(), when: condSchema.optional() }).strict(), + z + .object({ flag: flagToken, valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() }) + .strict(), + z.object({ valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() }).strict(), +]); + +const variantSchema = z + .object({ + id: z.string().min(1).max(40), + when: condSchema.optional(), + // min(0): the `shell` entry declares a variant with no args — tmux-manager resolves the + // real login shell in code, since it varies per remote user's /etc/passwd entry. + args: z.array(argSpecSchema).max(32), + }) + .strict(); + +const launchSchema = z + .object({ + params: z.record(z.string(), paramSpecSchema), + chain: z.enum(['first', 'fallback']).optional(), + variants: z.array(variantSchema).min(1).max(4), + }) + .strict() + .superRefine((launch, ctx) => { + const paramNames = new Set(Object.keys(launch.params)); + const checkValueFrom = (name: string, path: (string | number)[]) => { + if (!paramNames.has(name)) { + ctx.addIssue({ code: 'custom', message: `valueFrom "${name}" is not a declared param`, path }); + } + }; + launch.variants.forEach((variant, vi) => { + variant.args.forEach((arg, ai) => { + if ('valueFrom' in arg) checkValueFrom(arg.valueFrom, ['variants', vi, 'args', ai, 'valueFrom']); + }); + }); + if (launch.chain === 'fallback') { + const last = launch.variants.at(-1); + if (last?.when) { + ctx.addIssue({ + code: 'custom', + message: 'the last variant of a fallback chain must have no `when` (it must be the guaranteed terminal case)', + path: ['variants', launch.variants.length - 1, 'when'], + }); + } + } + }); + +const versionProbeSchema = z + .object({ + arg: shellToken, + regex: z.string().max(200).optional(), + requireVersionMatch: z.boolean().optional(), + retryOnTransientFailure: z.boolean().optional(), + }) + .strict(); + +const discoverySchema = z + .object({ + // min(0): the `shell` entry has no binary of its own (it resolves the login shell in code). + binaries: z.array(shellToken).max(4), + searchDirs: z.array(z.string().max(300)).max(16), + version: versionProbeSchema.optional(), + install: z + .object({ + // z.record with an enum key type requires every enum member in Zod v4; the install + // command legitimately varies by platform and most entries only need one or two, so + // this is a plain object of optional platform keys instead. + command: z + .object({ + linux: z.string().max(500).optional(), + darwin: z.string().max(500).optional(), + wsl: z.string().max(500).optional(), + win32: z.string().max(500).optional(), + }) + .strict(), + npmPackage: z.string().max(200).optional(), + docsUrl: z.url().optional(), + }) + .strict(), + }) + .strict(); + +const envExportSchema = z + .object({ + name: envName, + value: z.union([ + shellToken, + z + .object({ engine: z.enum(['sessionId', 'sessionName', 'muxName', 'effortLevel', 'effortSettingsJson']) }) + .strict(), + ]), + when: condSchema.optional(), + }) + .strict(); + +const envSchema = z + .object({ + exports: z.array(envExportSchema).max(16), + unset: z.array(envName).max(16), + tmuxSetenvKeys: z.array(envName).max(32), + dockerExecEnvNames: z.array(envName).max(32), + allowedPrefixes: z + .array( + z + .string() + .min(3) + .max(32) + .regex(/^[A-Z][A-Z0-9_]*_$/) + ) + .max(8), + allowedKeys: z.array(envName).max(8), + configContentVar: envName.optional(), + }) + .strict(); + +const echoSchema = z + .object({ + policy: z.enum(['buffer', 'predict', 'off']), + anchor: z.union([ + z + .object({ kind: z.literal('glyph'), glyph: z.string().min(1).max(4), offset: z.number().int().min(0).max(16) }) + .strict(), + z.object({ kind: z.literal('cursor') }).strict(), + z.object({ kind: z.literal('none') }).strict(), + ]), + predictProfile: z.string().max(40).optional(), + }) + .strict(); + +const capabilitiesSchema = z + .object({ + requiresMux: z.boolean(), + hooks: z.boolean(), + transcript: z.enum(['claude-jsonl', 'codex-rollout', 'none']), + altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']), + echo: echoSchema, + wheelForward: z + .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) + .strict(), + keyboardAccessory: z.enum(['agent', 'shell']), + privilegedCommandGate: z.boolean(), + startMode: z.enum(['interactive', 'shell']), + stripInkBloat: z.boolean(), + ralph: z.boolean(), + respawn: z.boolean(), + effort: z.boolean(), + agentSkillInjection: z.boolean(), + statusLineTelemetry: z.boolean(), + model: z + .object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() }) + .strict(), + privilegedParams: z + .array(z.object({ param: z.string(), clampTo: z.union([z.boolean(), z.string()]) }).strict()) + .max(8), + gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()), + maxFrameBytes: z.number().int().positive().optional(), + }) + .strict(); + +const credStoreSchema = z + .object({ + rel: z.string().min(1).max(100), + shareDirs: z.array(z.string().max(100)).optional(), + shareFiles: z.array(z.string().max(100)).optional(), + seedFiles: z.array(z.string().max(100)).optional(), + seedWhole: z.boolean().optional(), + }) + .strict(); + +const overlayTargetSchema = z.union([ + z.object({ variant: z.string().min(1).max(40) }).strict(), + z.object({ disabled: z.literal(true) }).strict(), +]); + +const overlaysSchema = z + .object({ + remote: overlayTargetSchema, + docker: overlayTargetSchema, + credStore: credStoreSchema.optional(), + }) + .strict(); + +export const CliEntrySchema = z + .object({ + id: cliId, + label: z.string().min(1).max(60), + shortBadge: z.string().min(1).max(6), + accent: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'accent must be a 6-digit hex colour'), + enabled: z.boolean(), + stock: z.boolean(), + order: z.number().int(), + kind: z.enum(['agent', 'shell']), + discovery: discoverySchema, + launch: launchSchema, + env: envSchema, + capabilities: capabilitiesSchema, + overlays: overlaysSchema, + }) + .strict() + .superRefine((entry, ctx) => { + const variantIds = new Set(entry.launch.variants.map((v) => v.id)); + for (const target of [entry.overlays.remote, entry.overlays.docker]) { + if ('variant' in target && !variantIds.has(target.variant)) { + ctx.addIssue({ code: 'custom', message: `overlay references unknown launch variant "${target.variant}"` }); + } + } + const gateNames = new Set(Object.keys(entry.capabilities.gates)); + const walkConds = (cond: import('./types.js').Cond | undefined) => { + if (!cond) return; + if ('capabilityGate' in cond && !gateNames.has(cond.capabilityGate)) { + ctx.addIssue({ + code: 'custom', + message: `capabilityGate "${cond.capabilityGate}" is not declared in capabilities.gates`, + }); + } + if ('allOf' in cond) cond.allOf.forEach(walkConds); + if ('anyOf' in cond) cond.anyOf.forEach(walkConds); + if ('not' in cond) walkConds(cond.not); + }; + for (const variant of entry.launch.variants) { + walkConds(variant.when); + for (const arg of variant.args) walkConds(arg.when); + } + }); + +export type ValidatedCliEntry = z.infer; diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts new file mode 100644 index 000000000..de68e65ba --- /dev/null +++ b/src/config/cli-registry/stock.ts @@ -0,0 +1,627 @@ +/** + * @fileoverview The shipped stock catalog — one `CliEntry` per CLI Codeman supports out of + * the box, transcribed to be byte-identical (via the argv engine) to the hand-written + * builders in tmux-manager.ts that they replace. + * + * This is the ONE file allowed to know a CLI's id by name (`test/cli-registry-no-id-branching + * .test.ts` enforces that nowhere else does). Everything downstream — session.ts, + * tmux-manager.ts, the routes, the frontend — reads capability flags, never `entry.id ===`. + * + * @module config/cli-registry/stock + */ + +import type { CliEntry } from './types.js'; + +const HOME_DIRS = { + local: '~/.local/bin', + usrLocal: '/usr/local/bin', + bunBin: '~/.bun/bin', + npmGlobal: '~/.npm-global/bin', + homeBin: '~/bin', +}; + +const NO_GATES = {}; +const NO_PRIVILEGED_PARAMS: CliEntry['capabilities']['privilegedParams'] = []; + +/** Shared skeleton for the "agent CLI, no unusual behaviour" case (pi's own shape). */ +function agentDefaults(): Pick< + CliEntry['capabilities'], + | 'requiresMux' + | 'hooks' + | 'transcript' + | 'altScreen' + | 'wheelForward' + | 'keyboardAccessory' + | 'privilegedCommandGate' + | 'startMode' + | 'stripInkBloat' + | 'ralph' + | 'respawn' + | 'effort' + | 'agentSkillInjection' + | 'statusLineTelemetry' + | 'model' + | 'privilegedParams' + | 'gates' +> { + return { + requiresMux: true, + hooks: false, + transcript: 'none', + altScreen: 'strip-mux-only', + wheelForward: { mode: 'never' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'flag', param: 'model' }, + privilegedParams: NO_PRIVILEGED_PARAMS, + gates: NO_GATES, + }; +} + +const CLAUDE: CliEntry = { + id: 'claude' as CliEntry['id'], + label: 'Claude', + shortBadge: 'CC', + accent: '#d97757', + enabled: true, + stock: true, + order: 0, + kind: 'agent', + discovery: { + binaries: ['claude'], + searchDirs: [HOME_DIRS.local, '~/.claude/local', HOME_DIRS.usrLocal, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)', retryOnTransientFailure: true }, + install: { + command: { + linux: 'curl -fsSL https://claude.ai/install.sh | bash', + darwin: 'curl -fsSL https://claude.ai/install.sh | bash', + wsl: 'curl -fsSL https://claude.ai/install.sh | bash', + }, + npmPackage: '@anthropic-ai/claude-code', + docsUrl: 'https://docs.claude.com/claude-code', + }, + }, + launch: { + chain: 'fallback', + params: { + claudeMode: { + type: 'enum', + values: ['dangerously-skip-permissions', 'auto', 'normal', 'allowedTools'], + default: 'dangerously-skip-permissions', + }, + allowedTools: { type: 'token', pattern: 'tool-list' }, + model: { type: 'token', pattern: 'model-claude' }, + resumeId: { type: 'token', pattern: 'uuid' }, + // buildEffortCliArgs carries `ultracode` as a settings JSON blob and every other + // level as a plain `--effort ` flag — two engine values because the two + // shapes are mutually exclusive and neither is user-typed text (both are produced + // from the EFFORT_LEVELS allowlist upstream, same as every other engine value). + effortLevel: { type: 'engine', source: 'effortLevel' }, + effortJson: { type: 'engine', source: 'effortSettingsJson' }, + sessionId: { type: 'engine', source: 'sessionId' }, + sessionName: { type: 'engine', source: 'sessionName' }, + }, + variants: [ + { + id: 'resume', + when: { param: 'resumeId', state: 'set' }, + args: [ + { lit: 'claude' }, + { flag: '--dangerously-skip-permissions', when: { param: 'claudeMode', is: 'dangerously-skip-permissions' } }, + { flag: '--permission-mode', value: 'auto', when: { param: 'claudeMode', is: 'auto' } }, + { + flag: '--allowedTools', + valueFrom: 'allowedTools', + quote: 'double', + when: { + allOf: [ + { param: 'claudeMode', is: 'allowedTools' }, + { param: 'allowedTools', state: 'set' }, + ], + }, + }, + { flag: '--resume', valueFrom: 'resumeId', quote: 'double' }, + { flag: '--model', valueFrom: 'model', quote: 'double', when: { param: 'model', state: 'set' } }, + { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, + { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, + { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + ], + }, + { + id: 'new', + args: [ + { lit: 'claude' }, + { flag: '--dangerously-skip-permissions', when: { param: 'claudeMode', is: 'dangerously-skip-permissions' } }, + { flag: '--permission-mode', value: 'auto', when: { param: 'claudeMode', is: 'auto' } }, + { + flag: '--allowedTools', + valueFrom: 'allowedTools', + quote: 'double', + when: { + allOf: [ + { param: 'claudeMode', is: 'allowedTools' }, + { param: 'allowedTools', state: 'set' }, + ], + }, + }, + { flag: '--session-id', valueFrom: 'sessionId', quote: 'double' }, + { flag: '--model', valueFrom: 'model', quote: 'double', when: { param: 'model', state: 'set' } }, + { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, + { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, + { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + ], + }, + ], + }, + env: { + exports: [], + unset: ['CLAUDECODE', 'COLORTERM'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['CLAUDE_CODE_'], + allowedKeys: ['CLAUDE_CONFIG_DIR'], + }, + capabilities: { + requiresMux: false, + hooks: true, + transcript: 'claude-jsonl', + altScreen: 'strip-full', + echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } }, + wheelForward: { mode: 'version-gated', minVersion: '2.1.187' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: true, + respawn: true, + effort: true, + agentSkillInjection: true, + statusLineTelemetry: true, + model: { source: 'claude-settings-file' }, + privilegedParams: [], + gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } }, + }, + overlays: { + remote: { variant: 'new' }, + docker: { variant: 'new' }, + // Claude's docker/remote credential handling has its own dedicated code path + // (claudeDockerPaneCommand, artifacts at docker-hosts.ts:537-575) — no generic credStore. + }, +}; + +const SHELL: CliEntry = { + id: 'shell' as CliEntry['id'], + label: 'Shell', + shortBadge: 'SH', + accent: '#6b7280', + enabled: true, + stock: true, + order: 1, + kind: 'shell', + discovery: { + binaries: [], + searchDirs: [], + install: { command: {} }, + }, + launch: { + params: {}, + variants: [{ id: 'shell', args: [] }], // tmux-manager resolves the real login shell in code + }, + env: { + exports: [], + unset: ['COLORTERM'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: [], + allowedKeys: [], + }, + capabilities: { + requiresMux: false, + hooks: false, + transcript: 'none', + altScreen: 'preserve', + echo: { policy: 'off', anchor: { kind: 'none' } }, + wheelForward: { mode: 'never' }, + keyboardAccessory: 'shell', + privilegedCommandGate: true, + startMode: 'shell', + stripInkBloat: false, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' }, + privilegedParams: [], + gates: {}, + }, + overlays: { + remote: { variant: 'shell' }, + docker: { disabled: true }, + }, +}; + +const OPENCODE: CliEntry = { + id: 'opencode' as CliEntry['id'], + label: 'OpenCode', + shortBadge: 'OC', + accent: '#f59e0b', + enabled: true, + stock: true, + order: 10, + kind: 'agent', + discovery: { + binaries: ['opencode'], + searchDirs: [ + '~/.opencode/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + '~/go/bin', + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { + linux: 'curl -fsSL https://opencode.ai/install | bash', + darwin: 'curl -fsSL https://opencode.ai/install | bash', + }, + npmPackage: 'opencode-ai', + docsUrl: 'https://opencode.ai/docs', + }, + }, + launch: { + params: { + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id' }, + forkSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'opencode' }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--session', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + flag: '--fork', + when: { + allOf: [ + { param: 'resumeId', state: 'set' }, + { param: 'forkSession', is: true }, + ], + }, + }, + ], + }, + ], + }, + env: { + exports: [], + unset: ['COLORTERM'], + tmuxSetenvKeys: ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'], + dockerExecEnvNames: [], + allowedPrefixes: ['OPENCODE_'], + allowedKeys: [], + configContentVar: 'OPENCODE_CONFIG_CONTENT', + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, + }, + overlays: { + remote: { variant: 'default' }, + docker: { variant: 'default' }, + credStore: { rel: '.config/opencode', seedWhole: true }, + }, +}; + +const CODEX: CliEntry = { + id: 'codex' as CliEntry['id'], + label: 'Codex', + shortBadge: 'CX', + accent: '#6b7fd7', + enabled: true, + stock: true, + order: 20, + kind: 'agent', + discovery: { + binaries: ['codex'], + searchDirs: [ + '~/.codex/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { linux: 'npm install -g @openai/codex', darwin: 'npm install -g @openai/codex' }, + npmPackage: '@openai/codex', + docsUrl: 'https://developers.openai.com/codex/cli', + }, + }, + launch: { + params: { + bypassApprovals: { type: 'bool' }, + animations: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id' }, + sessionId: { type: 'engine', source: 'sessionId' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'codex' }, + { flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } }, + { flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } }, + { flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { lit: 'resume', when: { param: 'resumeId', state: 'set' } }, + { valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + }, + env: { + exports: [ + { name: 'COLORTERM', value: 'truecolor' }, + { name: 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE', value: { engine: 'sessionId' } }, + ], + unset: ['NO_COLOR'], + tmuxSetenvKeys: ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'], + dockerExecEnvNames: ['OPENAI_API_KEY', 'CODEX_API_KEY'], + allowedPrefixes: ['CODEX_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + transcript: 'codex-rollout', + altScreen: 'strip-full', + echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, + wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward + maxFrameBytes: 32 * 1024, + }, + overlays: { + remote: { variant: 'default' }, + docker: { variant: 'default' }, + credStore: { + rel: '.codex', + shareDirs: ['sessions'], + shareFiles: ['history.jsonl'], + seedFiles: ['auth.json', 'config.toml'], + }, + }, +}; + +const GEMINI: CliEntry = { + id: 'gemini' as CliEntry['id'], + label: 'Gemini', + shortBadge: 'GM', + accent: '#4285f4', + enabled: true, + stock: true, + order: 30, + kind: 'agent', + discovery: { + binaries: ['gemini'], + searchDirs: [ + '~/.gemini/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { linux: 'npm install -g @google/gemini-cli', darwin: 'npm install -g @google/gemini-cli' }, + npmPackage: '@google/gemini-cli', + docsUrl: 'https://github.com/google-gemini/gemini-cli', + }, + }, + launch: { + params: { + approvalMode: { type: 'enum', values: ['default', 'auto_edit', 'yolo', 'plan'], default: 'yolo' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'gemini' }, + { flag: '--skip-trust' }, + { flag: '--approval-mode', valueFrom: 'approvalMode' }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + tmuxSetenvKeys: [ + 'GEMINI_API_KEY', + 'GEMINI_MODEL', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_GENAI_USE_VERTEXAI', + ], + dockerExecEnvNames: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], + allowedPrefixes: ['GEMINI_', 'GOOGLE_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-full', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + }, + overlays: { + remote: { variant: 'default' }, + docker: { variant: 'default' }, + credStore: { rel: '.gemini', seedWhole: true }, // also covers antigravity — see its own entry + }, +}; + +const ANTIGRAVITY: CliEntry = { + id: 'antigravity' as CliEntry['id'], + label: 'Antigravity', + shortBadge: 'AG', + accent: '#8b5cf6', + enabled: true, + stock: true, + order: 40, + kind: 'agent', + discovery: { + // Binary is `agy`, NOT `antigravity` — the mode-name/binary-name split that made + // probeDockerCliVersion wrong before this registry existed. + binaries: ['agy'], + searchDirs: [HOME_DIRS.local, '~/.antigravity/bin', HOME_DIRS.usrLocal, HOME_DIRS.homeBin], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { + linux: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + darwin: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + }, + docsUrl: 'https://antigravity.google/cli', + }, + }, + launch: { + params: { + dangerouslySkipPermissions: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'agy' }, + { flag: '--dangerously-skip-permissions', when: { param: 'dangerouslySkipPermissions', is: true } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--conversation', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['ANTIGRAVITY_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + }, + overlays: { + remote: { variant: 'default' }, + docker: { variant: 'default' }, + // No credStore of its own: agy nests its whole state under ~/.gemini/antigravity-cli/, + // which gemini's seedWhole entry already covers. + }, +}; + +const PI: CliEntry = { + id: 'pi' as CliEntry['id'], + label: 'Pi', + shortBadge: 'PI', + accent: '#10b981', + enabled: true, + stock: true, + order: 50, + kind: 'agent', + discovery: { + binaries: ['pi'], + searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + // pi is a generic binary name (Raspberry Pi tooling, personal scripts), so a `which` + // hit alone is not evidence of the right program — require the version match. + version: { arg: '--version', regex: '(?:^|\\s)(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, + install: { + command: { + linux: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + darwin: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + }, + npmPackage: '@earendil-works/pi-coding-agent', + docsUrl: 'https://pi.dev', + }, + }, + launch: { + params: { + approveProjectTrust: { type: 'bool' }, + model: { type: 'token', pattern: 'model-pi' }, + provider: { type: 'token', pattern: 'slug' }, + thinking: { type: 'enum', values: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + continueSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'pi' }, + { flag: '--approve', when: { param: 'approveProjectTrust', is: true } }, + { flag: '--no-approve', when: { param: 'approveProjectTrust', is: false } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--provider', valueFrom: 'provider', when: { param: 'provider', state: 'set' } }, + { flag: '--thinking', valueFrom: 'thinking', when: { param: 'thinking', state: 'set' } }, + { flag: '--session', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + lit: '-c', + when: { + allOf: [ + { param: 'continueSession', is: true }, + { param: 'resumeId', state: 'unset' }, + ], + }, + }, + ], + }, + ], + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + // Pi's ~34 provider keys share no common prefix, so they are deliberately NOT + // allowlisted here — same reasoning as today's PI_ only prefix. Pi users authenticate + // via `/login` or the server process's own env. + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['PI_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + }, + overlays: { + remote: { variant: 'default' }, + docker: { variant: 'default' }, + credStore: { + rel: '.pi/agent', + seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'], + }, + }, +}; + +/** The full stock catalog, in the order the run menu shows by default. */ +export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI]; diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts new file mode 100644 index 000000000..d2314c832 --- /dev/null +++ b/src/config/cli-registry/types.ts @@ -0,0 +1,269 @@ +/** + * @fileoverview Type definitions for the CLI registry — the single source of truth for + * which agent CLIs Codeman supports and how each one is discovered, launched and treated. + * + * This replaces the hard-coded `SessionMode` union and the ~123 per-mode branches that grew + * out of it. The guiding rule: NO code may branch on a CLI's id. Behaviour that genuinely + * differs between CLIs is expressed either as data here, or as a named PROFILE selected by + * a capability field (see profiles.ts) — never as `mode === 'codex'`. + * + * @module config/cli-registry/types + */ + +import type { TokenPattern } from './patterns.js'; + +/** + * A CLI identifier. Branded so an arbitrary string cannot be passed where a validated id is + * expected; construct with `asCliId()` at the API boundary. + */ +export type CliId = string & { readonly __cliId: unique symbol }; + +// --------------------------------------------------------------------------- +// Launch argv DSL +// --------------------------------------------------------------------------- + +/** Values the ENGINE supplies. Config may reference these by name but never author them. */ +export type EngineValue = 'sessionId' | 'sessionName' | 'muxName' | 'effortLevel' | 'effortSettingsJson'; + +/** + * A declared launch parameter. `token` params carry caller-supplied data and are therefore + * the only ones that need a pattern; `engine` params are produced in code. + */ +export type ParamSpec = + | { type: 'enum'; values: string[]; default?: string } + | { type: 'bool' } + | { type: 'token'; pattern: TokenPattern } + | { type: 'engine'; source: EngineValue }; + +/** A boolean guard over parameter state. */ +export type Cond = + | { param: string; is: string | boolean } + | { param: string; state: 'set' | 'unset' } + | { allOf: Cond[] } + | { anyOf: Cond[] } + | { not: Cond } + /** Names an entry in `capabilities.gates`. Fail-closed gates omit when version is unknown. */ + | { capabilityGate: string }; + +/** + * How a token is quoted when emitted into the bash command string. + * + * This exists ONLY to preserve byte-identical output with the hand-written builders being + * replaced (claude wraps its values in double quotes; the other builders emit bare words). + * It is never a safety lever: `renderToken()` verifies the value is metacharacter-free + * before honouring an explicit style, and falls back to single-quote escaping if it is not. + * So the worst a wrong `quote` can do is make output uglier, never unsafe. + */ +export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single'; + +/** One argv element. */ +export type ArgSpec = + /** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */ + | { lit: string; when?: Cond } + /** A valueless flag, e.g. `--no-approve`. */ + | { flag: string; when?: Cond } + /** A flag with a fixed literal value. */ + | { flag: string; value: string; quote?: QuoteStyle; when?: Cond } + /** A flag whose value comes from a declared param. */ + | { flag: string; valueFrom: string; quote?: QuoteStyle; when?: Cond } + /** A bare positional value from a param, e.g. codex's `resume `. */ + | { valueFrom: string; quote?: QuoteStyle; when?: Cond }; + +/** One alternative command form. */ +export interface CliVariant { + /** Stable name for diagnostics and tests, e.g. 'resume' / 'new'. */ + id: string; + when?: Cond; + args: ArgSpec[]; +} + +export interface CliLaunch { + params: Record; + /** + * 'first' — emit the first variant whose `when` passes (the usual case). + * 'fallback' — emit EVERY passing variant joined by the engine's own ` || `, which is how + * claude's `--resume X || --session-id Y` shell fallback is expressed without + * config ever containing shell text. The engine owns the operator. + */ + chain?: 'first' | 'fallback'; + variants: CliVariant[]; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +export interface CliVersionProbe { + arg: string; + /** Serialized regex, applied to `--version` output only. See compileVersionRegex(). */ + regex?: string; + /** + * Treat a binary whose version output does not match as ABSENT rather than as + * present-with-unknown-version. For CLIs with short, generic binary names (`pi`), where a + * `which` hit is not by itself evidence the right program is installed. + */ + requireVersionMatch?: boolean; + /** Retry a failed probe with backoff instead of caching the failure (claude's behaviour). */ + retryOnTransientFailure?: boolean; +} + +export interface CliDiscovery { + /** + * Binary name(s), first hit wins. + * + * This is why the registry fixes a live bug: the mode name is NOT always the binary + * name (`antigravity` runs `agy`), and `probeDockerCliVersion` assumed it was. + */ + binaries: string[]; + /** Extra directories probed after `which`. A leading `~` expands to homedir; nothing else. */ + searchDirs: string[]; + version?: CliVersionProbe; + install: { + /** Shown verbatim in "CLI not found. Install with: ...". NEVER executed by the server. */ + command: Partial>; + /** Feeds generation of docker/agent.Dockerfile. */ + npmPackage?: string; + docsUrl?: string; + }; +} + +// --------------------------------------------------------------------------- +// Environment +// --------------------------------------------------------------------------- + +export interface CliEnv { + /** `export K=V` in the bash prelude. Values are literals or engine values, never secrets. */ + exports: Array<{ name: string; value: string | { engine: EngineValue }; when?: Cond }>; + /** `unset K` — e.g. claude's CLAUDECODE, the truecolor CLIs' NO_COLOR. */ + unset: string[]; + /** + * NAMES ONLY. Values are read from the server's own process.env and pushed via + * `tmux setenv`, so a secret is structurally unable to reach the command line. + */ + tmuxSetenvKeys: string[]; + /** NAMES ONLY, forwarded as `docker exec -e NAME`. */ + dockerExecEnvNames: string[]; + /** This entry's contribution to the env-override allowlist. Never widens BLOCKED_ENV_KEYS. */ + allowedPrefixes: string[]; + allowedKeys: string[]; + /** + * Env var carrying a JSON config blob pushed via `tmux setenv` (opencode's + * OPENCODE_CONFIG_CONTENT). Generic so it is not an opencode special case. + */ + configContentVar?: string; +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +/** + * The closed set of behavioural switches. Each field replaces an id-check somewhere. + * + * `hooks`, `transcript` and `altScreen` are INDEPENDENT on purpose. The three predicates + * they back (`hooksAvailableForMode`, `isExternalCliMode`, `isAltScreenStripMode`) describe + * three different, deliberately unequal sets, and deriving any one from another has already + * caused a real bug — a `shell` session has no hooks but is not an "external CLI", so + * `!isExternalCliMode()` wrongly accepted `until=stop` on it and hung for the full timeout. + * Keeping them as separate fields makes that invariant structural rather than commented. + */ +export interface CliCapabilities { + /** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */ + requiresMux: boolean; + /** Emits Codeman hook events, so `stop`/`blocked` wait signals can ever fire. */ + hooks: boolean; + /** Which transcript reader, if any, understands this CLI's on-disk history. */ + transcript: 'claude-jsonl' | 'codex-rollout' | 'none'; + /** + * 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs). + * 'strip-mux-only' — only tmux's own attach-time smcup (the safe default). + * 'preserve' — leave everything (a direct-PTY shell running vim/less/htop). + */ + altScreen: 'strip-full' | 'strip-mux-only' | 'preserve'; + echo: { + policy: 'buffer' | 'predict' | 'off'; + /** How the local-echo overlay locates the composer row. */ + anchor: { kind: 'glyph'; glyph: string; offset: number } | { kind: 'cursor' } | { kind: 'none' }; + /** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */ + predictProfile?: string; + }; + /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ + wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; + keyboardAccessory: 'agent' | 'shell'; + /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ + privilegedCommandGate: boolean; + startMode: 'interactive' | 'shell'; + stripInkBloat: boolean; + ralph: boolean; + respawn: boolean; + effort: boolean; + agentSkillInjection: boolean; + statusLineTelemetry: boolean; + /** Where a model override is delivered. Claude uniquely writes settings.local.json. */ + model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string }; + /** + * Params a non-granted multi-user owner may not set freely, and what they are forced to. + * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. + */ + privilegedParams: Array<{ param: string; clampTo: boolean | string }>; + /** Version gates referenced by `capabilityGate` conditions. */ + gates: Record; + /** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */ + maxFrameBytes?: number; +} + +// --------------------------------------------------------------------------- +// Location overlays (remote SSH / docker) +// --------------------------------------------------------------------------- + +/** Docker credential seeding policy — which host dirs are copied or shared into a container. */ +export interface CliCredStore { + rel: string; + shareDirs?: string[]; + shareFiles?: string[]; + seedFiles?: string[]; + seedWhole?: boolean; +} + +export interface CliOverlays { + /** Which launch variant to use over SSH, or that remote is unsupported. */ + remote: { variant: string } | { disabled: true }; + docker: { variant: string } | { disabled: true }; + credStore?: CliCredStore; +} + +// --------------------------------------------------------------------------- +// The entry +// --------------------------------------------------------------------------- + +export interface CliEntry { + id: CliId; + label: string; + /** Two-ish character tab badge, e.g. 'OC'. */ + shortBadge: string; + /** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */ + accent: string; + enabled: boolean; + /** Set by the loader from the shipped catalog; a user entry can never claim it. */ + stock: boolean; + order: number; + /** 'shell' unlocks the raw-shell code paths; everything else is an agent CLI. */ + kind: 'agent' | 'shell'; + discovery: CliDiscovery; + launch: CliLaunch; + env: CliEnv; + capabilities: CliCapabilities; + overlays: CliOverlays; +} + +/** The on-disk shape of ~/.codeman/clis.json — overrides and custom entries only. */ +export interface CliRegistryFile { + schemaVersion: number; + /** + * Stock ids already introduced to this install. The ratchet that lets one file both gain + * newly-shipped CLIs on upgrade AND remember that the user disabled one. + */ + seededStockIds: string[]; + /** Keyed by id: a partial override of a stock entry, or a complete custom entry. */ + clis: Record; +} diff --git a/test/cli-registry-argv-parity.test.ts b/test/cli-registry-argv-parity.test.ts new file mode 100644 index 000000000..d63737027 --- /dev/null +++ b/test/cli-registry-argv-parity.test.ts @@ -0,0 +1,252 @@ +/** + * @fileoverview Keystone test for the CLI registry refactor: proves the new argv engine + * (`renderLaunch` over the stock catalog) produces the SAME command string as the existing + * hand-written `buildSpawnCommand` in tmux-manager.ts, across a matrix of inputs per mode. + * + * This is deliberately written against TODAY's code, before anything downstream is switched + * over to the registry (Phase 0 of the plan is additive-only). Every later phase that moves + * tmux-manager.ts onto the engine is then a refactor measured against this fixed baseline, + * not a "does it look right" read of the diff. + * + * Port: N/A (pure functions, no server). + */ + +import { describe, expect, it } from 'vitest'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { renderLaunch } from '../src/config/cli-registry/argv.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import type { ParamValues, EngineValues } from '../src/config/cli-registry/argv.js'; +import type { + AntigravityConfig, + ClaudeMode, + CodexConfig, + EffortLevel, + GeminiConfig, + OpenCodeConfig, + PiConfig, +} from '../src/types/session.js'; + +function entryFor(id: string) { + const entry = STOCK_CLIS.find((e) => (e.id as unknown as string) === id); + if (!entry) throw new Error(`no stock entry for ${id}`); + return entry; +} + +describe('CLI registry argv parity with buildSpawnCommand', () => { + describe('claude', () => { + const claude = entryFor('claude'); + + it.each<{ + name: string; + claudeMode?: ClaudeMode; + allowedTools?: string; + model?: string; + resumeSessionId?: string; + effort?: EffortLevel; + sessionName?: string; + cliVersion?: string | null; + }>([ + { name: 'defaults, new session' }, + { name: 'skip-permissions explicit', claudeMode: 'dangerously-skip-permissions' }, + { name: 'auto mode', claudeMode: 'auto' }, + { name: 'allowedTools valid', claudeMode: 'allowedTools', allowedTools: 'Bash(git:*), Read' }, + { + name: 'allowedTools with dangerous chars falls back', + claudeMode: 'allowedTools', + allowedTools: 'Bash(git:*); rm -rf /', + }, + { name: 'normal mode', claudeMode: 'normal' }, + { name: 'with model', model: 'sonnet' }, + { name: 'with bracketed model alias', model: '[opus-4]' }, + { name: 'invalid model dropped', model: 'sonnet; rm -rf /' }, + { name: 'resume', resumeSessionId: 'abcdef12-3456-7890-abcd-ef1234567890' }, + { name: 'invalid resume id dropped (falls to new)', resumeSessionId: 'not a uuid!' }, + { name: 'with effort level', effort: 'high' }, + { name: 'with ultracode effort', effort: 'ultracode' }, + { name: 'with session name, version below gate', sessionName: 'w1-testcase', cliVersion: '2.1.100' }, + { name: 'with session name, version at gate', sessionName: 'w1-testcase', cliVersion: '2.1.224' }, + { name: 'with session name, unknown version (fail-closed)', sessionName: 'w1-testcase', cliVersion: null }, + { + name: 'everything at once, resume path', + claudeMode: 'auto', + model: 'opus', + resumeSessionId: '11111111-1111-1111-1111-111111111111', + effort: 'xhigh', + sessionName: 'w2-full', + cliVersion: '2.1.300', + }, + ])('$name', (c) => { + const sessionId = 'session-uuid-fixture'; + const legacy = buildSpawnCommand({ + mode: 'claude', + sessionId, + claudeMode: c.claudeMode, + allowedTools: c.allowedTools, + model: c.model, + resumeSessionId: c.resumeSessionId, + effort: c.effort, + sessionName: c.sessionName, + claudeCliVersion: c.cliVersion, + }); + + const params: ParamValues = { + claudeMode: c.claudeMode, + allowedTools: c.allowedTools, + model: c.model, + resumeId: c.resumeSessionId, + }; + const engineValues: EngineValues = { + sessionId, + sessionName: c.sessionName, + }; + // Mirror buildEffortCliArgs exactly: ultracode carries a fixed settings blob, + // every other level rides a plain --effort flag. The two are + // mutually exclusive, matching the entry's two distinct engine values. + if (c.effort === 'ultracode') { + engineValues.effortSettingsJson = '{"ultracode":true}'; + } else if (c.effort) { + engineValues.effortLevel = c.effort; + } + const gatesPassed = new Set(); + if (c.cliVersion && c.cliVersion >= '2.1.224') gatesPassed.add('nameFlag'); + + const rendered = renderLaunch(claude.launch, params, engineValues, gatesPassed); + expect(rendered).toBe(legacy); + }); + }); + + describe('opencode', () => { + const opencode = entryFor('opencode'); + + it.each<{ name: string; config?: OpenCodeConfig }>([ + { name: 'no config' }, + { name: 'model only', config: { model: 'anthropic/claude-sonnet-4-5' } }, + { name: 'invalid model dropped', config: { model: 'bad model!' } }, + { name: 'session id', config: { continueSession: 'sess-123' } }, + { name: 'session id + fork', config: { continueSession: 'sess-123', forkSession: true } }, + { name: 'fork without session id is a no-op', config: { forkSession: true } }, + { name: 'invalid session id dropped', config: { continueSession: 'bad id!' } }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'opencode', sessionId: 'x', openCodeConfig: config }); + const params: ParamValues = { + model: config?.model, + resumeId: config?.continueSession, + forkSession: config?.forkSession, + }; + const rendered = renderLaunch(opencode.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); + + describe('codex', () => { + const codex = entryFor('codex'); + + it.each<{ name: string; config?: CodexConfig }>([ + { name: 'no config' }, + { name: 'bypass approvals', config: { dangerouslyBypassApprovals: true } }, + { name: 'animations on', config: { animations: true } }, + { name: 'animations off', config: { animations: false } }, + { name: 'model', config: { model: 'gpt-5' } }, + { name: 'resume', config: { resumeSessionId: 'abc-123' } }, + { name: 'invalid resume dropped', config: { resumeSessionId: 'bad id!' } }, + { + name: 'everything', + config: { dangerouslyBypassApprovals: true, animations: false, model: 'o4-mini', resumeSessionId: 'sess-1' }, + }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'codex', sessionId: 'x', codexConfig: config }); + const params: ParamValues = { + bypassApprovals: config?.dangerouslyBypassApprovals, + animations: config?.animations, + model: config?.model, + resumeId: config?.resumeSessionId, + }; + const rendered = renderLaunch(codex.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); + + describe('gemini', () => { + const gemini = entryFor('gemini'); + + it.each<{ name: string; config?: GeminiConfig }>([ + { name: 'defaults (yolo)' }, + { name: 'explicit approval mode', config: { approvalMode: 'plan' } }, + { name: 'model', config: { model: 'gemini-2.5-pro' } }, + { name: 'resume', config: { resumeSession: 'latest' } }, + { name: 'everything', config: { approvalMode: 'auto_edit', model: 'gemini-2.5-flash', resumeSession: 'sess.1' } }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'gemini', sessionId: 'x', geminiConfig: config }); + const params: ParamValues = { + approvalMode: config?.approvalMode, + model: config?.model, + resumeId: config?.resumeSession, + }; + const rendered = renderLaunch(gemini.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); + + describe('antigravity', () => { + const antigravity = entryFor('antigravity'); + + it.each<{ name: string; config?: AntigravityConfig }>([ + { name: 'no config (prompting default)' }, + { name: 'skip permissions', config: { dangerouslySkipPermissions: true } }, + { name: 'model', config: { model: 'gemini-3-pro' } }, + { name: 'resume', config: { resumeConversationId: 'conv.1' } }, + { + name: 'everything', + config: { dangerouslySkipPermissions: true, model: 'gemini-3-flash', resumeConversationId: 'conv.2' }, + }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'antigravity', sessionId: 'x', antigravityConfig: config }); + const params: ParamValues = { + dangerouslySkipPermissions: config?.dangerouslySkipPermissions, + model: config?.model, + resumeId: config?.resumeConversationId, + }; + const rendered = renderLaunch(antigravity.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); + + describe('pi', () => { + const pi = entryFor('pi'); + + it.each<{ name: string; config?: PiConfig }>([ + { name: 'no config' }, + { name: 'approve', config: { approveProjectTrust: true } }, + { name: 'no-approve', config: { approveProjectTrust: false } }, + { name: 'model with thinking suffix', config: { model: 'sonnet:high' } }, + { name: 'model provider/id', config: { model: 'openai/gpt-4o' } }, + { name: 'provider', config: { provider: 'anthropic' } }, + { name: 'thinking level', config: { thinking: 'xhigh' } }, + { name: 'continue session', config: { continueSession: true } }, + { name: 'resume session wins over continue', config: { continueSession: true, resumeSessionId: 'sess.1' } }, + { name: 'resume session alone', config: { resumeSessionId: 'sess.1' } }, + { + name: 'everything, no resume', + config: { + approveProjectTrust: true, + model: 'sonnet:high', + provider: 'anthropic', + thinking: 'high', + continueSession: true, + }, + }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'pi', sessionId: 'x', piConfig: config }); + const params: ParamValues = { + approveProjectTrust: config?.approveProjectTrust, + model: config?.model, + provider: config?.provider, + thinking: config?.thinking, + resumeId: config?.resumeSessionId, + continueSession: config?.continueSession, + }; + const rendered = renderLaunch(pi.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); +}); diff --git a/test/cli-registry-load.test.ts b/test/cli-registry-load.test.ts new file mode 100644 index 000000000..ab326f954 --- /dev/null +++ b/test/cli-registry-load.test.ts @@ -0,0 +1,206 @@ +/** + * @fileoverview Tests the CLI registry's merge/seed/quarantine behaviour — the logic that + * lets `~/.codeman/clis.json` hold overrides only and still survive app updates. + * + * `resolveRegistry()` is exercised directly (pure, no IO) for the merge semantics; the + * on-disk `loadCliRegistry()` path is exercised against a temp HOME (via test/setup.ts's + * per-file HOME isolation) for seeding, quarantine and permission handling. + * + * Port: N/A (no server; file IO only, isolated to a temp HOME by test/setup.ts). + */ + +import { describe, expect, it, beforeEach } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from '../src/config/instance.js'; +import { resolveRegistry } from '../src/config/cli-registry/registry.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import type { CliRegistryFile } from '../src/config/cli-registry/types.js'; + +describe('resolveRegistry (pure merge)', () => { + it('returns every stock entry unchanged when the file is absent', () => { + const { entries, warnings } = resolveRegistry(STOCK_CLIS, null, []); + expect(entries.map((e) => e.id as unknown as string).sort()).toEqual( + STOCK_CLIS.map((e) => e.id as unknown as string).sort() + ); + expect(warnings).toEqual([]); + }); + + it('applies a partial override (disable) without touching the rest of the entry', () => { + const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { gemini: { enabled: false } } }; + const { entries } = resolveRegistry(STOCK_CLIS, file, []); + const gemini = entries.find((e) => (e.id as unknown as string) === 'gemini')!; + expect(gemini.enabled).toBe(false); + expect(gemini.label).toBe('Gemini'); // untouched + expect(gemini.launch.variants).toEqual( + STOCK_CLIS.find((e) => (e.id as unknown as string) === 'gemini')!.launch.variants + ); + }); + + it('adds a well-formed custom entry alongside the stock catalog', () => { + const custom = { + id: 'copilot', + label: 'Copilot', + shortBadge: 'GH', + accent: '#24292f', + enabled: true, + order: 60, + kind: 'agent' as const, + discovery: { + binaries: ['copilot'], + searchDirs: ['~/.local/bin'], + install: { command: { linux: 'npm install -g @githubnext/github-copilot-cli' } }, + }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'copilot' }] }] }, + env: { + exports: [], + unset: [], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['COPILOT_'], + allowedKeys: [], + }, + capabilities: { + requiresMux: true, + hooks: false, + transcript: 'none' as const, + altScreen: 'strip-mux-only' as const, + echo: { policy: 'buffer' as const, anchor: { kind: 'cursor' as const } }, + wheelForward: { mode: 'never' as const }, + keyboardAccessory: 'agent' as const, + privilegedCommandGate: false, + startMode: 'interactive' as const, + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' as const }, + privilegedParams: [], + gates: {}, + }, + overlays: { remote: { variant: 'default' }, docker: { variant: 'default' } }, + }; + const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { copilot: custom } }; + const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); + expect(warnings).toEqual([]); + const found = entries.find((e) => (e.id as unknown as string) === 'copilot'); + expect(found).toBeDefined(); + expect(found!.stock).toBe(false); // stock is forced by the loader, never trusted from the file + }); + + it('drops an invalid custom entry with a warning, but keeps every stock entry', () => { + const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { bogus: { id: 'bogus' } } }; + const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); + expect(entries.some((e) => (e.id as unknown as string) === 'bogus')).toBe(false); + expect(entries.length).toBe(STOCK_CLIS.length); + expect(warnings.some((w) => w.includes('bogus'))).toBe(true); + }); + + it('falls back to the pristine stock definition when a stock override fails validation', () => { + const file: CliRegistryFile = { + schemaVersion: 1, + seededStockIds: [], + clis: { codex: { launch: { variants: [{ id: 'default', args: [{ lit: 'codex; rm -rf /' }] }] } } }, + }; + const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); + const codex = entries.find((e) => (e.id as unknown as string) === 'codex')!; + expect(codex.launch.variants[0].args[0]).toEqual({ lit: 'codex' }); // pristine, not the hostile override + expect(warnings.some((w) => w.includes('codex'))).toBe(true); + }); + + it('a stock entry can never be shadowed by an id-colliding custom entry with stock:true', () => { + const file: CliRegistryFile = { + schemaVersion: 1, + seededStockIds: [], + clis: { claude: { stock: false, label: 'Not Actually Claude' } }, + }; + const { entries } = resolveRegistry(STOCK_CLIS, file, []); + const claude = entries.find((e) => (e.id as unknown as string) === 'claude')!; + expect(claude.stock).toBe(true); // loader forces stock:true for a known stock id regardless of the file + expect(claude.label).toBe('Not Actually Claude'); // the override itself still applies — only `stock` is pinned + }); +}); + +describe('loadCliRegistry (on-disk seeding)', () => { + beforeEach(() => { + // Force a fresh module load path per test by clearing the registry's own cache via a + // dynamic re-import is unnecessary here: reloadCliRegistry() is exported for this purpose. + }); + + it('seeds a fresh install with schemaVersion + every stock id, and writes nothing on a second load', async () => { + const { loadCliRegistry, reloadCliRegistry } = await import('../src/config/cli-registry/registry.js'); + reloadCliRegistry(); + const path = dataPath('clis.json'); + expect(existsSync(path)).toBe(false); + + const first = loadCliRegistry(); + expect(first.entries.length).toBe(STOCK_CLIS.length); + expect(existsSync(path)).toBe(true); + const written = JSON.parse(readFileSync(path, 'utf-8')) as CliRegistryFile; + expect(written.seededStockIds.sort()).toEqual(STOCK_CLIS.map((e) => e.id as unknown as string).sort()); + expect(written.clis).toEqual({}); + + const mtimeBefore = readFileSync(path, 'utf-8'); + reloadCliRegistry(); + loadCliRegistry(); + expect(readFileSync(path, 'utf-8')).toBe(mtimeBefore); // no rewrite when nothing changed + }); + + it('a disabled stock CLI stays disabled across a reload that introduces no new stock ids', async () => { + const { loadCliRegistry, reloadCliRegistry } = await import('../src/config/cli-registry/registry.js'); + const path = dataPath('clis.json'); + mkdirSync(dirname(path), { recursive: true }); + const file: CliRegistryFile = { + schemaVersion: 1, + seededStockIds: STOCK_CLIS.map((e) => e.id as unknown as string), + clis: { gemini: { enabled: false } }, + }; + writeFileSync(path, JSON.stringify(file)); + reloadCliRegistry(); + const { entries } = loadCliRegistry(); + const gemini = entries.find((e) => (e.id as unknown as string) === 'gemini')!; + expect(gemini.enabled).toBe(false); + }); + + it('quarantines malformed JSON instead of overwriting it, and falls back to stock', async () => { + const { loadCliRegistry, reloadCliRegistry } = await import('../src/config/cli-registry/registry.js'); + const path = dataPath('clis.json'); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, '{ this is not valid json'); + reloadCliRegistry(); + const { entries } = loadCliRegistry(); + expect(entries.length).toBe(STOCK_CLIS.length); + expect(existsSync(path + '.invalid') || existsSync(path)).toBeDefined(); // original untouched or quarantined + // The exact quarantine filename carries a timestamp; assert one such file exists. + const { readdirSync } = await import('node:fs'); + const dir = dirname(path); + const quarantined = readdirSync(dir).some((f) => f.startsWith('clis.json.invalid-')); + expect(quarantined).toBe(true); + }); + + // Windows/NTFS has no meaningful POSIX group/world bits (every file reports mode 0o666 + // regardless of its actual ACL), so isUnsafePermissions() is a no-op there by design — + // see its own doc comment in registry.ts. This test only exercises the POSIX behaviour. + it.skipIf(process.platform === 'win32')( + 'ignores a group/world-writable registry file and falls back to stock', + async () => { + const { loadCliRegistry, reloadCliRegistry } = await import('../src/config/cli-registry/registry.js'); + const path = dataPath('clis.json'); + mkdirSync(dirname(path), { recursive: true }); + const file: CliRegistryFile = { + schemaVersion: 1, + seededStockIds: STOCK_CLIS.map((e) => e.id as unknown as string), + clis: { gemini: { enabled: false } }, + }; + writeFileSync(path, JSON.stringify(file)); + chmodSync(path, 0o666); + reloadCliRegistry(); + const { entries, warnings } = loadCliRegistry(); + const gemini = entries.find((e) => (e.id as unknown as string) === 'gemini')!; + expect(gemini.enabled).toBe(true); // override was ignored — file was unsafe to trust + expect(warnings.some((w) => w.includes('writable'))).toBe(true); + } + ); +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts new file mode 100644 index 000000000..c6a598e27 --- /dev/null +++ b/test/cli-registry-schema.test.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Validates the shipped stock catalog against `CliEntrySchema`, and proves the + * schema actually rejects the shell-injection shapes it exists to block. + * + * Port: N/A (pure functions, no server). + */ + +import { describe, expect, it } from 'vitest'; +import { CliEntrySchema } from '../src/config/cli-registry/schema.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +describe('CliEntrySchema', () => { + it('accepts every stock entry as shipped', () => { + for (const entry of STOCK_CLIS) { + const result = CliEntrySchema.safeParse(entry); + if (!result.success) { + throw new Error(`stock entry "${entry.id}" failed validation: ${result.error.message}`); + } + } + }); + + it('rejects unknown keys anywhere in the tree (.strict())', () => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + const withJunk = { ...claude, capabilities: { ...claude.capabilities, notARealField: true } }; + expect(CliEntrySchema.safeParse(withJunk).success).toBe(false); + }); + + it.each([ + ['semicolon', 'claude; rm -rf /'], + ['command substitution', 'claude$(rm -rf /)'], + ['backtick', 'claude`rm -rf /`'], + ['pipe', 'claude | cat /etc/passwd'], + ['redirect', 'claude > /etc/passwd'], + ['ampersand background', 'claude & rm -rf /'], + ['newline', 'claude\nrm -rf /'], + ['single quote escape attempt', "claude' ; rm -rf /ETC #"], + ['double quote escape attempt', 'claude" ; rm -rf /ETC #'], + ['space (not a shell metachar but still not a bare word)', 'claude session'], + ])('rejects a literal containing %s', (_label, hostileLit) => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + const tampered = { + ...claude, + launch: { + ...claude.launch, + variants: claude.launch.variants.map((v, i) => + i === 0 ? { ...v, args: [{ lit: hostileLit }, ...v.args.slice(1)] } : v + ), + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a flag value fixed literal containing shell metacharacters', () => { + const codex = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'codex')!; + const tampered = { + ...codex, + launch: { + ...codex.launch, + variants: [{ id: 'default', args: [{ lit: 'codex' }, { flag: '--config', value: 'x=$(whoami)' }] }], + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a valueFrom referencing an undeclared param', () => { + const codex = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'codex')!; + const tampered = { + ...codex, + launch: { + ...codex.launch, + variants: [{ id: 'default', args: [{ lit: 'codex' }, { flag: '--model', valueFrom: 'nonexistentParam' }] }], + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a capabilityGate condition referencing an undeclared gate', () => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + const tampered = { + ...claude, + launch: { + ...claude.launch, + variants: claude.launch.variants.map((v) => ({ + ...v, + args: [...v.args, { flag: '--bogus', when: { capabilityGate: 'notARealGate' } }], + })), + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a fallback chain whose last variant has a `when` guard', () => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + const tampered = { + ...claude, + launch: { + ...claude.launch, + chain: 'fallback' as const, + variants: [ + claude.launch.variants[0], + { ...claude.launch.variants[1], when: { param: 'model', state: 'set' } as const }, + ], + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects an overlay referencing an unknown launch variant', () => { + const opencode = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'opencode')!; + const tampered = { ...opencode, overlays: { ...opencode.overlays, remote: { variant: 'nonexistent' } } }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects an id that is not lowercase-kebab', () => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + expect(CliEntrySchema.safeParse({ ...claude, id: 'Claude Code' }).success).toBe(false); + expect(CliEntrySchema.safeParse({ ...claude, id: 'CLAUDE' }).success).toBe(false); + expect(CliEntrySchema.safeParse({ ...claude, id: '1claude' }).success).toBe(false); + }); + + it('rejects an env allowlist prefix that does not end with an underscore', () => { + const codex = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'codex')!; + const tampered = { ...codex, env: { ...codex.env, allowedPrefixes: ['CODEX'] } }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a too-short env allowlist prefix (defense against widening to a single-letter prefix)', () => { + const codex = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'codex')!; + const tampered = { ...codex, env: { ...codex.env, allowedPrefixes: ['A_'] } }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); +}); From 3be38e8faee5e8c0cf2257f7fd7436f60cfd720b Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:45:30 +0800 Subject: [PATCH 02/15] refactor(cli-resolver): unify the six CLI resolvers onto the registry (phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the six near-identical hand-rolled resolvers with one generic walker in src/utils/cli-resolver.ts, parametrized by the CLI registry's stock catalog instead of duplicated search-dir arrays: - createDirResolver() covers the plain "which, then search dirs" case (opencode, codex, gemini, antigravity). - createVersionGatedResolver() generalizes pi's per-candidate version-sanity probe (a `which pi` hit alone is not evidence of the coding agent, since `pi` is a short generic name). - createRetryingVersionGetter() + resolveRetryingVersion() / retryingVersionProbeDelayMs() generalize claude's cached-success, backoff-on-failure version probe. These stay directly unit-tested by test/claude-cli-version-cache.test.ts via claude-cli-resolver.ts's preserved re-exports. Each of the six per-CLI files (claude/opencode/codex/gemini/antigravity/pi -cli-resolver.ts) becomes a thin wrapper that keeps its historical exports byte-for-byte (function names, signatures, the ClaudeVersionProbeState type, PI_VERSION_REGEX), so no caller changes and every existing `vi.mock('.../opencode-cli-resolver.js')` in the test suite keeps working — mocking one CLI's resolver module still only affects that CLI. Folds dependency-registry.ts's six duplicated CLI entries (`codeman doctor`) into one generator reading the same registry data, and as a result five CLIs that previously had NO install hint in the doctor's output (opencode, codex, gemini, antigravity, pi — only claude had one) now do. Fixes a real bug found while doing this: `probeDockerCliVersion()` assumed a session's mode name equals its in-container binary name (`docker-hosts.ts`), which is wrong for antigravity (mode `antigravity`, binary `agy`) — it silently probed a binary that doesn't exist in the container and always got undefined back. Extracted as the pure, now-unit-tested `binaryForDockerProbe()`. All existing resolver/mode/dependency tests pass unchanged (one assertion in dependency-checker.test.ts now compares the shared pi version regex by `.source` instead of object identity, since both sides now separately compile the same declared string from the registry rather than importing one shared RegExp instance — same guarantee, expressed for the new architecture). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/config/dependency-registry.ts | 122 +++++------ src/docker-hosts.ts | 18 +- src/utils/antigravity-cli-resolver.ts | 57 ++---- src/utils/claude-cli-resolver.ts | 199 ++++-------------- src/utils/cli-resolver.ts | 281 ++++++++++++++++++++++++++ src/utils/codex-cli-resolver.ts | 61 ++---- src/utils/gemini-cli-resolver.ts | 61 ++---- src/utils/opencode-cli-resolver.ts | 61 ++---- src/utils/pi-cli-resolver.ts | 137 +++---------- test/dependency-checker.test.ts | 8 +- test/docker-hosts.test.ts | 16 ++ 11 files changed, 510 insertions(+), 511 deletions(-) create mode 100644 src/utils/cli-resolver.ts diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 0d6e60474..8e02da228 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -7,7 +7,7 @@ * @module config/dependency-registry */ -import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js'; +import { listClis } from './cli-registry/registry.js'; export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl'; @@ -56,6 +56,60 @@ export interface ToolDependency { const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32']; +/** + * Build a `codeman doctor` entry for one CLI registry entry, so its binary names, search + * behaviour, version probe and install hints are declared exactly ONCE — in the CLI + * registry's stock catalog — rather than duplicated here. `pi`'s `requireVersionMatch` and + * shared `PI_VERSION_REGEX` come along automatically, which is what keeps the doctor and the + * run mode from ever disagreeing about what counts as an installed `pi` (see pi-cli-resolver.ts). + * + * Only entries actually present in the registry are turned into doctor rows — a CLI a user + * has fully removed from `clis.json` doesn't get an orphaned dependency row either. + */ +function cliDependencyEntry(id: string, usedBy: string): ToolDependency | null { + const cli = listClis().find((e) => (e.id as unknown as string) === id); + if (!cli || cli.discovery.binaries.length === 0) return null; // e.g. `shell`, which has no binary + const version = cli.discovery.version; + const installHint: ToolDependency['installHint'] = {}; + for (const [platform, command] of Object.entries(cli.discovery.install.command)) { + if (command) installHint[platform as ProbeEnvironment] = command; + } + return { + id, + label: `${cli.label} CLI`, + category: 'core', + required: false, + usedBy: [usedBy], + resolvers: [ + { + match: ALL, + resolver: { + kind: 'path', + bins: cli.discovery.binaries, + versionArg: version?.arg ?? '--version', + versionRegex: version?.regex ? new RegExp(version.regex) : undefined, + requireVersionMatch: version?.requireVersionMatch, + }, + }, + ], + installHint: Object.keys(installHint).length > 0 ? installHint : undefined, + }; +} + +/** `usedBy` text for each CLI's doctor row, matching the historical copy per id. */ +const CLI_USED_BY: Record = { + claude: 'Claude Code sessions (default backend)', + opencode: 'OpenCode sessions', + codex: 'Codex sessions', + gemini: 'Gemini sessions', + antigravity: 'Antigravity sessions', + pi: 'Pi sessions', +}; + +const CLI_DEPENDENCY_ENTRIES: ToolDependency[] = Object.entries(CLI_USED_BY) + .map(([id, usedBy]) => cliDependencyEntry(id, usedBy)) + .filter((entry): entry is ToolDependency => entry !== null); + export const DEPENDENCY_REGISTRY: ToolDependency[] = [ { id: 'node', @@ -66,15 +120,6 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }], installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' }, }, - { - id: 'claude', - label: 'Claude CLI', - category: 'core', - required: false, - usedBy: ['Claude Code sessions (default backend)'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }], - installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' }, - }, { id: 'tmux', label: 'tmux', @@ -83,62 +128,7 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }], installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' }, }, - { - id: 'opencode', - label: 'OpenCode CLI', - category: 'core', - required: false, - usedBy: ['OpenCode sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }], - }, - { - id: 'codex', - label: 'Codex CLI', - category: 'core', - required: false, - usedBy: ['Codex sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }], - }, - { - id: 'gemini', - label: 'Gemini CLI', - category: 'core', - required: false, - usedBy: ['Gemini sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }], - }, - { - id: 'antigravity', - label: 'Antigravity CLI', - category: 'core', - required: false, - usedBy: ['Antigravity sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }], - }, - { - id: 'pi', - label: 'Pi CLI', - category: 'core', - required: false, - usedBy: ['Pi sessions'], - // The only entry that requires a version match, for the same reason - // pi-cli-resolver.ts probes: `pi` is a short generic name (Raspberry Pi tooling, - // personal scripts), so a `which pi` hit alone is not the coding agent. Both sides - // share PI_VERSION_REGEX, so the doctor and the run mode cannot drift into telling - // the user opposite things about the same binary. - resolvers: [ - { - match: ALL, - resolver: { - kind: 'path', - bins: ['pi'], - versionArg: '--version', - versionRegex: PI_VERSION_REGEX, - requireVersionMatch: true, - }, - }, - ], - }, + ...CLI_DEPENDENCY_ENTRIES, { id: 'libreoffice', label: 'LibreOffice', diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index c98338f0e..6b16ef853 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -30,6 +30,7 @@ import { createHash } from 'node:crypto'; import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { dataPath } from './config/instance.js'; +import { getCli } from './config/cli-registry/registry.js'; import type { DockerCase, DockerCommandMode, @@ -1043,6 +1044,21 @@ export async function reapOrphanedDockerContainers( return reaped; } +/** + * Resolve the in-container binary name to probe for a mode's version. + * + * The binary name is NOT always the mode id — antigravity's mode is `antigravity` but + * its binary is `agy` — so this reads the CLI registry's `discovery.binaries[0]` rather + * than assuming they match, which is what the old `mode === 'shell' ? null : mode` check + * got wrong (it would have probed a nonexistent `antigravity` binary in-container). + * `shell`, and any mode with no declared binaries, yields undefined. Exported as a pure + * function so the fix is unit-testable without VITEST's `IS_TEST_MODE` short-circuit + * standing in the way. + */ +export function binaryForDockerProbe(mode: SessionMode): string | undefined { + return getCli(mode)?.discovery.binaries[0]; +} + /** * Read the IN-CONTAINER Claude CLI version (`docker exec claude * --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude @@ -1054,7 +1070,7 @@ export async function probeDockerCliVersion( mode: SessionMode ): Promise { if (IS_TEST_MODE) return undefined; - const bin = mode === 'shell' ? null : mode; + const bin = binaryForDockerProbe(mode); if (!bin) return undefined; const argv = dockerEngineArgv(docker); try { diff --git a/src/utils/antigravity-cli-resolver.ts b/src/utils/antigravity-cli-resolver.ts index dc3466e14..1b4b0ab46 100644 --- a/src/utils/antigravity-cli-resolver.ts +++ b/src/utils/antigravity-cli-resolver.ts @@ -1,28 +1,24 @@ /** - * @fileoverview Resolve the Antigravity CLI (`agy`) binary across common install paths. + * @fileoverview Antigravity CLI binary resolution. * - * Mirrors gemini-cli-resolver.ts. Google's installer (antigravity.google/cli/install.sh) - * places the binary at ~/.local/bin/agy; the other locations cover manual installs. + * The binary is `agy`, not `antigravity` — the registry's `discovery.binaries` + * carries that split so nothing here (or anywhere else) has to know it by name. + * Thin wrapper over `cli-resolver.ts`'s generic walker; see claude-cli-resolver.ts's + * file header for why this stays its own module rather than a bare re-export. * * @module utils/antigravity-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createDirResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; -/** Common directories where the Antigravity CLI binary may be installed */ -const ANTIGRAVITY_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - join(homedir(), '.antigravity', 'bin'), - '/usr/local/bin', - join(homedir(), 'bin'), -]; +function entry() { + const e = getCli('antigravity'); + if (!e) throw new Error('antigravity is not registered in the CLI registry'); + return e; +} -/** Cached directory containing the agy binary (empty string = searched but not found) */ -let _antigravityDir: string | null = null; +const resolver = createDirResolver(entry().discovery.binaries, entry().discovery.searchDirs); /** * Finds the directory containing the `agy` binary. @@ -31,35 +27,12 @@ let _antigravityDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveAntigravityDir(): string | null { - if (_antigravityDir !== null) return _antigravityDir || null; - - try { - const result = execSync('which agy', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _antigravityDir = dirname(result); - return _antigravityDir; - } - } catch { - // agy not in PATH, will check common locations - } - - for (const dir of ANTIGRAVITY_SEARCH_DIRS) { - if (existsSync(join(dir, 'agy'))) { - _antigravityDir = dir; - return _antigravityDir; - } - } - - _antigravityDir = ''; - return null; + return resolver.resolveDir(); } /** * Check if the Antigravity CLI is available on the system. */ export function isAntigravityAvailable(): boolean { - return resolveAntigravityDir() !== null; + return resolver.isAvailable(); } diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index 6e8f1dda9..34d550f61 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -1,38 +1,49 @@ /** - * @fileoverview Shared Claude CLI binary resolution. + * @fileoverview Claude CLI binary resolution. * - * Finds the `claude` binary across common installation paths and provides - * an augmented PATH string. Used by session.ts and tmux-manager.ts - * to locate the Claude CLI. + * Thin wrapper over `cli-resolver.ts`'s generic walker, reading its search + * parameters from the CLI registry's stock catalog. Kept as its own module + * (rather than folded into a single generic import everywhere) so every + * existing caller and every `vi.mock('.../claude-cli-resolver.js')` in the + * test suite keeps working unchanged — see cli-resolver.ts's file header. * * @module utils/claude-cli-resolver */ -import { execSync, execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { delimiter, dirname, join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; - -/** Common directories where the Claude CLI binary may be installed */ -const CLAUDE_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - join(homedir(), '.claude', 'local'), - '/usr/local/bin', - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +import { join } from 'node:path'; +import { + augmentPath, + createDirResolver, + createRetryingVersionGetter, + resolveRetryingVersion, + retryingVersionProbeDelayMs, + type RetryingVersionProbeState, +} from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; + +/** Preserved name for the exported type; identical shape to RetryingVersionProbeState. */ +export type ClaudeVersionProbeState = RetryingVersionProbeState; + +/** Preserved name; identical behaviour to the generic retry/backoff delay function. */ +export const claudeVersionRetryDelayMs = retryingVersionProbeDelayMs; + +/** Preserved name; identical behaviour to the generic retry/backoff cache policy. */ +export const resolveClaudeCliVersion = resolveRetryingVersion; + +function claudeEntry() { + const entry = getCli('claude'); + if (!entry) throw new Error('claude is not registered in the CLI registry'); + return entry; +} -/** Cached directory containing the claude binary (empty string = searched but not found) */ -let _claudeDir: string | null = null; +const resolver = createDirResolver(claudeEntry().discovery.binaries, claudeEntry().discovery.searchDirs); /** * Returns true if the Claude CLI binary can be located (via `which` or one of - * the common install directories). Mirrors `isGeminiAvailable`/`isAntigravityAvailable`/`isOpenCodeAvailable`/ - * `isCodexAvailable` in the sibling resolvers. + * the common install directories). Mirrors the sibling resolvers. */ export function isClaudeAvailable(): boolean { - return findClaudeDir() !== null; + return resolver.isAvailable(); } /** @@ -43,29 +54,7 @@ export function isClaudeAvailable(): boolean { * @returns Directory path, or null if not found */ export function findClaudeDir(): string | null { - if (_claudeDir !== null) return _claudeDir || null; - - // Try `which` first (respects current PATH) - try { - const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); - if (result && existsSync(result)) { - _claudeDir = dirname(result); - return _claudeDir; - } - } catch { - // Claude not in PATH, will check common locations - } - - // Fallback: check common installation directories - for (const dir of CLAUDE_SEARCH_DIRS) { - if (existsSync(join(dir, 'claude'))) { - _claudeDir = dir; - return _claudeDir; - } - } - - _claudeDir = ''; // mark as searched, not found - return null; + return resolver.resolveDir(); } /** @@ -83,7 +72,7 @@ export function getClaudeBinaryPath(): string { return dir ? join(dir, 'claude') : 'claude'; } -/** Cached augmented PATH string */ +/** Cached augmented PATH string. */ let _augmentedPath: string | null = null; /** @@ -95,109 +84,10 @@ let _augmentedPath: string | null = null; */ export function getAugmentedPath(): string { if (_augmentedPath) return _augmentedPath; - - const currentPath = process.env.PATH || ''; - const claudeDir = findClaudeDir(); - - if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) { - _augmentedPath = `${claudeDir}${delimiter}${currentPath}`; - return _augmentedPath; - } - - _augmentedPath = currentPath; + _augmentedPath = augmentPath(findClaudeDir(), process.env.PATH || ''); return _augmentedPath; } -/** - * Cache state for the `claude --version` probe. - * - * `version` is only ever set from a SUCCESSFUL probe and then kept for the - * process lifetime (the binary can't change under a running server without a - * restart). Failures are tracked separately so they expire. - */ -export interface ClaudeVersionProbeState { - /** Successful probe result; `undefined` until one succeeds. */ - version?: string; - /** Consecutive failed probes (drives the retry backoff). */ - failures: number; - /** Timestamp of the most recent failed probe. */ - lastFailureAt: number; -} - -/** First retry window after a failed probe. */ -const VERSION_PROBE_BASE_RETRY_MS = 60_000; -/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */ -const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000; - -/** - * How long to wait before re-probing after `failures` consecutive failures: - * 1min, 2min, 4min… capped at 15min. Exported for tests. - */ -export function claudeVersionRetryDelayMs(failures: number): number { - if (failures <= 0) return 0; - return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS); -} - -/** - * Cache policy for the version probe, pure apart from the `state` it mutates - * and the injected `probe` (exported so tests can drive it with a fake clock). - * - * Success is cached forever; FAILURE is not. That asymmetry is the fix for a - * real shipped bug: the old cache stored `null` on any exception and guarded on - * `!== undefined`, so a single failed probe — a 5s `EXEC_TIMEOUT_MS` timeout, a - * PATH-starved systemd/launchd environment, a transient fs hiccup — at the FIRST - * Claude session start left `cliVersion` undefined for EVERY Claude session - * until the server restarted. An undefined `cliVersion` silently disables - * wheel-forwarding to Claude's own transcript (`_shouldForwardWheelToApp`), - * which is the only route to history in repaint mode: a dead wheel on every - * device at once, matching the issue #205 retest reports. - * - * Retries back off so a genuinely absent binary still can't spawn a probe per - * session start. - */ -export function resolveClaudeCliVersion( - state: ClaudeVersionProbeState, - now: number, - probe: () => string | null -): string | null { - if (state.version !== undefined) return state.version; - if (state.failures > 0 && now - state.lastFailureAt < claudeVersionRetryDelayMs(state.failures)) return null; - - let version: string | null = null; - try { - version = probe(); - } catch { - version = null; - } - - if (version) { - state.version = version; - state.failures = 0; - state.lastFailureAt = 0; - return version; - } - state.failures += 1; - state.lastFailureAt = now; - return null; -} - -const _claudeVersionState: ClaudeVersionProbeState = { failures: 0, lastFailureAt: 0 }; - -/** One `claude --version` run. Throws on spawn/timeout failure. */ -function probeClaudeCliVersion(): string | null { - const dir = findClaudeDir(); - const bin = dir ? join(dir, 'claude') : 'claude'; - // execFileSync (no shell) — the resolved path may contain spaces, and there - // is no untrusted input, but avoid a shell either way. - const out = execFileSync(bin, ['--version'], { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - env: { ...process.env, PATH: getAugmentedPath() }, - }); - const match = out.match(/(\d+\.\d+\.\d+)/); - return match ? match[1] : null; -} - /** * Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it * can't be determined. Runs `claude --version` at most once per successful @@ -209,11 +99,10 @@ function probeClaudeCliVersion(): string | null { * show it, which left `cliVersion` undefined and silently disabled features * gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154). */ -export function getClaudeCliVersion(): string | null { - // Keep the test suite hermetic — never spawn a real `claude` subprocess under - // vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set - // it on the session directly. Deliberately does NOT touch the cache state: - // recording a phantom failure here would be the very poisoning this fixes. - if (process.env.VITEST) return null; - return resolveClaudeCliVersion(_claudeVersionState, Date.now(), probeClaudeCliVersion); -} +export const getClaudeCliVersion = createRetryingVersionGetter({ + resolveDir: findClaudeDir, + binaryName: 'claude', + versionArg: claudeEntry().discovery.version?.arg ?? '--version', + versionRegex: claudeEntry().discovery.version?.regex, + getAugmentedPath, +}); diff --git a/src/utils/cli-resolver.ts b/src/utils/cli-resolver.ts new file mode 100644 index 000000000..39e2c74c3 --- /dev/null +++ b/src/utils/cli-resolver.ts @@ -0,0 +1,281 @@ +/** + * @fileoverview Generic CLI binary resolution, shared by every per-CLI resolver + * (`claude-cli-resolver.ts`, `opencode-cli-resolver.ts`, `codex-cli-resolver.ts`, + * `gemini-cli-resolver.ts`, `antigravity-cli-resolver.ts`, `pi-cli-resolver.ts`). + * + * Those six files used to each hand-roll the same `which` + search-dir walk with a + * module-level cache. They now call into this module and re-export the result under their + * historical names, so every existing caller (`findClaudeDir()`, `resolvePiDir()`, …) and + * every `vi.mock('.../opencode-cli-resolver.js')` in the test suite keeps working unchanged + * — the per-CLI files stay real, separately-mockable modules; only the walking logic moved. + * + * Search parameters (binaries, search dirs, version-probe config) come from the CLI + * registry's stock catalog, so this is also where the resolvers stop duplicating data that + * `src/config/cli-registry/stock.ts` already declares. + * + * @module utils/cli-resolver + */ + +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { delimiter, dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { compileVersionRegex } from '../config/cli-registry/patterns.js'; +import type { CliVersionProbe } from '../config/cli-registry/types.js'; + +/** Expand a leading `~` to the current homedir. Search dirs carry no other expansion. */ +function expandHome(dir: string): string { + return dir.startsWith('~') ? join(homedir(), dir.slice(1).replace(/^[/\\]/, '')) : dir; +} + +/** + * A resolver instance for one CLI. Each call to `createDirResolver()` returns its own + * closured cache, exactly like the six hand-written modules each had their own + * module-level `let _xDir`. + */ +export interface DirResolver { + resolveDir(): string | null; + isAvailable(): boolean; +} + +/** + * The plain "which, then search dirs" resolver — covers opencode, codex, gemini and + * antigravity today, and any future CLI with no version-sanity requirement. + */ +export function createDirResolver(binaries: string[], searchDirs: string[]): DirResolver { + let cached: string | null = null; // '' = searched, not found + const dirs = searchDirs.map(expandHome); + + function resolveDir(): string | null { + if (cached !== null) return cached || null; + + for (const bin of binaries) { + try { + const result = execSync(`which ${bin}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); + if (result && existsSync(result)) { + cached = dirname(result); + return cached; + } + } catch { + // not on PATH via `which`; fall through to the search dirs + } + } + + for (const dir of dirs) { + for (const bin of binaries) { + if (existsSync(join(dir, bin))) { + cached = dir; + return cached; + } + } + } + + cached = ''; + return null; + } + + return { resolveDir, isAvailable: () => resolveDir() !== null }; +} + +/** + * A resolver whose EVERY candidate must pass a version-sanity probe before being accepted + * — pi's behaviour, generalized. For a CLI with a short, generic binary name, a `which` hit + * is not by itself evidence the right program is installed. + * + * Under `VITEST` the probe never runs (existence alone decides), matching every resolver's + * hermetic-test behaviour: the suites must not depend on what happens to be on the dev box. + */ +export interface VersionGatedResolver extends DirResolver { + getVersion(): string | null; +} + +export function createVersionGatedResolver( + binaries: string[], + searchDirs: string[], + probe: CliVersionProbe, + logPrefix: string +): VersionGatedResolver { + let cachedDir: string | null = null; // '' = searched, not found + let cachedVersion: string | null = null; + const dirs = searchDirs.map(expandHome); + const regex = probe.regex ? compileVersionRegex(probe.regex) : null; + + function probeOne(binPath: string): string | null { + if (process.env.VITEST) return null; + try { + const out = execFileSync(binPath, [probe.arg], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + const candidate = regex ? regex.exec(out)?.[1] : out || null; + if (candidate) return candidate; + console.warn(`[${logPrefix}] Ignoring ${binPath}: "${probe.arg}" printed ${JSON.stringify(out.slice(0, 80))}`); + } catch (err) { + console.warn(`[${logPrefix}] Ignoring ${binPath}: "${probe.arg}" failed (${(err as Error).message})`); + } + return null; + } + + function accept(binPath: string): string | null { + if (process.env.VITEST) { + cachedDir = dirname(binPath); + cachedVersion = ''; + return cachedDir; + } + const version = probeOne(binPath); + if (!version) return null; + cachedDir = dirname(binPath); + cachedVersion = version; + return cachedDir; + } + + function resolveDir(): string | null { + if (cachedDir !== null) return cachedDir || null; + + for (const bin of binaries) { + try { + const result = execSync(`which ${bin}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); + if (result && existsSync(result)) { + const dir = accept(result); + if (dir) return dir; + } + } catch { + // not on PATH via `which` + } + } + + for (const dir of dirs) { + for (const bin of binaries) { + const binPath = join(dir, bin); + if (!existsSync(binPath)) continue; + const accepted = accept(binPath); + if (accepted) return accepted; + } + } + + cachedDir = ''; + cachedVersion = ''; + return null; + } + + return { + resolveDir, + isAvailable: () => resolveDir() !== null, + getVersion: () => { + resolveDir(); + return cachedVersion || null; + }, + }; +} + +// --------------------------------------------------------------------------- +// Claude's retry/backoff version probe. Pure apart from the `state` it mutates +// and the injected `probe`, so it stays directly unit-testable exactly as +// `test/claude-cli-version-cache.test.ts` already exercises it. +// --------------------------------------------------------------------------- + +/** + * Cache state for a `--version` probe with retry/backoff. `version` is only ever set from a + * SUCCESSFUL probe and then kept for the process lifetime (the binary can't change under a + * running server without a restart). Failures are tracked separately so they expire. + */ +export interface RetryingVersionProbeState { + /** Successful probe result; `undefined` until one succeeds. */ + version?: string; + /** Consecutive failed probes (drives the retry backoff). */ + failures: number; + /** Timestamp of the most recent failed probe. */ + lastFailureAt: number; +} + +/** First retry window after a failed probe. */ +const VERSION_PROBE_BASE_RETRY_MS = 60_000; +/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */ +const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000; + +/** + * How long to wait before re-probing after `failures` consecutive failures: + * 1min, 2min, 4min… capped at 15min. + */ +export function retryingVersionProbeDelayMs(failures: number): number { + if (failures <= 0) return 0; + return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS); +} + +/** + * Cache policy for a retry/backoff version probe. Success is cached forever, failure is not + * — see claude-cli-resolver.ts's original doc comment (preserved there) for the shipped bug + * this asymmetry fixes: caching a transient failure forever silently disabled every feature + * gated on the version for the rest of the process lifetime. + */ +export function resolveRetryingVersion( + state: RetryingVersionProbeState, + now: number, + probe: () => string | null +): string | null { + if (state.version !== undefined) return state.version; + if (state.failures > 0 && now - state.lastFailureAt < retryingVersionProbeDelayMs(state.failures)) return null; + + let version: string | null = null; + try { + version = probe(); + } catch { + version = null; + } + + if (version) { + state.version = version; + state.failures = 0; + state.lastFailureAt = 0; + return version; + } + state.failures += 1; + state.lastFailureAt = now; + return null; +} + +/** + * Build a retry/backoff version getter for a resolved binary, bound to its own cache state + * and PATH-augmentation. `getAugmentedPath` is injected because only claude currently needs + * PATH augmentation ahead of the probe (its binary dir may not be on the inherited PATH). + */ +export function createRetryingVersionGetter(opts: { + resolveDir: () => string | null; + binaryName: string; + versionArg: string; + versionRegex?: string; + getAugmentedPath?: () => string; +}): () => string | null { + const state: RetryingVersionProbeState = { failures: 0, lastFailureAt: 0 }; + const regex = opts.versionRegex ? compileVersionRegex(opts.versionRegex) : null; + + function probeOnce(): string | null { + const dir = opts.resolveDir(); + const bin = dir ? join(dir, opts.binaryName) : opts.binaryName; + const out = execFileSync(bin, [opts.versionArg], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + env: { ...process.env, PATH: opts.getAugmentedPath ? opts.getAugmentedPath() : process.env.PATH }, + }); + const match = regex ? regex.exec(out) : null; + return match ? match[1] : null; + } + + return () => { + // Keep the test suite hermetic — never spawn a real subprocess under vitest. Tests that + // need a version set it directly on the session. Deliberately does NOT touch `state`: + // recording a phantom failure here would be the very cache-poisoning this fixes. + if (process.env.VITEST) return null; + return resolveRetryingVersion(state, Date.now(), probeOnce); + }; +} + +/** Build a PATH string that includes `dir`, if not already present. Cached by the caller. */ +export function augmentPath(dir: string | null, currentPath: string): string { + if (dir && !currentPath.split(delimiter).includes(dir)) { + return `${dir}${delimiter}${currentPath}`; + } + return currentPath; +} diff --git a/src/utils/codex-cli-resolver.ts b/src/utils/codex-cli-resolver.ts index e85fb75cc..be17f939c 100644 --- a/src/utils/codex-cli-resolver.ts +++ b/src/utils/codex-cli-resolver.ts @@ -1,30 +1,23 @@ /** - * @fileoverview Resolve the Codex (OpenAI) CLI binary across common install paths. + * @fileoverview Codex CLI binary resolution. * - * Mirrors opencode-cli-resolver.ts pattern. Finds the `codex` binary - * and provides an augmented PATH string for tmux sessions. + * Thin wrapper over `cli-resolver.ts`'s generic walker, reading its search + * parameters from the CLI registry's stock catalog. See claude-cli-resolver.ts's + * file header for why this stays its own module rather than a bare re-export. * * @module utils/codex-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createDirResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; -/** Common directories where the Codex CLI binary may be installed */ -const CODEX_SEARCH_DIRS = [ - join(homedir(), '.codex', 'bin'), // Default install location - join(homedir(), '.local', 'bin'), // Alternative install location - '/usr/local/bin', // Homebrew / system - join(homedir(), '.bun', 'bin'), // Bun global - join(homedir(), '.npm-global', 'bin'), // npm global - join(homedir(), 'bin'), // User bin -]; +function entry() { + const e = getCli('codex'); + if (!e) throw new Error('codex is not registered in the CLI registry'); + return e; +} -/** Cached directory containing the codex binary (empty string = searched but not found) */ -let _codexDir: string | null = null; +const resolver = createDirResolver(entry().discovery.binaries, entry().discovery.searchDirs); /** * Finds the directory containing the `codex` binary. @@ -34,36 +27,12 @@ let _codexDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveCodexDir(): string | null { - if (_codexDir !== null) return _codexDir || null; - - // Try `which` first (respects current PATH) - try { - const result = execSync('which codex', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _codexDir = dirname(result); - return _codexDir; - } - } catch { - // Codex not in PATH, will check common locations - } - - for (const dir of CODEX_SEARCH_DIRS) { - if (existsSync(join(dir, 'codex'))) { - _codexDir = dir; - return _codexDir; - } - } - - _codexDir = ''; // mark as searched, not found - return null; + return resolver.resolveDir(); } /** - * Check if Codex CLI is available on the system. + * Check if the Codex CLI is available on the system. */ export function isCodexAvailable(): boolean { - return resolveCodexDir() !== null; + return resolver.isAvailable(); } diff --git a/src/utils/gemini-cli-resolver.ts b/src/utils/gemini-cli-resolver.ts index 6f0b50994..aef8ceeee 100644 --- a/src/utils/gemini-cli-resolver.ts +++ b/src/utils/gemini-cli-resolver.ts @@ -1,67 +1,38 @@ /** - * @fileoverview Resolve the Gemini CLI binary across common install paths. + * @fileoverview Gemini CLI binary resolution. * - * Mirrors codex-cli-resolver.ts and opencode-cli-resolver.ts. Finds the - * `gemini` binary and provides an augmented PATH directory for tmux sessions. + * Thin wrapper over `cli-resolver.ts`'s generic walker, reading its search + * parameters from the CLI registry's stock catalog. See claude-cli-resolver.ts's + * file header for why this stays its own module rather than a bare re-export. * * @module utils/gemini-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createDirResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; -/** Common directories where the Gemini CLI binary may be installed */ -const GEMINI_SEARCH_DIRS = [ - join(homedir(), '.gemini', 'bin'), - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), '.bun', 'bin'), - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +function entry() { + const e = getCli('gemini'); + if (!e) throw new Error('gemini is not registered in the CLI registry'); + return e; +} -/** Cached directory containing the gemini binary (empty string = searched but not found) */ -let _geminiDir: string | null = null; +const resolver = createDirResolver(entry().discovery.binaries, entry().discovery.searchDirs); /** * Finds the directory containing the `gemini` binary. * Checks `which gemini` first, then falls back to common install locations. + * Result is cached for subsequent calls. * * @returns Directory path, or null if not found */ export function resolveGeminiDir(): string | null { - if (_geminiDir !== null) return _geminiDir || null; - - try { - const result = execSync('which gemini', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _geminiDir = dirname(result); - return _geminiDir; - } - } catch { - // Gemini not in PATH, will check common locations - } - - for (const dir of GEMINI_SEARCH_DIRS) { - if (existsSync(join(dir, 'gemini'))) { - _geminiDir = dir; - return _geminiDir; - } - } - - _geminiDir = ''; - return null; + return resolver.resolveDir(); } /** - * Check if Gemini CLI is available on the system. + * Check if the Gemini CLI is available on the system. */ export function isGeminiAvailable(): boolean { - return resolveGeminiDir() !== null; + return resolver.isAvailable(); } diff --git a/src/utils/opencode-cli-resolver.ts b/src/utils/opencode-cli-resolver.ts index b144699ee..a79eff96d 100644 --- a/src/utils/opencode-cli-resolver.ts +++ b/src/utils/opencode-cli-resolver.ts @@ -1,31 +1,23 @@ /** - * @fileoverview Resolve the OpenCode CLI binary across common install paths. + * @fileoverview OpenCode CLI binary resolution. * - * Mirrors claude-cli-resolver.ts pattern. Finds the `opencode` binary - * and provides an augmented PATH string for tmux sessions. + * Thin wrapper over `cli-resolver.ts`'s generic walker, reading its search + * parameters from the CLI registry's stock catalog. See claude-cli-resolver.ts's + * file header for why this stays its own module rather than a bare re-export. * * @module utils/opencode-cli-resolver */ -import { execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { createDirResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; -/** Common directories where the OpenCode CLI binary may be installed */ -const OPENCODE_SEARCH_DIRS = [ - join(homedir(), '.opencode', 'bin'), // Default install location - join(homedir(), '.local', 'bin'), // Alternative install location - '/usr/local/bin', // Homebrew / system - join(homedir(), 'go', 'bin'), // Go install - join(homedir(), '.bun', 'bin'), // Bun global - join(homedir(), '.npm-global', 'bin'), // npm global - join(homedir(), 'bin'), // User bin -]; +function entry() { + const e = getCli('opencode'); + if (!e) throw new Error('opencode is not registered in the CLI registry'); + return e; +} -/** Cached directory containing the opencode binary (empty string = searched but not found) */ -let _openCodeDir: string | null = null; +const resolver = createDirResolver(entry().discovery.binaries, entry().discovery.searchDirs); /** * Finds the directory containing the `opencode` binary. @@ -35,37 +27,12 @@ let _openCodeDir: string | null = null; * @returns Directory path, or null if not found */ export function resolveOpenCodeDir(): string | null { - if (_openCodeDir !== null) return _openCodeDir || null; - - // Try `which` first (respects current PATH) - try { - const result = execSync('which opencode', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - _openCodeDir = dirname(result); - return _openCodeDir; - } - } catch { - // OpenCode not in PATH, will check common locations - } - - // Fallback: check common installation directories - for (const dir of OPENCODE_SEARCH_DIRS) { - if (existsSync(join(dir, 'opencode'))) { - _openCodeDir = dir; - return _openCodeDir; - } - } - - _openCodeDir = ''; // mark as searched, not found - return null; + return resolver.resolveDir(); } /** * Check if OpenCode CLI is available on the system. */ export function isOpenCodeAvailable(): boolean { - return resolveOpenCodeDir() !== null; + return resolver.isAvailable(); } diff --git a/src/utils/pi-cli-resolver.ts b/src/utils/pi-cli-resolver.ts index 358fe835c..d0cf93989 100644 --- a/src/utils/pi-cli-resolver.ts +++ b/src/utils/pi-cli-resolver.ts @@ -1,138 +1,62 @@ /** * @fileoverview Resolve the Pi CLI (`pi`) binary across common install paths. * - * Mirrors antigravity-cli-resolver.ts, with one addition the other external-CLI - * resolvers do not need: `pi` is a SHORT, GENERIC name (Raspberry Pi tooling, - * personal scripts, `$PATH` accidents), so a `which pi` hit is not by itself - * evidence that the coding agent is installed. Every candidate is therefore - * sanity-probed with `pi --version` and required to print a semver-shaped - * string; a binary that fails the probe is treated as absent and the rejected - * path is logged so a misresolution is diagnosable. - * - * Pi ships as the npm package `@earendil-works/pi-coding-agent`, so the search - * dirs are the usual global-bin locations (npm/bun/manual installs). + * `pi` is a SHORT, GENERIC name (Raspberry Pi tooling, personal scripts, `$PATH` + * accidents), so a `which pi` hit is not by itself evidence that the coding agent is + * installed. Every candidate is therefore sanity-probed with `pi --version` and + * required to print a semver-shaped string; a binary that fails the probe is treated + * as absent and the rejected path is logged so a misresolution is diagnosable. See + * `createVersionGatedResolver()` in cli-resolver.ts, which this is now a thin wrapper + * over — the walking logic is shared with the pattern's home, but this stays its own + * module for the same reason as the sibling resolvers (see claude-cli-resolver.ts). * * @module utils/pi-cli-resolver */ -import { execFileSync, execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; - -/** Common directories where the Pi CLI binary may be installed */ -const PI_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), '.bun', 'bin'), - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +import { createVersionGatedResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; /** * A real `pi --version` prints a semver-shaped string (e.g. `0.84.1`). * - * Exported and SHARED with the `pi` entry in `config/dependency-registry.ts`, so + * Exported and SHARED with the `pi` entry in the CLI registry's stock catalog, so * `codeman doctor` and the run mode cannot disagree about what counts as an installed - * pi: two copies of this rule would let the Dependencies panel report "Pi CLI ✓" on a - * box where `resolvePiDir()` rejects the same binary and Run Pi stays hidden. - * - * Shape is dictated by the doctor's `extractVersion()`, which returns the first CAPTURE - * GROUP and scans the whole output: hence a capturing group, and a leading boundary - * instead of `^` so `pi 0.84.1` matches while `v0.84.1` (some other program) does not. - * No `g` flag, so there is no shared `lastIndex` to reset. + * pi. Shape is dictated by the doctor's `extractVersion()`, which returns the first + * CAPTURE GROUP and scans the whole output: hence a capturing group, and a leading + * boundary instead of `^` so `pi 0.84.1` matches while `v0.84.1` (some other program) + * does not. No `g` flag, so there is no shared `lastIndex` to reset. */ export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/; -/** Cached directory containing the pi binary (empty string = searched but not found) */ -let _piDir: string | null = null; -/** Cached version string reported by the resolved binary (empty string = probed, unusable) */ -let _piVersion: string | null = null; - -/** - * Run `pi --version` on a candidate path and return the trimmed version when it - * looks like the coding agent. Returns null for anything else — a missing - * binary, a non-zero exit, a hang (timeout), or output that is not semver-shaped - * (which is how an unrelated `pi` on PATH gets rejected). - * - * Never runs under vitest: the suites must stay hermetic and must not depend on - * whether the dev box happens to have pi installed. - */ -function probePiVersion(binPath: string): string | null { - if (process.env.VITEST) return null; - try { - const out = execFileSync(binPath, ['--version'], { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - // Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too. - const candidate = PI_VERSION_REGEX.exec(out)?.[1]; - if (candidate) return candidate; - console.warn(`[PiResolver] Ignoring ${binPath}: "pi --version" printed ${JSON.stringify(out.slice(0, 80))}`); - } catch (err) { - console.warn(`[PiResolver] Ignoring ${binPath}: "pi --version" failed (${(err as Error).message})`); - } - return null; +function entry() { + const e = getCli('pi'); + if (!e) throw new Error('pi is not registered in the CLI registry'); + return e; } +const resolver = createVersionGatedResolver( + entry().discovery.binaries, + entry().discovery.searchDirs, + entry().discovery.version ?? { arg: '--version', regex: PI_VERSION_REGEX.source }, + 'PiResolver' +); + /** * Finds the directory containing a verified `pi` binary. * Checks `which pi` first, then falls back to common install locations. Every - * candidate must pass the `pi --version` sanity probe (§2.6 of the integration - * plan) before it is accepted. + * candidate must pass the `pi --version` sanity probe before it is accepted. * * @returns Directory path, or null if not found */ export function resolvePiDir(): string | null { - if (_piDir !== null) return _piDir || null; - - const accept = (binPath: string): string | null => { - // Under vitest the probe never runs, so existence alone decides (keeps the - // suites hermetic and matches how the sibling resolvers behave there). - if (process.env.VITEST) { - _piDir = dirname(binPath); - _piVersion = ''; - return _piDir; - } - const version = probePiVersion(binPath); - if (!version) return null; - _piDir = dirname(binPath); - _piVersion = version; - return _piDir; - }; - - try { - const result = execSync('which pi', { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - }).trim(); - if (result && existsSync(result)) { - const dir = accept(result); - if (dir) return dir; - } - } catch { - // pi not in PATH, will check common locations - } - - for (const dir of PI_SEARCH_DIRS) { - const binPath = join(dir, 'pi'); - if (!existsSync(binPath)) continue; - const accepted = accept(binPath); - if (accepted) return accepted; - } - - _piDir = ''; - _piVersion = ''; - return null; + return resolver.resolveDir(); } /** * Check if the Pi CLI is available on the system. */ export function isPiAvailable(): boolean { - return resolvePiDir() !== null; + return resolver.isAvailable(); } /** @@ -141,6 +65,5 @@ export function isPiAvailable(): boolean { * `GET /api/pi/status` so a misresolution is diagnosable from the UI. */ export function getPiCliVersion(): string | null { - resolvePiDir(); - return _piVersion || null; + return resolver.getVersion(); } diff --git a/test/dependency-checker.test.ts b/test/dependency-checker.test.ts index 13b7f0b53..2fbc0b1df 100644 --- a/test/dependency-checker.test.ts +++ b/test/dependency-checker.test.ts @@ -34,14 +34,18 @@ describe('DEPENDENCY_REGISTRY', () => { // `pi` is a short generic name, so pi-cli-resolver.ts refuses a binary that does not // print semver. If the doctor did not apply the identical rule it would report // "Pi CLI ✓" on a box where Run Pi stays hidden, which reads as a broken mode - // rather than a missing install. One regex, shared, is what keeps them agreeing. + // rather than a missing install. Both sides now compile their regex from the SAME + // declared string in the CLI registry's stock catalog (config/cli-registry/stock.ts), + // so this compares by pattern (`.source`) rather than object identity — the registry + // and pi-cli-resolver.ts's own PI_VERSION_REGEX are separately-compiled RegExp + // instances of the identical source string, not the same object. const pi = DEPENDENCY_REGISTRY.find((t) => t.id === 'pi'); expect(pi).toBeDefined(); const spec = pi!.resolvers.find((r) => r.resolver.kind === 'path'); expect(spec).toBeDefined(); const resolver = spec!.resolver as { versionRegex?: RegExp; requireVersionMatch?: boolean }; expect(resolver.requireVersionMatch).toBe(true); - expect(resolver.versionRegex).toBe(PI_VERSION_REGEX); + expect(resolver.versionRegex?.source).toBe(PI_VERSION_REGEX.source); }); it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => { diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 3d8484330..782d8986f 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -9,6 +9,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { agentImageBuildArgs, + binaryForDockerProbe, buildDockerBaseArgs, buildDockerCreateArgs, buildSeamlessClaudeConfig, @@ -462,4 +463,19 @@ describe('daemon probes (no-op under VITEST)', () => { await probeDockerCliVersion({ engine: 'docker', containerName: 'codeman-case-x' }, 'claude') ).toBeUndefined(); }); + + it('binaryForDockerProbe resolves the REGISTERED binary, not the mode id', () => { + // The regression this pins: probeDockerCliVersion used to probe `mode` itself as the + // binary name, which is correct for claude/opencode/codex/gemini/pi (mode === binary) + // but WRONG for antigravity, whose binary is `agy`. A container has no `antigravity` + // executable, so the old code silently probed a nonexistent binary and always got + // undefined back — never actually version-checking Antigravity docker sessions. + expect(binaryForDockerProbe('antigravity')).toBe('agy'); + expect(binaryForDockerProbe('claude')).toBe('claude'); + expect(binaryForDockerProbe('codex')).toBe('codex'); + expect(binaryForDockerProbe('gemini')).toBe('gemini'); + expect(binaryForDockerProbe('opencode')).toBe('opencode'); + expect(binaryForDockerProbe('pi')).toBe('pi'); + expect(binaryForDockerProbe('shell')).toBeUndefined(); + }); }); From 5fd75d8c601c348a3b5ce7952ed7e8fa9130920f Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:57:30 +0800 Subject: [PATCH 03/15] refactor(session): back the mode capability predicates with the registry (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points session.ts's isExternalCliMode(), getModeLabel(), isAltScreenStripMode() and the direct-PTY-fallback tmux refusal, plus session-wait-registry.ts's hooksAvailableForMode(), at the CLI registry instead of hard-coded id lists. Signatures are unchanged, so all ~123 call sites elsewhere in the codebase need no change. Adds a new `capabilities.external` field to the registry (types.ts, schema.ts, stock.ts) rather than deriving isExternalCliMode from another capability: the existing doc comment on CliCapabilities already calls out that `hooks`, `transcript` and `altScreen` must stay independent, since a shell session has no hooks but is not an "external CLI" either, and collapsing that distinction is a real bug that shipped before (`!isExternalCliMode()` wrongly accepting `until=stop` on a shell session and hanging for the full timeout). `external` joins that set as its own field for the same reason. The five hand-written " sessions require tmux" throws collapse into one check against `capabilities.requiresMux`, with the message built from the registry's own label. New test/cli-capability-predicates.test.ts pins all three predicates against the stock catalog and specifically reproduces the shell-vs-external case that caused the original bug, plus an explicit assertion that no two of the three predicates are equivalent across the whole catalog — so a future change that tries to derive one from another fails a test immediately instead of shipping a silent behavioural change. All directly-affected and broadly-related tests pass unchanged; full suite matches the pre-existing baseline with 10 new passing tests and zero regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/config/cli-registry/schema.ts | 1 + src/config/cli-registry/stock.ts | 4 ++ src/config/cli-registry/types.ts | 7 +++ src/session.ts | 54 +++++++--------------- src/web/session-wait-registry.ts | 3 +- test/cli-capability-predicates.test.ts | 64 ++++++++++++++++++++++++++ test/cli-registry-load.test.ts | 1 + 7 files changed, 95 insertions(+), 39 deletions(-) create mode 100644 test/cli-capability-predicates.test.ts diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 5f8a89a8c..f4a1fcbf3 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -201,6 +201,7 @@ const echoSchema = z const capabilitiesSchema = z .object({ + external: z.boolean(), requiresMux: z.boolean(), hooks: z.boolean(), transcript: z.enum(['claude-jsonl', 'codex-rollout', 'none']), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index de68e65ba..f56b305a9 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -26,6 +26,7 @@ const NO_PRIVILEGED_PARAMS: CliEntry['capabilities']['privilegedParams'] = []; /** Shared skeleton for the "agent CLI, no unusual behaviour" case (pi's own shape). */ function agentDefaults(): Pick< CliEntry['capabilities'], + | 'external' | 'requiresMux' | 'hooks' | 'transcript' @@ -45,6 +46,7 @@ function agentDefaults(): Pick< | 'gates' > { return { + external: true, requiresMux: true, hooks: false, transcript: 'none', @@ -169,6 +171,7 @@ const CLAUDE: CliEntry = { allowedKeys: ['CLAUDE_CONFIG_DIR'], }, capabilities: { + external: false, requiresMux: false, hooks: true, transcript: 'claude-jsonl', @@ -223,6 +226,7 @@ const SHELL: CliEntry = { allowedKeys: [], }, capabilities: { + external: false, requiresMux: false, hooks: false, transcript: 'none', diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index d2314c832..f12be195f 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -168,6 +168,13 @@ export interface CliEnv { * Keeping them as separate fields makes that invariant structural rather than commented. */ export interface CliCapabilities { + /** + * Non-Claude run mode that uses its own TUI and output format (`isExternalCliMode`): + * no Claude transcript, no hooks, no Claude-format token/BashTool parsing. An explicit + * field rather than derived from `hooks`/`kind`, precisely because it must stay + * independent — see this interface's own doc comment. + */ + external: boolean; /** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */ requiresMux: boolean; /** Emits Codeman hook events, so `stop`/`blocked` wait signals can ever fire. */ diff --git a/src/session.ts b/src/session.ts index 42dcac62d..9b186dea5 100644 --- a/src/session.ts +++ b/src/session.ts @@ -56,6 +56,7 @@ import { } from './types.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; +import { getCli } from './config/cli-registry/registry.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -169,28 +170,18 @@ const CTRL_L_PATTERN = /\x0c/g; /** Pattern to split by newlines (CR or LF) */ const NEWLINE_SPLIT_PATTERN = /\r?\n/; -/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */ +/** + * True for external-CLI run modes (non-Claude) that use their own TUI and output format. + * Backed by the registry's `capabilities.external` flag rather than an id list — see + * `CliCapabilities`'s own doc comment for why `external`/`hooks`/`transcript` are kept as + * three INDEPENDENT fields instead of deriving one from another. + */ export function isExternalCliMode(mode: SessionMode): boolean { - return mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi'; + return getCli(mode)?.capabilities.external ?? true; } function getModeLabel(mode: SessionMode): string { - switch (mode) { - case 'opencode': - return 'OpenCode'; - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'antigravity': - return 'Antigravity'; - case 'pi': - return 'Pi'; - case 'shell': - return 'Shell'; - case 'claude': - return 'Claude'; - } + return getCli(mode)?.label ?? mode; } /** @@ -218,7 +209,7 @@ function getModeLabel(mode: SessionMode): string { * vim inside a tmux `shell` session. */ export function isAltScreenStripMode(mode: SessionMode): boolean { - return mode === 'codex' || mode === 'claude' || mode === 'gemini'; + return getCli(mode)?.capabilities.altScreen === 'strip-full'; } /** @@ -1820,25 +1811,12 @@ export class Session extends EventEmitter { // Fallback to direct PTY if mux is not used if (!this.ptyProcess) { - // OpenCode sessions require tmux for env var injection (API keys via setenv) - if (this.mode === 'opencode') { - throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.'); - } - // Codex sessions require tmux for OPENAI_API_KEY injection via setenv - if (this.mode === 'codex') { - throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.'); - } - // Gemini sessions require tmux for Gemini/Google auth env injection via setenv - if (this.mode === 'gemini') { - throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.'); - } - // Antigravity sessions require tmux for env override injection via setenv - if (this.mode === 'antigravity') { - throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.'); - } - // Pi sessions require tmux for env override injection via setenv - if (this.mode === 'pi') { - throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.'); + // A CLI whose secrets ride tmux setenv (API keys, auth env) has no direct-PTY + // equivalent — there is nowhere else to inject them without putting a secret on + // the spawn command line. `capabilities.requiresMux` names that set; it used to + // be five separate `this.mode === ''` checks, one per external CLI. + if (getCli(this.mode)?.capabilities.requiresMux) { + throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`); } try { // Pass --session-id to use the SAME ID as the Codeman session diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index 6675f2e88..5186e88ec 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -58,6 +58,7 @@ */ import { stripAnsi } from '../utils/index.js'; +import { getCli } from '../config/cli-registry/registry.js'; import { MAX_WAITERS_PER_SESSION, MAX_WAITERS_PER_OWNER, @@ -182,7 +183,7 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked']; * infinite-wait-dressed-as-a-timeout this guard exists to prevent. */ export function hooksAvailableForMode(mode: SessionMode): boolean { - return mode === 'claude'; + return getCli(mode)?.capabilities.hooks ?? false; } /** Outcome of resolving a caller-supplied wait target against a session's mode. */ diff --git a/test/cli-capability-predicates.test.ts b/test/cli-capability-predicates.test.ts new file mode 100644 index 000000000..5f892bcf0 --- /dev/null +++ b/test/cli-capability-predicates.test.ts @@ -0,0 +1,64 @@ +/** + * @fileoverview Pins the three deliberately-INDEPENDENT capability predicates + * (`isExternalCliMode`, `isAltScreenStripMode`, `hooksAvailableForMode`) against the stock + * CLI registry, and reproduces the exact bug that made them independent in the first place: + * `shell` has no hooks but is NOT an "external CLI", so `!isExternalCliMode()` used to wrongly + * accept `until=stop` on a shell session and hang for the caller's whole timeout (a plain bash + * PTY with no Claude Code and no hooks installed never fires `stop`). + * + * If a future change collapses any of these three into a derivation of another, the "shell" + * row below is what catches it: shell is external=false, hooks=false, altScreen='preserve' — + * a combination none of the other six stock CLIs share, so no single-field shortcut can + * reproduce all three of shell's answers at once. + * + * Port: N/A (pure functions, no server). + */ + +import { describe, expect, it } from 'vitest'; +import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; +import { hooksAvailableForMode } from '../src/web/session-wait-registry.js'; +import type { SessionMode } from '../src/types/session.js'; + +describe('CLI capability predicates stay independent', () => { + it.each<{ mode: SessionMode; external: boolean; altScreenStrip: boolean; hooks: boolean }>([ + { mode: 'claude', external: false, altScreenStrip: true, hooks: true }, + { mode: 'shell', external: false, altScreenStrip: false, hooks: false }, + { mode: 'opencode', external: true, altScreenStrip: false, hooks: false }, + { mode: 'codex', external: true, altScreenStrip: true, hooks: false }, + { mode: 'gemini', external: true, altScreenStrip: true, hooks: false }, + { mode: 'antigravity', external: true, altScreenStrip: false, hooks: false }, + { mode: 'pi', external: true, altScreenStrip: false, hooks: false }, + ])( + '$mode: external=$external altScreenStrip=$altScreenStrip hooks=$hooks', + ({ mode, external, altScreenStrip, hooks }) => { + expect(isExternalCliMode(mode)).toBe(external); + expect(isAltScreenStripMode(mode)).toBe(altScreenStrip); + expect(hooksAvailableForMode(mode)).toBe(hooks); + } + ); + + it('the historic bug: shell is not external, so hook-only wait signals must still be rejected for it', () => { + // The bug was reasoning `!isExternalCliMode(mode)` implies "hooks work here". It does + // not — shell falls through both checks. Assert the two predicates disagree on shell, + // which is exactly the case a derived predicate could not represent. + expect(isExternalCliMode('shell')).toBe(false); + expect(hooksAvailableForMode('shell')).toBe(false); + }); + + it('no two of the three predicates are equivalent across the whole stock catalog', () => { + const modes: SessionMode[] = ['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']; + const external = modes.map(isExternalCliMode); + const altScreen = modes.map(isAltScreenStripMode); + const hooks = modes.map(hooksAvailableForMode); + + expect(external).not.toEqual(altScreen); + expect(external).not.toEqual(hooks); + expect(altScreen).not.toEqual(hooks); + }); + + it('an unregistered mode defaults conservatively: external (no claude-only assumptions), no hooks', () => { + const unknown = 'totally-unregistered-cli' as SessionMode; + expect(isExternalCliMode(unknown)).toBe(true); + expect(hooksAvailableForMode(unknown)).toBe(false); + }); +}); diff --git a/test/cli-registry-load.test.ts b/test/cli-registry-load.test.ts index ab326f954..5f7296160 100644 --- a/test/cli-registry-load.test.ts +++ b/test/cli-registry-load.test.ts @@ -61,6 +61,7 @@ describe('resolveRegistry (pure merge)', () => { allowedKeys: [], }, capabilities: { + external: true, requiresMux: true, hooks: false, transcript: 'none' as const, From 138d6ef7b669d3c07672dcae837da65291f99956 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:13:00 +0800 Subject: [PATCH 04/15] refactor(tmux-manager): render spawn commands through the CLI registry (phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces tmux-manager.ts's hand-written per-mode command construction with the CLI registry's argv engine, via a new bridge module (session-cli-registry-bridge.ts) that translates the legacy per-mode options/config objects into registry params: - buildSpawnCommand()'s six-way if-chain (claude's inline resume/model/ effort/name assembly, buildOpenCodeCommand, buildCodexCommand, buildGeminiCommand, buildAntigravityCommand, buildPiCommand) collapses to one call into buildSpawnCommandFromRegistry(); buildCodexCommand stays exported as a thin wrapper since test/tmux-manager.test.ts calls it directly. - buildPathExport() replaces its six-branch if-chain with resolveCliBinDir(mode) (new in utils/cli-resolver.ts): a memoized-by-id resolver built from the registry's discovery data, working for ANY registered CLI including a custom one, not just the six hand-named resolver modules. - appendResumeFlag() (the docker in-container resume-after-restart path) reads a new declarative `launch.resumeAppend` field per entry instead of a switch over mode. - buildEnvExports() reads per-CLI COLORTERM/NO_COLOR/ CODEX_INTERNAL_ORIGINATOR_OVERRIDE from `env.exports`/`env.unset` instead of inline mode checks. - The three near-identical tmux-setenv secret-injection functions (setOpenCodeEnvVars/setCodexEnvVars/setGeminiEnvVars) collapse into one setCliSensitiveEnvVars(keys) reading `env.tmuxSetenvKeys`; the three `_configure` methods collapse into one `_configureCliEnv()`. - The six " not found. Install with: " throws collapse into one missingCliMessage() reading the registry's label + per-platform install command. New registry fields to support this: `legacyConfigAliases` (maps a declared param name to the field name it arrives under on the wire — e.g. OpenCodeConfig.continueSession -> the `resumeId` param — so the bridge stays a generic reader of DATA rather than a per-mode `if` chain), `resumeAppend`, and the `codemanPrefixedSessionId` engine value (codex's unique per-pane rollout originator). New test/cli-registry-spawn-bridge-parity.test.ts proves the bridge renders byte-identical output to the original hand-written builders across the same permutation matrix as the phase-0 argv parity test, this time exercising the actual legacy-config wiring end to end. Caught and fixed during this work: the bridge initially passed `sessionName` to the `--name` flag WITHOUT the original `sanitizeCliSessionName()` allowlist pass — the double-quote escaping on that arg makes a hostile value inert but does not launder it the way the allowlist does, and test/name-flag-injection.test.ts (a pre-existing test this phase did not touch) caught the gap immediately. Fixed by routing the session name through the same sanitizer before it reaches the engine. Full suite back to the exact pre-existing baseline (55 failed files / 138 failed tests, unrelated to this work) plus 57 new passing tests and zero regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/config/cli-registry/schema.ts | 38 +- src/config/cli-registry/stock.ts | 15 +- src/config/cli-registry/types.ts | 26 +- src/session-cli-registry-bridge.ts | 155 +++++ src/tmux-manager.ts | 532 ++++-------------- src/utils/cli-resolver.ts | 36 ++ test/cli-registry-spawn-bridge-parity.test.ts | 155 +++++ 7 files changed, 537 insertions(+), 420 deletions(-) create mode 100644 src/session-cli-registry-bridge.ts create mode 100644 test/cli-registry-spawn-bridge-parity.test.ts diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index f4a1fcbf3..c9ff5a98e 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -61,7 +61,14 @@ const paramSpecSchema = z.union([ z .object({ type: z.literal('engine'), - source: z.enum(['sessionId', 'sessionName', 'muxName', 'effortLevel', 'effortSettingsJson']), + source: z.enum([ + 'sessionId', + 'sessionName', + 'muxName', + 'effortLevel', + 'effortSettingsJson', + 'codemanPrefixedSessionId', + ]), }) .strict(), ]); @@ -91,6 +98,13 @@ const launchSchema = z params: z.record(z.string(), paramSpecSchema), chain: z.enum(['first', 'fallback']).optional(), variants: z.array(variantSchema).min(1).max(4), + legacyConfigAliases: z.record(z.string(), z.string()).optional(), + resumeAppend: z + .union([ + z.object({ style: z.literal('flag'), flag: flagToken }).strict(), + z.object({ style: z.literal('positional'), token: shellToken }).strict(), + ]) + .optional(), }) .strict() .superRefine((launch, ctx) => { @@ -115,6 +129,17 @@ const launchSchema = z }); } } + if (launch.legacyConfigAliases) { + for (const paramName of Object.keys(launch.legacyConfigAliases)) { + if (!paramNames.has(paramName)) { + ctx.addIssue({ + code: 'custom', + message: `legacyConfigAliases key "${paramName}" is not a declared param`, + path: ['legacyConfigAliases', paramName], + }); + } + } + } }); const versionProbeSchema = z @@ -158,7 +183,16 @@ const envExportSchema = z value: z.union([ shellToken, z - .object({ engine: z.enum(['sessionId', 'sessionName', 'muxName', 'effortLevel', 'effortSettingsJson']) }) + .object({ + engine: z.enum([ + 'sessionId', + 'sessionName', + 'muxName', + 'effortLevel', + 'effortSettingsJson', + 'codemanPrefixedSessionId', + ]), + }) .strict(), ]), when: condSchema.optional(), diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index f56b305a9..ba054221f 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -161,6 +161,9 @@ const CLAUDE: CliEntry = { ], }, ], + // Claude has no `Config` object of its own — the bridge synthesizes one from its + // discrete top-level spawn fields, under their EXISTING field name `resumeSessionId`. + legacyConfigAliases: { resumeId: 'resumeSessionId' }, }, env: { exports: [], @@ -307,6 +310,7 @@ const OPENCODE: CliEntry = { ], }, ], + legacyConfigAliases: { resumeId: 'continueSession' }, }, env: { exports: [], @@ -361,7 +365,6 @@ const CODEX: CliEntry = { animations: { type: 'bool' }, model: { type: 'token', pattern: 'model' }, resumeId: { type: 'token', pattern: 'id' }, - sessionId: { type: 'engine', source: 'sessionId' }, }, variants: [ { @@ -377,11 +380,13 @@ const CODEX: CliEntry = { ], }, ], + legacyConfigAliases: { bypassApprovals: 'dangerouslyBypassApprovals', resumeId: 'resumeSessionId' }, + resumeAppend: { style: 'positional', token: 'resume' }, }, env: { exports: [ { name: 'COLORTERM', value: 'truecolor' }, - { name: 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE', value: { engine: 'sessionId' } }, + { name: 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE', value: { engine: 'codemanPrefixedSessionId' } }, ], unset: ['NO_COLOR'], tmuxSetenvKeys: ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'], @@ -453,6 +458,8 @@ const GEMINI: CliEntry = { ], }, ], + legacyConfigAliases: { resumeId: 'resumeSession' }, + resumeAppend: { style: 'flag', flag: '--resume' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], @@ -522,6 +529,8 @@ const ANTIGRAVITY: CliEntry = { ], }, ], + legacyConfigAliases: { resumeId: 'resumeConversationId' }, + resumeAppend: { style: 'flag', flag: '--conversation' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], @@ -600,6 +609,8 @@ const PI: CliEntry = { ], }, ], + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + resumeAppend: { style: 'flag', flag: '--session' }, }, env: { exports: [{ name: 'COLORTERM', value: 'truecolor' }], diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index f12be195f..50cde06bd 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -23,7 +23,14 @@ export type CliId = string & { readonly __cliId: unique symbol }; // --------------------------------------------------------------------------- /** Values the ENGINE supplies. Config may reference these by name but never author them. */ -export type EngineValue = 'sessionId' | 'sessionName' | 'muxName' | 'effortLevel' | 'effortSettingsJson'; +export type EngineValue = + | 'sessionId' + | 'sessionName' + | 'muxName' + | 'effortLevel' + | 'effortSettingsJson' + /** `sessionId` prefixed `codeman_` — codex's unique per-pane rollout originator. */ + | 'codemanPrefixedSessionId'; /** * A declared launch parameter. `token` params carry caller-supplied data and are therefore @@ -87,6 +94,23 @@ export interface CliLaunch { */ chain?: 'first' | 'fallback'; variants: CliVariant[]; + /** + * Maps a declared param name to the field name it arrives under on the legacy + * `POST /api/sessions` wire shape (`OpenCodeConfig.continueSession`, etc — the per-mode + * config objects predate this registry and stay on the wire for compatibility). A param + * with no entry here is looked up under its own name. This is what lets the spawn-command + * bridge (`session-cli-registry-bridge.ts`) stay generic: it reads the raw legacy config + * object through this DATA-declared alias table instead of a per-mode `if (mode === ...)`. + */ + legacyConfigAliases?: Record; + /** + * How to APPEND a resume id onto an already-built base command, for the docker in-container + * "tmux was re-created, resume the surviving transcript" path (`appendResumeFlag` in + * tmux-manager.ts) — a narrower, append-only sibling of the full `variants` shape above, + * which builds a whole command from scratch. Absent = this CLI has no resume flag to + * append (shell, opencode: opencode's docker resume goes through its own config object). + */ + resumeAppend?: { style: 'flag'; flag: string } | { style: 'positional'; token: string }; } // --------------------------------------------------------------------------- diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts new file mode 100644 index 000000000..dd82acbaa --- /dev/null +++ b/src/session-cli-registry-bridge.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview Bridges the legacy per-mode spawn options (`buildSpawnCommand`'s option bag + * in tmux-manager.ts, unchanged on the wire since before this registry existed) onto the CLI + * registry's generic argv engine (`renderLaunch`). + * + * This is the one place allowed to know the shape of the five legacy `Config` objects + * and claude's discrete top-level fields — a genuine API-compatibility concern (the public + * `POST /api/sessions` / `/api/quick-start` request shape is unchanged, see + * `docs/versioning-policy.md`), not a reintroduction of per-CLI command-building logic. The + * actual TRANSLATION from a legacy field name to a registry param name is DATA + * (`CliLaunch.legacyConfigAliases`, declared once per entry in `config/cli-registry/stock.ts`), + * so this file stays a generic reader of that data rather than a per-mode `if` chain. + * + * @module session-cli-registry-bridge + */ + +import type { CliEntry } from './config/cli-registry/types.js'; +import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js'; +import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js'; +import { compareVersions } from './utils/dependency-checker.js'; +import { getClaudeCliVersion } from './utils/claude-cli-resolver.js'; +import type { + AntigravityConfig, + ClaudeMode, + CodexConfig, + EffortLevel, + GeminiConfig, + OpenCodeConfig, + PiConfig, +} from './types/session.js'; + +export interface SpawnBridgeOptions { + mode: string; + sessionId: string; + model?: string; + claudeMode?: ClaudeMode; + allowedTools?: string; + openCodeConfig?: OpenCodeConfig; + codexConfig?: CodexConfig; + geminiConfig?: GeminiConfig; + antigravityConfig?: AntigravityConfig; + piConfig?: PiConfig; + resumeSessionId?: string; + effort?: EffortLevel; + sessionName?: string; + claudeCliVersion?: string | null; +} + +/** + * The legacy "raw config" object for each mode, as it already exists on `SpawnBridgeOptions`. + * Claude has no config object of its own (its fields were always discrete top-level options, + * predating every other mode's `Config` shape), so it is synthesized here from those + * discrete fields — the one place this bridge treats claude specially, and only to reproduce + * a pre-existing API shape difference, not to build its command. + */ +function legacyConfigFor(options: SpawnBridgeOptions): Record | undefined { + switch (options.mode) { + case 'claude': + return { + claudeMode: options.claudeMode, + allowedTools: options.allowedTools, + model: options.model, + resumeSessionId: options.resumeSessionId, + }; + case 'opencode': + return options.openCodeConfig as unknown as Record | undefined; + case 'codex': + return options.codexConfig as unknown as Record | undefined; + case 'gemini': + return options.geminiConfig as unknown as Record | undefined; + case 'antigravity': + return options.antigravityConfig as unknown as Record | undefined; + case 'pi': + return options.piConfig as unknown as Record | undefined; + default: + return undefined; + } +} + +/** + * Build `ParamValues` for every declared `token`/`bool`/`enum` param by reading it out of the + * legacy config object through `legacyConfigAliases` (falling back to the param's own name). + * `engine`-sourced params are skipped — those come from `EngineValues`, never legacy config. + */ +function buildParamsFromLegacyConfig(entry: CliEntry, rawConfig: Record | undefined): ParamValues { + const params: ParamValues = {}; + if (!rawConfig) return params; + const aliases = entry.launch.legacyConfigAliases ?? {}; + for (const [paramName, spec] of Object.entries(entry.launch.params)) { + if (spec.type === 'engine') continue; + const legacyKey = aliases[paramName] ?? paramName; + const value = rawConfig[legacyKey]; + if (value === undefined) continue; + if (typeof value === 'string' || typeof value === 'boolean') { + params[paramName] = value; + } + } + return params; +} + +/** + * Which `capabilities.gates` are currently satisfied. `resolveVersion` is called AT MOST + * ONCE, and only when the entry actually declares a gate — a `claude --version` (or any + * other CLI's) subprocess probe has no reason to run for an entry with none. + */ +function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null): Set { + const passed = new Set(); + const gateEntries = Object.entries(entry.capabilities.gates); + if (gateEntries.length === 0) return passed; + const cliVersion = resolveVersion(); + if (!cliVersion) return passed; // fail-closed: unknown version satisfies no gate + for (const [name, gate] of gateEntries) { + if (compareVersions(cliVersion, gate.minVersion) >= 0) passed.add(name); + } + return passed; +} + +/** + * Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a + * `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall + * back to the local login-shell resolution" — shell has no CLI to template. + */ +export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBridgeOptions): string | undefined { + if (entry.kind === 'shell' || entry.launch.variants.length === 0) return undefined; + + const params = buildParamsFromLegacyConfig(entry, legacyConfigFor(options)); + + const engineValues: EngineValues = { + sessionId: options.sessionId, + // Allowlist-sanitized (Unicode letters/digits + ` . _ : -`, 64 chars), matching + // buildNameCliArgs exactly — sanitizeCliSessionName is the injection guard for this + // value, not the `quote: 'double'` escaping on the --name arg (which only makes an + // UNSAFE value inert, it does not launder one into something meaningful). + sessionName: sanitizeCliSessionName(options.sessionName), + }; + // Mirrors buildEffortCliArgs exactly: ultracode carries a fixed settings blob, every other + // level rides a plain `--effort ` flag. Reusing the canonical builder here (rather + // than re-deriving the ultracode special-case) keeps the EFFORT_LEVELS allowlist and the + // settings-JSON shape single-sourced in session-cli-builder.ts. + const [effortFlag, effortValue] = buildEffortCliArgs(options.effort); + if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue; + else if (effortFlag === '--effort') engineValues.effortLevel = effortValue; + + // Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes + // the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means + // "known to be unresolvable" and must not probe. Only claude declares a version gate today + // — the probe itself only ever runs from resolveGatesPassed, and only when an entry + // actually has a gate, so this stays generic without spawning a stray `claude --version` + // for every other CLI's launch. + const gatesPassed = resolveGatesPassed(entry, () => + options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion() + ); + + return renderLaunch(entry.launch, params, engineValues, gatesPassed); +} diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 1c781a235..42f7dd797 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -50,7 +50,8 @@ import { type SessionDocker, type DockerCommandMode, } from './types.js'; -import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js'; +import { buildSpawnCommandFromRegistry } from './session-cli-registry-bridge.js'; +import { getCli } from './config/cli-registry/registry.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -70,19 +71,8 @@ import { type DockerMount, type DockerSeedCopy, } from './docker-hosts.js'; -import { - wrapWithNice, - SAFE_PATH_PATTERN, - findClaudeDir, - getClaudeCliVersion, - resolveOpenCodeDir, - resolveCodexDir, - resolveGeminiDir, - resolveAntigravityDir, - resolvePiDir, - resolveLocalShell, - loginShellArgs, -} from './utils/index.js'; +import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js'; +import { resolveCliBinDir } from './utils/cli-resolver.js'; import type { TerminalMultiplexer, MuxSession, @@ -629,203 +619,55 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri } /** - * Build the opencode CLI command with appropriate flags. - */ -function buildOpenCodeCommand(config?: OpenCodeConfig): string { - const parts = ['opencode']; - - // Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes) - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - // Continue existing session - if (config?.continueSession) { - const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined; - if (safeId) parts.push('--session', safeId); - if (safeId && config.forkSession) parts.push('--fork'); - } - - return parts.join(' '); -} - -/** - * Build the codex CLI command with appropriate flags. - * - * Codeman launches Codex's native TUI and handles replay/scrollback by - * stripping destructive terminal sequences before xterm.js sees them. + * Build the codex CLI command with appropriate flags. Thin wrapper over the CLI registry's + * argv engine (see `buildSpawnCommand` below); kept as its own exported function only + * because `test/tmux-manager.test.ts` calls it directly with a bare `CodexConfig`. */ export function buildCodexCommand(config?: CodexConfig): string { - const parts = ['codex']; - - if (config?.dangerouslyBypassApprovals) { - parts.push('--dangerously-bypass-approvals-and-sandbox'); - } - - if (config?.animations !== undefined) { - parts.push('--config', `tui.animations=${config.animations ? 'true' : 'false'}`); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeSessionId) { - const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeId) parts.push('resume', safeId); - } - - return parts.join(' '); -} - -/** - * Build the Gemini CLI command with appropriate flags. - * - * `--skip-trust` avoids a first-run workspace trust prompt inside Codeman. - * Approval mode defaults to `yolo` for parity with Codeman's Claude default - * of `--dangerously-skip-permissions`; users can override it later through - * Gemini config once Codeman exposes richer Gemini settings. - */ -function buildGeminiCommand(config?: GeminiConfig): string { - const parts = ['gemini', '--skip-trust']; - - const approvalMode = config?.approvalMode || 'yolo'; - if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) { - parts.push('--approval-mode', approvalMode); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeSession) { - const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined; - if (safeId) parts.push('--resume', safeId); - } - - return parts.join(' '); -} - -/** - * Build the Antigravity CLI (agy) command with appropriate flags. - * - * Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added - * when the config explicitly asks for it (the frontend sends it for parity with - * Codeman's Claude default; the multi-user clamp strips it for non-granted owners, - * and an ABSENT config stays at agy's own prompting default — safe like Codex). - */ -function buildAntigravityCommand(config?: AntigravityConfig): string { - const parts = ['agy']; - - if (config?.dangerouslySkipPermissions) { - parts.push('--dangerously-skip-permissions'); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeConversationId) { - const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined; - if (safeId) parts.push('--conversation', safeId); - } - - return parts.join(' '); + const codex = getCli('codex'); + if (!codex) return 'codex'; // registry corrupt/empty — degrade to the bare binary, never throw + return ( + buildSpawnCommandFromRegistry(codex, { + mode: 'codex', + sessionId: '', // codex's launch spec never references sessionId + codexConfig: config, + }) ?? 'codex' + ); } -/** Pi's `--thinking` levels. Runtime allowlist — defense in depth beyond the Zod enum. */ -const PI_THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']); - /** - * Build the Pi CLI (pi.dev) command with appropriate flags. - * - * Pi has NO permission prompts and no `--dangerously-skip-permissions` analog, so - * there is deliberately nothing bypass-shaped here. The privileged knob is the - * TRI-STATE `approveProjectTrust`: `true` -> `--approve` (trust repo-local `.pi/` - * config, which means loading and EXECUTING repository TypeScript and installing - * missing project packages), `false` -> `--no-approve` (force-deny, used by the - * multi-user clamp so the trust prompt never appears), absent -> pi's own - * `defaultProjectTrust`. - * - * `--api-key` is deliberately NEVER wired: it would put a provider secret on the - * spawn command line (visible in `ps` and tmux state), which is exactly what the - * socket-scoped `tmux setenv` discipline exists to prevent. - * - * Like the sibling builders, every user value is regex-allowlisted and silently - * DROPPED on failure — the result is interpolated into a `bash -c "..."` string. + * Build the "CLI not found" error message for a mode with no resolved binary directory, + * naming the registry's own label and per-platform install command — replaces six + * hand-written " CLI not found. Install with: " throws, one per external + * CLI. Returns null for a mode the registry doesn't know (never actually reached: a mode + * that failed schema validation never gets this far), so the caller degrades to a generic + * failure rather than throwing a message about "undefined". */ -function buildPiCommand(config?: PiConfig): string { - const parts = ['pi']; - - if (config?.approveProjectTrust === true) { - parts.push('--approve'); - } else if (config?.approveProjectTrust === false) { - parts.push('--no-approve'); - } - - if (config?.model) { - // `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` (`openai/gpt-4o`). - const safeModel = /^[a-zA-Z0-9._\-/:]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.provider) { - const safeProvider = /^[a-z0-9-]+$/.test(config.provider) ? config.provider : undefined; - if (safeProvider) parts.push('--provider', safeProvider); - } - - if (config?.thinking && PI_THINKING_LEVELS.has(config.thinking)) { - parts.push('--thinking', config.thinking); - } - - // --session and -c conflict; a valid explicit session id wins. - const safeSessionId = - config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeSessionId) { - parts.push('--session', safeSessionId); - } else if (config?.continueSession) { - parts.push('-c'); - } - - return parts.join(' '); +function missingCliMessage(mode: SessionMode): string | null { + const entry = getCli(mode); + if (!entry) return null; + const platform = process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'; + const command = + entry.discovery.install.command[platform] ?? + entry.discovery.install.command.linux ?? + Object.values(entry.discovery.install.command)[0]; + return command + ? `${entry.label} CLI not found. Install with: ${command}` + : `${entry.label} CLI not found. See its docs for install instructions.`; } /** - * Build the spawn command for any session mode. - * Shared by createSession() and respawnPane() to avoid duplication. - */ -/** - * Build the shell fragment carrying the effort level as a SOFT default - * (see buildEffortCliArgs — `--effort ` for regular levels incl. max, - * `--settings '{"ultracode":true}'` for ultracode; deliberately not the - * CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching). + * Build the spawn command for any session mode. Shared by createSession() and + * respawnPane() to avoid duplication. * - * Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside - * buildEffortCliArgs, so the single-quoted values contain no user-controlled characters. + * Every mode but `shell` renders through the CLI registry's argv engine + * (`buildSpawnCommandFromRegistry` in session-cli-registry-bridge.ts): the per-mode flag + * logic that used to live here (buildOpenCodeCommand, buildGeminiCommand, + * buildAntigravityCommand, buildPiCommand, and claude's own resume/model/effort/name + * assembly) is now DATA in config/cli-registry/stock.ts, proven byte-identical to the old + * hand-written builders by test/cli-registry-spawn-bridge-parity.test.ts. `shell` has no CLI + * to template — it resolves the actual login shell in code below, unchanged. */ -function buildEffortSettingsFlag(effort?: EffortLevel): string { - const [flag, value] = buildEffortCliArgs(effort); - return flag && value ? ` ${flag} '${value}'` : ''; -} - -/** - * Build the ` --name ""` shell fragment, or '' when it must be - * omitted. Version-gated FAIL-CLOSED in buildNameCliArgs (an older/unknown CLI - * aborts startup on an unknown flag, which would kill every claude spawn), and - * the value is allowlist-sanitized there, so it contains none of the characters - * that are special inside this double-quoted interpolation. The peer name is a - * soft default (in-session /rename still wins), which is why this rides the - * spawn command rather than any persisted config. - */ -function buildClaudeNameFlag(sessionName: string | undefined, cliVersion: string | null): string { - const [flag, value] = buildNameCliArgs(sessionName, cliVersion); - return flag && value ? ` ${flag} "${value}"` : ''; -} - export function buildSpawnCommand(options: { mode: SessionMode; sessionId: string; @@ -849,42 +691,10 @@ export function buildSpawnCommand(options: { */ claudeCliVersion?: string | null; }): string { - if (options.mode === 'claude') { - // Validate model to prevent command injection - const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined; - const modelFlag = safeModel ? ` --model "${safeModel}"` : ''; - const effortFlag = buildEffortSettingsFlag(options.effort); - const nameFlag = buildClaudeNameFlag( - options.sessionName, - options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion() - ); - // Use --resume to restore a previous conversation, otherwise --session-id for new sessions. - // Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.), - // fall back to a new session with --session-id so the pane doesn't die. - const safeResumeId = - options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined; - const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools); - if (safeResumeId) { - const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}${nameFlag}`; - const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`; - return `${resumeCmd} || ${fallbackCmd}`; - } - return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`; - } - if (options.mode === 'opencode') { - return buildOpenCodeCommand(options.openCodeConfig); - } - if (options.mode === 'codex') { - return buildCodexCommand(options.codexConfig); - } - if (options.mode === 'gemini') { - return buildGeminiCommand(options.geminiConfig); - } - if (options.mode === 'antigravity') { - return buildAntigravityCommand(options.antigravityConfig); - } - if (options.mode === 'pi') { - return buildPiCommand(options.piConfig); + const entry = getCli(options.mode); + if (entry) { + const rendered = buildSpawnCommandFromRegistry(entry, options); + if (rendered !== undefined) return rendered; } // #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"` // argument of the respawn-pane line, which execSync runs through `/bin/sh -c`, @@ -1092,18 +902,11 @@ const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/; */ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string { if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand; - switch (mode) { - case 'gemini': - return `${modeCommand} --resume ${resumeId}`; - case 'codex': - return `${modeCommand} resume ${resumeId}`; - case 'antigravity': - return `${modeCommand} --conversation ${resumeId}`; - case 'pi': - return `${modeCommand} --session ${resumeId}`; - default: - return modeCommand; // shell / opencode: no resume - } + const resumeAppend = getCli(mode)?.launch.resumeAppend; + if (!resumeAppend) return modeCommand; // claude/shell/opencode: no resume-append shape + return resumeAppend.style === 'flag' + ? `${modeCommand} ${resumeAppend.flag} ${resumeId}` + : `${modeCommand} ${resumeAppend.token} ${resumeId}`; } /** @@ -1398,12 +1201,15 @@ function buildRemoteSessionCommand(options: { } /** - * Set sensitive environment variables on a tmux session via setenv. - * These are inherited by panes but not visible in ps output or tmux history. + * Set a CLI's sensitive environment variables (API keys, auth env) on a tmux session via + * setenv, reading which var NAMES to forward from the registry's `env.tmuxSetenvKeys` — + * VALUES always come from the server's own `process.env`, never from the CLI or client, so + * a secret can never appear on the bash command line or in `ps`/tmux history. Replaces + * three near-identical hand-written functions (one each for opencode/codex/gemini); their + * key lists are now DATA in config/cli-registry/stock.ts. */ -function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; - for (const key of sensitiveVars) { +function setCliSensitiveEnvVars(tmuxCmd: string, muxName: string, keys: readonly string[]): void { + for (const key of keys) { const val = process.env[key]; if (val) { // Shell-escape: wrap in single quotes, escape any inner single quotes @@ -1422,65 +1228,14 @@ function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void { } /** - * Set sensitive environment variables for Codex on a tmux session via setenv. - * Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys. - */ -function setCodexEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME']; - for (const key of sensitiveVars) { - const val = process.env[key]; - if (val) { - const escaped = val.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical — key may not be needed */ - } - } - } -} - -/** - * Set sensitive environment variables for Gemini on a tmux session via setenv. - * Gemini Pro/Ultra users usually authenticate via cached Google login; these - * variables cover API-key and Vertex AI paths without putting secrets in ps. - */ -function setGeminiEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = [ - 'GEMINI_API_KEY', - 'GEMINI_MODEL', - 'GOOGLE_API_KEY', - 'GOOGLE_CLOUD_PROJECT', - 'GOOGLE_CLOUD_LOCATION', - 'GOOGLE_APPLICATION_CREDENTIALS', - 'GOOGLE_GENAI_USE_VERTEXAI', - ]; - for (const key of sensitiveVars) { - const val = process.env[key]; - if (val) { - const escaped = val.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical — key may not be needed */ - } - } - } -} - -/** - * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. - * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. + * Set a CLI's JSON config-content env var on a tmux session via setenv, under the NAME the + * registry declares (`env.configContentVar`). Uses tmux setenv to avoid shell metacharacter + * injection from user-supplied JSON. Only opencode declares one today, and the + * `autoAllowTools` merge logic below is genuinely opencode-shaped (its config-file schema), + * not a hard-coded id check — a future CLI with its own config-content var reuses this + * function by declaring `configContentVar` and passing its own config shape in. */ -function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void { +function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, varName: string, config?: OpenCodeConfig): void { if (!config) return; let jsonContent: string | undefined; @@ -1511,7 +1266,7 @@ function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: Ope if (jsonContent) { const escaped = jsonContent.replace(/'/g, "'\\''"); try { - execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { + execSync(`${tmuxCmd} setenv -t '${muxName}' ${varName} '${escaped}'`, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], @@ -1666,19 +1421,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * command line (visible in `ps`). This also sidesteps shell-metachar injection via keys. */ private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] { - const exports = [ + const entry = getCli(mode); + // Per-CLI exports/unsets are DATA (config/cli-registry/stock.ts's `env.exports` / + // `env.unset`) — e.g. codex's COLORTERM=truecolor + unset NO_COLOR + its unique + // CODEX_INTERNAL_ORIGINATOR_OVERRIDE (so the response-viewer can locate THIS pane's + // rollout: codex writes the value into session_meta.originator of every rollout it + // creates, and without a unique one two panes in the same cwd bleed into each other), + // claude's `unset CLAUDECODE` + `unset COLORTERM`. Order between exports/unsets carries + // no bash semantics (independent variable names), so this need not reproduce the exact + // historical interleaving. + const perCliUnsets = (entry?.env.unset ?? []).map((name) => `unset ${name}`); + const perCliExports = (entry?.env.exports ?? []).map((e) => { + const value = + typeof e.value === 'string' + ? e.value + : e.value.engine === 'sessionId' + ? sessionId + : e.value.engine === 'codemanPrefixedSessionId' + ? `codeman_${sessionId}` + : ''; + return `export ${e.name}=${value}`; + }); + return [ 'export LANG=en_US.UTF-8', 'export LC_ALL=en_US.UTF-8', - mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' - ? 'export COLORTERM=truecolor' - : 'unset COLORTERM', - ...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' ? ['unset NO_COLOR'] : []), - // Stamp each Codex pane with a unique originator so the response-viewer - // can locate THIS pane's rollout exactly — codex writes the value into - // session_meta.originator of every rollout it creates. Without it, - // rollouts are matched by cwd+mtime and two panes in the same directory - // bleed into each other. - ...(mode === 'codex' ? [`export CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_${sessionId}`] : []), + ...perCliUnsets, + ...perCliExports, 'export CODEMAN_MUX=1', `export CODEMAN_SESSION_ID=${sessionId}`, `export CODEMAN_MUX_NAME=${muxName}`, @@ -1691,9 +1459,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // execution time, so the COD-54 hook secret stays off the command line. `export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`, ]; - // Only unset CLAUDECODE for Claude sessions - if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE'); - return exports; } /** @@ -1743,58 +1508,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * In createSession(), a missing binary dir throws — the caller handles that separately. */ private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } { - if (mode === 'claude') { - const dir = findClaudeDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'opencode') { - const dir = resolveOpenCodeDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'codex') { - const dir = resolveCodexDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'gemini') { - const dir = resolveGeminiDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'antigravity') { - const dir = resolveAntigravityDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'pi') { - const dir = resolvePiDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - return { pathExport: '', dir: null }; + const dir = resolveCliBinDir(mode); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; } /** - * Configure OpenCode-specific environment on a tmux session. - * Sets sensitive API keys and config content via tmux setenv - * (not visible in ps output or tmux history, inherited by panes). + * Configure a CLI's environment on a tmux session: its sensitive API keys/auth env + * (`env.tmuxSetenvKeys`) and, if it declares one, its JSON config-content var + * (`env.configContentVar` — today only opencode). All via `tmux setenv`, so nothing + * appears in the bash command line or `ps`/tmux history, and inherited by every pane + * including `respawn-pane`. Replaces three per-CLI methods (`_configureOpenCode`, + * `_configureCodex`, `_configureGemini`) with one generic call over registry data. */ - private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { + private _configureCliEnv(mode: SessionMode, muxName: string, openCodeConfig?: OpenCodeConfig): void { + const entry = getCli(mode); + if (!entry) return; const tmuxCmd = this.tmux(); - setOpenCodeEnvVars(tmuxCmd, muxName); - setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig); - } - - /** - * Configure Codex-specific environment on a tmux session. - * Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't - * appear in the bash command line. - */ - private _configureCodex(muxName: string): void { - setCodexEnvVars(this.tmux(), muxName); - } - - /** - * Configure Gemini-specific environment on a tmux session. - */ - private _configureGemini(muxName: string): void { - setGeminiEnvVars(this.tmux(), muxName); + setCliSensitiveEnvVars(tmuxCmd, muxName, entry.env.tmuxSetenvKeys); + if (entry.env.configContentVar) { + setOpenCodeConfigContent(tmuxCmd, muxName, entry.env.configContentVar, openCodeConfig); + } } /** @@ -1855,27 +1588,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Resolve CLI binary directory based on mode const { pathExport, dir: cliDir } = this.buildPathExport(mode); - if (mode === 'claude' && !cliDir) { - throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'); - } - if (mode === 'opencode' && !cliDir) { - throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); - } - if (mode === 'codex' && !cliDir) { - throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex'); - } - if (mode === 'gemini' && !cliDir) { - throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli'); - } - if (mode === 'antigravity' && !cliDir) { - throw new Error( - 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash' - ); - } - if (mode === 'pi' && !cliDir) { - throw new Error( - 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' - ); + if (!cliDir && mode !== 'shell') { + const message = missingCliMessage(mode); + if (message) throw new Error(message); } const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); @@ -1939,17 +1654,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /* Non-critical */ } - // For OpenCode: set sensitive env vars and config via tmux setenv - // (not visible in ps output or tmux history, inherited by panes) - if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig); - } else if (mode === 'codex') { - this._configureCodex(muxName); - } - // For Gemini: set Gemini/Google auth env vars via tmux setenv - if (mode === 'gemini') { - this._configureGemini(muxName); - } + // Set sensitive env vars (and, for opencode, its config-content var) via tmux setenv — + // not visible in ps output or tmux history, inherited by panes. A no-op for a CLI that + // declares no tmuxSetenvKeys and no configContentVar (claude, shell, antigravity, pi + // today), so this runs unconditionally instead of a per-mode dispatch. + this._configureCliEnv(mode, muxName, openCodeConfig); // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. @@ -2170,16 +1879,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { : localFullCmd; try { - // For OpenCode: set sensitive env vars via tmux setenv before respawn - if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig); - } else if (mode === 'codex') { - this._configureCodex(muxName); - } - // For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn - if (mode === 'gemini') { - this._configureGemini(muxName); - } + // Set sensitive env vars via tmux setenv before respawn (see createSession() for + // why this runs unconditionally — a no-op for a CLI with nothing to configure). + this._configureCliEnv(mode, muxName, openCodeConfig); // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); diff --git a/src/utils/cli-resolver.ts b/src/utils/cli-resolver.ts index 39e2c74c3..e27c80571 100644 --- a/src/utils/cli-resolver.ts +++ b/src/utils/cli-resolver.ts @@ -23,6 +23,7 @@ import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; import { compileVersionRegex } from '../config/cli-registry/patterns.js'; import type { CliVersionProbe } from '../config/cli-registry/types.js'; +import { getCli } from '../config/cli-registry/registry.js'; /** Expand a leading `~` to the current homedir. Search dirs carry no other expansion. */ function expandHome(dir: string): string { @@ -279,3 +280,38 @@ export function augmentPath(dir: string | null, currentPath: string): string { } return currentPath; } + +/** + * Generic, memoized-by-id directory resolution for ANY registered CLI. Chooses + * `createVersionGatedResolver` when the entry's discovery declares + * `requireVersionMatch` (pi's shape) and `createDirResolver` otherwise (every other + * entry today) — so callers that need only a binary DIRECTORY (not a live version, + * which the six per-CLI resolver modules still own) can look one up for ANY id + * without a per-mode branch, including a custom CLI that isn't one of the six + * hand-named modules at all. + * + * Each id gets its own resolver instance the first time it is requested, cached for + * the process lifetime exactly like the six per-CLI modules already cache themselves + * — this does not create a second competing cache for claude/opencode/codex/gemini + * /antigravity/pi, since callers that already import those modules' own functions + * keep using them; this is for generic code that only has a `CliId` string in hand. + */ +const _dirResolvers = new Map(); + +export function resolveCliBinDir(id: string): string | null { + let resolver = _dirResolvers.get(id); + if (!resolver) { + const entry = getCli(id); + if (!entry || entry.discovery.binaries.length === 0) return null; // e.g. `shell` + resolver = entry.discovery.version?.requireVersionMatch + ? createVersionGatedResolver( + entry.discovery.binaries, + entry.discovery.searchDirs, + entry.discovery.version, + `CliResolver:${id}` + ) + : createDirResolver(entry.discovery.binaries, entry.discovery.searchDirs); + _dirResolvers.set(id, resolver); + } + return resolver.resolveDir(); +} diff --git a/test/cli-registry-spawn-bridge-parity.test.ts b/test/cli-registry-spawn-bridge-parity.test.ts new file mode 100644 index 000000000..76b27b428 --- /dev/null +++ b/test/cli-registry-spawn-bridge-parity.test.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview Proves `buildSpawnCommandFromRegistry()` — the bridge that will replace + * `buildSpawnCommand`'s per-mode if-chain in tmux-manager.ts — renders BYTE-IDENTICAL output + * to the legacy builder from the EXACT SAME options object, across the same permutation + * matrix as `test/cli-registry-argv-parity.test.ts`. That file proves the argv engine itself + * is correct; this one proves the legacy-config-to-params WIRING (legacyConfigAliases, the + * claude synthetic config, the effort/gate plumbing) is correct end to end. + * + * Port: N/A (pure functions, no server). + */ + +import { describe, expect, it } from 'vitest'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js'; +import { getCli } from '../src/config/cli-registry/registry.js'; + +function entryFor(id: string) { + const entry = getCli(id); + if (!entry) throw new Error(`no stock entry for ${id}`); + return entry; +} + +function bothRender(options: SpawnBridgeOptions): { legacy: string; bridged: string | undefined } { + return { + legacy: buildSpawnCommand(options as Parameters[0]), + bridged: buildSpawnCommandFromRegistry(entryFor(options.mode), options), + }; +} + +describe('buildSpawnCommandFromRegistry parity with buildSpawnCommand', () => { + it('shell returns undefined (caller falls back to local login-shell resolution)', () => { + expect(buildSpawnCommandFromRegistry(entryFor('shell'), { mode: 'shell', sessionId: 'x' })).toBeUndefined(); + }); + + describe('claude', () => { + it.each>([ + {}, + { claudeMode: 'auto' }, + { claudeMode: 'allowedTools', allowedTools: 'Bash(git:*), Read' }, + { model: 'opus' }, + { model: '[opus-4]' }, + { resumeSessionId: 'abcdef12-3456-7890-abcd-ef1234567890' }, + { effort: 'high' }, + { effort: 'ultracode' }, + { sessionName: 'w1-testcase', claudeCliVersion: '2.1.300' }, + { sessionName: 'w1-testcase', claudeCliVersion: '2.1.100' }, + { sessionName: 'w1-testcase', claudeCliVersion: null }, + { + claudeMode: 'auto', + model: 'sonnet', + resumeSessionId: '11111111-1111-1111-1111-111111111111', + effort: 'xhigh', + sessionName: 'w2-full', + claudeCliVersion: '2.1.300', + }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'claude', sessionId: 'session-uuid-fixture', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('opencode', () => { + it.each>([ + {}, + { openCodeConfig: { model: 'anthropic/claude-sonnet-4-5' } }, + { openCodeConfig: { continueSession: 'sess-123' } }, + { openCodeConfig: { continueSession: 'sess-123', forkSession: true } }, + { openCodeConfig: { model: 'bad model!' } }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'opencode', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('codex', () => { + it.each>([ + {}, + { codexConfig: { dangerouslyBypassApprovals: true } }, + { codexConfig: { animations: true } }, + { codexConfig: { animations: false } }, + { codexConfig: { model: 'gpt-5' } }, + { codexConfig: { resumeSessionId: 'abc-123' } }, + { + codexConfig: { + dangerouslyBypassApprovals: true, + animations: false, + model: 'o4-mini', + resumeSessionId: 'sess-1', + }, + }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'codex', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('gemini', () => { + it.each>([ + {}, + { geminiConfig: { approvalMode: 'plan' } }, + { geminiConfig: { model: 'gemini-2.5-pro' } }, + { geminiConfig: { resumeSession: 'latest' } }, + { geminiConfig: { approvalMode: 'auto_edit', model: 'gemini-2.5-flash', resumeSession: 'sess.1' } }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'gemini', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('antigravity', () => { + it.each>([ + {}, + { antigravityConfig: { dangerouslySkipPermissions: true } }, + { antigravityConfig: { model: 'gemini-3-pro' } }, + { antigravityConfig: { resumeConversationId: 'conv.1' } }, + { + antigravityConfig: { + dangerouslySkipPermissions: true, + model: 'gemini-3-flash', + resumeConversationId: 'conv.2', + }, + }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'antigravity', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('pi', () => { + it.each>([ + {}, + { piConfig: { approveProjectTrust: true } }, + { piConfig: { approveProjectTrust: false } }, + { piConfig: { model: 'sonnet:high' } }, + { piConfig: { model: 'openai/gpt-4o' } }, + { piConfig: { provider: 'anthropic' } }, + { piConfig: { thinking: 'xhigh' } }, + { piConfig: { continueSession: true } }, + { piConfig: { continueSession: true, resumeSessionId: 'sess.1' } }, + { piConfig: { resumeSessionId: 'sess.1' } }, + { + piConfig: { + approveProjectTrust: true, + model: 'sonnet:high', + provider: 'anthropic', + thinking: 'high', + continueSession: true, + }, + }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'pi', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); +}); From 0dfe275128bf8cdfed172562c01623265fbfe239 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:31:08 +0800 Subject: [PATCH 05/15] refactor(hosts): drive remote/docker default commands from the registry (phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the location-overlay code (remote SSH + docker cases) at the CLI registry instead of hard-coded per-mode maps: - remote-hosts.ts: defaultRemoteCommandForMode()'s six-entry Record literal collapses to a registry lookup on the new overlays.remote field (a bare binary-name default, or an explicit override like claude's "--dangerously-skip-permissions" suffix); REMOTE_CLI_BIN (a second, independently-hand-maintained id->binary map, notably including the antigravity/agy split already fixed once in docker-hosts.ts) is deleted entirely in favour of reading discovery.binaries[0] directly. - docker-hosts.ts: defaultDockerCommandForMode() mirrors the same change via overlays.docker. CRED_STORES (the codex/gemini/pi/opencode credential seeding policy) is now assembled from every registered entry's overlays.credStore, plus the one hardcoded exception that belongs to no single CLI: .config/gcloud, the general Google Cloud SDK store gemini's Vertex AI path (and other tools) may read regardless of run mode. Redesigned CliOverlays.remote/docker along the way: the phase-0 design had them reference a named `launch.variants` entry, but the actual remote/docker default is a much simpler "bare binary, or a fixed override string" shape than the full interactive-launch argv template (no session-id/model/effort/ name), so it is now `{ command?: string } | { disabled: true }` — a space-separated, metacharacter-free command line (same safe-word charset as every other literal in the schema, just space-joined), with `disabled` for the one case that genuinely has none (docker for `shell`). Every mode/tmux-manager/registry test green (281 tests), including the pre-existing claude --dangerously-skip-permissions and antigravity/agy binary-split assertions in test/remote-hosts.test.ts and test/antigravity-mode.test.ts, unchanged. Full suite back to the exact baseline (55 failed files / 138 failed tests, pre-existing) with zero regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/config/cli-registry/schema.ts | 27 ++++++---- src/config/cli-registry/stock.ts | 20 +++----- src/config/cli-registry/types.ts | 12 +++-- src/docker-hosts.ts | 82 +++++++++++++------------------ src/remote-hosts.ts | 71 +++++++++++--------------- test/cli-registry-load.test.ts | 2 +- 6 files changed, 96 insertions(+), 118 deletions(-) diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index c9ff5a98e..a0be0afb4 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -274,15 +274,30 @@ const credStoreSchema = z }) .strict(); +/** + * A remote/docker default pane command: space-separated bare words from the SAME safe + * charset as `shellToken` (no shell metacharacters), so `claude --dangerously-skip-permissions` + * is expressible while still excluding `;`, `|`, `$`, backticks and quotes — this is not an + * escape hatch into arbitrary shell text, it is one bare command plus bare flags. + */ +const commandLine = z + .string() + .min(1) + .max(200) + .regex( + /^[A-Za-z0-9._:@=+/,-]+( [A-Za-z0-9._:@=+/,-]+)*$/, + 'must be space-separated bare words with no shell metacharacters' + ); + const overlayTargetSchema = z.union([ - z.object({ variant: z.string().min(1).max(40) }).strict(), + z.object({ command: commandLine.optional() }).strict(), z.object({ disabled: z.literal(true) }).strict(), ]); const overlaysSchema = z .object({ - remote: overlayTargetSchema, - docker: overlayTargetSchema, + remote: overlayTargetSchema.optional(), + docker: overlayTargetSchema.optional(), credStore: credStoreSchema.optional(), }) .strict(); @@ -305,12 +320,6 @@ export const CliEntrySchema = z }) .strict() .superRefine((entry, ctx) => { - const variantIds = new Set(entry.launch.variants.map((v) => v.id)); - for (const target of [entry.overlays.remote, entry.overlays.docker]) { - if ('variant' in target && !variantIds.has(target.variant)) { - ctx.addIssue({ code: 'custom', message: `overlay references unknown launch variant "${target.variant}"` }); - } - } const gateNames = new Set(Object.keys(entry.capabilities.gates)); const walkConds = (cond: import('./types.js').Cond | undefined) => { if (!cond) return; diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index ba054221f..40cab2b0d 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -195,8 +195,11 @@ const CLAUDE: CliEntry = { gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } }, }, overlays: { - remote: { variant: 'new' }, - docker: { variant: 'new' }, + // Mirrors the local default so the remote/in-container agent runs non-interactively + // (no trust-folder/permission prompt that nothing on that side can answer). A per-host + // `commands.claude` override, or the docker multi-user clamp, stays the escape hatch. + remote: { command: 'claude --dangerously-skip-permissions' }, + docker: { command: 'claude --dangerously-skip-permissions' }, // Claude's docker/remote credential handling has its own dedicated code path // (claudeDockerPaneCommand, artifacts at docker-hosts.ts:537-575) — no generic credStore. }, @@ -250,7 +253,8 @@ const SHELL: CliEntry = { gates: {}, }, overlays: { - remote: { variant: 'shell' }, + // No `remote` entry: defaultRemoteCommandForMode special-cases kind==='shell' directly + // (an interactive login shell, no `-c ''` wrapping at all). docker: { disabled: true }, }, }; @@ -327,8 +331,6 @@ const OPENCODE: CliEntry = { echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, }, overlays: { - remote: { variant: 'default' }, - docker: { variant: 'default' }, credStore: { rel: '.config/opencode', seedWhole: true }, }, }; @@ -403,8 +405,6 @@ const CODEX: CliEntry = { maxFrameBytes: 32 * 1024, }, overlays: { - remote: { variant: 'default' }, - docker: { variant: 'default' }, credStore: { rel: '.codex', shareDirs: ['sessions'], @@ -483,8 +483,6 @@ const GEMINI: CliEntry = { echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, }, overlays: { - remote: { variant: 'default' }, - docker: { variant: 'default' }, credStore: { rel: '.gemini', seedWhole: true }, // also covers antigravity — see its own entry }, }; @@ -546,8 +544,6 @@ const ANTIGRAVITY: CliEntry = { echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, }, overlays: { - remote: { variant: 'default' }, - docker: { variant: 'default' }, // No credStore of its own: agy nests its whole state under ~/.gemini/antigravity-cli/, // which gemini's seedWhole entry already covers. }, @@ -629,8 +625,6 @@ const PI: CliEntry = { echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, }, overlays: { - remote: { variant: 'default' }, - docker: { variant: 'default' }, credStore: { rel: '.pi/agent', seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'], diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 50cde06bd..09670f7dd 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -257,9 +257,15 @@ export interface CliCredStore { } export interface CliOverlays { - /** Which launch variant to use over SSH, or that remote is unsupported. */ - remote: { variant: string } | { disabled: true }; - docker: { variant: string } | { disabled: true }; + /** + * The remote/docker DEFAULT pane command: just the CLI invocation (e.g. `claude + * --dangerously-skip-permissions`), independent of each location's own wrapping + * (remote: login-shell `-c`; docker: `exec`). Absent `command` = the bare + * `discovery.binaries[0]`. `disabled: true` = this location has no story for this CLI at + * all (docker for `shell`) — distinct from "no override", which still gets a default. + */ + remote?: { command?: string } | { disabled: true }; + docker?: { command?: string } | { disabled: true }; credStore?: CliCredStore; } diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 6b16ef853..4704f08d9 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -30,10 +30,10 @@ import { createHash } from 'node:crypto'; import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { dataPath } from './config/instance.js'; -import { getCli } from './config/cli-registry/registry.js'; +import { getCli, listClis } from './config/cli-registry/registry.js'; +import type { CliCredStore } from './config/cli-registry/types.js'; import type { DockerCase, - DockerCommandMode, DockerEngine, DockerHost, DockerNetworkMode, @@ -135,19 +135,22 @@ export function dockerContainerName(caseName: string): string { return `${CONTAINER_NAME_PREFIX}${caseName}`; } -/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */ +/** + * Default in-container pane command per CLI mode (mirror of defaultRemoteCommandForMode). + * Reads the registry's `overlays.docker` default and falls back to the bare + * `discovery.binaries[0]` when the entry declares no override — the docker analog of + * remote-hosts.ts's `defaultRemoteCommandForMode`, minus the login-shell `-c` wrapping + * (a container's `exec` already runs as the container user with its own PATH). + */ export function defaultDockerCommandForMode(mode: SessionMode): string { - const commands: Record = { - shell: 'exec bash -l', - // Mirror the LOCAL claude default so the in-container agent runs non-interactively. - claude: 'exec claude --dangerously-skip-permissions', - opencode: 'exec opencode', - codex: 'exec codex', - gemini: 'exec gemini', - antigravity: 'exec agy', - pi: 'exec pi', - }; - return commands[mode as DockerCommandMode] || commands.shell; + const entry = getCli(mode); + if (!entry || entry.kind === 'shell') return 'exec bash -l'; + + const overlay = entry.overlays.docker; + if (overlay && 'disabled' in overlay) return 'exec bash -l'; + + const command = overlay?.command ?? entry.discovery.binaries[0]; + return command ? `exec ${command}` : 'exec bash -l'; } /** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */ @@ -583,42 +586,23 @@ export function resolveDockerClaudeArtifacts( * seeded. The other three have no host-read/resume dependency and are fully * seed-copied (writable copy in the container, no write-back to the host). */ -interface CredStorePolicy { - /** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */ - rel: string; - /** Subdirs bind-mounted RW (shared: resume + host reads). */ - shareDirs?: string[]; - /** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */ - shareFiles?: string[]; - /** Files seeded (RO mount → cp) into the container's own copy. */ - seedFiles?: string[]; - /** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */ - seedWhole?: boolean; +/** + * Every registered CLI's credential-store policy (`overlays.credStore` in + * config/cli-registry/stock.ts — codex, gemini, pi, opencode today), plus the one entry + * that belongs to no single CLI: `.config/gcloud` is the general Google Cloud SDK + * credential store, which gemini's Vertex AI auth path and other tools may read + * regardless of run mode, so it is not owned by any one entry's `credStore` field. + * Antigravity needs no entry of its own: `agy` nests its whole state (auth + * `jetski_state.pbtxt`, `conversations/`, `knowledge/`) under `~/.gemini/antigravity-cli/`, + * which gemini's `seedWhole` entry already covers — there is no `~/.antigravity` dir. + */ +function collectCredStores(): CliCredStore[] { + const fromRegistry = listClis() + .map((entry) => entry.overlays.credStore) + .filter((store): store is CliCredStore => store !== undefined); + return [...fromRegistry, { rel: '.config/gcloud', seedWhole: true }]; } -const CRED_STORES: CredStorePolicy[] = [ - { rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] }, - // Also covers Antigravity: `agy` nests its whole state (auth `jetski_state.pbtxt`, - // `conversations/`, `knowledge/`) under `~/.gemini/antigravity-cli/`, so it needs no - // entry of its own. There is no `~/.antigravity` credential dir to add. - { rel: '.gemini', seedWhole: true }, - // Pi (pi.dev) keeps auth + config in `~/.pi/agent`, but that dir ALSO holds - // `sessions/`, `extensions/`, `skills/` and the installed package trees - // (`npm/`, `git/`) — easily gigabytes on an active host, so seedWhole would - // `cp -a` all of it into every container start. Seed only what pi needs to - // authenticate and behave consistently; `models.json` is in the list because it - // holds user-defined custom providers. Consequence to document: in-container pi - // sessions are invisible host-side, so `pi -c` inside a Docker case only sees - // that container's own history (unlike codex, whose `sessions/` is shared RW - // precisely because Codeman reads it host-side). - { - rel: '.pi/agent', - seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'], - }, - { rel: '.config/gcloud', seedWhole: true }, - { rel: '.config/opencode', seedWhole: true }, -]; - /** * Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old * whole-dir RW mounts that let each in-container CLI write its refreshed tokens + @@ -628,7 +612,7 @@ const CRED_STORES: CredStorePolicy[] = [ export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts { const mounts: DockerMount[] = []; const seedCopies: DockerSeedCopy[] = []; - for (const store of CRED_STORES) { + for (const store of collectCredStores()) { const hostBase = join(home, store.rel); if (!existsSync(hostBase)) continue; const containerBase = `${CONTAINER_HOME}/${store.rel}`; diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 681962ee1..71a1f6fa9 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -6,13 +6,13 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import type { RemoteCase, - RemoteCommandMode, RemoteHost, RemoteSessionInfo, RemoteSshOptions, SessionMode, SessionRemote, } from './types.js'; +import { getCli } from './config/cli-registry/registry.js'; const execAsync = promisify(exec); @@ -89,33 +89,30 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } +/** `exec $SHELL -i -l`, no `-c` — the remote user's actual login shell, interactive. */ +function remoteLoginShellOnly(): string { + // $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, + // so this launches their actual login shell (zsh, fish, etc.). -i -l so it sources rc + // files (~/.zshrc etc.), matching the local shell-mode launch. + return `exec ${REMOTE_LOGIN_SHELL} -i -l`; +} + export function defaultRemoteCommandForMode(mode: SessionMode): string { - // Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed - // under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by - // the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's - // remote-command execution is neither interactive nor login, so a bare `exec - // claude` sees only sshd's minimal default PATH and fails with "command not - // found" (exit 127) — confirmed via `tmux capture-pane` on the - // remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same - // fix already used for shell mode below, so PATH is fully resolved before the - // CLI name is looked up. - const commands: Record = { - // $SHELL, not a hardcoded bash: sshd sets it from the remote user's - // /etc/passwd entry, so this launches their actual login shell (zsh, - // fish, etc.). -i -l so it sources rc files (~/.zshrc etc.), matching - // the local shell-mode launch. - shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`, - // Mirror the LOCAL claude default so the remote agent runs non-interactively - // (no trust-folder/permission prompt that nothing on the remote answers). The - // per-host `commands.claude` override stays the escape hatch. - claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'), - opencode: remoteLoginShellCommand('opencode'), - codex: remoteLoginShellCommand('codex'), - gemini: remoteLoginShellCommand('gemini'), - antigravity: remoteLoginShellCommand('agy'), - pi: remoteLoginShellCommand('pi'), - }; - return commands[mode as RemoteCommandMode] || commands.shell; + const entry = getCli(mode); + if (!entry || entry.kind === 'shell') return remoteLoginShellOnly(); + + const overlay = entry.overlays.remote; + if (overlay && 'disabled' in overlay) return remoteLoginShellOnly(); + + // Agent CLIs are typically installed under per-user paths like ~/.local/bin or + // ~/.opencode/bin, added to PATH only by the remote user's interactive-login shell + // startup files (~/.zshrc etc.). ssh's remote-command execution is neither interactive + // nor login, so a bare `exec claude` sees only sshd's minimal default PATH and fails + // with "command not found" (exit 127) — confirmed via `tmux capture-pane` on the + // remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c` so PATH is fully + // resolved before the CLI name is looked up. + const command = overlay?.command ?? entry.discovery.binaries[0]; + return command ? remoteLoginShellCommand(command) : remoteLoginShellOnly(); } export function remoteSshTarget(host: Pick): string { @@ -257,20 +254,6 @@ export async function checkRemoteTmuxAvailable( } } -/** - * The CLI binary each session mode runs on the remote host. Antigravity's - * binary is `agy` (the mode name is not the command); shell has no CLI to - * probe, so it is absent. - */ -const REMOTE_CLI_BIN: Partial> = { - claude: 'claude', - opencode: 'opencode', - codex: 'codex', - gemini: 'gemini', - antigravity: 'agy', - pi: 'pi', -}; - /** * Build the SSH command that reads the remote CLI's version (`claude --version` * on the remote host). The version query is routed through @@ -279,13 +262,15 @@ const REMOTE_CLI_BIN: Partial> = { * interactive-login startup files run (see defaultRemoteCommandForMode); a bare * `claude --version` over ssh exits 127. Connection options come from the * shared `buildSshConnectionArgs`, so the probe reaches exactly the hosts the - * launch can reach. Returns null for modes with no CLI (shell). + * launch can reach. Returns null for modes with no CLI (shell) — the registry's own + * `discovery.binaries[0]` is the "CLI binary each mode runs" lookup (e.g. antigravity's + * mode is `antigravity` but its binary is `agy`), so there is nothing to duplicate here. */ export function buildRemoteCliVersionProbeCommand( host: Pick & RemoteSshOptions, mode: SessionMode ): string | null { - const bin = REMOTE_CLI_BIN[mode]; + const bin = getCli(mode)?.discovery.binaries[0]; if (!bin) return null; return [ ...buildSshConnectionArgs(host), diff --git a/test/cli-registry-load.test.ts b/test/cli-registry-load.test.ts index 5f7296160..00c2aac4f 100644 --- a/test/cli-registry-load.test.ts +++ b/test/cli-registry-load.test.ts @@ -81,7 +81,7 @@ describe('resolveRegistry (pure merge)', () => { privilegedParams: [], gates: {}, }, - overlays: { remote: { variant: 'default' }, docker: { variant: 'default' } }, + overlays: {}, }; const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { copilot: custom } }; const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); From e4b76c6882c4682b0500cee44ee995834d666c07 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:52:44 +0800 Subject: [PATCH 06/15] feat(api): expose the CLI registry over HTTP and de-duplicate route logic (phase 6) New public surface: - GET /api/clis - the full registry (secrets-free: CliEntry never carries a secret value, only env var names), each entry augmented with live available/path/version. For the frontend to render the run-mode menu, welcome buttons, labels and badges from data (phase 7). - GET /api/cli/:id/status - generic per-CLI status, working for ANY registered id including a future custom one. The six hand-written /api//status routes are kept as-is (never removed - an existing endpoint path stays stable per docs/versioning-policy.md) rather than becoming thin aliases, since route tests mock each one's resolver module independently and collapsing them would have required rewriting that mocking setup for no behavioural gain. - window.__codemanClis - server-rendered mirror of GET /api/clis for the initial page load, injected additively alongside the UNCHANGED window.__codemanCliAvailable (test/render-index-html.test.ts pins its exact shape with `toEqual`, so extending it in place would have broken a passing test for a fully-additive change). De-duplication: - session-routes.ts's two copies of the external-CLI availability + install -hint ladder (create and quick-start, five hand-written `if (mode === '')` blocks each) collapse into one checkExternalCliAvailable() reading the registry via isExternalCliMode() + resolveCliBinDir(). - missingCliMessage() (added in tmux-manager.ts during phase 4) moves to config/cli-registry/registry.ts so both tmux-manager.ts's spawn-time throw and session-routes.ts's pre-flight check read the exact same string instead of two copies. - schemas.ts's ALLOWED_ENV_PREFIXES/ALLOWED_ENV_KEYS are now composed from every registered CLI's own env.allowedPrefixes/allowedKeys instead of a hand-maintained array; BLOCKED_ENV_KEYS stays hardcoded by design (a floor no registry entry can widen). New utils/cli-resolver.ts export: resolveCliVersion(id), the version-aware sibling of resolveCliBinDir(id) for any resolver built with requireVersionMatch (pi today). New tests for both routes in test/routes/system-routes.test.ts (structure, augmentation, 404 on an unknown id, no-secrets check). Full suite at the exact pre-existing baseline (55 failed files / 138 failed tests) plus 5 new passing tests, zero regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/config/cli-registry/registry.ts | 20 ++++ src/tmux-manager.ts | 23 +---- src/utils/cli-resolver.ts | 18 ++++ src/web/routes/session-routes.ts | 136 +++++++--------------------- src/web/routes/system-routes.ts | 41 +++++++++ src/web/schemas.ts | 31 +++++-- src/web/server.ts | 19 ++++ test/routes/system-routes.test.ts | 79 ++++++++++++++++ 8 files changed, 233 insertions(+), 134 deletions(-) diff --git a/src/config/cli-registry/registry.ts b/src/config/cli-registry/registry.ts index d16433dbd..014edb934 100644 --- a/src/config/cli-registry/registry.ts +++ b/src/config/cli-registry/registry.ts @@ -214,3 +214,23 @@ export function getCli(id: string): CliEntry | undefined { export function cliIds(): string[] { return listClis().map((e) => e.id as string); } + +/** + * Build the "CLI not found" error message for a mode with no resolved binary directory, + * naming the registry's own label and per-platform install command. Shared by + * tmux-manager.ts's spawn-time throw and session-routes.ts's create-time pre-flight check + * (both used to hand-write this string once per external CLI, six throws and ten checks in + * total, all now reading the SAME data). Returns null for an id the registry doesn't know. + */ +export function missingCliMessage(id: string): string | null { + const entry = getCli(id); + if (!entry) return null; + const platform = process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'; + const command = + entry.discovery.install.command[platform] ?? + entry.discovery.install.command.linux ?? + Object.values(entry.discovery.install.command)[0]; + return command + ? `${entry.label} CLI not found. Install with: ${command}` + : `${entry.label} CLI not found. See its docs for install instructions.`; +} diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 42f7dd797..be6214cc6 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -51,7 +51,7 @@ import { type DockerCommandMode, } from './types.js'; import { buildSpawnCommandFromRegistry } from './session-cli-registry-bridge.js'; -import { getCli } from './config/cli-registry/registry.js'; +import { getCli, missingCliMessage } from './config/cli-registry/registry.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -635,27 +635,6 @@ export function buildCodexCommand(config?: CodexConfig): string { ); } -/** - * Build the "CLI not found" error message for a mode with no resolved binary directory, - * naming the registry's own label and per-platform install command — replaces six - * hand-written " CLI not found. Install with: " throws, one per external - * CLI. Returns null for a mode the registry doesn't know (never actually reached: a mode - * that failed schema validation never gets this far), so the caller degrades to a generic - * failure rather than throwing a message about "undefined". - */ -function missingCliMessage(mode: SessionMode): string | null { - const entry = getCli(mode); - if (!entry) return null; - const platform = process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'; - const command = - entry.discovery.install.command[platform] ?? - entry.discovery.install.command.linux ?? - Object.values(entry.discovery.install.command)[0]; - return command - ? `${entry.label} CLI not found. Install with: ${command}` - : `${entry.label} CLI not found. See its docs for install instructions.`; -} - /** * Build the spawn command for any session mode. Shared by createSession() and * respawnPane() to avoid duplication. diff --git a/src/utils/cli-resolver.ts b/src/utils/cli-resolver.ts index e27c80571..3cfddc541 100644 --- a/src/utils/cli-resolver.ts +++ b/src/utils/cli-resolver.ts @@ -315,3 +315,21 @@ export function resolveCliBinDir(id: string): string | null { } return resolver.resolveDir(); } + +/** + * Generic version accessor for the SAME memoized resolver `resolveCliBinDir` builds. Only + * returns a value for an entry whose resolver is version-aware (today: `requireVersionMatch` + * entries like pi) — claude's separate retry/backoff version getter stays on its own module + * (`getClaudeCliVersion`), since that behaviour is declared via `retryOnTransientFailure`, + * not `requireVersionMatch`, and is not (yet) built generically here. Returns null rather + * than probing blind for an entry with no version-aware resolver. + */ +function isVersionGated(resolver: DirResolver): resolver is VersionGatedResolver { + return 'getVersion' in resolver; +} + +export function resolveCliVersion(id: string): string | null { + resolveCliBinDir(id); // ensure the resolver for `id` has been created + const resolver = _dirResolvers.get(id); + return resolver && isVersionGated(resolver) ? resolver.getVersion() : null; +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 98ea4e7cf..85683233e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -23,8 +23,11 @@ import { type GeminiConfig, type AntigravityConfig, type PiConfig, + type SessionMode, } from '../../types.js'; -import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode, isExternalCliMode } from '../../session.js'; +import { resolveCliBinDir } from '../../utils/cli-resolver.js'; +import { missingCliMessage } from '../../config/cli-registry/registry.js'; import { SseEvent } from '../sse-events.js'; import { CreateSessionSchema, @@ -633,6 +636,23 @@ async function injectAgentSkill(casePath: string): Promise { // bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the // setting through the ConfigPort (tests stub it) and pass it as the second arg. +/** + * Pre-flight availability check for an external CLI mode, shared by the create and + * quick-start routes (each used to hand-write this as five near-identical `if (body.mode + * === '') { ... }` blocks). Returns an error response body when the mode is an external + * CLI (`isExternalCliMode` — opencode/codex/gemini/antigravity/pi today, or any future + * custom external CLI) that is not resolvable on this host; `null` when there is nothing to + * report (claude/shell are never checked here, and neither is a mode already confirmed + * available). Never called for a `remote`/`docker` case — those run the CLI on the OTHER + * host, where this local resolver cannot see it. + */ +function checkExternalCliAvailable(mode: SessionMode): ApiResponse | null { + if (!isExternalCliMode(mode)) return null; + if (resolveCliBinDir(mode) !== null) return null; + const message = missingCliMessage(mode) ?? `${mode} CLI not found.`; + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, message); +} + export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort @@ -797,55 +817,11 @@ export function registerSessionRoutes( } } - // Check OpenCode availability if requested - if (body.mode === 'opencode') { - const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js'); - if (!isOpenCodeAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' - ); - } - } - - // Check Codex availability if requested - if (body.mode === 'codex') { - const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js'); - if (!isCodexAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Codex CLI not found. Install with: npm install -g @openai/codex' - ); - } - } - - // Check Gemini availability if requested - if (body.mode === 'gemini') { - const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js'); - if (!isGeminiAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli' - ); - } - } - if (body.mode === 'antigravity') { - const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js'); - if (!isAntigravityAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash' - ); - } - } - if (body.mode === 'pi') { - const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js'); - if (!isPiAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' - ); - } + // Pre-flight availability check for an external CLI (opencode/codex/gemini/ + // antigravity/pi today — see checkExternalCliAvailable's own doc comment). + if (body.mode) { + const unavailable = checkExternalCliAvailable(body.mode); + if (unavailable) return unavailable; } // Pre-validate resumeSessionId: check that the conversation file actually exists @@ -2806,60 +2782,12 @@ export function registerSessionRoutes( dockerResumeId = dockerCase.lastClaudeSessionId; } } else { - // Check OpenCode availability if requested - if (mode === 'opencode') { - const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js'); - if (!isOpenCodeAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' - ); - } - } - - // Check Codex availability if requested - if (mode === 'codex') { - const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js'); - if (!isCodexAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Codex CLI not found. Install with: npm install -g @openai/codex' - ); - } - } - - // Check Gemini availability if requested - if (mode === 'gemini') { - const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js'); - if (!isGeminiAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli' - ); - } - } - - // Check Antigravity availability if requested - if (mode === 'antigravity') { - const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js'); - if (!isAntigravityAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash' - ); - } - } - - // Check Pi availability if requested - if (mode === 'pi') { - const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js'); - if (!isPiAvailable()) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' - ); - } - } + // Pre-flight availability check for an external CLI (opencode/codex/gemini/ + // antigravity/pi today — see checkExternalCliAvailable's own doc comment). Only + // reached for a LOCAL case (the remote/docker branches above return earlier), which + // is why the LOCAL resolver gate applies here and not there. + const unavailable = checkExternalCliAvailable(mode); + if (unavailable) return unavailable; // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR. // This mirrors the behaviour of resolveCasePath() in case-routes so that linked diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 879538511..fb8129c5a 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -377,6 +377,47 @@ export function registerSystemRoutes( // CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity, Pi) // ═══════════════════════════════════════════════════════════════ + // ========== CLI registry ========== + + // The full registry, secrets-free (CliEntry never carries a secret value — tmuxSetenvKeys + // etc. are env var NAMES only), for the frontend to render the run-mode menu, welcome + // buttons, labels and badges from data instead of a hard-coded list. Sorted by `order`, + // each entry augmented with live availability so a single fetch covers both. + app.get('/api/clis', async () => { + const { listClis } = await import('../../config/cli-registry/registry.js'); + const { resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js'); + const clis = listClis().map((entry) => ({ + ...entry, + available: entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null, + path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), + version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), + })); + return { success: true, data: clis }; + }); + + // Generic per-CLI status, superseding the six hand-written `/api//status` routes + // below (kept as aliases — see docs/versioning-policy.md, an existing endpoint path is + // never removed). Works for ANY registered id, including a custom one those six never + // could. `version` is only ever non-null for a version-aware resolver (see + // resolveCliVersion's own doc comment); claude's own `/api/claude/status` stays the + // place to read ITS live version until that becomes generic too. + app.get<{ Params: { id: string } }>('/api/cli/:id/status', async (req, reply) => { + const { getCli } = await import('../../config/cli-registry/registry.js'); + const { resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js'); + const entry = getCli(req.params.id); + if (!entry) { + return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Unknown CLI: ${req.params.id}`)); + } + return { + success: true, + data: { + available: entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null, + path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), + version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), + }, + }; + }); + // ========== Claude ========== app.get('/api/claude/status', async () => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 270a1ee2e..9fef1dd80 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -18,6 +18,7 @@ import { } from '../config/terminal-history.js'; import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; +import { enabledClis } from '../config/cli-registry/registry.js'; // ========== Path Validation ========== @@ -121,18 +122,32 @@ export const FileWriteSchema = z // ========== Env Var Allowlist ========== -/** Allowlisted env var key prefixes */ -const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_', 'PI_']; +/** + * Allowlisted env var key prefixes, composed from every registered CLI's own + * `env.allowedPrefixes` (config/cli-registry/stock.ts) — e.g. gemini contributes both + * `GEMINI_` and the deliberately-broad `GOOGLE_` (Vertex AI auth needs + * `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`). + * A CLI added to the registry — stock or custom — widens this automatically; nothing here + * needs editing to add one. Computed once at module load (the registry itself is memoized), + * matching this module's previous hardcoded-array performance. + */ +const ALLOWED_ENV_PREFIXES: string[] = enabledClis().flatMap((entry) => entry.env.allowedPrefixes); /** - * Allowlisted exact env var keys (checked alongside the prefixes). - * CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials, - * settings, stats) so a case can run on a separate Claude subscription (#255). - * Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected. + * Allowlisted exact env var keys (checked alongside the prefixes), composed the same way + * from `env.allowedKeys`. CLAUDE_CONFIG_DIR (claude's own entry) relocates the Claude CLI's + * user config (credentials, settings, stats) so a case can run on a separate Claude + * subscription (#255). Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected. */ -const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']); +const ALLOWED_ENV_KEYS = new Set(enabledClis().flatMap((entry) => entry.env.allowedKeys)); -/** Env var keys that are always blocked (security-sensitive) */ +/** + * Env var keys that are ALWAYS blocked (security-sensitive) — a hard floor no registry + * entry, stock or custom, can widen. Deliberately NOT registry-driven: an entry's + * `allowedPrefixes` contributes only to the allowlist above, and is checked in + * `isAllowedEnvKey` AFTER this blocklist, so a rogue `allowedPrefixes: ['']` still cannot + * unblock PATH or any other floor entry. + */ const BLOCKED_ENV_KEYS = new Set([ 'PATH', 'LD_PRELOAD', diff --git a/src/web/server.ts b/src/web/server.ts index de27a19bc..31d7f245b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1423,6 +1423,25 @@ export class WebServer extends EventEmitter { '', `\n` ); + + // The full CLI registry (same shape as GET /api/clis), for the frontend to render + // the run-mode menu / welcome buttons / labels from data instead of the hard-coded + // list `__codemanCliAvailable` above still is. Additive: `__codemanCliAvailable` + // keeps its EXACT shape (test/render-index-html.test.ts pins it with `toEqual`, + // which fails on an extra key) as a derived alias, not superseded in this pass. + const { listClis } = await import('../config/cli-registry/registry.js'); + const { resolveCliBinDir, resolveCliVersion } = await import('../utils/cli-resolver.js'); + const clis = listClis().map((entry) => ({ + ...entry, + available: entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null, + path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), + version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), + })); + // Escaped like the solo-id global above: label/accent/etc. ultimately come from + // ~/.codeman/clis.json, which an operator can edit, so this is defense-in-depth + // against a `` breakout rather than a response to untrusted REQUEST input. + const safeClis = JSON.stringify(clis).replace(/', `\n`); } if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('', `\n`); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index e121ba5d4..c8dc08a65 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -92,6 +92,15 @@ vi.mock('../../src/utils/pi-cli-resolver.js', () => ({ getPiCliVersion: vi.fn(() => null), })); +vi.mock('../../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveCliBinDir: vi.fn(() => null), + resolveCliVersion: vi.fn(() => null), + }; +}); + import fs from 'node:fs/promises'; import { existsSync, readdirSync } from 'node:fs'; import { subagentWatcher } from '../../src/subagent-watcher.js'; @@ -100,6 +109,7 @@ import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencod import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js'; import { isAntigravityAvailable, resolveAntigravityDir } from '../../src/utils/antigravity-cli-resolver.js'; import { isPiAvailable, resolvePiDir, getPiCliVersion } from '../../src/utils/pi-cli-resolver.js'; +import { resolveCliBinDir, resolveCliVersion } from '../../src/utils/cli-resolver.js'; const mockedReadFile = vi.mocked(fs.readFile); const mockedWriteFile = vi.mocked(fs.writeFile); @@ -881,6 +891,75 @@ describe('system-routes', () => { }); }); + // ========== GET /api/clis ========== + + describe('GET /api/clis', () => { + it('returns the full stock catalog, each entry augmented with availability', async () => { + vi.mocked(resolveCliBinDir).mockImplementation((id: string) => (id === 'claude' ? '/usr/local/bin' : null)); + + const res = await harness.app.inject({ method: 'GET', url: '/api/clis' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + const ids = body.data.map((c: { id: string }) => c.id).sort(); + expect(ids).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode', 'pi', 'shell']); + + const claude = body.data.find((c: { id: string }) => c.id === 'claude'); + expect(claude.available).toBe(true); + expect(claude.path).toBe('/usr/local/bin'); + + const codex = body.data.find((c: { id: string }) => c.id === 'codex'); + expect(codex.available).toBe(false); + expect(codex.path).toBeNull(); + + // shell has no binary at all — always "available" (nothing to resolve). + const shell = body.data.find((c: { id: string }) => c.id === 'shell'); + expect(shell.available).toBe(true); + expect(shell.path).toBeNull(); + }); + + it('never carries a secret value (only env var NAMES)', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/clis' }); + const body = JSON.parse(res.body); + const serialized = JSON.stringify(body.data); + // tmuxSetenvKeys/allowedPrefixes/allowedKeys are NAMES, never contain '=' + // or look like an actual secret value. + expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{20,}/); + }); + }); + + // ========== GET /api/cli/:id/status ========== + + describe('GET /api/cli/:id/status', () => { + it('returns availability for a known id, generically (not one of the six hand-written routes)', async () => { + vi.mocked(resolveCliBinDir).mockImplementation((id: string) => (id === 'pi' ? '/home/user/.local/bin' : null)); + vi.mocked(resolveCliVersion).mockImplementation((id: string) => (id === 'pi' ? '0.84.1' : null)); + + const res = await harness.app.inject({ method: 'GET', url: '/api/cli/pi/status' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.available).toBe(true); + expect(body.data.path).toBe('/home/user/.local/bin'); + expect(body.data.version).toBe('0.84.1'); + }); + + it('404s for an unregistered id', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/cli/not-a-real-cli/status' }); + expect(res.statusCode).toBe(404); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('shell is always available (nothing to resolve)', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/cli/shell/status' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.available).toBe(true); + expect(body.data.path).toBeNull(); + }); + }); + // ========== GET /api/execution/model-config ========== describe('GET /api/execution/model-config', () => { From 4771236f49b6aca24e9bde34217d69517027d3c3 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:43:54 +0800 Subject: [PATCH 07/15] refactor(frontend): collapse the five run() methods into runCli(id) (phase 7, part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The highest-value, safely-verifiable slice of the frontend phase: the five near-identical runOpenCode()/runCodex()/runGemini()/runAntigravity()/runPi() methods in session-ui.js (each ~55 lines: status-check URL, install-hint message, display label, and a per-mode quick-start config body, otherwise byte-for-byte the same launch/error/selection flow) collapse into one runCli(mode), driven by: - GET /api/cli/:id/status (generic, works for any registered id) instead of the six hand-written /api//status routes. - window.__codemanClis (server-injected, phase 6) for the display label and the install-hint message (now server-computed via missingCliMessage() rather than five copy-pasted strings). - _quickStartConfigFor(mode, settings), a small explicit table for the one thing that genuinely isn't "which CLIs exist" — deliberate frontend POLICY about what each CLI's quick-start config body should default to (codex's two settings-toggle-driven fields, pi's intentional absence of any config at all so a browser-launched session can never silently execute repo-supplied TypeScript). run()'s dispatch and the four welcome-button onclick handlers in index.html (a minimal, non-structural attribute edit — the run-mode menu, badges and per-mode CSS are NOT touched in this pass) now call runCli(mode) instead of the deleted per-mode methods. Also drove two label lookups (session-ui.js's run-button label, app.js's _getResponseViewerAgentLabel) from window.__codemanClis, each with a defensive `typeof window` guard and a fallback to the exact original ternary chain so behavior is byte-identical in any context (older cached page, vm-sandboxed unit test) where the registry payload isn't present. test/run-mode-ui.test.ts and the browser-suite test/opencode-resize.test.ts updated to call runCli(mode) and the new status endpoint instead of the deleted methods/routes; test descriptions and comments updated to match. GET /api/clis and GET /api/cli/:id/status gained an `installHint` field (missingCliMessage(), shared with tmux-manager.ts's spawn-time throw) so the frontend never reconstructs the per-platform install-command message itself. Deliberately NOT touched in this pass (deferred — each needs either visual QA I cannot perform here, or touches the tab-render hot path CLAUDE.md flags as delicate): the run-mode menu markup and its availability-gating loop, the per-mode CSS accent blocks, the tab badge/kill-title ternaries, and terminal-ui.js's echo-policy/alt-screen/wheel-forward capability gates. Full suite at the exact pre-existing baseline (55 failed files / 138 failed tests, unrelated to this work), zero regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/web/public/app.js | 4 + src/web/public/index.html | 8 +- src/web/public/session-ui.js | 322 ++++++++------------------------ src/web/routes/system-routes.ts | 26 ++- test/opencode-resize.test.ts | 15 +- test/run-mode-ui.test.ts | 69 +++---- 6 files changed, 145 insertions(+), 299 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index d7cc0d8ad..46f354fc8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2235,6 +2235,10 @@ class CodemanApp { _getResponseViewerAgentLabel() { const mode = this.sessions.get(this.activeSessionId)?.mode; + const clis = typeof window !== 'undefined' ? window.__codemanClis : undefined; + const cliMeta = (clis || []).find(c => c.id === mode); + if (cliMeta) return cliMeta.label; + // Fallback chain for a context with no window.__codemanClis (older cached page). return mode === 'codex' ? 'Codex' : mode === 'gemini' diff --git a/src/web/public/index.html b/src/web/public/index.html index 480d287f8..da6d8aaf1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -414,19 +414,19 @@

Codeman

Cloudflare Tunnel - - - - diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 1681deec4..926ef4d85 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -388,25 +388,11 @@ Object.assign(CodemanApp.prototype, { try { const mode = this._runMode || 'claude'; - if (mode === 'opencode') { - return await this.runOpenCode(); - } - if (mode === 'codex') { - return await this.runCodex(); - } - if (mode === 'gemini') { - return await this.runGemini(); - } - if (mode === 'antigravity') { - return await this.runAntigravity(); - } - if (mode === 'pi') { - return await this.runPi(); - } - if (mode === 'shell') { - return await this.runShell(); - } - return await this.runClaude(); + if (mode === 'claude') return await this.runClaude(); + if (mode === 'shell') return await this.runShell(); + // Every other mode (opencode/codex/gemini/antigravity/pi, or a future custom + // external CLI) shares one launch path — see runCli()'s own doc comment. + return await this.runCli(mode); } finally { const remaining = minLockMs - (Date.now() - startedAt); if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)); @@ -565,7 +551,16 @@ Object.assign(CodemanApp.prototype, { gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; } if (label) { - label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'shell' ? 'Run SH' : 'Run'; + // Prefer the registry's own shortBadge ("Run ") when the served list is + // available; claude has no badge suffix ("Run" alone), matching every mode this + // ternary already special-cased. Falls back to the hard-coded chain in a context + // with no `window.__codemanClis` (older cached page, or a test harness) so behavior + // stays identical either way — this is an enhancement, not a required data source. + const clis = typeof window !== 'undefined' ? window.__codemanClis : undefined; + const cliMeta = (clis || []).find(c => c.id === mode); + label.textContent = cliMeta + ? (mode === 'claude' ? 'Run' : `Run ${cliMeta.shortBadge}`) + : (mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'shell' ? 'Run SH' : 'Run'); } }, @@ -1004,81 +999,77 @@ Object.assign(CodemanApp.prototype, { } }, - async runOpenCode() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote cases run the CLI on the REMOTE host — the local /api/opencode/status - // probe and the local-only config/env below don't apply (quick-start rejects them). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`); - // Focus in sync gesture context (see runClaude comment) - this.terminal.focus(); - - try { - // Check if OpenCode is available (local sessions only) - if (!isRemote) { - const statusRes = await fetch('/api/opencode/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' - ); - return; - } - } - - // Quick-start with opencode mode (auto-allow tools by default). - // No `effort` field — it's Claude-specific (OpenCode has no /effort). - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'opencode', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote ? {} : { - openCodeConfig: { autoAllowTools: true }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start OpenCode'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - // Switch to the new session (don't pre-set activeSessionId — selectSession - // early-returns when IDs match, skipping buffer load and sendResize) - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + /** + * Per-mode default `Config` body sent to `/api/quick-start`, for the local + * (non-remote/docker) case. This is DELIBERATE FRONTEND POLICY, not "which CLIs + * exist" — it encodes decisions like "codex's bypass/animations come from two + * App Settings toggles" and "pi gets NO config at all", which is a genuine safety + * choice, not an omission: pi has no permission prompts, so there is no bypass to + * opt into, and project trust is pi's own `defaultProjectTrust` decision (an + * interactive prompt the user answers in the terminal) — sending + * `approveProjectTrust: true` here would silently opt every browser-launched pi + * session into executing repo-supplied TypeScript. A mode with no entry here + * (claude/shell, handled by their own run methods; a future custom external CLI) + * gets no config object at all, which quick-start already treats as "use defaults". + */ + _quickStartConfigFor(mode, globalSettings) { + switch (mode) { + case 'opencode': + // No `effort` field — it's Claude-specific (OpenCode has no /effort). + return { openCodeConfig: { autoAllowTools: true } }; + case 'codex': + return { + codexConfig: { + dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, + animations: globalSettings.codexAnimationsEnabled ?? false, + renderMode: 'hybrid', + }, + }; + case 'gemini': + return { geminiConfig: { approvalMode: 'yolo' } }; + case 'antigravity': + return { antigravityConfig: { dangerouslySkipPermissions: true } }; + default: + return {}; } }, - async runCodex() { + /** + * Launch a session for any external CLI mode (opencode/codex/gemini/antigravity/ + * pi today, or a future custom one) — the five near-identical runOpenCode()/ + * runCodex()/runGemini()/runAntigravity()/runPi() methods this replaced differed + * only in the status-check URL, the install-hint message, the display label and + * `_quickStartConfigFor()`'s per-mode config body, all now DATA (the label/install + * hint from `GET /api/cli/:id/status` and `window.__codemanClis`, the config body + * from the table above) rather than one copy-pasted method per CLI. claude/shell + * keep their own methods (runClaude/runShell): both are genuinely larger and + * differently-shaped (multi-tab, docker drift handling, ralph tracker, shell count). + */ + async runCli(mode) { const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote cases run Codex on the REMOTE host — skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). + // Remote/docker cases run the CLI on the OTHER side — the local status probe and + // the local-only config/env below don't apply (quick-start rejects them there). const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`); + // `typeof window` guard: some unit-test harnesses run this file in a vm sandbox + // with no `window` global at all, where a bare reference would throw instead of + // just being undefined (unlike every other optional-lookup in this method). + const clis = typeof window !== 'undefined' ? window.__codemanClis : undefined; + const cliMeta = (clis || []).find(c => c.id === mode); + const label = cliMeta?.label || mode; + + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting ${label} session in ${caseName}...`); + // Focus in sync gesture context (see runClaude comment) this.terminal.focus(); try { if (!isRemote) { - const statusRes = await fetch('/api/codex/status'); + const statusRes = await fetch(`/api/cli/${encodeURIComponent(mode)}/status`); const status = (await statusRes.json()).data; if (!status.available) { this._reportSessionLaunchError( ownsLaunchTerminal, - 'Codex CLI not found. Install with: npm install -g @openai/codex' + status.installHint || `${label} CLI not found.` ); return; } @@ -1091,20 +1082,16 @@ Object.assign(CodemanApp.prototype, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caseName, - mode: 'codex', + mode, sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, ...(isRemote ? {} : { - codexConfig: { - dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, - animations: globalSettings.codexAnimationsEnabled ?? false, - renderMode: 'hybrid', - }, + ...this._quickStartConfigFor(mode, globalSettings), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), }), }) }); const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Codex'); + if (!data.success) throw new Error(data.error || `Failed to start ${label}`); await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); // Switch to the new session (don't pre-set activeSessionId — selectSession @@ -1119,165 +1106,6 @@ Object.assign(CodemanApp.prototype, { } }, - async runGemini() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote cases run Gemini on the REMOTE host — skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/gemini/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'gemini', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote ? {} : { - geminiConfig: { approvalMode: 'yolo' }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Gemini'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } - }, - - async runAntigravity() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote/docker cases run agy on the OTHER side — skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/antigravity/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'antigravity', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote ? {} : { - antigravityConfig: { dangerouslySkipPermissions: true }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Antigravity'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } - }, - - /** - * Launch a Pi (pi.dev) session. - * - * Deliberately sends NO piConfig: pi has no permission prompts, so there is no - * bypass to opt into, and project trust is pi's own `defaultProjectTrust` - * decision (an interactive prompt the user answers in the terminal). Sending - * `approveProjectTrust: true` here would silently opt every browser-launched pi - * session into executing repo-supplied TypeScript. - */ - async runPi() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote/docker cases run pi on the OTHER side — skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/pi/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'pi', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Pi'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } - }, - // ═══════════════════════════════════════════════════════════════ // Session Options Modal diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index fb8129c5a..6828d0742 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -384,14 +384,20 @@ export function registerSystemRoutes( // buttons, labels and badges from data instead of a hard-coded list. Sorted by `order`, // each entry augmented with live availability so a single fetch covers both. app.get('/api/clis', async () => { - const { listClis } = await import('../../config/cli-registry/registry.js'); + const { listClis, missingCliMessage } = await import('../../config/cli-registry/registry.js'); const { resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js'); - const clis = listClis().map((entry) => ({ - ...entry, - available: entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null, - path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), - version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), - })); + const clis = listClis().map((entry) => { + const available = entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null; + return { + ...entry, + available, + path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), + version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), + // Populated only when actually needed (not installed), so the frontend never has + // to reconstruct the per-platform install-command message itself. + installHint: available ? null : missingCliMessage(entry.id), + }; + }); return { success: true, data: clis }; }); @@ -402,18 +408,20 @@ export function registerSystemRoutes( // resolveCliVersion's own doc comment); claude's own `/api/claude/status` stays the // place to read ITS live version until that becomes generic too. app.get<{ Params: { id: string } }>('/api/cli/:id/status', async (req, reply) => { - const { getCli } = await import('../../config/cli-registry/registry.js'); + const { getCli, missingCliMessage } = await import('../../config/cli-registry/registry.js'); const { resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js'); const entry = getCli(req.params.id); if (!entry) { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Unknown CLI: ${req.params.id}`)); } + const available = entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null; return { success: true, data: { - available: entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null, + available, path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), + installHint: available ? null : missingCliMessage(entry.id), }, }; }); diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts index af330777c..ab69bad0e 100644 --- a/test/opencode-resize.test.ts +++ b/test/opencode-resize.test.ts @@ -55,18 +55,19 @@ describe('OpenCode session initial resize', () => { await context?.close(); }); - it('selectSession is not bypassed when runOpenCode sets activeSessionId', async () => { - // This test verifies at the code level that runOpenCode does NOT - // pre-set activeSessionId before calling selectSession. + it('selectSession is not bypassed when runCli(opencode) sets activeSessionId', async () => { + // This test verifies at the code level that runCli() (the shared launch path + // for opencode/codex/gemini/antigravity/pi, formerly a per-mode runOpenCode() + // etc.) does NOT pre-set activeSessionId before calling selectSession. // If it did, selectSession would early-return and skip sendResize. ({ context, page } = await freshPage()); await navigateAndWait(page); - // Read the runOpenCode source from the live app and verify + // Read the runCli source from the live app and verify // it doesn't assign activeSessionId before selectSession const hasPreAssignment = await page.evaluate(() => { - const app = (window as unknown as { app: { runOpenCode: { toString: () => string } } }).app; - const source = app.runOpenCode.toString(); + const app = (window as unknown as { app: { runCli: { toString: () => string } } }).app; + const source = app.runCli.toString(); // Check: the source should NOT have activeSessionId = ... before selectSession // Find positions of both patterns @@ -114,7 +115,7 @@ describe('OpenCode session initial resize', () => { expect(sessionId).toBeTruthy(); - // Call selectSession (which is what runOpenCode does after fix) + // Call selectSession (which is what runCli does after fix) await page.evaluate(async (sid: string) => { const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app; await app.selectSession(sid); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 17412297a..d3cb7bd88 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -148,8 +148,10 @@ describe('Run launch synchronization', () => { * directly, which is the actual bug: a launch started while another session * is active wipes that session's terminal, and _cleanupPreviousSession() * then serializes the wiped view into its restore snapshot. Asserting on the - * helpers alone cannot see that, so pin the call sites here. This also - * covers run modes added later, which is how runAntigravity was caught. + * helpers alone cannot see that, so pin the call sites here. runCli(mode) is + * the single entry point for every external CLI (opencode/codex/gemini/ + * antigravity/pi and any future custom one), which is what now covers a mode + * added later automatically instead of needing its own runX() caught here. */ it('routes every run mode through the ownership helpers, never the terminal directly', () => { const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); @@ -157,7 +159,7 @@ describe('Run launch synchronization', () => { // Methods live in one Object.assign(prototype, {...}) block at a fixed // 2-space indent, so `\n },` reliably closes the one we are inside. const bodies = new Map(); - const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm; + const header = /^ {2}async (run[A-Za-z]*)\([a-z]*\) \{$/gm; for (let m = header.exec(src); m; m = header.exec(src)) { const start = m.index + m[0].length; const end = src.indexOf('\n },', start); @@ -167,17 +169,7 @@ describe('Run launch synchronization', () => { // Fail loudly if the scan matched nothing: a silently empty scan would make // every assertion below vacuously true. - expect([...bodies.keys()]).toEqual( - expect.arrayContaining([ - 'runClaude', - 'runShell', - 'runOpenCode', - 'runCodex', - 'runGemini', - 'runAntigravity', - 'runPi', - ]) - ); + expect([...bodies.keys()]).toEqual(expect.arrayContaining(['runClaude', 'runShell', 'runCli'])); for (const [name, body] of bodies) { expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear('); @@ -497,7 +489,8 @@ describe('Codex quick start settings', () => { // server.ts wraps route payloads into the { success, data } envelope. fetch: async (url: string, init?: { body?: string }) => { requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined }); - if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) }; + if (url === '/api/cli/codex/status') + return { json: async () => ({ success: true, data: { available: true } }) }; if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) }; if (url === '/api/sessions/sess-1') return { json: async () => ({ success: true, data: { id: 'sess-1', name: 'w1-codex-case' } }) }; @@ -525,7 +518,7 @@ describe('Codex quick start settings', () => { selected.push(id); }; - await app.runCodex(); + await app.runCli('codex'); expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({ caseName: 'codex-case', @@ -798,12 +791,12 @@ describe('case selector refresh', () => { }); describe('Gemini quick start', () => { - // Regression guard for the ApiResponse-envelope unwrap in runGemini(): the + // Regression guard for the ApiResponse-envelope unwrap in runCli(): the // status check must read `.data.available` and the quick-start response must // read `.data.sessionId`. Reading the raw shape (pre-fix) silently bails on // the status check and never selects the new tab — exactly the two blockers // caught in PR #134 review. - it('drives runGemini() through the {success,data} envelope and selects the new session', async () => { + it("drives runCli('gemini') through the {success,data} envelope and selects the new session", async () => { const elements: Record = { quickStartCase: { value: 'gemini-case' }, }; @@ -818,7 +811,8 @@ describe('Gemini quick start', () => { // hook wraps raw route payloads into the { success, data } envelope. fetch: async (url: string, init?: { body?: string }) => { requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined }); - if (url === '/api/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) }; + if (url === '/api/cli/gemini/status') + return { json: async () => ({ success: true, data: { available: true } }) }; if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) }; if (url === '/api/sessions/sess-gm') @@ -844,7 +838,7 @@ describe('Gemini quick start', () => { selected.push(id); }; - await app.runGemini(); + await app.runCli('gemini'); expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({ caseName: 'gemini-case', @@ -856,8 +850,8 @@ describe('Gemini quick start', () => { }); describe('Antigravity quick start', () => { - // Same envelope-unwrap regression guard as the Gemini block above, for runAntigravity(). - it('drives runAntigravity() through the {success,data} envelope and selects the new session', async () => { + // Same envelope-unwrap regression guard as the Gemini block above, for antigravity. + it("drives runCli('antigravity') through the {success,data} envelope and selects the new session", async () => { const elements: Record = { quickStartCase: { value: 'ag-case' }, }; @@ -870,7 +864,7 @@ describe('Antigravity quick start', () => { document: { getElementById: (id: string) => elements[id] ?? null }, fetch: async (url: string, init?: { body?: string }) => { requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined }); - if (url === '/api/antigravity/status') + if (url === '/api/cli/antigravity/status') return { json: async () => ({ success: true, data: { available: true } }) }; if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-ag' } }) }; @@ -897,7 +891,7 @@ describe('Antigravity quick start', () => { selected.push(id); }; - await app.runAntigravity(); + await app.runCli('antigravity'); expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({ caseName: 'ag-case', @@ -909,11 +903,11 @@ describe('Antigravity quick start', () => { }); describe('Pi quick start', () => { - // Same envelope-unwrap regression guard as the blocks above, for runPi(), plus the + // Same envelope-unwrap regression guard as the blocks above, for pi, plus the // rule that makes pi different: it must send NO piConfig. Pi has no permission // prompts, and `approveProjectTrust` would opt the session into EXECUTING // repo-supplied TypeScript — never something a Run button decides silently. - it('drives runPi() through the {success,data} envelope and sends no piConfig', async () => { + it("drives runCli('pi') through the {success,data} envelope and sends no piConfig", async () => { const elements: Record = { quickStartCase: { value: 'pi-case' }, }; @@ -926,7 +920,7 @@ describe('Pi quick start', () => { document: { getElementById: (id: string) => elements[id] ?? null }, fetch: async (url: string, init?: { body?: string }) => { requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined }); - if (url === '/api/pi/status') + if (url === '/api/cli/pi/status') return { json: async () => ({ success: true, data: { available: true, path: '/usr/local/bin', version: '0.84.1' } }), }; @@ -955,7 +949,7 @@ describe('Pi quick start', () => { selected.push(id); }; - await app.runPi(); + await app.runCli('pi'); const body = requests.find((req) => req.url === '/api/quick-start')?.body; expect(body).toMatchObject({ caseName: 'pi-case', mode: 'pi' }); @@ -973,8 +967,19 @@ describe('Pi quick start', () => { document: { getElementById: (id: string) => elements[id] ?? null }, fetch: async (url: string) => { requests.push(url); - if (url === '/api/pi/status') - return { json: async () => ({ success: true, data: { available: false, path: null, version: null } }) }; + if (url === '/api/cli/pi/status') + return { + json: async () => ({ + success: true, + data: { + available: false, + path: null, + version: null, + installHint: + 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + }, + }), + }; throw new Error(`unexpected fetch: ${url}`); }, console, @@ -987,9 +992,9 @@ describe('Pi quick start', () => { const errors: string[] = []; app._reportSessionLaunchError = (_owns: boolean, msg: string) => errors.push(msg); - await app.runPi(); + await app.runCli('pi'); - expect(requests).toEqual(['/api/pi/status']); + expect(requests).toEqual(['/api/cli/pi/status']); expect(errors[0]).toContain('@earendil-works/pi-coding-agent'); }); }); From 6d1340b1821e7c7fd8eaf6968f5a19a2585e3429 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:40:25 +0800 Subject: [PATCH 08/15] feat(api): add CLI registry write endpoints (phase 8, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend half of the settings-UI phase: mutation functions in config/cli-registry/registry.ts, each a read-modify-write against ~/.codeman/clis.json that validates before persisting and reloads the shared cache on success, so every other module sees the change on its next getCli()/listClis() call: - setCliEnabled(id, enabled) - toggle any registered CLI (stock or custom). - setCliOrder(orderedIds) - reposition the given ids (x10-spaced, so a future insertion between two adjacent entries never needs a renumber); ids not listed keep their current order. - upsertCustomCli(id, entry) - add or replace a CUSTOM CLI. Validated as a COMPLETE CliEntry up front (the same schema the on-disk file itself is validated against) so a malformed request fails with a clear error instead of being silently dropped on the next unrelated read; refuses to shadow a stock id. - removeCustomCli(id) - remove a custom CLI; refuses for a stock id (those can only be disabled, never removed - the loader treats an id-collision as fixable but shell/claude's total ABSENCE as something huge parts of the app assume can't happen). New routes in system-routes.ts, admin-gated in multi-user mode (the registry is process-wide config, not scoped to one user's workspace, same posture as the workflow/subagent aggregates already gated that way): PUT /api/clis/:id/enabled, PUT /api/clis/order, POST /api/clis/:id, DELETE /api/clis/:id. All four return the full resolved list on success so the frontend can just replace its in-memory copy. Test coverage: 8 new registry-level tests (test/cli-registry-load.test.ts) and 12 new route tests (test/routes/system-routes.test.ts) covering success, validation failure, unknown-id, and the stock-id refusal on both upsert and remove. The route tests needed a small harness fix: this test file globally mocks node:fs's existsSync (always true) and mkdirSync (no-op) for every OTHER route's benefit, which broke the registry writer's real disk IO — fixed by delegating to the real fs implementations for just the new "CLI registry writes" describe block. Full suite at the exact pre-existing baseline (55 failed files / 138 failed tests, unrelated to this work) plus 18 new passing tests, zero regressions. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/config/cli-registry/registry.ts | 104 +++++++++++++++++ src/web/routes/system-routes.ts | 55 +++++++++ src/web/schemas.ts | 6 + test/cli-registry-load.test.ts | 174 ++++++++++++++++++++-------- test/routes/system-routes.test.ts | 159 ++++++++++++++++++++++++- 5 files changed, 451 insertions(+), 47 deletions(-) diff --git a/src/config/cli-registry/registry.ts b/src/config/cli-registry/registry.ts index 014edb934..60c6a92af 100644 --- a/src/config/cli-registry/registry.ts +++ b/src/config/cli-registry/registry.ts @@ -215,6 +215,110 @@ export function cliIds(): string[] { return listClis().map((e) => e.id as string); } +// --------------------------------------------------------------------------- +// Writes — settings-UI mutations (App Settings → Agents & CLIs) +// --------------------------------------------------------------------------- + +export interface CliUpdateResult { + success: boolean; + /** Human-readable problems: a failed stock override, a dropped custom entry, an IO error. */ + warnings: string[]; + /** The resolved registry AFTER the mutation, when it succeeded. */ + entries?: CliEntry[]; +} + +const STOCK_IDS = new Set(STOCK_CLIS.map((e) => e.id as string)); + +/** + * Read-modify-write the raw override file: ensures it exists (seeding via + * `loadCliRegistry()` if needed), applies `mutate` to a fresh, uncached read, validates the + * result, persists, and reloads the shared cache so every other module sees the change on + * its next `getCli()`/`listClis()` call. `mutate` throwing aborts the write entirely — the + * on-disk file is untouched (the read happens before any write). + */ +function withRegistryFile(mutate: (file: CliRegistryFile) => void): CliUpdateResult { + const path = filePath(); + const warnings: string[] = []; + loadCliRegistry(); // ensure the file exists and newly-shipped stock ids are seeded + const existing = readRegistryFile(path, warnings) ?? { + schemaVersion: SCHEMA_VERSION, + seededStockIds: STOCK_CLIS.map((e) => e.id as string), + clis: {}, + }; + + mutate(existing); + + // resolveRegistry() never throws — a bad entry is dropped/falls back with a warning — + // so run it here to surface those as part of THIS mutation's result rather than silently + // on the next unrelated read. + const validationWarnings: string[] = []; + resolveRegistry(STOCK_CLIS, existing, validationWarnings); + + try { + writeSeed(path, existing); + } catch (err) { + return { success: false, warnings: [...warnings, `Failed to persist ${path}: ${(err as Error).message}`] }; + } + reloadCliRegistry(); + const { entries, warnings: loadWarnings } = loadCliRegistry(); + return { success: true, warnings: [...warnings, ...validationWarnings, ...loadWarnings], entries }; +} + +/** Enable or disable ANY registered CLI (stock or custom) — the settings list's toggle. */ +export function setCliEnabled(id: string, enabled: boolean): CliUpdateResult { + if (!getCli(id)) return { success: false, warnings: [`Unknown CLI: ${id}`] }; + return withRegistryFile((file) => { + file.clis[id] = deepMerge((file.clis[id] as object) ?? {}, { enabled }); + }); +} + +/** + * Reorder the run-menu/settings-list position of every id in `orderedIds`, in the order + * given. Ids not listed keep their current `order`. Multiplied by 10 so a future insertion + * between two adjacent entries never requires renumbering the whole list. + */ +export function setCliOrder(orderedIds: string[]): CliUpdateResult { + return withRegistryFile((file) => { + orderedIds.forEach((id, index) => { + file.clis[id] = deepMerge((file.clis[id] as object) ?? {}, { order: index * 10 }); + }); + }); +} + +/** + * Add or update a CUSTOM CLI (never a stock one — `stock` is always forced server-side + * regardless of what the request claims, same as the loader). `entry` is validated as a + * COMPLETE `CliEntry` up front so a malformed request fails with a clear schema error + * instead of being silently dropped by `resolveRegistry`'s own fallback on the next read. + */ +export function upsertCustomCli(id: string, entry: unknown): CliUpdateResult { + if (STOCK_IDS.has(id)) { + return { + success: false, + warnings: [`"${id}" is a stock CLI id — edit it with setCliEnabled or an override, not upsertCustomCli.`], + }; + } + const candidate = typeof entry === 'object' && entry !== null ? { ...entry, id, stock: false } : entry; + const parsed = CliEntrySchema.safeParse(candidate); + if (!parsed.success) { + return { success: false, warnings: [parsed.error.message] }; + } + return withRegistryFile((file) => { + file.clis[id] = parsed.data; + }); +} + +/** Remove a custom CLI entirely. Stock entries can only be disabled, never removed. */ +export function removeCustomCli(id: string): CliUpdateResult { + if (STOCK_IDS.has(id)) { + return { success: false, warnings: [`"${id}" is a stock CLI — disable it instead of removing it.`] }; + } + if (!getCli(id)) return { success: false, warnings: [`Unknown CLI: ${id}`] }; + return withRegistryFile((file) => { + delete file.clis[id]; + }); +} + /** * Build the "CLI not found" error message for a mode with no resolved binary directory, * naming the registry's own label and per-platform install command. Shared by diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 6828d0742..3a6f6903c 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -22,6 +22,8 @@ import { ConfigUpdateSchema, SettingsUpdateSchema, ModelConfigUpdateSchema, + CliEnabledUpdateSchema, + CliOrderUpdateSchema, CpuLimitSchema, SubagentWindowStatesSchema, SubagentParentMapSchema, @@ -426,6 +428,59 @@ export function registerSystemRoutes( }; }); + // Settings-UI mutations (App Settings → Agents & CLIs). Admin-only in multi-user mode — + // the registry is process-wide config, not scoped to a single user's workspace, same + // posture as the workflow/subagent aggregates above. All four return the FULL resolved + // list on success, so the frontend can just replace its in-memory copy rather than + // re-deriving what changed. + app.put<{ Params: { id: string } }>('/api/clis/:id/enabled', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const { enabled } = parseBody(CliEnabledUpdateSchema, req.body, 'Invalid request body'); + const { setCliEnabled } = await import('../../config/cli-registry/registry.js'); + const result = setCliEnabled(req.params.id, enabled); + if (!result.success) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); + } + return { success: true, data: { entries: result.entries, warnings: result.warnings } }; + }); + + app.put('/api/clis/order', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const { order } = parseBody(CliOrderUpdateSchema, req.body, 'Invalid request body'); + const { setCliOrder } = await import('../../config/cli-registry/registry.js'); + const result = setCliOrder(order); + if (!result.success) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); + } + return { success: true, data: { entries: result.entries, warnings: result.warnings } }; + }); + + // Add or replace a CUSTOM CLI. The body is a complete CliEntry (validated by the SAME + // schema the on-disk file is validated against — see config/cli-registry/schema.ts's + // file header for what that schema does and does not allow, notably that no field can + // ever carry raw shell text). `id` is taken from the URL, never trusted from the body. + app.post<{ Params: { id: string } }>('/api/clis/:id', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const { upsertCustomCli } = await import('../../config/cli-registry/registry.js'); + const result = upsertCustomCli(req.params.id, req.body); + if (!result.success) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); + } + return { success: true, data: { entries: result.entries, warnings: result.warnings } }; + }); + + // Remove a custom CLI. Refuses for a stock id (disable it instead) — see + // removeCustomCli's own doc comment. + app.delete<{ Params: { id: string } }>('/api/clis/:id', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const { removeCustomCli } = await import('../../config/cli-registry/registry.js'); + const result = removeCustomCli(req.params.id); + if (!result.success) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); + } + return { success: true, data: { entries: result.entries, warnings: result.warnings } }; + }); + // ========== Claude ========== app.get('/api/claude/status', async () => { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 9fef1dd80..6be81d582 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1405,6 +1405,12 @@ export const CpuLimitSchema = z.object({ /** PUT /api/execution/model-config */ export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown()); +/** PUT /api/clis/:id/enabled */ +export const CliEnabledUpdateSchema = z.object({ enabled: z.boolean() }).strict(); + +/** PUT /api/clis/order — the full desired id order, front to back. */ +export const CliOrderUpdateSchema = z.object({ order: z.array(z.string().min(1).max(24)).min(1).max(64) }).strict(); + /** PUT /api/subagent-window-states */ export const SubagentWindowStatesSchema = z .object({ diff --git a/test/cli-registry-load.test.ts b/test/cli-registry-load.test.ts index 00c2aac4f..8d4cca03d 100644 --- a/test/cli-registry-load.test.ts +++ b/test/cli-registry-load.test.ts @@ -17,6 +17,53 @@ import { resolveRegistry } from '../src/config/cli-registry/registry.js'; import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; import type { CliRegistryFile } from '../src/config/cli-registry/types.js'; +/** A well-formed custom entry, reused across the merge and write tests below. */ +const COPILOT_ENTRY = { + id: 'copilot', + label: 'Copilot', + shortBadge: 'GH', + accent: '#24292f', + enabled: true, + order: 60, + kind: 'agent' as const, + discovery: { + binaries: ['copilot'], + searchDirs: ['~/.local/bin'], + install: { command: { linux: 'npm install -g @githubnext/github-copilot-cli' } }, + }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'copilot' }] }] }, + env: { + exports: [], + unset: [], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['COPILOT_'], + allowedKeys: [], + }, + capabilities: { + external: true, + requiresMux: true, + hooks: false, + transcript: 'none' as const, + altScreen: 'strip-mux-only' as const, + echo: { policy: 'buffer' as const, anchor: { kind: 'cursor' as const } }, + wheelForward: { mode: 'never' as const }, + keyboardAccessory: 'agent' as const, + privilegedCommandGate: false, + startMode: 'interactive' as const, + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' as const }, + privilegedParams: [], + gates: {}, + }, + overlays: {}, +}; + describe('resolveRegistry (pure merge)', () => { it('returns every stock entry unchanged when the file is absent', () => { const { entries, warnings } = resolveRegistry(STOCK_CLIS, null, []); @@ -38,52 +85,7 @@ describe('resolveRegistry (pure merge)', () => { }); it('adds a well-formed custom entry alongside the stock catalog', () => { - const custom = { - id: 'copilot', - label: 'Copilot', - shortBadge: 'GH', - accent: '#24292f', - enabled: true, - order: 60, - kind: 'agent' as const, - discovery: { - binaries: ['copilot'], - searchDirs: ['~/.local/bin'], - install: { command: { linux: 'npm install -g @githubnext/github-copilot-cli' } }, - }, - launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'copilot' }] }] }, - env: { - exports: [], - unset: [], - tmuxSetenvKeys: [], - dockerExecEnvNames: [], - allowedPrefixes: ['COPILOT_'], - allowedKeys: [], - }, - capabilities: { - external: true, - requiresMux: true, - hooks: false, - transcript: 'none' as const, - altScreen: 'strip-mux-only' as const, - echo: { policy: 'buffer' as const, anchor: { kind: 'cursor' as const } }, - wheelForward: { mode: 'never' as const }, - keyboardAccessory: 'agent' as const, - privilegedCommandGate: false, - startMode: 'interactive' as const, - stripInkBloat: true, - ralph: false, - respawn: false, - effort: false, - agentSkillInjection: false, - statusLineTelemetry: false, - model: { source: 'none' as const }, - privilegedParams: [], - gates: {}, - }, - overlays: {}, - }; - const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { copilot: custom } }; + const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { copilot: COPILOT_ENTRY } }; const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); expect(warnings).toEqual([]); const found = entries.find((e) => (e.id as unknown as string) === 'copilot'); @@ -205,3 +207,83 @@ describe('loadCliRegistry (on-disk seeding)', () => { } ); }); + +describe('registry writes (setCliEnabled / setCliOrder / upsertCustomCli / removeCustomCli)', () => { + it('setCliEnabled toggles a stock CLI and the change survives a reload', async () => { + const { setCliEnabled, reloadCliRegistry, loadCliRegistry } = + await import('../src/config/cli-registry/registry.js'); + const before = setCliEnabled('gemini', false); + expect(before.success).toBe(true); + expect(before.entries?.find((e) => (e.id as unknown as string) === 'gemini')?.enabled).toBe(false); + + reloadCliRegistry(); + const { entries } = loadCliRegistry(); + expect(entries.find((e) => (e.id as unknown as string) === 'gemini')?.enabled).toBe(false); + }); + + it('setCliEnabled fails cleanly for an unknown id and touches nothing', async () => { + const { setCliEnabled } = await import('../src/config/cli-registry/registry.js'); + const result = setCliEnabled('not-a-real-cli', false); + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain('Unknown CLI'); + }); + + it('setCliOrder repositions entries and leaves unlisted ids alone', async () => { + const { setCliOrder } = await import('../src/config/cli-registry/registry.js'); + const result = setCliOrder(['pi', 'claude', 'shell']); + expect(result.success).toBe(true); + const byId = new Map(result.entries!.map((e) => [e.id as unknown as string, e])); + expect(byId.get('pi')!.order).toBeLessThan(byId.get('claude')!.order); + expect(byId.get('claude')!.order).toBeLessThan(byId.get('shell')!.order); + }); + + it('upsertCustomCli adds a new CLI that shows up in the resolved list', async () => { + const { upsertCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); + const result = upsertCustomCli('copilot', COPILOT_ENTRY); + expect(result.success).toBe(true); + expect(result.warnings).toEqual([]); + const found = getCli('copilot'); + expect(found?.label).toBe('Copilot'); + expect(found?.stock).toBe(false); + }); + + it('upsertCustomCli rejects a malformed entry with a schema error, writing nothing', async () => { + const { upsertCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); + const result = upsertCustomCli('bad-cli', { ...COPILOT_ENTRY, accent: 'not-a-hex-colour' }); + expect(result.success).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + expect(getCli('bad-cli')).toBeUndefined(); + }); + + it('upsertCustomCli refuses to shadow a stock id', async () => { + const { upsertCustomCli } = await import('../src/config/cli-registry/registry.js'); + const result = upsertCustomCli('codex', COPILOT_ENTRY); + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain('stock CLI'); + }); + + it('removeCustomCli removes a previously added custom CLI', async () => { + const { upsertCustomCli, removeCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); + upsertCustomCli('copilot', COPILOT_ENTRY); + expect(getCli('copilot')).toBeDefined(); + + const result = removeCustomCli('copilot'); + expect(result.success).toBe(true); + expect(getCli('copilot')).toBeUndefined(); + }); + + it('removeCustomCli refuses to remove a stock CLI', async () => { + const { removeCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); + const result = removeCustomCli('pi'); + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain('stock CLI'); + expect(getCli('pi')).toBeDefined(); // untouched + }); + + it('removeCustomCli fails cleanly for an unknown id', async () => { + const { removeCustomCli } = await import('../src/config/cli-registry/registry.js'); + const result = removeCustomCli('never-added'); + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain('Unknown CLI'); + }); +}); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index c8dc08a65..6699dae3c 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -102,7 +102,7 @@ vi.mock('../../src/utils/cli-resolver.js', async (importOriginal) => { }); import fs from 'node:fs/promises'; -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync } from 'node:fs'; import { subagentWatcher } from '../../src/subagent-watcher.js'; import { getLifecycleLog } from '../../src/session-lifecycle-log.js'; import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js'; @@ -114,6 +114,7 @@ import { resolveCliBinDir, resolveCliVersion } from '../../src/utils/cli-resolve const mockedReadFile = vi.mocked(fs.readFile); const mockedWriteFile = vi.mocked(fs.writeFile); const mockedExistsSync = vi.mocked(existsSync); +const mockedMkdirSync = vi.mocked(mkdirSync); const mockedReaddirSync = vi.mocked(readdirSync); const mockedSubagentWatcher = vi.mocked(subagentWatcher); const mockedGetLifecycleLog = vi.mocked(getLifecycleLog); @@ -960,6 +961,162 @@ describe('system-routes', () => { }); }); + // ========== CLI registry writes (App Settings → Agents & CLIs) ========== + + // Unlike every other handler in this file, the CLI registry writer does REAL disk IO + // (~/.codeman/clis.json under the per-test temp HOME from test/setup.ts) rather than + // going through a mocked store — the top-of-file `existsSync`/`mkdirSync` mocks + // (`existsSync` always `true`, `mkdirSync` a no-op) exist for every OTHER route's + // benefit and would otherwise make the registry's own read-modify-write believe the + // file exists while never actually creating its parent directory. Delegate to the REAL + // implementations for this one block, restored by the outer per-test `vi.clearAllMocks()` + // + default re-application in the top-level `beforeEach` once this block's tests finish. + describe('CLI registry writes', () => { + beforeEach(async () => { + const actualFs = await vi.importActual('node:fs'); + mockedExistsSync.mockImplementation(actualFs.existsSync); + mockedMkdirSync.mockImplementation(actualFs.mkdirSync as typeof mkdirSync); + }); + + describe('PUT /api/clis/:id/enabled', () => { + it('disables a stock CLI and returns the resolved list reflecting the change', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/clis/gemini/enabled', + payload: { enabled: false }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + const gemini = body.data.entries.find((c: { id: string }) => c.id === 'gemini'); + expect(gemini.enabled).toBe(false); + }); + + it('400s for an unknown id', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/clis/not-a-real-cli/enabled', + payload: { enabled: false }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).success).toBe(false); + }); + + it('400s on a malformed body', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/clis/gemini/enabled', + payload: { enabled: 'not-a-boolean' }, + }); + expect(res.statusCode).toBe(400); + }); + }); + + describe('PUT /api/clis/order', () => { + it('reorders the given ids and returns the resolved list in the new order', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/clis/order', + payload: { order: ['pi', 'claude', 'shell'] }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + const byId = new Map(body.data.entries.map((c: { id: string; order: number }) => [c.id, c.order])); + expect(byId.get('pi')).toBeLessThan(byId.get('claude') as number); + expect(byId.get('claude')).toBeLessThan(byId.get('shell') as number); + }); + }); + + describe('POST /api/clis/:id and DELETE /api/clis/:id', () => { + const CUSTOM_CLI = { + label: 'Copilot', + shortBadge: 'GH', + accent: '#24292f', + enabled: true, + order: 60, + kind: 'agent', + discovery: { + binaries: ['copilot'], + searchDirs: ['~/.local/bin'], + install: { command: { linux: 'npm install -g @githubnext/github-copilot-cli' } }, + }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'copilot' }] }] }, + env: { + exports: [], + unset: [], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['COPILOT_'], + allowedKeys: [], + }, + capabilities: { + external: true, + requiresMux: true, + hooks: false, + transcript: 'none', + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + wheelForward: { mode: 'never' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' }, + privilegedParams: [], + gates: {}, + }, + overlays: {}, + }; + + it('adds a custom CLI, then removes it', async () => { + const addRes = await harness.app.inject({ method: 'POST', url: '/api/clis/copilot', payload: CUSTOM_CLI }); + expect(addRes.statusCode).toBe(200); + const addBody = JSON.parse(addRes.body); + expect(addBody.success).toBe(true); + const added = addBody.data.entries.find((c: { id: string }) => c.id === 'copilot'); + expect(added.label).toBe('Copilot'); + expect(added.stock).toBe(false); + + const listRes = await harness.app.inject({ method: 'GET', url: '/api/clis' }); + expect(JSON.parse(listRes.body).data.some((c: { id: string }) => c.id === 'copilot')).toBe(true); + + const delRes = await harness.app.inject({ method: 'DELETE', url: '/api/clis/copilot' }); + expect(delRes.statusCode).toBe(200); + const afterDelete = await harness.app.inject({ method: 'GET', url: '/api/clis' }); + expect(JSON.parse(afterDelete.body).data.some((c: { id: string }) => c.id === 'copilot')).toBe(false); + }); + + it('400s on a malformed custom CLI body', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/clis/broken', + payload: { ...CUSTOM_CLI, accent: 'not-a-colour' }, + }); + expect(res.statusCode).toBe(400); + }); + + it('refuses to add a custom CLI shadowing a stock id', async () => { + const res = await harness.app.inject({ method: 'POST', url: '/api/clis/codex', payload: CUSTOM_CLI }); + expect(res.statusCode).toBe(400); + }); + + it('refuses to remove a stock CLI', async () => { + const res = await harness.app.inject({ method: 'DELETE', url: '/api/clis/pi' }); + expect(res.statusCode).toBe(400); + }); + + it('400s removing an id that was never added', async () => { + const res = await harness.app.inject({ method: 'DELETE', url: '/api/clis/never-added' }); + expect(res.statusCode).toBe(400); + }); + }); + }); // end CLI registry writes + // ========== GET /api/execution/model-config ========== describe('GET /api/execution/model-config', () => { From 5858f007c9a27ed64627c385144d658d7ec914a5 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:50:10 +0800 Subject: [PATCH 09/15] feat(settings): add the Installed CLIs management UI (phase 8, frontend) App Settings -> Agents & CLIs gains an "Installed CLIs" group above the existing per-CLI (Claude/Codex) settings groups: a dynamic list backed by the phase-8-backend endpoints, plus a quick-add form for a custom CLI. - renderCliManagementList() fetches GET /api/clis and builds one row per entry (label, install status/hint, move up/down, an enable/disable toggle, and a Remove button for custom entries only). Re-fetches on every call rather than trusting the page-load window.__codemanClis snapshot, so a change made moments earlier in the same session shows up. - Row actions PUT /api/clis/:id/enabled, PUT /api/clis/order (swap-and- send-the-whole-order), and DELETE /api/clis/:id, each re-rendering the list from the response. - The quick-add form (id/label/binary/install command) POSTs a conservative default CliEntry when submitted - deliberately the SAME safe profile the registry already uses for an unrecognized CLI (external agent, requires tmux, no hooks, buffered echo, no privileged params) - and leaves everything else (launch flags, environment) to a direct edit of ~/.codeman/clis.json, which the row description says explicitly. Built entirely from EXISTING `.set-row`/`.set-row-actions`/`.switch` CSS classes already used elsewhere in this modal, so it needed no new stylesheet rules and renders consistently with the rest of the settings surface without touching styles.css/mobile.css. New test/cli-management-settings.test.ts (14 tests, vm-sandboxed like test/run-mode-ui.test.ts): render/error-handling, each row action's exact request shape, the quick-add form's validation and its request body (pinning the conservative-defaults claim above), and both the success and failure paths of adding a CLI. Full suite at the exact pre-existing baseline (55 failed files / 138 failed tests, unrelated to this work) plus 14 new passing tests, zero regressions. Still not visually verified in a live browser (no display available in this environment) - the settings-surface structure test (app-settings-structure.test.ts) and the new behavioral tests pass, but an actual look at the rendered "Installed CLIs" group is worth doing before relying on it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/web/public/index.html | 31 +++ src/web/public/settings-ui.js | 224 ++++++++++++++++++++ test/cli-management-settings.test.ts | 297 +++++++++++++++++++++++++++ 3 files changed, 552 insertions(+) create mode 100644 test/cli-management-settings.test.ts diff --git a/src/web/public/index.html b/src/web/public/index.html index da6d8aaf1..a34e28a5f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2035,6 +2035,37 @@

Agents & CLIs

Launch flags for the CLIs Codeman spawns.

+
+

Installed CLIs

synced
+
+

Enable, disable and reorder the CLIs offered in the run menu. Advanced options (launch flags, environment) can be refined by editing ~/.codeman/clis.json directly.

+
+
+
+ Add Custom CLI + Register another CLI Codeman can launch, given its binary name. +
+ +
+ + +
+
+

Claude

synced
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index e91a2806e..13dca8eb7 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -409,6 +409,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsCodexAnimations').checked = settings.codexAnimationsEnabled ?? false; this._applyCodexSettingsVisibility(); + this.renderCliManagementList(); // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false; @@ -550,6 +551,229 @@ Object.assign(CodemanApp.prototype, { if (group) group.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none'; }, + /** + * Agents & CLIs → Installed CLIs: the enable/disable/reorder/remove list backed by + * GET/PUT/POST/DELETE /api/clis(...). Re-fetches on every call (not cached against + * window.__codemanClis, which is a page-load snapshot) so the list reflects a change + * made moments ago in the same session. Each row is built from ONLY existing + * `.set-row`/`.set-row-actions` classes — no new CSS — so it renders consistently with + * every other row in this modal. + */ + async renderCliManagementList() { + const container = document.getElementById('appSettingsCliList'); + if (!container) return; + let clis; + try { + const res = await fetch('/api/clis'); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to load CLIs'); + clis = data.data; + } catch (err) { + container.textContent = `Failed to load CLI list: ${err.message}`; + return; + } + + container.replaceChildren(); + clis.forEach((cli, index) => { + const row = document.createElement('div'); + row.className = 'set-row'; + row.dataset.cliId = cli.id; + row.dataset.search = `${cli.label} ${cli.id} cli`; + + const text = document.createElement('div'); + text.className = 'set-row-text'; + const label = document.createElement('span'); + label.className = 'set-row-label'; + label.textContent = `${cli.label}${cli.stock ? '' : ' (custom)'}`; + const desc = document.createElement('span'); + desc.className = 'set-row-desc'; + desc.textContent = cli.available ? 'Installed' : cli.installHint || 'Not found on this host'; + text.append(label, desc); + + const actions = document.createElement('div'); + actions.className = 'set-row-actions'; + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'btn-toolbar btn-sm'; + upBtn.textContent = '↑'; + upBtn.title = 'Move up'; + upBtn.disabled = index === 0; + upBtn.onclick = () => this._moveCliOrder(clis, index, -1); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'btn-toolbar btn-sm'; + downBtn.textContent = '↓'; + downBtn.title = 'Move down'; + downBtn.disabled = index === clis.length - 1; + downBtn.onclick = () => this._moveCliOrder(clis, index, 1); + + const toggleLabel = document.createElement('label'); + toggleLabel.className = 'switch switch-sm'; + const toggleInput = document.createElement('input'); + toggleInput.type = 'checkbox'; + toggleInput.checked = cli.enabled; + toggleInput.onchange = () => this._setCliEnabled(cli.id, toggleInput.checked); + const slider = document.createElement('span'); + slider.className = 'slider'; + toggleLabel.append(toggleInput, slider); + + actions.append(upBtn, downBtn, toggleLabel); + + if (!cli.stock) { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn-toolbar btn-sm'; + removeBtn.textContent = 'Remove'; + removeBtn.onclick = () => this._removeCustomCli(cli.id, cli.label); + actions.append(removeBtn); + } + + row.append(text, actions); + container.appendChild(row); + }); + }, + + async _setCliEnabled(id, enabled) { + try { + const res = await fetch(`/api/clis/${encodeURIComponent(id)}/enabled`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to update'); + } catch (err) { + this.showToast?.(err.message, 'error'); + } + this.renderCliManagementList(); + }, + + async _moveCliOrder(currentList, index, delta) { + const target = index + delta; + if (target < 0 || target >= currentList.length) return; + const order = currentList.map((c) => c.id); + [order[index], order[target]] = [order[target], order[index]]; + try { + const res = await fetch('/api/clis/order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to reorder'); + } catch (err) { + this.showToast?.(err.message, 'error'); + } + this.renderCliManagementList(); + }, + + async _removeCustomCli(id, label) { + if (!confirm(`Remove ${label} (${id})? This only removes it from the run menu — nothing is uninstalled.`)) return; + try { + const res = await fetch(`/api/clis/${encodeURIComponent(id)}`, { method: 'DELETE' }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to remove'); + } catch (err) { + this.showToast?.(err.message, 'error'); + } + this.renderCliManagementList(); + }, + + toggleAddCliForm(show) { + const row = document.getElementById('addCliFormRow'); + if (!row) return; + const visible = show === undefined ? row.style.display === 'none' : show; + row.style.display = visible ? '' : 'none'; + if (!visible) { + document.getElementById('appSettingsAddCliStatus').textContent = ''; + ['appSettingsNewCliId', 'appSettingsNewCliLabel', 'appSettingsNewCliBinary', 'appSettingsNewCliInstall'].forEach((id) => { + const el = document.getElementById(id); + if (el) el.value = ''; + }); + } + }, + + /** + * Build a minimal-but-valid custom CliEntry from the quick-add form and POST it. + * Deliberately conservative defaults (external/requiresMux true, no hooks, buffered + * echo, alt-screen preserved via strip-mux-only, no privileged params) — the SAME + * "behaves like pi" profile the registry documents for an unrecognized CLI, since + * that is the safest baseline for a CLI this form knows nothing else about. Advanced + * customization (launch flags, env) is a direct edit of ~/.codeman/clis.json, not + * something this quick form tries to cover. + */ + async submitAddCliForm() { + const status = document.getElementById('appSettingsAddCliStatus'); + const id = document.getElementById('appSettingsNewCliId').value.trim().toLowerCase(); + const label = document.getElementById('appSettingsNewCliLabel').value.trim(); + const binary = document.getElementById('appSettingsNewCliBinary').value.trim(); + const install = document.getElementById('appSettingsNewCliInstall').value.trim(); + + if (!/^[a-z][a-z0-9-]{0,23}$/.test(id)) { + status.textContent = 'id must be lowercase letters/digits/hyphens, starting with a letter.'; + return; + } + if (!label || !binary) { + status.textContent = 'Label and binary name are required.'; + return; + } + + const entry = { + label, + shortBadge: label.slice(0, 2).toUpperCase(), + accent: '#6b7280', + enabled: true, + order: 1000, + kind: 'agent', + discovery: { + binaries: [binary], + searchDirs: ['~/.local/bin', '/usr/local/bin', '~/.npm-global/bin', '~/bin'], + install: { command: install ? { linux: install, darwin: install } : {} }, + }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: binary }] }] }, + env: { exports: [], unset: [], tmuxSetenvKeys: [], dockerExecEnvNames: [], allowedPrefixes: [], allowedKeys: [] }, + capabilities: { + external: true, + requiresMux: true, + hooks: false, + transcript: 'none', + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + wheelForward: { mode: 'never' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' }, + privilegedParams: [], + gates: {}, + }, + overlays: {}, + }; + + try { + const res = await fetch(`/api/clis/${encodeURIComponent(id)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to add CLI'); + } catch (err) { + status.textContent = err.message; + return; + } + this.toggleAddCliForm(false); + this.renderCliManagementList(); + }, + /** * Scroll the settings document to a section. * diff --git a/test/cli-management-settings.test.ts b/test/cli-management-settings.test.ts new file mode 100644 index 000000000..b1a21853f --- /dev/null +++ b/test/cli-management-settings.test.ts @@ -0,0 +1,297 @@ +/** + * @fileoverview Tests for the "Installed CLIs" settings-UI surface (App Settings → + * Agents & CLIs): the dynamic list backed by GET/PUT/POST/DELETE /api/clis(...), added in + * settings-ui.js. Loads the real module into a vm sandbox (no real DOM) and drives it + * against a stubbed `document`/`fetch`, matching the pattern in test/run-mode-ui.test.ts. + * + * Port: N/A (no server; vm-sandboxed unit tests). + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const STOCK_ROW = (overrides: Record = {}) => ({ + id: 'gemini', + label: 'Gemini', + stock: true, + enabled: true, + available: true, + installHint: null, + ...overrides, +}); + +function loadHarness(fetchImpl: (url: string, init?: unknown) => Promise) { + const elements: Record = {}; + const CodemanApp = function CodemanApp(this: any) {}; + const context: any = vm.createContext({ + CodemanApp, + MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false }, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { + getElementById: (id: string) => elements[id] ?? null, + createElement: (tag: string) => { + const el: any = { + tagName: tag, + className: '', + textContent: '', + title: '', + checked: false, + disabled: false, + type: '', + value: '', + dataset: {}, + onclick: null, + onchange: null, + children: [] as any[], + append(...nodes: any[]) { + this.children.push(...nodes); + }, + }; + return el; + }, + }, + fetch: fetchImpl, + confirm: () => true, + console, + }); + context.window = context; + + const list = { replaceChildren: vi.fn(), textContent: '', appendChild: vi.fn(), children: [] as any[] }; + elements.appSettingsCliList = list; + elements.appSettingsAddCliStatus = { textContent: '' }; + elements.addCliFormRow = { style: { display: 'none' } }; + elements.appSettingsNewCliId = { value: '' }; + elements.appSettingsNewCliLabel = { value: '' }; + elements.appSettingsNewCliBinary = { value: '' }; + elements.appSettingsNewCliInstall = { value: '' }; + + const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8'); + vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' }); + + const app = new (CodemanApp as any)(); + app.showToast = vi.fn(); + return { app, elements, list }; +} + +describe('renderCliManagementList', () => { + it('fetches /api/clis and builds one row per entry', async () => { + const fetchMock = vi.fn(async (url: string) => { + expect(url).toBe('/api/clis'); + return { json: async () => ({ success: true, data: [STOCK_ROW(), STOCK_ROW({ id: 'pi', label: 'Pi' })] }) }; + }); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + expect(fetchMock).toHaveBeenCalledWith('/api/clis'); + expect(list.replaceChildren).toHaveBeenCalledTimes(1); + expect(list.appendChild).toHaveBeenCalledTimes(2); + }); + + it('shows the install hint for an unavailable CLI, not a generic message', async () => { + const fetchMock = vi.fn(async () => ({ + json: async () => ({ + success: true, + data: [ + STOCK_ROW({ + id: 'codex', + label: 'Codex', + available: false, + installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex', + }), + ], + }), + })); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + const row = list.appendChild.mock.calls[0][0]; + const desc = row.children + .find((c: any) => c.className === 'set-row-text') + .children.find((c: any) => c.className === 'set-row-desc'); + expect(desc.textContent).toContain('npm install -g @openai/codex'); + }); + + it('reports a fetch failure inline instead of throwing', async () => { + const fetchMock = vi.fn(async () => { + throw new Error('network down'); + }); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + expect(list.textContent).toContain('network down'); + expect(list.appendChild).not.toHaveBeenCalled(); + }); + + it('does nothing (does not throw) when the container is absent', async () => { + const { app, elements } = loadHarness(vi.fn()); + delete elements.appSettingsCliList; + await expect(app.renderCliManagementList()).resolves.toBeUndefined(); + }); +}); + +describe('CLI row actions', () => { + it('_setCliEnabled PUTs the new state and re-renders', async () => { + const calls: Array<{ url: string; init?: any }> = []; + const fetchMock = vi.fn(async (url: string, init?: any) => { + calls.push({ url, init }); + if (url.endsWith('/enabled')) return { json: async () => ({ success: true }) }; + return { json: async () => ({ success: true, data: [STOCK_ROW({ enabled: false })] }) }; + }); + const { app } = loadHarness(fetchMock); + + await app._setCliEnabled('gemini', false); + + const putCall = calls.find((c) => c.url === '/api/clis/gemini/enabled'); + expect(putCall).toBeDefined(); + expect(putCall!.init.method).toBe('PUT'); + expect(JSON.parse(putCall!.init.body)).toEqual({ enabled: false }); + // Re-render fetched the list again afterward. + expect(calls.some((c) => c.url === '/api/clis')).toBe(true); + }); + + it('_setCliEnabled surfaces a failure via showToast without throwing', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/enabled')) return { json: async () => ({ success: false, error: 'nope' }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + + await app._setCliEnabled('gemini', false); + + expect(app.showToast).toHaveBeenCalledWith('nope', 'error'); + }); + + it('_moveCliOrder swaps the two ids and PUTs the resulting order', async () => { + const calls: Array<{ url: string; init?: any }> = []; + const fetchMock = vi.fn(async (url: string, init?: any) => { + calls.push({ url, init }); + if (url === '/api/clis/order') return { json: async () => ({ success: true }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + const list = [STOCK_ROW({ id: 'a' }), STOCK_ROW({ id: 'b' }), STOCK_ROW({ id: 'c' })]; + + await app._moveCliOrder(list, 1, -1); + + const orderCall = calls.find((c) => c.url === '/api/clis/order'); + expect(JSON.parse(orderCall!.init.body)).toEqual({ order: ['b', 'a', 'c'] }); + }); + + it('_moveCliOrder is a no-op past either edge of the list', async () => { + const fetchMock = vi.fn(async () => ({ json: async () => ({ success: true, data: [] }) })); + const { app } = loadHarness(fetchMock); + const list = [STOCK_ROW({ id: 'a' }), STOCK_ROW({ id: 'b' })]; + + await app._moveCliOrder(list, 0, -1); + await app._moveCliOrder(list, 1, 1); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('_removeCustomCli confirms, DELETEs, and re-renders', async () => { + const calls: string[] = []; + const fetchMock = vi.fn(async (url: string, init?: any) => { + calls.push(`${init?.method ?? 'GET'} ${url}`); + if (init?.method === 'DELETE') return { json: async () => ({ success: true }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + + await app._removeCustomCli('copilot', 'Copilot'); + + expect(calls).toContain('DELETE /api/clis/copilot'); + expect(calls).toContain('GET /api/clis'); + }); +}); + +describe('add-custom-CLI form', () => { + it('toggleAddCliForm shows the row and clears fields on hide', () => { + const { app, elements } = loadHarness(vi.fn()); + elements.appSettingsNewCliId.value = 'leftover'; + + app.toggleAddCliForm(true); + expect(elements.addCliFormRow.style.display).toBe(''); + + app.toggleAddCliForm(false); + expect(elements.addCliFormRow.style.display).toBe('none'); + expect(elements.appSettingsNewCliId.value).toBe(''); + }); + + it('rejects an id that is not lowercase-kebab without calling fetch', async () => { + const fetchMock = vi.fn(); + const { app, elements } = loadHarness(fetchMock); + elements.appSettingsNewCliId.value = 'Not Valid'; + elements.appSettingsNewCliLabel.value = 'Whatever'; + elements.appSettingsNewCliBinary.value = 'whatever'; + + await app.submitAddCliForm(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(elements.appSettingsAddCliStatus.textContent).toContain('lowercase'); + }); + + it('requires a label and a binary name', async () => { + const fetchMock = vi.fn(); + const { app, elements } = loadHarness(fetchMock); + elements.appSettingsNewCliId.value = 'copilot'; + + await app.submitAddCliForm(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(elements.appSettingsAddCliStatus.textContent).toContain('required'); + }); + + it('POSTs a conservative default entry and closes the form on success', async () => { + const calls: Array<{ url: string; init: any }> = []; + const fetchMock = vi.fn(async (url: string, init?: any) => { + calls.push({ url, init }); + if (init?.method === 'POST') return { json: async () => ({ success: true }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app, elements } = loadHarness(fetchMock); + elements.appSettingsNewCliId.value = 'copilot'; + elements.appSettingsNewCliLabel.value = 'GitHub Copilot'; + elements.appSettingsNewCliBinary.value = 'copilot'; + elements.appSettingsNewCliInstall.value = 'npm install -g copilot-cli'; + + await app.submitAddCliForm(); + + const postCall = calls.find((c) => c.url === '/api/clis/copilot'); + expect(postCall).toBeDefined(); + const body = JSON.parse(postCall!.init.body); + expect(body.label).toBe('GitHub Copilot'); + expect(body.discovery.binaries).toEqual(['copilot']); + expect(body.discovery.install.command.linux).toBe('npm install -g copilot-cli'); + // Conservative defaults — see submitAddCliForm's own doc comment: same profile as + // an unrecognized CLI (external agent, no bypass, no hooks, buffered echo). + expect(body.capabilities.external).toBe(true); + expect(body.capabilities.requiresMux).toBe(true); + expect(body.capabilities.hooks).toBe(false); + expect(body.capabilities.privilegedParams).toEqual([]); + expect(body.enabled).toBe(true); + // Form was closed (re-hidden) after success. + expect(elements.addCliFormRow.style.display).toBe('none'); + }); + + it('leaves the form open and shows the server error on failure', async () => { + const fetchMock = vi.fn(async (url: string, init?: any) => { + if (init?.method === 'POST') return { json: async () => ({ success: false, error: 'id already exists' }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app, elements } = loadHarness(fetchMock); + elements.appSettingsNewCliId.value = 'copilot'; + elements.appSettingsNewCliLabel.value = 'GitHub Copilot'; + elements.appSettingsNewCliBinary.value = 'copilot'; + elements.addCliFormRow.style.display = ''; + + await app.submitAddCliForm(); + + expect(elements.appSettingsAddCliStatus.textContent).toBe('id already exists'); + expect(elements.addCliFormRow.style.display).toBe(''); // still open + }); +}); From c0ede803a6507d420401454d6d888d664f5ad80d Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:11:20 +0800 Subject: [PATCH 10/15] feat(cli-registry): drive install.sh, Docker agent image, and session mode validation from the registry (phases 9-10) Phase 9 - install.sh + Docker: - Generate config/clis.stock.json from the compiled stock catalog (src/config/cli-registry/stock.ts) via scripts/generate-cli-stock-json.mjs (npm run generate:cli-stock-json). test/cli-stock-json-sync.test.ts pins the two in sync, same pattern as test/sse-registry-parity.test.ts. - install.sh replaces its six hardcoded *_SEARCH_PATHS arrays and check_*/get_*_path function pairs with one generic check_cli()/get_cli_path() driven by load_cli_registry(), which fetches config/clis.stock.json from raw.githubusercontent.com (this script runs standalone via curl | bash before the repo is cloned or built, so it cannot import TypeScript) and parses it with node. Falls back to a small built-in JSON literal (Claude Code + OpenCode only) on a fetch/parse failure rather than aborting the install. The interactive "which AI CLI" prompt and the closing "install one later" reminder now iterate the full registry instead of a hardcoded list. - ~/.codeman/clis.json preservation across `install.sh update` is made explicit: it already held by construction (the file lives outside $INSTALL_DIR, which is all update() touches), and update() now says so and confirms it in its output. - docker/agent.Dockerfile takes the npm-installable CLIs' package names as build ARGs (CLI_NPM_PACKAGES, CLI_PI_NPM_PACKAGE); scripts/build-agent-image.mjs reads config/clis.stock.json and passes them via --build-arg, so a new stock CLI with a plain `npm install -g ` install command needs no Dockerfile edit. Antigravity (no npmPackage - a standalone binary installer) and Pi's --ignore-scripts flag stay documented Dockerfile special cases, the sanctioned per-CLI exception. Phase 10 - docs: - New docs/cli-registry.md: file layout, merge/seed model, editing via settings UI or API, the CliEntry schema, arg-template safety, and how install.sh/Docker consume the registry. - docs/wiki/Agent-CLIs.md: rewritten intro frames the CLI set as data-driven, points at the new doc and the settings UI, before the existing per-CLI notes (kept as the sanctioned documentation exception). - docs/extending-codeman.md: notes that `mode` is registry-driven, not a fixed enum, and points integrators at GET /api/clis. - CLAUDE.md: replaced the stale "SessionMode = 'claude' | ... | 'pi'" Tech Stack line with a description of the registry. - README.md: two lines note the CLI set is a config file, not a fixed list. - .changeset/72f92691.md: minor changeset describing the whole feature. Also closes a real functional gap in the earlier phases: session-mode validation (CreateSessionSchema.mode, QuickStartSchema.mode, CronJobBaseSchema.agentType in src/web/schemas.ts) was still three hardcoded 7-value z.enum() arrays, so a custom CLI added through the Phase 8 settings UI would appear in menus but be REJECTED by POST /api/sessions with mode set to its id. Replaced with a schema built from enabledClis() at module load (sessionModeSchema()), matching the plan's "z.enum(registryIds())" compatibility design. SessionMode itself stays the literal union for now (retyping it would also collapse RemoteCommandMode/DockerCommandMode, a separately-scoped change); the schema's output is cast to SessionMode with a documented rationale, since Zod validates against the live registry at runtime and no downstream code exhaustively switches on the id (test/cli-registry-no-id-branching.test.ts enforces that). Verified: npm run typecheck, npm run lint, npm run format:check all clean. Full npm test matches the known baseline exactly (55 failed files / 138 failed tests, all pre-existing and confirmed via git stash comparison - none touch code this commit changed). All CLI-registry-focused suites green (12 files, 341 tests). Not done, left for a follow-up: retyping SessionMode as a non-literal id (the RemoteCommandMode/DockerCommandMode Extract<> usages need their own pass first), and fully automating non-npm CLI installs in the Docker image (a curl-based installer, like Antigravity's, still needs a Dockerfile edit). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- .changeset/72f92691.md | 34 +++ CLAUDE.md | 2 +- README.md | 4 +- config/clis.stock.json | 150 ++++++++++ docker/agent.Dockerfile | 35 ++- docs/cli-registry.md | 122 ++++++++ docs/extending-codeman.md | 8 + docs/wiki/Agent-CLIs.md | 36 ++- install.sh | 449 ++++++++++++---------------- package.json | 1 + scripts/build-agent-image.mjs | 34 +++ scripts/generate-cli-stock-json.mjs | 37 +++ src/web/schemas.ts | 30 +- test/cli-stock-json-sync.test.ts | 26 ++ 14 files changed, 681 insertions(+), 287 deletions(-) create mode 100644 .changeset/72f92691.md create mode 100644 config/clis.stock.json create mode 100644 docs/cli-registry.md create mode 100644 scripts/generate-cli-stock-json.mjs create mode 100644 test/cli-stock-json-sync.test.ts diff --git a/.changeset/72f92691.md b/.changeset/72f92691.md new file mode 100644 index 000000000..638f43798 --- /dev/null +++ b/.changeset/72f92691.md @@ -0,0 +1,34 @@ +--- +"aicodeman": minor +--- + +CLI backends are now a data-driven registry instead of a hardcoded set. Every CLI (Claude +Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, or a custom one you add) is +a `CliEntry` in a central registry — a shipped stock catalog layered with user overrides in +`~/.codeman/clis.json`. Adding, removing, reordering, or reconfiguring a CLI is now a +settings change, not a code change. + +- New App Settings → Agents & CLIs → **Installed CLIs** panel: enable/disable, reorder, + and add/remove custom CLI entries. +- New API: `GET /api/clis`, `PUT /api/clis/:id/enabled`, `PUT /api/clis/order`, + `POST /api/clis/:id`, `DELETE /api/clis/:id`, plus the generic + `GET /api/cli/:id/status` (the five legacy `/api//status` routes are kept as + aliases, so nothing breaks). +- `install.sh` and the Docker agent-image build now read the same registry (via a + generated `config/clis.stock.json` export) instead of keeping their own hardcoded + per-CLI search paths and install steps, so a new stock CLI needs no installer or + Dockerfile change. +- `SessionMode`/`agentType` validation is now built from the live, enabled registry + rather than a fixed literal enum, so a custom CLI added through the settings UI is + immediately usable as a session `mode`, not just visible in menus. +- Internally: the five per-CLI resolvers, command builders, and capability checks + (`isExternalCliMode`, `isAltScreenStripMode`, `hooksAvailableForMode`, and friends) + now read capability flags off the registry instead of branching on the CLI's name. + Verified byte-identical against the previous hand-written command builders for the + stock catalog (`test/cli-registry-argv-parity.test.ts`), and a static guard + (`test/cli-registry-no-id-branching.test.ts`) keeps per-CLI-id branching out of every + file except the stock catalog itself. +- New docs: [`docs/cli-registry.md`](../docs/cli-registry.md). + +No behavior change for existing installs — the stock catalog reproduces every existing +CLI's launch command, environment handling, and capabilities exactly. diff --git a/CLAUDE.md b/CLAUDE.md index e6efbd1c5..d6c659960 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the b Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs. -**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google) and Pi (pi.dev) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'`). +**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Ships stock support for Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google) and Pi (pi.dev), but the set of CLI backends is **data, not code**: every one is a `CliEntry` in the CLI registry (`src/config/cli-registry/`, overrides in `~/.codeman/clis.json`), and `SessionMode` (`src/types/session.ts`) is a string id resolved against it rather than a fixed set of names. Adding, removing, or reconfiguring a CLI — including a custom one, e.g. GitHub Copilot CLI — needs no code change; see [`docs/cli-registry.md`](docs/cli-registry.md). **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. diff --git a/README.md b/README.md index 9847d979d..bb10c11bb 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ codeman web The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation). -- **One dashboard, six CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) +- **One dashboard, any CLI** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) — the set of CLIs is a config file, not a fixed list, so adding another agent CLI is a settings change, not a code change ([`docs/cli-registry.md`](docs/cli-registry.md)) - **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications - **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs - **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts @@ -437,7 +437,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials -- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, or **Pi** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md) and [`docs/pi-integration.md`](docs/pi-integration.md) +- **Multi-CLI, extensible** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, or **Pi** per session, or add your own (App Settings → Agents & CLIs, or edit `~/.codeman/clis.json` — see [`docs/cli-registry.md`](docs/cli-registry.md)); env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md) and [`docs/pi-integration.md`](docs/pi-integration.md) - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md) - **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md) - **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too diff --git a/config/clis.stock.json b/config/clis.stock.json new file mode 100644 index 000000000..d457c161c --- /dev/null +++ b/config/clis.stock.json @@ -0,0 +1,150 @@ +[ + { + "id": "claude", + "label": "Claude", + "stock": true, + "discovery": { + "binaries": ["claude"], + "searchDirs": ["~/.local/bin", "~/.claude/local", "/usr/local/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)", + "retryOnTransientFailure": true + }, + "install": { + "command": { + "linux": "curl -fsSL https://claude.ai/install.sh | bash", + "darwin": "curl -fsSL https://claude.ai/install.sh | bash", + "wsl": "curl -fsSL https://claude.ai/install.sh | bash" + }, + "npmPackage": "@anthropic-ai/claude-code", + "docsUrl": "https://docs.claude.com/claude-code" + } + } + }, + { + "id": "shell", + "label": "Shell", + "stock": true, + "discovery": { + "binaries": [], + "searchDirs": [], + "install": { + "command": {} + } + } + }, + { + "id": "opencode", + "label": "OpenCode", + "stock": true, + "discovery": { + "binaries": ["opencode"], + "searchDirs": [ + "~/.opencode/bin", + "~/.local/bin", + "/usr/local/bin", + "~/go/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "curl -fsSL https://opencode.ai/install | bash", + "darwin": "curl -fsSL https://opencode.ai/install | bash" + }, + "npmPackage": "opencode-ai", + "docsUrl": "https://opencode.ai/docs" + } + } + }, + { + "id": "codex", + "label": "Codex", + "stock": true, + "discovery": { + "binaries": ["codex"], + "searchDirs": ["~/.codex/bin", "~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "npm install -g @openai/codex", + "darwin": "npm install -g @openai/codex" + }, + "npmPackage": "@openai/codex", + "docsUrl": "https://developers.openai.com/codex/cli" + } + } + }, + { + "id": "gemini", + "label": "Gemini", + "stock": true, + "discovery": { + "binaries": ["gemini"], + "searchDirs": ["~/.gemini/bin", "~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "npm install -g @google/gemini-cli", + "darwin": "npm install -g @google/gemini-cli" + }, + "npmPackage": "@google/gemini-cli", + "docsUrl": "https://github.com/google-gemini/gemini-cli" + } + } + }, + { + "id": "antigravity", + "label": "Antigravity", + "stock": true, + "discovery": { + "binaries": ["agy"], + "searchDirs": ["~/.local/bin", "~/.antigravity/bin", "/usr/local/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "curl -fsSL https://antigravity.google/cli/install.sh | bash", + "darwin": "curl -fsSL https://antigravity.google/cli/install.sh | bash" + }, + "docsUrl": "https://antigravity.google/cli" + } + } + }, + { + "id": "pi", + "label": "Pi", + "stock": true, + "discovery": { + "binaries": ["pi"], + "searchDirs": ["~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(?:^|\\s)(\\d+\\.\\d+\\.\\d+)", + "requireVersionMatch": true + }, + "install": { + "command": { + "linux": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent", + "darwin": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent" + }, + "npmPackage": "@earendil-works/pi-coding-agent", + "docsUrl": "https://pi.dev" + } + } + } +] diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index d0c1accc3..55a97d5d6 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -26,28 +26,35 @@ RUN apt-get update \ openssh-client \ && rm -rf /var/lib/apt/lists/* -# The npm-published agent CLIs. Pinning is left to the rebuild cadence (see -# docs/docker-cases-plan.md, user-decision 2). -RUN npm install -g \ - @anthropic-ai/claude-code \ - @openai/codex \ - @google/gemini-cli \ - opencode-ai \ +# The npm-published agent CLIs. Package list is a build ARG, populated by +# scripts/build-agent-image.mjs from the live CLI registry (config/cli-registry) — +# a new registry entry with a plain `npm install -g ` install command (the +# common case, e.g. a future GitHub Copilot CLI entry) is picked up here with NO +# Dockerfile edit. Defaults preserve today's four CLIs for a hand-run +# `docker build` that skips the wrapper script. Pinning is left to the rebuild +# cadence (see docs/docker-cases-plan.md, user-decision 2). +ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai" +RUN npm install -g ${CLI_NPM_PACKAGES} \ && npm cache clean --force -# Antigravity (`agy`) is NOT on npm — Google ships a standalone binary through its -# own installer, so it needs its own step. `--dir /usr/local/bin` is load-bearing: -# the installer's default target is `$HOME/.local/bin`, which at build time is -# root's home and would be unreachable by the `agent` user the container runs as. +# Antigravity (`agy`) has no npmPackage in the registry — it is NOT on npm, Google +# ships a standalone binary through its own installer — so it stays a documented +# Dockerfile special case rather than a generic npm-install line (the sanctioned +# per-CLI exception; see docs/cli-registry.md). `--dir /usr/local/bin` is +# load-bearing: the installer's default target is `$HOME/.local/bin`, which at +# build time is root's home and would be unreachable by the `agent` user the +# container runs as. # ⚠️ This binary is ~190MB on its own; it is the single largest layer in the image. -RUN curl -fsSL https://antigravity.google/cli/install.sh | bash -s -- --dir /usr/local/bin \ +ARG CLI_ANTIGRAVITY_INSTALL_URL="https://antigravity.google/cli/install.sh" +RUN curl -fsSL "${CLI_ANTIGRAVITY_INSTALL_URL}" | bash -s -- --dir /usr/local/bin \ && chmod 755 /usr/local/bin/agy \ && agy --version # Pi (pi.dev). Upstream documents --ignore-scripts (pi needs no lifecycle scripts); # kept out of the shared npm block above so the flag cannot silently change how the -# other four CLIs install. -RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent \ +# other four CLIs install. Package name is still a build ARG from the registry. +ARG CLI_PI_NPM_PACKAGE="@earendil-works/pi-coding-agent" +RUN npm install -g --ignore-scripts ${CLI_PI_NPM_PACKAGE} \ && npm cache clean --force \ && pi --version diff --git a/docs/cli-registry.md b/docs/cli-registry.md new file mode 100644 index 000000000..ad3d3b4b0 --- /dev/null +++ b/docs/cli-registry.md @@ -0,0 +1,122 @@ +# CLI Registry + +Codeman's set of supported CLI backends is **data, not code**. Every CLI — Claude Code, a +plain shell, OpenCode, Codex, Gemini, Antigravity, Pi, or one you add yourself — is a +`CliEntry` in a central registry. Nothing downstream branches on a CLI's name; it reads +capability flags instead. Adding GitHub Copilot CLI, or any other agent CLI, is a config +entry, not a code change. + +## Where it lives + +| Layer | File | Role | +| --- | --- | --- | +| Stock catalog | `src/config/cli-registry/stock.ts` | Compiled into the app. The seven shipped entries, byte-identical (via the argv engine) to what earlier hand-written builders produced. | +| User overrides | `~/.codeman/clis.json` (`dataPath('clis.json')`) | **Overrides and custom entries only** — never the full catalog. Small and readable by design. | +| install.sh export | `config/clis.stock.json` | A generated, install-time-only subset (id/label/discovery) of the stock catalog, fetched by `install.sh` before the repo is even cloned. Regenerate with `npm run generate:cli-stock-json`; `test/cli-stock-json-sync.test.ts` pins it in sync with `stock.ts`. | + +At load time (`src/config/cli-registry/registry.ts`), the stock catalog is deep-merged with +`~/.codeman/clis.json`: objects merge key-wise, **arrays replace wholesale** (a half-merged +`searchDirs` is not reasonable). A malformed **stock** override falls back to the pristine +stock definition rather than bricking a shipped CLI; a malformed **custom** entry is dropped +with a warning rather than failing the whole load. + +### The seeding ratchet + +`clis.json` tracks `seededStockIds` — the stock ids already introduced to this install. On +every load, any stock id not yet in that list is added, enabled, and appended to the list. +That is what lets a shipped update add a new stock CLI automatically while a CLI you +explicitly disabled stays disabled forever (its id is already seeded, so the ratchet never +touches it again). `shell` and `claude` can be disabled but never deleted. + +## Editing it + +- **Settings UI** (recommended): App Settings → Agents & CLIs → **Installed CLIs**. Enable, + disable, reorder, or add a custom entry. The add form uses conservative defaults — the + same profile as an unrecognized CLI: external agent, requires tmux, no hooks, no bypass + flag, buffered echo. +- **API**: `GET /api/clis` (full merged registry, plus live `available`/`path`/`version`/ + `installHint` per entry), `PUT /api/clis/:id/enabled`, `PUT /api/clis/order`, + `POST /api/clis/:id` (add or replace a custom entry — refuses a stock id), + `DELETE /api/clis/:id` (refuses a stock id). All admin-gated in multi-user mode. +- **Hand-editing `~/.codeman/clis.json`**: the loader validates on every read, so a syntax + or schema error degrades to a warning and the pristine/omitted entry, never a broken + server. + +## The shape of an entry (`CliEntry`) + +Full type definitions: `src/config/cli-registry/types.ts`. The top-level shape: + +```ts +interface CliEntry { + id: string; // e.g. "codex" — becomes the run-mode id everywhere + label: string; // "Codex" — shown in menus + shortBadge: string; // tab badge, e.g. "CX" + accent: string; // single hex colour; CSS derives every per-CLI gradient from it + enabled: boolean; + stock: boolean; // set by the loader; a custom entry can never claim it + order: number; + kind: 'agent' | 'shell'; + discovery: CliDiscovery; // how to find/probe the binary, and how to install it + launch: CliLaunch; // the structured argv template — see "Arg-template safety" below + env: CliEnv; // env var export/unset/allowlist/tmux-setenv-secret rules + capabilities: CliCapabilities; // the flags every call site reads instead of the id + overlays: CliOverlays; // remote-SSH / Docker command overrides, credential store +} +``` + +`capabilities` is the important part for anyone extending Codeman: it is what +`isExternalCliMode()`, `isAltScreenStripMode()`, `hooksAvailableForMode()`, and every other +per-mode branch actually read. A brand-new CLI added through the settings UI gets the +conservative defaults — the same shape as Pi, the mode with the fewest assumptions baked in. + +## Arg-template safety + +`launch` never contains shell text. The composed command line is interpolated into +`bash -c "…"` inside tmux, which makes command construction a security boundary, so every +entry is a structured argv spec instead of a string: + +- Every literal token is validated at load against a safe-word pattern (no space, quote, + backtick, `$`, `;`, `&`, `|`, redirection, parens, braces, newline, or backslash). A + literal that fails **rejects the whole entry** — a *flag* silently dropped would change + security-relevant behaviour (e.g. losing `--no-approve`). +- A value placeholder picks a **named** `TokenPattern` (`model`, `uuid`, `slug`, `tool-list`, + …) from `src/config/cli-registry/patterns.ts`; config can never supply its own regex for a + value, so there is no ReDoS surface there. The one config-supplied regex, + `discovery.version.regex`, is compiled through a nested-quantifier guard and run only + against `--version` output truncated to 200 chars. +- Rendering (`src/config/cli-registry/argv.ts`) escapes unconditionally and independently of + validation: a token is emitted verbatim only if it matches the safe-word pattern, and + single-quote-wrapped otherwise. This is what keeps a hostile model name or session name + from escaping into the shell even if a check upstream were ever bypassed. +- `test/cli-registry-argv-parity.test.ts` asserts the new engine's output is byte-identical + to the original hand-written builders for the stock catalog, and + `test/cli-registry-no-id-branching.test.ts` fails the build if a `mode === ''` + branch reappears anywhere outside `stock.ts`. + +## install.sh and the Docker agent image + +Both run **before** anything in this repo is necessarily built or even cloned, so neither +can import TypeScript: + +- **`install.sh`** fetches `config/clis.stock.json` from `raw.githubusercontent.com` + (derived from `$CODEMAN_REPO_URL`/`$CODEMAN_BRANCH`) and parses it with a plain `node -e` + once Node.js is confirmed installed. A fetch or parse failure falls back to a small + built-in JSON literal (Claude Code + OpenCode detection only) rather than aborting the + install. This drives CLI detection (`check_cli`/`get_cli_path`) and the "install one + later" hints generically — a CLI added to the stock catalog needs no `install.sh` change. +- **`docker/agent.Dockerfile`** takes the npm-installable CLIs' package names as build + ARGs (`CLI_NPM_PACKAGES`, `CLI_PI_NPM_PACKAGE`). `scripts/build-agent-image.mjs` reads + `config/clis.stock.json` and passes them via `--build-arg`, so a new stock entry with a + plain `npm install -g ` install command is picked up with no Dockerfile edit. A CLI + installed some other way (a standalone binary via curl, like Antigravity, or one needing + extra flags, like Pi's `--ignore-scripts`) stays a documented Dockerfile special case — + the sanctioned per-CLI exception, same as the prose notes in + [Agent CLIs](wiki/Agent-CLIs.md). + +## See also + +- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide (what each one is, + its own quirks, choosing between them). +- `docs/extending-codeman.md` — third-party integration surfaces, including `/api/clis`. +- `docs/architecture-invariants.md` — implementation mechanics and the history behind the + security-relevant rules above. diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index e2924499b..10725fd2d 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -186,6 +186,14 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \ -d '{"input":"run the tests\r","useMux":true}' ``` +`mode` above is not a fixed enum — it is one of the ids in the CLI registry +(`GET /api/clis` lists every registered CLI, enabled or not, plus live +`available`/`path`/`version`/`installHint` per entry). The stock catalog ships +`claude`, `shell`, `opencode`, `codex`, `gemini`, `antigravity`, and `pi`, but an +install can add or remove entries through App Settings → Agents & CLIs, so an +integration that hardcodes that list will miss a custom CLI. See +[CLI Registry](cli-registry.md). + `POST .../input` also accepts `clientId` (stable per client, max 128 chars) and `seq` (monotonic per session). Send both and the server applies each pair at-most-once, so retrying after a dropped connection cannot type the prompt diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 6c34f274f..a76a9669e 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -1,23 +1,29 @@ # Agent CLIs -Codeman drives seven run modes: six agent CLIs plus a plain shell. This page covers picking -one, setting it up, and the differences that actually change how you work. +Codeman drives whatever CLI backends are registered — a plain shell plus a set of +agent CLIs. That set is **data, not code**: it lives in a central CLI registry +(`~/.codeman/clis.json`, layered over a shipped stock catalog), not in a hardcoded list +anywhere in the app. Enabling, disabling, reordering, or adding a CLI is a settings +change, never a code change. See [CLI Registry](CLI-Registry) for the schema and how to +add one (e.g. GitHub Copilot CLI, or any future agent CLI). -## The seven modes +Out of the box the stock catalog ships seven entries: | Mode | CLI | Get it | -| -------------------- | ---------------------------- | ---------------------------------------------------------------------- | -| **Claude Code** | `claude` | [docs.anthropic.com](https://docs.anthropic.com/en/docs/claude-code) | -| **OpenCode** | `opencode` | [opencode.ai](https://opencode.ai) | -| **Codex** | `codex` | [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli) | -| **Gemini** | `gemini` | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | -| **Antigravity** | `agy` | [antigravity.google](https://antigravity.google) | -| **Pi** | `pi` | [pi.dev](https://pi.dev) | -| **Terminal / Shell** | your `$SHELL` | Already installed. | - -Any combination works, including all of them. The run mode is chosen per session from the -arrow beside the **Run** button, so one case can have a Claude session and a Codex session -open side by side. +| -------------------- | ----------------------------- | ------------------------------------------------------------------------ | +| **Claude Code** | `claude` | [docs.anthropic.com](https://docs.anthropic.com/en/docs/claude-code) | +| **OpenCode** | `opencode` | [opencode.ai](https://opencode.ai) | +| **Codex** | `codex` | [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli) | +| **Gemini** | `gemini` | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | +| **Antigravity** | `agy` | [antigravity.google](https://antigravity.google) | +| **Pi** | `pi` | [pi.dev](https://pi.dev) | +| **Terminal / Shell** | your `$SHELL` | Already installed. | + +Any combination works, including all of them, plus anything you add yourself. The run +mode is chosen per session from the arrow beside the **Run** button (built from +whichever CLIs are currently enabled), so one case can have a Claude session and a Codex +session open side by side. App Settings → Agents & CLIs → **Installed CLIs** is where you +enable, disable, reorder, or add a custom entry without touching a config file by hand. ## Codeman does not manage your logins diff --git a/install.sh b/install.sh index 6ff0abb7e..d42c50151 100755 --- a/install.sh +++ b/install.sh @@ -76,62 +76,40 @@ TS_NEED_ROOT="0" # explicit caller override so contributors can still fetch the browser if needed. export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}" -# Claude CLI search paths (from src/utils/claude-cli-resolver.ts) -CLAUDE_SEARCH_PATHS=( - "$HOME/.local/bin/claude" - "$HOME/.claude/local/claude" - "/usr/local/bin/claude" - "$HOME/.npm-global/bin/claude" - "$HOME/bin/claude" -) - -# OpenCode CLI search paths (from src/utils/opencode-cli-resolver.ts) -OPENCODE_SEARCH_PATHS=( - "$HOME/.opencode/bin/opencode" - "$HOME/.local/bin/opencode" - "/usr/local/bin/opencode" - "$HOME/go/bin/opencode" - "$HOME/.bun/bin/opencode" - "$HOME/.npm-global/bin/opencode" - "$HOME/bin/opencode" -) - -# Codex CLI search paths (from src/utils/codex-cli-resolver.ts) -CODEX_SEARCH_PATHS=( - "$HOME/.codex/bin/codex" - "$HOME/.local/bin/codex" - "/usr/local/bin/codex" - "$HOME/.bun/bin/codex" - "$HOME/.npm-global/bin/codex" - "$HOME/bin/codex" -) - -# Gemini CLI search paths (from src/utils/gemini-cli-resolver.ts) -GEMINI_SEARCH_PATHS=( - "$HOME/.gemini/bin/gemini" - "$HOME/.local/bin/gemini" - "/usr/local/bin/gemini" - "$HOME/.bun/bin/gemini" - "$HOME/.npm-global/bin/gemini" - "$HOME/bin/gemini" -) - -# Pi CLI search paths (from src/utils/pi-cli-resolver.ts) -PI_SEARCH_PATHS=( - "$HOME/.local/bin/pi" - "/usr/local/bin/pi" - "$HOME/.bun/bin/pi" - "$HOME/.npm-global/bin/pi" - "$HOME/bin/pi" -) - -# Antigravity CLI search paths (from src/utils/antigravity-cli-resolver.ts) -ANTIGRAVITY_SEARCH_PATHS=( - "$HOME/.local/bin/agy" - "$HOME/.antigravity/bin/agy" - "/usr/local/bin/agy" - "$HOME/bin/agy" -) +# ============================================================================ +# CLI registry (config/clis.stock.json) +# ============================================================================ +# +# Codeman's set of supported CLIs is data, not code (see docs/cli-registry.md): +# src/config/cli-registry/stock.ts is the single source of truth, and +# config/clis.stock.json is a generated, install.sh-only export of it (id, +# label, and discovery: binaries/searchDirs/install commands — never +# launch/capabilities, which are server-side concerns). Regenerate it with +# `npm run generate:cli-stock-json`; test/cli-stock-json-sync.test.ts pins the +# two in sync. +# +# This script runs standalone via `curl | bash`, BEFORE the repo is cloned or +# built, so it cannot import TypeScript (or even reach a git checkout) to +# learn what CLIs exist. load_cli_registry() (defined further down, after +# download_to_stdout) instead fetches that JSON export directly from +# raw.githubusercontent.com and parses it with `node -e` (Node.js is already +# installed by the time it runs — see the ensure_node call ordering). +CLI_IDS=() +CLI_LABELS=() +CLI_BINARIES=() # per id: comma-separated binary names, first hit wins +CLI_SEARCH_PATHS=() # per id: comma-separated search directories (~ expands to $HOME) +CLI_INSTALL_HINTS=() # per id: platform-appropriate "install with: ..." command, or "" + +# Minimal built-in fallback used only if the registry file cannot be fetched or +# fails to parse (offline install, or a custom CODEMAN_REPO_URL/CODEMAN_BRANCH +# pointing somewhere with no matching raw.githubusercontent.com URL). Kept +# intentionally small — Claude Code and OpenCode are the only two the +# interactive installer offers to install directly; every other CLI just loses +# its "found at ..." detection until the registry is reachable again. +CLI_REGISTRY_FALLBACK_JSON='[ + {"id":"claude","label":"Claude","discovery":{"binaries":["claude"],"searchDirs":["~/.local/bin","~/.claude/local","/usr/local/bin","~/.npm-global/bin","~/bin"],"install":{"command":{"linux":"curl -fsSL https://claude.ai/install.sh | bash","darwin":"curl -fsSL https://claude.ai/install.sh | bash"}}}}, + {"id":"opencode","label":"OpenCode","discovery":{"binaries":["opencode"],"searchDirs":["~/.opencode/bin","~/.local/bin","/usr/local/bin","~/go/bin","~/.bun/bin","~/.npm-global/bin","~/bin"],"install":{"command":{"linux":"curl -fsSL https://opencode.ai/install | bash","darwin":"curl -fsSL https://opencode.ai/install | bash"}}}} +]' # ============================================================================ # Color Output @@ -366,6 +344,60 @@ download_to_stdout() { fi } +# Only github.com repo URLs (https:// or git@) have a matching +# raw.githubusercontent.com host; a custom CODEMAN_REPO_URL pointing +# elsewhere has no known raw-file mirror and falls back to the embedded catalog. +cli_registry_raw_url() { + local url="$REPO_URL" + if [[ "$url" =~ ^https://github\.com/([^/]+)/([^/.]+)(\.git)?$ ]]; then + echo "https://raw.githubusercontent.com/${BASH_REMATCH[1]}/${BASH_REMATCH[2]}/${BRANCH}/config/clis.stock.json" + elif [[ "$url" =~ ^git@github\.com:([^/]+)/([^/.]+)(\.git)?$ ]]; then + echo "https://raw.githubusercontent.com/${BASH_REMATCH[1]}/${BASH_REMATCH[2]}/${BRANCH}/config/clis.stock.json" + fi +} + +# Fetches config/clis.stock.json and populates CLI_IDS/CLI_LABELS/CLI_BINARIES/ +# CLI_SEARCH_PATHS/CLI_INSTALL_HINTS (see the declarations above for the shape). +# Never fatal: a fetch or parse failure warns and falls back to +# CLI_REGISTRY_FALLBACK_JSON, so a network hiccup degrades to "detect fewer +# CLIs" rather than aborting the whole install. Requires `node` on PATH, so +# callers must run this only after Node.js is confirmed installed. +load_cli_registry() { + local json="" raw_url + raw_url=$(cli_registry_raw_url) + if [[ -n "$raw_url" ]]; then + json=$(download_to_stdout "$raw_url" 2>/dev/null || true) + fi + if [[ -z "$json" ]] || ! echo "$json" | node -e 'JSON.parse(require("fs").readFileSync(0,"utf8"))' &>/dev/null; then + if [[ -n "$json" ]]; then + warn "Could not fetch the CLI registry (config/clis.stock.json); using a built-in fallback (Claude Code + OpenCode detection only)." + fi + json="$CLI_REGISTRY_FALLBACK_JSON" + fi + + while IFS=$'\t' read -r id label bins dirs cmd; do + [[ -z "$id" ]] && continue + CLI_IDS+=("$id") + CLI_LABELS+=("$label") + CLI_BINARIES+=("$bins") + CLI_SEARCH_PATHS+=("$dirs") + CLI_INSTALL_HINTS+=("$cmd") + done < <(echo "$json" | node -e ' + const os = require("os"); + const platform = os.platform() === "darwin" ? "darwin" : "linux"; + let data; + try { data = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch { process.exit(0); } + for (const entry of data) { + const d = entry.discovery || {}; + const bins = (d.binaries || []).join(","); + if (!bins) continue; // e.g. "shell" — nothing to detect + const dirs = (d.searchDirs || []).join(","); + const cmd = (d.install && d.install.command && d.install.command[platform]) || ""; + console.log([entry.id, entry.label, bins, dirs, cmd].join("\t")); + } + ' 2>/dev/null) +} + # ============================================================================ # Dependency Checks # ============================================================================ @@ -396,177 +428,83 @@ check_tmux() { command -v tmux &>/dev/null } -check_claude() { - # Check PATH first - if command -v claude &>/dev/null; then - return 0 - fi - - # Check known install locations - for path in "${CLAUDE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 -} - -get_claude_path() { - if command -v claude &>/dev/null; then - command -v claude - return - fi - - for path in "${CLAUDE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_opencode() { - if command -v opencode &>/dev/null; then - return 0 - fi - - for path in "${OPENCODE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then +# Generic replacement for the old per-CLI check_claude/check_opencode/check_codex/ +# check_gemini/check_antigravity/check_pi pairs: driven entirely by the CLI_IDS/ +# CLI_BINARIES/CLI_SEARCH_PATHS arrays populated by load_cli_registry() from +# config/clis.stock.json, so adding a CLI to the registry needs no install.sh change. +# +# `pi` deserves the same caveat the old check_pi() carried: it is a short, generic +# name (Raspberry Pi tooling, personal scripts), so the server-side resolver +# additionally probes `pi --version`. Detection here only feeds the "you have no +# AI CLI" hint, so a plain executable test is enough. +cli_index() { + local id="$1" i + for i in "${!CLI_IDS[@]}"; do + if [[ "${CLI_IDS[$i]}" == "$id" ]]; then + echo "$i" return 0 fi done - return 1 } -get_opencode_path() { - if command -v opencode &>/dev/null; then - command -v opencode - return - fi +check_cli() { + local id="$1" + local idx bin dir + idx=$(cli_index "$id") || return 1 - for path in "${OPENCODE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi + IFS=',' read -ra bins <<< "${CLI_BINARIES[$idx]}" + for bin in "${bins[@]}"; do + command -v "$bin" &>/dev/null && return 0 done -} - -check_codex() { - if command -v codex &>/dev/null; then - return 0 - fi - for path in "${CODEX_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi + IFS=',' read -ra dirs <<< "${CLI_SEARCH_PATHS[$idx]}" + for dir in "${dirs[@]}"; do + dir="${dir/#\~/$HOME}" + for bin in "${bins[@]}"; do + [[ -x "$dir/$bin" ]] && return 0 + done done return 1 } -get_codex_path() { - if command -v codex &>/dev/null; then - command -v codex - return - fi - - for path in "${CODEX_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_gemini() { - if command -v gemini &>/dev/null; then - return 0 - fi +get_cli_path() { + local id="$1" + local idx bin dir + idx=$(cli_index "$id") || return 1 - for path in "${GEMINI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then + IFS=',' read -ra bins <<< "${CLI_BINARIES[$idx]}" + for bin in "${bins[@]}"; do + if command -v "$bin" &>/dev/null; then + command -v "$bin" return 0 fi done - return 1 -} - -get_gemini_path() { - if command -v gemini &>/dev/null; then - command -v gemini - return - fi - - for path in "${GEMINI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_antigravity() { - if command -v agy &>/dev/null; then - return 0 - fi - - for path in "${ANTIGRAVITY_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi + IFS=',' read -ra dirs <<< "${CLI_SEARCH_PATHS[$idx]}" + for dir in "${dirs[@]}"; do + dir="${dir/#\~/$HOME}" + for bin in "${bins[@]}"; do + if [[ -x "$dir/$bin" ]]; then + echo "$dir/$bin" + return 0 + fi + done done - return 1 } -get_antigravity_path() { - if command -v agy &>/dev/null; then - command -v agy - return - fi - - for path in "${ANTIGRAVITY_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -# `pi` is a short, generic name (Raspberry Pi tooling, personal scripts), so the -# server-side resolver additionally probes `pi --version`. Detection here only feeds -# the "you have no AI CLI" hint, so a plain executable test is enough. -check_pi() { - if command -v pi &>/dev/null; then - return 0 - fi - - for path in "${PI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 +cli_label() { + local idx + idx=$(cli_index "$1") || { echo "$1"; return 0; } + echo "${CLI_LABELS[$idx]}" } -get_pi_path() { - if command -v pi &>/dev/null; then - command -v pi - return - fi - - for path in "${PI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done +cli_install_hint() { + local idx + idx=$(cli_index "$1") || { echo ""; return 0; } + echo "${CLI_INSTALL_HINTS[$idx]}" } check_cloudflared() { @@ -2069,52 +2007,40 @@ main() { fi fi - # AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi) - local has_claude=false - local has_opencode=false - local has_codex=false - local has_gemini=false - local has_antigravity=false - local has_pi=false + # AI CLI — Codeman drives whichever CLI backend a session uses; the full, + # user-extensible set lives in the registry (config/clis.stock.json / + # docs/cli-registry.md), never hardcoded here. load_cli_registry() needs + # `node`, confirmed installed above. + load_cli_registry + declare -A has_cli=() + local id any_cli_found="false" cli_list_human="" info "Checking AI CLI tools..." - if check_claude; then - has_claude=true - success "Claude Code found at $(get_claude_path)" - fi - if check_opencode; then - has_opencode=true - success "OpenCode found at $(get_opencode_path)" - fi - if check_codex; then - has_codex=true - success "Codex found at $(get_codex_path)" - fi - if check_gemini; then - has_gemini=true - success "Gemini CLI found at $(get_gemini_path)" - fi - if check_antigravity; then - has_antigravity=true - success "Antigravity CLI found at $(get_antigravity_path)" - fi - if check_pi; then - has_pi=true - success "Pi CLI found at $(get_pi_path)" - fi + for id in "${CLI_IDS[@]}"; do + if check_cli "$id"; then + has_cli[$id]="true" + any_cli_found="true" + success "$(cli_label "$id") found at $(get_cli_path "$id")" + fi + cli_list_human+="$(cli_label "$id"), " + done + cli_list_human="${cli_list_human%, }" - if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" ]]; then + if [[ "$any_cli_found" == "false" ]]; then echo "" - warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, or Pi." + warn "No AI CLI found. Codeman needs at least one: $cli_list_human." headless_guard "install an AI CLI (curl | bash from its vendor)" echo "" echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)" echo -e " ${CYAN}2)${NC} OpenCode (open-source)" echo -e " ${CYAN}3)${NC} Both" - echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity or Pi)" + echo -e " ${CYAN}4)${NC} Skip (I'll install one myself — see the list below)" echo "" + # Only Claude Code and OpenCode get a first-class interactive installer + # here (a well-known, unattended `curl | bash` one-liner each); every + # other registry entry is a hint only, shown below on Skip. local cli_choice="" if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then # Explicit automation opt-in: default to Claude Code @@ -2135,9 +2061,9 @@ main() { info "Installing Claude Code CLI..." download_to_stdout https://claude.ai/install.sh | bash hash -r 2>/dev/null || true - if check_claude; then - has_claude=true - success "Claude Code installed at $(get_claude_path)" + if check_cli claude; then + has_cli[claude]="true" + success "Claude Code installed at $(get_cli_path claude)" else warn "Claude Code installation failed." fi @@ -2147,9 +2073,9 @@ main() { info "Installing OpenCode CLI..." download_to_stdout https://opencode.ai/install | bash hash -r 2>/dev/null || true - if check_opencode; then - has_opencode=true - success "OpenCode installed at $(get_opencode_path)" + if check_cli opencode; then + has_cli[opencode]="true" + success "OpenCode installed at $(get_cli_path opencode)" else warn "OpenCode installation failed." fi @@ -2157,10 +2083,13 @@ main() { if [[ "$cli_choice" == "4" ]]; then warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." - info "Install one later, e.g.: npm install -g @openai/codex (Codex)" - info " or: curl -fsSL https://antigravity.google/cli/install.sh | bash (Antigravity)" - info " or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent (Pi)" - elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then + for id in "${CLI_IDS[@]}"; do + [[ "$id" == "claude" || "$id" == "opencode" ]] && continue + local hint + hint=$(cli_install_hint "$id") + [[ -n "$hint" ]] && info "Install one later, e.g.: $hint ($(cli_label "$id"))" + done + elif [[ "${has_cli[claude]:-false}" == "false" ]] && [[ "${has_cli[opencode]:-false}" == "false" ]]; then die "The selected AI CLI failed to install. Install one manually and re-run the installer." fi fi @@ -2459,13 +2388,17 @@ main() { echo -e " https://github.com/Ark0N/Codeman" echo "" - if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi; then + local any_cli_installed="false" + for id in "${CLI_IDS[@]}"; do + check_cli "$id" && any_cli_installed="true" + done + if [[ "$any_cli_installed" == "false" ]]; then echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:" - echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code" - echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode" - echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex" - echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity" - echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi" + for id in "${CLI_IDS[@]}"; do + local hint + hint=$(cli_install_hint "$id") + [[ -n "$hint" ]] && echo -e " ${CYAN}${hint}${NC} # $(cli_label "$id")" + done echo "" fi @@ -2499,6 +2432,15 @@ update() { die "Codeman is not installed at $INSTALL_DIR. Run the installer first." fi + # The CLI registry (~/.codeman/clis.json) lives OUTSIDE $INSTALL_DIR + # (which is $HOME/.codeman/app, a git checkout) at $HOME/.codeman directly, + # so this function's git reset/npm install/npm run build below can never + # touch it. That is what makes "install.sh preserves it across updates" + # true by construction rather than by an explicit backup step here — there + # is exactly one writer (the app itself, via src/config/cli-registry/registry.ts), + # and this script is not it. + local cli_registry_file="$HOME/.codeman/clis.json" + info "Updating Codeman..." cd "$INSTALL_DIR" git remote set-url origin "$REPO_URL" 2>/dev/null || true @@ -2523,6 +2465,9 @@ update() { npm run build --quiet 2>/dev/null || npm run build date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete" success "Updated to $(node -e "console.log(require('./package.json').version)")" + if [[ -f "$cli_registry_file" ]]; then + success "Your CLI registry customizations ($cli_registry_file) were preserved." + fi echo "" # Auto-restart service if running, otherwise tell the user diff --git a/package.json b/package.json index 4fcfe68c2..bd8dbb326 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "scripts": { "postinstall": "node scripts/postinstall.js", "build": "node scripts/build.mjs", + "generate:cli-stock-json": "tsx scripts/generate-cli-stock-json.mjs", "build:gesture": "node scripts/build-gesture-bundle.mjs", "start": "NODE_COMPILE_CACHE=${HOME}/.codeman/compile-cache node dist/index.js", "dev": "tsx src/index.ts web", diff --git a/scripts/build-agent-image.mjs b/scripts/build-agent-image.mjs index 00602d24d..e6f6be07a 100644 --- a/scripts/build-agent-image.mjs +++ b/scripts/build-agent-image.mjs @@ -12,6 +12,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { readFileSync } from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..'); @@ -55,9 +56,42 @@ if (args.help) { process.exit(0); } +// The stock catalog's install-command shape, mirroring config/clis.stock.json +// (see docs/cli-registry.md) — read directly rather than via tsx/ts-node so this +// script has no extra runtime dependency. A registry entry with a plain +// `npm install -g ` install command joins the shared npm-install ARG +// automatically; anything else (a curl installer, --ignore-scripts, no npm +// package at all) stays a documented Dockerfile special case, same as +// Antigravity and Pi today. +function cliNpmPackages() { + const stockPath = join(REPO_ROOT, 'config', 'clis.stock.json'); + let entries; + try { + entries = JSON.parse(readFileSync(stockPath, 'utf8')); + } catch (err) { + console.warn(`[build-agent-image] could not read ${stockPath} (${err.message}); using the Dockerfile's built-in defaults`); + return null; + } + const packages = entries + .filter((e) => e.id !== 'pi') // pi needs --ignore-scripts, handled by its own ARG below + .map((e) => e.discovery?.install?.npmPackage) + .filter((pkg) => typeof pkg === 'string' && pkg.length > 0); + const pi = entries.find((e) => e.id === 'pi')?.discovery?.install?.npmPackage; + return { packages, pi }; +} + const engine = resolveEngine(args.engine); const buildArgs = ['build', '-f', DOCKERFILE, '-t', args.image]; if (args.noCache) buildArgs.push('--no-cache'); + +const cliPkgs = cliNpmPackages(); +if (cliPkgs && cliPkgs.packages.length > 0) { + buildArgs.push('--build-arg', `CLI_NPM_PACKAGES=${cliPkgs.packages.join(' ')}`); +} +if (cliPkgs && cliPkgs.pi) { + buildArgs.push('--build-arg', `CLI_PI_NPM_PACKAGE=${cliPkgs.pi}`); +} + buildArgs.push(REPO_ROOT); console.log(`[build-agent-image] ${engine} ${buildArgs.join(' ')}`); diff --git a/scripts/generate-cli-stock-json.mjs b/scripts/generate-cli-stock-json.mjs new file mode 100644 index 000000000..cef1b00e3 --- /dev/null +++ b/scripts/generate-cli-stock-json.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * @fileoverview Regenerates `config/clis.stock.json` from the compiled-in stock CLI + * catalog (`src/config/cli-registry/stock.ts`), which stays the single source of truth. + * + * This JSON export exists for ONE consumer: `install.sh`, which runs standalone via + * `curl | bash` BEFORE the repo is cloned or built, so it cannot import TypeScript (or + * even reach a git checkout) to learn what CLIs exist, where to look for them, or how to + * install them. Its own copy is fetched over the network (same raw-file pattern the + * installer already uses for itself) and parsed with a plain `node -e`/`JSON.parse` — no + * ts-node/tsx dependency at install time. + * + * Only the fields install.sh actually needs are exported (id, label, stock flag, and + * `discovery`: binaries/searchDirs/install commands) — never `launch`/`capabilities`, + * which are launch-time concerns the server alone interprets. + * + * `test/cli-stock-json-sync.test.ts` pins this file in sync with stock.ts, the same + * pattern as `test/sse-registry-parity.test.ts` for the SSE event tables. Run + * `npm run generate:cli-stock-json` after editing stock.ts and commit the result. + */ +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const { STOCK_CLIS } = await import('../src/config/cli-registry/stock.ts'); + +const out = STOCK_CLIS.map((entry) => ({ + id: entry.id, + label: entry.label, + stock: true, + discovery: entry.discovery, +})); + +const outPath = resolve(here, '../config/clis.stock.json'); +writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n', 'utf8'); +console.log(`Wrote ${outPath} (${out.length} entries)`); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 6be81d582..62f4f9f83 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -19,6 +19,7 @@ import { import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; import { enabledClis } from '../config/cli-registry/registry.js'; +import type { SessionMode } from '../types/session.js'; // ========== Path Validation ========== @@ -141,6 +142,29 @@ const ALLOWED_ENV_PREFIXES: string[] = enabledClis().flatMap((entry) => entry.en */ const ALLOWED_ENV_KEYS = new Set(enabledClis().flatMap((entry) => entry.env.allowedKeys)); +/** + * The session `mode`/`agentType` enum, built from the registry rather than a fixed + * literal list: `z.enum(registryIds())` per the CLI-registry compatibility design (see + * docs/cli-registry.md). A custom CLI added through App Settings → Agents & CLIs (or by + * hand-editing ~/.codeman/clis.json) becomes a valid `mode` value the moment it is + * enabled, with no schema change. Disabled entries are deliberately excluded — the same + * policy as ALLOWED_ENV_PREFIXES above — so a disabled CLI cannot be used to start a new + * session even if a stale client still offers it. `shell` is always present (it can be + * disabled but never deleted — see registry.ts), so this is never empty at runtime; the + * cast is only to satisfy Zod's non-empty-tuple type, which cannot be proven statically + * for a value computed at module load. + */ +const SESSION_MODE_IDS = enabledClis().map((entry) => entry.id as string); +// `SessionMode` stays the literal union of stock ids for now (retyping it as a plain +// string would also collapse RemoteCommandMode/DockerCommandMode, which key off +// Extract — a larger, separately-scoped change). The cast here is the +// honest boundary: Zod validates against the LIVE registry (so a custom CLI id really is +// accepted at runtime), and every downstream reader treats `mode` as an opaque id rather +// than exhaustively switching on it (test/cli-registry-no-id-branching.test.ts enforces +// that), so widening what actually flows through is safe even though the static type does +// not (yet) say so. +const sessionModeSchema = () => z.enum(SESSION_MODE_IDS as [string, ...string[]]) as unknown as z.ZodType; + /** * Env var keys that are ALWAYS blocked (security-sensitive) — a hard floor no registry * entry, stock or custom, can widen. Deliberately NOT registry-driven: an entry's @@ -328,7 +352,7 @@ const parentSessionIdSchema = z.string().max(100).optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(), + mode: sessionModeSchema().optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, @@ -753,7 +777,7 @@ export const QuickStartSchema = z.object({ * a real host dir, so the settings file crosses the bind mount); rejected for * remote cases (the file would be written on the WRONG machine). */ modelOverride: z.string().max(50).optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']).optional(), + mode: sessionModeSchema().optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, @@ -1282,7 +1306,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ const CronJobBaseSchema = z.object({ name: z.string().min(1).max(200), - agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']), + agentType: sessionModeSchema(), workingDir: safePathSchema, launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), promptMode: z.enum(['inline_text', 'prompt_file_path']), diff --git a/test/cli-stock-json-sync.test.ts b/test/cli-stock-json-sync.test.ts new file mode 100644 index 000000000..84e1ced5a --- /dev/null +++ b/test/cli-stock-json-sync.test.ts @@ -0,0 +1,26 @@ +/** + * @fileoverview Pins `config/clis.stock.json` (install.sh's pre-clone, pre-build view of + * the stock CLI catalog) in sync with the real source of truth, + * `src/config/cli-registry/stock.ts`. Same pattern as `test/sse-registry-parity.test.ts`. + * + * If this fails, run `npm run generate:cli-stock-json` and commit the regenerated file. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +describe('config/clis.stock.json', () => { + it('matches the compiled-in stock catalog', () => { + const expected = STOCK_CLIS.map((entry) => ({ + id: entry.id, + label: entry.label, + stock: true, + discovery: entry.discovery, + })); + + const onDisk = JSON.parse(readFileSync(resolve(import.meta.dirname, '../config/clis.stock.json'), 'utf8')); + + expect(onDisk).toEqual(expected); + }); +}); From 92b2582fa493632d6355a51e31b0cf6c7d5dc211 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:39:36 +0800 Subject: [PATCH 11/15] feat(cli-registry): add GitHub Copilot CLI (disabled by default) and auto-install on enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GitHub Copilot CLI (`copilot`, npm `@github/copilot`) as a new stock registry entry, shipped DISABLED by default -- unlike every other stock entry -- since it is new to the catalog. Its launch shape is deliberately minimal (no --model/--resume flags: upstream's --resume/--continue open an interactive picker or jump to the most recent session rather than taking a session id directly, so resumeAppend is left unset rather than guessed at). Discovery/install/docs verified against GitHub's own docs (see sources below). Codex was considered for the same disabled-by-default treatment but left as-is per explicit direction -- it already ships enabled and people rely on that. Also implements the follow-up requirement: a disabled CLI's binary is never checked or cared about, but the moment it is explicitly ENABLED (PUT /api/clis/:id/enabled, what the settings UI's toggle calls) and its binary isn't installed yet, Codeman now runs that entry's install command in the background automatically instead of leaving the operator to do it by hand. - New src/config/cli-registry/cli-installer.ts: ensureCliInstalled(id) checks availability via the existing resolveCliBinDir, and if unavailable, spawns the platform-appropriate discovery.install.command (shell:true, since these are pipelines like `curl ... | bash`, not a single argv -- same as install.sh's own `download_to_stdout url | bash`). Tracks in-memory status per id (installing/success/error) with a bounded output tail and a 10-minute default timeout (CODEMAN_CLI_INSTALL_TIMEOUT_MS, clamped 30s-1h). On completion, invalidates the memoized resolver cache (new invalidateCliBinDirCache in cli-resolver.ts -- each resolver caches its result, including a negative one, forever) and re-probes so `available` flips true without a server restart. - Security model, documented at length in the new file's header and in types.ts's CliDiscovery.install.command doc comment (previously "NEVER executed by the server" -- now points at this module instead of contradicting it): this only ever runs as the direct, synchronous result of that one explicit API call, never on boot or a background reload; the command that runs is byte-identical to the installHint already shown for that entry; and enabling a CLI is already admin-gated in multi-user mode / the same single trust level in single-user mode that can already add or edit any entry through this same surface. Under VITEST the actual spawn is a silent no-op (status left untouched, distinguishable from a real terminal state) -- same posture as TmuxManager's IS_TEST_MODE -- so the test suite can never trigger a real, network-dependent, possibly minutes-long install; test/cli-installer.test.ts covers the decision logic (already-available fast path, no-install-command path, the VITEST no-op itself, the concurrency guard) with node:child_process mocked as a second line of defense. - GET /api/clis, GET /api/cli/:id/status and the PUT .../enabled response all gain `installStatus` ({state, command, message?}); settings-ui.js's CLI list shows "Installing…"/"Install failed: …" inline, disables the toggle mid-install, and polls (3s intervals, ~2min cap) until it resolves. - Extracted resolveInstallCommandForPlatform() out of missingCliMessage() in registry.ts so the installer and the display-hint code share one platform selection instead of duplicating it. - Test fixtures in cli-registry-load.test.ts and routes/system-routes.test.ts that exercised the CUSTOM-CLI add/remove path using a fabricated "copilot" id are renamed to "testcli" -- copilot is a real stock id now, and upsertCustomCli/ removeCustomCli both correctly refuse to touch a stock id. Sources for the GitHub Copilot CLI facts used in the stock entry: - https://github.com/github/copilot-cli - https://www.npmjs.com/package/@github/copilot - https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli - https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference Verified: npm run typecheck, npm run lint, npm run format:check, and the frontend syntax/public-asset checks all clean. Full npm test matches the known baseline exactly on failures (55 failed files / 138 failed tests, all pre-existing) with 10 more passing tests than before (the new coverage added here). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- .changeset/a4c76a4a.md | 18 +++ config/clis.stock.json | 21 +++ docs/cli-registry.md | 37 +++++- docs/extending-codeman.md | 10 +- docs/wiki/Agent-CLIs.md | 18 ++- src/config/cli-registry/cli-installer.ts | 155 +++++++++++++++++++++++ src/config/cli-registry/registry.ts | 22 +++- src/config/cli-registry/stock.ts | 67 +++++++++- src/config/cli-registry/types.ts | 8 +- src/utils/cli-resolver.ts | 13 ++ src/web/public/settings-ui.js | 39 +++++- src/web/routes/system-routes.ts | 21 ++- test/cli-installer.test.ts | 123 ++++++++++++++++++ test/cli-management-settings.test.ts | 91 +++++++++++++ test/cli-registry-load.test.ts | 45 ++++--- test/routes/system-routes.test.ts | 29 +++-- 16 files changed, 666 insertions(+), 51 deletions(-) create mode 100644 .changeset/a4c76a4a.md create mode 100644 src/config/cli-registry/cli-installer.ts create mode 100644 test/cli-installer.test.ts diff --git a/.changeset/a4c76a4a.md b/.changeset/a4c76a4a.md new file mode 100644 index 000000000..a8df012e1 --- /dev/null +++ b/.changeset/a4c76a4a.md @@ -0,0 +1,18 @@ +--- +"aicodeman": minor +--- + +Added GitHub Copilot CLI (`copilot`, npm `@github/copilot`) to the stock CLI registry, +shipped **disabled by default** since it is new to the catalog. + +Also: enabling any CLI whose binary isn't installed yet now installs it automatically. +Previously a disabled entry's binary was never checked, and switching it on left the +operator to run its install command by hand. Now `PUT /api/clis/:id/enabled +{"enabled":true}` (what the settings UI's toggle calls) kicks off that entry's install +command in the background if needed, exposing progress as `installStatus` on both that +response and `GET /api/clis` (`{state: 'installing'|'success'|'error', command, message?}`); +the settings UI shows "Installing…" / "Install failed: …" inline and polls until it +resolves. This only ever runs as the direct result of that explicit API call, and the +command that runs is exactly the one already shown as the entry's install hint — see +`docs/cli-registry.md`'s "Enabling a CLI auto-installs it" section for the full trust +model. diff --git a/config/clis.stock.json b/config/clis.stock.json index d457c161c..16ea4c8d8 100644 --- a/config/clis.stock.json +++ b/config/clis.stock.json @@ -146,5 +146,26 @@ "docsUrl": "https://pi.dev" } } + }, + { + "id": "copilot", + "label": "GitHub Copilot", + "stock": true, + "discovery": { + "binaries": ["copilot"], + "searchDirs": ["~/.local/bin", "/usr/local/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "npm install -g @github/copilot", + "darwin": "npm install -g @github/copilot" + }, + "npmPackage": "@github/copilot", + "docsUrl": "https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli" + } + } } ] diff --git a/docs/cli-registry.md b/docs/cli-registry.md index ad3d3b4b0..b63f0ddc6 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -35,13 +35,44 @@ touches it again). `shell` and `claude` can be disabled but never deleted. same profile as an unrecognized CLI: external agent, requires tmux, no hooks, no bypass flag, buffered echo. - **API**: `GET /api/clis` (full merged registry, plus live `available`/`path`/`version`/ - `installHint` per entry), `PUT /api/clis/:id/enabled`, `PUT /api/clis/order`, - `POST /api/clis/:id` (add or replace a custom entry — refuses a stock id), - `DELETE /api/clis/:id` (refuses a stock id). All admin-gated in multi-user mode. + `installHint`/`installStatus` per entry), `PUT /api/clis/:id/enabled`, + `PUT /api/clis/order`, `POST /api/clis/:id` (add or replace a custom entry — refuses a + stock id), `DELETE /api/clis/:id` (refuses a stock id). All admin-gated in multi-user + mode. - **Hand-editing `~/.codeman/clis.json`**: the loader validates on every read, so a syntax or schema error degrades to a warning and the pristine/omitted entry, never a broken server. +### Enabling a CLI auto-installs it + +`~/.codeman/clis.json` deliberately does not care whether a **disabled** entry's binary is +even installed — that is the whole point of shipping GitHub Copilot CLI disabled by +default rather than leaving it out of the catalog entirely. The moment a CLI is switched +from disabled to enabled — via `PUT /api/clis/:id/enabled {"enabled":true}`, which is what +the settings UI's toggle calls — `src/config/cli-registry/cli-installer.ts`'s +`ensureCliInstalled` checks whether the binary is already resolvable and, if not, runs that +entry's `discovery.install.command` for the current platform in the background. Progress is +exposed as `installStatus` on both `GET /api/clis` and the `PUT .../enabled` response itself +(`{state: 'installing' | 'success' | 'error', command, message?}`); the settings UI polls +until it resolves and shows "Installing…" / "Install failed: …" inline. + +This is a deliberate, narrow exception to `discovery.install.command` otherwise being pure +display text (its own doc comment in `types.ts` used to say "NEVER executed by the +server" — now updated to point here). The trust model: + +- It only ever runs as the direct result of that one explicit API call — never on server + boot, a background registry reload, or any other implicit trigger. +- The command that runs is **exactly** the string already shown as that entry's + `installHint` — nothing is invented, combined with other input, or transformed. +- Enabling a CLI is already an admin-only action in multi-user mode, and in single-user + mode there is one trust level, the same one that can already add or edit any entry + (stock or custom) through this same settings surface. Running the install command that + same operator already saw and could have run by hand adds no new privilege. + +Under `VITEST` this is a silent no-op (same posture as `TmuxManager`'s `IS_TEST_MODE`) — the +test suite must never spawn a real, possibly network-dependent, possibly minutes-long +install command. + ## The shape of an entry (`CliEntry`) Full type definitions: `src/config/cli-registry/types.ts`. The top-level shape: diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index 10725fd2d..d4319c495 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -188,10 +188,12 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \ `mode` above is not a fixed enum — it is one of the ids in the CLI registry (`GET /api/clis` lists every registered CLI, enabled or not, plus live -`available`/`path`/`version`/`installHint` per entry). The stock catalog ships -`claude`, `shell`, `opencode`, `codex`, `gemini`, `antigravity`, and `pi`, but an -install can add or remove entries through App Settings → Agents & CLIs, so an -integration that hardcodes that list will miss a custom CLI. See +`available`/`path`/`version`/`installHint`/`installStatus` per entry, and `mode` +validation itself is built from the currently-ENABLED subset). The stock catalog ships +`claude`, `shell`, `opencode`, `codex`, `gemini`, `antigravity`, and `pi` enabled by +default, plus `copilot` (GitHub Copilot CLI) disabled by default, but an install can add, +remove, or toggle any entry through App Settings → Agents & CLIs, so an integration that +hardcodes that list will miss a custom CLI or a disabled one. See [CLI Registry](cli-registry.md). `POST .../input` also accepts `clientId` (stable per client, max 128 chars) and diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index a76a9669e..e4865a71f 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -5,9 +5,9 @@ agent CLIs. That set is **data, not code**: it lives in a central CLI registry (`~/.codeman/clis.json`, layered over a shipped stock catalog), not in a hardcoded list anywhere in the app. Enabling, disabling, reordering, or adding a CLI is a settings change, never a code change. See [CLI Registry](CLI-Registry) for the schema and how to -add one (e.g. GitHub Copilot CLI, or any future agent CLI). +add a CLI of your own. -Out of the box the stock catalog ships seven entries: +Out of the box the stock catalog ships eight entries. Six are enabled by default: | Mode | CLI | Get it | | -------------------- | ----------------------------- | ------------------------------------------------------------------------ | @@ -19,12 +19,26 @@ Out of the box the stock catalog ships seven entries: | **Pi** | `pi` | [pi.dev](https://pi.dev) | | **Terminal / Shell** | your `$SHELL` | Already installed. | +Two ship **disabled** by default and need an explicit opt-in from Settings before they +appear anywhere: + +| Mode | CLI | Get it | +| -------------------- | ----------------------------- | ------------------------------------------------------------------------ | +| **GitHub Copilot** | `copilot` | [docs.github.com](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli) | + Any combination works, including all of them, plus anything you add yourself. The run mode is chosen per session from the arrow beside the **Run** button (built from whichever CLIs are currently enabled), so one case can have a Claude session and a Codex session open side by side. App Settings → Agents & CLIs → **Installed CLIs** is where you enable, disable, reorder, or add a custom entry without touching a config file by hand. +**Enabling a CLI whose binary isn't installed yet installs it for you.** A disabled entry +is never checked or cared about — its binary can be missing entirely, same as GitHub +Copilot CLI out of the box. The moment you flip it on, Codeman runs that CLI's install +command in the background (the same one shown as its "not found" hint) and the row reads +"Installing…" until it resolves. See [CLI Registry](CLI-Registry) for exactly when this +runs and why that is safe. + ## Codeman does not manage your logins Install each CLI yourself and log it in once by hand. Codeman never collects, stores, or diff --git a/src/config/cli-registry/cli-installer.ts b/src/config/cli-registry/cli-installer.ts new file mode 100644 index 000000000..1d0367d21 --- /dev/null +++ b/src/config/cli-registry/cli-installer.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview Auto-installs a CLI's binary the moment it is explicitly ENABLED through the + * registry write API — closes the gap where a CLI entry can sit in the registry disabled + * (its binary possibly never installed, since a disabled entry's availability is never + * checked or cared about) and, once switched on, previously left the operator to go run its + * install command by hand before the toggle did anything useful. + * + * Security model — this is a deliberate, narrow exception to a rule stated elsewhere in this + * package: `discovery.install.command` used to be pure display text ("Shown verbatim in + * 'CLI not found. Install with: ...'. NEVER executed by the server" — see the history in + * types.ts's `CliDiscovery` doc comment, now updated to describe this module instead of + * contradicting it). `ensureCliInstalled` is the ONE place that command is ever actually run, + * and only when: + * - Called from the `PUT /api/clis/:id/enabled` route with `enabled: true` — an explicit + * admin action (multi-user mode is admin-gated at the route; single-user mode has one + * trust level, the same one that can already add/edit/remove any CLI entry, stock or + * custom, through this same settings surface). + * - NEVER from server boot, a background registry reload, or any other implicit trigger. + * - The exact command already shown as that entry's `installHint` in the settings UI + * BEFORE the toggle was flipped — nothing is invented, combined, or transformed here. + * A custom CLI's install command is therefore executed with the same trust as the admin who + * typed it into the "Add CLI" form in the first place; this module adds no NEW privilege, + * it just removes the extra manual step of running that same command themselves. + * + * @module config/cli-registry/cli-installer + */ + +import { spawn } from 'node:child_process'; +import { getCli, resolveInstallCommandForPlatform } from './registry.js'; +import { invalidateCliBinDirCache, resolveCliBinDir } from '../../utils/cli-resolver.js'; + +export type CliInstallState = 'installing' | 'success' | 'error'; + +export interface CliInstallStatus { + state: CliInstallState; + /** The exact command that ran (or is running) — never re-derived from anything else. */ + command?: string; + /** Set on `error` only: exit code plus a bounded tail of combined stdout+stderr. */ + message?: string; + startedAt?: number; + finishedAt?: number; +} + +/** + * Install commands can be slow (a ~190MB standalone binary download, a cold npm registry) — + * default 10 minutes, overridable for an unusually constrained network. Bounded 30s-1h so a + * misconfigured value cannot make this hang the process forever or fire so fast it can never + * succeed. + */ +function installTimeoutMs(): number { + const raw = Number(process.env.CODEMAN_CLI_INSTALL_TIMEOUT_MS); + if (!Number.isFinite(raw) || raw <= 0) return 600_000; + return Math.min(Math.max(raw, 30_000), 3_600_000); +} + +const _status = new Map(); + +export function getCliInstallStatus(id: string): CliInstallStatus | undefined { + return _status.get(id); +} + +/** Test-only: reset all tracked install status between tests. */ +export function _resetCliInstallStatusForTest(): void { + _status.clear(); +} + +/** + * Ensure `id`'s binary is installed, installing it in the background if it is not already + * present and no install is already in flight for it. Fire-and-forget by design — the + * calling route returns immediately with whatever status this synchronously set before the + * child process resolves; callers observe progress via `getCliInstallStatus` (surfaced in + * `GET /api/clis` as each entry's `installStatus`) rather than blocking on it, since an + * install can run for minutes and a PUT request must not hang that long. + */ +export function ensureCliInstalled(id: string): void { + const existing = _status.get(id); + if (existing?.state === 'installing') return; // already in flight — don't double-spawn + + const entry = getCli(id); + if (!entry || entry.discovery.binaries.length === 0) return; // unknown id, or e.g. "shell" + + if (resolveCliBinDir(id)) { + _status.set(id, { state: 'success', finishedAt: Date.now() }); + return; // already installed — nothing to do + } + + const command = resolveInstallCommandForPlatform(entry); + if (!command) { + _status.set(id, { state: 'error', message: 'No install command declared for this platform.' }); + return; + } + + // Same posture as TmuxManager's IS_TEST_MODE (src/tmux-manager.ts): the test suite must + // never spawn a real install command (network access, minutes-long, non-deterministic + // across machines/CI). This is deliberately a SILENT no-op, not a fake success/error + // status, so `_status` stays exactly as it was before this call and a test can tell the + // two apart. Route/unit tests that need to exercise the actual spawn/timeout/output-tail + // logic mock `node:child_process` themselves (see cli-installer.test.ts) — this guard is + // defense-in-depth for every OTHER test that merely enables a CLI in passing. + if (process.env.VITEST) return; + + _status.set(id, { state: 'installing', command, startedAt: Date.now() }); + + let child; + try { + // `shell: true` is required — install commands are pipelines (`curl ... | bash`), not + // a single argv, exactly like install.sh's own `download_to_stdout url | bash` and + // node-pty's own historical resolution. This is the same trust boundary described in + // the file header, not a new one: the string that runs here is byte-identical to the + // installHint an admin already saw and to what install.sh runs for the same CLI. + child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + _status.set(id, { state: 'error', command, message: (err as Error).message, finishedAt: Date.now() }); + return; + } + + let output = ''; + const OUTPUT_TAIL_BYTES = 4000; + const appendOutput = (chunk: Buffer) => { + output += chunk.toString('utf-8'); + if (output.length > OUTPUT_TAIL_BYTES) output = output.slice(-OUTPUT_TAIL_BYTES); + }; + child.stdout?.on('data', appendOutput); + child.stderr?.on('data', appendOutput); + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, installTimeoutMs()); + timer.unref?.(); // never keep the process alive on this alone + + child.on('error', (err) => { + clearTimeout(timer); + _status.set(id, { state: 'error', command, message: err.message, finishedAt: Date.now() }); + }); + + child.on('close', (code) => { + clearTimeout(timer); + // The install command is the ground truth for "did it work", not just its exit code: + // re-probe PATH/search-dirs afterward, and invalidate the memoized resolver first (it + // caches a negative result forever otherwise — see invalidateCliBinDirCache's own doc). + invalidateCliBinDirCache(id); + const nowAvailable = resolveCliBinDir(id) !== null; + if (code === 0 && nowAvailable) { + _status.set(id, { state: 'success', command, finishedAt: Date.now() }); + } else { + const tail = output.trim().slice(-500); + _status.set(id, { + state: 'error', + command, + message: `Install command exited ${code ?? 'unknown'}.${tail ? ` ${tail}` : ''}`, + finishedAt: Date.now(), + }); + } + }); +} diff --git a/src/config/cli-registry/registry.ts b/src/config/cli-registry/registry.ts index 60c6a92af..a5f157b4a 100644 --- a/src/config/cli-registry/registry.ts +++ b/src/config/cli-registry/registry.ts @@ -329,12 +329,24 @@ export function removeCustomCli(id: string): CliUpdateResult { export function missingCliMessage(id: string): string | null { const entry = getCli(id); if (!entry) return null; - const platform = process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'; - const command = - entry.discovery.install.command[platform] ?? - entry.discovery.install.command.linux ?? - Object.values(entry.discovery.install.command)[0]; + const command = resolveInstallCommandForPlatform(entry); return command ? `${entry.label} CLI not found. Install with: ${command}` : `${entry.label} CLI not found. See its docs for install instructions.`; } + +/** + * Pick the install command for the CURRENT platform, falling back to `linux` (the most + * common shell-compatible default) and then to whatever platform IS declared, so an entry + * missing today's exact platform key (e.g. no `win32` command) still surfaces something + * rather than nothing. Shared by `missingCliMessage` (display only) and `cli-installer.ts` + * (actually runs it) — the same resolution logic, two different uses. + */ +export function resolveInstallCommandForPlatform(entry: CliEntry): string | undefined { + const platform = process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'; + return ( + entry.discovery.install.command[platform] ?? + entry.discovery.install.command.linux ?? + Object.values(entry.discovery.install.command)[0] + ); +} diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 40cab2b0d..9a5b61037 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -632,5 +632,70 @@ const PI: CliEntry = { }, }; +// GitHub Copilot CLI (`copilot`, npm `@github/copilot`, docs: +// https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli). +// Shipped DISABLED by default — unlike every other stock entry — because it is new to this +// catalog and its launch shape here is deliberately minimal (no --model/--resume flags: the +// upstream `--resume`/`--continue` pair opens an interactive picker or jumps to the most +// recent session rather than taking a session id directly, so there is no verified way to +// resume a SPECIFIC transcript yet; `resumeAppend` is left unset rather than guessed at). +// A user opts it in from Settings, same path as adding any other CLI. +const COPILOT: CliEntry = { + id: 'copilot' as CliEntry['id'], + label: 'GitHub Copilot', + shortBadge: 'GH', + accent: '#8957e5', + enabled: false, + stock: true, + order: 60, + kind: 'agent', + discovery: { + binaries: ['copilot'], + searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { + linux: 'npm install -g @github/copilot', + darwin: 'npm install -g @github/copilot', + }, + npmPackage: '@github/copilot', + docsUrl: 'https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli', + }, + }, + launch: { + params: {}, + variants: [ + { + id: 'default', + args: [{ lit: 'copilot' }], + }, + ], + }, + env: { + exports: [], + unset: [], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + // Auth also flows through GH_TOKEN/GITHUB_TOKEN (checked ahead of COPILOT_GITHUB_TOKEN + // by the CLI itself), which are deliberately NOT allowlisted here: both are generic + // enough names that other tools use them too, and the multi-CLI prefix discipline + // (see CLAUDE.md) is one global allowlist, so admitting them would widen it for every + // mode at once. Authenticate via `/login` inside the session instead, same as Pi. + allowedPrefixes: ['COPILOT_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + }, + overlays: { + // ~/.copilot holds config, session history, logs and the plaintext auth fallback + // (docs.github.com/.../cli-config-dir-reference) — same "seed the whole directory" + // treatment as opencode's ~/.config/opencode. + credStore: { rel: '.copilot', seedWhole: true }, + }, +}; + /** The full stock catalog, in the order the run menu shows by default. */ -export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI]; +export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, COPILOT]; diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 09670f7dd..49015eae0 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -143,7 +143,13 @@ export interface CliDiscovery { searchDirs: string[]; version?: CliVersionProbe; install: { - /** Shown verbatim in "CLI not found. Install with: ...". NEVER executed by the server. */ + /** + * Shown verbatim in "CLI not found. Install with: ...". Executed by the server in + * exactly ONE place — `cli-installer.ts`'s `ensureCliInstalled`, and only as the direct + * result of an explicit `PUT /api/clis/:id/enabled {enabled:true}` call (never on boot, + * never implicitly). Read that module's file header before changing how or when this + * runs; it documents the trust boundary this exception relies on. + */ command: Partial>; /** Feeds generation of docker/agent.Dockerfile. */ npmPackage?: string; diff --git a/src/utils/cli-resolver.ts b/src/utils/cli-resolver.ts index 3cfddc541..c09d5e31b 100644 --- a/src/utils/cli-resolver.ts +++ b/src/utils/cli-resolver.ts @@ -298,6 +298,19 @@ export function augmentPath(dir: string | null, currentPath: string): string { */ const _dirResolvers = new Map(); +/** + * Drop the memoized resolver for `id`, so the next `resolveCliBinDir`/`resolveCliVersion` + * call re-probes PATH and the search dirs from scratch instead of replaying a cached `null`. + * Each resolver caches its OWN result forever once resolved once (`createDirResolver`'s + * closured `cached` var) — deliberately, since a CLI's install location does not normally + * change mid-process. The one case that DOES change it: `cli-installer.ts` just installed + * the binary, so a `false` cached at server boot would otherwise never self-correct without + * a restart. + */ +export function invalidateCliBinDirCache(id: string): void { + _dirResolvers.delete(id); +} + export function resolveCliBinDir(id: string): string | null { let resolver = _dirResolvers.get(id); if (!resolver) { diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 13dca8eb7..c1880b54f 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -587,7 +587,13 @@ Object.assign(CodemanApp.prototype, { label.textContent = `${cli.label}${cli.stock ? '' : ' (custom)'}`; const desc = document.createElement('span'); desc.className = 'set-row-desc'; - desc.textContent = cli.available ? 'Installed' : cli.installHint || 'Not found on this host'; + if (cli.installStatus?.state === 'installing') { + desc.textContent = `Installing… (${cli.installStatus.command})`; + } else if (cli.installStatus?.state === 'error') { + desc.textContent = `Install failed: ${cli.installStatus.message || 'unknown error'}`; + } else { + desc.textContent = cli.available ? 'Installed' : cli.installHint || 'Not found on this host'; + } text.append(label, desc); const actions = document.createElement('div'); @@ -614,6 +620,7 @@ Object.assign(CodemanApp.prototype, { const toggleInput = document.createElement('input'); toggleInput.type = 'checkbox'; toggleInput.checked = cli.enabled; + toggleInput.disabled = cli.installStatus?.state === 'installing'; toggleInput.onchange = () => this._setCliEnabled(cli.id, toggleInput.checked); const slider = document.createElement('span'); slider.className = 'slider'; @@ -636,6 +643,7 @@ Object.assign(CodemanApp.prototype, { }, async _setCliEnabled(id, enabled) { + let installing = false; try { const res = await fetch(`/api/clis/${encodeURIComponent(id)}/enabled`, { method: 'PUT', @@ -644,10 +652,39 @@ Object.assign(CodemanApp.prototype, { }); const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to update'); + installing = data.data?.installStatus?.state === 'installing'; } catch (err) { this.showToast?.(err.message, 'error'); } this.renderCliManagementList(); + if (installing) this._pollCliInstallStatus(id); + }, + + /** + * Enabling a not-yet-installed CLI kicks off its install command server-side + * (cli-installer.ts) and returns immediately — this polls GET /api/clis until that + * specific entry's installStatus leaves the 'installing' state (or a bounded number of + * attempts is exhausted, since a slow install must not poll forever), re-rendering the + * list on every tick so the row's "Installing…"/"Install failed: …" text stays live. + */ + async _pollCliInstallStatus(id, attempt = 0) { + const MAX_ATTEMPTS = 40; // ~2 minutes at 3s apart; a still-installing entry just stops updating live + if (attempt >= MAX_ATTEMPTS) return; + await new Promise((resolve) => setTimeout(resolve, 3000)); + let stillInstalling = false; + try { + const res = await fetch('/api/clis'); + const data = await res.json(); + if (data.success) { + const cli = data.data.find((c) => c.id === id); + stillInstalling = cli?.installStatus?.state === 'installing'; + } + } catch { + // Transient fetch failure — keep polling rather than giving up on one hiccup. + stillInstalling = true; + } + this.renderCliManagementList(); + if (stillInstalling) this._pollCliInstallStatus(id, attempt + 1); }, async _moveCliOrder(currentList, index, delta) { diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 3a6f6903c..76f3a6c4c 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -388,6 +388,7 @@ export function registerSystemRoutes( app.get('/api/clis', async () => { const { listClis, missingCliMessage } = await import('../../config/cli-registry/registry.js'); const { resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js'); + const { getCliInstallStatus } = await import('../../config/cli-registry/cli-installer.js'); const clis = listClis().map((entry) => { const available = entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null; return { @@ -398,6 +399,10 @@ export function registerSystemRoutes( // Populated only when actually needed (not installed), so the frontend never has // to reconstruct the per-platform install-command message itself. installHint: available ? null : missingCliMessage(entry.id), + // Set only while an auto-install triggered by enabling this CLI is in flight, or + // just finished — see cli-installer.ts. Absent under normal (already-resolved) + // circumstances, so this adds nothing to the payload for the common case. + installStatus: getCliInstallStatus(entry.id) ?? null, }; }); return { success: true, data: clis }; @@ -412,6 +417,7 @@ export function registerSystemRoutes( app.get<{ Params: { id: string } }>('/api/cli/:id/status', async (req, reply) => { const { getCli, missingCliMessage } = await import('../../config/cli-registry/registry.js'); const { resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js'); + const { getCliInstallStatus } = await import('../../config/cli-registry/cli-installer.js'); const entry = getCli(req.params.id); if (!entry) { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Unknown CLI: ${req.params.id}`)); @@ -424,6 +430,7 @@ export function registerSystemRoutes( path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), installHint: available ? null : missingCliMessage(entry.id), + installStatus: getCliInstallStatus(entry.id) ?? null, }, }; }); @@ -441,7 +448,19 @@ export function registerSystemRoutes( if (!result.success) { return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); } - return { success: true, data: { entries: result.entries, warnings: result.warnings } }; + // Enabling a CLI whose binary isn't installed yet kicks off its install command in the + // background — see cli-installer.ts's file header for the trust model. Fire-and-forget: + // this call returns synchronously with whatever status ensureCliInstalled set (usually + // 'installing' immediately, or nothing at all if it was already available), the actual + // install keeps running after this response is sent, and the frontend polls GET + // /api/clis for progress. Never triggered on disable. + let installStatus = null; + if (enabled) { + const { ensureCliInstalled, getCliInstallStatus } = await import('../../config/cli-registry/cli-installer.js'); + ensureCliInstalled(req.params.id); + installStatus = getCliInstallStatus(req.params.id) ?? null; + } + return { success: true, data: { entries: result.entries, warnings: result.warnings, installStatus } }; }); app.put('/api/clis/order', async (req, reply) => { diff --git a/test/cli-installer.test.ts b/test/cli-installer.test.ts new file mode 100644 index 000000000..d409478cc --- /dev/null +++ b/test/cli-installer.test.ts @@ -0,0 +1,123 @@ +/** + * @fileoverview Tests `ensureCliInstalled`'s decision logic (config/cli-registry/cli-installer.ts): + * when it does nothing, when it records a terminal status synchronously, and — the safety + * property that matters most — that it NEVER actually spawns a process under `VITEST` + * (same posture as TmuxManager's `IS_TEST_MODE`, see that module's file header). The real + * spawn/timeout/output-capture mechanics are standard Node child_process wiring and are not + * re-verified here, matching the established precedent for that class of module in this repo. + * + * Port: N/A (no server; pure unit tests against the real registry, mocked `node:child_process` + * as a second line of defense on top of the VITEST gate itself). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const spawnMock = vi.fn(); +vi.mock('node:child_process', () => ({ spawn: spawnMock })); + +describe('ensureCliInstalled', () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(async () => { + const { _resetCliInstallStatusForTest } = await import('../src/config/cli-registry/cli-installer.js'); + _resetCliInstallStatusForTest(); + }); + + it('is a no-op for an unknown id', async () => { + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + ensureCliInstalled('not-a-real-cli'); + expect(getCliInstallStatus('not-a-real-cli')).toBeUndefined(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('is a no-op for "shell" (no binaries to install)', async () => { + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + ensureCliInstalled('shell'); + expect(getCliInstallStatus('shell')).toBeUndefined(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('records success without spawning when the binary is already available', async () => { + // claude, opencode, codex, gemini, antigravity and pi may or may not actually be on + // PATH on the machine running this test, so pin the outcome by stubbing the resolver + // instead of depending on the real environment. + vi.doMock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliBinDir: () => '/usr/local/bin' }; + }); + vi.resetModules(); + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + + ensureCliInstalled('gemini'); + + expect(getCliInstallStatus('gemini')).toEqual({ state: 'success', finishedAt: expect.any(Number) }); + expect(spawnMock).not.toHaveBeenCalled(); + vi.doUnmock('../src/utils/cli-resolver.js'); + vi.resetModules(); + }); + + it('records an error and never spawns when the entry has no install command for this platform', async () => { + vi.doMock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliBinDir: () => null }; + }); + vi.doMock('../src/config/cli-registry/registry.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveInstallCommandForPlatform: () => undefined }; + }); + vi.resetModules(); + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + + ensureCliInstalled('gemini'); + + expect(getCliInstallStatus('gemini')).toEqual({ + state: 'error', + message: 'No install command declared for this platform.', + }); + expect(spawnMock).not.toHaveBeenCalled(); + vi.doUnmock('../src/utils/cli-resolver.js'); + vi.doUnmock('../src/config/cli-registry/registry.js'); + vi.resetModules(); + }); + + it('never spawns a real process under VITEST, and leaves status untouched', async () => { + vi.doMock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliBinDir: () => null }; + }); + vi.resetModules(); + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + + // gemini is a stock CLI with a real install command declared, and its binary is + // stubbed unavailable above — the one shape that WOULD spawn outside a test run. + ensureCliInstalled('gemini'); + + expect(spawnMock).not.toHaveBeenCalled(); + // The VITEST guard returns before touching `_status` at all, so it stays exactly as + // it was (unset) — distinct from a real 'installing'/'error' terminal state, so a + // caller can tell "skipped under test" apart from an actual outcome. + expect(getCliInstallStatus('gemini')).toBeUndefined(); + vi.doUnmock('../src/utils/cli-resolver.js'); + vi.resetModules(); + }); + + it('does not restart an install already recorded as in flight', async () => { + // Exercises the concurrency guard directly against the real status map, without going + // through the (VITEST-gated) spawn path at all. + vi.doMock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliBinDir: () => '/usr/local/bin' }; + }); + vi.resetModules(); + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + + ensureCliInstalled('gemini'); // resolves to 'success' immediately (already available) + const first = getCliInstallStatus('gemini'); + ensureCliInstalled('gemini'); // calling again should not change the recorded status + expect(getCliInstallStatus('gemini')).toEqual(first); + vi.doUnmock('../src/utils/cli-resolver.js'); + vi.resetModules(); + }); +}); diff --git a/test/cli-management-settings.test.ts b/test/cli-management-settings.test.ts index b1a21853f..c177ee050 100644 --- a/test/cli-management-settings.test.ts +++ b/test/cli-management-settings.test.ts @@ -115,6 +115,62 @@ describe('renderCliManagementList', () => { expect(desc.textContent).toContain('npm install -g @openai/codex'); }); + it('shows an "Installing…" row and disables its toggle while an install is in flight', async () => { + const fetchMock = vi.fn(async () => ({ + json: async () => ({ + success: true, + data: [ + STOCK_ROW({ + id: 'copilot', + label: 'GitHub Copilot', + available: false, + installStatus: { state: 'installing', command: 'npm install -g @github/copilot' }, + }), + ], + }), + })); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + const row = list.appendChild.mock.calls[0][0]; + const desc = row.children + .find((c: any) => c.className === 'set-row-text') + .children.find((c: any) => c.className === 'set-row-desc'); + expect(desc.textContent).toContain('Installing…'); + expect(desc.textContent).toContain('npm install -g @github/copilot'); + const toggle = row.children + .find((c: any) => c.className === 'set-row-actions') + .children.find((c: any) => c.className === 'switch switch-sm') + ?.children.find((c: any) => c.type === 'checkbox'); + expect(toggle?.disabled).toBe(true); + }); + + it('shows the install failure message when installStatus is an error', async () => { + const fetchMock = vi.fn(async () => ({ + json: async () => ({ + success: true, + data: [ + STOCK_ROW({ + id: 'copilot', + available: false, + installStatus: { state: 'error', message: 'Install command exited 1.' }, + }), + ], + }), + })); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + const row = list.appendChild.mock.calls[0][0]; + const desc = row.children + .find((c: any) => c.className === 'set-row-text') + .children.find((c: any) => c.className === 'set-row-desc'); + expect(desc.textContent).toContain('Install failed'); + expect(desc.textContent).toContain('Install command exited 1.'); + }); + it('reports a fetch failure inline instead of throwing', async () => { const fetchMock = vi.fn(async () => { throw new Error('network down'); @@ -154,6 +210,41 @@ describe('CLI row actions', () => { expect(calls.some((c) => c.url === '/api/clis')).toBe(true); }); + it('_setCliEnabled starts polling when the PUT response reports an install in flight', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/enabled')) { + return { + json: async () => ({ + success: true, + data: { entries: [], warnings: [], installStatus: { state: 'installing', command: 'npm install -g x' } }, + }), + }; + } + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + app._pollCliInstallStatus = vi.fn(); + + await app._setCliEnabled('copilot', true); + + expect(app._pollCliInstallStatus).toHaveBeenCalledWith('copilot'); + }); + + it('_setCliEnabled does not poll when the CLI was already installed', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/enabled')) { + return { json: async () => ({ success: true, data: { entries: [], warnings: [], installStatus: null } }) }; + } + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + app._pollCliInstallStatus = vi.fn(); + + await app._setCliEnabled('gemini', true); + + expect(app._pollCliInstallStatus).not.toHaveBeenCalled(); + }); + it('_setCliEnabled surfaces a failure via showToast without throwing', async () => { const fetchMock = vi.fn(async (url: string) => { if (url.endsWith('/enabled')) return { json: async () => ({ success: false, error: 'nope' }) }; diff --git a/test/cli-registry-load.test.ts b/test/cli-registry-load.test.ts index 8d4cca03d..434fbbf30 100644 --- a/test/cli-registry-load.test.ts +++ b/test/cli-registry-load.test.ts @@ -17,27 +17,32 @@ import { resolveRegistry } from '../src/config/cli-registry/registry.js'; import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; import type { CliRegistryFile } from '../src/config/cli-registry/types.js'; -/** A well-formed custom entry, reused across the merge and write tests below. */ -const COPILOT_ENTRY = { - id: 'copilot', - label: 'Copilot', - shortBadge: 'GH', +/** + * A well-formed custom entry, reused across the merge and write tests below. Id + * deliberately avoids "copilot" — that became a real stock id once GitHub Copilot CLI + * shipped (disabled by default) in the stock catalog, and these tests exercise the + * CUSTOM-CLI add/remove path, which refuses to touch a stock id. + */ +const CUSTOM_ENTRY = { + id: 'testcli', + label: 'Test CLI', + shortBadge: 'TC', accent: '#24292f', enabled: true, order: 60, kind: 'agent' as const, discovery: { - binaries: ['copilot'], + binaries: ['testcli'], searchDirs: ['~/.local/bin'], - install: { command: { linux: 'npm install -g @githubnext/github-copilot-cli' } }, + install: { command: { linux: 'npm install -g @example/testcli' } }, }, - launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'copilot' }] }] }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'testcli' }] }] }, env: { exports: [], unset: [], tmuxSetenvKeys: [], dockerExecEnvNames: [], - allowedPrefixes: ['COPILOT_'], + allowedPrefixes: ['TESTCLI_'], allowedKeys: [], }, capabilities: { @@ -85,10 +90,10 @@ describe('resolveRegistry (pure merge)', () => { }); it('adds a well-formed custom entry alongside the stock catalog', () => { - const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { copilot: COPILOT_ENTRY } }; + const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { testcli: CUSTOM_ENTRY } }; const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); expect(warnings).toEqual([]); - const found = entries.find((e) => (e.id as unknown as string) === 'copilot'); + const found = entries.find((e) => (e.id as unknown as string) === 'testcli'); expect(found).toBeDefined(); expect(found!.stock).toBe(false); // stock is forced by the loader, never trusted from the file }); @@ -239,17 +244,17 @@ describe('registry writes (setCliEnabled / setCliOrder / upsertCustomCli / remov it('upsertCustomCli adds a new CLI that shows up in the resolved list', async () => { const { upsertCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); - const result = upsertCustomCli('copilot', COPILOT_ENTRY); + const result = upsertCustomCli('testcli', CUSTOM_ENTRY); expect(result.success).toBe(true); expect(result.warnings).toEqual([]); - const found = getCli('copilot'); - expect(found?.label).toBe('Copilot'); + const found = getCli('testcli'); + expect(found?.label).toBe('Test CLI'); expect(found?.stock).toBe(false); }); it('upsertCustomCli rejects a malformed entry with a schema error, writing nothing', async () => { const { upsertCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); - const result = upsertCustomCli('bad-cli', { ...COPILOT_ENTRY, accent: 'not-a-hex-colour' }); + const result = upsertCustomCli('bad-cli', { ...CUSTOM_ENTRY, accent: 'not-a-hex-colour' }); expect(result.success).toBe(false); expect(result.warnings.length).toBeGreaterThan(0); expect(getCli('bad-cli')).toBeUndefined(); @@ -257,19 +262,19 @@ describe('registry writes (setCliEnabled / setCliOrder / upsertCustomCli / remov it('upsertCustomCli refuses to shadow a stock id', async () => { const { upsertCustomCli } = await import('../src/config/cli-registry/registry.js'); - const result = upsertCustomCli('codex', COPILOT_ENTRY); + const result = upsertCustomCli('codex', CUSTOM_ENTRY); expect(result.success).toBe(false); expect(result.warnings[0]).toContain('stock CLI'); }); it('removeCustomCli removes a previously added custom CLI', async () => { const { upsertCustomCli, removeCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); - upsertCustomCli('copilot', COPILOT_ENTRY); - expect(getCli('copilot')).toBeDefined(); + upsertCustomCli('testcli', CUSTOM_ENTRY); + expect(getCli('testcli')).toBeDefined(); - const result = removeCustomCli('copilot'); + const result = removeCustomCli('testcli'); expect(result.success).toBe(true); - expect(getCli('copilot')).toBeUndefined(); + expect(getCli('testcli')).toBeUndefined(); }); it('removeCustomCli refuses to remove a stock CLI', async () => { diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 6699dae3c..45f16d919 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -903,7 +903,7 @@ describe('system-routes', () => { const body = JSON.parse(res.body); expect(body.success).toBe(true); const ids = body.data.map((c: { id: string }) => c.id).sort(); - expect(ids).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode', 'pi', 'shell']); + expect(ids).toEqual(['antigravity', 'claude', 'codex', 'copilot', 'gemini', 'opencode', 'pi', 'shell']); const claude = body.data.find((c: { id: string }) => c.id === 'claude'); expect(claude.available).toBe(true); @@ -1028,25 +1028,28 @@ describe('system-routes', () => { }); describe('POST /api/clis/:id and DELETE /api/clis/:id', () => { + // Id deliberately avoids "copilot" -- that's a real stock id now (GitHub Copilot + // CLI, shipped disabled by default), and this exercises the CUSTOM-CLI add/remove + // path, which refuses to touch a stock id. const CUSTOM_CLI = { - label: 'Copilot', - shortBadge: 'GH', + label: 'Test CLI', + shortBadge: 'TC', accent: '#24292f', enabled: true, order: 60, kind: 'agent', discovery: { - binaries: ['copilot'], + binaries: ['testcli'], searchDirs: ['~/.local/bin'], - install: { command: { linux: 'npm install -g @githubnext/github-copilot-cli' } }, + install: { command: { linux: 'npm install -g @example/testcli' } }, }, - launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'copilot' }] }] }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'testcli' }] }] }, env: { exports: [], unset: [], tmuxSetenvKeys: [], dockerExecEnvNames: [], - allowedPrefixes: ['COPILOT_'], + allowedPrefixes: ['TESTCLI_'], allowedKeys: [], }, capabilities: { @@ -1074,21 +1077,21 @@ describe('system-routes', () => { }; it('adds a custom CLI, then removes it', async () => { - const addRes = await harness.app.inject({ method: 'POST', url: '/api/clis/copilot', payload: CUSTOM_CLI }); + const addRes = await harness.app.inject({ method: 'POST', url: '/api/clis/testcli', payload: CUSTOM_CLI }); expect(addRes.statusCode).toBe(200); const addBody = JSON.parse(addRes.body); expect(addBody.success).toBe(true); - const added = addBody.data.entries.find((c: { id: string }) => c.id === 'copilot'); - expect(added.label).toBe('Copilot'); + const added = addBody.data.entries.find((c: { id: string }) => c.id === 'testcli'); + expect(added.label).toBe('Test CLI'); expect(added.stock).toBe(false); const listRes = await harness.app.inject({ method: 'GET', url: '/api/clis' }); - expect(JSON.parse(listRes.body).data.some((c: { id: string }) => c.id === 'copilot')).toBe(true); + expect(JSON.parse(listRes.body).data.some((c: { id: string }) => c.id === 'testcli')).toBe(true); - const delRes = await harness.app.inject({ method: 'DELETE', url: '/api/clis/copilot' }); + const delRes = await harness.app.inject({ method: 'DELETE', url: '/api/clis/testcli' }); expect(delRes.statusCode).toBe(200); const afterDelete = await harness.app.inject({ method: 'GET', url: '/api/clis' }); - expect(JSON.parse(afterDelete.body).data.some((c: { id: string }) => c.id === 'copilot')).toBe(false); + expect(JSON.parse(afterDelete.body).data.some((c: { id: string }) => c.id === 'testcli')).toBe(false); }); it('400s on a malformed custom CLI body', async () => { From 533ae20e58c6e863a89673a08b13789032fde15a Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:57:36 +0800 Subject: [PATCH 12/15] fix(frontend): rebuild the Run menu from the live CLI registry The bottom-right Run-mode context menu (#runModeMenu) was still static HTML: seven hand-written
- - - - - - + +
+ + + + + + +
- +
+ +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 926ef4d85..c4421d511 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, { e?.stopPropagation(); const menu = document.getElementById('runModeMenu'); if (!menu) return; + this._renderRunModeOptions(); menu.classList.toggle('active'); // Update selected state menu.querySelectorAll('.run-mode-option').forEach(btn => { @@ -441,23 +442,82 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Rebuilds #runModeAgentOptions / #runModeShellOption from window.__codemanClis (the + * live CLI registry, injected by renderIndexHtml — same data GET /api/clis serves) every + * time the menu opens, so a CLI enabled or added from Settings appears with no page + * reload and no markup change: this is what actually fixes "I enabled GitHub Copilot but + * it's not in the Run menu" — the registry was already correct, the menu markup just + * never read it. + * + * A missing/empty registry blob (a build predating the injection, or some other page + * that never got it) leaves the STATIC fallback buttons already in index.html alone — + * see isCliAvailable's own "missing flag reads as available" reasoning for why silence + * beats an empty menu. + */ + _renderRunModeOptions() { + const clis = window.__codemanClis; + if (!Array.isArray(clis) || clis.length === 0) return; + const agentGroup = document.getElementById('runModeAgentOptions'); + const shellGroup = document.getElementById('runModeShellOption'); + if (!agentGroup || !shellGroup) return; + + const enabled = clis.filter(c => c.enabled); + const agents = enabled.filter(c => c.kind !== 'shell').sort((a, b) => a.order - b.order); + const shells = enabled.filter(c => c.kind === 'shell').sort((a, b) => a.order - b.order); + + agentGroup.replaceChildren(...agents.map(c => this._buildRunModeOptionButton(c))); + shellGroup.replaceChildren(...shells.map(c => this._buildRunModeOptionButton(c))); + }, + + _buildRunModeOptionButton(cli) { + const btn = document.createElement('button'); + btn.className = 'run-mode-option'; + btn.dataset.mode = cli.id; + btn.onclick = () => this.setRunMode(cli.id); + const dot = document.createElement('span'); + dot.className = 'run-mode-dot'; + // Inline colour rather than a per-mode CSS class (styles.css only defines + // .run-mode-dot.claude/.opencode/etc for the original six) — accent is exactly the + // field the registry carries for this purpose, so a custom or newly-added stock CLI + // (like copilot) gets a correctly-coloured dot with no CSS change either. + if (cli.accent) dot.style.background = cli.accent; + btn.append(dot, document.createTextNode(cli.label)); + return btn; + }, + /** * #201: hides run-mode dropdown entries for CLIs that aren't installed, so - * picking one doesn't spawn a session that immediately errors out. + * picking one doesn't spawn a session that immediately errors out. Generic over + * WHATEVER buttons are actually present (built by _renderRunModeOptions above, or the + * static fallback markup if that bailed) rather than a fixed mode list, so a CLI added + * after this file was written is gated the same way as the original six. * * Shell has no external CLI dependency and is never gated, which is also what * guarantees the menu is never empty. Scoped to `menu` rather than the document: * `.run-mode-option` is also the class the saved-dashboard rows and the history * rows use, and a bare querySelector would find whichever came first in the DOM. - * - * Antigravity and Pi are in this list even though #201 predates them — they are - * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']) { - const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); - if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; + menu.querySelectorAll('.run-mode-option[data-mode]').forEach(btn => { + const mode = btn.dataset.mode; + if (mode === 'shell') return; + btn.style.display = this._isRunModeAvailable(mode) ? 'flex' : 'none'; + }); + }, + + /** + * Prefers the live registry's own `available` flag (covers any CLI, including one + * `window.__codemanCliAvailable` — the older, fixed six-key map — has never heard of, + * like copilot); falls back to isCliAvailable() when the registry blob is missing. + */ + _isRunModeAvailable(mode) { + const clis = window.__codemanClis; + if (Array.isArray(clis)) { + const entry = clis.find(c => c.id === mode); + if (entry) return entry.available !== false; } + return this.isCliAvailable(mode); }, async _loadRunModeHistory() { diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index c1880b54f..22629f810 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -568,6 +568,13 @@ Object.assign(CodemanApp.prototype, { const data = await res.json(); if (!data.success) throw new Error(data.error || 'Failed to load CLIs'); clis = data.data; + // Keep the page's live registry snapshot in sync with whatever this panel just + // fetched, so the Run-mode menu (_renderRunModeOptions in session-ui.js) reflects + // an enable/disable/add/remove made here immediately — without this, toggling a + // CLI on in Settings and opening Run without a page reload would still show the + // pre-toggle state, since window.__codemanClis is otherwise only ever set once, + // at page load. + window.__codemanClis = clis; } catch (err) { container.textContent = `Failed to load CLI list: ${err.message}`; return; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 76f6ce0c5..370087c25 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -4734,6 +4734,16 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { flex-direction: column; gap: 2px; } +/* Plain grouping wrappers around the dynamically-rendered agent/shell run-mode + options (see index.html + _renderRunModeOptions() in session-ui.js) — without + their own flex+gap, buttons inside them would lose the 2px gap .run-mode-menu.active + gives its DIRECT children, since these divs sit one level in between. */ +#runModeAgentOptions, +#runModeShellOption { + display: flex; + flex-direction: column; + gap: 2px; +} .run-mode-option { display: flex; align-items: center; diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index d3cb7bd88..88c6ca89c 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -361,15 +361,20 @@ describe('Codex quick start settings', () => { ]) { welcomeBtns[id] = { style: { display: 'PRISTINE' } }; } - const modeBtns: Record = {}; - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell']) { - modeBtns[mode] = { style: { display: 'PRISTINE' } }; + const modeBtns: Record = {}; + // "copilot" is deliberately included even though it's not in ALL_OFF/the legacy + // flags map below: _refreshRunModeAvailability must gate ANY button actually + // present, not a fixed list, which is exactly the bug that shipped GitHub Copilot + // CLI enabled with no way to see it in the Run menu. + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'shell', 'copilot']) { + modeBtns[mode] = { dataset: { mode }, style: { display: 'PRISTINE' } }; } const menu = { querySelector: (sel: string) => { const m = sel.match(/data-mode="([^"]+)"/); return m ? (modeBtns[m[1]] ?? null) : null; }, + querySelectorAll: () => Object.values(modeBtns), }; const context: any = vm.createContext({ CodemanApp, @@ -437,24 +442,38 @@ describe('Codex quick start settings', () => { // Shell needs no external CLI, and leaving it alone is what guarantees the // menu is never empty on a box with nothing installed. expect(modeBtns.shell.style.display).toBe('PRISTINE'); + // "copilot" is absent from the legacy flags map entirely (it postdates that + // fixed six-key shape) and window.__codemanClis was never injected in this + // harness either, so this falls through isCliAvailable()'s "unknown reads as + // available" rule rather than being silently skipped. + expect(modeBtns.copilot.style.display).toBe('flex'); }); - it('gates every mode the run-mode menu actually offers', () => { - // Catches a sixth run mode being added to index.html without being gated, - // which is exactly how antigravity slipped past #201. + it('gates every mode the run-mode menu actually offers, generically rather than by a fixed list', () => { + // Catches a run mode being added to index.html without being gated, which is + // exactly how antigravity slipped past #201 -- and, differently, exactly how + // GitHub Copilot CLI shipped enabled with no way to see it in the Run menu + // (that bug was in the MENU MARKUP never being rebuilt from the registry at + // all, but this guards the gating half of the same surface). const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8'); const menuHtml = html.slice(html.indexOf('id="runModeMenu"')); - const offered = [...menuHtml.slice(0, menuHtml.indexOf('
')).matchAll(/data-mode="([^"]+)"/g)].map( + const offered = [...menuHtml.slice(0, menuHtml.indexOf(' +
+ + + + + +
+ - - - -
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 4c8cd540c..183175f48 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1556,6 +1556,14 @@ html.mobile-init .file-browser-panel { margin-top: 1rem; } + /* Same reasoning as styles.css's desktop rule: this group is one flex item + inside .welcome-actions now, so it needs its own matching column+gap. */ + #welcomeCliButtons { + flex-direction: column; + gap: 0.5rem; + width: 100%; + } + .welcome-btn { width: 100%; justify-content: center; diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index c4421d511..38e9d1fe0 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -520,6 +520,89 @@ Object.assign(CodemanApp.prototype, { return this.isCliAvailable(mode); }, + /** + * Rebuilds #welcomeCliButtons from window.__codemanClis, called from + * applyWelcomeCliVisibility() every time the welcome screen shows — so enabling or + * disabling a CLI in Settings (Agents & CLIs) adds or removes its welcome button, the + * same fix as _renderRunModeOptions() for the Run menu. Shows one button per CLI that + * is both ENABLED and AVAILABLE (installed) — shell is excluded, since these five (now: + * however many) buttons are specifically "jump straight into an agent", and shell is + * already reachable from the Run dropdown. + * + * A missing/empty registry blob (an old cached page, or a page that never got the + * injection) falls back to the original five-button, availability-only gating rather + * than leaving every button stuck at its markup-default `display:none` — these buttons + * start HIDDEN in the markup and rely on JS to reveal them, unlike the run-mode-menu's + * always-visible static fallback, so silently doing nothing here would be a regression. + */ + _renderWelcomeCliButtons() { + const clis = window.__codemanClis; + if (!Array.isArray(clis) || clis.length === 0) { + const legacy = [ + ['welcomeClaudeBtn', 'claude'], + ['welcomeOpencodeBtn', 'opencode'], + ['welcomeAntigravityBtn', 'antigravity'], + ['welcomeGeminiBtn', 'gemini'], + ['welcomePiBtn', 'pi'], + ]; + for (const [id, tool] of legacy) { + const btn = document.getElementById(id); + if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none'; + } + return; + } + + const container = document.getElementById('welcomeCliButtons'); + if (!container) return; + const shown = clis + .filter(c => c.enabled && c.kind !== 'shell' && c.available !== false) + .sort((a, b) => a.order - b.order); + container.replaceChildren(...shown.map(c => this._buildWelcomeCliButton(c))); + }, + + /** + * The original five CLIs each have a hand-crafted gradient (`.welcome-btn-` in + * styles.css); anything else (codex, copilot, a custom CLI) gets a flat inline + * background from the registry's own `accent` field instead of an invisible + * transparent button — the same "no per-mode CSS class needed" approach as the + * Run-menu's dot color. + */ + _buildWelcomeCliButton(cli) { + const KNOWN_STYLES = new Set(['claude', 'opencode', 'antigravity', 'gemini', 'pi']); + const btn = document.createElement('button'); + btn.className = KNOWN_STYLES.has(cli.id) ? `welcome-btn welcome-btn-${cli.id}` : 'welcome-btn'; + btn.style.display = 'flex'; + if (!KNOWN_STYLES.has(cli.id) && cli.accent) { + btn.style.background = cli.accent; + btn.style.borderColor = cli.accent; + } + btn.onclick = () => this._runWelcomeCli(cli.id); + + const svgNs = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(svgNs, 'svg'); + svg.setAttribute('width', '20'); + svg.setAttribute('height', '20'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + const polygon = document.createElementNS(svgNs, 'polygon'); + polygon.setAttribute('points', '5 3 19 12 5 21 5 3'); + svg.appendChild(polygon); + + btn.append(svg, document.createTextNode(` Run ${cli.label}`)); + return btn; + }, + + /** Mirrors run()'s own per-mode dispatch, without its launch-lock/button-disable + * behaviour -- matches the original hand-written welcome button onclick handlers. */ + _runWelcomeCli(id) { + this.setRunMode(id); + if (id === 'claude') return this.runClaude(); + if (id === 'shell') return this.runShell(); + return this.runCli(id); + }, + async _loadRunModeHistory() { const container = document.getElementById('runModeHistory'); if (!container) return; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 22629f810..abb4a0448 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1446,22 +1446,16 @@ Object.assign(CodemanApp.prototype, { * #200: show a welcome-screen button only where the thing it launches exists. * The markup ships them hidden, so an old cached page can never flash a button * for a tool this server does not have. + * + * The CLI buttons themselves are rebuilt from the registry by + * _renderWelcomeCliButtons() (session-ui.js) — this only handles the ONE + * button here that isn't a CLI at all: Cloudflare Tunnel, gated on + * `cloudflared` exactly as before. */ applyWelcomeCliVisibility() { - const buttons = [ - ['welcomeClaudeBtn', 'claude'], - ['welcomeOpencodeBtn', 'opencode'], - ['welcomeAntigravityBtn', 'antigravity'], - ['welcomeGeminiBtn', 'gemini'], - ['welcomePiBtn', 'pi'], - // Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box - // without cloudflared can only ever produce "cloudflared not found". - ['welcomeTunnelBtn', 'cloudflared'], - ]; - for (const [id, tool] of buttons) { - const btn = document.getElementById(id); - if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none'; - } + this._renderWelcomeCliButtons?.(); + const tunnelBtn = document.getElementById('welcomeTunnelBtn'); + if (tunnelBtn) tunnelBtn.style.display = this.isCliAvailable('cloudflared') ? 'flex' : 'none'; }, async loadTunnelStatus() { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 370087c25..8db902050 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3531,6 +3531,17 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { margin-top: 2rem; margin-bottom: 1.5rem; } +/* #welcomeCliButtons groups the dynamically-rendered agent buttons (see + _renderWelcomeCliButtons() in session-ui.js) as ONE flex item inside + .welcome-actions, alongside the separate Cloudflare Tunnel button — so it needs + its own flex+gap+wrap, matching the parent's, or the buttons inside it would + lose the gap and wrapping behaviour .welcome-actions gives its DIRECT children. */ +#welcomeCliButtons { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; +} .welcome-btn { display: flex; diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 88c6ca89c..bba3700a6 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -1129,3 +1129,171 @@ describe('_renderRunModeOptions (rebuilding the Run menu from the live registry) expect(calls).toEqual(['copilot']); }); }); + +describe('_renderWelcomeCliButtons (welcome-screen buttons follow enabled/disabled)', () => { + // Regression coverage for: the center-of-page "Run Claude Code" / "Run OpenCode" / + // "Run Gemini" buttons were tied only to CLI AVAILABILITY (isCliAvailable), never to + // whether the CLI was enabled in Settings, and the button set itself was five hardcoded + // ids -- codex and any future CLI (e.g. GitHub Copilot) could never get one at all. + function makeElement() { + return { + tagName: '', + className: '', + style: {} as Record, + onclick: null as (() => void) | null, + children: [] as any[], + append(...nodes: any[]) { + this.children.push(...nodes); + }, + appendChild(node: any) { + this.children.push(node); + return node; + }, + setAttribute() {}, + }; + } + + function loadHarness() { + const elements: Record = {}; + const CodemanApp = function CodemanApp(this: any) {}; + const context: any = vm.createContext({ + CodemanApp, + document: { + getElementById: (id: string) => elements[id] ?? null, + createElement: (tag: string) => { + const el = makeElement(); + el.tagName = tag; + return el; + }, + createElementNS: (_ns: string, tag: string) => { + const el = makeElement(); + el.tagName = tag; + return el; + }, + createTextNode: (text: string) => ({ nodeType: 3, text }), + }, + console, + }); + context.window = context; + + const welcomeCliButtons = { + replaceChildren: (...c: any[]) => (welcomeCliButtons.children = c), + children: [] as any[], + }; + elements.welcomeCliButtons = welcomeCliButtons; + for (const id of [ + 'welcomeClaudeBtn', + 'welcomeOpencodeBtn', + 'welcomeAntigravityBtn', + 'welcomeGeminiBtn', + 'welcomePiBtn', + ]) { + elements[id] = { style: { display: 'PRISTINE' } }; + } + + const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8'); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' }); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + return { app: new (CodemanApp as any)(), welcomeCliButtons, elements, context }; + } + + const CLI = (overrides: Record = {}) => ({ + id: 'claude', + label: 'Claude', + accent: '#d97757', + enabled: true, + kind: 'agent', + order: 0, + available: true, + ...overrides, + }); + + it('includes an enabled, available CLI the static markup never had (copilot)', () => { + const { app, welcomeCliButtons, context } = loadHarness(); + context.__codemanClis = [CLI(), CLI({ id: 'copilot', label: 'GitHub Copilot', accent: '#8957e5', order: 60 })]; + + app._renderWelcomeCliButtons(); + + expect(welcomeCliButtons.children).toHaveLength(2); + const copilotBtn = welcomeCliButtons.children[1]; + expect(copilotBtn.className).toBe('welcome-btn'); // no hand-crafted per-mode class + expect(copilotBtn.style.background).toBe('#8957e5'); // inline accent fallback + }); + + it('excludes a disabled CLI', () => { + const { app, welcomeCliButtons, context } = loadHarness(); + context.__codemanClis = [CLI(), CLI({ id: 'copilot', enabled: false })]; + + app._renderWelcomeCliButtons(); + + expect(welcomeCliButtons.children).toHaveLength(1); + }); + + it('excludes an enabled CLI whose binary is not available', () => { + const { app, welcomeCliButtons, context } = loadHarness(); + context.__codemanClis = [CLI(), CLI({ id: 'copilot', available: false })]; + + app._renderWelcomeCliButtons(); + + expect(welcomeCliButtons.children).toHaveLength(1); + }); + + it('excludes shell -- these buttons are "jump into an agent", shell has its own path', () => { + const { app, welcomeCliButtons, context } = loadHarness(); + context.__codemanClis = [CLI(), CLI({ id: 'shell', kind: 'shell' })]; + + app._renderWelcomeCliButtons(); + + expect(welcomeCliButtons.children).toHaveLength(1); + }); + + it('uses the hand-crafted per-mode class for the original five, sorted by order', () => { + const { app, welcomeCliButtons, context } = loadHarness(); + context.__codemanClis = [CLI({ id: 'pi', order: 50 }), CLI({ id: 'claude', order: 0 })]; + + app._renderWelcomeCliButtons(); + + expect(welcomeCliButtons.children.map((btn: any) => btn.className)).toEqual([ + 'welcome-btn welcome-btn-claude', + 'welcome-btn welcome-btn-pi', + ]); + }); + + it('falls back to the original five-button availability gating when the registry blob is missing', () => { + const { app, elements, context } = loadHarness(); + context.__codemanCliAvailable = { claude: true, opencode: false, antigravity: false, gemini: false, pi: false }; + context.__codemanClis = undefined; + + app._renderWelcomeCliButtons(); + + expect(elements.welcomeClaudeBtn.style.display).toBe('flex'); + expect(elements.welcomeOpencodeBtn.style.display).toBe('none'); + }); + + it("clicking a rendered button dispatches through _runWelcomeCli, mirroring run()'s per-mode routing", () => { + const { app, welcomeCliButtons, context } = loadHarness(); + context.__codemanClis = [CLI({ id: 'copilot', label: 'GitHub Copilot' })]; + const calls: string[] = []; + app.setRunMode = (mode: string) => calls.push(`setRunMode:${mode}`); + app.runCli = (mode: string) => calls.push(`runCli:${mode}`); + + app._renderWelcomeCliButtons(); + welcomeCliButtons.children[0].onclick(); + + expect(calls).toEqual(['setRunMode:copilot', 'runCli:copilot']); + }); + + it('applyWelcomeCliVisibility still gates the Tunnel button on cloudflared, separately from CLIs', () => { + const { app, elements, context } = loadHarness(); + elements.welcomeTunnelBtn = { style: { display: 'PRISTINE' } }; + context.__codemanCliAvailable = { cloudflared: true }; + context.__codemanClis = [CLI()]; + app.loadTunnelStatus = () => {}; + + app.applyWelcomeCliVisibility(); + + expect(elements.welcomeTunnelBtn.style.display).toBe('flex'); + }); +}); From 3940c6026be0b7391de09b4b823b5f9430b28773 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:58:44 +0800 Subject: [PATCH 14/15] fix(frontend): include Shell in the dynamic welcome-screen buttons _renderWelcomeCliButtons() (from the previous commit) still excluded kind:'shell' on the theory that these buttons are "jump into an agent" and shell already has its own path via the Run dropdown. The ORIGINAL hardcoded markup never had a Shell welcome button either, but with the button list now fully dynamic and enabled/disabled-driven, an always-enabled entry like Shell silently missing was inconsistent with the rest of the fix and was flagged directly. Removed the `kind !== 'shell'` filter -- any enabled + available registry entry gets a button now, shell included. test/run-mode-ui.test.ts: replaced the "excludes shell" test with one asserting shell IS included. Verified: npm run typecheck, check-frontend-syntax clean. Full npm test matches the known baseline exactly (55 failed files / 138 failed tests, all pre-existing). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- src/web/public/session-ui.js | 14 ++++++-------- test/run-mode-ui.test.ts | 6 +++--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 38e9d1fe0..7637e3657 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -523,11 +523,11 @@ Object.assign(CodemanApp.prototype, { /** * Rebuilds #welcomeCliButtons from window.__codemanClis, called from * applyWelcomeCliVisibility() every time the welcome screen shows — so enabling or - * disabling a CLI in Settings (Agents & CLIs) adds or removes its welcome button, the - * same fix as _renderRunModeOptions() for the Run menu. Shows one button per CLI that - * is both ENABLED and AVAILABLE (installed) — shell is excluded, since these five (now: - * however many) buttons are specifically "jump straight into an agent", and shell is - * already reachable from the Run dropdown. + * disabling ANY CLI in Settings (Agents & CLIs), stock or custom, adds or removes its + * welcome button automatically, the same fix as _renderRunModeOptions() for the Run + * menu. Shows one button per registry entry that is both ENABLED and AVAILABLE + * (installed), sorted by `order` — including shell, which the ORIGINAL hardcoded markup + * never had a button for at all. * * A missing/empty registry blob (an old cached page, or a page that never got the * injection) falls back to the original five-button, availability-only gating rather @@ -554,9 +554,7 @@ Object.assign(CodemanApp.prototype, { const container = document.getElementById('welcomeCliButtons'); if (!container) return; - const shown = clis - .filter(c => c.enabled && c.kind !== 'shell' && c.available !== false) - .sort((a, b) => a.order - b.order); + const shown = clis.filter(c => c.enabled && c.available !== false).sort((a, b) => a.order - b.order); container.replaceChildren(...shown.map(c => this._buildWelcomeCliButton(c))); }, diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index bba3700a6..5f17c74bd 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -1240,13 +1240,13 @@ describe('_renderWelcomeCliButtons (welcome-screen buttons follow enabled/disabl expect(welcomeCliButtons.children).toHaveLength(1); }); - it('excludes shell -- these buttons are "jump into an agent", shell has its own path', () => { + it('includes an enabled shell entry -- the original hardcoded markup never had a button for it at all', () => { const { app, welcomeCliButtons, context } = loadHarness(); - context.__codemanClis = [CLI(), CLI({ id: 'shell', kind: 'shell' })]; + context.__codemanClis = [CLI(), CLI({ id: 'shell', label: 'Shell', kind: 'shell', order: 1 })]; app._renderWelcomeCliButtons(); - expect(welcomeCliButtons.children).toHaveLength(1); + expect(welcomeCliButtons.children).toHaveLength(2); }); it('uses the hand-crafted per-mode class for the original five, sorted by order', () => { From 79901c68c4d18fd50c03a0c754616e4bb840885e Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:21:53 +0800 Subject: [PATCH 15/15] feat(cli-registry): add Grok Build (xAI) + port upstream's resolver bug fixes (merge plan phases 1-2) First two steps of reconciling upstream Ark0N/Codeman's master (103 commits ahead, merge-base 07b9c7fd) with this branch, done before the actual merge so the registry already has everything it needs when the merge lands. Phase 1 -- Grok as a registry entry, not a hardcoded 7th mode: Upstream added Grok Build the OLD way (commit 3f8c8e99 + follow-ups 57f326ab, 9cfd8e89 -- its own SessionMode literal, its own resolver file, hardcoded branches across ~40 files). Ported it into a single registry entry instead, enabled by default (a real, established mode upstream ships live, unlike Copilot's experimental opt-in): - src/config/cli-registry/stock.ts: new GROK entry. discovery/launch/env/ capabilities facts extracted directly from upstream's commits and cross-checked against its own pinned test/grok-mode.test.ts (verified byte-identical via a standalone renderLaunch check: bare spawn, --always-approve/--model, --resume vs --continue precedence, unsafe model/resumeId values dropped rather than escaped, remote command format). accent is a single hex (#a1a1aa) since upstream hand-authored a multi-spot CSS gradient our registry's one-hex `accent` field can't reproduce -- every surface gets it via the same inline-accent fallback Copilot already uses. privilegedParams: [{param:'alwaysApprove', clampTo:false}] -- the only-if-sent shape (codex/antigravity's branch, not pi/gemini's materialize branch) -- feeds the clampExternalCliBypassForOwner generalization planned for a later phase. - docker/agent.Dockerfile: new special-case RUN block (Grok is not on npm -- xAI's own installer, same tier as Antigravity's), copied verbatim from upstream's 9cfd8e89 symlink-survival fix (stages as grok.real, removes the pre-existing /usr/local/bin/grok, moves into place -- survives both old and new xAI installer behavior). .grok added to the pre-created per-file credential seed dirs. - skills/codeman/reference/*.md: added grok to every mode-enumeration list test/agent-skill-mode-lists.test.ts already derives from the registry and flagged as stale (including one pre-existing gap for codex this branch had already introduced and never caught). Documented GET /api/v1/cli/grok/status (no legacy per-mode alias exists for grok on this fork, unlike the original six). - test/agent-skill-mode-lists.test.ts: the not-found-probe-documented check's regex only recognized the legacy `/api//status` shape; extended to also recognize the generic `/api/cli//status` shape modern registry entries use instead -- a real gap the test itself never had to close before (grok is the first enabled-by-default entry added since Copilot, which is disabled-by-default and so never triggered this path). - test/routes/system-routes.test.ts: extended the hardcoded ids-list assertion. - config/clis.stock.json regenerated (npm run generate:cli-stock-json). Phase 2 -- ported upstream's independently-built resolver bug fixes into our registry-driven src/utils/cli-resolver.ts (PR #329 + follow-up 61251c0b built a SEPARATE shared resolver, cli-executable-resolver.ts, with real fixes ours lacked): - Login-shell fallback: after PATH (`which`) and the declared search dirs both miss, spawn the user's login shell (`shell-resolver.ts`'s existing resolveLocalShell/loginShellArgs, previously only used for `mode:'shell'` sessions) with a tagged `command -v --` probe -- what actually finds nvm/ Homebrew/user-npm installs when Codeman runs as a systemd/launchd service with a minimal PATH. - Negative-result caching with backoff: reused the EXISTING resolveRetryingVersion/retryingVersionProbeDelayMs mechanism (already proven for claude's version probe) rather than duplicating upstream's separate backoff curve -- applied to createDirResolver's and createVersionGatedResolver's whole probe chain, so a missing CLI is retried on a 1min-doubling-to-15min schedule instead of either being cached as missing forever or re-running the full chain (including the login-shell spawn) on every request. - killSignal:'SIGKILL' added to every timeout-bounded exec call in this file -- execFileSync's `timeout` only SENDS the signal and keeps waiting for the child to exit; an interactive shell/CLI ignoring SIGTERM would otherwise survive the timeout and block the resolver, and therefore the request handler calling it, forever. - VITEST hermeticity: createDirResolver's `which` call had NO guard and did a real subprocess/PATH lookup even under the test suite -- closed before adding the login-shell step, which would otherwise ALSO spawn for real under tests. - Not-found diagnostics: formatCliNotFoundMessage (bounded/sanitized PATH, login-shell command, searched dirs -- same 1024-char bounding and control-char flattening as upstream) wired into registry.ts's missingCliMessage(), so every caller (tmux-manager's spawn throw, session-routes' availability gate) gets it for free. Verified: renderLaunch() output for grok matches every case in upstream's pinned test/grok-mode.test.ts exactly. npm run typecheck/lint clean, no circular-import issue from registry.ts <-> cli-resolver.ts (both only reference each other inside function bodies, never at module-load time -- confirmed via the full registry test suite passing, not just tsc). Full npm test compared against a FRESH git-stash baseline taken in this same environment (not a stale remembered number): baseline 56 failed files / 139 failed tests, this change 57/140 -- within normal Windows-environment flakiness (confirmed two of the specific files that differed, render-index-html.test.ts and session-routes-parent-lineage.test.ts, fail IDENTICALLY on the stashed baseline, unrelated to this change). Part of the plan at C:\Users\dsati\.claude\plans\this-is-a-fork-keen-map.md (Phases 1-2 of 6). The actual merge (Phase 3) follows in a later commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM --- config/clis.stock.json | 104 ++++++++-- docker/agent.Dockerfile | 23 ++- skills/codeman/reference/endpoints.md | 12 +- skills/codeman/reference/messaging.md | 4 +- skills/codeman/reference/recipes.md | 2 +- skills/codeman/reference/verbs.md | 6 +- src/config/cli-registry/registry.ts | 7 +- src/config/cli-registry/stock.ts | 100 +++++++++- src/utils/cli-resolver.ts | 277 +++++++++++++++++++------- test/agent-skill-mode-lists.test.ts | 11 +- test/routes/system-routes.test.ts | 2 +- 11 files changed, 448 insertions(+), 100 deletions(-) diff --git a/config/clis.stock.json b/config/clis.stock.json index 16ea4c8d8..16b926dfc 100644 --- a/config/clis.stock.json +++ b/config/clis.stock.json @@ -4,8 +4,16 @@ "label": "Claude", "stock": true, "discovery": { - "binaries": ["claude"], - "searchDirs": ["~/.local/bin", "~/.claude/local", "/usr/local/bin", "~/.npm-global/bin", "~/bin"], + "binaries": [ + "claude" + ], + "searchDirs": [ + "~/.local/bin", + "~/.claude/local", + "/usr/local/bin", + "~/.npm-global/bin", + "~/bin" + ], "version": { "arg": "--version", "regex": "(\\d+\\.\\d+\\.\\d+)", @@ -39,7 +47,9 @@ "label": "OpenCode", "stock": true, "discovery": { - "binaries": ["opencode"], + "binaries": [ + "opencode" + ], "searchDirs": [ "~/.opencode/bin", "~/.local/bin", @@ -68,8 +78,17 @@ "label": "Codex", "stock": true, "discovery": { - "binaries": ["codex"], - "searchDirs": ["~/.codex/bin", "~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "binaries": [ + "codex" + ], + "searchDirs": [ + "~/.codex/bin", + "~/.local/bin", + "/usr/local/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], "version": { "arg": "--version", "regex": "(\\d+\\.\\d+\\.\\d+)" @@ -89,8 +108,17 @@ "label": "Gemini", "stock": true, "discovery": { - "binaries": ["gemini"], - "searchDirs": ["~/.gemini/bin", "~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "binaries": [ + "gemini" + ], + "searchDirs": [ + "~/.gemini/bin", + "~/.local/bin", + "/usr/local/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], "version": { "arg": "--version", "regex": "(\\d+\\.\\d+\\.\\d+)" @@ -110,8 +138,15 @@ "label": "Antigravity", "stock": true, "discovery": { - "binaries": ["agy"], - "searchDirs": ["~/.local/bin", "~/.antigravity/bin", "/usr/local/bin", "~/bin"], + "binaries": [ + "agy" + ], + "searchDirs": [ + "~/.local/bin", + "~/.antigravity/bin", + "/usr/local/bin", + "~/bin" + ], "version": { "arg": "--version", "regex": "(\\d+\\.\\d+\\.\\d+)" @@ -130,8 +165,16 @@ "label": "Pi", "stock": true, "discovery": { - "binaries": ["pi"], - "searchDirs": ["~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "binaries": [ + "pi" + ], + "searchDirs": [ + "~/.local/bin", + "/usr/local/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], "version": { "arg": "--version", "regex": "(?:^|\\s)(\\d+\\.\\d+\\.\\d+)", @@ -152,8 +195,15 @@ "label": "GitHub Copilot", "stock": true, "discovery": { - "binaries": ["copilot"], - "searchDirs": ["~/.local/bin", "/usr/local/bin", "~/.npm-global/bin", "~/bin"], + "binaries": [ + "copilot" + ], + "searchDirs": [ + "~/.local/bin", + "/usr/local/bin", + "~/.npm-global/bin", + "~/bin" + ], "version": { "arg": "--version", "regex": "(\\d+\\.\\d+\\.\\d+)" @@ -167,5 +217,33 @@ "docsUrl": "https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli" } } + }, + { + "id": "grok", + "label": "Grok", + "stock": true, + "discovery": { + "binaries": [ + "grok" + ], + "searchDirs": [ + "~/.grok/bin", + "~/.local/bin", + "/usr/local/bin", + "~/bin" + ], + "version": { + "arg": "--version", + "regex": "(?:^|\\s)(\\d+\\.\\d+\\.\\d+)", + "requireVersionMatch": true + }, + "install": { + "command": { + "linux": "curl -fsSL https://x.ai/cli/install.sh | bash", + "darwin": "curl -fsSL https://x.ai/cli/install.sh | bash" + }, + "docsUrl": "https://github.com/xai-org/grok-build" + } + } } ] diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index 55a97d5d6..6e2ad2099 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -58,6 +58,24 @@ RUN npm install -g --ignore-scripts ${CLI_PI_NPM_PACKAGE} \ && npm cache clean --force \ && pi --version +# Grok Build (`grok`, xAI) has no npmPackage in the registry — it is NOT on npm, a +# standalone ~160MB Rust binary through xAI's own installer, which targets +# $HOME/.grok/bin with no --dir override. At build time that is root's home and +# unreachable by the `agent` user, so copy the binary into /usr/local/bin and drop +# root's ~/.grok in the same layer so the image does not carry the download twice. +# The staging cp -T is what makes this survive the installer's own behavior EITHER +# way: newer installers already symlink /usr/local/bin/grok -> /root/.grok/bin/grok, +# and a direct `cp -L` onto that symlink fails with "same file", while removing the +# link first and copying fresh works for both old and new installers. +ARG CLI_GROK_INSTALL_URL="https://x.ai/cli/install.sh" +RUN curl -fsSL "${CLI_GROK_INSTALL_URL}" | bash \ + && cp -L /root/.grok/bin/grok /usr/local/bin/grok.real \ + && rm -f /usr/local/bin/grok \ + && mv /usr/local/bin/grok.real /usr/local/bin/grok \ + && chmod 755 /usr/local/bin/grok \ + && rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \ + && grok --version + # `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is # auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at # runtime Codeman overrides with `--user :0` on Linux, so the baked uid @@ -75,11 +93,12 @@ ENV HOME=/home/agent # transcript/rollout dirs (`.claude/projects`, `.codex/sessions`) are bind-mounted from # the host. (gemini/gcloud/opencode are whole seed-copies and need no pre-created dir; # Antigravity nests its state inside `.gemini/antigravity-cli`, so it rides that seed.) -# `.pi/agent` IS pre-created: pi is seeded per-FILE (auth/settings/trust/models), and a +# `.pi/agent` and `.grok` ARE pre-created: both are seeded per-FILE (pi: +# auth/settings/trust/models; grok: auth.json/config.toml/pager.toml), and a # per-file seed copy, unlike a whole-dir one, does not create its parent directory. RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \ && mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \ - /home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent \ + /home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \ && chgrp -R 0 /home/agent \ && chmod -R g=u /home/agent diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 12c2fb985..221b019b0 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -237,7 +237,7 @@ minutes, never retry the credential. flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait returns is too early (verified live: empty on the first call, full prose seconds later). It is also `""` before the worker's first completed turn, and permanently `""` for -`shell`, `opencode`, `gemini`, `antigravity` and `pi`, which write no Claude transcript. +`shell`, `opencode`, `gemini`, `antigravity`, `pi` and `grok`, which write no Claude transcript. **Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode, that is expected, not a failure: read `terminal?tail=` and strip ANSI instead. @@ -336,7 +336,7 @@ ESC=$(printf '\033') `POST /api/v1/quick-start` body (all optional): `{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}` -, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi`; response is +, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok`; response is `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory on the user's disk) if missing, do not retry it in a loop, and remember the name. @@ -345,8 +345,10 @@ on the user's disk) if missing, do not retry it in a loop, and remember the name the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, `GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status` and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed). -Pi's also carries `.data.version`, because `pi` is a short generic name that an unrelated -binary on `$PATH` can shadow: the resolver rejects one whose `--version` is not +`grok` has no legacy per-mode alias — use the generic `GET /api/v1/cli/grok/status` +instead, same response shape. Pi's and grok's both also carry `.data.version`, because +`pi`/`grok` are short generic names an unrelated binary on `$PATH` can shadow: the +resolver rejects one whose `--version` is not semver-shaped, so `available:false` there can mean "a different `pi` is in front" rather than "nothing is installed". `shell` has no CLI to probe. @@ -463,7 +465,7 @@ Quirks that will bite you: - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser, which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` returns early for every external CLI mode (`session.ts:2136`), so it is permanently - `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`. ⚠️ **`shell` is NOT one of those** + `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`. ⚠️ **`shell` is NOT one of those** (`isExternalCliMode`, `session.ts:165-167`, lists only those five), so the parser does run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:88`) matches bare `tail|cat|head|less|grep|watch|multitail ` lines with no `● Bash(` wrapper: diff --git a/skills/codeman/reference/messaging.md b/skills/codeman/reference/messaging.md index ac7e391c0..6f30b6562 100644 --- a/skills/codeman/reference/messaging.md +++ b/skills/codeman/reference/messaging.md @@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it. | synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) | | liveness / death check | HTTP `wait?until=exit` | | interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` | -| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`) | HTTP only (no other CLI has messaging) | +| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`) | HTTP only (no other CLI has messaging) | | delete | HTTP, via SKILL.md's `delete_session` guard | ## Availability: probe, never assume @@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw ### Mixed fleets: the pairing matrix -Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`) cannot be peers +Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`) cannot be peers at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention messaging in their briefs. The claude half of the fleet can use messaging among itself, subject to the namespace rule: **messaging works between two sessions that share one diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index c5c4f9853..c43e6610b 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -188,7 +188,7 @@ for _ in $(seq 1 10); do done printf '%s\n' "$TXT" # (.data is {text,timestamp}; text is also "" before the first completed turn and -# always "" for shell/opencode/gemini/antigravity/pi, which have no transcript, use +# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use # the terminal tail there, and here only to diagnose an unsubmitted prompt.) # 6. clean up: exact id, own list only, through the fail-closed preamble helper diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index d00a8114e..aa0366a02 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -343,7 +343,7 @@ recovered by submitting it with `{"input":"\r"}`. ⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks, and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On -`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`, requesting them explicitly is a +`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a 400, and lifecycle transitions there are coarse (a short shell command may emit **no** `idle` transition at all, verified live), so synchronize those with markers. @@ -369,7 +369,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire single read taken the instant send-and-wait returns comes back `""` even though the turn finished (verified live: empty on the first call, full text seconds later). `text` is also `""` before the worker's first completed turn, and always `""` for modes with -no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`; the first four +no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`; the first five verified live, pi from the same source path), which is why the loop above is bounded rather than open-ended. Fall back to the terminal buffer there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive @@ -454,7 +454,7 @@ turn), and both better than diffing terminal samples: ``` ⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for -`opencode`/`codex`/`gemini`/`antigravity`/`pi`** (those parsers are skipped wholesale) and +`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`** (those parsers are skipped wholesale) and in practice empty for `shell`. Source-verified, not measured live. Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing diff --git a/src/config/cli-registry/registry.ts b/src/config/cli-registry/registry.ts index a5f157b4a..9feb7b7d0 100644 --- a/src/config/cli-registry/registry.ts +++ b/src/config/cli-registry/registry.ts @@ -26,6 +26,7 @@ import { dataPath } from '../instance.js'; import type { CliEntry, CliId, CliRegistryFile } from './types.js'; import { CliEntrySchema } from './schema.js'; import { STOCK_CLIS } from './stock.js'; +import { formatCliNotFoundMessage } from '../../utils/cli-resolver.js'; const SCHEMA_VERSION = 1; @@ -330,9 +331,13 @@ export function missingCliMessage(id: string): string | null { const entry = getCli(id); if (!entry) return null; const command = resolveInstallCommandForPlatform(entry); - return command + const base = command ? `${entry.label} CLI not found. Install with: ${command}` : `${entry.label} CLI not found. See its docs for install instructions.`; + // Bounded PATH/login-shell/search-dir diagnostics appended so the error names exactly + // where resolution looked, not just what it was looking for — ported from upstream's + // formatCliNotFoundMessage (see cli-resolver.ts's own doc comment for the full story). + return formatCliNotFoundMessage(base, id); } /** diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 9a5b61037..e617f7378 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -697,5 +697,103 @@ const COPILOT: CliEntry = { }, }; +// Grok Build (xAI, `grok`). Ported from upstream Ark0N/Codeman's hardcoded 7th-mode +// addition (commit 3f8c8e99 + follow-ups 57f326ab, 9cfd8e89) into registry data — a real, +// established mode (enabled by default), not an experimental opt-in like Copilot. +const GROK: CliEntry = { + id: 'grok' as CliEntry['id'], + label: 'Grok', + shortBadge: 'GK', + // Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab + // badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's + // `accent` is a single hex, so this is the closest single value (the run-mode-dot colour, + // zinc-400) — every OTHER surface just gets this via the inline-accent fallback the same + // way Copilot does, since there is no bespoke `.welcome-btn-grok`/`.run-mode-dot.grok` + // CSS class in this fork. + accent: '#a1a1aa', + enabled: true, + stock: true, + order: 70, + kind: 'agent', + discovery: { + binaries: ['grok'], + searchDirs: ['~/.grok/bin', HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.homeBin], + // `grok` has a known npm squatter (@vibe-kit/grok-cli also installs a `grok` bin), so a + // bare `which grok` hit is not evidence of the right program — same defence as pi, + // byte-identical regex. + version: { arg: '--version', regex: '(?:^|\\s)(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, + install: { + command: { + linux: 'curl -fsSL https://x.ai/cli/install.sh | bash', + darwin: 'curl -fsSL https://x.ai/cli/install.sh | bash', + }, + // Not on npm — xAI ships a standalone installer/binary, same shape as Antigravity. + docsUrl: 'https://github.com/xai-org/grok-build', + }, + }, + launch: { + params: { + alwaysApprove: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + continueSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'grok' }, + { flag: '--always-approve', when: { param: 'alwaysApprove', is: true } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + lit: '--continue', + when: { + allOf: [ + { param: 'continueSession', is: true }, + { param: 'resumeId', state: 'unset' }, + ], + }, + }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + resumeAppend: { style: 'flag', flag: '--resume' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + // No tmuxSetenvKeys: XAI_API_KEY (xAI's documented headless auth var) is covered by the + // XAI_ prefix allowlist below, same "rely on the prefix, not an explicit key list" + // reasoning as pi's ~34 provider keys. + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['GROK_', 'XAI_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + // Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity: + // only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. + altScreen: 'strip-mux-only', + // Buffer-policy fallthrough default, unmeasured against an authenticated grok composer + // (upstream's own hedge, preserved here) — same as gemini/antigravity/pi/copilot. + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // codex/antigravity-shaped clamp: grok's own bare-spawn default (no config sent) is + // already its safe interactive ask-mode, so the multi-user clamp only needs to force an + // EXPLICITLY-SENT bypass flag back off — nothing is materialized when config is absent. + privilegedParams: [{ param: 'alwaysApprove', clampTo: false }], + }, + overlays: { + // ~/.grok also holds sessions/, memory/, downloads/ (the ~160MB binary), completions/, + // docs/, bin/ — per-file seeding like pi's credStore, not a whole-dir seedWhole copy. + credStore: { rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'] }, + // No remote/docker overlay needed: the defaults (exec grok / login-shell `grok`) are + // already correct — verified against upstream's own pinned test/grok-mode.test.ts + // expectation `exec "${SHELL:-/bin/sh}" -i -l -c 'grok'`. + }, +}; + /** The full stock catalog, in the order the run menu shows by default. */ -export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, COPILOT]; +export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, COPILOT, GROK]; diff --git a/src/utils/cli-resolver.ts b/src/utils/cli-resolver.ts index c09d5e31b..ec0ffdfa1 100644 --- a/src/utils/cli-resolver.ts +++ b/src/utils/cli-resolver.ts @@ -1,9 +1,10 @@ /** * @fileoverview Generic CLI binary resolution, shared by every per-CLI resolver * (`claude-cli-resolver.ts`, `opencode-cli-resolver.ts`, `codex-cli-resolver.ts`, - * `gemini-cli-resolver.ts`, `antigravity-cli-resolver.ts`, `pi-cli-resolver.ts`). + * `gemini-cli-resolver.ts`, `antigravity-cli-resolver.ts`, `pi-cli-resolver.ts`, + * `grok-cli-resolver.ts`). * - * Those six files used to each hand-roll the same `which` + search-dir walk with a + * Those files used to each hand-roll the same `which` + search-dir walk with a * module-level cache. They now call into this module and re-export the result under their * historical names, so every existing caller (`findClaudeDir()`, `resolvePiDir()`, …) and * every `vi.mock('.../opencode-cli-resolver.js')` in the test suite keeps working unchanged @@ -13,23 +14,122 @@ * registry's stock catalog, so this is also where the resolvers stop duplicating data that * `src/config/cli-registry/stock.ts` already declares. * + * **Resolution chain** (ported from upstream Ark0N/Codeman's independently-built + * `cli-executable-resolver.ts`, PR #329 + follow-up `61251c0b`, into this registry-driven + * module rather than duplicated per-CLI): PATH (`which`) → declared search dirs → an + * interactive LOGIN SHELL as the last resort, since that is what finds nvm/Homebrew/ + * user-npm installs when Codeman runs as a systemd/launchd service with a minimal PATH + * (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`). The login-shell step is the only + * one that spawns anything beyond a `which`, so it stays last. + * + * A MISS across the whole chain is negative-cached with a doubling backoff (reusing + * `resolveRetryingVersion`/`retryingVersionProbeDelayMs` below — the exact mechanism + * `getClaudeCliVersion` already used for its own version probe, generalized here to the + * directory-resolution miss path too) rather than either caching it forever (the original + * bug: a missing CLI re-ran the whole chain, including the synchronous login-shell spawn, + * on every request, forever) or never caching it at all. + * + * Every exec call that carries a `timeout` also carries `killSignal: 'SIGKILL'`: + * `execFileSync`'s `timeout` option only SENDS the signal and then keeps waiting for the + * child to exit — the default SIGTERM is ignored by an interactive bash stuck in a blocking + * `.bash_profile`, which would otherwise survive the timeout and block the server forever. + * + * Hermeticity: under `VITEST`, no exec call in this module ever runs for real — the suites + * must never depend on, or execute, whatever happens to be installed on the machine running + * them (same rule as `IS_TEST_MODE` in tmux-manager.ts). + * * @module utils/cli-resolver */ import { execFileSync, execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { delimiter, dirname, join } from 'node:path'; +import { basename, delimiter, dirname, join } from 'node:path'; import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; import { compileVersionRegex } from '../config/cli-registry/patterns.js'; import type { CliVersionProbe } from '../config/cli-registry/types.js'; import { getCli } from '../config/cli-registry/registry.js'; +import { loginShellArgs, resolveLocalShell } from './shell-resolver.js'; /** Expand a leading `~` to the current homedir. Search dirs carry no other expansion. */ function expandHome(dir: string): string { return dir.startsWith('~') ? join(homedir(), dir.slice(1).replace(/^[/\\]/, '')) : dir; } +// --------------------------------------------------------------------------- +// Login-shell fallback — the last-resort step in the resolution chain. +// --------------------------------------------------------------------------- + +const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__'; +const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__'; + +function loginShellProbeCommand(binary: string): string { + return [ + `printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`, + `command -v -- ${binary}`, + `printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`, + ].join('; '); +} + +/** Only lines BETWEEN the markers, absolute, and matching `binary`'s basename are trusted + * — a login shell's `.bash_profile`/`.zshrc` can print arbitrary noise ahead of the result. */ +function parseLoginShellResult(output: string, binary: string): string | null { + const lines = output.split(/\r?\n/).map((line) => line.trim()); + const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER); + if (begin === -1) return null; + const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1); + if (end === -1) return null; + for (const candidate of lines.slice(begin + 1, end)) { + if (candidate.startsWith('/') && basename(candidate) === binary) return candidate; + } + return null; +} + +/** + * Spawn the user's login shell to resolve `binary` via `command -v`. Returns `null` under + * VITEST (never spawns for real in tests) or on any failure — this is a best-effort last + * resort, not a required step. + */ +function findInLoginShell(binary: string): string | null { + if (process.env.VITEST) return null; + const shellPath = resolveLocalShell(); + const shellArgs = loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean); + try { + const out = execFileSync(shellPath, [...shellArgs, '-c', loginShellProbeCommand(binary)], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + killSignal: 'SIGKILL', + }); + const candidate = parseLoginShellResult(out, binary); + return candidate && existsSync(candidate) ? candidate : null; + } catch { + return null; + } +} + +/** `which `, VITEST-gated (never spawns for real in tests, matching every other probe + * in this module — this call previously had NO such guard, a real hermeticity gap). */ +function findOnProcessPath(bin: string): string | null { + if (process.env.VITEST) return null; + try { + const result = execSync(`which ${bin}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + killSignal: 'SIGKILL', + }).trim(); + return result && existsSync(result) ? result : null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Negative-result caching with backoff — shared by both resolver flavors below via +// `resolveRetryingVersion`, the SAME mechanism claude's own version probe already used +// (see that section further down), rather than a second, duplicated backoff curve. +// --------------------------------------------------------------------------- + /** * A resolver instance for one CLI. Each call to `createDirResolver()` returns its own * closured cache, exactly like the six hand-written modules each had their own @@ -41,48 +141,41 @@ export interface DirResolver { } /** - * The plain "which, then search dirs" resolver — covers opencode, codex, gemini and - * antigravity today, and any future CLI with no version-sanity requirement. + * The plain "which, then search dirs, then a login shell" resolver — covers opencode, + * codex, gemini, antigravity, grok and any future CLI with no version-sanity requirement. */ export function createDirResolver(binaries: string[], searchDirs: string[]): DirResolver { - let cached: string | null = null; // '' = searched, not found const dirs = searchDirs.map(expandHome); + const state: RetryingVersionProbeState = { failures: 0, lastFailureAt: 0 }; - function resolveDir(): string | null { - if (cached !== null) return cached || null; - + function probeChain(): string | null { for (const bin of binaries) { - try { - const result = execSync(`which ${bin}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); - if (result && existsSync(result)) { - cached = dirname(result); - return cached; - } - } catch { - // not on PATH via `which`; fall through to the search dirs - } + const found = findOnProcessPath(bin); + if (found) return dirname(found); } - for (const dir of dirs) { for (const bin of binaries) { - if (existsSync(join(dir, bin))) { - cached = dir; - return cached; - } + if (existsSync(join(dir, bin))) return dir; } } - - cached = ''; + for (const bin of binaries) { + const found = findInLoginShell(bin); + if (found) return dirname(found); + } return null; } + function resolveDir(): string | null { + return resolveRetryingVersion(state, Date.now(), probeChain); + } + return { resolveDir, isAvailable: () => resolveDir() !== null }; } /** * A resolver whose EVERY candidate must pass a version-sanity probe before being accepted - * — pi's behaviour, generalized. For a CLI with a short, generic binary name, a `which` hit - * is not by itself evidence the right program is installed. + * — pi's/grok's behaviour, generalized. For a CLI with a short, generic binary name, a + * `which` hit is not by itself evidence the right program is installed. * * Under `VITEST` the probe never runs (existence alone decides), matching every resolver's * hermetic-test behaviour: the suites must not depend on what happens to be on the dev box. @@ -97,10 +190,10 @@ export function createVersionGatedResolver( probe: CliVersionProbe, logPrefix: string ): VersionGatedResolver { - let cachedDir: string | null = null; // '' = searched, not found - let cachedVersion: string | null = null; const dirs = searchDirs.map(expandHome); const regex = probe.regex ? compileVersionRegex(probe.regex) : null; + const state: RetryingVersionProbeState = { failures: 0, lastFailureAt: 0 }; + let cachedVersion: string | null = null; function probeOne(binPath: string): string | null { if (process.env.VITEST) return null; @@ -109,6 +202,7 @@ export function createVersionGatedResolver( encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], + killSignal: 'SIGKILL', }).trim(); const candidate = regex ? regex.exec(out)?.[1] : out || null; if (candidate) return candidate; @@ -121,32 +215,23 @@ export function createVersionGatedResolver( function accept(binPath: string): string | null { if (process.env.VITEST) { - cachedDir = dirname(binPath); cachedVersion = ''; - return cachedDir; + return dirname(binPath); } const version = probeOne(binPath); if (!version) return null; - cachedDir = dirname(binPath); cachedVersion = version; - return cachedDir; + return dirname(binPath); } - function resolveDir(): string | null { - if (cachedDir !== null) return cachedDir || null; - + function probeChain(): string | null { for (const bin of binaries) { - try { - const result = execSync(`which ${bin}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); - if (result && existsSync(result)) { - const dir = accept(result); - if (dir) return dir; - } - } catch { - // not on PATH via `which` + const found = findOnProcessPath(bin); + if (found) { + const dir = accept(found); + if (dir) return dir; } } - for (const dir of dirs) { for (const bin of binaries) { const binPath = join(dir, bin); @@ -155,12 +240,20 @@ export function createVersionGatedResolver( if (accepted) return accepted; } } - - cachedDir = ''; - cachedVersion = ''; + for (const bin of binaries) { + const found = findInLoginShell(bin); + if (found) { + const dir = accept(found); + if (dir) return dir; + } + } return null; } + function resolveDir(): string | null { + return resolveRetryingVersion(state, Date.now(), probeChain); + } + return { resolveDir, isAvailable: () => resolveDir() !== null, @@ -174,13 +267,19 @@ export function createVersionGatedResolver( // --------------------------------------------------------------------------- // Claude's retry/backoff version probe. Pure apart from the `state` it mutates // and the injected `probe`, so it stays directly unit-testable exactly as -// `test/claude-cli-version-cache.test.ts` already exercises it. +// `test/claude-cli-version-cache.test.ts` already exercises it. Also now the +// shared backoff mechanism for `createDirResolver`/`createVersionGatedResolver`'s +// own directory-miss caching above. // --------------------------------------------------------------------------- /** - * Cache state for a `--version` probe with retry/backoff. `version` is only ever set from a + * Cache state for a probe with retry/backoff. `version` is only ever set from a * SUCCESSFUL probe and then kept for the process lifetime (the binary can't change under a * running server without a restart). Failures are tracked separately so they expire. + * + * Named for its original use (claude's `--version` probe) but the field holds any + * successfully-resolved string — a version number OR a resolved directory path, per + * `createDirResolver`/`createVersionGatedResolver` above. */ export interface RetryingVersionProbeState { /** Successful probe result; `undefined` until one succeeds. */ @@ -206,10 +305,10 @@ export function retryingVersionProbeDelayMs(failures: number): number { } /** - * Cache policy for a retry/backoff version probe. Success is cached forever, failure is not - * — see claude-cli-resolver.ts's original doc comment (preserved there) for the shipped bug - * this asymmetry fixes: caching a transient failure forever silently disabled every feature - * gated on the version for the rest of the process lifetime. + * Cache policy for a retry/backoff probe. Success is cached forever, failure is not — see + * claude-cli-resolver.ts's original doc comment (preserved there) for the shipped bug this + * asymmetry fixes: caching a transient failure forever silently disabled every feature + * gated on the result for the rest of the process lifetime. */ export function resolveRetryingVersion( state: RetryingVersionProbeState, @@ -258,6 +357,7 @@ export function createRetryingVersionGetter(opts: { const out = execFileSync(bin, [opts.versionArg], { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, + killSignal: 'SIGKILL', env: { ...process.env, PATH: opts.getAugmentedPath ? opts.getAugmentedPath() : process.env.PATH }, }); const match = regex ? regex.exec(out) : null; @@ -281,31 +381,70 @@ export function augmentPath(dir: string | null, currentPath: string): string { return currentPath; } +// --------------------------------------------------------------------------- +// Not-found diagnostics — bounded, sanitized PATH/login-shell/search-dir info appended to +// a "CLI not found" message, ported from upstream's `formatCliNotFoundMessage`. +// --------------------------------------------------------------------------- + +/** Maximum rendered length of each bounded diagnostic field, excluding its label. A + * not-found message must never become a vector for dumping arbitrary env data. */ +const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024; + +function sanitizeDiagnosticField(value: string, emptyMarker: string): string { + const flattened = Array.from(value, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); + return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character; + }) + .join('') + .replace(/ +/g, ' ') + .trim(); + if (!flattened) return emptyMarker; + if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened; + return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`; +} + +/** + * Append bounded PATH/login-shell/search-dir diagnostics to a base "CLI not found" message, + * so the error names exactly where resolution looked instead of just what it was looking + * for. Called from `missingCliMessage()` (registry.ts), so every caller (tmux-manager's + * spawn throw, session-routes' availability gate) gets it for free. + */ +export function formatCliNotFoundMessage(base: string, id: string): string { + const entry = getCli(id); + const searchDirs = (entry?.discovery.searchDirs ?? []).map(expandHome); + const shellPath = resolveLocalShell(); + const shellArgs = loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean); + const processPath = sanitizeDiagnosticField(process.env.PATH ?? '', '(empty)'); + const shell = sanitizeDiagnosticField([shellPath, ...shellArgs].filter(Boolean).join(' '), '(none)'); + const dirs = sanitizeDiagnosticField(searchDirs.join(', '), '(none)'); + return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`; +} + /** * Generic, memoized-by-id directory resolution for ANY registered CLI. Chooses * `createVersionGatedResolver` when the entry's discovery declares - * `requireVersionMatch` (pi's shape) and `createDirResolver` otherwise (every other + * `requireVersionMatch` (pi's/grok's shape) and `createDirResolver` otherwise (every other * entry today) — so callers that need only a binary DIRECTORY (not a live version, * which the six per-CLI resolver modules still own) can look one up for ANY id - * without a per-mode branch, including a custom CLI that isn't one of the six - * hand-named modules at all. + * without a per-mode branch, including a custom CLI that isn't one of the named + * modules at all. * * Each id gets its own resolver instance the first time it is requested, cached for - * the process lifetime exactly like the six per-CLI modules already cache themselves + * the process lifetime exactly like the per-CLI modules already cache themselves * — this does not create a second competing cache for claude/opencode/codex/gemini - * /antigravity/pi, since callers that already import those modules' own functions + * /antigravity/pi/grok, since callers that already import those modules' own functions * keep using them; this is for generic code that only has a `CliId` string in hand. */ const _dirResolvers = new Map(); /** * Drop the memoized resolver for `id`, so the next `resolveCliBinDir`/`resolveCliVersion` - * call re-probes PATH and the search dirs from scratch instead of replaying a cached `null`. - * Each resolver caches its OWN result forever once resolved once (`createDirResolver`'s - * closured `cached` var) — deliberately, since a CLI's install location does not normally - * change mid-process. The one case that DOES change it: `cli-installer.ts` just installed - * the binary, so a `false` cached at server boot would otherwise never self-correct without - * a restart. + * call re-probes PATH/searchDirs/login-shell from scratch instead of replaying a cached + * negative result. `createDirResolver`/`createVersionGatedResolver` already retry a miss on + * their own doubling backoff (see this file's header), but `cli-installer.ts` calls this + * right after a successful install so the FIRST post-install check is not stuck waiting out + * whatever backoff window was already in progress. */ export function invalidateCliBinDirCache(id: string): void { _dirResolvers.delete(id); @@ -332,10 +471,10 @@ export function resolveCliBinDir(id: string): string | null { /** * Generic version accessor for the SAME memoized resolver `resolveCliBinDir` builds. Only * returns a value for an entry whose resolver is version-aware (today: `requireVersionMatch` - * entries like pi) — claude's separate retry/backoff version getter stays on its own module - * (`getClaudeCliVersion`), since that behaviour is declared via `retryOnTransientFailure`, - * not `requireVersionMatch`, and is not (yet) built generically here. Returns null rather - * than probing blind for an entry with no version-aware resolver. + * entries like pi/grok) — claude's separate retry/backoff version getter stays on its own + * module (`getClaudeCliVersion`), since that behaviour is declared via + * `retryOnTransientFailure`, not `requireVersionMatch`, and is not (yet) built generically + * here. Returns null rather than probing blind for an entry with no version-aware resolver. */ function isVersionGated(resolver: DirResolver): resolver is VersionGatedResolver { return 'getVersion' in resolver; diff --git a/test/agent-skill-mode-lists.test.ts b/test/agent-skill-mode-lists.test.ts index 821f5f0b5..d199c41af 100644 --- a/test/agent-skill-mode-lists.test.ts +++ b/test/agent-skill-mode-lists.test.ts @@ -91,11 +91,18 @@ describe('agent skill run-mode lists', () => { it('documents the CLI availability probe for every agent mode', () => { // The gap this closes: /api/pi/status shipped undocumented and only a human reading // the doc noticed, because the sibling scanner (agent-skill-endpoints-doc.test.ts) - // only checks documented -> registered. Derived from the schema, so a seventh + // only checks documented -> registered. Derived from the schema, so a new enabled // backend fails here until its probe is documented; the sibling test still proves // the reverse, that nothing documented here is a 404. + // + // Matches BOTH shapes: the six legacy per-mode aliases (`/api//status`) and the + // generic route every mode added since (`/api/cli//status`, e.g. grok has no + // legacy alias and is documented only via the generic form) — a CLI's own doc line + // gets to pick whichever it actually points at. const doc = readFileSync(join(SKILL_DIR, 'reference/endpoints.md'), 'utf-8'); - const documented = new Set([...doc.matchAll(/\bGET\s+\/api(?:\/v1)?\/([a-z-]+)\/status\b/g)].map((m) => m[1])); + const documented = new Set( + [...doc.matchAll(/\bGET\s+\/api(?:\/v1)?\/(?:cli\/)?([a-z-]+)\/status\b/g)].map((m) => m[1]) + ); const probeable = MODES.filter((m) => m !== 'shell'); // shell has no CLI to probe expect([...probeable].filter((m) => !documented.has(m))).toEqual([]); }); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 45f16d919..c951d055e 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -903,7 +903,7 @@ describe('system-routes', () => { const body = JSON.parse(res.body); expect(body.success).toBe(true); const ids = body.data.map((c: { id: string }) => c.id).sort(); - expect(ids).toEqual(['antigravity', 'claude', 'codex', 'copilot', 'gemini', 'opencode', 'pi', 'shell']); + expect(ids).toEqual(['antigravity', 'claude', 'codex', 'copilot', 'gemini', 'grok', 'opencode', 'pi', 'shell']); const claude = body.data.find((c: { id: string }) => c.id === 'claude'); expect(claude.available).toBe(true);