mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
* feat(cli-registry): add cliManagementEnabled flag and GET /api/clis
Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the #343
review): a synced, default-OFF master flag gating the upcoming CLI
management surface, plus a read-only GET /api/clis endpoint listing
every registry entry (stock + custom, enabled or not) for the
Settings UI. Non-admins in multi-user mode see an empty list rather
than a 403. Write endpoints, auto-install, custom entry CRUD and the
Settings UI list itself land in later phases.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n
* 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>
378 lines
19 KiB
TypeScript
378 lines
19 KiB
TypeScript
/**
|
|
* @fileoverview Static guard: no code outside the stock catalog branches on a CLI's ID.
|
|
*
|
|
* The whole point of the registry is that behaviour which differs between CLIs is DATA (a
|
|
* `CliEntry` field) or a NAMED PROFILE selected by a field — never `mode === 'codex'`. A
|
|
* single reintroduced id-check is how the old shape grows back, one "just this once" at a
|
|
* time, until adding a CLI means editing forty files again.
|
|
*
|
|
* This guard was cited by name in three separate file headers of an earlier attempt at this
|
|
* refactor and never actually written — and in its absence four id-branches survived that
|
|
* migration, one of them dead code sitting directly under the generic check that replaced it.
|
|
* So the guard is not decoration: it is the thing that makes the rule true rather than
|
|
* aspirational.
|
|
*
|
|
* ## What is allowlisted, and why an allowlist rather than zero
|
|
*
|
|
* Some branches are not CLI-behaviour branches at all, and forcing them through a capability
|
|
* would make the code worse, not better. Each entry below carries its reason. The categories:
|
|
*
|
|
* - **Legacy `<Mode>Config` plumbing.** `POST /api/sessions` has carried named per-CLI
|
|
* config objects since before the registry, and `docs/versioning-policy.md` makes that
|
|
* wire shape public. Selecting `codexConfig` for codex is a fact about the HTTP API, not
|
|
* about codex, and the `Session` constructor mirrors it. The registry already owns the
|
|
* translation (`launch.legacyConfigField`); collapsing the constructor too is a public-API
|
|
* change and belongs in its own PR.
|
|
* - **Claude's remote/docker command construction.** Claude's pane command varies with the
|
|
* session's permission mode and its docker form is `--session-id … || resume`, semantics
|
|
* no other CLI has and a static `overlays.command` string cannot express.
|
|
* - **Genuinely per-CLI prose.** One error message that explains why a deepseek session in
|
|
* particular will never deliver a `stop` signal.
|
|
*
|
|
* ⚠️ Adding an entry here is a decision, not a formality. If the branch is about what a CLI
|
|
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
|
|
* `config/cli-registry/profiles.ts` as a named profile.
|
|
*
|
|
* Port: none (pure static analysis).
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, relative, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
|
|
|
const SRC = fileURLToPath(new URL('../src', import.meta.url));
|
|
|
|
/**
|
|
* Files exempt from the scan entirely, because naming CLI ids IS their job.
|
|
*
|
|
* `stock.ts` is the catalog. The per-CLI resolver modules are each ABOUT one CLI and look up
|
|
* their own entry by id — the same reason the catalog may, and the reason they are not a
|
|
* loophole: they resolve a binary, they decide no behaviour.
|
|
*/
|
|
const EXEMPT_FILES = new Set(
|
|
[
|
|
'config/cli-registry/stock.ts',
|
|
'utils/claude-cli-resolver.ts',
|
|
'utils/opencode-cli-resolver.ts',
|
|
'utils/codex-cli-resolver.ts',
|
|
'utils/gemini-cli-resolver.ts',
|
|
'utils/antigravity-cli-resolver.ts',
|
|
'utils/pi-cli-resolver.ts',
|
|
'utils/grok-cli-resolver.ts',
|
|
'utils/deepseek-cli-resolver.ts',
|
|
// Names the deepseek launcher profile's implementation; keyed by profile, not by id.
|
|
'utils/cli-launcher.ts',
|
|
].map((p) => p.split('/').join(sep))
|
|
);
|
|
|
|
/**
|
|
* Specific surviving branches, each with the reason it is not a capability.
|
|
* Keyed `<relative path>::<the matched expression>`.
|
|
*/
|
|
const ALLOWED_BRANCHES: Record<string, string> = {
|
|
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
|
|
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing',
|
|
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing',
|
|
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing',
|
|
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing',
|
|
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
|
|
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
|
|
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
|
|
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
|
|
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
|
|
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
|
|
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)',
|
|
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)',
|
|
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)',
|
|
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)',
|
|
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)',
|
|
|
|
// --- Claude's remote/docker command construction ---
|
|
"tmux-manager.ts::mode === 'claude'":
|
|
"claude's remote pane command carries per-session permission flags, and its docker form is " +
|
|
'`--session-id … || resume`; neither fits a static overlays.command string',
|
|
"tmux-manager.ts::mode === 'omp'":
|
|
'remote omp respawn needs the pinned/continue --resume override threaded through ' +
|
|
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
|
|
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
|
|
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode',
|
|
|
|
// --- Per-CLI prose and launch handling not yet generalised ---
|
|
"web/session-wait-registry.ts::mode === 'deepseek'":
|
|
'an error message explaining why THIS mode in particular will never deliver a stop signal',
|
|
"web/routes/approval-routes.ts::mode === 'deepseek'":
|
|
'the DeepSeek status bridge is the only non-claude source of approval items',
|
|
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised',
|
|
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised',
|
|
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id',
|
|
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour',
|
|
|
|
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
|
|
//
|
|
// None of these is a regression: every one predates the registry and survived the
|
|
// conversion only because the guard could not see `!==`. They are listed here with reasons
|
|
// rather than silently converted, because each would change behaviour or invent a
|
|
// capability field, and this change is meant to change nothing a user can see.
|
|
|
|
// Read My Mind + intent capture read CLAUDE's OWN transcript, so `mode === 'claude'` is
|
|
// the right question and `hooksAvailableForMode()` is NOT — once `deepseek` earned a yes
|
|
// there, the shared predicate silently widened both to a mode with no transcript to read.
|
|
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
|
|
// capability here would be actively wrong.
|
|
"web/routes/readmymind-routes.ts::mode !== 'claude'":
|
|
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts',
|
|
"web/server.ts::mode !== 'claude'":
|
|
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
|
|
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)',
|
|
|
|
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
|
|
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
|
|
// purely so the common case reads clean. The badge one is cosmetic and not a capability at
|
|
// all; the resume one would need a "resumable from a claude transcript" field that nothing
|
|
// else would read.
|
|
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction',
|
|
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode',
|
|
|
|
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
|
|
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
|
|
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
|
|
// capability; deliberately not invented here.
|
|
"web/routes/hook-event-routes.ts::mode !== 'deepseek'":
|
|
'push buttons withheld where the answer route refuses keystrokes',
|
|
|
|
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
|
|
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)',
|
|
|
|
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
|
|
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
|
|
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
|
|
// change; expressing the chain as a capability would have to pick a side and would
|
|
// therefore be a behaviour change. Left exactly as found, and named here so it is visible.
|
|
"web/routes/session-routes.ts::mode !== 'opencode'":
|
|
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
|
|
'so any capability form would change behaviour — see PR discussion',
|
|
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)',
|
|
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)',
|
|
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)',
|
|
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)',
|
|
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)',
|
|
};
|
|
|
|
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
|
|
const IDS = STOCK_CLIS.map((e) => e.id as string);
|
|
const ID_ALT = IDS.join('|');
|
|
|
|
/**
|
|
* The shapes an id-branch actually takes, all four of them.
|
|
*
|
|
* ⚠️ An earlier version of this guard matched `===` ONLY, and that was not a small gap: the
|
|
* refactor it guards converted the `===` sites and left the negated ones, so 36
|
|
* `mode !== '<id>'` branches survived it — 28 in session-routes.ts alone, including a
|
|
* seven-mode chain auto-enabling Ralph under a comment asking the next person to keep it in
|
|
* step with a predicate BY HAND, while the sibling quick-start path already read
|
|
* `capabilities.ralph`. A guard that sees half the shapes reports a count measured over the
|
|
* half it happens to catch.
|
|
*
|
|
* `switch`/`case` and `[...].includes(mode)` are here for the same reason: each is a way of
|
|
* writing the banned rule that the narrower pattern could not see.
|
|
*/
|
|
const BRANCH_PATTERN = new RegExp(
|
|
[
|
|
// mode === 'codex' / mode !== 'codex'
|
|
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
|
|
// case 'codex':
|
|
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
|
|
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
|
|
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
|
|
].join('|'),
|
|
'g'
|
|
);
|
|
|
|
/**
|
|
* BLANK comment lines before scanning, rather than dropping them. Comments legitimately quote
|
|
* the very pattern being banned — several of them explain WHY a branch was removed — and
|
|
* flagging those would push the next author to delete the explanation rather than the code.
|
|
*
|
|
* ⚠️ Blanking rather than removing is what keeps reported line numbers pointing at the real
|
|
* file. Dropping the lines shifted every finding upward by however many comments preceded it,
|
|
* so the guard's own diagnostic sent you to the wrong place — which for a rule about not
|
|
* writing a branch is exactly the moment you need the right one.
|
|
*/
|
|
function uncommented(source: string): string {
|
|
return source
|
|
.split('\n')
|
|
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
|
|
.join('\n');
|
|
}
|
|
|
|
function walk(dir: string, out: string[] = []): string[] {
|
|
for (const name of readdirSync(dir)) {
|
|
const full = join(dir, name);
|
|
if (statSync(full).isDirectory()) walk(full, out);
|
|
else if (name.endsWith('.ts')) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface Finding {
|
|
file: string;
|
|
expression: string;
|
|
line: number;
|
|
key: string;
|
|
}
|
|
|
|
function scan(): { findings: Finding[]; filesScanned: number } {
|
|
const findings: Finding[] = [];
|
|
const files = walk(SRC);
|
|
let scanned = 0;
|
|
for (const full of files) {
|
|
const rel = relative(SRC, full);
|
|
if (EXEMPT_FILES.has(rel)) continue;
|
|
scanned++;
|
|
const lines = uncommented(readFileSync(full, 'utf-8')).split('\n');
|
|
lines.forEach((line, i) => {
|
|
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
|
|
for (const match of line.matchAll(BRANCH_PATTERN)) {
|
|
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
|
|
const posix = rel.split(sep).join('/');
|
|
findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` });
|
|
}
|
|
});
|
|
}
|
|
return { findings, filesScanned: scanned };
|
|
}
|
|
|
|
const { findings, filesScanned } = scan();
|
|
|
|
describe('no CLI-id branching outside the stock catalog', () => {
|
|
it('scans a meaningful number of source files (sanity)', () => {
|
|
// If this collapses toward zero the walker or the exemption list drifted and every
|
|
// assertion below would pass vacuously. Fix the scanner, do not delete the test.
|
|
expect(filesScanned).toBeGreaterThan(100);
|
|
});
|
|
|
|
it('builds its id list from the live catalog (sanity)', () => {
|
|
expect(IDS).toContain('claude');
|
|
expect(IDS).toContain('deepseek');
|
|
expect(IDS.length).toBeGreaterThanOrEqual(9);
|
|
});
|
|
|
|
it('still detects a branch when one exists (anti-vacuity)', () => {
|
|
// Proves the pattern actually matches every shape it is meant to ban, so a regex typo
|
|
// cannot silently turn this whole file into a no-op. One case per alternative, because
|
|
// the `===`-only version of this test passed happily while `!==` went unseen.
|
|
const samples = [
|
|
"if (session.mode === 'codex') { doSomething(); }",
|
|
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
|
|
"switch (mode) { case 'gemini': return 1; }",
|
|
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
|
|
];
|
|
for (const sample of samples) {
|
|
BRANCH_PATTERN.lastIndex = 0;
|
|
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
|
|
}
|
|
BRANCH_PATTERN.lastIndex = 0;
|
|
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
|
|
});
|
|
|
|
it('has no unapproved id branches', () => {
|
|
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
|
|
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
|
|
expect(
|
|
offenders,
|
|
offenders.length === 0
|
|
? ''
|
|
: `Found ${offenders.length} CLI-id branch(es) outside the stock catalog:\n${detail}\n\n` +
|
|
'Two ways out, in order of preference:\n' +
|
|
' 1. Express the difference as data on the CliEntry (a CliCapabilities field), or as a\n' +
|
|
' NAMED PROFILE in config/cli-registry/profiles.ts if it genuinely needs to run code.\n' +
|
|
' 2. If it is not a CLI-behaviour branch at all, add it to ALLOWED_BRANCHES in this file\n' +
|
|
" WITH the reason. Read this file's header before choosing option 2."
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('has no stale allowlist entries', () => {
|
|
// An allowlisted branch that no longer exists is a lie about the codebase, and the next
|
|
// person to reintroduce that exact branch would sail straight through.
|
|
const present = new Set(findings.map((f) => f.key));
|
|
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
|
|
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('declared-for-later fields', () => {
|
|
/**
|
|
* The fields `CliEntry`'s header declares as not-yet-read. Each is frontend behaviour, and
|
|
* the frontend is untouched by this change.
|
|
*
|
|
* This is here so the list cannot quietly GROW. An unread field is a promise the code does
|
|
* not keep, and the failure mode is a reader trusting one: the next person sees
|
|
* `echo.policy: 'buffer'` on an entry and assumes the terminal honours it. Adding a field
|
|
* nobody reads should be a decision someone makes on purpose, which means updating this
|
|
* list — and wiring one up should make its line here fail, which is the good direction.
|
|
*/
|
|
const DECLARED_FOR_LATER = [
|
|
'accent',
|
|
'capabilities.echo',
|
|
'capabilities.wheelForward',
|
|
'capabilities.keyboardAccessory',
|
|
'capabilities.maxFrameBytes',
|
|
// The Docker credential-seeding path still reads its own CRED_STORES table: this shape
|
|
// allows ONE store per CLI and the live table needs two for gemini. See CliOverlays.
|
|
'overlays.credStore',
|
|
];
|
|
|
|
/** Read every `.ts` under src/, minus the registry itself (which of course names them). */
|
|
function sourceOutsideRegistry(): string {
|
|
const parts: string[] = [];
|
|
const stack = [SRC];
|
|
while (stack.length > 0) {
|
|
const dir = stack.pop()!;
|
|
for (const name of readdirSync(dir)) {
|
|
const full = join(dir, name);
|
|
if (statSync(full).isDirectory()) {
|
|
if (name !== 'cli-registry') stack.push(full);
|
|
continue;
|
|
}
|
|
if (name.endsWith('.ts')) parts.push(uncommented(readFileSync(full, 'utf-8')));
|
|
}
|
|
}
|
|
return parts.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Receivers whose same-named property is NOT this field. A leaf-name match is all a static
|
|
* check can do, and `cli.ts` calls `palette.accent('admin')` — the terminal colour helper,
|
|
* unrelated to `CliEntry.accent`. Listing the receiver is better than dropping the field
|
|
* from the check: a real read through any OTHER receiver still fails.
|
|
*/
|
|
const UNRELATED_RECEIVERS: Record<string, string[]> = { accent: ['palette'] };
|
|
|
|
const outside = sourceOutsideRegistry();
|
|
|
|
it.each(DECLARED_FOR_LATER)('%s is still unread outside the registry', (field) => {
|
|
const leaf = field.split('.').pop()!;
|
|
const ignore = UNRELATED_RECEIVERS[leaf] ?? [];
|
|
// `.<leaf>` as a property access. Comment lines are already blanked, so a mention in
|
|
// prose does not count as a read; a receiver listed above does not either.
|
|
const pattern = new RegExp(`(\\w*)\\.${leaf}\\b`, 'g');
|
|
const uses = [...outside.matchAll(pattern)].filter((m) => !ignore.includes(m[1])).map((m) => m[0]);
|
|
expect(
|
|
uses,
|
|
`${field} now looks READ outside config/cli-registry. If that is deliberate, drop it ` +
|
|
"from DECLARED_FOR_LATER here and from CliEntry's header comment — the point of both " +
|
|
'is that a reader can tell which fields are load-bearing.'
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('still catches a read when there is one (anti-vacuity)', () => {
|
|
// The check is only worth having if it fires, so prove it against a field that IS read.
|
|
// `capabilities.ralph` is live in session-routes; if this ever stops matching, the
|
|
// scanner has drifted and every assertion above is passing vacuously.
|
|
expect(outside).toMatch(/\.ralph\b/);
|
|
expect(DECLARED_FOR_LATER.length).toBeGreaterThan(0);
|
|
});
|
|
});
|