diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts new file mode 100644 index 00000000..ecbb6602 --- /dev/null +++ b/src/commands/cursor-command.ts @@ -0,0 +1,238 @@ +/** + * Cursor CLI Command + * + * Handles `ccs cursor ` commands. + */ + +import { + autoDetectTokens, + checkAuthStatus, + startDaemon, + stopDaemon, + getDaemonStatus, + getAvailableModels, +} from '../cursor'; +import { ok, fail, info, color } from '../utils/ui'; + +// Temporary default config until #521 adds cursor to unified config +const DEFAULT_CURSOR_CONFIG = { + port: 4242, + model: 'gpt-4.1', +}; + +/** + * Handle cursor subcommand. + */ +export async function handleCursorCommand(args: string[]): Promise { + const subcommand = args[0]; + + switch (subcommand) { + case 'auth': + return handleAuth(); + case 'status': + return handleStatus(); + case 'models': + return handleModels(); + case 'start': + return handleStart(); + case 'stop': + return handleStop(); + case undefined: + case 'help': + case '--help': + case '-h': + return handleHelp(); + default: + console.error(fail(`Unknown subcommand: ${subcommand}`)); + console.error(''); + return handleHelp(); + } +} + +/** + * Show help for cursor commands. + */ +function handleHelp(): number { + console.log('Cursor IDE Integration'); + console.log(''); + console.log('Usage: ccs cursor '); + console.log(''); + console.log('Subcommands:'); + console.log(' auth Import Cursor IDE authentication token'); + console.log(' status Show authentication and daemon status'); + console.log(' models List available models'); + console.log(' start Start cursor daemon'); + console.log(' stop Stop cursor daemon'); + console.log(' help Show this help message'); + console.log(''); + console.log('Quick start:'); + console.log(' 1. ccs cursor auth # Import Cursor IDE token'); + console.log(' 2. ccs cursor start # Start daemon'); + console.log(' 3. Use cursor models # Via daemon on configured port'); + console.log(''); + console.log('Or use the web UI: ccs config → Cursor tab'); + console.log(''); + return 0; +} + +/** + * Handle auth subcommand. + */ +async function handleAuth(): Promise { + console.log(info('Importing Cursor IDE authentication...')); + console.log(''); + + // Try auto-detection first + console.log(info('Attempting auto-detection...')); + const autoResult = await autoDetectTokens(); + + if (autoResult.found && autoResult.accessToken && autoResult.machineId) { + console.log(ok('Auto-detected Cursor credentials')); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Start daemon: ccs cursor start'); + console.log(' 2. Check status: ccs cursor status'); + return 0; + } + + // Fall back to manual import + console.log(''); + console.log('Auto-detection failed. Please provide credentials manually.'); + console.log(''); + console.log('To find your Cursor credentials:'); + console.log(' 1. Open Cursor IDE'); + console.log(' 2. Check application data directory'); + console.log(' 3. Look for access token and machine ID'); + console.log(''); + + // For now, just show instructions + // Manual import flow will be implemented when needed + console.error(fail('Manual import not yet implemented')); + console.error(''); + console.error('Use auto-detection for now or wait for manual import feature.'); + + return 1; +} + +/** + * Handle status subcommand. + */ +async function handleStatus(): Promise { + // TODO: Load from unified config when #521 is complete + const cursorConfig = DEFAULT_CURSOR_CONFIG; + + const authStatus = await checkAuthStatus(); + const daemonStatus = await getDaemonStatus(cursorConfig.port); + + console.log('Cursor IDE Status'); + console.log('─────────────────'); + console.log(''); + + // Auth status + const authIcon = authStatus.authenticated ? color('[OK]', 'success') : color('[X]', 'error'); + const authText = authStatus.authenticated ? 'Authenticated' : 'Not authenticated'; + console.log(`Authentication: ${authIcon} ${authText}`); + + if (authStatus.authenticated && authStatus.tokenAge !== undefined) { + console.log(` Token age: ${authStatus.tokenAge.toFixed(1)} hours`); + } + + // Daemon status + const daemonIcon = daemonStatus.running ? color('[OK]', 'success') : color('[X]', 'error'); + const daemonText = daemonStatus.running ? 'Running' : 'Not running'; + console.log(`Daemon: ${daemonIcon} ${daemonText}`); + + if (daemonStatus.pid) { + console.log(` PID: ${daemonStatus.pid}`); + } + + console.log(''); + console.log('Configuration:'); + console.log(` Port: ${cursorConfig.port}`); + console.log(` Model: ${cursorConfig.model}`); + + console.log(''); + + // Show next steps if not fully configured + if (!authStatus.authenticated || !daemonStatus.running) { + console.log('Next steps:'); + if (!authStatus.authenticated) { + console.log(' - Auth: ccs cursor auth'); + } + if (!daemonStatus.running) { + console.log(' - Start: ccs cursor start'); + } + } + + return 0; +} + +/** + * Handle models subcommand. + */ +async function handleModels(): Promise { + // TODO: Load from unified config when #521 is complete + const cursorConfig = DEFAULT_CURSOR_CONFIG; + + console.log('Available Cursor Models'); + console.log('───────────────────────'); + console.log(''); + + const models = await getAvailableModels(cursorConfig.port); + + for (const model of models) { + const current = model.id === cursorConfig.model ? ' [CURRENT]' : ''; + const defaultMark = model.isDefault ? ' (default)' : ''; + console.log(` ${model.id}${current}${defaultMark}`); + console.log(` Provider: ${model.provider}`); + } + + console.log(''); + console.log('To change model: ccs config (Cursor section)'); + + return 0; +} + +/** + * Handle start subcommand. + */ +async function handleStart(): Promise { + // TODO: Load from unified config when #521 is complete + const cursorConfig = DEFAULT_CURSOR_CONFIG; + + // Check auth first + const authStatus = await checkAuthStatus(); + if (!authStatus.authenticated) { + console.error(fail('Not authenticated. Run: ccs cursor auth')); + return 1; + } + + console.log(info(`Starting cursor daemon on port ${cursorConfig.port}...`)); + + const result = await startDaemon(cursorConfig); + + if (result.success) { + console.log(ok(`Daemon started (PID: ${result.pid})`)); + return 0; + } else { + console.error(fail(result.error || 'Failed to start daemon')); + return 1; + } +} + +/** + * Handle stop subcommand. + */ +async function handleStop(): Promise { + console.log(info('Stopping cursor daemon...')); + + const result = await stopDaemon(); + + if (result.success) { + console.log(ok('Daemon stopped')); + return 0; + } else { + console.error(fail(result.error || 'Failed to stop daemon')); + return 1; + } +} diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts new file mode 100644 index 00000000..0166be41 --- /dev/null +++ b/src/cursor/cursor-daemon.ts @@ -0,0 +1,253 @@ +/** + * Cursor Daemon Manager + * + * Manages the cursor daemon lifecycle (start/stop/status). + * Uses CursorExecutor for OpenAI-compatible API proxy to Cursor backend. + */ + +import { spawn, ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as http from 'http'; +import type { CursorDaemonStatus } from './types'; +import { getCcsDir } from '../utils/config-manager'; + +// Temporary interface until #521 adds cursor to unified config +interface CursorConfig { + port: number; + model: string; +} + +/** + * Get Cursor directory path. + */ +function getCursorDir(): string { + return path.join(getCcsDir(), 'cursor'); +} + +const PID_FILE = path.join(getCursorDir(), 'daemon.pid'); + +/** + * Check if cursor daemon is running on the specified port. + * Uses 127.0.0.1 instead of localhost for more reliable local connections. + */ +export async function isDaemonRunning(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/health', + method: 'GET', + timeout: 3000, + }, + (res) => { + resolve(res.statusCode === 200); + } + ); + + req.on('error', () => { + resolve(false); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); +} + +/** + * Get daemon status. + */ +export async function getDaemonStatus(port: number): Promise { + const running = await isDaemonRunning(port); + const pid = getPidFromFile(); + + return { + running, + port, + pid: running ? (pid ?? undefined) : undefined, + }; +} + +/** + * Read PID from file. + */ +function getPidFromFile(): number | null { + try { + if (fs.existsSync(PID_FILE)) { + const content = fs.readFileSync(PID_FILE, 'utf8').trim(); + const pid = parseInt(content, 10); + return isNaN(pid) ? null : pid; + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Write PID to file. + */ +function writePidToFile(pid: number): void { + try { + const dir = path.dirname(PID_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(PID_FILE, pid.toString(), { mode: 0o600 }); + } catch { + // Ignore errors + } +} + +/** + * Remove PID file. + */ +function removePidFile(): void { + try { + if (fs.existsSync(PID_FILE)) { + fs.unlinkSync(PID_FILE); + } + } catch { + // Ignore errors + } +} + +/** + * Start the cursor daemon. + * + * @param config Cursor configuration + * @returns Promise that resolves when daemon is ready + */ +export async function startDaemon( + config: CursorConfig +): Promise<{ success: boolean; pid?: number; error?: string }> { + // Check if already running + if (await isDaemonRunning(config.port)) { + return { success: true, pid: getPidFromFile() ?? undefined }; + } + + // For now, create a simple structure that will be filled in later + // The actual server implementation will be added in a separate task + return new Promise((resolve) => { + let proc: ChildProcess; + + try { + // Spawn a placeholder Node.js process + // TODO: Replace with actual CursorExecutor-based server + const args = [ + '-e', + ` + const http = require('http'); + const server = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200); + res.end('OK'); + } else if (req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ data: [] })); + } else { + res.writeHead(404); + res.end('Not found'); + } + }); + server.listen(${config.port}, '127.0.0.1'); + `, + ]; + + proc = spawn('node', args, { + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + shell: process.platform === 'win32', + }); + + // Unref so parent can exit + proc.unref(); + + if (proc.pid) { + writePidToFile(proc.pid); + } + + // Wait for daemon to be ready (poll for up to 30 seconds) + let attempts = 0; + const maxAttempts = 30; + const checkInterval = setInterval(async () => { + attempts++; + + if (await isDaemonRunning(config.port)) { + clearInterval(checkInterval); + resolve({ success: true, pid: proc.pid }); + } else if (attempts >= maxAttempts) { + clearInterval(checkInterval); + resolve({ + success: false, + error: 'Daemon did not start within 30 seconds', + }); + } + }, 1000); + + proc.on('error', (err) => { + clearInterval(checkInterval); + resolve({ + success: false, + error: `Failed to start daemon: ${err.message}`, + }); + }); + } catch (err) { + resolve({ + success: false, + error: `Failed to spawn daemon: ${(err as Error).message}`, + }); + } + }); +} + +/** + * Stop the cursor daemon. + */ +export async function stopDaemon(): Promise<{ success: boolean; error?: string }> { + const pid = getPidFromFile(); + + if (!pid) { + // No PID file, try to find by port + removePidFile(); + return { success: true }; + } + + try { + // Send SIGTERM to the process + process.kill(pid, 'SIGTERM'); + + // Wait for process to exit (up to 5 seconds) + let attempts = 0; + while (attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 500)); + try { + // Check if process still exists (kill(pid, 0) throws if not) + process.kill(pid, 0); + attempts++; + } catch { + // Process no longer exists + break; + } + } + + removePidFile(); + return { success: true }; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ESRCH') { + // Process doesn't exist + removePidFile(); + return { success: true }; + } + return { + success: false, + error: `Failed to stop daemon: ${error.message}`, + }; + } +} diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts new file mode 100644 index 00000000..4735e4c5 --- /dev/null +++ b/src/cursor/cursor-models.ts @@ -0,0 +1,166 @@ +/** + * Cursor Model Catalog + * + * Manages available models from Cursor IDE. + * Based on Cursor's supported models as of Feb 2025. + */ + +import * as http from 'http'; +import type { CursorModel } from './types'; + +/** + * Default models available through Cursor IDE. + * Used as fallback when daemon is not reachable. + * Source: Cursor IDE supported models (Feb 2025) + */ +export const DEFAULT_CURSOR_MODELS: CursorModel[] = [ + // Anthropic Models + { + id: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + provider: 'anthropic', + }, + { + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5', + provider: 'anthropic', + }, + { + id: 'claude-opus-4', + name: 'Claude Opus 4', + provider: 'anthropic', + }, + + // OpenAI Models + { + id: 'gpt-4.1', + name: 'GPT-4.1', + provider: 'openai', + isDefault: true, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + provider: 'openai', + }, + { + id: 'o3-mini', + name: 'O3 Mini', + provider: 'openai', + }, + + // Google Models + { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + provider: 'google', + }, + + // Cursor Custom Models + { + id: 'cursor-small', + name: 'Cursor Small', + provider: 'cursor', + }, +]; + +/** + * Fetch available models from running cursor daemon. + * + * @param port The port cursor daemon is running on + * @returns List of available models + */ +export async function fetchModelsFromDaemon(port: number): Promise { + return new Promise((resolve) => { + const req = http.request( + { + // Use 127.0.0.1 instead of localhost for more reliable local connections + hostname: '127.0.0.1', + port, + path: '/v1/models', + method: 'GET', + timeout: 5000, + }, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('end', () => { + try { + const response = JSON.parse(data) as { data?: Array<{ id: string }> }; + if (response.data && Array.isArray(response.data)) { + const models: CursorModel[] = response.data.map((m) => ({ + id: m.id, + name: formatModelName(m.id), + provider: detectProvider(m.id), + isDefault: m.id === 'gpt-4.1', + })); + resolve(models.length > 0 ? models : DEFAULT_CURSOR_MODELS); + } else { + resolve(DEFAULT_CURSOR_MODELS); + } + } catch { + resolve(DEFAULT_CURSOR_MODELS); + } + }); + } + ); + + req.on('error', () => { + resolve(DEFAULT_CURSOR_MODELS); + }); + + req.on('timeout', () => { + req.destroy(); + resolve(DEFAULT_CURSOR_MODELS); + }); + + req.end(); + }); +} + +/** + * Get available models (from daemon or defaults). + */ +export async function getAvailableModels(port: number): Promise { + return fetchModelsFromDaemon(port); +} + +/** + * Get the default model. + * Uses gpt-4.1 as it's commonly available. + */ +export function getDefaultModel(): string { + return 'gpt-4.1'; +} + +/** + * Detect provider from model ID. + */ +function detectProvider(modelId: string): string { + if (modelId.includes('claude')) return 'anthropic'; + if (modelId.includes('gpt') || modelId.includes('o3')) return 'openai'; + if (modelId.includes('gemini')) return 'google'; + if (modelId.includes('cursor')) return 'cursor'; + return 'openai'; +} + +/** + * Format model ID to human-readable name. + */ +function formatModelName(modelId: string): string { + // Find model in catalog for metadata + const model = DEFAULT_CURSOR_MODELS.find((m) => m.id === modelId); + if (model) { + return model.name; + } + + // Fallback: convert kebab-case to title case + return modelId + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} diff --git a/src/cursor/index.ts b/src/cursor/index.ts new file mode 100644 index 00000000..014cf84d --- /dev/null +++ b/src/cursor/index.ts @@ -0,0 +1,30 @@ +/** + * Cursor Module Index + * + * Central exports for Cursor IDE integration. + */ + +// Types +export * from './types'; + +// Auth +export { + autoDetectTokens, + saveCredentials, + loadCredentials, + checkAuthStatus, +} from './cursor-auth'; + +// Daemon +export { isDaemonRunning, getDaemonStatus, startDaemon, stopDaemon } from './cursor-daemon'; + +// Models +export { + DEFAULT_CURSOR_MODELS, + fetchModelsFromDaemon, + getAvailableModels, + getDefaultModel, +} from './cursor-models'; + +// Executor +export { CursorExecutor } from './cursor-executor';