Skip to content

feat: data-driven CLI registry (replaces per-CLI hardcoding; adds GitHub Copilot + Grok Build) - #343

Open
opticon454 wants to merge 16 commits into
Ark0N:masterfrom
opticon454:feature/data-driven-cli-registry
Open

feat: data-driven CLI registry (replaces per-CLI hardcoding; adds GitHub Copilot + Grok Build)#343
opticon454 wants to merge 16 commits into
Ark0N:masterfrom
opticon454:feature/data-driven-cli-registry

Conversation

@opticon454

@opticon454 opticon454 commented Aug 25, 2026

Copy link
Copy Markdown

Summary

Makes Codeman vendor-neutral. Every CLI backend (Claude, OpenCode, Codex, Gemini,
Antigravity, Pi, and now GitHub Copilot and Grok Build) used to be hardcoded:
its own SessionMode, its own resolver file, its own if (mode === 'x') branch
repeated across ~40 files. Adding or removing a CLI meant editing code in a dozen
places by hand, and it was easy to miss a spot.

This PR replaces that with a single data-driven CLI registry
(src/config/cli-registry/, see docs/cli-registry.md). A CLI backend is now
just a record — how to find its binary, how to install it, how to build its
launch command, what it's capable of. Supporting a new CLI, or dropping one, is
a config change, not a code change.

GitHub Copilot and Grok Build are included as proof of that: both are added as
pure data with zero new hardcoded branches anywhere, and the registry also
auto-installs a CLI's binary the moment it's enabled if it isn't present yet.

This branch diverged from master before ~100 commits landed there — the
largest of which added Grok the old, hardcoded way — so this PR also merges that
history in and reconciles the two. Diffed against current master, this PR is
100 files, +8234/-4340.

tsc/eslint/prettier/check-frontend-syntax all clean, no new test
failures, and manually verified end-to-end on a live deployment (enabled
Copilot/Grok from Settings, confirmed they auto-installed and then appeared
correctly in the Run menu and welcome screen).

Happy to answer questions on any part of the diff or expand on specific pieces
in review.

opticon454 and others added 16 commits August 20, 2026 11:32
Additive-only: no existing file is modified or consumed yet. This lays the
foundation for removing the ~123 hard-coded per-CLI branches spread across
the codebase, per the plan to make the set of supported CLIs (claude, shell,
opencode, codex, gemini, antigravity, pi) configurable via a single JSON
registry instead of a compiled-in union type.

Adds src/config/cli-registry/:
- types.ts - CliEntry schema shape: identity, discovery, a structured argv
  DSL, env, capability flags, and remote/docker overlays.
- patterns.ts - named, code-owned value patterns (never a raw user regex
  reaches a shell token); a guarded compiler for the one legitimate
  user-supplied regex (version-string matching).
- argv.ts - the command-rendering engine. Config carries no shell text;
  every literal is validated at load, every resolved value is re-escaped at
  render time independent of validation, so the safety property holds even
  if a pattern check were ever bypassed.
- schema.ts - Zod validation, entirely .strict(), enforcing the argv safety
  rules plus internal consistency (every valueFrom/capabilityGate/overlay
  variant must reference something the entry actually declares).
- stock.ts - the seven current CLIs transcribed as registry entries, kept
  byte-identical to today's hand-written tmux-manager.ts builders.
- registry.ts - load/merge/seed against ~/.codeman/clis.json: the file
  holds overrides and custom entries only, a seededStockIds ratchet lets
  new stock CLIs arrive on update while respecting a user's earlier
  enabled:false, and a malformed or unsafely-permissioned file is
  quarantined/ignored rather than trusted.

Keystone test test/cli-registry-argv-parity.test.ts proves the new argv
engine renders BYTE-IDENTICAL output to buildSpawnCommand for every mode
across ~50 input permutations - the fixed baseline later phases (wiring
tmux-manager.ts, session.ts, routes, and the frontend onto this registry)
get measured against, rather than eyeballing diffs.

test/cli-registry-schema.test.ts and test/cli-registry-load.test.ts cover
schema rejection of injection-shaped literals and the merge/seed/quarantine
semantics respectively.

Fixed along the way: isUnsafePermissions() is a POSIX-only check - Windows
reports a uniform file mode regardless of ACL, so the check now no-ops on
win32 instead of treating every file as unsafe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
… (phase 1)

Replaces the six near-identical hand-rolled resolvers with one generic
walker in src/utils/cli-resolver.ts, parametrized by the CLI registry's
stock catalog instead of duplicated search-dir arrays:

- createDirResolver() covers the plain "which, then search dirs" case
  (opencode, codex, gemini, antigravity).
- createVersionGatedResolver() generalizes pi's per-candidate version-sanity
  probe (a `which pi` hit alone is not evidence of the coding agent, since
  `pi` is a short generic name).
- createRetryingVersionGetter() + resolveRetryingVersion() /
  retryingVersionProbeDelayMs() generalize claude's cached-success,
  backoff-on-failure version probe. These stay directly unit-tested by
  test/claude-cli-version-cache.test.ts via claude-cli-resolver.ts's
  preserved re-exports.

Each of the six per-CLI files (claude/opencode/codex/gemini/antigravity/pi
-cli-resolver.ts) becomes a thin wrapper that keeps its historical exports
byte-for-byte (function names, signatures, the ClaudeVersionProbeState type,
PI_VERSION_REGEX), so no caller changes and every existing
`vi.mock('.../opencode-cli-resolver.js')` in the test suite keeps working —
mocking one CLI's resolver module still only affects that CLI.

Folds dependency-registry.ts's six duplicated CLI entries (`codeman doctor`)
into one generator reading the same registry data, and as a result five
CLIs that previously had NO install hint in the doctor's output (opencode,
codex, gemini, antigravity, pi — only claude had one) now do.

Fixes a real bug found while doing this: `probeDockerCliVersion()` assumed
a session's mode name equals its in-container binary name
(`docker-hosts.ts`), which is wrong for antigravity (mode `antigravity`,
binary `agy`) — it silently probed a binary that doesn't exist in the
container and always got undefined back. Extracted as the pure,
now-unit-tested `binaryForDockerProbe()`.

All existing resolver/mode/dependency tests pass unchanged (one assertion
in dependency-checker.test.ts now compares the shared pi version regex by
`.source` instead of object identity, since both sides now separately
compile the same declared string from the registry rather than importing
one shared RegExp instance — same guarantee, expressed for the new
architecture).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
…try (phase 2)

