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
+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 {