fix(cli-resolvers): negative-result caching, SIGKILL on probes, restored VITEST hermeticity, wired not-found diagnostics

Post-merge follow-ups for PR #329 (shared CLI executable resolution):

- Negative-cache resolution misses with a doubling backoff (1min -> 5min
  cap, cliResolveRetryDelayMs, mirroring claudeVersionRetryDelayMs): the
  shared resolver cached success only, so a missing CLI re-ran the whole
  chain - ending in a synchronous interactive login-shell spawn bounded by
  the 5s EXEC_TIMEOUT_MS - on every /api/<cli>/status request and Run
  attempt, stalling the event loop each time, forever. Success still caches
  for the process lifetime, so an installed CLI is picked up within minutes
  without a restart. Tests drive the backoff via an injectable clock
  (createCliExecutableResolver `now` option, threaded through the
  createPiResolverForTest / createAntigravityResolverForTest wrappers).

- Pass killSignal: 'SIGKILL' on the resolver's login-shell spawn and on the
  pi/claude --version probes: execFileSync's timeout only SENDS the kill
  signal and then keeps waiting for the child to exit, and interactive bash
  ignores SIGTERM, so a login shell stuck in a blocking .bash_profile
  survived the timeout and blocked the server permanently.

- Restore test hermeticity (PR #329 deleted pi's VITEST guards, and one
  test pinned the deletion): under vitest the production resolver host now
  replaces un-injected IO primitives with inert stubs - no real PATH
  scanning, no login-shell spawns - and probePiVersion never executes a
  `pi` candidate again (`pi` is a generic binary name, so route tests
  hitting /api/pi/status executed whatever binary the machine carried).
  Tests opt in through the runCommand/isExecutableFile injection hooks or
  allowRealIoUnderVitest for real-filesystem fixtures. The deletion-pinning
  test is replaced by behavioral pins, including a real-executable fixture
  in the new test/pi-cli-resolver.test.ts that fails loudly if the pi gate
  is ever removed again.

- Wire the six get*NotFoundMessage() exports (previously dead) into their
  intended call sites: the createSession throws in tmux-manager and the
  availability gates on POST /api/sessions and POST /api/quick-start in
  session-routes, replacing a third hardcoded copy of the text. A not-found
  error now names where resolution looked (server PATH, login shell,
  checked directories). npm run knip no longer reports any unused export
  from the resolver modules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-21 02:37:58 +02:00