Points session.ts's isExternalCliMode(), getModeLabel(), isAltScreenStripMode()
and the direct-PTY-fallback tmux refusal, plus session-wait-registry.ts's
hooksAvailableForMode(), at the CLI registry instead of hard-coded id lists.
Signatures are unchanged, so all ~123 call sites elsewhere in the codebase
need no change.

Adds a new `capabilities.external` field to the registry (types.ts, schema.ts,
stock.ts) rather than deriving isExternalCliMode from another capability: the
existing doc comment on CliCapabilities already calls out that `hooks`,
`transcript` and `altScreen` must stay independent, since a shell session has
no hooks but is not an "external CLI" either, and collapsing that distinction
is a real bug that shipped before (`!isExternalCliMode()` wrongly accepting
`until=stop` on a shell session and hanging for the full timeout). `external`
joins that set as its own field for the same reason.

The five hand-written "<CLI> sessions require tmux" throws collapse into one
check against `capabilities.requiresMux`, with the message built from the
registry's own label.

New test/cli-capability-predicates.test.ts pins all three predicates against
the stock catalog and specifically reproduces the shell-vs-external case that
caused the original bug, plus an explicit assertion that no two of the three
predicates are equivalent across the whole catalog — so a future change that
tries to derive one from another fails a test immediately instead of shipping
a silent behavioural change.

All directly-affected and broadly-related tests pass unchanged; full suite
matches the pre-existing baseline with 10 new passing tests and zero
regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
…y (phase 4)

Replaces tmux-manager.ts's hand-written per-mode command construction with
the CLI registry's argv engine, via a new bridge module
(session-cli-registry-bridge.ts) that translates the legacy per-mode
options/config objects into registry params:

- buildSpawnCommand()'s six-way if-chain (claude's inline resume/model/
  effort/name assembly, buildOpenCodeCommand, buildCodexCommand,
  buildGeminiCommand, buildAntigravityCommand, buildPiCommand) collapses to
  one call into buildSpawnCommandFromRegistry(); buildCodexCommand stays
  exported as a thin wrapper since test/tmux-manager.test.ts calls it
  directly.
- buildPathExport() replaces its six-branch if-chain with
  resolveCliBinDir(mode) (new in utils/cli-resolver.ts): a memoized-by-id
  resolver built from the registry's discovery data, working for ANY
  registered CLI including a custom one, not just the six hand-named
  resolver modules.
- appendResumeFlag() (the docker in-container resume-after-restart path)
  reads a new declarative `launch.resumeAppend` field per entry instead of
  a switch over mode.
- buildEnvExports() reads per-CLI COLORTERM/NO_COLOR/
  CODEX_INTERNAL_ORIGINATOR_OVERRIDE from `env.exports`/`env.unset` instead
  of inline mode checks.
- The three near-identical tmux-setenv secret-injection functions
  (setOpenCodeEnvVars/setCodexEnvVars/setGeminiEnvVars) collapse into one
  setCliSensitiveEnvVars(keys) reading `env.tmuxSetenvKeys`; the three
  `_configure<X>` methods collapse into one `_configureCliEnv()`.
- The six "<CLI> not found. Install with: <command>" throws collapse into
  one missingCliMessage() reading the registry's label + per-platform
  install command.

New registry fields to support this: `legacyConfigAliases` (maps a
declared param name to the field name it arrives under on the wire — e.g.
OpenCodeConfig.continueSession -> the `resumeId` param — so the bridge
stays a generic reader of DATA rather than a per-mode `if` chain),
`resumeAppend`, and the `codemanPrefixedSessionId` engine value (codex's
unique per-pane rollout originator).

New test/cli-registry-spawn-bridge-parity.test.ts proves the bridge
renders byte-identical output to the original hand-written builders across
the same permutation matrix as the phase-0 argv parity test, this time
exercising the actual legacy-config wiring end to end.

Caught and fixed during this work: the bridge initially passed
`sessionName` to the `--name` flag WITHOUT the original
`sanitizeCliSessionName()` allowlist pass — the double-quote escaping on
that arg makes a hostile value inert but does not launder it the way the
allowlist does, and test/name-flag-injection.test.ts (a pre-existing test
this phase did not touch) caught the gap immediately. Fixed by routing the
session name through the same sanitizer before it reaches the engine.

Full suite back to the exact pre-existing baseline (55 failed files / 138
failed tests, unrelated to this work) plus 57 new passing tests and zero
regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
…ry (phase 5)

Points the location-overlay code (remote SSH + docker cases) at the CLI
registry instead of hard-coded per-mode maps:

- remote-hosts.ts: defaultRemoteCommandForMode()'s six-entry Record literal
  collapses to a registry lookup on the new overlays.remote field (a bare
  binary-name default, or an explicit override like claude's
  "--dangerously-skip-permissions" suffix); REMOTE_CLI_BIN (a second,
  independently-hand-maintained id->binary map, notably including the
  antigravity/agy split already fixed once in docker-hosts.ts) is deleted
  entirely in favour of reading discovery.binaries[0] directly.
- docker-hosts.ts: defaultDockerCommandForMode() mirrors the same change via
  overlays.docker. CRED_STORES (the codex/gemini/pi/opencode credential
  seeding policy) is now assembled from every registered entry's
  overlays.credStore, plus the one hardcoded exception that belongs to no
  single CLI: .config/gcloud, the general Google Cloud SDK store gemini's
  Vertex AI path (and other tools) may read regardless of run mode.

Redesigned CliOverlays.remote/docker along the way: the phase-0 design had
them reference a named `launch.variants` entry, but the actual remote/docker
default is a much simpler "bare binary, or a fixed override string" shape
than the full interactive-launch argv template (no session-id/model/effort/
name), so it is now `{ command?: string } | { disabled: true }` — a
space-separated, metacharacter-free command line (same safe-word charset as
every other literal in the schema, just space-joined), with `disabled` for
the one case that genuinely has none (docker for `shell`).

Every mode/tmux-manager/registry test green (281 tests), including the
pre-existing claude --dangerously-skip-permissions and antigravity/agy
binary-split assertions in test/remote-hosts.test.ts and
test/antigravity-mode.test.ts, unchanged. Full suite back to the exact
baseline (55 failed files / 138 failed tests, pre-existing) with zero
regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
…ogic (phase 6)

New public surface:
- GET /api/clis - the full registry (secrets-free: CliEntry never carries a
  secret value, only env var names), each entry augmented with live
  available/path/version. For the frontend to render the run-mode menu,
  welcome buttons, labels and badges from data (phase 7).
