import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { getShellPreferredHome, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv'; import * as fs from 'fs'; import * as path from 'path'; import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe'; import { getConfiguredCliFlavor } from './cliFlavor'; export interface ClaudeBinaryResolveProgress { phase: string; message: string; } export interface ClaudeBinaryResolveOptions { onProgress?: (progress: ClaudeBinaryResolveProgress) => void; } function emitProgress( options: ClaudeBinaryResolveOptions | undefined, phase: string, message: string ): void { options?.onProgress?.({ phase, message }); } 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; } catch { return false; } } 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 { if (process.platform === 'win32') { return collectNvmWindowsCandidates(); } const nvmNodeRoot = path.join(getShellPreferredHome(), '.nvm', 'versions', 'node'); let versions: string[]; try { versions = await fs.promises.readdir(nvmNodeRoot); } catch { return []; } return versions .map((version) => path.join(nvmNodeRoot, version, 'bin', 'claude')) .sort((a, b) => a.localeCompare(b)) .reverse(); } /** * Collect NVM for Windows (nvm-windows) candidates. * nvm-windows stores Node versions under %APPDATA%\nvm\\. */ async function collectNvmWindowsCandidates(): Promise { const appdata = process.env.APPDATA; if (!appdata) return []; const nvmRoot = path.join(appdata, 'nvm'); let versions: string[]; try { versions = await fs.promises.readdir(nvmRoot); } catch { return []; } const exts = getWindowsExecutableExtensions(); return versions .toSorted((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' })) .flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`))); } async function resolveFromPathEnv(binaryName: string, pathEnv?: string): Promise { // TODO: Consider migrating this PATH candidate collection to runtimePathBinaryResolver once // Claude-specific executable checks, Windows PATHEXT handling, and parallel stat behavior // can be preserved exactly. const rawPath = pathEnv && pathEnv.length > 0 ? pathEnv : process.env.PATH; if (!rawPath) { return null; } const pathParts = rawPath.split(path.delimiter); const binaryNames = process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName]; // Check all PATH directories in parallel. Each directory checks all extension // variants concurrently. This turns N_dirs × N_exts sequential stat() calls // into a single parallel batch, dramatically reducing startup time on Windows. const dirResults = await Promise.all( pathParts.map(async (part) => { if (!part) return null; const cleanedPart = stripSurroundingQuotes(part); if (!cleanedPart) return null; const candidates = binaryNames.map((name) => path.join(cleanedPart, name)); const results = await Promise.all( candidates.map(async (candidate) => ({ path: candidate, ok: await isExecutable(candidate), })) ); // Return the first matching extension variant within this directory return results.find((r) => r.ok)?.path ?? null; }) ); // Return first non-null result, preserving PATH priority order return dirResults.find((r) => r !== null) ?? null; } async function resolveFromExplicitPath(inputPath: string): Promise { const trimmed = inputPath.trim(); if (!trimmed) { return null; } if (process.platform === 'win32' && !path.extname(trimmed)) { for (const ext of getWindowsExecutableExtensions()) { const candidate = `${trimmed}${ext}`; if (await isExecutable(candidate)) { return candidate; } } } if (await isExecutable(trimmed)) { return trimmed; } return null; } async function resolveFromCandidateList(candidates: string[]): Promise { for (const candidate of candidates) { if (await isExecutable(candidate)) { return candidate; } } return null; } async function resolveFromDoctorFallback(commandName: string): Promise { const candidates = await getDoctorInvokedCandidates(commandName); for (let index = candidates.length - 1; index >= 0; index -= 1) { const candidate = candidates[index]; if (!candidate) { continue; } const resolved = await resolveFromExplicitPath(candidate); if (resolved) { return resolved; } } return null; } async function resolveBundledOrchestratorBinary(): Promise { const resourcesPath = process.resourcesPath?.trim(); if (!resourcesPath) { return null; } const binaryName = process.platform === 'win32' ? 'claude-multimodel.exe' : 'claude-multimodel'; return resolveFromCandidateList([path.join(resourcesPath, 'runtime', binaryName)]); } function getConfiguredRuntimeOverrideRaw(flavor: 'claude' | 'agent_teams_orchestrator'): string { return ( (flavor === 'agent_teams_orchestrator' ? (process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ?? process.env.CLAUDE_CLI_PATH?.trim()) : process.env.CLAUDE_CLI_PATH?.trim()) ?? '' ); } function looksLikeExplicitPath(value: string): boolean { return path.isAbsolute(value) || value.includes('\\') || value.includes('/'); } let cachedPath: string | null | undefined; /** Timestamp of last successful cache verification (ms). */ let cacheVerifiedAt = 0; /** Re-verify cached binary at most once per 30 seconds. */ const CACHE_VERIFY_TTL_MS = 30_000; /** Coalesce concurrent first resolves so `cachedPath` is not torn by parallel scans. */ let resolveInFlight: Promise | null = null; export class ClaudeBinaryResolver { /** * Clear the cached binary path. * Call after CLI install/update so the next resolve() picks up the new location. */ static clearCache(): void { cachedPath = undefined; cacheVerifiedAt = 0; } static async resolve(options: ClaudeBinaryResolveOptions = {}): Promise { if (cachedPath !== undefined) { const now = Date.now(); // Re-verify the cached binary still exists, but at most once per TTL if (cachedPath !== null && now - cacheVerifiedAt > CACHE_VERIFY_TTL_MS) { emitProgress(options, 'cache-verify', 'Verifying cached runtime...'); if (await isExecutable(cachedPath)) { cacheVerifiedAt = now; emitProgress(options, 'cache-hit', 'Using cached runtime...'); return cachedPath; } cachedPath = undefined; cacheVerifiedAt = 0; // Fall through to full resolution below } else { emitProgress( options, cachedPath ? 'cache-hit' : 'cache-miss', 'Using cached runtime status...' ); return cachedPath; } } if (!resolveInFlight) { resolveInFlight = ClaudeBinaryResolver.runResolve(options).finally(() => { resolveInFlight = null; }); } else { emitProgress(options, 'in-flight', 'Waiting for runtime lookup...'); } return resolveInFlight; } private static async runResolve(options: ClaudeBinaryResolveOptions): Promise { const flavor = getConfiguredCliFlavor(); emitProgress(options, 'flavor', `Using ${flavor} runtime mode...`); const overrideRaw = getConfiguredRuntimeOverrideRaw(flavor); const overrideIsExplicitPath = overrideRaw ? looksLikeExplicitPath(overrideRaw) : false; if (overrideRaw && overrideIsExplicitPath) { emitProgress(options, 'configured-path', 'Checking configured runtime path...'); const resolvedOverride = await resolveFromExplicitPath(overrideRaw); if (resolvedOverride) { cachedPath = resolvedOverride; cacheVerifiedAt = Date.now(); emitProgress(options, 'configured-path-found', 'Using configured runtime path...'); return cachedPath; } } const shouldTryBundledOrchestratorBeforeShell = flavor === 'agent_teams_orchestrator' && (!overrideRaw || overrideIsExplicitPath); if (shouldTryBundledOrchestratorBeforeShell) { emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...'); const bundledBinary = await resolveBundledOrchestratorBinary(); if (bundledBinary) { cachedPath = bundledBinary; cacheVerifiedAt = Date.now(); emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...'); return cachedPath; } } await resolveInteractiveShellEnvBestEffort({ timeoutMs: 1_500, fallbackEnv: process.env, background: false, onProgress: (progress) => emitProgress(options, progress.phase, progress.message), }); const enrichedPath = buildMergedCliPath(null); if (overrideRaw && !overrideIsExplicitPath) { emitProgress(options, 'configured-path', 'Checking configured runtime path...'); const resolvedOverride = await resolveFromPathEnv(overrideRaw, enrichedPath); if (resolvedOverride) { cachedPath = resolvedOverride; cacheVerifiedAt = Date.now(); emitProgress(options, 'configured-path-found', 'Using configured runtime path...'); return cachedPath; } } if (flavor === 'agent_teams_orchestrator') { if (!shouldTryBundledOrchestratorBeforeShell) { emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...'); const bundledBinary = await resolveBundledOrchestratorBinary(); if (bundledBinary) { cachedPath = bundledBinary; cacheVerifiedAt = Date.now(); emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...'); return cachedPath; } } // Keep agent_teams_orchestrator resolution generic. Dev flows should // inject an explicit CLI path, while non-dev setups can expose // claude-multimodel on PATH without making this resolver guess a sibling // repo name or folder. const orchestratorBinaryName = 'claude-multimodel'; emitProgress(options, 'path-runtime', 'Searching PATH for Agent Teams runtime...'); const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath); if (fromPath) { cachedPath = fromPath; cacheVerifiedAt = Date.now(); emitProgress(options, 'path-runtime-found', 'Using Agent Teams runtime from PATH...'); return cachedPath; } emitProgress(options, 'doctor-runtime', 'Checking runtime diagnostics fallback...'); const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName); if (fromDoctor) { cachedPath = fromDoctor; cacheVerifiedAt = Date.now(); emitProgress(options, 'doctor-runtime-found', 'Using runtime from diagnostics fallback...'); return cachedPath; } // agent_teams_orchestrator mode is explicit. If the configured local // runtime is missing, fail closed instead of silently falling back to a // different CLI. return null; } const baseBinaryName = 'claude'; emitProgress(options, 'path-claude', 'Searching PATH for Claude CLI...'); const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath); if (fromPath) { cachedPath = fromPath; cacheVerifiedAt = Date.now(); emitProgress(options, 'path-claude-found', 'Using Claude CLI from PATH...'); return cachedPath; } const platformBinaryNames = process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName]; const home = getShellPreferredHome(); const vendorBinDir = path.join(getClaudeBasePath(), 'local', 'node_modules', '.bin'); const candidateDirs: string[] = process.platform === 'win32' ? [ // Windows: Claude npm-local vendor install vendorBinDir, // Windows: npm global install path.join(home, 'AppData', 'Roaming', 'npm'), // Windows: scoop, chocolatey, and other package managers path.join(home, 'scoop', 'shims'), // Windows: Local programs ...(process.env.LOCALAPPDATA ? [path.join(process.env.LOCALAPPDATA, 'Programs', 'claude')] : []), // Windows: Program Files ...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []), ] : [ // Unix: Claude npm-local vendor install vendorBinDir, // Unix: native binary installation path (claude install) path.join(home, '.local', 'bin'), path.join(home, '.npm-global', 'bin'), path.join(home, '.npm', 'bin'), '/usr/local/bin', '/opt/homebrew/bin', ]; const candidates = candidateDirs.flatMap((dir) => platformBinaryNames.map((name) => path.join(dir, name)) ); emitProgress(options, 'standard-locations', 'Checking standard Claude install locations...'); const nvmCandidates = await collectNvmCandidates(); if (nvmCandidates.length > 0) { emitProgress(options, 'nvm-locations', 'Checking nvm-managed Claude installs...'); } const allCandidates = [...candidates, ...nvmCandidates]; // Check all fallback candidates in parallel for speed const results = await Promise.all( allCandidates.map(async (candidate) => ({ path: candidate, ok: await isExecutable(candidate), })) ); // Return first match, preserving candidate priority order const found = results.find((r) => r.ok); if (found) { cachedPath = found.path; cacheVerifiedAt = Date.now(); emitProgress( options, 'fallback-location-found', 'Using Claude CLI from install locations...' ); return cachedPath; } emitProgress(options, 'doctor-claude', 'Checking Claude diagnostics fallback...'); const fromDoctor = await resolveFromDoctorFallback(baseBinaryName); if (fromDoctor) { cachedPath = fromDoctor; cacheVerifiedAt = Date.now(); emitProgress(options, 'doctor-claude-found', 'Using Claude CLI from diagnostics fallback...'); return cachedPath; } // Don't cache null — CLI may be installed later without app restart emitProgress( options, 'not-found', 'Runtime not found. Continuing with limited launch support...' ); return null; } }