| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { execSync } from 'child_process'; |
| import * as fs from 'fs'; |
| import * as path from 'path'; |
| import * as os from 'os'; |
| import { findCliInWsl, isWslAvailable } from '@automaker/platform'; |
| import { |
| CliProvider, |
| type CliSpawnConfig, |
| type CliDetectionResult, |
| type CliErrorInfo, |
| } from './cli-provider.js'; |
| import type { |
| ProviderConfig, |
| ExecuteOptions, |
| ProviderMessage, |
| InstallationStatus, |
| ModelDefinition, |
| ContentBlock, |
| } from './types.js'; |
| import { validateBareModelId } from '@automaker/types'; |
| import { validateApiKey } from '../lib/auth-utils.js'; |
| import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.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, execInWsl } from '@automaker/platform'; |
|
|
| |
| const logger = createLogger('CursorProvider'); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| interface CursorToolHandler<TArgs = unknown, TResult = unknown> { |
| |
| name: string; |
| |
| mapInput: (args: TArgs) => unknown; |
| |
| formatResult?: (result: TResult, args?: TArgs) => string; |
| |
| formatRejected?: (reason: string) => string; |
| } |
|
|
| |
| |
| |
| |
| |
| const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = { |
| readToolCall: { |
| name: 'Read', |
| mapInput: (args: { path: string }) => ({ file_path: args.path }), |
| formatResult: (result: { content: string }) => result.content, |
| }, |
|
|
| writeToolCall: { |
| name: 'Write', |
| mapInput: (args: { path: string; fileText: string }) => ({ |
| file_path: args.path, |
| content: args.fileText, |
| }), |
| formatResult: (result: { linesCreated: number; path: string }) => |
| `Wrote ${result.linesCreated} lines to ${result.path}`, |
| }, |
|
|
| editToolCall: { |
| name: 'Edit', |
| mapInput: (args: { path: string; oldText?: string; newText?: string }) => ({ |
| file_path: args.path, |
| old_string: args.oldText, |
| new_string: args.newText, |
| }), |
| formatResult: (_result: unknown, args?: { path: string }) => `Edited file: ${args?.path}`, |
| }, |
|
|
| shellToolCall: { |
| name: 'Bash', |
| mapInput: (args: { command: string }) => ({ command: args.command }), |
| formatResult: (result: { exitCode: number; stdout?: string; stderr?: string }) => { |
| let content = `Exit code: ${result.exitCode}`; |
| if (result.stdout) content += `\n${result.stdout}`; |
| if (result.stderr) content += `\nStderr: ${result.stderr}`; |
| return content; |
| }, |
| formatRejected: (reason: string) => `Rejected: ${reason}`, |
| }, |
|
|
| deleteToolCall: { |
| name: 'Delete', |
| mapInput: (args: { path: string }) => ({ file_path: args.path }), |
| formatResult: (_result: unknown, args?: { path: string }) => `Deleted: ${args?.path}`, |
| formatRejected: (reason: string) => `Delete rejected: ${reason}`, |
| }, |
|
|
| grepToolCall: { |
| name: 'Grep', |
| mapInput: (args: { pattern: string; path?: string }) => ({ |
| pattern: args.pattern, |
| path: args.path, |
| }), |
| formatResult: (result: { matchedLines: number }) => |
| `Found ${result.matchedLines} matching lines`, |
| }, |
|
|
| lsToolCall: { |
| name: 'Ls', |
| mapInput: (args: { path: string }) => ({ path: args.path }), |
| formatResult: (result: { childrenFiles: number; childrenDirs: number }) => |
| `Found ${result.childrenFiles} files, ${result.childrenDirs} directories`, |
| }, |
|
|
| globToolCall: { |
| name: 'Glob', |
| mapInput: (args: { globPattern: string; targetDirectory?: string }) => ({ |
| pattern: args.globPattern, |
| path: args.targetDirectory, |
| }), |
| formatResult: (result: { totalFiles: number }) => `Found ${result.totalFiles} matching files`, |
| }, |
|
|
| semSearchToolCall: { |
| name: 'SemanticSearch', |
| mapInput: (args: { query: string; targetDirectories?: string[]; explanation?: string }) => ({ |
| query: args.query, |
| targetDirectories: args.targetDirectories, |
| explanation: args.explanation, |
| }), |
| formatResult: (result: { results: string; codeResults?: unknown[] }) => { |
| const resultCount = result.codeResults?.length || 0; |
| return resultCount > 0 |
| ? `Found ${resultCount} semantic search result(s)` |
| : result.results || 'No results found'; |
| }, |
| }, |
|
|
| readLintsToolCall: { |
| name: 'ReadLints', |
| mapInput: (args: { paths: string[] }) => ({ paths: args.paths }), |
| formatResult: (result: { totalDiagnostics: number; totalFiles: number }) => |
| `Found ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`, |
| }, |
| }; |
|
|
| |
| |
| |
| |
| function processCursorToolCall( |
| toolCall: CursorToolCallEvent['tool_call'] |
| ): { toolName: string; toolInput: unknown } | null { |
| |
| for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) { |
| const toolData = toolCall[key as keyof typeof toolCall] as { args?: unknown } | undefined; |
| if (toolData) { |
| |
| if (!toolData.args) return null; |
| return { |
| toolName: handler.name, |
| toolInput: handler.mapInput(toolData.args), |
| }; |
| } |
| } |
|
|
| |
| if (toolCall.function) { |
| let toolInput: unknown; |
| try { |
| toolInput = JSON.parse(toolCall.function.arguments || '{}'); |
| } catch { |
| toolInput = { raw: toolCall.function.arguments }; |
| } |
| return { |
| toolName: toolCall.function.name, |
| toolInput, |
| }; |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| function formatCursorToolResult(toolCall: CursorToolCallEvent['tool_call']): string { |
| for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) { |
| const toolData = toolCall[key as keyof typeof toolCall] as |
| | { |
| args?: unknown; |
| result?: { success?: unknown; rejected?: { reason: string } }; |
| } |
| | undefined; |
|
|
| if (toolData?.result) { |
| if (toolData.result.success && handler.formatResult) { |
| return handler.formatResult(toolData.result.success, toolData.args); |
| } |
| if (toolData.result.rejected && handler.formatRejected) { |
| return handler.formatRejected(toolData.result.rejected.reason); |
| } |
| } |
| } |
|
|
| return ''; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export class CursorProvider extends CliProvider { |
| |
| |
| |
| |
| |
| private static VERSIONS_DIR = path.join(os.homedir(), '.local/share/cursor-agent/versions'); |
|
|
| constructor(config: ProviderConfig = {}) { |
| super(config); |
| |
| this.ensureCliDetected(); |
| } |
|
|
| |
| |
| |
|
|
| getName(): string { |
| return 'cursor'; |
| } |
|
|
| getCliName(): string { |
| return 'cursor-agent'; |
| } |
|
|
| getSpawnConfig(): CliSpawnConfig { |
| return { |
| windowsStrategy: 'direct', |
| commonPaths: { |
| linux: [ |
| path.join(os.homedir(), '.local/bin/cursor-agent'), |
| '/usr/local/bin/cursor-agent', |
| ], |
| darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'], |
| win32: [ |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'Programs', |
| 'Cursor', |
| 'resources', |
| 'app', |
| 'bin', |
| 'cursor-agent.exe' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'Programs', |
| 'Cursor', |
| 'resources', |
| 'app', |
| 'bin', |
| 'cursor-agent.cmd' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'Programs', |
| 'Cursor', |
| 'resources', |
| 'app', |
| 'bin', |
| 'cursor.exe' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'Programs', |
| 'Cursor', |
| 'cursor.exe' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'Programs', |
| 'cursor', |
| 'resources', |
| 'app', |
| 'bin', |
| 'cursor-agent.exe' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'Programs', |
| 'cursor', |
| 'resources', |
| 'app', |
| 'bin', |
| 'cursor-agent.cmd' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'Programs', |
| 'cursor', |
| 'resources', |
| 'app', |
| 'bin', |
| 'cursor.exe' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'Programs', |
| 'cursor', |
| 'cursor.exe' |
| ), |
| path.join( |
| process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), |
| 'npm', |
| 'cursor-agent.cmd' |
| ), |
| path.join( |
| process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), |
| 'npm', |
| 'cursor.cmd' |
| ), |
| path.join( |
| process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), |
| '.npm-global', |
| 'bin', |
| 'cursor-agent.cmd' |
| ), |
| path.join( |
| process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), |
| '.npm-global', |
| 'bin', |
| 'cursor.cmd' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'pnpm', |
| 'cursor-agent.cmd' |
| ), |
| path.join( |
| process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), |
| 'pnpm', |
| 'cursor.cmd' |
| ), |
| ], |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| private extractPromptText(options: ExecuteOptions): string { |
| if (typeof options.prompt === 'string') { |
| return options.prompt; |
| } else if (Array.isArray(options.prompt)) { |
| return options.prompt |
| .filter((p) => p.type === 'text' && p.text) |
| .map((p) => p.text) |
| .join('\n'); |
| } else { |
| throw new Error('Invalid prompt format'); |
| } |
| } |
|
|
| buildCliArgs(options: ExecuteOptions): string[] { |
| |
| const model = options.model || 'auto'; |
|
|
| |
| |
| |
| const cliArgs: string[] = []; |
|
|
| |
| if (this.cliPath && !this.cliPath.includes('cursor-agent')) { |
| cliArgs.push('agent'); |
| } |
|
|
| cliArgs.push( |
| '-p', |
| '--output-format', |
| 'stream-json', |
| '--stream-partial-output' |
| ); |
|
|
| |
| |
| if (options.readOnly) { |
| cliArgs.push('--mode', 'ask'); |
| } else { |
| cliArgs.push('--force'); |
| } |
|
|
| |
| if (model !== 'auto') { |
| cliArgs.push('--model', model); |
| } |
|
|
| |
| if (options.sdkSessionId) { |
| cliArgs.push('--resume', options.sdkSessionId); |
| } |
|
|
| |
| cliArgs.push('-'); |
|
|
| return cliArgs; |
| } |
|
|
| |
| |
| |
| |
| normalizeEvent(event: unknown): ProviderMessage | null { |
| const cursorEvent = event as CursorStreamEvent; |
|
|
| switch (cursorEvent.type) { |
| case 'system': |
| |
| return null; |
|
|
| case 'user': |
| |
| return null; |
|
|
| case 'assistant': { |
| const assistantEvent = cursorEvent 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 = cursorEvent as CursorToolCallEvent; |
| const toolCall = toolEvent.tool_call; |
|
|
| |
| const processed = processCursorToolCall(toolCall); |
| if (!processed) { |
| |
| const toolCallKeys = Object.keys(toolCall); |
| logger.warn( |
| `[UNHANDLED TOOL_CALL] Unknown tool call structure. Keys: ${toolCallKeys.join(', ')}. ` + |
| `Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}` |
| ); |
| return null; |
| } |
|
|
| const { toolName, toolInput } = processed; |
|
|
| |
| 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, |
| }, |
| ], |
| }, |
| }; |
| } |
|
|
| |
| if (toolEvent.subtype === 'completed') { |
| const resultContent = formatCursorToolResult(toolCall); |
|
|
| 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, |
| }, |
| { |
| type: 'tool_result', |
| tool_use_id: toolEvent.call_id, |
| content: resultContent, |
| }, |
| ], |
| }, |
| }; |
| } |
|
|
| return null; |
| } |
|
|
| case 'result': { |
| const resultEvent = cursorEvent as CursorResultEvent; |
|
|
| if (resultEvent.is_error) { |
| const errorText = resultEvent.error || resultEvent.result || ''; |
| const enrichedError = |
| errorText || |
| `Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`; |
| return { |
| type: 'error', |
| session_id: resultEvent.session_id, |
| error: enrichedError, |
| }; |
| } |
|
|
| return { |
| type: 'result', |
| subtype: 'success', |
| session_id: resultEvent.session_id, |
| result: resultEvent.result, |
| }; |
| } |
|
|
| default: |
| return null; |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| protected detectCli(): CliDetectionResult { |
| if (process.platform === 'win32') { |
| const findInPath = (command: string): string | null => { |
| try { |
| const result = execSync(`where ${command}`, { |
| encoding: 'utf8', |
| timeout: 5000, |
| stdio: ['pipe', 'pipe', 'pipe'], |
| windowsHide: true, |
| }) |
| .trim() |
| .split(/\r?\n/)[0]; |
|
|
| if (result && fs.existsSync(result)) { |
| return result; |
| } |
| } catch { |
| |
| } |
|
|
| return null; |
| }; |
|
|
| const isCursorAgentBinary = (cliPath: string) => |
| cliPath.toLowerCase().includes('cursor-agent'); |
|
|
| const supportsCursorAgentSubcommand = (cliPath: string) => { |
| try { |
| execSync(`"${cliPath}" agent --version`, { |
| encoding: 'utf8', |
| timeout: 5000, |
| stdio: 'pipe', |
| windowsHide: true, |
| }); |
| return true; |
| } catch { |
| return false; |
| } |
| }; |
|
|
| const pathResult = findInPath('cursor-agent') || findInPath('cursor'); |
| if (pathResult) { |
| if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) { |
| return { |
| cliPath: pathResult, |
| useWsl: false, |
| strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct', |
| }; |
| } |
| } |
|
|
| const config = this.getSpawnConfig(); |
| for (const candidate of config.commonPaths.win32 || []) { |
| const resolved = candidate; |
| if (!fs.existsSync(resolved)) { |
| continue; |
| } |
| if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) { |
| return { |
| cliPath: resolved, |
| useWsl: false, |
| strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct', |
| }; |
| } |
| } |
|
|
| const wslLogger = (msg: string) => logger.debug(msg); |
| if (isWslAvailable({ logger: wslLogger })) { |
| const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger }); |
| if (wslResult) { |
| logger.debug( |
| `Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}` |
| ); |
| return { |
| cliPath: 'wsl.exe', |
| useWsl: true, |
| wslCliPath: wslResult.wslPath, |
| wslDistribution: wslResult.distribution, |
| strategy: 'wsl', |
| }; |
| } |
| } |
|
|
| logger.debug('cursor-agent not found on Windows'); |
| return { cliPath: null, useWsl: false, strategy: 'direct' }; |
| } |
|
|
| |
| const result = super.detectCli(); |
| if (result.cliPath) { |
| return result; |
| } |
|
|
| |
| |
| if (fs.existsSync(CursorProvider.VERSIONS_DIR)) { |
| try { |
| const versions = fs |
| .readdirSync(CursorProvider.VERSIONS_DIR) |
| .filter((v) => !v.startsWith('.')) |
| .sort() |
| .reverse(); |
|
|
| for (const version of versions) { |
| const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, 'cursor-agent'); |
| if (fs.existsSync(versionPath)) { |
| logger.debug(`Found cursor-agent version ${version} at: ${versionPath}`); |
| return { |
| cliPath: versionPath, |
| useWsl: false, |
| strategy: 'native', |
| }; |
| } |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| const cursorPaths = [ |
| '/usr/bin/cursor', |
| '/usr/local/bin/cursor', |
| path.join(os.homedir(), '.local/bin/cursor'), |
| '/opt/cursor/cursor', |
| ]; |
|
|
| for (const cursorPath of cursorPaths) { |
| if (fs.existsSync(cursorPath)) { |
| |
| try { |
| execSync(`"${cursorPath}" agent --version`, { |
| encoding: 'utf8', |
| timeout: 5000, |
| stdio: 'pipe', |
| }); |
| logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`); |
| |
| return { |
| cliPath: cursorPath, |
| useWsl: false, |
| strategy: 'native', |
| }; |
| } catch { |
| |
| } |
| } |
| } |
|
|
| return result; |
| } |
|
|
| |
| |
| |
| 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: CursorErrorCode.NOT_AUTHENTICATED, |
| message: 'Cursor CLI is not authenticated', |
| recoverable: true, |
| suggestion: 'Run "cursor-agent login" to authenticate with your browser', |
| }; |
| } |
|
|
| if ( |
| lower.includes('rate limit') || |
| lower.includes('too many requests') || |
| lower.includes('429') |
| ) { |
| return { |
| code: CursorErrorCode.RATE_LIMITED, |
| message: 'Cursor API rate limit exceeded', |
| recoverable: true, |
| suggestion: '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 { |
| code: CursorErrorCode.MODEL_UNAVAILABLE, |
| message: 'Requested model is not available', |
| recoverable: true, |
| suggestion: 'Try using "auto" mode or select a different model', |
| }; |
| } |
|
|
| if ( |
| lower.includes('network') || |
| lower.includes('connection') || |
| lower.includes('econnrefused') || |
| lower.includes('timeout') |
| ) { |
| return { |
| code: CursorErrorCode.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: CursorErrorCode.PROCESS_CRASHED, |
| message: 'Cursor agent process was terminated', |
| recoverable: true, |
| suggestion: 'The process may have run out of memory. Try a simpler task.', |
| }; |
| } |
|
|
| return { |
| code: CursorErrorCode.UNKNOWN, |
| message: stderr || `Cursor agent exited with code ${exitCode}`, |
| recoverable: false, |
| }; |
| } |
|
|
| |
| |
| |
| protected getInstallInstructions(): string { |
| if (process.platform === 'win32') { |
| return 'cursor-agent requires WSL on Windows. Install WSL, then run in WSL: curl https://cursor.com/install -fsS | bash'; |
| } |
| return 'Install with: curl https://cursor.com/install -fsS | bash'; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> { |
| this.ensureCliDetected(); |
|
|
| |
| |
| |
| validateBareModelId(options.model, 'CursorProvider', 'cursor'); |
|
|
| if (!this.cliPath) { |
| throw this.createError( |
| CursorErrorCode.NOT_INSTALLED, |
| 'Cursor CLI is not installed', |
| true, |
| this.getInstallInstructions() |
| ); |
| } |
|
|
| |
| if (options.mcpServers && Object.keys(options.mcpServers).length > 0) { |
| const serverCount = Object.keys(options.mcpServers).length; |
| logger.warn( |
| `MCP servers configured (${serverCount}) but not yet supported by Cursor CLI in AutoMaker. ` + |
| `MCP support for Cursor will be added in a future release. ` + |
| `The configured MCP servers will be ignored for this execution.` |
| ); |
| } |
|
|
| |
| const effectiveOptions = this.embedSystemPromptIntoPrompt(options); |
|
|
| |
| const promptText = this.extractPromptText(effectiveOptions); |
|
|
| const cliArgs = this.buildCliArgs(effectiveOptions); |
| const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); |
|
|
| |
| |
| subprocessOptions.stdinData = promptText; |
|
|
| let sessionId: string | undefined; |
|
|
| |
| let lastTextBlock = ''; |
| let accumulatedText = ''; |
|
|
| logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`); |
|
|
| |
| const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd()); |
| const activeProfile = detectProfile(effectivePermissions); |
| logger.debug( |
| `Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}` |
| ); |
|
|
| |
| const debugRawEvents = |
| process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' || |
| process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1'; |
|
|
| try { |
| for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) { |
| const event = rawEvent as CursorStreamEvent; |
|
|
| |
| if (debugRawEvents) { |
| const subtype = 'subtype' in event ? (event.subtype as string) : 'none'; |
| logger.info(`[RAW EVENT] type=${event.type} subtype=${subtype}`); |
| if (event.type === 'tool_call') { |
| const toolEvent = event as CursorToolCallEvent; |
| const tc = toolEvent.tool_call; |
| const toolTypes = |
| [ |
| tc.readToolCall && 'read', |
| tc.writeToolCall && 'write', |
| tc.editToolCall && 'edit', |
| tc.shellToolCall && 'shell', |
| tc.deleteToolCall && 'delete', |
| tc.grepToolCall && 'grep', |
| tc.lsToolCall && 'ls', |
| tc.globToolCall && 'glob', |
| tc.function && `function:${tc.function.name}`, |
| ] |
| .filter(Boolean) |
| .join(',') || 'unknown'; |
| logger.info( |
| `[RAW TOOL_CALL] call_id=${toolEvent.call_id} types=[${toolTypes}]` + |
| (tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') + |
| (tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '') |
| ); |
| } |
| } |
|
|
| |
| if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') { |
| sessionId = event.session_id; |
| logger.debug(`Session started: ${sessionId}`); |
| } |
|
|
| |
| const normalized = this.normalizeEvent(event); |
| if (!normalized && debugRawEvents) { |
| logger.info(`[DROPPED EVENT] type=${event.type} - normalizeEvent returned null`); |
| } |
| if (normalized) { |
| |
| if (!normalized.session_id && sessionId) { |
| normalized.session_id = sessionId; |
| } |
|
|
| |
| if (normalized.type === 'assistant' && normalized.message?.content) { |
| const dedupedContent = this.deduplicateTextBlocks( |
| normalized.message.content, |
| lastTextBlock, |
| accumulatedText |
| ); |
|
|
| if (dedupedContent.content.length === 0) { |
| |
| continue; |
| } |
|
|
| |
| lastTextBlock = dedupedContent.lastBlock; |
| accumulatedText = dedupedContent.accumulated; |
|
|
| |
| normalized.message.content = dedupedContent.content; |
| } |
|
|
| yield normalized; |
| } |
| } |
| } catch (error) { |
| if (isAbortError(error)) { |
| logger.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 |
| ); |
| throw this.createError( |
| errorInfo.code as CursorErrorCode, |
| errorInfo.message, |
| errorInfo.recoverable, |
| errorInfo.suggestion |
| ); |
| } |
| throw error; |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private deduplicateTextBlocks( |
| content: ContentBlock[], |
| lastTextBlock: string, |
| accumulatedText: string |
| ): { content: ContentBlock[]; lastBlock: string; accumulated: string } { |
| const filtered: ContentBlock[] = []; |
| let newLastBlock = lastTextBlock; |
| let newAccumulated = accumulatedText; |
|
|
| for (const block of content) { |
| if (block.type !== 'text' || !block.text) { |
| filtered.push(block); |
| continue; |
| } |
|
|
| const text = block.text; |
|
|
| |
| if (!text.trim()) continue; |
|
|
| |
| if (text === newLastBlock) { |
| continue; |
| } |
|
|
| |
| |
| if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) { |
| const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim(); |
| const normalizedNew = text.replace(/\s+/g, ' ').trim(); |
| if (normalizedNew.includes(normalizedAccum.slice(0, 100))) { |
| |
| continue; |
| } |
| } |
|
|
| |
| newLastBlock = text; |
| newAccumulated += text; |
| filtered.push(block); |
| } |
|
|
| return { |
| content: filtered, |
| lastBlock: newLastBlock, |
| accumulated: newAccumulated, |
| }; |
| } |
|
|
| |
| |
| |
| async getVersion(): Promise<string | null> { |
| this.ensureCliDetected(); |
| if (!this.cliPath) return null; |
|
|
| try { |
| if (this.useWsl && this.wslCliPath) { |
| const result = execInWsl(`${this.wslCliPath} --version`, { |
| timeout: 5000, |
| distribution: this.wslDistribution, |
| }); |
| return result; |
| } |
|
|
| |
| const versionCmd = this.cliPath.includes('cursor-agent') |
| ? `"${this.cliPath}" --version` |
| : `"${this.cliPath}" agent --version`; |
|
|
| const result = execSync(versionCmd, { |
| encoding: 'utf8', |
| timeout: 5000, |
| stdio: 'pipe', |
| }).trim(); |
| return result; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| async checkAuth(): Promise<CursorAuthStatus> { |
| this.ensureCliDetected(); |
| if (!this.cliPath) { |
| return { authenticated: false, method: 'none' }; |
| } |
|
|
| |
| if (process.env.CURSOR_API_KEY) { |
| const validation = validateApiKey(process.env.CURSOR_API_KEY, 'cursor'); |
| if (!validation.isValid) { |
| logger.warn('Cursor API key validation failed:', validation.error); |
| return { authenticated: false, method: 'api_key', error: validation.error }; |
| } |
| return { authenticated: true, method: 'api_key' }; |
| } |
|
|
| |
| if (this.useWsl && this.wslCliPath) { |
| const wslOpts = { timeout: 5000, distribution: this.wslDistribution }; |
|
|
| |
| const wslCredPaths = [ |
| '$HOME/.cursor/credentials.json', |
| '$HOME/.config/cursor/credentials.json', |
| ]; |
|
|
| for (const credPath of wslCredPaths) { |
| const content = execInWsl(`sh -c "cat ${credPath} 2>/dev/null || echo ''"`, wslOpts); |
| if (content && content.trim()) { |
| try { |
| const creds = JSON.parse(content); |
| if (creds.accessToken || creds.token) { |
| return { authenticated: true, method: 'login', hasCredentialsFile: true }; |
| } |
| } catch { |
| |
| } |
| } |
| } |
|
|
| |
| const versionResult = execInWsl(`${this.wslCliPath} --version`, { |
| timeout: 10000, |
| distribution: this.wslDistribution, |
| }); |
| if (versionResult) { |
| return { authenticated: true, method: 'login' }; |
| } |
|
|
| return { authenticated: false, method: 'none' }; |
| } |
|
|
| |
| 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 { |
| |
| } |
| } |
| } |
|
|
| |
| try { |
| execSync(`"${this.cliPath}" --version`, { |
| encoding: 'utf8', |
| timeout: 10000, |
| env: { ...process.env }, |
| }); |
| 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' }; |
| } |
|
|
| |
| |
| |
| async detectInstallation(): Promise<InstallationStatus> { |
| const installed = await this.isInstalled(); |
| const version = installed ? await this.getVersion() : undefined; |
| const auth = await this.checkAuth(); |
|
|
| |
| const displayPath = |
| this.useWsl && this.wslCliPath |
| ? `(WSL${this.wslDistribution ? `:${this.wslDistribution}` : ''}) ${this.wslCliPath}` |
| : this.cliPath || undefined; |
|
|
| return { |
| installed, |
| version: version || undefined, |
| path: displayPath, |
| method: this.useWsl ? 'wsl' : 'cli', |
| hasApiKey: !!process.env.CURSOR_API_KEY, |
| authenticated: auth.authenticated, |
| }; |
| } |
|
|
| |
| |
| |
| getCliPath(): string | null { |
| this.ensureCliDetected(); |
| return this.cliPath; |
| } |
|
|
| |
| |
| |
| getAvailableModels(): ModelDefinition[] { |
| return Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({ |
| id: `cursor-${id}`, |
| name: config.label, |
| modelString: id, |
| provider: 'cursor', |
| description: config.description, |
| supportsTools: true, |
| supportsVision: config.supportsVision, |
| })); |
| } |
|
|
| |
| |
| |
| supportsFeature(feature: string): boolean { |
| const supported = ['tools', 'text', 'streaming']; |
| return supported.includes(feature); |
| } |
| } |
|
|