| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { |
| createWslCommand, |
| findCliInWsl, |
| isWslAvailable, |
| spawnJSONLProcess, |
| windowsToWslPath, |
| type SubprocessOptions, |
| type WslCliResult, |
| } from '@automaker/platform'; |
| import { calculateReasoningTimeout } from '@automaker/types'; |
| import { createLogger, isAbortError } from '@automaker/utils'; |
| import { execSync } from 'child_process'; |
| import * as fs from 'fs'; |
| import * as os from 'os'; |
| import * as path from 'path'; |
| import { BaseProvider } from './base-provider.js'; |
| import type { ExecuteOptions, ProviderConfig, ProviderMessage } from './types.js'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export type SpawnStrategy = 'wsl' | 'npx' | 'direct' | 'cmd'; |
|
|
| |
| |
| |
| export interface CliSpawnConfig { |
| |
| windowsStrategy: SpawnStrategy; |
|
|
| |
| npxPackage?: string; |
|
|
| |
| wslDistribution?: string; |
|
|
| |
| |
| |
| |
| |
| commonPaths: Record<string, string[]>; |
|
|
| |
| versionCommand?: string; |
| } |
|
|
| |
| |
| |
| export interface CliErrorInfo { |
| code: string; |
| message: string; |
| recoverable: boolean; |
| suggestion?: string; |
| } |
|
|
| |
| |
| |
| export interface CliDetectionResult { |
| |
| cliPath: string | null; |
| |
| useWsl: boolean; |
| |
| wslCliPath?: string; |
| |
| wslDistribution?: string; |
| |
| strategy: SpawnStrategy | 'native'; |
| } |
|
|
| |
| const cliLogger = createLogger('CliProvider'); |
|
|
| |
| |
| |
| |
| |
| |
| |
| const CLI_BASE_TIMEOUT_MS = 120000; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export abstract class CliProvider extends BaseProvider { |
| |
| protected cliPath: string | null = null; |
| protected useWsl: boolean = false; |
| protected wslCliPath: string | null = null; |
| protected wslDistribution: string | undefined = undefined; |
| protected detectedStrategy: SpawnStrategy | 'native' = 'native'; |
|
|
| |
| protected npxArgs: string[] = []; |
|
|
| constructor(config: ProviderConfig = {}) { |
| super(config); |
| |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| abstract getCliName(): string; |
|
|
| |
| |
| |
| abstract getSpawnConfig(): CliSpawnConfig; |
|
|
| |
| |
| |
| |
| |
| abstract buildCliArgs(options: ExecuteOptions): string[]; |
|
|
| |
| |
| |
| |
| |
| abstract normalizeEvent(event: unknown): ProviderMessage | null; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| protected mapError(stderr: string, exitCode: number | null): CliErrorInfo { |
| const lower = stderr.toLowerCase(); |
|
|
| |
| 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`, |
| }; |
| } |
|
|
| |
| 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', |
| }; |
| } |
|
|
| |
| 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', |
| }; |
| } |
|
|
| |
| 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.', |
| }; |
| } |
|
|
| |
| return { |
| code: 'UNKNOWN_ERROR', |
| message: stderr || `Process exited with code ${exitCode}`, |
| recoverable: false, |
| }; |
| } |
|
|
| |
| |
| |
| |
| 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.`; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| private expandPath(p: string): string { |
| if (p.startsWith('~')) { |
| return path.join(os.homedir(), p.slice(1)); |
| } |
| return p; |
| } |
|
|
| |
| |
| |
| 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 { |
| |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| protected detectCli(): CliDetectionResult { |
| const config = this.getSpawnConfig(); |
| const cliName = this.getCliName(); |
| const wslLogger = (msg: string) => cliLogger.debug(msg); |
|
|
| |
| if (process.platform === 'win32') { |
| switch (config.windowsStrategy) { |
| case 'wsl': { |
| |
| 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': { |
| |
| cliLogger.debug(`Using ${cliName} via npx (package: ${config.npxPackage})`); |
| return { |
| cliPath: 'npx', |
| useWsl: false, |
| strategy: 'npx', |
| }; |
| } |
|
|
| case 'direct': |
| case 'cmd': { |
| |
| 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 }; |
| } |
| } |
| } |
|
|
| |
| 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' }; |
| } |
|
|
| |
| |
| |
| protected ensureCliDetected(): void { |
| if (this.cliPath !== null || this.detectedStrategy !== 'native') { |
| return; |
| } |
|
|
| 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; |
|
|
| |
| const config = this.getSpawnConfig(); |
| if (result.strategy === 'npx' && config.npxPackage) { |
| this.npxArgs = [config.npxPackage]; |
| } |
| } |
|
|
| |
| |
| |
| async isInstalled(): Promise<boolean> { |
| this.ensureCliDetected(); |
| return this.cliPath !== null; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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(); |
|
|
| |
| const filteredEnv: Record<string, string> = {}; |
| for (const [key, value] of Object.entries(process.env)) { |
| if (value !== undefined) { |
| filteredEnv[key] = value; |
| } |
| } |
|
|
| |
| |
| const timeout = calculateReasoningTimeout(options.reasoningEffort, CLI_BASE_TIMEOUT_MS); |
|
|
| |
| if (this.useWsl && this.wslCliPath) { |
| const wslCwd = windowsToWslPath(cwd); |
| const wslCmd = createWslCommand(this.wslCliPath, cliArgs, { |
| distribution: this.wslDistribution, |
| }); |
|
|
| |
| 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, |
| env: filteredEnv, |
| abortController: options.abortController, |
| timeout, |
| }; |
| } |
|
|
| |
| 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, |
| }; |
| } |
|
|
| |
| cliLogger.debug(`Direct spawn: ${this.cliPath} ${cliArgs.slice(0, 6).join(' ')}...`); |
|
|
| return { |
| command: this.cliPath, |
| args: cliArgs, |
| cwd, |
| env: filteredEnv, |
| abortController: options.abortController, |
| timeout, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> { |
| this.ensureCliDetected(); |
|
|
| if (!this.cliPath) { |
| throw new Error(`${this.getCliName()} CLI not found. ${this.getInstallInstructions()}`); |
| } |
|
|
| |
| |
| |
| const effectiveOptions = this.embedSystemPromptIntoPrompt(options); |
|
|
| const cliArgs = this.buildCliArgs(effectiveOptions); |
| const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, 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; |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| protected embedSystemPromptIntoPrompt(options: ExecuteOptions): ExecuteOptions { |
| if (!options.systemPrompt) { |
| return options; |
| } |
|
|
| |
| |
| |
| const systemText = |
| typeof options.systemPrompt === 'string' |
| ? options.systemPrompt |
| : options.systemPrompt.append |
| ? options.systemPrompt.append |
| : ''; |
|
|
| if (!systemText) { |
| return { ...options, systemPrompt: undefined }; |
| } |
|
|
| |
| if (typeof options.prompt === 'string') { |
| return { |
| ...options, |
| prompt: `${systemText}\n\n---\n\n${options.prompt}`, |
| systemPrompt: undefined, |
| }; |
| } |
|
|
| if (Array.isArray(options.prompt)) { |
| return { |
| ...options, |
| prompt: [{ type: 'text', text: systemText }, ...options.prompt], |
| systemPrompt: undefined, |
| }; |
| } |
|
|
| |
| return { ...options, systemPrompt: undefined }; |
| } |
| } |
|
|