/** * CliProvider - Abstract base class for CLI-based AI providers * * Provides common infrastructure for CLI tools that spawn subprocesses * and stream JSONL output. Handles: * - Platform-specific CLI detection (PATH, common locations) * - Windows execution strategies (WSL, npx, direct, cmd) * - JSONL subprocess spawning and streaming * - Error mapping infrastructure * * @example * ```typescript * class CursorProvider extends CliProvider { * getCliName(): string { return 'cursor-agent'; } * getSpawnConfig(): CliSpawnConfig { * return { * windowsStrategy: 'wsl', * commonPaths: { * linux: ['~/.local/bin/cursor-agent'], * darwin: ['~/.local/bin/cursor-agent'], * } * }; * } * // ... implement abstract methods * } * ``` */ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { BaseProvider } from './base-provider.js'; import type { ProviderConfig, ExecuteOptions, ProviderMessage } from './types.js'; import { spawnJSONLProcess, type SubprocessOptions, isWslAvailable, findCliInWsl, createWslCommand, windowsToWslPath, type WslCliResult, } from '@automaker/platform'; import { createLogger, isAbortError } from '@automaker/utils'; /** * Spawn strategy for CLI tools on Windows * * Different CLI tools require different execution strategies: * - 'wsl': Requires WSL, CLI only available on Linux/macOS (e.g., cursor-agent) * - 'npx': Installed globally via npm/npx, use `npx ` to run * - 'direct': Native Windows binary, can spawn directly * - 'cmd': Windows batch file (.cmd/.bat), needs cmd.exe shell */ export type SpawnStrategy = 'wsl' | 'npx' | 'direct' | 'cmd'; /** * Configuration for CLI tool spawning */ export interface CliSpawnConfig { /** How to spawn on Windows */ windowsStrategy: SpawnStrategy; /** NPX package name (required if windowsStrategy is 'npx') */ npxPackage?: string; /** Preferred WSL distribution (if windowsStrategy is 'wsl') */ wslDistribution?: string; /** * Common installation paths per platform * Use ~ for home directory (will be expanded) * Keys: 'linux', 'darwin', 'win32' */ commonPaths: Record; /** Version check command (defaults to --version) */ versionCommand?: string; } /** * CLI error information for consistent error handling */ export interface CliErrorInfo { code: string; message: string; recoverable: boolean; suggestion?: string; } /** * Detection result from CLI path finding */ export interface CliDetectionResult { /** Path to the CLI (or 'npx' for npx strategy) */ cliPath: string | null; /** Whether using WSL mode */ useWsl: boolean; /** WSL path if using WSL */ wslCliPath?: string; /** WSL distribution if using WSL */ wslDistribution?: string; /** Detected strategy used */ strategy: SpawnStrategy | 'native'; } // Create logger for CLI operations const cliLogger = createLogger('CliProvider'); /** * Abstract base class for CLI-based providers * * Subclasses must implement: * - getCliName(): CLI executable name * - getSpawnConfig(): Platform-specific spawn configuration * - buildCliArgs(): Convert ExecuteOptions to CLI arguments * - normalizeEvent(): Convert CLI output to ProviderMessage */ export abstract class CliProvider extends BaseProvider { // CLI detection results (cached after first detection) protected cliPath: string | null = null; protected useWsl: boolean = false; protected wslCliPath: string | null = null; protected wslDistribution: string | undefined = undefined; protected detectedStrategy: SpawnStrategy | 'native' = 'native'; // NPX args (used when strategy is 'npx') protected npxArgs: string[] = []; constructor(config: ProviderConfig = {}) { super(config); // Detection happens lazily on first use } // ========================================================================== // Abstract methods - must be implemented by subclasses // ========================================================================== /** * Get the CLI executable name (e.g., 'cursor-agent', 'aider') */ abstract getCliName(): string; /** * Get spawn configuration for this CLI */ abstract getSpawnConfig(): CliSpawnConfig; /** * Build CLI arguments from execution options * @param options Execution options * @returns Array of CLI arguments */ abstract buildCliArgs(options: ExecuteOptions): string[]; /** * Normalize a raw CLI event to ProviderMessage format * @param event Raw event from CLI JSONL output * @returns Normalized ProviderMessage or null to skip */ abstract normalizeEvent(event: unknown): ProviderMessage | null; // ========================================================================== // Optional overrides // ========================================================================== /** * Map CLI stderr/exit code to error info * Override to provide CLI-specific error mapping */ protected mapError(stderr: string, exitCode: number | null): CliErrorInfo { const lower = stderr.toLowerCase(); // Common authentication errors if ( lower.includes('not authenticated') || lower.includes('please log in') || lower.includes('unauthorized') ) { return { code: 'NOT_AUTHENTICATED', message: `${this.getCliName()} is not authenticated`, recoverable: true, suggestion: `Run "${this.getCliName()} login" to authenticate`, }; } // Rate limiting if ( lower.includes('rate limit') || lower.includes('too many requests') || lower.includes('429') ) { return { code: 'RATE_LIMITED', message: 'API rate limit exceeded', recoverable: true, suggestion: 'Wait a few minutes and try again', }; } // Network errors if ( lower.includes('network') || lower.includes('connection') || lower.includes('econnrefused') || lower.includes('timeout') ) { return { code: 'NETWORK_ERROR', message: 'Network connection error', recoverable: true, suggestion: 'Check your internet connection and try again', }; } // Process killed if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { return { code: 'PROCESS_CRASHED', message: 'Process was terminated', recoverable: true, suggestion: 'The process may have run out of memory. Try a simpler task.', }; } // Generic error return { code: 'UNKNOWN_ERROR', message: stderr || `Process exited with code ${exitCode}`, recoverable: false, }; } /** * Get installation instructions for this CLI * Override to provide CLI-specific instructions */ protected getInstallInstructions(): string { const cliName = this.getCliName(); const config = this.getSpawnConfig(); if (process.platform === 'win32') { switch (config.windowsStrategy) { case 'wsl': return `${cliName} requires WSL on Windows. Install WSL, then run inside WSL to install.`; case 'npx': return `Install with: npm install -g ${config.npxPackage || cliName}`; case 'cmd': case 'direct': return `${cliName} is not installed. Check the documentation for installation instructions.`; } } return `${cliName} is not installed. Check the documentation for installation instructions.`; } // ========================================================================== // CLI Detection // ========================================================================== /** * Expand ~ to home directory in path */ private expandPath(p: string): string { if (p.startsWith('~')) { return path.join(os.homedir(), p.slice(1)); } return p; } /** * Find CLI in PATH using 'which' (Unix) or 'where' (Windows) */ private findCliInPath(): string | null { const cliName = this.getCliName(); try { const command = process.platform === 'win32' ? 'where' : 'which'; const result = execSync(`${command} ${cliName}`, { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }) .trim() .split('\n')[0]; if (result && fs.existsSync(result)) { cliLogger.debug(`Found ${cliName} in PATH: ${result}`); return result; } } catch { // Not in PATH } return null; } /** * Find CLI in common installation paths for current platform */ private findCliInCommonPaths(): string | null { const config = this.getSpawnConfig(); const cliName = this.getCliName(); const platform = process.platform as 'linux' | 'darwin' | 'win32'; const paths = config.commonPaths[platform] || []; for (const p of paths) { const expandedPath = this.expandPath(p); if (fs.existsSync(expandedPath)) { cliLogger.debug(`Found ${cliName} at: ${expandedPath}`); return expandedPath; } } return null; } /** * Detect CLI installation using appropriate strategy */ protected detectCli(): CliDetectionResult { const config = this.getSpawnConfig(); const cliName = this.getCliName(); const wslLogger = (msg: string) => cliLogger.debug(msg); // Windows - use configured strategy if (process.platform === 'win32') { switch (config.windowsStrategy) { case 'wsl': { // Check WSL for CLI if (isWslAvailable({ logger: wslLogger })) { const wslResult: WslCliResult | null = findCliInWsl(cliName, { logger: wslLogger, distribution: config.wslDistribution, }); if (wslResult) { cliLogger.debug( `Using ${cliName} via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}` ); return { cliPath: 'wsl.exe', useWsl: true, wslCliPath: wslResult.wslPath, wslDistribution: wslResult.distribution, strategy: 'wsl', }; } } cliLogger.debug(`${cliName} not found (WSL not available or CLI not installed in WSL)`); return { cliPath: null, useWsl: false, strategy: 'wsl' }; } case 'npx': { // For npx, we don't need to find the CLI, just return npx cliLogger.debug(`Using ${cliName} via npx (package: ${config.npxPackage})`); return { cliPath: 'npx', useWsl: false, strategy: 'npx', }; } case 'direct': case 'cmd': { // Native Windows - check PATH and common paths const pathResult = this.findCliInPath(); if (pathResult) { return { cliPath: pathResult, useWsl: false, strategy: config.windowsStrategy }; } const commonResult = this.findCliInCommonPaths(); if (commonResult) { return { cliPath: commonResult, useWsl: false, strategy: config.windowsStrategy }; } cliLogger.debug(`${cliName} not found on Windows`); return { cliPath: null, useWsl: false, strategy: config.windowsStrategy }; } } } // Linux/macOS - native execution const pathResult = this.findCliInPath(); if (pathResult) { return { cliPath: pathResult, useWsl: false, strategy: 'native' }; } const commonResult = this.findCliInCommonPaths(); if (commonResult) { return { cliPath: commonResult, useWsl: false, strategy: 'native' }; } cliLogger.debug(`${cliName} not found`); return { cliPath: null, useWsl: false, strategy: 'native' }; } /** * Ensure CLI is detected (lazy initialization) */ protected ensureCliDetected(): void { if (this.cliPath !== null || this.detectedStrategy !== 'native') { return; // Already detected } const result = this.detectCli(); this.cliPath = result.cliPath; this.useWsl = result.useWsl; this.wslCliPath = result.wslCliPath || null; this.wslDistribution = result.wslDistribution; this.detectedStrategy = result.strategy; // Set up npx args if using npx strategy const config = this.getSpawnConfig(); if (result.strategy === 'npx' && config.npxPackage) { this.npxArgs = [config.npxPackage]; } } /** * Check if CLI is installed */ async isInstalled(): Promise { this.ensureCliDetected(); return this.cliPath !== null; } // ========================================================================== // Subprocess Spawning // ========================================================================== /** * Build subprocess options based on detected strategy */ protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions { this.ensureCliDetected(); if (!this.cliPath) { throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); } const cwd = options.cwd || process.cwd(); // Filter undefined values from process.env const filteredEnv: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (value !== undefined) { filteredEnv[key] = value; } } // WSL strategy if (this.useWsl && this.wslCliPath) { const wslCwd = windowsToWslPath(cwd); const wslCmd = createWslCommand(this.wslCliPath, cliArgs, { distribution: this.wslDistribution, }); // Add --cd flag to change directory inside WSL let args: string[]; if (this.wslDistribution) { args = ['-d', this.wslDistribution, '--cd', wslCwd, this.wslCliPath, ...cliArgs]; } else { args = ['--cd', wslCwd, this.wslCliPath, ...cliArgs]; } cliLogger.debug(`WSL spawn: ${wslCmd.command} ${args.slice(0, 6).join(' ')}...`); return { command: wslCmd.command, args, cwd, // Windows cwd for spawn env: filteredEnv, abortController: options.abortController, timeout: 120000, // CLI operations may take longer }; } // NPX strategy if (this.detectedStrategy === 'npx') { const allArgs = [...this.npxArgs, ...cliArgs]; cliLogger.debug(`NPX spawn: npx ${allArgs.slice(0, 6).join(' ')}...`); return { command: 'npx', args: allArgs, cwd, env: filteredEnv, abortController: options.abortController, timeout: 120000, }; } // Direct strategy (native Unix or Windows direct/cmd) cliLogger.debug(`Direct spawn: ${this.cliPath} ${cliArgs.slice(0, 6).join(' ')}...`); return { command: this.cliPath, args: cliArgs, cwd, env: filteredEnv, abortController: options.abortController, timeout: 120000, }; } /** * Execute a query using the CLI with JSONL streaming * * This is a default implementation that: * 1. Builds CLI args from options * 2. Spawns the subprocess with appropriate strategy * 3. Streams and normalizes events * * Subclasses can override for custom behavior. */ async *executeQuery(options: ExecuteOptions): AsyncGenerator { this.ensureCliDetected(); if (!this.cliPath) { throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); } const cliArgs = this.buildCliArgs(options); const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); try { for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { const normalized = this.normalizeEvent(rawEvent); if (normalized) { yield normalized; } } } catch (error) { if (isAbortError(error)) { cliLogger.debug('Query aborted'); return; } // Map CLI errors if (error instanceof Error && 'stderr' in error) { const errorInfo = this.mapError( (error as { stderr?: string }).stderr || error.message, (error as { exitCode?: number | null }).exitCode ?? null ); const cliError = new Error(errorInfo.message) as Error & CliErrorInfo; cliError.code = errorInfo.code; cliError.recoverable = errorInfo.recoverable; cliError.suggestion = errorInfo.suggestion; throw cliError; } throw error; } } }