✨ feat(vscode): 代码阅读加「问 AI」,块下面问 Claude Code,问答存进 .spx 挂在块下
This commit is contained in:
1 parent
badc722640
commit
fb53bb727c
34 files changed
+2726
-43
No files matched your search
@@ -27,7 +27,8 @@ function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function describeToolUse(name: string, input: Record<string, unknown>): string {
|
||||
/** 一次工具调用写成人看得懂的短语:「读取 routes.py」「搜索 get_user」。 */
|
||||
export function describeToolUse(name: string, input: Record<string, unknown>): string {
|
||||
switch (name) {
|
||||
case 'Bash':
|
||||
return `执行命令:${truncate(asString(input.description) || asString(input.command))}`
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { promises as fsp } from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from './spawnClaude'
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fake-claude-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** 假的 claude:一段 sh 脚本 */
|
||||
async function fakeBin(script: string): Promise<string> {
|
||||
const bin = path.join(dir, 'claude')
|
||||
await fsp.writeFile(bin, `#!/bin/sh\n${script}\n`, { mode: 0o755 })
|
||||
return bin
|
||||
}
|
||||
|
||||
describe('跑 claude、逐行交出 stdout', () => {
|
||||
it('每出来完整的一行交一行,最后没带换行的那段结束时交出;参数原样传、在 cwd 里跑', async () => {
|
||||
const bin = await fakeBin('printf \'{"a":1}\\n{"b":\'; sleep 0.1; printf \'2}\\n\'; pwd; printf "%s|" "$@"')
|
||||
const lines: string[] = []
|
||||
await runClaudeLines({ bin, args: ['-p', '带 空格 的问题'], cwd: dir, onLine: item => lines.push(item) })
|
||||
expect(lines).toEqual(['{"a":1}', '{"b":2}', await fsp.realpath(dir), '-p|带 空格 的问题|'])
|
||||
})
|
||||
|
||||
it('退出码非零:带上 stderr 的原话', async () => {
|
||||
const bin = await fakeBin('echo "No conversation found with session ID: x" >&2; exit 1')
|
||||
const error = await runClaudeLines({ bin, args: [], cwd: dir, onLine: () => {} }).catch((err: unknown) => err)
|
||||
expect(error).toBeInstanceOf(ClaudeError)
|
||||
expect((error as ClaudeError).message).toBe('Claude 退出码非零 (1): No conversation found with session ID: x')
|
||||
})
|
||||
|
||||
it('叫停:马上结束进程,报 ClaudeCancelledError;已经叫停的不再启动', async () => {
|
||||
const marker = path.join(dir, 'still-running')
|
||||
const bin = await fakeBin(`echo started; sleep 2; touch "${marker}"`)
|
||||
const controller = new AbortController()
|
||||
const started = Date.now()
|
||||
const run = runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => controller.abort() })
|
||||
await expect(run).rejects.toBeInstanceOf(ClaudeCancelledError)
|
||||
expect(Date.now() - started).toBeLessThan(1500)
|
||||
await new Promise(resolve => setTimeout(resolve, 2500))
|
||||
await expect(fsp.access(marker)).rejects.toThrow()
|
||||
await expect(runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeCancelledError)
|
||||
}, 10_000)
|
||||
|
||||
it('超时:结束进程,报 ClaudeTimeoutError', async () => {
|
||||
const bin = await fakeBin('sleep 5')
|
||||
await expect(runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 200, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeTimeoutError)
|
||||
})
|
||||
})
|
||||
+121
-12
@@ -58,6 +58,14 @@ export class ClaudeTimeoutError extends ClaudeError {
|
||||
}
|
||||
}
|
||||
|
||||
/** 调用方叫停(AbortSignal)时进程已被结束。 */
|
||||
export class ClaudeCancelledError extends ClaudeError {
|
||||
constructor(stderr: string) {
|
||||
super('Claude 调用已停止', stderr)
|
||||
this.name = 'ClaudeCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedClaudeJson {
|
||||
session_id: string
|
||||
result: string
|
||||
@@ -185,21 +193,33 @@ export type ClaudeProgressListener = (label: string) => void
|
||||
|
||||
/**
|
||||
* 按行切 stdout:stream-json 每行一个对象,但 data 事件的 chunk 边界和行边界
|
||||
* 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上。
|
||||
* 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上;
|
||||
* 进程结束时 flush 交出最后那段没带换行的。
|
||||
*/
|
||||
function createLineFeeder(onLine: (line: string) => void): { feed: (chunk: string) => void, flush: () => void } {
|
||||
let pending = ''
|
||||
return {
|
||||
feed: (chunk) => {
|
||||
pending += chunk
|
||||
const lines = pending.split('\n')
|
||||
pending = lines.pop() ?? ''
|
||||
lines.forEach(onLine)
|
||||
},
|
||||
flush: () => {
|
||||
if (pending !== '')
|
||||
onLine(pending)
|
||||
pending = ''
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createProgressFeeder(onProgress?: ClaudeProgressListener): (chunk: string) => void {
|
||||
if (!onProgress)
|
||||
return () => {}
|
||||
let pending = ''
|
||||
return (chunk) => {
|
||||
pending += chunk
|
||||
const lines = pending.split('\n')
|
||||
pending = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
for (const label of summarizeClaudeStreamLine(line))
|
||||
onProgress(label)
|
||||
}
|
||||
}
|
||||
return createLineFeeder((line) => {
|
||||
for (const label of summarizeClaudeStreamLine(line))
|
||||
onProgress(label)
|
||||
}).feed
|
||||
}
|
||||
|
||||
function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
|
||||
@@ -233,7 +253,7 @@ function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
|
||||
* - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION:
|
||||
* 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。
|
||||
*/
|
||||
const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin)'
|
||||
export const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin)'
|
||||
|
||||
function buildClaudeChildEnv(bin?: string): NodeJS.ProcessEnv {
|
||||
// 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源
|
||||
@@ -537,3 +557,92 @@ function spawnClaudeStreamed(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一次 claude(参数由调用方拼好,一般带 `--output-format stream-json`),stdout 每出来完整的一行就交给 onLine。
|
||||
* 正常退出时 resolve;找不到 claude、退出码非零、输出过大时 reject ClaudeError,超时 ClaudeTimeoutError,
|
||||
* signal 叫停时结束进程并 reject ClaudeCancelledError。
|
||||
*/
|
||||
export function runClaudeLines(opts: {
|
||||
args: string[]
|
||||
cwd: string
|
||||
onLine: (line: string) => void
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
/** 默认按 resolveClaudeBin 找 */
|
||||
bin?: string
|
||||
}): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const bin = opts.bin ?? resolveClaudeBin()
|
||||
if (!bin)
|
||||
return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, ''))
|
||||
if (opts.signal?.aborted)
|
||||
return Promise.reject(new ClaudeCancelledError(''))
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let stdoutBytes = 0
|
||||
let settled = false
|
||||
const lines = createLineFeeder(opts.onLine)
|
||||
|
||||
const child = spawn(bin, opts.args, {
|
||||
cwd: opts.cwd,
|
||||
env: buildClaudeChildEnv(bin),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
settle(new ClaudeTimeoutError(`Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`, stderr))
|
||||
}, timeoutMs)
|
||||
function onAbort(): void {
|
||||
child.kill('SIGTERM')
|
||||
settle(new ClaudeCancelledError(stderr))
|
||||
}
|
||||
function settle(error?: ClaudeError): void {
|
||||
if (settled)
|
||||
return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
opts.signal?.removeEventListener('abort', onAbort)
|
||||
if (error)
|
||||
reject(error)
|
||||
else
|
||||
resolve()
|
||||
}
|
||||
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
child.on('error', (err) => {
|
||||
const errCode = (err as NodeJS.ErrnoException).code
|
||||
settle(new ClaudeError(errCode === 'ENOENT' ? CLAUDE_MISSING_MSG : `Claude 调用失败: ${err.message}`, stderr))
|
||||
})
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
if (settled)
|
||||
return
|
||||
stdoutBytes += chunk.length
|
||||
if (stdoutBytes > MAX_BUFFER_BYTES) {
|
||||
child.kill('SIGKILL')
|
||||
settle(new ClaudeError(`Claude 输出超过 ${MAX_BUFFER_BYTES} bytes`, stderr))
|
||||
return
|
||||
}
|
||||
const text = chunk.toString('utf8')
|
||||
stdout += text
|
||||
lines.feed(text)
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (settled)
|
||||
return
|
||||
lines.flush()
|
||||
settle(code === 0
|
||||
? undefined
|
||||
: new ClaudeError(`Claude 退出码非零 (${code}): ${buildClaudeExitDiagnostic(stderr, stdout)}`, stderr))
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user