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