fix(cli-resolvers): find CLIs installed via nvm/Homebrew when running as a service

A CLI installed by nvm, Homebrew or a user-level npm prefix lives on a PATH that
only a login shell sets up. Codeman running under systemd or launchd does not get
that PATH — launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin` — so every
resolver reported the CLI as unavailable on installs where it is plainly there
and works from a terminal.

Each of the six resolvers had its own hand-rolled copy of the same PATH walk, so
the fix is factored into one shared `createCliExecutableResolver()` with an
explicit lookup order: the server process PATH, then common install directories in
order, then an interactive login shell as the last resort. Only the last step
spawns anything, and only when the cheap lookups have already missed.

Also adds `formatCliNotFoundMessage()`, so a failure explains where it looked
instead of just asserting the CLI is missing. Its diagnostics are bounded and
control characters are flattened, so a not-found message cannot dump arbitrary
environment data.

Success is cached and failure is retried, so installing a CLI while the server is
running is picked up without a restart.

Net -103 lines across the six resolvers. Behaviour is unchanged wherever the CLI
was already on the process PATH: that remains the first thing checked.

Tests: 20 cases in test/cli-executable-resolver.test.ts covering the precedence
order, login-shell-only resolution, the caching rule, unsafe-name rejection, and
the bounded diagnostics.
This commit is contained in:
Aamer Akhter
2026-08-20 12:47:42 -04:00
parent 07b9c7fd7b
commit fef903df98
9 changed files with 742 additions and 207 deletions
+26 -30
View File
@@ -7,22 +7,37 @@
* @module utils/antigravity-cli-resolver
*/
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliResolverHost,
} from './cli-executable-resolver.js';
/** Common directories where the Antigravity CLI binary may be installed */
const ANTIGRAVITY_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
join(homedir(), '.antigravity', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/** Cached directory containing the agy binary (empty string = searched but not found) */
let _antigravityDir: string | null = null;
const ANTIGRAVITY_NOT_FOUND =
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash';
function createAntigravityResolver(host?: CliResolverHost) {
return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS }, host);
}
/** Creates an isolated Antigravity wrapper around an injected resolver host. */
export function createAntigravityResolverForTest(host: CliResolverHost) {
return createAntigravityResolver(host);
}
const antigravityResolver = createAntigravityResolver();
/**
* Finds the directory containing the `agy` binary.
@@ -31,30 +46,7 @@ let _antigravityDir: string | null = null;
* @returns Directory path, or null if not found
*/
export function resolveAntigravityDir(): string | null {
if (_antigravityDir !== null) return _antigravityDir || null;
try {
const result = execSync('which agy', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) {
_antigravityDir = dirname(result);
return _antigravityDir;
}
} catch {
// agy not in PATH, will check common locations
}
for (const dir of ANTIGRAVITY_SEARCH_DIRS) {
if (existsSync(join(dir, 'agy'))) {
_antigravityDir = dir;
return _antigravityDir;
}
}
_antigravityDir = '';
return null;
return antigravityResolver.resolve()?.directory ?? null;
}
/**
@@ -63,3 +55,7 @@ export function resolveAntigravityDir(): string | null {
export function isAntigravityAvailable(): boolean {
return resolveAntigravityDir() !== null;
}
export function getAntigravityNotFoundMessage(): string {
return formatCliNotFoundMessage(ANTIGRAVITY_NOT_FOUND, antigravityResolver.diagnostics());
}
+12 -28
View File
@@ -8,11 +8,11 @@
* @module utils/claude-cli-resolver
*/
import { execSync, execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { delimiter, dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { delimiter, join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
/** Common directories where the Claude CLI binary may be installed */
const CLAUDE_SEARCH_DIRS = [
@@ -23,8 +23,8 @@ const CLAUDE_SEARCH_DIRS = [
join(homedir(), 'bin'),
];
/** Cached directory containing the claude binary (empty string = searched but not found) */
let _claudeDir: string | null = null;
const claudeResolver = createCliExecutableResolver({ binary: 'claude', searchDirs: CLAUDE_SEARCH_DIRS });
const CLAUDE_NOT_FOUND = 'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash';
/**
* Returns true if the Claude CLI binary can be located (via `which` or one of
@@ -43,29 +43,11 @@ export function isClaudeAvailable(): boolean {
* @returns Directory path, or null if not found
*/
export function findClaudeDir(): string | null {
if (_claudeDir !== null) return _claudeDir || null;
return claudeResolver.resolve()?.directory ?? null;
}
// Try `which` first (respects current PATH)
try {
const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
if (result && existsSync(result)) {
_claudeDir = dirname(result);
return _claudeDir;
}
} catch {
// Claude not in PATH, will check common locations
}
// Fallback: check common installation directories
for (const dir of CLAUDE_SEARCH_DIRS) {
if (existsSync(join(dir, 'claude'))) {
_claudeDir = dir;
return _claudeDir;
}
}
_claudeDir = ''; // mark as searched, not found
return null;
export function getClaudeNotFoundMessage(): string {
return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics());
}
/**
@@ -99,7 +81,9 @@ export function getAugmentedPath(): string {
const currentPath = process.env.PATH || '';
const claudeDir = findClaudeDir();
if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) {
if (!claudeDir) return currentPath;
if (!currentPath.split(delimiter).includes(claudeDir)) {
_augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
return _augmentedPath;
}
+208
View File
@@ -0,0 +1,208 @@
import { execFileSync } from 'node:child_process';
import { accessSync, constants, statSync } from 'node:fs';
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { loginShellArgs, resolveLocalShell } from './shell-resolver.js';
const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell';
export interface CliResolutionDiagnostics {
binary: string;
processPath: string;
shellPath: string;
shellArgs: string[];
searchDirs: string[];
}
export interface CliResolverHost {
processPath: string;
shellPath: string;
shellArgs: string[];
findOnProcessPath(binary: string): string | null;
findInLoginShell(binary: string): string | null;
exists(path: string): boolean;
}
export interface CandidateValidation<T> {
accepted: boolean;
metadata?: T;
}
export interface CliResolution<T = undefined> {
binaryPath: string;
directory: string;
source: CliResolutionSource;
metadata?: T;
}
export interface CliExecutableResolver<T = undefined> {
resolve(): CliResolution<T> | null;
diagnostics(): CliResolutionDiagnostics;
}
export interface CliResolverCommandOptions {
encoding: 'utf8';
timeout: number;
stdio: ['ignore', 'pipe', 'ignore'];
}
export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string;
export interface ProductionCliResolverHostOptions {
processPath?: string;
shellPath?: string;
shellArgs?: string[];
runCommand?: CliResolverCommandRunner;
isExecutableFile?: (path: string) => boolean;
}
function isExecutableRegularFile(path: string): boolean {
try {
if (!statSync(path).isFile()) return false;
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}
function parseLoginShellResult(output: string, binary: string): string | null {
const lines = output.split(/\r?\n/).map((line) => line.trim());
const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER);
if (begin === -1) return null;
const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1);
if (end === -1) return null;
for (const candidate of lines.slice(begin + 1, end)) {
if (isAbsolute(candidate) && basename(candidate) === binary) return candidate;
}
return null;
}
function loginShellCommand(binary: string): string {
return [
`printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`,
`command -v -- ${binary}`,
`printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`,
].join('; ');
}
export function createProductionCliResolverHost(options: ProductionCliResolverHostOptions = {}): CliResolverHost {
const shellPath = options.shellPath ?? resolveLocalShell();
const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean);
const processPath = options.processPath ?? process.env.PATH ?? '';
const isExecutableFile = options.isExecutableFile ?? isExecutableRegularFile;
const runCommand: CliResolverCommandRunner =
options.runCommand ?? ((file, args, commandOptions) => execFileSync(file, args, commandOptions));
const run = (file: string, args: string[]): string => {
try {
return runCommand(file, args, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
return '';
}
};
return {
processPath,
shellPath,
shellArgs: [...shellArgs],
findOnProcessPath: (binary) => {
if (!SAFE_BINARY_NAME.test(binary)) return null;
for (const directory of processPath.split(delimiter).filter(Boolean)) {
const candidate = join(directory, binary);
if (isAbsolute(candidate) && isExecutableFile(candidate)) return candidate;
}
return null;
},
findInLoginShell: (binary) => {
if (!SAFE_BINARY_NAME.test(binary)) return null;
const candidate = parseLoginShellResult(run(shellPath, [...shellArgs, '-c', loginShellCommand(binary)]), binary);
return candidate && isExecutableFile(candidate) ? candidate : null;
},
exists: isExecutableFile,
};
}
export function createCliExecutableResolver<T = undefined>(
options: {
binary: string;
searchDirs: string[];
validateCandidate?: (path: string) => CandidateValidation<T>;
},
host: CliResolverHost = createProductionCliResolverHost()
): CliExecutableResolver<T> {
if (!SAFE_BINARY_NAME.test(options.binary)) {
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
}
let cached: CliResolution<T> | null = null;
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
if (!validation.accepted) return null;
return {
binaryPath: path,
directory: dirname(path),
source,
metadata: validation.metadata,
};
};
return {
resolve() {
if (cached) return cached;
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
if (cached) return cached;
for (const dir of options.searchDirs) {
cached = accept(join(dir, options.binary), 'common-directory');
if (cached) return cached;
}
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
return cached;
},
diagnostics: () => ({
binary: options.binary,
processPath: host.processPath,
shellPath: host.shellPath,
shellArgs: [...host.shellArgs],
searchDirs: [...options.searchDirs],
}),
};
}
function sanitizeDiagnosticField(value: string, emptyMarker: string): string {
const flattened = Array.from(value, (character) => {
const codePoint = character.codePointAt(0) ?? 0;
const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character;
})
.join('')
.replace(/ +/g, ' ')
.trim();
if (!flattened) return emptyMarker;
if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened;
return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`;
}
export function formatCliNotFoundMessage(base: string, diagnostics: CliResolutionDiagnostics): string {
const processPath = sanitizeDiagnosticField(diagnostics.processPath, '(empty)');
const shell = sanitizeDiagnosticField(
[diagnostics.shellPath, ...diagnostics.shellArgs].filter(Boolean).join(' '),
'(none)'
);
const dirs = sanitizeDiagnosticField(diagnostics.searchDirs.join(', '), '(none)');
return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`;
}
+9 -31
View File
@@ -7,11 +7,9 @@
* @module utils/codex-cli-resolver
*/
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
/** Common directories where the Codex CLI binary may be installed */
const CODEX_SEARCH_DIRS = [
@@ -23,8 +21,8 @@ const CODEX_SEARCH_DIRS = [
join(homedir(), 'bin'), // User bin
];
/** Cached directory containing the codex binary (empty string = searched but not found) */
let _codexDir: string | null = null;
const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS });
const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex';
/**
* Finds the directory containing the `codex` binary.
@@ -34,31 +32,7 @@ let _codexDir: string | null = null;
* @returns Directory path, or null if not found
*/
export function resolveCodexDir(): string | null {
if (_codexDir !== null) return _codexDir || null;
// Try `which` first (respects current PATH)
try {
const result = execSync('which codex', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) {
_codexDir = dirname(result);
return _codexDir;
}
} catch {
// Codex not in PATH, will check common locations
}
for (const dir of CODEX_SEARCH_DIRS) {
if (existsSync(join(dir, 'codex'))) {
_codexDir = dir;
return _codexDir;
}
}
_codexDir = ''; // mark as searched, not found
return null;
return codexResolver.resolve()?.directory ?? null;
}
/**
@@ -67,3 +41,7 @@ export function resolveCodexDir(): string | null {
export function isCodexAvailable(): boolean {
return resolveCodexDir() !== null;
}
export function getCodexNotFoundMessage(): string {
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
}
+9 -30
View File
@@ -7,11 +7,9 @@
* @module utils/gemini-cli-resolver
*/
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
/** Common directories where the Gemini CLI binary may be installed */
const GEMINI_SEARCH_DIRS = [
@@ -23,8 +21,8 @@ const GEMINI_SEARCH_DIRS = [
join(homedir(), 'bin'),
];
/** Cached directory containing the gemini binary (empty string = searched but not found) */
let _geminiDir: string | null = null;
const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS });
const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli';
/**
* Finds the directory containing the `gemini` binary.
@@ -33,30 +31,7 @@ let _geminiDir: string | null = null;
* @returns Directory path, or null if not found
*/
export function resolveGeminiDir(): string | null {
if (_geminiDir !== null) return _geminiDir || null;
try {
const result = execSync('which gemini', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) {
_geminiDir = dirname(result);
return _geminiDir;
}
} catch {
// Gemini not in PATH, will check common locations
}
for (const dir of GEMINI_SEARCH_DIRS) {
if (existsSync(join(dir, 'gemini'))) {
_geminiDir = dir;
return _geminiDir;
}
}
_geminiDir = '';
return null;
return geminiResolver.resolve()?.directory ?? null;
}
/**
@@ -65,3 +40,7 @@ export function resolveGeminiDir(): string | null {
export function isGeminiAvailable(): boolean {
return resolveGeminiDir() !== null;
}
export function getGeminiNotFoundMessage(): string {
return formatCliNotFoundMessage(GEMINI_NOT_FOUND, geminiResolver.diagnostics());
}
+9 -32
View File
@@ -7,11 +7,9 @@
* @module utils/opencode-cli-resolver
*/
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
/** Common directories where the OpenCode CLI binary may be installed */
const OPENCODE_SEARCH_DIRS = [
@@ -24,8 +22,8 @@ const OPENCODE_SEARCH_DIRS = [
join(homedir(), 'bin'), // User bin
];
/** Cached directory containing the opencode binary (empty string = searched but not found) */
let _openCodeDir: string | null = null;
const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS });
const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash';
/**
* Finds the directory containing the `opencode` binary.
@@ -35,32 +33,7 @@ let _openCodeDir: string | null = null;
* @returns Directory path, or null if not found
*/
export function resolveOpenCodeDir(): string | null {
if (_openCodeDir !== null) return _openCodeDir || null;
// Try `which` first (respects current PATH)
try {
const result = execSync('which opencode', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) {
_openCodeDir = dirname(result);
return _openCodeDir;
}
} catch {
// OpenCode not in PATH, will check common locations
}
// Fallback: check common installation directories
for (const dir of OPENCODE_SEARCH_DIRS) {
if (existsSync(join(dir, 'opencode'))) {
_openCodeDir = dir;
return _openCodeDir;
}
}
_openCodeDir = ''; // mark as searched, not found
return null;
return openCodeResolver.resolve()?.directory ?? null;
}
/**
@@ -69,3 +42,7 @@ export function resolveOpenCodeDir(): string | null {
export function isOpenCodeAvailable(): boolean {
return resolveOpenCodeDir() !== null;
}
export function getOpenCodeNotFoundMessage(): string {
return formatCliNotFoundMessage(OPENCODE_NOT_FOUND, openCodeResolver.diagnostics());
}
+39 -56
View File
@@ -15,11 +15,15 @@
* @module utils/pi-cli-resolver
*/
import { execFileSync, execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliResolverHost,
} from './cli-executable-resolver.js';
/** Common directories where the Pi CLI binary may be installed */
const PI_SEARCH_DIRS = [
@@ -45,22 +49,15 @@ const PI_SEARCH_DIRS = [
*/
export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
/** Cached directory containing the pi binary (empty string = searched but not found) */
let _piDir: string | null = null;
/** Cached version string reported by the resolved binary (empty string = probed, unusable) */
let _piVersion: string | null = null;
const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent';
/**
* Run `pi --version` on a candidate path and return the trimmed version when it
* looks like the coding agent. Returns null for anything else — a missing
* binary, a non-zero exit, a hang (timeout), or output that is not semver-shaped
* (which is how an unrelated `pi` on PATH gets rejected).
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have pi installed.
*/
function probePiVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
try {
const out = execFileSync(binPath, ['--version'], {
encoding: 'utf-8',
@@ -77,6 +74,29 @@ function probePiVersion(binPath: string): string | null {
return null;
}
type PiVersionProbe = (binPath: string) => string | null;
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion) {
return createCliExecutableResolver<string>(
{
binary: 'pi',
searchDirs: PI_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
},
host
);
}
/** Creates an isolated Pi wrapper around an injected host and version probe. */
export function createPiResolverForTest(host: CliResolverHost, versionProbe: PiVersionProbe) {
return createPiResolver(host, versionProbe);
}
const piResolver = createPiResolver();
/**
* Finds the directory containing a verified `pi` binary.
* Checks `which pi` first, then falls back to common install locations. Every
@@ -86,46 +106,7 @@ function probePiVersion(binPath: string): string | null {
* @returns Directory path, or null if not found
*/
export function resolvePiDir(): string | null {
if (_piDir !== null) return _piDir || null;
const accept = (binPath: string): string | null => {
// Under vitest the probe never runs, so existence alone decides (keeps the
// suites hermetic and matches how the sibling resolvers behave there).
if (process.env.VITEST) {
_piDir = dirname(binPath);
_piVersion = '';
return _piDir;
}
const version = probePiVersion(binPath);
if (!version) return null;
_piDir = dirname(binPath);
_piVersion = version;
return _piDir;
};
try {
const result = execSync('which pi', {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
if (result && existsSync(result)) {
const dir = accept(result);
if (dir) return dir;
}
} catch {
// pi not in PATH, will check common locations
}
for (const dir of PI_SEARCH_DIRS) {
const binPath = join(dir, 'pi');
if (!existsSync(binPath)) continue;
const accepted = accept(binPath);
if (accepted) return accepted;
}
_piDir = '';
_piVersion = '';
return null;
return piResolver.resolve()?.directory ?? null;
}
/**
@@ -135,12 +116,14 @@ export function isPiAvailable(): boolean {
return resolvePiDir() !== null;
}
export function getPiNotFoundMessage(): string {
return formatCliNotFoundMessage(PI_NOT_FOUND, piResolver.diagnostics());
}
/**
* Version reported by the resolved `pi` binary, or null when pi is unavailable
* (or when the probe was skipped, i.e. under vitest). Surfaced through
* `GET /api/pi/status` so a misresolution is diagnosable from the UI.
* Version reported by the resolved `pi` binary, or null when pi is unavailable.
* Surfaced through `GET /api/pi/status` so a misresolution is diagnosable from the UI.
*/
export function getPiCliVersion(): string | null {
resolvePiDir();
return _piVersion || null;
return piResolver.resolve()?.metadata ?? null;
}