mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Add reusable infrastructure for CLI-based AI providers:
- SpawnStrategy types ('wsl' | 'npx' | 'direct' | 'cmd')
- CliSpawnConfig interface for platform-specific configuration
- Common CLI path detection (PATH, common locations)
- WSL support for Windows (reuses @automaker/platform utilities)
- NPX strategy for npm-installed CLIs
- Strategy-aware subprocess spawning with JSONL streaming
- Error mapping infrastructure with recovery suggestions
Reuses existing utilities:
- spawnJSONLProcess, WSL utils from @automaker/platform
- createLogger, isAbortError from @automaker/utils
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
559 lines
16 KiB
TypeScript
559 lines
16 KiB
TypeScript
/**
|
|
* 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 <package>` 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<string, string[]>;
|
|
|
|
/** 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<boolean> {
|
|
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<string, string> = {};
|
|
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<ProviderMessage> {
|
|
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;
|
|
}
|
|
}
|
|
}
|