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
+1 -1
View File
@@ -26,7 +26,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
⚠️ **The agent is a PROFILE, not the binary.** `dsh` is a launcher over `$DSH_HOME/profiles/<name>` (an ordered stack of plugin-bundle patch layers), and DeepSeek ships only `web` (browser UI), `headless` (one-shot) and `base` (no app). The interactive terminal front door is ALWAYS third-party. So availability is TWO questions, not one, and `isDeepSeekRunnable()` (binary AND a pane-capable profile) is what the Run button gates on while `isDeepSeekAvailable()` (binary only) gates the "add a profile" affordance and the web-UI shortcut. Reporting only the binary would let Run spawn a pane that dies on arrival, which is this mode's single most confusing failure. `buildDeepSeekCommand()` emits `dsh --profile <name> [--resume [id]]`; an absent profile resolves through `resolveDefaultDeepSeekProfile()`, which prefers a recognized TUI, then an UNRECOGNIZED profile (third-party by construction — a classifier that has not heard of a bundle must not hide it), and refuses `web`/`headless`, which cannot drive a pane.
⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureDeepSeek()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace. ⚠️ **Clamping the config is only HALF the gate here, and this is the only CLI where that is true.** Every sibling's bypass is a command-line flag, reachable only through the per-CLI config `clampExternalCliBypassForOwner()` already owns. DeepSeek's is an env var, `DSH_*` is an allowlisted `envOverrides` prefix (it must be — that is also how the harness's ordinary knobs are set), and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()` in tmux-manager, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` sent on the SAME request as a clamped config lands last and wins. `clampEnvOverridesForOwner()` (session-routes.ts, exported as `_clampEnvOverridesForOwner` for tests) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureDeepSeek()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) rather than rewriting them, since dropping falls through to what `_configureDeepSeek()` exports, which is already the clamped value. `DSH_HOME` is on that list because it aims the launcher at a profile tree and a profile's plugin code executes at BOOT, before any approval row can apply — the wider of the two holes. No-op in single-user mode and for a granted owner, like every other clamp.
⚠️ **The permission switch is an ENV VAR, not a flag.** The harness has no `--dangerously-skip-permissions` equivalent; its sandbox/approval rows read `DSH_PERMISSION_MODE` with three presets (`read-only` / `workspace-write` / `danger-full-access`; measured from `dsh --dump-default-config`). It is exported via `tmux setenv` in `_configureCliEnv()`, never on the command line, and `test/deepseek-mode.test.ts` pins that nothing permission-shaped ever reaches the spawn line. This is the ONE place a Codeman env export is the right mechanism rather than the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks in-session `/effort`), the harness reads it with `??` as a boot-time DEFAULT, so it stays soft. Absent = `workspace-write`, which still asks, so the multi-user clamp is the only-if-sent branch (codex/antigravity/grok shape, not pi's materialize) — and it clamps down to `workspace-write`, NOT `read-only`, because the clamp removes privilege without breaking a session's ability to edit its own workspace. ⚠️ **Clamping the config is only HALF the gate here, and this is the only CLI where that is true.** Every sibling's bypass is a command-line flag, reachable only through the per-CLI config `clampExternalCliBypassForOwner()` already owns. DeepSeek's is an env var, `DSH_*` is an allowlisted `envOverrides` prefix (it must be — that is also how the harness's ordinary knobs are set), and `applyEnvOverrides()` runs AFTER `_configureCliEnv()` in tmux-manager, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` sent on the SAME request as a clamped config lands last and wins. `clampEnvOverridesForOwner()` (session-routes.ts, exported as `_clampEnvOverridesForOwner` for tests) DROPS `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for a non-granted owner (the last because `_configureCliEnv()` forwards the SERVER's own `DEEPSEEK_API_KEY` into the pane, so a redirected base URL would send it to a foreign host) rather than rewriting them, since dropping falls through to what `_configureCliEnv()` exports, which is already the clamped value. `DSH_HOME` is on that list because it aims the launcher at a profile tree and a profile's plugin code executes at BOOT, before any approval row can apply — the wider of the two holes. No-op in single-user mode and for a granted owner, like every other clamp.
⚠️ **It is the only non-claude mode that passes `hooksAvailableForMode()`, and it earned that.** The community terminal front door reports its own lifecycle to a supervising process through a generic env-var-gated contract inherited from Herdr: with `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` set it shells out `<bin> pane report-agent <paneId> --state idle|working|blocked …` on every state change and treats exit 0 as delivered. `deepseek-status-shim.ts` GENERATES a small script into the data dir (like `self-update-runner.sh`, so npm installs and git clones behave alike) and points `HERDR_BIN_PATH` at it; it forwards to `POST /api/hook-event` as `idle→stop`, `blocked→permission_prompt`, `working→agent_working`. So a dsh session gets real respawn triggers, real `wait` stop/blocked signals and real Approvals Inbox items instead of output-stabilization guesswork. This is an interface implementation, not an impersonation — no real `herdr` binary is ever executed. A TUI that does not implement the contract simply never calls the shim and falls back to stabilization, so the feature is inert rather than harmful there. ⚠️ **For deepseek alone, `hooksAvailableForMode()` is a per-SESSION question**, which is why it takes a `HookCapabilityOptions` second argument and every call site passes `sessionHookOptions(session)`: `deepSeekConfig.statusReporting: false` skips the `HERDR_*` export, and that triple is the only reason a dsh session posts anything, so answering from the mode alone would accept `until=stop` on a session where nothing can ever send one — the infinite-wait-dressed-as-a-timeout the predicate exists to prevent. The option defaults permissive (`!== false`), so a call site that forgets it degrades to the old behaviour instead of 400ing a working session. ⚠️ Profile conformance is the LIMIT of what is knowable at request time: `resolveDefaultDeepSeekProfile()` deliberately treats an unrecognized profile as launchable, so a non-conforming TUI still answers true and still times out on an explicit `stop` — which is why the DEFAULT signal set keeps `idle`/`exit`. ⚠️ **The predicate is not a stand-in for "is this a claude session"**, though it read like one while `claude` was the only true answer: Read My Mind (`POST /api/sessions/:id/readmymind`) and intent capture (`captureIntentPrompt`) read Claude's own transcript and were silently widened to deepseek by this change, so both compare `mode === 'claude'` directly and a static check in `test/deepseek-mode.test.ts` keeps them there.