diff --git a/src/main/agent-detector.ts b/src/main/agent-detector.ts index 67a10a4f..c666bc95 100644 --- a/src/main/agent-detector.ts +++ b/src/main/agent-detector.ts @@ -325,6 +325,8 @@ export class AgentDetector { path.join(localAppData, 'npm'), // Claude Code CLI install location (npm global) path.join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli'), + // User local bin (Claude Code standalone installer) + path.join(home, '.local', 'bin'), // User local programs path.join(localAppData, 'Programs'), path.join(localAppData, 'Microsoft', 'WindowsApps'), @@ -384,6 +386,7 @@ export class AgentDetector { /** * Check if a binary exists in PATH + * On Windows, this also handles .cmd and .exe extensions properly */ private async checkBinaryExists(binaryName: string): Promise<{ exists: boolean; path?: string }> { try { @@ -396,9 +399,56 @@ export class AgentDetector { const result = await execFileNoThrow(command, [binaryName], undefined, env); if (result.exitCode === 0 && result.stdout.trim()) { + // Get all matches (Windows 'where' can return multiple) + const matches = result.stdout.trim().split('\n').map(p => p.trim()).filter(p => p); + + if (process.platform === 'win32' && matches.length > 0) { + // On Windows, prefer .exe over .cmd over extensionless + // This helps with proper execution handling + const exeMatch = matches.find(p => p.toLowerCase().endsWith('.exe')); + const cmdMatch = matches.find(p => p.toLowerCase().endsWith('.cmd')); + + // Return the best match: .exe > .cmd > first result + let bestMatch = exeMatch || cmdMatch || matches[0]; + + // If the first match doesn't have an extension, check if .cmd or .exe version exists + // This handles cases where 'where' returns a path without extension + if (!bestMatch.toLowerCase().endsWith('.exe') && !bestMatch.toLowerCase().endsWith('.cmd')) { + const cmdPath = bestMatch + '.cmd'; + const exePath = bestMatch + '.exe'; + + // Check if the .exe or .cmd version exists + try { + await fs.promises.access(exePath, fs.constants.F_OK); + bestMatch = exePath; + logger.debug(`Found .exe version of ${binaryName}`, 'AgentDetector', { path: exePath }); + } catch { + try { + await fs.promises.access(cmdPath, fs.constants.F_OK); + bestMatch = cmdPath; + logger.debug(`Found .cmd version of ${binaryName}`, 'AgentDetector', { path: cmdPath }); + } catch { + // Neither .exe nor .cmd exists, use the original path + } + } + } + + logger.debug(`Windows binary detection for ${binaryName}`, 'AgentDetector', { + allMatches: matches, + selectedMatch: bestMatch, + isCmd: bestMatch.toLowerCase().endsWith('.cmd'), + isExe: bestMatch.toLowerCase().endsWith('.exe'), + }); + + return { + exists: true, + path: bestMatch, + }; + } + return { exists: true, - path: result.stdout.trim().split('\n')[0], // First match + path: matches[0], // First match for Unix }; } diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index dd407061..fa8eeda2 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -537,10 +537,26 @@ export class ProcessManager extends EventEmitter { hasStdio: 'default (pipe)' }); - const childProcess = spawn(command, finalArgs, { + // On Windows, .cmd files (npm-installed CLIs) need special handling + // They must be executed through cmd.exe since spawn() with shell:false + // cannot execute batch scripts directly + let spawnCommand = command; + let spawnArgs = finalArgs; + let useShell = false; + + if (isWindows && command.toLowerCase().endsWith('.cmd')) { + // For .cmd files, we need to use shell:true to execute them properly + // This is safe because we're executing a specific file path, not user input + useShell = true; + logger.debug('[ProcessManager] Using shell=true for Windows .cmd file', 'ProcessManager', { + command, + }); + } + + const childProcess = spawn(spawnCommand, spawnArgs, { cwd, env, - shell: false, // Explicitly disable shell to prevent injection + shell: useShell, // Enable shell only for .cmd files on Windows stdio: ['pipe', 'pipe', 'pipe'], // Explicitly set stdio to pipe }); diff --git a/src/main/utils/execFile.ts b/src/main/utils/execFile.ts index bed1b818..6d2f7bab 100644 --- a/src/main/utils/execFile.ts +++ b/src/main/utils/execFile.ts @@ -15,6 +15,9 @@ export interface ExecResult { /** * Safely execute a command without shell injection vulnerabilities * Uses execFile instead of exec to prevent shell interpretation + * + * On Windows, .cmd files (npm-installed CLIs) are handled by enabling shell mode, + * since execFile cannot directly execute batch scripts without the shell. */ export async function execFileNoThrow( command: string, @@ -23,11 +26,17 @@ export async function execFileNoThrow( env?: NodeJS.ProcessEnv ): Promise { try { + // On Windows, .cmd files need shell execution + // This is safe because we're executing a specific file path, not user input + const isWindows = process.platform === 'win32'; + const needsShell = isWindows && command.toLowerCase().endsWith('.cmd'); + const { stdout, stderr } = await execFileAsync(command, args, { cwd, env, encoding: 'utf8', maxBuffer: EXEC_MAX_BUFFER, + shell: needsShell, }); return {