parent bb4ba79791
commit 61251c0b94
10 changed files with 447 additions and 120 deletions
+16 -11
View File
@@ -75,11 +75,17 @@ import {
SAFE_PATH_PATTERN,
findClaudeDir,
getClaudeCliVersion,
getClaudeNotFoundMessage,
resolveOpenCodeDir,
getOpenCodeNotFoundMessage,
resolveCodexDir,
getCodexNotFoundMessage,
resolveGeminiDir,
getGeminiNotFoundMessage,
resolveAntigravityDir,
getAntigravityNotFoundMessage,
resolvePiDir,
getPiNotFoundMessage,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -1853,29 +1859,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return session;
}
// Resolve CLI binary directory based on mode
// Resolve CLI binary directory based on mode. The not-found messages come
// from the resolvers (formatCliNotFoundMessage) so the error names WHERE it
// looked — server PATH, login shell, checked directories — instead of just
// asserting the CLI is missing (the classic systemd/launchd PATH trap).
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
if (mode === 'claude' && !cliDir) {
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
throw new Error(getClaudeNotFoundMessage());
}
if (mode === 'opencode' && !cliDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
throw new Error(getOpenCodeNotFoundMessage());
}
if (mode === 'codex' && !cliDir) {
throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
throw new Error(getCodexNotFoundMessage());
}
if (mode === 'gemini' && !cliDir) {
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
throw new Error(getGeminiNotFoundMessage());
}
if (mode === 'antigravity' && !cliDir) {
throw new Error(
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
throw new Error(getAntigravityNotFoundMessage());
}
if (mode === 'pi' && !cliDir) {
throw new Error(
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
);
throw new Error(getPiNotFoundMessage());
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
+5 -5
View File
@@ -28,13 +28,13 @@ const ANTIGRAVITY_SEARCH_DIRS = [
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);
function createAntigravityResolver(host?: CliResolverHost, now?: () => number) {
return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS, now }, host);
}
/** Creates an isolated Antigravity wrapper around an injected resolver host. */
export function createAntigravityResolverForTest(host: CliResolverHost) {
return createAntigravityResolver(host);
/** Creates an isolated Antigravity wrapper around an injected resolver host and clock. */
export function createAntigravityResolverForTest(host: CliResolverHost, now?: () => number) {
return createAntigravityResolver(host, now);
}
const antigravityResolver = createAntigravityResolver();
+3
View File
@@ -177,6 +177,9 @@ function probeClaudeCliVersion(): string | null {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
env: { ...process.env, PATH: getAugmentedPath() },
// execFileSync's timeout only SENDS the signal and then keeps waiting; a
// child that ignores SIGTERM would block the server thread permanently.
killSignal: 'SIGKILL',
});
const match = out.match(/(\d+\.\d+\.\d+)/);
return match ? match[1] : null;
+102 -9
View File
@@ -1,3 +1,35 @@
/**
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
*
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
* codex, gemini, antigravity, pi): the server process PATH first, then the
* CLI's common install directories in order, then — last, because it is the
* only step that spawns anything — an interactive login shell, which is what
* finds nvm/Homebrew/user-npm installs when Codeman runs as a systemd/launchd
* service with a minimal PATH (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`).
*
* Caching is asymmetric, same shape as `resolveClaudeCliVersion` in
* claude-cli-resolver.ts: a successful resolution is cached for the process
* lifetime, a MISS is negative-cached and retried only after a doubling backoff
* (`cliResolveRetryDelayMs`). The callers are request-facing (the per-CLI
* status endpoints in system-routes.ts, the availability gates in
* session-routes.ts, and tmux-manager's spawn path), and the login-shell probe
* is a SYNCHRONOUS spawn bounded by `EXEC_TIMEOUT_MS` — without the negative
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
* to 5s on every request, forever.
*
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
* short-circuits — IO primitives that were not injected become inert stubs, so
* a suite can never scan the machine's PATH or spawn login shells (the same
* rule as `IS_TEST_MODE` in tmux-manager and the VITEST gate in
* `getClaudeCliVersion`). Tests opt back in through the injection hooks
* (`runCommand`/`isExecutableFile` fakes do no real IO by construction) or, for
* fixtures that need the real filesystem predicate against their own temp
* files, via `allowRealIoUnderVitest`.
*
* @module utils/cli-executable-resolver
*/
import { execFileSync } from 'node:child_process';
import { accessSync, constants, statSync } from 'node:fs';
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
@@ -10,6 +42,26 @@ 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;
/** First retry window after a full-chain resolution miss. */
const RESOLVE_RETRY_BASE_MS = 60_000;
/**
* Ceiling for the doubling backoff. Deliberately shorter than the 15min cap on
* the claude version probe: that one is cosmetic, while this gates the Run
* flow, and "installing a CLI while the server is running is picked up without
* a restart" should stay true within minutes.
*/
const RESOLVE_RETRY_MAX_MS = 5 * 60_000;
/**
* How long to wait before re-running the resolution chain after `failures`
* consecutive misses: 1min, 2min, 4min… capped at 5min. Mirrors
* `claudeVersionRetryDelayMs` in claude-cli-resolver.ts. Exported for tests.
*/
export function cliResolveRetryDelayMs(failures: number): number {
if (failures <= 0) return 0;
return Math.min(RESOLVE_RETRY_BASE_MS * 2 ** (failures - 1), RESOLVE_RETRY_MAX_MS);
}
export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell';
export interface CliResolutionDiagnostics {
@@ -50,6 +102,7 @@ export interface CliResolverCommandOptions {
encoding: 'utf8';
timeout: number;
stdio: ['ignore', 'pipe', 'ignore'];
killSignal: 'SIGKILL';
}
export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string;
@@ -60,6 +113,13 @@ export interface ProductionCliResolverHostOptions {
shellArgs?: string[];
runCommand?: CliResolverCommandRunner;
isExecutableFile?: (path: string) => boolean;
/**
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
* For tests that exercise `isExecutableRegularFile` against their own temp
* fixtures. Such a test must still inject `runCommand` if it can reach the
* login-shell step, or it would spawn a real interactive shell.
*/
allowRealIoUnderVitest?: boolean;
}
function isExecutableRegularFile(path: string): boolean {
@@ -97,15 +157,26 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
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;
// Hermeticity gate (see @fileoverview): under vitest, any IO primitive the
// caller did not inject is replaced by an inert stub. The suites must never
// depend on — or execute — whatever happens to be installed on the machine
// running them, and route tests hitting the per-CLI status endpoints would
// otherwise scan the real PATH and spawn real login shells on CI.
const inert = Boolean(process.env.VITEST) && options.allowRealIoUnderVitest !== true;
const isExecutableFile = options.isExecutableFile ?? (inert ? () => false : isExecutableRegularFile);
const runCommand: CliResolverCommandRunner =
options.runCommand ?? ((file, args, commandOptions) => execFileSync(file, args, commandOptions));
options.runCommand ?? (inert ? () => '' : (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'],
// SIGKILL is load-bearing: execFileSync's `timeout` only SENDS the kill
// signal and then keeps waiting for the child to exit. Interactive bash
// ignores SIGTERM (the default), so a login shell stuck in a blocking
// .bash_profile would survive the timeout and block the server forever.
killSignal: 'SIGKILL',
});
} catch {
return '';
@@ -138,6 +209,8 @@ export function createCliExecutableResolver<T = undefined>(
binary: string;
searchDirs: string[];
validateCandidate?: (path: string) => CandidateValidation<T>;
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
now?: () => number;
},
host: CliResolverHost = createProductionCliResolverHost()
): CliExecutableResolver<T> {
@@ -145,7 +218,13 @@ export function createCliExecutableResolver<T = undefined>(
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
}
const now = options.now ?? Date.now;
/** Successful resolution, cached for the process lifetime. */
let cached: CliResolution<T> | null = null;
/** Consecutive full-chain misses (drives the retry backoff). */
let failures = 0;
/** Timestamp of the most recent miss. */
let lastFailureAt = 0;
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>);
@@ -161,17 +240,31 @@ export function createCliExecutableResolver<T = undefined>(
return {
resolve() {
if (cached) return cached;
// Negative cache: a miss is remembered and the chain — whose login-shell
// tail is a synchronous 5s-bounded spawn — is not re-run until the
// backoff elapses. Without this, every status poll and Run click against
// a missing CLI froze the event loop for the full probe, forever.
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
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;
if (!cached) {
for (const dir of options.searchDirs) {
cached = accept(join(dir, options.binary), 'common-directory');
if (cached) break;
}
}
if (!cached) {
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
}
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
return cached;
if (cached) {
failures = 0;
lastFailureAt = 0;
return cached;
}
failures += 1;
lastFailureAt = now();
return null;
},
diagnostics: () => ({
binary: options.binary,
+16 -6
View File
@@ -28,12 +28,22 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
export { assertNever } from './type-safety.js';
export { wrapWithNice } from './nice-wrapper.js';
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
export {
findClaudeDir,
getAugmentedPath,
getClaudeCliVersion,
getClaudeBinaryPath,
getClaudeNotFoundMessage,
} from './claude-cli-resolver.js';
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js';
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js';
export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js';
export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
export {
resolveAntigravityDir,
isAntigravityAvailable,
getAntigravityNotFoundMessage,
} from './antigravity-cli-resolver.js';
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
+21 -4
View File
@@ -56,13 +56,25 @@ const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-sc
* 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 — and since `pi` is a short
* GENERIC name, this probe would EXECUTE whatever binary of that name the
* machine carries. The shared resolver host is already inert under vitest, so
* this gate is defense in depth for any opted-in host that still carries the
* default probe; tests drive resolution via `createPiResolverForTest`, whose
* injected probe bypasses it. Pinned by test/pi-cli-resolver.test.ts.
*/
function probePiVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
try {
const out = execFileSync(binPath, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `pi` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
// Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too.
const candidate = PI_VERSION_REGEX.exec(out)?.[1];
@@ -76,7 +88,7 @@ function probePiVersion(binPath: string): string | null {
type PiVersionProbe = (binPath: string) => string | null;
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion) {
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion, now?: () => number) {
return createCliExecutableResolver<string>(
{
binary: 'pi',
@@ -85,14 +97,19 @@ function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe =
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/** Creates an isolated Pi wrapper around an injected host and version probe. */
export function createPiResolverForTest(host: CliResolverHost, versionProbe: PiVersionProbe) {
return createPiResolver(host, versionProbe);
/**
* Creates an isolated Pi wrapper around an injected host, version probe and
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
* is exactly what the hermeticity test exercises.
*/
export function createPiResolverForTest(host: CliResolverHost, versionProbe?: PiVersionProbe, now?: () => number) {
return createPiResolver(host, versionProbe ?? probePiVersion, now);
}
const piResolver = createPiResolver();
+28 -52
View File
@@ -802,54 +802,42 @@ export function registerSessionRoutes(
}
}
// Check OpenCode availability if requested
// Check OpenCode availability if requested. The error text comes from the
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
// server PATH, login shell, common directories — same for the modes below.
if (body.mode === 'opencode') {
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
if (!isOpenCodeAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
}
}
// Check Codex availability if requested
if (body.mode === 'codex') {
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
}
}
// Check Gemini availability if requested
if (body.mode === 'gemini') {
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
if (!isGeminiAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
}
}
if (body.mode === 'antigravity') {
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
}
}
if (body.mode === 'pi') {
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
if (!isPiAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
@@ -2827,58 +2815,46 @@ export function registerSessionRoutes(
dockerResumeId = dockerCase.lastClaudeSessionId;
}
} else {
// Check OpenCode availability if requested
// Check OpenCode availability if requested. Error text comes from the
// resolver so it carries the resolution diagnostics; same for the modes below.
if (mode === 'opencode') {
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
await import('../../utils/opencode-cli-resolver.js');
if (!isOpenCodeAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
}
}
// Check Codex availability if requested
if (mode === 'codex') {
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
}
}
// Check Gemini availability if requested
if (mode === 'gemini') {
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
if (!isGeminiAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
}
}
// Check Antigravity availability if requested
if (mode === 'antigravity') {
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
}
}
// Check Pi availability if requested
if (mode === 'pi') {
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
if (!isPiAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}