mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-27 05:35:30 +08: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
|
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",
|
mode: "file",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||||
|
import { resolveClaudeCodeExecutablePath } from './shared/claude-cli-path.js';
|
||||||
import {
|
import {
|
||||||
createNotificationEvent,
|
createNotificationEvent,
|
||||||
notifyRunFailed,
|
notifyRunFailed,
|
||||||
@@ -153,11 +154,9 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
|
// Since SDK 0.2.113, options.env replaces process.env instead of overlaying it.
|
||||||
sdkOptions.env = { ...process.env };
|
sdkOptions.env = { ...process.env };
|
||||||
|
|
||||||
// Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to 'claude' on PATH.
|
// Resolve the executable eagerly on Windows because the SDK uses raw child_process.spawn,
|
||||||
// The SDK 0.2.113+ looks for a bundled native binary optional dep by default;
|
// which does not reliably follow npm's shell wrappers like cross-spawn does.
|
||||||
// this fallback ensures users who installed via the official installer still work
|
sdkOptions.pathToClaudeCodeExecutable = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||||
// even when npm prune --production has removed those optional deps.
|
|
||||||
sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
|
|
||||||
|
|
||||||
// Map working directory
|
// Map working directory
|
||||||
if (cwd) {
|
if (cwd) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import spawn from 'cross-spawn';
|
import spawn from 'cross-spawn';
|
||||||
|
|
||||||
|
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
|
||||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.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.
|
* Checks whether the Claude Code CLI is available on this host.
|
||||||
*/
|
*/
|
||||||
private checkInstalled(): boolean {
|
private checkInstalled(): boolean {
|
||||||
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
|
const cliPath = resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH);
|
||||||
try {
|
try {
|
||||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
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