agent-ecosystem/src/main/services/team/ClaudeBinaryResolver.ts

469 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<boolean> {
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<string[]> {
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\<version>\.
*/
async function collectNvmWindowsCandidates(): Promise<string[]> {
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<string | null> {
// 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<string | null> {
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<string | null> {
for (const candidate of candidates) {
if (await isExecutable(candidate)) {
return candidate;
}
}
return null;
}
async function resolveFromDoctorFallback(commandName: string): Promise<string | null> {
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<string | null> {
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<string | null> | 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<string | null> {
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<string | null> {
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;
}
}