🐛 fix(vscode): GUI 下解析 claude 绝对路径(含 ~/.local/bin 官方安装)
This commit is contained in:
@@ -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}'`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ClaudeResult>((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<ClaudeResult>((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(
|
||||
|
||||
Reference in New Issue
Block a user