From cd64b0a3f7dea66d64e0a52b6bf8d9ac0790ec64 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 19 Sep 2026 21:16:31 +0800 Subject: [PATCH] feat(cli-registry): drive the run-menu frontend from the CLI catalogue (PR B2) PR #380 (PR B) held back the frontend half of the CLI registry refactor, explicitly deferring window.__codemanCliCatalog and making session-ui.js / mobile-overview.js catalogue-driven as "PR B2". - Inject window.__codemanCliCatalog in renderIndexHtml(), following the existing __codemanCustomModelClis pattern (escapeScriptJson-guarded, resolved per-request). Reading CliEntry.shortBadge here is what makes it genuinely read, so it drops out of types.ts's DECLARED_FOR_LATER list. - Consolidate session-ui.js's 8 near-duplicate run() launch functions (opencode/codex/gemini/antigravity/pi/omp/grok/deepseek) into one shared _runCliMode() plus a local RUN_MODE_LAUNCH config table. The 8 method names stay as thin wrappers (index.html calls them by name; tests assert on the name). Also collapses a duplicated 8-way isAltMode/isExternalCli OR-chain (same expression, copy-pasted twice in openSessionOptions) into one EXTERNAL_CLI_MODES check. - Add test/frontend-cli-no-id-branching.test.ts, a guard scoped to session-ui.js/mobile-overview.js only (not the rest of src/web/public/, which stays explicitly out of scope per CLAUDE.md), mirroring the backend's own no-id-branching guard. mobile-overview.js and the wiring of accent/echo/wheelForward/ keyboardAccessory were investigated and deliberately left alone: the first is already a single, tested, gated table (not duplicated logic); the second set belongs to terminal-ui.js/keyboard-accessory.js/styles.css, files outside this PR's mandate. Verified on a tmux-capable devbox (this sandbox has no tmux): full CI gate at 405 files / 7717 tests / 0 failures, typecheck clean, 94 targeted tests covering exact per-CLI wire-body shapes unmodified and passing, and a live anti-vacuity check on the new guard (injected a real branch, confirmed it fails, reverted, confirmed green). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n --- src/config/cli-registry/types.ts | 11 +- src/web/public/session-ui.js | 601 ++++++---------------- src/web/server.ts | 18 + test/cli-registry-no-id-branching.test.ts | 1 - test/frontend-cli-no-id-branching.test.ts | 185 +++++++ test/render-index-html.test.ts | 33 ++ test/server-index-title.test.ts | 9 +- 7 files changed, 402 insertions(+), 456 deletions(-) create mode 100644 test/frontend-cli-no-id-branching.test.ts diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 871a3762..5022360c 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -629,12 +629,15 @@ export interface CliOverlays { /** * ⚠️ DECLARED-FOR-LATER: fields no code reads yet. * - * `shortBadge`, `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`, + * `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`, * `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND - * behaviour, and the frontend is deliberately untouched by the change that introduced this - * registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own + * behaviour, and most of the frontend is deliberately untouched by the change that introduced + * this registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own * hand-authored per-CLI rules, and moving them is its own piece of work with its own way of - * being verified (a mobile/browser suite the CI gate cannot see). + * being verified (a mobile/browser suite the CI gate cannot see). `shortBadge` graduated out of + * this list (PR B2): it is read server-side into `window.__codemanCliCatalog` + * (`src/web/server.ts`), the general run-menu catalogue injected for the frontend to consume — + * see `docs/cli-registry.md`. * * They are declared now because each entry should describe its CLI completely, and because * transcribing them while the hand-written source is still on screen is when the values are diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 3047f90c..9385a710 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -11,6 +11,115 @@ * @loadorder 12 of 15 — loaded after panels-ui.js, before ralph-wizard.js */ +/** + * PR B2: the single source for every non-Claude, non-Shell run mode's launch + * shape, consumed by `_runCliMode()` below. Before this table existed, each of + * `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/ + * `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select + * skeleton with only the CLI-specific pieces below actually differing — eight + * near-identical bodies guaranteed to drift, exactly what the CLI registry's + * own no-id-branching rule exists to prevent server-side. + * + * Deliberately a LOCAL table rather than reading `window.__codemanCliCatalog` + * (server.ts, PR B2): that catalogue carries only menu-facing metadata + * (id/label/shortBadge/order/kind), and several unit tests exercise these + * run*() methods inside a bare `vm.createContext()` sandbox with no `window` + * global at all (see test/run-mode-ui.test.ts) — referencing `window` there + * unguarded would throw, not degrade. `buildConfig` returns the CLI's + * top-level legacy config field for a LOCAL launch, or `null` for a CLI that + * sends none (pi: no bypass flag exists, so there is nothing to send — see + * runPi's own history below for why that must stay true). + */ +const RUN_MODE_LAUNCH = { + opencode: { + label: 'OpenCode', + installHint: 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash', + supportsCustomModel: true, + buildConfig: () => ({ openCodeConfig: { autoAllowTools: true } }), + }, + codex: { + label: 'Codex', + installHint: 'Codex CLI not found. Install with: npm install -g @openai/codex', + supportsCustomModel: true, + buildConfig: (globalSettings) => ({ + codexConfig: { + dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, + animations: globalSettings.codexAnimationsEnabled ?? false, + renderMode: 'hybrid', + }, + }), + }, + gemini: { + label: 'Gemini', + installHint: 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli', + supportsCustomModel: true, + buildConfig: () => ({ geminiConfig: { approvalMode: 'yolo' } }), + }, + antigravity: { + label: 'Antigravity', + installHint: 'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash', + // antigravity has no customModelInjection recipe (docs/custom-model-endpoints-plan.md + // calls it `unsupported`) — never fold a pending pick into its launch body. + supportsCustomModel: false, + buildConfig: () => ({ antigravityConfig: { dangerouslySkipPermissions: true } }), + }, + pi: { + label: 'Pi', + installHint: 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent', + supportsCustomModel: true, + // Deliberately 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 `approveProjectTrust: true` here would silently opt every + // browser-launched pi session into executing repo-supplied TypeScript. + buildConfig: () => null, + }, + omp: { + label: 'OMP', + installHint: 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh', + supportsCustomModel: true, + buildConfig: () => null, + }, + grok: { + label: 'Grok', + installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash', + supportsCustomModel: true, + // Sends `grokConfig: { alwaysApprove: true }` the way antigravity 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. + buildConfig: () => ({ grokConfig: { alwaysApprove: true } }), + }, + deepseek: { + label: 'DeepSeek', + installHint: 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh', + // The two-part availability check is deliberate. `dsh` being installed is + // not enough — DeepSeek ships no terminal front door, so a box can have a + // perfect binary and nothing a pane can run. + unrunnableHint: + 'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' + + 'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' + + 'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui', + supportsCustomModel: true, + // Sends `permissionMode: 'danger-full-access'` for the same reason every + // sibling Run button sends its bypass switch. The harness has no bypass + // FLAG, so this rides the `DSH_PERMISSION_MODE` export instead, and the + // multi-user clamp forces it back down to `workspace-write` server-side. + buildConfig: () => ({ deepSeekConfig: { permissionMode: 'danger-full-access' } }), + }, +}; + +/** + * External (non-Claude, non-Shell) CLI run modes — the keys of RUN_MODE_LAUNCH + * above, kept as its own Set so `_isAltCliMode()` doesn't recompute an array + * every call. Single source for what used to be two hand-copied 8-way + * `session.mode === '' || ...` chains inside one function + * (`openSessionOptions`), guaranteed to drift from each other the moment a + * ninth CLI landed in one and not the other. + */ +const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH)); + Object.assign(CodemanApp.prototype, { /** * Build envOverrides payload from case + global settings. @@ -395,34 +504,13 @@ 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 === 'omp') { - return await this.runOmp(); - } - if (mode === 'pi') { - return await this.runPi(); - } - if (mode === 'grok') { - return await this.runGrok(); - } - if (mode === 'deepseek') { - return await this.runDeepSeek(); - } if (mode === 'shell') { return await this.runShell(); } - return await this.runClaude(); + if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) { + return await this.runClaude(); + } + return await this._runCliMode(mode); } finally { const remaining = minLockMs - (Date.now() - startedAt); if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)); @@ -1917,88 +2005,41 @@ Object.assign(CodemanApp.prototype, { return firstSessionId; }, - async runOpenCode() { + /** + * Shared launcher for every RUN_MODE_LAUNCH entry (every run mode except + * claude/shell, which have their own flows — claude for its remote/docker + * branching and parallel-create path, shell for needing no CLI probe at + * all). The eight run() methods below are thin named wrappers: their + * names stay because index.html's welcome-screen buttons and the run-mode + * menu call them directly by name (`app.runOpenCode()` etc.), and several + * tests assert on that name directly too. + */ + async _runCliMode(mode) { + const entry = RUN_MODE_LAUNCH[mode]; 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). + // 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 for remote cases). const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const tabCount = this._readTabCount(); const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} OpenCode session(s) in ${caseName}...` + `Starting ${tabCount} ${entry.label} session(s) 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 statusRes = await fetch(`/api/${mode}/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' - ); + this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint); 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 firstSessionId = await this._launchQuickStartInstances( - caseName, - tabCount, - 'OpenCode', - (sessionName) => ({ - caseName, - mode: 'opencode', - sessionName, - ...(isRemote ? {} : { - openCodeConfig: { autoAllowTools: true }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - ...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}), - }), - }), - ownsLaunchTerminal - ); - - // Switch to the new session (don't pre-set activeSessionId — selectSession - // early-returns when IDs match, skipping buffer load and sendResize) - if (firstSessionId) { - await this.selectSession(firstSessionId); - } - - 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 tabCount = this._readTabCount(); - const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} Codex session(s) 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' - ); + if (entry.unrunnableHint && !status.runnable) { + this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint); return; } } @@ -2008,19 +2049,17 @@ Object.assign(CodemanApp.prototype, { const firstSessionId = await this._launchQuickStartInstances( caseName, tabCount, - 'Codex', + entry.label, (sessionName) => ({ caseName, - mode: 'codex', + mode, sessionName, ...(isRemote ? {} : { - codexConfig: { - dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, - animations: globalSettings.codexAnimationsEnabled ?? false, - renderMode: 'hybrid', - }, + ...(entry.buildConfig(globalSettings) || {}), ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - ...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}), + ...(entry.supportsCustomModel && this._pendingCustomModelForLaunch + ? { customModel: this._pendingCustomModelForLaunch } + : {}), }), }), ownsLaunchTerminal @@ -2038,368 +2077,36 @@ Object.assign(CodemanApp.prototype, { } }, + async runOpenCode() { + return this._runCliMode('opencode'); + }, + + async runCodex() { + return this._runCliMode('codex'); + }, + 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 tabCount = this._readTabCount(); - const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} Gemini session(s) 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 firstSessionId = await this._launchQuickStartInstances( - caseName, - tabCount, - 'Gemini', - (sessionName) => ({ - caseName, - mode: 'gemini', - sessionName, - ...(isRemote ? {} : { - geminiConfig: { approvalMode: 'yolo' }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - ...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}), - }), - }), - ownsLaunchTerminal - ); - - if (firstSessionId) { - await this.selectSession(firstSessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } + return this._runCliMode('gemini'); }, 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 tabCount = this._readTabCount(); - const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} Antigravity session(s) 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 firstSessionId = await this._launchQuickStartInstances( - caseName, - tabCount, - 'Antigravity', - (sessionName) => ({ - caseName, - mode: 'antigravity', - sessionName, - ...(isRemote ? {} : { - antigravityConfig: { dangerouslySkipPermissions: true }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - }), - }), - ownsLaunchTerminal - ); - - if (firstSessionId) { - await this.selectSession(firstSessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } + return this._runCliMode('antigravity'); }, - /** - * 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 - * `approveProjectTrust: true` here would silently opt every browser-launched pi - * session into executing repo-supplied TypeScript. - */ 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 tabCount = this._readTabCount(); - const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} Pi session(s) 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 firstSessionId = await this._launchQuickStartInstances( - caseName, - tabCount, - 'Pi', - (sessionName) => ({ - caseName, - mode: 'pi', - sessionName, - ...(isRemote || Object.keys(envOverrides).length === 0 ? {} : { envOverrides }), - ...(!isRemote && this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}), - }), - ownsLaunchTerminal - ); - - if (firstSessionId) { - await this.selectSession(firstSessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } + return this._runCliMode('pi'); }, async runOmp() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote/docker cases run omp on the OTHER side — skip the local status probe - // and the local-only config 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 tabCount = this._readTabCount(); - const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} OMP session(s) in ${caseName}...` - ); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/omp/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const firstSessionId = await this._launchQuickStartInstances( - caseName, - tabCount, - 'OMP', - (sessionName) => ({ - caseName, - mode: 'omp', - sessionName, - ...(isRemote ? {} : { - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - ...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}), - }), - }), - ownsLaunchTerminal - ); - - if (firstSessionId) { - await this.selectSession(firstSessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } + return this._runCliMode('omp'); }, - /** - * 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. - */ async runGrok() { - 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). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; - const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - - const tabCount = this._readTabCount(); - const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} Grok session(s) in ${caseName}...` - ); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/grok/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' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const firstSessionId = await this._launchQuickStartInstances( - caseName, - tabCount, - 'Grok', - (sessionName) => ({ - caseName, - mode: 'grok', - sessionName, - ...(isRemote ? {} : { - grokConfig: { alwaysApprove: true }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - ...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}), - }), - }), - ownsLaunchTerminal - ); - - if (firstSessionId) { - await this.selectSession(firstSessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } + return this._runCliMode('grok'); }, - /** - * Launch a DeepSeek Harness (`dsh`) session. - * - * Sends `permissionMode: 'danger-full-access'` for the same reason every - * sibling Run button sends its bypass switch: Codeman sessions exist for - * autonomous work. The harness has no bypass FLAG, so this rides the - * `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it - * back down to `workspace-write` for non-granted owners server-side. - * - * `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from - * output-stabilization guessing to definitive idle/blocked hook events. - * - * The two-part availability check is deliberate. `dsh` being installed is not - * enough — DeepSeek ships no terminal front door, so a box can have a perfect - * binary and nothing a pane can run. Reporting that precisely, with the exact - * command that fixes it, is the difference between "the Run button is broken" - * and a 30-second fix. - */ async runDeepSeek() { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; - // Remote/docker cases run dsh 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 tabCount = this._readTabCount(); - const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} DeepSeek session(s) in ${caseName}...` - ); - this.terminal.focus(); - - try { - if (!isRemote) { - const statusRes = await fetch('/api/deepseek/status'); - const status = (await statusRes.json()).data; - if (!status.available) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh' - ); - return; - } - if (!status.runnable) { - this._reportSessionLaunchError( - ownsLaunchTerminal, - 'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' + - 'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' + - 'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui' - ); - return; - } - } - - const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage()); - const firstSessionId = await this._launchQuickStartInstances( - caseName, - tabCount, - 'DeepSeek', - (sessionName) => ({ - caseName, - mode: 'deepseek', - sessionName, - ...(isRemote ? {} : { - deepSeekConfig: { permissionMode: 'danger-full-access' }, - ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), - ...(this._pendingCustomModelForLaunch ? { customModel: this._pendingCustomModelForLaunch } : {}), - }), - }), - ownsLaunchTerminal - ); - - if (firstSessionId) { - await this.selectSession(firstSessionId); - } - - this.terminal.focus(); - } catch (err) { - this._reportSessionLaunchError(ownsLaunchTerminal, err.message); - } + return this._runCliMode('deepseek'); }, @@ -2467,7 +2174,7 @@ Object.assign(CodemanApp.prototype, { if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId); // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only) - const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; + const isAltMode = EXTERNAL_CLI_MODES.has(session.mode); this.switchOptionsTab(isAltMode ? 'summary' : 'respawn'); // Update respawn status display and buttons @@ -2497,7 +2204,7 @@ Object.assign(CodemanApp.prototype, { } // Hide Claude-specific options for external CLI sessions - const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp'; + const isExternalCli = isAltMode; const claudeOnlyEls = document.querySelectorAll('[data-claude-only]'); claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; }); diff --git a/src/web/server.ts b/src/web/server.ts index f2f9b419..967efd06 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1674,6 +1674,24 @@ export class WebServer extends EventEmitter { '', `\n` ); + // The general-purpose run-menu catalogue (PR B2, docs/cli-registry.md): every + // ENABLED CliEntry's menu-facing fields, unfiltered by capability — unlike + // __codemanCustomModelClis above, which is narrowed to one picker's needs. + // Field list mirrors what scripts/generate-cli-catalog.mts exports to + // config/clis.stock.json (id/label/shortBadge/order/kind); launch/env/ + // capabilities/overlays are spawn-time concerns the server alone interprets + // and must never leak here, same rule as that generated artifact. + const cliCatalog = enabledClis().map((entry) => ({ + id: entry.id, + label: entry.label, + shortBadge: entry.shortBadge, + order: entry.order, + kind: entry.kind, + })); + // `label`/`shortBadge` are user-clis.json-settable strings, so this needs the + // same -breakout guard as __codemanCustomModelClis above. + const cliCatalogJson = escapeScriptJson(JSON.stringify(cliCatalog)); + html = html.replace('', `\n`); } if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('', `\n`); diff --git a/test/cli-registry-no-id-branching.test.ts b/test/cli-registry-no-id-branching.test.ts index 8d1bc4de..0c8cd1f1 100644 --- a/test/cli-registry-no-id-branching.test.ts +++ b/test/cli-registry-no-id-branching.test.ts @@ -314,7 +314,6 @@ describe('declared-for-later fields', () => { * list — and wiring one up should make its line here fail, which is the good direction. */ const DECLARED_FOR_LATER = [ - 'shortBadge', 'accent', 'capabilities.echo', 'capabilities.wheelForward', diff --git a/test/frontend-cli-no-id-branching.test.ts b/test/frontend-cli-no-id-branching.test.ts new file mode 100644 index 00000000..c36ba2a7 --- /dev/null +++ b/test/frontend-cli-no-id-branching.test.ts @@ -0,0 +1,185 @@ +/** + * @fileoverview Static guard: no NEW CLI-id branch in the two files PR B2 touched + * (`session-ui.js`, `mobile-overview.js`), mirroring + * `test/cli-registry-no-id-branching.test.ts` for the backend registry. + * + * Deliberately scoped to ONLY these two files, not all of `src/web/public/`. + * `docs/cli-registry.md` and CLAUDE.md are explicit that the rest of the + * frontend (`app.js`, `terminal-ui.js`, `styles.css`, `settings-ui.js`, …) + * keeps its own hand-authored per-CLI rules deliberately — "moving them is + * its own piece of work verified by a browser/mobile suite the CI gate cannot + * see." Widening this guard to the whole directory would force either fixing + * or allowlisting dozens of branches in files nobody has touched or reviewed + * for this change, which is scope B2 never took on. + * + * Port: none (pure static analysis). + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +const PUBLIC = fileURLToPath(new URL('../src/web/public/', import.meta.url)); +const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js']; + +/** + * Every currently-surviving branch, each with the reason it is not a + * CLI-behaviour branch a `CliCapabilities` field should express, keyed + * `::::`. Found by running the scanner + * below against the post-B2 state of both files (2026-09-19) and reviewing + * each hit in context — none of these are leftover oversights, each is a + * verified, deliberate exception. + */ +const ALLOWED_BRANCHES: Record = { + // --- run(): claude/shell get their own dispatch path, everything else goes --- + // --- through the shared _runCliMode() — see RUN_MODE_LAUNCH's header comment --- + "session-ui.js::507::mode === 'shell'": 'run() dispatch: shell needs no CLI probe at all', + "session-ui.js::510::mode === 'claude'": + 'run() dispatch: claude has its own remote/docker branching and parallel-create path ' + + '(runClaude), unlike every RUN_MODE_LAUNCH entry', + + // --- runCustomModelEntry(): claude restarts its CLI process in place; every --- + // --- other custom-model-eligible CLI applies the pick one-shot, before the --- + // --- session exists at all. Documented in CLAUDE.md's Custom Model Endpoint --- + // --- Profiles section: "Two launch paths, chosen by mechanism, not preference." --- + "session-ui.js::862::mode === 'claude'": 'restart-vs-one-shot custom-model launch mechanism, not a preference', + + // --- Button-label ternary (Open Question 7, DEPLOYMENT_PLAN.md): pinned by --- + // --- test/run-mode-ui.test.ts's exact-text assertions (e.g. 'Run OMP'), which --- + // --- diverge from CliEntry.shortBadge for at least one CLI (omp: 'OM' vs the --- + // --- displayed 'OMP') — a catalogue-driven rewrite would silently change --- + // --- user-visible text and break that pinned test. --- + "session-ui.js::1527::mode === 'opencode'": 'button-label ternary, pinned exact text (see Open Question 7)', + "session-ui.js::1527::mode === 'codex'": 'button-label ternary, pinned exact text (see Open Question 7)', + "session-ui.js::1527::mode === 'gemini'": 'button-label ternary, pinned exact text (see Open Question 7)', + "session-ui.js::1527::mode === 'antigravity'": 'button-label ternary, pinned exact text (see Open Question 7)', + "session-ui.js::1527::mode === 'pi'": 'button-label ternary, pinned exact text (see Open Question 7)', + "session-ui.js::1527::mode === 'grok'": 'button-label ternary, pinned exact text (see Open Question 7)', + "session-ui.js::1527::mode === 'deepseek'": 'button-label ternary, pinned exact text (see Open Question 7)', + "session-ui.js::1527::mode === 'omp'": 'button-label ternary, pinned exact text (see Open Question 7)', + "session-ui.js::1527::mode === 'shell'": 'button-label ternary, pinned exact text (see Open Question 7)', + + // --- Respawn/Ralph section: claude-only by product design, mirroring the --- + // --- backend's own capabilities.ralph gate (isExternalCliMode() already --- + // --- excludes Ralph tracking for every non-claude mode server-side). --- + "session-ui.js::2200::mode === 'claude'": 'Respawn/Ralph section is claude-only by design', + + // --- runMode property setter: a validity allowlist, not a behaviour branch. --- + // --- Left untouched in Phase 2 (uncertain 'shell' asymmetry — this chain has --- + // --- no shell arm at all — and no evidence of what callers rely on it). --- + "session-ui.js::4431::mode === 'opencode'": 'runMode setter validity check, not behaviour', + "session-ui.js::4431::mode === 'codex'": 'runMode setter validity check, not behaviour', + "session-ui.js::4431::mode === 'gemini'": 'runMode setter validity check, not behaviour', + "session-ui.js::4431::mode === 'antigravity'": 'runMode setter validity check, not behaviour', + "session-ui.js::4431::mode === 'pi'": 'runMode setter validity check, not behaviour', + "session-ui.js::4431::mode === 'grok'": 'runMode setter validity check, not behaviour', + "session-ui.js::4431::mode === 'deepseek'": 'runMode setter validity check, not behaviour', + "session-ui.js::4431::mode === 'omp'": 'runMode setter validity check, not behaviour', + "session-ui.js::4431::mode === 'claude'": 'runMode setter validity check, not behaviour', + + // --- mobile-overview.js: shell is exempt from the isCliAvailable() gate the --- + // --- same way the toolbar's #runModeMenu exempts it (shell needs no CLI). --- + "mobile-overview.js::574::mode !== 'shell'": 'shell needs no CLI, so it is exempt from the availability gate', +}; + +/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */ +const IDS = STOCK_CLIS.map((e) => e.id as string); +const ID_ALT = IDS.join('|'); + +/** Same four shapes as the backend guard — see its own comment for why all four matter. */ +const BRANCH_PATTERN = new RegExp( + [ + `\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`, + `\\bcase\\s+'(?:${ID_ALT})'\\s*:`, + `'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`, + ].join('|'), + 'g' +); + +/** Blanks comment lines before scanning — see the backend guard's own comment on why. */ +function uncommented(source: string): string { + return source + .split('\n') + .map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line)) + .join('\n'); +} + +interface Finding { + file: string; + expression: string; + line: number; + key: string; +} + +function scan(): Finding[] { + const findings: Finding[] = []; + for (const file of SCANNED_FILES) { + const lines = uncommented(readFileSync(PUBLIC + file, 'utf-8')).split('\n'); + lines.forEach((line, i) => { + BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts + for (const match of line.matchAll(BRANCH_PATTERN)) { + const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode'); + findings.push({ file, expression, line: i + 1, key: `${file}::${i + 1}::${expression}` }); + } + }); + } + return findings; +} + +const findings = scan(); + +describe('no NEW CLI-id branching in session-ui.js / mobile-overview.js (PR B2)', () => { + it('scans both files (sanity)', () => { + // If this drops to zero the scanner or the file list drifted and every + // assertion below would pass vacuously. + const scannedBytes = SCANNED_FILES.reduce((n, f) => n + readFileSync(PUBLIC + f, 'utf-8').length, 0); + expect(scannedBytes).toBeGreaterThan(10_000); + }); + + it('builds its id list from the live catalog (sanity)', () => { + expect(IDS).toContain('claude'); + expect(IDS).toContain('deepseek'); + expect(IDS.length).toBeGreaterThanOrEqual(9); + }); + + it('still detects a branch when one exists (anti-vacuity)', () => { + const samples = [ + "if (session.mode === 'codex') { doSomething(); }", + "if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }", + "switch (mode) { case 'gemini': return 1; }", + "if (['codex', 'gemini'].includes(mode)) { doSomething(); }", + ]; + for (const sample of samples) { + BRANCH_PATTERN.lastIndex = 0; + expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull(); + } + BRANCH_PATTERN.lastIndex = 0; + expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull(); + }); + + it('has no unapproved id branches', () => { + const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES)); + const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n'); + expect( + offenders, + offenders.length === 0 + ? '' + : `Found ${offenders.length} new CLI-id branch(es) in session-ui.js/mobile-overview.js:\n${detail}\n\n` + + 'Two ways out, in order of preference:\n' + + ' 1. Derive the difference from window.__codemanCliCatalog (server.ts) or a shared\n' + + ' module-level constant, the way _runCliMode()/EXTERNAL_CLI_MODES do.\n' + + ' 2. If it is a genuine mechanism difference (not a CLI-behaviour branch), add it to\n' + + ' ALLOWED_BRANCHES in this file WITH the reason.' + ).toEqual([]); + }); + + it('has no stale allowlist entries', () => { + // An allowlisted branch that no longer exists at that line is a lie about the + // codebase, and the next person to reintroduce that exact branch elsewhere + // would sail straight through under the old line number. + const present = new Set(findings.map((f) => f.key)); + const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key)); + expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]); + }); +}); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index bc3de380..32634483 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -209,6 +209,38 @@ describe('WebServer.renderIndexHtml', () => { } }); + it('exposes the general run-menu CLI catalogue with the menu-facing fields only', async () => { + // Unlike __codemanCustomModelClis above (narrowed to one picker's needs), this + // one carries every ENABLED CliEntry, agent and shell alike, with no capability + // filter — but still excludes launch/env/capabilities/overlays, the same rule + // scripts/generate-cli-catalog.mts follows for config/clis.stock.json. + const { server } = makeServer({}); + const html = await render(server); + expect(html).toContain('window.__codemanCliCatalog='); + const catalog = JSON.parse(html.match(/window\.__codemanCliCatalog=(\[.*?\]);/)![1]) as Array<{ + id: string; + label: string; + shortBadge: string; + order: number; + kind: string; + }>; + const ids = catalog.map((c) => c.id); + expect(ids).toContain('claude'); + expect(ids).toContain('shell'); + for (const cli of catalog) { + expect(typeof cli.id).toBe('string'); + expect(typeof cli.label).toBe('string'); + expect(typeof cli.shortBadge).toBe('string'); + expect(typeof cli.order).toBe('number'); + expect(['agent', 'shell']).toContain(cli.kind); + expect(cli).not.toHaveProperty('launch'); + expect(cli).not.toHaveProperty('env'); + expect(cli).not.toHaveProperty('capabilities'); + expect(cli).not.toHaveProperty('overlays'); + expect(cli).not.toHaveProperty('discovery'); + } + }); + it('escapeScriptJson neutralizes a literal , and still round-trips as a JS literal', () => { // CliEntry.label is a plain string a user's own clis.json can set (up to 60 // chars), unlike __codemanCliAvailable's booleans-only payload, so this is @@ -254,6 +286,7 @@ describe('WebServer.renderIndexHtml', () => { const html = await render(server, 'sess-123'); expect(html).not.toContain('__codemanCliAvailable'); expect(html).not.toContain('__codemanCustomModelClis'); + expect(html).not.toContain('__codemanCliCatalog'); }); it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => { diff --git a/test/server-index-title.test.ts b/test/server-index-title.test.ts index e2c7b979..d36e5606 100644 --- a/test/server-index-title.test.ts +++ b/test/server-index-title.test.ts @@ -96,9 +96,9 @@ describe('WebServer index.html templating (#82)', () => { it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => { // renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin - // .js/.css refs, and injects the CLI-availability flags plus the custom-model - // Run-menu picker's CLI list before </head>; strip all so the title remains - // the only other change. + // .js/.css refs, and injects the CLI-availability flags, the custom-model + // Run-menu picker's CLI list, and the general run-menu CLI catalogue (PR B2) + // before </head>; strip all so the title remains the only other change. // // The flag strips are what keep this test environment-independent. The // CLI-availability one used to pass here by luck: that script was injected @@ -109,7 +109,8 @@ describe('WebServer index.html <title> templating (#82)', () => { const html = (await render('laptop')) .replace(/(\.(?:js|css))\?v=[^"]*/g, '$1') .replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '') - .replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, ''); + .replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '') + .replace(/<script>window\.__codemanCliCatalog=\[.*?\];<\/script>\n/, ''); const beforeTitle = rawTemplate.split('<title>Codeman')[0]; const afterTitle = rawTemplate.split('Codeman')[1]; expect(html.startsWith(beforeTitle)).toBe(true);