fix(cli-registry): toggling a CLI off in Settings never hid it anywhere else

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD
This commit is contained in:
Devvyn
2026-09-22 07:13:57 +08:00
co-authored by Claude Sonnet 5
parent db4557d94b
commit 0c77dd0a18
3 changed files with 64 additions and 1 deletions
+12
View File
@@ -2810,6 +2810,13 @@ Object.assign(CodemanApp.prototype, {
.join(''); .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) { async toggleCliEnabled(id, checkbox) {
const next = checkbox.checked; const next = checkbox.checked;
const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'PUT', body: { enabled: next } }); 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'); this.showToast(`Failed to ${next ? 'enable' : 'disable'} "${id}"${detail ? `: ${detail}` : ''}`, 'error');
return; return;
} }
window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), [id]: next };
this.applyWelcomeCliVisibility?.();
this.renderMobileOverview?.();
const menu = document.getElementById('runModeMenu');
if (menu) this._refreshRunModeAvailability?.(menu);
await this.loadCliListForSettings(); await this.loadCliListForSettings();
}, },
+23
View File
@@ -1663,6 +1663,29 @@ export class WebServer extends EventEmitter {
// keep without git (issue #236), same reasoning as cloudflared above. // keep without git (issue #236), same reasoning as cloudflared above.
git: isGitAvailable(), 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<string>(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( html = html.replace(
'</head>', '</head>',
() => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>` () => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
+29 -1
View File
@@ -23,8 +23,11 @@ import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-c
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js'; import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js'; import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.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 { 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 // renderIndexHtml probes the real PATH for every CLI, which would make the
// assertions below depend on whatever happens to be installed on the machine // 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 () => { 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 // Read generically off the CLI registry's own capabilities, not a hardcoded id
// list — antigravity (`unsupported`) and shell (`kind !== 'agent'`) must be // list — antigravity (`unsupported`) and shell (`kind !== 'agent'`) must be