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());
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Installation/version only. Never an authentication, quota, or billing verdict. */
|
||||
export interface ClaudeCodeStatus {
|
||||
state: "not-found" | "meets-minimum" | "update-required" | "version-unchecked";
|
||||
version: string | null;
|
||||
minimumVersion: string;
|
||||
}
|
||||
|
||||
// --permission-prompts none was added in 2.1.259. This is not an official
|
||||
// minimum-version/stability contract for claudecode/toolUseId; admission fails closed.
|
||||
export const CLAUDE_CODE_MINIMUM_VERSION = "2.1.259";
|
||||
@@ -54,6 +54,7 @@ export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts
|
||||
export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
|
||||
export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts";
|
||||
export * from "./cost-summary.ts";
|
||||
export { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "./contracts/claude-code.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { inspectClaudeInstallation } from "../agent/claude-code/installation.ts";
|
||||
import { initAuthEnv } from "./keys.ts";
|
||||
|
||||
/** The Settings status seam: initialize provenance before any child env is copied. */
|
||||
export async function claudeCodeInstallationStatus() {
|
||||
initAuthEnv();
|
||||
return inspectClaudeInstallation();
|
||||
}
|
||||
@@ -45,3 +45,4 @@ export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
export { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
|
||||
export { sessionCost } from "./pricing.ts";
|
||||
export { claudeCodeInstallationStatus } from "./claude-code.ts";
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
} from "../core/auth-keys.ts";
|
||||
import { authStore } from "../store/auth.ts";
|
||||
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
|
||||
import { getClaudeBinary } from "../agent/claude-code/binary.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
|
||||
|
||||
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
|
||||
// without importing this store-backed module into the agent layer.
|
||||
@@ -187,6 +189,8 @@ export function keyValue(id: string): string | undefined {
|
||||
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
|
||||
* no key at all: nothing to gate, present by definition. */
|
||||
export function providerKeyPresent(provider: string): boolean {
|
||||
// Presence only, not authentication readiness. Launch performs the version gate.
|
||||
if (provider === CLAUDE_CODE_PROVIDER) return getClaudeBinary() !== null;
|
||||
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
|
||||
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||
return info ? keyValue(info.envVar) !== undefined : true;
|
||||
|
||||
Reference in New Issue
Block a user