mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29:42 +02:00
- The doctor now judges candidates like the run mode's resolver: the PATH hit, then each search dir, each one version-checked on its own and skipped on a mismatch (a wrong `pi`/`grok` on the PATH no longer hides the real one in a search dir). A search-dir candidate must be an absolute path to an executable regular file, so a relative dir or a file without the x bit reads as missing, as it does in the Run menu. `isExecutableRegularFile` is exported from cli-executable-resolver.ts and reused rather than copied. - Every doctor probe passes killSignal: 'SIGKILL'; a --version that ignores SIGTERM held the probe for its full runtime (15 s vs 5 s measured with a TERM-trapping script). - README no longer claims parity with the Run menu or nvm prefixes. - The Diagnostics panel marks a missing optional tool with ○, a missing required one with ✗, as the terminal doctor does. - expandSearchDir names its twin, expandHome() in cli-resolver.ts. - test/doctor-cli-json.test.ts is hermetic: temp HOME, a PATH of only `which` and `node`, and a clis.json that drops the registry's absolute search dirs, so it never runs the machine's installed agent CLIs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
244 lines
9.7 KiB
TypeScript
244 lines
9.7 KiB
TypeScript
/**
|
|
* @fileoverview Static registry of downstream tool dependencies probed by
|
|
* `codeman doctor`. Each entry declares per-environment resolvers and the
|
|
* skills that use it. EXTENSION POINT: skill-manifest-driven discovery
|
|
* (COD follow-up) will merge dynamically-found entries into this list.
|
|
*
|
|
* @module config/dependency-registry
|
|
*/
|
|
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { enabledClis } from './cli-registry/registry.js';
|
|
import { compileVersionRegex } from './cli-registry/patterns.js';
|
|
|
|
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
|
|
|
|
/** The valid `--category` filter values; single source of truth for the type, the CLI
|
|
* help text, and CLI input validation. */
|
|
export const TOOL_CATEGORIES = ['core', 'office', 'other'] as const;
|
|
export type ToolCategory = (typeof TOOL_CATEGORIES)[number];
|
|
|
|
/** Resolve a binary on the PATH and read its version. */
|
|
export interface PathResolver {
|
|
kind: 'path';
|
|
bins: string[];
|
|
versionArg?: string; // default '--version'
|
|
versionRegex?: RegExp; // default matches first \d+.\d+(.\d+)?
|
|
/**
|
|
* Treat a binary whose version output does not match as NOT INSTALLED, instead of
|
|
* reporting it with an unknown version. Only for tools with a short, generic binary
|
|
* name (`pi`), where a `which` hit is not by itself evidence the right program is
|
|
* there and a false "installed" contradicts the run mode's own resolver.
|
|
*/
|
|
requireVersionMatch?: boolean;
|
|
/**
|
|
* Absolute directories to probe (`<dir>/<bin>`) when `which` misses. A service (systemd,
|
|
* launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm
|
|
* prefix is invisible to `which` while the run mode, which falls back to the registry's
|
|
* `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees.
|
|
*/
|
|
searchDirs?: string[];
|
|
}
|
|
|
|
/**
|
|
* Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in
|
|
* src/utils/cli-resolver.ts, copied rather than imported because importing it from config/
|
|
* would pull in the whole resolver chain; keep the two in step.
|
|
*/
|
|
function expandSearchDir(dir: string): string {
|
|
if (dir === '~') return homedir();
|
|
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
|
|
return dir;
|
|
}
|
|
|
|
/** Resolve a Windows-installed app reachable from win32 or WSL. */
|
|
export interface WindowsSideResolver {
|
|
kind: 'windows-side';
|
|
appDirs: string[]; // relative to a Program Files root
|
|
exes: string[]; // candidate executables; first found wins
|
|
}
|
|
|
|
export interface ResolverSpec {
|
|
match: ProbeEnvironment[];
|
|
resolver: PathResolver | WindowsSideResolver;
|
|
}
|
|
|
|
export interface ToolDependency {
|
|
id: string;
|
|
label: string;
|
|
category: ToolCategory;
|
|
required: boolean;
|
|
usedBy?: string[];
|
|
minVersion?: string;
|
|
resolvers: ResolverSpec[];
|
|
installHint?: Partial<Record<ProbeEnvironment, string>>;
|
|
}
|
|
|
|
const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
|
|
|
|
/**
|
|
* 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[] }> = {
|
|
// The label override keeps the doctor row's historical "Claude CLI" spelling now that
|
|
// the registry label is the product name, "Claude Code".
|
|
claude: { label: 'Claude CLI', 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,
|
|
searchDirs: cli.discovery.searchDirs.map(expandSearchDir),
|
|
},
|
|
},
|
|
],
|
|
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: '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'],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
}
|