import { execFileSync } from 'node:child_process'; import { accessSync, constants } from 'node:fs'; export interface SpawnSpec { file: string; args: string[]; env: NodeJS.ProcessEnv; } export interface SpawnOptions { command: 'claude' | 'bash'; /** reprise d'une session existante (P2) : `--resume `, `--fork-session` si fork. */ resume?: { claudeSessionId: string; fork?: boolean }; /** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir ` répété. */ addDirs?: string[]; /** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */ claudeBinPath?: string | null; } /** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */ export interface ClaudeBinDiagnostic { /** chemin résolu du binaire, ou null si introuvable. */ path: string | null; /** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */ source: 'configured' | 'path' | null; /** true si le binaire est présent et exécutable. */ ok: boolean; } let cachedClaudeBin: string | null = null; /** Recherche `claude` dans le PATH (sans throw). null si absent. */ function findClaudeOnPath(): string | null { try { return execFileSync('which', ['claude'], { encoding: 'utf8' }).trim() || null; } catch { return null; } } function isExecutable(path: string): boolean { try { accessSync(path, constants.X_OK); return true; } catch { return false; } } /** * Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel * (validé exécutable, message clair sinon) et JAMAIS mis en cache (modifiable à chaud). Sinon : * `which claude`, mis en cache. Un service systemd/launchd démarre avec un PATH minimal sans * ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par * `arboretum install`). */ export function resolveClaudeBin(configuredPath?: string | null): string { if (configuredPath) { if (!isExecutable(configuredPath)) { throw new Error(`Configured Claude CLI path is not executable: ${configuredPath}`); } return configuredPath; } if (cachedClaudeBin) return cachedClaudeBin; const found = findClaudeOnPath(); if (!found) { throw new Error( 'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart', ); } cachedClaudeBin = found; return cachedClaudeBin; } /** Diagnostic non-throwing pour l'UI (Réglages) : recalculé à chaque appel, jamais caché. */ export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiagnostic { if (configuredPath) { return { path: configuredPath, source: 'configured', ok: isExecutable(configuredPath) }; } const found = findClaudeOnPath(); return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false }; } /** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec { const env: NodeJS.ProcessEnv = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', }; if (opts.command === 'bash') { return { file: 'bash', args: ['--norc'], env }; } const args: string[] = []; if (opts.resume) { // `--resume` doit toujours s'exécuter dans le cwd d'origine (garanti par l'appelant, spike S1). args.push('--resume', opts.resume.claudeSessionId); if (opts.resume.fork) args.push('--fork-session'); } // Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6). for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir); return { file: resolveClaudeBin(opts.claudeBinPath), args, env }; }