🐛 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.
|
* 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}'`)
|
||||||
|
|||||||
@@ -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 { 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user