mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
co-authored by
Claude Sonnet 5
parent
9466acfc1a
commit
da07b38c42
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -38,3 +38,4 @@ export {
|
||||
type CustomModelSessionLike,
|
||||
type CustomModelSwapDisplacement,
|
||||
} from './custom-model-routes.js';
|
||||
export { registerCliRegistryRoutes, readCliManagementEnabled, type CliListItem } from './cli-registry-routes.js';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user