11
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* 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 "<prompt>" --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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<Record<string, unknown>> = [
|
||||
{ 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<ClaudeResult> {
|
||||
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<ClaudeResult> {
|
||||
const args = [
|
||||
...(bare ? ['--bare'] : []),
|
||||
'--dangerously-skip-permissions',
|
||||
...(profilePath ? ['--settings', profilePath] : []),
|
||||
'-p',
|
||||
prompt,
|
||||
'--output-format',
|
||||
'json',
|
||||
]
|
||||
|
||||
return new Promise<ClaudeResult>((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<ClaudeResult> {
|
||||
// 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<ClaudeResult>((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.
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user