feat: report Claude CLI installation and gate unsupported versions

This commit is contained in:
amosblomqvist
2026-10-28 19:10:47 +02:00
parent c0de3f968d
commit c5a756d707
17 changed files with 334 additions and 21 deletions
+38
View File
@@ -0,0 +1,38 @@
import { execFile } from "node:child_process";
import { tmpdir } from "node:os";
import { externalCliEnv } from "../../core/auth-env.ts";
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "../../core/contracts/claude-code.ts";
import { getClaudeBinary } from "./binary.ts";
export type ReadClaudeVersion = (binary: string) => Promise<string>;
/** No shell, auth-status command, model call, or credential-file inspection. */
export const readClaudeVersion: ReadClaudeVersion = (binary) => new Promise((resolve, reject) => {
execFile(binary, ["--version"], {
cwd: tmpdir(), env: externalCliEnv(), windowsHide: true,
timeout: 5_000, killSignal: "SIGKILL", maxBuffer: 4_096,
}, (error, stdout) => error ? reject(error) : resolve(stdout));
});
export async function inspectClaudeInstallation(
findBinary: () => string | null = getClaudeBinary,
readVersion: ReadClaudeVersion = readClaudeVersion,
): Promise<ClaudeCodeStatus> {
const base = { version: null, minimumVersion: CLAUDE_CODE_MINIMUM_VERSION };
const binary = findBinary();
if (!binary) return { ...base, state: "not-found" };
try {
const output = await readVersion(binary);
// Expose only a version, never arbitrary CLI stdout/stderr or an absolute path.
const match = /^(\d+)\.(\d+)\.(\d+)(?: \(Claude Code\))?$/.exec(output.trim());
if (!match) return { ...base, state: "version-unchecked" };
const parts = match.slice(1).map(Number);
if (!parts.every(Number.isSafeInteger)) return { ...base, state: "version-unchecked" };
const minimum = CLAUDE_CODE_MINIMUM_VERSION.split(".").map(Number);
const differing = parts.findIndex((part, index) => part !== minimum[index]);
const meetsMinimum = differing === -1 || parts[differing]! > minimum[differing]!;
return { ...base, version: parts.join("."), state: meetsMinimum ? "meets-minimum" : "update-required" };
} catch {
return { ...base, state: "version-unchecked" };
}
}
+32
View File
@@ -0,0 +1,32 @@
import { spawn } from "node:child_process";
import { getClaudeBinary } from "./binary.ts";
import { inspectClaudeInstallation, readClaudeVersion, type ReadClaudeVersion } from "./installation.ts";
import type { RunClaude } from "./stream.ts";
export class ClaudeLaunchError extends Error {}
/** Injectable seams keep all verification offline; production uses the installed CLI. */
export function createClaudeLauncher({
findBinary = getClaudeBinary,
readVersion = readClaudeVersion,
spawnProcess = spawn,
}: {
findBinary?: () => string | null;
readVersion?: ReadClaudeVersion;
spawnProcess?: typeof spawn;
} = {}): RunClaude {
return async (args, options) => {
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
const binary = findBinary();
if (!binary) return null;
const status = await inspectClaudeInstallation(() => binary, readVersion);
if (status.state === "update-required") {
throw new ClaudeLaunchError(`Claude Code ${status.version} requires an update. LearnHarness requires at least ${status.minimumVersion}; update Claude Code outside LearnHarness.`);
}
if (status.state !== "meets-minimum") {
throw new ClaudeLaunchError("Claude Code's version could not be checked. Check your installation outside LearnHarness and try again.");
}
if (options.signal?.aborted) throw new ClaudeLaunchError("Request was aborted");
return spawnProcess(binary, args, { cwd: options.cwd, env: options.env, stdio: "pipe" });
};
}
+8 -13
View File
@@ -1,4 +1,4 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -24,7 +24,7 @@ import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messag
import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse";
import { externalCliEnv } from "../../core/auth-env.ts";
import { getClaudeBinary } from "./binary.ts";
import { ClaudeLaunchError, createClaudeLauncher } from "./launch.ts";
import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts";
import { ClaudeCodeError } from "./constants.ts";
import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts";
@@ -32,9 +32,10 @@ import { configurationForReuse, conversationForReuse } from "./reuse-context.ts"
import { BRIDGE_SERVER_NAME, createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts";
import { toClaudeInput, toStreamJsonLine } from "./transcript.ts";
type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv };
type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv; signal?: AbortSignal };
export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null;
type ClaudeChild = ChildProcessWithoutNullStreams | null;
export type RunClaude = (args: string[], options: RunClaudeOptions) => ClaudeChild | Promise<ClaudeChild>;
// A held MCP call lasts until pi calls back with the tool result, which for a question tool is
// whenever the learner answers; this must outlast the live-process idle timeout so the CLI never
@@ -637,7 +638,7 @@ async function startProcess(
mcpConfigFile: files.mcpConfigFile,
allowedTools: bridge.allowedTools,
});
child = runClaude(args, { cwd: files.cwd, env });
child = await runClaude(args, { cwd: files.cwd, env, signal: options?.signal });
} catch (error) {
await removeTurnResources(bridge, files);
throw error;
@@ -666,17 +667,11 @@ export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunc
startProcess(runClaude, turn, model, context, options, owner, configuration, env).catch((error: unknown) => {
console.error("claude CLI turn failed:", error);
turn.fail(START_FAILED_MESSAGE, "error");
turn.fail(options?.signal?.aborted ? ABORT_MESSAGE : error instanceof ClaudeLaunchError ? error.message : START_FAILED_MESSAGE, options?.signal?.aborted ? "aborted" : "error");
});
return stream;
};
}
function spawnClaude(args: string[], options: RunClaudeOptions): ChildProcessWithoutNullStreams | null {
const binary = getClaudeBinary();
return binary ? spawn(binary, args, { ...options, stdio: "pipe" }) : null;
}
export const streamClaudeCode = createClaudeCodeStreamFunction(spawnClaude);
export const streamClaudeCode = createClaudeCodeStreamFunction(createClaudeLauncher());