This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+485
View File
@@ -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_TOKENheadless `-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.
}
})
}