Files
automaker/plan/cursor-cli-integration/phases/phase-2-provider.md
Kacper 81f35ad6aa chore: Add Cursor CLI integration plan and phases
- Introduced a comprehensive integration plan for the Cursor CLI, including detailed phases for implementation.
- Created initial markdown files for each phase, outlining objectives, tasks, and verification steps.
- Established a global prompt template for starting new sessions with the Cursor CLI.
- Added necessary types and configuration for Cursor models and their integration into the AutoMaker architecture.
- Implemented routing logic to ensure proper model handling between Cursor and Claude providers.
- Developed UI components for setup and settings management related to Cursor integration.
- Included extensive testing and validation plans to ensure robust functionality across all scenarios.
2025-12-27 23:50:17 +01:00

23 KiB

Phase 2: Cursor Provider Implementation

Status: pending 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: pending

File: apps/server/src/providers/cursor-provider.ts

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: pending

File: apps/server/src/providers/cursor-config-manager.ts

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

// 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)

# 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)

// 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

// 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