mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery (search dirs, version + identity probes), the launch argv template, env handling, the `capabilities` flags that replace per-CLI branching, and the `overlays` that back the remote/docker pane commands. Code that used to ask "which CLI is this?" reads the entry instead. Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every spawn command as a literal string, captured from the hand-written builders before they were deleted, and `test/location-overlay-commands.test.ts` does the same for all 20 remote and in-container pane commands. Config can never contain shell text: an entry declares typed argv tokens, literals are validated against a safe-word pattern at LOAD time (a bad literal rejects the whole entry — a silently dropped `--no-approve` is not cosmetic), and values resolve through patterns NAMED in code, so a user `clis.json` cannot widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only in this release. OMP is included as a registry entry rather than a tenth hand-written builder, so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of `buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen and doctor ladders all drop out. Guard rails: - `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id branching reappears outside `stock.ts`, in any of its four shapes (`===`, `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the negated forms, which is how 36 of them survived an earlier pass. Every allowlisted branch carries its reason. - `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities; deriving one from another shipped the `until=stop`-hangs-on-shell bug. - `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config` wire field is separate, bridged only by `legacyConfigAliases`. Getting `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a wrong name clamps nothing with no error and no failing test — so `schema.ts` rejects an entry naming a param it never declared. - Registry data resolves AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks). A module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. - Six fields are annotated DECLARED-FOR-LATER and read by nothing (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/ `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed rather than measured. A test pins the list so it cannot quietly grow. Three user-visible changes, all deliberate and named: - `probeDockerCliVersion()` derives the in-container binary from the registry rather than assuming it equals the mode name (`antigravity` runs `agy`). - The remote CLI version probe now covers grok and deepseek, which the hardcoded map it replaces omitted while its own comment said the rule was "every mode except shell". - `codeman doctor`'s CLI rows are generated from the entries, so Claude's install hint is the install command rather than a docs URL, five CLIs gain hints they never had, and the row order follows the catalog. Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars (matching the `cliId` pattern) before its failure message quotes the value back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading the hand-editable `clis.json`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
203 lines
9.2 KiB
TypeScript
203 lines
9.2 KiB
TypeScript
/**
|
|
* @fileoverview Bridges the legacy per-mode spawn options (`buildSpawnCommand`'s option bag
|
|
* in tmux-manager.ts, unchanged on the wire since before this registry existed) onto the CLI
|
|
* registry's generic argv engine (`renderLaunch`).
|
|
*
|
|
* The per-mode `<Mode>Config` objects on `POST /api/sessions` predate the registry and stay
|
|
* on the wire for API compatibility (`docs/versioning-policy.md`), so SOMETHING has to know
|
|
* which field holds which CLI's config. That knowledge is DATA — `launch.legacyConfigField`
|
|
* and `launch.legacyConfigAliases`, declared once per entry in `config/cli-registry/stock.ts`
|
|
* — which is what lets this file stay a generic reader rather than a `switch (mode)`.
|
|
*
|
|
* An entry declaring NO `legacyConfigField` reads its params straight off the top-level
|
|
* option bag. That is claude, whose discrete `claudeMode`/`allowedTools`/`model`/
|
|
* `resumeSessionId` fields predate the `<Mode>Config` pattern — not a special case for
|
|
* claude, just the other of the two shapes the wire has always had.
|
|
*
|
|
* @module session-cli-registry-bridge
|
|
*/
|
|
|
|
import type { CliEntry } from './config/cli-registry/types.js';
|
|
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
|
|
import { matchesPattern } from './config/cli-registry/patterns.js';
|
|
import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
|
|
import { compareVersions } from './utils/dependency-checker.js';
|
|
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
|
|
import { launcherDefaultTarget } from './utils/cli-launcher.js';
|
|
import { getCli } from './config/cli-registry/registry.js';
|
|
import type {
|
|
AntigravityConfig,
|
|
ClaudeMode,
|
|
CodexConfig,
|
|
DeepSeekConfig,
|
|
EffortLevel,
|
|
GeminiConfig,
|
|
GrokConfig,
|
|
OmpConfig,
|
|
OpenCodeConfig,
|
|
PiConfig,
|
|
} from './types/session.js';
|
|
|
|
export interface SpawnBridgeOptions {
|
|
mode: string;
|
|
sessionId: string;
|
|
model?: string;
|
|
claudeMode?: ClaudeMode;
|
|
allowedTools?: string;
|
|
openCodeConfig?: OpenCodeConfig;
|
|
codexConfig?: CodexConfig;
|
|
geminiConfig?: GeminiConfig;
|
|
antigravityConfig?: AntigravityConfig;
|
|
piConfig?: PiConfig;
|
|
grokConfig?: GrokConfig;
|
|
deepSeekConfig?: DeepSeekConfig;
|
|
ompConfig?: OmpConfig;
|
|
resumeSessionId?: string;
|
|
effort?: EffortLevel;
|
|
sessionName?: string;
|
|
claudeCliVersion?: string | null;
|
|
}
|
|
|
|
/**
|
|
* The raw legacy config object this entry's params should be read from: the declared
|
|
* `<Mode>Config` field, or the option bag itself when none is declared.
|
|
*/
|
|
function legacyConfigFor(entry: CliEntry, options: SpawnBridgeOptions): Record<string, unknown> | undefined {
|
|
const field = entry.launch.legacyConfigField;
|
|
if (field === undefined) return options as unknown as Record<string, unknown>;
|
|
return (options as unknown as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
|
|
}
|
|
|
|
/**
|
|
* Same lookup, addressed by mode rather than by entry, for callers holding only a mode and an
|
|
* option bag (tmux-manager's env configuration). Returns undefined for an unregistered mode.
|
|
*/
|
|
export function legacyConfigForMode(
|
|
mode: string,
|
|
options: Record<string, unknown>
|
|
): Record<string, unknown> | undefined {
|
|
const entry = getCli(mode);
|
|
if (!entry) return undefined;
|
|
return legacyConfigFor(entry, options as unknown as SpawnBridgeOptions);
|
|
}
|
|
|
|
/**
|
|
* Build `ParamValues` for every declared `token`/`bool`/`enum` param by reading it out of the
|
|
* legacy config object through `legacyConfigAliases` (falling back to the param's own name).
|
|
* `engine`-sourced params are skipped — those come from `EngineValues`, never legacy config.
|
|
*/
|
|
function buildParamsFromLegacyConfig(entry: CliEntry, rawConfig: Record<string, unknown> | undefined): ParamValues {
|
|
const params: ParamValues = {};
|
|
if (!rawConfig) return params;
|
|
const aliases = entry.launch.legacyConfigAliases ?? {};
|
|
for (const [paramName, spec] of Object.entries(entry.launch.params)) {
|
|
if (spec.type === 'engine') continue;
|
|
const legacyKey = aliases[paramName] ?? paramName;
|
|
const value = rawConfig[legacyKey];
|
|
if (value === undefined) continue;
|
|
// Anything that is not already a string or boolean is DROPPED rather than coerced: the
|
|
// wire shape is Zod-validated upstream, so a surprise here means something is wrong,
|
|
// and `String({})` would happily produce a token nobody intended.
|
|
if (typeof value === 'string' || typeof value === 'boolean') {
|
|
params[paramName] = value;
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* The env vars this CLI declares in `env.configSetenv`, resolved from its legacy config
|
|
* object — i.e. the ones whose value comes from the CALLER rather than the server's own
|
|
* environment.
|
|
*
|
|
* ⚠️ Re-validated here against the declared `ParamSpec` even though the wire shape is already
|
|
* Zod-checked upstream. These values reach `tmux setenv`, and for DeepSeek the value IS a
|
|
* permission level: a builder must never trust its caller on a security-relevant field, and
|
|
* the cost of re-checking an enum is nothing.
|
|
*
|
|
* A value that fails validation is DROPPED, not defaulted — which is the safe direction: the
|
|
* var goes unset, and the CLI falls back to its own default (for dsh, `workspace-write`,
|
|
* which asks) rather than to something we guessed.
|
|
*/
|
|
export function configSetenvValues(
|
|
entry: CliEntry,
|
|
rawConfig: Record<string, unknown> | undefined
|
|
): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
const mappings = entry.env.configSetenv;
|
|
if (!mappings || !rawConfig) return out;
|
|
const aliases = entry.launch.legacyConfigAliases ?? {};
|
|
for (const { name, fromParam } of mappings) {
|
|
const spec = entry.launch.params[fromParam];
|
|
if (!spec) continue; // schema-validated at load; belt and braces
|
|
const raw = rawConfig[aliases[fromParam] ?? fromParam];
|
|
if (typeof raw !== 'string') continue;
|
|
if (spec.type === 'enum' && !spec.values.includes(raw)) continue;
|
|
if (spec.type === 'token' && !matchesPattern(spec.pattern, raw)) continue;
|
|
out[name] = raw;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Which `capabilities.gates` are currently satisfied. `resolveVersion` is called AT MOST
|
|
* ONCE, and only when the entry actually declares a gate — a `--version` subprocess probe
|
|
* has no reason to run for an entry with none.
|
|
*/
|
|
function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null): Set<string> {
|
|
const passed = new Set<string>();
|
|
const gateEntries = Object.entries(entry.capabilities.gates);
|
|
if (gateEntries.length === 0) return passed;
|
|
const cliVersion = resolveVersion();
|
|
if (!cliVersion) return passed; // fail-closed: an unknown version satisfies no gate
|
|
for (const [name, gate] of gateEntries) {
|
|
if (compareVersions(cliVersion, gate.minVersion) >= 0) passed.add(name);
|
|
}
|
|
return passed;
|
|
}
|
|
|
|
/**
|
|
* Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a
|
|
* `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall
|
|
* back to the local login-shell resolution" — shell has no CLI to template.
|
|
*/
|
|
export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBridgeOptions): string | undefined {
|
|
if (entry.kind === 'shell' || entry.launch.variants.length === 0) return undefined;
|
|
|
|
const params = buildParamsFromLegacyConfig(entry, legacyConfigFor(entry, options));
|
|
|
|
const engineValues: EngineValues = {
|
|
sessionId: options.sessionId,
|
|
// Allowlist-sanitized (Unicode letters/digits + ` . _ : -`, 64 chars), matching
|
|
// buildNameCliArgs exactly — sanitizeCliSessionName is the injection guard for this
|
|
// value, NOT the `quote: 'double'` escaping on the --name arg (which only makes an
|
|
// unsafe value inert, it does not launder one into something meaningful).
|
|
sessionName: sanitizeCliSessionName(options.sessionName),
|
|
};
|
|
|
|
// Only a launcher CLI has one, and resolving it means a filesystem scan of the launcher's
|
|
// profile tree, so skip the lookup entirely for the eight entries that declare no profile.
|
|
if (entry.discovery.launcherProfile !== undefined) {
|
|
engineValues.launcherDefaultTarget = launcherDefaultTarget(entry) ?? undefined;
|
|
}
|
|
|
|
// Mirrors buildEffortCliArgs exactly: ultracode carries a fixed settings blob, every other
|
|
// level rides a plain `--effort <level>` flag. Reusing the canonical builder here (rather
|
|
// than re-deriving the ultracode special case) keeps the EFFORT_LEVELS allowlist and the
|
|
// settings-JSON shape single-sourced in session-cli-builder.ts.
|
|
const [effortFlag, effortValue] = buildEffortCliArgs(options.effort);
|
|
if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue;
|
|
else if (effortFlag === '--effort') engineValues.effortLevel = effortValue;
|
|
|
|
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
|
|
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
|
|
// "known to be unresolvable" and must not probe. The probe only ever runs from
|
|
// resolveGatesPassed, and only for an entry that actually declares a gate, so this stays
|
|
// generic without spawning a stray `claude --version` for every other CLI's launch.
|
|
const gatesPassed = resolveGatesPassed(entry, () =>
|
|
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
|
|
);
|
|
|
|
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
|
|
}
|