diff --git a/.changeset/72f92691.md b/.changeset/72f92691.md new file mode 100644 index 000000000..638f43798 --- /dev/null +++ b/.changeset/72f92691.md @@ -0,0 +1,34 @@ +--- +"aicodeman": minor +--- + +CLI backends are now a data-driven registry instead of a hardcoded set. Every CLI (Claude +Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, or a custom one you add) is +a `CliEntry` in a central registry — a shipped stock catalog layered with user overrides in +`~/.codeman/clis.json`. Adding, removing, reordering, or reconfiguring a CLI is now a +settings change, not a code change. + +- New App Settings → Agents & CLIs → **Installed CLIs** panel: enable/disable, reorder, + and add/remove custom CLI entries. +- New API: `GET /api/clis`, `PUT /api/clis/:id/enabled`, `PUT /api/clis/order`, + `POST /api/clis/:id`, `DELETE /api/clis/:id`, plus the generic + `GET /api/cli/:id/status` (the five legacy `/api//status` routes are kept as + aliases, so nothing breaks). +- `install.sh` and the Docker agent-image build now read the same registry (via a + generated `config/clis.stock.json` export) instead of keeping their own hardcoded + per-CLI search paths and install steps, so a new stock CLI needs no installer or + Dockerfile change. +- `SessionMode`/`agentType` validation is now built from the live, enabled registry + rather than a fixed literal enum, so a custom CLI added through the settings UI is + immediately usable as a session `mode`, not just visible in menus. +- Internally: the five per-CLI resolvers, command builders, and capability checks + (`isExternalCliMode`, `isAltScreenStripMode`, `hooksAvailableForMode`, and friends) + now read capability flags off the registry instead of branching on the CLI's name. + Verified byte-identical against the previous hand-written command builders for the + stock catalog (`test/cli-registry-argv-parity.test.ts`), and a static guard + (`test/cli-registry-no-id-branching.test.ts`) keeps per-CLI-id branching out of every + file except the stock catalog itself. +- New docs: [`docs/cli-registry.md`](../docs/cli-registry.md). + +No behavior change for existing installs — the stock catalog reproduces every existing +CLI's launch command, environment handling, and capabilities exactly. diff --git a/.changeset/a4c76a4a.md b/.changeset/a4c76a4a.md new file mode 100644 index 000000000..a8df012e1 --- /dev/null +++ b/.changeset/a4c76a4a.md @@ -0,0 +1,18 @@ +--- +"aicodeman": minor +--- + +Added GitHub Copilot CLI (`copilot`, npm `@github/copilot`) to the stock CLI registry, +shipped **disabled by default** since it is new to the catalog. + +Also: enabling any CLI whose binary isn't installed yet now installs it automatically. +Previously a disabled entry's binary was never checked, and switching it on left the +operator to run its install command by hand. Now `PUT /api/clis/:id/enabled +{"enabled":true}` (what the settings UI's toggle calls) kicks off that entry's install +command in the background if needed, exposing progress as `installStatus` on both that +response and `GET /api/clis` (`{state: 'installing'|'success'|'error', command, message?}`); +the settings UI shows "Installing…" / "Install failed: …" inline and polls until it +resolves. This only ever runs as the direct result of that explicit API call, and the +command that runs is exactly the one already shown as the entry's install hint — see +`docs/cli-registry.md`'s "Enabling a CLI auto-installs it" section for the full trust +model. diff --git a/CLAUDE.md b/CLAUDE.md index 35e0addc9..60a80ace8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the b Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs. -**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev) and Grok Build (`grok`, xAI) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'`). +**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Ships stock support for Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev) and Grok Build (`grok`, xAI), but the set of CLI backends is **data, not code**: every one is a `CliEntry` in the CLI registry (`src/config/cli-registry/`, overrides in `~/.codeman/clis.json`), and `SessionMode` (`src/types/session.ts`) is a string id resolved against it rather than a fixed set of names. Adding, removing, or reconfiguring a CLI — including a custom one, e.g. GitHub Copilot CLI — needs no code change; see [`docs/cli-registry.md`](docs/cli-registry.md). **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. diff --git a/README.md b/README.md index 2a728ebcf..592098b62 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ codeman web The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation). -- **One dashboard, seven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) +- **One dashboard, any CLI** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions) — the set of CLIs is a config file, not a fixed list, so adding another agent CLI is a settings change, not a code change ([`docs/cli-registry.md`](docs/cli-registry.md)) - **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications - **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs - **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts @@ -437,7 +437,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials -- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, or **Grok** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md) +- **Multi-CLI, extensible** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, or **Grok** per session, or add your own (App Settings → Agents & CLIs, or edit `~/.codeman/clis.json` — see [`docs/cli-registry.md`](docs/cli-registry.md)); env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md) - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md) - **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md) - **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too diff --git a/config/clis.stock.json b/config/clis.stock.json new file mode 100644 index 000000000..82a6ce6dd --- /dev/null +++ b/config/clis.stock.json @@ -0,0 +1,192 @@ +[ + { + "id": "claude", + "label": "Claude", + "stock": true, + "discovery": { + "binaries": ["claude"], + "searchDirs": ["~/.local/bin", "~/.claude/local", "/usr/local/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)", + "retryOnTransientFailure": true + }, + "install": { + "command": { + "linux": "curl -fsSL https://claude.ai/install.sh | bash", + "darwin": "curl -fsSL https://claude.ai/install.sh | bash", + "wsl": "curl -fsSL https://claude.ai/install.sh | bash" + }, + "npmPackage": "@anthropic-ai/claude-code", + "docsUrl": "https://docs.claude.com/claude-code" + } + } + }, + { + "id": "shell", + "label": "Shell", + "stock": true, + "discovery": { + "binaries": [], + "searchDirs": [], + "install": { + "command": {} + } + } + }, + { + "id": "opencode", + "label": "OpenCode", + "stock": true, + "discovery": { + "binaries": ["opencode"], + "searchDirs": [ + "~/.opencode/bin", + "~/.local/bin", + "/usr/local/bin", + "~/go/bin", + "~/.bun/bin", + "~/.npm-global/bin", + "~/bin" + ], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "curl -fsSL https://opencode.ai/install | bash", + "darwin": "curl -fsSL https://opencode.ai/install | bash" + }, + "npmPackage": "opencode-ai", + "docsUrl": "https://opencode.ai/docs" + } + } + }, + { + "id": "codex", + "label": "Codex", + "stock": true, + "discovery": { + "binaries": ["codex"], + "searchDirs": ["~/.codex/bin", "~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "npm install -g @openai/codex", + "darwin": "npm install -g @openai/codex" + }, + "npmPackage": "@openai/codex", + "docsUrl": "https://developers.openai.com/codex/cli" + } + } + }, + { + "id": "gemini", + "label": "Gemini", + "stock": true, + "discovery": { + "binaries": ["gemini"], + "searchDirs": ["~/.gemini/bin", "~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "npm install -g @google/gemini-cli", + "darwin": "npm install -g @google/gemini-cli" + }, + "npmPackage": "@google/gemini-cli", + "docsUrl": "https://github.com/google-gemini/gemini-cli" + } + } + }, + { + "id": "antigravity", + "label": "Antigravity", + "stock": true, + "discovery": { + "binaries": ["agy"], + "searchDirs": ["~/.local/bin", "~/.antigravity/bin", "/usr/local/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "curl -fsSL https://antigravity.google/cli/install.sh | bash", + "darwin": "curl -fsSL https://antigravity.google/cli/install.sh | bash" + }, + "docsUrl": "https://antigravity.google/cli" + } + } + }, + { + "id": "pi", + "label": "Pi", + "stock": true, + "discovery": { + "binaries": ["pi"], + "searchDirs": ["~/.local/bin", "/usr/local/bin", "~/.bun/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(?:^|\\s)(\\d+\\.\\d+\\.\\d+)", + "requireVersionMatch": true + }, + "install": { + "command": { + "linux": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent", + "darwin": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent" + }, + "npmPackage": "@earendil-works/pi-coding-agent", + "docsUrl": "https://pi.dev" + } + } + }, + { + "id": "copilot", + "label": "GitHub Copilot", + "stock": true, + "discovery": { + "binaries": ["copilot"], + "searchDirs": ["~/.local/bin", "/usr/local/bin", "~/.npm-global/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(\\d+\\.\\d+\\.\\d+)" + }, + "install": { + "command": { + "linux": "npm install -g @github/copilot", + "darwin": "npm install -g @github/copilot" + }, + "npmPackage": "@github/copilot", + "docsUrl": "https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli" + } + } + }, + { + "id": "grok", + "label": "Grok", + "stock": true, + "discovery": { + "binaries": ["grok"], + "searchDirs": ["~/.grok/bin", "~/.local/bin", "/usr/local/bin", "~/bin"], + "version": { + "arg": "--version", + "regex": "(?:^|\\s)(\\d+\\.\\d+\\.\\d+)", + "requireVersionMatch": true + }, + "install": { + "command": { + "linux": "curl -fsSL https://x.ai/cli/install.sh | bash", + "darwin": "curl -fsSL https://x.ai/cli/install.sh | bash" + }, + "docsUrl": "https://github.com/xai-org/grok-build" + } + } + } +] diff --git a/docker/agent.Dockerfile b/docker/agent.Dockerfile index e81994806..d17170726 100644 --- a/docker/agent.Dockerfile +++ b/docker/agent.Dockerfile @@ -26,41 +26,50 @@ RUN apt-get update \ openssh-client \ && rm -rf /var/lib/apt/lists/* -# The npm-published agent CLIs. Pinning is left to the rebuild cadence (see -# docs/docker-cases-plan.md, user-decision 2). -RUN npm install -g \ - @anthropic-ai/claude-code \ - @openai/codex \ - @google/gemini-cli \ - opencode-ai \ +# The npm-published agent CLIs. Package list is a build ARG, populated by +# scripts/build-agent-image.mjs from the live CLI registry (config/cli-registry) — +# a new registry entry with a plain `npm install -g ` install command (the +# common case, e.g. a future GitHub Copilot CLI entry) is picked up here with NO +# Dockerfile edit. Defaults preserve today's four CLIs for a hand-run +# `docker build` that skips the wrapper script. Pinning is left to the rebuild +# cadence (see docs/docker-cases-plan.md, user-decision 2). +ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai" +RUN npm install -g ${CLI_NPM_PACKAGES} \ && npm cache clean --force -# Antigravity (`agy`) is NOT on npm — Google ships a standalone binary through its -# own installer, so it needs its own step. `--dir /usr/local/bin` is load-bearing: -# the installer's default target is `$HOME/.local/bin`, which at build time is -# root's home and would be unreachable by the `agent` user the container runs as. +# Antigravity (`agy`) has no npmPackage in the registry — it is NOT on npm, Google +# ships a standalone binary through its own installer — so it stays a documented +# Dockerfile special case rather than a generic npm-install line (the sanctioned +# per-CLI exception; see docs/cli-registry.md). `--dir /usr/local/bin` is +# load-bearing: the installer's default target is `$HOME/.local/bin`, which at +# build time is root's home and would be unreachable by the `agent` user the +# container runs as. # ⚠️ This binary is ~190MB on its own; it is the single largest layer in the image. -RUN curl -fsSL https://antigravity.google/cli/install.sh | bash -s -- --dir /usr/local/bin \ +ARG CLI_ANTIGRAVITY_INSTALL_URL="https://antigravity.google/cli/install.sh" +RUN curl -fsSL "${CLI_ANTIGRAVITY_INSTALL_URL}" | bash -s -- --dir /usr/local/bin \ && chmod 755 /usr/local/bin/agy \ && agy --version # Pi (pi.dev). Upstream documents --ignore-scripts (pi needs no lifecycle scripts); # kept out of the shared npm block above so the flag cannot silently change how the -# other four CLIs install. -RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent \ +# other four CLIs install. Package name is still a build ARG from the registry. +ARG CLI_PI_NPM_PACKAGE="@earendil-works/pi-coding-agent" +RUN npm install -g --ignore-scripts ${CLI_PI_NPM_PACKAGE} \ && npm cache clean --force \ && pi --version -# Grok Build (`grok`, xAI) is NOT on npm: a standalone ~160MB Rust binary through -# xAI's installer, which targets $HOME/.grok/bin with no --dir override. At build -# time that is root's home and unreachable by the `agent` user, so copy the binary -# into /usr/local/bin and drop root's ~/.grok in the same layer so the image does -# not carry the download twice. The staging cp -T is what makes this survive the -# installer's own behavior EITHER way: newer installers already symlink -# /usr/local/bin/grok -> /root/.grok/bin/grok, and a direct `cp -L` onto that -# symlink fails with "same file" (2026-08-24 rebuild), while removing the link -# first and copying fresh works for both old and new installers. -RUN curl -fsSL https://x.ai/cli/install.sh | bash \ +# Grok Build (`grok`, xAI) has no npmPackage in the registry — it is NOT on npm, a +# standalone ~160MB Rust binary through xAI's own installer, which targets +# $HOME/.grok/bin with no --dir override. At build time that is root's home and +# unreachable by the `agent` user, so copy the binary into /usr/local/bin and drop +# root's ~/.grok in the same layer so the image does not carry the download twice. +# The staging cp -T is what makes this survive the installer's own behavior EITHER +# way: newer installers already symlink /usr/local/bin/grok -> /root/.grok/bin/grok, +# and a direct `cp -L` onto that symlink fails with "same file" (2026-08-24 +# rebuild), while removing the link first and copying fresh works for both old and +# new installers. +ARG CLI_GROK_INSTALL_URL="https://x.ai/cli/install.sh" +RUN curl -fsSL "${CLI_GROK_INSTALL_URL}" | bash \ && cp -L /root/.grok/bin/grok /usr/local/bin/grok.real \ && rm -f /usr/local/bin/grok \ && mv /usr/local/bin/grok.real /usr/local/bin/grok \ diff --git a/docs/cli-registry.md b/docs/cli-registry.md new file mode 100644 index 000000000..b63f0ddc6 --- /dev/null +++ b/docs/cli-registry.md @@ -0,0 +1,153 @@ +# CLI Registry + +Codeman's set of supported CLI backends is **data, not code**. Every CLI — Claude Code, a +plain shell, OpenCode, Codex, Gemini, Antigravity, Pi, or one you add yourself — is a +`CliEntry` in a central registry. Nothing downstream branches on a CLI's name; it reads +capability flags instead. Adding GitHub Copilot CLI, or any other agent CLI, is a config +entry, not a code change. + +## Where it lives + +| Layer | File | Role | +| --- | --- | --- | +| Stock catalog | `src/config/cli-registry/stock.ts` | Compiled into the app. The seven shipped entries, byte-identical (via the argv engine) to what earlier hand-written builders produced. | +| User overrides | `~/.codeman/clis.json` (`dataPath('clis.json')`) | **Overrides and custom entries only** — never the full catalog. Small and readable by design. | +| install.sh export | `config/clis.stock.json` | A generated, install-time-only subset (id/label/discovery) of the stock catalog, fetched by `install.sh` before the repo is even cloned. Regenerate with `npm run generate:cli-stock-json`; `test/cli-stock-json-sync.test.ts` pins it in sync with `stock.ts`. | + +At load time (`src/config/cli-registry/registry.ts`), the stock catalog is deep-merged with +`~/.codeman/clis.json`: objects merge key-wise, **arrays replace wholesale** (a half-merged +`searchDirs` is not reasonable). A malformed **stock** override falls back to the pristine +stock definition rather than bricking a shipped CLI; a malformed **custom** entry is dropped +with a warning rather than failing the whole load. + +### The seeding ratchet + +`clis.json` tracks `seededStockIds` — the stock ids already introduced to this install. On +every load, any stock id not yet in that list is added, enabled, and appended to the list. +That is what lets a shipped update add a new stock CLI automatically while a CLI you +explicitly disabled stays disabled forever (its id is already seeded, so the ratchet never +touches it again). `shell` and `claude` can be disabled but never deleted. + +## Editing it + +- **Settings UI** (recommended): App Settings → Agents & CLIs → **Installed CLIs**. Enable, + disable, reorder, or add a custom entry. The add form uses conservative defaults — the + same profile as an unrecognized CLI: external agent, requires tmux, no hooks, no bypass + flag, buffered echo. +- **API**: `GET /api/clis` (full merged registry, plus live `available`/`path`/`version`/ + `installHint`/`installStatus` per entry), `PUT /api/clis/:id/enabled`, + `PUT /api/clis/order`, `POST /api/clis/:id` (add or replace a custom entry — refuses a + stock id), `DELETE /api/clis/:id` (refuses a stock id). All admin-gated in multi-user + mode. +- **Hand-editing `~/.codeman/clis.json`**: the loader validates on every read, so a syntax + or schema error degrades to a warning and the pristine/omitted entry, never a broken + server. + +### Enabling a CLI auto-installs it + +`~/.codeman/clis.json` deliberately does not care whether a **disabled** entry's binary is +even installed — that is the whole point of shipping GitHub Copilot CLI disabled by +default rather than leaving it out of the catalog entirely. The moment a CLI is switched +from disabled to enabled — via `PUT /api/clis/:id/enabled {"enabled":true}`, which is what +the settings UI's toggle calls — `src/config/cli-registry/cli-installer.ts`'s +`ensureCliInstalled` checks whether the binary is already resolvable and, if not, runs that +entry's `discovery.install.command` for the current platform in the background. Progress is +exposed as `installStatus` on both `GET /api/clis` and the `PUT .../enabled` response itself +(`{state: 'installing' | 'success' | 'error', command, message?}`); the settings UI polls +until it resolves and shows "Installing…" / "Install failed: …" inline. + +This is a deliberate, narrow exception to `discovery.install.command` otherwise being pure +display text (its own doc comment in `types.ts` used to say "NEVER executed by the +server" — now updated to point here). The trust model: + +- It only ever runs as the direct result of that one explicit API call — never on server + boot, a background registry reload, or any other implicit trigger. +- The command that runs is **exactly** the string already shown as that entry's + `installHint` — nothing is invented, combined with other input, or transformed. +- Enabling a CLI is already an admin-only action in multi-user mode, and in single-user + mode there is one trust level, the same one that can already add or edit any entry + (stock or custom) through this same settings surface. Running the install command that + same operator already saw and could have run by hand adds no new privilege. + +Under `VITEST` this is a silent no-op (same posture as `TmuxManager`'s `IS_TEST_MODE`) — the +test suite must never spawn a real, possibly network-dependent, possibly minutes-long +install command. + +## The shape of an entry (`CliEntry`) + +Full type definitions: `src/config/cli-registry/types.ts`. The top-level shape: + +```ts +interface CliEntry { + id: string; // e.g. "codex" — becomes the run-mode id everywhere + label: string; // "Codex" — shown in menus + shortBadge: string; // tab badge, e.g. "CX" + accent: string; // single hex colour; CSS derives every per-CLI gradient from it + enabled: boolean; + stock: boolean; // set by the loader; a custom entry can never claim it + order: number; + kind: 'agent' | 'shell'; + discovery: CliDiscovery; // how to find/probe the binary, and how to install it + launch: CliLaunch; // the structured argv template — see "Arg-template safety" below + env: CliEnv; // env var export/unset/allowlist/tmux-setenv-secret rules + capabilities: CliCapabilities; // the flags every call site reads instead of the id + overlays: CliOverlays; // remote-SSH / Docker command overrides, credential store +} +``` + +`capabilities` is the important part for anyone extending Codeman: it is what +`isExternalCliMode()`, `isAltScreenStripMode()`, `hooksAvailableForMode()`, and every other +per-mode branch actually read. A brand-new CLI added through the settings UI gets the +conservative defaults — the same shape as Pi, the mode with the fewest assumptions baked in. + +## Arg-template safety + +`launch` never contains shell text. The composed command line is interpolated into +`bash -c "…"` inside tmux, which makes command construction a security boundary, so every +entry is a structured argv spec instead of a string: + +- Every literal token is validated at load against a safe-word pattern (no space, quote, + backtick, `$`, `;`, `&`, `|`, redirection, parens, braces, newline, or backslash). A + literal that fails **rejects the whole entry** — a *flag* silently dropped would change + security-relevant behaviour (e.g. losing `--no-approve`). +- A value placeholder picks a **named** `TokenPattern` (`model`, `uuid`, `slug`, `tool-list`, + …) from `src/config/cli-registry/patterns.ts`; config can never supply its own regex for a + value, so there is no ReDoS surface there. The one config-supplied regex, + `discovery.version.regex`, is compiled through a nested-quantifier guard and run only + against `--version` output truncated to 200 chars. +- Rendering (`src/config/cli-registry/argv.ts`) escapes unconditionally and independently of + validation: a token is emitted verbatim only if it matches the safe-word pattern, and + single-quote-wrapped otherwise. This is what keeps a hostile model name or session name + from escaping into the shell even if a check upstream were ever bypassed. +- `test/cli-registry-argv-parity.test.ts` asserts the new engine's output is byte-identical + to the original hand-written builders for the stock catalog, and + `test/cli-registry-no-id-branching.test.ts` fails the build if a `mode === ''` + branch reappears anywhere outside `stock.ts`. + +## install.sh and the Docker agent image + +Both run **before** anything in this repo is necessarily built or even cloned, so neither +can import TypeScript: + +- **`install.sh`** fetches `config/clis.stock.json` from `raw.githubusercontent.com` + (derived from `$CODEMAN_REPO_URL`/`$CODEMAN_BRANCH`) and parses it with a plain `node -e` + once Node.js is confirmed installed. A fetch or parse failure falls back to a small + built-in JSON literal (Claude Code + OpenCode detection only) rather than aborting the + install. This drives CLI detection (`check_cli`/`get_cli_path`) and the "install one + later" hints generically — a CLI added to the stock catalog needs no `install.sh` change. +- **`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 entry with a + plain `npm install -g ` install command is picked up with no Dockerfile edit. A CLI + installed some other way (a standalone binary via curl, like Antigravity, or one needing + extra flags, like Pi's `--ignore-scripts`) stays a documented Dockerfile special case — + the sanctioned per-CLI exception, same as the prose notes in + [Agent CLIs](wiki/Agent-CLIs.md). + +## See also + +- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide (what each one is, + its own quirks, choosing between them). +- `docs/extending-codeman.md` — third-party integration surfaces, including `/api/clis`. +- `docs/architecture-invariants.md` — implementation mechanics and the history behind the + security-relevant rules above. diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index e2924499b..d4319c495 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -186,6 +186,16 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \ -d '{"input":"run the tests\r","useMux":true}' ``` +`mode` above is not a fixed enum — it is one of the ids in the CLI registry +(`GET /api/clis` lists every registered CLI, enabled or not, plus live +`available`/`path`/`version`/`installHint`/`installStatus` per entry, and `mode` +validation itself is built from the currently-ENABLED subset). The stock catalog ships +`claude`, `shell`, `opencode`, `codex`, `gemini`, `antigravity`, and `pi` enabled by +default, plus `copilot` (GitHub Copilot CLI) disabled by default, but an install can add, +remove, or toggle any entry through App Settings → Agents & CLIs, so an integration that +hardcodes that list will miss a custom CLI or a disabled one. See +[CLI Registry](cli-registry.md). + `POST .../input` also accepts `clientId` (stable per client, max 128 chars) and `seq` (monotonic per session). Send both and the server applies each pair at-most-once, so retrying after a dropped connection cannot type the prompt diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 6c34f274f..e4865a71f 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -1,23 +1,43 @@ # Agent CLIs -Codeman drives seven run modes: six agent CLIs plus a plain shell. This page covers picking -one, setting it up, and the differences that actually change how you work. +Codeman drives whatever CLI backends are registered — a plain shell plus a set of +agent CLIs. That set is **data, not code**: it lives in a central CLI registry +(`~/.codeman/clis.json`, layered over a shipped stock catalog), not in a hardcoded list +anywhere in the app. Enabling, disabling, reordering, or adding a CLI is a settings +change, never a code change. See [CLI Registry](CLI-Registry) for the schema and how to +add a CLI of your own. -## The seven modes +Out of the box the stock catalog ships eight entries. Six are enabled by default: | Mode | CLI | Get it | -| -------------------- | ---------------------------- | ---------------------------------------------------------------------- | -| **Claude Code** | `claude` | [docs.anthropic.com](https://docs.anthropic.com/en/docs/claude-code) | -| **OpenCode** | `opencode` | [opencode.ai](https://opencode.ai) | -| **Codex** | `codex` | [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli) | -| **Gemini** | `gemini` | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | -| **Antigravity** | `agy` | [antigravity.google](https://antigravity.google) | -| **Pi** | `pi` | [pi.dev](https://pi.dev) | -| **Terminal / Shell** | your `$SHELL` | Already installed. | - -Any combination works, including all of them. The run mode is chosen per session from the -arrow beside the **Run** button, so one case can have a Claude session and a Codex session -open side by side. +| -------------------- | ----------------------------- | ------------------------------------------------------------------------ | +| **Claude Code** | `claude` | [docs.anthropic.com](https://docs.anthropic.com/en/docs/claude-code) | +| **OpenCode** | `opencode` | [opencode.ai](https://opencode.ai) | +| **Codex** | `codex` | [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli) | +| **Gemini** | `gemini` | [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli) | +| **Antigravity** | `agy` | [antigravity.google](https://antigravity.google) | +| **Pi** | `pi` | [pi.dev](https://pi.dev) | +| **Terminal / Shell** | your `$SHELL` | Already installed. | + +Two ship **disabled** by default and need an explicit opt-in from Settings before they +appear anywhere: + +| Mode | CLI | Get it | +| -------------------- | ----------------------------- | ------------------------------------------------------------------------ | +| **GitHub Copilot** | `copilot` | [docs.github.com](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli) | + +Any combination works, including all of them, plus anything you add yourself. The run +mode is chosen per session from the arrow beside the **Run** button (built from +whichever CLIs are currently enabled), so one case can have a Claude session and a Codex +session open side by side. App Settings → Agents & CLIs → **Installed CLIs** is where you +enable, disable, reorder, or add a custom entry without touching a config file by hand. + +**Enabling a CLI whose binary isn't installed yet installs it for you.** A disabled entry +is never checked or cared about — its binary can be missing entirely, same as GitHub +Copilot CLI out of the box. The moment you flip it on, Codeman runs that CLI's install +command in the background (the same one shown as its "not found" hint) and the row reads +"Installing…" until it resolves. See [CLI Registry](CLI-Registry) for exactly when this +runs and why that is safe. ## Codeman does not manage your logins diff --git a/install.sh b/install.sh index e7e9a066e..1e0fb583b 100755 --- a/install.sh +++ b/install.sh @@ -76,70 +76,40 @@ TS_NEED_ROOT="0" # explicit caller override so contributors can still fetch the browser if needed. export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}" -# Claude CLI search paths (from src/utils/claude-cli-resolver.ts) -CLAUDE_SEARCH_PATHS=( - "$HOME/.local/bin/claude" - "$HOME/.claude/local/claude" - "/usr/local/bin/claude" - "$HOME/.npm-global/bin/claude" - "$HOME/bin/claude" -) - -# OpenCode CLI search paths (from src/utils/opencode-cli-resolver.ts) -OPENCODE_SEARCH_PATHS=( - "$HOME/.opencode/bin/opencode" - "$HOME/.local/bin/opencode" - "/usr/local/bin/opencode" - "$HOME/go/bin/opencode" - "$HOME/.bun/bin/opencode" - "$HOME/.npm-global/bin/opencode" - "$HOME/bin/opencode" -) - -# Codex CLI search paths (from src/utils/codex-cli-resolver.ts) -CODEX_SEARCH_PATHS=( - "$HOME/.codex/bin/codex" - "$HOME/.local/bin/codex" - "/usr/local/bin/codex" - "$HOME/.bun/bin/codex" - "$HOME/.npm-global/bin/codex" - "$HOME/bin/codex" -) - -# Gemini CLI search paths (from src/utils/gemini-cli-resolver.ts) -GEMINI_SEARCH_PATHS=( - "$HOME/.gemini/bin/gemini" - "$HOME/.local/bin/gemini" - "/usr/local/bin/gemini" - "$HOME/.bun/bin/gemini" - "$HOME/.npm-global/bin/gemini" - "$HOME/bin/gemini" -) - -# Pi CLI search paths (from src/utils/pi-cli-resolver.ts) -PI_SEARCH_PATHS=( - "$HOME/.local/bin/pi" - "/usr/local/bin/pi" - "$HOME/.bun/bin/pi" - "$HOME/.npm-global/bin/pi" - "$HOME/bin/pi" -) - -# Grok CLI search paths (from src/utils/grok-cli-resolver.ts) -GROK_SEARCH_PATHS=( - "$HOME/.grok/bin/grok" - "$HOME/.local/bin/grok" - "/usr/local/bin/grok" - "$HOME/bin/grok" -) - -# Antigravity CLI search paths (from src/utils/antigravity-cli-resolver.ts) -ANTIGRAVITY_SEARCH_PATHS=( - "$HOME/.local/bin/agy" - "$HOME/.antigravity/bin/agy" - "/usr/local/bin/agy" - "$HOME/bin/agy" -) +# ============================================================================ +# CLI registry (config/clis.stock.json) +# ============================================================================ +# +# Codeman's set of supported CLIs is data, not code (see docs/cli-registry.md): +# src/config/cli-registry/stock.ts is the single source of truth, and +# config/clis.stock.json is a generated, install.sh-only export of it (id, +# label, and discovery: binaries/searchDirs/install commands — never +# launch/capabilities, which are server-side concerns). Regenerate it with +# `npm run generate:cli-stock-json`; test/cli-stock-json-sync.test.ts pins the +# two in sync. +# +# This script runs standalone via `curl | bash`, BEFORE the repo is cloned or +# built, so it cannot import TypeScript (or even reach a git checkout) to +# learn what CLIs exist. load_cli_registry() (defined further down, after +# download_to_stdout) instead fetches that JSON export directly from +# raw.githubusercontent.com and parses it with `node -e` (Node.js is already +# installed by the time it runs — see the ensure_node call ordering). +CLI_IDS=() +CLI_LABELS=() +CLI_BINARIES=() # per id: comma-separated binary names, first hit wins +CLI_SEARCH_PATHS=() # per id: comma-separated search directories (~ expands to $HOME) +CLI_INSTALL_HINTS=() # per id: platform-appropriate "install with: ..." command, or "" + +# Minimal built-in fallback used only if the registry file cannot be fetched or +# fails to parse (offline install, or a custom CODEMAN_REPO_URL/CODEMAN_BRANCH +# pointing somewhere with no matching raw.githubusercontent.com URL). Kept +# intentionally small — Claude Code and OpenCode are the only two the +# interactive installer offers to install directly; every other CLI just loses +# its "found at ..." detection until the registry is reachable again. +CLI_REGISTRY_FALLBACK_JSON='[ + {"id":"claude","label":"Claude","discovery":{"binaries":["claude"],"searchDirs":["~/.local/bin","~/.claude/local","/usr/local/bin","~/.npm-global/bin","~/bin"],"install":{"command":{"linux":"curl -fsSL https://claude.ai/install.sh | bash","darwin":"curl -fsSL https://claude.ai/install.sh | bash"}}}}, + {"id":"opencode","label":"OpenCode","discovery":{"binaries":["opencode"],"searchDirs":["~/.opencode/bin","~/.local/bin","/usr/local/bin","~/go/bin","~/.bun/bin","~/.npm-global/bin","~/bin"],"install":{"command":{"linux":"curl -fsSL https://opencode.ai/install | bash","darwin":"curl -fsSL https://opencode.ai/install | bash"}}}} +]' # ============================================================================ # Color Output @@ -374,6 +344,60 @@ download_to_stdout() { fi } +# Only github.com repo URLs (https:// or git@) have a matching +# raw.githubusercontent.com host; a custom CODEMAN_REPO_URL pointing +# elsewhere has no known raw-file mirror and falls back to the embedded catalog. +cli_registry_raw_url() { + local url="$REPO_URL" + if [[ "$url" =~ ^https://github\.com/([^/]+)/([^/.]+)(\.git)?$ ]]; then + echo "https://raw.githubusercontent.com/${BASH_REMATCH[1]}/${BASH_REMATCH[2]}/${BRANCH}/config/clis.stock.json" + elif [[ "$url" =~ ^git@github\.com:([^/]+)/([^/.]+)(\.git)?$ ]]; then + echo "https://raw.githubusercontent.com/${BASH_REMATCH[1]}/${BASH_REMATCH[2]}/${BRANCH}/config/clis.stock.json" + fi +} + +# Fetches config/clis.stock.json and populates CLI_IDS/CLI_LABELS/CLI_BINARIES/ +# CLI_SEARCH_PATHS/CLI_INSTALL_HINTS (see the declarations above for the shape). +# Never fatal: a fetch or parse failure warns and falls back to +# CLI_REGISTRY_FALLBACK_JSON, so a network hiccup degrades to "detect fewer +# CLIs" rather than aborting the whole install. Requires `node` on PATH, so +# callers must run this only after Node.js is confirmed installed. +load_cli_registry() { + local json="" raw_url + raw_url=$(cli_registry_raw_url) + if [[ -n "$raw_url" ]]; then + json=$(download_to_stdout "$raw_url" 2>/dev/null || true) + fi + if [[ -z "$json" ]] || ! echo "$json" | node -e 'JSON.parse(require("fs").readFileSync(0,"utf8"))' &>/dev/null; then + if [[ -n "$json" ]]; then + warn "Could not fetch the CLI registry (config/clis.stock.json); using a built-in fallback (Claude Code + OpenCode detection only)." + fi + json="$CLI_REGISTRY_FALLBACK_JSON" + fi + + while IFS=$'\t' read -r id label bins dirs cmd; do + [[ -z "$id" ]] && continue + CLI_IDS+=("$id") + CLI_LABELS+=("$label") + CLI_BINARIES+=("$bins") + CLI_SEARCH_PATHS+=("$dirs") + CLI_INSTALL_HINTS+=("$cmd") + done < <(echo "$json" | node -e ' + const os = require("os"); + const platform = os.platform() === "darwin" ? "darwin" : "linux"; + let data; + try { data = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch { process.exit(0); } + for (const entry of data) { + const d = entry.discovery || {}; + const bins = (d.binaries || []).join(","); + if (!bins) continue; // e.g. "shell" — nothing to detect + const dirs = (d.searchDirs || []).join(","); + const cmd = (d.install && d.install.command && d.install.command[platform]) || ""; + console.log([entry.id, entry.label, bins, dirs, cmd].join("\t")); + } + ' 2>/dev/null) +} + # ============================================================================ # Dependency Checks # ============================================================================ @@ -404,208 +428,83 @@ check_tmux() { command -v tmux &>/dev/null } -check_claude() { - # Check PATH first - if command -v claude &>/dev/null; then - return 0 - fi - - # Check known install locations - for path in "${CLAUDE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 -} - -get_claude_path() { - if command -v claude &>/dev/null; then - command -v claude - return - fi - - for path in "${CLAUDE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_opencode() { - if command -v opencode &>/dev/null; then - return 0 - fi - - for path in "${OPENCODE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 -} - -get_opencode_path() { - if command -v opencode &>/dev/null; then - command -v opencode - return - fi - - for path in "${OPENCODE_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_codex() { - if command -v codex &>/dev/null; then - return 0 - fi - - for path in "${CODEX_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then +# Generic replacement for the old per-CLI check_claude/check_opencode/check_codex/ +# check_gemini/check_antigravity/check_pi pairs: driven entirely by the CLI_IDS/ +# CLI_BINARIES/CLI_SEARCH_PATHS arrays populated by load_cli_registry() from +# config/clis.stock.json, so adding a CLI to the registry needs no install.sh change. +# +# `pi` deserves the same caveat the old check_pi() carried: it is a short, generic +# name (Raspberry Pi tooling, personal scripts), so the server-side resolver +# additionally probes `pi --version`. Detection here only feeds the "you have no +# AI CLI" hint, so a plain executable test is enough. +cli_index() { + local id="$1" i + for i in "${!CLI_IDS[@]}"; do + if [[ "${CLI_IDS[$i]}" == "$id" ]]; then + echo "$i" return 0 fi done - return 1 } -get_codex_path() { - if command -v codex &>/dev/null; then - command -v codex - return - fi +check_cli() { + local id="$1" + local idx bin dir + idx=$(cli_index "$id") || return 1 - for path in "${CODEX_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi + IFS=',' read -ra bins <<< "${CLI_BINARIES[$idx]}" + for bin in "${bins[@]}"; do + command -v "$bin" &>/dev/null && return 0 done -} -check_gemini() { - if command -v gemini &>/dev/null; then - return 0 - fi - - for path in "${GEMINI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi + IFS=',' read -ra dirs <<< "${CLI_SEARCH_PATHS[$idx]}" + for dir in "${dirs[@]}"; do + dir="${dir/#\~/$HOME}" + for bin in "${bins[@]}"; do + [[ -x "$dir/$bin" ]] && return 0 + done done return 1 } -get_gemini_path() { - if command -v gemini &>/dev/null; then - command -v gemini - return - fi - - for path in "${GEMINI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -check_antigravity() { - if command -v agy &>/dev/null; then - return 0 - fi +get_cli_path() { + local id="$1" + local idx bin dir + idx=$(cli_index "$id") || return 1 - for path in "${ANTIGRAVITY_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then + IFS=',' read -ra bins <<< "${CLI_BINARIES[$idx]}" + for bin in "${bins[@]}"; do + if command -v "$bin" &>/dev/null; then + command -v "$bin" return 0 fi done - return 1 -} - -get_antigravity_path() { - if command -v agy &>/dev/null; then - command -v agy - return - fi - - for path in "${ANTIGRAVITY_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -# `pi` is a short, generic name (Raspberry Pi tooling, personal scripts), so the -# server-side resolver additionally probes `pi --version`. Detection here only feeds -# the "you have no AI CLI" hint, so a plain executable test is enough. -check_pi() { - if command -v pi &>/dev/null; then - return 0 - fi - - for path in "${PI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi + IFS=',' read -ra dirs <<< "${CLI_SEARCH_PATHS[$idx]}" + for dir in "${dirs[@]}"; do + dir="${dir/#\~/$HOME}" + for bin in "${bins[@]}"; do + if [[ -x "$dir/$bin" ]]; then + echo "$dir/$bin" + return 0 + fi + done done - return 1 } -get_pi_path() { - if command -v pi &>/dev/null; then - command -v pi - return - fi - - for path in "${PI_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done -} - -# `grok` has known squatters too (the unrelated @vibe-kit/grok-cli), so the -# server-side resolver additionally probes `grok --version`. Detection here only -# feeds the "you have no AI CLI" hint, so a plain executable test is enough. -check_grok() { - if command -v grok &>/dev/null; then - return 0 - fi - - for path in "${GROK_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - return 0 - fi - done - - return 1 +cli_label() { + local idx + idx=$(cli_index "$1") || { echo "$1"; return 0; } + echo "${CLI_LABELS[$idx]}" } -get_grok_path() { - if command -v grok &>/dev/null; then - command -v grok - return - fi - - for path in "${GROK_SEARCH_PATHS[@]}"; do - if [[ -x "$path" ]]; then - echo "$path" - return - fi - done +cli_install_hint() { + local idx + idx=$(cli_index "$1") || { echo ""; return 0; } + echo "${CLI_INSTALL_HINTS[$idx]}" } check_cloudflared() { @@ -2115,57 +2014,40 @@ main() { fi fi - # AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi) - local has_claude=false - local has_opencode=false - local has_codex=false - local has_gemini=false - local has_antigravity=false - local has_pi=false - local has_grok=false + # AI CLI — Codeman drives whichever CLI backend a session uses; the full, + # user-extensible set lives in the registry (config/clis.stock.json / + # docs/cli-registry.md), never hardcoded here. load_cli_registry() needs + # `node`, confirmed installed above. + load_cli_registry + declare -A has_cli=() + local id any_cli_found="false" cli_list_human="" info "Checking AI CLI tools..." - if check_claude; then - has_claude=true - success "Claude Code found at $(get_claude_path)" - fi - if check_opencode; then - has_opencode=true - success "OpenCode found at $(get_opencode_path)" - fi - if check_codex; then - has_codex=true - success "Codex found at $(get_codex_path)" - fi - if check_gemini; then - has_gemini=true - success "Gemini CLI found at $(get_gemini_path)" - fi - if check_antigravity; then - has_antigravity=true - success "Antigravity CLI found at $(get_antigravity_path)" - fi - if check_pi; then - has_pi=true - success "Pi CLI found at $(get_pi_path)" - fi - if check_grok; then - has_grok=true - success "Grok CLI found at $(get_grok_path)" - fi + for id in "${CLI_IDS[@]}"; do + if check_cli "$id"; then + has_cli[$id]="true" + any_cli_found="true" + success "$(cli_label "$id") found at $(get_cli_path "$id")" + fi + cli_list_human+="$(cli_label "$id"), " + done + cli_list_human="${cli_list_human%, }" - if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" ]]; then + if [[ "$any_cli_found" == "false" ]]; then echo "" - warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok." + warn "No AI CLI found. Codeman needs at least one: $cli_list_human." headless_guard "install an AI CLI (curl | bash from its vendor)" echo "" echo -e " ${BOLD}Which AI CLI would you like to install?${NC}" echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)" echo -e " ${CYAN}2)${NC} OpenCode (open-source)" echo -e " ${CYAN}3)${NC} Both" - echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Pi or Grok)" + echo -e " ${CYAN}4)${NC} Skip (I'll install one myself — see the list below)" echo "" + # Only Claude Code and OpenCode get a first-class interactive installer + # here (a well-known, unattended `curl | bash` one-liner each); every + # other registry entry is a hint only, shown below on Skip. local cli_choice="" if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then # Explicit automation opt-in: default to Claude Code @@ -2186,9 +2068,9 @@ main() { info "Installing Claude Code CLI..." download_to_stdout https://claude.ai/install.sh | bash hash -r 2>/dev/null || true - if check_claude; then - has_claude=true - success "Claude Code installed at $(get_claude_path)" + if check_cli claude; then + has_cli[claude]="true" + success "Claude Code installed at $(get_cli_path claude)" else warn "Claude Code installation failed." fi @@ -2198,9 +2080,9 @@ main() { info "Installing OpenCode CLI..." download_to_stdout https://opencode.ai/install | bash hash -r 2>/dev/null || true - if check_opencode; then - has_opencode=true - success "OpenCode installed at $(get_opencode_path)" + if check_cli opencode; then + has_cli[opencode]="true" + success "OpenCode installed at $(get_cli_path opencode)" else warn "OpenCode installation failed." fi @@ -2208,11 +2090,13 @@ main() { if [[ "$cli_choice" == "4" ]]; then warn "Skipping AI CLI install. Codeman will run, but sessions need a CLI to drive." - info "Install one later, e.g.: npm install -g @openai/codex (Codex)" - info " or: curl -fsSL https://antigravity.google/cli/install.sh | bash (Antigravity)" - info " or: npm install -g --ignore-scripts @earendil-works/pi-coding-agent (Pi)" - info " or: curl -fsSL https://x.ai/cli/install.sh | bash (Grok)" - elif [[ "$has_claude" == "false" ]] && [[ "$has_opencode" == "false" ]]; then + for id in "${CLI_IDS[@]}"; do + [[ "$id" == "claude" || "$id" == "opencode" ]] && continue + local hint + hint=$(cli_install_hint "$id") + [[ -n "$hint" ]] && info "Install one later, e.g.: $hint ($(cli_label "$id"))" + done + elif [[ "${has_cli[claude]:-false}" == "false" ]] && [[ "${has_cli[opencode]:-false}" == "false" ]]; then die "The selected AI CLI failed to install. Install one manually and re-run the installer." fi fi @@ -2512,14 +2396,17 @@ main() { echo -e " https://github.com/Ark0N/Codeman" echo "" - if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok; then + local any_cli_installed="false" + for id in "${CLI_IDS[@]}"; do + check_cli "$id" && any_cli_installed="true" + done + if [[ "$any_cli_installed" == "false" ]]; then echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:" - echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code" - echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode" - echo -e " ${CYAN}npm install -g @openai/codex${NC} # Codex" - echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity" - echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi" - echo -e " ${CYAN}curl -fsSL https://x.ai/cli/install.sh | bash${NC} # Grok" + for id in "${CLI_IDS[@]}"; do + local hint + hint=$(cli_install_hint "$id") + [[ -n "$hint" ]] && echo -e " ${CYAN}${hint}${NC} # $(cli_label "$id")" + done echo "" fi @@ -2553,6 +2440,15 @@ update() { die "Codeman is not installed at $INSTALL_DIR. Run the installer first." fi + # The CLI registry (~/.codeman/clis.json) lives OUTSIDE $INSTALL_DIR + # (which is $HOME/.codeman/app, a git checkout) at $HOME/.codeman directly, + # so this function's git reset/npm install/npm run build below can never + # touch it. That is what makes "install.sh preserves it across updates" + # true by construction rather than by an explicit backup step here — there + # is exactly one writer (the app itself, via src/config/cli-registry/registry.ts), + # and this script is not it. + local cli_registry_file="$HOME/.codeman/clis.json" + info "Updating Codeman..." cd "$INSTALL_DIR" git remote set-url origin "$REPO_URL" 2>/dev/null || true @@ -2577,6 +2473,9 @@ update() { npm run build --quiet 2>/dev/null || npm run build date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete" success "Updated to $(node -e "console.log(require('./package.json').version)")" + if [[ -f "$cli_registry_file" ]]; then + success "Your CLI registry customizations ($cli_registry_file) were preserved." + fi echo "" # Auto-restart service if running, otherwise tell the user diff --git a/package.json b/package.json index 39f3790f0..4c858b329 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "scripts": { "postinstall": "node scripts/postinstall.js", "build": "node scripts/build.mjs", + "generate:cli-stock-json": "tsx scripts/generate-cli-stock-json.mjs", "build:gesture": "node scripts/build-gesture-bundle.mjs", "start": "NODE_COMPILE_CACHE=${HOME}/.codeman/compile-cache node dist/index.js", "dev": "tsx src/index.ts web", diff --git a/scripts/build-agent-image.mjs b/scripts/build-agent-image.mjs index 00602d24d..e6f6be07a 100644 --- a/scripts/build-agent-image.mjs +++ b/scripts/build-agent-image.mjs @@ -12,6 +12,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { readFileSync } from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, '..'); @@ -55,9 +56,42 @@ if (args.help) { process.exit(0); } +// The stock catalog's install-command shape, mirroring config/clis.stock.json +// (see docs/cli-registry.md) — read directly rather than via tsx/ts-node so this +// script has no extra runtime dependency. A registry entry with a plain +// `npm install -g ` install command joins the shared npm-install ARG +// automatically; anything else (a curl installer, --ignore-scripts, no npm +// package at all) stays a documented Dockerfile special case, same as +// Antigravity and Pi today. +function cliNpmPackages() { + const stockPath = join(REPO_ROOT, 'config', 'clis.stock.json'); + let entries; + try { + entries = JSON.parse(readFileSync(stockPath, 'utf8')); + } catch (err) { + console.warn(`[build-agent-image] could not read ${stockPath} (${err.message}); using the Dockerfile's built-in defaults`); + return null; + } + const packages = entries + .filter((e) => e.id !== 'pi') // pi needs --ignore-scripts, handled by its own ARG below + .map((e) => e.discovery?.install?.npmPackage) + .filter((pkg) => typeof pkg === 'string' && pkg.length > 0); + const pi = entries.find((e) => e.id === 'pi')?.discovery?.install?.npmPackage; + return { packages, pi }; +} + const engine = resolveEngine(args.engine); const buildArgs = ['build', '-f', DOCKERFILE, '-t', args.image]; if (args.noCache) buildArgs.push('--no-cache'); + +const cliPkgs = cliNpmPackages(); +if (cliPkgs && cliPkgs.packages.length > 0) { + buildArgs.push('--build-arg', `CLI_NPM_PACKAGES=${cliPkgs.packages.join(' ')}`); +} +if (cliPkgs && cliPkgs.pi) { + buildArgs.push('--build-arg', `CLI_PI_NPM_PACKAGE=${cliPkgs.pi}`); +} + buildArgs.push(REPO_ROOT); console.log(`[build-agent-image] ${engine} ${buildArgs.join(' ')}`); diff --git a/scripts/generate-cli-stock-json.mjs b/scripts/generate-cli-stock-json.mjs new file mode 100644 index 000000000..cef1b00e3 --- /dev/null +++ b/scripts/generate-cli-stock-json.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * @fileoverview Regenerates `config/clis.stock.json` from the compiled-in stock CLI + * catalog (`src/config/cli-registry/stock.ts`), which stays the single source of truth. + * + * This JSON export exists for ONE consumer: `install.sh`, which runs standalone via + * `curl | bash` BEFORE the repo is cloned or built, so it cannot import TypeScript (or + * even reach a git checkout) to learn what CLIs exist, where to look for them, or how to + * install them. Its own copy is fetched over the network (same raw-file pattern the + * installer already uses for itself) and parsed with a plain `node -e`/`JSON.parse` — no + * ts-node/tsx dependency at install time. + * + * Only the fields install.sh actually needs are exported (id, label, stock flag, and + * `discovery`: binaries/searchDirs/install commands) — never `launch`/`capabilities`, + * which are launch-time concerns the server alone interprets. + * + * `test/cli-stock-json-sync.test.ts` pins this file in sync with stock.ts, the same + * pattern as `test/sse-registry-parity.test.ts` for the SSE event tables. Run + * `npm run generate:cli-stock-json` after editing stock.ts and commit the result. + */ +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const { STOCK_CLIS } = await import('../src/config/cli-registry/stock.ts'); + +const out = STOCK_CLIS.map((entry) => ({ + id: entry.id, + label: entry.label, + stock: true, + discovery: entry.discovery, +})); + +const outPath = resolve(here, '../config/clis.stock.json'); +writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n', 'utf8'); +console.log(`Wrote ${outPath} (${out.length} entries)`); diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 015ad9b99..7cbcdd2fd 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -343,13 +343,14 @@ on the user's disk) if missing, do not retry it in a loop, and remember the name ⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with `OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, -`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status` +`GET /api/v1/codex/status`, `GET /api/v1/gemini/status` and `GET /api/v1/antigravity/status` and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed). -Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and -`grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either: -the resolver rejects one whose `--version` is not version-shaped, so `available:false` -there can mean "a different `pi`/`grok` is in front" rather than "nothing is installed". -`shell` has no CLI to probe. +`grok` has no legacy per-mode alias — use the generic `GET /api/v1/cli/grok/status` +instead, same response shape. Pi's and grok's both also carry `.data.version`, because +`pi`/`grok` are short generic names an unrelated binary on `$PATH` can shadow: the +resolver rejects one whose `--version` is not semver-shaped, so `available:false` there +can mean "a different `pi`/`grok` is in front" rather than "nothing is installed". `shell` +has no CLI to probe. ⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field is absent, `jq -r` prints the literal string `null`, and every later call then targets @@ -463,9 +464,10 @@ Quirks that will bite you: session answers with an empty timeline rather than a 404. - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser, which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` - returns early for every external CLI mode (`session.ts:2261`), so it is permanently + returns early for every external CLI mode (`session.ts:2227`), so it is permanently `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`. ⚠️ **`shell` is NOT one of those** - (`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does + (`isExternalCliMode`, `session.ts:180`, reads the CLI registry's `capabilities.external` + flag rather than a hardcoded list, and `shell`'s entry has it `false`), so the parser does run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches bare `tail|cat|head|less|grep|watch|multitail ` lines with no `● Bash(` wrapper: a shell worker running `cat build.log` really does populate this. In practice it stays diff --git a/src/config/cli-registry/argv.ts b/src/config/cli-registry/argv.ts new file mode 100644 index 000000000..ddbc88b90 --- /dev/null +++ b/src/config/cli-registry/argv.ts @@ -0,0 +1,190 @@ +/** + * @fileoverview The argv rendering engine — turns a `CliLaunch` spec plus a set of resolved + * parameter values into the shell command string that goes into `bash -c "..."`. + * + * SECURITY MODEL (read before touching this file): + * + * 1. Config contains no shell text. There is no `command: "..."` field anywhere in the + * schema. An entry declares a sequence of typed tokens (`ArgSpec`); this module is the + * ONLY place that turns them into a string, and it owns every separator itself: a single + * space between tokens, and ` || ` between fallback variants. Neither can originate from + * config, because config has no field that could hold either. + * 2. Every literal (`lit`, `flag`, `value`) is validated against `SAFE_BARE_TOKEN` — no + * space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens, braces, newline or + * backslash — at LOAD time (see schema.ts), so a bad literal fails registry validation + * rather than reaching this renderer. + * 3. Every `valueFrom` resolves through a declared `ParamSpec`, whose `token` variant names + * a PATTERN rather than accepting one — see patterns.ts. A value that fails its pattern + * causes the WHOLE ArgSpec to be dropped, exactly like the hand-written builders this + * replaces (an invalid `--model` value silently omits `--model`, it does not substitute + * something else). + * 4. Escaping and validation are independent. `renderToken()` always re-checks the resolved + * value against `SAFE_BARE_TOKEN` before emitting it unquoted; anything else is + * single-quote-escaped. So even a value that somehow bypassed pattern validation is still + * quoted, never concatenated raw. + * + * @module config/cli-registry/argv + */ + +import type { ArgSpec, CliEntry, CliLaunch, Cond, EngineValue, ParamSpec, QuoteStyle } from './types.js'; +import { matchesPattern } from './patterns.js'; +import { SAFE_BARE_TOKEN } from './patterns.js'; + +/** Resolved parameter values, keyed by the name declared in `CliLaunch.params`. */ +export type ParamValues = Record; + +/** Values the caller supplies for the reserved engine params. */ +export type EngineValues = Partial>; + +/** + * POSIX single-quote escaping: end-quote, escaped-literal-quote, restart-quote. Identical in + * shape to the three copies already in the codebase (tmux-manager.ts, remote-hosts.ts, + * docker-hosts.ts) — kept local rather than importing one of them so this module has no + * dependency on the files it is replacing. + */ +function singleQuoteEscape(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function doubleQuoteEscape(value: string): string { + // Escape the characters that are special inside a double-quoted bash string. SAFE_BARE_TOKEN + // already excludes all of them, so in practice this never fires; kept as defense in depth. + return `"${value.replace(/([$`"\\])/g, '\\$1')}"`; +} + +/** + * Render a single resolved value per its requested quote style. `auto` (the default) emits + * bare only when the value is provably safe; every other case single-quotes. + */ +function renderToken(value: string, style: QuoteStyle | undefined): string { + const safe = SAFE_BARE_TOKEN.test(value); + switch (style) { + case 'double': + return doubleQuoteEscape(value); + case 'single': + return singleQuoteEscape(value); + case 'bare': + return safe ? value : singleQuoteEscape(value); + case 'auto': + default: + return safe ? value : singleQuoteEscape(value); + } +} + +/** Resolve one parameter to a plain string, or undefined if it is unset / invalid. */ +function resolveParam( + name: string, + spec: ParamSpec | undefined, + params: ParamValues, + engineValues: EngineValues +): string | undefined { + if (!spec) return undefined; + if (spec.type === 'engine') return engineValues[spec.source]; + + const raw = params[name]; + if (raw === undefined) return spec.type === 'enum' ? spec.default : undefined; + + if (spec.type === 'bool') return typeof raw === 'boolean' ? String(raw) : undefined; + if (spec.type === 'enum') { + const s = String(raw); + return spec.values.includes(s) ? s : spec.default; + } + // token + const s = String(raw); + return matchesPattern(spec.pattern, s) ? s : undefined; +} + +/** Is the resolved value "set" for the purposes of a `state` condition? */ +function isSet(name: string, params: ParamValues, resolved: (n: string) => string | undefined): boolean { + if (name in params) { + const raw = params[name]; + if (typeof raw === 'boolean') return true; // a bool param is always "set" once declared + } + return resolved(name) !== undefined; +} + +function evalCond( + cond: Cond | undefined, + params: ParamValues, + resolved: (n: string) => string | undefined, + gatesPassed: ReadonlySet +): boolean { + if (!cond) return true; + if ('allOf' in cond) return cond.allOf.every((c) => evalCond(c, params, resolved, gatesPassed)); + if ('anyOf' in cond) return cond.anyOf.some((c) => evalCond(c, params, resolved, gatesPassed)); + if ('not' in cond) return !evalCond(cond.not, params, resolved, gatesPassed); + if ('capabilityGate' in cond) return gatesPassed.has(cond.capabilityGate); + if ('state' in cond) { + const set = isSet(cond.param, params, resolved); + return cond.state === 'set' ? set : !set; + } + // { param, is } + const raw = params[cond.param]; + if (typeof cond.is === 'boolean') return raw === cond.is; + return resolved(cond.param) === cond.is; +} + +function renderArg( + spec: ArgSpec, + params: ParamValues, + resolved: (n: string) => string | undefined, + gatesPassed: ReadonlySet +): string | null { + if (!evalCond(spec.when, params, resolved, gatesPassed)) return null; + + if ('lit' in spec) return spec.lit; + if ('flag' in spec && !('value' in spec) && !('valueFrom' in spec)) return spec.flag; + if ('flag' in spec && 'value' in spec) return `${spec.flag} ${renderToken(spec.value, spec.quote)}`; + if ('flag' in spec && 'valueFrom' in spec) { + const v = resolved(spec.valueFrom); + return v === undefined ? null : `${spec.flag} ${renderToken(v, spec.quote)}`; + } + // bare positional + const v = resolved((spec as { valueFrom: string }).valueFrom); + return v === undefined ? null : renderToken(v, (spec as { quote?: QuoteStyle }).quote); +} + +/** + * Render one CLI's launch command. Returns the full `bash -c` payload — never a shell + * fragment with embedded newlines or unescaped separators, by construction (see file header). + * + * `gatesPassed` — the set of `capabilities.gates` keys whose version requirement is + * currently satisfied. Callers compute this once per spawn (it depends on a version probe), + * never inside the renderer, keeping this function pure and easy to test byte-for-byte. + */ +export function renderLaunch( + launch: CliLaunch, + params: ParamValues, + engineValues: EngineValues, + gatesPassed: ReadonlySet = new Set() +): string { + const cache = new Map(); + const resolved = (name: string): string | undefined => { + if (cache.has(name)) return cache.get(name); + const v = resolveParam(name, launch.params[name], params, engineValues); + cache.set(name, v); + return v; + }; + + const passing = launch.variants.filter((variant) => evalCond(variant.when, params, resolved, gatesPassed)); + const chosen = launch.chain === 'fallback' ? passing : passing.slice(0, 1); + + const rendered = chosen.map((variant) => + variant.args + .map((arg) => renderArg(arg, params, resolved, gatesPassed)) + .filter((tok): tok is string => tok !== null) + .join(' ') + ); + + return rendered.join(' || '); +} + +/** Convenience: render an entry's launch command straight from a `CliEntry`. */ +export function renderCliCommand( + entry: CliEntry, + params: ParamValues, + engineValues: EngineValues, + gatesPassed?: ReadonlySet +): string { + return renderLaunch(entry.launch, params, engineValues, gatesPassed); +} diff --git a/src/config/cli-registry/cli-installer.ts b/src/config/cli-registry/cli-installer.ts new file mode 100644 index 000000000..1d0367d21 --- /dev/null +++ b/src/config/cli-registry/cli-installer.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview Auto-installs a CLI's binary the moment it is explicitly ENABLED through the + * registry write API — closes the gap where a CLI entry can sit in the registry disabled + * (its binary possibly never installed, since a disabled entry's availability is never + * checked or cared about) and, once switched on, previously left the operator to go run its + * install command by hand before the toggle did anything useful. + * + * Security model — this is a deliberate, narrow exception to a rule stated elsewhere in this + * package: `discovery.install.command` used to be pure display text ("Shown verbatim in + * 'CLI not found. Install with: ...'. NEVER executed by the server" — see the history in + * types.ts's `CliDiscovery` doc comment, now updated to describe this module instead of + * contradicting it). `ensureCliInstalled` is the ONE place that command is ever actually run, + * and only when: + * - Called from the `PUT /api/clis/:id/enabled` route with `enabled: true` — an explicit + * admin action (multi-user mode is admin-gated at the route; single-user mode has one + * trust level, the same one that can already add/edit/remove any CLI entry, stock or + * custom, through this same settings surface). + * - NEVER from server boot, a background registry reload, or any other implicit trigger. + * - The exact command already shown as that entry's `installHint` in the settings UI + * BEFORE the toggle was flipped — nothing is invented, combined, or transformed here. + * A custom CLI's install command is therefore executed with the same trust as the admin who + * typed it into the "Add CLI" form in the first place; this module adds no NEW privilege, + * it just removes the extra manual step of running that same command themselves. + * + * @module config/cli-registry/cli-installer + */ + +import { spawn } from 'node:child_process'; +import { getCli, resolveInstallCommandForPlatform } from './registry.js'; +import { invalidateCliBinDirCache, resolveCliBinDir } from '../../utils/cli-resolver.js'; + +export type CliInstallState = 'installing' | 'success' | 'error'; + +export interface CliInstallStatus { + state: CliInstallState; + /** The exact command that ran (or is running) — never re-derived from anything else. */ + command?: string; + /** Set on `error` only: exit code plus a bounded tail of combined stdout+stderr. */ + message?: string; + startedAt?: number; + finishedAt?: number; +} + +/** + * Install commands can be slow (a ~190MB standalone binary download, a cold npm registry) — + * default 10 minutes, overridable for an unusually constrained network. Bounded 30s-1h so a + * misconfigured value cannot make this hang the process forever or fire so fast it can never + * succeed. + */ +function installTimeoutMs(): number { + const raw = Number(process.env.CODEMAN_CLI_INSTALL_TIMEOUT_MS); + if (!Number.isFinite(raw) || raw <= 0) return 600_000; + return Math.min(Math.max(raw, 30_000), 3_600_000); +} + +const _status = new Map(); + +export function getCliInstallStatus(id: string): CliInstallStatus | undefined { + return _status.get(id); +} + +/** Test-only: reset all tracked install status between tests. */ +export function _resetCliInstallStatusForTest(): void { + _status.clear(); +} + +/** + * Ensure `id`'s binary is installed, installing it in the background if it is not already + * present and no install is already in flight for it. Fire-and-forget by design — the + * calling route returns immediately with whatever status this synchronously set before the + * child process resolves; callers observe progress via `getCliInstallStatus` (surfaced in + * `GET /api/clis` as each entry's `installStatus`) rather than blocking on it, since an + * install can run for minutes and a PUT request must not hang that long. + */ +export function ensureCliInstalled(id: string): void { + const existing = _status.get(id); + if (existing?.state === 'installing') return; // already in flight — don't double-spawn + + const entry = getCli(id); + if (!entry || entry.discovery.binaries.length === 0) return; // unknown id, or e.g. "shell" + + if (resolveCliBinDir(id)) { + _status.set(id, { state: 'success', finishedAt: Date.now() }); + return; // already installed — nothing to do + } + + const command = resolveInstallCommandForPlatform(entry); + if (!command) { + _status.set(id, { state: 'error', message: 'No install command declared for this platform.' }); + return; + } + + // Same posture as TmuxManager's IS_TEST_MODE (src/tmux-manager.ts): the test suite must + // never spawn a real install command (network access, minutes-long, non-deterministic + // across machines/CI). This is deliberately a SILENT no-op, not a fake success/error + // status, so `_status` stays exactly as it was before this call and a test can tell the + // two apart. Route/unit tests that need to exercise the actual spawn/timeout/output-tail + // logic mock `node:child_process` themselves (see cli-installer.test.ts) — this guard is + // defense-in-depth for every OTHER test that merely enables a CLI in passing. + if (process.env.VITEST) return; + + _status.set(id, { state: 'installing', command, startedAt: Date.now() }); + + let child; + try { + // `shell: true` is required — install commands are pipelines (`curl ... | bash`), not + // a single argv, exactly like install.sh's own `download_to_stdout url | bash` and + // node-pty's own historical resolution. This is the same trust boundary described in + // the file header, not a new one: the string that runs here is byte-identical to the + // installHint an admin already saw and to what install.sh runs for the same CLI. + child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + _status.set(id, { state: 'error', command, message: (err as Error).message, finishedAt: Date.now() }); + return; + } + + let output = ''; + const OUTPUT_TAIL_BYTES = 4000; + const appendOutput = (chunk: Buffer) => { + output += chunk.toString('utf-8'); + if (output.length > OUTPUT_TAIL_BYTES) output = output.slice(-OUTPUT_TAIL_BYTES); + }; + child.stdout?.on('data', appendOutput); + child.stderr?.on('data', appendOutput); + + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, installTimeoutMs()); + timer.unref?.(); // never keep the process alive on this alone + + child.on('error', (err) => { + clearTimeout(timer); + _status.set(id, { state: 'error', command, message: err.message, finishedAt: Date.now() }); + }); + + child.on('close', (code) => { + clearTimeout(timer); + // The install command is the ground truth for "did it work", not just its exit code: + // re-probe PATH/search-dirs afterward, and invalidate the memoized resolver first (it + // caches a negative result forever otherwise — see invalidateCliBinDirCache's own doc). + invalidateCliBinDirCache(id); + const nowAvailable = resolveCliBinDir(id) !== null; + if (code === 0 && nowAvailable) { + _status.set(id, { state: 'success', command, finishedAt: Date.now() }); + } else { + const tail = output.trim().slice(-500); + _status.set(id, { + state: 'error', + command, + message: `Install command exited ${code ?? 'unknown'}.${tail ? ` ${tail}` : ''}`, + finishedAt: Date.now(), + }); + } + }); +} diff --git a/src/config/cli-registry/index.ts b/src/config/cli-registry/index.ts new file mode 100644 index 000000000..f8d1d8d00 --- /dev/null +++ b/src/config/cli-registry/index.ts @@ -0,0 +1,47 @@ +/** + * @fileoverview Barrel for the CLI registry module. + * @module config/cli-registry + */ + +export type { + ArgSpec, + CliCapabilities, + CliCredStore, + CliDiscovery, + CliEntry, + CliEnv, + CliId, + CliLaunch, + CliOverlays, + CliRegistryFile, + CliVariant, + CliVersionProbe, + Cond, + EngineValue, + ParamSpec, + QuoteStyle, +} from './types.js'; +export { + matchesPattern, + TOKEN_PATTERNS, + SAFE_BARE_TOKEN, + compileVersionRegex, + MAX_VERSION_OUTPUT, +} from './patterns.js'; +export type { TokenPattern } from './patterns.js'; +export { renderLaunch, renderCliCommand } from './argv.js'; +export type { EngineValues, ParamValues } from './argv.js'; +export { CliEntrySchema } from './schema.js'; +export type { ValidatedCliEntry } from './schema.js'; +export { STOCK_CLIS } from './stock.js'; +export { + asCliId, + cliIds, + enabledClis, + getCli, + listClis, + loadCliRegistry, + reloadCliRegistry, + resolveRegistry, +} from './registry.js'; +export { PREDICT_PROFILES, isKnownPredictProfile, TRANSCRIPT_READER_NAMES, COMPOSER_ANCHOR_KINDS } from './profiles.js'; diff --git a/src/config/cli-registry/patterns.ts b/src/config/cli-registry/patterns.ts new file mode 100644 index 000000000..84e8983e6 --- /dev/null +++ b/src/config/cli-registry/patterns.ts @@ -0,0 +1,115 @@ +/** + * @fileoverview Named value patterns for the CLI registry's argv engine. + * + * Config entries select a pattern BY NAME; the regexes themselves live here, in code. + * That is deliberate and is the reason a user-editable `clis.json` cannot widen its own + * validation: there is no field anywhere in the schema that accepts a raw regex for a + * shell token, so no entry can supply `.*` (nor a catastrophically backtracking one). + * + * The sole user-supplied regex in the whole registry is `discovery.version.regex`, which + * is applied to `--version` OUTPUT rather than to a shell token, and goes through + * `compileVersionRegex()` below. + * + * Every pattern here is transcribed from the builder it replaces in tmux-manager.ts, so + * the argv engine accepts and rejects exactly the values the hand-written builders did. + * + * @module config/cli-registry/patterns + */ + +/** Names a value pattern. Config may only reference these. */ +export type TokenPattern = + | 'model' + | 'model-claude' + | 'model-pi' + | 'id' + | 'id-dotted' + | 'uuid' + | 'slug' + | 'tool-list' + | 'config-kv'; + +/** + * The patterns, each traced to the builder it came from. + * + * ⚠️ These are ALLOWLISTS (`^...$` over a safe character class), never blocklists — with + * one deliberate exception, `tool-list`, which mirrors the existing `--allowedTools` + * sanitizer. That one is a metacharacter REJECTION because tool specs legitimately contain + * `(`, `)`, `*`, `:` and spaces (`Bash(git:*), Read`), so an allowlist of safe words cannot + * express it. Keeping it byte-identical to the original matters more than making it uniform. + */ +const PATTERNS: Record = { + // buildOpenCodeCommand / buildCodexCommand / buildGeminiCommand / buildAntigravityCommand + model: /^[a-zA-Z0-9._\-/]+$/, + // buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases + 'model-claude': /^[a-zA-Z0-9._\-[\]]+$/, + // buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` + 'model-pi': /^[a-zA-Z0-9._\-/:]+$/, + // opencode --session, codex resume + id: /^[a-zA-Z0-9_-]+$/, + // gemini --resume, antigravity --conversation, pi --session + 'id-dotted': /^[a-zA-Z0-9._-]+$/, + // claude --resume / --session-id + uuid: /^[a-f0-9-]+$/, + // pi --provider + slug: /^[a-z0-9-]+$/, + // codex --config tui.animations=false + 'config-kv': /^[A-Za-z0-9._-]+=[A-Za-z0-9._-]+$/, + // Placeholder; `tool-list` is handled by isSafeToolList() below, not by a match. + 'tool-list': /^$/, +}; + +/** + * Shell metacharacters rejected in an `--allowedTools` value. Transcribed verbatim from + * buildClaudePermissionFlags so the accepted set does not move. + */ +const TOOL_LIST_DANGEROUS = /[;&|$`\\{}<>'"[\]\n\r]/; + +/** Does `value` satisfy the named pattern? */ +export function matchesPattern(pattern: TokenPattern, value: string): boolean { + if (pattern === 'tool-list') return value.length > 0 && !TOOL_LIST_DANGEROUS.test(value); + return PATTERNS[pattern].test(value); +} + +/** Every pattern name, for schema validation and error messages. */ +export const TOKEN_PATTERNS = Object.keys(PATTERNS) as TokenPattern[]; + +/** + * Characters a token may contain and still be emitted UNQUOTED into the `bash -c "..."` + * command string. Intentionally narrower than "what bash tolerates": anything outside it + * gets single-quoted, so the classification can only ever err toward more quoting. + */ +export const SAFE_BARE_TOKEN = /^[A-Za-z0-9._:@=+/,-]+$/; + +/** + * Longest `--version` output we will run a user-supplied regex over. A version banner is a + * line or two; anything larger is a misconfiguration, and capping the input is what keeps a + * sloppy (not necessarily malicious) regex from becoming a stall. + */ +export const MAX_VERSION_OUTPUT = 200; + +/** Longest permitted `discovery.version.regex` source. */ +const MAX_VERSION_REGEX_SOURCE = 200; + +/** + * Nested quantifiers — `(a+)+`, `(a*)*`, `(a+)*` and friends — the classic catastrophic + * backtracking shape. Rejected outright rather than analysed: this field exists to pull a + * semver out of a banner, and nothing legitimate for that job needs a nested quantifier. + */ +const NESTED_QUANTIFIER = /\([^)]*[+*][^)]*\)\s*[+*{]/; + +/** + * Compile a user-supplied version regex, or return null if it is not one we are willing to + * run. Returning null (rather than throwing) lets the caller degrade to "version unknown", + * which every consumer already handles. + */ +export function compileVersionRegex(source: string): RegExp | null { + if (source.length > MAX_VERSION_REGEX_SOURCE) return null; + if (NESTED_QUANTIFIER.test(source)) return null; + try { + // No `g`: a global regex carries lastIndex state across calls, which is a documented + // footgun in this codebase (see utils/regex-patterns.ts). + return new RegExp(source); + } catch { + return null; + } +} diff --git a/src/config/cli-registry/profiles.ts b/src/config/cli-registry/profiles.ts new file mode 100644 index 000000000..8a5b96d8b --- /dev/null +++ b/src/config/cli-registry/profiles.ts @@ -0,0 +1,44 @@ +/** + * @fileoverview Named code profiles that a `CliEntry.capabilities` field may select BY NAME. + * + * This is the escape hatch for behaviour that is genuinely code-shaped and cannot be + * expressed as data — codex's predictive write-through echo, claude's transcript parsing — + * without letting any of that code branch on a CLI's id. A capability field names a profile; + * the profile itself lives here, and later phases plug the real implementations + * (`CODEX_COMPOSER_ROW_RE`, the claude JSONL reader, the codex rollout reader) in as the + * corresponding module is migrated. + * + * The rule this enforces: `test/cli-registry-no-id-branching.test.ts` fails on any + * `mode === ''` comparison outside `stock.ts`, so a NEW behavioural special case + * must be added here, named, and referenced from a capability field — never inlined as an id + * check at the call site. + * + * @module config/cli-registry/profiles + */ + +/** + * Predictive local-echo profiles, selected via `capabilities.echo.predictProfile`. + * A name with no entry here (or `echo.policy !== 'predict'`) degrades to the 'buffer' + * policy — never to a crash — which is why `predictProfile` is optional in the schema. + */ +export const PREDICT_PROFILES: Record = { + // Phase 5 wires this to the real codex predictive-echo addon + // (packages/xterm-zerolag-input/src/predictive-echo-addon.ts) and CODEX_COMPOSER_ROW_RE. + codex: true, +}; + +/** + * Transcript readers, selected via `capabilities.transcript`. Unlike the other profile + * registries this one is closed over the schema enum itself (`'claude-jsonl' | + * 'codex-rollout' | 'none'`) rather than an open string, since transcript format is a small, + * genuinely fixed set — see CliCapabilities['transcript'] in types.ts. + */ +export const TRANSCRIPT_READER_NAMES = ['claude-jsonl', 'codex-rollout', 'none'] as const; + +/** Composer-row finders, selected via `capabilities.echo.anchor.kind`. Also schema-closed. */ +export const COMPOSER_ANCHOR_KINDS = ['glyph', 'cursor', 'none'] as const; + +/** True when `name` is a profile this build actually implements. */ +export function isKnownPredictProfile(name: string | undefined): boolean { + return name !== undefined && Object.prototype.hasOwnProperty.call(PREDICT_PROFILES, name); +} diff --git a/src/config/cli-registry/registry.ts b/src/config/cli-registry/registry.ts new file mode 100644 index 000000000..9feb7b7d0 --- /dev/null +++ b/src/config/cli-registry/registry.ts @@ -0,0 +1,357 @@ +/** + * @fileoverview Loads, merges, seeds and re-validates the CLI registry. + * + * `~/.codeman/clis.json` holds OVERRIDES and CUSTOM entries only — never a full copy of the + * stock catalog — so a shipped fix to a stock definition actually reaches an existing + * install, and the file stays small enough to hand-edit. + * + * Resolution: start from `STOCK_CLIS` → deep-merge each override by id (objects merge + * key-wise, arrays replace wholesale) → validate every resulting entry. A stock entry that + * fails validation after merge falls back to its pristine stock definition (a fat-fingered + * override cannot brick a shipped CLI); a custom entry that fails is dropped. `shell` and + * `claude` may be disabled but the loader refuses to let either be entirely absent, since + * huge parts of the app assume at least a shell fallback exists. + * + * `seededStockIds` is the ratchet that makes "one file, no generated fragments" survive + * `install.sh update`: any stock id not yet in that list is a NEWLY SHIPPED CLI, so it is + * added (enabled) and the id recorded; an id already in the list that carries no override is + * left exactly as-is, including a user's earlier `enabled: false`. + * + * @module config/cli-registry/registry + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from '../instance.js'; +import type { CliEntry, CliId, CliRegistryFile } from './types.js'; +import { CliEntrySchema } from './schema.js'; +import { STOCK_CLIS } from './stock.js'; +import { formatCliNotFoundMessage } from '../../utils/cli-resolver.js'; + +const SCHEMA_VERSION = 1; + +/** Construct a validated CliId. Throws if `raw` is not a well-formed id — call at API boundaries. */ +export function asCliId(raw: string): CliId { + if (!/^[a-z][a-z0-9-]{0,23}$/.test(raw)) { + throw new Error(`invalid CLI id: ${JSON.stringify(raw)}`); + } + return raw as CliId; +} + +function filePath(): string { + return dataPath('clis.json'); +} + +/** Plain-object deep merge: nested objects merge key-wise, arrays and primitives replace. */ +function deepMerge(base: T, override: unknown): T { + if (override === null || typeof override !== 'object' || Array.isArray(override)) { + return (override === undefined ? base : (override as T)) ?? base; + } + if (base === null || typeof base !== 'object' || Array.isArray(base)) { + return override as T; + } + const result: Record = { ...(base as Record) }; + for (const [key, value] of Object.entries(override as Record)) { + result[key] = deepMerge((base as Record)[key], value); + } + return result as T; +} + +interface LoadResult { + entries: CliEntry[]; + warnings: string[]; +} + +/** + * Refuse a group/world-writable registry file — same posture as the ssh-key discipline. + * + * POSIX only: Windows has no meaningful group/world bits on NTFS (Node reports every file + * as mode 0o666 there regardless of its actual ACL), so this check would flag every file on + * Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which + * this check cannot see and does not attempt to. + */ +function isUnsafePermissions(path: string): boolean { + if (process.platform === 'win32') return false; + try { + const mode = statSync(path).mode & 0o777; + return (mode & 0o077) !== 0; + } catch { + return false; + } +} + +function readRegistryFile(path: string, warnings: string[]): CliRegistryFile | null { + if (!existsSync(path)) return null; + if (isUnsafePermissions(path)) { + warnings.push(`${path} is group/world-writable; ignoring it and falling back to stock CLIs.`); + return null; + } + let raw: string; + try { + raw = readFileSync(path, 'utf-8'); + } catch (err) { + warnings.push(`Failed to read ${path}: ${(err as Error).message}. Falling back to stock CLIs.`); + return null; + } + try { + const parsed = JSON.parse(raw) as CliRegistryFile; + if (typeof parsed !== 'object' || parsed === null || typeof parsed.clis !== 'object') { + throw new Error('missing "clis" object'); + } + return parsed; + } catch (err) { + const quarantined = `${path}.invalid-${Date.now()}`; + try { + renameSync(path, quarantined); + warnings.push(`${path} was not valid JSON (${(err as Error).message}); moved to ${quarantined}.`); + } catch { + warnings.push( + `${path} was not valid JSON (${(err as Error).message}); left in place, falling back to stock CLIs.` + ); + } + return null; + } +} + +/** Merge the stock catalog with a (possibly absent) registry file. Pure — no IO. */ +export function resolveRegistry(stock: CliEntry[], file: CliRegistryFile | null, warnings: string[]): LoadResult { + const stockById = new Map(stock.map((e) => [e.id as string, e])); + const seeded = new Set(file?.seededStockIds ?? []); + const overrides = file?.clis ?? {}; + const entries: CliEntry[] = []; + + for (const stockEntry of stock) { + const id = stockEntry.id as string; + const override = overrides[id]; + const merged = override ? deepMerge(stockEntry, override) : stockEntry; + const parsed = CliEntrySchema.safeParse({ ...merged, id, stock: true }); + if (parsed.success) { + entries.push(parsed.data as CliEntry); + } else { + warnings.push( + `Override for stock CLI "${id}" failed validation; using the shipped definition. ${parsed.error.message}` + ); + entries.push(stockEntry); + } + seeded.add(id); + } + + for (const [id, raw] of Object.entries(overrides)) { + if (stockById.has(id)) continue; // handled above + const parsed = CliEntrySchema.safeParse({ ...(raw as object), id, stock: false }); + if (parsed.success) { + entries.push(parsed.data as CliEntry); + } else { + warnings.push(`Custom CLI "${id}" failed validation and was dropped. ${parsed.error.message}`); + } + } + + entries.sort((a, b) => a.order - b.order); + return { entries, warnings }; +} + +/** Persist the ratcheted `seededStockIds` (and any pass-through overrides) atomically. */ +function writeSeed(path: string, file: CliRegistryFile): void { + mkdirSync(dirname(path), { recursive: true }); + const tmpPath = `${path}.tmp`; + writeFileSync(tmpPath, JSON.stringify(file, null, 2), { mode: 0o600 }); + renameSync(tmpPath, path); +} + +let cache: LoadResult | null = null; + +/** + * Load the effective registry (stock + user overrides), seeding newly-shipped stock ids into + * the on-disk file as a side effect. Memoized; call `reloadCliRegistry()` after a settings + * write to invalidate. + */ +export function loadCliRegistry(): LoadResult { + if (cache) return cache; + const path = filePath(); + const warnings: string[] = []; + const existing = readRegistryFile(path, warnings); + + const knownStockIds = new Set(STOCK_CLIS.map((e) => e.id as string)); + const previouslySeeded = new Set(existing?.seededStockIds ?? []); + const newlyShipped = [...knownStockIds].filter((id) => !previouslySeeded.has(id)); + + const file: CliRegistryFile = { + schemaVersion: SCHEMA_VERSION, + seededStockIds: [...previouslySeeded, ...newlyShipped], + clis: existing?.clis ?? {}, + }; + + // Write back when the file is new, or a previously-unseeded stock CLI just joined — + // otherwise this is a pure read (no write on every boot). + if (!existing || newlyShipped.length > 0) { + try { + writeSeed(path, file); + } catch (err) { + warnings.push(`Failed to persist ${path}: ${(err as Error).message}`); + } + } + + cache = resolveRegistry(STOCK_CLIS, file, warnings); + return cache; +} + +/** Drop the memoized registry so the next `loadCliRegistry()` re-reads the file. */ +export function reloadCliRegistry(): void { + cache = null; +} + +export function listClis(): CliEntry[] { + return loadCliRegistry().entries; +} + +export function enabledClis(): CliEntry[] { + return listClis().filter((e) => e.enabled); +} + +export function getCli(id: string): CliEntry | undefined { + return listClis().find((e) => (e.id as string) === id); +} + +export function cliIds(): string[] { + return listClis().map((e) => e.id as string); +} + +// --------------------------------------------------------------------------- +// Writes — settings-UI mutations (App Settings → Agents & CLIs) +// --------------------------------------------------------------------------- + +export interface CliUpdateResult { + success: boolean; + /** Human-readable problems: a failed stock override, a dropped custom entry, an IO error. */ + warnings: string[]; + /** The resolved registry AFTER the mutation, when it succeeded. */ + entries?: CliEntry[]; +} + +const STOCK_IDS = new Set(STOCK_CLIS.map((e) => e.id as string)); + +/** + * Read-modify-write the raw override file: ensures it exists (seeding via + * `loadCliRegistry()` if needed), applies `mutate` to a fresh, uncached read, validates the + * result, persists, and reloads the shared cache so every other module sees the change on + * its next `getCli()`/`listClis()` call. `mutate` throwing aborts the write entirely — the + * on-disk file is untouched (the read happens before any write). + */ +function withRegistryFile(mutate: (file: CliRegistryFile) => void): CliUpdateResult { + const path = filePath(); + const warnings: string[] = []; + loadCliRegistry(); // ensure the file exists and newly-shipped stock ids are seeded + const existing = readRegistryFile(path, warnings) ?? { + schemaVersion: SCHEMA_VERSION, + seededStockIds: STOCK_CLIS.map((e) => e.id as string), + clis: {}, + }; + + mutate(existing); + + // resolveRegistry() never throws — a bad entry is dropped/falls back with a warning — + // so run it here to surface those as part of THIS mutation's result rather than silently + // on the next unrelated read. + const validationWarnings: string[] = []; + resolveRegistry(STOCK_CLIS, existing, validationWarnings); + + try { + writeSeed(path, existing); + } catch (err) { + return { success: false, warnings: [...warnings, `Failed to persist ${path}: ${(err as Error).message}`] }; + } + reloadCliRegistry(); + const { entries, warnings: loadWarnings } = loadCliRegistry(); + return { success: true, warnings: [...warnings, ...validationWarnings, ...loadWarnings], entries }; +} + +/** Enable or disable ANY registered CLI (stock or custom) — the settings list's toggle. */ +export function setCliEnabled(id: string, enabled: boolean): CliUpdateResult { + if (!getCli(id)) return { success: false, warnings: [`Unknown CLI: ${id}`] }; + return withRegistryFile((file) => { + file.clis[id] = deepMerge((file.clis[id] as object) ?? {}, { enabled }); + }); +} + +/** + * Reorder the run-menu/settings-list position of every id in `orderedIds`, in the order + * given. Ids not listed keep their current `order`. Multiplied by 10 so a future insertion + * between two adjacent entries never requires renumbering the whole list. + */ +export function setCliOrder(orderedIds: string[]): CliUpdateResult { + return withRegistryFile((file) => { + orderedIds.forEach((id, index) => { + file.clis[id] = deepMerge((file.clis[id] as object) ?? {}, { order: index * 10 }); + }); + }); +} + +/** + * Add or update a CUSTOM CLI (never a stock one — `stock` is always forced server-side + * regardless of what the request claims, same as the loader). `entry` is validated as a + * COMPLETE `CliEntry` up front so a malformed request fails with a clear schema error + * instead of being silently dropped by `resolveRegistry`'s own fallback on the next read. + */ +export function upsertCustomCli(id: string, entry: unknown): CliUpdateResult { + if (STOCK_IDS.has(id)) { + return { + success: false, + warnings: [`"${id}" is a stock CLI id — edit it with setCliEnabled or an override, not upsertCustomCli.`], + }; + } + const candidate = typeof entry === 'object' && entry !== null ? { ...entry, id, stock: false } : entry; + const parsed = CliEntrySchema.safeParse(candidate); + if (!parsed.success) { + return { success: false, warnings: [parsed.error.message] }; + } + return withRegistryFile((file) => { + file.clis[id] = parsed.data; + }); +} + +/** Remove a custom CLI entirely. Stock entries can only be disabled, never removed. */ +export function removeCustomCli(id: string): CliUpdateResult { + if (STOCK_IDS.has(id)) { + return { success: false, warnings: [`"${id}" is a stock CLI — disable it instead of removing it.`] }; + } + if (!getCli(id)) return { success: false, warnings: [`Unknown CLI: ${id}`] }; + return withRegistryFile((file) => { + delete file.clis[id]; + }); +} + +/** + * Build the "CLI not found" error message for a mode with no resolved binary directory, + * naming the registry's own label and per-platform install command. Shared by + * tmux-manager.ts's spawn-time throw and session-routes.ts's create-time pre-flight check + * (both used to hand-write this string once per external CLI, six throws and ten checks in + * total, all now reading the SAME data). Returns null for an id the registry doesn't know. + */ +export function missingCliMessage(id: string): string | null { + const entry = getCli(id); + if (!entry) return null; + const command = resolveInstallCommandForPlatform(entry); + const base = command + ? `${entry.label} CLI not found. Install with: ${command}` + : `${entry.label} CLI not found. See its docs for install instructions.`; + // Bounded PATH/login-shell/search-dir diagnostics appended so the error names exactly + // where resolution looked, not just what it was looking for — ported from upstream's + // formatCliNotFoundMessage (see cli-resolver.ts's own doc comment for the full story). + return formatCliNotFoundMessage(base, id); +} + +/** + * Pick the install command for the CURRENT platform, falling back to `linux` (the most + * common shell-compatible default) and then to whatever platform IS declared, so an entry + * missing today's exact platform key (e.g. no `win32` command) still surfaces something + * rather than nothing. Shared by `missingCliMessage` (display only) and `cli-installer.ts` + * (actually runs it) — the same resolution logic, two different uses. + */ +export function resolveInstallCommandForPlatform(entry: CliEntry): string | undefined { + const platform = process.platform === 'win32' ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'; + return ( + entry.discovery.install.command[platform] ?? + entry.discovery.install.command.linux ?? + Object.values(entry.discovery.install.command)[0] + ); +} diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts new file mode 100644 index 000000000..e8af0ebea --- /dev/null +++ b/src/config/cli-registry/schema.ts @@ -0,0 +1,350 @@ +/** + * @fileoverview Zod validation for CLI registry entries. + * + * Every object here is `.strict()`: an unknown key is a hard validation error, not a + * silently-ignored one. That matters for a security-relevant schema — a typo in a field name + * must never degrade to "field absent, so the permissive default applies". + * + * The load-bearing rule enforced here is `SHELL_TOKEN`: it is what makes it impossible for a + * `clis.json` entry to smuggle shell metacharacters into the eventual `bash -c "..."` string + * (see argv.ts's file header for the full model). + * + * @module config/cli-registry/schema + */ + +import { z } from 'zod'; +import { TOKEN_PATTERNS } from './patterns.js'; + +/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ +const cliId = z + .string() + .regex(/^[a-z][a-z0-9-]{0,23}$/, 'id must be lowercase, start with a letter, and be at most 24 chars'); + +/** An env var name. */ +const envName = z + .string() + .regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE') + .max(64); + +/** + * A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens, + * braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names, + * fixed values) must satisfy this — see argv.ts's file header. + */ +const shellToken = z + .string() + .min(1) + .max(256) + .regex(/^[A-Za-z0-9._:@=+/,-]+$/, 'must be a plain word with no shell metacharacters'); + +const flagToken = z.string().regex(/^--?[A-Za-z0-9][A-Za-z0-9-]*$/, 'must look like -x or --long-flag'); + +const quoteStyle = z.enum(['auto', 'bare', 'double', 'single']); + +const condSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ param: z.string(), is: z.union([z.string(), z.boolean()]) }).strict(), + z.object({ param: z.string(), state: z.enum(['set', 'unset']) }).strict(), + z.object({ allOf: z.array(condSchema).min(1).max(8) }).strict(), + z.object({ anyOf: z.array(condSchema).min(1).max(8) }).strict(), + z.object({ not: condSchema }).strict(), + z.object({ capabilityGate: z.string() }).strict(), + ]) +); + +const paramSpecSchema = z.union([ + z + .object({ type: z.literal('enum'), values: z.array(z.string()).min(1).max(16), default: z.string().optional() }) + .strict(), + z.object({ type: z.literal('bool') }).strict(), + z.object({ type: z.literal('token'), pattern: z.enum(TOKEN_PATTERNS as [string, ...string[]]) }).strict(), + z + .object({ + type: z.literal('engine'), + source: z.enum([ + 'sessionId', + 'sessionName', + 'muxName', + 'effortLevel', + 'effortSettingsJson', + 'codemanPrefixedSessionId', + ]), + }) + .strict(), +]); + +const argSpecSchema = z.union([ + z.object({ lit: shellToken, when: condSchema.optional() }).strict(), + z.object({ flag: flagToken, when: condSchema.optional() }).strict(), + z.object({ flag: flagToken, value: shellToken, quote: quoteStyle.optional(), when: condSchema.optional() }).strict(), + z + .object({ flag: flagToken, valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() }) + .strict(), + z.object({ valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() }).strict(), +]); + +const variantSchema = z + .object({ + id: z.string().min(1).max(40), + when: condSchema.optional(), + // min(0): the `shell` entry declares a variant with no args — tmux-manager resolves the + // real login shell in code, since it varies per remote user's /etc/passwd entry. + args: z.array(argSpecSchema).max(32), + }) + .strict(); + +const launchSchema = z + .object({ + params: z.record(z.string(), paramSpecSchema), + chain: z.enum(['first', 'fallback']).optional(), + variants: z.array(variantSchema).min(1).max(4), + legacyConfigAliases: z.record(z.string(), z.string()).optional(), + resumeAppend: z + .union([ + z.object({ style: z.literal('flag'), flag: flagToken }).strict(), + z.object({ style: z.literal('positional'), token: shellToken }).strict(), + ]) + .optional(), + }) + .strict() + .superRefine((launch, ctx) => { + const paramNames = new Set(Object.keys(launch.params)); + const checkValueFrom = (name: string, path: (string | number)[]) => { + if (!paramNames.has(name)) { + ctx.addIssue({ code: 'custom', message: `valueFrom "${name}" is not a declared param`, path }); + } + }; + launch.variants.forEach((variant, vi) => { + variant.args.forEach((arg, ai) => { + if ('valueFrom' in arg) checkValueFrom(arg.valueFrom, ['variants', vi, 'args', ai, 'valueFrom']); + }); + }); + if (launch.chain === 'fallback') { + const last = launch.variants.at(-1); + if (last?.when) { + ctx.addIssue({ + code: 'custom', + message: 'the last variant of a fallback chain must have no `when` (it must be the guaranteed terminal case)', + path: ['variants', launch.variants.length - 1, 'when'], + }); + } + } + if (launch.legacyConfigAliases) { + for (const paramName of Object.keys(launch.legacyConfigAliases)) { + if (!paramNames.has(paramName)) { + ctx.addIssue({ + code: 'custom', + message: `legacyConfigAliases key "${paramName}" is not a declared param`, + path: ['legacyConfigAliases', paramName], + }); + } + } + } + }); + +const versionProbeSchema = z + .object({ + arg: shellToken, + regex: z.string().max(200).optional(), + requireVersionMatch: z.boolean().optional(), + retryOnTransientFailure: z.boolean().optional(), + }) + .strict(); + +const discoverySchema = z + .object({ + // min(0): the `shell` entry has no binary of its own (it resolves the login shell in code). + binaries: z.array(shellToken).max(4), + searchDirs: z.array(z.string().max(300)).max(16), + version: versionProbeSchema.optional(), + install: z + .object({ + // z.record with an enum key type requires every enum member in Zod v4; the install + // command legitimately varies by platform and most entries only need one or two, so + // this is a plain object of optional platform keys instead. + command: z + .object({ + linux: z.string().max(500).optional(), + darwin: z.string().max(500).optional(), + wsl: z.string().max(500).optional(), + win32: z.string().max(500).optional(), + }) + .strict(), + npmPackage: z.string().max(200).optional(), + docsUrl: z.url().optional(), + }) + .strict(), + }) + .strict(); + +const envExportSchema = z + .object({ + name: envName, + value: z.union([ + shellToken, + z + .object({ + engine: z.enum([ + 'sessionId', + 'sessionName', + 'muxName', + 'effortLevel', + 'effortSettingsJson', + 'codemanPrefixedSessionId', + ]), + }) + .strict(), + ]), + when: condSchema.optional(), + }) + .strict(); + +const envSchema = z + .object({ + exports: z.array(envExportSchema).max(16), + unset: z.array(envName).max(16), + tmuxSetenvKeys: z.array(envName).max(32), + dockerExecEnvNames: z.array(envName).max(32), + allowedPrefixes: z + .array( + z + .string() + .min(3) + .max(32) + .regex(/^[A-Z][A-Z0-9_]*_$/) + ) + .max(8), + allowedKeys: z.array(envName).max(8), + configContentVar: envName.optional(), + }) + .strict(); + +const echoSchema = z + .object({ + policy: z.enum(['buffer', 'predict', 'off']), + anchor: z.union([ + z + .object({ kind: z.literal('glyph'), glyph: z.string().min(1).max(4), offset: z.number().int().min(0).max(16) }) + .strict(), + z.object({ kind: z.literal('cursor') }).strict(), + z.object({ kind: z.literal('none') }).strict(), + ]), + predictProfile: z.string().max(40).optional(), + }) + .strict(); + +const capabilitiesSchema = z + .object({ + external: z.boolean(), + requiresMux: z.boolean(), + hooks: z.boolean(), + transcript: z.enum(['claude-jsonl', 'codex-rollout', 'none']), + altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']), + echo: echoSchema, + wheelForward: z + .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) + .strict(), + keyboardAccessory: z.enum(['agent', 'shell']), + privilegedCommandGate: z.boolean(), + startMode: z.enum(['interactive', 'shell']), + stripInkBloat: z.boolean(), + ralph: z.boolean(), + respawn: z.boolean(), + effort: z.boolean(), + agentSkillInjection: z.boolean(), + statusLineTelemetry: z.boolean(), + model: z + .object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() }) + .strict(), + privilegedParams: z + .array( + z + .object({ + param: z.string(), + clampTo: z.union([z.boolean(), z.string()]), + materializeWhenAbsent: z.boolean().optional(), + }) + .strict() + ) + .max(8), + gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()), + maxFrameBytes: z.number().int().positive().optional(), + }) + .strict(); + +const credStoreSchema = z + .object({ + rel: z.string().min(1).max(100), + shareDirs: z.array(z.string().max(100)).optional(), + shareFiles: z.array(z.string().max(100)).optional(), + seedFiles: z.array(z.string().max(100)).optional(), + seedWhole: z.boolean().optional(), + }) + .strict(); + +/** + * A remote/docker default pane command: space-separated bare words from the SAME safe + * charset as `shellToken` (no shell metacharacters), so `claude --dangerously-skip-permissions` + * is expressible while still excluding `;`, `|`, `$`, backticks and quotes — this is not an + * escape hatch into arbitrary shell text, it is one bare command plus bare flags. + */ +const commandLine = z + .string() + .min(1) + .max(200) + .regex( + /^[A-Za-z0-9._:@=+/,-]+( [A-Za-z0-9._:@=+/,-]+)*$/, + 'must be space-separated bare words with no shell metacharacters' + ); + +const overlayTargetSchema = z.union([ + z.object({ command: commandLine.optional() }).strict(), + z.object({ disabled: z.literal(true) }).strict(), +]); + +const overlaysSchema = z + .object({ + remote: overlayTargetSchema.optional(), + docker: overlayTargetSchema.optional(), + credStore: credStoreSchema.optional(), + }) + .strict(); + +export const CliEntrySchema = z + .object({ + id: cliId, + label: z.string().min(1).max(60), + shortBadge: z.string().min(1).max(6), + accent: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'accent must be a 6-digit hex colour'), + enabled: z.boolean(), + stock: z.boolean(), + order: z.number().int(), + kind: z.enum(['agent', 'shell']), + discovery: discoverySchema, + launch: launchSchema, + env: envSchema, + capabilities: capabilitiesSchema, + overlays: overlaysSchema, + }) + .strict() + .superRefine((entry, ctx) => { + const gateNames = new Set(Object.keys(entry.capabilities.gates)); + const walkConds = (cond: import('./types.js').Cond | undefined) => { + if (!cond) return; + if ('capabilityGate' in cond && !gateNames.has(cond.capabilityGate)) { + ctx.addIssue({ + code: 'custom', + message: `capabilityGate "${cond.capabilityGate}" is not declared in capabilities.gates`, + }); + } + if ('allOf' in cond) cond.allOf.forEach(walkConds); + if ('anyOf' in cond) cond.anyOf.forEach(walkConds); + if ('not' in cond) walkConds(cond.not); + }; + for (const variant of entry.launch.variants) { + walkConds(variant.when); + for (const arg of variant.args) walkConds(arg.when); + } + }); + +export type ValidatedCliEntry = z.infer; diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts new file mode 100644 index 000000000..175abd6a2 --- /dev/null +++ b/src/config/cli-registry/stock.ts @@ -0,0 +1,813 @@ +/** + * @fileoverview The shipped stock catalog — one `CliEntry` per CLI Codeman supports out of + * the box, transcribed to be byte-identical (via the argv engine) to the hand-written + * builders in tmux-manager.ts that they replace. + * + * This is the ONE file allowed to know a CLI's id by name (`test/cli-registry-no-id-branching + * .test.ts` enforces that nowhere else does). Everything downstream — session.ts, + * tmux-manager.ts, the routes, the frontend — reads capability flags, never `entry.id ===`. + * + * @module config/cli-registry/stock + */ + +import type { CliEntry } from './types.js'; + +const HOME_DIRS = { + local: '~/.local/bin', + usrLocal: '/usr/local/bin', + bunBin: '~/.bun/bin', + npmGlobal: '~/.npm-global/bin', + homeBin: '~/bin', +}; + +const NO_GATES = {}; +const NO_PRIVILEGED_PARAMS: CliEntry['capabilities']['privilegedParams'] = []; + +/** Shared skeleton for the "agent CLI, no unusual behaviour" case (pi's own shape). */ +function agentDefaults(): Pick< + CliEntry['capabilities'], + | 'external' + | 'requiresMux' + | 'hooks' + | 'transcript' + | 'altScreen' + | 'wheelForward' + | 'keyboardAccessory' + | 'privilegedCommandGate' + | 'startMode' + | 'stripInkBloat' + | 'ralph' + | 'respawn' + | 'effort' + | 'agentSkillInjection' + | 'statusLineTelemetry' + | 'model' + | 'privilegedParams' + | 'gates' +> { + return { + external: true, + requiresMux: true, + hooks: false, + transcript: 'none', + altScreen: 'strip-mux-only', + wheelForward: { mode: 'never' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'flag', param: 'model' }, + privilegedParams: NO_PRIVILEGED_PARAMS, + gates: NO_GATES, + }; +} + +const CLAUDE: CliEntry = { + id: 'claude' as CliEntry['id'], + label: 'Claude', + shortBadge: 'CC', + accent: '#d97757', + enabled: true, + stock: true, + order: 0, + kind: 'agent', + discovery: { + binaries: ['claude'], + searchDirs: [HOME_DIRS.local, '~/.claude/local', HOME_DIRS.usrLocal, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)', retryOnTransientFailure: true }, + install: { + command: { + linux: 'curl -fsSL https://claude.ai/install.sh | bash', + darwin: 'curl -fsSL https://claude.ai/install.sh | bash', + wsl: 'curl -fsSL https://claude.ai/install.sh | bash', + }, + npmPackage: '@anthropic-ai/claude-code', + docsUrl: 'https://docs.claude.com/claude-code', + }, + }, + launch: { + chain: 'fallback', + params: { + claudeMode: { + type: 'enum', + values: ['dangerously-skip-permissions', 'auto', 'normal', 'allowedTools'], + default: 'dangerously-skip-permissions', + }, + allowedTools: { type: 'token', pattern: 'tool-list' }, + model: { type: 'token', pattern: 'model-claude' }, + resumeId: { type: 'token', pattern: 'uuid' }, + // buildEffortCliArgs carries `ultracode` as a settings JSON blob and every other + // level as a plain `--effort ` flag — two engine values because the two + // shapes are mutually exclusive and neither is user-typed text (both are produced + // from the EFFORT_LEVELS allowlist upstream, same as every other engine value). + effortLevel: { type: 'engine', source: 'effortLevel' }, + effortJson: { type: 'engine', source: 'effortSettingsJson' }, + sessionId: { type: 'engine', source: 'sessionId' }, + sessionName: { type: 'engine', source: 'sessionName' }, + }, + variants: [ + { + id: 'resume', + when: { param: 'resumeId', state: 'set' }, + args: [ + { lit: 'claude' }, + { flag: '--dangerously-skip-permissions', when: { param: 'claudeMode', is: 'dangerously-skip-permissions' } }, + { flag: '--permission-mode', value: 'auto', when: { param: 'claudeMode', is: 'auto' } }, + { + flag: '--allowedTools', + valueFrom: 'allowedTools', + quote: 'double', + when: { + allOf: [ + { param: 'claudeMode', is: 'allowedTools' }, + { param: 'allowedTools', state: 'set' }, + ], + }, + }, + { flag: '--resume', valueFrom: 'resumeId', quote: 'double' }, + { flag: '--model', valueFrom: 'model', quote: 'double', when: { param: 'model', state: 'set' } }, + { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, + { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, + { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + ], + }, + { + id: 'new', + args: [ + { lit: 'claude' }, + { flag: '--dangerously-skip-permissions', when: { param: 'claudeMode', is: 'dangerously-skip-permissions' } }, + { flag: '--permission-mode', value: 'auto', when: { param: 'claudeMode', is: 'auto' } }, + { + flag: '--allowedTools', + valueFrom: 'allowedTools', + quote: 'double', + when: { + allOf: [ + { param: 'claudeMode', is: 'allowedTools' }, + { param: 'allowedTools', state: 'set' }, + ], + }, + }, + { flag: '--session-id', valueFrom: 'sessionId', quote: 'double' }, + { flag: '--model', valueFrom: 'model', quote: 'double', when: { param: 'model', state: 'set' } }, + { flag: '--effort', valueFrom: 'effortLevel', quote: 'single', when: { param: 'effortLevel', state: 'set' } }, + { flag: '--settings', valueFrom: 'effortJson', quote: 'single', when: { param: 'effortJson', state: 'set' } }, + { flag: '--name', valueFrom: 'sessionName', quote: 'double', when: { capabilityGate: 'nameFlag' } }, + ], + }, + ], + // Claude has no `Config` object of its own — the bridge synthesizes one from its + // discrete top-level spawn fields, under their EXISTING field name `resumeSessionId`. + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + }, + env: { + exports: [], + unset: ['CLAUDECODE', 'COLORTERM'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['CLAUDE_CODE_'], + allowedKeys: ['CLAUDE_CONFIG_DIR'], + }, + capabilities: { + external: false, + requiresMux: false, + hooks: true, + transcript: 'claude-jsonl', + altScreen: 'strip-full', + echo: { policy: 'buffer', anchor: { kind: 'glyph', glyph: '❯', offset: 2 } }, + wheelForward: { mode: 'version-gated', minVersion: '2.1.187' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: true, + respawn: true, + effort: true, + agentSkillInjection: true, + statusLineTelemetry: true, + model: { source: 'claude-settings-file' }, + privilegedParams: [], + gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } }, + }, + overlays: { + // Mirrors the local default so the remote/in-container agent runs non-interactively + // (no trust-folder/permission prompt that nothing on that side can answer). A per-host + // `commands.claude` override, or the docker multi-user clamp, stays the escape hatch. + remote: { command: 'claude --dangerously-skip-permissions' }, + docker: { command: 'claude --dangerously-skip-permissions' }, + // Claude's docker/remote credential handling has its own dedicated code path + // (claudeDockerPaneCommand, artifacts at docker-hosts.ts:537-575) — no generic credStore. + }, +}; + +const SHELL: CliEntry = { + id: 'shell' as CliEntry['id'], + label: 'Shell', + shortBadge: 'SH', + accent: '#6b7280', + enabled: true, + stock: true, + order: 1, + kind: 'shell', + discovery: { + binaries: [], + searchDirs: [], + install: { command: {} }, + }, + launch: { + params: {}, + variants: [{ id: 'shell', args: [] }], // tmux-manager resolves the real login shell in code + }, + env: { + exports: [], + unset: ['COLORTERM'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: [], + allowedKeys: [], + }, + capabilities: { + external: false, + requiresMux: false, + hooks: false, + transcript: 'none', + altScreen: 'preserve', + echo: { policy: 'off', anchor: { kind: 'none' } }, + wheelForward: { mode: 'never' }, + keyboardAccessory: 'shell', + privilegedCommandGate: true, + startMode: 'shell', + stripInkBloat: false, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' }, + privilegedParams: [], + gates: {}, + }, + overlays: { + // No `remote` entry: defaultRemoteCommandForMode special-cases kind==='shell' directly + // (an interactive login shell, no `-c ''` wrapping at all). + docker: { disabled: true }, + }, +}; + +const OPENCODE: CliEntry = { + id: 'opencode' as CliEntry['id'], + label: 'OpenCode', + shortBadge: 'OC', + accent: '#f59e0b', + enabled: true, + stock: true, + order: 10, + kind: 'agent', + discovery: { + binaries: ['opencode'], + searchDirs: [ + '~/.opencode/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + '~/go/bin', + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { + linux: 'curl -fsSL https://opencode.ai/install | bash', + darwin: 'curl -fsSL https://opencode.ai/install | bash', + }, + npmPackage: 'opencode-ai', + docsUrl: 'https://opencode.ai/docs', + }, + }, + launch: { + params: { + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id' }, + forkSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'opencode' }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--session', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + flag: '--fork', + when: { + allOf: [ + { param: 'resumeId', state: 'set' }, + { param: 'forkSession', is: true }, + ], + }, + }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'continueSession' }, + }, + env: { + exports: [], + unset: ['COLORTERM'], + tmuxSetenvKeys: ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'], + dockerExecEnvNames: [], + allowedPrefixes: ['OPENCODE_'], + allowedKeys: [], + configContentVar: 'OPENCODE_CONFIG_CONTENT', + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, + }, + overlays: { + credStore: { rel: '.config/opencode', seedWhole: true }, + }, +}; + +const CODEX: CliEntry = { + id: 'codex' as CliEntry['id'], + label: 'Codex', + shortBadge: 'CX', + accent: '#6b7fd7', + enabled: true, + stock: true, + order: 20, + kind: 'agent', + discovery: { + binaries: ['codex'], + searchDirs: [ + '~/.codex/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { linux: 'npm install -g @openai/codex', darwin: 'npm install -g @openai/codex' }, + npmPackage: '@openai/codex', + docsUrl: 'https://developers.openai.com/codex/cli', + }, + }, + launch: { + params: { + bypassApprovals: { type: 'bool' }, + animations: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'codex' }, + { flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } }, + { flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } }, + { flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { lit: 'resume', when: { param: 'resumeId', state: 'set' } }, + { valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + legacyConfigAliases: { bypassApprovals: 'dangerouslyBypassApprovals', resumeId: 'resumeSessionId' }, + resumeAppend: { style: 'positional', token: 'resume' }, + }, + env: { + exports: [ + { name: 'COLORTERM', value: 'truecolor' }, + { name: 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE', value: { engine: 'codemanPrefixedSessionId' } }, + ], + unset: ['NO_COLOR'], + tmuxSetenvKeys: ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'], + dockerExecEnvNames: ['OPENAI_API_KEY', 'CODEX_API_KEY'], + allowedPrefixes: ['CODEX_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + transcript: 'codex-rollout', + altScreen: 'strip-full', + echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, + wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward + maxFrameBytes: 32 * 1024, + // codex's own bare-spawn default (no config sent) is already safe (no bypass flag), so + // the multi-user clamp only needs to force an EXPLICITLY-SENT bypass back off. + privilegedParams: [{ param: 'dangerouslyBypassApprovals', clampTo: false }], + }, + overlays: { + credStore: { + rel: '.codex', + shareDirs: ['sessions'], + shareFiles: ['history.jsonl'], + seedFiles: ['auth.json', 'config.toml'], + }, + }, +}; + +const GEMINI: CliEntry = { + id: 'gemini' as CliEntry['id'], + label: 'Gemini', + shortBadge: 'GM', + accent: '#4285f4', + enabled: true, + stock: true, + order: 30, + kind: 'agent', + discovery: { + binaries: ['gemini'], + searchDirs: [ + '~/.gemini/bin', + HOME_DIRS.local, + HOME_DIRS.usrLocal, + HOME_DIRS.bunBin, + HOME_DIRS.npmGlobal, + HOME_DIRS.homeBin, + ], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { linux: 'npm install -g @google/gemini-cli', darwin: 'npm install -g @google/gemini-cli' }, + npmPackage: '@google/gemini-cli', + docsUrl: 'https://github.com/google-gemini/gemini-cli', + }, + }, + launch: { + params: { + approvalMode: { type: 'enum', values: ['default', 'auto_edit', 'yolo', 'plan'], default: 'yolo' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'gemini' }, + { flag: '--skip-trust' }, + { flag: '--approval-mode', valueFrom: 'approvalMode' }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeSession' }, + resumeAppend: { style: 'flag', flag: '--resume' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + tmuxSetenvKeys: [ + 'GEMINI_API_KEY', + 'GEMINI_MODEL', + 'GOOGLE_API_KEY', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_LOCATION', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_GENAI_USE_VERTEXAI', + ], + dockerExecEnvNames: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], + allowedPrefixes: ['GEMINI_', 'GOOGLE_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-full', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // gemini's builder defaults an ABSENT approvalMode to 'yolo', so the clamp must + // MATERIALIZE a config (not just touch an already-sent one) or a non-granted owner who + // sends no geminiConfig at all would still get yolo for free. + privilegedParams: [{ param: 'approvalMode', clampTo: 'auto_edit', materializeWhenAbsent: true }], + }, + overlays: { + credStore: { rel: '.gemini', seedWhole: true }, // also covers antigravity — see its own entry + }, +}; + +const ANTIGRAVITY: CliEntry = { + id: 'antigravity' as CliEntry['id'], + label: 'Antigravity', + shortBadge: 'AG', + accent: '#8b5cf6', + enabled: true, + stock: true, + order: 40, + kind: 'agent', + discovery: { + // Binary is `agy`, NOT `antigravity` — the mode-name/binary-name split that made + // probeDockerCliVersion wrong before this registry existed. + binaries: ['agy'], + searchDirs: [HOME_DIRS.local, '~/.antigravity/bin', HOME_DIRS.usrLocal, HOME_DIRS.homeBin], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { + linux: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + darwin: 'curl -fsSL https://antigravity.google/cli/install.sh | bash', + }, + docsUrl: 'https://antigravity.google/cli', + }, + }, + launch: { + params: { + dangerouslySkipPermissions: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'agy' }, + { flag: '--dangerously-skip-permissions', when: { param: 'dangerouslySkipPermissions', is: true } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--conversation', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeConversationId' }, + resumeAppend: { style: 'flag', flag: '--conversation' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['ANTIGRAVITY_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // Like codex: an ABSENT config already defaults safe (no bypass flag), so only a + // SENT config needs the flag forced off — nothing is materialized. + privilegedParams: [{ param: 'dangerouslySkipPermissions', clampTo: false }], + }, + overlays: { + // No credStore of its own: agy nests its whole state under ~/.gemini/antigravity-cli/, + // which gemini's seedWhole entry already covers. + }, +}; + +const PI: CliEntry = { + id: 'pi' as CliEntry['id'], + label: 'Pi', + shortBadge: 'PI', + accent: '#10b981', + enabled: true, + stock: true, + order: 50, + kind: 'agent', + discovery: { + binaries: ['pi'], + searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + // pi is a generic binary name (Raspberry Pi tooling, personal scripts), so a `which` + // hit alone is not evidence of the right program — require the version match. + version: { arg: '--version', regex: '(?:^|\\s)(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, + install: { + command: { + linux: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + darwin: 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + }, + npmPackage: '@earendil-works/pi-coding-agent', + docsUrl: 'https://pi.dev', + }, + }, + launch: { + params: { + approveProjectTrust: { type: 'bool' }, + model: { type: 'token', pattern: 'model-pi' }, + provider: { type: 'token', pattern: 'slug' }, + thinking: { type: 'enum', values: ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + continueSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'pi' }, + { flag: '--approve', when: { param: 'approveProjectTrust', is: true } }, + { flag: '--no-approve', when: { param: 'approveProjectTrust', is: false } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--provider', valueFrom: 'provider', when: { param: 'provider', state: 'set' } }, + { flag: '--thinking', valueFrom: 'thinking', when: { param: 'thinking', state: 'set' } }, + { flag: '--session', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + lit: '-c', + when: { + allOf: [ + { param: 'continueSession', is: true }, + { param: 'resumeId', state: 'unset' }, + ], + }, + }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + resumeAppend: { style: 'flag', flag: '--session' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + // Pi's ~34 provider keys share no common prefix, so they are deliberately NOT + // allowlisted here — same reasoning as today's PI_ only prefix. Pi users authenticate + // via `/login` or the server process's own env. + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['PI_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // pi's absent-config default is an interactive trust PROMPT the session user could + // just answer "yes" to, so omitting --approve is not itself a clamp — MATERIALIZE + // approveProjectTrust:false so buildPiCommand emits --no-approve outright. + privilegedParams: [{ param: 'approveProjectTrust', clampTo: false, materializeWhenAbsent: true }], + }, + overlays: { + credStore: { + rel: '.pi/agent', + seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'], + }, + }, +}; + +// GitHub Copilot CLI (`copilot`, npm `@github/copilot`, docs: +// https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli). +// Shipped DISABLED by default — unlike every other stock entry — because it is new to this +// catalog and its launch shape here is deliberately minimal (no --model/--resume flags: the +// upstream `--resume`/`--continue` pair opens an interactive picker or jumps to the most +// recent session rather than taking a session id directly, so there is no verified way to +// resume a SPECIFIC transcript yet; `resumeAppend` is left unset rather than guessed at). +// A user opts it in from Settings, same path as adding any other CLI. +const COPILOT: CliEntry = { + id: 'copilot' as CliEntry['id'], + label: 'GitHub Copilot', + shortBadge: 'GH', + accent: '#8957e5', + enabled: false, + stock: true, + order: 60, + kind: 'agent', + discovery: { + binaries: ['copilot'], + searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin], + version: { arg: '--version', regex: '(\\d+\\.\\d+\\.\\d+)' }, + install: { + command: { + linux: 'npm install -g @github/copilot', + darwin: 'npm install -g @github/copilot', + }, + npmPackage: '@github/copilot', + docsUrl: 'https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli', + }, + }, + launch: { + params: {}, + variants: [ + { + id: 'default', + args: [{ lit: 'copilot' }], + }, + ], + }, + env: { + exports: [], + unset: [], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + // Auth also flows through GH_TOKEN/GITHUB_TOKEN (checked ahead of COPILOT_GITHUB_TOKEN + // by the CLI itself), which are deliberately NOT allowlisted here: both are generic + // enough names that other tools use them too, and the multi-CLI prefix discipline + // (see CLAUDE.md) is one global allowlist, so admitting them would widen it for every + // mode at once. Authenticate via `/login` inside the session instead, same as Pi. + allowedPrefixes: ['COPILOT_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + }, + overlays: { + // ~/.copilot holds config, session history, logs and the plaintext auth fallback + // (docs.github.com/.../cli-config-dir-reference) — same "seed the whole directory" + // treatment as opencode's ~/.config/opencode. + credStore: { rel: '.copilot', seedWhole: true }, + }, +}; + +// Grok Build (xAI, `grok`). Ported from upstream Ark0N/Codeman's hardcoded 7th-mode +// addition (commit 3f8c8e99 + follow-ups 57f326ab, 9cfd8e89) into registry data — a real, +// established mode (enabled by default), not an experimental opt-in like Copilot. +const GROK: CliEntry = { + id: 'grok' as CliEntry['id'], + label: 'Grok', + shortBadge: 'GK', + // Upstream hand-authored a charcoal GRADIENT across 4+ CSS spots (welcome button, tab + // badge, run-mode dot, mobile skin overrides) rather than one flat colour; our registry's + // `accent` is a single hex, so this is the closest single value (the run-mode-dot colour, + // zinc-400) — every OTHER surface just gets this via the inline-accent fallback the same + // way Copilot does, since there is no bespoke `.welcome-btn-grok`/`.run-mode-dot.grok` + // CSS class in this fork. + accent: '#a1a1aa', + enabled: true, + stock: true, + order: 70, + kind: 'agent', + discovery: { + binaries: ['grok'], + searchDirs: ['~/.grok/bin', HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.homeBin], + // `grok` has a known npm squatter (@vibe-kit/grok-cli also installs a `grok` bin), so a + // bare `which grok` hit is not evidence of the right program — same defence as pi, + // byte-identical regex. + version: { arg: '--version', regex: '(?:^|\\s)(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true }, + install: { + command: { + linux: 'curl -fsSL https://x.ai/cli/install.sh | bash', + darwin: 'curl -fsSL https://x.ai/cli/install.sh | bash', + }, + // Not on npm — xAI ships a standalone installer/binary, same shape as Antigravity. + docsUrl: 'https://github.com/xai-org/grok-build', + }, + }, + launch: { + params: { + alwaysApprove: { type: 'bool' }, + model: { type: 'token', pattern: 'model' }, + resumeId: { type: 'token', pattern: 'id-dotted' }, + continueSession: { type: 'bool' }, + }, + variants: [ + { + id: 'default', + args: [ + { lit: 'grok' }, + { flag: '--always-approve', when: { param: 'alwaysApprove', is: true } }, + { flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } }, + { flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } }, + { + lit: '--continue', + when: { + allOf: [ + { param: 'continueSession', is: true }, + { param: 'resumeId', state: 'unset' }, + ], + }, + }, + ], + }, + ], + legacyConfigAliases: { resumeId: 'resumeSessionId' }, + resumeAppend: { style: 'flag', flag: '--resume' }, + }, + env: { + exports: [{ name: 'COLORTERM', value: 'truecolor' }], + unset: ['NO_COLOR'], + // No tmuxSetenvKeys: XAI_API_KEY (xAI's documented headless auth var) is covered by the + // XAI_ prefix allowlist below, same "rely on the prefix, not an explicit key list" + // reasoning as pi's ~34 provider keys. + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['GROK_', 'XAI_'], + allowedKeys: [], + }, + capabilities: { + ...agentDefaults(), + // Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity: + // only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. + altScreen: 'strip-mux-only', + // Buffer-policy fallthrough default, unmeasured against an authenticated grok composer + // (upstream's own hedge, preserved here) — same as gemini/antigravity/pi/copilot. + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + // codex/antigravity-shaped clamp: grok's own bare-spawn default (no config sent) is + // already its safe interactive ask-mode, so the multi-user clamp only needs to force an + // EXPLICITLY-SENT bypass flag back off — nothing is materialized when config is absent. + privilegedParams: [{ param: 'alwaysApprove', clampTo: false }], + }, + overlays: { + // ~/.grok also holds sessions/, memory/, downloads/ (the ~160MB binary), completions/, + // docs/, bin/ — per-file seeding like pi's credStore, not a whole-dir seedWhole copy. + credStore: { rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'] }, + // No remote/docker overlay needed: the defaults (exec grok / login-shell `grok`) are + // already correct — verified against upstream's own pinned test/grok-mode.test.ts + // expectation `exec "${SHELL:-/bin/sh}" -i -l -c 'grok'`. + }, +}; + +/** The full stock catalog, in the order the run menu shows by default. */ +export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, COPILOT, GROK]; diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts new file mode 100644 index 000000000..14bb96877 --- /dev/null +++ b/src/config/cli-registry/types.ts @@ -0,0 +1,321 @@ +/** + * @fileoverview Type definitions for the CLI registry — the single source of truth for + * which agent CLIs Codeman supports and how each one is discovered, launched and treated. + * + * This replaces the hard-coded `SessionMode` union and the ~123 per-mode branches that grew + * out of it. The guiding rule: NO code may branch on a CLI's id. Behaviour that genuinely + * differs between CLIs is expressed either as data here, or as a named PROFILE selected by + * a capability field (see profiles.ts) — never as `mode === 'codex'`. + * + * @module config/cli-registry/types + */ + +import type { TokenPattern } from './patterns.js'; + +/** + * A CLI identifier. Branded so an arbitrary string cannot be passed where a validated id is + * expected; construct with `asCliId()` at the API boundary. + */ +export type CliId = string & { readonly __cliId: unique symbol }; + +// --------------------------------------------------------------------------- +// Launch argv DSL +// --------------------------------------------------------------------------- + +/** Values the ENGINE supplies. Config may reference these by name but never author them. */ +export type EngineValue = + | 'sessionId' + | 'sessionName' + | 'muxName' + | 'effortLevel' + | 'effortSettingsJson' + /** `sessionId` prefixed `codeman_` — codex's unique per-pane rollout originator. */ + | 'codemanPrefixedSessionId'; + +/** + * A declared launch parameter. `token` params carry caller-supplied data and are therefore + * the only ones that need a pattern; `engine` params are produced in code. + */ +export type ParamSpec = + | { type: 'enum'; values: string[]; default?: string } + | { type: 'bool' } + | { type: 'token'; pattern: TokenPattern } + | { type: 'engine'; source: EngineValue }; + +/** A boolean guard over parameter state. */ +export type Cond = + | { param: string; is: string | boolean } + | { param: string; state: 'set' | 'unset' } + | { allOf: Cond[] } + | { anyOf: Cond[] } + | { not: Cond } + /** Names an entry in `capabilities.gates`. Fail-closed gates omit when version is unknown. */ + | { capabilityGate: string }; + +/** + * How a token is quoted when emitted into the bash command string. + * + * This exists ONLY to preserve byte-identical output with the hand-written builders being + * replaced (claude wraps its values in double quotes; the other builders emit bare words). + * It is never a safety lever: `renderToken()` verifies the value is metacharacter-free + * before honouring an explicit style, and falls back to single-quote escaping if it is not. + * So the worst a wrong `quote` can do is make output uglier, never unsafe. + */ +export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single'; + +/** One argv element. */ +export type ArgSpec = + /** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */ + | { lit: string; when?: Cond } + /** A valueless flag, e.g. `--no-approve`. */ + | { flag: string; when?: Cond } + /** A flag with a fixed literal value. */ + | { flag: string; value: string; quote?: QuoteStyle; when?: Cond } + /** A flag whose value comes from a declared param. */ + | { flag: string; valueFrom: string; quote?: QuoteStyle; when?: Cond } + /** A bare positional value from a param, e.g. codex's `resume `. */ + | { valueFrom: string; quote?: QuoteStyle; when?: Cond }; + +/** One alternative command form. */ +export interface CliVariant { + /** Stable name for diagnostics and tests, e.g. 'resume' / 'new'. */ + id: string; + when?: Cond; + args: ArgSpec[]; +} + +export interface CliLaunch { + params: Record; + /** + * 'first' — emit the first variant whose `when` passes (the usual case). + * 'fallback' — emit EVERY passing variant joined by the engine's own ` || `, which is how + * claude's `--resume X || --session-id Y` shell fallback is expressed without + * config ever containing shell text. The engine owns the operator. + */ + chain?: 'first' | 'fallback'; + variants: CliVariant[]; + /** + * Maps a declared param name to the field name it arrives under on the legacy + * `POST /api/sessions` wire shape (`OpenCodeConfig.continueSession`, etc — the per-mode + * config objects predate this registry and stay on the wire for compatibility). A param + * with no entry here is looked up under its own name. This is what lets the spawn-command + * bridge (`session-cli-registry-bridge.ts`) stay generic: it reads the raw legacy config + * object through this DATA-declared alias table instead of a per-mode `if (mode === ...)`. + */ + legacyConfigAliases?: Record; + /** + * How to APPEND a resume id onto an already-built base command, for the docker in-container + * "tmux was re-created, resume the surviving transcript" path (`appendResumeFlag` in + * tmux-manager.ts) — a narrower, append-only sibling of the full `variants` shape above, + * which builds a whole command from scratch. Absent = this CLI has no resume flag to + * append (shell, opencode: opencode's docker resume goes through its own config object). + */ + resumeAppend?: { style: 'flag'; flag: string } | { style: 'positional'; token: string }; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +export interface CliVersionProbe { + arg: string; + /** Serialized regex, applied to `--version` output only. See compileVersionRegex(). */ + regex?: string; + /** + * Treat a binary whose version output does not match as ABSENT rather than as + * present-with-unknown-version. For CLIs with short, generic binary names (`pi`), where a + * `which` hit is not by itself evidence the right program is installed. + */ + requireVersionMatch?: boolean; + /** Retry a failed probe with backoff instead of caching the failure (claude's behaviour). */ + retryOnTransientFailure?: boolean; +} + +export interface CliDiscovery { + /** + * Binary name(s), first hit wins. + * + * This is why the registry fixes a live bug: the mode name is NOT always the binary + * name (`antigravity` runs `agy`), and `probeDockerCliVersion` assumed it was. + */ + binaries: string[]; + /** Extra directories probed after `which`. A leading `~` expands to homedir; nothing else. */ + searchDirs: string[]; + version?: CliVersionProbe; + install: { + /** + * Shown verbatim in "CLI not found. Install with: ...". Executed by the server in + * exactly ONE place — `cli-installer.ts`'s `ensureCliInstalled`, and only as the direct + * result of an explicit `PUT /api/clis/:id/enabled {enabled:true}` call (never on boot, + * never implicitly). Read that module's file header before changing how or when this + * runs; it documents the trust boundary this exception relies on. + */ + command: Partial>; + /** Feeds generation of docker/agent.Dockerfile. */ + npmPackage?: string; + docsUrl?: string; + }; +} + +// --------------------------------------------------------------------------- +// Environment +// --------------------------------------------------------------------------- + +export interface CliEnv { + /** `export K=V` in the bash prelude. Values are literals or engine values, never secrets. */ + exports: Array<{ name: string; value: string | { engine: EngineValue }; when?: Cond }>; + /** `unset K` — e.g. claude's CLAUDECODE, the truecolor CLIs' NO_COLOR. */ + unset: string[]; + /** + * NAMES ONLY. Values are read from the server's own process.env and pushed via + * `tmux setenv`, so a secret is structurally unable to reach the command line. + */ + tmuxSetenvKeys: string[]; + /** NAMES ONLY, forwarded as `docker exec -e NAME`. */ + dockerExecEnvNames: string[]; + /** This entry's contribution to the env-override allowlist. Never widens BLOCKED_ENV_KEYS. */ + allowedPrefixes: string[]; + allowedKeys: string[]; + /** + * Env var carrying a JSON config blob pushed via `tmux setenv` (opencode's + * OPENCODE_CONFIG_CONTENT). Generic so it is not an opencode special case. + */ + configContentVar?: string; +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +/** + * The closed set of behavioural switches. Each field replaces an id-check somewhere. + * + * `hooks`, `transcript` and `altScreen` are INDEPENDENT on purpose. The three predicates + * they back (`hooksAvailableForMode`, `isExternalCliMode`, `isAltScreenStripMode`) describe + * three different, deliberately unequal sets, and deriving any one from another has already + * caused a real bug — a `shell` session has no hooks but is not an "external CLI", so + * `!isExternalCliMode()` wrongly accepted `until=stop` on it and hung for the full timeout. + * Keeping them as separate fields makes that invariant structural rather than commented. + */ +export interface CliCapabilities { + /** + * Non-Claude run mode that uses its own TUI and output format (`isExternalCliMode`): + * no Claude transcript, no hooks, no Claude-format token/BashTool parsing. An explicit + * field rather than derived from `hooks`/`kind`, precisely because it must stay + * independent — see this interface's own doc comment. + */ + external: boolean; + /** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */ + requiresMux: boolean; + /** Emits Codeman hook events, so `stop`/`blocked` wait signals can ever fire. */ + hooks: boolean; + /** Which transcript reader, if any, understands this CLI's on-disk history. */ + transcript: 'claude-jsonl' | 'codex-rollout' | 'none'; + /** + * 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs). + * 'strip-mux-only' — only tmux's own attach-time smcup (the safe default). + * 'preserve' — leave everything (a direct-PTY shell running vim/less/htop). + */ + altScreen: 'strip-full' | 'strip-mux-only' | 'preserve'; + echo: { + policy: 'buffer' | 'predict' | 'off'; + /** How the local-echo overlay locates the composer row. */ + anchor: { kind: 'glyph'; glyph: string; offset: number } | { kind: 'cursor' } | { kind: 'none' }; + /** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */ + predictProfile?: string; + }; + /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ + wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; + keyboardAccessory: 'agent' | 'shell'; + /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ + privilegedCommandGate: boolean; + startMode: 'interactive' | 'shell'; + stripInkBloat: boolean; + ralph: boolean; + respawn: boolean; + effort: boolean; + agentSkillInjection: boolean; + statusLineTelemetry: boolean; + /** Where a model override is delivered. Claude uniquely writes settings.local.json. */ + model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string }; + /** + * Params a non-granted multi-user owner may not set freely, and what they are forced to. + * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's. + * + * `materializeWhenAbsent` distinguishes two real shapes, not one: + * - only-if-sent (false/omitted; codex, antigravity, grok): the CLI's own + * absent-config default already spawns safe, so the clamp should only touch + * a config the caller actually sent. + * - materialize (true; gemini, pi): the absent-config default is ITSELF unsafe + * for a non-granted owner (gemini defaults to `yolo`; pi's absent default is + * an interactive trust prompt the session user could just answer "yes" to), + * so the clamp must CREATE a config object even when none was sent. + */ + privilegedParams: Array<{ param: string; clampTo: boolean | string; materializeWhenAbsent?: boolean }>; + /** Version gates referenced by `capabilityGate` conditions. */ + gates: Record; + /** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */ + maxFrameBytes?: number; +} + +// --------------------------------------------------------------------------- +// Location overlays (remote SSH / docker) +// --------------------------------------------------------------------------- + +/** Docker credential seeding policy — which host dirs are copied or shared into a container. */ +export interface CliCredStore { + rel: string; + shareDirs?: string[]; + shareFiles?: string[]; + seedFiles?: string[]; + seedWhole?: boolean; +} + +export interface CliOverlays { + /** + * The remote/docker DEFAULT pane command: just the CLI invocation (e.g. `claude + * --dangerously-skip-permissions`), independent of each location's own wrapping + * (remote: login-shell `-c`; docker: `exec`). Absent `command` = the bare + * `discovery.binaries[0]`. `disabled: true` = this location has no story for this CLI at + * all (docker for `shell`) — distinct from "no override", which still gets a default. + */ + remote?: { command?: string } | { disabled: true }; + docker?: { command?: string } | { disabled: true }; + credStore?: CliCredStore; +} + +// --------------------------------------------------------------------------- +// The entry +// --------------------------------------------------------------------------- + +export interface CliEntry { + id: CliId; + label: string; + /** Two-ish character tab badge, e.g. 'OC'. */ + shortBadge: string; + /** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */ + accent: string; + enabled: boolean; + /** Set by the loader from the shipped catalog; a user entry can never claim it. */ + stock: boolean; + order: number; + /** 'shell' unlocks the raw-shell code paths; everything else is an agent CLI. */ + kind: 'agent' | 'shell'; + discovery: CliDiscovery; + launch: CliLaunch; + env: CliEnv; + capabilities: CliCapabilities; + overlays: CliOverlays; +} + +/** The on-disk shape of ~/.codeman/clis.json — overrides and custom entries only. */ +export interface CliRegistryFile { + schemaVersion: number; + /** + * Stock ids already introduced to this install. The ratchet that lets one file both gain + * newly-shipped CLIs on upgrade AND remember that the user disabled one. + */ + seededStockIds: string[]; + /** Keyed by id: a partial override of a stock entry, or a complete custom entry. */ + clis: Record; +} diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 722f19b17..8e02da228 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -7,8 +7,7 @@ * @module config/dependency-registry */ -import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js'; -import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js'; +import { listClis } from './cli-registry/registry.js'; export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl'; @@ -57,6 +56,60 @@ export interface ToolDependency { const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32']; +/** + * Build a `codeman doctor` entry for one CLI registry entry, so its binary names, search + * behaviour, version probe and install hints are declared exactly ONCE — in the CLI + * registry's stock catalog — rather than duplicated here. `pi`'s `requireVersionMatch` and + * shared `PI_VERSION_REGEX` come along automatically, which is what keeps the doctor and the + * run mode from ever disagreeing about what counts as an installed `pi` (see pi-cli-resolver.ts). + * + * Only entries actually present in the registry are turned into doctor rows — a CLI a user + * has fully removed from `clis.json` doesn't get an orphaned dependency row either. + */ +function cliDependencyEntry(id: string, usedBy: string): ToolDependency | null { + const cli = listClis().find((e) => (e.id as unknown as string) === id); + if (!cli || cli.discovery.binaries.length === 0) return null; // e.g. `shell`, which has no binary + const version = cli.discovery.version; + const installHint: ToolDependency['installHint'] = {}; + for (const [platform, command] of Object.entries(cli.discovery.install.command)) { + if (command) installHint[platform as ProbeEnvironment] = command; + } + return { + id, + label: `${cli.label} CLI`, + category: 'core', + required: false, + usedBy: [usedBy], + resolvers: [ + { + match: ALL, + resolver: { + kind: 'path', + bins: cli.discovery.binaries, + versionArg: version?.arg ?? '--version', + versionRegex: version?.regex ? new RegExp(version.regex) : undefined, + requireVersionMatch: version?.requireVersionMatch, + }, + }, + ], + installHint: Object.keys(installHint).length > 0 ? installHint : undefined, + }; +} + +/** `usedBy` text for each CLI's doctor row, matching the historical copy per id. */ +const CLI_USED_BY: Record = { + claude: 'Claude Code sessions (default backend)', + opencode: 'OpenCode sessions', + codex: 'Codex sessions', + gemini: 'Gemini sessions', + antigravity: 'Antigravity sessions', + pi: 'Pi sessions', +}; + +const CLI_DEPENDENCY_ENTRIES: ToolDependency[] = Object.entries(CLI_USED_BY) + .map(([id, usedBy]) => cliDependencyEntry(id, usedBy)) + .filter((entry): entry is ToolDependency => entry !== null); + export const DEPENDENCY_REGISTRY: ToolDependency[] = [ { id: 'node', @@ -67,15 +120,6 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }], installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' }, }, - { - id: 'claude', - label: 'Claude CLI', - category: 'core', - required: false, - usedBy: ['Claude Code sessions (default backend)'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }], - installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' }, - }, { id: 'tmux', label: 'tmux', @@ -84,85 +128,7 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [ resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }], installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' }, }, - { - id: 'opencode', - label: 'OpenCode CLI', - category: 'core', - required: false, - usedBy: ['OpenCode sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }], - }, - { - id: 'codex', - label: 'Codex CLI', - category: 'core', - required: false, - usedBy: ['Codex sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }], - }, - { - id: 'gemini', - label: 'Gemini CLI', - category: 'core', - required: false, - usedBy: ['Gemini sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }], - }, - { - id: 'antigravity', - label: 'Antigravity CLI', - category: 'core', - required: false, - usedBy: ['Antigravity sessions'], - resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }], - }, - { - id: 'pi', - label: 'Pi CLI', - category: 'core', - required: false, - usedBy: ['Pi sessions'], - // The only entry that requires a version match, for the same reason - // pi-cli-resolver.ts probes: `pi` is a short generic name (Raspberry Pi tooling, - // personal scripts), so a `which pi` hit alone is not the coding agent. Both sides - // share PI_VERSION_REGEX, so the doctor and the run mode cannot drift into telling - // the user opposite things about the same binary. - resolvers: [ - { - match: ALL, - resolver: { - kind: 'path', - bins: ['pi'], - versionArg: '--version', - versionRegex: PI_VERSION_REGEX, - requireVersionMatch: true, - }, - }, - ], - }, - { - id: 'grok', - label: 'Grok CLI', - category: 'core', - required: false, - usedBy: ['Grok sessions'], - // Version match required for the same reason as pi: `grok` has known squatters - // (the unrelated @vibe-kit/grok-cli npm package also installs a `grok` bin), so a - // bare `which grok` hit is not the coding agent. Both sides share - // GROK_VERSION_REGEX, so the doctor and the run mode cannot drift. - resolvers: [ - { - match: ALL, - resolver: { - kind: 'path', - bins: ['grok'], - versionArg: '--version', - versionRegex: GROK_VERSION_REGEX, - requireVersionMatch: true, - }, - }, - ], - }, + ...CLI_DEPENDENCY_ENTRIES, { id: 'libreoffice', label: 'LibreOffice', diff --git a/src/docker-hosts.ts b/src/docker-hosts.ts index 40319d244..4704f08d9 100644 --- a/src/docker-hosts.ts +++ b/src/docker-hosts.ts @@ -30,9 +30,10 @@ import { createHash } from 'node:crypto'; import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { dataPath } from './config/instance.js'; +import { getCli, listClis } from './config/cli-registry/registry.js'; +import type { CliCredStore } from './config/cli-registry/types.js'; import type { DockerCase, - DockerCommandMode, DockerEngine, DockerHost, DockerNetworkMode, @@ -134,20 +135,22 @@ export function dockerContainerName(caseName: string): string { return `${CONTAINER_NAME_PREFIX}${caseName}`; } -/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */ +/** + * Default in-container pane command per CLI mode (mirror of defaultRemoteCommandForMode). + * Reads the registry's `overlays.docker` default and falls back to the bare + * `discovery.binaries[0]` when the entry declares no override — the docker analog of + * remote-hosts.ts's `defaultRemoteCommandForMode`, minus the login-shell `-c` wrapping + * (a container's `exec` already runs as the container user with its own PATH). + */ export function defaultDockerCommandForMode(mode: SessionMode): string { - const commands: Record = { - shell: 'exec bash -l', - // Mirror the LOCAL claude default so the in-container agent runs non-interactively. - claude: 'exec claude --dangerously-skip-permissions', - opencode: 'exec opencode', - codex: 'exec codex', - gemini: 'exec gemini', - antigravity: 'exec agy', - pi: 'exec pi', - grok: 'exec grok', - }; - return commands[mode as DockerCommandMode] || commands.shell; + const entry = getCli(mode); + if (!entry || entry.kind === 'shell') return 'exec bash -l'; + + const overlay = entry.overlays.docker; + if (overlay && 'disabled' in overlay) return 'exec bash -l'; + + const command = overlay?.command ?? entry.discovery.binaries[0]; + return command ? `exec ${command}` : 'exec bash -l'; } /** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */ @@ -583,52 +586,23 @@ export function resolveDockerClaudeArtifacts( * seeded. The other three have no host-read/resume dependency and are fully * seed-copied (writable copy in the container, no write-back to the host). */ -interface CredStorePolicy { - /** Path relative to HOME (host + container), e.g. '.codex' or '.config/gcloud'. */ - rel: string; - /** Subdirs bind-mounted RW (shared: resume + host reads). */ - shareDirs?: string[]; - /** Files bind-mounted RW (append-only, e.g. codex history.jsonl — never renamed). */ - shareFiles?: string[]; - /** Files seeded (RO mount → cp) into the container's own copy. */ - seedFiles?: string[]; - /** Seed the WHOLE dir (RO mount → cp -a) — for stores with no shared/host-read state. */ - seedWhole?: boolean; +/** + * Every registered CLI's credential-store policy (`overlays.credStore` in + * config/cli-registry/stock.ts — codex, gemini, pi, opencode today), plus the one entry + * that belongs to no single CLI: `.config/gcloud` is the general Google Cloud SDK + * credential store, which gemini's Vertex AI auth path and other tools may read + * regardless of run mode, so it is not owned by any one entry's `credStore` field. + * Antigravity needs no entry of its own: `agy` nests its whole state (auth + * `jetski_state.pbtxt`, `conversations/`, `knowledge/`) under `~/.gemini/antigravity-cli/`, + * which gemini's `seedWhole` entry already covers — there is no `~/.antigravity` dir. + */ +function collectCredStores(): CliCredStore[] { + const fromRegistry = listClis() + .map((entry) => entry.overlays.credStore) + .filter((store): store is CliCredStore => store !== undefined); + return [...fromRegistry, { rel: '.config/gcloud', seedWhole: true }]; } -const CRED_STORES: CredStorePolicy[] = [ - { rel: '.codex', shareDirs: ['sessions'], shareFiles: ['history.jsonl'], seedFiles: ['auth.json', 'config.toml'] }, - // Also covers Antigravity: `agy` nests its whole state (auth `jetski_state.pbtxt`, - // `conversations/`, `knowledge/`) under `~/.gemini/antigravity-cli/`, so it needs no - // entry of its own. There is no `~/.antigravity` credential dir to add. - { rel: '.gemini', seedWhole: true }, - // Pi (pi.dev) keeps auth + config in `~/.pi/agent`, but that dir ALSO holds - // `sessions/`, `extensions/`, `skills/` and the installed package trees - // (`npm/`, `git/`) — easily gigabytes on an active host, so seedWhole would - // `cp -a` all of it into every container start. Seed only what pi needs to - // authenticate and behave consistently; `models.json` is in the list because it - // holds user-defined custom providers. Consequence to document: in-container pi - // sessions are invisible host-side, so `pi -c` inside a Docker case only sees - // that container's own history (unlike codex, whose `sessions/` is shared RW - // precisely because Codeman reads it host-side). - { - rel: '.pi/agent', - seedFiles: ['auth.json', 'settings.json', 'trust.json', 'models.json', 'models-store.json'], - }, - // Grok (xAI) keeps auth + config in `~/.grok`, but that dir ALSO holds - // `sessions/`, `memory/`, `downloads/` (the ~100MB binary itself) and `bin/`, - // so seedWhole would copy all of it into every container start. Seed only what - // grok needs to authenticate and behave consistently. Same trade-off as pi: - // in-container grok sessions are invisible host-side, so `grok -c` inside a - // Docker case only sees that container's own history. - { - rel: '.grok', - seedFiles: ['auth.json', 'config.toml', 'pager.toml'], - }, - { rel: '.config/gcloud', seedWhole: true }, - { rel: '.config/opencode', seedWhole: true }, -]; - /** * Resolve the ISOLATED codex/gemini/gcloud/opencode artifacts (replaces the old * whole-dir RW mounts that let each in-container CLI write its refreshed tokens + @@ -638,7 +612,7 @@ const CRED_STORES: CredStorePolicy[] = [ export function resolveDockerCredentialArtifacts(home: string = homedir()): DockerClaudeArtifacts { const mounts: DockerMount[] = []; const seedCopies: DockerSeedCopy[] = []; - for (const store of CRED_STORES) { + for (const store of collectCredStores()) { const hostBase = join(home, store.rel); if (!existsSync(hostBase)) continue; const containerBase = `${CONTAINER_HOME}/${store.rel}`; @@ -1054,6 +1028,21 @@ export async function reapOrphanedDockerContainers( return reaped; } +/** + * Resolve the in-container binary name to probe for a mode's version. + * + * The binary name is NOT always the mode id — antigravity's mode is `antigravity` but + * its binary is `agy` — so this reads the CLI registry's `discovery.binaries[0]` rather + * than assuming they match, which is what the old `mode === 'shell' ? null : mode` check + * got wrong (it would have probed a nonexistent `antigravity` binary in-container). + * `shell`, and any mode with no declared binaries, yields undefined. Exported as a pure + * function so the fix is unit-testable without VITEST's `IS_TEST_MODE` short-circuit + * standing in the way. + */ +export function binaryForDockerProbe(mode: SessionMode): string | undefined { + return getCli(mode)?.discovery.binaries[0]; +} + /** * Read the IN-CONTAINER Claude CLI version (`docker exec claude * --version`). Feeds Session.cliVersion for docker sessions (the LOCAL claude @@ -1065,7 +1054,7 @@ export async function probeDockerCliVersion( mode: SessionMode ): Promise { if (IS_TEST_MODE) return undefined; - const bin = mode === 'shell' ? null : mode; + const bin = binaryForDockerProbe(mode); if (!bin) return undefined; const argv = dockerEngineArgv(docker); try { diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 617c986ab..71a1f6fa9 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -6,13 +6,13 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import type { RemoteCase, - RemoteCommandMode, RemoteHost, RemoteSessionInfo, RemoteSshOptions, SessionMode, SessionRemote, } from './types.js'; +import { getCli } from './config/cli-registry/registry.js'; const execAsync = promisify(exec); @@ -89,34 +89,30 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } +/** `exec $SHELL -i -l`, no `-c` — the remote user's actual login shell, interactive. */ +function remoteLoginShellOnly(): string { + // $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, + // so this launches their actual login shell (zsh, fish, etc.). -i -l so it sources rc + // files (~/.zshrc etc.), matching the local shell-mode launch. + return `exec ${REMOTE_LOGIN_SHELL} -i -l`; +} + export function defaultRemoteCommandForMode(mode: SessionMode): string { - // Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed - // under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by - // the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's - // remote-command execution is neither interactive nor login, so a bare `exec - // claude` sees only sshd's minimal default PATH and fails with "command not - // found" (exit 127) — confirmed via `tmux capture-pane` on the - // remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same - // fix already used for shell mode below, so PATH is fully resolved before the - // CLI name is looked up. - const commands: Record = { - // $SHELL, not a hardcoded bash: sshd sets it from the remote user's - // /etc/passwd entry, so this launches their actual login shell (zsh, - // fish, etc.). -i -l so it sources rc files (~/.zshrc etc.), matching - // the local shell-mode launch. - shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`, - // Mirror the LOCAL claude default so the remote agent runs non-interactively - // (no trust-folder/permission prompt that nothing on the remote answers). The - // per-host `commands.claude` override stays the escape hatch. - claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'), - opencode: remoteLoginShellCommand('opencode'), - codex: remoteLoginShellCommand('codex'), - gemini: remoteLoginShellCommand('gemini'), - antigravity: remoteLoginShellCommand('agy'), - pi: remoteLoginShellCommand('pi'), - grok: remoteLoginShellCommand('grok'), - }; - return commands[mode as RemoteCommandMode] || commands.shell; + const entry = getCli(mode); + if (!entry || entry.kind === 'shell') return remoteLoginShellOnly(); + + const overlay = entry.overlays.remote; + if (overlay && 'disabled' in overlay) return remoteLoginShellOnly(); + + // Agent CLIs are typically installed under per-user paths like ~/.local/bin or + // ~/.opencode/bin, added to PATH only by the remote user's interactive-login shell + // startup files (~/.zshrc etc.). ssh's remote-command execution is neither interactive + // nor login, so a bare `exec claude` sees only sshd's minimal default PATH and fails + // with "command not found" (exit 127) — confirmed via `tmux capture-pane` on the + // remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c` so PATH is fully + // resolved before the CLI name is looked up. + const command = overlay?.command ?? entry.discovery.binaries[0]; + return command ? remoteLoginShellCommand(command) : remoteLoginShellOnly(); } export function remoteSshTarget(host: Pick): string { @@ -258,20 +254,6 @@ export async function checkRemoteTmuxAvailable( } } -/** - * The CLI binary each session mode runs on the remote host. Antigravity's - * binary is `agy` (the mode name is not the command); shell has no CLI to - * probe, so it is absent. - */ -const REMOTE_CLI_BIN: Partial> = { - claude: 'claude', - opencode: 'opencode', - codex: 'codex', - gemini: 'gemini', - antigravity: 'agy', - pi: 'pi', -}; - /** * Build the SSH command that reads the remote CLI's version (`claude --version` * on the remote host). The version query is routed through @@ -280,13 +262,15 @@ const REMOTE_CLI_BIN: Partial> = { * interactive-login startup files run (see defaultRemoteCommandForMode); a bare * `claude --version` over ssh exits 127. Connection options come from the * shared `buildSshConnectionArgs`, so the probe reaches exactly the hosts the - * launch can reach. Returns null for modes with no CLI (shell). + * launch can reach. Returns null for modes with no CLI (shell) — the registry's own + * `discovery.binaries[0]` is the "CLI binary each mode runs" lookup (e.g. antigravity's + * mode is `antigravity` but its binary is `agy`), so there is nothing to duplicate here. */ export function buildRemoteCliVersionProbeCommand( host: Pick & RemoteSshOptions, mode: SessionMode ): string | null { - const bin = REMOTE_CLI_BIN[mode]; + const bin = getCli(mode)?.discovery.binaries[0]; if (!bin) return null; return [ ...buildSshConnectionArgs(host), diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts new file mode 100644 index 000000000..c45fe3b39 --- /dev/null +++ b/src/session-cli-registry-bridge.ts @@ -0,0 +1,159 @@ +/** + * @fileoverview Bridges the legacy per-mode spawn options (`buildSpawnCommand`'s option bag + * in tmux-manager.ts, unchanged on the wire since before this registry existed) onto the CLI + * registry's generic argv engine (`renderLaunch`). + * + * This is the one place allowed to know the shape of the five legacy `Config` objects + * and claude's discrete top-level fields — a genuine API-compatibility concern (the public + * `POST /api/sessions` / `/api/quick-start` request shape is unchanged, see + * `docs/versioning-policy.md`), not a reintroduction of per-CLI command-building logic. The + * actual TRANSLATION from a legacy field name to a registry param name is DATA + * (`CliLaunch.legacyConfigAliases`, declared once per entry in `config/cli-registry/stock.ts`), + * so this file stays a generic reader of that data rather than a per-mode `if` chain. + * + * @module session-cli-registry-bridge + */ + +import type { CliEntry } from './config/cli-registry/types.js'; +import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js'; +import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js'; +import { compareVersions } from './utils/dependency-checker.js'; +import { getClaudeCliVersion } from './utils/claude-cli-resolver.js'; +import type { + AntigravityConfig, + ClaudeMode, + CodexConfig, + EffortLevel, + GeminiConfig, + GrokConfig, + OpenCodeConfig, + PiConfig, +} from './types/session.js'; + +export interface SpawnBridgeOptions { + mode: string; + sessionId: string; + model?: string; + claudeMode?: ClaudeMode; + allowedTools?: string; + openCodeConfig?: OpenCodeConfig; + codexConfig?: CodexConfig; + geminiConfig?: GeminiConfig; + antigravityConfig?: AntigravityConfig; + piConfig?: PiConfig; + grokConfig?: GrokConfig; + resumeSessionId?: string; + effort?: EffortLevel; + sessionName?: string; + claudeCliVersion?: string | null; +} + +/** + * The legacy "raw config" object for each mode, as it already exists on `SpawnBridgeOptions`. + * Claude has no config object of its own (its fields were always discrete top-level options, + * predating every other mode's `Config` shape), so it is synthesized here from those + * discrete fields — the one place this bridge treats claude specially, and only to reproduce + * a pre-existing API shape difference, not to build its command. + */ +function legacyConfigFor(options: SpawnBridgeOptions): Record | undefined { + switch (options.mode) { + case 'claude': + return { + claudeMode: options.claudeMode, + allowedTools: options.allowedTools, + model: options.model, + resumeSessionId: options.resumeSessionId, + }; + case 'opencode': + return options.openCodeConfig as unknown as Record | undefined; + case 'codex': + return options.codexConfig as unknown as Record | undefined; + case 'gemini': + return options.geminiConfig as unknown as Record | undefined; + case 'antigravity': + return options.antigravityConfig as unknown as Record | undefined; + case 'pi': + return options.piConfig as unknown as Record | undefined; + case 'grok': + return options.grokConfig as unknown as Record | undefined; + default: + return undefined; + } +} + +/** + * Build `ParamValues` for every declared `token`/`bool`/`enum` param by reading it out of the + * legacy config object through `legacyConfigAliases` (falling back to the param's own name). + * `engine`-sourced params are skipped — those come from `EngineValues`, never legacy config. + */ +function buildParamsFromLegacyConfig(entry: CliEntry, rawConfig: Record | undefined): ParamValues { + const params: ParamValues = {}; + if (!rawConfig) return params; + const aliases = entry.launch.legacyConfigAliases ?? {}; + for (const [paramName, spec] of Object.entries(entry.launch.params)) { + if (spec.type === 'engine') continue; + const legacyKey = aliases[paramName] ?? paramName; + const value = rawConfig[legacyKey]; + if (value === undefined) continue; + if (typeof value === 'string' || typeof value === 'boolean') { + params[paramName] = value; + } + } + return params; +} + +/** + * Which `capabilities.gates` are currently satisfied. `resolveVersion` is called AT MOST + * ONCE, and only when the entry actually declares a gate — a `claude --version` (or any + * other CLI's) subprocess probe has no reason to run for an entry with none. + */ +function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null): Set { + const passed = new Set(); + const gateEntries = Object.entries(entry.capabilities.gates); + if (gateEntries.length === 0) return passed; + const cliVersion = resolveVersion(); + if (!cliVersion) return passed; // fail-closed: unknown version satisfies no gate + for (const [name, gate] of gateEntries) { + if (compareVersions(cliVersion, gate.minVersion) >= 0) passed.add(name); + } + return passed; +} + +/** + * Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a + * `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall + * back to the local login-shell resolution" — shell has no CLI to template. + */ +export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBridgeOptions): string | undefined { + if (entry.kind === 'shell' || entry.launch.variants.length === 0) return undefined; + + const params = buildParamsFromLegacyConfig(entry, legacyConfigFor(options)); + + const engineValues: EngineValues = { + sessionId: options.sessionId, + // Allowlist-sanitized (Unicode letters/digits + ` . _ : -`, 64 chars), matching + // buildNameCliArgs exactly — sanitizeCliSessionName is the injection guard for this + // value, not the `quote: 'double'` escaping on the --name arg (which only makes an + // UNSAFE value inert, it does not launder one into something meaningful). + sessionName: sanitizeCliSessionName(options.sessionName), + }; + // Mirrors buildEffortCliArgs exactly: ultracode carries a fixed settings blob, every other + // level rides a plain `--effort ` flag. Reusing the canonical builder here (rather + // than re-deriving the ultracode special-case) keeps the EFFORT_LEVELS allowlist and the + // settings-JSON shape single-sourced in session-cli-builder.ts. + const [effortFlag, effortValue] = buildEffortCliArgs(options.effort); + if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue; + else if (effortFlag === '--effort') engineValues.effortLevel = effortValue; + + // Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes + // the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means + // "known to be unresolvable" and must not probe. Only claude declares a version gate today + // — the probe itself only ever runs from resolveGatesPassed, and only when an entry + // actually has a gate, so this stays generic without spawning a stray `claude --version` + // for every other CLI's launch. + const gatesPassed = resolveGatesPassed(entry, () => + options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion() + ); + + return renderLaunch(entry.launch, params, engineValues, gatesPassed); +} diff --git a/src/session.ts b/src/session.ts index 593ffc379..cdf7b6185 100644 --- a/src/session.ts +++ b/src/session.ts @@ -57,6 +57,7 @@ import { } from './types.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; +import { getCli } from './config/cli-registry/registry.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -170,37 +171,18 @@ const CTRL_L_PATTERN = /\x0c/g; /** Pattern to split by newlines (CR or LF) */ const NEWLINE_SPLIT_PATTERN = /\r?\n/; -/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */ +/** + * True for external-CLI run modes (non-Claude) that use their own TUI and output format. + * Backed by the registry's `capabilities.external` flag rather than an id list — see + * `CliCapabilities`'s own doc comment for why `external`/`hooks`/`transcript` are kept as + * three INDEPENDENT fields instead of deriving one from another. + */ export function isExternalCliMode(mode: SessionMode): boolean { - return ( - mode === 'opencode' || - mode === 'codex' || - mode === 'gemini' || - mode === 'antigravity' || - mode === 'pi' || - mode === 'grok' - ); + return getCli(mode)?.capabilities.external ?? true; } function getModeLabel(mode: SessionMode): string { - switch (mode) { - case 'opencode': - return 'OpenCode'; - case 'codex': - return 'Codex'; - case 'gemini': - return 'Gemini'; - case 'antigravity': - return 'Antigravity'; - case 'pi': - return 'Pi'; - case 'grok': - return 'Grok'; - case 'shell': - return 'Shell'; - case 'claude': - return 'Claude'; - } + return getCli(mode)?.label ?? mode; } /** @@ -229,7 +211,7 @@ function getModeLabel(mode: SessionMode): string { * vim inside a tmux `shell` session. */ export function isAltScreenStripMode(mode: SessionMode): boolean { - return mode === 'codex' || mode === 'claude' || mode === 'gemini'; + return getCli(mode)?.capabilities.altScreen === 'strip-full'; } /** @@ -1900,25 +1882,12 @@ export class Session extends EventEmitter { // Fallback to direct PTY if mux is not used if (!this.ptyProcess) { - // OpenCode sessions require tmux for env var injection (API keys via setenv) - if (this.mode === 'opencode') { - throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.'); - } - // Codex sessions require tmux for OPENAI_API_KEY injection via setenv - if (this.mode === 'codex') { - throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.'); - } - // Gemini sessions require tmux for Gemini/Google auth env injection via setenv - if (this.mode === 'gemini') { - throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.'); - } - // Antigravity sessions require tmux for env override injection via setenv - if (this.mode === 'antigravity') { - throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.'); - } - // Pi sessions require tmux for env override injection via setenv - if (this.mode === 'pi') { - throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.'); + // A CLI whose secrets ride tmux setenv (API keys, auth env) has no direct-PTY + // equivalent — there is nowhere else to inject them without putting a secret on + // the spawn command line. `capabilities.requiresMux` names that set; it used to + // be five separate `this.mode === ''` checks, one per external CLI. + if (getCli(this.mode)?.capabilities.requiresMux) { + throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`); } // Grok sessions require tmux for XAI_API_KEY / GROK_* injection via setenv if (this.mode === 'grok') { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index a97bc33cd..6beee87c0 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -57,7 +57,8 @@ import { type SessionDocker, type DockerCommandMode, } from './types.js'; -import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js'; +import { buildSpawnCommandFromRegistry } from './session-cli-registry-bridge.js'; +import { getCli, missingCliMessage } from './config/cli-registry/registry.js'; import { buildSshConnectionArgs, defaultRemoteCommandForMode, @@ -77,27 +78,8 @@ import { type DockerMount, type DockerSeedCopy, } from './docker-hosts.js'; -import { - wrapWithNice, - SAFE_PATH_PATTERN, - findClaudeDir, - getClaudeCliVersion, - getClaudeNotFoundMessage, - resolveOpenCodeDir, - getOpenCodeNotFoundMessage, - resolveCodexDir, - getCodexNotFoundMessage, - resolveGeminiDir, - getGeminiNotFoundMessage, - resolveAntigravityDir, - getAntigravityNotFoundMessage, - resolvePiDir, - getPiNotFoundMessage, - resolveGrokDir, - getGrokNotFoundMessage, - resolveLocalShell, - loginShellArgs, -} from './utils/index.js'; +import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js'; +import { resolveCliBinDir } from './utils/cli-resolver.js'; import type { TerminalMultiplexer, MuxSession, @@ -640,244 +622,34 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri } /** - * Build the opencode CLI command with appropriate flags. - */ -function buildOpenCodeCommand(config?: OpenCodeConfig): string { - const parts = ['opencode']; - - // Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes) - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - // Continue existing session - if (config?.continueSession) { - const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined; - if (safeId) parts.push('--session', safeId); - if (safeId && config.forkSession) parts.push('--fork'); - } - - return parts.join(' '); -} - -/** - * Build the codex CLI command with appropriate flags. - * - * Codeman launches Codex's native TUI and handles replay/scrollback by - * stripping destructive terminal sequences before xterm.js sees them. + * Build the codex CLI command with appropriate flags. Thin wrapper over the CLI registry's + * argv engine (see `buildSpawnCommand` below); kept as its own exported function only + * because `test/tmux-manager.test.ts` calls it directly with a bare `CodexConfig`. */ export function buildCodexCommand(config?: CodexConfig): string { - const parts = ['codex']; - - if (config?.dangerouslyBypassApprovals) { - parts.push('--dangerously-bypass-approvals-and-sandbox'); - } - - if (config?.animations !== undefined) { - parts.push('--config', `tui.animations=${config.animations ? 'true' : 'false'}`); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeSessionId) { - const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeId) parts.push('resume', safeId); - } - - return parts.join(' '); -} - -/** - * Build the Gemini CLI command with appropriate flags. - * - * `--skip-trust` avoids a first-run workspace trust prompt inside Codeman. - * Approval mode defaults to `yolo` for parity with Codeman's Claude default - * of `--dangerously-skip-permissions`; users can override it later through - * Gemini config once Codeman exposes richer Gemini settings. - */ -function buildGeminiCommand(config?: GeminiConfig): string { - const parts = ['gemini', '--skip-trust']; - - const approvalMode = config?.approvalMode || 'yolo'; - if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) { - parts.push('--approval-mode', approvalMode); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeSession) { - const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined; - if (safeId) parts.push('--resume', safeId); - } - - return parts.join(' '); -} - -/** - * Build the Antigravity CLI (agy) command with appropriate flags. - * - * Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added - * when the config explicitly asks for it (the frontend sends it for parity with - * Codeman's Claude default; the multi-user clamp strips it for non-granted owners, - * and an ABSENT config stays at agy's own prompting default — safe like Codex). - */ -function buildAntigravityCommand(config?: AntigravityConfig): string { - const parts = ['agy']; - - if (config?.dangerouslySkipPermissions) { - parts.push('--dangerously-skip-permissions'); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.resumeConversationId) { - const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined; - if (safeId) parts.push('--conversation', safeId); - } - - return parts.join(' '); -} - -/** Pi's `--thinking` levels. Runtime allowlist — defense in depth beyond the Zod enum. */ -const PI_THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']); - -/** - * Build the Pi CLI (pi.dev) command with appropriate flags. - * - * Pi has NO permission prompts and no `--dangerously-skip-permissions` analog, so - * there is deliberately nothing bypass-shaped here. The privileged knob is the - * TRI-STATE `approveProjectTrust`: `true` -> `--approve` (trust repo-local `.pi/` - * config, which means loading and EXECUTING repository TypeScript and installing - * missing project packages), `false` -> `--no-approve` (force-deny, used by the - * multi-user clamp so the trust prompt never appears), absent -> pi's own - * `defaultProjectTrust`. - * - * `--api-key` is deliberately NEVER wired: it would put a provider secret on the - * spawn command line (visible in `ps` and tmux state), which is exactly what the - * socket-scoped `tmux setenv` discipline exists to prevent. - * - * Like the sibling builders, every user value is regex-allowlisted and silently - * DROPPED on failure — the result is interpolated into a `bash -c "..."` string. - */ -function buildPiCommand(config?: PiConfig): string { - const parts = ['pi']; - - if (config?.approveProjectTrust === true) { - parts.push('--approve'); - } else if (config?.approveProjectTrust === false) { - parts.push('--no-approve'); - } - - if (config?.model) { - // `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` (`openai/gpt-4o`). - const safeModel = /^[a-zA-Z0-9._\-/:]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - if (config?.provider) { - const safeProvider = /^[a-z0-9-]+$/.test(config.provider) ? config.provider : undefined; - if (safeProvider) parts.push('--provider', safeProvider); - } - - if (config?.thinking && PI_THINKING_LEVELS.has(config.thinking)) { - parts.push('--thinking', config.thinking); - } - - // --session and -c conflict; a valid explicit session id wins. - const safeSessionId = - config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeSessionId) { - parts.push('--session', safeSessionId); - } else if (config?.continueSession) { - parts.push('-c'); - } - - return parts.join(' '); -} - -/** - * Build the Grok Build CLI (xAI `grok`) command with appropriate flags. - * - * The bypass switch is `--always-approve` ("auto-approve all tool executions", - * grok's `bypassPermissions` permission mode; config-level deny rules still - * apply on top). Absent config spawns bare `grok`, i.e. grok's own default - * ask-mode, which is why the multi-user clamp only needs the only-if-sent - * branch for grok. Flag surface verified against grok 1.0.5. - * - * `XAI_API_KEY` is deliberately never wired as a flag: secrets flow through - * socket-scoped `tmux setenv` (envOverrides), never the spawn command line. - * - * Like the sibling builders, every user value is regex-allowlisted and silently - * DROPPED on failure: the result is interpolated into a `bash -c "..."` string. - */ -function buildGrokCommand(config?: GrokConfig): string { - const parts = ['grok']; - - if (config?.alwaysApprove) { - parts.push('--always-approve'); - } - - if (config?.model) { - const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; - if (safeModel) parts.push('--model', safeModel); - } - - // --resume and -c conflict; a valid explicit session id wins. Ids only: - // grok's --resume also accepts session TITLES, which are arbitrary user - // strings, so the id regex doubles as the no-titles rule here. - const safeSessionId = - config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined; - if (safeSessionId) { - parts.push('--resume', safeSessionId); - } else if (config?.continueSession) { - parts.push('--continue'); - } - - return parts.join(' '); + const codex = getCli('codex'); + if (!codex) return 'codex'; // registry corrupt/empty — degrade to the bare binary, never throw + return ( + buildSpawnCommandFromRegistry(codex, { + mode: 'codex', + sessionId: '', // codex's launch spec never references sessionId + codexConfig: config, + }) ?? 'codex' + ); } /** - * Build the spawn command for any session mode. - * Shared by createSession() and respawnPane() to avoid duplication. - */ -/** - * Build the shell fragment carrying the effort level as a SOFT default - * (see buildEffortCliArgs — `--effort ` for regular levels incl. max, - * `--settings '{"ultracode":true}'` for ultracode; deliberately not the - * CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching). + * Build the spawn command for any session mode. Shared by createSession() and + * respawnPane() to avoid duplication. * - * Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside - * buildEffortCliArgs, so the single-quoted values contain no user-controlled characters. - */ -function buildEffortSettingsFlag(effort?: EffortLevel): string { - const [flag, value] = buildEffortCliArgs(effort); - return flag && value ? ` ${flag} '${value}'` : ''; -} - -/** - * Build the ` --name ""` shell fragment, or '' when it must be - * omitted. Version-gated FAIL-CLOSED in buildNameCliArgs (an older/unknown CLI - * aborts startup on an unknown flag, which would kill every claude spawn), and - * the value is allowlist-sanitized there, so it contains none of the characters - * that are special inside this double-quoted interpolation. The peer name is a - * soft default (in-session /rename still wins), which is why this rides the - * spawn command rather than any persisted config. + * Every mode but `shell` renders through the CLI registry's argv engine + * (`buildSpawnCommandFromRegistry` in session-cli-registry-bridge.ts): the per-mode flag + * logic that used to live here (buildOpenCodeCommand, buildGeminiCommand, + * buildAntigravityCommand, buildPiCommand, and claude's own resume/model/effort/name + * assembly) is now DATA in config/cli-registry/stock.ts, proven byte-identical to the old + * hand-written builders by test/cli-registry-spawn-bridge-parity.test.ts. `shell` has no CLI + * to template — it resolves the actual login shell in code below, unchanged. */ -function buildClaudeNameFlag(sessionName: string | undefined, cliVersion: string | null): string { - const [flag, value] = buildNameCliArgs(sessionName, cliVersion); - return flag && value ? ` ${flag} "${value}"` : ''; -} - export function buildSpawnCommand(options: { mode: SessionMode; sessionId: string; @@ -902,45 +674,10 @@ export function buildSpawnCommand(options: { */ claudeCliVersion?: string | null; }): string { - if (options.mode === 'claude') { - // Validate model to prevent command injection - const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined; - const modelFlag = safeModel ? ` --model "${safeModel}"` : ''; - const effortFlag = buildEffortSettingsFlag(options.effort); - const nameFlag = buildClaudeNameFlag( - options.sessionName, - options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion() - ); - // Use --resume to restore a previous conversation, otherwise --session-id for new sessions. - // Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.), - // fall back to a new session with --session-id so the pane doesn't die. - const safeResumeId = - options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined; - const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools); - if (safeResumeId) { - const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}${nameFlag}`; - const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`; - return `${resumeCmd} || ${fallbackCmd}`; - } - return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`; - } - if (options.mode === 'opencode') { - return buildOpenCodeCommand(options.openCodeConfig); - } - if (options.mode === 'codex') { - return buildCodexCommand(options.codexConfig); - } - if (options.mode === 'gemini') { - return buildGeminiCommand(options.geminiConfig); - } - if (options.mode === 'antigravity') { - return buildAntigravityCommand(options.antigravityConfig); - } - if (options.mode === 'pi') { - return buildPiCommand(options.piConfig); - } - if (options.mode === 'grok') { - return buildGrokCommand(options.grokConfig); + const entry = getCli(options.mode); + if (entry) { + const rendered = buildSpawnCommandFromRegistry(entry, options); + if (rendered !== undefined) return rendered; } // #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"` // argument of the respawn-pane line, which execSync runs through `/bin/sh -c`, @@ -1148,20 +885,11 @@ const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/; */ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string { if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand; - switch (mode) { - case 'gemini': - return `${modeCommand} --resume ${resumeId}`; - case 'codex': - return `${modeCommand} resume ${resumeId}`; - case 'antigravity': - return `${modeCommand} --conversation ${resumeId}`; - case 'pi': - return `${modeCommand} --session ${resumeId}`; - case 'grok': - return `${modeCommand} --resume ${resumeId}`; - default: - return modeCommand; // shell / opencode: no resume - } + const resumeAppend = getCli(mode)?.launch.resumeAppend; + if (!resumeAppend) return modeCommand; // claude/shell/opencode: no resume-append shape + return resumeAppend.style === 'flag' + ? `${modeCommand} ${resumeAppend.flag} ${resumeId}` + : `${modeCommand} ${resumeAppend.token} ${resumeId}`; } /** @@ -1456,12 +1184,15 @@ function buildRemoteSessionCommand(options: { } /** - * Set sensitive environment variables on a tmux session via setenv. - * These are inherited by panes but not visible in ps output or tmux history. + * Set a CLI's sensitive environment variables (API keys, auth env) on a tmux session via + * setenv, reading which var NAMES to forward from the registry's `env.tmuxSetenvKeys` — + * VALUES always come from the server's own `process.env`, never from the CLI or client, so + * a secret can never appear on the bash command line or in `ps`/tmux history. Replaces + * three near-identical hand-written functions (one each for opencode/codex/gemini); their + * key lists are now DATA in config/cli-registry/stock.ts. */ -function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY']; - for (const key of sensitiveVars) { +function setCliSensitiveEnvVars(tmuxCmd: string, muxName: string, keys: readonly string[]): void { + for (const key of keys) { const val = process.env[key]; if (val) { // Shell-escape: wrap in single quotes, escape any inner single quotes @@ -1480,65 +1211,14 @@ function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void { } /** - * Set sensitive environment variables for Codex on a tmux session via setenv. - * Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys. - */ -function setCodexEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME']; - for (const key of sensitiveVars) { - const val = process.env[key]; - if (val) { - const escaped = val.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical — key may not be needed */ - } - } - } -} - -/** - * Set sensitive environment variables for Gemini on a tmux session via setenv. - * Gemini Pro/Ultra users usually authenticate via cached Google login; these - * variables cover API-key and Vertex AI paths without putting secrets in ps. - */ -function setGeminiEnvVars(tmuxCmd: string, muxName: string): void { - const sensitiveVars = [ - 'GEMINI_API_KEY', - 'GEMINI_MODEL', - 'GOOGLE_API_KEY', - 'GOOGLE_CLOUD_PROJECT', - 'GOOGLE_CLOUD_LOCATION', - 'GOOGLE_APPLICATION_CREDENTIALS', - 'GOOGLE_GENAI_USE_VERTEXAI', - ]; - for (const key of sensitiveVars) { - const val = process.env[key]; - if (val) { - const escaped = val.replace(/'/g, "'\\''"); - try { - execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch { - /* Non-critical — key may not be needed */ - } - } - } -} - -/** - * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv. - * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON. + * Set a CLI's JSON config-content env var on a tmux session via setenv, under the NAME the + * registry declares (`env.configContentVar`). Uses tmux setenv to avoid shell metacharacter + * injection from user-supplied JSON. Only opencode declares one today, and the + * `autoAllowTools` merge logic below is genuinely opencode-shaped (its config-file schema), + * not a hard-coded id check — a future CLI with its own config-content var reuses this + * function by declaring `configContentVar` and passing its own config shape in. */ -function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void { +function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, varName: string, config?: OpenCodeConfig): void { if (!config) return; let jsonContent: string | undefined; @@ -1569,7 +1249,7 @@ function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: Ope if (jsonContent) { const escaped = jsonContent.replace(/'/g, "'\\''"); try { - execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, { + execSync(`${tmuxCmd} setenv -t '${muxName}' ${varName} '${escaped}'`, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], @@ -1746,21 +1426,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * command line (visible in `ps`). This also sidesteps shell-metachar injection via keys. */ private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] { - const exports = [ + const entry = getCli(mode); + // Per-CLI exports/unsets are DATA (config/cli-registry/stock.ts's `env.exports` / + // `env.unset`) — e.g. codex's COLORTERM=truecolor + unset NO_COLOR + its unique + // CODEX_INTERNAL_ORIGINATOR_OVERRIDE (so the response-viewer can locate THIS pane's + // rollout: codex writes the value into session_meta.originator of every rollout it + // creates, and without a unique one two panes in the same cwd bleed into each other), + // claude's `unset CLAUDECODE` + `unset COLORTERM`. Order between exports/unsets carries + // no bash semantics (independent variable names), so this need not reproduce the exact + // historical interleaving. + const perCliUnsets = (entry?.env.unset ?? []).map((name) => `unset ${name}`); + const perCliExports = (entry?.env.exports ?? []).map((e) => { + const value = + typeof e.value === 'string' + ? e.value + : e.value.engine === 'sessionId' + ? sessionId + : e.value.engine === 'codemanPrefixedSessionId' + ? `codeman_${sessionId}` + : ''; + return `export ${e.name}=${value}`; + }); + return [ 'export LANG=en_US.UTF-8', 'export LC_ALL=en_US.UTF-8', - mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' - ? 'export COLORTERM=truecolor' - : 'unset COLORTERM', - ...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' - ? ['unset NO_COLOR'] - : []), - // Stamp each Codex pane with a unique originator so the response-viewer - // can locate THIS pane's rollout exactly — codex writes the value into - // session_meta.originator of every rollout it creates. Without it, - // rollouts are matched by cwd+mtime and two panes in the same directory - // bleed into each other. - ...(mode === 'codex' ? [`export CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_${sessionId}`] : []), + ...perCliUnsets, + ...perCliExports, 'export CODEMAN_MUX=1', `export CODEMAN_SESSION_ID=${sessionId}`, `export CODEMAN_MUX_NAME=${muxName}`, @@ -1773,9 +1464,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // execution time, so the COD-54 hook secret stays off the command line. `export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`, ]; - // Only unset CLAUDECODE for Claude sessions - if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE'); - return exports; } /** @@ -1825,62 +1513,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * In createSession(), a missing binary dir throws — the caller handles that separately. */ private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } { - if (mode === 'claude') { - const dir = findClaudeDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'opencode') { - const dir = resolveOpenCodeDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'codex') { - const dir = resolveCodexDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'gemini') { - const dir = resolveGeminiDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'antigravity') { - const dir = resolveAntigravityDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'pi') { - const dir = resolvePiDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - if (mode === 'grok') { - const dir = resolveGrokDir(); - return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; - } - return { pathExport: '', dir: null }; + const dir = resolveCliBinDir(mode); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; } /** - * Configure OpenCode-specific environment on a tmux session. - * Sets sensitive API keys and config content via tmux setenv - * (not visible in ps output or tmux history, inherited by panes). + * Configure a CLI's environment on a tmux session: its sensitive API keys/auth env + * (`env.tmuxSetenvKeys`) and, if it declares one, its JSON config-content var + * (`env.configContentVar` — today only opencode). All via `tmux setenv`, so nothing + * appears in the bash command line or `ps`/tmux history, and inherited by every pane + * including `respawn-pane`. Replaces three per-CLI methods (`_configureOpenCode`, + * `_configureCodex`, `_configureGemini`) with one generic call over registry data. */ - private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { + private _configureCliEnv(mode: SessionMode, muxName: string, openCodeConfig?: OpenCodeConfig): void { + const entry = getCli(mode); + if (!entry) return; const tmuxCmd = this.tmux(); - setOpenCodeEnvVars(tmuxCmd, muxName); - setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig); - } - - /** - * Configure Codex-specific environment on a tmux session. - * Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't - * appear in the bash command line. - */ - private _configureCodex(muxName: string): void { - setCodexEnvVars(this.tmux(), muxName); - } - - /** - * Configure Gemini-specific environment on a tmux session. - */ - private _configureGemini(muxName: string): void { - setGeminiEnvVars(this.tmux(), muxName); + setCliSensitiveEnvVars(tmuxCmd, muxName, entry.env.tmuxSetenvKeys); + if (entry.env.configContentVar) { + setOpenCodeConfigContent(tmuxCmd, muxName, entry.env.configContentVar, openCodeConfig); + } } /** @@ -1945,26 +1597,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // looked — server PATH, login shell, checked directories — instead of just // asserting the CLI is missing (the classic systemd/launchd PATH trap). const { pathExport, dir: cliDir } = this.buildPathExport(mode); - if (mode === 'claude' && !cliDir) { - throw new Error(getClaudeNotFoundMessage()); - } - if (mode === 'opencode' && !cliDir) { - throw new Error(getOpenCodeNotFoundMessage()); - } - if (mode === 'codex' && !cliDir) { - throw new Error(getCodexNotFoundMessage()); - } - if (mode === 'gemini' && !cliDir) { - throw new Error(getGeminiNotFoundMessage()); - } - if (mode === 'antigravity' && !cliDir) { - throw new Error(getAntigravityNotFoundMessage()); - } - if (mode === 'pi' && !cliDir) { - throw new Error(getPiNotFoundMessage()); - } - if (mode === 'grok' && !cliDir) { - throw new Error(getGrokNotFoundMessage()); + if (!cliDir && mode !== 'shell') { + const message = missingCliMessage(mode); + if (message) throw new Error(message); } const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); @@ -2038,17 +1673,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /* Non-critical */ } - // For OpenCode: set sensitive env vars and config via tmux setenv - // (not visible in ps output or tmux history, inherited by panes) - if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig); - } else if (mode === 'codex') { - this._configureCodex(muxName); - } - // For Gemini: set Gemini/Google auth env vars via tmux setenv - if (mode === 'gemini') { - this._configureGemini(muxName); - } + // Set sensitive env vars (and, for opencode, its config-content var) via tmux setenv — + // not visible in ps output or tmux history, inherited by panes. A no-op for a CLI that + // declares no tmuxSetenvKeys and no configContentVar (claude, shell, antigravity, pi + // today), so this runs unconditionally instead of a per-mode dispatch. + this._configureCliEnv(mode, muxName, openCodeConfig); // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv // so secret values stay off the bash command line. Must run before respawn-pane. @@ -2250,16 +1879,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { : localFullCmd; try { - // For OpenCode: set sensitive env vars via tmux setenv before respawn - if (mode === 'opencode') { - this._configureOpenCode(muxName, openCodeConfig); - } else if (mode === 'codex') { - this._configureCodex(muxName); - } - // For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn - if (mode === 'gemini') { - this._configureGemini(muxName); - } + // Set sensitive env vars via tmux setenv before respawn (see createSession() for + // why this runs unconditionally — a no-op for a CLI with nothing to configure). + this._configureCliEnv(mode, muxName, openCodeConfig); // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index c2074c853..3dfc2c139 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -1014,6 +1014,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string { id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' }, { id: 'pi', label: 'pi', detail: 'pi.dev' }, { id: 'grok', label: 'grok', detail: 'xAI Grok Build' }, + { id: 'copilot', label: 'copilot', detail: 'GitHub Copilot CLI' }, ]; // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 64c61d891..0295515d3 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -149,7 +149,7 @@ export type TuiAnswerResult = export interface TuiQuickStartOptions { caseName: string; - mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'; + mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'copilot'; sessionName?: string; /** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */ parentSessionId?: string; diff --git a/src/utils/antigravity-cli-resolver.ts b/src/utils/antigravity-cli-resolver.ts index 85d25dd6e..1b4b0ab46 100644 --- a/src/utils/antigravity-cli-resolver.ts +++ b/src/utils/antigravity-cli-resolver.ts @@ -1,43 +1,24 @@ /** - * @fileoverview Resolve the Antigravity CLI (`agy`) binary across common install paths. + * @fileoverview Antigravity CLI binary resolution. * - * Mirrors gemini-cli-resolver.ts. Google's installer (antigravity.google/cli/install.sh) - * places the binary at ~/.local/bin/agy; the other locations cover manual installs. + * The binary is `agy`, not `antigravity` — the registry's `discovery.binaries` + * carries that split so nothing here (or anywhere else) has to know it by name. + * Thin wrapper over `cli-resolver.ts`'s generic walker; see claude-cli-resolver.ts's + * file header for why this stays its own module rather than a bare re-export. * * @module utils/antigravity-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { - createCliExecutableResolver, - formatCliNotFoundMessage, - type CliResolverHost, -} from './cli-executable-resolver.js'; +import { createDirResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; -/** Common directories where the Antigravity CLI binary may be installed */ -const ANTIGRAVITY_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - join(homedir(), '.antigravity', 'bin'), - '/usr/local/bin', - join(homedir(), '.bun', 'bin'), - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; - -const ANTIGRAVITY_NOT_FOUND = - 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'; - -function createAntigravityResolver(host?: CliResolverHost, now?: () => number) { - return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS, now }, host); -} - -/** Creates an isolated Antigravity wrapper around an injected resolver host and clock. */ -export function createAntigravityResolverForTest(host: CliResolverHost, now?: () => number) { - return createAntigravityResolver(host, now); +function entry() { + const e = getCli('antigravity'); + if (!e) throw new Error('antigravity is not registered in the CLI registry'); + return e; } -const antigravityResolver = createAntigravityResolver(); +const resolver = createDirResolver(entry().discovery.binaries, entry().discovery.searchDirs); /** * Finds the directory containing the `agy` binary. @@ -46,16 +27,12 @@ const antigravityResolver = createAntigravityResolver(); * @returns Directory path, or null if not found */ export function resolveAntigravityDir(): string | null { - return antigravityResolver.resolve()?.directory ?? null; + return resolver.resolveDir(); } /** * Check if the Antigravity CLI is available on the system. */ export function isAntigravityAvailable(): boolean { - return resolveAntigravityDir() !== null; -} - -export function getAntigravityNotFoundMessage(): string { - return formatCliNotFoundMessage(ANTIGRAVITY_NOT_FOUND, antigravityResolver.diagnostics()); + return resolver.isAvailable(); } diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index 04a1b15f7..34d550f61 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -1,38 +1,49 @@ /** - * @fileoverview Shared Claude CLI binary resolution. + * @fileoverview Claude CLI binary resolution. * - * Finds the `claude` binary across common installation paths and provides - * an augmented PATH string. Used by session.ts and tmux-manager.ts - * to locate the Claude CLI. + * Thin wrapper over `cli-resolver.ts`'s generic walker, reading its search + * parameters from the CLI registry's stock catalog. Kept as its own module + * (rather than folded into a single generic import everywhere) so every + * existing caller and every `vi.mock('.../claude-cli-resolver.js')` in the + * test suite keeps working unchanged — see cli-resolver.ts's file header. * * @module utils/claude-cli-resolver */ -import { execFileSync } from 'node:child_process'; -import { delimiter, join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; -import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; - -/** Common directories where the Claude CLI binary may be installed */ -const CLAUDE_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - join(homedir(), '.claude', 'local'), - '/usr/local/bin', - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +import { join } from 'node:path'; +import { + augmentPath, + createDirResolver, + createRetryingVersionGetter, + resolveRetryingVersion, + retryingVersionProbeDelayMs, + type RetryingVersionProbeState, +} from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; + +/** Preserved name for the exported type; identical shape to RetryingVersionProbeState. */ +export type ClaudeVersionProbeState = RetryingVersionProbeState; + +/** Preserved name; identical behaviour to the generic retry/backoff delay function. */ +export const claudeVersionRetryDelayMs = retryingVersionProbeDelayMs; + +/** Preserved name; identical behaviour to the generic retry/backoff cache policy. */ +export const resolveClaudeCliVersion = resolveRetryingVersion; + +function claudeEntry() { + const entry = getCli('claude'); + if (!entry) throw new Error('claude is not registered in the CLI registry'); + return entry; +} -const claudeResolver = createCliExecutableResolver({ binary: 'claude', searchDirs: CLAUDE_SEARCH_DIRS }); -const CLAUDE_NOT_FOUND = 'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'; +const resolver = createDirResolver(claudeEntry().discovery.binaries, claudeEntry().discovery.searchDirs); /** * Returns true if the Claude CLI binary can be located (via `which` or one of - * the common install directories). Mirrors `isGeminiAvailable`/`isAntigravityAvailable`/`isOpenCodeAvailable`/ - * `isCodexAvailable` in the sibling resolvers. + * the common install directories). Mirrors the sibling resolvers. */ export function isClaudeAvailable(): boolean { - return findClaudeDir() !== null; + return resolver.isAvailable(); } /** @@ -43,11 +54,7 @@ export function isClaudeAvailable(): boolean { * @returns Directory path, or null if not found */ export function findClaudeDir(): string | null { - return claudeResolver.resolve()?.directory ?? null; -} - -export function getClaudeNotFoundMessage(): string { - return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics()); + return resolver.resolveDir(); } /** @@ -65,7 +72,7 @@ export function getClaudeBinaryPath(): string { return dir ? join(dir, 'claude') : 'claude'; } -/** Cached augmented PATH string */ +/** Cached augmented PATH string. */ let _augmentedPath: string | null = null; /** @@ -77,114 +84,10 @@ let _augmentedPath: string | null = null; */ export function getAugmentedPath(): string { if (_augmentedPath) return _augmentedPath; - - const currentPath = process.env.PATH || ''; - const claudeDir = findClaudeDir(); - - if (!claudeDir) return currentPath; - - if (!currentPath.split(delimiter).includes(claudeDir)) { - _augmentedPath = `${claudeDir}${delimiter}${currentPath}`; - return _augmentedPath; - } - - _augmentedPath = currentPath; + _augmentedPath = augmentPath(findClaudeDir(), process.env.PATH || ''); return _augmentedPath; } -/** - * Cache state for the `claude --version` probe. - * - * `version` is only ever set from a SUCCESSFUL probe and then kept for the - * process lifetime (the binary can't change under a running server without a - * restart). Failures are tracked separately so they expire. - */ -export interface ClaudeVersionProbeState { - /** Successful probe result; `undefined` until one succeeds. */ - version?: string; - /** Consecutive failed probes (drives the retry backoff). */ - failures: number; - /** Timestamp of the most recent failed probe. */ - lastFailureAt: number; -} - -/** First retry window after a failed probe. */ -const VERSION_PROBE_BASE_RETRY_MS = 60_000; -/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */ -const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000; - -/** - * How long to wait before re-probing after `failures` consecutive failures: - * 1min, 2min, 4min… capped at 15min. Exported for tests. - */ -export function claudeVersionRetryDelayMs(failures: number): number { - if (failures <= 0) return 0; - return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS); -} - -/** - * Cache policy for the version probe, pure apart from the `state` it mutates - * and the injected `probe` (exported so tests can drive it with a fake clock). - * - * Success is cached forever; FAILURE is not. That asymmetry is the fix for a - * real shipped bug: the old cache stored `null` on any exception and guarded on - * `!== undefined`, so a single failed probe — a 5s `EXEC_TIMEOUT_MS` timeout, a - * PATH-starved systemd/launchd environment, a transient fs hiccup — at the FIRST - * Claude session start left `cliVersion` undefined for EVERY Claude session - * until the server restarted. An undefined `cliVersion` silently disables - * wheel-forwarding to Claude's own transcript (`_shouldForwardWheelToApp`), - * which is the only route to history in repaint mode: a dead wheel on every - * device at once, matching the issue #205 retest reports. - * - * Retries back off so a genuinely absent binary still can't spawn a probe per - * session start. - */ -export function resolveClaudeCliVersion( - state: ClaudeVersionProbeState, - now: number, - probe: () => string | null -): string | null { - if (state.version !== undefined) return state.version; - if (state.failures > 0 && now - state.lastFailureAt < claudeVersionRetryDelayMs(state.failures)) return null; - - let version: string | null = null; - try { - version = probe(); - } catch { - version = null; - } - - if (version) { - state.version = version; - state.failures = 0; - state.lastFailureAt = 0; - return version; - } - state.failures += 1; - state.lastFailureAt = now; - return null; -} - -const _claudeVersionState: ClaudeVersionProbeState = { failures: 0, lastFailureAt: 0 }; - -/** One `claude --version` run. Throws on spawn/timeout failure. */ -function probeClaudeCliVersion(): string | null { - const dir = findClaudeDir(); - const bin = dir ? join(dir, 'claude') : 'claude'; - // execFileSync (no shell) — the resolved path may contain spaces, and there - // is no untrusted input, but avoid a shell either way. - const out = execFileSync(bin, ['--version'], { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - env: { ...process.env, PATH: getAugmentedPath() }, - // execFileSync's timeout only SENDS the signal and then keeps waiting; a - // child that ignores SIGTERM would block the server thread permanently. - killSignal: 'SIGKILL', - }); - const match = out.match(/(\d+\.\d+\.\d+)/); - return match ? match[1] : null; -} - /** * Returns the installed Claude CLI version (e.g. `"2.1.210"`), or null if it * can't be determined. Runs `claude --version` at most once per successful @@ -196,11 +99,10 @@ function probeClaudeCliVersion(): string | null { * show it, which left `cliVersion` undefined and silently disabled features * gated on it (e.g. wheel-forwarding to Claude's transcript — issue #154). */ -export function getClaudeCliVersion(): string | null { - // Keep the test suite hermetic — never spawn a real `claude` subprocess under - // vitest (matches IS_TEST_MODE in tmux-manager). Tests that need a version set - // it on the session directly. Deliberately does NOT touch the cache state: - // recording a phantom failure here would be the very poisoning this fixes. - if (process.env.VITEST) return null; - return resolveClaudeCliVersion(_claudeVersionState, Date.now(), probeClaudeCliVersion); -} +export const getClaudeCliVersion = createRetryingVersionGetter({ + resolveDir: findClaudeDir, + binaryName: 'claude', + versionArg: claudeEntry().discovery.version?.arg ?? '--version', + versionRegex: claudeEntry().discovery.version?.regex, + getAugmentedPath, +}); diff --git a/src/utils/cli-executable-resolver.ts b/src/utils/cli-executable-resolver.ts deleted file mode 100644 index 1256c1bb5..000000000 --- a/src/utils/cli-executable-resolver.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * @fileoverview Shared CLI executable resolution for the per-CLI resolvers. - * - * One lookup chain behind all seven *-cli-resolver modules (claude, opencode, - * codex, gemini, antigravity, pi, grok): the server process PATH first, then the - * CLI's common install directories in order, then — last, because it is the - * only step that spawns anything — an interactive login shell, which is what - * finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd - * service with a minimal PATH (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`). - * - * Caching is asymmetric, same shape as `resolveClaudeCliVersion` in - * claude-cli-resolver.ts: a successful resolution is cached for the process - * lifetime, a MISS is negative-cached and retried only after a doubling backoff - * (`cliResolveRetryDelayMs`). The callers are request-facing (the per-CLI - * status endpoints in system-routes.ts, the availability gates in - * session-routes.ts, and tmux-manager's spawn path), and the login-shell probe - * is a SYNCHRONOUS spawn bounded by `EXEC_TIMEOUT_MS` — without the negative - * cache, a missing CLI re-ran the whole chain and stalled the event loop for up - * to 5s on every request, forever. - * - * Test hermeticity: under vitest (`process.env.VITEST`) the production host - * short-circuits — IO primitives that were not injected become inert stubs, so - * a suite can never scan the machine's PATH or spawn login shells (the same - * rule as `IS_TEST_MODE` in tmux-manager and the VITEST gate in - * `getClaudeCliVersion`). Tests opt back in through the injection hooks - * (`runCommand`/`isExecutableFile` fakes do no real IO by construction) or, for - * fixtures that need the real filesystem predicate against their own temp - * files, via `allowRealIoUnderVitest`. - * - * @module utils/cli-executable-resolver - */ - -import { execFileSync } from 'node:child_process'; -import { accessSync, constants, statSync } from 'node:fs'; -import { basename, delimiter, dirname, isAbsolute, join } from 'node:path'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; -import { loginShellArgs, resolveLocalShell } from './shell-resolver.js'; - -const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i; -const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__'; -const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__'; -/** Maximum rendered length of each bounded diagnostic field, excluding its label. */ -const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024; - -/** First retry window after a full-chain resolution miss. */ -const RESOLVE_RETRY_BASE_MS = 60_000; -/** - * Ceiling for the doubling backoff. Deliberately shorter than the 15min cap on - * the claude version probe: that one is cosmetic, while this gates the Run - * flow, and "installing a CLI while the server is running is picked up without - * a restart" should stay true within minutes. - */ -const RESOLVE_RETRY_MAX_MS = 5 * 60_000; - -/** - * How long to wait before re-running the resolution chain after `failures` - * consecutive misses: 1min, 2min, 4min… capped at 5min. Mirrors - * `claudeVersionRetryDelayMs` in claude-cli-resolver.ts. Exported for tests. - */ -export function cliResolveRetryDelayMs(failures: number): number { - if (failures <= 0) return 0; - return Math.min(RESOLVE_RETRY_BASE_MS * 2 ** (failures - 1), RESOLVE_RETRY_MAX_MS); -} - -export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell'; - -export interface CliResolutionDiagnostics { - binary: string; - processPath: string; - shellPath: string; - shellArgs: string[]; - searchDirs: string[]; -} - -export interface CliResolverHost { - processPath: string; - shellPath: string; - shellArgs: string[]; - findOnProcessPath(binary: string): string | null; - findInLoginShell(binary: string): string | null; - exists(path: string): boolean; -} - -export interface CandidateValidation { - accepted: boolean; - metadata?: T; -} - -export interface CliResolution { - binaryPath: string; - directory: string; - source: CliResolutionSource; - metadata?: T; -} - -export interface CliExecutableResolver { - resolve(): CliResolution | null; - diagnostics(): CliResolutionDiagnostics; -} - -export interface CliResolverCommandOptions { - encoding: 'utf8'; - timeout: number; - stdio: ['ignore', 'pipe', 'ignore']; - killSignal: 'SIGKILL'; -} - -export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string; - -export interface ProductionCliResolverHostOptions { - processPath?: string; - shellPath?: string; - shellArgs?: string[]; - runCommand?: CliResolverCommandRunner; - isExecutableFile?: (path: string) => boolean; - /** - * Test-only escape hatch: keep the REAL IO primitives even under vitest. - * For tests that exercise `isExecutableRegularFile` against their own temp - * fixtures. Such a test must still inject `runCommand` if it can reach the - * login-shell step, or it would spawn a real interactive shell. - */ - allowRealIoUnderVitest?: boolean; -} - -function isExecutableRegularFile(path: string): boolean { - try { - if (!statSync(path).isFile()) return false; - accessSync(path, constants.X_OK); - return true; - } catch { - return false; - } -} - -function parseLoginShellResult(output: string, binary: string): string | null { - const lines = output.split(/\r?\n/).map((line) => line.trim()); - const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER); - if (begin === -1) return null; - const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1); - if (end === -1) return null; - - for (const candidate of lines.slice(begin + 1, end)) { - if (isAbsolute(candidate) && basename(candidate) === binary) return candidate; - } - return null; -} - -function loginShellCommand(binary: string): string { - return [ - `printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`, - `command -v -- ${binary}`, - `printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`, - ].join('; '); -} - -export function createProductionCliResolverHost(options: ProductionCliResolverHostOptions = {}): CliResolverHost { - const shellPath = options.shellPath ?? resolveLocalShell(); - const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean); - const processPath = options.processPath ?? process.env.PATH ?? ''; - // Hermeticity gate (see @fileoverview): under vitest, any IO primitive the - // caller did not inject is replaced by an inert stub. The suites must never - // depend on — or execute — whatever happens to be installed on the machine - // running them, and route tests hitting the per-CLI status endpoints would - // otherwise scan the real PATH and spawn real login shells on CI. - const inert = Boolean(process.env.VITEST) && options.allowRealIoUnderVitest !== true; - const isExecutableFile = options.isExecutableFile ?? (inert ? () => false : isExecutableRegularFile); - const runCommand: CliResolverCommandRunner = - options.runCommand ?? (inert ? () => '' : (file, args, commandOptions) => execFileSync(file, args, commandOptions)); - const run = (file: string, args: string[]): string => { - try { - return runCommand(file, args, { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['ignore', 'pipe', 'ignore'], - // SIGKILL is load-bearing: execFileSync's `timeout` only SENDS the kill - // signal and then keeps waiting for the child to exit. Interactive bash - // ignores SIGTERM (the default), so a login shell stuck in a blocking - // .bash_profile would survive the timeout and block the server forever. - killSignal: 'SIGKILL', - }); - } catch { - return ''; - } - }; - - return { - processPath, - shellPath, - shellArgs: [...shellArgs], - findOnProcessPath: (binary) => { - if (!SAFE_BINARY_NAME.test(binary)) return null; - for (const directory of processPath.split(delimiter).filter(Boolean)) { - const candidate = join(directory, binary); - if (isAbsolute(candidate) && isExecutableFile(candidate)) return candidate; - } - return null; - }, - findInLoginShell: (binary) => { - if (!SAFE_BINARY_NAME.test(binary)) return null; - const candidate = parseLoginShellResult(run(shellPath, [...shellArgs, '-c', loginShellCommand(binary)]), binary); - return candidate && isExecutableFile(candidate) ? candidate : null; - }, - exists: isExecutableFile, - }; -} - -export function createCliExecutableResolver( - options: { - binary: string; - searchDirs: string[]; - validateCandidate?: (path: string) => CandidateValidation; - /** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */ - now?: () => number; - }, - host: CliResolverHost = createProductionCliResolverHost() -): CliExecutableResolver { - if (!SAFE_BINARY_NAME.test(options.binary)) { - throw new Error(`Unsafe CLI binary name: ${options.binary}`); - } - - const now = options.now ?? Date.now; - /** Successful resolution, cached for the process lifetime. */ - let cached: CliResolution | null = null; - /** Consecutive full-chain misses (drives the retry backoff). */ - let failures = 0; - /** Timestamp of the most recent miss. */ - let lastFailureAt = 0; - const accept = (path: string | null, source: CliResolutionSource): CliResolution | null => { - if (!path || !isAbsolute(path) || !host.exists(path)) return null; - const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation); - if (!validation.accepted) return null; - return { - binaryPath: path, - directory: dirname(path), - source, - metadata: validation.metadata, - }; - }; - - return { - resolve() { - if (cached) return cached; - // Negative cache: a miss is remembered and the chain — whose login-shell - // tail is a synchronous 5s-bounded spawn — is not re-run until the - // backoff elapses. Without this, every status poll and Run click against - // a missing CLI froze the event loop for the full probe, forever. - if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null; - - cached = accept(host.findOnProcessPath(options.binary), 'process-path'); - if (!cached) { - for (const dir of options.searchDirs) { - cached = accept(join(dir, options.binary), 'common-directory'); - if (cached) break; - } - } - if (!cached) { - cached = accept(host.findInLoginShell(options.binary), 'login-shell'); - } - - if (cached) { - failures = 0; - lastFailureAt = 0; - return cached; - } - failures += 1; - lastFailureAt = now(); - return null; - }, - diagnostics: () => ({ - binary: options.binary, - processPath: host.processPath, - shellPath: host.shellPath, - shellArgs: [...host.shellArgs], - searchDirs: [...options.searchDirs], - }), - }; -} - -function sanitizeDiagnosticField(value: string, emptyMarker: string): string { - const flattened = Array.from(value, (character) => { - const codePoint = character.codePointAt(0) ?? 0; - const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); - return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character; - }) - .join('') - .replace(/ +/g, ' ') - .trim(); - if (!flattened) return emptyMarker; - if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened; - return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`; -} - -export function formatCliNotFoundMessage(base: string, diagnostics: CliResolutionDiagnostics): string { - const processPath = sanitizeDiagnosticField(diagnostics.processPath, '(empty)'); - const shell = sanitizeDiagnosticField( - [diagnostics.shellPath, ...diagnostics.shellArgs].filter(Boolean).join(' '), - '(none)' - ); - const dirs = sanitizeDiagnosticField(diagnostics.searchDirs.join(', '), '(none)'); - return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`; -} diff --git a/src/utils/cli-resolver.ts b/src/utils/cli-resolver.ts new file mode 100644 index 000000000..ec0ffdfa1 --- /dev/null +++ b/src/utils/cli-resolver.ts @@ -0,0 +1,487 @@ +/** + * @fileoverview Generic CLI binary resolution, shared by every per-CLI resolver + * (`claude-cli-resolver.ts`, `opencode-cli-resolver.ts`, `codex-cli-resolver.ts`, + * `gemini-cli-resolver.ts`, `antigravity-cli-resolver.ts`, `pi-cli-resolver.ts`, + * `grok-cli-resolver.ts`). + * + * Those files used to each hand-roll the same `which` + search-dir walk with a + * module-level cache. They now call into this module and re-export the result under their + * historical names, so every existing caller (`findClaudeDir()`, `resolvePiDir()`, …) and + * every `vi.mock('.../opencode-cli-resolver.js')` in the test suite keeps working unchanged + * — the per-CLI files stay real, separately-mockable modules; only the walking logic moved. + * + * Search parameters (binaries, search dirs, version-probe config) come from the CLI + * registry's stock catalog, so this is also where the resolvers stop duplicating data that + * `src/config/cli-registry/stock.ts` already declares. + * + * **Resolution chain** (ported from upstream Ark0N/Codeman's independently-built + * `cli-executable-resolver.ts`, PR #329 + follow-up `61251c0b`, into this registry-driven + * module rather than duplicated per-CLI): PATH (`which`) → declared search dirs → an + * interactive LOGIN SHELL as the last resort, since that is what finds nvm/Homebrew/ + * user-npm installs when Codeman runs as a systemd/launchd service with a minimal PATH + * (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`). The login-shell step is the only + * one that spawns anything beyond a `which`, so it stays last. + * + * A MISS across the whole chain is negative-cached with a doubling backoff (reusing + * `resolveRetryingVersion`/`retryingVersionProbeDelayMs` below — the exact mechanism + * `getClaudeCliVersion` already used for its own version probe, generalized here to the + * directory-resolution miss path too) rather than either caching it forever (the original + * bug: a missing CLI re-ran the whole chain, including the synchronous login-shell spawn, + * on every request, forever) or never caching it at all. + * + * Every exec call that carries a `timeout` also carries `killSignal: 'SIGKILL'`: + * `execFileSync`'s `timeout` option only SENDS the signal and then keeps waiting for the + * child to exit — the default SIGTERM is ignored by an interactive bash stuck in a blocking + * `.bash_profile`, which would otherwise survive the timeout and block the server forever. + * + * Hermeticity: under `VITEST`, no exec call in this module ever runs for real — the suites + * must never depend on, or execute, whatever happens to be installed on the machine running + * them (same rule as `IS_TEST_MODE` in tmux-manager.ts). + * + * @module utils/cli-resolver + */ + +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { basename, delimiter, dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { compileVersionRegex } from '../config/cli-registry/patterns.js'; +import type { CliVersionProbe } from '../config/cli-registry/types.js'; +import { getCli } from '../config/cli-registry/registry.js'; +import { loginShellArgs, resolveLocalShell } from './shell-resolver.js'; + +/** Expand a leading `~` to the current homedir. Search dirs carry no other expansion. */ +function expandHome(dir: string): string { + return dir.startsWith('~') ? join(homedir(), dir.slice(1).replace(/^[/\\]/, '')) : dir; +} + +// --------------------------------------------------------------------------- +// Login-shell fallback — the last-resort step in the resolution chain. +// --------------------------------------------------------------------------- + +const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__'; +const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__'; + +function loginShellProbeCommand(binary: string): string { + return [ + `printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`, + `command -v -- ${binary}`, + `printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`, + ].join('; '); +} + +/** Only lines BETWEEN the markers, absolute, and matching `binary`'s basename are trusted + * — a login shell's `.bash_profile`/`.zshrc` can print arbitrary noise ahead of the result. */ +function parseLoginShellResult(output: string, binary: string): string | null { + const lines = output.split(/\r?\n/).map((line) => line.trim()); + const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER); + if (begin === -1) return null; + const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1); + if (end === -1) return null; + for (const candidate of lines.slice(begin + 1, end)) { + if (candidate.startsWith('/') && basename(candidate) === binary) return candidate; + } + return null; +} + +/** + * Spawn the user's login shell to resolve `binary` via `command -v`. Returns `null` under + * VITEST (never spawns for real in tests) or on any failure — this is a best-effort last + * resort, not a required step. + */ +function findInLoginShell(binary: string): string | null { + if (process.env.VITEST) return null; + const shellPath = resolveLocalShell(); + const shellArgs = loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean); + try { + const out = execFileSync(shellPath, [...shellArgs, '-c', loginShellProbeCommand(binary)], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + killSignal: 'SIGKILL', + }); + const candidate = parseLoginShellResult(out, binary); + return candidate && existsSync(candidate) ? candidate : null; + } catch { + return null; + } +} + +/** `which `, VITEST-gated (never spawns for real in tests, matching every other probe + * in this module — this call previously had NO such guard, a real hermeticity gap). */ +function findOnProcessPath(bin: string): string | null { + if (process.env.VITEST) return null; + try { + const result = execSync(`which ${bin}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + killSignal: 'SIGKILL', + }).trim(); + return result && existsSync(result) ? result : null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Negative-result caching with backoff — shared by both resolver flavors below via +// `resolveRetryingVersion`, the SAME mechanism claude's own version probe already used +// (see that section further down), rather than a second, duplicated backoff curve. +// --------------------------------------------------------------------------- + +/** + * A resolver instance for one CLI. Each call to `createDirResolver()` returns its own + * closured cache, exactly like the six hand-written modules each had their own + * module-level `let _xDir`. + */ +export interface DirResolver { + resolveDir(): string | null; + isAvailable(): boolean; +} + +/** + * The plain "which, then search dirs, then a login shell" resolver — covers opencode, + * codex, gemini, antigravity, grok and any future CLI with no version-sanity requirement. + */ +export function createDirResolver(binaries: string[], searchDirs: string[]): DirResolver { + const dirs = searchDirs.map(expandHome); + const state: RetryingVersionProbeState = { failures: 0, lastFailureAt: 0 }; + + function probeChain(): string | null { + for (const bin of binaries) { + const found = findOnProcessPath(bin); + if (found) return dirname(found); + } + for (const dir of dirs) { + for (const bin of binaries) { + if (existsSync(join(dir, bin))) return dir; + } + } + for (const bin of binaries) { + const found = findInLoginShell(bin); + if (found) return dirname(found); + } + return null; + } + + function resolveDir(): string | null { + return resolveRetryingVersion(state, Date.now(), probeChain); + } + + return { resolveDir, isAvailable: () => resolveDir() !== null }; +} + +/** + * A resolver whose EVERY candidate must pass a version-sanity probe before being accepted + * — pi's/grok's behaviour, generalized. For a CLI with a short, generic binary name, a + * `which` hit is not by itself evidence the right program is installed. + * + * Under `VITEST` the probe never runs (existence alone decides), matching every resolver's + * hermetic-test behaviour: the suites must not depend on what happens to be on the dev box. + */ +export interface VersionGatedResolver extends DirResolver { + getVersion(): string | null; +} + +export function createVersionGatedResolver( + binaries: string[], + searchDirs: string[], + probe: CliVersionProbe, + logPrefix: string +): VersionGatedResolver { + const dirs = searchDirs.map(expandHome); + const regex = probe.regex ? compileVersionRegex(probe.regex) : null; + const state: RetryingVersionProbeState = { failures: 0, lastFailureAt: 0 }; + let cachedVersion: string | null = null; + + function probeOne(binPath: string): string | null { + if (process.env.VITEST) return null; + try { + const out = execFileSync(binPath, [probe.arg], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + stdio: ['ignore', 'pipe', 'ignore'], + killSignal: 'SIGKILL', + }).trim(); + const candidate = regex ? regex.exec(out)?.[1] : out || null; + if (candidate) return candidate; + console.warn(`[${logPrefix}] Ignoring ${binPath}: "${probe.arg}" printed ${JSON.stringify(out.slice(0, 80))}`); + } catch (err) { + console.warn(`[${logPrefix}] Ignoring ${binPath}: "${probe.arg}" failed (${(err as Error).message})`); + } + return null; + } + + function accept(binPath: string): string | null { + if (process.env.VITEST) { + cachedVersion = ''; + return dirname(binPath); + } + const version = probeOne(binPath); + if (!version) return null; + cachedVersion = version; + return dirname(binPath); + } + + function probeChain(): string | null { + for (const bin of binaries) { + const found = findOnProcessPath(bin); + if (found) { + const dir = accept(found); + if (dir) return dir; + } + } + for (const dir of dirs) { + for (const bin of binaries) { + const binPath = join(dir, bin); + if (!existsSync(binPath)) continue; + const accepted = accept(binPath); + if (accepted) return accepted; + } + } + for (const bin of binaries) { + const found = findInLoginShell(bin); + if (found) { + const dir = accept(found); + if (dir) return dir; + } + } + return null; + } + + function resolveDir(): string | null { + return resolveRetryingVersion(state, Date.now(), probeChain); + } + + return { + resolveDir, + isAvailable: () => resolveDir() !== null, + getVersion: () => { + resolveDir(); + return cachedVersion || null; + }, + }; +} + +// --------------------------------------------------------------------------- +// Claude's retry/backoff version probe. Pure apart from the `state` it mutates +// and the injected `probe`, so it stays directly unit-testable exactly as +// `test/claude-cli-version-cache.test.ts` already exercises it. Also now the +// shared backoff mechanism for `createDirResolver`/`createVersionGatedResolver`'s +// own directory-miss caching above. +// --------------------------------------------------------------------------- + +/** + * Cache state for a probe with retry/backoff. `version` is only ever set from a + * SUCCESSFUL probe and then kept for the process lifetime (the binary can't change under a + * running server without a restart). Failures are tracked separately so they expire. + * + * Named for its original use (claude's `--version` probe) but the field holds any + * successfully-resolved string — a version number OR a resolved directory path, per + * `createDirResolver`/`createVersionGatedResolver` above. + */ +export interface RetryingVersionProbeState { + /** Successful probe result; `undefined` until one succeeds. */ + version?: string; + /** Consecutive failed probes (drives the retry backoff). */ + failures: number; + /** Timestamp of the most recent failed probe. */ + lastFailureAt: number; +} + +/** First retry window after a failed probe. */ +const VERSION_PROBE_BASE_RETRY_MS = 60_000; +/** Ceiling for the doubling backoff, so a permanently missing binary settles down. */ +const VERSION_PROBE_MAX_RETRY_MS = 15 * 60_000; + +/** + * How long to wait before re-probing after `failures` consecutive failures: + * 1min, 2min, 4min… capped at 15min. + */ +export function retryingVersionProbeDelayMs(failures: number): number { + if (failures <= 0) return 0; + return Math.min(VERSION_PROBE_BASE_RETRY_MS * 2 ** (failures - 1), VERSION_PROBE_MAX_RETRY_MS); +} + +/** + * Cache policy for a retry/backoff probe. Success is cached forever, failure is not — see + * claude-cli-resolver.ts's original doc comment (preserved there) for the shipped bug this + * asymmetry fixes: caching a transient failure forever silently disabled every feature + * gated on the result for the rest of the process lifetime. + */ +export function resolveRetryingVersion( + state: RetryingVersionProbeState, + now: number, + probe: () => string | null +): string | null { + if (state.version !== undefined) return state.version; + if (state.failures > 0 && now - state.lastFailureAt < retryingVersionProbeDelayMs(state.failures)) return null; + + let version: string | null = null; + try { + version = probe(); + } catch { + version = null; + } + + if (version) { + state.version = version; + state.failures = 0; + state.lastFailureAt = 0; + return version; + } + state.failures += 1; + state.lastFailureAt = now; + return null; +} + +/** + * Build a retry/backoff version getter for a resolved binary, bound to its own cache state + * and PATH-augmentation. `getAugmentedPath` is injected because only claude currently needs + * PATH augmentation ahead of the probe (its binary dir may not be on the inherited PATH). + */ +export function createRetryingVersionGetter(opts: { + resolveDir: () => string | null; + binaryName: string; + versionArg: string; + versionRegex?: string; + getAugmentedPath?: () => string; +}): () => string | null { + const state: RetryingVersionProbeState = { failures: 0, lastFailureAt: 0 }; + const regex = opts.versionRegex ? compileVersionRegex(opts.versionRegex) : null; + + function probeOnce(): string | null { + const dir = opts.resolveDir(); + const bin = dir ? join(dir, opts.binaryName) : opts.binaryName; + const out = execFileSync(bin, [opts.versionArg], { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + killSignal: 'SIGKILL', + env: { ...process.env, PATH: opts.getAugmentedPath ? opts.getAugmentedPath() : process.env.PATH }, + }); + const match = regex ? regex.exec(out) : null; + return match ? match[1] : null; + } + + return () => { + // Keep the test suite hermetic — never spawn a real subprocess under vitest. Tests that + // need a version set it directly on the session. Deliberately does NOT touch `state`: + // recording a phantom failure here would be the very cache-poisoning this fixes. + if (process.env.VITEST) return null; + return resolveRetryingVersion(state, Date.now(), probeOnce); + }; +} + +/** Build a PATH string that includes `dir`, if not already present. Cached by the caller. */ +export function augmentPath(dir: string | null, currentPath: string): string { + if (dir && !currentPath.split(delimiter).includes(dir)) { + return `${dir}${delimiter}${currentPath}`; + } + return currentPath; +} + +// --------------------------------------------------------------------------- +// Not-found diagnostics — bounded, sanitized PATH/login-shell/search-dir info appended to +// a "CLI not found" message, ported from upstream's `formatCliNotFoundMessage`. +// --------------------------------------------------------------------------- + +/** Maximum rendered length of each bounded diagnostic field, excluding its label. A + * not-found message must never become a vector for dumping arbitrary env data. */ +const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024; + +function sanitizeDiagnosticField(value: string, emptyMarker: string): string { + const flattened = Array.from(value, (character) => { + const codePoint = character.codePointAt(0) ?? 0; + const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f); + return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character; + }) + .join('') + .replace(/ +/g, ' ') + .trim(); + if (!flattened) return emptyMarker; + if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened; + return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`; +} + +/** + * Append bounded PATH/login-shell/search-dir diagnostics to a base "CLI not found" message, + * so the error names exactly where resolution looked instead of just what it was looking + * for. Called from `missingCliMessage()` (registry.ts), so every caller (tmux-manager's + * spawn throw, session-routes' availability gate) gets it for free. + */ +export function formatCliNotFoundMessage(base: string, id: string): string { + const entry = getCli(id); + const searchDirs = (entry?.discovery.searchDirs ?? []).map(expandHome); + const shellPath = resolveLocalShell(); + const shellArgs = loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean); + const processPath = sanitizeDiagnosticField(process.env.PATH ?? '', '(empty)'); + const shell = sanitizeDiagnosticField([shellPath, ...shellArgs].filter(Boolean).join(' '), '(none)'); + const dirs = sanitizeDiagnosticField(searchDirs.join(', '), '(none)'); + return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`; +} + +/** + * Generic, memoized-by-id directory resolution for ANY registered CLI. Chooses + * `createVersionGatedResolver` when the entry's discovery declares + * `requireVersionMatch` (pi's/grok's shape) and `createDirResolver` otherwise (every other + * entry today) — so callers that need only a binary DIRECTORY (not a live version, + * which the six per-CLI resolver modules still own) can look one up for ANY id + * without a per-mode branch, including a custom CLI that isn't one of the named + * modules at all. + * + * Each id gets its own resolver instance the first time it is requested, cached for + * the process lifetime exactly like the per-CLI modules already cache themselves + * — this does not create a second competing cache for claude/opencode/codex/gemini + * /antigravity/pi/grok, since callers that already import those modules' own functions + * keep using them; this is for generic code that only has a `CliId` string in hand. + */ +const _dirResolvers = new Map(); + +/** + * Drop the memoized resolver for `id`, so the next `resolveCliBinDir`/`resolveCliVersion` + * call re-probes PATH/searchDirs/login-shell from scratch instead of replaying a cached + * negative result. `createDirResolver`/`createVersionGatedResolver` already retry a miss on + * their own doubling backoff (see this file's header), but `cli-installer.ts` calls this + * right after a successful install so the FIRST post-install check is not stuck waiting out + * whatever backoff window was already in progress. + */ +export function invalidateCliBinDirCache(id: string): void { + _dirResolvers.delete(id); +} + +export function resolveCliBinDir(id: string): string | null { + let resolver = _dirResolvers.get(id); + if (!resolver) { + const entry = getCli(id); + if (!entry || entry.discovery.binaries.length === 0) return null; // e.g. `shell` + resolver = entry.discovery.version?.requireVersionMatch + ? createVersionGatedResolver( + entry.discovery.binaries, + entry.discovery.searchDirs, + entry.discovery.version, + `CliResolver:${id}` + ) + : createDirResolver(entry.discovery.binaries, entry.discovery.searchDirs); + _dirResolvers.set(id, resolver); + } + return resolver.resolveDir(); +} + +/** + * Generic version accessor for the SAME memoized resolver `resolveCliBinDir` builds. Only + * returns a value for an entry whose resolver is version-aware (today: `requireVersionMatch` + * entries like pi/grok) — claude's separate retry/backoff version getter stays on its own + * module (`getClaudeCliVersion`), since that behaviour is declared via + * `retryOnTransientFailure`, not `requireVersionMatch`, and is not (yet) built generically + * here. Returns null rather than probing blind for an entry with no version-aware resolver. + */ +function isVersionGated(resolver: DirResolver): resolver is VersionGatedResolver { + return 'getVersion' in resolver; +} + +export function resolveCliVersion(id: string): string | null { + resolveCliBinDir(id); // ensure the resolver for `id` has been created + const resolver = _dirResolvers.get(id); + return resolver && isVersionGated(resolver) ? resolver.getVersion() : null; +} diff --git a/src/utils/codex-cli-resolver.ts b/src/utils/codex-cli-resolver.ts index 5cb7221a2..be17f939c 100644 --- a/src/utils/codex-cli-resolver.ts +++ b/src/utils/codex-cli-resolver.ts @@ -1,28 +1,23 @@ /** - * @fileoverview Resolve the Codex (OpenAI) CLI binary across common install paths. + * @fileoverview Codex CLI binary resolution. * - * Mirrors opencode-cli-resolver.ts pattern. Finds the `codex` binary - * and provides an augmented PATH string for tmux sessions. + * Thin wrapper over `cli-resolver.ts`'s generic walker, reading its search + * parameters from the CLI registry's stock catalog. See claude-cli-resolver.ts's + * file header for why this stays its own module rather than a bare re-export. * * @module utils/codex-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; +import { createDirResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; -/** Common directories where the Codex CLI binary may be installed */ -const CODEX_SEARCH_DIRS = [ - join(homedir(), '.codex', 'bin'), // Default install location - join(homedir(), '.local', 'bin'), // Alternative install location - '/usr/local/bin', // Homebrew / system - join(homedir(), '.bun', 'bin'), // Bun global - join(homedir(), '.npm-global', 'bin'), // npm global - join(homedir(), 'bin'), // User bin -]; +function entry() { + const e = getCli('codex'); + if (!e) throw new Error('codex is not registered in the CLI registry'); + return e; +} -const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS }); -const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex'; +const resolver = createDirResolver(entry().discovery.binaries, entry().discovery.searchDirs); /** * Finds the directory containing the `codex` binary. @@ -32,16 +27,12 @@ const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @open * @returns Directory path, or null if not found */ export function resolveCodexDir(): string | null { - return codexResolver.resolve()?.directory ?? null; + return resolver.resolveDir(); } /** - * Check if Codex CLI is available on the system. + * Check if the Codex CLI is available on the system. */ export function isCodexAvailable(): boolean { - return resolveCodexDir() !== null; -} - -export function getCodexNotFoundMessage(): string { - return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics()); + return resolver.isAvailable(); } diff --git a/src/utils/gemini-cli-resolver.ts b/src/utils/gemini-cli-resolver.ts index 45936d605..aef8ceeee 100644 --- a/src/utils/gemini-cli-resolver.ts +++ b/src/utils/gemini-cli-resolver.ts @@ -1,46 +1,38 @@ /** - * @fileoverview Resolve the Gemini CLI binary across common install paths. + * @fileoverview Gemini CLI binary resolution. * - * Mirrors codex-cli-resolver.ts and opencode-cli-resolver.ts. Finds the - * `gemini` binary and provides an augmented PATH directory for tmux sessions. + * Thin wrapper over `cli-resolver.ts`'s generic walker, reading its search + * parameters from the CLI registry's stock catalog. See claude-cli-resolver.ts's + * file header for why this stays its own module rather than a bare re-export. * * @module utils/gemini-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; +import { createDirResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; -/** Common directories where the Gemini CLI binary may be installed */ -const GEMINI_SEARCH_DIRS = [ - join(homedir(), '.gemini', 'bin'), - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), '.bun', 'bin'), - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +function entry() { + const e = getCli('gemini'); + if (!e) throw new Error('gemini is not registered in the CLI registry'); + return e; +} -const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS }); -const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'; +const resolver = createDirResolver(entry().discovery.binaries, entry().discovery.searchDirs); /** * Finds the directory containing the `gemini` binary. * Checks `which gemini` first, then falls back to common install locations. + * Result is cached for subsequent calls. * * @returns Directory path, or null if not found */ export function resolveGeminiDir(): string | null { - return geminiResolver.resolve()?.directory ?? null; + return resolver.resolveDir(); } /** - * Check if Gemini CLI is available on the system. + * Check if the Gemini CLI is available on the system. */ export function isGeminiAvailable(): boolean { - return resolveGeminiDir() !== null; -} - -export function getGeminiNotFoundMessage(): string { - return formatCliNotFoundMessage(GEMINI_NOT_FOUND, geminiResolver.diagnostics()); + return resolver.isAvailable(); } diff --git a/src/utils/grok-cli-resolver.ts b/src/utils/grok-cli-resolver.ts index f0f9f26a6..4f09d6450 100644 --- a/src/utils/grok-cli-resolver.ts +++ b/src/utils/grok-cli-resolver.ts @@ -1,151 +1,65 @@ /** * @fileoverview Resolve the Grok Build CLI (`grok`, xAI) binary across common install paths. * - * Mirrors pi-cli-resolver.ts, version probe included: `grok` is another short - * name with known squatters (the unrelated `@vibe-kit/grok-cli` npm package also - * installs a `grok` bin), so a `which grok` hit is not by itself evidence that - * xAI's coding agent is installed. Every candidate is sanity-probed with - * `grok --version` and required to print a version-shaped string (the real CLI - * prints `grok 1.0.5 (5115b46bc9)`); a binary that fails the probe is treated - * as absent and the rejected path is logged. The probe cannot tell two - * version-printing `grok`s apart, which is why `GET /api/grok/status` surfaces - * path AND version: a misresolution is diagnosable rather than presenting as - * "the mode just doesn't work". - * - * The official installer (`curl -fsSL https://x.ai/cli/install.sh | bash`) - * places the binary in `~/.grok/bin` and symlinks it into `~/.local/bin`, so - * those two head the search list. + * `grok` has a known npm squatter (`@vibe-kit/grok-cli` also installs a `grok` bin), so a + * `which grok` hit is not by itself evidence that xAI's coding agent is installed — same + * shape as `pi`. See `createVersionGatedResolver()` in cli-resolver.ts, which this is now a + * thin wrapper over — the walking logic is shared with the pattern's home, but this stays + * its own module for the same reason as the sibling resolvers (see claude-cli-resolver.ts). * * @module utils/grok-cli-resolver */ -import { execFileSync } from 'node:child_process'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; -import { - createCliExecutableResolver, - formatCliNotFoundMessage, - type CliResolverHost, -} from './cli-executable-resolver.js'; - -/** Common directories where the Grok CLI binary may be installed */ -const GROK_SEARCH_DIRS = [ - join(homedir(), '.grok', 'bin'), - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), 'bin'), -]; +import { createVersionGatedResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; /** - * A real `grok --version` prints `grok 1.0.5 (5115b46bc9)` (measured, 1.0.5). + * A real `grok --version` prints `grok 1.0.5 (5115b46bc9)`. * - * Exported and SHARED with the `grok` entry in `config/dependency-registry.ts`, - * so `codeman doctor` and the run mode cannot disagree about what counts as an - * installed grok (the same single-source rule as PI_VERSION_REGEX). Shape is - * dictated by the doctor's `extractVersion()` (first capture group, whole-output - * scan): hence a capturing group and a leading boundary instead of `^`. No `g` - * flag, so there is no shared `lastIndex` to reset. + * Exported and SHARED with the `grok` entry in the CLI registry's stock catalog, so + * `codeman doctor` and the run mode cannot disagree about what counts as an installed + * grok. Shape is dictated by the doctor's `extractVersion()`, which returns the first + * CAPTURE GROUP and scans the whole output: hence a capturing group, and a leading + * boundary instead of `^`. No `g` flag, so there is no shared `lastIndex` to reset. */ export const GROK_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/; -const GROK_NOT_FOUND = 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash'; - -/** - * Run `grok --version` on a candidate path and return the version token when it - * looks like the coding agent. Returns null for anything else: a missing - * binary, a non-zero exit, a hang (timeout), or output with no version-shaped - * token (which is how an unrelated `grok` on PATH gets rejected). - * - * Never runs under vitest: the suites must stay hermetic and must not depend on - * whether the dev box happens to have grok installed, and since `grok` is a - * name with known squatters, this probe would EXECUTE whatever binary of that - * name the machine carries. The shared resolver host is already inert under - * vitest, so this gate is defense in depth for any opted-in host that still - * carries the default probe; tests drive resolution via - * `createGrokResolverForTest`, whose injected probe bypasses it. Pinned by - * test/grok-cli-resolver.test.ts. - */ -function probeGrokVersion(binPath: string): string | null { - if (process.env.VITEST) return null; - try { - const out = execFileSync(binPath, ['--version'], { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['ignore', 'pipe', 'ignore'], - // A stuck or hostile `grok` that ignores SIGTERM would survive the timeout - // and block the server (execFileSync keeps waiting after the signal). - killSignal: 'SIGKILL', - }).trim(); - const candidate = GROK_VERSION_REGEX.exec(out)?.[1]; - if (candidate) return candidate; - console.warn(`[GrokResolver] Ignoring ${binPath}: "grok --version" printed ${JSON.stringify(out.slice(0, 80))}`); - } catch (err) { - console.warn(`[GrokResolver] Ignoring ${binPath}: "grok --version" failed (${(err as Error).message})`); - } - return null; +function entry() { + const e = getCli('grok'); + if (!e) throw new Error('grok is not registered in the CLI registry'); + return e; } -type GrokVersionProbe = (binPath: string) => string | null; - -function createGrokResolver( - host?: CliResolverHost, - versionProbe: GrokVersionProbe = probeGrokVersion, - now?: () => number -) { - return createCliExecutableResolver( - { - binary: 'grok', - searchDirs: GROK_SEARCH_DIRS, - validateCandidate: (binPath) => { - const version = versionProbe(binPath); - return version ? { accepted: true, metadata: version } : { accepted: false }; - }, - now, - }, - host - ); -} - -/** - * Creates an isolated Grok wrapper around an injected host, version probe and - * clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which - * is exactly what the hermeticity test exercises. - */ -export function createGrokResolverForTest(host: CliResolverHost, versionProbe?: GrokVersionProbe, now?: () => number) { - return createGrokResolver(host, versionProbe ?? probeGrokVersion, now); -} - -const grokResolver = createGrokResolver(); +const resolver = createVersionGatedResolver( + entry().discovery.binaries, + entry().discovery.searchDirs, + entry().discovery.version ?? { arg: '--version', regex: GROK_VERSION_REGEX.source }, + 'GrokResolver' +); /** * Finds the directory containing a verified `grok` binary. - * Checks the server PATH first, then the common install locations - * (`~/.grok/bin` leading, the official installer's target). Every candidate - * must pass the `grok --version` sanity probe before it is accepted. + * Checks `which grok` first, then falls back to common install locations. Every + * candidate must pass the `grok --version` sanity probe before it is accepted. * * @returns Directory path, or null if not found */ export function resolveGrokDir(): string | null { - return grokResolver.resolve()?.directory ?? null; + return resolver.resolveDir(); } /** * Check if the Grok CLI is available on the system. */ export function isGrokAvailable(): boolean { - return resolveGrokDir() !== null; -} - -export function getGrokNotFoundMessage(): string { - return formatCliNotFoundMessage(GROK_NOT_FOUND, grokResolver.diagnostics()); + return resolver.isAvailable(); } /** - * Version reported by the resolved `grok` binary, or null when grok is - * unavailable. Surfaced through `GET /api/grok/status` so a misresolution is - * diagnosable from the UI. + * Version reported by the resolved `grok` binary, or null when grok is unavailable + * (or when the probe was skipped, i.e. under vitest). Surfaced through + * `GET /api/cli/grok/status` so a misresolution is diagnosable from the UI. */ export function getGrokCliVersion(): string | null { - return grokResolver.resolve()?.metadata ?? null; + return resolver.getVersion(); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 625005594..b857ccdc4 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -28,23 +28,13 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si export { assertNever } from './type-safety.js'; export { wrapWithNice } from './nice-wrapper.js'; export { resolveLocalShell, loginShellArgs } from './shell-resolver.js'; -export { - findClaudeDir, - getAugmentedPath, - getClaudeCliVersion, - getClaudeBinaryPath, - getClaudeNotFoundMessage, -} from './claude-cli-resolver.js'; +export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js'; export { spawnPtyWithHelperRepair } from './node-pty-repair.js'; -export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js'; -export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js'; -export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js'; -export { - resolveAntigravityDir, - isAntigravityAvailable, - getAntigravityNotFoundMessage, -} from './antigravity-cli-resolver.js'; -export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js'; -export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js'; +export { resolveOpenCodeDir } from './opencode-cli-resolver.js'; +export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js'; +export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js'; +export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js'; +export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js'; +export { resolveGrokDir, isGrokAvailable, getGrokCliVersion } from './grok-cli-resolver.js'; export { compileFileQuery, matchFileQuery } from './file-query.js'; export type { FileQueryMatcher } from './file-query.js'; diff --git a/src/utils/opencode-cli-resolver.ts b/src/utils/opencode-cli-resolver.ts index 3225dd6da..a79eff96d 100644 --- a/src/utils/opencode-cli-resolver.ts +++ b/src/utils/opencode-cli-resolver.ts @@ -1,29 +1,23 @@ /** - * @fileoverview Resolve the OpenCode CLI binary across common install paths. + * @fileoverview OpenCode CLI binary resolution. * - * Mirrors claude-cli-resolver.ts pattern. Finds the `opencode` binary - * and provides an augmented PATH string for tmux sessions. + * Thin wrapper over `cli-resolver.ts`'s generic walker, reading its search + * parameters from the CLI registry's stock catalog. See claude-cli-resolver.ts's + * file header for why this stays its own module rather than a bare re-export. * * @module utils/opencode-cli-resolver */ -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js'; +import { createDirResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; -/** Common directories where the OpenCode CLI binary may be installed */ -const OPENCODE_SEARCH_DIRS = [ - join(homedir(), '.opencode', 'bin'), // Default install location - join(homedir(), '.local', 'bin'), // Alternative install location - '/usr/local/bin', // Homebrew / system - join(homedir(), 'go', 'bin'), // Go install - join(homedir(), '.bun', 'bin'), // Bun global - join(homedir(), '.npm-global', 'bin'), // npm global - join(homedir(), 'bin'), // User bin -]; +function entry() { + const e = getCli('opencode'); + if (!e) throw new Error('opencode is not registered in the CLI registry'); + return e; +} -const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS }); -const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'; +const resolver = createDirResolver(entry().discovery.binaries, entry().discovery.searchDirs); /** * Finds the directory containing the `opencode` binary. @@ -33,16 +27,12 @@ const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL htt * @returns Directory path, or null if not found */ export function resolveOpenCodeDir(): string | null { - return openCodeResolver.resolve()?.directory ?? null; + return resolver.resolveDir(); } /** * Check if OpenCode CLI is available on the system. */ export function isOpenCodeAvailable(): boolean { - return resolveOpenCodeDir() !== null; -} - -export function getOpenCodeNotFoundMessage(): string { - return formatCliNotFoundMessage(OPENCODE_NOT_FOUND, openCodeResolver.diagnostics()); + return resolver.isAvailable(); } diff --git a/src/utils/pi-cli-resolver.ts b/src/utils/pi-cli-resolver.ts index 4cfa40112..d0cf93989 100644 --- a/src/utils/pi-cli-resolver.ts +++ b/src/utils/pi-cli-resolver.ts @@ -1,146 +1,69 @@ /** * @fileoverview Resolve the Pi CLI (`pi`) binary across common install paths. * - * Mirrors antigravity-cli-resolver.ts, with one addition the other external-CLI - * resolvers do not need: `pi` is a SHORT, GENERIC name (Raspberry Pi tooling, - * personal scripts, `$PATH` accidents), so a `which pi` hit is not by itself - * evidence that the coding agent is installed. Every candidate is therefore - * sanity-probed with `pi --version` and required to print a semver-shaped - * string; a binary that fails the probe is treated as absent and the rejected - * path is logged so a misresolution is diagnosable. - * - * Pi ships as the npm package `@earendil-works/pi-coding-agent`, so the search - * dirs are the usual global-bin locations (npm/bun/manual installs). + * `pi` is a SHORT, GENERIC name (Raspberry Pi tooling, personal scripts, `$PATH` + * accidents), so a `which pi` hit is not by itself evidence that the coding agent is + * installed. Every candidate is therefore sanity-probed with `pi --version` and + * required to print a semver-shaped string; a binary that fails the probe is treated + * as absent and the rejected path is logged so a misresolution is diagnosable. See + * `createVersionGatedResolver()` in cli-resolver.ts, which this is now a thin wrapper + * over — the walking logic is shared with the pattern's home, but this stays its own + * module for the same reason as the sibling resolvers (see claude-cli-resolver.ts). * * @module utils/pi-cli-resolver */ -import { execFileSync } from 'node:child_process'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; -import { - createCliExecutableResolver, - formatCliNotFoundMessage, - type CliResolverHost, -} from './cli-executable-resolver.js'; - -/** Common directories where the Pi CLI binary may be installed */ -const PI_SEARCH_DIRS = [ - join(homedir(), '.local', 'bin'), - '/usr/local/bin', - join(homedir(), '.bun', 'bin'), - join(homedir(), '.npm-global', 'bin'), - join(homedir(), 'bin'), -]; +import { createVersionGatedResolver } from './cli-resolver.js'; +import { getCli } from '../config/cli-registry/registry.js'; /** * A real `pi --version` prints a semver-shaped string (e.g. `0.84.1`). * - * Exported and SHARED with the `pi` entry in `config/dependency-registry.ts`, so + * Exported and SHARED with the `pi` entry in the CLI registry's stock catalog, so * `codeman doctor` and the run mode cannot disagree about what counts as an installed - * pi: two copies of this rule would let the Dependencies panel report "Pi CLI ✓" on a - * box where `resolvePiDir()` rejects the same binary and Run Pi stays hidden. - * - * Shape is dictated by the doctor's `extractVersion()`, which returns the first CAPTURE - * GROUP and scans the whole output: hence a capturing group, and a leading boundary - * instead of `^` so `pi 0.84.1` matches while `v0.84.1` (some other program) does not. - * No `g` flag, so there is no shared `lastIndex` to reset. + * pi. Shape is dictated by the doctor's `extractVersion()`, which returns the first + * CAPTURE GROUP and scans the whole output: hence a capturing group, and a leading + * boundary instead of `^` so `pi 0.84.1` matches while `v0.84.1` (some other program) + * does not. No `g` flag, so there is no shared `lastIndex` to reset. */ export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/; -const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'; - -/** - * Run `pi --version` on a candidate path and return the trimmed version when it - * looks like the coding agent. Returns null for anything else — a missing - * binary, a non-zero exit, a hang (timeout), or output that is not semver-shaped - * (which is how an unrelated `pi` on PATH gets rejected). - * - * Never runs under vitest: the suites must stay hermetic and must not depend on - * whether the dev box happens to have pi installed — and since `pi` is a short - * GENERIC name, this probe would EXECUTE whatever binary of that name the - * machine carries. The shared resolver host is already inert under vitest, so - * this gate is defense in depth for any opted-in host that still carries the - * default probe; tests drive resolution via `createPiResolverForTest`, whose - * injected probe bypasses it. Pinned by test/pi-cli-resolver.test.ts. - */ -function probePiVersion(binPath: string): string | null { - if (process.env.VITEST) return null; - try { - const out = execFileSync(binPath, ['--version'], { - encoding: 'utf-8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['ignore', 'pipe', 'ignore'], - // A stuck or hostile `pi` that ignores SIGTERM would survive the timeout - // and block the server (execFileSync keeps waiting after the signal). - killSignal: 'SIGKILL', - }).trim(); - // Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too. - const candidate = PI_VERSION_REGEX.exec(out)?.[1]; - if (candidate) return candidate; - console.warn(`[PiResolver] Ignoring ${binPath}: "pi --version" printed ${JSON.stringify(out.slice(0, 80))}`); - } catch (err) { - console.warn(`[PiResolver] Ignoring ${binPath}: "pi --version" failed (${(err as Error).message})`); - } - return null; -} - -type PiVersionProbe = (binPath: string) => string | null; - -function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion, now?: () => number) { - return createCliExecutableResolver( - { - binary: 'pi', - searchDirs: PI_SEARCH_DIRS, - validateCandidate: (binPath) => { - const version = versionProbe(binPath); - return version ? { accepted: true, metadata: version } : { accepted: false }; - }, - now, - }, - host - ); -} - -/** - * Creates an isolated Pi wrapper around an injected host, version probe and - * clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which - * is exactly what the hermeticity test exercises. - */ -export function createPiResolverForTest(host: CliResolverHost, versionProbe?: PiVersionProbe, now?: () => number) { - return createPiResolver(host, versionProbe ?? probePiVersion, now); +function entry() { + const e = getCli('pi'); + if (!e) throw new Error('pi is not registered in the CLI registry'); + return e; } -const piResolver = createPiResolver(); +const resolver = createVersionGatedResolver( + entry().discovery.binaries, + entry().discovery.searchDirs, + entry().discovery.version ?? { arg: '--version', regex: PI_VERSION_REGEX.source }, + 'PiResolver' +); /** * Finds the directory containing a verified `pi` binary. * Checks `which pi` first, then falls back to common install locations. Every - * candidate must pass the `pi --version` sanity probe (§2.6 of the integration - * plan) before it is accepted. + * candidate must pass the `pi --version` sanity probe before it is accepted. * * @returns Directory path, or null if not found */ export function resolvePiDir(): string | null { - return piResolver.resolve()?.directory ?? null; + return resolver.resolveDir(); } /** * Check if the Pi CLI is available on the system. */ export function isPiAvailable(): boolean { - return resolvePiDir() !== null; -} - -export function getPiNotFoundMessage(): string { - return formatCliNotFoundMessage(PI_NOT_FOUND, piResolver.diagnostics()); + return resolver.isAvailable(); } /** - * Version reported by the resolved `pi` binary, or null when pi is unavailable. - * Surfaced through `GET /api/pi/status` so a misresolution is diagnosable from the UI. + * Version reported by the resolved `pi` binary, or null when pi is unavailable + * (or when the probe was skipped, i.e. under vitest). Surfaced through + * `GET /api/pi/status` so a misresolution is diagnosable from the UI. */ export function getPiCliVersion(): string | null { - return piResolver.resolve()?.metadata ?? null; + return resolver.getVersion(); } diff --git a/src/web/public/app.js b/src/web/public/app.js index 52e97b149..fd9e711a8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2242,6 +2242,10 @@ class CodemanApp { _getResponseViewerAgentLabel() { const mode = this.sessions.get(this.activeSessionId)?.mode; + const clis = typeof window !== 'undefined' ? window.__codemanClis : undefined; + const cliMeta = (clis || []).find(c => c.id === mode); + if (cliMeta) return cliMeta.label; + // Fallback chain for a context with no window.__codemanClis (older cached page). return mode === 'codex' ? 'Codex' : mode === 'gemini' diff --git a/src/web/public/index.html b/src/web/public/index.html index 2031f5e26..5e314b3b3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -420,34 +420,41 @@

Codeman

Manage AI Coding tools in persistent tmux sessions.

- + +
+ + + + + +
+ - - - - -
@@ -609,31 +616,38 @@

Resume Conversation

- - - - - - - + +
+ + + + + + +
- +
+ +
@@ -2107,6 +2121,37 @@

Agents & CLIs

Launch flags for the CLIs Codeman spawns.

+
+

Installed CLIs

synced
+
+

Enable, disable and reorder the CLIs offered in the run menu. Advanced options (launch flags, environment) can be refined by editing ~/.codeman/clis.json directly.

+
+
+
+ Add Custom CLI + Register another CLI Codeman can launch, given its binary name. +
+ +
+ + +
+
+

Claude

synced
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index a8796851d..13438416f 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1570,6 +1570,14 @@ html.mobile-init .file-browser-panel { margin-top: 1rem; } + /* Same reasoning as styles.css's desktop rule: this group is one flex item + inside .welcome-actions now, so it needs its own matching column+gap. */ + #welcomeCliButtons { + flex-direction: column; + gap: 0.5rem; + width: 100%; + } + .welcome-btn { width: 100%; justify-content: center; diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index ed014e3b7..43825c1c8 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -388,28 +388,11 @@ Object.assign(CodemanApp.prototype, { try { const mode = this._runMode || 'claude'; - if (mode === 'opencode') { - return await this.runOpenCode(); - } - if (mode === 'codex') { - return await this.runCodex(); - } - if (mode === 'gemini') { - return await this.runGemini(); - } - if (mode === 'antigravity') { - return await this.runAntigravity(); - } - if (mode === 'pi') { - return await this.runPi(); - } - if (mode === 'grok') { - return await this.runGrok(); - } - if (mode === 'shell') { - return await this.runShell(); - } - return await this.runClaude(); + if (mode === 'claude') return await this.runClaude(); + if (mode === 'shell') return await this.runShell(); + // Every other mode (opencode/codex/gemini/antigravity/pi/grok, or a future custom + // external CLI) shares one launch path — see runCli()'s own doc comment. + return await this.runCli(mode); } finally { const remaining = minLockMs - (Date.now() - startedAt); if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)); @@ -439,6 +422,7 @@ Object.assign(CodemanApp.prototype, { e?.stopPropagation(); const menu = document.getElementById('runModeMenu'); if (!menu) return; + this._renderRunModeOptions(); menu.classList.toggle('active'); // Update selected state menu.querySelectorAll('.run-mode-option').forEach(btn => { @@ -458,23 +442,163 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Rebuilds #runModeAgentOptions / #runModeShellOption from window.__codemanClis (the + * live CLI registry, injected by renderIndexHtml — same data GET /api/clis serves) every + * time the menu opens, so a CLI enabled or added from Settings appears with no page + * reload and no markup change: this is what actually fixes "I enabled GitHub Copilot but + * it's not in the Run menu" — the registry was already correct, the menu markup just + * never read it. + * + * A missing/empty registry blob (a build predating the injection, or some other page + * that never got it) leaves the STATIC fallback buttons already in index.html alone — + * see isCliAvailable's own "missing flag reads as available" reasoning for why silence + * beats an empty menu. + */ + _renderRunModeOptions() { + const clis = window.__codemanClis; + if (!Array.isArray(clis) || clis.length === 0) return; + const agentGroup = document.getElementById('runModeAgentOptions'); + const shellGroup = document.getElementById('runModeShellOption'); + if (!agentGroup || !shellGroup) return; + + const enabled = clis.filter(c => c.enabled); + const agents = enabled.filter(c => c.kind !== 'shell').sort((a, b) => a.order - b.order); + const shells = enabled.filter(c => c.kind === 'shell').sort((a, b) => a.order - b.order); + + agentGroup.replaceChildren(...agents.map(c => this._buildRunModeOptionButton(c))); + shellGroup.replaceChildren(...shells.map(c => this._buildRunModeOptionButton(c))); + }, + + _buildRunModeOptionButton(cli) { + const btn = document.createElement('button'); + btn.className = 'run-mode-option'; + btn.dataset.mode = cli.id; + btn.onclick = () => this.setRunMode(cli.id); + const dot = document.createElement('span'); + dot.className = 'run-mode-dot'; + // Inline colour rather than a per-mode CSS class (styles.css only defines + // .run-mode-dot.claude/.opencode/etc for the original six) — accent is exactly the + // field the registry carries for this purpose, so a custom or newly-added stock CLI + // (like copilot) gets a correctly-coloured dot with no CSS change either. + if (cli.accent) dot.style.background = cli.accent; + btn.append(dot, document.createTextNode(cli.label)); + return btn; + }, + /** * #201: hides run-mode dropdown entries for CLIs that aren't installed, so - * picking one doesn't spawn a session that immediately errors out. + * picking one doesn't spawn a session that immediately errors out. Generic over + * WHATEVER buttons are actually present (built by _renderRunModeOptions above, or the + * static fallback markup if that bailed) rather than a fixed mode list, so a CLI added + * after this file was written is gated the same way as the original six. * * Shell has no external CLI dependency and is never gated, which is also what * guarantees the menu is never empty. Scoped to `menu` rather than the document: * `.run-mode-option` is also the class the saved-dashboard rows and the history * rows use, and a bare querySelector would find whichever came first in the DOM. - * - * Antigravity and Pi are in this list even though #201 predates them — they are - * run modes like the rest, and neither `agy` nor `pi` is likely to be installed. */ _refreshRunModeAvailability(menu) { - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']) { - const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`); - if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none'; + menu.querySelectorAll('.run-mode-option[data-mode]').forEach(btn => { + const mode = btn.dataset.mode; + if (mode === 'shell') return; + btn.style.display = this._isRunModeAvailable(mode) ? 'flex' : 'none'; + }); + }, + + /** + * Prefers the live registry's own `available` flag (covers any CLI, including one + * `window.__codemanCliAvailable` — the older, fixed six-key map — has never heard of, + * like copilot); falls back to isCliAvailable() when the registry blob is missing. + */ + _isRunModeAvailable(mode) { + const clis = window.__codemanClis; + if (Array.isArray(clis)) { + const entry = clis.find(c => c.id === mode); + if (entry) return entry.available !== false; + } + return this.isCliAvailable(mode); + }, + + /** + * Rebuilds #welcomeCliButtons from window.__codemanClis, called from + * applyWelcomeCliVisibility() every time the welcome screen shows — so enabling or + * disabling ANY CLI in Settings (Agents & CLIs), stock or custom, adds or removes its + * welcome button automatically, the same fix as _renderRunModeOptions() for the Run + * menu. Shows one button per registry entry that is both ENABLED and AVAILABLE + * (installed), sorted by `order` — including shell, which the ORIGINAL hardcoded markup + * never had a button for at all. + * + * A missing/empty registry blob (an old cached page, or 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 in the markup and rely on JS to reveal them, unlike the run-mode-menu's + * always-visible static fallback, so silently doing nothing here would be a regression. + */ + _renderWelcomeCliButtons() { + const clis = window.__codemanClis; + if (!Array.isArray(clis) || clis.length === 0) { + const legacy = [ + ['welcomeClaudeBtn', 'claude'], + ['welcomeOpencodeBtn', 'opencode'], + ['welcomeAntigravityBtn', 'antigravity'], + ['welcomeGeminiBtn', 'gemini'], + ['welcomePiBtn', 'pi'], + ]; + for (const [id, tool] of legacy) { + const btn = document.getElementById(id); + if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none'; + } + return; } + + const container = document.getElementById('welcomeCliButtons'); + if (!container) return; + const shown = clis.filter(c => c.enabled && c.available !== false).sort((a, b) => a.order - b.order); + container.replaceChildren(...shown.map(c => this._buildWelcomeCliButton(c))); + }, + + /** + * The original five CLIs each have a hand-crafted gradient (`.welcome-btn-` in + * styles.css); anything else (codex, copilot, a custom CLI) gets a flat inline + * background from the registry's own `accent` field instead of an invisible + * transparent button — the same "no per-mode CSS class needed" approach as the + * Run-menu's dot color. + */ + _buildWelcomeCliButton(cli) { + const KNOWN_STYLES = new Set(['claude', 'opencode', 'antigravity', 'gemini', 'pi']); + const btn = document.createElement('button'); + btn.className = KNOWN_STYLES.has(cli.id) ? `welcome-btn welcome-btn-${cli.id}` : 'welcome-btn'; + btn.style.display = 'flex'; + if (!KNOWN_STYLES.has(cli.id) && cli.accent) { + btn.style.background = cli.accent; + btn.style.borderColor = cli.accent; + } + btn.onclick = () => this._runWelcomeCli(cli.id); + + const svgNs = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(svgNs, 'svg'); + svg.setAttribute('width', '20'); + svg.setAttribute('height', '20'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + const polygon = document.createElementNS(svgNs, 'polygon'); + polygon.setAttribute('points', '5 3 19 12 5 21 5 3'); + svg.appendChild(polygon); + + btn.append(svg, document.createTextNode(` Run ${cli.label}`)); + return btn; + }, + + /** Mirrors run()'s own per-mode dispatch, without its launch-lock/button-disable + * behaviour -- matches the original hand-written welcome button onclick handlers. */ + _runWelcomeCli(id) { + this.setRunMode(id); + if (id === 'claude') return this.runClaude(); + if (id === 'shell') return this.runShell(); + return this.runCli(id); }, async _loadRunModeHistory() { @@ -568,7 +692,16 @@ Object.assign(CodemanApp.prototype, { gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`; } if (label) { - label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'shell' ? 'Run SH' : 'Run'; + // Prefer the registry's own shortBadge ("Run ") when the served list is + // available; claude has no badge suffix ("Run" alone), matching every mode this + // ternary already special-cased. Falls back to the hard-coded chain in a context + // with no `window.__codemanClis` (older cached page, or a test harness) so behavior + // stays identical either way — this is an enhancement, not a required data source. + const clis = typeof window !== 'undefined' ? window.__codemanClis : undefined; + const cliMeta = (clis || []).find(c => c.id === mode); + label.textContent = cliMeta + ? (mode === 'claude' ? 'Run' : `Run ${cliMeta.shortBadge}`) + : (mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'shell' ? 'Run SH' : 'Run'); } }, @@ -1007,330 +1140,109 @@ Object.assign(CodemanApp.prototype, { } }, - async runOpenCode() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote cases run the CLI on the REMOTE host — the local /api/opencode/status - // probe and the local-only config/env below don't apply (quick-start rejects them). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`); - // Focus in sync gesture context (see runClaude comment) - this.terminal.focus(); - - try { - // Check if OpenCode is available (local sessions only) - if (!isRemote) { - const statusRes = await fetch('/api/opencode/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' - ); - return; - } - } - - // Quick-start with opencode mode (auto-allow tools by default). - // No `effort` field — it's Claude-specific (OpenCode has no /effort). - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'opencode', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote ? {} : { - openCodeConfig: { autoAllowTools: true }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start OpenCode'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - // Switch to the new session (don't pre-set activeSessionId — selectSession - // early-returns when IDs match, skipping buffer load and sendResize) - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } - }, - - async runCodex() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote cases run Codex on the REMOTE host — skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/codex/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'Codex CLI not found. Install with: npm install -g @openai/codex' - ); - return; - } - } - - const globalSettings = this.loadAppSettingsFromStorage(); - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'codex', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote ? {} : { - codexConfig: { - dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, - animations: globalSettings.codexAnimationsEnabled ?? false, - renderMode: 'hybrid', - }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Codex'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - // Switch to the new session (don't pre-set activeSessionId — selectSession - // early-returns when IDs match, skipping buffer load and sendResize) - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } - }, - - async runGemini() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote cases run Gemini on the REMOTE host — skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/gemini/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'gemini', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote ? {} : { - geminiConfig: { approvalMode: 'yolo' }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Gemini'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } - }, - - async runAntigravity() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote/docker cases run agy on the OTHER side — skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Antigravity session in ${caseName}...`); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/antigravity/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'antigravity', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote ? {} : { - antigravityConfig: { dangerouslySkipPermissions: true }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Antigravity'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } - }, - /** - * Launch a Pi (pi.dev) session. - * - * Deliberately sends NO piConfig: pi has no permission prompts, so there is no - * bypass to opt into, and project trust is pi's own `defaultProjectTrust` - * decision (an interactive prompt the user answers in the terminal). Sending + * Per-mode default `Config` body sent to `/api/quick-start`, for the local + * (non-remote/docker) case. This is DELIBERATE FRONTEND POLICY, not "which CLIs + * exist" — it encodes decisions like "codex's bypass/animations come from two + * App Settings toggles" and "pi gets NO config at all", which is a genuine safety + * choice, not an omission: pi has no permission prompts, so there is no bypass to + * opt into, and project trust is pi's own `defaultProjectTrust` decision (an + * interactive prompt the user answers in the terminal) — sending * `approveProjectTrust: true` here would silently opt every browser-launched pi - * session into executing repo-supplied TypeScript. + * session into executing repo-supplied TypeScript. A mode with no entry here + * (claude/shell, handled by their own run methods; a future custom external CLI) + * gets no config object at all, which quick-start already treats as "use defaults". */ - async runPi() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote/docker cases run pi on the OTHER side — skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Pi session in ${caseName}...`); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/pi/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const res = await fetch('/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - caseName, - mode: 'pi', - sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, - ...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }), - }) - }); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Pi'); - await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); - - if (data.data.sessionId) { - await this.selectSession(data.data.sessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); + _quickStartConfigFor(mode, globalSettings) { + switch (mode) { + case 'opencode': + // No `effort` field — it's Claude-specific (OpenCode has no /effort). + return { openCodeConfig: { autoAllowTools: true } }; + case 'codex': + return { + codexConfig: { + dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, + animations: globalSettings.codexAnimationsEnabled ?? false, + renderMode: 'hybrid', + }, + }; + case 'gemini': + return { geminiConfig: { approvalMode: 'yolo' } }; + case 'antigravity': + return { antigravityConfig: { dangerouslySkipPermissions: true } }; + case 'grok': + // Mirrors runAntigravity()'s dangerouslySkipPermissions: Codeman sessions exist + // for autonomous work, so the Run button opts into grok's bypassPermissions mode + // (--always-approve; config-level deny rules still apply on top). The multi-user + // clamp forces it back off for non-granted owners server-side. + return { grokConfig: { alwaysApprove: true } }; + default: + return {}; } }, /** - * Launch a Grok Build (xAI `grok`) session. - * - * Sends `grokConfig: { alwaysApprove: true }` the way runAntigravity() sends - * `dangerouslySkipPermissions: true`: Codeman sessions exist for autonomous - * work, so the Run button opts into grok's bypassPermissions mode - * (`--always-approve`; config-level deny rules still apply on top). The - * multi-user clamp forces it back off for non-granted owners server-side. + * Launch a session for any external CLI mode (opencode/codex/gemini/antigravity/ + * pi today, or a future custom one) — the five near-identical runOpenCode()/ + * runCodex()/runGemini()/runAntigravity()/runPi() methods this replaced differed + * only in the status-check URL, the install-hint message, the display label and + * `_quickStartConfigFor()`'s per-mode config body, all now DATA (the label/install + * hint from `GET /api/cli/:id/status` and `window.__codemanClis`, the config body + * from the table above) rather than one copy-pasted method per CLI. claude/shell + * keep their own methods (runClaude/runShell): both are genuinely larger and + * differently-shaped (multi-tab, docker drift handling, ralph tracker, shell count). */ - async runGrok() { + async runCli(mode) { const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote/docker cases run grok on the OTHER side: skip the local status probe and the - // local-only config/env below (quick-start rejects them for remote cases). + // Remote/docker cases run the CLI on the OTHER side — the local status probe and + // the local-only config/env below don't apply (quick-start rejects them there). const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Grok session in ${caseName}...`); + // `typeof window` guard: some unit-test harnesses run this file in a vm sandbox + // with no `window` global at all, where a bare reference would throw instead of + // just being undefined (unlike every other optional-lookup in this method). + const clis = typeof window !== 'undefined' ? window.__codemanClis : undefined; + const cliMeta = (clis || []).find(c => c.id === mode); + const label = cliMeta?.label || mode; + + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting ${label} session in ${caseName}...`); + // Focus in sync gesture context (see runClaude comment) this.terminal.focus(); try { if (!isRemote) { - const statusRes = await fetch('/api/grok/status'); + const statusRes = await fetch(`/api/cli/${encodeURIComponent(mode)}/status`); const status = (await statusRes.json()).data; if (!status.available) { this._reportSessionLaunchError( ownsLaunchTerminal, - 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash' + status.installHint || `${label} CLI not found.` ); return; } } - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); + const globalSettings = this.loadAppSettingsFromStorage(); + const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings); const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caseName, - mode: 'grok', + mode, sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`, ...(isRemote ? {} : { - grokConfig: { alwaysApprove: true }, + ...this._quickStartConfigFor(mode, globalSettings), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), }), }) }); const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Failed to start Grok'); + if (!data.success) throw new Error(data.error || `Failed to start ${label}`); await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); + // Switch to the new session (don't pre-set activeSessionId — selectSession + // early-returns when IDs match, skipping buffer load and sendResize) if (data.data.sessionId) { await this.selectSession(data.data.sessionId); } @@ -1341,7 +1253,6 @@ Object.assign(CodemanApp.prototype, { } }, - // ═══════════════════════════════════════════════════════════════ // Session Options Modal // ═══════════════════════════════════════════════════════════════ @@ -1763,7 +1674,6 @@ Object.assign(CodemanApp.prototype, { } }, - // ═══════════════════════════════════════════════════════════════ // Session Options Modal Tabs // ═══════════════════════════════════════════════════════════════ @@ -1982,7 +1892,6 @@ Object.assign(CodemanApp.prototype, { }); }, - // ═══════════════════════════════════════════════════════════════ // Case Settings // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 9d9a42bd0..281e19bdc 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -433,6 +433,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsCodexAnimations').checked = settings.codexAnimationsEnabled ?? false; this._applyCodexSettingsVisibility(); + this.renderCliManagementList(); // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false; @@ -574,6 +575,273 @@ Object.assign(CodemanApp.prototype, { if (group) group.style.display = window.__codemanCliAvailable?.codex === true ? '' : 'none'; }, + /** + * Agents & CLIs → Installed CLIs: the enable/disable/reorder/remove list backed by + * GET/PUT/POST/DELETE /api/clis(...). Re-fetches on every call (not cached against + * window.__codemanClis, which is a page-load snapshot) so the list reflects a change + * made moments ago in the same session. Each row is built from ONLY existing + * `.set-row`/`.set-row-actions` classes — no new CSS — so it renders consistently with + * every other row in this modal. + */ + async renderCliManagementList() { + const container = document.getElementById('appSettingsCliList'); + if (!container) return; + let clis; + try { + const res = await fetch('/api/clis'); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to load CLIs'); + clis = data.data; + // Keep the page's live registry snapshot in sync with whatever this panel just + // fetched, so the Run-mode menu (_renderRunModeOptions in session-ui.js) reflects + // an enable/disable/add/remove made here immediately — without this, toggling a + // CLI on in Settings and opening Run without a page reload would still show the + // pre-toggle state, since window.__codemanClis is otherwise only ever set once, + // at page load. + window.__codemanClis = clis; + } catch (err) { + container.textContent = `Failed to load CLI list: ${err.message}`; + return; + } + + container.replaceChildren(); + clis.forEach((cli, index) => { + const row = document.createElement('div'); + row.className = 'set-row'; + row.dataset.cliId = cli.id; + row.dataset.search = `${cli.label} ${cli.id} cli`; + + const text = document.createElement('div'); + text.className = 'set-row-text'; + const label = document.createElement('span'); + label.className = 'set-row-label'; + label.textContent = `${cli.label}${cli.stock ? '' : ' (custom)'}`; + const desc = document.createElement('span'); + desc.className = 'set-row-desc'; + if (cli.installStatus?.state === 'installing') { + desc.textContent = `Installing… (${cli.installStatus.command})`; + } else if (cli.installStatus?.state === 'error') { + desc.textContent = `Install failed: ${cli.installStatus.message || 'unknown error'}`; + } else { + desc.textContent = cli.available ? 'Installed' : cli.installHint || 'Not found on this host'; + } + text.append(label, desc); + + const actions = document.createElement('div'); + actions.className = 'set-row-actions'; + + const upBtn = document.createElement('button'); + upBtn.type = 'button'; + upBtn.className = 'btn-toolbar btn-sm'; + upBtn.textContent = '↑'; + upBtn.title = 'Move up'; + upBtn.disabled = index === 0; + upBtn.onclick = () => this._moveCliOrder(clis, index, -1); + + const downBtn = document.createElement('button'); + downBtn.type = 'button'; + downBtn.className = 'btn-toolbar btn-sm'; + downBtn.textContent = '↓'; + downBtn.title = 'Move down'; + downBtn.disabled = index === clis.length - 1; + downBtn.onclick = () => this._moveCliOrder(clis, index, 1); + + const toggleLabel = document.createElement('label'); + toggleLabel.className = 'switch switch-sm'; + const toggleInput = document.createElement('input'); + toggleInput.type = 'checkbox'; + toggleInput.checked = cli.enabled; + toggleInput.disabled = cli.installStatus?.state === 'installing'; + toggleInput.onchange = () => this._setCliEnabled(cli.id, toggleInput.checked); + const slider = document.createElement('span'); + slider.className = 'slider'; + toggleLabel.append(toggleInput, slider); + + actions.append(upBtn, downBtn, toggleLabel); + + if (!cli.stock) { + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn-toolbar btn-sm'; + removeBtn.textContent = 'Remove'; + removeBtn.onclick = () => this._removeCustomCli(cli.id, cli.label); + actions.append(removeBtn); + } + + row.append(text, actions); + container.appendChild(row); + }); + }, + + async _setCliEnabled(id, enabled) { + let installing = false; + try { + const res = await fetch(`/api/clis/${encodeURIComponent(id)}/enabled`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to update'); + installing = data.data?.installStatus?.state === 'installing'; + } catch (err) { + this.showToast?.(err.message, 'error'); + } + this.renderCliManagementList(); + if (installing) this._pollCliInstallStatus(id); + }, + + /** + * Enabling a not-yet-installed CLI kicks off its install command server-side + * (cli-installer.ts) and returns immediately — this polls GET /api/clis until that + * specific entry's installStatus leaves the 'installing' state (or a bounded number of + * attempts is exhausted, since a slow install must not poll forever), re-rendering the + * list on every tick so the row's "Installing…"/"Install failed: …" text stays live. + */ + async _pollCliInstallStatus(id, attempt = 0) { + const MAX_ATTEMPTS = 40; // ~2 minutes at 3s apart; a still-installing entry just stops updating live + if (attempt >= MAX_ATTEMPTS) return; + await new Promise((resolve) => setTimeout(resolve, 3000)); + let stillInstalling = false; + try { + const res = await fetch('/api/clis'); + const data = await res.json(); + if (data.success) { + const cli = data.data.find((c) => c.id === id); + stillInstalling = cli?.installStatus?.state === 'installing'; + } + } catch { + // Transient fetch failure — keep polling rather than giving up on one hiccup. + stillInstalling = true; + } + this.renderCliManagementList(); + if (stillInstalling) this._pollCliInstallStatus(id, attempt + 1); + }, + + async _moveCliOrder(currentList, index, delta) { + const target = index + delta; + if (target < 0 || target >= currentList.length) return; + const order = currentList.map((c) => c.id); + [order[index], order[target]] = [order[target], order[index]]; + try { + const res = await fetch('/api/clis/order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to reorder'); + } catch (err) { + this.showToast?.(err.message, 'error'); + } + this.renderCliManagementList(); + }, + + async _removeCustomCli(id, label) { + if (!confirm(`Remove ${label} (${id})? This only removes it from the run menu — nothing is uninstalled.`)) return; + try { + const res = await fetch(`/api/clis/${encodeURIComponent(id)}`, { method: 'DELETE' }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to remove'); + } catch (err) { + this.showToast?.(err.message, 'error'); + } + this.renderCliManagementList(); + }, + + toggleAddCliForm(show) { + const row = document.getElementById('addCliFormRow'); + if (!row) return; + const visible = show === undefined ? row.style.display === 'none' : show; + row.style.display = visible ? '' : 'none'; + if (!visible) { + document.getElementById('appSettingsAddCliStatus').textContent = ''; + ['appSettingsNewCliId', 'appSettingsNewCliLabel', 'appSettingsNewCliBinary', 'appSettingsNewCliInstall'].forEach((id) => { + const el = document.getElementById(id); + if (el) el.value = ''; + }); + } + }, + + /** + * Build a minimal-but-valid custom CliEntry from the quick-add form and POST it. + * Deliberately conservative defaults (external/requiresMux true, no hooks, buffered + * echo, alt-screen preserved via strip-mux-only, no privileged params) — the SAME + * "behaves like pi" profile the registry documents for an unrecognized CLI, since + * that is the safest baseline for a CLI this form knows nothing else about. Advanced + * customization (launch flags, env) is a direct edit of ~/.codeman/clis.json, not + * something this quick form tries to cover. + */ + async submitAddCliForm() { + const status = document.getElementById('appSettingsAddCliStatus'); + const id = document.getElementById('appSettingsNewCliId').value.trim().toLowerCase(); + const label = document.getElementById('appSettingsNewCliLabel').value.trim(); + const binary = document.getElementById('appSettingsNewCliBinary').value.trim(); + const install = document.getElementById('appSettingsNewCliInstall').value.trim(); + + if (!/^[a-z][a-z0-9-]{0,23}$/.test(id)) { + status.textContent = 'id must be lowercase letters/digits/hyphens, starting with a letter.'; + return; + } + if (!label || !binary) { + status.textContent = 'Label and binary name are required.'; + return; + } + + const entry = { + label, + shortBadge: label.slice(0, 2).toUpperCase(), + accent: '#6b7280', + enabled: true, + order: 1000, + kind: 'agent', + discovery: { + binaries: [binary], + searchDirs: ['~/.local/bin', '/usr/local/bin', '~/.npm-global/bin', '~/bin'], + install: { command: install ? { linux: install, darwin: install } : {} }, + }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: binary }] }] }, + env: { exports: [], unset: [], tmuxSetenvKeys: [], dockerExecEnvNames: [], allowedPrefixes: [], allowedKeys: [] }, + capabilities: { + external: true, + requiresMux: true, + hooks: false, + transcript: 'none', + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + wheelForward: { mode: 'never' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' }, + privilegedParams: [], + gates: {}, + }, + overlays: {}, + }; + + try { + const res = await fetch(`/api/clis/${encodeURIComponent(id)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to add CLI'); + } catch (err) { + status.textContent = err.message; + return; + } + this.toggleAddCliForm(false); + this.renderCliManagementList(); + }, + /** * Scroll the settings document to a section. * @@ -1202,23 +1470,16 @@ Object.assign(CodemanApp.prototype, { * #200: show a welcome-screen button only where the thing it launches exists. * The markup ships them hidden, so an old cached page can never flash a button * for a tool this server does not have. + * + * The CLI buttons themselves are rebuilt from the registry by + * _renderWelcomeCliButtons() (session-ui.js) — this only handles the ONE + * button here that isn't a CLI at all: Cloudflare Tunnel, gated on + * `cloudflared` exactly as before. */ applyWelcomeCliVisibility() { - const buttons = [ - ['welcomeClaudeBtn', 'claude'], - ['welcomeOpencodeBtn', 'opencode'], - ['welcomeAntigravityBtn', 'antigravity'], - ['welcomeGeminiBtn', 'gemini'], - ['welcomePiBtn', 'pi'], - ['welcomeGrokBtn', 'grok'], - // Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box - // without cloudflared can only ever produce "cloudflared not found". - ['welcomeTunnelBtn', 'cloudflared'], - ]; - for (const [id, tool] of buttons) { - const btn = document.getElementById(id); - if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none'; - } + this._renderWelcomeCliButtons?.(); + const tunnelBtn = document.getElementById('welcomeTunnelBtn'); + if (tunnelBtn) tunnelBtn.style.display = this.isCliAvailable('cloudflared') ? 'flex' : 'none'; }, async loadTunnelStatus() { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 7533ea50f..3dc24edd8 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3786,6 +3786,17 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { margin-top: 2rem; margin-bottom: 1.5rem; } +/* #welcomeCliButtons groups the dynamically-rendered agent buttons (see + _renderWelcomeCliButtons() in session-ui.js) as ONE flex item inside + .welcome-actions, alongside the separate Cloudflare Tunnel button — so it needs + its own flex+gap+wrap, matching the parent's, or the buttons inside it would + lose the gap and wrapping behaviour .welcome-actions gives its DIRECT children. */ +#welcomeCliButtons { + display: flex; + gap: 0.75rem; + justify-content: center; + flex-wrap: wrap; +} .welcome-btn { display: flex; @@ -5025,6 +5036,16 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { flex-direction: column; gap: 2px; } +/* Plain grouping wrappers around the dynamically-rendered agent/shell run-mode + options (see index.html + _renderRunModeOptions() in session-ui.js) — without + their own flex+gap, buttons inside them would lose the 2px gap .run-mode-menu.active + gives its DIRECT children, since these divs sit one level in between. */ +#runModeAgentOptions, +#runModeShellOption { + display: flex; + flex-direction: column; + gap: 2px; +} .run-mode-option { display: flex; align-items: center; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index c5aeed84e..da82ec7ca 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -25,8 +25,11 @@ import { type AntigravityConfig, type PiConfig, type GrokConfig, + type SessionMode, } from '../../types.js'; -import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode, isExternalCliMode } from '../../session.js'; +import { resolveCliBinDir } from '../../utils/cli-resolver.js'; +import { missingCliMessage, getCli } from '../../config/cli-registry/registry.js'; import { SseEvent } from '../sse-events.js'; import { CreateSessionSchema, @@ -317,27 +320,37 @@ export function _resetPasteRateBuckets(): void { /** * Security (multi-user §6.3): the Claude-only permission-mode downgrade does not * cover the other CLIs' bypass switches. Codex `--dangerously-bypass-approvals-and-sandbox`, - * Gemini `--approval-mode yolo`, and Antigravity `--dangerously-skip-permissions` disable - * the safety classifier the non-granted-user downgrade is meant to keep on, so clamp them - * for a non-granted owner. buildGeminiCommand defaults an ABSENT approvalMode to yolo, so - * the gemini config must be MATERIALIZED (auto_edit) even when the request sent none. - * Antigravity is like Codex: an ABSENT config already defaults safe (no bypass flag), so - * only a sent config needs the flag forced off. No-op in single-user mode / for a granted - * owner (canUsernameRunPrivilegedCommands returns true when !isMultiUserMode()). + * Gemini `--approval-mode yolo`, Antigravity `--dangerously-skip-permissions`, Pi's + * `approveProjectTrust`, and Grok `--always-approve` all disable a safety gate the + * non-granted-user downgrade is meant to keep on, so clamp them for a non-granted owner. + * No-op in single-user mode / for a granted owner (canUsernameRunPrivilegedCommands + * returns true when !isMultiUserMode()). * - * Pi has no permission prompts at all, so there is no bypass switch to clamp; its - * privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE - * repo-local `.pi/extensions` TypeScript and npm-install missing project packages. - * Pi joins the gemini-style MATERIALIZE branch, not the codex/antigravity - * only-if-sent one: pi's absent-config default is an interactive trust prompt the - * session user could simply answer "yes" to in the terminal, so merely omitting - * `--approve` is not a clamp. Forcing `approveProjectTrust: false` makes - * buildPiCommand emit `--no-approve`, and the prompt never appears. - * - * Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove` - * (`--always-approve`), and an ABSENT config already spawns in grok's own - * ask-mode default, so only a sent config needs the flag forced off. + * Generalized over the registry's `capabilities.privilegedParams` + * (`src/config/cli-registry/types.ts`) rather than one hand-written branch per CLI, so a + * CUSTOM CLI's own bypass flag is clamped exactly like codex's with zero code here. Each + * entry names its param and what a non-granted owner is forced to (`clampTo`), plus + * whether an ABSENT config must be MATERIALIZED: + * - only-if-sent (materializeWhenAbsent false/omitted; codex, antigravity, grok): the + * CLI's own absent-config default already spawns safe, so only a config the caller + * actually sent gets touched. + * - materialize (true; gemini, pi): the absent-config default is ITSELF unsafe for a + * non-granted owner (gemini's builder defaults an absent approvalMode to `yolo`; + * pi's absent default is an interactive trust prompt the session user could just + * answer "yes" to), so the clamp must CREATE a config even when none was sent. */ +function clampConfigForMode(mode: string, config: T | undefined): T | undefined { + const params = getCli(mode)?.capabilities.privilegedParams ?? []; + if (params.length === 0) return config; + const materializes = params.some((p) => p.materializeWhenAbsent); + if (!config && !materializes) return config; + const clamped: Record = { ...(config as Record | undefined) }; + for (const { param, clampTo, materializeWhenAbsent } of params) { + if (config || materializeWhenAbsent) clamped[param] = clampTo; + } + return clamped as T; +} + async function clampExternalCliBypassForOwner( owner: string | undefined, codexConfig: CodexConfig | undefined, @@ -354,22 +367,12 @@ async function clampExternalCliBypassForOwner( }> { const granted = await canUsernameRunPrivilegedCommands(owner); if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig }; - // Non-granted: force codex/antigravity bypass off (only meaningful when a config was - // sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default) - // and pi to --no-approve (clamps an explicit true AND pi's own "ask" default). - const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig; - const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' }; - const clampedAntigravity = antigravityConfig - ? { ...antigravityConfig, dangerouslySkipPermissions: false } - : antigravityConfig; - const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false }; - const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig; return { - codexConfig: clampedCodex, - geminiConfig: clampedGemini, - antigravityConfig: clampedAntigravity, - piConfig: clampedPi, - grokConfig: clampedGrok, + codexConfig: clampConfigForMode('codex', codexConfig), + geminiConfig: clampConfigForMode('gemini', geminiConfig), + antigravityConfig: clampConfigForMode('antigravity', antigravityConfig), + piConfig: clampConfigForMode('pi', piConfig), + grokConfig: clampConfigForMode('grok', grokConfig), }; } @@ -649,6 +652,23 @@ async function injectAgentSkill(casePath: string): Promise { // bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the // setting through the ConfigPort (tests stub it) and pass it as the second arg. +/** + * Pre-flight availability check for an external CLI mode, shared by the create and + * quick-start routes (each used to hand-write this as five near-identical `if (body.mode + * === '') { ... }` blocks). Returns an error response body when the mode is an external + * CLI (`isExternalCliMode` — opencode/codex/gemini/antigravity/pi today, or any future + * custom external CLI) that is not resolvable on this host; `null` when there is nothing to + * report (claude/shell are never checked here, and neither is a mode already confirmed + * available). Never called for a `remote`/`docker` case — those run the CLI on the OTHER + * host, where this local resolver cannot see it. + */ +function checkExternalCliAvailable(mode: SessionMode): ApiResponse | null { + if (!isExternalCliMode(mode)) return null; + if (resolveCliBinDir(mode) !== null) return null; + const message = missingCliMessage(mode) ?? `${mode} CLI not found.`; + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, message); +} + export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort @@ -764,12 +784,7 @@ export function registerSessionRoutes( // values). const managedCasesBase = resolveCasesDir(getAuthUser(req)); const canStripDisk = - body.mode !== 'opencode' && - body.mode !== 'codex' && - body.mode !== 'gemini' && - body.mode !== 'antigravity' && - body.mode !== 'pi' && - body.mode !== 'grok' && + !isExternalCliMode(body.mode ?? 'claude') && body.envOverrides && Object.keys(body.envOverrides).length > 0 && (workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/')); @@ -821,49 +836,11 @@ export function registerSessionRoutes( } } - // Check OpenCode availability if requested. The error text comes from the - // resolver (formatCliNotFoundMessage) so it names where resolution looked — - // server PATH, login shell, common directories — same for the modes below. - if (body.mode === 'opencode') { - const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js'); - if (!isOpenCodeAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage()); - } - } - - // Check Codex availability if requested - if (body.mode === 'codex') { - const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js'); - if (!isCodexAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage()); - } - } - - // Check Gemini availability if requested - if (body.mode === 'gemini') { - const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js'); - if (!isGeminiAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage()); - } - } - if (body.mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = - await import('../../utils/antigravity-cli-resolver.js'); - if (!isAntigravityAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); - } - } - if (body.mode === 'pi') { - const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js'); - if (!isPiAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); - } - } - if (body.mode === 'grok') { - const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js'); - if (!isGrokAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage()); - } + // Pre-flight availability check for an external CLI (opencode/codex/gemini/ + // antigravity/pi/grok today — see checkExternalCliAvailable's own doc comment). + if (body.mode) { + const unavailable = checkExternalCliAvailable(body.mode); + if (unavailable) return unavailable; } // Pre-validate resumeSessionId: check that the conversation file actually exists @@ -1171,17 +1148,13 @@ export function registerSessionRoutes( try { // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) - // Ralph tracker is not supported for opencode / codex / gemini / antigravity / pi sessions. - // Keep this list in step with isExternalCliMode(): _processExpensiveParsers() returns early - // for those modes, so a tracker enabled here would never be fed, and the session would - // still report ralphEnabled + Ralph UI state that no other external CLI shows. + // Ralph tracker is not supported for external CLI sessions (opencode/codex/gemini/ + // antigravity/pi/grok/etc — anything isExternalCliMode() covers): + // _processExpensiveParsers() returns early for those modes, so a tracker enabled + // here would never be fed, and the session would still report ralphEnabled + Ralph + // UI state that no other external CLI shows. if ( - session.mode !== 'opencode' && - session.mode !== 'codex' && - session.mode !== 'gemini' && - session.mode !== 'antigravity' && - session.mode !== 'pi' && - session.mode !== 'grok' && + !isExternalCliMode(session.mode) && ctx.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled ) { @@ -2858,56 +2831,12 @@ export function registerSessionRoutes( dockerResumeId = dockerCase.lastClaudeSessionId; } } else { - // Check OpenCode availability if requested. Error text comes from the - // resolver so it carries the resolution diagnostics; same for the modes below. - if (mode === 'opencode') { - const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = - await import('../../utils/opencode-cli-resolver.js'); - if (!isOpenCodeAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage()); - } - } - - // Check Codex availability if requested - if (mode === 'codex') { - const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js'); - if (!isCodexAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage()); - } - } - - // Check Gemini availability if requested - if (mode === 'gemini') { - const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js'); - if (!isGeminiAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage()); - } - } - - // Check Antigravity availability if requested - if (mode === 'antigravity') { - const { isAntigravityAvailable, getAntigravityNotFoundMessage } = - await import('../../utils/antigravity-cli-resolver.js'); - if (!isAntigravityAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage()); - } - } - - // Check Pi availability if requested - if (mode === 'pi') { - const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js'); - if (!isPiAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage()); - } - } - - // Check Grok availability if requested - if (mode === 'grok') { - const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js'); - if (!isGrokAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage()); - } - } + // Pre-flight availability check for an external CLI (opencode/codex/gemini/ + // antigravity/pi/grok today — see checkExternalCliAvailable's own doc comment). Only + // reached for a LOCAL case (the remote/docker branches above return earlier), which + // is why the LOCAL resolver gate applies here and not there. + const unavailable = checkExternalCliAvailable(mode); + if (unavailable) return unavailable; // Resolve case path: check linked-cases registry first, then fall back to CASES_DIR. // This mirrors the behaviour of resolveCasePath() in case-routes so that linked @@ -2955,15 +2884,9 @@ export function registerSessionRoutes( writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications - // (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems) - if ( - mode !== 'opencode' && - mode !== 'codex' && - mode !== 'gemini' && - mode !== 'antigravity' && - mode !== 'pi' && - mode !== 'grok' - ) { + // (Claude-specific — every external CLI, isExternalCliMode()'s own set, uses its + // own systems instead) + if (!isExternalCliMode(mode)) { await writeHooksConfig(resolvedCasePath); } @@ -3029,17 +2952,7 @@ export function registerSessionRoutes( // Strip stale disk entries for keys this request is actively setting (Claude only — // see POST /api/sessions for full rationale). - if ( - mode !== 'opencode' && - mode !== 'codex' && - mode !== 'gemini' && - mode !== 'antigravity' && - mode !== 'pi' && - mode !== 'grok' && - !remote && - envOverrides && - Object.keys(envOverrides).length > 0 - ) { + if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) { await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides)); } diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 433829aba..5cbc39dfc 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -22,6 +22,8 @@ import { ConfigUpdateSchema, SettingsUpdateSchema, ModelConfigUpdateSchema, + CliEnabledUpdateSchema, + CliOrderUpdateSchema, CpuLimitSchema, SubagentWindowStatesSchema, SubagentParentMapSchema, @@ -383,6 +385,127 @@ export function registerSystemRoutes( // CLI Integrations (Claude, OpenCode, Codex, Gemini, Antigravity, Pi, Grok) // ═══════════════════════════════════════════════════════════════ + // ========== CLI registry ========== + + // The full registry, secrets-free (CliEntry never carries a secret value — tmuxSetenvKeys + // etc. are env var NAMES only), for the frontend to render the run-mode menu, welcome + // buttons, labels and badges from data instead of a hard-coded list. Sorted by `order`, + // each entry augmented with live availability so a single fetch covers both. + app.get('/api/clis', async () => { + const { listClis, missingCliMessage } = await import('../../config/cli-registry/registry.js'); + const { resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js'); + const { getCliInstallStatus } = await import('../../config/cli-registry/cli-installer.js'); + const clis = listClis().map((entry) => { + const available = entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null; + return { + ...entry, + available, + path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), + version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), + // Populated only when actually needed (not installed), so the frontend never has + // to reconstruct the per-platform install-command message itself. + installHint: available ? null : missingCliMessage(entry.id), + // Set only while an auto-install triggered by enabling this CLI is in flight, or + // just finished — see cli-installer.ts. Absent under normal (already-resolved) + // circumstances, so this adds nothing to the payload for the common case. + installStatus: getCliInstallStatus(entry.id) ?? null, + }; + }); + return { success: true, data: clis }; + }); + + // Generic per-CLI status, superseding the six hand-written `/api//status` routes + // below (kept as aliases — see docs/versioning-policy.md, an existing endpoint path is + // never removed). Works for ANY registered id, including a custom one those six never + // could. `version` is only ever non-null for a version-aware resolver (see + // resolveCliVersion's own doc comment); claude's own `/api/claude/status` stays the + // place to read ITS live version until that becomes generic too. + app.get<{ Params: { id: string } }>('/api/cli/:id/status', async (req, reply) => { + const { getCli, missingCliMessage } = await import('../../config/cli-registry/registry.js'); + const { resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js'); + const { getCliInstallStatus } = await import('../../config/cli-registry/cli-installer.js'); + const entry = getCli(req.params.id); + if (!entry) { + return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Unknown CLI: ${req.params.id}`)); + } + const available = entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null; + return { + success: true, + data: { + available, + path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), + version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), + installHint: available ? null : missingCliMessage(entry.id), + installStatus: getCliInstallStatus(entry.id) ?? null, + }, + }; + }); + + // Settings-UI mutations (App Settings → Agents & CLIs). Admin-only in multi-user mode — + // the registry is process-wide config, not scoped to a single user's workspace, same + // posture as the workflow/subagent aggregates above. All four return the FULL resolved + // list on success, so the frontend can just replace its in-memory copy rather than + // re-deriving what changed. + app.put<{ Params: { id: string } }>('/api/clis/:id/enabled', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const { enabled } = parseBody(CliEnabledUpdateSchema, req.body, 'Invalid request body'); + const { setCliEnabled } = await import('../../config/cli-registry/registry.js'); + const result = setCliEnabled(req.params.id, enabled); + if (!result.success) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); + } + // Enabling a CLI whose binary isn't installed yet kicks off its install command in the + // background — see cli-installer.ts's file header for the trust model. Fire-and-forget: + // this call returns synchronously with whatever status ensureCliInstalled set (usually + // 'installing' immediately, or nothing at all if it was already available), the actual + // install keeps running after this response is sent, and the frontend polls GET + // /api/clis for progress. Never triggered on disable. + let installStatus = null; + if (enabled) { + const { ensureCliInstalled, getCliInstallStatus } = await import('../../config/cli-registry/cli-installer.js'); + ensureCliInstalled(req.params.id); + installStatus = getCliInstallStatus(req.params.id) ?? null; + } + return { success: true, data: { entries: result.entries, warnings: result.warnings, installStatus } }; + }); + + app.put('/api/clis/order', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const { order } = parseBody(CliOrderUpdateSchema, req.body, 'Invalid request body'); + const { setCliOrder } = await import('../../config/cli-registry/registry.js'); + const result = setCliOrder(order); + if (!result.success) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); + } + return { success: true, data: { entries: result.entries, warnings: result.warnings } }; + }); + + // Add or replace a CUSTOM CLI. The body is a complete CliEntry (validated by the SAME + // schema the on-disk file is validated against — see config/cli-registry/schema.ts's + // file header for what that schema does and does not allow, notably that no field can + // ever carry raw shell text). `id` is taken from the URL, never trusted from the body. + app.post<{ Params: { id: string } }>('/api/clis/:id', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const { upsertCustomCli } = await import('../../config/cli-registry/registry.js'); + const result = upsertCustomCli(req.params.id, req.body); + if (!result.success) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); + } + return { success: true, data: { entries: result.entries, warnings: result.warnings } }; + }); + + // Remove a custom CLI. Refuses for a stock id (disable it instead) — see + // removeCustomCli's own doc comment. + app.delete<{ Params: { id: string } }>('/api/clis/:id', async (req, reply) => { + if (isMultiUserMode() && !requireAdmin(req, reply)) return; + const { removeCustomCli } = await import('../../config/cli-registry/registry.js'); + const result = removeCustomCli(req.params.id); + if (!result.success) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, result.warnings.join('; '))); + } + return { success: true, data: { entries: result.entries, warnings: result.warnings } }; + }); + // ========== Claude ========== app.get('/api/claude/status', async () => { @@ -446,21 +569,6 @@ export function registerSystemRoutes( }; }); - // ========== Grok ========== - - // Carries `version` on top of the sibling shape, same reason as pi: `grok` is a - // binary name with known squatters, so the resolver version-probes candidates and - // this endpoint is where a misresolution shows up (path + version) instead of - // presenting as "the mode just doesn't work". - app.get('/api/grok/status', async () => { - const { isGrokAvailable, resolveGrokDir, getGrokCliVersion } = await import('../../utils/grok-cli-resolver.js'); - return { - available: isGrokAvailable(), - path: resolveGrokDir(), - version: getGrokCliVersion(), - }; - }); - // ═══════════════════════════════════════════════════════════════ // State & Lifecycle (cleanup, lifecycle log, stats) // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 3e09b01ab..f367f0587 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -18,6 +18,8 @@ import { } from '../config/terminal-history.js'; import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; +import { enabledClis } from '../config/cli-registry/registry.js'; +import type { SessionMode } from '../types/session.js'; // ========== Path Validation ========== @@ -121,28 +123,55 @@ export const FileWriteSchema = z // ========== Env Var Allowlist ========== -/** Allowlisted env var key prefixes */ -const ALLOWED_ENV_PREFIXES = [ - 'CLAUDE_CODE_', - 'OPENCODE_', - 'CODEX_', - 'GEMINI_', - 'GOOGLE_', - 'ANTIGRAVITY_', - 'PI_', - 'GROK_', - 'XAI_', -]; +/** + * Allowlisted env var key prefixes, composed from every registered CLI's own + * `env.allowedPrefixes` (config/cli-registry/stock.ts) — e.g. gemini contributes both + * `GEMINI_` and the deliberately-broad `GOOGLE_` (Vertex AI auth needs + * `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`). + * A CLI added to the registry — stock or custom — widens this automatically; nothing here + * needs editing to add one. Computed once at module load (the registry itself is memoized), + * matching this module's previous hardcoded-array performance. + */ +const ALLOWED_ENV_PREFIXES: string[] = enabledClis().flatMap((entry) => entry.env.allowedPrefixes); + +/** + * Allowlisted exact env var keys (checked alongside the prefixes), composed the same way + * from `env.allowedKeys`. CLAUDE_CONFIG_DIR (claude's own entry) relocates the Claude CLI's + * user config (credentials, settings, stats) so a case can run on a separate Claude + * subscription (#255). Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected. + */ +const ALLOWED_ENV_KEYS = new Set(enabledClis().flatMap((entry) => entry.env.allowedKeys)); /** - * Allowlisted exact env var keys (checked alongside the prefixes). - * CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials, - * settings, stats) so a case can run on a separate Claude subscription (#255). - * Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected. + * The session `mode`/`agentType` enum, built from the registry rather than a fixed + * literal list: `z.enum(registryIds())` per the CLI-registry compatibility design (see + * docs/cli-registry.md). A custom CLI added through App Settings → Agents & CLIs (or by + * hand-editing ~/.codeman/clis.json) becomes a valid `mode` value the moment it is + * enabled, with no schema change. Disabled entries are deliberately excluded — the same + * policy as ALLOWED_ENV_PREFIXES above — so a disabled CLI cannot be used to start a new + * session even if a stale client still offers it. `shell` is always present (it can be + * disabled but never deleted — see registry.ts), so this is never empty at runtime; the + * cast is only to satisfy Zod's non-empty-tuple type, which cannot be proven statically + * for a value computed at module load. */ -const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']); +const SESSION_MODE_IDS = enabledClis().map((entry) => entry.id as string); +// `SessionMode` stays the literal union of stock ids for now (retyping it as a plain +// string would also collapse RemoteCommandMode/DockerCommandMode, which key off +// Extract — a larger, separately-scoped change). The cast here is the +// honest boundary: Zod validates against the LIVE registry (so a custom CLI id really is +// accepted at runtime), and every downstream reader treats `mode` as an opaque id rather +// than exhaustively switching on it (test/cli-registry-no-id-branching.test.ts enforces +// that), so widening what actually flows through is safe even though the static type does +// not (yet) say so. +const sessionModeSchema = () => z.enum(SESSION_MODE_IDS as [string, ...string[]]) as unknown as z.ZodType; -/** Env var keys that are always blocked (security-sensitive) */ +/** + * Env var keys that are ALWAYS blocked (security-sensitive) — a hard floor no registry + * entry, stock or custom, can widen. Deliberately NOT registry-driven: an entry's + * `allowedPrefixes` contributes only to the allowlist above, and is checked in + * `isAllowedEnvKey` AFTER this blocklist, so a rogue `allowedPrefixes: ['']` still cannot + * unblock PATH or any other floor entry. + */ const BLOCKED_ENV_KEYS = new Set([ 'PATH', 'LD_PRELOAD', @@ -349,7 +378,7 @@ const parentSessionIdSchema = z.string().max(100).optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(), + mode: sessionModeSchema().optional(), name: z.string().max(100).optional(), /** Session that spawned this one — see parentSessionIdSchema. */ parentSessionId: parentSessionIdSchema, @@ -776,7 +805,7 @@ export const QuickStartSchema = z.object({ * a real host dir, so the settings file crosses the bind mount); rejected for * remote cases (the file would be written on the WRONG machine). */ modelOverride: z.string().max(50).optional(), - mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']).optional(), + mode: sessionModeSchema().optional(), openCodeConfig: OpenCodeConfigSchema, codexConfig: CodexConfigSchema, geminiConfig: GeminiConfigSchema, @@ -1310,7 +1339,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ const CronJobBaseSchema = z.object({ name: z.string().min(1).max(200), - agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok']), + agentType: sessionModeSchema(), workingDir: safePathSchema, launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(), promptMode: z.enum(['inline_text', 'prompt_file_path']), @@ -1433,6 +1462,12 @@ export const CpuLimitSchema = z.object({ /** PUT /api/execution/model-config */ export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown()); +/** PUT /api/clis/:id/enabled */ +export const CliEnabledUpdateSchema = z.object({ enabled: z.boolean() }).strict(); + +/** PUT /api/clis/order — the full desired id order, front to back. */ +export const CliOrderUpdateSchema = z.object({ order: z.array(z.string().min(1).max(24)).min(1).max(64) }).strict(); + /** PUT /api/subagent-window-states */ export const SubagentWindowStatesSchema = z .object({ diff --git a/src/web/server.ts b/src/web/server.ts index 594f53371..9e38d1754 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1466,6 +1466,25 @@ export class WebServer extends EventEmitter { '', `\n` ); + + // The full CLI registry (same shape as GET /api/clis), for the frontend to render + // the run-mode menu / welcome buttons / labels from data instead of the hard-coded + // list `__codemanCliAvailable` above still is. Additive: `__codemanCliAvailable` + // keeps its EXACT shape (test/render-index-html.test.ts pins it with `toEqual`, + // which fails on an extra key) as a derived alias, not superseded in this pass. + const { listClis } = await import('../config/cli-registry/registry.js'); + const { resolveCliBinDir, resolveCliVersion } = await import('../utils/cli-resolver.js'); + const clis = listClis().map((entry) => ({ + ...entry, + available: entry.kind === 'shell' ? true : resolveCliBinDir(entry.id) !== null, + path: entry.kind === 'shell' ? null : resolveCliBinDir(entry.id), + version: entry.kind === 'shell' ? null : resolveCliVersion(entry.id), + })); + // Escaped like the solo-id global above: label/accent/etc. ultimately come from + // ~/.codeman/clis.json, which an operator can edit, so this is defense-in-depth + // against a `` breakout rather than a response to untrusted REQUEST input. + const safeClis = JSON.stringify(clis).replace(/', `\n`); } if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('', `\n`); diff --git a/src/web/session-wait-registry.ts b/src/web/session-wait-registry.ts index 6675f2e88..5186e88ec 100644 --- a/src/web/session-wait-registry.ts +++ b/src/web/session-wait-registry.ts @@ -58,6 +58,7 @@ */ import { stripAnsi } from '../utils/index.js'; +import { getCli } from '../config/cli-registry/registry.js'; import { MAX_WAITERS_PER_SESSION, MAX_WAITERS_PER_OWNER, @@ -182,7 +183,7 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked']; * infinite-wait-dressed-as-a-timeout this guard exists to prevent. */ export function hooksAvailableForMode(mode: SessionMode): boolean { - return mode === 'claude'; + return getCli(mode)?.capabilities.hooks ?? false; } /** Outcome of resolving a caller-supplied wait target against a session's mode. */ diff --git a/test/agent-skill-mode-lists.test.ts b/test/agent-skill-mode-lists.test.ts index 821f5f0b5..d199c41af 100644 --- a/test/agent-skill-mode-lists.test.ts +++ b/test/agent-skill-mode-lists.test.ts @@ -91,11 +91,18 @@ describe('agent skill run-mode lists', () => { it('documents the CLI availability probe for every agent mode', () => { // The gap this closes: /api/pi/status shipped undocumented and only a human reading // the doc noticed, because the sibling scanner (agent-skill-endpoints-doc.test.ts) - // only checks documented -> registered. Derived from the schema, so a seventh + // only checks documented -> registered. Derived from the schema, so a new enabled // backend fails here until its probe is documented; the sibling test still proves // the reverse, that nothing documented here is a 404. + // + // Matches BOTH shapes: the six legacy per-mode aliases (`/api//status`) and the + // generic route every mode added since (`/api/cli//status`, e.g. grok has no + // legacy alias and is documented only via the generic form) — a CLI's own doc line + // gets to pick whichever it actually points at. const doc = readFileSync(join(SKILL_DIR, 'reference/endpoints.md'), 'utf-8'); - const documented = new Set([...doc.matchAll(/\bGET\s+\/api(?:\/v1)?\/([a-z-]+)\/status\b/g)].map((m) => m[1])); + const documented = new Set( + [...doc.matchAll(/\bGET\s+\/api(?:\/v1)?\/(?:cli\/)?([a-z-]+)\/status\b/g)].map((m) => m[1]) + ); const probeable = MODES.filter((m) => m !== 'shell'); // shell has no CLI to probe expect([...probeable].filter((m) => !documented.has(m))).toEqual([]); }); diff --git a/test/antigravity-cli-resolver.test.ts b/test/antigravity-cli-resolver.test.ts deleted file mode 100644 index 482361fda..000000000 --- a/test/antigravity-cli-resolver.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @fileoverview Tests for the Antigravity CLI resolver wrapper. - */ -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createAntigravityResolverForTest, isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js'; -import { - cliResolveRetryDelayMs, - type CliResolution, - type CliResolverHost, -} from '../src/utils/cli-executable-resolver.js'; - -const availabilityResolution = vi.hoisted(() => ({ current: null as CliResolution | null })); - -vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createCliExecutableResolver: (options: { binary: string; searchDirs: string[] }, host?: CliResolverHost) => - host - ? actual.createCliExecutableResolver(options, host) - : { - resolve: () => availabilityResolution.current, - diagnostics: () => ({ - binary: options.binary, - processPath: '/service/bin', - shellPath: '/bin/zsh', - shellArgs: ['-l'], - searchDirs: [...options.searchDirs], - }), - }, - }; -}); - -function createHost( - options: { - processPathResult?: string | null; - loginShellResults?: Array; - existingPaths?: string[]; - } = {} -): CliResolverHost { - const loginShellResults = [...(options.loginShellResults ?? [])]; - const existingPaths = new Set(options.existingPaths ?? []); - return { - processPath: '/service/bin', - shellPath: '/bin/zsh', - shellArgs: ['-l'], - findOnProcessPath: () => options.processPathResult ?? null, - findInLoginShell: () => loginShellResults.shift() ?? null, - exists: (path) => existingPaths.has(path), - }; -} - -describe('Antigravity CLI resolver', () => { - beforeEach(() => { - availabilityResolution.current = null; - }); - - it('resolves agy from the service PATH', () => { - const binaryPath = '/service/bin/agy'; - const resolver = createAntigravityResolverForTest( - createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }) - ); - - expect(resolver.resolve()?.directory).toBe('/service/bin'); - }); - - it('falls back to a common install directory', () => { - const binaryPath = join(homedir(), '.local', 'bin', 'agy'); - const resolver = createAntigravityResolverForTest(createHost({ existingPaths: [binaryPath] })); - - expect(resolver.resolve()?.directory).toBe(join(homedir(), '.local', 'bin')); - }); - - it('resolves agy found only by the login shell', () => { - const binaryPath = '/login-shell/bin/agy'; - const resolver = createAntigravityResolverForTest( - createHost({ loginShellResults: [binaryPath], existingPaths: [binaryPath] }) - ); - - expect(resolver.resolve()?.directory).toBe('/login-shell/bin'); - }); - - it('returns null when agy is unavailable', () => { - const resolver = createAntigravityResolverForTest(createHost()); - - expect(resolver.resolve()).toBeNull(); - }); - - it('retries a failed lookup after the backoff and caches the first successful login-shell discovery', () => { - const binaryPath = '/late-login-shell/bin/agy'; - let now = 0; - const resolver = createAntigravityResolverForTest( - createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }), - () => now - ); - - expect(resolver.resolve()).toBeNull(); - // A miss is negative-cached: within the backoff window nothing re-runs the - // chain (its login-shell tail is a synchronous bounded spawn in production). - expect(resolver.resolve()).toBeNull(); - now = cliResolveRetryDelayMs(1); - expect(resolver.resolve()?.binaryPath).toBe(binaryPath); - expect(resolver.resolve()?.binaryPath).toBe(binaryPath); - }); - - it('reports the public wrapper as available when agy resolves', () => { - availabilityResolution.current = { - binaryPath: '/service/bin/agy', - directory: '/service/bin', - source: 'process-path', - }; - - expect(isAntigravityAvailable()).toBe(true); - }); - - it('reports the public wrapper as unavailable when agy does not resolve', () => { - expect(isAntigravityAvailable()).toBe(false); - }); -}); diff --git a/test/bash-tool-parser.test.ts b/test/bash-tool-parser.test.ts index 1fe17068c..3a32c1c2b 100644 --- a/test/bash-tool-parser.test.ts +++ b/test/bash-tool-parser.test.ts @@ -447,9 +447,7 @@ describe('BashToolParser', () => { parser.on('toolStart', startHandler); parser.on('toolEnd', endHandler); - parser.processTerminalData( - '● Bash(tail -f /var/log/a.log)\n✓ Bash\n● Bash(cat /var/log/b.log)\n', - ); + parser.processTerminalData('● Bash(tail -f /var/log/a.log)\n✓ Bash\n● Bash(cat /var/log/b.log)\n'); expect(startHandler).toHaveBeenCalledTimes(2); expect(endHandler).toHaveBeenCalledTimes(1); diff --git a/test/buffer-management.test.ts b/test/buffer-management.test.ts index 1940eecc4..a20936b89 100644 --- a/test/buffer-management.test.ts +++ b/test/buffer-management.test.ts @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest'; describe('Buffer Management', () => { describe('Terminal Buffer Limits', () => { const MAX_TERMINAL_BUFFER = 2 * 1024 * 1024; // 2MB - const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB + const TRIM_TERMINAL_TO = 1.5 * 1024 * 1024; // 1.5MB class TerminalBuffer { private buffer = ''; @@ -75,7 +75,7 @@ describe('Buffer Management', () => { describe('Text Output Buffer Limits', () => { const MAX_TEXT_BUFFER = 1 * 1024 * 1024; // 1MB - const TRIM_TEXT_TO = 768 * 1024; // 768KB + const TRIM_TEXT_TO = 768 * 1024; // 768KB class TextBuffer { private buffer = ''; @@ -226,7 +226,7 @@ describe('Buffer Management', () => { describe('Respawn Buffer Limits', () => { const MAX_RESPAWN_BUFFER = 1 * 1024 * 1024; // 1MB - const TRIM_RESPAWN_TO = 512 * 1024; // 512KB + const TRIM_RESPAWN_TO = 512 * 1024; // 512KB class RespawnBuffer { private buffer = ''; diff --git a/test/cleanup-manager.test.ts b/test/cleanup-manager.test.ts index 38f74f6fe..37faab757 100644 --- a/test/cleanup-manager.test.ts +++ b/test/cleanup-manager.test.ts @@ -302,9 +302,13 @@ describe('CleanupManager', () => { }); it('logs errors during disposal', () => { - cm.registerCleanup('timer', () => { - throw new Error('fail'); - }, 'bad cleanup'); + cm.registerCleanup( + 'timer', + () => { + throw new Error('fail'); + }, + 'bad cleanup' + ); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/test/cli-capability-predicates.test.ts b/test/cli-capability-predicates.test.ts new file mode 100644 index 000000000..5f892bcf0 --- /dev/null +++ b/test/cli-capability-predicates.test.ts @@ -0,0 +1,64 @@ +/** + * @fileoverview Pins the three deliberately-INDEPENDENT capability predicates + * (`isExternalCliMode`, `isAltScreenStripMode`, `hooksAvailableForMode`) against the stock + * CLI registry, and reproduces the exact bug that made them independent in the first place: + * `shell` has no hooks but is NOT an "external CLI", so `!isExternalCliMode()` used to wrongly + * accept `until=stop` on a shell session and hang for the caller's whole timeout (a plain bash + * PTY with no Claude Code and no hooks installed never fires `stop`). + * + * If a future change collapses any of these three into a derivation of another, the "shell" + * row below is what catches it: shell is external=false, hooks=false, altScreen='preserve' — + * a combination none of the other six stock CLIs share, so no single-field shortcut can + * reproduce all three of shell's answers at once. + * + * Port: N/A (pure functions, no server). + */ + +import { describe, expect, it } from 'vitest'; +import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js'; +import { hooksAvailableForMode } from '../src/web/session-wait-registry.js'; +import type { SessionMode } from '../src/types/session.js'; + +describe('CLI capability predicates stay independent', () => { + it.each<{ mode: SessionMode; external: boolean; altScreenStrip: boolean; hooks: boolean }>([ + { mode: 'claude', external: false, altScreenStrip: true, hooks: true }, + { mode: 'shell', external: false, altScreenStrip: false, hooks: false }, + { mode: 'opencode', external: true, altScreenStrip: false, hooks: false }, + { mode: 'codex', external: true, altScreenStrip: true, hooks: false }, + { mode: 'gemini', external: true, altScreenStrip: true, hooks: false }, + { mode: 'antigravity', external: true, altScreenStrip: false, hooks: false }, + { mode: 'pi', external: true, altScreenStrip: false, hooks: false }, + ])( + '$mode: external=$external altScreenStrip=$altScreenStrip hooks=$hooks', + ({ mode, external, altScreenStrip, hooks }) => { + expect(isExternalCliMode(mode)).toBe(external); + expect(isAltScreenStripMode(mode)).toBe(altScreenStrip); + expect(hooksAvailableForMode(mode)).toBe(hooks); + } + ); + + it('the historic bug: shell is not external, so hook-only wait signals must still be rejected for it', () => { + // The bug was reasoning `!isExternalCliMode(mode)` implies "hooks work here". It does + // not — shell falls through both checks. Assert the two predicates disagree on shell, + // which is exactly the case a derived predicate could not represent. + expect(isExternalCliMode('shell')).toBe(false); + expect(hooksAvailableForMode('shell')).toBe(false); + }); + + it('no two of the three predicates are equivalent across the whole stock catalog', () => { + const modes: SessionMode[] = ['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi']; + const external = modes.map(isExternalCliMode); + const altScreen = modes.map(isAltScreenStripMode); + const hooks = modes.map(hooksAvailableForMode); + + expect(external).not.toEqual(altScreen); + expect(external).not.toEqual(hooks); + expect(altScreen).not.toEqual(hooks); + }); + + it('an unregistered mode defaults conservatively: external (no claude-only assumptions), no hooks', () => { + const unknown = 'totally-unregistered-cli' as SessionMode; + expect(isExternalCliMode(unknown)).toBe(true); + expect(hooksAvailableForMode(unknown)).toBe(false); + }); +}); diff --git a/test/cli-executable-resolver.test.ts b/test/cli-executable-resolver.test.ts deleted file mode 100644 index 36800a8b5..000000000 --- a/test/cli-executable-resolver.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js'; -import { - cliResolveRetryDelayMs, - createCliExecutableResolver, - createProductionCliResolverHost, - formatCliNotFoundMessage, - type CliResolverHost, -} from '../src/utils/cli-executable-resolver.js'; - -// Pass-through spy on execFileSync so the vitest-hermeticity test below can -// PROVE the un-injected production host never spawns anything. -const { execFileSyncSpy } = vi.hoisted(() => ({ execFileSyncSpy: vi.fn() })); -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal(); - execFileSyncSpy.mockImplementation(actual.execFileSync as (...args: unknown[]) => unknown); - return { ...actual, execFileSync: execFileSyncSpy }; -}); - -const BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__'; -const END_MARKER = '__CODEMAN_CLI_RESOLVE_END__'; - -const temporaryDirectories: string[] = []; - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } -}); - -function host(overrides: Partial = {}): CliResolverHost { - return { - processPath: '/usr/bin:/bin', - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - findOnProcessPath: vi.fn(() => null), - findInLoginShell: vi.fn(() => null), - exists: vi.fn(() => false), - ...overrides, - }; -} - -describe('createCliExecutableResolver', () => { - it('prefers the server process PATH over common directories and the login shell', () => { - const h = host({ - findOnProcessPath: vi.fn(() => '/process/bin/codex'), - findInLoginShell: vi.fn(() => '/shell/bin/codex'), - exists: vi.fn(() => true), - }); - const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h); - - expect(resolver.resolve()).toMatchObject({ binaryPath: '/process/bin/codex', source: 'process-path' }); - expect(h.exists).toHaveBeenCalledTimes(1); - expect(h.findInLoginShell).not.toHaveBeenCalled(); - }); - - it('prefers common directories in order over the login shell', () => { - const h = host({ - findInLoginShell: vi.fn(() => '/shell/bin/codex'), - exists: vi.fn((path) => path === '/second/bin/codex' || path === '/shell/bin/codex'), - }); - const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/first/bin', '/second/bin'] }, h); - - expect(resolver.resolve()).toMatchObject({ binaryPath: '/second/bin/codex', source: 'common-directory' }); - expect(h.exists).toHaveBeenNthCalledWith(1, '/first/bin/codex'); - expect(h.exists).toHaveBeenNthCalledWith(2, '/second/bin/codex'); - expect(h.findInLoginShell).not.toHaveBeenCalled(); - }); - - it('finds an executable exposed only by the interactive login shell', () => { - const h = host({ - findInLoginShell: vi.fn(() => '/home/u/.nvm/versions/node/v22/bin/codex'), - exists: vi.fn((path) => path === '/home/u/.nvm/versions/node/v22/bin/codex'), - }); - const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h); - - expect(resolver.resolve()).toMatchObject({ - binaryPath: '/home/u/.nvm/versions/node/v22/bin/codex', - directory: '/home/u/.nvm/versions/node/v22/bin', - source: 'login-shell', - }); - }); - - it('continues after a validator rejects an earlier candidate', () => { - const h = host({ - findOnProcessPath: vi.fn(() => '/usr/bin/pi'), - findInLoginShell: vi.fn(() => '/home/u/.npm/bin/pi'), - exists: vi.fn(() => true), - }); - const resolver = createCliExecutableResolver( - { - binary: 'pi', - searchDirs: [], - validateCandidate: (path) => - path.includes('.npm') ? { accepted: true, metadata: '0.84.1' } : { accepted: false }, - }, - h - ); - - expect(resolver.resolve()).toMatchObject({ - binaryPath: '/home/u/.npm/bin/pi', - source: 'login-shell', - metadata: '0.84.1', - }); - }); - - it('caches success, and retries a miss only after the backoff elapses', () => { - let now = 0; - const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/codex'); - const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/codex') }); - const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h); - - expect(resolver.resolve()).toBeNull(); - // Within the backoff window the miss is answered from the negative cache: - // the chain — whose login-shell tail is a synchronous 5s-bounded spawn — - // must NOT re-run per call, or a missing CLI stalls every status request. - now = cliResolveRetryDelayMs(1) - 1; - expect(resolver.resolve()).toBeNull(); - expect(findInLoginShell).toHaveBeenCalledTimes(1); - - // Once the backoff elapses the retry runs, so installing a CLI while the - // server is up is still picked up without a restart. - now = cliResolveRetryDelayMs(1); - expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex'); - expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex'); - expect(findInLoginShell).toHaveBeenCalledTimes(2); - }); - - it('doubles the retry delay per consecutive miss and caps it at five minutes', () => { - let now = 0; - const findInLoginShell = vi.fn(() => null); - const resolver = createCliExecutableResolver( - { binary: 'codex', searchDirs: [], now: () => now }, - host({ findInLoginShell }) - ); - - expect(cliResolveRetryDelayMs(0)).toBe(0); - expect(cliResolveRetryDelayMs(1)).toBe(60_000); - expect(cliResolveRetryDelayMs(2)).toBe(120_000); - expect(cliResolveRetryDelayMs(3)).toBe(240_000); - expect(cliResolveRetryDelayMs(4)).toBe(300_000); - expect(cliResolveRetryDelayMs(60)).toBe(300_000); - - // Consecutive misses stack: after the second miss the SECOND delay applies. - expect(resolver.resolve()).toBeNull(); - now += cliResolveRetryDelayMs(1); - expect(resolver.resolve()).toBeNull(); - expect(findInLoginShell).toHaveBeenCalledTimes(2); - now += cliResolveRetryDelayMs(2) - 1; - expect(resolver.resolve()).toBeNull(); - expect(findInLoginShell).toHaveBeenCalledTimes(2); - now += 1; - expect(resolver.resolve()).toBeNull(); - expect(findInLoginShell).toHaveBeenCalledTimes(3); - }); - - it('rejects unsafe binary names', () => { - const h = host(); - - expect(() => createCliExecutableResolver({ binary: 'codex;id', searchDirs: [] }, h)).toThrow( - 'Unsafe CLI binary name' - ); - expect(() => createCliExecutableResolver({ binary: '../codex', searchDirs: [] }, h)).toThrow( - 'Unsafe CLI binary name' - ); - }); - - it('rejects relative and nonexistent candidates', () => { - let now = 0; - const findInLoginShell = vi - .fn<() => string | null>() - .mockReturnValueOnce('relative/codex') - .mockReturnValue('/missing/codex'); - const h = host({ findInLoginShell, exists: vi.fn(() => false) }); - const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h); - - expect(resolver.resolve()).toBeNull(); - now = cliResolveRetryDelayMs(1); - expect(resolver.resolve()).toBeNull(); - expect(h.exists).toHaveBeenCalledTimes(1); - expect(h.exists).toHaveBeenCalledWith('/missing/codex'); - }); -}); - -describe('formatCliNotFoundMessage', () => { - it('includes only the base install hint and bounded resolution diagnostics', () => { - const base = 'Codex CLI not found. Install with: npm install -g @openai/codex'; - const diagnostics = { - binary: 'codex', - processPath: '/usr/bin:/bin', - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - searchDirs: ['/home/u/.local/bin', '/usr/local/bin'], - API_KEY: 'super-secret', - }; - const message = formatCliNotFoundMessage(base, diagnostics); - - expect(message).toContain(base); - expect(message).toContain('Server PATH: /usr/bin:/bin'); - expect(message).toContain('Login shell: /bin/bash -i -l'); - expect(message).toContain('Checked directories: /home/u/.local/bin, /usr/local/bin'); - expect(message).not.toContain('API_KEY'); - expect(message).not.toContain('super-secret'); - }); - - it('marks empty diagnostic values without dumping arbitrary environment data', () => { - const message = formatCliNotFoundMessage('Missing CLI', { - binary: 'codex', - processPath: '', - shellPath: '', - shellArgs: [], - searchDirs: [], - }); - - expect(message).toBe('Missing CLI\nServer PATH: (empty)\nLogin shell: (none)\nChecked directories: (none)'); - expect(message).not.toContain('HOME='); - expect(message).not.toContain('TOKEN='); - }); - - it('flattens control characters and bounds every diagnostic field', () => { - const pathological = `first\r\nforged label: value\u0000${'x'.repeat(10_000)}`; - const message = formatCliNotFoundMessage('Missing CLI', { - binary: 'codex', - processPath: pathological, - shellPath: pathological, - shellArgs: [pathological], - searchDirs: [pathological, pathological], - }); - const lines = message.split('\n'); - - expect(lines).toHaveLength(4); - expect(lines[1]).toMatch(/^Server PATH: first forged label: value x+…$/); - expect(lines[2]).toMatch(/^Login shell: first forged label: value x+…$/); - expect(lines[3]).toMatch(/^Checked directories: first forged label: value x+…$/); - expect(lines.slice(1).every((line) => line.length <= 1_050)).toBe(true); - }); -}); - -describe('createProductionCliResolverHost', () => { - // Hermeticity gate (the guards PR #329 deleted, restored shared): under - // vitest an un-injected host must neither scan the machine nor spawn a login - // shell — route tests hitting the per-CLI status endpoints would otherwise - // walk the real PATH and execute real binaries on whatever box runs the suite. - it('never scans the machine or spawns a login shell under vitest without injected IO', () => { - const root = mkdtempSync(join(tmpdir(), 'codeman-cli-vitest-gate-')); - temporaryDirectories.push(root); - writeFileSync(join(root, 'codex'), '#!/bin/sh\n'); - chmodSync(join(root, 'codex'), 0o755); - execFileSyncSpy.mockClear(); - - const gatedHost = createProductionCliResolverHost({ - processPath: root, - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - }); - - // The real, executable candidate is invisible: the filesystem predicate is inert. - expect(gatedHost.findOnProcessPath('codex')).toBeNull(); - expect(gatedHost.exists(join(root, 'codex'))).toBe(false); - // The login-shell step yields nothing and never reaches execFileSync. - expect(gatedHost.findInLoginShell('codex')).toBeNull(); - expect(execFileSyncSpy).not.toHaveBeenCalled(); - - // The same fixture through the test-only real-IO opt-in IS found, proving - // the nulls above come from the vitest gate rather than from the fixture. - const optedInHost = createProductionCliResolverHost({ - processPath: root, - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - runCommand: () => '', - allowRealIoUnderVitest: true, - }); - expect(optedInHost.findOnProcessPath('codex')).toBe(join(root, 'codex')); - }); - - it('resolves through injected IO hooks under vitest (injection is the opt-in)', () => { - const runCommand = vi.fn(() => `${BEGIN_MARKER}\n/home/u/.nvm/bin/codex\n${END_MARKER}`); - const productionHost = createProductionCliResolverHost({ - processPath: '', - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - runCommand, - isExecutableFile: () => true, - }); - const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, productionHost); - - expect(resolver.resolve()).toMatchObject({ - binaryPath: '/home/u/.nvm/bin/codex', - source: 'login-shell', - }); - expect(runCommand).toHaveBeenCalledTimes(1); - }); - - it('searches the captured process PATH directly in directory order without running a command', () => { - const runCommand = vi.fn(() => ''); - const isExecutableFile = vi.fn((path: string) => path === '/second/bin/codex'); - const productionHost = createProductionCliResolverHost({ - processPath: '/first/bin:/second/bin:/third/bin', - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - runCommand, - isExecutableFile, - }); - - expect(productionHost.findOnProcessPath('codex')).toBe('/second/bin/codex'); - expect(isExecutableFile).toHaveBeenNthCalledWith(1, '/first/bin/codex'); - expect(isExecutableFile).toHaveBeenNthCalledWith(2, '/second/bin/codex'); - expect(runCommand).not.toHaveBeenCalled(); - }); - - it('accepts only executable regular files with the production predicate', () => { - const root = mkdtempSync(join(tmpdir(), 'codeman-cli-resolver-')); - temporaryDirectories.push(root); - const executableDirectory = join(root, 'executable'); - const plainDirectory = join(root, 'plain'); - const directoryCandidate = join(root, 'directory'); - mkdirSync(executableDirectory); - mkdirSync(plainDirectory); - mkdirSync(directoryCandidate); - writeFileSync(join(executableDirectory, 'codex'), '#!/bin/sh\n'); - chmodSync(join(executableDirectory, 'codex'), 0o755); - writeFileSync(join(plainDirectory, 'codex'), '#!/bin/sh\n'); - mkdirSync(join(directoryCandidate, 'codex')); - const productionHost = createProductionCliResolverHost({ - processPath: [directoryCandidate, plainDirectory, executableDirectory].join(':'), - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - // This test exists to exercise the REAL executable-regular-file predicate - // against its own temp fixtures, so it opts out of the vitest inert-IO - // gate; the stubbed runCommand keeps the login-shell path inert anyway. - runCommand: () => '', - allowRealIoUnderVitest: true, - }); - - expect(productionHost.findOnProcessPath('codex')).toBe(join(executableDirectory, 'codex')); - }); - - it('uses the resolved shell, allowlisted args, tagged command, and bounded timeout', () => { - const runCommand = vi.fn(() => - ['/profile/absolute-noise', BEGIN_MARKER, '/home/u/.nvm/bin/codex', END_MARKER, '/exit-trap/absolute-noise'].join( - '\n' - ) - ); - const productionHost = createProductionCliResolverHost({ - processPath: '', - shellPath: '/usr/bin/fish', - shellArgs: ['-i', '-l'], - runCommand, - isExecutableFile: () => true, - }); - - expect(productionHost.findInLoginShell('codex')).toBe('/home/u/.nvm/bin/codex'); - expect(runCommand).toHaveBeenCalledWith( - '/usr/bin/fish', - ['-i', '-l', '-c', `printf '%s\\n' '${BEGIN_MARKER}'; command -v -- codex; printf '%s\\n' '${END_MARKER}'`], - { - encoding: 'utf8', - timeout: EXEC_TIMEOUT_MS, - stdio: ['ignore', 'pipe', 'ignore'], - // SIGKILL is load-bearing: interactive bash ignores SIGTERM, and - // execFileSync's timeout only sends the signal, then keeps waiting. - killSignal: 'SIGKILL', - } - ); - }); - - it.each([ - ['mismatched basename', `${BEGIN_MARKER}\n/opt/bin/not-codex\n${END_MARKER}`], - ['missing begin marker', `/opt/bin/codex\n${END_MARKER}`], - ['missing end marker', `${BEGIN_MARKER}\n/opt/bin/codex`], - ['absolute output outside markers', `/profile/codex\n${BEGIN_MARKER}\nrelative/codex\n${END_MARKER}\n/exit/codex`], - ])('rejects malformed tagged shell output: %s', (_name, output) => { - const productionHost = createProductionCliResolverHost({ - processPath: '', - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - runCommand: () => output, - isExecutableFile: () => true, - }); - - expect(productionHost.findInLoginShell('codex')).toBeNull(); - }); - - it('returns null when the shell command throws', () => { - const productionHost = createProductionCliResolverHost({ - processPath: '', - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'], - runCommand: () => { - throw new Error('exit 1'); - }, - isExecutableFile: () => true, - }); - - expect(productionHost.findInLoginShell('codex')).toBeNull(); - }); -}); diff --git a/test/cli-installer.test.ts b/test/cli-installer.test.ts new file mode 100644 index 000000000..d409478cc --- /dev/null +++ b/test/cli-installer.test.ts @@ -0,0 +1,123 @@ +/** + * @fileoverview Tests `ensureCliInstalled`'s decision logic (config/cli-registry/cli-installer.ts): + * when it does nothing, when it records a terminal status synchronously, and — the safety + * property that matters most — that it NEVER actually spawns a process under `VITEST` + * (same posture as TmuxManager's `IS_TEST_MODE`, see that module's file header). The real + * spawn/timeout/output-capture mechanics are standard Node child_process wiring and are not + * re-verified here, matching the established precedent for that class of module in this repo. + * + * Port: N/A (no server; pure unit tests against the real registry, mocked `node:child_process` + * as a second line of defense on top of the VITEST gate itself). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const spawnMock = vi.fn(); +vi.mock('node:child_process', () => ({ spawn: spawnMock })); + +describe('ensureCliInstalled', () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(async () => { + const { _resetCliInstallStatusForTest } = await import('../src/config/cli-registry/cli-installer.js'); + _resetCliInstallStatusForTest(); + }); + + it('is a no-op for an unknown id', async () => { + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + ensureCliInstalled('not-a-real-cli'); + expect(getCliInstallStatus('not-a-real-cli')).toBeUndefined(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('is a no-op for "shell" (no binaries to install)', async () => { + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + ensureCliInstalled('shell'); + expect(getCliInstallStatus('shell')).toBeUndefined(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('records success without spawning when the binary is already available', async () => { + // claude, opencode, codex, gemini, antigravity and pi may or may not actually be on + // PATH on the machine running this test, so pin the outcome by stubbing the resolver + // instead of depending on the real environment. + vi.doMock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliBinDir: () => '/usr/local/bin' }; + }); + vi.resetModules(); + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + + ensureCliInstalled('gemini'); + + expect(getCliInstallStatus('gemini')).toEqual({ state: 'success', finishedAt: expect.any(Number) }); + expect(spawnMock).not.toHaveBeenCalled(); + vi.doUnmock('../src/utils/cli-resolver.js'); + vi.resetModules(); + }); + + it('records an error and never spawns when the entry has no install command for this platform', async () => { + vi.doMock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliBinDir: () => null }; + }); + vi.doMock('../src/config/cli-registry/registry.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveInstallCommandForPlatform: () => undefined }; + }); + vi.resetModules(); + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + + ensureCliInstalled('gemini'); + + expect(getCliInstallStatus('gemini')).toEqual({ + state: 'error', + message: 'No install command declared for this platform.', + }); + expect(spawnMock).not.toHaveBeenCalled(); + vi.doUnmock('../src/utils/cli-resolver.js'); + vi.doUnmock('../src/config/cli-registry/registry.js'); + vi.resetModules(); + }); + + it('never spawns a real process under VITEST, and leaves status untouched', async () => { + vi.doMock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliBinDir: () => null }; + }); + vi.resetModules(); + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + + // gemini is a stock CLI with a real install command declared, and its binary is + // stubbed unavailable above — the one shape that WOULD spawn outside a test run. + ensureCliInstalled('gemini'); + + expect(spawnMock).not.toHaveBeenCalled(); + // The VITEST guard returns before touching `_status` at all, so it stays exactly as + // it was (unset) — distinct from a real 'installing'/'error' terminal state, so a + // caller can tell "skipped under test" apart from an actual outcome. + expect(getCliInstallStatus('gemini')).toBeUndefined(); + vi.doUnmock('../src/utils/cli-resolver.js'); + vi.resetModules(); + }); + + it('does not restart an install already recorded as in flight', async () => { + // Exercises the concurrency guard directly against the real status map, without going + // through the (VITEST-gated) spawn path at all. + vi.doMock('../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, resolveCliBinDir: () => '/usr/local/bin' }; + }); + vi.resetModules(); + const { ensureCliInstalled, getCliInstallStatus } = await import('../src/config/cli-registry/cli-installer.js'); + + ensureCliInstalled('gemini'); // resolves to 'success' immediately (already available) + const first = getCliInstallStatus('gemini'); + ensureCliInstalled('gemini'); // calling again should not change the recorded status + expect(getCliInstallStatus('gemini')).toEqual(first); + vi.doUnmock('../src/utils/cli-resolver.js'); + vi.resetModules(); + }); +}); diff --git a/test/cli-management-settings.test.ts b/test/cli-management-settings.test.ts new file mode 100644 index 000000000..c177ee050 --- /dev/null +++ b/test/cli-management-settings.test.ts @@ -0,0 +1,388 @@ +/** + * @fileoverview Tests for the "Installed CLIs" settings-UI surface (App Settings → + * Agents & CLIs): the dynamic list backed by GET/PUT/POST/DELETE /api/clis(...), added in + * settings-ui.js. Loads the real module into a vm sandbox (no real DOM) and drives it + * against a stubbed `document`/`fetch`, matching the pattern in test/run-mode-ui.test.ts. + * + * Port: N/A (no server; vm-sandboxed unit tests). + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const STOCK_ROW = (overrides: Record = {}) => ({ + id: 'gemini', + label: 'Gemini', + stock: true, + enabled: true, + available: true, + installHint: null, + ...overrides, +}); + +function loadHarness(fetchImpl: (url: string, init?: unknown) => Promise) { + const elements: Record = {}; + const CodemanApp = function CodemanApp(this: any) {}; + const context: any = vm.createContext({ + CodemanApp, + MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false }, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { + getElementById: (id: string) => elements[id] ?? null, + createElement: (tag: string) => { + const el: any = { + tagName: tag, + className: '', + textContent: '', + title: '', + checked: false, + disabled: false, + type: '', + value: '', + dataset: {}, + onclick: null, + onchange: null, + children: [] as any[], + append(...nodes: any[]) { + this.children.push(...nodes); + }, + }; + return el; + }, + }, + fetch: fetchImpl, + confirm: () => true, + console, + }); + context.window = context; + + const list = { replaceChildren: vi.fn(), textContent: '', appendChild: vi.fn(), children: [] as any[] }; + elements.appSettingsCliList = list; + elements.appSettingsAddCliStatus = { textContent: '' }; + elements.addCliFormRow = { style: { display: 'none' } }; + elements.appSettingsNewCliId = { value: '' }; + elements.appSettingsNewCliLabel = { value: '' }; + elements.appSettingsNewCliBinary = { value: '' }; + elements.appSettingsNewCliInstall = { value: '' }; + + const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8'); + vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' }); + + const app = new (CodemanApp as any)(); + app.showToast = vi.fn(); + return { app, elements, list }; +} + +describe('renderCliManagementList', () => { + it('fetches /api/clis and builds one row per entry', async () => { + const fetchMock = vi.fn(async (url: string) => { + expect(url).toBe('/api/clis'); + return { json: async () => ({ success: true, data: [STOCK_ROW(), STOCK_ROW({ id: 'pi', label: 'Pi' })] }) }; + }); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + expect(fetchMock).toHaveBeenCalledWith('/api/clis'); + expect(list.replaceChildren).toHaveBeenCalledTimes(1); + expect(list.appendChild).toHaveBeenCalledTimes(2); + }); + + it('shows the install hint for an unavailable CLI, not a generic message', async () => { + const fetchMock = vi.fn(async () => ({ + json: async () => ({ + success: true, + data: [ + STOCK_ROW({ + id: 'codex', + label: 'Codex', + available: false, + installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex', + }), + ], + }), + })); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + const row = list.appendChild.mock.calls[0][0]; + const desc = row.children + .find((c: any) => c.className === 'set-row-text') + .children.find((c: any) => c.className === 'set-row-desc'); + expect(desc.textContent).toContain('npm install -g @openai/codex'); + }); + + it('shows an "Installing…" row and disables its toggle while an install is in flight', async () => { + const fetchMock = vi.fn(async () => ({ + json: async () => ({ + success: true, + data: [ + STOCK_ROW({ + id: 'copilot', + label: 'GitHub Copilot', + available: false, + installStatus: { state: 'installing', command: 'npm install -g @github/copilot' }, + }), + ], + }), + })); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + const row = list.appendChild.mock.calls[0][0]; + const desc = row.children + .find((c: any) => c.className === 'set-row-text') + .children.find((c: any) => c.className === 'set-row-desc'); + expect(desc.textContent).toContain('Installing…'); + expect(desc.textContent).toContain('npm install -g @github/copilot'); + const toggle = row.children + .find((c: any) => c.className === 'set-row-actions') + .children.find((c: any) => c.className === 'switch switch-sm') + ?.children.find((c: any) => c.type === 'checkbox'); + expect(toggle?.disabled).toBe(true); + }); + + it('shows the install failure message when installStatus is an error', async () => { + const fetchMock = vi.fn(async () => ({ + json: async () => ({ + success: true, + data: [ + STOCK_ROW({ + id: 'copilot', + available: false, + installStatus: { state: 'error', message: 'Install command exited 1.' }, + }), + ], + }), + })); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + const row = list.appendChild.mock.calls[0][0]; + const desc = row.children + .find((c: any) => c.className === 'set-row-text') + .children.find((c: any) => c.className === 'set-row-desc'); + expect(desc.textContent).toContain('Install failed'); + expect(desc.textContent).toContain('Install command exited 1.'); + }); + + it('reports a fetch failure inline instead of throwing', async () => { + const fetchMock = vi.fn(async () => { + throw new Error('network down'); + }); + const { app, list } = loadHarness(fetchMock); + + await app.renderCliManagementList(); + + expect(list.textContent).toContain('network down'); + expect(list.appendChild).not.toHaveBeenCalled(); + }); + + it('does nothing (does not throw) when the container is absent', async () => { + const { app, elements } = loadHarness(vi.fn()); + delete elements.appSettingsCliList; + await expect(app.renderCliManagementList()).resolves.toBeUndefined(); + }); +}); + +describe('CLI row actions', () => { + it('_setCliEnabled PUTs the new state and re-renders', async () => { + const calls: Array<{ url: string; init?: any }> = []; + const fetchMock = vi.fn(async (url: string, init?: any) => { + calls.push({ url, init }); + if (url.endsWith('/enabled')) return { json: async () => ({ success: true }) }; + return { json: async () => ({ success: true, data: [STOCK_ROW({ enabled: false })] }) }; + }); + const { app } = loadHarness(fetchMock); + + await app._setCliEnabled('gemini', false); + + const putCall = calls.find((c) => c.url === '/api/clis/gemini/enabled'); + expect(putCall).toBeDefined(); + expect(putCall!.init.method).toBe('PUT'); + expect(JSON.parse(putCall!.init.body)).toEqual({ enabled: false }); + // Re-render fetched the list again afterward. + expect(calls.some((c) => c.url === '/api/clis')).toBe(true); + }); + + it('_setCliEnabled starts polling when the PUT response reports an install in flight', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/enabled')) { + return { + json: async () => ({ + success: true, + data: { entries: [], warnings: [], installStatus: { state: 'installing', command: 'npm install -g x' } }, + }), + }; + } + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + app._pollCliInstallStatus = vi.fn(); + + await app._setCliEnabled('copilot', true); + + expect(app._pollCliInstallStatus).toHaveBeenCalledWith('copilot'); + }); + + it('_setCliEnabled does not poll when the CLI was already installed', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/enabled')) { + return { json: async () => ({ success: true, data: { entries: [], warnings: [], installStatus: null } }) }; + } + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + app._pollCliInstallStatus = vi.fn(); + + await app._setCliEnabled('gemini', true); + + expect(app._pollCliInstallStatus).not.toHaveBeenCalled(); + }); + + it('_setCliEnabled surfaces a failure via showToast without throwing', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/enabled')) return { json: async () => ({ success: false, error: 'nope' }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + + await app._setCliEnabled('gemini', false); + + expect(app.showToast).toHaveBeenCalledWith('nope', 'error'); + }); + + it('_moveCliOrder swaps the two ids and PUTs the resulting order', async () => { + const calls: Array<{ url: string; init?: any }> = []; + const fetchMock = vi.fn(async (url: string, init?: any) => { + calls.push({ url, init }); + if (url === '/api/clis/order') return { json: async () => ({ success: true }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + const list = [STOCK_ROW({ id: 'a' }), STOCK_ROW({ id: 'b' }), STOCK_ROW({ id: 'c' })]; + + await app._moveCliOrder(list, 1, -1); + + const orderCall = calls.find((c) => c.url === '/api/clis/order'); + expect(JSON.parse(orderCall!.init.body)).toEqual({ order: ['b', 'a', 'c'] }); + }); + + it('_moveCliOrder is a no-op past either edge of the list', async () => { + const fetchMock = vi.fn(async () => ({ json: async () => ({ success: true, data: [] }) })); + const { app } = loadHarness(fetchMock); + const list = [STOCK_ROW({ id: 'a' }), STOCK_ROW({ id: 'b' })]; + + await app._moveCliOrder(list, 0, -1); + await app._moveCliOrder(list, 1, 1); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('_removeCustomCli confirms, DELETEs, and re-renders', async () => { + const calls: string[] = []; + const fetchMock = vi.fn(async (url: string, init?: any) => { + calls.push(`${init?.method ?? 'GET'} ${url}`); + if (init?.method === 'DELETE') return { json: async () => ({ success: true }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app } = loadHarness(fetchMock); + + await app._removeCustomCli('copilot', 'Copilot'); + + expect(calls).toContain('DELETE /api/clis/copilot'); + expect(calls).toContain('GET /api/clis'); + }); +}); + +describe('add-custom-CLI form', () => { + it('toggleAddCliForm shows the row and clears fields on hide', () => { + const { app, elements } = loadHarness(vi.fn()); + elements.appSettingsNewCliId.value = 'leftover'; + + app.toggleAddCliForm(true); + expect(elements.addCliFormRow.style.display).toBe(''); + + app.toggleAddCliForm(false); + expect(elements.addCliFormRow.style.display).toBe('none'); + expect(elements.appSettingsNewCliId.value).toBe(''); + }); + + it('rejects an id that is not lowercase-kebab without calling fetch', async () => { + const fetchMock = vi.fn(); + const { app, elements } = loadHarness(fetchMock); + elements.appSettingsNewCliId.value = 'Not Valid'; + elements.appSettingsNewCliLabel.value = 'Whatever'; + elements.appSettingsNewCliBinary.value = 'whatever'; + + await app.submitAddCliForm(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(elements.appSettingsAddCliStatus.textContent).toContain('lowercase'); + }); + + it('requires a label and a binary name', async () => { + const fetchMock = vi.fn(); + const { app, elements } = loadHarness(fetchMock); + elements.appSettingsNewCliId.value = 'copilot'; + + await app.submitAddCliForm(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(elements.appSettingsAddCliStatus.textContent).toContain('required'); + }); + + it('POSTs a conservative default entry and closes the form on success', async () => { + const calls: Array<{ url: string; init: any }> = []; + const fetchMock = vi.fn(async (url: string, init?: any) => { + calls.push({ url, init }); + if (init?.method === 'POST') return { json: async () => ({ success: true }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app, elements } = loadHarness(fetchMock); + elements.appSettingsNewCliId.value = 'copilot'; + elements.appSettingsNewCliLabel.value = 'GitHub Copilot'; + elements.appSettingsNewCliBinary.value = 'copilot'; + elements.appSettingsNewCliInstall.value = 'npm install -g copilot-cli'; + + await app.submitAddCliForm(); + + const postCall = calls.find((c) => c.url === '/api/clis/copilot'); + expect(postCall).toBeDefined(); + const body = JSON.parse(postCall!.init.body); + expect(body.label).toBe('GitHub Copilot'); + expect(body.discovery.binaries).toEqual(['copilot']); + expect(body.discovery.install.command.linux).toBe('npm install -g copilot-cli'); + // Conservative defaults — see submitAddCliForm's own doc comment: same profile as + // an unrecognized CLI (external agent, no bypass, no hooks, buffered echo). + expect(body.capabilities.external).toBe(true); + expect(body.capabilities.requiresMux).toBe(true); + expect(body.capabilities.hooks).toBe(false); + expect(body.capabilities.privilegedParams).toEqual([]); + expect(body.enabled).toBe(true); + // Form was closed (re-hidden) after success. + expect(elements.addCliFormRow.style.display).toBe('none'); + }); + + it('leaves the form open and shows the server error on failure', async () => { + const fetchMock = vi.fn(async (url: string, init?: any) => { + if (init?.method === 'POST') return { json: async () => ({ success: false, error: 'id already exists' }) }; + return { json: async () => ({ success: true, data: [] }) }; + }); + const { app, elements } = loadHarness(fetchMock); + elements.appSettingsNewCliId.value = 'copilot'; + elements.appSettingsNewCliLabel.value = 'GitHub Copilot'; + elements.appSettingsNewCliBinary.value = 'copilot'; + elements.addCliFormRow.style.display = ''; + + await app.submitAddCliForm(); + + expect(elements.appSettingsAddCliStatus.textContent).toBe('id already exists'); + expect(elements.addCliFormRow.style.display).toBe(''); // still open + }); +}); diff --git a/test/cli-registry-argv-parity.test.ts b/test/cli-registry-argv-parity.test.ts new file mode 100644 index 000000000..d63737027 --- /dev/null +++ b/test/cli-registry-argv-parity.test.ts @@ -0,0 +1,252 @@ +/** + * @fileoverview Keystone test for the CLI registry refactor: proves the new argv engine + * (`renderLaunch` over the stock catalog) produces the SAME command string as the existing + * hand-written `buildSpawnCommand` in tmux-manager.ts, across a matrix of inputs per mode. + * + * This is deliberately written against TODAY's code, before anything downstream is switched + * over to the registry (Phase 0 of the plan is additive-only). Every later phase that moves + * tmux-manager.ts onto the engine is then a refactor measured against this fixed baseline, + * not a "does it look right" read of the diff. + * + * Port: N/A (pure functions, no server). + */ + +import { describe, expect, it } from 'vitest'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { renderLaunch } from '../src/config/cli-registry/argv.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import type { ParamValues, EngineValues } from '../src/config/cli-registry/argv.js'; +import type { + AntigravityConfig, + ClaudeMode, + CodexConfig, + EffortLevel, + GeminiConfig, + OpenCodeConfig, + PiConfig, +} from '../src/types/session.js'; + +function entryFor(id: string) { + const entry = STOCK_CLIS.find((e) => (e.id as unknown as string) === id); + if (!entry) throw new Error(`no stock entry for ${id}`); + return entry; +} + +describe('CLI registry argv parity with buildSpawnCommand', () => { + describe('claude', () => { + const claude = entryFor('claude'); + + it.each<{ + name: string; + claudeMode?: ClaudeMode; + allowedTools?: string; + model?: string; + resumeSessionId?: string; + effort?: EffortLevel; + sessionName?: string; + cliVersion?: string | null; + }>([ + { name: 'defaults, new session' }, + { name: 'skip-permissions explicit', claudeMode: 'dangerously-skip-permissions' }, + { name: 'auto mode', claudeMode: 'auto' }, + { name: 'allowedTools valid', claudeMode: 'allowedTools', allowedTools: 'Bash(git:*), Read' }, + { + name: 'allowedTools with dangerous chars falls back', + claudeMode: 'allowedTools', + allowedTools: 'Bash(git:*); rm -rf /', + }, + { name: 'normal mode', claudeMode: 'normal' }, + { name: 'with model', model: 'sonnet' }, + { name: 'with bracketed model alias', model: '[opus-4]' }, + { name: 'invalid model dropped', model: 'sonnet; rm -rf /' }, + { name: 'resume', resumeSessionId: 'abcdef12-3456-7890-abcd-ef1234567890' }, + { name: 'invalid resume id dropped (falls to new)', resumeSessionId: 'not a uuid!' }, + { name: 'with effort level', effort: 'high' }, + { name: 'with ultracode effort', effort: 'ultracode' }, + { name: 'with session name, version below gate', sessionName: 'w1-testcase', cliVersion: '2.1.100' }, + { name: 'with session name, version at gate', sessionName: 'w1-testcase', cliVersion: '2.1.224' }, + { name: 'with session name, unknown version (fail-closed)', sessionName: 'w1-testcase', cliVersion: null }, + { + name: 'everything at once, resume path', + claudeMode: 'auto', + model: 'opus', + resumeSessionId: '11111111-1111-1111-1111-111111111111', + effort: 'xhigh', + sessionName: 'w2-full', + cliVersion: '2.1.300', + }, + ])('$name', (c) => { + const sessionId = 'session-uuid-fixture'; + const legacy = buildSpawnCommand({ + mode: 'claude', + sessionId, + claudeMode: c.claudeMode, + allowedTools: c.allowedTools, + model: c.model, + resumeSessionId: c.resumeSessionId, + effort: c.effort, + sessionName: c.sessionName, + claudeCliVersion: c.cliVersion, + }); + + const params: ParamValues = { + claudeMode: c.claudeMode, + allowedTools: c.allowedTools, + model: c.model, + resumeId: c.resumeSessionId, + }; + const engineValues: EngineValues = { + sessionId, + sessionName: c.sessionName, + }; + // Mirror buildEffortCliArgs exactly: ultracode carries a fixed settings blob, + // every other level rides a plain --effort flag. The two are + // mutually exclusive, matching the entry's two distinct engine values. + if (c.effort === 'ultracode') { + engineValues.effortSettingsJson = '{"ultracode":true}'; + } else if (c.effort) { + engineValues.effortLevel = c.effort; + } + const gatesPassed = new Set(); + if (c.cliVersion && c.cliVersion >= '2.1.224') gatesPassed.add('nameFlag'); + + const rendered = renderLaunch(claude.launch, params, engineValues, gatesPassed); + expect(rendered).toBe(legacy); + }); + }); + + describe('opencode', () => { + const opencode = entryFor('opencode'); + + it.each<{ name: string; config?: OpenCodeConfig }>([ + { name: 'no config' }, + { name: 'model only', config: { model: 'anthropic/claude-sonnet-4-5' } }, + { name: 'invalid model dropped', config: { model: 'bad model!' } }, + { name: 'session id', config: { continueSession: 'sess-123' } }, + { name: 'session id + fork', config: { continueSession: 'sess-123', forkSession: true } }, + { name: 'fork without session id is a no-op', config: { forkSession: true } }, + { name: 'invalid session id dropped', config: { continueSession: 'bad id!' } }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'opencode', sessionId: 'x', openCodeConfig: config }); + const params: ParamValues = { + model: config?.model, + resumeId: config?.continueSession, + forkSession: config?.forkSession, + }; + const rendered = renderLaunch(opencode.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); + + describe('codex', () => { + const codex = entryFor('codex'); + + it.each<{ name: string; config?: CodexConfig }>([ + { name: 'no config' }, + { name: 'bypass approvals', config: { dangerouslyBypassApprovals: true } }, + { name: 'animations on', config: { animations: true } }, + { name: 'animations off', config: { animations: false } }, + { name: 'model', config: { model: 'gpt-5' } }, + { name: 'resume', config: { resumeSessionId: 'abc-123' } }, + { name: 'invalid resume dropped', config: { resumeSessionId: 'bad id!' } }, + { + name: 'everything', + config: { dangerouslyBypassApprovals: true, animations: false, model: 'o4-mini', resumeSessionId: 'sess-1' }, + }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'codex', sessionId: 'x', codexConfig: config }); + const params: ParamValues = { + bypassApprovals: config?.dangerouslyBypassApprovals, + animations: config?.animations, + model: config?.model, + resumeId: config?.resumeSessionId, + }; + const rendered = renderLaunch(codex.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); + + describe('gemini', () => { + const gemini = entryFor('gemini'); + + it.each<{ name: string; config?: GeminiConfig }>([ + { name: 'defaults (yolo)' }, + { name: 'explicit approval mode', config: { approvalMode: 'plan' } }, + { name: 'model', config: { model: 'gemini-2.5-pro' } }, + { name: 'resume', config: { resumeSession: 'latest' } }, + { name: 'everything', config: { approvalMode: 'auto_edit', model: 'gemini-2.5-flash', resumeSession: 'sess.1' } }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'gemini', sessionId: 'x', geminiConfig: config }); + const params: ParamValues = { + approvalMode: config?.approvalMode, + model: config?.model, + resumeId: config?.resumeSession, + }; + const rendered = renderLaunch(gemini.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); + + describe('antigravity', () => { + const antigravity = entryFor('antigravity'); + + it.each<{ name: string; config?: AntigravityConfig }>([ + { name: 'no config (prompting default)' }, + { name: 'skip permissions', config: { dangerouslySkipPermissions: true } }, + { name: 'model', config: { model: 'gemini-3-pro' } }, + { name: 'resume', config: { resumeConversationId: 'conv.1' } }, + { + name: 'everything', + config: { dangerouslySkipPermissions: true, model: 'gemini-3-flash', resumeConversationId: 'conv.2' }, + }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'antigravity', sessionId: 'x', antigravityConfig: config }); + const params: ParamValues = { + dangerouslySkipPermissions: config?.dangerouslySkipPermissions, + model: config?.model, + resumeId: config?.resumeConversationId, + }; + const rendered = renderLaunch(antigravity.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); + + describe('pi', () => { + const pi = entryFor('pi'); + + it.each<{ name: string; config?: PiConfig }>([ + { name: 'no config' }, + { name: 'approve', config: { approveProjectTrust: true } }, + { name: 'no-approve', config: { approveProjectTrust: false } }, + { name: 'model with thinking suffix', config: { model: 'sonnet:high' } }, + { name: 'model provider/id', config: { model: 'openai/gpt-4o' } }, + { name: 'provider', config: { provider: 'anthropic' } }, + { name: 'thinking level', config: { thinking: 'xhigh' } }, + { name: 'continue session', config: { continueSession: true } }, + { name: 'resume session wins over continue', config: { continueSession: true, resumeSessionId: 'sess.1' } }, + { name: 'resume session alone', config: { resumeSessionId: 'sess.1' } }, + { + name: 'everything, no resume', + config: { + approveProjectTrust: true, + model: 'sonnet:high', + provider: 'anthropic', + thinking: 'high', + continueSession: true, + }, + }, + ])('$name', ({ config }) => { + const legacy = buildSpawnCommand({ mode: 'pi', sessionId: 'x', piConfig: config }); + const params: ParamValues = { + approveProjectTrust: config?.approveProjectTrust, + model: config?.model, + provider: config?.provider, + thinking: config?.thinking, + resumeId: config?.resumeSessionId, + continueSession: config?.continueSession, + }; + const rendered = renderLaunch(pi.launch, params, {}); + expect(rendered).toBe(legacy); + }); + }); +}); diff --git a/test/cli-registry-load.test.ts b/test/cli-registry-load.test.ts new file mode 100644 index 000000000..434fbbf30 --- /dev/null +++ b/test/cli-registry-load.test.ts @@ -0,0 +1,294 @@ +/** + * @fileoverview Tests the CLI registry's merge/seed/quarantine behaviour — the logic that + * lets `~/.codeman/clis.json` hold overrides only and still survive app updates. + * + * `resolveRegistry()` is exercised directly (pure, no IO) for the merge semantics; the + * on-disk `loadCliRegistry()` path is exercised against a temp HOME (via test/setup.ts's + * per-file HOME isolation) for seeding, quarantine and permission handling. + * + * Port: N/A (no server; file IO only, isolated to a temp HOME by test/setup.ts). + */ + +import { describe, expect, it, beforeEach } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from '../src/config/instance.js'; +import { resolveRegistry } from '../src/config/cli-registry/registry.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import type { CliRegistryFile } from '../src/config/cli-registry/types.js'; + +/** + * A well-formed custom entry, reused across the merge and write tests below. Id + * deliberately avoids "copilot" — that became a real stock id once GitHub Copilot CLI + * shipped (disabled by default) in the stock catalog, and these tests exercise the + * CUSTOM-CLI add/remove path, which refuses to touch a stock id. + */ +const CUSTOM_ENTRY = { + id: 'testcli', + label: 'Test CLI', + shortBadge: 'TC', + accent: '#24292f', + enabled: true, + order: 60, + kind: 'agent' as const, + discovery: { + binaries: ['testcli'], + searchDirs: ['~/.local/bin'], + install: { command: { linux: 'npm install -g @example/testcli' } }, + }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'testcli' }] }] }, + env: { + exports: [], + unset: [], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['TESTCLI_'], + allowedKeys: [], + }, + capabilities: { + external: true, + requiresMux: true, + hooks: false, + transcript: 'none' as const, + altScreen: 'strip-mux-only' as const, + echo: { policy: 'buffer' as const, anchor: { kind: 'cursor' as const } }, + wheelForward: { mode: 'never' as const }, + keyboardAccessory: 'agent' as const, + privilegedCommandGate: false, + startMode: 'interactive' as const, + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' as const }, + privilegedParams: [], + gates: {}, + }, + overlays: {}, +}; + +describe('resolveRegistry (pure merge)', () => { + it('returns every stock entry unchanged when the file is absent', () => { + const { entries, warnings } = resolveRegistry(STOCK_CLIS, null, []); + expect(entries.map((e) => e.id as unknown as string).sort()).toEqual( + STOCK_CLIS.map((e) => e.id as unknown as string).sort() + ); + expect(warnings).toEqual([]); + }); + + it('applies a partial override (disable) without touching the rest of the entry', () => { + const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { gemini: { enabled: false } } }; + const { entries } = resolveRegistry(STOCK_CLIS, file, []); + const gemini = entries.find((e) => (e.id as unknown as string) === 'gemini')!; + expect(gemini.enabled).toBe(false); + expect(gemini.label).toBe('Gemini'); // untouched + expect(gemini.launch.variants).toEqual( + STOCK_CLIS.find((e) => (e.id as unknown as string) === 'gemini')!.launch.variants + ); + }); + + it('adds a well-formed custom entry alongside the stock catalog', () => { + const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { testcli: CUSTOM_ENTRY } }; + const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); + expect(warnings).toEqual([]); + const found = entries.find((e) => (e.id as unknown as string) === 'testcli'); + expect(found).toBeDefined(); + expect(found!.stock).toBe(false); // stock is forced by the loader, never trusted from the file + }); + + it('drops an invalid custom entry with a warning, but keeps every stock entry', () => { + const file: CliRegistryFile = { schemaVersion: 1, seededStockIds: [], clis: { bogus: { id: 'bogus' } } }; + const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); + expect(entries.some((e) => (e.id as unknown as string) === 'bogus')).toBe(false); + expect(entries.length).toBe(STOCK_CLIS.length); + expect(warnings.some((w) => w.includes('bogus'))).toBe(true); + }); + + it('falls back to the pristine stock definition when a stock override fails validation', () => { + const file: CliRegistryFile = { + schemaVersion: 1, + seededStockIds: [], + clis: { codex: { launch: { variants: [{ id: 'default', args: [{ lit: 'codex; rm -rf /' }] }] } } }, + }; + const { entries, warnings } = resolveRegistry(STOCK_CLIS, file, []); + const codex = entries.find((e) => (e.id as unknown as string) === 'codex')!; + expect(codex.launch.variants[0].args[0]).toEqual({ lit: 'codex' }); // pristine, not the hostile override + expect(warnings.some((w) => w.includes('codex'))).toBe(true); + }); + + it('a stock entry can never be shadowed by an id-colliding custom entry with stock:true', () => { + const file: CliRegistryFile = { + schemaVersion: 1, + seededStockIds: [], + clis: { claude: { stock: false, label: 'Not Actually Claude' } }, + }; + const { entries } = resolveRegistry(STOCK_CLIS, file, []); + const claude = entries.find((e) => (e.id as unknown as string) === 'claude')!; + expect(claude.stock).toBe(true); // loader forces stock:true for a known stock id regardless of the file + expect(claude.label).toBe('Not Actually Claude'); // the override itself still applies — only `stock` is pinned + }); +}); + +describe('loadCliRegistry (on-disk seeding)', () => { + beforeEach(() => { + // Force a fresh module load path per test by clearing the registry's own cache via a + // dynamic re-import is unnecessary here: reloadCliRegistry() is exported for this purpose. + }); + + it('seeds a fresh install with schemaVersion + every stock id, and writes nothing on a second load', async () => { + const { loadCliRegistry, reloadCliRegistry } = await import('../src/config/cli-registry/registry.js'); + reloadCliRegistry(); + const path = dataPath('clis.json'); + expect(existsSync(path)).toBe(false); + + const first = loadCliRegistry(); + expect(first.entries.length).toBe(STOCK_CLIS.length); + expect(existsSync(path)).toBe(true); + const written = JSON.parse(readFileSync(path, 'utf-8')) as CliRegistryFile; + expect(written.seededStockIds.sort()).toEqual(STOCK_CLIS.map((e) => e.id as unknown as string).sort()); + expect(written.clis).toEqual({}); + + const mtimeBefore = readFileSync(path, 'utf-8'); + reloadCliRegistry(); + loadCliRegistry(); + expect(readFileSync(path, 'utf-8')).toBe(mtimeBefore); // no rewrite when nothing changed + }); + + it('a disabled stock CLI stays disabled across a reload that introduces no new stock ids', async () => { + const { loadCliRegistry, reloadCliRegistry } = await import('../src/config/cli-registry/registry.js'); + const path = dataPath('clis.json'); + mkdirSync(dirname(path), { recursive: true }); + const file: CliRegistryFile = { + schemaVersion: 1, + seededStockIds: STOCK_CLIS.map((e) => e.id as unknown as string), + clis: { gemini: { enabled: false } }, + }; + writeFileSync(path, JSON.stringify(file)); + reloadCliRegistry(); + const { entries } = loadCliRegistry(); + const gemini = entries.find((e) => (e.id as unknown as string) === 'gemini')!; + expect(gemini.enabled).toBe(false); + }); + + it('quarantines malformed JSON instead of overwriting it, and falls back to stock', async () => { + const { loadCliRegistry, reloadCliRegistry } = await import('../src/config/cli-registry/registry.js'); + const path = dataPath('clis.json'); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, '{ this is not valid json'); + reloadCliRegistry(); + const { entries } = loadCliRegistry(); + expect(entries.length).toBe(STOCK_CLIS.length); + expect(existsSync(path + '.invalid') || existsSync(path)).toBeDefined(); // original untouched or quarantined + // The exact quarantine filename carries a timestamp; assert one such file exists. + const { readdirSync } = await import('node:fs'); + const dir = dirname(path); + const quarantined = readdirSync(dir).some((f) => f.startsWith('clis.json.invalid-')); + expect(quarantined).toBe(true); + }); + + // Windows/NTFS has no meaningful POSIX group/world bits (every file reports mode 0o666 + // regardless of its actual ACL), so isUnsafePermissions() is a no-op there by design — + // see its own doc comment in registry.ts. This test only exercises the POSIX behaviour. + it.skipIf(process.platform === 'win32')( + 'ignores a group/world-writable registry file and falls back to stock', + async () => { + const { loadCliRegistry, reloadCliRegistry } = await import('../src/config/cli-registry/registry.js'); + const path = dataPath('clis.json'); + mkdirSync(dirname(path), { recursive: true }); + const file: CliRegistryFile = { + schemaVersion: 1, + seededStockIds: STOCK_CLIS.map((e) => e.id as unknown as string), + clis: { gemini: { enabled: false } }, + }; + writeFileSync(path, JSON.stringify(file)); + chmodSync(path, 0o666); + reloadCliRegistry(); + const { entries, warnings } = loadCliRegistry(); + const gemini = entries.find((e) => (e.id as unknown as string) === 'gemini')!; + expect(gemini.enabled).toBe(true); // override was ignored — file was unsafe to trust + expect(warnings.some((w) => w.includes('writable'))).toBe(true); + } + ); +}); + +describe('registry writes (setCliEnabled / setCliOrder / upsertCustomCli / removeCustomCli)', () => { + it('setCliEnabled toggles a stock CLI and the change survives a reload', async () => { + const { setCliEnabled, reloadCliRegistry, loadCliRegistry } = + await import('../src/config/cli-registry/registry.js'); + const before = setCliEnabled('gemini', false); + expect(before.success).toBe(true); + expect(before.entries?.find((e) => (e.id as unknown as string) === 'gemini')?.enabled).toBe(false); + + reloadCliRegistry(); + const { entries } = loadCliRegistry(); + expect(entries.find((e) => (e.id as unknown as string) === 'gemini')?.enabled).toBe(false); + }); + + it('setCliEnabled fails cleanly for an unknown id and touches nothing', async () => { + const { setCliEnabled } = await import('../src/config/cli-registry/registry.js'); + const result = setCliEnabled('not-a-real-cli', false); + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain('Unknown CLI'); + }); + + it('setCliOrder repositions entries and leaves unlisted ids alone', async () => { + const { setCliOrder } = await import('../src/config/cli-registry/registry.js'); + const result = setCliOrder(['pi', 'claude', 'shell']); + expect(result.success).toBe(true); + const byId = new Map(result.entries!.map((e) => [e.id as unknown as string, e])); + expect(byId.get('pi')!.order).toBeLessThan(byId.get('claude')!.order); + expect(byId.get('claude')!.order).toBeLessThan(byId.get('shell')!.order); + }); + + it('upsertCustomCli adds a new CLI that shows up in the resolved list', async () => { + const { upsertCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); + const result = upsertCustomCli('testcli', CUSTOM_ENTRY); + expect(result.success).toBe(true); + expect(result.warnings).toEqual([]); + const found = getCli('testcli'); + expect(found?.label).toBe('Test CLI'); + expect(found?.stock).toBe(false); + }); + + it('upsertCustomCli rejects a malformed entry with a schema error, writing nothing', async () => { + const { upsertCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); + const result = upsertCustomCli('bad-cli', { ...CUSTOM_ENTRY, accent: 'not-a-hex-colour' }); + expect(result.success).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + expect(getCli('bad-cli')).toBeUndefined(); + }); + + it('upsertCustomCli refuses to shadow a stock id', async () => { + const { upsertCustomCli } = await import('../src/config/cli-registry/registry.js'); + const result = upsertCustomCli('codex', CUSTOM_ENTRY); + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain('stock CLI'); + }); + + it('removeCustomCli removes a previously added custom CLI', async () => { + const { upsertCustomCli, removeCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); + upsertCustomCli('testcli', CUSTOM_ENTRY); + expect(getCli('testcli')).toBeDefined(); + + const result = removeCustomCli('testcli'); + expect(result.success).toBe(true); + expect(getCli('testcli')).toBeUndefined(); + }); + + it('removeCustomCli refuses to remove a stock CLI', async () => { + const { removeCustomCli, getCli } = await import('../src/config/cli-registry/registry.js'); + const result = removeCustomCli('pi'); + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain('stock CLI'); + expect(getCli('pi')).toBeDefined(); // untouched + }); + + it('removeCustomCli fails cleanly for an unknown id', async () => { + const { removeCustomCli } = await import('../src/config/cli-registry/registry.js'); + const result = removeCustomCli('never-added'); + expect(result.success).toBe(false); + expect(result.warnings[0]).toContain('Unknown CLI'); + }); +}); diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts new file mode 100644 index 000000000..c6a598e27 --- /dev/null +++ b/test/cli-registry-schema.test.ts @@ -0,0 +1,132 @@ +/** + * @fileoverview Validates the shipped stock catalog against `CliEntrySchema`, and proves the + * schema actually rejects the shell-injection shapes it exists to block. + * + * Port: N/A (pure functions, no server). + */ + +import { describe, expect, it } from 'vitest'; +import { CliEntrySchema } from '../src/config/cli-registry/schema.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +describe('CliEntrySchema', () => { + it('accepts every stock entry as shipped', () => { + for (const entry of STOCK_CLIS) { + const result = CliEntrySchema.safeParse(entry); + if (!result.success) { + throw new Error(`stock entry "${entry.id}" failed validation: ${result.error.message}`); + } + } + }); + + it('rejects unknown keys anywhere in the tree (.strict())', () => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + const withJunk = { ...claude, capabilities: { ...claude.capabilities, notARealField: true } }; + expect(CliEntrySchema.safeParse(withJunk).success).toBe(false); + }); + + it.each([ + ['semicolon', 'claude; rm -rf /'], + ['command substitution', 'claude$(rm -rf /)'], + ['backtick', 'claude`rm -rf /`'], + ['pipe', 'claude | cat /etc/passwd'], + ['redirect', 'claude > /etc/passwd'], + ['ampersand background', 'claude & rm -rf /'], + ['newline', 'claude\nrm -rf /'], + ['single quote escape attempt', "claude' ; rm -rf /ETC #"], + ['double quote escape attempt', 'claude" ; rm -rf /ETC #'], + ['space (not a shell metachar but still not a bare word)', 'claude session'], + ])('rejects a literal containing %s', (_label, hostileLit) => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + const tampered = { + ...claude, + launch: { + ...claude.launch, + variants: claude.launch.variants.map((v, i) => + i === 0 ? { ...v, args: [{ lit: hostileLit }, ...v.args.slice(1)] } : v + ), + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a flag value fixed literal containing shell metacharacters', () => { + const codex = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'codex')!; + const tampered = { + ...codex, + launch: { + ...codex.launch, + variants: [{ id: 'default', args: [{ lit: 'codex' }, { flag: '--config', value: 'x=$(whoami)' }] }], + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a valueFrom referencing an undeclared param', () => { + const codex = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'codex')!; + const tampered = { + ...codex, + launch: { + ...codex.launch, + variants: [{ id: 'default', args: [{ lit: 'codex' }, { flag: '--model', valueFrom: 'nonexistentParam' }] }], + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a capabilityGate condition referencing an undeclared gate', () => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + const tampered = { + ...claude, + launch: { + ...claude.launch, + variants: claude.launch.variants.map((v) => ({ + ...v, + args: [...v.args, { flag: '--bogus', when: { capabilityGate: 'notARealGate' } }], + })), + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a fallback chain whose last variant has a `when` guard', () => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + const tampered = { + ...claude, + launch: { + ...claude.launch, + chain: 'fallback' as const, + variants: [ + claude.launch.variants[0], + { ...claude.launch.variants[1], when: { param: 'model', state: 'set' } as const }, + ], + }, + }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects an overlay referencing an unknown launch variant', () => { + const opencode = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'opencode')!; + const tampered = { ...opencode, overlays: { ...opencode.overlays, remote: { variant: 'nonexistent' } } }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects an id that is not lowercase-kebab', () => { + const claude = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'claude')!; + expect(CliEntrySchema.safeParse({ ...claude, id: 'Claude Code' }).success).toBe(false); + expect(CliEntrySchema.safeParse({ ...claude, id: 'CLAUDE' }).success).toBe(false); + expect(CliEntrySchema.safeParse({ ...claude, id: '1claude' }).success).toBe(false); + }); + + it('rejects an env allowlist prefix that does not end with an underscore', () => { + const codex = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'codex')!; + const tampered = { ...codex, env: { ...codex.env, allowedPrefixes: ['CODEX'] } }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); + + it('rejects a too-short env allowlist prefix (defense against widening to a single-letter prefix)', () => { + const codex = STOCK_CLIS.find((e) => (e.id as unknown as string) === 'codex')!; + const tampered = { ...codex, env: { ...codex.env, allowedPrefixes: ['A_'] } }; + expect(CliEntrySchema.safeParse(tampered).success).toBe(false); + }); +}); diff --git a/test/cli-registry-spawn-bridge-parity.test.ts b/test/cli-registry-spawn-bridge-parity.test.ts new file mode 100644 index 000000000..76b27b428 --- /dev/null +++ b/test/cli-registry-spawn-bridge-parity.test.ts @@ -0,0 +1,155 @@ +/** + * @fileoverview Proves `buildSpawnCommandFromRegistry()` — the bridge that will replace + * `buildSpawnCommand`'s per-mode if-chain in tmux-manager.ts — renders BYTE-IDENTICAL output + * to the legacy builder from the EXACT SAME options object, across the same permutation + * matrix as `test/cli-registry-argv-parity.test.ts`. That file proves the argv engine itself + * is correct; this one proves the legacy-config-to-params WIRING (legacyConfigAliases, the + * claude synthetic config, the effort/gate plumbing) is correct end to end. + * + * Port: N/A (pure functions, no server). + */ + +import { describe, expect, it } from 'vitest'; +import { buildSpawnCommand } from '../src/tmux-manager.js'; +import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js'; +import { getCli } from '../src/config/cli-registry/registry.js'; + +function entryFor(id: string) { + const entry = getCli(id); + if (!entry) throw new Error(`no stock entry for ${id}`); + return entry; +} + +function bothRender(options: SpawnBridgeOptions): { legacy: string; bridged: string | undefined } { + return { + legacy: buildSpawnCommand(options as Parameters[0]), + bridged: buildSpawnCommandFromRegistry(entryFor(options.mode), options), + }; +} + +describe('buildSpawnCommandFromRegistry parity with buildSpawnCommand', () => { + it('shell returns undefined (caller falls back to local login-shell resolution)', () => { + expect(buildSpawnCommandFromRegistry(entryFor('shell'), { mode: 'shell', sessionId: 'x' })).toBeUndefined(); + }); + + describe('claude', () => { + it.each>([ + {}, + { claudeMode: 'auto' }, + { claudeMode: 'allowedTools', allowedTools: 'Bash(git:*), Read' }, + { model: 'opus' }, + { model: '[opus-4]' }, + { resumeSessionId: 'abcdef12-3456-7890-abcd-ef1234567890' }, + { effort: 'high' }, + { effort: 'ultracode' }, + { sessionName: 'w1-testcase', claudeCliVersion: '2.1.300' }, + { sessionName: 'w1-testcase', claudeCliVersion: '2.1.100' }, + { sessionName: 'w1-testcase', claudeCliVersion: null }, + { + claudeMode: 'auto', + model: 'sonnet', + resumeSessionId: '11111111-1111-1111-1111-111111111111', + effort: 'xhigh', + sessionName: 'w2-full', + claudeCliVersion: '2.1.300', + }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'claude', sessionId: 'session-uuid-fixture', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('opencode', () => { + it.each>([ + {}, + { openCodeConfig: { model: 'anthropic/claude-sonnet-4-5' } }, + { openCodeConfig: { continueSession: 'sess-123' } }, + { openCodeConfig: { continueSession: 'sess-123', forkSession: true } }, + { openCodeConfig: { model: 'bad model!' } }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'opencode', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('codex', () => { + it.each>([ + {}, + { codexConfig: { dangerouslyBypassApprovals: true } }, + { codexConfig: { animations: true } }, + { codexConfig: { animations: false } }, + { codexConfig: { model: 'gpt-5' } }, + { codexConfig: { resumeSessionId: 'abc-123' } }, + { + codexConfig: { + dangerouslyBypassApprovals: true, + animations: false, + model: 'o4-mini', + resumeSessionId: 'sess-1', + }, + }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'codex', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('gemini', () => { + it.each>([ + {}, + { geminiConfig: { approvalMode: 'plan' } }, + { geminiConfig: { model: 'gemini-2.5-pro' } }, + { geminiConfig: { resumeSession: 'latest' } }, + { geminiConfig: { approvalMode: 'auto_edit', model: 'gemini-2.5-flash', resumeSession: 'sess.1' } }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'gemini', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('antigravity', () => { + it.each>([ + {}, + { antigravityConfig: { dangerouslySkipPermissions: true } }, + { antigravityConfig: { model: 'gemini-3-pro' } }, + { antigravityConfig: { resumeConversationId: 'conv.1' } }, + { + antigravityConfig: { + dangerouslySkipPermissions: true, + model: 'gemini-3-flash', + resumeConversationId: 'conv.2', + }, + }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'antigravity', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); + + describe('pi', () => { + it.each>([ + {}, + { piConfig: { approveProjectTrust: true } }, + { piConfig: { approveProjectTrust: false } }, + { piConfig: { model: 'sonnet:high' } }, + { piConfig: { model: 'openai/gpt-4o' } }, + { piConfig: { provider: 'anthropic' } }, + { piConfig: { thinking: 'xhigh' } }, + { piConfig: { continueSession: true } }, + { piConfig: { continueSession: true, resumeSessionId: 'sess.1' } }, + { piConfig: { resumeSessionId: 'sess.1' } }, + { + piConfig: { + approveProjectTrust: true, + model: 'sonnet:high', + provider: 'anthropic', + thinking: 'high', + continueSession: true, + }, + }, + ])('%#', (overrides) => { + const { legacy, bridged } = bothRender({ mode: 'pi', sessionId: 'x', ...overrides }); + expect(bridged).toBe(legacy); + }); + }); +}); diff --git a/test/cli-stock-json-sync.test.ts b/test/cli-stock-json-sync.test.ts new file mode 100644 index 000000000..84e1ced5a --- /dev/null +++ b/test/cli-stock-json-sync.test.ts @@ -0,0 +1,26 @@ +/** + * @fileoverview Pins `config/clis.stock.json` (install.sh's pre-clone, pre-build view of + * the stock CLI catalog) in sync with the real source of truth, + * `src/config/cli-registry/stock.ts`. Same pattern as `test/sse-registry-parity.test.ts`. + * + * If this fails, run `npm run generate:cli-stock-json` and commit the regenerated file. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +describe('config/clis.stock.json', () => { + it('matches the compiled-in stock catalog', () => { + const expected = STOCK_CLIS.map((entry) => ({ + id: entry.id, + label: entry.label, + stock: true, + discovery: entry.discovery, + })); + + const onDisk = JSON.parse(readFileSync(resolve(import.meta.dirname, '../config/clis.stock.json'), 'utf8')); + + expect(onDisk).toEqual(expected); + }); +}); diff --git a/test/dependency-checker.test.ts b/test/dependency-checker.test.ts index 13b7f0b53..2fbc0b1df 100644 --- a/test/dependency-checker.test.ts +++ b/test/dependency-checker.test.ts @@ -34,14 +34,18 @@ describe('DEPENDENCY_REGISTRY', () => { // `pi` is a short generic name, so pi-cli-resolver.ts refuses a binary that does not // print semver. If the doctor did not apply the identical rule it would report // "Pi CLI ✓" on a box where Run Pi stays hidden, which reads as a broken mode - // rather than a missing install. One regex, shared, is what keeps them agreeing. + // rather than a missing install. Both sides now compile their regex from the SAME + // declared string in the CLI registry's stock catalog (config/cli-registry/stock.ts), + // so this compares by pattern (`.source`) rather than object identity — the registry + // and pi-cli-resolver.ts's own PI_VERSION_REGEX are separately-compiled RegExp + // instances of the identical source string, not the same object. const pi = DEPENDENCY_REGISTRY.find((t) => t.id === 'pi'); expect(pi).toBeDefined(); const spec = pi!.resolvers.find((r) => r.resolver.kind === 'path'); expect(spec).toBeDefined(); const resolver = spec!.resolver as { versionRegex?: RegExp; requireVersionMatch?: boolean }; expect(resolver.requireVersionMatch).toBe(true); - expect(resolver.versionRegex).toBe(PI_VERSION_REGEX); + expect(resolver.versionRegex?.source).toBe(PI_VERSION_REGEX.source); }); it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => { diff --git a/test/docker-hosts.test.ts b/test/docker-hosts.test.ts index 3d8484330..782d8986f 100644 --- a/test/docker-hosts.test.ts +++ b/test/docker-hosts.test.ts @@ -9,6 +9,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { agentImageBuildArgs, + binaryForDockerProbe, buildDockerBaseArgs, buildDockerCreateArgs, buildSeamlessClaudeConfig, @@ -462,4 +463,19 @@ describe('daemon probes (no-op under VITEST)', () => { await probeDockerCliVersion({ engine: 'docker', containerName: 'codeman-case-x' }, 'claude') ).toBeUndefined(); }); + + it('binaryForDockerProbe resolves the REGISTERED binary, not the mode id', () => { + // The regression this pins: probeDockerCliVersion used to probe `mode` itself as the + // binary name, which is correct for claude/opencode/codex/gemini/pi (mode === binary) + // but WRONG for antigravity, whose binary is `agy`. A container has no `antigravity` + // executable, so the old code silently probed a nonexistent binary and always got + // undefined back — never actually version-checking Antigravity docker sessions. + expect(binaryForDockerProbe('antigravity')).toBe('agy'); + expect(binaryForDockerProbe('claude')).toBe('claude'); + expect(binaryForDockerProbe('codex')).toBe('codex'); + expect(binaryForDockerProbe('gemini')).toBe('gemini'); + expect(binaryForDockerProbe('opencode')).toBe('opencode'); + expect(binaryForDockerProbe('pi')).toBe('pi'); + expect(binaryForDockerProbe('shell')).toBeUndefined(); + }); }); diff --git a/test/grok-cli-resolver.test.ts b/test/grok-cli-resolver.test.ts deleted file mode 100644 index f2eb4c080..000000000 --- a/test/grok-cli-resolver.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * @fileoverview Tests for the Grok CLI resolver wrapper. - * - * Grok is the second resolver with a version probe: `grok` is a binary name - * with known squatters (the unrelated @vibe-kit/grok-cli npm package also - * installs a `grok` bin), so a resolved path is only accepted once - * `grok --version` prints a version-shaped string. The probe EXECUTES the - * candidate, which is exactly why it must never run under vitest: the - * hermeticity test below pins that gate with a real executable fixture, the - * same behavior-level pin test/pi-cli-resolver.test.ts carries. - */ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createGrokResolverForTest, GROK_VERSION_REGEX } from '../src/utils/grok-cli-resolver.js'; -import { - cliResolveRetryDelayMs, - createProductionCliResolverHost, - type CliResolverHost, -} from '../src/utils/cli-executable-resolver.js'; - -const temporaryDirectories: string[] = []; - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } -}); - -function createHost( - options: { - processPathResult?: string | null; - loginShellResults?: Array; - existingPaths?: string[]; - } = {} -): CliResolverHost { - const loginShellResults = [...(options.loginShellResults ?? [])]; - const existingPaths = new Set(options.existingPaths ?? []); - return { - processPath: '/service/bin', - shellPath: '/bin/zsh', - shellArgs: ['-l'], - findOnProcessPath: () => options.processPathResult ?? null, - findInLoginShell: () => loginShellResults.shift() ?? null, - exists: (path) => existingPaths.has(path), - }; -} - -describe('Grok CLI resolver', () => { - it('accepts a candidate the version probe verifies and carries the version as metadata', () => { - const binaryPath = '/service/bin/grok'; - const probe = vi.fn(() => '1.0.5'); - const resolver = createGrokResolverForTest( - createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }), - probe - ); - - expect(resolver.resolve()).toMatchObject({ - binaryPath, - directory: '/service/bin', - source: 'process-path', - metadata: '1.0.5', - }); - expect(probe).toHaveBeenCalledWith(binaryPath); - }); - - it('rejects a candidate the probe refuses and falls through to a later one', () => { - // An unrelated `grok` on the service PATH (probe returns null) must not - // mask the real coding agent found by the login shell. - const impostor = '/service/bin/grok'; - const genuine = '/login-shell/bin/grok'; - const probe = vi.fn((binPath: string) => (binPath === genuine ? '1.0.5' : null)); - const resolver = createGrokResolverForTest( - createHost({ - processPathResult: impostor, - loginShellResults: [genuine], - existingPaths: [impostor, genuine], - }), - probe - ); - - expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '1.0.5' }); - }); - - it('negative-caches a miss and retries only after the backoff elapses', () => { - const binaryPath = '/late/bin/grok'; - let now = 0; - const probe = vi.fn(() => '1.0.5'); - const resolver = createGrokResolverForTest( - createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }), - probe, - () => now - ); - - expect(resolver.resolve()).toBeNull(); - expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run - expect(probe).not.toHaveBeenCalled(); - now = cliResolveRetryDelayMs(1); - expect(resolver.resolve()?.metadata).toBe('1.0.5'); - expect(resolver.resolve()?.binaryPath).toBe(binaryPath); - }); - - it('extracts the version from the real output shape (`grok 1.0.5 (5115b46bc9)`)', () => { - // GROK_VERSION_REGEX is shared with the dependency registry (doctor), so the - // shape it accepts is contract, not implementation detail. - expect(GROK_VERSION_REGEX.exec('grok 1.0.5 (5115b46bc9)')?.[1]).toBe('1.0.5'); - expect(GROK_VERSION_REGEX.exec('1.0.5')?.[1]).toBe('1.0.5'); - expect(GROK_VERSION_REGEX.exec('v1.0.5')).toBeNull(); - expect(GROK_VERSION_REGEX.exec('not a version')).toBeNull(); - }); - - it('never executes a grok candidate under vitest (the ambient probe is VITEST-gated)', () => { - // A REAL executable fixture that prints a valid version. If the guard in - // probeGrokVersion is ever removed, the probe runs this script, the - // resolution SUCCEEDS, and this test fails, pinning hermeticity by - // behavior rather than by source text. - const root = mkdtempSync(join(tmpdir(), 'codeman-grok-vitest-gate-')); - temporaryDirectories.push(root); - const binaryPath = join(root, 'grok'); - writeFileSync(binaryPath, '#!/bin/sh\necho "grok 9.9.9 (deadbeef)"\n'); - chmodSync(binaryPath, 0o755); - const hostOptions = { - processPath: root, - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'] as string[], - runCommand: () => '', - isExecutableFile: (path: string) => path === binaryPath, - }; - - // Default (ambient) probe: the candidate is found but never executed, so - // the VITEST gate reports it unusable and resolution misses. - const gated = createGrokResolverForTest(createProductionCliResolverHost(hostOptions)); - expect(gated.resolve()).toBeNull(); - - // Control: identical setup with an injected probe resolves, proving the - // null above comes from the gate, not from the fixture or the host. - const control = createGrokResolverForTest(createProductionCliResolverHost(hostOptions), () => '9.9.9'); - expect(control.resolve()).toMatchObject({ binaryPath, metadata: '9.9.9' }); - }); -}); diff --git a/test/memory-leak-prevention.test.ts b/test/memory-leak-prevention.test.ts index f7a75e7d7..c59f3ad01 100644 --- a/test/memory-leak-prevention.test.ts +++ b/test/memory-leak-prevention.test.ts @@ -507,7 +507,7 @@ describe('Memory Leak Prevention Patterns', () => { expect(manager.activeTimerCount).toBe(1); - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); expect(manager.activeTimerCount).toBe(0); expect(callback).toHaveBeenCalled(); @@ -552,10 +552,13 @@ describe('Memory Leak Prevention Patterns', () => { }); it('should handle nested object cleanup', () => { - const sessions = new Map; - handlers: Set<() => void>; - }>(); + const sessions = new Map< + string, + { + buffers: Map; + handlers: Set<() => void>; + } + >(); sessions.set('session1', { buffers: new Map([['terminal', 'data']]), diff --git a/test/mobile/accessibility.test.ts b/test/mobile/accessibility.test.ts index b50c2c408..7a1bb283f 100644 --- a/test/mobile/accessibility.test.ts +++ b/test/mobile/accessibility.test.ts @@ -6,8 +6,10 @@ import { createTestServer, stopTestServer } from './helpers/server.js'; import { createDevicePage, closeAllBrowsers } from './helpers/browser.js'; import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js'; import { - assertAccessibleTouchTargets, assertFontSizeNoZoom, - assertZoomNotDisabled, getCSSProperty, + assertAccessibleTouchTargets, + assertFontSizeNoZoom, + assertZoomNotDisabled, + getCSSProperty, } from './helpers/assertions.js'; import { REPRESENTATIVE_DEVICES } from './devices.js'; import type { WebServer } from '../src/web/server.js'; @@ -247,17 +249,14 @@ describe('Mobile Accessibility', () => { const style = getComputedStyle(btn); if (style.display === 'none' || style.visibility === 'hidden') continue; - const name = - btn.getAttribute('aria-label') || - btn.getAttribute('title') || - btn.textContent?.trim() || - ''; + const name = btn.getAttribute('aria-label') || btn.getAttribute('title') || btn.textContent?.trim() || ''; if (!name) { const id = btn.id ? `#${btn.id}` : ''; - const cls = btn.className && typeof btn.className === 'string' - ? '.' + btn.className.trim().split(/\s+/).slice(0, 2).join('.') - : ''; + const cls = + btn.className && typeof btn.className === 'string' + ? '.' + btn.className.trim().split(/\s+/).slice(0, 2).join('.') + : ''; violations.push(`button${id}${cls}`); } } diff --git a/test/mobile/device-matrix.test.ts b/test/mobile/device-matrix.test.ts index 82be7d612..3c54d5e80 100644 --- a/test/mobile/device-matrix.test.ts +++ b/test/mobile/device-matrix.test.ts @@ -15,12 +15,7 @@ import { getCSSProperty, getCSSNumericValue, } from './helpers/assertions.js'; -import { - REPRESENTATIVE_DEVICES, - DEVICE_REGISTRY, - type DeviceEntry, - type DeviceCategory, -} from './devices.js'; +import { REPRESENTATIVE_DEVICES, DEVICE_REGISTRY, type DeviceEntry, type DeviceCategory } from './devices.js'; const PORT = PORTS.DEVICE_MATRIX; const BASE_URL = `http://localhost:${PORT}`; @@ -37,10 +32,7 @@ const PHONE_HIDDEN_SELECTORS = [ ]; // Visible only on phones -const PHONE_ONLY_SELECTORS = [ - SELECTORS.SETTINGS_MOBILE, - SELECTORS.CASE_MOBILE, -]; +const PHONE_ONLY_SELECTORS = [SELECTORS.SETTINGS_MOBILE, SELECTORS.CASE_MOBILE]; describe('Device Matrix', () => { beforeAll(async () => { @@ -54,98 +46,97 @@ describe('Device Matrix', () => { // ─── Representative Devices ─────────────────────────────────────────────── - describe.each( - Object.entries(REPRESENTATIVE_DEVICES) as [DeviceCategory, DeviceEntry][], - )('Representative: %s', (category, device) => { - let context: BrowserContext; - let page: Page; - - beforeAll(async () => { - ({ context, page } = await createDevicePage(device, BASE_URL)); - }); - - afterAll(async () => { - await context.close(); - }); - - it(`has correct device class for ${device.name} (${device.viewport.width}px)`, async () => { - await assertDeviceClasses(page, device.viewport.width); - }); - - it('no horizontal overflow', async () => { - await assertNoHorizontalOverflow(page); - }); - - it('header positioning matches breakpoint', async () => { - const { width } = device.viewport; - const position = await getCSSProperty(page, SELECTORS.HEADER, 'position'); - if (width <= BREAKPOINTS.TABLET_MAX) { - // Phone + tablet (max-width: 768px includes 768): fixed header - expect(position).toBe('fixed'); - const top = await getCSSProperty(page, SELECTORS.HEADER, 'top'); - expect(parseFloat(top)).toBe(0); - } else { - // Desktop: relative header (not fixed) - expect(position).toBe('relative'); - } - }); - - it('toolbar positioning matches breakpoint', async () => { - const { width } = device.viewport; - const position = await getCSSProperty(page, SELECTORS.TOOLBAR, 'position'); - if (width <= BREAKPOINTS.PHONE_MAX) { - // Phone (max-width: 430px includes 430): fixed toolbar - expect(position).toBe('fixed'); - } else { - // Tablet/desktop: relative toolbar - expect(position).toBe('relative'); - } - }); - - it('correct elements hidden/visible for breakpoint', async () => { - const { width } = device.viewport; - const isPhone = width < BREAKPOINTS.PHONE_MAX; - // Skip strict assertions for devices at the phone/tablet boundary (±10px) - const atBoundary = Math.abs(width - BREAKPOINTS.PHONE_MAX) <= 10 - || Math.abs(width - BREAKPOINTS.TABLET_MAX) <= 10; - - if (atBoundary) return; - - if (isPhone) { - // Phone: certain elements hidden, mobile buttons visible - for (const sel of PHONE_HIDDEN_SELECTORS) { - await assertHidden(page, sel); + describe.each(Object.entries(REPRESENTATIVE_DEVICES) as [DeviceCategory, DeviceEntry][])( + 'Representative: %s', + (category, device) => { + let context: BrowserContext; + let page: Page; + + beforeAll(async () => { + ({ context, page } = await createDevicePage(device, BASE_URL)); + }); + + afterAll(async () => { + await context.close(); + }); + + it(`has correct device class for ${device.name} (${device.viewport.width}px)`, async () => { + await assertDeviceClasses(page, device.viewport.width); + }); + + it('no horizontal overflow', async () => { + await assertNoHorizontalOverflow(page); + }); + + it('header positioning matches breakpoint', async () => { + const { width } = device.viewport; + const position = await getCSSProperty(page, SELECTORS.HEADER, 'position'); + if (width <= BREAKPOINTS.TABLET_MAX) { + // Phone + tablet (max-width: 768px includes 768): fixed header + expect(position).toBe('fixed'); + const top = await getCSSProperty(page, SELECTORS.HEADER, 'top'); + expect(parseFloat(top)).toBe(0); + } else { + // Desktop: relative header (not fixed) + expect(position).toBe('relative'); + } + }); + + it('toolbar positioning matches breakpoint', async () => { + const { width } = device.viewport; + const position = await getCSSProperty(page, SELECTORS.TOOLBAR, 'position'); + if (width <= BREAKPOINTS.PHONE_MAX) { + // Phone (max-width: 430px includes 430): fixed toolbar + expect(position).toBe('fixed'); + } else { + // Tablet/desktop: relative toolbar + expect(position).toBe('relative'); } - for (const sel of PHONE_ONLY_SELECTORS) { - await assertVisible(page, sel); + }); + + it('correct elements hidden/visible for breakpoint', async () => { + const { width } = device.viewport; + const isPhone = width < BREAKPOINTS.PHONE_MAX; + // Skip strict assertions for devices at the phone/tablet boundary (±10px) + const atBoundary = + Math.abs(width - BREAKPOINTS.PHONE_MAX) <= 10 || Math.abs(width - BREAKPOINTS.TABLET_MAX) <= 10; + + if (atBoundary) return; + + if (isPhone) { + // Phone: certain elements hidden, mobile buttons visible + for (const sel of PHONE_HIDDEN_SELECTORS) { + await assertHidden(page, sel); + } + for (const sel of PHONE_ONLY_SELECTORS) { + await assertVisible(page, sel); + } + } else { + // Tablet/desktop: phone-hidden elements should be visible, mobile buttons hidden + for (const sel of PHONE_ONLY_SELECTORS) { + await assertHidden(page, sel); + } } - } else { - // Tablet/desktop: phone-hidden elements should be visible, mobile buttons hidden - for (const sel of PHONE_ONLY_SELECTORS) { - await assertHidden(page, sel); + }); + + it('touch targets pass minimum size', async () => { + const violations = await assertAccessibleTouchTargets(page); + // Log violations for debugging but allow a small number + if (violations.length > 0) { + console.warn( + `[${device.name}] Touch target violations (${violations.length}):\n` + + violations.map((v) => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n') + ); } - } - }); - - it('touch targets pass minimum size', async () => { - const violations = await assertAccessibleTouchTargets(page); - // Log violations for debugging but allow a small number - if (violations.length > 0) { - console.warn( - `[${device.name}] Touch target violations (${violations.length}):\n` + - violations.map(v => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n'), - ); - } - // Larger viewports show more UI elements, so allow more violations. - // Known violators: notification action buttons (26x26), some icon buttons. - const { width } = device.viewport; - // Larger viewports show more elements; scale threshold accordingly - const maxViolations = width >= BREAKPOINTS.TABLET_MAX ? 25 - : width >= BREAKPOINTS.PHONE_MAX ? 20 - : 15; - expect(violations.length).toBeLessThanOrEqual(maxViolations); - }); - }); + // Larger viewports show more UI elements, so allow more violations. + // Known violators: notification action buttons (26x26), some icon buttons. + const { width } = device.viewport; + // Larger viewports show more elements; scale threshold accordingly + const maxViolations = width >= BREAKPOINTS.TABLET_MAX ? 25 : width >= BREAKPOINTS.PHONE_MAX ? 20 : 15; + expect(violations.length).toBeLessThanOrEqual(maxViolations); + }); + } + ); // ─── Full Device Matrix (skip with CI_QUICK=1) ─────────────────────────── diff --git a/test/mobile/helpers/assertions.ts b/test/mobile/helpers/assertions.ts index 8521a34e9..e99cc847a 100644 --- a/test/mobile/helpers/assertions.ts +++ b/test/mobile/helpers/assertions.ts @@ -2,10 +2,7 @@ import type { Page, Locator } from 'playwright'; import { MIN_TOUCH_TARGET, BREAKPOINTS, BODY_CLASSES, SELECTORS } from './constants.js'; /** Assert an element meets minimum touch target size (WCAG 2.5.5 / Apple HIG) */ -export async function assertTouchTarget( - locator: Locator, - minSize: number = MIN_TOUCH_TARGET, -): Promise { +export async function assertTouchTarget(locator: Locator, minSize: number = MIN_TOUCH_TARGET): Promise { const box = await locator.boundingBox(); expect(box).not.toBeNull(); expect(box!.width).toBeGreaterThanOrEqual(minSize); @@ -68,27 +65,19 @@ export async function assertVisible(page: Page, selector: string): Promise } /** Get a computed CSS property value */ -export async function getCSSProperty( - page: Page, - selector: string, - property: string, -): Promise { +export async function getCSSProperty(page: Page, selector: string, property: string): Promise { return page.evaluate( ({ sel, prop }) => { const el = document.querySelector(sel); if (!el) throw new Error(`Element not found: ${sel}`); return getComputedStyle(el).getPropertyValue(prop); }, - { sel: selector, prop: property }, + { sel: selector, prop: property } ); } /** Get computed numeric value (parses px values) */ -export async function getCSSNumericValue( - page: Page, - selector: string, - property: string, -): Promise { +export async function getCSSNumericValue(page: Page, selector: string, property: string): Promise { const value = await getCSSProperty(page, selector, property); return parseFloat(value) || 0; } @@ -97,7 +86,7 @@ export async function getCSSNumericValue( * Returns list of violations (elements smaller than minSize). */ export async function assertAccessibleTouchTargets( page: Page, - minSize: number = MIN_TOUCH_TARGET, + minSize: number = MIN_TOUCH_TARGET ): Promise<{ selector: string; width: number; height: number }[]> { const violations = await page.evaluate((min) => { const interactiveSelectors = 'button, a, [role="button"], input, select, textarea, [tabindex]'; @@ -115,9 +104,8 @@ export async function assertAccessibleTouchTargets( // Generate a useful selector for the failing element const tag = el.tagName.toLowerCase(); const id = el.id ? `#${el.id}` : ''; - const cls = el.className && typeof el.className === 'string' - ? '.' + el.className.trim().split(/\s+/).join('.') - : ''; + const cls = + el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/).join('.') : ''; const text = el.textContent?.trim().substring(0, 20) || ''; results.push({ selector: `${tag}${id}${cls} ("${text}")`, @@ -140,19 +128,19 @@ export async function assertFontSizeNoZoom(page: Page, selector: string): Promis /** Assert an element has a specific CSS class */ export async function assertHasClass(page: Page, selector: string, className: string): Promise { - const has = await page.evaluate( - ({ sel, cls }) => document.querySelector(sel)?.classList.contains(cls) ?? false, - { sel: selector, cls: className }, - ); + const has = await page.evaluate(({ sel, cls }) => document.querySelector(sel)?.classList.contains(cls) ?? false, { + sel: selector, + cls: className, + }); expect(has).toBe(true); } /** Assert an element does NOT have a specific CSS class */ export async function assertNotHasClass(page: Page, selector: string, className: string): Promise { - const has = await page.evaluate( - ({ sel, cls }) => document.querySelector(sel)?.classList.contains(cls) ?? false, - { sel: selector, cls: className }, - ); + const has = await page.evaluate(({ sel, cls }) => document.querySelector(sel)?.classList.contains(cls) ?? false, { + sel: selector, + cls: className, + }); expect(has).toBe(false); } @@ -161,7 +149,7 @@ export async function assertTranslateY( page: Page, selector: string, expectedY: number, - tolerance: number = 2, + tolerance: number = 2 ): Promise { const transform = await getCSSProperty(page, selector, 'transform'); // transform is a matrix(...) string; extract translateY diff --git a/test/mobile/helpers/browser.ts b/test/mobile/helpers/browser.ts index 410914191..d49ba6a61 100644 --- a/test/mobile/helpers/browser.ts +++ b/test/mobile/helpers/browser.ts @@ -25,7 +25,7 @@ export async function getBrowser(engine: 'chromium' | 'webkit' = 'chromium'): Pr export async function createDeviceContext( device: DeviceEntry, - engineOverride?: 'chromium' | 'webkit', + engineOverride?: 'chromium' | 'webkit' ): Promise { const engine = engineOverride ?? device.defaultBrowserType; const browser = await getBrowser(engine); @@ -41,7 +41,7 @@ export async function createDeviceContext( export async function createDevicePage( device: DeviceEntry, url: string, - engineOverride?: 'chromium' | 'webkit', + engineOverride?: 'chromium' | 'webkit' ): Promise<{ context: BrowserContext; page: Page }> { const context = await createDeviceContext(device, engineOverride); const page = await context.newPage(); diff --git a/test/mobile/helpers/cdp.ts b/test/mobile/helpers/cdp.ts index 0407fe335..02b70dd63 100644 --- a/test/mobile/helpers/cdp.ts +++ b/test/mobile/helpers/cdp.ts @@ -11,7 +11,7 @@ export async function setVisualViewportHeight( cdp: CDPSession, width: number, height: number, - scale: number, + scale: number ): Promise { await cdp.send('Emulation.setDeviceMetricsOverride', { width, @@ -31,11 +31,11 @@ export async function clearDeviceMetricsOverride(cdp: CDPSession): Promise export async function dispatchTouchEvent( cdp: CDPSession, type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel', - touchPoints: Array<{ x: number; y: number }>, + touchPoints: Array<{ x: number; y: number }> ): Promise { await cdp.send('Input.dispatchTouchEvent', { type, - touchPoints: touchPoints.map(p => ({ x: Math.round(p.x), y: Math.round(p.y) })), + touchPoints: touchPoints.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) })), }); } @@ -54,7 +54,7 @@ export async function setNetworkThrottle( cdp: CDPSession, downloadKbps: number, uploadKbps: number, - latencyMs: number, + latencyMs: number ): Promise { await cdp.send('Network.enable'); await cdp.send('Network.emulateNetworkConditions', { diff --git a/test/mobile/helpers/keyboard-sim.ts b/test/mobile/helpers/keyboard-sim.ts index 83079c3dd..6d77c7cd9 100644 --- a/test/mobile/helpers/keyboard-sim.ts +++ b/test/mobile/helpers/keyboard-sim.ts @@ -50,12 +50,7 @@ export async function showKeyboardViaCDP(page: Page, keyboardHeight: number): Pr const cdp = await getCDP(page); const viewport = page.viewportSize()!; const newHeight = viewport.height - keyboardHeight; - await setVisualViewportHeight( - cdp, - viewport.width, - newHeight, - 1, - ); + await setVisualViewportHeight(cdp, viewport.width, newHeight, 1); await page.waitForTimeout(100); await page.evaluate(`(function(newH, origH) { @@ -148,10 +143,7 @@ export async function setupViewportMock(page: Page): Promise { const realVV = window.visualViewport; if (!realVV) return; - const origDesc = Object.getOwnPropertyDescriptor( - Object.getPrototypeOf(realVV), - 'height', - ); + const origDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(realVV), 'height'); Object.defineProperty(realVV, 'height', { get() { @@ -258,7 +250,7 @@ export async function hideKeyboardViaDOM(page: Page): Promise { export async function showKeyboard( page: Page, keyboardHeight: number, - options: KeyboardSimOptions = {}, + options: KeyboardSimOptions = {} ): Promise { const { preferredLayer, isChromium = true } = options; @@ -282,18 +274,21 @@ export async function showKeyboard( } /** Hide keyboard using the best available simulation layer */ -export async function hideKeyboard( - page: Page, - options: KeyboardSimOptions = {}, -): Promise { +export async function hideKeyboard(page: Page, options: KeyboardSimOptions = {}): Promise { const { preferredLayer, isChromium = true } = options; if (preferredLayer) { let success = false; switch (preferredLayer) { - case 'cdp': success = await hideKeyboardViaCDP(page); break; - case 'mock': success = await hideKeyboardViaMock(page); break; - case 'dom': success = await hideKeyboardViaDOM(page); break; + case 'cdp': + success = await hideKeyboardViaCDP(page); + break; + case 'mock': + success = await hideKeyboardViaMock(page); + break; + case 'dom': + success = await hideKeyboardViaDOM(page); + break; } return { layer: preferredLayer, success }; } @@ -314,7 +309,7 @@ async function tryLayer( page: Page, keyboardHeight: number, layer: KeyboardLayer, - isChromium: boolean, + isChromium: boolean ): Promise { switch (layer) { case 'cdp': diff --git a/test/mobile/helpers/touch-sim.ts b/test/mobile/helpers/touch-sim.ts index 9b57000ec..65c0acbb8 100644 --- a/test/mobile/helpers/touch-sim.ts +++ b/test/mobile/helpers/touch-sim.ts @@ -19,16 +19,8 @@ export interface SwipeOptions { /** Perform a swipe gesture via CDP Input.dispatchTouchEvent (trusted, Chromium only). * Target element defaults to '.main' (where SwipeHandler listens). */ -export async function swipeViaCDP( - page: Page, - direction: SwipeDirection, - options: SwipeOptions = {}, -): Promise { - const { - distance = SWIPE.MIN_DISTANCE + 20, - duration = 150, - steps = 5, - } = options; +export async function swipeViaCDP(page: Page, direction: SwipeDirection, options: SwipeOptions = {}): Promise { + const { distance = SWIPE.MIN_DISTANCE + 20, duration = 150, steps = 5 } = options; const cdp = await getCDP(page); const targetSelector = '.main'; @@ -91,13 +83,9 @@ export async function swipeViaCDP( export async function swipeViaSynthetic( page: Page, direction: SwipeDirection, - options: SwipeOptions = {}, + options: SwipeOptions = {} ): Promise { - const { - distance = SWIPE.MIN_DISTANCE + 20, - duration = 150, - steps = 5, - } = options; + const { distance = SWIPE.MIN_DISTANCE + 20, duration = 150, steps = 5 } = options; await page.evaluate( ({ dir, dist, dur, numSteps }) => { @@ -139,12 +127,14 @@ export async function swipeViaSynthetic( } // touchstart - main.dispatchEvent(new TouchEvent('touchstart', { - touches: [createTouch(startX, startY)], - changedTouches: [createTouch(startX, startY)], - bubbles: true, - cancelable: true, - })); + main.dispatchEvent( + new TouchEvent('touchstart', { + touches: [createTouch(startX, startY)], + changedTouches: [createTouch(startX, startY)], + bubbles: true, + cancelable: true, + }) + ); // Intermediate moves const stepDelay = dur / numSteps; @@ -153,26 +143,30 @@ export async function swipeViaSynthetic( const x = startX + (endX - startX) * progress; const y = startY + (endY - startY) * progress; setTimeout(() => { - main.dispatchEvent(new TouchEvent('touchmove', { - touches: [createTouch(x, y)], - changedTouches: [createTouch(x, y)], - bubbles: true, - cancelable: true, - })); + main.dispatchEvent( + new TouchEvent('touchmove', { + touches: [createTouch(x, y)], + changedTouches: [createTouch(x, y)], + bubbles: true, + cancelable: true, + }) + ); }, stepDelay * i); } // touchend setTimeout(() => { - main.dispatchEvent(new TouchEvent('touchend', { - touches: [], - changedTouches: [createTouch(endX, endY)], - bubbles: true, - cancelable: true, - })); + main.dispatchEvent( + new TouchEvent('touchend', { + touches: [], + changedTouches: [createTouch(endX, endY)], + bubbles: true, + cancelable: true, + }) + ); }, dur + 10); }, - { dir: direction, dist: distance, dur: duration, numSteps: steps }, + { dir: direction, dist: distance, dur: duration, numSteps: steps } ); // Wait for the full gesture + a little buffer @@ -186,7 +180,7 @@ export async function swipeViaSynthetic( export async function swipe( page: Page, direction: SwipeDirection, - options: SwipeOptions & { isChromium?: boolean } = {}, + options: SwipeOptions & { isChromium?: boolean } = {} ): Promise { const { isChromium = true, ...swipeOpts } = options; @@ -230,25 +224,25 @@ export async function tapViaSynthetic(page: Page, selector: string): Promise { +export async function tap(page: Page, selector: string, options: { isChromium?: boolean } = {}): Promise { if (options.isChromium !== false) { try { await tapViaCDP(page, selector); diff --git a/test/mobile/helpers/visual.ts b/test/mobile/helpers/visual.ts index 8e273597e..6de16e48f 100644 --- a/test/mobile/helpers/visual.ts +++ b/test/mobile/helpers/visual.ts @@ -32,7 +32,7 @@ export interface CompareResult { export async function compareScreenshot( page: Page, name: string, - options: CompareOptions = {}, + options: CompareOptions = {} ): Promise { const threshold = options.threshold ?? VISUAL.DEFAULT_THRESHOLD; const maxDiffPercent = options.maxDiffPercent ?? VISUAL.MAX_DIFF_PERCENT; @@ -63,21 +63,16 @@ export async function compareScreenshot( writeFileSync(actualPath, actualBuffer); throw new Error( `Dimension mismatch for "${name}": ` + - `baseline ${baseline.width}x${baseline.height} vs ` + - `actual ${actual.width}x${actual.height}. ` + - `Actual saved to ${actualPath}`, + `baseline ${baseline.width}x${baseline.height} vs ` + + `actual ${actual.width}x${actual.height}. ` + + `Actual saved to ${actualPath}` ); } const diff = new PNG({ width: baseline.width, height: baseline.height }); - const numDiffPixels = pixelmatch( - baseline.data, - actual.data, - diff.data, - baseline.width, - baseline.height, - { threshold }, - ); + const numDiffPixels = pixelmatch(baseline.data, actual.data, diff.data, baseline.width, baseline.height, { + threshold, + }); const totalPixels = baseline.width * baseline.height; const diffPercent = (numDiffPixels / totalPixels) * 100; @@ -107,17 +102,13 @@ export async function compareScreenshot( } /** Assert screenshot matches baseline, throwing on failure */ -export async function assertScreenshotMatch( - page: Page, - name: string, - options: CompareOptions = {}, -): Promise { +export async function assertScreenshotMatch(page: Page, name: string, options: CompareOptions = {}): Promise { const result = await compareScreenshot(page, name, options); if (!result.passed) { throw new Error( `Visual regression: "${name}" has ${result.diffPercent!.toFixed(2)}% pixel diff ` + - `(max ${options.maxDiffPercent ?? VISUAL.MAX_DIFF_PERCENT}%). ` + - `See ${result.diffPath}`, + `(max ${options.maxDiffPercent ?? VISUAL.MAX_DIFF_PERCENT}%). ` + + `See ${result.diffPath}` ); } } diff --git a/test/mobile/subagent-windows.test.ts b/test/mobile/subagent-windows.test.ts index 59c5c752a..a7599eb34 100644 --- a/test/mobile/subagent-windows.test.ts +++ b/test/mobile/subagent-windows.test.ts @@ -19,11 +19,12 @@ const standardPhone = REPRESENTATIVE_DEVICES['standard-phone']; // iPhone 14 Pro * Registers it with app.subagentWindows if the app object is available. */ async function injectMockSubagentWindow(page: Page, id: string, _index: number): Promise { - await page.evaluate(({ windowId }) => { - const el = document.createElement('div'); - el.className = 'subagent-window'; - el.dataset.agentId = windowId; - el.innerHTML = ` + await page.evaluate( + ({ windowId }) => { + const el = document.createElement('div'); + el.className = 'subagent-window'; + el.dataset.agentId = windowId; + el.innerHTML = `
A ${windowId} @@ -35,17 +36,19 @@ async function injectMockSubagentWindow(page: Page, id: string, _index: number):
Working on task...
`; - document.body.appendChild(el); - - // Register with app if available - if ((window as any).app?.subagentWindows) { - (window as any).app.subagentWindows.set(windowId, { - element: el, - minimized: false, - hidden: false, - }); - } - }, { windowId: id }); + document.body.appendChild(el); + + // Register with app if available + if ((window as any).app?.subagentWindows) { + (window as any).app.subagentWindows.set(windowId, { + element: el, + minimized: false, + hidden: false, + }); + } + }, + { windowId: id } + ); } /** @@ -53,7 +56,7 @@ async function injectMockSubagentWindow(page: Page, id: string, _index: number): */ async function clearMockSubagentWindows(page: Page): Promise { await page.evaluate(() => { - document.querySelectorAll('.subagent-window').forEach(el => el.remove()); + document.querySelectorAll('.subagent-window').forEach((el) => el.remove()); if ((window as any).app?.subagentWindows) { (window as any).app.subagentWindows.clear(); } @@ -197,18 +200,21 @@ describe('Mobile Subagent Windows', () => { } // Manually position them from top (simulating relayoutMobileSubagentWindows) - await page.evaluate(({ headerHeight, stride }) => { - const windows = document.querySelectorAll('.subagent-window'); - windows.forEach((win, idx) => { - const el = win as HTMLElement; - el.style.position = 'fixed'; - el.style.top = `${headerHeight + 8 + idx * stride}px`; - el.style.bottom = 'auto'; - el.style.left = '4px'; - el.style.width = 'calc(100% - 8px)'; - el.style.height = '110px'; - }); - }, { headerHeight: SUBAGENT.DEFAULT_HEADER_HEIGHT, stride: SUBAGENT.MOBILE_CARD_STRIDE }); + await page.evaluate( + ({ headerHeight, stride }) => { + const windows = document.querySelectorAll('.subagent-window'); + windows.forEach((win, idx) => { + const el = win as HTMLElement; + el.style.position = 'fixed'; + el.style.top = `${headerHeight + 8 + idx * stride}px`; + el.style.bottom = 'auto'; + el.style.left = '4px'; + el.style.width = 'calc(100% - 8px)'; + el.style.height = '110px'; + }); + }, + { headerHeight: SUBAGENT.DEFAULT_HEADER_HEIGHT, stride: SUBAGENT.MOBILE_CARD_STRIDE } + ); // Also try calling the real relayout function if available await triggerRelayout(page); @@ -216,7 +222,7 @@ describe('Mobile Subagent Windows', () => { // Verify stacking order: each window's top should increase const positions = await page.evaluate(() => { const windows = document.querySelectorAll('.subagent-window'); - return Array.from(windows).map(w => { + return Array.from(windows).map((w) => { const el = w as HTMLElement; const rect = el.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom }; @@ -254,25 +260,28 @@ describe('Mobile Subagent Windows', () => { await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); // Position windows from bottom (simulating keyboard-visible layout) - await page.evaluate(({ toolbarOffset, stride }) => { - const windows = document.querySelectorAll('.subagent-window'); - windows.forEach((win, idx) => { - const el = win as HTMLElement; - el.style.position = 'fixed'; - el.style.top = 'auto'; - el.style.bottom = `${toolbarOffset + idx * stride}px`; - el.style.left = '4px'; - el.style.width = 'calc(100% - 8px)'; - el.style.height = '110px'; - }); - }, { toolbarOffset: SUBAGENT.TOOLBAR_OFFSET, stride: SUBAGENT.MOBILE_CARD_STRIDE }); + await page.evaluate( + ({ toolbarOffset, stride }) => { + const windows = document.querySelectorAll('.subagent-window'); + windows.forEach((win, idx) => { + const el = win as HTMLElement; + el.style.position = 'fixed'; + el.style.top = 'auto'; + el.style.bottom = `${toolbarOffset + idx * stride}px`; + el.style.left = '4px'; + el.style.width = 'calc(100% - 8px)'; + el.style.height = '110px'; + }); + }, + { toolbarOffset: SUBAGENT.TOOLBAR_OFFSET, stride: SUBAGENT.MOBILE_CARD_STRIDE } + ); await triggerRelayout(page); // Verify bottom stacking: each window's bottom CSS value should increase const bottomValues = await page.evaluate(() => { const windows = document.querySelectorAll('.subagent-window'); - return Array.from(windows).map(w => { + return Array.from(windows).map((w) => { const el = w as HTMLElement; return parseFloat(el.style.bottom) || 0; }); @@ -298,22 +307,25 @@ describe('Mobile Subagent Windows', () => { await injectMockSubagentWindow(page, `stack-toggle-${i}`, i); } - await page.evaluate(({ headerHeight, stride }) => { - const windows = document.querySelectorAll('.subagent-window'); - windows.forEach((win, idx) => { - const el = win as HTMLElement; - el.style.position = 'fixed'; - el.style.top = `${headerHeight + 8 + idx * stride}px`; - el.style.bottom = 'auto'; - el.style.left = '4px'; - el.style.width = 'calc(100% - 8px)'; - el.style.height = '110px'; - }); - }, { headerHeight: SUBAGENT.DEFAULT_HEADER_HEIGHT, stride: SUBAGENT.MOBILE_CARD_STRIDE }); + await page.evaluate( + ({ headerHeight, stride }) => { + const windows = document.querySelectorAll('.subagent-window'); + windows.forEach((win, idx) => { + const el = win as HTMLElement; + el.style.position = 'fixed'; + el.style.top = `${headerHeight + 8 + idx * stride}px`; + el.style.bottom = 'auto'; + el.style.left = '4px'; + el.style.width = 'calc(100% - 8px)'; + el.style.height = '110px'; + }); + }, + { headerHeight: SUBAGENT.DEFAULT_HEADER_HEIGHT, stride: SUBAGENT.MOBILE_CARD_STRIDE } + ); // Record positions with keyboard hidden const posBeforeKeyboard = await page.evaluate(() => { - return Array.from(document.querySelectorAll('.subagent-window')).map(w => { + return Array.from(document.querySelectorAll('.subagent-window')).map((w) => { const el = w as HTMLElement; return { top: el.style.top, bottom: el.style.bottom }; }); @@ -323,19 +335,22 @@ describe('Mobile Subagent Windows', () => { await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); - await page.evaluate(({ toolbarOffset, stride }) => { - const windows = document.querySelectorAll('.subagent-window'); - windows.forEach((win, idx) => { - const el = win as HTMLElement; - el.style.top = 'auto'; - el.style.bottom = `${toolbarOffset + idx * stride}px`; - }); - }, { toolbarOffset: SUBAGENT.TOOLBAR_OFFSET, stride: SUBAGENT.MOBILE_CARD_STRIDE }); + await page.evaluate( + ({ toolbarOffset, stride }) => { + const windows = document.querySelectorAll('.subagent-window'); + windows.forEach((win, idx) => { + const el = win as HTMLElement; + el.style.top = 'auto'; + el.style.bottom = `${toolbarOffset + idx * stride}px`; + }); + }, + { toolbarOffset: SUBAGENT.TOOLBAR_OFFSET, stride: SUBAGENT.MOBILE_CARD_STRIDE } + ); await triggerRelayout(page); const posDuringKeyboard = await page.evaluate(() => { - return Array.from(document.querySelectorAll('.subagent-window')).map(w => { + return Array.from(document.querySelectorAll('.subagent-window')).map((w) => { const el = w as HTMLElement; return { top: el.style.top, bottom: el.style.bottom }; }); @@ -348,19 +363,22 @@ describe('Mobile Subagent Windows', () => { await hideKeyboard(page); await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); - await page.evaluate(({ headerHeight, stride }) => { - const windows = document.querySelectorAll('.subagent-window'); - windows.forEach((win, idx) => { - const el = win as HTMLElement; - el.style.top = `${headerHeight + 8 + idx * stride}px`; - el.style.bottom = 'auto'; - }); - }, { headerHeight: SUBAGENT.DEFAULT_HEADER_HEIGHT, stride: SUBAGENT.MOBILE_CARD_STRIDE }); + await page.evaluate( + ({ headerHeight, stride }) => { + const windows = document.querySelectorAll('.subagent-window'); + windows.forEach((win, idx) => { + const el = win as HTMLElement; + el.style.top = `${headerHeight + 8 + idx * stride}px`; + el.style.bottom = 'auto'; + }); + }, + { headerHeight: SUBAGENT.DEFAULT_HEADER_HEIGHT, stride: SUBAGENT.MOBILE_CARD_STRIDE } + ); await triggerRelayout(page); const posAfterHide = await page.evaluate(() => { - return Array.from(document.querySelectorAll('.subagent-window')).map(w => { + return Array.from(document.querySelectorAll('.subagent-window')).map((w) => { const el = w as HTMLElement; return { top: el.style.top, bottom: el.style.bottom }; }); @@ -572,7 +590,7 @@ describe('Mobile Subagent Windows', () => { // Verify each has a unique agent-id const ids = await page.evaluate(() => { return Array.from(document.querySelectorAll('.subagent-window')).map( - w => (w as HTMLElement).dataset.agentId ?? '', + (w) => (w as HTMLElement).dataset.agentId ?? '' ); }); const uniqueIds = new Set(ids); @@ -592,21 +610,24 @@ describe('Mobile Subagent Windows', () => { } // Position windows with proper stride - await page.evaluate(({ headerHeight, stride }) => { - const windows = document.querySelectorAll('.subagent-window'); - windows.forEach((win, idx) => { - const el = win as HTMLElement; - el.style.position = 'fixed'; - el.style.top = `${headerHeight + 8 + idx * stride}px`; - el.style.left = '4px'; - el.style.width = 'calc(100% - 8px)'; - el.style.height = '110px'; - }); - }, { headerHeight: SUBAGENT.DEFAULT_HEADER_HEIGHT, stride: SUBAGENT.MOBILE_CARD_STRIDE }); + await page.evaluate( + ({ headerHeight, stride }) => { + const windows = document.querySelectorAll('.subagent-window'); + windows.forEach((win, idx) => { + const el = win as HTMLElement; + el.style.position = 'fixed'; + el.style.top = `${headerHeight + 8 + idx * stride}px`; + el.style.left = '4px'; + el.style.width = 'calc(100% - 8px)'; + el.style.height = '110px'; + }); + }, + { headerHeight: SUBAGENT.DEFAULT_HEADER_HEIGHT, stride: SUBAGENT.MOBILE_CARD_STRIDE } + ); // Check that no two windows overlap vertically const rects = await page.evaluate(() => { - return Array.from(document.querySelectorAll('.subagent-window')).map(w => { + return Array.from(document.querySelectorAll('.subagent-window')).map((w) => { const rect = w.getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom }; }); diff --git a/test/mobile/visual-regression.test.ts b/test/mobile/visual-regression.test.ts index 19f50a384..52267a438 100644 --- a/test/mobile/visual-regression.test.ts +++ b/test/mobile/visual-regression.test.ts @@ -30,9 +30,7 @@ describe('Visual Regression', () => { // ─── Breakpoint Screenshots ─────────────────────────────────────────────── - describe.each( - KEY_BREAKPOINTS.map(w => [w] as [number]), - )('Width %ipx', (width) => { + describe.each(KEY_BREAKPOINTS.map((w) => [w] as [number]))('Width %ipx', (width) => { const height = 812; // Standard phone height for consistency it('landing page (no sessions)', async () => { @@ -44,7 +42,10 @@ describe('Visual Regression', () => { isMobile: width < 768, hasTouch: width < 768, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15', - expectedBreakpoint: (width < 430 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as 'phone' | 'tablet' | 'desktop', + expectedBreakpoint: (width < 430 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as + | 'phone' + | 'tablet' + | 'desktop', isIOS: true, defaultBrowserType: 'chromium' as const, }; @@ -71,7 +72,10 @@ describe('Visual Regression', () => { isMobile: width < 768, hasTouch: width < 768, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15', - expectedBreakpoint: (width < 430 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as 'phone' | 'tablet' | 'desktop', + expectedBreakpoint: (width < 430 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as + | 'phone' + | 'tablet' + | 'desktop', isIOS: true, defaultBrowserType: 'chromium' as const, }; @@ -104,7 +108,10 @@ describe('Visual Regression', () => { isMobile: width < 768, hasTouch: width < 768, userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15', - expectedBreakpoint: (width < 430 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as 'phone' | 'tablet' | 'desktop', + expectedBreakpoint: (width < 430 ? 'phone' : width < 768 ? 'tablet' : 'desktop') as + | 'phone' + | 'tablet' + | 'desktop', isIOS: true, defaultBrowserType: 'chromium' as const, }; @@ -116,7 +123,7 @@ describe('Visual Regression', () => { // Open settings modal - try mobile button first, then desktop const mobileBtn = page.locator(SELECTORS.SETTINGS_MOBILE); const isPhone = width < 430; - if (isPhone && await mobileBtn.isVisible()) { + if (isPhone && (await mobileBtn.isVisible())) { await mobileBtn.click(); } else { // Try clicking a desktop settings trigger if available diff --git a/test/nice-wrapper.test.ts b/test/nice-wrapper.test.ts index 51c025101..36802f30d 100644 --- a/test/nice-wrapper.test.ts +++ b/test/nice-wrapper.test.ts @@ -12,15 +12,13 @@ import type { NiceConfig } from '../src/types.js'; describe('wrapWithNice', () => { it('should return command unchanged when disabled', () => { const config: NiceConfig = { enabled: false, niceValue: 10 }; - expect(wrapWithNice('claude --dangerously-skip-permissions', config)).toBe( - 'claude --dangerously-skip-permissions', - ); + expect(wrapWithNice('claude --dangerously-skip-permissions', config)).toBe('claude --dangerously-skip-permissions'); }); it('should wrap command with nice when enabled', () => { const config: NiceConfig = { enabled: true, niceValue: 10 }; expect(wrapWithNice('claude --dangerously-skip-permissions', config)).toBe( - 'nice -n 10 claude --dangerously-skip-permissions', + 'nice -n 10 claude --dangerously-skip-permissions' ); }); diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts index af330777c..ab69bad0e 100644 --- a/test/opencode-resize.test.ts +++ b/test/opencode-resize.test.ts @@ -55,18 +55,19 @@ describe('OpenCode session initial resize', () => { await context?.close(); }); - it('selectSession is not bypassed when runOpenCode sets activeSessionId', async () => { - // This test verifies at the code level that runOpenCode does NOT - // pre-set activeSessionId before calling selectSession. + it('selectSession is not bypassed when runCli(opencode) sets activeSessionId', async () => { + // This test verifies at the code level that runCli() (the shared launch path + // for opencode/codex/gemini/antigravity/pi, formerly a per-mode runOpenCode() + // etc.) does NOT pre-set activeSessionId before calling selectSession. // If it did, selectSession would early-return and skip sendResize. ({ context, page } = await freshPage()); await navigateAndWait(page); - // Read the runOpenCode source from the live app and verify + // Read the runCli source from the live app and verify // it doesn't assign activeSessionId before selectSession const hasPreAssignment = await page.evaluate(() => { - const app = (window as unknown as { app: { runOpenCode: { toString: () => string } } }).app; - const source = app.runOpenCode.toString(); + const app = (window as unknown as { app: { runCli: { toString: () => string } } }).app; + const source = app.runCli.toString(); // Check: the source should NOT have activeSessionId = ... before selectSession // Find positions of both patterns @@ -114,7 +115,7 @@ describe('OpenCode session initial resize', () => { expect(sessionId).toBeTruthy(); - // Call selectSession (which is what runOpenCode does after fix) + // Call selectSession (which is what runCli does after fix) await page.evaluate(async (sid: string) => { const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app; await app.selectSession(sid); diff --git a/test/perf-subagent-load.test.ts b/test/perf-subagent-load.test.ts index 9cbb1bcd2..b604b727b 100644 --- a/test/perf-subagent-load.test.ts +++ b/test/perf-subagent-load.test.ts @@ -20,16 +20,20 @@ import { SubagentWatcher } from '../src/subagent-watcher.js'; // ========== Helpers ========== /** Generate a realistic JSONL transcript entry */ -function makeTranscriptEntry(type: 'user' | 'assistant', content: string, extras: Record = {}): string { +function makeTranscriptEntry( + type: 'user' | 'assistant', + content: string, + extras: Record = {} +): string { const base: Record = { type, timestamp: new Date().toISOString(), message: { role: type, - content: type === 'user' - ? [{ type: 'text', text: content }] - : content, - ...(type === 'assistant' ? { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 1500, output_tokens: 800 } } : {}), + content: type === 'user' ? [{ type: 'text', text: content }] : content, + ...(type === 'assistant' + ? { model: 'claude-sonnet-4-20250514', usage: { input_tokens: 1500, output_tokens: 800 } } + : {}), }, ...extras, }; @@ -48,11 +52,13 @@ function generateParentTranscript(lineCount: number, agentIds: string[] = []): s } // Sprinkle in toolUseResult entries for agent descriptions for (const agentId of agentIds) { - lines.push(JSON.stringify({ - type: 'user', - timestamp: new Date().toISOString(), - toolUseResult: { agentId, description: `Research task for ${agentId}` }, - })); + lines.push( + JSON.stringify({ + type: 'user', + timestamp: new Date().toISOString(), + toolUseResult: { agentId, description: `Research task for ${agentId}` }, + }) + ); } return lines.join('\n') + '\n'; } @@ -60,23 +66,29 @@ function generateParentTranscript(lineCount: number, agentIds: string[] = []): s /** Generate a subagent transcript file */ function generateAgentTranscript(lineCount: number): string { const lines: string[] = []; - lines.push(makeTranscriptEntry('user', 'Investigate the authentication module and suggest improvements for rate limiting')); + lines.push( + makeTranscriptEntry('user', 'Investigate the authentication module and suggest improvements for rate limiting') + ); for (let i = 1; i < lineCount; i++) { if (i % 3 === 0) { - lines.push(JSON.stringify({ - type: 'tool_call', - timestamp: new Date().toISOString(), - tool: 'Read', - input: { file_path: `/home/user/project/src/file-${i}.ts` }, - toolUseId: `tool-${i}`, - })); + lines.push( + JSON.stringify({ + type: 'tool_call', + timestamp: new Date().toISOString(), + tool: 'Read', + input: { file_path: `/home/user/project/src/file-${i}.ts` }, + toolUseId: `tool-${i}`, + }) + ); } else if (i % 3 === 1) { - lines.push(JSON.stringify({ - type: 'tool_result', - timestamp: new Date().toISOString(), - toolUseId: `tool-${i - 1}`, - content: 'x'.repeat(500), - })); + lines.push( + JSON.stringify({ + type: 'tool_result', + timestamp: new Date().toISOString(), + toolUseId: `tool-${i - 1}`, + content: 'x'.repeat(500), + }) + ); } else { lines.push(makeTranscriptEntry('assistant', `Analysis step ${i}: Found pattern in module...`)); } @@ -159,7 +171,9 @@ describe('SubagentWatcher performance', () => { found = true; break; } - } catch { /* skip */ } + } catch { + /* skip */ + } } const elapsed = performance.now() - start; @@ -196,7 +210,9 @@ describe('SubagentWatcher performance', () => { if (entry.type === 'user' && entry.toolUseResult?.agentId === 'target-agent') { return entry.toolUseResult.description; } - } catch { /* skip */ } + } catch { + /* skip */ + } } return undefined; }); @@ -211,7 +227,9 @@ describe('SubagentWatcher performance', () => { // Key assertion: max event loop lag should stay reasonable // JSON.parse of 10K lines is synchronous and blocks the event loop - console.log(`[10K transcript × 5 reads] max lag: ${lag.maxLagMs.toFixed(1)}ms, avg: ${lag.avgLagMs.toFixed(1)}ms`); + console.log( + `[10K transcript × 5 reads] max lag: ${lag.maxLagMs.toFixed(1)}ms, avg: ${lag.avgLagMs.toFixed(1)}ms` + ); // This WILL likely fail — proving the bottleneck // 50ms is the threshold where users notice UI jank @@ -242,20 +260,20 @@ describe('SubagentWatcher performance', () => { try { const entry = JSON.parse(line); if (entry.type === 'user' && entry.message?.content) { - const firstContent = Array.isArray(entry.message.content) - ? entry.message.content[0] - : undefined; + const firstContent = Array.isArray(entry.message.content) ? entry.message.content[0] : undefined; if (firstContent?.type === 'text') { description = firstContent.text.trim().slice(0, 45); } } - } catch { /* skip */ } + } catch { + /* skip */ + } } const readAllElapsed = performance.now() - start; // Approach 2: Read only first 8KB via partial read const start2 = performance.now(); - const fd = await import('node:fs/promises').then(m => m.open(agentFile, 'r')); + const fd = await import('node:fs/promises').then((m) => m.open(agentFile, 'r')); const buf = Buffer.alloc(8192); const { bytesRead } = await fd.read(buf, 0, 8192, 0); await fd.close(); @@ -266,18 +284,20 @@ describe('SubagentWatcher performance', () => { try { const entry = JSON.parse(line); if (entry.type === 'user' && entry.message?.content) { - const firstContent = Array.isArray(entry.message.content) - ? entry.message.content[0] - : undefined; + const firstContent = Array.isArray(entry.message.content) ? entry.message.content[0] : undefined; if (firstContent?.type === 'text') { description2 = firstContent.text.trim().slice(0, 45); } } - } catch { /* skip */ } + } catch { + /* skip */ + } } const partialElapsed = performance.now() - start2; - console.log(`[extractDescription] file: ${fileSizeKB.toFixed(0)}KB, readAll: ${readAllElapsed.toFixed(1)}ms, partial-8KB: ${partialElapsed.toFixed(1)}ms`); + console.log( + `[extractDescription] file: ${fileSizeKB.toFixed(0)}KB, readAll: ${readAllElapsed.toFixed(1)}ms, partial-8KB: ${partialElapsed.toFixed(1)}ms` + ); console.log(`[extractDescription] readAll bytes: ${content.length}, partial bytes: ${bytesRead}`); // Both approaches must find the same description @@ -338,7 +358,9 @@ describe('SubagentWatcher performance', () => { const start = performance.now(); try { await readFile('/proc/self/environ', 'utf8'); - } catch { /* may fail in containers */ } + } catch { + /* may fail in containers */ + } times.push(performance.now() - start); } @@ -404,7 +426,9 @@ describe('SubagentWatcher performance', () => { const elapsed = performance.now() - start; const totalMB = messages.reduce((sum, m) => sum + m.length, 0) / (1024 * 1024); - console.log(`[terminal flush × 20 sessions] serialization: ${elapsed.toFixed(1)}ms, payload: ${totalMB.toFixed(2)}MB`); + console.log( + `[terminal flush × 20 sessions] serialization: ${elapsed.toFixed(1)}ms, payload: ${totalMB.toFixed(2)}MB` + ); // 20 × 16KB = 320KB serialized simultaneously — should still be < 20ms expect(elapsed).toBeLessThan(50); @@ -426,7 +450,8 @@ describe('SubagentWatcher performance', () => { mkdirSync(subagentDir, { recursive: true }); // Large parent transcript (5000 lines) - const parentTranscript = generateParentTranscript(5000, + const parentTranscript = generateParentTranscript( + 5000, Array.from({ length: 10 }, (_, i) => `storm-agent-${i}`) ); writeFileSync(join(tmpDir, 'storm-project', 'hash123', 'session-main.jsonl'), parentTranscript); @@ -455,7 +480,9 @@ describe('SubagentWatcher performance', () => { if (entry.type === 'user' && entry.toolUseResult?.agentId === `storm-agent-${i}`) { return entry.toolUseResult.description; } - } catch { /* skip */ } + } catch { + /* skip */ + } } return undefined; }); @@ -482,9 +509,11 @@ describe('SubagentWatcher performance', () => { console.log(`[subagent storm] avg event loop lag: ${lag.avgLagMs.toFixed(1)}ms`); // Count how many lag samples exceeded 50ms (UI jank threshold) - const jankSamples = lag.samples.filter(s => s > 50).length; + const jankSamples = lag.samples.filter((s) => s > 50).length; const totalSamples = lag.samples.length; - console.log(`[subagent storm] jank samples (>50ms): ${jankSamples}/${totalSamples} (${((jankSamples / totalSamples) * 100).toFixed(1)}%)`); + console.log( + `[subagent storm] jank samples (>50ms): ${jankSamples}/${totalSamples} (${((jankSamples / totalSamples) * 100).toFixed(1)}%)` + ); // Verify correctness for (const d of descriptions) { @@ -515,8 +544,16 @@ describe('SubagentWatcher performance', () => { }); }); for (const pidStr of pids.slice(0, 10)) { - try { await readFile(`/proc/${pidStr}/environ`, 'utf8'); } catch { /* */ } - try { await readFile(`/proc/${pidStr}/cmdline`, 'utf8'); } catch { /* */ } + try { + await readFile(`/proc/${pidStr}/environ`, 'utf8'); + } catch { + /* */ + } + try { + await readFile(`/proc/${pidStr}/cmdline`, 'utf8'); + } catch { + /* */ + } } const elapsed = performance.now() - start; const lag = await lagPromise; @@ -545,7 +582,9 @@ describe('SubagentWatcher performance', () => { try { const s = await statFn(f); if (Date.now() - s.mtime.getTime() < 30000) aliveCount++; - } catch { /* */ } + } catch { + /* */ + } } const elapsed = performance.now() - start; const lag = await lagPromise; @@ -571,7 +610,9 @@ describe('SubagentWatcher performance', () => { try { await statFn(`/proc/${ourPid}`); aliveCount++; - } catch { /* */ } + } catch { + /* */ + } } const elapsed = performance.now() - start; const lag = await lagPromise; @@ -603,7 +644,11 @@ describe('SubagentWatcher performance', () => { await statFn(f); // tier 1 } for (let i = 0; i < 20; i++) { - try { await statFn(`/proc/${process.pid}`); } catch { /* */ } // tier 2 + try { + await statFn(`/proc/${process.pid}`); + } catch { + /* */ + } // tier 2 } const fastElapsed = performance.now() - startFast; @@ -616,13 +661,23 @@ describe('SubagentWatcher performance', () => { }); }); for (const pidStr of pids.slice(0, 10)) { - try { await readFile(`/proc/${pidStr}/environ`, 'utf8'); } catch { /* */ } - try { await readFile(`/proc/${pidStr}/cmdline`, 'utf8'); } catch { /* */ } + try { + await readFile(`/proc/${pidStr}/environ`, 'utf8'); + } catch { + /* */ + } + try { + await readFile(`/proc/${pidStr}/cmdline`, 'utf8'); + } catch { + /* */ + } } const slowElapsed = performance.now() - startSlow; const speedup = slowElapsed / Math.max(fastElapsed, 0.01); - console.log(`[comparison] tier-1+2: ${fastElapsed.toFixed(1)}ms, old pgrep: ${slowElapsed.toFixed(1)}ms, speedup: ${speedup.toFixed(0)}x`); + console.log( + `[comparison] tier-1+2: ${fastElapsed.toFixed(1)}ms, old pgrep: ${slowElapsed.toFixed(1)}ms, speedup: ${speedup.toFixed(0)}x` + ); // Tiered approach should be significantly faster expect(fastElapsed).toBeLessThan(slowElapsed); @@ -648,7 +703,9 @@ describe('SubagentWatcher performance', () => { } const elapsed = performance.now() - start; - console.log(`[JSON.parse × ${parsed} lines] ${elapsed.toFixed(2)}ms (${(elapsed / parsed * 1000).toFixed(1)}µs/line)`); + console.log( + `[JSON.parse × ${parsed} lines] ${elapsed.toFixed(2)}ms (${((elapsed / parsed) * 1000).toFixed(1)}µs/line)` + ); // 1000 lines should be fast, but 10 agents × 1000 lines = 10000 parses // all happening synchronously on the event loop during initial tail diff --git a/test/pi-cli-resolver.test.ts b/test/pi-cli-resolver.test.ts deleted file mode 100644 index de5946fdc..000000000 --- a/test/pi-cli-resolver.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @fileoverview Tests for the Pi CLI resolver wrapper. - * - * Pi is the resolver with a version probe: `pi` is a short, generic binary - * name, so a resolved path is only accepted once `pi --version` prints a - * semver-shaped string. The probe EXECUTES the candidate, which is exactly why - * it must never run under vitest — the hermeticity test below pins that gate - * with a real executable fixture that would make the test fail loudly if the - * gate were deleted again (as PR #329 once did). - */ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createPiResolverForTest } from '../src/utils/pi-cli-resolver.js'; -import { - cliResolveRetryDelayMs, - createProductionCliResolverHost, - type CliResolverHost, -} from '../src/utils/cli-executable-resolver.js'; - -const temporaryDirectories: string[] = []; - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } -}); - -function createHost( - options: { - processPathResult?: string | null; - loginShellResults?: Array; - existingPaths?: string[]; - } = {} -): CliResolverHost { - const loginShellResults = [...(options.loginShellResults ?? [])]; - const existingPaths = new Set(options.existingPaths ?? []); - return { - processPath: '/service/bin', - shellPath: '/bin/zsh', - shellArgs: ['-l'], - findOnProcessPath: () => options.processPathResult ?? null, - findInLoginShell: () => loginShellResults.shift() ?? null, - exists: (path) => existingPaths.has(path), - }; -} - -describe('Pi CLI resolver', () => { - it('accepts a candidate the version probe verifies and carries the version as metadata', () => { - const binaryPath = '/service/bin/pi'; - const probe = vi.fn(() => '0.84.1'); - const resolver = createPiResolverForTest( - createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }), - probe - ); - - expect(resolver.resolve()).toMatchObject({ - binaryPath, - directory: '/service/bin', - source: 'process-path', - metadata: '0.84.1', - }); - expect(probe).toHaveBeenCalledWith(binaryPath); - }); - - it('rejects a candidate the probe refuses and falls through to a later one', () => { - // An unrelated `pi` on the service PATH (probe returns null) must not mask - // the real coding agent found by the login shell. - const impostor = '/service/bin/pi'; - const genuine = '/login-shell/bin/pi'; - const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.84.1' : null)); - const resolver = createPiResolverForTest( - createHost({ - processPathResult: impostor, - loginShellResults: [genuine], - existingPaths: [impostor, genuine], - }), - probe - ); - - expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '0.84.1' }); - }); - - it('negative-caches a miss and retries only after the backoff elapses', () => { - const binaryPath = '/late/bin/pi'; - let now = 0; - const probe = vi.fn(() => '0.84.1'); - const resolver = createPiResolverForTest( - createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }), - probe, - () => now - ); - - expect(resolver.resolve()).toBeNull(); - expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run - expect(probe).not.toHaveBeenCalled(); - now = cliResolveRetryDelayMs(1); - expect(resolver.resolve()?.metadata).toBe('0.84.1'); - expect(resolver.resolve()?.binaryPath).toBe(binaryPath); - }); - - it('never executes a pi candidate under vitest (the ambient probe is VITEST-gated)', () => { - // A REAL executable fixture that prints a valid version. If the guard in - // probePiVersion is ever removed again, the probe runs this script, the - // resolution SUCCEEDS, and this test fails — pinning hermeticity by - // behavior rather than by source text. (The suites must never execute - // whatever `pi` binary the machine running them happens to carry.) - const root = mkdtempSync(join(tmpdir(), 'codeman-pi-vitest-gate-')); - temporaryDirectories.push(root); - const binaryPath = join(root, 'pi'); - writeFileSync(binaryPath, '#!/bin/sh\necho 0.99.0\n'); - chmodSync(binaryPath, 0o755); - const hostOptions = { - processPath: root, - shellPath: '/bin/bash', - shellArgs: ['-i', '-l'] as string[], - runCommand: () => '', - isExecutableFile: (path: string) => path === binaryPath, - }; - - // Default (ambient) probe: the candidate is found but never executed, so - // the VITEST gate reports it unusable and resolution misses. - const gated = createPiResolverForTest(createProductionCliResolverHost(hostOptions)); - expect(gated.resolve()).toBeNull(); - - // Control: identical setup with an injected probe resolves, proving the - // null above comes from the gate, not from the fixture or the host. - const control = createPiResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0'); - expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' }); - }); -}); diff --git a/test/ralph-config.test.ts b/test/ralph-config.test.ts index 442308168..c3cc1c693 100644 --- a/test/ralph-config.test.ts +++ b/test/ralph-config.test.ts @@ -6,10 +6,7 @@ */ import { describe, it, expect } from 'vitest'; -import { - parseRalphLoopConfigFromContent, - extractCompletionPhraseFromContent, -} from '../src/ralph-config.js'; +import { parseRalphLoopConfigFromContent, extractCompletionPhraseFromContent } from '../src/ralph-config.js'; describe('parseRalphLoopConfigFromContent', () => { describe('valid YAML frontmatter', () => { diff --git a/test/ralph-loop-deep.test.ts b/test/ralph-loop-deep.test.ts index 8e94407de..582710609 100644 --- a/test/ralph-loop-deep.test.ts +++ b/test/ralph-loop-deep.test.ts @@ -34,9 +34,7 @@ describe('Ralph Tracker Deep Logic', () => { // Simulate: Claude shows the prompt containing the completion phrase // (first occurrence = from the prompt, NOT actual completion) - tracker.processTerminalData( - 'When done, output exactly: ALL_TASKS_COMPLETE\n' - ); + tracker.processTerminalData('When done, output exactly: ALL_TASKS_COMPLETE\n'); tracker.flushPendingEvents(); // BUG: This SHOULD NOT fire, but currently does because of @@ -237,13 +235,13 @@ describe('Ralph Tracker Deep Logic', () => { tracker.processTerminalData('- [ ] Update documentation\n'); tracker.flushPendingEvents(); - expect(tracker.todos.filter(t => t.status === 'pending')).toHaveLength(3); + expect(tracker.todos.filter((t) => t.status === 'pending')).toHaveLength(3); // Trigger all-complete detection tracker.processTerminalData('All tasks completed successfully\n'); tracker.flushPendingEvents(); - expect(tracker.todos.filter(t => t.status === 'completed')).toHaveLength(3); + expect(tracker.todos.filter((t) => t.status === 'completed')).toHaveLength(3); }); it('should NOT trigger on long commentary lines', () => { @@ -251,7 +249,9 @@ describe('Ralph Tracker Deep Logic', () => { tracker.flushPendingEvents(); // Long line (>100 chars) should not trigger - const longLine = 'Once all tasks are complete, we should run the integration tests to make sure everything works properly and nothing is broken in the deployment pipeline' + '\n'; + const longLine = + 'Once all tasks are complete, we should run the integration tests to make sure everything works properly and nothing is broken in the deployment pipeline' + + '\n'; tracker.processTerminalData(longLine); tracker.flushPendingEvents(); @@ -278,7 +278,7 @@ describe('Ralph Tracker Deep Logic', () => { tracker.processTerminalData('All 15 files have been created\n'); tracker.flushPendingEvents(); - expect(tracker.todos.filter(t => t.status === 'pending')).toHaveLength(2); + expect(tracker.todos.filter((t) => t.status === 'pending')).toHaveLength(2); }); it('should emit completionDetected if completion phrase is set', () => { @@ -314,8 +314,12 @@ describe('Ralph Tracker Deep Logic', () => { tracker.enable(); // 2 status blocks with no progress - tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); - tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); + tracker.processTerminalData( + '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n' + ); + tracker.processTerminalData( + '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n' + ); tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('HALF_OPEN'); @@ -326,7 +330,9 @@ describe('Ralph Tracker Deep Logic', () => { // 3 no-progress iterations for (let i = 0; i < 3; i++) { - tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); + tracker.processTerminalData( + '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n' + ); } tracker.flushPendingEvents(); @@ -338,12 +344,16 @@ describe('Ralph Tracker Deep Logic', () => { // Get to HALF_OPEN for (let i = 0; i < 2; i++) { - tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); + tracker.processTerminalData( + '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n' + ); } expect(tracker.circuitBreakerStatus.state).toBe('HALF_OPEN'); // Progress detected - tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 2\nFILES_MODIFIED: 1\n---END_RALPH_STATUS---\n'); + tracker.processTerminalData( + '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 2\nFILES_MODIFIED: 1\n---END_RALPH_STATUS---\n' + ); tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('CLOSED'); @@ -352,7 +362,9 @@ describe('Ralph Tracker Deep Logic', () => { it('should open on BLOCKED status', () => { tracker.enable(); - tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: BLOCKED\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); + tracker.processTerminalData( + '---RALPH_STATUS---\nSTATUS: BLOCKED\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n' + ); tracker.flushPendingEvents(); expect(tracker.circuitBreakerStatus.state).toBe('OPEN'); @@ -362,7 +374,9 @@ describe('Ralph Tracker Deep Logic', () => { tracker.enable(); for (let i = 0; i < 5; i++) { - tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 1\nFILES_MODIFIED: 1\nTESTS_STATUS: FAILING\n---END_RALPH_STATUS---\n'); + tracker.processTerminalData( + '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 1\nFILES_MODIFIED: 1\nTESTS_STATUS: FAILING\n---END_RALPH_STATUS---\n' + ); } tracker.flushPendingEvents(); @@ -375,7 +389,9 @@ describe('Ralph Tracker Deep Logic', () => { // Get to OPEN state for (let i = 0; i < 3; i++) { - tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n'); + tracker.processTerminalData( + '---RALPH_STATUS---\nSTATUS: IN_PROGRESS\nTASKS_COMPLETED_THIS_LOOP: 0\nFILES_MODIFIED: 0\n---END_RALPH_STATUS---\n' + ); } expect(tracker.circuitBreakerStatus.state).toBe('OPEN'); @@ -398,14 +414,14 @@ describe('Ralph Tracker Deep Logic', () => { tracker.processTerminalData( '---RALPH_STATUS---\n' + - 'STATUS: IN_PROGRESS\n' + - 'TASKS_COMPLETED_THIS_LOOP: 3\n' + - 'FILES_MODIFIED: 7\n' + - 'TESTS_STATUS: PASSING\n' + - 'WORK_TYPE: IMPLEMENTATION\n' + - 'EXIT_SIGNAL: false\n' + - 'RECOMMENDATION: Continue with remaining tasks\n' + - '---END_RALPH_STATUS---\n' + 'STATUS: IN_PROGRESS\n' + + 'TASKS_COMPLETED_THIS_LOOP: 3\n' + + 'FILES_MODIFIED: 7\n' + + 'TESTS_STATUS: PASSING\n' + + 'WORK_TYPE: IMPLEMENTATION\n' + + 'EXIT_SIGNAL: false\n' + + 'RECOMMENDATION: Continue with remaining tasks\n' + + '---END_RALPH_STATUS---\n' ); tracker.flushPendingEvents(); @@ -425,11 +441,7 @@ describe('Ralph Tracker Deep Logic', () => { tracker.on('statusBlockDetected', statusHandler); tracker.enable(); - tracker.processTerminalData( - '---RALPH_STATUS---\n' + - 'STATUS: COMPLETE\n' + - '---END_RALPH_STATUS---\n' - ); + tracker.processTerminalData('---RALPH_STATUS---\n' + 'STATUS: COMPLETE\n' + '---END_RALPH_STATUS---\n'); expect(statusHandler).toHaveBeenCalled(); const block = statusHandler.mock.calls[0][0]; @@ -447,9 +459,7 @@ describe('Ralph Tracker Deep Logic', () => { tracker.enable(); tracker.processTerminalData( - '---RALPH_STATUS---\n' + - 'TASKS_COMPLETED_THIS_LOOP: 5\n' + - '---END_RALPH_STATUS---\n' + '---RALPH_STATUS---\n' + 'TASKS_COMPLETED_THIS_LOOP: 5\n' + '---END_RALPH_STATUS---\n' ); expect(statusHandler).not.toHaveBeenCalled(); @@ -501,9 +511,7 @@ describe('Ralph Tracker Deep Logic', () => { tracker.on('exitGateMet', exitHandler); tracker.enable(); - tracker.processTerminalData( - '---RALPH_STATUS---\nSTATUS: COMPLETE\nEXIT_SIGNAL: true\n---END_RALPH_STATUS---\n' - ); + tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: COMPLETE\nEXIT_SIGNAL: true\n---END_RALPH_STATUS---\n'); tracker.flushPendingEvents(); // Only 1 completion indicator, need >= 2 @@ -522,9 +530,7 @@ describe('Ralph Tracker Deep Logic', () => { const exitHandler = vi.fn(); tracker.on('exitGateMet', exitHandler); - tracker.processTerminalData( - '---RALPH_STATUS---\nSTATUS: COMPLETE\nEXIT_SIGNAL: true\n---END_RALPH_STATUS---\n' - ); + tracker.processTerminalData('---RALPH_STATUS---\nSTATUS: COMPLETE\nEXIT_SIGNAL: true\n---END_RALPH_STATUS---\n'); tracker.flushPendingEvents(); // 2 NL indicators + 1 COMPLETE status = 3 indicators, plus EXIT_SIGNAL @@ -558,10 +564,10 @@ describe('Ralph Tracker Deep Logic', () => { tracker.processTerminalData('✔ Task #1 updated: status → completed\n'); tracker.flushPendingEvents(); - const caching = tracker.todos.find(t => t.content.includes('caching layer')); + const caching = tracker.todos.find((t) => t.content.includes('caching layer')); expect(caching?.status).toBe('completed'); - const tests = tracker.todos.find(t => t.content.includes('tests')); + const tests = tracker.todos.find((t) => t.content.includes('tests')); expect(tests?.status).toBe('pending'); }); @@ -609,7 +615,7 @@ describe('Ralph Tracker Deep Logic', () => { tracker.flushPendingEvents(); expect(tracker.todos).toHaveLength(3); - expect(tracker.todos.every(t => t.status === 'completed')).toBe(true); + expect(tracker.todos.every((t) => t.status === 'completed')).toBe(true); }); it('should skip short content in plain checkmark', () => { @@ -708,7 +714,7 @@ describe('Ralph Tracker Deep Logic', () => { tracker.flushPendingEvents(); // The longest version should be kept - const matchingTodos = tracker.todos.filter(t => t.content.includes('Fix auth')); + const matchingTodos = tracker.todos.filter((t) => t.content.includes('Fix auth')); // Due to fuzzy dedup, similar items might get merged expect(matchingTodos.length).toBeGreaterThanOrEqual(1); }); @@ -750,7 +756,7 @@ describe('Ralph Tracker Deep Logic', () => { expect(tracker2.todos).toHaveLength(3); // Verify statuses are preserved - const completed = tracker2.todos.filter(t => t.status === 'completed'); + const completed = tracker2.todos.filter((t) => t.status === 'completed'); expect(completed).toHaveLength(1); expect(completed[0].content).toContain('CI pipeline'); @@ -778,14 +784,14 @@ describe('Ralph Tracker Deep Logic', () => { expect(count).toBe(5); - const p0 = tracker.todos.filter(t => t.priority === 'P0'); + const p0 = tracker.todos.filter((t) => t.priority === 'P0'); expect(p0).toHaveLength(2); - const p1 = tracker.todos.filter(t => t.priority === 'P1'); + const p1 = tracker.todos.filter((t) => t.priority === 'P1'); expect(p1).toHaveLength(1); expect(p1[0].status).toBe('in_progress'); - const completed = tracker.todos.filter(t => t.status === 'completed'); + const completed = tracker.todos.filter((t) => t.status === 'completed'); expect(completed).toHaveLength(1); }); }); @@ -859,10 +865,7 @@ describe('Ralph Tracker Deep Logic', () => { it('should give high confidence with promise tag + matching phrase + active loop', () => { tracker.startLoop('TARGET_PHRASE'); - const confidence = tracker.calculateCompletionConfidence( - 'TARGET_PHRASE', - 'TARGET_PHRASE' - ); + const confidence = tracker.calculateCompletionConfidence('TARGET_PHRASE', 'TARGET_PHRASE'); // hasPromiseTag(30) + matchesExpected(25) + contextAppropriate(10) + loopActive(10) = 75+ expect(confidence.score).toBeGreaterThanOrEqual(65); @@ -1019,12 +1022,12 @@ describe('Ralph Tracker Deep Logic', () => { // Step 4: Status block tracker.processTerminalData( '---RALPH_STATUS---\n' + - 'STATUS: IN_PROGRESS\n' + - 'TASKS_COMPLETED_THIS_LOOP: 1\n' + - 'FILES_MODIFIED: 3\n' + - 'TESTS_STATUS: PASSING\n' + - 'EXIT_SIGNAL: false\n' + - '---END_RALPH_STATUS---\n' + 'STATUS: IN_PROGRESS\n' + + 'TASKS_COMPLETED_THIS_LOOP: 1\n' + + 'FILES_MODIFIED: 3\n' + + 'TESTS_STATUS: PASSING\n' + + 'EXIT_SIGNAL: false\n' + + '---END_RALPH_STATUS---\n' ); tracker.flushPendingEvents(); @@ -1088,9 +1091,7 @@ describe('Ralph Tracker Deep Logic', () => { tracker.setWorkingDir(tmpDir); // Manually import todos (simulating fix_plan.md loaded) - tracker.importFixPlanMarkdown( - '# Fix Plan\n\n## Tasks\n- [ ] Pending task from file\n' - ); + tracker.importFixPlanMarkdown('# Fix Plan\n\n## Tasks\n- [ ] Pending task from file\n'); expect(tracker.todos).toHaveLength(1); expect(tracker.todos[0].status).toBe('pending'); diff --git a/test/ralph-tracker.test.ts b/test/ralph-tracker.test.ts index 344487865..314a3cf1d 100644 --- a/test/ralph-tracker.test.ts +++ b/test/ralph-tracker.test.ts @@ -237,7 +237,7 @@ describe('RalphTracker', () => { tracker.on('todoUpdate', todoHandler); tracker.processTerminalData('- [ ] First task\n'); - tracker.flushPendingEvents(); // Flush debounced events + tracker.flushPendingEvents(); // Flush debounced events expect(todoHandler).toHaveBeenCalled(); const todos = tracker.todos; @@ -384,8 +384,8 @@ describe('RalphTracker', () => { const todos = tracker.todos; expect(todos).toHaveLength(3); - expect(todos.filter(t => t.status === 'pending')).toHaveLength(2); - expect(todos.filter(t => t.status === 'completed')).toHaveLength(1); + expect(todos.filter((t) => t.status === 'pending')).toHaveLength(2); + expect(todos.filter((t) => t.status === 'completed')).toHaveLength(1); }); it('should not auto-enable on native todo pattern by default', () => { @@ -421,9 +421,9 @@ describe('RalphTracker', () => { tracker.on('todoUpdate', todoHandler); tracker.processTerminalData('- [ ] Task 1\n'); - tracker.flushPendingEvents(); // Flush debounced events + tracker.flushPendingEvents(); // Flush debounced events tracker.processTerminalData('- [ ] Task 2\n'); - tracker.flushPendingEvents(); // Flush debounced events + tracker.flushPendingEvents(); // Flush debounced events expect(todoHandler).toHaveBeenCalledTimes(2); }); @@ -747,7 +747,7 @@ Final text tracker.on('completionDetected', completionHandler); tracker.startLoop('COMPLETE'); - tracker.processTerminalData('COMPLE\n'); // Partial + tracker.processTerminalData('COMPLE\n'); // Partial expect(completionHandler).not.toHaveBeenCalled(); }); @@ -916,7 +916,7 @@ Final text tracker.processTerminalData('[x] not at start\n'); // Should not create todos from these false positives - const actualTodos = tracker.todos.filter(t => t.content.length > 0); + const actualTodos = tracker.todos.filter((t) => t.content.length > 0); expect(actualTodos.length).toBeLessThanOrEqual(1); }); @@ -1180,7 +1180,7 @@ Final text tracker.processTerminalData('✔ Task #2 updated: status → in progress\n'); tracker.flushPendingEvents(); - const task = tracker.todos.find(t => t.content === 'Implement feature'); + const task = tracker.todos.find((t) => t.content === 'Implement feature'); expect(task?.status).toBe('in_progress'); }); @@ -1189,7 +1189,7 @@ Final text tracker.processTerminalData('✔ Task #3 updated: status → pending\n'); tracker.flushPendingEvents(); - const task = tracker.todos.find(t => t.content === 'Review code'); + const task = tracker.todos.find((t) => t.content === 'Review code'); expect(task?.status).toBe('pending'); }); @@ -1300,11 +1300,7 @@ Final text * Lines are joined with newlines and wrapped with start/end markers. */ function feedStatusBlock(tracker: RalphTracker, fields: string[]): void { - const block = [ - '---RALPH_STATUS---', - ...fields, - '---END_RALPH_STATUS---', - ].join('\n') + '\n'; + const block = ['---RALPH_STATUS---', ...fields, '---END_RALPH_STATUS---'].join('\n') + '\n'; tracker.processTerminalData(block); } @@ -1339,9 +1335,7 @@ Final text tracker.on('statusBlockDetected', handler); // Only provide the required STATUS field - feedStatusBlock(tracker, [ - 'STATUS: COMPLETE', - ]); + feedStatusBlock(tracker, ['STATUS: COMPLETE']); expect(handler).toHaveBeenCalledTimes(1); const block: RalphStatusBlock = handler.mock.calls[0][0]; @@ -1360,10 +1354,7 @@ Final text // No ---END_RALPH_STATUS--- marker tracker.processTerminalData( - '---RALPH_STATUS---\n' + - 'STATUS: IN_PROGRESS\n' + - 'TASKS_COMPLETED_THIS_LOOP: 5\n' + - 'Some other text\n' + '---RALPH_STATUS---\n' + 'STATUS: IN_PROGRESS\n' + 'TASKS_COMPLETED_THIS_LOOP: 5\n' + 'Some other text\n' ); expect(handler).not.toHaveBeenCalled(); @@ -1374,10 +1365,7 @@ Final text tracker.on('statusBlockDetected', handler); // Block with no STATUS field - feedStatusBlock(tracker, [ - 'TASKS_COMPLETED_THIS_LOOP: 5', - 'FILES_MODIFIED: 2', - ]); + feedStatusBlock(tracker, ['TASKS_COMPLETED_THIS_LOOP: 5', 'FILES_MODIFIED: 2']); expect(handler).not.toHaveBeenCalled(); }); @@ -1386,16 +1374,9 @@ Final text const handler = vi.fn(); tracker.on('statusBlockDetected', handler); - feedStatusBlock(tracker, [ - 'STATUS: IN_PROGRESS', - 'FILES_MODIFIED: 1', - ]); + feedStatusBlock(tracker, ['STATUS: IN_PROGRESS', 'FILES_MODIFIED: 1']); - feedStatusBlock(tracker, [ - 'STATUS: COMPLETE', - 'FILES_MODIFIED: 10', - 'EXIT_SIGNAL: true', - ]); + feedStatusBlock(tracker, ['STATUS: COMPLETE', 'FILES_MODIFIED: 10', 'EXIT_SIGNAL: true']); expect(handler).toHaveBeenCalledTimes(2); @@ -1427,17 +1408,9 @@ Final text }); it('should update cumulative stats across multiple blocks', () => { - feedStatusBlock(tracker, [ - 'STATUS: IN_PROGRESS', - 'FILES_MODIFIED: 3', - 'TASKS_COMPLETED_THIS_LOOP: 2', - ]); + feedStatusBlock(tracker, ['STATUS: IN_PROGRESS', 'FILES_MODIFIED: 3', 'TASKS_COMPLETED_THIS_LOOP: 2']); - feedStatusBlock(tracker, [ - 'STATUS: IN_PROGRESS', - 'FILES_MODIFIED: 5', - 'TASKS_COMPLETED_THIS_LOOP: 1', - ]); + feedStatusBlock(tracker, ['STATUS: IN_PROGRESS', 'FILES_MODIFIED: 5', 'TASKS_COMPLETED_THIS_LOOP: 1']); const stats = tracker.cumulativeStats; expect(stats.filesModified).toBe(8); @@ -1448,10 +1421,7 @@ Final text const handler = vi.fn(); tracker.on('statusBlockDetected', handler); - feedStatusBlock(tracker, [ - 'STATUS: BLOCKED', - 'RECOMMENDATION: Need human review of failing tests', - ]); + feedStatusBlock(tracker, ['STATUS: BLOCKED', 'RECOMMENDATION: Need human review of failing tests']); expect(handler).toHaveBeenCalledTimes(1); const block: RalphStatusBlock = handler.mock.calls[0][0]; @@ -1464,12 +1434,15 @@ Final text /** * Helper to feed a status block with specific progress/test values. */ - function feedStatusBlock(tracker: RalphTracker, opts: { - filesModified?: number; - tasksCompleted?: number; - testsStatus?: string; - status?: string; - }): void { + function feedStatusBlock( + tracker: RalphTracker, + opts: { + filesModified?: number; + tasksCompleted?: number; + testsStatus?: string; + status?: string; + } + ): void { const fields = [ `STATUS: ${opts.status ?? 'IN_PROGRESS'}`, `FILES_MODIFIED: ${opts.filesModified ?? 0}`, @@ -1478,11 +1451,7 @@ Final text if (opts.testsStatus) { fields.push(`TESTS_STATUS: ${opts.testsStatus}`); } - const block = [ - '---RALPH_STATUS---', - ...fields, - '---END_RALPH_STATUS---', - ].join('\n') + '\n'; + const block = ['---RALPH_STATUS---', ...fields, '---END_RALPH_STATUS---'].join('\n') + '\n'; tracker.processTerminalData(block); } @@ -1611,21 +1580,20 @@ Final text /** * Helper to feed a RALPH_STATUS block. */ - function feedStatusBlock(tracker: RalphTracker, opts: { - status?: string; - exitSignal?: boolean; - filesModified?: number; - }): void { + function feedStatusBlock( + tracker: RalphTracker, + opts: { + status?: string; + exitSignal?: boolean; + filesModified?: number; + } + ): void { const fields = [ `STATUS: ${opts.status ?? 'IN_PROGRESS'}`, `EXIT_SIGNAL: ${opts.exitSignal ?? false}`, `FILES_MODIFIED: ${opts.filesModified ?? 0}`, ]; - const block = [ - '---RALPH_STATUS---', - ...fields, - '---END_RALPH_STATUS---', - ].join('\n') + '\n'; + const block = ['---RALPH_STATUS---', ...fields, '---END_RALPH_STATUS---'].join('\n') + '\n'; tracker.processTerminalData(block); } @@ -1781,9 +1749,9 @@ Final text const todos = tracker.todos; expect(todos).toHaveLength(3); - expect(todos.find(t => t.content.includes('Server'))?.priority).toBe('P0'); - expect(todos.find(t => t.content.includes('Review'))?.priority).toBe('P1'); - expect(todos.find(t => t.content.includes('logging'))?.priority).toBe('P2'); + expect(todos.find((t) => t.content.includes('Server'))?.priority).toBe('P0'); + expect(todos.find((t) => t.content.includes('Review'))?.priority).toBe('P1'); + expect(todos.find((t) => t.content.includes('logging'))?.priority).toBe('P2'); }); it('should be case-insensitive for priority keywords', () => { @@ -1822,9 +1790,9 @@ Final text // Should fire after debounce delay vi.advanceTimersByTime(EVENT_DEBOUNCE_MS); expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ content: expect.stringContaining('Fix the bug') }), - ])); + expect(handler).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ content: expect.stringContaining('Fix the bug') })]) + ); }); it('should debounce loopUpdate events (not fire immediately)', () => { diff --git a/test/respawn-team-awareness.test.ts b/test/respawn-team-awareness.test.ts index 52451afe0..feeec7287 100644 --- a/test/respawn-team-awareness.test.ts +++ b/test/respawn-team-awareness.test.ts @@ -38,8 +38,12 @@ class MockTeamWatcher extends TeamWatcher { } // Prevent actual filesystem polling - override start(): void { /* noop */ } - override stop(): void { /* noop */ } + override start(): void { + /* noop */ + } + override stop(): void { + /* noop */ + } } // ========== Tests ========== diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 3873f2f58..84060acbf 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -92,25 +92,29 @@ vi.mock('../../src/utils/pi-cli-resolver.js', () => ({ getPiCliVersion: vi.fn(() => null), })); -vi.mock('../../src/utils/grok-cli-resolver.js', () => ({ - isGrokAvailable: vi.fn(() => false), - resolveGrokDir: vi.fn(() => null), - getGrokCliVersion: vi.fn(() => null), -})); +vi.mock('../../src/utils/cli-resolver.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveCliBinDir: vi.fn(() => null), + resolveCliVersion: vi.fn(() => null), + }; +}); import fs from 'node:fs/promises'; -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync } from 'node:fs'; import { subagentWatcher } from '../../src/subagent-watcher.js'; import { getLifecycleLog } from '../../src/session-lifecycle-log.js'; import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js'; import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js'; import { isAntigravityAvailable, resolveAntigravityDir } from '../../src/utils/antigravity-cli-resolver.js'; import { isPiAvailable, resolvePiDir, getPiCliVersion } from '../../src/utils/pi-cli-resolver.js'; -import { isGrokAvailable, resolveGrokDir, getGrokCliVersion } from '../../src/utils/grok-cli-resolver.js'; +import { resolveCliBinDir, resolveCliVersion } from '../../src/utils/cli-resolver.js'; const mockedReadFile = vi.mocked(fs.readFile); const mockedWriteFile = vi.mocked(fs.writeFile); const mockedExistsSync = vi.mocked(existsSync); +const mockedMkdirSync = vi.mocked(mkdirSync); const mockedReaddirSync = vi.mocked(readdirSync); const mockedSubagentWatcher = vi.mocked(subagentWatcher); const mockedGetLifecycleLog = vi.mocked(getLifecycleLog); @@ -123,9 +127,6 @@ const mockedResolveAntigravityDir = vi.mocked(resolveAntigravityDir); const mockedIsPiAvailable = vi.mocked(isPiAvailable); const mockedResolvePiDir = vi.mocked(resolvePiDir); const mockedGetPiCliVersion = vi.mocked(getPiCliVersion); -const mockedIsGrokAvailable = vi.mocked(isGrokAvailable); -const mockedResolveGrokDir = vi.mocked(resolveGrokDir); -const mockedGetGrokCliVersion = vi.mocked(getGrokCliVersion); describe('system-routes', () => { let harness: RouteTestHarness; @@ -896,38 +897,234 @@ describe('system-routes', () => { }); }); - // ========== GET /api/grok/status ========== + // ========== GET /api/clis ========== - describe('GET /api/grok/status', () => { - it('returns unavailable when grok is not installed', async () => { - mockedIsGrokAvailable.mockReturnValue(false); - mockedResolveGrokDir.mockReturnValue(null); - mockedGetGrokCliVersion.mockReturnValue(null); + describe('GET /api/clis', () => { + it('returns the full stock catalog, each entry augmented with availability', async () => { + vi.mocked(resolveCliBinDir).mockImplementation((id: string) => (id === 'claude' ? '/usr/local/bin' : null)); - const res = await harness.app.inject({ method: 'GET', url: '/api/grok/status' }); + const res = await harness.app.inject({ method: 'GET', url: '/api/clis' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.available).toBe(false); - expect(body.path).toBeNull(); - expect(body.version).toBeNull(); + expect(body.success).toBe(true); + const ids = body.data.map((c: { id: string }) => c.id).sort(); + expect(ids).toEqual(['antigravity', 'claude', 'codex', 'copilot', 'gemini', 'grok', 'opencode', 'pi', 'shell']); + + const claude = body.data.find((c: { id: string }) => c.id === 'claude'); + expect(claude.available).toBe(true); + expect(claude.path).toBe('/usr/local/bin'); + + const codex = body.data.find((c: { id: string }) => c.id === 'codex'); + expect(codex.available).toBe(false); + expect(codex.path).toBeNull(); + + // shell has no binary at all — always "available" (nothing to resolve). + const shell = body.data.find((c: { id: string }) => c.id === 'shell'); + expect(shell.available).toBe(true); + expect(shell.path).toBeNull(); + }); + + it('never carries a secret value (only env var NAMES)', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/clis' }); + const body = JSON.parse(res.body); + const serialized = JSON.stringify(body.data); + // tmuxSetenvKeys/allowedPrefixes/allowedKeys are NAMES, never contain '=' + // or look like an actual secret value. + expect(serialized).not.toMatch(/sk-[A-Za-z0-9]{20,}/); }); + }); + + // ========== GET /api/cli/:id/status ========== - it('returns available with path AND version when grok is installed', async () => { - // `version` matters for the same reason as pi: `grok` has known squatters, - // so this endpoint is where a misresolution shows up. - mockedIsGrokAvailable.mockReturnValue(true); - mockedResolveGrokDir.mockReturnValue('/home/user/.grok/bin'); - mockedGetGrokCliVersion.mockReturnValue('1.0.5'); + describe('GET /api/cli/:id/status', () => { + it('returns availability for a known id, generically (not one of the six hand-written routes)', async () => { + vi.mocked(resolveCliBinDir).mockImplementation((id: string) => (id === 'pi' ? '/home/user/.local/bin' : null)); + vi.mocked(resolveCliVersion).mockImplementation((id: string) => (id === 'pi' ? '0.84.1' : null)); - const res = await harness.app.inject({ method: 'GET', url: '/api/grok/status' }); + const res = await harness.app.inject({ method: 'GET', url: '/api/cli/pi/status' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); - expect(body.available).toBe(true); - expect(body.path).toBe('/home/user/.grok/bin'); - expect(body.version).toBe('1.0.5'); + expect(body.success).toBe(true); + expect(body.data.available).toBe(true); + expect(body.data.path).toBe('/home/user/.local/bin'); + expect(body.data.version).toBe('0.84.1'); + }); + + it('404s for an unregistered id', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/cli/not-a-real-cli/status' }); + expect(res.statusCode).toBe(404); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('shell is always available (nothing to resolve)', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/cli/shell/status' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.available).toBe(true); + expect(body.data.path).toBeNull(); }); }); + // ========== CLI registry writes (App Settings → Agents & CLIs) ========== + + // Unlike every other handler in this file, the CLI registry writer does REAL disk IO + // (~/.codeman/clis.json under the per-test temp HOME from test/setup.ts) rather than + // going through a mocked store — the top-of-file `existsSync`/`mkdirSync` mocks + // (`existsSync` always `true`, `mkdirSync` a no-op) exist for every OTHER route's + // benefit and would otherwise make the registry's own read-modify-write believe the + // file exists while never actually creating its parent directory. Delegate to the REAL + // implementations for this one block, restored by the outer per-test `vi.clearAllMocks()` + // + default re-application in the top-level `beforeEach` once this block's tests finish. + describe('CLI registry writes', () => { + beforeEach(async () => { + const actualFs = await vi.importActual('node:fs'); + mockedExistsSync.mockImplementation(actualFs.existsSync); + mockedMkdirSync.mockImplementation(actualFs.mkdirSync as typeof mkdirSync); + }); + + describe('PUT /api/clis/:id/enabled', () => { + it('disables a stock CLI and returns the resolved list reflecting the change', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/clis/gemini/enabled', + payload: { enabled: false }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + const gemini = body.data.entries.find((c: { id: string }) => c.id === 'gemini'); + expect(gemini.enabled).toBe(false); + }); + + it('400s for an unknown id', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/clis/not-a-real-cli/enabled', + payload: { enabled: false }, + }); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).success).toBe(false); + }); + + it('400s on a malformed body', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/clis/gemini/enabled', + payload: { enabled: 'not-a-boolean' }, + }); + expect(res.statusCode).toBe(400); + }); + }); + + describe('PUT /api/clis/order', () => { + it('reorders the given ids and returns the resolved list in the new order', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/clis/order', + payload: { order: ['pi', 'claude', 'shell'] }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + const byId = new Map(body.data.entries.map((c: { id: string; order: number }) => [c.id, c.order])); + expect(byId.get('pi')).toBeLessThan(byId.get('claude') as number); + expect(byId.get('claude')).toBeLessThan(byId.get('shell') as number); + }); + }); + + describe('POST /api/clis/:id and DELETE /api/clis/:id', () => { + // Id deliberately avoids "copilot" -- that's a real stock id now (GitHub Copilot + // CLI, shipped disabled by default), and this exercises the CUSTOM-CLI add/remove + // path, which refuses to touch a stock id. + const CUSTOM_CLI = { + label: 'Test CLI', + shortBadge: 'TC', + accent: '#24292f', + enabled: true, + order: 60, + kind: 'agent', + discovery: { + binaries: ['testcli'], + searchDirs: ['~/.local/bin'], + install: { command: { linux: 'npm install -g @example/testcli' } }, + }, + launch: { params: {}, variants: [{ id: 'default', args: [{ lit: 'testcli' }] }] }, + env: { + exports: [], + unset: [], + tmuxSetenvKeys: [], + dockerExecEnvNames: [], + allowedPrefixes: ['TESTCLI_'], + allowedKeys: [], + }, + capabilities: { + external: true, + requiresMux: true, + hooks: false, + transcript: 'none', + altScreen: 'strip-mux-only', + echo: { policy: 'buffer', anchor: { kind: 'cursor' } }, + wheelForward: { mode: 'never' }, + keyboardAccessory: 'agent', + privilegedCommandGate: false, + startMode: 'interactive', + stripInkBloat: true, + ralph: false, + respawn: false, + effort: false, + agentSkillInjection: false, + statusLineTelemetry: false, + model: { source: 'none' }, + privilegedParams: [], + gates: {}, + }, + overlays: {}, + }; + + it('adds a custom CLI, then removes it', async () => { + const addRes = await harness.app.inject({ method: 'POST', url: '/api/clis/testcli', payload: CUSTOM_CLI }); + expect(addRes.statusCode).toBe(200); + const addBody = JSON.parse(addRes.body); + expect(addBody.success).toBe(true); + const added = addBody.data.entries.find((c: { id: string }) => c.id === 'testcli'); + expect(added.label).toBe('Test CLI'); + expect(added.stock).toBe(false); + + const listRes = await harness.app.inject({ method: 'GET', url: '/api/clis' }); + expect(JSON.parse(listRes.body).data.some((c: { id: string }) => c.id === 'testcli')).toBe(true); + + const delRes = await harness.app.inject({ method: 'DELETE', url: '/api/clis/testcli' }); + expect(delRes.statusCode).toBe(200); + const afterDelete = await harness.app.inject({ method: 'GET', url: '/api/clis' }); + expect(JSON.parse(afterDelete.body).data.some((c: { id: string }) => c.id === 'testcli')).toBe(false); + }); + + it('400s on a malformed custom CLI body', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/clis/broken', + payload: { ...CUSTOM_CLI, accent: 'not-a-colour' }, + }); + expect(res.statusCode).toBe(400); + }); + + it('refuses to add a custom CLI shadowing a stock id', async () => { + const res = await harness.app.inject({ method: 'POST', url: '/api/clis/codex', payload: CUSTOM_CLI }); + expect(res.statusCode).toBe(400); + }); + + it('refuses to remove a stock CLI', async () => { + const res = await harness.app.inject({ method: 'DELETE', url: '/api/clis/pi' }); + expect(res.statusCode).toBe(400); + }); + + it('400s removing an id that was never added', async () => { + const res = await harness.app.inject({ method: 'DELETE', url: '/api/clis/never-added' }); + expect(res.statusCode).toBe(400); + }); + }); + }); // end CLI registry writes + // ========== GET /api/execution/model-config ========== describe('GET /api/execution/model-config', () => { diff --git a/test/routes/team-routes.test.ts b/test/routes/team-routes.test.ts index e39162cd8..1292dba69 100644 --- a/test/routes/team-routes.test.ts +++ b/test/routes/team-routes.test.ts @@ -58,7 +58,6 @@ describe('team-routes', () => { describe('GET /api/teams/:name/tasks', () => { it('returns empty array for unknown team', async () => { - const res = await harness.app.inject({ method: 'GET', url: '/api/teams/nonexistent/tasks', diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 50103ed5f..cd69b2e7f 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -148,8 +148,10 @@ describe('Run launch synchronization', () => { * directly, which is the actual bug: a launch started while another session * is active wipes that session's terminal, and _cleanupPreviousSession() * then serializes the wiped view into its restore snapshot. Asserting on the - * helpers alone cannot see that, so pin the call sites here. This also - * covers run modes added later, which is how runAntigravity was caught. + * helpers alone cannot see that, so pin the call sites here. runCli(mode) is + * the single entry point for every external CLI (opencode/codex/gemini/ + * antigravity/pi and any future custom one), which is what now covers a mode + * added later automatically instead of needing its own runX() caught here. */ it('routes every run mode through the ownership helpers, never the terminal directly', () => { const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); @@ -157,7 +159,7 @@ describe('Run launch synchronization', () => { // Methods live in one Object.assign(prototype, {...}) block at a fixed // 2-space indent, so `\n },` reliably closes the one we are inside. const bodies = new Map(); - const header = /^ {2}async (run[A-Za-z]*)\(\) \{$/gm; + const header = /^ {2}async (run[A-Za-z]*)\([a-z]*\) \{$/gm; for (let m = header.exec(src); m; m = header.exec(src)) { const start = m.index + m[0].length; const end = src.indexOf('\n },', start); @@ -167,18 +169,7 @@ describe('Run launch synchronization', () => { // Fail loudly if the scan matched nothing: a silently empty scan would make // every assertion below vacuously true. - expect([...bodies.keys()]).toEqual( - expect.arrayContaining([ - 'runClaude', - 'runShell', - 'runOpenCode', - 'runCodex', - 'runGemini', - 'runAntigravity', - 'runPi', - 'runGrok', - ]) - ); + expect([...bodies.keys()]).toEqual(expect.arrayContaining(['runClaude', 'runShell', 'runCli'])); for (const [name, body] of bodies) { expect(body, `${name}() must not clear a terminal it may not own`).not.toContain('this.terminal.clear('); @@ -371,15 +362,20 @@ describe('Codex quick start settings', () => { ]) { welcomeBtns[id] = { style: { display: 'PRISTINE' } }; } - const modeBtns: Record = {}; - for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) { - modeBtns[mode] = { style: { display: 'PRISTINE' } }; + const modeBtns: Record = {}; + // "copilot" is deliberately included even though it's not in ALL_OFF/the legacy + // flags map below: _refreshRunModeAvailability must gate ANY button actually + // present, not a fixed list, which is exactly the bug that shipped GitHub Copilot + // CLI enabled with no way to see it in the Run menu. + for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell', 'copilot']) { + modeBtns[mode] = { dataset: { mode }, style: { display: 'PRISTINE' } }; } const menu = { querySelector: (sel: string) => { const m = sel.match(/data-mode="([^"]+)"/); return m ? (modeBtns[m[1]] ?? null) : null; }, + querySelectorAll: () => Object.values(modeBtns), }; const context: any = vm.createContext({ CodemanApp, @@ -455,25 +451,38 @@ describe('Codex quick start settings', () => { // Shell needs no external CLI, and leaving it alone is what guarantees the // menu is never empty on a box with nothing installed. expect(modeBtns.shell.style.display).toBe('PRISTINE'); + // "copilot" is absent from the legacy flags map entirely (it postdates that + // fixed six-key shape) and window.__codemanClis was never injected in this + // harness either, so this falls through isCliAvailable()'s "unknown reads as + // available" rule rather than being silently skipped. + expect(modeBtns.copilot.style.display).toBe('flex'); }); - it('gates every mode the run-mode menu actually offers', () => { - // Catches a sixth run mode being added to index.html without being gated, - // which is exactly how antigravity slipped past #201. + it('gates every mode the run-mode menu actually offers, generically rather than by a fixed list', () => { + // Catches a run mode being added to index.html without being gated, which is + // exactly how antigravity slipped past #201 -- and, differently, exactly how + // GitHub Copilot CLI shipped enabled with no way to see it in the Run menu + // (that bug was in the MENU MARKUP never being rebuilt from the registry at + // all, but this guards the gating half of the same surface). const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8'); const menuHtml = html.slice(html.indexOf('id="runModeMenu"')); - const offered = [...menuHtml.slice(0, menuHtml.indexOf('
')).matchAll(/data-mode="([^"]+)"/g)].map( + const offered = [...menuHtml.slice(0, menuHtml.indexOf('