Files
claudecodeui/server/shared/claude-cli-path.ts

140 lines
4.0 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_CLAUDE_COMMAND = 'claude';
const CLAUDE_SCRIPT_EXTENSIONS = new Set(['.cjs', '.js', '.jsx', '.mjs', '.ts', '.tsx']);
const CLAUDE_WRAPPER_SEGMENTS = ['node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'] as const;
export type ResolveClaudeCodeExecutablePathDependencies = {
execFileSync?: typeof execFileSync;
existsSync?: typeof fs.existsSync;
platform?: NodeJS.Platform;
readFileSync?: typeof fs.readFileSync;
};
function getPathApi(platform: NodeJS.Platform) {
return platform === 'win32' ? path.win32 : path;
}
function stripWrappingQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function isPathLike(value: string): boolean {
return value.includes('/') || value.includes('\\');
}
function resolveClaudeWrapperBinary(
wrapperPath: string,
deps: Required<ResolveClaudeCodeExecutablePathDependencies>,
): string | null {
const pathApi = getPathApi(deps.platform);
const directCandidate = pathApi.resolve(pathApi.dirname(wrapperPath), ...CLAUDE_WRAPPER_SEGMENTS);
if (deps.existsSync(directCandidate)) {
return directCandidate;
}
let content: string;
try {
content = deps.readFileSync(wrapperPath, 'utf8');
} catch {
return null;
}
const matches = content.matchAll(/["']([^"'\\\r\n]*claude\.exe)["']/gi);
for (const match of matches) {
const rawTarget = match[1]
.replace(/^\$basedir[\\/]/i, '')
.replace(/^%dp0%[\\/]/i, '')
.replace(/^%~dp0[\\/]/i, '');
const normalizedTarget = rawTarget.replace(/[\\/]/g, pathApi.sep);
const candidate = pathApi.isAbsolute(normalizedTarget)
? normalizedTarget
: pathApi.resolve(pathApi.dirname(wrapperPath), normalizedTarget);
if (deps.existsSync(candidate)) {
return candidate;
}
}
return null;
}
function resolveWindowsClaudeExecutablePath(
configuredPath: string,
deps: Required<ResolveClaudeCodeExecutablePathDependencies>,
): string {
const pathApi = getPathApi(deps.platform);
const extension = pathApi.extname(configuredPath).toLowerCase();
const explicitPath = isPathLike(configuredPath) || pathApi.isAbsolute(configuredPath);
if (CLAUDE_SCRIPT_EXTENSIONS.has(extension)) {
return configuredPath;
}
if (explicitPath && extension === '.exe') {
return configuredPath;
}
if (explicitPath) {
return resolveClaudeWrapperBinary(configuredPath, deps) ?? configuredPath;
}
try {
const stdout = deps.execFileSync('where.exe', [configuredPath], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
const candidates = stdout
.split(/\r?\n/)
.map((entry) => entry.trim())
.filter(Boolean);
for (const candidate of candidates) {
if (pathApi.extname(candidate).toLowerCase() === '.exe') {
return candidate;
}
}
for (const candidate of candidates) {
const resolved = resolveClaudeWrapperBinary(candidate, deps);
if (resolved) {
return resolved;
}
}
} catch {
return configuredPath;
}
return configuredPath;
}
export function resolveClaudeCodeExecutablePath(
configuredPath: string | undefined = process.env.CLAUDE_CLI_PATH,
dependencies: ResolveClaudeCodeExecutablePathDependencies = {},
): string {
const deps: Required<ResolveClaudeCodeExecutablePathDependencies> = {
execFileSync: dependencies.execFileSync ?? execFileSync,
existsSync: dependencies.existsSync ?? fs.existsSync,
platform: dependencies.platform ?? process.platform,
readFileSync: dependencies.readFileSync ?? fs.readFileSync,
};
const normalizedPath = stripWrappingQuotes(configuredPath || DEFAULT_CLAUDE_COMMAND);
if (deps.platform !== 'win32') {
return normalizedPath;
}
return resolveWindowsClaudeExecutablePath(normalizedPath, deps);
}