feat: report Claude CLI installation and gate unsupported versions
This commit is contained in:
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user