diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index dcbbb7ed..254689a2 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -53,4 +53,5 @@ Use the "Test Environment" button in the UI to validate the adapter config. It c - Claude CLI is installed and accessible - Working directory is absolute and available (auto-created if missing and permitted) -- API key is configured (warning if missing) +- API key/auth mode hints (`ANTHROPIC_API_KEY` vs subscription login) +- A live hello probe (`claude --print - --output-format stream-json --verbose` with prompt `Respond with hello.`) to verify CLI readiness diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index f58213fb..d87172f8 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -36,4 +36,5 @@ The environment test checks: - Codex CLI is installed and accessible - Working directory is absolute and available (auto-created if missing and permitted) -- API key is configured +- Authentication signal (`OPENAI_API_KEY` presence) +- A live hello probe (`codex exec --json -` with prompt `Respond with hello.`) to verify the CLI can actually run diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index bc44d9dc..98d570c4 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -5,11 +5,17 @@ import type { } from "@paperclipai/adapter-utils"; import { asString, + asBoolean, + asNumber, + asStringArray, parseObject, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, + runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import path from "node:path"; +import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -21,6 +27,28 @@ function isNonEmpty(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function commandLooksLike(command: string, expected: string): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function summarizeProbeDetail(stdout: string, stderr: string): string | null { + const raw = firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +} + export async function testEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { @@ -87,6 +115,105 @@ export async function testEnvironment( }); } + const canRunProbe = + checks.every((check) => check.code !== "claude_cwd_invalid" && check.code !== "claude_command_unresolvable"); + if (canRunProbe) { + if (!commandLooksLike(command, "claude")) { + checks.push({ + code: "claude_hello_probe_skipped_custom_command", + level: "info", + message: "Skipped hello probe because command is not `claude`.", + detail: command, + hint: "Use the `claude` CLI command to run the automatic login and installation probe.", + }); + } else { + const model = asString(config.model, "").trim(); + const effort = asString(config.effort, "").trim(); + const chrome = asBoolean(config.chrome, false); + const maxTurns = asNumber(config.maxTurnsPerRun, 0); + const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const args = ["--print", "-", "--output-format", "stream-json", "--verbose"]; + if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions"); + if (chrome) args.push("--chrome"); + if (model) args.push("--model", model); + if (effort) args.push("--effort", effort); + if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); + if (extraArgs.length > 0) args.push(...extraArgs); + + const probe = await runChildProcess( + `claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 45, + graceSec: 5, + stdin: "Respond with hello.", + onLog: async () => {}, + }, + ); + + const parsedStream = parseClaudeStreamJson(probe.stdout); + const parsed = parsedStream.resultJson; + const loginMeta = detectClaudeLoginRequired({ + parsed, + stdout: probe.stdout, + stderr: probe.stderr, + }); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr); + + if (probe.timedOut) { + checks.push({ + code: "claude_hello_probe_timed_out", + level: "warn", + message: "Claude hello probe timed out.", + hint: "Retry the probe. If this persists, verify Claude can run `Respond with hello` from this directory manually.", + }); + } else if (loginMeta.requiresLogin) { + checks.push({ + code: "claude_hello_probe_auth_required", + level: "warn", + message: "Claude CLI is installed, but login is required.", + ...(detail ? { detail } : {}), + hint: loginMeta.loginUrl + ? `Run \`claude login\` and complete sign-in at ${loginMeta.loginUrl}, then retry.` + : "Run `claude login` in this environment, then retry the probe.", + }); + } else if ((probe.exitCode ?? 1) === 0) { + const summary = parsedStream.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "claude_hello_probe_passed" : "claude_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "Claude hello probe succeeded." + : "Claude probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Try the probe manually (`claude --print - --output-format stream-json --verbose`) and prompt `Respond with hello`.", + }), + }); + } else { + checks.push({ + code: "claude_hello_probe_failed", + level: "error", + message: "Claude hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `claude --print - --output-format stream-json --verbose` manually in this directory and prompt `Respond with hello` to debug.", + }); + } + } + } + return { adapterType: ctx.adapterType, status: summarizeStatus(checks), diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 606ec233..292e53ee 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -5,11 +5,16 @@ import type { } from "@paperclipai/adapter-utils"; import { asString, + asBoolean, + asStringArray, parseObject, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, + runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import path from "node:path"; +import { parseCodexJsonl } from "./parse.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -21,6 +26,31 @@ function isNonEmpty(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function commandLooksLike(command: string, expected: string): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +} + +const CODEX_AUTH_REQUIRED_RE = + /(?:not\s+logged\s+in|login\s+required|authentication\s+required|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|api[_\s-]?key.*required|please\s+run\s+`?codex\s+login`?)/i; + export async function testEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { @@ -86,6 +116,104 @@ export async function testEnvironment( }); } + const canRunProbe = + checks.every((check) => check.code !== "codex_cwd_invalid" && check.code !== "codex_command_unresolvable"); + if (canRunProbe) { + if (!commandLooksLike(command, "codex")) { + checks.push({ + code: "codex_hello_probe_skipped_custom_command", + level: "info", + message: "Skipped hello probe because command is not `codex`.", + detail: command, + hint: "Use the `codex` CLI command to run the automatic login and installation probe.", + }); + } else { + const model = asString(config.model, "").trim(); + const modelReasoningEffort = asString( + config.modelReasoningEffort, + asString(config.reasoningEffort, ""), + ).trim(); + const search = asBoolean(config.search, false); + const bypass = asBoolean( + config.dangerouslyBypassApprovalsAndSandbox, + asBoolean(config.dangerouslyBypassSandbox, false), + ); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const args = ["exec", "--json"]; + if (search) args.unshift("--search"); + if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); + if (model) args.push("--model", model); + if (modelReasoningEffort) { + args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`); + } + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("-"); + + const probe = await runChildProcess( + `codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 45, + graceSec: 5, + stdin: "Respond with hello.", + onLog: async () => {}, + }, + ); + const parsed = parseCodexJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + + if (probe.timedOut) { + checks.push({ + code: "codex_hello_probe_timed_out", + level: "warn", + message: "Codex hello probe timed out.", + hint: "Retry the probe. If this persists, verify Codex can run `Respond with hello` from this directory manually.", + }); + } else if ((probe.exitCode ?? 1) === 0) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "codex_hello_probe_passed" : "codex_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "Codex hello probe succeeded." + : "Codex probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Try the probe manually (`codex exec --json -` then prompt: Respond with hello) to inspect full output.", + }), + }); + } else if (CODEX_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "codex_hello_probe_auth_required", + level: "warn", + message: "Codex CLI is installed, but authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Configure OPENAI_API_KEY in adapter env/shell or run `codex login`, then retry the probe.", + }); + } else { + checks.push({ + code: "codex_hello_probe_failed", + level: "error", + message: "Codex hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `codex exec --json -` manually in this working directory and prompt `Respond with hello` to debug.", + }); + } + } + } + return { adapterType: ctx.adapterType, status: summarizeStatus(checks), diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 69c81650..577e5418 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, useRef, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { companiesApi } from "../api/companies"; @@ -77,6 +78,9 @@ export function OnboardingWizard() { const [command, setCommand] = useState(""); const [args, setArgs] = useState(""); const [url, setUrl] = useState(""); + const [adapterEnvResult, setAdapterEnvResult] = useState(null); + const [adapterEnvError, setAdapterEnvError] = useState(null); + const [adapterEnvLoading, setAdapterEnvLoading] = useState(false); // Step 3 const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md"); @@ -125,6 +129,14 @@ export function OnboardingWizard() { queryFn: () => agentsApi.adapterModels(adapterType), enabled: onboardingOpen && step === 2, }); + const isLocalAdapter = adapterType === "claude_local" || adapterType === "codex_local"; + const effectiveAdapterCommand = command.trim() || (adapterType === "codex_local" ? "codex" : "claude"); + + useEffect(() => { + if (step !== 2) return; + setAdapterEnvResult(null); + setAdapterEnvError(null); + }, [step, adapterType, cwd, model, command, args, url]); const selectedModel = (adapterModels ?? []).find((m) => m.id === model); @@ -141,6 +153,9 @@ export function OnboardingWizard() { setCommand(""); setArgs(""); setUrl(""); + setAdapterEnvResult(null); + setAdapterEnvError(null); + setAdapterEnvLoading(false); setTaskTitle("Create your CEO HEARTBEAT.md"); setTaskDescription(DEFAULT_TASK_DESCRIPTION); setCreatedCompanyId(null); @@ -172,6 +187,27 @@ export function OnboardingWizard() { }); } + async function runAdapterEnvironmentTest(): Promise { + if (!createdCompanyId) { + setAdapterEnvError("Create or select a company before testing adapter environment."); + return null; + } + setAdapterEnvLoading(true); + setAdapterEnvError(null); + try { + const result = await agentsApi.testEnvironment(createdCompanyId, adapterType, { + adapterConfig: buildAdapterConfig(), + }); + setAdapterEnvResult(result); + return result; + } catch (err) { + setAdapterEnvError(err instanceof Error ? err.message : "Adapter environment test failed"); + return null; + } finally { + setAdapterEnvLoading(false); + } + } + async function handleStep1Next() { setLoading(true); setError(null); @@ -204,6 +240,15 @@ export function OnboardingWizard() { setLoading(true); setError(null); try { + if (isLocalAdapter) { + const result = adapterEnvResult ?? (await runAdapterEnvironmentTest()); + if (!result) return; + if (result.status === "fail") { + setError("Adapter environment test failed. Fix the errors and test again before continuing."); + return; + } + } + const agent = await agentsApi.create(createdCompanyId, { name: agentName.trim(), role: "ceo", @@ -545,6 +590,60 @@ export function OnboardingWizard() { )} + {isLocalAdapter && ( +
+
+
+

Adapter environment check

+

+ Runs a live probe that asks the adapter CLI to respond with hello. +

+
+ +
+ + {adapterEnvError && ( +
+ {adapterEnvError} +
+ )} + + {adapterEnvResult && ( + + )} + +
+

Manual debug

+

+ {adapterType === "codex_local" + ? `${effectiveAdapterCommand} exec --json -` + : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`} +

+

+ Prompt: Respond with hello. +

+ {adapterType === "codex_local" ? ( +

+ If auth fails, set OPENAI_API_KEY in env or run{" "} + codex login. +

+ ) : ( +

+ If login is required, run claude login and retry. +

+ )} +
+
+ )} + {adapterType === "process" && (
@@ -713,7 +812,7 @@ export function OnboardingWizard() { {step === 2 && (