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
This commit is contained in:
Devvyn
2026-09-02 08:26:45 +08:00
co-authored by Claude Opus 5
parent 71ffbf18e4
commit 4830e662f9
45 changed files with 5772 additions and 1478 deletions
+190
View File
@@ -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<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);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* @fileoverview Barrel for the CLI registry module.
* @module config/cli-registry
*/
export type {
ArgSpec,
CliCapabilities,
CliCredStore,
CliDiscovery,
CliEntry,
CliEnv,
CliId,
CliIdentityProbe,
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,
enabledCliIds,
enabledClis,
getCli,
listClis,
loadCliRegistry,
reloadCliRegistry,
resolveInstallCommandForPlatform,
resolveRegistry,
} from './registry.js';
export type { LoadResult } from './registry.js';
export {
COMPOSER_ANCHOR_KINDS,
isKnownLauncherProfile,
isKnownPredictProfile,
isKnownSetenvProfile,
LAUNCHER_PROFILE_NAMES,
PREDICT_PROFILES,
SETENV_PROFILE_NAMES,
TRANSCRIPT_READER_NAMES,
} from './profiles.js';
export type { LauncherProfileName, SetenvProfileName } from './profiles.js';
+121
View File
@@ -0,0 +1,121 @@
/**
* @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'
| 'path-segment'
| '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<TokenPattern, RegExp> = {
// 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-]+$/,
// dsh --profile. Deliberately STRICTER than `id-dotted`: a profile name is both
// interpolated into the shell line AND joined into a filesystem path, so it must be a
// single path segment. Requiring a leading alphanumeric is what rules out `.`, `..` and
// dotfile names, which `id-dotted` would happily accept.
'path-segment': /^[a-zA-Z0-9][a-zA-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;
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @fileoverview The NAMES of code profiles a `CliEntry` field may select, and the helpers
* that validate them.
*
* A profile is the escape hatch for behaviour that is genuinely code-shaped and cannot be
* expressed as data — codex's predictive write-through echo, deepseek's profile-launcher
* runnability check, deepseek's status bridge — without letting any of that code branch on
* a CLI's id. A registry field names a profile; the implementation lives beside whatever it
* needs, and looks its name up here.
*
* ⚠️ This module is PURE and must stay that way: names, types and predicates only, no
* imports outside this directory. The implementations pull in resolvers and the status
* shim, which in turn reach back into the registry, so holding them here would close an
* import cycle (profiles → deepseek-cli-resolver → cli-resolver → registry → schema →
* profiles). Keeping the names here and the implementations at their call sites is what
* lets `schema.ts` validate a profile name at LOAD time — a custom entry naming a profile
* this build does not implement fails loudly instead of silently failing closed later.
*
* The rule all of this enforces: `test/cli-registry-no-id-branching.test.ts` fails on any
* `mode === '<stock id>'` comparison outside `stock.ts`, so a NEW behavioural special case
* must be added here, named, and referenced from a registry field — never inlined as an id
* check at the call site.
*
* ⚠️ A profile is a LAST resort, not a convenience. Reach for one only when the behaviour
* needs to run code (a side effect, a computed value, a probe); anything that is a list, a
* flag, or a string belongs in the entry as data, where a custom CLI can also use it.
*
* @module config/cli-registry/profiles
*/
/**
* Predictive local-echo profiles, selected via `capabilities.echo.predictProfile`.
*
* Implementation: packages/xterm-zerolag-input/src/predictive-echo-addon.ts.
*
* ⚠️ Unlike the other two registries, an unknown name here degrades to the 'buffer' policy
* rather than failing. Echo is a comfort feature — a worse-but-working overlay beats a
* refused session — which is why `predictProfile` alone is not schema-validated below.
*/
export const PREDICT_PROFILES: Record<string, true> = {
codex: true,
};
/**
* Launcher profiles, selected via `discovery.launcherProfile`.
*
* For a CLI whose binary launches some further target, and so cannot answer two questions
* from the binary alone: is it RUNNABLE (stricter than "is the binary on disk?"), and what
* is the DEFAULT target when the caller names none? A CLI naming no profile is runnable
* exactly when its binary resolves, and has no default target.
*
* Implementation: `src/utils/cli-launcher.ts`.
*/
export const LAUNCHER_PROFILE_NAMES = [
// `dsh` is a launcher over $DSH_HOME/profiles/<name>, and the profiles DeepSeek itself
// ships (web, headless) cannot drive a terminal pane. Binary AND a pane-capable profile.
'deepseek-profile',
] as const;
/**
* Extra `tmux setenv` work, selected via `env.setenvProfile`.
*
* Implementation: `src/tmux-manager.ts`, which already owns every setenv call.
*
* ⚠️ Anything that is merely "forward this name from the server's own env" belongs in
* `env.tmuxSetenvKeys` as data and must NOT be given a profile.
*/
export const SETENV_PROFILE_NAMES = [
// DeepSeek's terminal front door reports idle/working/blocked to a supervisor over the
// generic env-gated Herdr contract; this makes Codeman that supervisor. It needs a
// profile rather than key names because it writes an executable shim to disk and then
// exports that shim's path along with the session's own pane id.
'deepseek-status-bridge',
] as const;
export type LauncherProfileName = (typeof LAUNCHER_PROFILE_NAMES)[number];
export type SetenvProfileName = (typeof SETENV_PROFILE_NAMES)[number];
/**
* Transcript readers, selected via `capabilities.transcript`. Unlike the profile registries
* above this one is closed over the schema enum itself rather than an open string, since
* transcript format is a small, genuinely fixed set — see CliCapabilities['transcript'].
*/
export const TRANSCRIPT_READER_NAMES = ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', '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 predictive-echo profile this build actually implements. */
export function isKnownPredictProfile(name: string | undefined): boolean {
return name !== undefined && Object.prototype.hasOwnProperty.call(PREDICT_PROFILES, name);
}
export function isKnownLauncherProfile(name: string): name is LauncherProfileName {
return (LAUNCHER_PROFILE_NAMES as readonly string[]).includes(name);
}
export function isKnownSetenvProfile(name: string): name is SetenvProfileName {
return (SETENV_PROFILE_NAMES as readonly string[]).includes(name);
}
+222
View File
@@ -0,0 +1,222 @@
/**
* @fileoverview Loads, merges 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 with a warning
* rather than failing the whole load. Stock entries are always emitted, so `shell` and
* `claude` can be disabled but can never go missing — large parts of the app assume at
* minimum that a shell fallback exists.
*
* ⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file. That is a
* deliberate property, not a missing feature: there is no settings UI and no write API yet,
* so there is nothing to persist, and it means importing the registry — which
* `src/web/schemas.ts` does, transitively, just to validate a request — performs no
* filesystem writes. A `seededStockIds` ratchet belongs with the write API that needs it.
*
* @module config/cli-registry/registry
*/
import { existsSync, readFileSync, renameSync, statSync } from 'node:fs';
import { dataPath } from '../instance.js';
import type { CliEntry, CliId, CliRegistryFile } from './types.js';
import { CliEntrySchema } from './schema.js';
import { STOCK_CLIS } from './stock.js';
/** 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');
}
/**
* Keys that must never be merged out of a hand-editable JSON file.
*
* `JSON.parse` produces `__proto__` as an ORDINARY own property, but `result[key] = …` on a
* plain object walks the setter chain and would set the merged object's PROTOTYPE instead.
* Not exploitable today — every merged entry is spread into `{ ...merged, id, stock }` and
* then Zod-parsed before anything reads it, which drops the effect — but "not exploitable
* because of what a caller happens to do afterwards" is a property that quietly stops
* holding. A `continue` in the loop that reads the file is the cheap end of that trade.
*/
const UNMERGEABLE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/** Plain-object deep merge: nested objects merge key-wise, arrays and primitives replace. */
function deepMerge<T>(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<string, unknown> = { ...(base as Record<string, unknown>) };
for (const [key, value] of Object.entries(override as Record<string, unknown>)) {
if (UNMERGEABLE_KEYS.has(key)) continue;
result[key] = deepMerge((base as Record<string, unknown>)[key], value);
}
return result as T;
}
export interface LoadResult {
entries: CliEntry[];
warnings: string[];
}
/**
* Refuse a group/world-writable registry file — same posture as the ssh-key discipline.
* This file selects the binaries Codeman spawns, so a writable one is a way to redirect
* every session.
*
* 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) {
// QUARANTINE, never overwrite: the file is hand-editable, so a syntax error is far more
// likely to be a half-finished edit than junk. Renaming keeps the user's work.
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, which is
* what lets the load tests drive every merge case directly.
*/
export function resolveRegistry(stock: CliEntry[], file: CliRegistryFile | null, warnings: string[]): LoadResult {
const stockById = new Map(stock.map((e) => [e.id as string, e]));
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;
// `stock: true` is forced here rather than read from the merged object, so an override
// can never flip a custom entry's provenance or vice versa.
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);
}
}
for (const [id, raw] of Object.entries(overrides)) {
if (stockById.has(id)) continue; // already merged above
// Same forcing in the other direction: a custom entry claiming `stock: true` cannot
// shadow or impersonate a shipped one.
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 };
}
let cache: LoadResult | null = null;
/**
* Load the effective registry (stock + user overrides). Memoized for the process lifetime;
* `reloadCliRegistry()` invalidates.
*/
export function loadCliRegistry(): LoadResult {
if (cache) return cache;
const warnings: string[] = [];
const existing = readRegistryFile(filePath(), warnings);
cache = resolveRegistry(STOCK_CLIS, existing, 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);
}
/** Every enabled entry's id, in registry order. */
export function enabledCliIds(): string[] {
return enabledClis().map((e) => e.id as string);
}
/**
* Resolve the install command for the current platform, falling back to the linux one (the
* common case for a `curl | bash` or `npm install -g` line) and then to whatever is
* declared. Display text only — never executed. See CliDiscovery.install.command.
*/
export function resolveInstallCommandForPlatform(entry: CliEntry): string | undefined {
const { command } = entry.discovery.install;
const platform = process.platform as 'linux' | 'darwin' | 'win32';
return command[platform] ?? command.linux ?? Object.values(command)[0];
}
+428
View File
@@ -0,0 +1,428 @@
/**
* @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';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.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<import('./types.js').Cond> = 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',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
})
.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),
legacyConfigAliases: z.record(z.string(), z.string()).optional(),
legacyConfigField: z.string().min(1).max(40).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) => {
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'],
});
}
}
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
.object({
arg: shellToken,
regex: z.string().max(200).optional(),
requireVersionMatch: z.boolean().optional(),
retryOnTransientFailure: z.boolean().optional(),
})
.strict();
const identityProbeSchema = z
.object({
arg: shellToken,
// Same 200-char cap as version.regex, and compiled through the same compileVersionRegex()
// guard at use time. This is the second and last config-supplied regex in the registry.
regex: z.string().min(1).max(200),
})
.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(),
identity: identityProbeSchema.optional(),
launcherProfile: z.string().max(40).optional(),
launcherTargetParam: z.string().max(40).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',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
})
.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),
configSetenv: z
.array(z.object({ name: envName, fromParam: z.string().min(1).max(40) }).strict())
.max(8)
.optional(),
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(),
setenvProfile: z.string().max(40).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({
external: z.boolean(),
requiresMux: z.boolean(),
hooks: z.enum(['none', 'always', 'supervised']),
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', '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()]),
materializeWhenAbsent: z.boolean().optional(),
})
.strict()
)
.max(8),
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
// would let one entry silently strip a whole namespace off every owner's overrides.
privilegedEnvKeys: z.array(envName).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();
/**
* 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({ command: commandLine.optional() }).strict(),
z.object({ disabled: z.literal(true) }).strict(),
]);
const overlaysSchema = z
.object({
remote: overlayTargetSchema.optional(),
docker: overlayTargetSchema.optional(),
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 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);
}
// Reject a profile name this build does not implement, rather than letting it fail
// closed at use time. An unimplemented `launcherProfile` would make the CLI look
// permanently uninstalled, and an unimplemented `setenvProfile` would silently skip
// setup the CLI needs; both are far easier to diagnose as a load-time error naming the
// field. (`echo.predictProfile` is deliberately NOT checked here — see profiles.ts.)
const { launcherProfile } = entry.discovery;
if (launcherProfile !== undefined && !isKnownLauncherProfile(launcherProfile)) {
ctx.addIssue({
code: 'custom',
message: `discovery.launcherProfile "${launcherProfile}" is not a profile this build implements`,
path: ['discovery', 'launcherProfile'],
});
}
// An env var exported from a param that does not exist would silently export nothing,
// and for DSH_PERMISSION_MODE that means silently losing a permission clamp.
const declaredParams = new Set(Object.keys(entry.launch.params));
entry.env.configSetenv?.forEach((mapping, i) => {
if (!declaredParams.has(mapping.fromParam)) {
ctx.addIssue({
code: 'custom',
message: `configSetenv fromParam "${mapping.fromParam}" is not a declared launch param`,
path: ['env', 'configSetenv', i, 'fromParam'],
});
}
});
// Same class of silent failure on the OTHER privileged surface, and this one is a
// security control: `privilegedParams[].param` is the multi-user bypass clamp's only
// handle on a CLI's privilege switch, and a name that is not a declared param clamps
// NOTHING — no load error, no failing test, the clamp simply stops running. The clamp
// resolves the name through `legacyConfigAliases`, so this check is what keeps the two
// in ONE namespace rather than two that merely coincide today: they do not for codex
// (`bypassApprovals` vs `dangerouslyBypassApprovals`), and giving deepseek's
// `permissionMode` an alias later would otherwise have removed its clamp with nothing
// saying so.
entry.capabilities.privilegedParams.forEach((clamp, i) => {
if (!declaredParams.has(clamp.param)) {
ctx.addIssue({
code: 'custom',
message: `privilegedParams param "${clamp.param}" is not a declared launch param`,
path: ['capabilities', 'privilegedParams', i, 'param'],
});
}
});
const { setenvProfile } = entry.env;
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
ctx.addIssue({
code: 'custom',
message: `env.setenvProfile "${setenvProfile}" is not a profile this build implements`,
path: ['env', 'setenvProfile'],
});
}
});
export type ValidatedCliEntry = z.infer<typeof CliEntrySchema>;
File diff suppressed because it is too large Load Diff
+515
View File
@@ -0,0 +1,515 @@
/**
* @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'
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
| 'codemanPrefixedSessionId'
/**
* For a launcher CLI (`discovery.launcherProfile`), the target to launch when the caller
* named none — deepseek's default `dsh` profile. Resolved at spawn time, never frozen
* into config, because it depends on what is installed on this machine right now.
*/
| 'launcherDefaultTarget';
/**
* 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 <id>`. */
| { 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<string, ParamSpec>;
/**
* '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[];
/**
* 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<string, string>;
/**
* The field on the legacy spawn option bag holding this CLI's `<Mode>Config` object
* (`openCodeConfig`, `codexConfig`, …). Those per-mode objects predate this registry and
* stay on the wire for API compatibility, so SOMETHING has to know which one to read —
* declaring it here as data is what keeps the bridge a generic reader instead of a
* `switch (mode)`.
*
* ABSENT means this CLI's launch fields live at the TOP LEVEL of the option bag rather
* than nested in a config object. That is claude, whose discrete `claudeMode` /
* `allowedTools` / `model` / `resumeSessionId` fields predate the `<Mode>Config` pattern
* entirely — so "read the option bag itself" is not a special case for it, it is just
* the other shape.
*/
legacyConfigField?: string;
/**
* 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 };
}
// ---------------------------------------------------------------------------
// 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;
}
/**
* An identity probe: proof that the binary we found is the program we meant, not an
* unrelated one that happens to share the name.
*
* A version probe is not enough on its own. Debian ships a `dsh` (dancer's shell) that
* answers `--version` perfectly happily, and npm carries squatters for `pi` and `grok`.
* `requireVersionMatch` catches a binary whose version output has the WRONG SHAPE; this
* catches one whose output has the right shape but names the wrong program.
*
* Ordering matters and belongs to the resolver, not to config: identity is checked FIRST,
* so an impostor is rejected before its version string is ever parsed.
*/
export interface CliIdentityProbe {
/** Argument that makes the binary describe itself, e.g. `--help`. */
arg: string;
/**
* Serialized regex the output must match. Compiled through `compileVersionRegex()`, so
* it inherits the same length cap and nested-quantifier rejection — this is the second
* (and last) config-supplied regex in the registry, and it runs against truncated
* command output exactly like the first.
*/
regex: string;
}
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;
/** Proof the binary is the right program, checked BEFORE the version probe. */
identity?: CliIdentityProbe;
/**
* Names a LAUNCHER profile (profiles.ts): this CLI's binary is a launcher over some
* further target, so two questions the registry normally answers from the binary alone
* have to be asked of that target instead.
*
* - Is it RUNNABLE? Stricter than "is the binary on disk?".
* - What is the DEFAULT target, when the caller names none?
*
* DeepSeek is why this exists and is its only user. `dsh` launches a profile from
* `$DSH_HOME/profiles/<name>`, and the profiles DeepSeek itself ships (`web`,
* `headless`) cannot drive a terminal pane — so a perfectly-installed `dsh` with no
* third-party TUI profile is installed-but-NOT-runnable. The Run button gates on
* runnability while the "add a profile" affordance gates on mere availability;
* collapsing the two would either hide the affordance that fixes the problem or offer a
* run that always fails.
*
* The default target reaches the launch spec as the `launcherDefaultTarget` engine
* value, so it stays a runtime lookup rather than a value frozen into config.
*
* Absent (the normal case) means the binary IS the program, and its presence IS
* runnability.
*/
launcherProfile?: string;
/**
* The launch param naming the target a caller asked for, so the launcher profile can say
* why THAT specific target will not start rather than only whether any will. Meaningless
* without `launcherProfile`.
*/
launcherTargetParam?: string;
install: {
/**
* DISPLAY TEXT ONLY. Shown verbatim in "CLI not found. Install with: ...".
*
* ⚠️ NEVER executed by the server. That is a documented invariant, not an oversight:
* running it would turn a config file into a code-execution surface. A proposal to
* execute this on enable is deliberately deferred to its own change so the trust
* model can be decided on its own merits rather than inside a refactor.
*/
command: Partial<Record<'linux' | 'darwin' | 'wsl' | 'win32', string>>;
/** Package name for an npm-installable CLI. Display/tooling metadata only. */
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[];
/**
* Env vars set via `tmux setenv` from a LAUNCH PARAM rather than from the server's own
* environment — for a CLI whose switch is an env var instead of a flag.
*
* DeepSeek's `DSH_PERMISSION_MODE` is the case this exists for. Routing it through a
* declared param (rather than a bespoke configure step) is what lets the ordinary
* `privilegedParams` clamp apply to it: the clamp rewrites the param, and whatever the
* param ends up as is what gets exported.
*
* ⚠️ Values are read from a declared, schema-validated param, never from free text, and
* they reach the pane through `tmux setenv` rather than the command line.
*/
configSetenv?: Array<{ name: string; fromParam: 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;
/**
* Names an entry in `SETENV_PROFILES` (profiles.ts): extra `tmux setenv` work that is
* genuinely code-shaped rather than a list of key names.
*
* DeepSeek's status bridge is the only current user. It has to write an executable shim
* to disk (`ensureDeepSeekStatusShim()`), then export the shim's path and this session's
* pane id — a side effect and two computed values, none of which `tmuxSetenvKeys` (a
* list of names forwarded from the server's own env) can express.
*
* Plain secret forwarding stays in `tmuxSetenvKeys` and must NOT move here.
*/
setenvProfile?: 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 {
/**
* 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;
/**
* Whether `stop`/`blocked` wait signals can ever fire for this CLI.
*
* ⚠️ A TRI-STATE, not a boolean, because for one CLI this is a per-SESSION question:
* 'none' — no hook signals, ever (every external CLI, and `shell`).
* 'always' — the CLI installs Codeman's hooks (claude).
* 'supervised' — the CLI REPORTS its own idle/working/blocked state to a supervisor
* over a generic env-gated contract, and Codeman is that supervisor
* (deepseek, via deepseek-status-shim.ts). Definitive rather than
* inferred, so it earns real signals — but the session can disarm the
* bridge (`deepSeekConfig.statusReporting: false`), and a docker or
* remote session cannot reach it at all.
*
* That last case is why `hooksAvailableForMode()` takes per-session options and why
* every call site must pass `sessionHookOptions(session)`. Answering from the mode alone
* would promise a `stop` that never arrives, which is the infinite-wait-dressed-as-a-
* timeout the predicate exists to prevent.
*/
hooks: 'none' | 'always' | 'supervised';
/**
* Which transcript reader, if any, understands this CLI's on-disk history.
*
* `deepseek-zstd` is the odd one out: dsh writes zstd-compressed session files and
* appends ONE FRAME PER WRITE, so it needs a reader that walks frame headers itself
* rather than the stock decoder. It exists because the pane segmenter served dsh's
* ASCII-art splash as the worker's first answer.
*/
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | '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.
*
* `materializeWhenAbsent` distinguishes two real shapes, not one:
* - only-if-sent (false/omitted; codex, antigravity, grok): the CLI's own
* absent-config default already spawns safe, so the clamp should only touch
* a config the caller actually sent.
* - materialize (true; gemini, pi): the absent-config default is ITSELF unsafe
* for a non-granted owner (gemini defaults to `yolo`; pi's absent default is
* an interactive trust prompt the session user could just answer "yes" to),
* so the clamp must CREATE a config object even when none was sent.
*
* ⚠️ `param` names the LAUNCH PARAM, like every other `param` in this file — never the
* legacy wire field. The clamp translates it through `legacyConfigAliases` on the way out,
* the same hop `env.configSetenv` makes. The two names coincide for most entries and
* DELIBERATELY do not for codex (`bypassApprovals` here, `dangerouslyBypassApprovals` on
* the wire), which is what keeps the distinction visible. `schema.ts` rejects an entry
* naming a param it never declared, because getting this wrong is a SILENT no-op: no load
* error, no failing test, the clamp just stops clamping.
*/
privilegedParams: Array<{ param: string; clampTo: boolean | string; materializeWhenAbsent?: boolean }>;
/**
* Env var names a non-granted multi-user owner may not set at all, DROPPED from
* `envOverrides` before spawn.
*
* ⚠️ This is a second, structurally different privileged surface from `privilegedParams`
* above, and one cannot substitute for the other. `privilegedParams` clamps a field on a
* per-CLI config object, which reaches the CLI as an argv flag. These clamp env vars,
* which reach it through `tmux setenv` — a path no argv clamp can see.
*
* DeepSeek is why this exists. Its permission switch IS an env var
* (`DSH_PERMISSION_MODE`), not a flag, so a config-level clamp alone leaves a real
* multi-user control with nothing enforcing it. Worse, `DSH_*` is an allowlisted
* `envOverrides` prefix and `applyEnvOverrides()` runs AFTER the per-CLI env configure
* step, so a non-granted owner sending that key on the SAME request would land last and
* hand back exactly the privilege the config clamp just removed.
*
* Dropping (rather than rewriting) is deliberate: the value then falls through to what
* the CLI's own env configuration exports, which is already the clamped one.
*
* The other two DeepSeek keys are here for reasons worth keeping written down:
* - `DSH_HOME` points the launcher at a profile tree whose plugin code runs at BOOT,
* before any approval row could apply.
* - `DEEPSEEK_BASE_URL` would redirect the server's OWN forwarded `DEEPSEEK_API_KEY`
* to a host of the caller's choosing.
*
* Every other CLI's bypass is a command-line flag reachable only through its config
* object, which is why `privilegedParams` alone is the whole gate for them.
*/
privilegedEnvKeys: string[];
/** Version gates referenced by `capabilityGate` conditions. */
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** 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 {
/**
* 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;
}
// ---------------------------------------------------------------------------
// The entry
// ---------------------------------------------------------------------------
/**
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
*
* `shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`,
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
* behaviour, and the frontend is deliberately untouched by the change that introduced this
* registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
* being verified (a mobile/browser suite the CI gate cannot see).
*
* They are declared now because each entry should describe its CLI completely, and because
* transcribing them while the hand-written source is still on screen is when the values are
* actually known. But an unread field is a promise, not a fact: nothing enforces that
* `echo.policy` here matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches
* the gradient CSS paints. Treat every value in this group as TRANSCRIBED, not authoritative,
* and re-measure against the frontend before wiring one up.
*
* The rest of the interface is live: something reads it, and `test/cli-registry-*.test.ts`
* pins what it does with it.
*/
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, never the
* full catalog. Small and hand-readable by design.
*
* ⚠️ READ-ONLY in this build. Nothing here writes this file: there is no settings UI and no
* write API yet, so there is nothing to persist. That also means importing the registry
* (and therefore `schemas.ts`, which validates against it) performs no filesystem writes —
* an import side effect worth not having.
*/
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.
*
* Read and IGNORED here, and never written: the ratchet only earns its keep once a CLI
* can be disabled, which needs the write API. Declared now purely so a file written by a
* later version still loads cleanly under this one instead of failing `.strict()`.
*/
seededStockIds?: string[];
/** Keyed by id: a partial override of a stock entry, or a complete custom entry. */
clis: Record<string, unknown>;
}