import { execFileSync } from 'node:child_process'; import { accessSync, constants, existsSync } 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 le PATH. */ claudeBinPath?: string | null; /** * Lancement de projet (« Démarrer le projet ») : au lieu de `bash --norc`, lance le shell de * login interactif de l'utilisateur (`$SHELL -l -i`) pour charger son environnement complet * (PATH nvm/asdf/~/.local/bin). Indispensable quand le daemon tourne en service systemd/launchd * (PATH minimal, cf. resolveClaudeBin) : sinon `npm`/`docker` seraient introuvables. Ignoré pour claude. */ login?: boolean; /** plateforme cible (injectable pour les tests) ; défaut `process.platform`. */ platform?: NodeJS.Platform; } /** 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; /** * Commande de recherche dans le PATH selon la plateforme : `which` n'existe PAS sur Windows, c'est * `where.exe` (qui peut renvoyer plusieurs lignes, la première étant la retenue). */ export function whichCommand(platform: NodeJS.Platform = process.platform): { file: string; args: string[] } { return platform === 'win32' ? { file: 'where.exe', args: ['claude'] } : { file: 'which', args: ['claude'] }; } /** Recherche `claude` dans le PATH (sans throw). null si absent. */ function findClaudeOnPath(platform: NodeJS.Platform = process.platform): string | null { const { file, args } = whichCommand(platform); try { const out = execFileSync(file, args, { encoding: 'utf8' }); // `where.exe` liste toutes les correspondances : on garde la première. return out.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? null; } catch { return null; } } /** * « Est-ce lançable ? ». Sur Windows, le bit d'exécution POSIX n'a aucun sens (NTFS n'en a pas) et * `accessSync(X_OK)` y répond au hasard : on se contente donc de l'existence du fichier. */ function isExecutable(path: string, platform: NodeJS.Platform = process.platform): boolean { if (platform === 'win32') return existsSync(path); 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 : * recherche dans le PATH (`which` / `where.exe`), mise en cache. Un service systemd/launchd démarre * avec un PATH minimal sans ~/.local/bin → la recherche 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; } // Cache REVALIDÉ : le daemon vit des jours. Un changement de version nvm/asdf, une réinstallation // du CLI ou un simple `npm i -g` remplace le chemin, et le cache pointait alors sur un fichier // disparu : node-pty spawnait dans le vide, le PTY mourait sans un octet, et l'utilisateur n'avait // qu'un terminal vide sans explication. if (cachedClaudeBin) { if (isExecutable(cachedClaudeBin)) return cachedClaudeBin; cachedClaudeBin = null; } 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 }; } /** Shells interactifs connus supportant `-l -i` (login + interactif). */ const KNOWN_LOGIN_SHELLS = new Set(['bash', 'zsh', 'fish']); /** * Shell interactif pour « Démarrer le projet ». * * POSIX : `$SHELL -l -i` s'il fait partie des shells connus supportant ces options (bash/zsh/fish), * sinon `bash` (un `$SHELL=dash` sortirait aussitôt avec `-l -i`, laissant un terminal vide). * * Windows : PowerShell, en restant attaché après la commande auto-tapée (`-NoExit`), avec repli sur * `cmd.exe /K`. `%COMSPEC%` n'est PAS utilisé comme shell de lancement : il pointe cmd.exe, qui ne * charge aucun profil utilisateur. La commande est ensuite auto-tapée par le PtyManager, exactement * comme sous POSIX · le mécanisme est indépendant du shell. */ export function resolveInteractiveShell( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, ): { file: string; args: string[] } { if (platform === 'win32') { const pwsh = env.ARBORETUM_SHELL ?? 'powershell.exe'; return { file: pwsh, args: ['-NoLogo', '-NoExit'] }; } const shell = env.SHELL; const file = shell && KNOWN_LOGIN_SHELLS.has(shell.split('/').pop() ?? '') ? shell : 'bash'; return { file, args: ['-l', '-i'] }; } /** Shell non interactif « neutre » (terminal simple, hors lancement de projet). */ export function resolvePlainShell(platform: NodeJS.Platform = process.platform): { file: string; args: string[] } { if (platform === 'win32') return { file: 'powershell.exe', args: ['-NoLogo', '-NoExit'] }; return { file: 'bash', args: ['--norc'] }; } /** * Marqueurs d'EXÉCUTION que le CLI claude pose dans l'environnement de ses processus enfants. Si le * daemon a lui-même été lancé depuis une session Claude Code (ce qui arrive : `arboretum` démarré * depuis un terminal Claude, ou l'app de bureau lancée par un agent), il les hérite et les * retransmettait à CHAQUE session qu'il lance. Conséquences observées : * - `CLAUDE_CODE_CHILD_SESSION=1` fait croire au CLI qu'il est une sous-session : il DÉSACTIVE la * sauvegarde du transcript (« Transcript saving is off »), donc plus d'historique, plus de * `--resume`, et `claudeSessionId` reste null (l'état fin busy/waiting/idle tombe avec lui) ; * - `CLAUDE_CODE_SESSION_ID` / `CLAUDE_PID` désignent la session PARENTE, pas celle qu'on lance. * On ne retire QUE ces marqueurs : la configuration légitime de l'utilisateur (`CLAUDE_CONFIG_DIR`, * `ANTHROPIC_*`, proxies...) doit passer telle quelle, sinon on casserait son installation. */ export const INHERITED_CLAUDE_MARKERS = [ 'CLAUDECODE', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CLAUDE_EFFORT', ] as const; /** * Environnement assaini pour un PTY : pur et testable. Appliqué aussi au shell (`bash`), car un * `claude` lancé à la main dans ce terminal hériterait des mêmes marqueurs. */ export function sanitizeInheritedEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...source }; for (const key of INHERITED_CLAUDE_MARKERS) delete env[key]; return env; } /** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec { const platform = opts.platform ?? process.platform; const env: NodeJS.ProcessEnv = { ...sanitizeInheritedEnv(process.env), TERM: 'xterm-256color', COLORTERM: 'truecolor', }; if (opts.command === 'bash') { // `'bash'` désigne « le shell de la machine », pas littéralement bash : le contrat d'API reste // stable (claude|bash) et c'est ici qu'on choisit le shell réel par plateforme. const { file, args } = opts.login ? resolveInteractiveShell(platform) : resolvePlainShell(platform); return { file, args, 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 }; }