feat(cli-registry): CLI management write API + Settings UI (Phases 1-6) (#476)

* 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

* feat(cli-registry): Phases 3-6 - write API + custom entries + Settings UI

Completes docs/cli-enable-disable-plan.md ("PR C" from the #343 review).

Phase 3: PUT /api/clis/:id toggles enabled for any EXISTING entry (stock or
custom) via a shallow merge onto its clis.json override; shell/claude are
structurally un-disableable (Decision 4), an unknown id 404s rather than
becoming a creation backdoor.

Phase 4: POST /api/clis/:id/install runs a STOCK entry's already-vetted
install command (shell:true, bounded by timeout, process-group killed on
expiry, output captured, audit-logged). A custom entry's id is refused
outright, independent of anything Phase 5 does (Decision 3: a custom
entry's install text is display-only, never executed).

Phase 5: POST /api/clis (create) / PUT /api/clis/custom/:id (update) /
DELETE /api/clis/:id (custom only) — a deliberately minimal request shape
(id/label/shortBadge/binaries/a simple launch variant), assembled into a
full CliEntry with conservative capability defaults and re-validated
through CliEntrySchema before writing, never a relaxed path for
UI-originated entries. Stock-id collisions, duplicate custom ids, and
edits/deletes against a stock id are all rejected explicitly.

Phase 6: the Settings UI section (App Settings -> Agents & CLIs), gated
independently on cliManagementEnabled AND admin-in-multi-user-mode
(Decision 5), fetching/rendering GET /api/clis and wiring every write
endpoint above.

Every write endpoint answers the same way when the feature is off: 403
FORBIDDEN via one shared requireCliManagementGate() (Phase 1's own
checklist item). registry-writer.ts is a new, deliberately separate write
module so registry.ts itself stays import-side-effect-free, same tmp+
rename+0600 shape as custom-model-hosts.ts.

27 new/updated route tests covering every gate, collision, and cleanup
path; full CI gate green (415/416 files, 7854 tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* 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

* docs(cli-registry): update the CLI-management plan with status, gotchas, and the Run-menu gap

Phases 1-6 were implemented across two commits (da07b38c, db4557d9) with no
corresponding update to the plan doc itself — every checklist still read
Status: TODO and every box unchecked. Brings the doc in line with the tree:

- A new "Status as of 2026-09-22" section up top: what's actually
  implemented (verified by grepping the routes/schema/UI, not just trusting
  the commit messages), the availability-flag staleness bug found and fixed
  in this session (commit 0c77dd0a) with its devbox verification record, and
  one real outstanding gap.

- The outstanding gap: a custom CLI created via Phase 5's write API has no
  way to actually be launched. The Run menu is static per-mode markup with
  no consumer of window.__codemanCliCatalog, so Phase 6's own "create a
  custom entry, confirm it can be launched" verify step was never actually
  exercised against this. Documented with two candidate fixes, neither
  started.

- Each phase's checklist flipped to [x] where confirmed present in the tree,
  Status lines updated from TODO to DONE, and the two originally-open
  questions (Phase 2's installed source, Phase 5's PUT endpoint shape)
  marked resolved against what actually shipped.

No code changes in this commit — documentation only, so a future session
(or the one already mid-flight on a separate checkout of this same branch)
picks up accurate status instead of a stale plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* docs: add the CLI-registry deployment plan and the parked Copilot plan

Both were sitting as untracked scratch files in the master checkout,
never committed to any branch. Moving them here rather than leaving them
loose:

- DEPLOYMENT_PLAN.md is the live tracker for the CLI-registry follow-up
  series (PR A #347 merged, PR B #380 merged, PR B2 merged as #458) and
  is where PR C (this branch's own CLI-management work) belongs.
- docs/copilot-integration-plan.md is explicitly PARKED, referenced by
  name in docs/cli-enable-disable-plan.md's own header as a sibling plan
  tracked separately — kept for continuity, not active on this branch.

The other scratch files found alongside these (PRA.md, PRB.md, PR-B2.md
and their review-response counterparts) described PR A/B/B2, all now
merged — deleted from the master checkout as stale rather than committed
anywhere, since their content is superseded by the real merged PRs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* fix(cli-registry): render enabled CLIs in launch surfaces

* test(cli-registry): update frontend branch guard

* fix(test): isolate suite from deployment environment

* fix(cli-registry): revise Decision 4 - claude is toggleable, shell stays permanent

shell/claude were both structurally un-disableable in the original plan
(Decision 4). Revised: shell keeps the hard backend guarantee (it is the
one non-agent mode several code paths assume always exists as a raw-
terminal fallback), but claude is now a normal toggleable entry like any
other CLI.

Safe to do because internal session creation (tmux-manager.ts, session.ts,
Ralph, plan-orchestrator) resolves a CLI via getCli(), which does not
check `enabled` at all - only the Run menu and the HTTP-facing
sessionModeSchema() (new session requests through the normal API) key off
it. Disabling claude therefore behaves identically in kind to disabling
any other CLI: no internal fallback path breaks, it just stops being
offered for new sessions until re-enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): hide shell's toggle entirely instead of greying it out

A permanently-disabled switch next to every other row's working toggle
read as broken rather than intentional. shell now renders no switch at
all - a plain "Always available" label - so there is nothing to click
that could look like it should work but doesn't. Backend guard is
unchanged (UNDISABLEABLE_IDS still refuses shell unconditionally); this
is UI-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): sort the Installed CLIs list, installed-first then alphabetical

renderCliList() previously rendered in registry order (each entry's fixed
order field). Now sorts installed CLIs first, then not-installed, each
group alphabetical by label - matches how a user actually scans the list
(what's ready to use, then what needs installing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* style: prettier fixes from the master merge

* fix(cli-registry): install/edit take effect immediately, confirm before install, phone labels

Four gaps found verifying #476 against the #343 review trail:

- Installed or edited CLIs kept reading as missing/stale. Every binary lookup
  (the nine per-CLI resolvers and the generic registry one) caches in its own
  closure, with a negative-cache backoff of up to 5 minutes, and nothing
  cleared them. invalidateCliExecutableResolvers(binaries) now drops those
  caches per binary; install (success or failure), create, edit and delete
  call it plus invalidateCliResolverCache(id). Before this, a CLI installed
  from Settings could fail to launch for minutes, and an edited custom entry
  kept launching its old binary until a restart.
- The Settings "installed" badge for a custom entry used a private `which`,
  ignoring the entry's searchDirs and the login-shell lookup that spawn and
  the Run menu use; it now asks the same generic resolver they do.
- Install ran on a single click. The #343 review asked for auto-install to
  sit behind an explicit confirm; the confirm now names the exact command,
  which GET /api/clis returns for stock entries only (installCommand).
- The phone Run button showed the two-letter tab badge ("CC", "CX") instead
  of the word ("Claude", "Codex"). It uses the registry label again, which is
  identical to the old static table for every stock CLI (now pinned).

14 new tests; 9 of them fail against the previous head and pass here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): address #476 review — safe serialized writes, no id branches, docs

Must-fix:
- registry-writer: start fresh only on ENOENT; refuse (409) a clis.json that
  does not parse or has group/world permission bits instead of overwriting it
  (isUnsafePermissions now exported from registry.ts)
- mutateRegistryFile(): one promise chain for every mutation, with the
  existence/duplicate checks inside the serialized step, plus a unique tmp
  name per write
- docs: CLAUDE.md, architecture-invariants, cli-registry (new Settings
  section) and api-reference (the six /api/clis routes)
- drop DEPLOYMENT_PLAN.md and docs/copilot-integration-plan.md

Smaller:
- PUT /api/clis/custom/:id keeps the entry's current enabled state when the
  body omits it
- runMode setter falls back to the first enabled catalogue entry, not 'claude'
- shell guard keyed on kind === 'shell' (routes + Settings list); stock probe
  map shared with server.ts via utils/cli-installed-probes.ts
- stock claude label is now 'Claude Code', so the Run menu / phone overview
  label rewrites are gone (doctor row keeps "Claude CLI" via its override)
- welcome buttons are translatable again and read "Run Claude Code" /
  "Run Shell"; zh-CN gains "Run Codex" / "Run OMP"
- install: per-id in-flight guard (409) and CODEMAN_* stripped from its env
- fileoverview / CliEnableSchema comments no longer say stock-only
- test-env isolation changes moved to their own PR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* test(cli-registry): pin the #343/#347 findings #476 makes reachable

A CLI toggled or created through the routes is accepted or rejected by
CreateSessionSchema with no restart (#343 finding 2), and a custom CLI created
through the API renders a real local, remote and docker launch command
(#347 finding 5: no more `cd <path> && undefined`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-09-24 01:48:26 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c46e87fd7a
commit 0a52a99ca9
34 changed files with 2898 additions and 253 deletions
+115
View File
@@ -0,0 +1,115 @@
/**
* @fileoverview Write side of the CLI registry (docs/cli-enable-disable-plan.md, Phases 3/5).
*
* Kept deliberately SEPARATE from `registry.ts`, whose reading path does no writes on import
* (`schemas.ts` imports it, transitively). Only `cli-registry-routes.ts` imports this module,
* so that property still holds for every OTHER importer of the registry.
*
* Every mutation goes through `mutateRegistryFile()`, which does three things the #476 review
* found missing:
*
* - **Serialized.** Mutations run one at a time on a single promise chain, and each one
* reads, changes, writes and reloads before the next starts. Unserialized read-modify-write
* lost toggles when three `PUT /api/clis/:id` calls ran in parallel.
* - **Refuses a file it must not trust.** The reader ignores a `clis.json` with any
* group/world permission bit and quarantines one that does not parse. The writer used to
* treat both as "start fresh", so one Settings click replaced a hand-edited file with a
* one-key file, or rewrote a refused file as 0600 and so trusted it. It now starts fresh
* ONLY on ENOENT and otherwise throws `RegistryWriteRefusedError`, leaving the file alone.
* - **Unique temp file.** Every write gets its own tmp name before the rename, so two writes
* can never rename each other's temp file away (the ENOENT-on-rename 500s).
*
* Same tmp+rename+0600 shape as `custom-model-hosts.ts`. The file is hand-editable, so a
* write must never leave it half-written, and 0600 is the mode `isUnsafePermissions()`
* requires on the next read.
*/
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { isUnsafePermissions, registryFilePath, reloadCliRegistry } from './registry.js';
import type { CliRegistryFile } from './types.js';
/** A write refused because the existing `clis.json` must not be overwritten. The message is user-facing. */
export class RegistryWriteRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = 'RegistryWriteRefusedError';
}
}
/**
* Read the raw override file for mutation. Only a MISSING file starts fresh. A file with
* unsafe permissions, one that cannot be read, or one that does not parse is refused rather
* than overwritten, because the user's hand-edit is worth more than one toggle.
*/
export async function readRegistryFileForWrite(): Promise<CliRegistryFile> {
const path = registryFilePath();
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { schemaVersion: 1, clis: {} };
throw new RegistryWriteRefusedError(`Cannot read ${path} (${(err as Error).message}); not changing it.`);
}
if (isUnsafePermissions(path)) {
throw new RegistryWriteRefusedError(
`${path} has group/world permission bits, so Codeman ignores it. Run \`chmod 600 ${path}\` and check its contents before changing CLIs here.`
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new RegistryWriteRefusedError(
`${path} is not valid JSON (${(err as Error).message}). Fix or remove it before changing CLIs here.`
);
}
const clis = (parsed as { clis?: unknown } | null)?.clis;
if (typeof parsed !== 'object' || parsed === null || typeof clis !== 'object' || clis === null) {
throw new RegistryWriteRefusedError(`${path} has no "clis" object. Fix or remove it before changing CLIs here.`);
}
return parsed as CliRegistryFile;
}
export async function writeRegistryFile(file: CliRegistryFile): Promise<void> {
const target = registryFilePath();
const dir = dirname(target);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
await fs.rename(tmp, target);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/**
* Run one registry mutation. The chain holds exactly one at a time: `fn` receives the
* current file and returns `{ file, result }`. If `file` is set it is written and the
* registry reloaded before the next mutation starts; if not, nothing is written, which is
* how a validation failure returns early. Checks made inside `fn` (does this id exist,
* is it a duplicate) therefore see every earlier mutation's result.
*
* A failed mutation rejects its own caller only. The chain keeps going.
*/
export function mutateRegistryFile<T>(
fn: (file: CliRegistryFile) => Promise<{ file?: CliRegistryFile; result: T }> | { file?: CliRegistryFile; result: T }
): Promise<T> {
const run = mutationChain.then(async () => {
const current = await readRegistryFileForWrite();
const { file, result } = await fn(current);
if (file) {
await writeRegistryFile(file);
reloadCliRegistry();
}
return result;
});
mutationChain = run.catch(() => {});
return run;
}
+14 -1
View File
@@ -48,6 +48,16 @@ function filePath(): string {
return dataPath('clis.json');
}
/**
* The resolved path of `~/.codeman/clis.json`, exported for the write API
* (`cli-registry-writer.ts`, docs/cli-enable-disable-plan.md Phases 3/5) so both the read and
* write sides resolve the SAME path through the SAME instance-scoped helper — never a second
* `dataPath('clis.json')` call that could drift from this one under a future `dataPath()` change.
*/
export function registryFilePath(): string {
return filePath();
}
/**
* Keys that must never be merged out of a hand-editable JSON file.
*
@@ -90,8 +100,11 @@ export interface LoadResult {
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
* this check cannot see and does not attempt to.
*
* Exported for `registry-writer.ts`, which must refuse the same files: rewriting a refused
* file as 0600 would silently turn it into trusted config.
*/
function isUnsafePermissions(path: string): boolean {
export function isUnsafePermissions(path: string): boolean {
if (process.platform === 'win32') return false;
try {
const mode = statSync(path).mode & 0o777;
+1 -1
View File
@@ -90,7 +90,7 @@ function agentDefaults(): Pick<
// `accent` still has no reader, so nothing rendered changes because of it.
const CLAUDE: CliEntry = {
id: 'claude' as CliEntry['id'],
label: 'Claude',
label: 'Claude Code',
shortBadge: 'CC',
accent: '#3b82f6',
enabled: true,
+6 -4
View File
@@ -670,12 +670,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
+3 -1
View File
@@ -69,7 +69,9 @@ const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
* shown to the user and claude's does not follow the pattern.
*/
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
claude: { usedBy: ['Claude Code sessions (default backend)'] },
// The label override keeps the doctor row's historical "Claude CLI" spelling now that
// the registry label is the product name, "Claude Code".
claude: { label: 'Claude CLI', usedBy: ['Claude Code sessions (default backend)'] },
opencode: { usedBy: ['OpenCode sessions'] },
codex: { usedBy: ['Codex sessions'] },
gemini: { usedBy: ['Gemini sessions'] },
+31
View File
@@ -204,6 +204,28 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
};
}
/**
* Per-binary invalidation generation, bumped by `invalidateCliExecutableResolvers()`.
*
* Every resolver instance (each per-CLI module's private one AND the generic registry
* resolver in cli-resolver.ts) is built by the factory below and caches in its own
* closure, so there is no instance to reach from outside. Keying on the BINARY name is
* what lets one call reach all of them: the CLI-management install/update routes know
* which binaries just changed, and every resolver knows its own.
*/
const binaryGenerations = new Map<string, number>();
/**
* Forget every cached result — success and negative-cache backoff alike — for these
* binaries, so the next `resolve()` re-runs the chain immediately. For an action that
* just changed what is on disk (an install) or what a CLI's binary IS (editing a custom
* entry): without it a CLI installed from Settings kept reading as missing for up to the
* 5-minute backoff, and an edited entry kept launching its old binary until a restart.
*/
export function invalidateCliExecutableResolvers(binaries: readonly string[]): void {
for (const binary of binaries) binaryGenerations.set(binary, (binaryGenerations.get(binary) ?? 0) + 1);
}
export function createCliExecutableResolver<T = undefined>(
options: {
binary: string;
@@ -235,6 +257,8 @@ export function createCliExecutableResolver<T = undefined>(
let failures = 0;
/** Timestamp of the most recent miss. */
let lastFailureAt = 0;
/** The invalidation generation the cached state above belongs to. */
let generation = binaryGenerations.get(options.binary) ?? 0;
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
@@ -249,6 +273,13 @@ export function createCliExecutableResolver<T = undefined>(
return {
resolve() {
const current = binaryGenerations.get(options.binary) ?? 0;
if (current !== generation) {
generation = current;
cached = null;
failures = 0;
lastFailureAt = 0;
}
if (cached) return cached;
// Negative cache: a miss is remembered and the chain — whose login-shell
// tail is a synchronous 5s-bounded spawn — is not re-run until the
+69
View File
@@ -0,0 +1,69 @@
/**
* @fileoverview One answer to "is this CLI installed here?", shared by the page render
* (`renderIndexHtml` in server.ts, which injects `window.__codemanCliAvailable` and
* `window.__codemanCliCatalog`) and `GET /api/clis` (the Settings list's badge).
*
* The two used to keep their own copies of the per-CLI probe map, so they could drift apart
* and the badge could disagree with the Run menu.
*
* Every probe is a memoized resolver, so this is cheap to call per request. Dynamic imports
* keep the nine resolvers out of any module that never asks.
*/
import type { CliEntry } from '../config/cli-registry/types.js';
import { isCliAvailable as isRegistryCliAvailable } from './cli-resolver.js';
/**
* The stock CLIs whose own resolver answers availability. It keeps the resolver's specific
* semantics (pi/grok/deepseek identity probes). DeepSeek reports RUNNABLE here, not merely
* installed: `dsh` is a profile launcher, and a dsh with no pane-capable profile would
* offer a Run button that spawns a pane which dies on arrival.
*/
export async function probeStockCliAvailability(): Promise<Record<string, boolean>> {
const [
{ isClaudeAvailable },
{ isOpenCodeAvailable },
{ isCodexAvailable },
{ isGeminiAvailable },
{ isAntigravityAvailable },
{ isPiAvailable },
{ isGrokAvailable },
{ isDeepSeekRunnable },
{ isOmpAvailable },
] = await Promise.all([
import('./claude-cli-resolver.js'),
import('./opencode-cli-resolver.js'),
import('./codex-cli-resolver.js'),
import('./gemini-cli-resolver.js'),
import('./antigravity-cli-resolver.js'),
import('./pi-cli-resolver.js'),
import('./grok-cli-resolver.js'),
import('./deepseek-cli-resolver.js'),
import('./omp-cli-resolver.js'),
]);
return {
claude: isClaudeAvailable(),
opencode: isOpenCodeAvailable(),
codex: isCodexAvailable(),
gemini: isGeminiAvailable(),
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
grok: isGrokAvailable(),
deepseek: isDeepSeekRunnable(),
omp: isOmpAvailable(),
};
}
/**
* Is `entry` installed? A shell entry has no binary to probe, since it is the server's own
* login shell. A stock entry with a dedicated resolver uses `stockAvailability`. Anything
* else, custom entries included, uses the registry's GENERIC resolver. That is the one a
* session spawn uses, and it understands the entry's declared binaries and search dirs.
*/
export function isCliEntryInstalled(entry: CliEntry, stockAvailability: Record<string, boolean>): boolean {
if (entry.kind === 'shell') return true;
const id = entry.id as string;
return Object.prototype.hasOwnProperty.call(stockAvailability, id)
? stockAvailability[id]
: isRegistryCliAvailable(id);
}
+2
View File
@@ -111,11 +111,13 @@
Run: '运行',
'Run Claude Code': '运行 Claude Code',
'Run OpenCode': '运行 OpenCode',
'Run Codex': '运行 Codex',
'Run Gemini': '运行 Gemini',
'Run Antigravity': '运行 Antigravity',
'Run Pi': '运行 Pi',
'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek',
'Run OMP': '运行 OMP',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
+60 -59
View File
@@ -451,42 +451,11 @@
<h1 class="welcome-title">Codeman</h1>
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
<div class="welcome-actions">
<button class="welcome-btn welcome-btn-claude" id="welcomeClaudeBtn" style="display: none;" onclick="app.setRunMode('claude'); app.runClaude()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Claude Code
</button>
<div class="welcome-cli-actions" id="welcomeCliActions"></div>
<button class="welcome-btn welcome-btn-tunnel" id="welcomeTunnelBtn" style="display: none;" onclick="app.toggleTunnelFromWelcome()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
Cloudflare Tunnel
</button>
<button class="welcome-btn welcome-btn-opencode" id="welcomeOpencodeBtn" style="display: none;" onclick="app.setRunMode('opencode'); app.runOpenCode()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run OpenCode
</button>
<button class="welcome-btn welcome-btn-antigravity" id="welcomeAntigravityBtn" style="display: none;" onclick="app.setRunMode('antigravity'); app.runAntigravity()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Antigravity
</button>
<button class="welcome-btn welcome-btn-gemini" id="welcomeGeminiBtn" style="display: none;" onclick="app.setRunMode('gemini'); app.runGemini()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Gemini
</button>
<button class="welcome-btn welcome-btn-pi" id="welcomePiBtn" style="display: none;" onclick="app.setRunMode('pi'); app.runPi()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Pi
</button>
<button class="welcome-btn welcome-btn-grok" id="welcomeGrokBtn" style="display: none;" onclick="app.setRunMode('grok'); app.runGrok()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Grok
</button>
<button class="welcome-btn welcome-btn-deepseek" id="welcomeDeepSeekBtn" style="display: none;" onclick="app.setRunMode('deepseek'); app.runDeepSeek()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run DeepSeek
</button>
<button class="welcome-btn welcome-btn-omp" id="welcomeOmpBtn" style="display: none;" onclick="app.setRunMode('omp'); app.runOmp()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run OMP
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -648,39 +617,13 @@
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="run-mode-menu" id="runModeMenu">
<button class="run-mode-option" data-mode="claude" onclick="app.setRunMode('claude')">
<span class="run-mode-dot claude"></span>Claude Code
</button>
<button class="run-mode-option" data-mode="opencode" onclick="app.setRunMode('opencode')">
<span class="run-mode-dot opencode"></span>OpenCode
</button>
<button class="run-mode-option" data-mode="codex" onclick="app.setRunMode('codex')">
<span class="run-mode-dot codex"></span>Codex
</button>
<button class="run-mode-option" data-mode="gemini" onclick="app.setRunMode('gemini')">
<span class="run-mode-dot gemini"></span>Gemini
</button>
<button class="run-mode-option" data-mode="antigravity" onclick="app.setRunMode('antigravity')">
<span class="run-mode-dot antigravity"></span>Antigravity
</button>
<button class="run-mode-option" data-mode="pi" onclick="app.setRunMode('pi')">
<span class="run-mode-dot pi"></span>Pi
</button>
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
<span class="run-mode-dot grok"></span>Grok
</button>
<button class="run-mode-option" data-mode="deepseek" onclick="app.setRunMode('deepseek')">
<span class="run-mode-dot deepseek"></span>DeepSeek
</button>
<div class="run-mode-cli-options" id="runModeCliOptions"></div>
<!-- Shown only when `dsh` is installed but no pane-capable profile is:
DeepSeek ships no terminal front door, so the fix is an install,
not a greyed-out entry the user cannot act on. -->
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
</button>
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
<span class="run-mode-dot omp"></span>OMP
</button>
<!-- Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): one
generated entry per (harness, saved endpoint) pair, e.g. "Claude Code
(llama.cpp)". Built entirely by _refreshCustomModelRunOptions() — hidden
@@ -2370,6 +2313,64 @@
</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" onchange="app.applyCliManagementVisibility()"><span class="slider"></span></label>
</div>
</div>
</div>
<div class="set-group" id="cliListGroup" style="display: none;">
<div class="set-group-head"><h4>Installed CLIs</h4></div>
<div class="set-group-body">
<div id="cliListRows"></div>
<div class="set-row" data-search="add custom cli">
<div class="set-row-text">
<span class="set-row-label">Add a custom CLI</span>
<span class="set-row-desc">A launch command Codeman doesn't ship — id, label, badge, binary and its bare argv.</span>
</div>
<button type="button" class="btn btn-xs" id="cliCustomAddToggle" onclick="app.openCliCustomForm()">Add</button>
</div>
<form id="cliCustomForm" style="display: none;" onsubmit="app.submitCliCustomForm(event)">
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Id</span></div>
<input type="text" id="cliCustomId" class="set-input" placeholder="my-cli" maxlength="24">
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Label</span></div>
<input type="text" id="cliCustomLabel" class="set-input" placeholder="My CLI" maxlength="60">
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Badge</span></div>
<input type="text" id="cliCustomBadge" class="set-input" placeholder="MC" maxlength="6">
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Binary</span></div>
<input type="text" id="cliCustomBinary" class="set-input" placeholder="my-cli">
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Launch argv</span>
<span class="set-row-desc">Space-separated bare words, e.g. "my-cli --flag". No quoting or shell syntax.</span>
</div>
<input type="text" id="cliCustomArgv" class="set-input" placeholder="my-cli --flag">
</div>
<div class="set-row">
<button type="submit" class="btn btn-xs" id="cliCustomSubmit">Create</button>
<button type="button" class="btn btn-xs" id="cliCustomCancel" onclick="app.closeCliCustomForm()">Cancel</button>
</div>
<div id="cliCustomFormError" class="set-row-desc" style="color: var(--error, #e5484d); display: none;"></div>
</form>
</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">
+21 -3
View File
@@ -42,13 +42,14 @@ const MOBILE_OVERVIEW_PHONE_QUERY = '(max-width: 599px)';
/** How many past conversations show before the "Show all" toggle. */
const MOBILE_OVERVIEW_PAST_LIMIT = 8;
const SHELL_KIND = 'shell';
/**
* Backends offered by the Run picker, mirroring the toolbar's run-mode menu
* (`#runModeMenu` in index.html). `short` is the badge on the Run button itself.
*/
const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'claude', label: 'Claude Code', short: 'Claude' },
{ mode: 'claude', label: 'Claude Code', short: 'Claude Code' },
{ mode: 'opencode', label: 'OpenCode', short: 'OpenCode' },
{ mode: 'codex', label: 'Codex', short: 'Codex' },
{ mode: 'gemini', label: 'Gemini', short: 'Gemini' },
@@ -68,6 +69,23 @@ const MOBILE_OVERVIEW_RUN_MODES = [
const WATCHING_BADGE_TEXT = 'watching';
const watchingBadgeTitle = (label) => 'Still running in the background: ' + label;
function mobileOverviewRunModes() {
const catalog =
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
if (catalog.length === 0) return MOBILE_OVERVIEW_RUN_MODES;
return catalog
.filter((entry) => entry.enabled)
.map((entry) => ({
mode: entry.id,
label: entry.kind === SHELL_KIND ? 'Terminal / Shell' : entry.label,
// The registry `label`, not `shortBadge`: the Run button has always shown a word
// ("Claude", "Codex", "Shell"), and every stock label IS that word, so this stays
// identical to MOBILE_OVERVIEW_RUN_MODES above. `shortBadge` is the two-letter tab
// code ("CC", "CX"), which read as a regression on the button.
short: entry.label,
}));
}
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
const MOBILE_OVERVIEW_PILL_LABEL = {
needs: 'needs you',
@@ -527,7 +545,7 @@ Object.assign(CodemanApp.prototype, {
const runMode = document.createElement('span');
runMode.className = 'mobile-overview-run-mode';
runMode.setAttribute('data-i18n-skip', '');
runMode.textContent = MOBILE_OVERVIEW_RUN_MODES.find((m) => m.mode === mode)?.short || mode;
runMode.textContent = mobileOverviewRunModes().find((m) => m.mode === mode)?.short || mode;
run.appendChild(runMode);
group.appendChild(run);
@@ -582,7 +600,7 @@ Object.assign(CodemanApp.prototype, {
menu.className = 'mobile-overview-run-menu';
const current = this.runMode || 'claude';
for (const entry of MOBILE_OVERVIEW_RUN_MODES) {
for (const entry of mobileOverviewRunModes()) {
if (entry.mode !== 'shell' && !this.isCliAvailable(entry.mode)) continue;
const option = document.createElement('button');
option.type = 'button';
+74 -14
View File
@@ -123,6 +123,19 @@ const RUN_MODE_LAUNCH = {
* ninth CLI landed in one and not the other.
*/
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
function registryCliCatalog() {
return typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
}
function registryCliById(id) {
return registryCliCatalog().find((entry) => entry.id === id);
}
function isExternalCliRunMode(mode) {
return EXTERNAL_CLI_MODES.has(mode) || registryCliById(mode)?.kind === 'agent';
}
Object.assign(CodemanApp.prototype, {
/**
@@ -511,7 +524,7 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'shell') {
return await this.runShell();
}
if (mode === 'claude' || !EXTERNAL_CLI_MODES.has(mode)) {
if (mode === 'claude' || !isExternalCliRunMode(mode)) {
return await this.runClaude();
}
return await this._runCliMode(mode);
@@ -544,6 +557,7 @@ Object.assign(CodemanApp.prototype, {
e?.stopPropagation();
const menu = document.getElementById('runModeMenu');
if (!menu) return;
this.renderRegistryRunOptions();
menu.classList.toggle('active');
// Update selected state
menu.querySelectorAll('.run-mode-option').forEach(btn => {
@@ -602,13 +616,13 @@ Object.assign(CodemanApp.prototype, {
// reporting it as a fault hid every agent mode on a freshly linked Docker case
// behind "start it yourself first", for a container Codeman was about to create.
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (!btn) continue;
for (const option of menu.querySelectorAll('.run-mode-option[data-mode]')) {
const mode = option.dataset.mode;
if (!mode || mode === 'shell') continue;
let available;
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
else available = this.isCliAvailable(mode);
btn.style.display = available ? 'flex' : 'none';
option.style.display = available ? 'flex' : 'none';
}
this._renderRunModeNotice(menu, probeError);
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
@@ -627,6 +641,29 @@ Object.assign(CodemanApp.prototype, {
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
},
/** Render every enabled agent entry from the server's registry projection. */
renderRegistryRunOptions() {
const container = document.getElementById('runModeCliOptions');
if (!container) return;
const catalog = registryCliCatalog();
if (catalog.length === 0) return; // cached pages from before the catalog keep their static fallback.
container.replaceChildren();
for (const cli of catalog) {
if (cli.kind !== 'agent' || !cli.enabled) continue;
const option = document.createElement('button');
option.type = 'button';
option.className = 'run-mode-option';
option.dataset.mode = cli.id;
option.onclick = () => this.setRunMode(cli.id);
const dot = document.createElement('span');
dot.className = `run-mode-dot ${cli.id}`;
dot.setAttribute('aria-hidden', 'true');
option.appendChild(dot);
option.append(cli.label);
container.appendChild(option);
}
},
/**
* Generates the Run menu's Custom Model Endpoint entries
* (docs/custom-model-endpoints-plan.md): one button per (capable harness, saved
@@ -1640,7 +1677,8 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run';
const registryEntry = registryCliById(mode);
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : registryEntry ? `Run ${registryEntry.shortBadge}` : 'Run';
}
},
@@ -1667,7 +1705,12 @@ Object.assign(CodemanApp.prototype, {
},
_initRunMode() {
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
this.renderRegistryRunOptions();
let savedMode = 'claude';
try { savedMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { /* localStorage unavailable */ }
// Go through the setter so a CLI disabled after the previous visit, or a
// removed custom CLI, cannot survive in localStorage as a runnable mode.
this.runMode = savedMode;
this._applyRunMode();
},
@@ -2131,7 +2174,15 @@ Object.assign(CodemanApp.prototype, {
* tests assert on that name directly too.
*/
async _runCliMode(mode) {
const entry = RUN_MODE_LAUNCH[mode];
const catalogEntry = registryCliById(mode);
const entry = RUN_MODE_LAUNCH[mode] ||
(catalogEntry && {
label: catalogEntry.label,
installHint: `${catalogEntry.label} is not available on this host.`,
supportsCustomModel: false,
buildConfig: () => null,
});
if (!entry) throw new Error(`Unknown run mode: ${mode}`);
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run the CLI on the OTHER side — the local status
// probe and the local-only config/env below don't apply (quick-start
@@ -2147,7 +2198,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
try {
if (!isRemote) {
if (!isRemote && RUN_MODE_LAUNCH[mode]) {
const statusRes = await fetch(`/api/${mode}/status`);
const status = (await statusRes.json()).data;
if (!status.available) {
@@ -2158,6 +2209,9 @@ Object.assign(CodemanApp.prototype, {
this._reportSessionLaunchError(ownsLaunchTerminal, entry.unrunnableHint);
return;
}
} else if (!isRemote && !this.isCliAvailable(mode)) {
this._reportSessionLaunchError(ownsLaunchTerminal, entry.installHint);
return;
}
const globalSettings = this.loadAppSettingsFromStorage();
@@ -2294,7 +2348,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = EXTERNAL_CLI_MODES.has(session.mode);
const isAltMode = isExternalCliRunMode(session.mode);
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -4547,9 +4601,15 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
return this._runMode || 'claude';
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
? mode
: 'claude';
const entry = registryCliById(mode);
if ((entry && entry.enabled) || (!entry && BUILT_IN_RUN_MODES.has(mode))) {
this._runMode = mode;
return;
}
// A disabled (or unknown) mode falls back to the first ENABLED catalogue entry, never a
// hardcoded 'claude': claude can be disabled too, and the server rejects a disabled mode.
const catalog = registryCliCatalog();
const firstEnabled = catalog.find((cli) => cli.enabled && cli.kind === 'agent') || catalog.find((cli) => cli.enabled);
this._runMode = firstEnabled ? firstEnabled.id : 'claude';
},
});
+328 -20
View File
@@ -410,6 +410,12 @@ 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;
// Same reasoning as applyCustomModelEndpointsVisibility above: assigning
// .checked fires no onchange, so the list's visibility (and lazy load)
// needs an explicit sync on every open, not just a save.
this.applyCliManagementVisibility();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -1307,29 +1313,53 @@ Object.assign(CodemanApp.prototype, {
return flags[tool] !== false;
},
/** Render the registry's enabled, available CLIs as welcome-screen actions. */
renderWelcomeCliActions() {
const container = document.getElementById('welcomeCliActions');
if (!container) return;
const catalog = Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
container.replaceChildren();
for (const cli of catalog) {
if (!cli.enabled || !this.isCliAvailable(cli.id)) continue;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `welcome-btn welcome-btn-cli welcome-btn-${cli.id}`;
btn.dataset.mode = cli.id;
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
icon.setAttribute('width', '20');
icon.setAttribute('height', '20');
icon.setAttribute('viewBox', '0 0 24 24');
icon.setAttribute('fill', 'none');
icon.setAttribute('stroke', 'currentColor');
icon.setAttribute('stroke-width', '2');
icon.setAttribute('aria-hidden', 'true');
const play = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
play.setAttribute('points', '5 3 19 12 5 21 5 3');
icon.appendChild(play);
btn.appendChild(icon);
// Same "Run <label>" text the static buttons had ("Run Claude Code", "Run Shell"),
// left translatable on purpose: i18n.js carries these strings, and a custom CLI's
// label simply has no dictionary entry, so it renders as typed.
btn.append(`Run ${cli.kind === 'shell' ? 'Shell' : cli.label}`);
btn.onclick = () => {
this.setRunMode(cli.id);
void this.run();
};
container.appendChild(btn);
}
},
/**
* #200: show a welcome-screen button only where the thing it launches exists.
* The markup ships them hidden, so an old cached page can never flash a button
* for a tool this server does not have.
* #200: show a welcome-screen action only where the thing it launches exists.
* The registry catalog is injected with the initial document and is updated in
* place after a Settings toggle, so the page never offers a disabled CLI.
*/
applyWelcomeCliVisibility() {
const buttons = [
['welcomeClaudeBtn', 'claude'],
['welcomeOpencodeBtn', 'opencode'],
['welcomeAntigravityBtn', 'antigravity'],
['welcomeOmpBtn', 'omp'],
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'],
['welcomeDeepSeekBtn', 'deepseek'],
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
['welcomeTunnelBtn', 'cloudflared'],
];
for (const [id, tool] of buttons) {
const btn = document.getElementById(id);
if (btn) btn.style.display = this.isCliAvailable(tool) ? 'flex' : 'none';
}
this.renderWelcomeCliActions();
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
const tunnel = document.getElementById('welcomeTunnelBtn');
if (tunnel) tunnel.style.display = this.isCliAvailable('cloudflared') ? 'flex' : 'none';
},
async loadTunnelStatus() {
@@ -2128,6 +2158,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,
@@ -2723,6 +2754,282 @@ Object.assign(CodemanApp.prototype, {
}
},
// ═══════════════════════════════════════════════════════════════
// CLI management (docs/cli-enable-disable-plan.md)
//
// CRUD against /api/clis, rendered into the Agents & CLIs settings section.
// Same load/save-pair-outside-openAppSettings reasoning as the Custom Model
// Endpoints block above: these are server-side registry records, not a
// settings-payload field — only the `cliManagementEnabled` toggle itself
// goes through openAppSettings/saveAppSettings.
// ═══════════════════════════════════════════════════════════════
/**
* Same two-caller shape as applyCustomModelEndpointsVisibility (assigning
* .checked fires no change event, so this needs both an explicit call on
* open AND the checkbox's own onchange) and the same reasoning for hiding
* the whole list rather than showing it disabled: with the flag off the
* rows would be controls that only 403.
*/
applyCliManagementVisibility() {
const enabled = document.getElementById('appSettingsCliManagement').checked;
const group = document.getElementById('cliListGroup');
if (group) group.style.display = enabled ? '' : 'none';
if (enabled) this.loadCliListForSettings();
else this.closeCliCustomForm();
this._applyCliManagementAdminGate();
},
/**
* Decision 5 (docs/cli-enable-disable-plan.md): hidden entirely for a
* non-admin in multi-user mode, not shown-empty. GET /api/clis already
* answers a non-admin with [], which empties the row list on its own; the
* "Add a custom CLI" row has no list row to hide behind, so it needs its
* own gate the same way the Custom Model Endpoints "+ Add" button does.
*/
_applyCliManagementAdminGate() {
const group = document.getElementById('cliListGroup');
if (!group) return;
const me = window.__codemanUser || {};
const blocked = me.multiUser && me.role !== 'admin';
const featureOn = document.getElementById('appSettingsCliManagement')?.checked ?? false;
group.style.display = blocked || !featureOn ? 'none' : '';
const addRow = document.getElementById('cliCustomAddToggle');
if (addRow) addRow.style.display = blocked ? 'none' : '';
},
async loadCliListForSettings() {
// GET /api/clis wraps its body in the { success, data } envelope like every
// other /api route — _apiJson() unwraps it, same reasoning as the Custom
// Model Endpoints list load above.
const clis = await this._apiJson('/api/clis');
this._cliList = Array.isArray(clis) ? clis : [];
this._syncCliLaunchCatalog();
this.renderCliList();
},
/** Keep the launch surfaces in sync with Settings mutations without a reload. */
_syncCliLaunchCatalog() {
if (!Array.isArray(this._cliList) || this._cliList.length === 0) return;
window.__codemanCliCatalog = this._cliList.map((cli) => ({
id: cli.id,
label: cli.label,
shortBadge: cli.shortBadge,
order: cli.order,
kind: cli.kind,
enabled: cli.enabled,
available: cli.kind === 'shell' || (cli.enabled && cli.installed),
}));
window.__codemanCliAvailable = {
...(window.__codemanCliAvailable || {}),
...Object.fromEntries(this._cliList.map((cli) => [cli.id, cli.kind === 'shell' || (cli.enabled && cli.installed)])),
};
if (!window.__codemanCliCatalog.some((cli) => cli.id === this.runMode && cli.enabled)) {
this.setRunMode?.('claude');
}
this.applyWelcomeCliVisibility?.();
this.renderRegistryRunOptions?.();
this.renderMobileOverview?.();
const menu = document.getElementById('runModeMenu');
if (menu) this._refreshRunModeAvailability?.(menu);
},
renderCliList() {
const list = document.getElementById('cliListRows');
if (!list) return;
const clis = [...(this._cliList || [])].sort((a, b) => {
// Installed CLIs first, alphabetically; then not-installed, alphabetically.
if (a.installed !== b.installed) return a.installed ? -1 : 1;
return a.label.localeCompare(b.label);
});
if (clis.length === 0) {
list.innerHTML = '<p class="set-group-hint">No CLIs found.</p>';
return;
}
// Mirrors cli-registry-routes.ts's own isUndisableable(): a kind 'shell' entry
// is the one the backend refuses to ever disable (keyed on kind, never an id).
// Revised 2026-09-23: rather
// than render a permanently-greyed switch for it (which read as "broken"
// next to every other row's working toggle), shell gets NO switch at all —
// a plain "Always available" label, so there is nothing to click that
// could look like it should work but doesn't.
list.innerHTML = clis
.map((c) => {
const idArg = escapeHtml(JSON.stringify(c.id));
const untoggleable = c.kind === 'shell';
const installBtn =
c.stock && !c.installed
? `<button type="button" class="btn-toolbar btn-sm" onclick="app.installCliEntry(${idArg})" id="cliInstallBtn-${escapeHtml(c.id)}">Install</button>`
: '';
const customActions = c.stock
? ''
: `<button type="button" class="btn-toolbar btn-sm" onclick="app.openCliCustomForm(${idArg})">Edit</button>
<button type="button" class="btn-toolbar btn-danger btn-sm" onclick="app.deleteCliCustom(${idArg})">Delete</button>`;
const toggle = untoggleable
? '<span class="set-row-desc">Always available</span>'
: `<label class="switch switch-sm">
<input type="checkbox" ${c.enabled ? 'checked' : ''} onchange="app.toggleCliEnabled(${idArg}, this)">
<span class="slider"></span>
</label>`;
return `
<div class="set-row" data-cli-id="${escapeHtml(c.id)}">
<div class="set-row-text">
<span class="set-row-label">${escapeHtml(c.label)} <span class="set-scope">${escapeHtml(c.shortBadge)}</span></span>
<span class="set-row-desc">${c.installed ? 'Installed' : 'Not installed'}${c.stock ? '' : ' · custom'}</span>
</div>
<div class="set-row-actions">
${installBtn}
${customActions}
${toggle}
</div>
</div>`;
})
.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) {
const next = checkbox.checked;
const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'PUT', body: { enabled: next } });
if (!res || !res.ok) {
checkbox.checked = !next; // revert on failure — the row must not lie about server state
let detail = '';
try {
detail = (await res?.json())?.error || '';
} catch {
/* no body to read */
}
this.showToast(`Failed to ${next ? 'enable' : 'disable'} "${id}"${detail ? `: ${detail}` : ''}`, 'error');
return;
}
await this.loadCliListForSettings();
},
async installCliEntry(id) {
// Installing runs a command on the server, so it never happens on a single click:
// the confirm names the exact command POST /api/clis/:id/install would run (the
// #343 review's "auto-install may end up behind an explicit confirm").
const entry = (this._cliList || []).find((c) => c.id === id);
const label = entry?.label || id;
const command = entry?.installCommand;
const prompt = command
? `Install ${label}? This runs the following on the Codeman server:\n\n${command}`
: `Install ${label}? This runs its official install command on the Codeman server.`;
if (!confirm(prompt)) return;
const btn = document.getElementById(`cliInstallBtn-${id}`);
if (btn) {
btn.disabled = true;
btn.textContent = 'Installing…';
}
try {
const res = await this._api(`/api/clis/${encodeURIComponent(id)}/install`, { method: 'POST' });
if (!res || !res.ok) {
let detail = '';
try {
detail = (await res?.json())?.error || '';
} catch {
/* no body to read */
}
this.showToast(`Installing "${id}" failed${detail ? `: ${detail}` : ''}`, 'error');
return;
}
this.showToast(`Installed "${id}"`, 'success');
} finally {
await this.loadCliListForSettings();
}
},
/**
* Pass no id to create a new entry; pass an existing CUSTOM id to edit one.
* ⚠️ GET /api/clis deliberately excludes discovery/launch (Phase 2's own
* scope), so an edit cannot be pre-filled with the entry's existing binary
* or argv — those two fields start blank and must be re-entered, since the
* update endpoint (PUT /api/clis/custom/:id) replaces the whole launch
* spec rather than patching it. id/label/badge DO come from the list row.
*/
openCliCustomForm(editId) {
const form = document.getElementById('cliCustomForm');
const errorEl = document.getElementById('cliCustomFormError');
if (!form) return;
const existing = editId ? (this._cliList || []).find((c) => c.id === editId) : null;
this._editingCliCustomId = existing ? existing.id : null;
document.getElementById('cliCustomId').value = existing ? existing.id : '';
document.getElementById('cliCustomId').disabled = !!existing; // id is immutable once created
document.getElementById('cliCustomLabel').value = existing ? existing.label : '';
document.getElementById('cliCustomBadge').value = existing ? existing.shortBadge : '';
document.getElementById('cliCustomBinary').value = '';
document.getElementById('cliCustomArgv').value = '';
document.getElementById('cliCustomSubmit').textContent = existing ? 'Save' : 'Create';
if (errorEl) errorEl.style.display = 'none';
form.style.display = '';
},
closeCliCustomForm() {
const form = document.getElementById('cliCustomForm');
if (form) form.style.display = 'none';
this._editingCliCustomId = null;
},
/** Wired to #cliCustomForm's onsubmit; `event` is the submit event. */
async submitCliCustomForm(event) {
event.preventDefault();
const errorEl = document.getElementById('cliCustomFormError');
const showError = (msg) => {
if (errorEl) {
errorEl.textContent = msg;
errorEl.style.display = '';
}
};
const id = document.getElementById('cliCustomId').value.trim();
const label = document.getElementById('cliCustomLabel').value.trim();
const shortBadge = document.getElementById('cliCustomBadge').value.trim();
const binaries = document.getElementById('cliCustomBinary').value.trim().split(/\s+/).filter(Boolean);
const argv = document.getElementById('cliCustomArgv').value.trim().split(/\s+/).filter(Boolean);
if (!id || !label || !shortBadge || binaries.length === 0 || argv.length === 0) {
showError('All fields are required.');
return;
}
const editing = this._editingCliCustomId;
const path = editing ? `/api/clis/custom/${encodeURIComponent(editing)}` : '/api/clis';
const method = editing ? 'PUT' : 'POST';
const res = await this._api(path, { method, body: { id, label, shortBadge, binaries, argv } });
if (!res || !res.ok) {
let detail = 'Request failed';
try {
detail = (await res?.json())?.error || detail;
} catch {
/* no body to read */
}
showError(detail);
return;
}
this.closeCliCustomForm();
await this.loadCliListForSettings();
},
async deleteCliCustom(id) {
const entry = (this._cliList || []).find((c) => c.id === id);
if (!confirm(`Delete custom CLI "${entry?.label || id}"? This cannot be undone.`)) return;
const res = await this._api(`/api/clis/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res || !res.ok) {
let detail = '';
try {
detail = (await res?.json())?.error || '';
} catch {
/* no body to read */
}
this.showToast(`Failed to delete "${id}"${detail ? `: ${detail}` : ''}`, 'error');
return;
}
await this.loadCliListForSettings();
},
// ═══════════════════════════════════════════════════════════════
// Visibility Settings & Device-Specific Defaults
// ═══════════════════════════════════════════════════════════════
@@ -3798,4 +4105,5 @@ Object.assign(CodemanApp.prototype, {
// evaluation, not just this feature.
document.addEventListener?.('codeman:me', () => {
window.app?._applyCustomModelAdminGate?.();
window.app?._applyCliManagementAdminGate?.();
});
+36
View File
@@ -4045,6 +4045,27 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
margin-bottom: 1.5rem;
}
.welcome-cli-actions {
display: contents;
}
.welcome-btn-cli {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
border-color: rgba(148, 163, 184, 0.35);
color: #e2e8f0;
}
.welcome-btn-cli:hover {
background: linear-gradient(135deg, #374151 0%, #4b5563 100%);
border-color: rgba(203, 213, 225, 0.5);
color: #f8fafc;
transform: translateY(-1px);
}
.run-mode-cli-options {
display: contents;
}
.welcome-btn {
display: flex;
align-items: center;
@@ -4089,6 +4110,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transform: translateY(-1px);
}
.welcome-btn-codex {
background: linear-gradient(135deg, #2a0a3e 0%, #350b4d 50%, #400d5e 100%);
border-color: rgba(168, 85, 247, 0.4);
color: #d8b4fe;
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.welcome-btn-codex:hover {
background: linear-gradient(135deg, #400d5e 0%, #581c87 50%, #6b21a8 100%);
box-shadow: 0 4px 20px rgba(168, 85, 247, 0.3), 0 0 40px rgba(88, 28, 135, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(192, 132, 252, 0.5);
color: #e9d5ff;
transform: translateY(-1px);
}
/* Antigravity: cyan identity, matching .btn-toolbar.btn-run.mode-antigravity and
.run-mode-dot.antigravity so the welcome action reads as the same backend. */
.welcome-btn-antigravity {
+553
View File
@@ -0,0 +1,553 @@
/**
* @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.
*
* Phase 2: `GET /api/clis` — read-only list, ungated (reading is cheap, not the risky part).
* Phase 3: `PUT /api/clis/:id` — enable/disable an EXISTING entry, stock or custom; 404 for an
* id that does not exist, so this endpoint can never become a backdoor for creating an entry
* (that's Phase 5's job).
* Phase 4: `POST /api/clis/:id/install` — runs a STOCK entry's already-vetted install command
* (never a custom entry's — Decision 3). Never auto-enables; Phase 3's endpoint is still
* the only thing that flips `enabled`.
* Phase 5: `POST /api/clis` (create) / `PUT /api/clis/custom/:id` (update) / `DELETE
* /api/clis/:id` (custom only) — a deliberately separate write surface from Phase 3's, so
* "stock entries can only have `enabled` toggled, custom entries can be fully edited"
* stays structurally true rather than depending on every caller remembering the rule.
*
* Every registry mutation runs through `mutateRegistryFile()` (registry-writer.ts): one at a
* time, the existence/duplicate checks inside the same serialized step as the write, and a
* `clis.json` that is corrupt or has unsafe permissions refused with 409 rather than
* overwritten.
*
* Every write endpoint answers the SAME way when `cliManagementEnabled` is off: 403
* FORBIDDEN with a message naming the setting, via `requireCliManagementGate()`.
*
* 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, same tmp+rename+0600
* write path (`registry-writer.ts` mirrors `custom-model-hosts.ts`).
*/
import { spawn } from 'node:child_process';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
import { getAuthUser, isAdmin, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { listClis, resolveInstallCommandForPlatform } from '../../config/cli-registry/registry.js';
import { mutateRegistryFile, RegistryWriteRefusedError } from '../../config/cli-registry/registry-writer.js';
import { CliEntrySchema } from '../../config/cli-registry/schema.js';
import { STOCK_CLIS } from '../../config/cli-registry/stock.js';
import type { CliEntry } from '../../config/cli-registry/types.js';
import { CliCustomEntrySchema, CliEnableSchema } from '../schemas.js';
import { appendAdminAudit } from '../admin-audit.js';
import { invalidateCliExecutableResolvers } from '../../utils/cli-executable-resolver.js';
import { invalidateCliResolverCache } from '../../utils/cli-resolver.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../../utils/cli-installed-probes.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;
/**
* The command `POST /api/clis/:id/install` would run, for a STOCK entry only, so the
* Settings UI can name it in the confirm dialog before anything executes. The same
* display text `missingCliMessage()` already prints in "CLI not found. Install with: …";
* absent for a custom entry, whose install command is never executed (Decision 3).
*/
installCommand?: string;
}
/**
* Forget every cached binary lookup for this CLI — the generic per-id resolver (which
* captures the entry's binaries when first built) and every underlying per-binary cache,
* success and negative-cache backoff alike. Called after anything that changes what is on
* disk or what the CLI's binary IS; see `invalidateCliExecutableResolvers`.
*/
function forgetResolvedCli(id: string, binaries: readonly string[]): void {
invalidateCliExecutableResolvers(binaries);
invalidateCliResolverCache(id);
}
const STOCK_IDS = new Set(STOCK_CLIS.map((e) => e.id as string));
/**
* A `kind: 'shell'` entry can never be disabled — enforced here, not just in the UI (a
* frontend-only guard is bypassable with curl). Keyed on KIND, never on an id, per the
* registry's no-id-branching rule. Revised from Decision 4's original "shell/claude" scope
* (2026-09-23): `claude` is now a normal toggleable entry like any other CLI. Internal
* session creation (tmux-manager.ts, session.ts, Ralph, plan-orchestrator) resolves a CLI
* via `getCli()`, which does NOT check `enabled` at all, so disabling `claude` only affects
* the Run menu and the HTTP-facing `sessionModeSchema()` (new session requests via the
* normal API) — identical in kind to disabling any other CLI, never a break to an internal
* fallback path. The shell keeps the harder guarantee because it is the one non-agent mode
* several code paths assume always exists as a raw-terminal fallback.
*/
function isUndisableable(entry: CliEntry): boolean {
return entry.kind === 'shell';
}
/** A write `mutateRegistryFile()` refused (corrupt or unsafe `clis.json`) becomes a 409 naming the fix. */
function refusedWriteResponse(err: unknown): ApiResponse<never> {
if (err instanceof RegistryWriteRefusedError) {
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
throw err;
}
/**
* Every write endpoint (Phases 3-5) answers the SAME way when the feature is off or the
* caller is a non-admin in multi-user mode: 403 FORBIDDEN. Decided once here rather than
* per-route, per docs/cli-enable-disable-plan.md Phase 1's own checklist item ("decide
* exact behavior... before Phase 3 starts, so all three write endpoints answer the same way").
*/
async function requireCliManagementGate(req: FastifyRequest): Promise<ApiResponse<never> | null> {
if (isMultiUserMode() && !isAdmin(req)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
if (!(await readCliManagementEnabled())) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'CLI management is disabled. Enable it in Settings first.');
}
return null;
}
/**
* Assembles a full, schema-valid `CliEntry` from Phase 5's deliberately minimal request
* shape (id/label/shortBadge/binaries/a simple launch variant — nothing else exposed in
* v1), filling every other required field with conservative, safe defaults: no hooks, no
* mux-optional fallback, no privileged params, no install command (Decision 3: a custom
* entry's install text stays display-only, and there IS none here to display), no custom
* model injection. `CliEntrySchema` re-validates the WHOLE thing below — this function
* only shapes the object, it is not itself the safety layer.
*/
function buildCustomCliEntry(
input: { id: string; label: string; shortBadge: string; binaries: string[]; argv: string[]; enabled: boolean },
order: number
): unknown {
return {
id: input.id,
label: input.label,
shortBadge: input.shortBadge,
accent: '#6b7280',
enabled: input.enabled,
stock: false,
order,
kind: 'agent',
discovery: {
binaries: input.binaries,
searchDirs: [],
install: { command: {} },
},
launch: {
params: {},
variants: [{ id: 'default', args: input.argv.map((tok) => ({ lit: tok })) }],
},
env: {
exports: [],
unset: [],
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
allowedPrefixes: [],
allowedKeys: [],
},
capabilities: {
external: true,
requiresMux: true,
hooks: 'none',
transcript: 'none',
altScreen: 'strip-mux-only',
echo: { policy: 'buffer', anchor: { kind: 'none' } },
wheelForward: { mode: 'never' },
keyboardAccessory: 'agent',
privilegedCommandGate: false,
startMode: 'interactive',
stripInkBloat: false,
ralph: false,
respawn: false,
effort: false,
agentSkillInjection: false,
statusLineTelemetry: false,
model: { source: 'none' },
privilegedParams: [],
privilegedEnvKeys: [],
gates: {},
customModelInjection: { kind: 'unsupported' },
},
overlays: {},
};
}
function nextOrder(): number {
const orders = listClis().map((e) => e.order);
return (orders.length ? Math.max(...orders) : 0) + 10;
}
/** Bounded execution: `PATH_INSTALL_TIMEOUT_MS`, output capped, process GROUP killed on timeout. */
const CLI_INSTALL_TIMEOUT_MS = 300_000;
/**
* Ids with an install running right now. A second request for the same id gets 409 rather
* than a second `curl | bash` or `npm install -g` racing the first over the same prefix.
*/
const installsInFlight = new Set<string>();
/**
* The server's environment minus every `CODEMAN_*` variable. An install script is third-party
* code, and those variables carry Codeman's own secrets and wiring (`CODEMAN_PASSWORD`, the
* data dir, the tmux socket), none of which an installer needs.
*/
export function installEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('CODEMAN_')) env[key] = value;
}
return env;
}
interface InstallResult {
code: number | null;
output: string;
timedOut: boolean;
}
/**
* Runs a STOCK entry's already-vetted install command. `shell: true` is unavoidable here —
* the shipped commands are genuinely `curl | bash` / `npm install -g` one-liners — but this
* is NOT a reopening of the config-shell-text concern the registry's `shellToken` pattern
* exists to prevent: the string executed here is NEVER user input, only ever what is
* already hardcoded and reviewed in `stock.ts` (`resolveInstallCommandForPlatform`), and a
* CUSTOM entry can never reach this function at all — see the route's own guard below.
*/
async function runInstallCommand(command: string): Promise<InstallResult> {
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(command, {
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
// Own process group; the timeout below kills the whole tree by hand, mirroring
// the DeepSeek profile-install endpoint's own reasoning: an install command fans
// out into package-manager children, and spawn's own `timeout` option signals
// only the direct child, leaving survivors holding the pipes open forever.
detached: true,
env: installEnv(),
});
} catch (err) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
return;
}
let output = '';
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
let reapTimer: NodeJS.Timeout | undefined;
const capture = (chunk: Buffer) => {
if (output.length < 16_384) output += chunk.toString('utf-8');
};
child.stdout?.on('data', capture);
child.stderr?.on('data', capture);
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
/* already gone */
}
};
const finish = (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
if (reapTimer) clearTimeout(reapTimer);
resolve({ code, output, timedOut });
};
const timer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
reapTimer = setTimeout(() => finish(null), 8_000);
}, CLI_INSTALL_TIMEOUT_MS);
child.on('error', (err) => {
output = `${output}\n${err.message}`;
finish(null);
});
child.on('close', (code) => finish(code));
});
}
export function registerCliRegistryRoutes(app: FastifyInstance): void {
// ---- Phase 2: read ----------------------------------------------------
// 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.
//
// NOT gated on cliManagementEnabled: reading the list is cheap and is not
// the risky part. 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 stockAvailability = await probeStockCliAvailability();
const data = listClis().map((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: isCliEntryInstalled(entry, stockAvailability),
...(entry.stock ? { installCommand: resolveInstallCommandForPlatform(entry) } : {}),
}));
return { success: true, data };
});
// ---- Phase 3: enable/disable (stock OR custom) -------------------------
// PUT /api/clis/:id — body { enabled }. Toggles an EXISTING entry's
// `enabled` flag, stock or custom alike; a not-yet-existing id is 404,
// never a backdoor into CREATING one (Phase 5 owns creation via its own
// endpoint, POST /api/clis). This is deliberately the one simple toggle
// both kinds of entry share — full custom-entry editing is a SEPARATE path
// (PUT /api/clis/custom/:id) precisely so a caller can flip `enabled`
// without first knowing the rest of a custom entry's shape (its binaries,
// its argv), which the Settings UI list row never carries.
app.put('/api/clis/:id', async (req, reply): Promise<ApiResponse<{ id: string; enabled: boolean }>> => {
const denied = await requireCliManagementGate(req);
if (denied) {
reply.code(403);
return denied;
}
const { id } = req.params as { id: string };
const body = parseBody(CliEnableSchema, req.body);
try {
return await mutateRegistryFile<ApiResponse<{ id: string; enabled: boolean }>>((file) => {
const entry = listClis().find((e) => (e.id as string) === id);
if (!entry) {
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `"${id}" does not exist`) };
}
if (isUndisableable(entry) && !body.enabled) {
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" cannot be disabled`) };
}
const existingOverride = (file.clis[id] as Record<string, unknown> | undefined) ?? {};
file.clis = { ...file.clis, [id]: { ...existingOverride, enabled: body.enabled } };
return { file, result: { success: true as const, data: { id, enabled: body.enabled } } };
});
} catch (err) {
return refusedWriteResponse(err);
}
});
// ---- Phase 4: auto-install (stock only) --------------------------------
// One install per id at a time (409 otherwise), and the script never sees CODEMAN_* env.
// POST /api/clis/:id/install — runs the entry's already-vetted install
// command. Separate endpoint from Phase 3's toggle: installing is a bigger
// action than a boolean flip and gets its own audit entry. Never auto-
// enables — Phase 3's endpoint is still the only thing that flips `enabled`.
app.post(
'/api/clis/:id/install',
async (req, reply): Promise<ApiResponse<{ id: string; code: number | null; output: string }>> => {
const denied = await requireCliManagementGate(req);
if (denied) {
reply.code(403);
return denied;
}
const { id } = req.params as { id: string };
if (!STOCK_IDS.has(id)) {
// Decision 3: a custom entry's install command is NEVER executed, full
// stop — this guard is what makes that true independent of anything
// Phase 5 does, even if a caller invents an id that happens to match
// a custom entry's.
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Auto-install is only available for stock CLIs');
}
const entry = listClis().find((e) => (e.id as string) === id);
if (!entry) return createErrorResponse(ApiErrorCode.NOT_FOUND, `"${id}" is not a stock CLI`);
const command = resolveInstallCommandForPlatform(entry);
if (!command) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `"${id}" has no install command for this platform`);
}
if (installsInFlight.has(id)) {
return createErrorResponse(ApiErrorCode.CONFLICT, `"${id}" is already being installed`);
}
installsInFlight.add(id);
let result: InstallResult;
try {
result = await runInstallCommand(command);
} finally {
installsInFlight.delete(id);
}
// Even a failed or timed-out install may have left a binary behind, so forget the
// cached lookups either way: the next Run click or badge read probes afresh
// instead of replaying a pre-install miss for up to the 5-minute backoff.
forgetResolvedCli(id, entry.discovery.binaries);
const admin = getAuthUser(req).username;
void appendAdminAudit({
admin,
action: 'cli_install',
target: id,
ip: req.ip,
detail: { command, exitCode: result.code, timedOut: result.timedOut },
});
if (result.code !== 0) {
const detail = result.timedOut
? `timed out after ${Math.round(CLI_INSTALL_TIMEOUT_MS / 1000)}s`
: result.output.slice(-1000).trim() || 'no output';
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Installing "${id}" failed: ${detail}`);
}
return { success: true, data: { id, code: result.code, output: result.output.slice(-4000) } };
}
);
// ---- Phase 5: custom CLI entries ----------------------------------------
// POST /api/clis — create a custom entry. Deliberately separate from Phase
// 3's PUT: that endpoint can only ever toggle an EXISTING stock entry, this
// one can only ever create a NEW custom one, so the two write surfaces
// cannot be confused for each other by a caller.
app.post('/api/clis', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
const denied = await requireCliManagementGate(req);
if (denied) {
reply.code(403);
return denied;
}
const body = parseBody(CliCustomEntrySchema, req.body);
if (STOCK_IDS.has(body.id)) {
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`"${body.id}" is a stock CLI id and cannot be used for a custom entry`
);
}
let outcome: ApiResponse<{ id: string }>;
try {
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
if (Object.prototype.hasOwnProperty.call(file.clis, body.id)) {
return {
result: createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `A custom CLI "${body.id}" already exists`),
};
}
const candidate = buildCustomCliEntry({ ...body, enabled: body.enabled ?? true }, nextOrder());
const parsed = CliEntrySchema.safeParse(candidate);
if (!parsed.success) {
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.message) };
}
// Stored WITHOUT id/stock — those are forced back in by resolveRegistry() on every
// read, so the override file never duplicates what the key and provenance already say.
const { id: _id, stock: _stock, ...toStore } = parsed.data;
file.clis = { ...file.clis, [body.id]: toStore };
return { file, result: { success: true as const, data: { id: body.id } } };
});
} catch (err) {
return refusedWriteResponse(err);
}
if (!outcome.success) return outcome;
// A resolver may already exist for this id (a same-named entry deleted earlier in
// this process) and would keep probing that entry's binaries.
forgetResolvedCli(body.id, body.binaries);
return outcome;
});
// PUT /api/clis/custom/:id — full update of an EXISTING custom entry. A
// separate path from Phase 3's PUT /api/clis/:id on purpose: that one is
// structurally stock-only (404s any id it doesn't recognise as stock), so
// there is no shared route where "which fields this id may change" depends
// on a runtime check a caller could get wrong.
app.put('/api/clis/custom/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
const denied = await requireCliManagementGate(req);
if (denied) {
reply.code(403);
return denied;
}
const { id } = req.params as { id: string };
if (STOCK_IDS.has(id)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" is a stock CLI; use PUT /api/clis/${id} instead`);
}
const body = parseBody(CliCustomEntrySchema, { ...(req.body as object), id });
let previousBinaries: readonly string[] = [];
let outcome: ApiResponse<{ id: string }>;
try {
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
if (!Object.prototype.hasOwnProperty.call(file.clis, id)) {
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `No custom CLI "${id}"`) };
}
const existing = listClis().find((e) => (e.id as string) === id);
previousBinaries = existing?.discovery.binaries ?? [];
// The edit form never sends `enabled`, so an absent value keeps the entry's current
// state: editing a disabled CLI must not quietly re-enable it.
const enabled = body.enabled ?? existing?.enabled ?? true;
const candidate = buildCustomCliEntry({ ...body, enabled }, existing?.order ?? nextOrder());
const parsed = CliEntrySchema.safeParse(candidate);
if (!parsed.success) {
return { result: createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.message) };
}
const { id: _id, stock: _stock, ...toStore } = parsed.data;
file.clis = { ...file.clis, [id]: toStore };
return { file, result: { success: true as const, data: { id } } };
});
} catch (err) {
return refusedWriteResponse(err);
}
if (!outcome.success) return outcome;
// The generic resolver captured the OLD binaries when first built; without this a
// session spawn kept launching the previous binary until a restart.
forgetResolvedCli(id, [...previousBinaries, ...body.binaries]);
return outcome;
});
// DELETE /api/clis/:id — refuses any STOCK id outright; deleting only ever
// removes a CUSTOM entry's override.
app.delete('/api/clis/:id', async (req, reply): Promise<ApiResponse<{ id: string }>> => {
const denied = await requireCliManagementGate(req);
if (denied) {
reply.code(403);
return denied;
}
const { id } = req.params as { id: string };
if (STOCK_IDS.has(id)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `"${id}" is a stock CLI and cannot be deleted`);
}
let previousBinaries: readonly string[] = [];
let outcome: ApiResponse<{ id: string }>;
try {
outcome = await mutateRegistryFile<ApiResponse<{ id: string }>>((file) => {
if (!Object.prototype.hasOwnProperty.call(file.clis, id)) {
return { result: createErrorResponse(ApiErrorCode.NOT_FOUND, `No custom CLI "${id}"`) };
}
previousBinaries = listClis().find((e) => (e.id as string) === id)?.discovery.binaries ?? [];
const { [id]: _removed, ...rest } = file.clis;
file.clis = rest;
return { file, result: { success: true as const, data: { id } } };
});
} catch (err) {
return refusedWriteResponse(err);
}
if (!outcome.success) return outcome;
forgetResolvedCli(id, previousBinaries);
return outcome;
});
}
+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';
+42
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
@@ -1997,6 +2006,39 @@ export const CustomModelHostSchema = z.object({
modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(),
});
/**
* A shell-safe bare word, mirroring `config/cli-registry/schema.ts`'s own `shellToken` —
* duplicated rather than imported, since the REAL safety boundary for anything built from
* this is `CliEntrySchema` itself, re-applied server-side once the full entry is assembled
* (`cli-registry-routes.ts`). This is a request-shape sanity check, not the security gate.
*/
const cliShellToken = z
.string()
.min(1)
.max(256)
.regex(/^[A-Za-z0-9._:@=+/,-]+$/, 'must be a plain word with no shell metacharacters');
/** PUT /api/clis/:id (Phase 3) — enable/disable an existing entry, stock or custom; `enabled` is the ONLY thing this endpoint can flip. */
export const CliEnableSchema = z.object({ enabled: z.boolean() });
/**
* POST /api/clis + PUT /api/clis/custom/:id (Phase 5) — a deliberately MINIMAL custom-CLI
* shape (docs/cli-enable-disable-plan.md, Phase 6 checklist: "scope the FIRST version to the
* fields most stock entries actually use"), not the full `CliEntry`. `cli-registry-routes.ts`
* assembles the rest with safe, conservative capability defaults and re-validates the whole
* thing through `CliEntrySchema` before ever writing it — this schema exists to bound the
* REQUEST shape, not to BE the safety layer (Decision 3: typed-argv only, no raw shell text).
*/
export const CliCustomEntrySchema = z.object({
id: z.string().regex(/^[a-z][a-z0-9-]{0,23}$/, 'id must be lowercase, start with a letter, at most 24 chars'),
label: z.string().min(1).max(60),
shortBadge: z.string().min(1).max(6),
enabled: z.boolean().optional(),
binaries: z.array(cliShellToken).min(1).max(4),
/** Bare argv tokens for the single launch variant — no flags-with-values, no params. */
argv: z.array(cliShellToken).min(1).max(16),
});
/** POST /api/sessions/:id/custom-model — apply or clear a session's custom-model selection. */
export const CustomModelSelectionSchema = z.union([
z.object({
+47 -41
View File
@@ -71,7 +71,8 @@ import {
import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { getCli, enabledClis } from '../config/cli-registry/registry.js';
import { getCli, enabledClis, listClis } from '../config/cli-registry/registry.js';
import { isCliEntryInstalled, probeStockCliAvailability } from '../utils/cli-installed-probes.js';
import { readCustomModelHosts } from '../custom-model-hosts.js';
import { applyCustomModelInjection, customModelConfigDir, removeConfigDir } from '../custom-model-injection-apply.js';
import type { CustomModelBookkeeping } from '../types/session.js';
@@ -201,6 +202,7 @@ import {
detectCustomModelSwapDisplacements,
pruneIdleLlamaSwapLogTails,
tryWebviewRefererFallback,
registerCliRegistryRoutes,
} from './routes/index.js';
import { isLostWebviewFrameNavigation } from './webview-proxy.js';
import { CronService } from '../cron/cron-service.js';
@@ -1127,6 +1129,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.
@@ -1620,56 +1623,59 @@ export class WebServer extends EventEmitter {
//
// Solo popups skip it: no settings modal, no welcome screen, no run menu.
if (!soloSessionId) {
const [
{ isClaudeAvailable },
{ isOpenCodeAvailable },
{ isCodexAvailable },
{ isGeminiAvailable },
{ isAntigravityAvailable },
{ isPiAvailable },
{ isGrokAvailable },
{ isDeepSeekRunnable, isDeepSeekAvailable },
{ isOmpAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
import('../utils/claude-cli-resolver.js'),
import('../utils/opencode-cli-resolver.js'),
import('../utils/codex-cli-resolver.js'),
import('../utils/gemini-cli-resolver.js'),
import('../utils/antigravity-cli-resolver.js'),
import('../utils/pi-cli-resolver.js'),
import('../utils/grok-cli-resolver.js'),
import('../utils/deepseek-cli-resolver.js'),
import('../utils/omp-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
const available = {
claude: isClaudeAvailable(),
opencode: isOpenCodeAvailable(),
codex: isCodexAvailable(),
gemini: isGeminiAvailable(),
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
grok: isGrokAvailable(),
// RUNNABLE, not merely installed: `dsh` is a profile launcher, and a dsh
// with no pane-capable profile would offer a Run button that spawns a
// pane which dies on arrival. The Add-Profile affordance in the run menu
// keys off `deepseekBinary` instead, so a user who has the binary but no
// profile is offered the fix rather than a greyed-out entry.
deepseek: isDeepSeekRunnable(),
const [{ isDeepSeekAvailable }, { isCloudflaredAvailable }, { isGitAvailable }, stockAvailability] =
await Promise.all([
import('../utils/deepseek-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
// Shared with GET /api/clis so the Settings badge and the Run menu cannot disagree.
probeStockCliAvailability(),
]);
const available: Record<string, boolean> = {
...stockAvailability,
// `deepseek` above is RUNNABLE (binary + a pane-capable profile). The Add-Profile
// affordance in the run menu keys off `deepseekBinary` instead, so a user who has
// the binary but no profile is offered the fix rather than a greyed-out entry.
deepseekBinary: isDeepSeekAvailable(),
omp: isOmpAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
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 cliCatalog = listClis().map((entry) => {
const id = entry.id as string;
const installed = isCliEntryInstalled(entry, stockAvailability);
const enabled = entry.enabled;
available[id] = enabled && installed;
return {
id,
label: entry.label,
shortBadge: entry.shortBadge,
order: entry.order,
kind: entry.kind,
enabled,
available: enabled && installed,
};
});
html = html.replace(
'</head>',
() => `<script>window.__codemanCliAvailable=${JSON.stringify(available)};</script>\n</head>`
);
// The launch surfaces consume this deliberately small projection rather than
// carrying a second hand-maintained list of CLI ids. It includes disabled
// entries so Settings can redraw immediately after a toggle, while each
// renderer filters on `enabled`/`available` before offering a launch action.
const cliCatalogJson = escapeScriptJson(JSON.stringify(cliCatalog));
html = html.replace('</head>', () => `<script>window.__codemanCliCatalog=${cliCatalogJson};</script>\n</head>`);
// Which run modes the Run-menu picker (docs/custom-model-endpoints-plan.md) may
// generate an entry for: read generically off the registry's `capabilities`
// (never an id list here) so a CLI whose customModelInjection lands later shows