mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-05 20:25:52 +00:00
fix(cli): resolve executable path for Claude CLI on Windows
This commit is contained in:
@@ -157,7 +157,7 @@ export default tseslint.config(
|
||||
},
|
||||
{
|
||||
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
|
||||
pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly
|
||||
pattern: ["server/shared/utils.{js,ts}", "server/shared/claude-cli-path.ts"], // classify the shared utils file so modules can depend on it explicitly
|
||||
mode: "file",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||
import {
|
||||
createNotificationEvent,
|
||||
notifyRunFailed,
|
||||
@@ -153,11 +154,9 @@ function mapCliOptionsToSDK(options = {}) {
|
||||
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
|
||||
sdkOptions.env = { ...process.env };
|
||||
|
||||
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH.
|
||||
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default;
|
||||
// this fallback ensures users who installed via the official installer still work
|
||||
// even when npm prune --production has removed those optional deps.
|
||||
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
// Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
|
||||
// which does not reliably follow npm's shell wrappers like cross-spawn does.
|
||||
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||
|
||||
// Map working directory
|
||||
if (cwd) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
@@ -20,13 +21,13 @@ export class ClaudeProviderAuth implements IProviderAuth {
|
||||
* Checks whether the Claude Code CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const cliPath = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
47
server/shared/claude-cli-path.test.ts
Normal file
47
server/shared/claude-cli-path.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
resolveClaudeCodeExecutablePath,
|
||||
type ResolveClaudeCodeExecutablePathDependencies,
|
||||
} from '@/shared/claude-cli-path.js';
|
||||
|
||||
test('resolveClaudeCodeExecutablePath resolves the npm Claude wrapper to its native exe on Windows', () => {
|
||||
const wrapperDir = 'C:\\nvm4w\\nodejs';
|
||||
const nativePath = `${wrapperDir}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;
|
||||
const execFileSync =
|
||||
(() => `${wrapperDir}\\claude\r\n${wrapperDir}\\claude.cmd\r\n`) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync'];
|
||||
const readFileSync = (() => '') as unknown as ResolveClaudeCodeExecutablePathDependencies['readFileSync'];
|
||||
|
||||
const resolved = resolveClaudeCodeExecutablePath('claude', {
|
||||
platform: 'win32',
|
||||
execFileSync,
|
||||
existsSync: (candidate) => candidate === nativePath,
|
||||
readFileSync,
|
||||
});
|
||||
|
||||
assert.equal(resolved, nativePath);
|
||||
});
|
||||
|
||||
test('resolveClaudeCodeExecutablePath keeps an explicit JavaScript launcher path unchanged', () => {
|
||||
const scriptPath = 'C:\\tools\\claude.js';
|
||||
|
||||
const resolved = resolveClaudeCodeExecutablePath(scriptPath, {
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
assert.equal(resolved, scriptPath);
|
||||
});
|
||||
|
||||
test('resolveClaudeCodeExecutablePath falls back to the configured command when PATH lookup fails', () => {
|
||||
const execFileSync = (() => {
|
||||
throw new Error('not found');
|
||||
}) as unknown as ResolveClaudeCodeExecutablePathDependencies['execFileSync'];
|
||||
|
||||
const resolved = resolveClaudeCodeExecutablePath('claude', {
|
||||
platform: 'win32',
|
||||
execFileSync,
|
||||
});
|
||||
|
||||
assert.equal(resolved, 'claude');
|
||||
});
|
||||
139
server/shared/claude-cli-path.ts
Normal file
139
server/shared/claude-cli-path.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user