Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/cli-registry-core.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
'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.

Four 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.
- OMP now requires tmux like its seven siblings. `session.ts` carried a hand-written list of
modes with no direct-PTY fallback and omp was missing from it, even though CLAUDE.md's own
text says "all eight require tmux" — so an omp session whose mux creation failed silently
fell back to a direct PTY. `requiresMux` comes off the entry now, so the list cannot drift
from the rule again.
- `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`).
4 changes: 3 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/architecture-invariants.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.

Expand Down
Loading