/** * Typed wrapper around the `claude` CLI in `-p` (one-shot) mode with * `--output-format json`. We rely on `claude` being available on PATH; * callers should surface the install-hint error message to the user when * ENOENT is raised. * * Two execution paths share the same output-parsing logic: * * - **Text-only** (no images): plain `claude -p "" --output-format json` * via `spawn` with stdin ignored so the CLI does not wait for piped input. * * - **With images**: switch to `claude -p --input-format stream-json * --output-format json` via `spawn`, then feed a single NDJSON line on * stdin containing the user message with text + image content blocks * (Anthropic Messages API shape). The official docs state single-message * input does NOT support image attachments — stream-json is the only * supported path. See: * https://code.claude.com/docs/en/agent-sdk/streaming-vs-single-mode */ import { spawn } from 'node:child_process' const DEFAULT_TIMEOUT_MS = 300_000 const MAX_BUFFER_BYTES = 10 * 1024 * 1024 const EXIT_DIAGNOSTIC_MAX_CHARS = 2000 export interface ClaudeImage { /** e.g. "image/png", "image/jpeg", "image/webp", "image/gif" */ mediaType: string /** Raw base64 (no `data:...;base64,` prefix) */ base64: string } export interface ClaudeResult { sessionId: string resultText: string rawJson: string } export class ClaudeError extends Error { constructor(message: string, public readonly stderr: string) { super(message) this.name = 'ClaudeError' } } export class ClaudeTimeoutError extends ClaudeError { constructor(message: string, stderr: string) { super(message, stderr) this.name = 'ClaudeTimeoutError' } } interface ParsedClaudeJson { session_id: string result: string } function tryParseClaudeJson(text: string): ParsedClaudeJson | null { let parsed: unknown try { parsed = JSON.parse(text) } catch { return null } if ( parsed && typeof parsed === 'object' && 'result' in parsed && 'session_id' in parsed ) { const obj = parsed as Record if (typeof obj.result === 'string' && typeof obj.session_id === 'string') { return { session_id: obj.session_id, result: obj.result } } } return null } function extractClaudePayload(stdout: string): ParsedClaudeJson | null { const trimmed = stdout.trim() if (!trimmed) return null const direct = tryParseClaudeJson(trimmed) if (direct) return direct // Fallback: streaming JSON (one object per line). Walk from the end to // find the last line that parses with the right shape. const lines = trimmed.split('\n') for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim() if (!line) continue const candidate = tryParseClaudeJson(line) if (candidate) return candidate } return null } function truncateDiagnostic(text: string): string { if (text.length <= EXIT_DIAGNOSTIC_MAX_CHARS) return text return `${text.slice(0, EXIT_DIAGNOSTIC_MAX_CHARS)}…` } function formatDiagnosticValue(value: unknown): string | null { if (typeof value === 'string') { const trimmed = value.trim() return trimmed || null } if (typeof value === 'number' || typeof value === 'boolean') return String(value) if (value && typeof value === 'object') { try { return JSON.stringify(value) } catch { return null } } return null } function extractJsonDiagnostic(text: string): string | null { let parsed: unknown try { parsed = JSON.parse(text) } catch { return null } if (!parsed || typeof parsed !== 'object') return null const obj = parsed as Record for (const key of ['error', 'message', 'result', 'summary']) { const diagnostic = formatDiagnosticValue(obj[key]) if (diagnostic) return truncateDiagnostic(diagnostic) } return null } function extractStdoutDiagnostic(stdout: string): string | null { const trimmed = stdout.trim() if (!trimmed) return null const direct = extractJsonDiagnostic(trimmed) if (direct) return direct const lines = trimmed.split('\n') for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim() if (!line) continue const diagnostic = extractJsonDiagnostic(line) if (diagnostic) return diagnostic } return truncateDiagnostic(trimmed) } function buildClaudeExitDiagnostic(stderr: string, stdout: string): string { const stderrDiagnostic = stderr.trim() if (stderrDiagnostic) return truncateDiagnostic(stderrDiagnostic) return extractStdoutDiagnostic(stdout) ?? '(无 stderr/stdout)' } function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string { const content: Array> = [ { type: 'text', text: prompt }, ] for (const img of images) { content.push({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.base64, }, }) } const message = { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, } return `${JSON.stringify(message)}\n` } /** * 构造后台 claude 子进程的环境变量:从父进程 env 剔除会导致 headless 鉴权 * 失败或嵌套会话静默退出的变量,让子进程回落到订阅 OAuth 凭据。 * * - ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN:headless `-p` 无条件优先用它们, * 若属于受限/过期 org 会 403 Request not allowed。剔除后回落 ~/.claude 订阅登录。 * - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION: * 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。 */ function buildClaudeChildEnv(): NodeJS.ProcessEnv { // 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源 // (profile 没配则回落 ~/.claude 订阅 OAuth),避免宿主 env 残留的 BASE_URL/TOKEN // 覆盖 profile 导致 403。同时清除嵌套 Claude 会话标记,避免后台 `claude -p` 误判处于嵌套会话。 const NESTED_GUARDS = new Set([ 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_SESSION', ]) const out: NodeJS.ProcessEnv = {} for (const [k, v] of Object.entries(process.env)) { if (k.startsWith('ANTHROPIC_')) continue if (NESTED_GUARDS.has(k)) continue out[k] = v } return out } export async function spawnClaude(opts: { prompt: string cwd: string timeoutMs?: number images?: ClaudeImage[] profilePath?: string bare?: boolean }): Promise { const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS const hasImages = !!opts.images && opts.images.length > 0 if (!hasImages) { return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare) } return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare) } function spawnClaudeText( prompt: string, cwd: string, timeoutMs: number, profilePath?: string, bare?: boolean, ): Promise { const args = [ ...(bare ? ['--bare'] : []), '--dangerously-skip-permissions', ...(profilePath ? ['--settings', profilePath] : []), '-p', prompt, '--output-format', 'json', ] return new Promise((resolve, reject) => { let stdout = '' let stderr = '' let stdoutBytes = 0 let settled = false const child = spawn('claude', args, { cwd, env: buildClaudeChildEnv(), stdio: ['ignore', 'pipe', 'pipe'], }) const timer = setTimeout(() => { if (settled) return settled = true child.kill('SIGKILL') reject(new ClaudeTimeoutError( `Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`, stderr, )) }, timeoutMs) child.on('error', (err) => { if (settled) return settled = true clearTimeout(timer) const errCode = (err as NodeJS.ErrnoException).code if (errCode === 'ENOENT') { reject(new ClaudeError( '未检测到 claude CLI,请确认已安装并在 PATH 中', stderr, )) return } reject(new ClaudeError( `Claude 调用失败: ${err.message}`, stderr, )) }) child.stdout.on('data', (chunk: Buffer) => { stdoutBytes += chunk.length if (stdoutBytes > MAX_BUFFER_BYTES) { if (settled) return settled = true clearTimeout(timer) child.kill('SIGKILL') reject(new ClaudeError( `Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`, stderr, )) return } stdout += chunk.toString('utf8') }) child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) child.on('close', (code) => { if (settled) return settled = true clearTimeout(timer) if (code !== 0) { reject(new ClaudeError( `Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`, stderr, )) return } const payload = extractClaudePayload(stdout) if (!payload) { reject(new ClaudeError( 'Claude 返回异常: 无法解析 JSON 输出', stdout, )) return } resolve({ sessionId: payload.session_id, resultText: payload.result, rawJson: stdout, }) }) }) } function spawnClaudeStreamed( prompt: string, cwd: string, timeoutMs: number, images: ClaudeImage[], profilePath?: string, bare?: boolean, ): Promise { // Local Claude Code v2.1.143 enforces that --input-format=stream-json must // be paired with --output-format=stream-json (the friendlier `--output-format // json` is rejected). The output is NDJSON; extractClaudePayload walks lines // from the end to find the final result-shaped object. const args = [ ...(bare ? ['--bare'] : []), '--dangerously-skip-permissions', ...(profilePath ? ['--settings', profilePath] : []), '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', ] const ndjson = buildStreamJsonLine(prompt, images) return new Promise((resolve, reject) => { let stdout = '' let stderr = '' let stdoutBytes = 0 let settled = false const child = spawn('claude', args, { cwd, env: buildClaudeChildEnv(), stdio: ['pipe', 'pipe', 'pipe'], }) const timer = setTimeout(() => { if (settled) return settled = true child.kill('SIGKILL') reject(new ClaudeTimeoutError( `Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`, stderr, )) }, timeoutMs) child.on('error', (err) => { if (settled) return settled = true clearTimeout(timer) const errCode = (err as NodeJS.ErrnoException).code if (errCode === 'ENOENT') { reject(new ClaudeError( '未检测到 claude CLI,请确认已安装并在 PATH 中', stderr, )) return } reject(new ClaudeError( `Claude 调用失败: ${err.message}`, stderr, )) }) child.stdout.on('data', (chunk: Buffer) => { stdoutBytes += chunk.length if (stdoutBytes > MAX_BUFFER_BYTES) { if (settled) return settled = true clearTimeout(timer) child.kill('SIGKILL') reject(new ClaudeError( `Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`, stderr, )) return } stdout += chunk.toString('utf8') }) child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) child.on('close', (code) => { if (settled) return settled = true clearTimeout(timer) if (code !== 0) { reject(new ClaudeError( `Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`, stderr, )) return } const payload = extractClaudePayload(stdout) if (!payload) { reject(new ClaudeError( 'Claude 返回异常: 无法解析 JSON 输出', stdout, )) return } resolve({ sessionId: payload.session_id, resultText: payload.result, rawJson: stdout, }) }) // Send the single NDJSON line and close stdin so claude knows the user // turn is complete. If the child has already exited (e.g. arg validation // failed), stdin will EPIPE — swallow it; the 'close' handler will surface // the real error from stderr + exit code. child.stdin.on('error', () => { /* ignore EPIPE / ECONNRESET */ }) try { child.stdin.write(ndjson) child.stdin.end() } catch { // child already gone; close handler will run with the error code. } }) }