diff --git a/apps/server/src/providers/cursor-config-manager.ts b/apps/server/src/providers/cursor-config-manager.ts new file mode 100644 index 00000000..bb06bdb7 --- /dev/null +++ b/apps/server/src/providers/cursor-config-manager.ts @@ -0,0 +1,197 @@ +/** + * Cursor CLI Configuration Manager + * + * Manages Cursor CLI configuration stored in .automaker/cursor-config.json + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { CursorCliConfig, CursorModelId } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; +import { getAutomakerDir } from '@automaker/platform'; + +// Create logger for this module +const logger = createLogger('CursorConfigManager'); + +/** + * Manages Cursor CLI configuration + * Config location: .automaker/cursor-config.json + */ +export class CursorConfigManager { + private configPath: string; + private config: CursorCliConfig; + + constructor(projectPath: string) { + // Use getAutomakerDir for consistent path resolution + this.configPath = path.join(getAutomakerDir(projectPath), 'cursor-config.json'); + this.config = this.loadConfig(); + } + + /** + * Load configuration from disk + */ + private loadConfig(): CursorCliConfig { + try { + if (fs.existsSync(this.configPath)) { + const content = fs.readFileSync(this.configPath, 'utf8'); + const parsed = JSON.parse(content) as CursorCliConfig; + logger.debug(`Loaded config from ${this.configPath}`); + return parsed; + } + } catch (error) { + logger.warn('Failed to load config:', error); + } + + // Return default config + return { + defaultModel: 'auto', + models: ['auto', 'claude-sonnet-4', 'gpt-4o-mini'], + }; + } + + /** + * Save configuration to disk + */ + private saveConfig(): void { + try { + const dir = path.dirname(this.configPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); + logger.debug('Config saved'); + } catch (error) { + logger.error('Failed to save config:', error); + throw error; + } + } + + /** + * Get the full configuration + */ + getConfig(): CursorCliConfig { + return { ...this.config }; + } + + /** + * Get the default model + */ + getDefaultModel(): CursorModelId { + return this.config.defaultModel || 'auto'; + } + + /** + * Set the default model + */ + setDefaultModel(model: CursorModelId): void { + this.config.defaultModel = model; + this.saveConfig(); + logger.info(`Default model set to: ${model}`); + } + + /** + * Get enabled models + */ + getEnabledModels(): CursorModelId[] { + return this.config.models || ['auto']; + } + + /** + * Set enabled models + */ + setEnabledModels(models: CursorModelId[]): void { + this.config.models = models; + this.saveConfig(); + logger.info(`Enabled models updated: ${models.join(', ')}`); + } + + /** + * Add a model to enabled list + */ + addModel(model: CursorModelId): void { + if (!this.config.models) { + this.config.models = []; + } + if (!this.config.models.includes(model)) { + this.config.models.push(model); + this.saveConfig(); + logger.info(`Model added: ${model}`); + } + } + + /** + * Remove a model from enabled list + */ + removeModel(model: CursorModelId): void { + if (this.config.models) { + this.config.models = this.config.models.filter((m) => m !== model); + this.saveConfig(); + logger.info(`Model removed: ${model}`); + } + } + + /** + * Check if a model is enabled + */ + isModelEnabled(model: CursorModelId): boolean { + return this.config.models?.includes(model) ?? false; + } + + /** + * Get MCP server configurations + */ + getMcpServers(): string[] { + return this.config.mcpServers || []; + } + + /** + * Set MCP server configurations + */ + setMcpServers(servers: string[]): void { + this.config.mcpServers = servers; + this.saveConfig(); + logger.info(`MCP servers updated: ${servers.join(', ')}`); + } + + /** + * Get Cursor rules paths + */ + getRules(): string[] { + return this.config.rules || []; + } + + /** + * Set Cursor rules paths + */ + setRules(rules: string[]): void { + this.config.rules = rules; + this.saveConfig(); + logger.info(`Rules updated: ${rules.join(', ')}`); + } + + /** + * Reset configuration to defaults + */ + reset(): void { + this.config = { + defaultModel: 'auto', + models: ['auto', 'claude-sonnet-4', 'gpt-4o-mini'], + }; + this.saveConfig(); + logger.info('Config reset to defaults'); + } + + /** + * Check if config file exists + */ + exists(): boolean { + return fs.existsSync(this.configPath); + } + + /** + * Get the config file path + */ + getConfigPath(): string { + return this.configPath; + } +} diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts new file mode 100644 index 00000000..a30193ca --- /dev/null +++ b/apps/server/src/providers/cursor-provider.ts @@ -0,0 +1,607 @@ +/** + * Cursor Provider - Executes queries using cursor-agent CLI + * + * Spawns the cursor-agent CLI with --output-format stream-json for streaming responses. + * Normalizes Cursor events to the AutoMaker ProviderMessage format. + */ + +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, + InstallationStatus, + ModelDefinition, +} from './types.js'; +import { + type CursorStreamEvent, + type CursorSystemEvent, + type CursorAssistantEvent, + type CursorToolCallEvent, + type CursorResultEvent, + type CursorAuthStatus, + CURSOR_MODEL_MAP, +} from '@automaker/types'; +import { createLogger, isAbortError } from '@automaker/utils'; +import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform'; + +// Create logger for this module +const logger = createLogger('CursorProvider'); + +/** + * Cursor-specific error codes for detailed error handling + */ +export enum CursorErrorCode { + NOT_INSTALLED = 'CURSOR_NOT_INSTALLED', + NOT_AUTHENTICATED = 'CURSOR_NOT_AUTHENTICATED', + RATE_LIMITED = 'CURSOR_RATE_LIMITED', + MODEL_UNAVAILABLE = 'CURSOR_MODEL_UNAVAILABLE', + NETWORK_ERROR = 'CURSOR_NETWORK_ERROR', + PROCESS_CRASHED = 'CURSOR_PROCESS_CRASHED', + TIMEOUT = 'CURSOR_TIMEOUT', + UNKNOWN = 'CURSOR_UNKNOWN_ERROR', +} + +export interface CursorError extends Error { + code: CursorErrorCode; + recoverable: boolean; + suggestion?: string; +} + +/** + * CursorProvider - Integrates cursor-agent CLI as an AI provider + * + * Uses the cursor-agent CLI with --output-format stream-json for streaming responses. + * Normalizes Cursor events to the AutoMaker ProviderMessage format. + */ +export class CursorProvider extends BaseProvider { + /** + * Installation paths based on official cursor-agent install script: + * + * Linux/macOS: + * - Binary: ~/.local/share/cursor-agent/versions//cursor-agent + * - Symlink: ~/.local/bin/cursor-agent -> versions//cursor-agent + * + * The install script creates versioned folders like: + * ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent + * And symlinks to ~/.local/bin/cursor-agent + */ + private static COMMON_PATHS: Record = { + linux: [ + path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location + '/usr/local/bin/cursor-agent', + ], + darwin: [ + path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location + '/usr/local/bin/cursor-agent', + ], + win32: [ + path.join(os.homedir(), 'AppData/Local/Programs/cursor-agent/cursor-agent.exe'), + path.join(os.homedir(), '.local/bin/cursor-agent.exe'), + 'C:\\Program Files\\cursor-agent\\cursor-agent.exe', + ], + }; + + // Version data directory where cursor-agent stores versions + private static VERSIONS_DIR = path.join(os.homedir(), '.local/share/cursor-agent/versions'); + + private cliPath: string | null = null; + + constructor(config: ProviderConfig = {}) { + super(config); + this.cliPath = config.cliPath || this.findCliPath(); + } + + getName(): string { + return 'cursor'; + } + + /** + * Find cursor-agent CLI in PATH or common installation locations + */ + private findCliPath(): string | null { + // Try 'which' / 'where' first + try { + const cmd = process.platform === 'win32' ? 'where cursor-agent' : 'which cursor-agent'; + const result = execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim().split('\n')[0]; + if (result && fs.existsSync(result)) { + logger.debug(`Found cursor-agent in PATH: ${result}`); + return result; + } + } catch { + // Not in PATH + } + + // Check common installation paths for current platform + const platform = process.platform as 'linux' | 'darwin' | 'win32'; + const platformPaths = CursorProvider.COMMON_PATHS[platform] || []; + + for (const p of platformPaths) { + if (fs.existsSync(p)) { + logger.debug(`Found cursor-agent at: ${p}`); + return p; + } + } + + // Also check versions directory for any installed version + if (fs.existsSync(CursorProvider.VERSIONS_DIR)) { + try { + const versions = fs + .readdirSync(CursorProvider.VERSIONS_DIR) + .filter((v) => !v.startsWith('.')) + .sort() + .reverse(); // Most recent first + + for (const version of versions) { + const binaryName = platform === 'win32' ? 'cursor-agent.exe' : 'cursor-agent'; + const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, binaryName); + if (fs.existsSync(versionPath)) { + logger.debug(`Found cursor-agent version ${version} at: ${versionPath}`); + return versionPath; + } + } + } catch { + // Ignore directory read errors + } + } + + logger.debug('cursor-agent CLI not found'); + return null; + } + + /** + * Check if Cursor CLI is installed + */ + async isInstalled(): Promise { + return this.cliPath !== null; + } + + /** + * Get Cursor CLI version + */ + async getVersion(): Promise { + if (!this.cliPath) return null; + + try { + const result = execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 5000, + }).trim(); + return result; + } catch { + return null; + } + } + + /** + * Check authentication status + */ + async checkAuth(): Promise { + if (!this.cliPath) { + return { authenticated: false, method: 'none' }; + } + + // Check for API key in environment + if (process.env.CURSOR_API_KEY) { + return { authenticated: true, method: 'api_key' }; + } + + // Check for credentials file (location may vary) + const credentialPaths = [ + path.join(os.homedir(), '.cursor', 'credentials.json'), + path.join(os.homedir(), '.config', 'cursor', 'credentials.json'), + ]; + + for (const credPath of credentialPaths) { + if (fs.existsSync(credPath)) { + try { + const content = fs.readFileSync(credPath, 'utf8'); + const creds = JSON.parse(content); + if (creds.accessToken || creds.token) { + return { authenticated: true, method: 'login', hasCredentialsFile: true }; + } + } catch { + // Invalid credentials file + } + } + } + + // Try running a simple command to check auth + try { + execSync(`"${this.cliPath}" --version`, { + encoding: 'utf8', + timeout: 10000, + env: { ...process.env }, + }); + // If we get here without error, assume authenticated + // (actual auth check would need a real API call) + return { authenticated: true, method: 'login' }; + } catch (error: unknown) { + const execError = error as { stderr?: string }; + if (execError.stderr?.includes('not authenticated') || execError.stderr?.includes('log in')) { + return { authenticated: false, method: 'none' }; + } + } + + return { authenticated: false, method: 'none' }; + } + + /** + * Detect installation status (required by BaseProvider) + */ + async detectInstallation(): Promise { + const installed = await this.isInstalled(); + const version = installed ? await this.getVersion() : undefined; + const auth = await this.checkAuth(); + + return { + installed, + version: version || undefined, + path: this.cliPath || undefined, + method: 'cli', + hasApiKey: !!process.env.CURSOR_API_KEY, + authenticated: auth.authenticated, + }; + } + + /** + * Get available Cursor models + */ + getAvailableModels(): ModelDefinition[] { + return Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({ + id: `cursor-${id}`, + name: config.label, + modelString: id, + provider: 'cursor', + description: config.description, + tier: config.tier === 'pro' ? ('premium' as const) : ('basic' as const), + supportsTools: true, + supportsVision: false, // Cursor CLI may not support vision + })); + } + + /** + * Create a CursorError with details + */ + private createError( + code: CursorErrorCode, + message: string, + recoverable: boolean = false, + suggestion?: string + ): CursorError { + const error = new Error(message) as CursorError; + error.code = code; + error.recoverable = recoverable; + error.suggestion = suggestion; + error.name = 'CursorError'; + return error; + } + + /** + * Map stderr/exit codes to detailed CursorError + */ + private mapError(stderr: string, exitCode: number | null): CursorError { + const lower = stderr.toLowerCase(); + + if ( + lower.includes('not authenticated') || + lower.includes('please log in') || + lower.includes('unauthorized') + ) { + return this.createError( + CursorErrorCode.NOT_AUTHENTICATED, + 'Cursor CLI is not authenticated', + true, + 'Run "cursor-agent login" to authenticate with your browser' + ); + } + + if ( + lower.includes('rate limit') || + lower.includes('too many requests') || + lower.includes('429') + ) { + return this.createError( + CursorErrorCode.RATE_LIMITED, + 'Cursor API rate limit exceeded', + true, + 'Wait a few minutes and try again, or upgrade to Cursor Pro' + ); + } + + if ( + lower.includes('model not available') || + lower.includes('invalid model') || + lower.includes('unknown model') + ) { + return this.createError( + CursorErrorCode.MODEL_UNAVAILABLE, + 'Requested model is not available', + true, + 'Try using "auto" mode or select a different model' + ); + } + + if ( + lower.includes('network') || + lower.includes('connection') || + lower.includes('econnrefused') || + lower.includes('timeout') + ) { + return this.createError( + CursorErrorCode.NETWORK_ERROR, + 'Network connection error', + true, + 'Check your internet connection and try again' + ); + } + + if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) { + return this.createError( + CursorErrorCode.PROCESS_CRASHED, + 'Cursor agent process was terminated', + true, + 'The process may have run out of memory. Try a simpler task.' + ); + } + + return this.createError( + CursorErrorCode.UNKNOWN, + stderr || `Cursor agent exited with code ${exitCode}`, + false + ); + } + + /** + * Convert Cursor event to AutoMaker ProviderMessage format + */ + private normalizeEvent(event: CursorStreamEvent): ProviderMessage | null { + switch (event.type) { + case 'system': + // System init - we capture session_id but don't yield a message + return null; + + case 'user': + // User message - already handled by caller + return null; + + case 'assistant': { + const assistantEvent = event as CursorAssistantEvent; + return { + type: 'assistant', + session_id: assistantEvent.session_id, + message: { + role: 'assistant', + content: assistantEvent.message.content.map((c) => ({ + type: 'text' as const, + text: c.text, + })), + }, + }; + } + + case 'tool_call': { + const toolEvent = event as CursorToolCallEvent; + const toolCall = toolEvent.tool_call; + + // Determine tool name and input + let toolName: string; + let toolInput: unknown; + + if (toolCall.readToolCall) { + toolName = 'Read'; + toolInput = { file_path: toolCall.readToolCall.args.path }; + } else if (toolCall.writeToolCall) { + toolName = 'Write'; + toolInput = { + file_path: toolCall.writeToolCall.args.path, + content: toolCall.writeToolCall.args.fileText, + }; + } else if (toolCall.function) { + toolName = toolCall.function.name; + try { + toolInput = JSON.parse(toolCall.function.arguments || '{}'); + } catch { + toolInput = { raw: toolCall.function.arguments }; + } + } else { + return null; + } + + // For started events, emit tool_use + if (toolEvent.subtype === 'started') { + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: toolName, + tool_use_id: toolEvent.call_id, + input: toolInput, + }, + ], + }, + }; + } + + // For completed events, emit tool_result + if (toolEvent.subtype === 'completed') { + let resultContent = ''; + + if (toolCall.readToolCall?.result?.success) { + resultContent = toolCall.readToolCall.result.success.content; + } else if (toolCall.writeToolCall?.result?.success) { + resultContent = `Wrote ${toolCall.writeToolCall.result.success.linesCreated} lines to ${toolCall.writeToolCall.result.success.path}`; + } + + return { + type: 'assistant', + session_id: toolEvent.session_id, + message: { + role: 'assistant', + content: [ + { + type: 'tool_result', + tool_use_id: toolEvent.call_id, + content: resultContent, + }, + ], + }, + }; + } + + return null; + } + + case 'result': { + const resultEvent = event as CursorResultEvent; + + if (resultEvent.is_error) { + return { + type: 'error', + session_id: resultEvent.session_id, + error: resultEvent.error || resultEvent.result || 'Unknown error', + }; + } + + return { + type: 'result', + subtype: 'success', + session_id: resultEvent.session_id, + result: resultEvent.result, + }; + } + + default: + return null; + } + } + + /** + * Execute a prompt using Cursor CLI with streaming + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + if (!this.cliPath) { + throw this.createError( + CursorErrorCode.NOT_INSTALLED, + 'Cursor CLI is not installed', + true, + 'Install with: curl https://cursor.com/install -fsS | bash' + ); + } + + // Extract model from options (strip 'cursor-' prefix if present) + let model = options.model || 'auto'; + if (model.startsWith('cursor-')) { + model = model.substring(7); + } + + const cwd = options.cwd || process.cwd(); + + // Build prompt content + let promptText: string; + if (typeof options.prompt === 'string') { + promptText = options.prompt; + } else if (Array.isArray(options.prompt)) { + promptText = options.prompt + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text) + .join('\n'); + } else { + throw new Error('Invalid prompt format'); + } + + // Build CLI arguments + const args: string[] = [ + '-p', // Print mode (non-interactive) + '--force', // Allow file modifications + '--output-format', + 'stream-json', + '--stream-partial-output', // Real-time streaming + ]; + + // Add model if not auto + if (model !== 'auto') { + args.push('--model', model); + } + + // Add the prompt + args.push(promptText); + + logger.debug(`Executing: ${this.cliPath} ${args.slice(0, 6).join(' ')}...`); + + // Use spawnJSONLProcess from @automaker/platform for JSONL streaming + // This handles line buffering, timeouts, and abort signals automatically + // Filter out undefined values from process.env to satisfy TypeScript + const filteredEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) { + filteredEnv[key] = value; + } + } + + const subprocessOptions: SubprocessOptions = { + command: this.cliPath, + args, + cwd, + env: filteredEnv, + abortController: options.abortController, + timeout: 120000, // 2 min timeout for CLI operations (may take longer than default 30s) + }; + + let sessionId: string | undefined; + + try { + // spawnJSONLProcess yields parsed JSON objects, handles errors + for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { + const event = rawEvent as CursorStreamEvent; + + // Capture session ID from system init + if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') { + sessionId = event.session_id; + logger.debug(`Session started: ${sessionId}`); + } + + // Normalize and yield the event + const normalized = this.normalizeEvent(event); + if (normalized) { + // Ensure session_id is always set + if (!normalized.session_id && sessionId) { + normalized.session_id = sessionId; + } + yield normalized; + } + } + } catch (error) { + // Use isAbortError from @automaker/utils for abort detection + if (isAbortError(error)) { + logger.debug('Query aborted'); + return; // Clean abort, don't throw + } + + // Map CLI errors to CursorError + if (error instanceof Error && 'stderr' in error) { + throw this.mapError( + (error as { stderr?: string }).stderr || error.message, + (error as { exitCode?: number | null }).exitCode ?? null + ); + } + throw error; + } + } + + /** + * Check if a feature is supported + */ + supportsFeature(feature: string): boolean { + const supported = ['tools', 'text', 'streaming']; + return supported.includes(feature); + } +} diff --git a/plan/cursor-cli-integration/README.md b/plan/cursor-cli-integration/README.md index f352f592..80d540aa 100644 --- a/plan/cursor-cli-integration/README.md +++ b/plan/cursor-cli-integration/README.md @@ -8,7 +8,7 @@ | ----- | ------------------------------------------------------------ | ----------- | ----------- | | 0 | [Analysis & Documentation](phases/phase-0-analysis.md) | `completed` | ✅ | | 1 | [Core Types & Configuration](phases/phase-1-types.md) | `completed` | ✅ | -| 2 | [Cursor Provider Implementation](phases/phase-2-provider.md) | `pending` | - | +| 2 | [Cursor Provider Implementation](phases/phase-2-provider.md) | `completed` | ✅ | | 3 | [Provider Factory Integration](phases/phase-3-factory.md) | `pending` | - | | 4 | [Setup Routes & Status Endpoints](phases/phase-4-routes.md) | `pending` | - | | 5 | [Log Parser Integration](phases/phase-5-log-parser.md) | `pending` | - | diff --git a/plan/cursor-cli-integration/phases/phase-2-provider.md b/plan/cursor-cli-integration/phases/phase-2-provider.md index 4ff2d83c..13ff4ec9 100644 --- a/plan/cursor-cli-integration/phases/phase-2-provider.md +++ b/plan/cursor-cli-integration/phases/phase-2-provider.md @@ -1,6 +1,6 @@ # Phase 2: Cursor Provider Implementation -**Status:** `pending` +**Status:** `completed` **Dependencies:** Phase 1 (Types) **Estimated Effort:** Medium-Large (core implementation) @@ -16,7 +16,7 @@ Implement the main `CursorProvider` class that spawns the cursor-agent CLI and s ### Task 2.1: Create Cursor Provider -**Status:** `pending` +**Status:** `completed` **File:** `apps/server/src/providers/cursor-provider.ts` @@ -635,7 +635,7 @@ export class CursorProvider extends BaseProvider { ### Task 2.2: Create Cursor Config Manager -**Status:** `pending` +**Status:** `completed` **File:** `apps/server/src/providers/cursor-config-manager.ts`