feat(cursor): add daemon lifecycle, models catalog, and CLI commands

Implements #520 - Cursor IDE daemon, models catalog, module index, and CLI.

Files created:
- src/cursor/cursor-daemon.ts (~230 LOC) - Lifecycle management (start/stop/status)
- src/cursor/cursor-models.ts (~145 LOC) - Model catalog with Anthropic/OpenAI/Google/Cursor models
- src/cursor/index.ts (~30 LOC) - Module barrel exports
- src/commands/cursor-command.ts (~230 LOC) - CLI commands (auth/status/models/start/stop)

Key features:
- Daemon health checks via HTTP /health endpoint
- PID file management in ~/.ccs/cursor/daemon.pid
- Model catalog with fetchModelsFromDaemon() and fallback defaults
- CLI subcommand routing following copilot-command.ts pattern
- ASCII-only output ([OK], [X], [i] markers)

Temporary defaults for config (port: 4242, model: gpt-4.1) until #521 adds
cursor to unified config schema.

Note: Actual daemon server implementation (CursorExecutor integration) deferred
to #522 - this creates the lifecycle structure only.
This commit is contained in:
Tam Nhu Tran
2026-02-11 18:06:46 +07:00
parent 75095bde1b
commit aaa31c6427
4 changed files with 687 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
/**
* Cursor CLI Command
*
* Handles `ccs cursor <subcommand>` 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<number> {
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 <subcommand>');
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<number> {
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<number> {
// 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<number> {
// 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<number> {
// 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<number> {
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;
}
}
+253
View File
@@ -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<boolean> {
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<CursorDaemonStatus> {
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}`,
};
}
}
+166
View File
@@ -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<CursorModel[]> {
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<CursorModel[]> {
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(' ');
}
+30
View File
@@ -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';