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
+6
View File
@@ -0,0 +1,6 @@
import { claudeCodeInstallationStatus } from "@/src/server";
/** Installation/version only; never reads auth status or makes a model request. */
export async function GET() {
return Response.json(await claudeCodeInstallationStatus(), { headers: { "Cache-Control": "no-store" } });
}
@@ -0,0 +1,49 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { CLAUDE_CODE_MINIMUM_VERSION, type ClaudeCodeStatus } from "@/src/core";
import { ConnectionRow } from "./connection-controls.tsx";
export function claudeCodeStatusLabel(status: ClaudeCodeStatus | null): string {
switch (status?.state) {
case "not-found": return "Not found";
case "meets-minimum": return `Version ${status.version} · meets minimum`;
case "update-required": return `Version ${status.version} · update required`;
default: return "Version could not be checked";
}
}
export function ClaudeCodeInstallationStatus() {
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
const [busy, setBusy] = useState(true);
const request = useRef<AbortController | null>(null);
const refresh = useCallback(async () => {
request.current?.abort();
const controller = new AbortController();
request.current = controller;
setBusy(true);
try {
const response = await fetch("/api/claude-code", { cache: "no-store", signal: controller.signal });
if (!response.ok) throw new Error("status unavailable");
const value = await response.json() as ClaudeCodeStatus;
if (!["not-found", "meets-minimum", "update-required", "version-unchecked"].includes(value.state)
|| value.minimumVersion !== CLAUDE_CODE_MINIMUM_VERSION
|| (value.version !== null && (typeof value.version !== "string" || !/^\d+\.\d+\.\d+$/.test(value.version)))) throw new Error("invalid status");
if (!controller.signal.aborted) setStatus(value);
} catch {
if (!controller.signal.aborted) setStatus(null);
} finally {
if (!controller.signal.aborted) setBusy(false);
}
}, []);
useEffect(() => {
void refresh();
return () => request.current?.abort();
}, [refresh]);
return <div className="space-y-2" data-testid="claude-code-installation">
<ConnectionRow label="Claude Code" labelClassName="font-prose text-(length:--lh-prose-size) text-foreground" action="Check again" disabled={busy} onAction={() => void refresh()} actionTestid="claude-code-check-again">
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
</ConnectionRow>
<p className="text-xs leading-relaxed text-muted-foreground">Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.</p>
<p className="text-xs leading-relaxed text-muted-foreground">Minimum version: {CLAUDE_CODE_MINIMUM_VERSION}. Meeting this minimum does not guarantee MCP compatibility, model access, or available quota. <a href="https://code.claude.com/docs/en/setup" target="_blank" rel="noreferrer" className="underline underline-offset-2 hover:text-foreground">Claude Code setup ↗</a></p>
</div>;
}
+5
View File
@@ -27,6 +27,7 @@ import { useEffect, useRef, useState } from "react";
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
import { SettingsGroup } from "./controls.tsx";
import { SubscriptionConnections } from "./subscription-connections.tsx";
import { ClaudeCodeInstallationStatus } from "./claude-code-status.tsx";
import { ProviderMenuList } from "./model-menu.tsx";
import { MenuShell } from "@/components/menu-shell";
import { notifySettingsSaved } from "@/components/settings-signal";
@@ -471,6 +472,10 @@ export function KeysSection({
<SubscriptionConnections />
</SettingsGroup>
<SettingsGroup label="External CLI" testid="group-external-cli">
<ClaudeCodeInstallationStatus />
</SettingsGroup>
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
<KeyTableRow
+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());
+10
View File
@@ -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";
+1
View File
@@ -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
+8
View File
@@ -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();
}
+1
View File
@@ -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";
+4
View File
@@ -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;
+4 -2
View File
@@ -77,14 +77,16 @@ test("Providers is the first/default page; General is last and other pages keep
assert.doesNotMatch(html, />Model providers<\/button>/);
});
test("Providers has exactly four ordered sections; unknown variables stay masked/read-only at the end of Model API keys", async () => {
test("Providers has five ordered sections including External CLI; unknown variables stay masked/read-only at the end of Model API keys", async () => {
globalThis.fetch = async (url) => {
if (url === "/api/claude-code") return Response.json({ state: "not-found", version: null, minimumVersion: "2.1.259" });
assert.equal(url, "/api/keys");
return Response.json({ providers: [{ id: "openai", envVar: "OPENAI_API_KEY", label: "OpenAI" }], keys: [{ id: "FUTURE_VAR", info: null, present: true, masked: "…abcd", source: "file", shadowedByEnv: false }] });
};
fixture(); await mount(<KeysSection />);
const headings = [...document.querySelectorAll('[data-testid^="group-"] > p')].map((el) => el.textContent);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "Dictation", "Search"]);
assert.deepEqual(headings, ["Model API keys", "Subscriptions", "External CLI", "Dictation", "Search"]);
assert(byTest("claude-code-installation").closest('[data-testid="group-external-cli"]'));
assert(byTest("key-deepgram_api_key").closest('[data-testid="group-dictation"]'));
assert(byTest("key-tavily_api_key").closest('[data-testid="group-search"]'));
assert(byTest("key-brave_api_key").closest('[data-testid="group-search"]'));
+7 -1
View File
@@ -12,7 +12,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { once } from "node:events";
import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { delimiter, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import test, { type TestContext } from "node:test";
@@ -63,6 +63,12 @@ function toAskScenario(): string[] {
function createFakeRun(t: TestContext, scenarios: string[][]): FakeRun {
const dir = mkdtempSync(join(tmpdir(), "claude-code-harness-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
// Satisfy the real presence gate; this marker is NEVER executed. All work
// below goes through the injected Node fake-CLI seam, not an installed CLI.
writeFileSync(join(dir, process.platform === "win32" ? "claude.exe" : "claude"), "", { mode: 0o755 });
const savedPath = process.env.PATH;
process.env.PATH = `${dir}${delimiter}${savedPath ?? ""}`;
t.after(() => { if (savedPath === undefined) delete process.env.PATH; else process.env.PATH = savedPath; });
const children: ChildProcessWithoutNullStreams[] = [];
const stdinFiles: string[] = [];
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { type ChildProcessWithoutNullStreams, type spawn } from "node:child_process";
import { test } from "node:test";
import { inspectClaudeInstallation } from "../src/agent/claude-code/installation.ts";
import { ClaudeLaunchError, createClaudeLauncher } from "../src/agent/claude-code/launch.ts";
const binary = "/fake/claude";
const options = { cwd: "/fake/work", env: { NODE_ENV: "test" as const, ANTHROPIC_API_KEY: "native-test-key" } };
test("missing installation does not execute a command", async () => {
assert.deepEqual(await inspectClaudeInstallation(() => null, async () => { throw new Error("must not run"); }), {
state: "not-found", version: null, minimumVersion: "2.1.259",
});
});
for (const [output, state, version] of [
["2.1.258 (Claude Code)\n", "update-required", "2.1.258"],
["2.1.259 (Claude Code)", "meets-minimum", "2.1.259"],
["2.1.310 (Claude Code)", "meets-minimum", "2.1.310"],
["2.2.0", "meets-minimum", "2.2.0"],
["2.0.999", "update-required", "2.0.999"],
["3.0.0", "meets-minimum", "3.0.0"],
["unexpected output containing private details", "version-unchecked", null],
["2.1.310-preview", "version-unchecked", null],
["99999999999999999999999999.1.1", "version-unchecked", null],
] as const) test(`installation version output ${JSON.stringify(output)} gives ${state}`, async () => {
const status = await inspectClaudeInstallation(() => binary, async (path) => { assert.equal(path, binary); return output; });
assert.deepEqual(status, { state, version, minimumVersion: "2.1.259" });
});
test("probe failures expose neither raw errors nor binary paths", async () => {
const status = await inspectClaudeInstallation(() => binary, async () => { throw new Error("timeout /private/path secret-token"); });
assert.deepEqual(status, { state: "version-unchecked", version: null, minimumVersion: "2.1.259" });
});
for (const output of ["2.1.258", "garbage", null]) test(`launcher refuses ${output ?? "failed"} version check before model work`, async () => {
const launch = createClaudeLauncher({ findBinary: () => binary,
readVersion: async () => { if (output === null) throw new Error("failed probe"); return output; },
spawnProcess: (() => { assert.fail("must not start model work"); }) as typeof spawn,
});
await assert.rejects(launch(["-p"], options) as Promise<unknown>, ClaudeLaunchError);
});
test("launcher uses the verified executable directly and preserves model args and native env", async () => {
const child = {} as ChildProcessWithoutNullStreams;
let calls = 0;
const launch = createClaudeLauncher({ findBinary: () => binary, readVersion: async () => "2.1.310 (Claude Code)",
spawnProcess: ((path: string, args: string[], opts: unknown) => {
calls++;
assert.equal(path, binary);
assert.deepEqual(args, ["-p", "--tools", ""]);
assert.deepEqual(opts, { ...options, stdio: "pipe" });
return child;
}) as typeof spawn,
});
assert.equal(await launch(["-p", "--tools", ""], options), child);
assert.equal(calls, 1);
});
test("aborting during a version check prevents model process creation", async () => {
const controller = new AbortController();
const launch = createClaudeLauncher({ findBinary: () => binary,
readVersion: async () => { controller.abort(); return "2.1.310"; },
spawnProcess: (() => { assert.fail("must not start after abort"); }) as typeof spawn,
});
await assert.rejects(launch([], { ...options, signal: controller.signal }) as Promise<unknown>, /aborted/);
});
test("missing executable is not mistaken for an unchecked version", async () => {
const launch = createClaudeLauncher({ findBinary: () => null, readVersion: async () => assert.fail("must not probe") });
assert.equal(await launch([], options), null);
});
+81
View File
@@ -0,0 +1,81 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
const originalFetch = globalThis.fetch;
let root: Root | undefined, host: HTMLDivElement | undefined;
const status = (state: ClaudeCodeStatus["state"], version: string | null = null): ClaudeCodeStatus => ({ state, version, minimumVersion: "2.1.259" });
const button = () => document.querySelector<HTMLButtonElement>('[data-testid="claude-code-check-again"]')!;
const label = () => document.querySelector('[role="status"]')?.textContent;
async function mount() {
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root!.render(<ClaudeCodeInstallationStatus />));
}
afterEach(async () => {
await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined;
globalThis.fetch = originalFetch;
});
test("row is informational, disclaims auth/billing readiness, and links only to official setup", () => {
const html = renderToStaticMarkup(<ClaudeCodeInstallationStatus />);
assert.match(html, /Authentication is not checked/);
assert.match(html, /configuration determines billing/);
assert.match(html, /does not guarantee MCP compatibility/);
assert.match(html, /https:\/\/code.claude.com\/docs\/en\/setup/);
assert.equal((html.match(/<button/g) ?? []).length, 1);
assert(!html.includes("Connect</button>"));
assert(!html.includes("Sign in</button>"));
});
for (const [value, text] of [
[status("not-found"), "Not found"],
[status("meets-minimum", "2.1.310"), "Version 2.1.310 · meets minimum"],
[status("update-required", "2.1.258"), "Version 2.1.258 · update required"],
[status("version-unchecked"), "Version could not be checked"],
] as const) test(`installation display: ${text}`, () => assert.equal(claudeCodeStatusLabel(value), text));
test("mount checks once, Check again refreshes, and neither request asks for authentication", async () => {
const calls: string[] = [];
globalThis.fetch = (async (url, init) => {
calls.push(String(url));
assert.equal(init?.cache, "no-store");
return Response.json(calls.length === 1 ? status("not-found") : status("meets-minimum", "2.1.310"));
}) as typeof fetch;
await mount();
assert.equal(label(), "Not found");
await act(async () => button().click());
assert.equal(label(), "Version 2.1.310 · meets minimum");
assert.deepEqual(calls, ["/api/claude-code", "/api/claude-code"]);
});
test("slow checks disable recheck and unmount aborts outstanding work", async () => {
let signal: AbortSignal | null | undefined;
let resolve!: (response: Response) => void;
globalThis.fetch = ((_url, init) => { signal = init?.signal; return new Promise<Response>((done) => { resolve = done; }); }) as typeof fetch;
await mount();
assert.equal(label(), "Checking…"); assert(button().disabled);
await act(async () => { root!.unmount(); root = undefined; });
assert(signal?.aborted);
await act(async () => resolve(Response.json(status("not-found"))));
assert.equal(document.querySelector('[role="status"]'), null);
});
for (const failure of ["network", "http", "payload"] as const) test(`${failure} failure never retains a stale meets-minimum status`, async () => {
let calls = 0;
globalThis.fetch = (async () => {
if (++calls === 1) return Response.json(status("meets-minimum", "2.1.310"));
if (failure === "network") throw new Error("offline");
return failure === "http" ? new Response(null, { status: 503 }) : Response.json({ state: "authenticated" });
}) as typeof fetch;
await mount(); assert.equal(label(), "Version 2.1.310 · meets minimum");
await act(async () => button().click());
assert.equal(label(), "Version could not be checked"); assert(!button().disabled);
});
+4 -2
View File
@@ -37,6 +37,7 @@ import { ClaudeCodeError } from "../src/agent/claude-code/constants.ts";
import { LIVE_PROCESS_IDLE_TIMEOUT_MS, liveProcessIdleTimeoutMs } from "../src/agent/claude-code/live-processes.ts";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { authEnvRecord } from "../src/core/auth-env.ts";
import { ClaudeLaunchError } from "../src/agent/claude-code/launch.ts";
import { createClaudeCodeStreamFunction, toClaudeArgs, toEffort, type RunClaude } from "../src/agent/claude-code/stream.ts";
import { toStreamJsonLine } from "../src/agent/claude-code/transcript.ts";
@@ -899,15 +900,16 @@ test("normal completion escalates termination when the child ignores SIGTERM", a
assert.equal(getOnlyCall(calls).child.signalCode, "SIGKILL");
});
test("a synchronous spawn failure removes both temp directories and the bridge", async () => {
for (const asyncVersionFailure of [false, true]) test(`${asyncVersionFailure ? "an asynchronous version-check" : "a synchronous spawn"} failure removes both temp directories and the bridge`, async () => {
let resources: { cwd: string; privateDir: string; url: string } | undefined;
const stream = createClaudeCodeStreamFunction((args, options) => {
const config = JSON.parse(readFileSync(getFlagValue(args, "--mcp-config"), "utf8"));
resources = { cwd: options.cwd, privateDir: dirname(getFlagValue(args, "--mcp-config")), url: config.mcpServers.learnharness.url };
if (asyncVersionFailure) return Promise.reject(new ClaudeLaunchError("Claude Code requires an update"));
throw new Error("synthetic spawn failure");
});
const events = await collectEvents(stream(getModel(), normalizeContext({ messages: [HI] })));
assert.match(getErrorMessage(events) ?? "", /failed to start/);
assert.match(getErrorMessage(events) ?? "", asyncVersionFailure ? /requires an update/ : /failed to start/);
assert.ok(resources);
assert.equal(existsSync(resources.cwd), false);
assert.equal(existsSync(resources.privateDir), false);
+4 -3
View File
@@ -354,7 +354,7 @@ function withClaudeInstalled(installed: boolean, fn: () => void): void {
}
}
describe("the claude-code provider (subscription CLI, no key)", () => {
describe("the claude-code provider (native CLI authentication, no LearnHarness key)", () => {
it("modelCatalog() lists claude-code opus/sonnet/haiku when the CLI is installed", () => {
withClaudeInstalled(true, () => {
const ids = modelCatalog()
@@ -394,7 +394,8 @@ describe("the claude-code provider (subscription CLI, no key)", () => {
assert.equal(engine.model.id, "sonnet");
});
it('providerKeyPresent("claude-code") is true', () => {
assert.equal(providerKeyPresent("claude-code"), true);
it('providerKeyPresent("claude-code") follows executable presence, not a generic keyless default', () => {
withClaudeInstalled(true, () => assert.equal(providerKeyPresent("claude-code"), true));
withClaudeInstalled(false, () => assert.equal(providerKeyPresent("claude-code"), false));
});
});