Files
Codeman/.changeset/cli-registry-core.md
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

3.4 KiB

aicodeman
aicodeman
minor

CLI backends are now a data-driven registry instead of a hardcoded set of run modes. Every CLI (Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness and OMP) is a CliEntry in src/config/cli-registry/, and the code that used to branch on a CLI's name now reads capability flags off that entry instead.

This is an internal refactor with no behaviour change: no new endpoints, no new settings, no change to any request or response shape, and the spawn command every CLI receives is byte-identical to what the hand-written builders produced. test/cli-registry-spawn-golden.test.ts pins those command lines as literal strings, captured from the previous builders before they were removed, and test/location-overlay-commands.test.ts does the same for every remote and in-container pane command.

What the registry owns: binary discovery (search paths, version and identity probes), the launch argv template, environment handling (exports, tmux setenv keys, the env-override allowlist), the multi-user privileged-parameter and privileged-env-key clamps, the remote/docker location overlays, and the behavioural capabilities the rest of the app reads (isExternalCliMode, isAltScreenStripMode, hooksAvailableForMode, alt-screen strip class, echo policy, transcript format, and friends). codeman doctor's per-CLI rows are generated from the same entries, so its version rules and the run modes' resolvers can no longer disagree about whether a given binary counts as installed.

Registry data is resolved at call time, never frozen at module import: session-mode and env-prefix validation, the doctor's tool list, and each resolver's search directories all re-read the catalog, so a CLI enabled while the server is running moves every surface at once rather than only the run menu.

Three user-visible changes, all small and all deliberate:

  • probeDockerCliVersion() derives the in-container binary name from the registry rather than assuming it equals the mode name. Only Claude reaches that path today, so nothing was broken in practice, but antigravity runs agy and the assumption would not have survived the next CLI that needs a version.
  • The remote CLI version probe now covers Grok and DeepSeek, which the hardcoded map it replaces simply omitted — its own comment said the rule was "every mode except shell", so the two were an oversight from when those CLIs were added, and a remote session in either mode reported no version at all.
  • codeman doctor's CLI rows come from the registry, so Claude's install hint is now the documented install command rather than a docs URL, five CLIs gain install hints they never had, and the row order follows the catalog (Claude now sorts below tmux).

A user-editable ~/.codeman/clis.json can override any stock entry or add a custom CLI. It is READ-ONLY in this release — nothing writes it, so importing the registry has no filesystem side effects. Config never contains shell text: an entry declares typed argv tokens, every literal is validated against a safe-word pattern at load, and values resolve through named patterns that live in code, so a clis.json cannot widen its own validation. test/cli-registry-no-id-branching.test.ts fails the build if per-CLI-id branching reappears outside the stock catalog, in any of its four shapes (===, !==, switch/case, and includes).