mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
feat(providers): Create CliProvider abstract base class
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>
This commit is contained in:
558
apps/server/src/providers/cli-provider.ts
Normal file
558
apps/server/src/providers/cli-provider.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,14 @@
|
||||
* Provider exports
|
||||
*/
|
||||
|
||||
// Base provider
|
||||
// Base providers
|
||||
export { BaseProvider } from './base-provider.js';
|
||||
export {
|
||||
CliProvider,
|
||||
type SpawnStrategy,
|
||||
type CliSpawnConfig,
|
||||
type CliErrorInfo,
|
||||
} from './cli-provider.js';
|
||||
export type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
|
||||
Reference in New Issue
Block a user