feat(cli-registry): add cliManagementEnabled flag and GET /api/clis

Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the #343
review): a synced, default-OFF master flag gating the upcoming CLI
management surface, plus a read-only GET /api/clis endpoint listing
every registry entry (stock + custom, enabled or not) for the
Settings UI. Non-admins in multi-user mode see an empty list rather
than a 403. Write endpoints, auto-install, custom entry CRUD and the
Settings UI list itself land in later phases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
This commit is contained in:
Devvyn
2026-09-21 15:20:16 +08:00
co-authored by Claude Sonnet 5
parent 9466acfc1a
commit da07b38c42
9 changed files with 221 additions and 5 deletions
+6 -4
View File
@@ -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
+14
View File
@@ -2363,6 +2363,20 @@
</div>
<p class="set-section-blurb">Launch flags for the CLIs Codeman spawns.</p>
<div class="set-group" id="cliManagementGroup">
<div class="set-group-head"><h4>CLI management</h4><span class="set-scope">synced</span></div>
<p class="set-group-hint">Enable/disable a CLI, install one that's missing, or add your own — without hand-editing ~/.codeman/clis.json.</p>
<div class="set-group-body">
<div class="set-row" data-search="cli management enable disable install custom">
<div class="set-row-text">
<span class="set-row-label">Enable CLI management</span>
<span class="set-row-desc">Adds the list below and its write endpoints. Off by default: this changes machine configuration, not just what you see.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCliManagement"><span class="slider"></span></label>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Claude</h4><span class="set-scope">synced</span></div>
<div class="set-group-body">
+3
View File
@@ -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,
+109
View File
@@ -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<boolean> {
const settings = await readJsonConfig<Record<string, unknown>>(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<string, () => Promise<boolean>> = {
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<boolean> {
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 };
});
}
+1
View File
@@ -38,3 +38,4 @@ export {
type CustomModelSessionLike,
type CustomModelSwapDisplacement,
} from './custom-model-routes.js';
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
+9
View File
@@ -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
+2
View File
@@ -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.
@@ -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',
+77
View File
@@ -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([]);
});
});