mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
refactor(cli-registry): make CLI backends data instead of per-mode branching
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
This commit is contained in:
+159
-204
@@ -7,10 +7,8 @@
|
||||
* @module config/dependency-registry
|
||||
*/
|
||||
|
||||
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
|
||||
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
|
||||
import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js';
|
||||
import { OMP_VERSION_REGEX } from '../utils/omp-cli-resolver.js';
|
||||
import { enabledClis } from './cli-registry/registry.js';
|
||||
import { compileVersionRegex } from './cli-registry/patterns.js';
|
||||
|
||||
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
|
||||
|
||||
@@ -59,207 +57,164 @@ export interface ToolDependency {
|
||||
|
||||
const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
|
||||
|
||||
export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
{
|
||||
id: 'node',
|
||||
label: 'Node.js',
|
||||
category: 'core',
|
||||
required: true,
|
||||
minVersion: '22.0.0',
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
|
||||
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Claude CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Claude Code sessions (default backend)'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }],
|
||||
installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' },
|
||||
},
|
||||
{
|
||||
id: 'tmux',
|
||||
label: 'tmux',
|
||||
category: 'core',
|
||||
required: true,
|
||||
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
|
||||
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['OpenCode sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
label: 'Codex CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Codex sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
label: 'Gemini CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Gemini sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Antigravity sessions'],
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }],
|
||||
},
|
||||
{
|
||||
id: 'pi',
|
||||
label: 'Pi CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Pi sessions'],
|
||||
// The only entry that requires a version match, for the same reason
|
||||
// pi-cli-resolver.ts probes: `pi` is a short generic name (Raspberry Pi tooling,
|
||||
// personal scripts), so a `which pi` hit alone is not the coding agent. Both sides
|
||||
// share PI_VERSION_REGEX, so the doctor and the run mode cannot drift into telling
|
||||
// the user opposite things about the same binary.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['pi'],
|
||||
versionArg: '--version',
|
||||
versionRegex: PI_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
/**
|
||||
* The doctor's ROW IDENTITY for a CLI, where it differs from the registry id.
|
||||
*
|
||||
* These are two separate contracts and they have never been the same thing: `codeman doctor`
|
||||
* prints a tool table whose ids predate the registry, and `dsh` names the BINARY while the
|
||||
* run mode is `deepseek`. Keeping the historical id here means the doctor's output does not
|
||||
* shift under a refactor that was supposed to change nothing a user can see.
|
||||
*
|
||||
* `usedBy` is likewise preserved verbatim rather than generated, because the strings are
|
||||
* shown to the user and claude's does not follow the pattern.
|
||||
*/
|
||||
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
|
||||
claude: { usedBy: ['Claude Code sessions (default backend)'] },
|
||||
opencode: { usedBy: ['OpenCode sessions'] },
|
||||
codex: { usedBy: ['Codex sessions'] },
|
||||
gemini: { usedBy: ['Gemini sessions'] },
|
||||
antigravity: { usedBy: ['Antigravity sessions'] },
|
||||
pi: { usedBy: ['Pi sessions'] },
|
||||
grok: { usedBy: ['Grok sessions'] },
|
||||
// Both the id and the label are historical: `dsh` names the binary, and the doctor has
|
||||
// always spelled this row out in full rather than as `${label} CLI`.
|
||||
deepseek: { id: 'dsh', label: 'DeepSeek Harness CLI', usedBy: ['DeepSeek sessions'] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Build one `codeman doctor` row per enabled CLI, straight from its registry entry.
|
||||
*
|
||||
* This replaces eight hand-written rows that had to be kept in step with the run modes by
|
||||
* hand — and were not: an earlier draft of this refactor silently dropped the Grok and
|
||||
* DeepSeek rows, so `codeman doctor` stopped reporting two shipped CLIs at all. Deriving
|
||||
* the list makes that class of omission impossible.
|
||||
*
|
||||
* ⚠️ The version regex is compiled through `compileVersionRegex()`, NOT `new RegExp()`. It
|
||||
* is a config-supplied pattern, so it goes through the same length cap and
|
||||
* nested-quantifier rejection the argv engine applies; the doctor runs it over command
|
||||
* output exactly like the resolver does, and skipping the guard here would leave one
|
||||
* unguarded path into a user-supplied regex.
|
||||
*
|
||||
* ⚠️ Sharing the entry's regex with the resolver is what stops the doctor and the run mode
|
||||
* telling the user opposite things about the same binary — the Dependencies panel reporting
|
||||
* "Pi CLI ✓" on a box where Run Pi stays hidden.
|
||||
*/
|
||||
function cliDependencyEntries(): ToolDependency[] {
|
||||
const rows: ToolDependency[] = [];
|
||||
for (const cli of enabledClis()) {
|
||||
// `shell` has no binary of its own (the login shell is resolved at spawn time), so
|
||||
// there is nothing for the doctor to probe.
|
||||
const bin = cli.discovery.binaries[0];
|
||||
if (!bin) continue;
|
||||
|
||||
const override = DOCTOR_ROW_OVERRIDES[cli.id as string];
|
||||
const version = cli.discovery.version;
|
||||
const versionRegex = version?.regex ? (compileVersionRegex(version.regex) ?? undefined) : undefined;
|
||||
|
||||
rows.push({
|
||||
id: override?.id ?? (cli.id as string),
|
||||
label: override?.label ?? `${cli.label} CLI`,
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: override?.usedBy ?? [`${cli.label} sessions`],
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: [bin],
|
||||
versionArg: version?.arg ?? '--version',
|
||||
versionRegex,
|
||||
// Only meaningful for a CLI whose binary name is short, generic or squatted
|
||||
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
|
||||
// program, so a version mismatch means MISSING rather than unknown-version.
|
||||
requireVersionMatch: version?.requireVersionMatch,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'grok',
|
||||
label: 'Grok CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['Grok sessions'],
|
||||
// Version match required for the same reason as pi: `grok` has known squatters
|
||||
// (the unrelated @vibe-kit/grok-cli npm package also installs a `grok` bin), so a
|
||||
// bare `which grok` hit is not the coding agent. Both sides share
|
||||
// GROK_VERSION_REGEX, so the doctor and the run mode cannot drift.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['grok'],
|
||||
versionArg: '--version',
|
||||
versionRegex: GROK_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dsh',
|
||||
label: 'DeepSeek Harness CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['DeepSeek sessions'],
|
||||
// Version match required, and for a sharper reason than pi or grok: `dsh` is
|
||||
// not merely a squattable npm name, it is an existing Debian program
|
||||
// (dancer's shell, `apt install dsh`). The run mode's resolver additionally
|
||||
// demands the harness's own help banner before it will point a spawn line at
|
||||
// a candidate; the doctor is advisory and settles for the shared
|
||||
// DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even
|
||||
// though the resolver is the stricter of the pair about IDENTITY.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['dsh'],
|
||||
versionArg: '--version',
|
||||
versionRegex: DEEPSEEK_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'omp',
|
||||
label: 'OMP CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['OMP sessions'],
|
||||
// Same version-match discipline as pi: `omp` is a short generic name, so a
|
||||
// `which omp` hit alone is not the coding agent. Both sides share
|
||||
// OMP_VERSION_REGEX, so the doctor and the run mode cannot drift into telling
|
||||
// the user opposite things about the same binary.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['omp'],
|
||||
versionArg: '--version',
|
||||
versionRegex: OMP_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
installHint: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp' },
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'thumbnails'],
|
||||
resolvers: [
|
||||
{
|
||||
match: ['linux', 'darwin', 'wsl'],
|
||||
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
|
||||
},
|
||||
],
|
||||
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
|
||||
},
|
||||
{
|
||||
id: 'pdftoppm',
|
||||
label: 'pdftoppm',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
|
||||
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
|
||||
resolvers: [
|
||||
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
|
||||
],
|
||||
installHint: {
|
||||
linux: 'sudo apt install poppler-utils',
|
||||
darwin: 'brew install poppler',
|
||||
wsl: 'sudo apt install poppler-utils',
|
||||
],
|
||||
installHint: cli.discovery.install.command,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools `codeman doctor` probes, resolved AT CALL TIME.
|
||||
*
|
||||
* ⚠️ A FUNCTION, not a module-level const, and for the same reason `sessionModeSchema()` and
|
||||
* `allowedEnvPrefixes()` are functions: `cliDependencyEntries()` reads the CLI registry, and
|
||||
* a const would have frozen the doctor's rows at first import while every schema resolved
|
||||
* per parse. A CLI enabled while the server was running — or a `reloadCliRegistry()` — then
|
||||
* moved the run menu and the validation but never the doctor, which would keep reporting the
|
||||
* catalog as it stood when something first imported this module. Building the array per call
|
||||
* costs a handful of object literals on a command that shells out to probe binaries anyway.
|
||||
*/
|
||||
export function dependencyRegistry(): ToolDependency[] {
|
||||
return [
|
||||
{
|
||||
id: 'node',
|
||||
label: 'Node.js',
|
||||
category: 'core',
|
||||
required: true,
|
||||
minVersion: '22.0.0',
|
||||
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
|
||||
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'msoffice',
|
||||
label: 'MS Office',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'thumbnails'],
|
||||
resolvers: [
|
||||
{
|
||||
match: ['wsl', 'win32'],
|
||||
resolver: {
|
||||
kind: 'windows-side',
|
||||
appDirs: ['Microsoft Office/root/Office16'],
|
||||
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
|
||||
{
|
||||
id: 'tmux',
|
||||
label: 'tmux',
|
||||
category: 'core',
|
||||
required: true,
|
||||
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
|
||||
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
|
||||
},
|
||||
...cliDependencyEntries(),
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'thumbnails'],
|
||||
resolvers: [
|
||||
{
|
||||
match: ['linux', 'darwin', 'wsl'],
|
||||
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
|
||||
},
|
||||
],
|
||||
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
|
||||
},
|
||||
{
|
||||
id: 'pdftoppm',
|
||||
label: 'pdftoppm',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
|
||||
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
|
||||
resolvers: [
|
||||
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
|
||||
],
|
||||
installHint: {
|
||||
linux: 'sudo apt install poppler-utils',
|
||||
darwin: 'brew install poppler',
|
||||
wsl: 'sudo apt install poppler-utils',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
{
|
||||
id: 'msoffice',
|
||||
label: 'MS Office',
|
||||
category: 'office',
|
||||
required: false,
|
||||
usedBy: ['document preview', 'thumbnails'],
|
||||
resolvers: [
|
||||
{
|
||||
match: ['wsl', 'win32'],
|
||||
resolver: {
|
||||
kind: 'windows-side',
|
||||
appDirs: ['Microsoft Office/root/Office16'],
|
||||
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user