- GET /api/cli/:id/status - generic per-CLI status, working for ANY
  registered id including a future custom one. The six hand-written
  /api/<mode>/status routes are kept as-is (never removed - an existing
  endpoint path stays stable per docs/versioning-policy.md) rather than
  becoming thin aliases, since route tests mock each one's resolver module
  independently and collapsing them would have required rewriting that
  mocking setup for no behavioural gain.
- window.__codemanClis - server-rendered mirror of GET /api/clis for the
  initial page load, injected additively alongside the UNCHANGED
  window.__codemanCliAvailable (test/render-index-html.test.ts pins its
  exact shape with `toEqual`, so extending it in place would have broken
  a passing test for a fully-additive change).

De-duplication:
- session-routes.ts's two copies of the external-CLI availability + install
  -hint ladder (create and quick-start, five hand-written `if (mode ===
  '<id>')` blocks each) collapse into one checkExternalCliAvailable()
  reading the registry via isExternalCliMode() + resolveCliBinDir().
- missingCliMessage() (added in tmux-manager.ts during phase 4) moves to
  config/cli-registry/registry.ts so both tmux-manager.ts's spawn-time throw
  and session-routes.ts's pre-flight check read the exact same string
  instead of two copies.
- schemas.ts's ALLOWED_ENV_PREFIXES/ALLOWED_ENV_KEYS are now composed from
  every registered CLI's own env.allowedPrefixes/allowedKeys instead of a
  hand-maintained array; BLOCKED_ENV_KEYS stays hardcoded by design (a floor
  no registry entry can widen).

New utils/cli-resolver.ts export: resolveCliVersion(id), the version-aware
sibling of resolveCliBinDir(id) for any resolver built with
requireVersionMatch (pi today).

New tests for both routes in test/routes/system-routes.test.ts (structure,
augmentation, 404 on an unknown id, no-secrets check). Full suite at the
exact pre-existing baseline (55 failed files / 138 failed tests) plus 5 new
passing tests, zero regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
…) (phase 7, part 1)

The highest-value, safely-verifiable slice of the frontend phase: the five
near-identical runOpenCode()/runCodex()/runGemini()/runAntigravity()/runPi()
methods in session-ui.js (each ~55 lines: status-check URL, install-hint
message, display label, and a per-mode quick-start config body, otherwise
byte-for-byte the same launch/error/selection flow) collapse into one
runCli(mode), driven by:
- GET /api/cli/:id/status (generic, works for any registered id) instead of
  the six hand-written /api/<mode>/status routes.
- window.__codemanClis (server-injected, phase 6) for the display label and
  the install-hint message (now server-computed via missingCliMessage()
  rather than five copy-pasted strings).
- _quickStartConfigFor(mode, settings), a small explicit table for the one
  thing that genuinely isn't "which CLIs exist" — deliberate frontend POLICY
  about what each CLI's quick-start config body should default to (codex's
  two settings-toggle-driven fields, pi's intentional absence of any config
  at all so a browser-launched session can never silently execute
  repo-supplied TypeScript).

run()'s dispatch and the four welcome-button onclick handlers in index.html
(a minimal, non-structural attribute edit — the run-mode menu, badges and
per-mode CSS are NOT touched in this pass) now call runCli(mode) instead of
the deleted per-mode methods.

