From 42e5a2431c9b1c7c9b82d5ea5def98560ac32adf Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 15:52:01 +0200 Subject: [PATCH] Fix Claude CLI resolution on Windows --- pnpm-workspace.yaml | 1 - .../services/team/ClaudeBinaryResolver.ts | 127 ++++++++++++++++-- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b58ca0b..c5739b74 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,2 @@ ignoredBuiltDependencies: - - electron - esbuild diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index e05b766e..d8f6f580 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -3,6 +3,15 @@ import * as os from 'os'; import * as path from 'path'; async function isExecutable(filePath: string): Promise { + if (process.platform === 'win32') { + try { + const stat = await fs.promises.stat(filePath); + return stat.isFile(); + } catch { + return false; + } + } + try { await fs.promises.access(filePath, fs.constants.X_OK); return true; @@ -11,6 +20,46 @@ async function isExecutable(filePath: string): Promise { } } +function stripSurroundingQuotes(value: string): string { + const trimmed = value.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function getWindowsExecutableExtensions(): string[] { + const raw = process.env.PATHEXT; + if (!raw) { + return ['.exe', '.cmd', '.bat', '.com']; + } + + const exts = raw + .split(';') + .map((ext) => ext.trim()) + .filter((ext) => ext.length > 0) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)) + .map((ext) => ext.toLowerCase()); + + return Array.from(new Set(exts)); +} + +function expandWindowsBinaryNames(binaryName: string): string[] { + const trimmed = binaryName.trim(); + if (!trimmed) { + return []; + } + + const ext = path.extname(trimmed); + if (ext) { + return [trimmed]; + } + + const exts = getWindowsExecutableExtensions(); + const withExt = exts.map((e) => `${trimmed}${e}`); + return [...withExt, trimmed]; +} + async function collectNvmCandidates(): Promise { const nvmNodeRoot = path.join(os.homedir(), '.nvm', 'versions', 'node'); let versions: string[]; @@ -33,16 +82,53 @@ async function resolveFromPathEnv(binaryName: string): Promise { } const pathParts = rawPath.split(path.delimiter); + const binaryNames = + process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName]; for (const part of pathParts) { if (!part) { continue; } - const candidate = path.join(part, binaryName); + const cleanedPart = stripSurroundingQuotes(part); + if (!cleanedPart) { + continue; + } + + for (const name of binaryNames) { + const candidate = path.join(cleanedPart, name); + if (await isExecutable(candidate)) { + return candidate; + } + } + } + return null; +} + +async function resolveFromExplicitPath(inputPath: string): Promise { + const trimmed = inputPath.trim(); + if (!trimmed) { + return null; + } + + if (await isExecutable(trimmed)) { + return trimmed; + } + + if (process.platform !== 'win32') { + return null; + } + + if (path.extname(trimmed)) { + return null; + } + + for (const ext of getWindowsExecutableExtensions()) { + const candidate = `${trimmed}${ext}`; if (await isExecutable(candidate)) { return candidate; } } + return null; } @@ -52,22 +138,45 @@ export class ClaudeBinaryResolver { static async resolve(): Promise { if (cachedPath !== undefined) return cachedPath; - const platformBinaryName = process.platform === 'win32' ? 'claude.cmd' : 'claude'; - const fromPath = await resolveFromPathEnv(platformBinaryName); + const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim(); + if (overrideRaw) { + const looksLikePath = + path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/'); + const resolvedOverride = looksLikePath + ? await resolveFromExplicitPath(overrideRaw) + : await resolveFromPathEnv(overrideRaw); + + if (resolvedOverride) { + cachedPath = resolvedOverride; + return cachedPath; + } + } + + const baseBinaryName = 'claude'; + const fromPath = await resolveFromPathEnv(baseBinaryName); if (fromPath) { cachedPath = fromPath; return cachedPath; } - const candidates: string[] = [ - path.join(os.homedir(), '.npm-global', 'bin', platformBinaryName), - path.join(os.homedir(), '.npm', 'bin', platformBinaryName), + const platformBinaryNames = + process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName]; + + const candidateDirs: string[] = [ + path.join(os.homedir(), '.npm-global', 'bin'), + path.join(os.homedir(), '.npm', 'bin'), process.platform === 'win32' - ? path.join(process.env.APPDATA ?? '', 'npm', 'claude.cmd') - : '/usr/local/bin/claude', - process.platform === 'win32' ? '' : '/opt/homebrew/bin/claude', + ? process.env.APPDATA + ? path.join(process.env.APPDATA, 'npm') + : '' + : '/usr/local/bin', + process.platform === 'win32' ? '' : '/opt/homebrew/bin', ].filter((candidate) => candidate.length > 0); + const candidates = candidateDirs.flatMap((dir) => + platformBinaryNames.map((name) => path.join(dir, name)) + ); + const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates(); for (const candidate of [...candidates, ...nvmCandidates]) { if (await isExecutable(candidate)) {