From 0c77dd0a184d2ab0e64e87c1f7dce0ad77824238 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:13:57 +0800 Subject: [PATCH] fix(cli-registry): toggling a CLI off in Settings never hid it anywhere else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit window.__codemanCliAvailable — the flag isCliAvailable() reads client-side to gate the welcome-screen buttons, the Run-menu dropdown and the mobile overview — was built purely from each CLI's own installed-on-PATH resolver (isClaudeAvailable() etc.), with no reference to the registry's `enabled` flag at all. So disabling a CLI via the new Settings UI (or a hand-edited clis.json) updated the settings row and nothing else: every launch surface kept offering it, both live and after a full page reload, since even a fresh render never consulted the registry. Fixed in two places: - server.ts: after building `available`, intersect the nine real SessionMode ids against `enabledClis()`. git/cloudflared (utility binaries, not CLI registry entries) and deepseekBinary (a secondary installed-only flag for the "add a profile" affordance) are deliberately left alone. - settings-ui.js: `toggleCliEnabled()` now patches `window.__codemanCliAvailable` in place and refreshes the welcome screen, the mobile overview and an already-open Run menu, mirroring the existing `installDeepSeekProfile()` pattern for the same "injected once, needs an explicit patch" reason — without this half, the server-side fix alone still left every surface stale until the next reload. New test in test/render-index-html.test.ts: an installed-but-disabled CLI (codex, forced via clis.json + reloadCliRegistry()) reads as unavailable, while an installed-and-enabled one (claude) is unaffected by the override. Verified on the Debian devbox (codeman-devbox, real tmux — this sandbox has none and WebServer's constructor hard-requires it): typecheck clean, the new test passes (17/17 in render-index-html.test.ts), the CLI-registry suites pass (86/86), and the full CI gate is green (415 test files, 7855 tests, 0 failures). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD --- src/web/public/settings-ui.js | 12 ++++++++++++ src/web/server.ts | 23 +++++++++++++++++++++++ test/render-index-html.test.ts | 30 +++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 754c7a02..39428edd 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2810,6 +2810,13 @@ Object.assign(CodemanApp.prototype, { .join(''); }, + /** + * ⚠️ A successful toggle must patch `window.__codemanCliAvailable` and refresh + * every surface that reads it, or the change is invisible everywhere except + * this settings row until the next full page reload — `window.__codemanCliAvailable` + * is injected ONCE at initial page render (server.ts) and nothing else refetches + * it. Same pattern `installDeepSeekProfile()` already uses for the same reason. + */ async toggleCliEnabled(id, checkbox) { const next = checkbox.checked; const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'PUT', body: { enabled: next } }); @@ -2824,6 +2831,11 @@ Object.assign(CodemanApp.prototype, { this.showToast(`Failed to ${next ? 'enable' : 'disable'} "${id}"${detail ? `: ${detail}` : ''}`, 'error'); return; } + window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), [id]: next }; + this.applyWelcomeCliVisibility?.(); + this.renderMobileOverview?.(); + const menu = document.getElementById('runModeMenu'); + if (menu) this._refreshRunModeAvailability?.(menu); await this.loadCliListForSettings(); }, diff --git a/src/web/server.ts b/src/web/server.ts index 7f2d9b71..beaf302c 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1663,6 +1663,29 @@ export class WebServer extends EventEmitter { // keep without git (issue #236), same reasoning as cloudflared above. git: isGitAvailable(), }; + // A CLI disabled via the registry (docs/cli-enable-disable-plan.md's Settings UI, + // or a hand-edited clis.json) must read as unavailable here too — `isCliAvailable()` + // on the frontend is what the welcome screen, the Run-menu dropdown and the mobile + // overview all gate on, and none of them otherwise know the registry's `enabled` + // flag exists; without this, disabling a CLI in Settings toggled the row there but + // left every launch surface still offering it. `git`/`cloudflared` are utility + // binaries, not CLI registry entries, and `deepseekBinary` is a secondary + // installed-only flag for the "add a profile" affordance — none of the three are + // registry ids, so only the nine real SessionMode entries are gated. + const registryEnabledIds = new Set(enabledClis().map((entry) => entry.id)); + for (const id of [ + 'claude', + 'opencode', + 'codex', + 'gemini', + 'antigravity', + 'pi', + 'grok', + 'deepseek', + 'omp', + ] as const) { + if (!registryEnabledIds.has(id)) available[id] = false; + } html = html.replace( '', () => `\n` diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 0fb7a321..b9dc3697 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -23,8 +23,11 @@ import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-c import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js'; import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js'; import { isGitAvailable } from '../src/git-clone.js'; -import { enabledClis } from '../src/config/cli-registry/registry.js'; +import { enabledClis, reloadCliRegistry } from '../src/config/cli-registry/registry.js'; import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import { dataPath } from '../src/config/instance.js'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; // renderIndexHtml probes the real PATH for every CLI, which would make the // assertions below depend on whatever happens to be installed on the machine @@ -197,6 +200,31 @@ describe('WebServer.renderIndexHtml', () => { }); }); + it('reads as unavailable for a CLI disabled via the registry, even though it is installed', async () => { + // The bug this guards: a CLI toggled off in Settings (docs/cli-enable-disable-plan.md) + // still offered itself in the welcome screen / Run menu / mobile overview, because + // window.__codemanCliAvailable was built purely from each resolver's own PATH probe — + // it never consulted the registry's `enabled` flag at all. Installed AND enabled must + // both hold for `isCliAvailable()` (the client-side gate every one of those surfaces + // reads) to read true. + vi.mocked(isCodexAvailable).mockReturnValue(true); + vi.mocked(isClaudeAvailable).mockReturnValue(true); + const path = dataPath('clis.json'); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify({ clis: { codex: { enabled: false } } }, null, 2), { mode: 0o600 }); + reloadCliRegistry(); + try { + const { server } = makeServer({}); + const html = await render(server); + const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]); + expect(flags.codex).toBe(false); // installed, but disabled in the registry + expect(flags.claude).toBe(true); // installed and enabled — unaffected by codex's override + } finally { + writeFileSync(path, JSON.stringify({ clis: {} }, null, 2), { mode: 0o600 }); + reloadCliRegistry(); + } + }); + it('reports which run modes the custom-model Run-menu picker may generate an entry for', async () => { // Read generically off the CLI registry's own capabilities, not a hardcoded id // list — antigravity (`unsupported`) and shell (`kind !== 'agent'`) must be