Also drove two label lookups (session-ui.js's run-button label,
app.js's _getResponseViewerAgentLabel) from window.__codemanClis, each with
a defensive `typeof window` guard and a fallback to the exact original
ternary chain so behavior is byte-identical in any context (older cached
page, vm-sandboxed unit test) where the registry payload isn't present.

test/run-mode-ui.test.ts and the browser-suite test/opencode-resize.test.ts
updated to call runCli(mode) and the new status endpoint instead of the
deleted methods/routes; test descriptions and comments updated to match.
GET /api/clis and GET /api/cli/:id/status gained an `installHint` field
(missingCliMessage(), shared with tmux-manager.ts's spawn-time throw) so the
frontend never reconstructs the per-platform install-command message itself.

Deliberately NOT touched in this pass (deferred — each needs either visual
QA I cannot perform here, or touches the tab-render hot path CLAUDE.md flags
as delicate): the run-mode menu markup and its availability-gating loop, the
per-mode CSS accent blocks, the tab badge/kill-title ternaries, and
terminal-ui.js's echo-policy/alt-screen/wheel-forward capability gates.

Full suite at the exact pre-existing baseline (55 failed files / 138 failed
tests, unrelated to this work), zero regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
Backend half of the settings-UI phase: mutation functions in
config/cli-registry/registry.ts, each a read-modify-write against
~/.codeman/clis.json that validates before persisting and reloads the
shared cache on success, so every other module sees the change on its next
getCli()/listClis() call:

- setCliEnabled(id, enabled) - toggle any registered CLI (stock or custom).
- setCliOrder(orderedIds) - reposition the given ids (x10-spaced, so a
  future insertion between two adjacent entries never needs a renumber);
  ids not listed keep their current order.
- upsertCustomCli(id, entry) - add or replace a CUSTOM CLI. Validated as a
  COMPLETE CliEntry up front (the same schema the on-disk file itself is
  validated against) so a malformed request fails with a clear error
  instead of being silently dropped on the next unrelated read; refuses to
  shadow a stock id.
- removeCustomCli(id) - remove a custom CLI; refuses for a stock id (those
  can only be disabled, never removed - the loader treats an id-collision
  as fixable but shell/claude's total ABSENCE as something huge parts of
  the app assume can't happen).

New routes in system-routes.ts, admin-gated in multi-user mode (the
registry is process-wide config, not scoped to one user's workspace, same
posture as the workflow/subagent aggregates already gated that way):
PUT /api/clis/:id/enabled, PUT /api/clis/order, POST /api/clis/:id,
DELETE /api/clis/:id. All four return the full resolved list on success so
the frontend can just replace its in-memory copy.

Test coverage: 8 new registry-level tests (test/cli-registry-load.test.ts)
and 12 new route tests (test/routes/system-routes.test.ts) covering
success, validation failure, unknown-id, and the stock-id refusal on both
upsert and remove. The route tests needed a small harness fix: this test
file globally mocks node:fs's existsSync (always true) and mkdirSync
(no-op) for every OTHER route's benefit, which broke the registry writer's
real disk IO — fixed by delegating to the real fs implementations for just
the new "CLI registry writes" describe block.

Full suite at the exact pre-existing baseline (55 failed files / 138 failed
tests, unrelated to this work) plus 18 new passing tests, zero
regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
App Settings -> Agents & CLIs gains an "Installed CLIs" group above the
existing per-CLI (Claude/Codex) settings groups: a dynamic list backed by
the phase-8-backend endpoints, plus a quick-add form for a custom CLI.

- renderCliManagementList() fetches GET /api/clis and builds one row per
  entry (label, install status/hint, move up/down, an enable/disable
  toggle, and a Remove button for custom entries only). Re-fetches on
  every call rather than trusting the page-load window.__codemanClis
  snapshot, so a change made moments earlier in the same session shows up.
- Row actions PUT /api/clis/:id/enabled, PUT /api/clis/order (swap-and-
  send-the-whole-order), and DELETE /api/clis/:id, each re-rendering the
  list from the response.
- The quick-add form (id/label/binary/install command) POSTs a conservative
  default CliEntry when submitted - deliberately the SAME safe profile the
  registry already uses for an unrecognized CLI (external agent, requires
  tmux, no hooks, buffered echo, no privileged params) - and leaves
  everything else (launch flags, environment) to a direct edit of
  ~/.codeman/clis.json, which the row description says explicitly.

Built entirely from EXISTING `.set-row`/`.set-row-actions`/`.switch` CSS
classes already used elsewhere in this modal, so it needed no new
stylesheet rules and renders consistently with the rest of the settings
surface without touching styles.css/mobile.css.

New test/cli-management-settings.test.ts (14 tests, vm-sandboxed like
test/run-mode-ui.test.ts): render/error-handling, each row action's exact
request shape, the quick-add form's validation and its request body
(pinning the conservative-defaults claim above), and both the success and
failure paths of adding a CLI.

Full suite at the exact pre-existing baseline (55 failed files / 138 failed
tests, unrelated to this work) plus 14 new passing tests, zero
regressions.

Still not visually verified in a live browser (no display available in
this environment) - the settings-surface structure test
(app-settings-structure.test.ts) and the new behavioral tests pass, but an
actual look at the rendered "Installed CLIs" group is worth doing before
relying on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
… mode validation from the registry (phases 9-10)

Phase 9 - install.sh + Docker:
- Generate config/clis.stock.json from the compiled stock catalog
  (src/config/cli-registry/stock.ts) via scripts/generate-cli-stock-json.mjs
  (npm run generate:cli-stock-json). test/cli-stock-json-sync.test.ts pins the two
  in sync, same pattern as test/sse-registry-parity.test.ts.
- install.sh replaces its six hardcoded *_SEARCH_PATHS arrays and check_*/get_*_path
  function pairs with one generic check_cli()/get_cli_path() driven by
  load_cli_registry(), which fetches config/clis.stock.json from
  raw.githubusercontent.com (this script runs standalone via curl | bash before the
  repo is cloned or built, so it cannot import TypeScript) and parses it with node.
  Falls back to a small built-in JSON literal (Claude Code + OpenCode only) on a
  fetch/parse failure rather than aborting the install. The interactive
  "which AI CLI" prompt and the closing "install one later" reminder now iterate the
  full registry instead of a hardcoded list.
- ~/.codeman/clis.json preservation across `install.sh update` is made explicit:
  it already held by construction (the file lives outside $INSTALL_DIR, which is
  all update() touches), and update() now says so and confirms it in its output.
- docker/agent.Dockerfile takes the npm-installable CLIs' package names as build
  ARGs (CLI_NPM_PACKAGES, CLI_PI_NPM_PACKAGE); scripts/build-agent-image.mjs reads
  config/clis.stock.json and passes them via --build-arg, so a new stock CLI with a
  plain `npm install -g <pkg>` install command needs no Dockerfile edit. Antigravity
  (no npmPackage - a standalone binary installer) and Pi's --ignore-scripts flag stay
  documented Dockerfile special cases, the sanctioned per-CLI exception.

Phase 10 - docs:
- New docs/cli-registry.md: file layout, merge/seed model, editing via settings UI
  or API, the CliEntry schema, arg-template safety, and how install.sh/Docker consume
  the registry.
- docs/wiki/Agent-CLIs.md: rewritten intro frames the CLI set as data-driven,
  points at the new doc and the settings UI, before the existing per-CLI notes
  (kept as the sanctioned documentation exception).
- docs/extending-codeman.md: notes that `mode` is registry-driven, not a fixed
  enum, and points integrators at GET /api/clis.
- CLAUDE.md: replaced the stale "SessionMode = 'claude' | ... | 'pi'" Tech Stack
  line with a description of the registry.
- README.md: two lines note the CLI set is a config file, not a fixed list.
- .changeset/72f92691.md: minor changeset describing the whole feature.

Also closes a real functional gap in the earlier phases: session-mode validation
(CreateSessionSchema.mode, QuickStartSchema.mode, CronJobBaseSchema.agentType in
src/web/schemas.ts) was still three hardcoded 7-value z.enum() arrays, so a custom
CLI added through the Phase 8 settings UI would appear in menus but be REJECTED by
POST /api/sessions with mode set to its id. Replaced with a schema built from
enabledClis() at module load (sessionModeSchema()), matching the plan's
"z.enum(registryIds())" compatibility design. SessionMode itself stays the literal
union for now (retyping it would also collapse RemoteCommandMode/DockerCommandMode,
a separately-scoped change); the schema's output is cast to SessionMode with a
documented rationale, since Zod validates against the live registry at runtime and
no downstream code exhaustively switches on the id
(test/cli-registry-no-id-branching.test.ts enforces that).

Verified: npm run typecheck, npm run lint, npm run format:check all clean. Full
npm test matches the known baseline exactly (55 failed files / 138 failed tests,
all pre-existing and confirmed via git stash comparison - none touch code this
commit changed). All CLI-registry-focused suites green (12 files, 341 tests).

Not done, left for a follow-up: retyping SessionMode as a non-literal id (the
RemoteCommandMode/DockerCommandMode Extract<> usages need their own pass first),
and fully automating non-npm CLI installs in the Docker image (a curl-based
installer, like Antigravity's, still needs a Dockerfile edit).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
…auto-install on enable

Add GitHub Copilot CLI (`copilot`, npm `@github/copilot`) as a new stock registry
entry, shipped DISABLED by default -- unlike every other stock entry -- since it is
new to the catalog. Its launch shape is deliberately minimal (no --model/--resume
flags: upstream's --resume/--continue open an interactive picker or jump to the
most recent session rather than taking a session id directly, so resumeAppend is
left unset rather than guessed at). Discovery/install/docs verified against GitHub's
own docs (see sources below). Codex was considered for the same disabled-by-default
treatment but left as-is per explicit direction -- it already ships enabled and
people rely on that.

Also implements the follow-up requirement: a disabled CLI's binary is never checked
or cared about, but the moment it is explicitly ENABLED (PUT /api/clis/:id/enabled,
what the settings UI's toggle calls) and its binary isn't installed yet, Codeman now
runs that entry's install command in the background automatically instead of
leaving the operator to do it by hand.

- New src/config/cli-registry/cli-installer.ts: ensureCliInstalled(id) checks
  availability via the existing resolveCliBinDir, and if unavailable, spawns the
  platform-appropriate discovery.install.command (shell:true, since these are
  pipelines like `curl ... | bash`, not a single argv -- same as install.sh's own
  `download_to_stdout url | bash`). Tracks in-memory status per id
  (installing/success/error) with a bounded output tail and a 10-minute default
  timeout (CODEMAN_CLI_INSTALL_TIMEOUT_MS, clamped 30s-1h). On completion,
  invalidates the memoized resolver cache (new invalidateCliBinDirCache in
  cli-resolver.ts -- each resolver caches its result, including a negative one,
  forever) and re-probes so `available` flips true without a server restart.
- Security model, documented at length in the new file's header and in
  types.ts's CliDiscovery.install.command doc comment (previously "NEVER executed
  by the server" -- now points at this module instead of contradicting it): this
  only ever runs as the direct, synchronous result of that one explicit API call,
  never on boot or a background reload; the command that runs is byte-identical to
  the installHint already shown for that entry; and enabling a CLI is already
  admin-gated in multi-user mode / the same single trust level in single-user mode
  that can already add or edit any entry through this same surface. Under VITEST
  the actual spawn is a silent no-op (status left untouched, distinguishable from a
  real terminal state) -- same posture as TmuxManager's IS_TEST_MODE -- so the test
  suite can never trigger a real, network-dependent, possibly minutes-long install;
  test/cli-installer.test.ts covers the decision logic (already-available fast
  path, no-install-command path, the VITEST no-op itself, the concurrency guard)
  with node:child_process mocked as a second line of defense.
- GET /api/clis, GET /api/cli/:id/status and the PUT .../enabled response all gain
  `installStatus` ({state, command, message?}); settings-ui.js's CLI list shows
  "Installing…"/"Install failed: …" inline, disables the toggle mid-install, and
  polls (3s intervals, ~2min cap) until it resolves.
- Extracted resolveInstallCommandForPlatform() out of missingCliMessage() in
  registry.ts so the installer and the display-hint code share one platform
  selection instead of duplicating it.
- Test fixtures in cli-registry-load.test.ts and routes/system-routes.test.ts that
  exercised the CUSTOM-CLI add/remove path using a fabricated "copilot" id are
  renamed to "testcli" -- copilot is a real stock id now, and upsertCustomCli/
  removeCustomCli both correctly refuse to touch a stock id.

Sources for the GitHub Copilot CLI facts used in the stock entry:
- https://github.com/github/copilot-cli
- https://www.npmjs.com/package/@github/copilot
- https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli
- https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference

Verified: npm run typecheck, npm run lint, npm run format:check, and the frontend
syntax/public-asset checks all clean. Full npm test matches the known baseline
exactly on failures (55 failed files / 138 failed tests, all pre-existing) with 10
more passing tests than before (the new coverage added here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
The bottom-right Run-mode context menu (#runModeMenu) was still static HTML: seven
hand-written <button data-mode="..."> entries for the original six CLIs plus shell,
never rebuilt from window.__codemanClis. So enabling GitHub Copilot CLI (or any
future CLI) from Settings correctly updated the registry -- confirmed live via
/api/clis and the injected window.__codemanClis blob -- but the menu itself had no
code path that ever added a button for it, even after a full page reload.

- index.html: wrapped the static agent buttons in #runModeAgentOptions and the
  static shell button in #runModeShellOption. They stay as the fallback markup for
  the rare case the registry blob is missing (same "unknown reads as available"
  posture as isCliAvailable()).
- session-ui.js: new _renderRunModeOptions(), called at the top of
  toggleRunModeMenu() every time the menu opens. Reads window.__codemanClis,
  filters to enabled entries, sorts by `order`, and rebuilds both groups from
  scratch -- so a newly-enabled stock or custom CLI appears with zero markup
  changes required in the future. Per-entry accent colour is applied inline
  (dot.style.background = cli.accent) rather than via a per-mode CSS class, since
  styles.css only ever defined .run-mode-dot.claude/.opencode/etc for the
  original six.
- _refreshRunModeAvailability() (the Ark0N#200/Ark0N#201 "hide what's not installed" gate)
  was ALSO a hardcoded six-mode loop; genericized to iterate whatever
  .run-mode-option[data-mode] buttons are actually present and check
  window.__codemanClis's own `available` flag first (falling back to the legacy
  isCliAvailable()/window.__codemanCliAvailable map, which never gained a
  `copilot` key and was never going to).
- settings-ui.js: renderCliManagementList() now also writes window.__codemanClis
  after every fetch, so toggling a CLI on/off in Settings updates the Run menu
  immediately on the SAME page load -- not just after a fresh reload.
- styles.css: the two new wrapper divs need their own flex+gap (matching
  .run-mode-menu.active's), since inserting a plain <div> between the menu and its
  buttons would otherwise swallow the 2px gap between grouped buttons.

Test changes (test/run-mode-ui.test.ts):
- The "gates every mode the run-mode menu actually offers" test used to assert the
  literal string 'antigravity' etc. appeared in _refreshRunModeAvailability's
  SOURCE TEXT -- exactly the hardcoded-list assumption this fix removes. Rewritten
  to drive the function with the actual buttons index.html's static markup offers
  and assert every one of them was visited (none left at its PRISTINE sentinel),
  which is a genuine behavioural check rather than a string match.
- Added a `copilot` button to the CLI-availability-gating harness throughout, to
  prove a mode absent from the legacy flags map falls through to "available"
  rather than being silently skipped.
- New describe block for _renderRunModeOptions() itself: includes/excludes CLIs by
  `enabled`, sorts by `order`, routes kind:"shell" to the shell slot rather than
  the agent group, leaves the static markup untouched when the registry blob is
  missing, and wires each rendered button's click through to setRunMode(id).

Verified: npm run typecheck, npm run lint, check-frontend-syntax all clean. Full
npm test matches the known baseline exactly on failures (55 failed files / 138
failed tests, all pre-existing, confirmed via git stash comparison earlier this
branch) with 5 more passing tests than before this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
…, not just availability

The center-of-page "Run Claude Code" / "Run OpenCode" / "Run Gemini" (etc.) buttons
were five hardcoded <button id="welcome<X>Btn"> elements in index.html, gated only
on CLI AVAILABILITY (isCliAvailable, itself reading the legacy fixed six-key
window.__codemanCliAvailable map) -- never on whether the CLI was enabled in
Settings -> Agents & CLIs. Codex had no button at all, and no future CLI (GitHub
Copilot included) ever could, regardless of its enabled state -- exactly the
"must be hardcoded somewhere" suspicion.

- index.html: wrapped the five static buttons in #welcomeCliButtons (the fallback
  markup for when the registry blob is missing) and moved the separate, non-CLI
  Cloudflare Tunnel button outside that group, to the end of the list (was 2nd).
- session-ui.js: new _renderWelcomeCliButtons(), called from
  applyWelcomeCliVisibility() every time the welcome screen shows. Builds one
  button per CLI that is both `enabled` and `available` (kind !== 'shell' --
  these are "jump into an agent" actions; shell already has its own path via the
  Run dropdown), sorted by `order`. The original five keep their hand-crafted
  gradient CSS class (.welcome-btn-<id>); any other CLI (codex, copilot, a custom
  one) gets a flat inline background from the registry's own `accent` field
  instead of an invisible transparent button -- same "no per-mode CSS class
  needed" approach the Run-menu dot already uses. Icon SVG is built via
  createElementNS (a plain createElement('svg') would not render as SVG in a real
  browser). New _runWelcomeCli(id) mirrors run()'s own per-mode dispatch
  (runClaude/runShell/runCli) without its launch-lock behaviour, matching what
  the original per-button onclick handlers did.
- A missing/empty window.__codemanClis (old cached page, a page that never got
  the injection) falls back to the ORIGINAL five-button availability-only gating
  rather than leaving every button stuck at its markup-default display:none --
  these buttons start hidden, unlike the Run-menu's always-visible static
  fallback, so silently doing nothing here would have been a regression.
- applyWelcomeCliVisibility() now only handles the Tunnel button's own
  cloudflared gating; the CLI-button logic moved into _renderWelcomeCliButtons().
- styles.css / mobile.css: #welcomeCliButtons needs its own flex+gap (desktop) and
  flex-direction:column+gap (mobile), matching .welcome-actions's own rules --
  otherwise the buttons inside it would lose the gap .welcome-actions gives its
  DIRECT children, since the new wrapper div sits one level in between (same fix
  as #runModeAgentOptions needed for the Run menu).

test/run-mode-ui.test.ts: new describe block for _renderWelcomeCliButtons --
includes an enabled+available CLI the static markup never had, excludes a
disabled one, excludes an unavailable one, excludes shell, keeps the hand-crafted
classes for the original five in `order`, falls back correctly when the registry
blob is missing, dispatches clicks through _runWelcomeCli, and confirms
applyWelcomeCliVisibility still gates the Tunnel button separately.

Verified: npm run typecheck, npm run lint, check-frontend-syntax all clean. Full
npm test matches the known baseline exactly on failures (55 failed files / 138
failed tests, all pre-existing) with 8 more passing tests than before this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
_renderWelcomeCliButtons() (from the previous commit) still excluded kind:'shell'
on the theory that these buttons are "jump into an agent" and shell already has
its own path via the Run dropdown. The ORIGINAL hardcoded markup never had a
Shell welcome button either, but with the button list now fully dynamic and
enabled/disabled-driven, an always-enabled entry like Shell silently missing was
inconsistent with the rest of the fix and was flagged directly. Removed the
`kind !== 'shell'` filter -- any enabled + available registry entry gets a
button now, shell included.

test/run-mode-ui.test.ts: replaced the "excludes shell" test with one asserting
shell IS included.

Verified: npm run typecheck, check-frontend-syntax clean. Full npm test matches
the known baseline exactly (55 failed files / 138 failed tests, all pre-existing).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
…ug fixes (merge plan phases 1-2)

First two steps of reconciling upstream Ark0N/Codeman's master (103 commits ahead,
merge-base 07b9c7f) with this branch, done before the actual merge so the registry
already has everything it needs when the merge lands.

Phase 1 -- Grok as a registry entry, not a hardcoded 7th mode:

Upstream added Grok Build the OLD way (commit 3f8c8e9 + follow-ups 57f326a,
9cfd8e8 -- its own SessionMode literal, its own resolver file, hardcoded branches
across ~40 files). Ported it into a single registry entry instead, enabled by
default (a real, established mode upstream ships live, unlike Copilot's
experimental opt-in):

- src/config/cli-registry/stock.ts: new GROK entry. discovery/launch/env/
  capabilities facts extracted directly from upstream's commits and cross-checked
  against its own pinned test/grok-mode.test.ts (verified byte-identical via a
  standalone renderLaunch check: bare spawn, --always-approve/--model, --resume vs
  --continue precedence, unsafe model/resumeId values dropped rather than escaped,
  remote command format). accent is a single hex (#a1a1aa) since upstream
  hand-authored a multi-spot CSS gradient our registry's one-hex `accent` field
  can't reproduce -- every surface gets it via the same inline-accent fallback
  Copilot already uses. privilegedParams: [{param:'alwaysApprove', clampTo:false}]
  -- the only-if-sent shape (codex/antigravity's branch, not pi/gemini's
  materialize branch) -- feeds the clampExternalCliBypassForOwner generalization
  planned for a later phase.
- docker/agent.Dockerfile: new special-case RUN block (Grok is not on npm -- xAI's
  own installer, same tier as Antigravity's), copied verbatim from upstream's
  9cfd8e8 symlink-survival fix (stages as grok.real, removes the pre-existing
  /usr/local/bin/grok, moves into place -- survives both old and new xAI installer
  behavior). .grok added to the pre-created per-file credential seed dirs.
- skills/codeman/reference/*.md: added grok to every mode-enumeration list
  test/agent-skill-mode-lists.test.ts already derives from the registry and
  flagged as stale (including one pre-existing gap for codex this branch had
  already introduced and never caught). Documented GET /api/v1/cli/grok/status (no
  legacy per-mode alias exists for grok on this fork, unlike the original six).
- test/agent-skill-mode-lists.test.ts: the not-found-probe-documented check's
  regex only recognized the legacy `/api/<mode>/status` shape; extended to also
  recognize the generic `/api/cli/<mode>/status` shape modern registry entries use
  instead -- a real gap the test itself never had to close before (grok is the
  first enabled-by-default entry added since Copilot, which is disabled-by-default
  and so never triggered this path).
- test/routes/system-routes.test.ts: extended the hardcoded ids-list assertion.
- config/clis.stock.json regenerated (npm run generate:cli-stock-json).

Phase 2 -- ported upstream's independently-built resolver bug fixes into our
registry-driven src/utils/cli-resolver.ts (PR Ark0N#329 + follow-up 61251c0 built a
SEPARATE shared resolver, cli-executable-resolver.ts, with real fixes ours lacked):

- Login-shell fallback: after PATH (`which`) and the declared search dirs both
  miss, spawn the user's login shell (`shell-resolver.ts`'s existing
  resolveLocalShell/loginShellArgs, previously only used for `mode:'shell'`
  sessions) with a tagged `command -v --` probe -- what actually finds nvm/
  Homebrew/user-npm installs when Codeman runs as a systemd/launchd service with a
  minimal PATH.
- Negative-result caching with backoff: reused the EXISTING
  resolveRetryingVersion/retryingVersionProbeDelayMs mechanism (already proven for
  claude's version probe) rather than duplicating upstream's separate backoff
  curve -- applied to createDirResolver's and createVersionGatedResolver's whole
  probe chain, so a missing CLI is retried on a 1min-doubling-to-15min schedule
  instead of either being cached as missing forever or re-running the full chain
  (including the login-shell spawn) on every request.
- killSignal:'SIGKILL' added to every timeout-bounded exec call in this file --
  execFileSync's `timeout` only SENDS the signal and keeps waiting for the child to
  exit; an interactive shell/CLI ignoring SIGTERM would otherwise survive the
  timeout and block the resolver, and therefore the request handler calling it,
  forever.
- VITEST hermeticity: createDirResolver's `which` call had NO guard and did a real
  subprocess/PATH lookup even under the test suite -- closed before adding the
  login-shell step, which would otherwise ALSO spawn for real under tests.
- Not-found diagnostics: formatCliNotFoundMessage (bounded/sanitized PATH,
  login-shell command, searched dirs -- same 1024-char bounding and control-char
  flattening as upstream) wired into registry.ts's missingCliMessage(), so every
  caller (tmux-manager's spawn throw, session-routes' availability gate) gets it
  for free.

Verified: renderLaunch() output for grok matches every case in upstream's pinned
test/grok-mode.test.ts exactly. npm run typecheck/lint clean, no circular-import
issue from registry.ts <-> cli-resolver.ts (both only reference each other inside
function bodies, never at module-load time -- confirmed via the full registry test
suite passing, not just tsc). Full npm test compared against a FRESH git-stash
baseline taken in this same environment (not a stale remembered number): baseline
56 failed files / 139 failed tests, this change 57/140 -- within normal
Windows-environment flakiness (confirmed two of the specific files that differed,
render-index-html.test.ts and session-routes-parent-lineage.test.ts, fail
IDENTICALLY on the stashed baseline, unrelated to this change).

Part of the plan at C:\Users\dsati\.claude\plans\this-is-a-fork-keen-map.md
(Phases 1-2 of 6). The actual merge (Phase 3) follows in a later commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtttS396SFgaXi8iyJPXQM
… the CLI registry

103 upstream commits merged, largest conflicts in the frontend run-mode surfaces
(index.html, session-ui.js, settings-ui.js) and session-routes.ts, all resolved
in favor of the data-driven registry mechanism wherever upstream hardcoded a
7th `grok` branch in a file the registry already made generic. Genuinely new/
unrelated upstream work (codeman tui, the vertical session rail / tab-layout
feature, Auto Copy Selection, the response-viewer transcript module) is
preserved in full.

- src/web/public/{index.html,session-ui.js,settings-ui.js}: kept
  _renderRunModeOptions()/_renderWelcomeCliButtons() as the single source of
  Run-menu/welcome-button markup; discarded upstream's static grok button/menu
  additions and its five per-mode runOpenCode()/runCodex()/.../runGrok()
  methods (superseded by the generic runCli()); added a `grok` case to
  _quickStartConfigFor() so the Run button still opts grok into
  --always-approve, matching upstream's runGrok() byte-for-byte.
- test/run-mode-ui.test.ts, test/routes/system-routes.test.ts: reconciled onto
  the generic/dynamic assertion style already proven for Copilot.
- src/session.ts, src/tmux-manager.ts, src/web/schemas.ts,
  src/utils/*-cli-resolver.ts, install.sh, docker-hosts.ts, remote-hosts.ts,
  dependency-registry.ts: kept the registry-driven mechanism; removed
  upstream's redundant grok-specific branches/functions/routes (including the
  legacy GET /api/grok/status alias — only the generic
  GET /api/cli/:id/status serves CLIs beyond the original six).
- src/utils/index.ts: removed 7 dead get*NotFoundMessage re-exports (functions
  that no longer exist in any per-CLI resolver file) — a pre-existing bug from
  the registry refactor, surfaced by upstream's own getGrokNotFoundMessage
  addition to the same file.
- Reformatted the whole tracked *.ts tree with prettier post-merge (CRLF/LF
  normalization only, no content changes) so format:check stays green.

Full tsc --noEmit: 0 errors. check-frontend-syntax: 34/34 clean.
@opticon454

Copy link
Copy Markdown
Author

@Ark0N This was an idea I had to expand Codeman into a AI neutral platform going forward (By that I mean, no hard coding and using central config files).
It provides the capability for any existing or future AI to be added and removed by a toggle button or config file.

It was coded by Claude and tested by me. 1st time creating a PR anywhere so hopefully I've done it right

@opticon454

Copy link
Copy Markdown
Author

This solves

[@](#198)

@Ark0N

Ark0N commented Aug 25, 2026

Copy link
Copy Markdown
Owner

@opticon454 First: thank you. For a first PR anywhere this is remarkable work, and the direction (CLI backends as data instead of ~40 hardcoded branch sites) is one I'm genuinely interested in. I read the whole diff. The argv engine's security model (typed tokens, no shell text in config, named patterns living in code, quote-on-doubt) is better than the hand-written builders it replaces, and the transcriptions are faithful down to the allowlist regexes and the pi/grok version probes.

I can't merge it as it stands though, and some of the reasons are structural rather than review-fixable. Here's the full picture so you can decide how to proceed.

1. The branch predates 1.23.0, and the mode it's missing doesn't fit the registry as designed

Your merge base is master as of Aug 24 (1.22.0 era). 1.23.0 landed the day this PR opened and added DeepSeek Harness (dsh) as a ninth run mode (#337/#341); the diff has zero mentions of it. That's also why GitHub shows the PR as conflicting, and a conflicting PR gets no CI runs at all, so none of the checks ever executed here.

This is more than a rebase, because deepseek breaks four assumptions the CliEntry schema bakes in:

  • Its permission switch is an env var (DSH_PERMISSION_MODE), not a launch flag, and the multi-user clamp for it lives in clampEnvOverridesForOwner() (session-routes.ts on master), which drops DSH_PERMISSION_MODE/DSH_HOME/DEEPSEEK_BASE_URL for non-granted owners. That function doesn't exist on this branch, and capabilities.privilegedParams can only clamp argv params, so the registry as designed cannot express it. Merged as-is, a real multi-user security control silently disappears.
  • hooksAvailableForMode('deepseek') is a per-session question on master (deepSeekConfig.statusReporting can disarm it), not the static hooks: boolean the schema offers.
  • dsh is a profile launcher, so "binary installed" is not "runnable" (isDeepSeekRunnable), which the discovery model can't say.
  • Its transcript is read from zstd session files, and capabilities.transcript is a closed enum of claude-jsonl | codex-rollout | none.

Concretely, test/deepseek-mode.test.ts and test/routes/external-cli-bypass-clamp.test.ts fail at compile against this branch (imports and signatures). A rebase has to add a real deepseek entry plus a schema extension for env-var-shaped privileged params, not just resolve conflicts.

2. Enabling a CLI doesn't actually make it usable until the server restarts

SESSION_MODE_IDS, ALLOWED_ENV_PREFIXES and ALLOWED_ENV_KEYS in schemas.ts are computed once at module load from enabledClis(), and sessionModeSchema() closes over that snapshot; reloadCliRegistry() invalidates the registry cache but can't re-run schemas.ts. So the flow the PR is built around (toggle Copilot on, then run it) fails POST /api/sessions with INVALID_INPUT until restart: the Run menu updates (the frontend refreshes window.__codemanClis), validation doesn't. The changeset claims the opposite ("immediately usable as a session mode"). The schema needs to resolve modes/prefixes at parse time rather than import time.

3. Copilot has to come out for now

This one's on me, not you: I deliberately parked #198. Interest aside, there are two open upstream bugs that each break Codeman's exact spawn model, and I re-checked both this week:

So a Run Copilot toggle today produces a backend that installs, renders, then ignores everything you type. Two side effects worth fixing regardless: the docker agent-image build collects npm packages from the generated stock JSON, which carries no enabled field, so @github/copilot gets baked into every image even though the entry ships disabled; and once the COPILOT_ env prefix is allowlisted, COPILOT_ALLOW_ALL=true is a full permission bypass that nothing clamps (a flag-level clamp can't reach it). I'd genuinely like to revisit Copilot as pure registry data the moment those upstream issues close; it would be a great proof of the registry.

4. Executing install commands from config is a trust-model change I want decided separately

discovery.install.command was display-only by documented rule; this PR runs it (spawn(command, {shell: true})) when a CLI is enabled, including free-text commands typed into the Add CLI form, and registry entries also contribute their own env.allowedPrefixes to the global env allowlist. Your write-up of the trust model is honest and the admin gating is right, but this converts two documented invariants into runtime config, and I want to make that call on its own, not inside a 100-file diff. Same goes for the write API generally: I'd take the registry core first, and the settings UI / write endpoints / auto-install as their own follow-up.

5. Assorted findings from the read-through

  • GET /api/grok/status is removed while the other six per-mode status routes stay as aliases. It shipped in 1.22.0, endpoint paths are covered by the versioning policy (and by installed copies of the agent skill), so it needs the same alias treatment. The changeset text ("the five legacy routes are kept, so nothing breaks") doesn't match either way.
  • install.sh now uses declare -A, which is bash 4+. macOS ships bash 3.2 and the documented install is curl | bash with set -euo pipefail, so a stock Mac dies mid-install. Related: when the clis.stock.json fetch comes back empty (plain network failure), the fallback to the embedded two-CLI list is silent; the warning only fires on unparseable content.
  • test/cli-registry-no-id-branching.test.ts is referenced five times (docs, changeset, code comments) but isn't in the PR, and a leftover if (this.mode === 'grok') branch in session.ts right under the new generic requiresMux check shows why it's needed.
  • The two parity suites compare renderLaunch/the bridge against buildSpawnCommand, but after phase 4 buildSpawnCommand IS the bridge, so they now compare the engine with itself. They were the right scaffold during development; the final tree needs literal expected-string pins instead.
  • The four deleted resolver test files took real protections with them: the pi/grok impostor-rejection tests (npm squatters), the version-regex contracts, and the vitest hermeticity pin. The new resolver is also fail-open under vitest (existence alone accepts a candidate) where the old one was pinned fail-closed, and grok has no coverage in the new parity suites at all.
  • codeman doctor loses its grok row (the rebuilt dependency table stops at pi).
  • The phone overview's run picker (mobile-overview.js) is untouched and still hardcoded, so registry enable/disable never reaches phones.
  • Roughly 1,500 diff lines are a Prettier pass over test/**, which is deliberately outside the repo's format scope. I verified it's formatting-only (nothing semantic changed), but it's a big share of the conflict surface, so please drop it.

Where I'd like to take this

I want the registry. The shape I can review and land with confidence:

  1. PR A, registry core as a pure internal refactor: rebased on current master, all nine modes as stock entries including deepseek (with the env-var privileged-param extension), no write API, no auto-install, no Copilot, no install.sh/docker changes. Literal-string spawn parity tests, the no-id-branching guard actually present, resolver behavior tests restored, grok covered. Behavior byte-identical.
  2. PR B, install.sh + docker image reading the stock JSON, with the bash-3.2 fix and an enabled filter.
  3. PR C, settings UI + write endpoints + auto-install, once we've settled the trust model (auto-install may end up behind an explicit confirm, or off).
  4. Copilot as its own PR when upstream unblocks.

That's a lot of asks, I know, but I'm asking because I think you can land this yourself, and I'd like you to. You built a registry that reproduces six CLIs' spawn behavior faithfully enough that I had to go digging to find the seams; the rest is scoping and hardening, and this PR shows you have the patience for both. Start with PR A whenever you're ready, open it early as a draft if you want eyes on the deepseek extension design before you sink time into it, and ping me anytime, I'll turn reviews around quickly.

Really good work. For a first PR anywhere this sets a high bar, and I'm looking forward to the next round.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants