Skip to content

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

Merged
Ark0N merged 17 commits into
Ark0N:masterfrom
opticon454:feat/cli-management
Sep 23, 2026
Merged

Ark0N merged 17 commits into
Ark0N:masterfrom
opticon454:feat/cli-management

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

Summary

Completes docs/cli-enable-disable-plan.md — "PR C" from the original #343 review, which deliberately split the trust-model decision (settings UI + write endpoints + auto-install) out of that PR to be decided on its own merits rather than inside a 100-file diff. That decision is now made and implemented in phases.

~/.codeman/clis.json has been read-only since the registry existed. This adds the write side: a Settings UI section to enable/disable a CLI, install one that's missing, or add a custom one — without hand-editing the file.

  • Phase 1 — cliManagementEnabled master feature flag (synced, default OFF). Every write endpoint refuses with 403 FORBIDDEN when it's off.
  • Phase 2 — GET /api/clis: every registry entry (stock + custom, enabled or not), admin-gated to an empty list (never a 403) for a non-admin in multi-user mode.
  • Phase 3 — PUT /api/clis/:id: toggles enabled for any existing entry, stock or custom. shell is structurally un-disableable (several code paths assume a raw-terminal fallback always exists); claude is a normal toggleable entry like any other CLI — internal session creation resolves a CLI via getCli(), which doesn't check enabled at all, so disabling it only affects the Run menu and new-session requests through the API, identical in kind to disabling grok/codex/etc.
  • Phase 4 — POST /api/clis/:id/install: runs a stock entry's already-vetted install command (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 — a custom entry's install text stays 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 the same CliEntrySchema every stock entry goes through — no relaxed path for UI-originated entries. Stock-id collisions and edits/deletes against a stock id are rejected explicitly.
  • Phase 6 — Settings UI (App Settings → Agents & CLIs), gated independently on the flag AND admin-in-multi-user-mode, so it's hidden entirely for a non-admin rather than shown-empty. The Installed CLIs list sorts installed-first, then alphabetically within each group. shell's row shows no toggle at all ("Always available") rather than a permanently-greyed switch that read as broken.

registry-writer.ts is a new, deliberately separate module so registry.ts itself stays import-side-effect-free (its own header documents that property), same tmp+rename+0600 write shape as custom-model-hosts.ts.

Test plan

  • 27 new/updated route tests covering every gate (flag off, admin, unknown id), collision (stock-id reuse, duplicate custom id), and cleanup path
  • Merged against current upstream master (56 commits ahead of the branch point) in an isolated worktree; 3 conflicts, all independent non-contradicting additions at the same insertion point (upstream's watching-badge feature / our disabled-CLI hiding fix; two test files each expecting a different newly-added strip), resolved by keeping both sides
  • Full CI gate on the merged tree, fresh clone: 426/427 test files, 8198 tests, 0 failures
  • npm run typecheck && npm run lint && npm run check:frontend-syntax && npm run check:public-assets && npm run format:check all clean
  • Live-verified end-to-end against a real deployment: flag gate, stock toggle, shell un-disableable guard, claude toggle, custom-entry create/list/delete, auth enforcement

🤖 Generated with Claude Code

opticon454 and others added 14 commits September 21, 2026 15:20
Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the Ark0N#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
…s UI

Completes docs/cli-enable-disable-plan.md ("PR C" from the Ark0N#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
…re 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
…as, and the Run-menu gap

Phases 1-6 were implemented across two commits (da07b38, db4557d) 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 0c77dd0) 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
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 Ark0N#347 merged, PR B Ark0N#380 merged, PR B2 merged as Ark0N#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
…ays 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
… 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
… 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
# Conflicts:
#	src/web/public/mobile-overview.js
#	test/server-index-title.test.ts
#	test/test-env-isolation.test.ts
@opticon454

opticon454 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

@Ark0N This is the last piece of #343
Please review in full and let me know if you have any questions or things to fix

…re install, phone labels

Four gaps found verifying Ark0N#476 against the Ark0N#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 Ark0N#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
@Ark0N

Ark0N commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Thanks @opticon454 for finishing the last piece of #343. This adds the write side of the CLI registry: an opt-in cliManagementEnabled flag, /api/clis endpoints to toggle, install and add custom CLIs, a Settings section for it, and a catalogue-driven Run menu and welcome screen. The gating and the re-validation of custom entries through CliEntrySchema are exactly the shape I wanted, and the full gate is green here.

A few things need fixing before merge, mostly in the new writer:

Must fix

  1. src/config/cli-registry/registry-writer.ts:29: an unparseable clis.json is treated as missing, so the next toggle overwrites it. The registry is memoized, so a hand-edit with a typo made while the server runs is never quarantined; one Settings click then replaces the user's file with a one-key file (I reproduced this). Please refuse the write, or quarantine the file the way readRegistryFile() does, and only start fresh on ENOENT. A test for it would be great.
  2. registry-writer.ts:50: every write uses the same clis.json.<pid>.tmp name and the routes do an unserialized read-modify-write. Three parallel PUT /api/clis/:id calls gave me two 500s (ENOENT on rename) and lost two of the three toggles. Please serialize registry mutations through one promise chain and use a unique tmp suffix.
  3. registry-writer.ts:29: the reader ignores clis.json when it has any group/world permission bit, but the writer reads it anyway and rewrites it 0600, which turns a refused file into trusted config. Please apply the same isUnsafePermissions() check before writing and refuse with a chmod hint.
  4. Docs: CLAUDE.md (CLI registry paragraph) and docs/architecture-invariants.md:116 still say clis.json is read-only, docs/api-reference.md has none of the six new routes (they are public under /api/v1), and docs/cli-registry.md does not mention the Settings UI.
  5. Please drop DEPLOYMENT_PLAN.md (the repo root is kept to tool-required files, and it carries your local paths and session notes) and docs/copilot-integration-plan.md (unrelated parked work). docs/cli-enable-disable-plan.md can stay.

Smaller things (same round if you can)

  • src/web/routes/cli-registry-routes.ts:171: editing a disabled custom CLI re-enables it, since the form never sends enabled and the builder defaults to true. Default to the existing entry's value on update.
  • src/web/public/session-ui.js:4605: with claude disabled, the runMode setter still falls back to 'claude', so the Run button posts a mode the server rejects. Fall back to the first enabled catalogue entry.
  • cli-registry-routes.ts:81 and :135, plus UNTOGGLEABLE in settings-ui.js and the label === 'Claude' rewrites in session-ui.js / mobile-overview.js: these are id (or label) branches the registry rule asks us to avoid. kind === 'shell' covers the un-disableable case, and the stock probe map could be shared with server.ts rather than duplicated.
  • src/web/public/settings-ui.js:1343: the generated welcome buttons are data-i18n-skip and read Run Claude instead of Run Claude Code, so zh-CN loses its existing translations. They also add new Codex and Shell welcome buttons; fine if intended, just say so in the description.
  • cli-registry-routes.ts:397: please add a per-id in-flight guard for install (409 on a second request) and strip CODEMAN_* from the env you hand to the install script.
  • The fileoverview and the CliEnableSchema comment still describe PUT /api/clis/:id as stock-only.
  • The test/setup.ts env strip and the quick-start port move are fine, but would be easier to land as their own small PR.

Once the five must-fix items are in, I'm happy to merge; I can pick up any of the smaller ones at merge time if you run out of time.

… 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
@opticon454

Copy link
Copy Markdown
Contributor Author

@Ark0N thanks for the thorough review. Everything is addressed in 2776ae0, must-fix and smaller items alike.

Must fix

  1. Unparseable clis.json. The writer now starts fresh only on ENOENT. For a file that doesn't parse, or has no clis object, the write is refused with a 409 naming the fix, and the file is left byte-for-byte untouched. There's a test with a trailing-comma hand-edit.
  2. Lost parallel toggles. Every mutation goes through mutateRegistryFile(), one promise chain. The existence and duplicate checks run inside the same serialized step as the write, and the registry is reloaded before the next mutation starts. The temp file is clis.json.<pid>.<uuid>.tmp, and it's cleaned up if the rename fails. There's a test firing four parallel PUT /api/clis/:id: all 200, all four on disk.
  3. Permissions. isUnsafePermissions() is now exported from registry.ts, and the writer refuses (409) with the chmod 600 <path> hint. There's a POSIX test that the file keeps its 0644 mode rather than being rewritten as trusted 0600.
  4. Docs. Updated the CLAUDE.md CLI registry paragraph and architecture-invariants.md (the writer, the serialization, the refusals). api-reference.md gains a CLI management section with all six routes, and cli-registry.md gains a Managing CLIs from Settings section. Also fixed a stale claim I found on the way: shortBadge was still listed as declared-for-later, but the Settings list reads it now.
  5. Stray files. Removed DEPLOYMENT_PLAN.md and docs/copilot-integration-plan.md.

Smaller items

  • Editing a custom CLI. An absent enabled now keeps the entry's current state (tested).
  • runMode setter. A disabled mode now falls back to the first enabled agent in the catalogue, then any enabled entry, and never a hardcoded 'claude' (tested).
  • Id and label branches. The shell guard is kind === 'shell' on both the server and in Settings; UNDISABLEABLE_IDS and UNTOGGLEABLE are gone. The stock probe map now lives in one place, src/utils/cli-installed-probes.ts, which both server.ts and GET /api/clis use. For the label === 'Claude' rewrites, I changed the stock label itself to Claude Code and dropped the rewrites. That regenerates config/clis.stock.json and the install.sh block. The codeman doctor row keeps its historical "Claude CLI" spelling via its existing override map. The phone Run button now reads "Claude Code", the same width as the "Antigravity" it already shows.
  • Welcome buttons. They're translatable again, reading "Run Claude Code" / "Run Shell", which are the existing i18n keys. I added zh-CN entries for "Run Codex" and "Run OMP". The Codex and Shell welcome buttons are intended: the welcome screen now offers every enabled, available registry entry, custom ones included.
  • Install. There's a per-id in-flight guard (a second request gets 409 CONFLICT), and the install script gets the environment with every CODEMAN_* variable removed. Both are tested.
  • Comments. The fileoverview and the CliEnableSchema comment now describe the toggle as covering stock and custom entries.
  • Test-env changes. Split out into test: strip every inherited CODEMAN_* var from the suite (split from #476) #479 and reverted here, so this PR no longer touches test/setup.ts or the quick-start port.

Full gate on Linux: typecheck, lint, format:check, check:frontend-syntax and generate:cli-catalog --check are all clean, and npm test gives 427 files and 8217 tests passing.

Ark0N pushed a commit that referenced this pull request Sep 23, 2026
…ff 3099 (#479)

Split out of #476. A Docker Compose deployment exports CODEMAN_CASES_PATH,
which bypasses the temp HOME, so route tests wrote into the real case root.


Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…kes reachable

A CLI toggled or created through the routes is accepted or rejected by
CreateSessionSchema with no restart (Ark0N#343 finding 2), and a custom CLI created
through the API renders a real local, remote and docker launch command
(Ark0N#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
@Ark0N
Ark0N merged commit 0a52a99 into Ark0N:master Sep 23, 2026
2 checks passed
@Ark0N

Ark0N commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Merged and shipped in 1.33.0 (https://github.com/Ark0N/Codeman/releases/tag/codeman@1.33.0). Thanks @opticon454, this closes out the CLI registry work you started in #343. The fix round was one of the cleanest I've had: every must-fix came back with a test that pins it (the trailing-comma hand-edit, the four parallel toggles, the 0644 file that stays 0644), and moving the probe map into cli-installed-probes.ts and relabelling the stock entry "Claude Code" got rid of the id branches properly instead of just hiding them. I ran the full gate on it merged with current master before landing (427 files, 8223 tests green). It's the headline of this release.

@opticon454
opticon454 deleted the feat/cli-management branch September 24, 2026 00:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants