import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { homedir, platform } from 'node:os'; import { join } from 'node:path'; import { serverLog } from './server-logger'; import { posixUserBinDirs, probeViaLoginShell } from './cli-resolver-helpers'; const isWindows = platform() === 'win32'; /** Windows npm global installs may create .cmd or .ps1 wrappers — try both */ function winNpmCandidates(dir: string, name: string): string[] { return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]; } /** On Windows, `where` may return an extensionless shell script — prefer .cmd/.ps1/.exe */ function resolveWinExtension(binPath: string): string { if (!isWindows) return binPath; if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath; for (const ext of ['.cmd', '.ps1', '.exe']) { if (existsSync(binPath + ext)) return binPath + ext; } return binPath; } /** * Resolve the absolute path to the standalone `claude` binary. * * When Nitro bundles @anthropic-ai/claude-agent-sdk, the SDK's internal * `import.meta.url`-based resolution to find its own `cli.js` breaks. * Instead we locate the standalone native binary and pass it via * `pathToClaudeCodeExecutable` — the SDK detects non-.js paths as native * binaries and spawns them directly (no `node` wrapper needed). */ export function resolveClaudeCli(): string | undefined { serverLog.info( `[resolve-claude-cli] platform=${platform()}, isWindows=${isWindows}, SHELL=${process.env.SHELL ?? 'unset'}`, ); serverLog.info(`[resolve-claude-cli] PATH=${(process.env.PATH ?? '').slice(0, 400)}`); // 1. Try PATH lookup try { const cmd = isWindows ? 'where claude 2>nul' : 'which claude 2>/dev/null'; serverLog.info(`[resolve-claude-cli] PATH lookup: ${cmd}`); const raw = execSync(cmd, { encoding: 'utf-8', timeout: 3000, }).trim(); const p = raw.split(/\r?\n/)[0]; // `where` on Windows may return multiple lines serverLog.info( `[resolve-claude-cli] PATH lookup result: "${p}" (exists=${p ? existsSync(p) : false})`, ); if (p && existsSync(p)) return resolveWinExtension(p); } catch (err) { serverLog.info( `[resolve-claude-cli] PATH lookup failed: ${err instanceof Error ? err.message : err}`, ); } // 2. On macOS/Linux, ask the user's login shell — Electron does not // inherit the user's shell config, so nvm/pnpm/bun/mise/asdf shims are // invisible to a bare `which`. This is the single most common cause of // "CLI installed but OpenPencil can't find it" reports on Mac. if (!isWindows) { const viaShell = probeViaLoginShell('claude', 'resolve-claude-cli'); if (viaShell) return viaShell; } // 3. Try `npm prefix -g` to find actual npm global bin directory // On Windows, must use `npm.cmd` since Electron spawns cmd.exe if (isWindows) { try { serverLog.info('[resolve-claude-cli] trying npm.cmd prefix -g'); const prefix = execSync('npm.cmd prefix -g', { encoding: 'utf-8', timeout: 5000, }).trim(); serverLog.info(`[resolve-claude-cli] npm global prefix: "${prefix}"`); if (prefix) { for (const bin of winNpmCandidates(prefix, 'claude')) { serverLog.info( `[resolve-claude-cli] checking npm global bin: "${bin}" (exists=${existsSync(bin)})`, ); if (existsSync(bin)) return bin; } } } catch (err) { serverLog.info( `[resolve-claude-cli] npm prefix -g failed: ${err instanceof Error ? err.message : err}`, ); } } // 4. Common install locations const candidates = isWindows ? [ // npm global (.cmd + .ps1) ...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'claude'), // nvm-windows / fnm ...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'claude'), ...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'claude'), // Native .exe install locations join(process.env.LOCALAPPDATA || '', 'Programs', 'claude-code', 'claude.exe'), join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'claude.exe'), join(homedir(), '.claude', 'local', 'claude.exe'), join(homedir(), 'AppData', 'Local', 'Programs', 'claude-code', 'claude.exe'), ] : posixUserBinDirs().map((dir) => join(dir, 'claude')); for (const c of candidates) { const exists = c ? existsSync(c) : false; serverLog.info(`[resolve-claude-cli] candidate: "${c}" (exists=${exists})`); if (c && exists) return c; } serverLog.warn( '[resolve-claude-cli] no claude binary found after PATH, login-shell probe, and candidate scan', ); return undefined; }