🐛 fix(vscode): GUI 下解析 claude 绝对路径(含 ~/.local/bin 官方安装)

This commit is contained in:
2026-08-05 14:57:48 +08:00
parent 81ab00bdba
commit 9255b55625
4 changed files with 127 additions and 14 deletions
+7 -1
View File
@@ -1,3 +1,5 @@
import { resolveClaudeBin } from './claudeBin'
/** /**
* Single source of truth for the `claude` (cc) terminal launch command. * Single source of truth for the `claude` (cc) terminal launch command.
* *
@@ -20,8 +22,12 @@ export interface CcCommandOpts {
prompt?: string prompt?: string
} }
function quoteIfNeeded(bin: string): string {
return bin.includes(' ') ? `'${bin}'` : bin
}
export function buildCcCommand(opts: CcCommandOpts): string { export function buildCcCommand(opts: CcCommandOpts): string {
const parts = ['claude'] const parts = [quoteIfNeeded(resolveClaudeBin() ?? 'claude')]
if (opts.effort) if (opts.effort)
parts.push(`--effort ${opts.effort}`) parts.push(`--effort ${opts.effort}`)
parts.push('--dangerously-skip-permissions', `--settings '${opts.profilePath}'`) parts.push('--dangerously-skip-permissions', `--settings '${opts.profilePath}'`)
+59
View File
@@ -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)
})
})
+36
View File
@@ -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
}
+25 -13
View File
@@ -19,6 +19,8 @@
*/ */
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import * as path from 'node:path'
import { resolveClaudeBin } from './claudeBin'
const DEFAULT_TIMEOUT_MS = 300_000 const DEFAULT_TIMEOUT_MS = 300_000
const MAX_BUFFER_BYTES = 10 * 1024 * 1024 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 * - CLAUDECODE / CLAUDE_CODE_ENTRYPOINT / CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_SESSION
* 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。 * 从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出、零输出。
*/ */
function buildClaudeChildEnv(): NodeJS.ProcessEnv { const CLAUDE_MISSING_MSG = '未检测到 claude CLI,请确认已安装并在 PATH 中(常见位置 ~/.local/bin'
function buildClaudeChildEnv(bin?: string): NodeJS.ProcessEnv {
// 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源 // 剔除所有 ANTHROPIC_* 是为了让 `--settings` profile 成为 provider/鉴权的唯一来源
// profile 没配则回落 ~/.claude 订阅 OAuth),避免宿主 env 残留的 BASE_URL/TOKEN // profile 没配则回落 ~/.claude 订阅 OAuth),避免宿主 env 残留的 BASE_URL/TOKEN
// 覆盖 profile 导致 403。同时清除嵌套 Claude 会话标记,避免后台 `claude -p` 误判处于嵌套会话。 // 覆盖 profile 导致 403。同时清除嵌套 Claude 会话标记,避免后台 `claude -p` 误判处于嵌套会话。
@@ -224,6 +228,12 @@ function buildClaudeChildEnv(): NodeJS.ProcessEnv {
continue continue
out[k] = v 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 return out
} }
@@ -265,15 +275,19 @@ function spawnClaudeText(
'json', 'json',
] ]
const bin = resolveClaudeBin()
if (!bin)
return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, ''))
return new Promise<ClaudeResult>((resolve, reject) => { return new Promise<ClaudeResult>((resolve, reject) => {
let stdout = '' let stdout = ''
let stderr = '' let stderr = ''
let stdoutBytes = 0 let stdoutBytes = 0
let settled = false let settled = false
const child = spawn('claude', args, { const child = spawn(bin, args, {
cwd, cwd,
env: buildClaudeChildEnv(), env: buildClaudeChildEnv(bin),
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}) })
@@ -295,10 +309,7 @@ function spawnClaudeText(
clearTimeout(timer) clearTimeout(timer)
const errCode = (err as NodeJS.ErrnoException).code const errCode = (err as NodeJS.ErrnoException).code
if (errCode === 'ENOENT') { if (errCode === 'ENOENT') {
reject(new ClaudeError( reject(new ClaudeError(CLAUDE_MISSING_MSG, stderr))
'未检测到 claude CLI,请确认已安装并在 PATH 中',
stderr,
))
return return
} }
reject(new ClaudeError( reject(new ClaudeError(
@@ -385,15 +396,19 @@ function spawnClaudeStreamed(
] ]
const ndjson = buildStreamJsonLine(prompt, images) const ndjson = buildStreamJsonLine(prompt, images)
const bin = resolveClaudeBin()
if (!bin)
return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, ''))
return new Promise<ClaudeResult>((resolve, reject) => { return new Promise<ClaudeResult>((resolve, reject) => {
let stdout = '' let stdout = ''
let stderr = '' let stderr = ''
let stdoutBytes = 0 let stdoutBytes = 0
let settled = false let settled = false
const child = spawn('claude', args, { const child = spawn(bin, args, {
cwd, cwd,
env: buildClaudeChildEnv(), env: buildClaudeChildEnv(bin),
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}) })
@@ -415,10 +430,7 @@ function spawnClaudeStreamed(
clearTimeout(timer) clearTimeout(timer)
const errCode = (err as NodeJS.ErrnoException).code const errCode = (err as NodeJS.ErrnoException).code
if (errCode === 'ENOENT') { if (errCode === 'ENOENT') {
reject(new ClaudeError( reject(new ClaudeError(CLAUDE_MISSING_MSG, stderr))
'未检测到 claude CLI,请确认已安装并在 PATH 中',
stderr,
))
return return
} }
reject(new ClaudeError( reject(new ClaudeError(