From 9255b55625b17b43f575151d403f1719b41e2edd Mon Sep 17 00:00:00 2001 From: cruldra Date: Wed, 5 Aug 2026 14:57:48 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20GUI=20=E4=B8=8B?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=20claude=20=E7=BB=9D=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=EF=BC=88=E5=90=AB=20~/.local/bin=20=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E5=AE=89=E8=A3=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vscode/src/cc/ccCommand.ts | 8 ++++- vscode/src/cc/claudeBin.test.ts | 59 +++++++++++++++++++++++++++++++++ vscode/src/cc/claudeBin.ts | 36 ++++++++++++++++++++ vscode/src/cc/spawnClaude.ts | 38 +++++++++++++-------- 4 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 vscode/src/cc/claudeBin.test.ts create mode 100644 vscode/src/cc/claudeBin.ts diff --git a/vscode/src/cc/ccCommand.ts b/vscode/src/cc/ccCommand.ts index 2345b69..8337738 100644 --- a/vscode/src/cc/ccCommand.ts +++ b/vscode/src/cc/ccCommand.ts @@ -1,3 +1,5 @@ +import { resolveClaudeBin } from './claudeBin' + /** * Single source of truth for the `claude` (cc) terminal launch command. * @@ -20,8 +22,12 @@ export interface CcCommandOpts { prompt?: string } +function quoteIfNeeded(bin: string): string { + return bin.includes(' ') ? `'${bin}'` : bin +} + export function buildCcCommand(opts: CcCommandOpts): string { - const parts = ['claude'] + const parts = [quoteIfNeeded(resolveClaudeBin() ?? 'claude')] if (opts.effort) parts.push(`--effort ${opts.effort}`) parts.push('--dangerously-skip-permissions', `--settings '${opts.profilePath}'`) diff --git a/vscode/src/cc/claudeBin.test.ts b/vscode/src/cc/claudeBin.test.ts new file mode 100644 index 0000000..f78aa9e --- /dev/null +++ b/vscode/src/cc/claudeBin.test.ts @@ -0,0 +1,59 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveClaudeBin } from './claudeBin' + +const tempDirs: string[] = [] + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix = 'claude-bin-'): string { + const dir = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +function touchClaude(dir: string): string { + mkdirSync(dir, { recursive: true }) + const bin = path.join(dir, 'claude') + writeFileSync(bin, '') + return bin +} + +describe('resolveClaudeBin', () => { + it('优先命中 PATH 中的 claude', () => { + const dir = tempDir() + const bin = touchClaude(dir) + expect(resolveClaudeBin({ PATH: dir }, '/no-such-home')).toBe(bin) + }) + + it('PATH 未命中时回退到 $HOME/.local/bin/claude', () => { + const home = tempDir() + const bin = touchClaude(path.join(home, '.local', 'bin')) + expect(resolveClaudeBin({ PATH: '' }, home)).toBe(bin) + }) + + it('PATH 未命中时回退到 $HOME/.claude/local/claude', () => { + const home = tempDir() + const bin = touchClaude(path.join(home, '.claude', 'local')) + expect(resolveClaudeBin({ PATH: '' }, home)).toBe(bin) + }) + + it('PATH 与候选皆空 → null', () => { + const home = tempDir() + expect(resolveClaudeBin({ PATH: '' }, home)).toBeNull() + }) + + it('PATH 中目录不存在时跳过并继续', () => { + const home = tempDir() + const bin = touchClaude(path.join(home, '.local', 'bin')) + expect(resolveClaudeBin({ PATH: '/definitely/missing/bin' }, home)).toBe(bin) + }) +}) diff --git a/vscode/src/cc/claudeBin.ts b/vscode/src/cc/claudeBin.ts new file mode 100644 index 0000000..66cb2d0 --- /dev/null +++ b/vscode/src/cc/claudeBin.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +/** + * 解析 claude 可执行文件绝对路径;找不到返回 null。 + * + * 顺序:env.PATH 各目录下的 claude → 官方/常见绝对路径候选。 + * GUI 启动的 VS Code 常缺 ~/.local/bin,不能只靠 spawn('claude')。 + */ +export function resolveClaudeBin( + env: NodeJS.ProcessEnv = process.env, + homeDir?: string, +): string | null { + const home = homeDir ?? env.HOME ?? os.homedir() + + for (const dir of (env.PATH ?? '').split(path.delimiter)) { + if (!dir) + continue + const candidate = path.join(dir, 'claude') + if (existsSync(candidate)) + return candidate + } + + const fallbacks = [ + path.join(home, '.local', 'bin', 'claude'), + '/usr/local/bin/claude', + '/usr/bin/claude', + path.join(home, '.claude', 'local', 'claude'), + ] + for (const candidate of fallbacks) { + if (existsSync(candidate)) + return candidate + } + return null +} diff --git a/vscode/src/cc/spawnClaude.ts b/vscode/src/cc/spawnClaude.ts index d271878..2a50c2a 100644 --- a/vscode/src/cc/spawnClaude.ts +++ b/vscode/src/cc/spawnClaude.ts @@ -19,6 +19,8 @@ */ import { spawn } from 'node:child_process' +import * as path from 'node:path' +import { resolveClaudeBin } from './claudeBin' const DEFAULT_TIMEOUT_MS = 300_000 const MAX_BUFFER_BYTES = 10 * 1024 * 1024 @@ -206,7 +208,9 @@ function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string { * - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION: * 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。 */ -function buildClaudeChildEnv(): NodeJS.ProcessEnv { +const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin)' + +function buildClaudeChildEnv(bin?: string): NodeJS.ProcessEnv { // 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源 // (profile 没配则回落 ~/.claude 订阅 OAuth),避免宿主 env 残留的 BASE_URL/TOKEN // 覆盖 profile 导致 403。同时清除嵌套 Claude 会话标记,避免后台 `claude -p` 误判处于嵌套会话。 @@ -224,6 +228,12 @@ function buildClaudeChildEnv(): NodeJS.ProcessEnv { continue out[k] = v } + // claude 再 spawn 子工具时仍要能找到自己所在目录 + if (bin) { + const dir = path.dirname(bin) + const prev = out.PATH ?? '' + out.PATH = prev ? `${dir}${path.delimiter}${prev}` : dir + } return out } @@ -265,15 +275,19 @@ function spawnClaudeText( 'json', ] + const bin = resolveClaudeBin() + if (!bin) + return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, '')) + return new Promise((resolve, reject) => { let stdout = '' let stderr = '' let stdoutBytes = 0 let settled = false - const child = spawn('claude', args, { + const child = spawn(bin, args, { cwd, - env: buildClaudeChildEnv(), + env: buildClaudeChildEnv(bin), stdio: ['ignore', 'pipe', 'pipe'], }) @@ -295,10 +309,7 @@ function spawnClaudeText( clearTimeout(timer) const errCode = (err as NodeJS.ErrnoException).code if (errCode === 'ENOENT') { - reject(new ClaudeError( - '未检测到 claude CLI,请确认已安装并在 PATH 中', - stderr, - )) + reject(new ClaudeError(CLAUDE_MISSING_MSG, stderr)) return } reject(new ClaudeError( @@ -385,15 +396,19 @@ function spawnClaudeStreamed( ] const ndjson = buildStreamJsonLine(prompt, images) + const bin = resolveClaudeBin() + if (!bin) + return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, '')) + return new Promise((resolve, reject) => { let stdout = '' let stderr = '' let stdoutBytes = 0 let settled = false - const child = spawn('claude', args, { + const child = spawn(bin, args, { cwd, - env: buildClaudeChildEnv(), + env: buildClaudeChildEnv(bin), stdio: ['pipe', 'pipe', 'pipe'], }) @@ -415,10 +430,7 @@ function spawnClaudeStreamed( clearTimeout(timer) const errCode = (err as NodeJS.ErrnoException).code if (errCode === 'ENOENT') { - reject(new ClaudeError( - '未检测到 claude CLI,请确认已安装并在 PATH 中', - stderr, - )) + reject(new ClaudeError(CLAUDE_MISSING_MSG, stderr)) return } reject(new ClaudeError(