Files
Codeman/src/config/cli-registry/argv.ts
T
DevvynandClaude Opus 5 4830e662f9 refactor(cli-registry): make CLI backends data instead of per-mode branching
Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery
(search dirs, version + identity probes), the launch argv template, env
handling, the `capabilities` flags that replace per-CLI branching, and the
`overlays` that back the remote/docker pane commands. Code that used to ask
"which CLI is this?" reads the entry instead.

Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every
spawn command as a literal string, captured from the hand-written builders
before they were deleted, and `test/location-overlay-commands.test.ts` does the
same for all 20 remote and in-container pane commands.

Config can never contain shell text: an entry declares typed argv tokens,
literals are validated against a safe-word pattern at LOAD time (a bad literal
rejects the whole entry — a silently dropped `--no-approve` is not cosmetic),
and values resolve through patterns NAMED in code, so a user `clis.json` cannot
widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only
in this release.

OMP is included as a registry entry rather than a tenth hand-written builder,
so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of
`buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen
and doctor ladders all drop out.

Guard rails:

- `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id
  branching reappears outside `stock.ts`, in any of its four shapes (`===`,
  `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the
  negated forms, which is how 36 of them survived an earlier pass. Every
  allowlisted branch carries its reason.
- `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities;
  deriving one from another shipped the `until=stop`-hangs-on-shell bug.
- `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and
  `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config`
  wire field is separate, bridged only by `legacyConfigAliases`. Getting
  `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass
  clamp's only handle on a CLI's privilege switch, and a wrong name clamps
  nothing with no error and no failing test — so `schema.ts` rejects an entry
  naming a param it never declared.
- Registry data resolves AT CALL TIME (`sessionModeSchema()`,
  `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs`
  thunks). A module-level const freezes at first import, so a CLI enabled while
  the server ran moved the run menu but not that surface.
- Six fields are annotated DECLARED-FOR-LATER and read by nothing
  (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/
  `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed
  rather than measured. A test pins the list so it cannot quietly grow.

Three user-visible changes, all deliberate and named:

- `probeDockerCliVersion()` derives the in-container binary from the registry
  rather than assuming it equals the mode name (`antigravity` runs `agy`).
- The remote CLI version probe now covers grok and deepseek, which the
  hardcoded map it replaces omitted while its own comment said the rule was
  "every mode except shell".
- `codeman doctor`'s CLI rows are generated from the entries, so Claude's
  install hint is the install command rather than a docs URL, five CLIs gain
  hints they never had, and the row order follows the catalog.

Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars
(matching the `cliId` pattern) before its failure message quotes the value
back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading
the hand-editable `clis.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
2026-09-02 08:26:45 +08:00

191 lines
7.8 KiB
TypeScript

/**
* @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<string, string | boolean | undefined>;
/** Values the caller supplies for the reserved engine params. */
export type EngineValues = Partial<Record<EngineValue, string>>;
/**
* 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<string>
): 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>
): 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<string> = new Set()
): string {
const cache = new Map<string, string | undefined>();
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>
): string {
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
}