mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
- Added CursorConfigManager to manage Cursor CLI configuration, including loading, saving, and resetting settings. - Introduced CursorProvider to integrate the cursor-agent CLI, handling installation checks, authentication, and query execution. - Enhanced error handling with detailed CursorError codes for better debugging and user feedback. - Updated documentation to reflect the completion of the Cursor Provider implementation phase.
851 lines
23 KiB
Markdown
851 lines
23 KiB
Markdown
# Phase 2: Cursor Provider Implementation
|
|
|
|
**Status:** `completed`
|
|
**Dependencies:** Phase 1 (Types)
|
|
**Estimated Effort:** Medium-Large (core implementation)
|
|
|
|
---
|
|
|
|
## Objective
|
|
|
|
Implement the main `CursorProvider` class that spawns the cursor-agent CLI and streams responses in the AutoMaker provider format.
|
|
|
|
---
|
|
|
|
## Tasks
|
|
|
|
### Task 2.1: Create Cursor Provider
|
|
|
|
**Status:** `completed`
|
|
|
|
**File:** `apps/server/src/providers/cursor-provider.ts`
|
|
|
|
```typescript
|
|
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';
|
|
import {
|
|
ProviderConfig,
|
|
ExecuteOptions,
|
|
ProviderMessage,
|
|
InstallationStatus,
|
|
ModelDefinition,
|
|
} from './types';
|
|
import {
|
|
CursorModelId,
|
|
CursorStreamEvent,
|
|
CursorSystemEvent,
|
|
CursorAssistantEvent,
|
|
CursorToolCallEvent,
|
|
CursorResultEvent,
|
|
CURSOR_MODEL_MAP,
|
|
CursorAuthStatus,
|
|
} 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 {
|
|
private static CLI_NAME = 'cursor-agent';
|
|
|
|
/**
|
|
* Installation paths based on official cursor-agent install script:
|
|
*
|
|
* Linux/macOS:
|
|
* - Binary: ~/.local/share/cursor-agent/versions/<version>/cursor-agent
|
|
* - Symlink: ~/.local/bin/cursor-agent -> versions/<version>/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<string, string[]> = {
|
|
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;
|
|
private currentProcess: ChildProcess | 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)) {
|
|
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)) {
|
|
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)) {
|
|
return versionPath;
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore directory read errors
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if Cursor CLI is installed
|
|
*/
|
|
async isInstalled(): Promise<boolean> {
|
|
return this.cliPath !== null;
|
|
}
|
|
|
|
/**
|
|
* Get Cursor CLI version
|
|
*/
|
|
async getVersion(): Promise<string | null> {
|
|
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<CursorAuthStatus> {
|
|
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: any) {
|
|
if (error.stderr?.includes('not authenticated') || error.stderr?.includes('log in')) {
|
|
return { authenticated: false, method: 'none' };
|
|
}
|
|
}
|
|
|
|
return { authenticated: false, method: 'none' };
|
|
}
|
|
|
|
/**
|
|
* Detect installation status (required by BaseProvider)
|
|
*/
|
|
async detectInstallation(): Promise<InstallationStatus> {
|
|
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' : 'basic',
|
|
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
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse a line of stream-json output
|
|
*/
|
|
private parseStreamLine(line: string): CursorStreamEvent | null {
|
|
if (!line.trim()) return null;
|
|
|
|
try {
|
|
return JSON.parse(line) as CursorStreamEvent;
|
|
} catch {
|
|
logger.debug('[CursorProvider] Failed to parse stream line:', line);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<ProviderMessage> {
|
|
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(`[CursorProvider] 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
|
|
const subprocessOptions: SubprocessOptions = {
|
|
command: this.cliPath,
|
|
args,
|
|
cwd,
|
|
env: { ...process.env },
|
|
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;
|
|
}
|
|
|
|
// 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)) {
|
|
return; // Clean abort, don't throw
|
|
}
|
|
|
|
// Map CLI errors to CursorError
|
|
if (error instanceof Error && 'stderr' in error) {
|
|
throw this.mapError((error as any).stderr || error.message, (error as any).exitCode);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Abort the current execution
|
|
*/
|
|
abort(): void {
|
|
if (this.currentProcess) {
|
|
this.currentProcess.kill('SIGTERM');
|
|
this.currentProcess = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a feature is supported
|
|
*/
|
|
supportsFeature(feature: string): boolean {
|
|
const supported = ['tools', 'text', 'streaming'];
|
|
return supported.includes(feature);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Task 2.2: Create Cursor Config Manager
|
|
|
|
**Status:** `completed`
|
|
|
|
**File:** `apps/server/src/providers/cursor-config-manager.ts`
|
|
|
|
```typescript
|
|
import * as path from 'path';
|
|
import { CursorCliConfig, CursorModelId } from '@automaker/types';
|
|
import { createLogger, mkdirSafe, existsSafe } from '@automaker/utils';
|
|
import { getAutomakerDir } from '@automaker/platform';
|
|
import { secureFs } 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();
|
|
}
|
|
|
|
private loadConfig(): CursorCliConfig {
|
|
try {
|
|
if (fs.existsSync(this.configPath)) {
|
|
const content = fs.readFileSync(this.configPath, 'utf8');
|
|
return JSON.parse(content);
|
|
}
|
|
} catch (error) {
|
|
logger.warn('[CursorConfigManager] Failed to load config:', error);
|
|
}
|
|
|
|
// Return default config
|
|
return {
|
|
defaultModel: 'auto',
|
|
models: ['auto', 'claude-sonnet-4', 'gpt-4o-mini'],
|
|
};
|
|
}
|
|
|
|
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('[CursorConfigManager] Config saved');
|
|
} catch (error) {
|
|
logger.error('[CursorConfigManager] Failed to save config:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
getConfig(): CursorCliConfig {
|
|
return { ...this.config };
|
|
}
|
|
|
|
getDefaultModel(): CursorModelId {
|
|
return this.config.defaultModel || 'auto';
|
|
}
|
|
|
|
setDefaultModel(model: CursorModelId): void {
|
|
this.config.defaultModel = model;
|
|
this.saveConfig();
|
|
}
|
|
|
|
getEnabledModels(): CursorModelId[] {
|
|
return this.config.models || ['auto'];
|
|
}
|
|
|
|
setEnabledModels(models: CursorModelId[]): void {
|
|
this.config.models = models;
|
|
this.saveConfig();
|
|
}
|
|
|
|
addModel(model: CursorModelId): void {
|
|
if (!this.config.models) {
|
|
this.config.models = [];
|
|
}
|
|
if (!this.config.models.includes(model)) {
|
|
this.config.models.push(model);
|
|
this.saveConfig();
|
|
}
|
|
}
|
|
|
|
removeModel(model: CursorModelId): void {
|
|
if (this.config.models) {
|
|
this.config.models = this.config.models.filter((m) => m !== model);
|
|
this.saveConfig();
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Verification
|
|
|
|
### Test 1: Provider Instantiation
|
|
|
|
```typescript
|
|
// test-cursor-provider.ts
|
|
import { CursorProvider } from './apps/server/src/providers/cursor-provider';
|
|
|
|
const provider = new CursorProvider();
|
|
console.log('Provider name:', provider.getName()); // Should be 'cursor'
|
|
|
|
const status = await provider.detectInstallation();
|
|
console.log('Installation status:', status);
|
|
|
|
const models = provider.getAvailableModels();
|
|
console.log('Available models:', models.length);
|
|
```
|
|
|
|
### Test 2: CLI Detection (requires cursor-agent installed)
|
|
|
|
```bash
|
|
# Check if cursor-agent is found
|
|
node -e "
|
|
const { CursorProvider } = require('./apps/server/dist/providers/cursor-provider');
|
|
const p = new CursorProvider();
|
|
p.isInstalled().then(installed => {
|
|
console.log('Installed:', installed);
|
|
if (installed) {
|
|
p.getVersion().then(v => console.log('Version:', v));
|
|
p.checkAuth().then(a => console.log('Auth:', a));
|
|
}
|
|
});
|
|
"
|
|
```
|
|
|
|
### Test 3: Simple Query (requires cursor-agent authenticated)
|
|
|
|
```typescript
|
|
// test-cursor-query.ts
|
|
import { CursorProvider } from './apps/server/src/providers/cursor-provider';
|
|
|
|
const provider = new CursorProvider();
|
|
const stream = provider.executeQuery({
|
|
prompt: 'What is 2 + 2? Reply with just the number.',
|
|
model: 'auto',
|
|
cwd: process.cwd(),
|
|
});
|
|
|
|
for await (const msg of stream) {
|
|
console.log('Message:', JSON.stringify(msg, null, 2));
|
|
}
|
|
```
|
|
|
|
### Test 4: Error Handling
|
|
|
|
```typescript
|
|
// Test with invalid model
|
|
try {
|
|
const stream = provider.executeQuery({
|
|
prompt: 'test',
|
|
model: 'invalid-model-xyz',
|
|
cwd: process.cwd(),
|
|
});
|
|
for await (const msg of stream) {
|
|
// Should not reach here
|
|
}
|
|
} catch (error) {
|
|
console.log('Error code:', error.code);
|
|
console.log('Suggestion:', error.suggestion);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Verification Checklist
|
|
|
|
Before marking this phase complete:
|
|
|
|
- [ ] `cursor-provider.ts` compiles without errors
|
|
- [ ] `cursor-config-manager.ts` compiles without errors
|
|
- [ ] Provider returns correct name ('cursor')
|
|
- [ ] `detectInstallation()` correctly detects CLI
|
|
- [ ] `getAvailableModels()` returns model definitions
|
|
- [ ] `executeQuery()` streams messages (if CLI installed)
|
|
- [ ] Errors are properly mapped to CursorError
|
|
- [ ] Abort signal terminates process
|
|
|
|
---
|
|
|
|
## Files Changed
|
|
|
|
| File | Action | Description |
|
|
| ---------------------------------------------------- | ------ | ----------------- |
|
|
| `apps/server/src/providers/cursor-provider.ts` | Create | Main provider |
|
|
| `apps/server/src/providers/cursor-config-manager.ts` | Create | Config management |
|
|
|
|
---
|
|
|
|
## Known Limitations
|
|
|
|
1. **Windows Support**: CLI path detection may need adjustment
|
|
2. **Vision**: Cursor CLI may not support image inputs
|
|
3. **Resume**: Session resumption not implemented in Phase 2
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- The provider uses `--stream-partial-output` for real-time character streaming
|
|
- Tool call events are normalized to match Claude SDK format
|
|
- Session IDs are captured from system init event
|