feat: data-driven CLI registry (replaces per-CLI hardcoding; adds GitHub Copilot + Grok Build) - #343
feat: data-driven CLI registry (replaces per-CLI hardcoding; adds GitHub Copilot + Grok Build)#343opticon454 wants to merge 16 commits into
Conversation
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.
|
@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 was coded by Claude and tested by me. 1st time creating a PR anywhere so hopefully I've done it right |
|
This solves [@](#198) |
|
@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 designedYour 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 ( This is more than a rebase, because deepseek breaks four assumptions the
Concretely, 2. Enabling a CLI doesn't actually make it usable until the server restarts
3. Copilot has to come out for nowThis 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 4. Executing install commands from config is a trust-model change I want decided separately
5. Assorted findings from the read-through
Where I'd like to take thisI want the registry. The shape I can review and land with confidence:
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. |
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 ownif (mode === 'x')branchrepeated 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/, seedocs/cli-registry.md). A CLI backend is nowjust 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
masterbefore ~100 commits landed there — thelargest 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 is100 files, +8234/-4340.
tsc/eslint/prettier/check-frontend-syntaxall clean, no new testfailures, 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.