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.

+
+

CLI management

synced
+

Enable/disable a CLI, install one that's missing, or add your own — without hand-editing ~/.codeman/clis.json.

+
+
+
+ Enable CLI management + Adds the list below and its write endpoints. Off by default: this changes machine configuration, not just what you see. +
+ +
+
+
+

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([]); + }); +});