diff --git a/eslint.config.js b/eslint.config.js index 6419a9fd..57a71453 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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", }, { diff --git a/server/claude-sdk.js b/server/claude-sdk.js index db3a205c..0f1cac97 100644 --- a/server/claude-sdk.js +++ b/server/claude-sdk.js @@ -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) { diff --git a/server/modules/providers/list/claude/claude-auth.provider.ts b/server/modules/providers/list/claude/claude-auth.provider.ts index 1194ae1d..c94fe1b4 100644 --- a/server/modules/providers/list/claude/claude-auth.provider.ts +++ b/server/modules/providers/list/claude/claude-auth.provider.ts @@ -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; + } } /** diff --git a/server/shared/claude-cli-path.test.ts b/server/shared/claude-cli-path.test.ts new file mode 100644 index 00000000..87cde218 --- /dev/null +++ b/server/shared/claude-cli-path.test.ts @@ -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'); +}); diff --git a/server/shared/claude-cli-path.ts b/server/shared/claude-cli-path.ts new file mode 100644 index 00000000..ae8565d5 --- /dev/null +++ b/server/shared/claude-cli-path.ts @@ -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, +): 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, +): 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 = { + 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); +}