diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts
index 871a3762..1331ea03 100644
--- a/src/config/cli-registry/types.ts
+++ b/src/config/cli-registry/types.ts
@@ -629,12 +629,14 @@ 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 (docs/cli-enable-disable-plan.md, Phase 2): `GET /api/clis` reads it for the
+ * CLI-management Settings list.
*
* 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/index.html b/src/web/public/index.html
index dd972edc..aa820c4e 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -2363,6 +2363,20 @@
Launch flags for the CLIs Codeman spawns.
+
Claude
synced
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 975efa77..f6111120 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -402,6 +402,8 @@ Object.assign(CodemanApp.prototype, {
// Assigning .checked above does not fire onchange, so the body's visibility
// (and its lazy load) needs an explicit sync on every open, not just a save.
this.applyCustomModelEndpointsVisibility();
+ // CLI management (docs/cli-enable-disable-plan.md): synced, default OFF.
+ document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true;
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -2118,6 +2120,7 @@ Object.assign(CodemanApp.prototype, {
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked,
+ cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked,
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
diff --git a/src/web/routes/cli-registry-routes.ts b/src/web/routes/cli-registry-routes.ts
new file mode 100644
index 00000000..3292da62
--- /dev/null
+++ b/src/web/routes/cli-registry-routes.ts
@@ -0,0 +1,109 @@
+/**
+ * @fileoverview CLI management (docs/cli-enable-disable-plan.md) — "PR C" from the
+ * original #343 review, done in phases with the trust-model scope decided up front
+ * (see that doc's "Decisions" section) rather than folded into a large diff.
+ *
+ * This file currently holds Phase 2 only: `GET /api/clis`, a read-only list of
+ * every registry entry (stock + custom, enabled or not) for the Settings UI.
+ * Phase 3 (write: enable/disable), Phase 4 (auto-install) and Phase 5 (custom
+ * entry CRUD) land as their own additions here, each behind `cliManagementEnabled`.
+ *
+ * Mirrors `custom-model-routes.ts`'s shape for the closest existing precedent:
+ * same admin-gating pattern, same `readXEnabled()` helper shape reading
+ * `settings.json` directly rather than threading the setting through every
+ * caller.
+ */
+
+import type { FastifyInstance, FastifyRequest } from 'fastify';
+import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
+import { isMultiUserMode } from '../../config/multiuser.js';
+import { listClis } from '../../config/cli-registry/registry.js';
+import type { CliEntry } from '../../config/cli-registry/types.js';
+
+/**
+ * `cliManagementEnabled` defaults OFF, same reasoning as
+ * `readCustomModelEndpointsEnabled` in custom-model-routes.ts: this gate gets
+ * checked by every WRITE endpoint (Phases 3-5), so it needs its own reader
+ * rather than threading the setting value through every route handler.
+ */
+export async function readCliManagementEnabled(): Promise {
+ const settings = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {});
+ return settings.cliManagementEnabled === true;
+}
+
+export interface CliListItem {
+ id: string;
+ label: string;
+ shortBadge: string;
+ order: number;
+ kind: CliEntry['kind'];
+ enabled: boolean;
+ stock: boolean;
+ installed: boolean;
+}
+
+/**
+ * Per-STOCK-id installed probes, the same memoized resolvers `renderIndexHtml`
+ * injects into `window.__codemanCliAvailable` (server.ts) — reused rather than
+ * re-probed, since every resolver already memoizes its own PATH lookup for the
+ * process lifetime. Dynamic imports so this module doesn't pay for all nine
+ * resolvers when the CLI-management section is never opened; Node caches the
+ * module after the first call, so repeat requests cost nothing extra.
+ *
+ * ⚠️ STOCK-ONLY. There is no per-id resolver for a CUSTOM entry — Phase 5
+ * (custom CLI creation) needs a GENERIC installed check built from the
+ * entry's own `discovery.binaries`/`searchDirs` directly, not this map. Until
+ * then a custom entry (none can exist before Phase 5 ships) reports `installed:
+ * false` rather than guessing.
+ */
+const STOCK_INSTALLED_PROBES: Record Promise> = {
+ claude: async () => (await import('../../utils/claude-cli-resolver.js')).isClaudeAvailable(),
+ shell: async () => true, // no binary to probe — the server's own login shell
+ opencode: async () => (await import('../../utils/opencode-cli-resolver.js')).isOpenCodeAvailable(),
+ codex: async () => (await import('../../utils/codex-cli-resolver.js')).isCodexAvailable(),
+ gemini: async () => (await import('../../utils/gemini-cli-resolver.js')).isGeminiAvailable(),
+ antigravity: async () => (await import('../../utils/antigravity-cli-resolver.js')).isAntigravityAvailable(),
+ pi: async () => (await import('../../utils/pi-cli-resolver.js')).isPiAvailable(),
+ grok: async () => (await import('../../utils/grok-cli-resolver.js')).isGrokAvailable(),
+ // RUNNABLE (binary + a pane-capable profile), same choice server.ts's
+ // __codemanCliAvailable makes for the identical reason — see its comment.
+ deepseek: async () => (await import('../../utils/deepseek-cli-resolver.js')).isDeepSeekRunnable(),
+ omp: async () => (await import('../../utils/omp-cli-resolver.js')).isOmpAvailable(),
+};
+
+async function probeInstalled(entry: CliEntry): Promise {
+ const probe = STOCK_INSTALLED_PROBES[entry.id as string];
+ return probe ? probe() : false;
+}
+
+export function registerCliRegistryRoutes(app: FastifyInstance): void {
+ // GET /api/clis — every registry entry, disabled ones included (this is an
+ // admin/settings surface; every SPAWN-time caller elsewhere uses
+ // enabledClis() instead). Deliberately excludes launch/env/capabilities/
+ // overlays/discovery — the same rule every other catalogue-export surface in
+ // this codebase follows (scripts/generate-cli-catalog.mts, the reverted PR
+ // B2 window.__codemanCliCatalog before it).
+ //
+ // NOT gated on cliManagementEnabled: reading the list is cheap and is not
+ // the risky part (docs/cli-enable-disable-plan.md, Phase 1). The Settings UI
+ // section simply never fetches this while the flag is off (Phase 6).
+ app.get('/api/clis', async (req: FastifyRequest): Promise<{ success: true; data: CliListItem[] }> => {
+ if (isMultiUserMode() && !isAdmin(req)) {
+ return { success: true, data: [] };
+ }
+ const entries = listClis();
+ const data = await Promise.all(
+ entries.map(async (entry) => ({
+ id: entry.id as string,
+ label: entry.label,
+ shortBadge: entry.shortBadge,
+ order: entry.order,
+ kind: entry.kind,
+ enabled: entry.enabled,
+ stock: entry.stock,
+ installed: await probeInstalled(entry),
+ }))
+ );
+ return { success: true, data };
+ });
+}
diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts
index 8a3f166f..2a7e41b5 100644
--- a/src/web/routes/index.ts
+++ b/src/web/routes/index.ts
@@ -38,3 +38,4 @@ export {
type CustomModelSessionLike,
type CustomModelSwapDisplacement,
} from './custom-model-routes.js';
+export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index f1b1fc22..0b6f18a2 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -1317,6 +1317,15 @@ export const SettingsUpdateSchema = z
* discovery, and the extra toolbar surface are all opt-in.
*/
customModelEndpointsEnabled: z.boolean().optional(),
+ /**
+ * CLI management (docs/cli-enable-disable-plan.md): the Settings UI section that
+ * lets an admin enable/disable a stock CLI, trigger its install, and add/edit/
+ * remove custom CLI entries — all previously hand-edit-only via ~/.codeman/clis.json.
+ * SYNCED, default OFF: this is a machine-configuration surface (like Custom Model
+ * Endpoints), not a display preference, and enabling it is what makes the write
+ * endpoints (PUT/POST/DELETE /api/clis...) answer instead of refusing outright.
+ */
+ cliManagementEnabled: z.boolean().optional(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
diff --git a/src/web/server.ts b/src/web/server.ts
index 8d8f9368..7f2d9b71 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -201,6 +201,7 @@ import {
detectCustomModelSwapDisplacements,
pruneIdleLlamaSwapLogTails,
tryWebviewRefererFallback,
+ registerCliRegistryRoutes,
} from './routes/index.js';
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
import { CronService } from '../cron/cron-service.js';
@@ -1122,6 +1123,7 @@ export class WebServer extends EventEmitter {
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
registerCustomModelRoutes(this.app);
+ registerCliRegistryRoutes(this.app);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.
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/routes/cli-registry-routes.test.ts b/test/routes/cli-registry-routes.test.ts
new file mode 100644
index 00000000..e3fe3886
--- /dev/null
+++ b/test/routes/cli-registry-routes.test.ts
@@ -0,0 +1,77 @@
+/**
+ * @fileoverview Route tests for GET /api/clis (docs/cli-enable-disable-plan.md,
+ * Phase 2). Mirrors the admin-gating test shape used for other admin/settings
+ * surfaces (see test/routes/search-routes.test.ts's multi-user block).
+ *
+ * Port: N/A (app.inject(), no live server).
+ */
+import { afterEach, describe, expect, it } from 'vitest';
+import { createRouteTestHarness } from './_route-test-utils.js';
+import { registerCliRegistryRoutes, type CliListItem } from '../../src/web/routes/cli-registry-routes.js';
+
+describe('GET /api/clis', () => {
+ afterEach(() => {
+ delete process.env.CODEMAN_MULTIUSER;
+ });
+
+ it('single-user mode: returns every registry entry, disabled stock CLIs included', async () => {
+ const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
+ const res = await app.inject({ method: 'GET', url: '/api/clis' });
+ expect(res.statusCode).toBe(200);
+ const body = res.json() as { success: true; data: CliListItem[] };
+ expect(body.success).toBe(true);
+ const ids = body.data.map((c) => c.id);
+ expect(ids).toContain('claude');
+ expect(ids).toContain('shell');
+ expect(ids.length).toBeGreaterThanOrEqual(9);
+ });
+
+ it('every item has the expected shape and excludes spawn-time fields', async () => {
+ const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
+ const res = await app.inject({ method: 'GET', url: '/api/clis' });
+ const body = res.json() as { success: true; data: CliListItem[] };
+ for (const cli of body.data) {
+ 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(typeof cli.enabled).toBe('boolean');
+ expect(typeof cli.stock).toBe('boolean');
+ expect(typeof cli.installed).toBe('boolean');
+ 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('every entry is stock: true (no custom entries exist before Phase 5)', async () => {
+ const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
+ const res = await app.inject({ method: 'GET', url: '/api/clis' });
+ const body = res.json() as { success: true; data: CliListItem[] };
+ expect(body.data.every((c) => c.stock === true)).toBe(true);
+ });
+
+ it('multi-user mode: an admin sees the full list', async () => {
+ process.env.CODEMAN_MULTIUSER = '1';
+ const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
+ authUser: { username: 'root', role: 'admin' },
+ });
+ const res = await app.inject({ method: 'GET', url: '/api/clis' });
+ const body = res.json() as { success: true; data: CliListItem[] };
+ expect(body.data.length).toBeGreaterThanOrEqual(9);
+ });
+
+ it('multi-user mode: a non-admin sees an empty list, not a 403', async () => {
+ process.env.CODEMAN_MULTIUSER = '1';
+ const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
+ authUser: { username: 'bob', role: 'user' },
+ });
+ const res = await app.inject({ method: 'GET', url: '/api/clis' });
+ expect(res.statusCode).toBe(200);
+ const body = res.json() as { success: true; data: CliListItem[] };
+ expect(body.data).toEqual([]);
+ });
+});