diff --git a/CLAUDE.md b/CLAUDE.md index a8aaa564..715f18d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs cliproxy --help` | `src/commands/cliproxy-command.ts` → `showHelp()` | | `ccs config --help` | `src/commands/config-command.ts` → `showHelp()` | | `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` | +| `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` | | `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` | | `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` | | `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | diff --git a/package.json b/package.json index 4b1dcab9..554eeb92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.43.0-dev.2", + "version": "7.43.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/ccs.ts b/src/ccs.ts index 4404712e..c12e35b3 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -532,6 +532,14 @@ async function main(): Promise { return; } + // Special case: cursor command (Cursor IDE integration) + // All `ccs cursor *` routes to cursor command handler — cursor has no profile-switching mode + if (firstArg === 'cursor') { + const { handleCursorCommand } = await import('./commands/cursor-command'); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + // Special case: copilot command (GitHub Copilot integration) // Only route to command handler for known subcommands, otherwise treat as profile const COPILOT_SUBCOMMANDS = [ @@ -553,15 +561,6 @@ async function main(): Promise { process.exit(exitCode); } - // Special case: cursor command (Cursor IDE integration) - // Route all cursor args to handler — handler deals with unknown subcommands - // Note: cursor does not have enable/disable — it uses daemon start/stop instead - if (firstArg === 'cursor') { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - // Special case: headless delegation (-p flag) if (args.includes('-p') || args.includes('--prompt')) { const { DelegationHandler } = await import('./delegation/delegation-handler'); diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 56c73412..ad6ce59a 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -1,15 +1,264 @@ /** - * Cursor Command Handler - Cursor IDE integration commands - * This is a stub file - the actual implementation is in task #520 + * Cursor CLI Command + * + * Handles `ccs cursor ` commands. */ +import { + autoDetectTokens, + saveCredentials, + checkAuthStatus, + startDaemon, + stopDaemon, + getDaemonStatus, + getAvailableModels, + DEFAULT_CURSOR_PORT, + DEFAULT_CURSOR_MODEL, +} 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: DEFAULT_CURSOR_PORT, + model: DEFAULT_CURSOR_MODEL, +}; + +/** Valid cursor subcommands — imported by ccs.ts for routing */ +export const CURSOR_SUBCOMMANDS = [ + 'auth', + 'status', + 'models', + 'start', + 'stop', + 'help', + '--help', + '-h', +] as const; + /** - * Handle cursor command routing - * @param _args - Command arguments (unused in stub) - * @returns Exit code + * Handle cursor subcommand. */ -export async function handleCursorCommand(_args: string[]): Promise { - console.error('[!] Cursor command not yet implemented (task #520)'); - console.error(' Full implementation coming in task #520.'); +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(''); + void handleHelp(); // Print help but keep exit code 1 + return 1; + } +} + +/** + * 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 = autoDetectTokens(); + + if (autoResult.found && autoResult.accessToken && autoResult.machineId) { + saveCredentials({ + accessToken: autoResult.accessToken, + machineId: autoResult.machineId, + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }); + 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(''); + if (autoResult.error) { + console.log(`Auto-detection failed: ${autoResult.error}`); + } else { + 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 = 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} 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 = 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..99fef7b2 --- /dev/null +++ b/src/cursor/cursor-daemon.ts @@ -0,0 +1,320 @@ +/** + * 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 { CursorConfig, CursorDaemonStatus } from './types'; +import { getCcsDir } from '../utils/config-manager'; + +/** + * Get Cursor directory path. + */ +function getCursorDir(): string { + return path.join(getCcsDir(), 'cursor'); +} + +/** + * Get PID file path. + * Computed at runtime to respect CCS_HOME changes (e.g., in tests). + */ +function getPidFilePath(): string { + return 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) => { + res.resume(); // Drain response body + 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. + */ +export function getPidFromFile(): number | null { + const pidFile = getPidFilePath(); + try { + if (fs.existsSync(pidFile)) { + const content = fs.readFileSync(pidFile, 'utf8').trim(); + const pid = parseInt(content, 10); + return isNaN(pid) ? null : pid; + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Write PID to file. + */ +export function writePidToFile(pid: number): void { + const pidFile = getPidFilePath(); + try { + const dir = path.dirname(pidFile); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(pidFile, pid.toString(), { mode: 0o600 }); + } catch { + // Ignore errors + } +} + +/** + * Remove PID file. + */ +export function removePidFile(): void { + const pidFile = getPidFilePath(); + try { + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + } 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 }; + } + + // Validate port before interpolation (prevents injection) + if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) { + return { success: false, error: `Invalid port: ${config.port}` }; + } + + // 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; + let resolved = false; + + const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => { + if (resolved) return; + resolved = true; + if (checkTimeout) clearTimeout(checkTimeout); + if (!result.success) removePidFile(); + resolve(result); + }; + + let checkTimeout: NodeJS.Timeout | null = null; + + 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'); + `, + ]; + + // Append --ccs-daemon marker for PID validation in stopDaemon + proc = spawn(process.execPath, [...args, '--ccs-daemon'], { + stdio: 'ignore', + detached: true, + }); + + // 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 pollHealth = async () => { + attempts++; + + if (await isDaemonRunning(config.port)) { + safeResolve({ success: true, pid: proc.pid }); + } else if (attempts >= maxAttempts) { + // Kill orphaned process + if (proc.pid) { + try { + process.kill(proc.pid, 'SIGTERM'); + } catch { + /* already dead */ + } + } + safeResolve({ + success: false, + error: 'Daemon did not start within 30 seconds', + }); + } else { + checkTimeout = setTimeout(pollHealth, 1000); + } + }; + checkTimeout = setTimeout(pollHealth, 1000); + + proc.on('error', (err) => { + safeResolve({ + success: false, + error: `Failed to start daemon: ${err.message}`, + }); + }); + + proc.on('exit', (code, signal) => { + if (code === null) { + safeResolve({ + success: false, + error: `Daemon process was killed by signal ${signal}`, + }); + } else if (code === 0) { + safeResolve({ + success: false, + error: 'Daemon process exited unexpectedly with code 0', + }); + } else { + safeResolve({ + success: false, + error: `Daemon process exited with code ${code}`, + }); + } + }); + } catch (err) { + safeResolve({ + 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 — daemon is not running or was already stopped + return { success: true }; + } + + try { + // Verify the PID belongs to our daemon before signaling + try { + const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + if (!cmdline.includes('--ccs-daemon')) { + // PID was reused by an unrelated process + removePidFile(); + return { success: true }; + } + } catch { + // /proc not available (macOS/Windows) or process gone — proceed with kill + } + + // 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; + } + } + + // Escalate to SIGKILL only if SIGTERM wait loop exhausted + if (attempts >= 10) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already dead — good + } + } + + 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..cc74cb1e --- /dev/null +++ b/src/cursor/cursor-models.ts @@ -0,0 +1,189 @@ +/** + * Cursor Model Catalog + * + * Manages available models from Cursor IDE. + * Based on Cursor's supported models catalog. + */ + +import * as http from 'http'; +import type { CursorModel } from './types'; +import { isDaemonRunning } from './cursor-daemon'; + +/** Default daemon port */ +export const DEFAULT_CURSOR_PORT = 4242; + +/** Default model ID */ +export const DEFAULT_CURSOR_MODEL = 'gpt-4.1'; + +/** + * 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) => { + let resolved = false; + const safeResolve = (models: CursorModel[]) => { + if (resolved) return; + resolved = true; + resolve(models); + }; + + 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) => { + const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + if (data.length > MAX_BODY_SIZE) { + req.destroy(); + safeResolve(DEFAULT_CURSOR_MODELS); + } + }); + + 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 === DEFAULT_CURSOR_MODEL, + })); + safeResolve(models.length > 0 ? models : DEFAULT_CURSOR_MODELS); + } else { + safeResolve(DEFAULT_CURSOR_MODELS); + } + } catch { + safeResolve(DEFAULT_CURSOR_MODELS); + } + }); + } + ); + + req.on('error', () => { + safeResolve(DEFAULT_CURSOR_MODELS); + }); + + req.on('timeout', () => { + req.destroy(); + safeResolve(DEFAULT_CURSOR_MODELS); + }); + + req.end(); + }); +} + +/** + * Get available models (from daemon or defaults). + * Checks daemon health first to avoid 5s timeout when daemon is not running. + */ +export async function getAvailableModels(port: number): Promise { + if (!(await isDaemonRunning(port))) { + return DEFAULT_CURSOR_MODELS; + } + return fetchModelsFromDaemon(port); +} + +/** + * Get the default model. + * Uses gpt-4.1 as it's commonly available. + */ +export function getDefaultModel(): string { + return DEFAULT_CURSOR_MODEL; +} + +/** + * Detect provider from model ID. + */ +export function detectProvider(modelId: string): string { + if (modelId.includes('claude')) return 'anthropic'; + if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai'; + if (modelId.includes('gemini')) return 'google'; + if (modelId.includes('cursor')) return 'cursor'; + return 'unknown'; +} + +/** + * Format model ID to human-readable name. + */ +export 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..3647a612 --- /dev/null +++ b/src/cursor/index.ts @@ -0,0 +1,37 @@ +/** + * 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, + getPidFromFile, + writePidToFile, + removePidFile, +} from './cursor-daemon'; + +// Models +export { + DEFAULT_CURSOR_MODELS, + DEFAULT_CURSOR_PORT, + DEFAULT_CURSOR_MODEL, + fetchModelsFromDaemon, + getAvailableModels, + getDefaultModel, + detectProvider, + formatModelName, +} from './cursor-models'; + +// Executor +export { CursorExecutor } from './cursor-executor'; diff --git a/src/cursor/types.ts b/src/cursor/types.ts index 9418fe70..c16150cc 100644 --- a/src/cursor/types.ts +++ b/src/cursor/types.ts @@ -1,9 +1,18 @@ /** * Cursor IDE Type Definitions * - * TypeScript interfaces for the Cursor auth module. + * TypeScript interfaces for the Cursor module. */ +/** + * Cursor daemon configuration. + * Temporary interface until #521 adds cursor to unified config. + */ +export interface CursorConfig { + port: number; + model: string; +} + /** * Cursor authentication credentials */ @@ -49,3 +58,29 @@ export interface AutoDetectResult { /** Error message (if detection failed) */ error?: string; } + +/** + * Cursor daemon/process status + */ +export interface CursorDaemonStatus { + /** Whether daemon is running */ + running: boolean; + /** Port number daemon is listening on */ + port: number; + /** Process ID (if available) */ + pid?: number; +} + +/** + * Cursor AI model + */ +export interface CursorModel { + /** Model ID */ + id: string; + /** Display name */ + name: string; + /** Provider (e.g., 'openai', 'anthropic') */ + provider: string; + /** Whether this is the default model */ + isDefault?: boolean; +} diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts new file mode 100644 index 00000000..e6480b9d --- /dev/null +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -0,0 +1,215 @@ +/** + * Unit tests for Cursor daemon module + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + getPidFromFile, + writePidToFile, + removePidFile, + isDaemonRunning, + getDaemonStatus, + stopDaemon, + startDaemon, +} from '../../../src/cursor/cursor-daemon'; +import { getCcsDir } from '../../../src/utils/config-manager'; +import { handleCursorCommand } from '../../../src/commands/cursor-command'; + +// Test isolation +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-daemon-test-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + // Cleanup temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +// Use getCcsDir() for consistent path resolution with production code +const getTestCursorDir = () => path.join(getCcsDir(), 'cursor'); + +describe('getPidFromFile', () => { + it('returns null when no PID file exists', () => { + expect(getPidFromFile()).toBeNull(); + }); + + it('returns PID when valid PID file exists', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), '12345'); + + expect(getPidFromFile()).toBe(12345); + }); + + it('returns null when PID file contains invalid content', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), 'not-a-number'); + + expect(getPidFromFile()).toBeNull(); + }); + + it('trims whitespace from PID file content', () => { + const dir = getTestCursorDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'daemon.pid'), ' 42 \n'); + + expect(getPidFromFile()).toBe(42); + }); +}); + +describe('writePidToFile', () => { + it('creates PID file with correct content', () => { + writePidToFile(12345); + + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(true); + expect(fs.readFileSync(pidFile, 'utf8')).toBe('12345'); + }); + + it('creates cursor directory if it does not exist', () => { + const dir = getTestCursorDir(); + expect(fs.existsSync(dir)).toBe(false); + + writePidToFile(999); + + expect(fs.existsSync(dir)).toBe(true); + }); + + it('overwrites existing PID file', () => { + writePidToFile(111); + writePidToFile(222); + + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.readFileSync(pidFile, 'utf8')).toBe('222'); + }); +}); + +describe('removePidFile', () => { + it('removes existing PID file', () => { + writePidToFile(12345); + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(true); + + removePidFile(); + + expect(fs.existsSync(pidFile)).toBe(false); + }); + + it('does not throw when PID file does not exist', () => { + expect(() => removePidFile()).not.toThrow(); + }); +}); + +describe('startDaemon', () => { + it('rejects invalid port (0)', async () => { + const result = await startDaemon({ port: 0, model: 'test' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it('rejects invalid port (65536)', async () => { + const result = await startDaemon({ port: 65536, model: 'test' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it('rejects non-integer port', async () => { + const result = await startDaemon({ port: 3.14, model: 'test' }); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid port'); + }); + + it( + 'starts and stops daemon successfully', + async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, model: 'test' }); + expect(result.success).toBe(true); + expect(result.pid).toBeDefined(); + + // Verify health + const running = await isDaemonRunning(port); + expect(running).toBe(true); + + // Stop + const stopResult = await stopDaemon(); + expect(stopResult.success).toBe(true); + + // Verify stopped + const stillRunning = await isDaemonRunning(port); + expect(stillRunning).toBe(false); + }, + 35000 + ); +}); + +describe('isDaemonRunning', () => { + it('returns false when no daemon is running on port', async () => { + // Use a port that should not have anything running + const result = await isDaemonRunning(19999); + expect(result).toBe(false); + }); +}); + +describe('getDaemonStatus', () => { + it('returns status with running=false when no daemon running', async () => { + const status = await getDaemonStatus(19999); + expect(status.running).toBe(false); + expect(status.port).toBe(19999); + expect(status.pid).toBeUndefined(); + }); + + it('returns status with pid when PID file exists but daemon not running', async () => { + writePidToFile(99999); + const status = await getDaemonStatus(19999); + expect(status.running).toBe(false); + expect(status.port).toBe(19999); + expect(status.pid).toBeUndefined(); + }); +}); + +describe('stopDaemon', () => { + it('returns success when no PID file exists', async () => { + const result = await stopDaemon(); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('returns success when PID refers to non-existent process', async () => { + // Write a PID that doesn't exist + writePidToFile(999999); + const result = await stopDaemon(); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + + // PID file should be removed + const pidFile = path.join(getTestCursorDir(), 'daemon.pid'); + expect(fs.existsSync(pidFile)).toBe(false); + }); +}); + +describe('handleCursorCommand', () => { + it('returns exit code 1 for unknown subcommand', async () => { + const exitCode = await handleCursorCommand(['nonexistent']); + expect(exitCode).toBe(1); + }); +}); diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts new file mode 100644 index 00000000..f998d2b6 --- /dev/null +++ b/tests/unit/cursor/cursor-models.test.ts @@ -0,0 +1,99 @@ +/** + * Unit tests for Cursor models module + */ + +import { describe, it, expect } from 'bun:test'; +import { + DEFAULT_CURSOR_MODELS, + DEFAULT_CURSOR_PORT, + DEFAULT_CURSOR_MODEL, + getDefaultModel, + detectProvider, + formatModelName, + fetchModelsFromDaemon, +} from '../../../src/cursor/cursor-models'; + +describe('DEFAULT_CURSOR_MODELS', () => { + it('contains models from multiple providers', () => { + const providers = new Set(DEFAULT_CURSOR_MODELS.map((m) => m.provider)); + expect(providers.has('anthropic')).toBe(true); + expect(providers.has('openai')).toBe(true); + expect(providers.has('google')).toBe(true); + }); + + it('has exactly one default model', () => { + const defaults = DEFAULT_CURSOR_MODELS.filter((m) => m.isDefault); + expect(defaults).toHaveLength(1); + expect(defaults[0].id).toBe(DEFAULT_CURSOR_MODEL); + }); +}); + +describe('DEFAULT_CURSOR_PORT', () => { + it('is 4242', () => { + expect(DEFAULT_CURSOR_PORT).toBe(4242); + }); +}); + +describe('DEFAULT_CURSOR_MODEL', () => { + it('is gpt-4.1', () => { + expect(DEFAULT_CURSOR_MODEL).toBe('gpt-4.1'); + }); +}); + +describe('getDefaultModel', () => { + it('returns the default model constant', () => { + expect(getDefaultModel()).toBe(DEFAULT_CURSOR_MODEL); + }); +}); + +describe('detectProvider', () => { + it('detects anthropic models', () => { + expect(detectProvider('claude-sonnet-4')).toBe('anthropic'); + expect(detectProvider('claude-opus-4')).toBe('anthropic'); + }); + + it('detects openai models', () => { + expect(detectProvider('gpt-4.1')).toBe('openai'); + expect(detectProvider('gpt-5-mini')).toBe('openai'); + expect(detectProvider('o3-mini')).toBe('openai'); + }); + + it('detects o1 and o4 models as openai', () => { + expect(detectProvider('o1')).toBe('openai'); + expect(detectProvider('o1-preview')).toBe('openai'); + expect(detectProvider('o4-mini')).toBe('openai'); + }); + + it('detects google models', () => { + expect(detectProvider('gemini-2.5-pro')).toBe('google'); + }); + + it('detects cursor models', () => { + expect(detectProvider('cursor-small')).toBe('cursor'); + }); + + it('defaults to unknown for unrecognized models', () => { + expect(detectProvider('unknown-model')).toBe('unknown'); + }); +}); + +describe('formatModelName', () => { + it('returns catalog name for known models', () => { + expect(formatModelName('claude-sonnet-4')).toBe('Claude Sonnet 4'); + expect(formatModelName('gpt-4.1')).toBe('GPT-4.1'); + }); + + it('converts kebab-case to title case for unknown models', () => { + expect(formatModelName('my-custom-model')).toBe('My Custom Model'); + }); +}); + +describe('fetchModelsFromDaemon', () => { + it('falls back to DEFAULT_CURSOR_MODELS when daemon is unreachable', async () => { + // Use a port that nothing is listening on + const unreachablePort = 9999; + const models = await fetchModelsFromDaemon(unreachablePort); + + expect(models).toEqual(DEFAULT_CURSOR_MODELS); + }); +});