From 93dafa04d53367653aadb6d32c59d85a0502865e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 11 Feb 2026 18:08:24 +0700 Subject: [PATCH] feat(cursor): add config integration and dashboard routes --- src/ccs.ts | 10 + src/commands/cursor-command.ts | 15 ++ src/commands/help-command.ts | 19 ++ src/config/reserved-names.ts | 2 + src/config/unified-config-loader.ts | 22 +++ src/config/unified-config-types.ts | 26 +++ src/web-server/routes/cursor-routes.ts | 181 ++++++++++++++++++ .../routes/cursor-settings-routes.ts | 127 ++++++++++++ src/web-server/routes/index.ts | 4 + 9 files changed, 406 insertions(+) create mode 100644 src/commands/cursor-command.ts create mode 100644 src/web-server/routes/cursor-routes.ts create mode 100644 src/web-server/routes/cursor-settings-routes.ts diff --git a/src/ccs.ts b/src/ccs.ts index 851650bd..59ef7418 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -553,6 +553,16 @@ async function main(): Promise { process.exit(exitCode); } + // Special case: cursor command (Cursor IDE integration) + // Only route to command handler for known subcommands, otherwise treat as profile + const CURSOR_SUBCOMMANDS = ['auth', 'status', 'models', 'start', 'stop', 'help', '--help', '-h']; + if (firstArg === 'cursor' && args.length > 1 && CURSOR_SUBCOMMANDS.includes(args[1])) { + // `ccs cursor ` - route to cursor command handler + 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 new file mode 100644 index 00000000..739c9515 --- /dev/null +++ b/src/commands/cursor-command.ts @@ -0,0 +1,15 @@ +/** + * Cursor Command Handler - Cursor IDE integration commands + * This is a stub file - the actual implementation is in task #520 + */ + +/** + * Handle cursor command routing + * @param _args - Command arguments (unused in stub) + * @returns Exit code + */ +export async function handleCursorCommand(_args: string[]): Promise { + console.error('[!] Cursor command not yet implemented (task #520)'); + console.error(' Available after cursor-command.ts is created'); + return 1; +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 6c51f385..779e6be6 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -220,6 +220,25 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ] ); + // ═══════════════════════════════════════════════════════════════════════════ + // MAJOR SECTION 5: Cursor IDE Integration + // ═══════════════════════════════════════════════════════════════════════════ + printMajorSection( + 'Cursor IDE Integration', + [ + 'Use Cursor IDE with Claude Code via cursor proxy daemon', + 'Auto-detects token from Cursor installation', + ], + [ + ['ccs cursor', 'Use Cursor IDE integration'], + ['ccs cursor auth', 'Import Cursor token'], + ['ccs cursor status', 'Show connection status'], + ['ccs cursor models', 'List available models'], + ['ccs cursor start', 'Start proxy daemon'], + ['ccs cursor stop', 'Stop proxy daemon'], + ] + ); + // ═══════════════════════════════════════════════════════════════════════════ // SUB-SECTIONS (simpler styling) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 7e53f4c3..8705d63a 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -11,6 +11,8 @@ export const RESERVED_PROFILE_NAMES = [ 'iflow', // Copilot API (GitHub Copilot proxy) 'copilot', + // Cursor IDE (Cursor proxy daemon) + 'cursor', // CLI commands and special names 'default', 'config', diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 76401431..99c09043 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -15,6 +15,7 @@ import { createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION, DEFAULT_COPILOT_CONFIG, + DEFAULT_CURSOR_CONFIG, DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, @@ -25,6 +26,7 @@ import { ThinkingConfig, DashboardAuthConfig, ImageAnalysisConfig, + CursorConfig, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -276,6 +278,12 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { wait_on_limit: partial.copilot?.wait_on_limit ?? DEFAULT_COPILOT_CONFIG.wait_on_limit, model: partial.copilot?.model ?? DEFAULT_COPILOT_CONFIG.model, }, + // Cursor config - disabled by default, merge with defaults + cursor: { + port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, + }, // Global env - injected into all non-Claude subscription profiles global_env: { enabled: partial.global_env?.enabled ?? true, @@ -880,3 +888,17 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig { config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models, }; } + +/** + * Get cursor configuration. + * Returns defaults if not configured. + */ +export function getCursorConfig(): CursorConfig { + const config = loadOrCreateUnifiedConfig(); + + return { + port: config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 5a8dcffe..c7928040 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -231,6 +231,19 @@ export interface CopilotConfig { haiku_model?: string; } +/** + * Cursor IDE integration configuration. + * Enables Cursor IDE usage via cursor proxy daemon. + */ +export interface CursorConfig { + /** Port for cursor proxy daemon (default: 20129) */ + port: number; + /** Auto-start daemon when CCS starts (default: false) */ + auto_start: boolean; + /** Enable ghost mode to disable telemetry (default: true) */ + ghost_mode: boolean; +} + /** * Remote proxy configuration. * Connect to a remote CLIProxyAPI instance instead of spawning local binary. @@ -576,6 +589,8 @@ export interface UnifiedConfig { global_env?: GlobalEnvConfig; /** Copilot API configuration (GitHub Copilot proxy) */ copilot?: CopilotConfig; + /** Cursor IDE configuration (Cursor proxy daemon) */ + cursor?: CursorConfig; /** CLIProxy server configuration for remote/local mode */ cliproxy_server?: CliproxyServerConfig; /** Quota management configuration (v7+) */ @@ -603,6 +618,16 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = { model: 'gpt-4.1', // Free tier compatible }; +/** + * Default Cursor configuration. + * Disabled by default, ghost mode enabled for privacy. + */ +export const DEFAULT_CURSOR_CONFIG: CursorConfig = { + port: 20129, + auto_start: false, + ghost_mode: true, +}; + /** * Default CLIProxy server configuration. * Local mode by default - remote must be explicitly enabled. @@ -675,6 +700,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { env: { ...DEFAULT_GLOBAL_ENV }, }, copilot: { ...DEFAULT_COPILOT_CONFIG }, + cursor: { ...DEFAULT_CURSOR_CONFIG }, cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG }, quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG }, thinking: { ...DEFAULT_THINKING_CONFIG }, diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts new file mode 100644 index 00000000..65591d6a --- /dev/null +++ b/src/web-server/routes/cursor-routes.ts @@ -0,0 +1,181 @@ +/** + * Cursor Routes - Cursor IDE integration via cursor proxy daemon + */ + +import type { Router, Request, Response } from 'express'; +import { Router as ExpressRouter } from 'express'; +import { + checkAuthStatus, + autoDetectTokens, + saveCredentials, + validateToken, +} from '../../cursor/cursor-auth'; +import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import cursorSettingsRoutes from './cursor-settings-routes'; + +const router: Router = ExpressRouter(); + +// Mount settings sub-routes +router.use('/settings', cursorSettingsRoutes); + +/** + * Get daemon status + * TODO: Implement in cursor-executor.ts (#520) + */ +async function getDaemonStatus(port: number): Promise<{ running: boolean; port?: number }> { + // Stub - will be implemented in #520 + return { running: false, port }; +} + +/** + * Get available models + * TODO: Implement in cursor-executor.ts (#520) + */ +async function getAvailableModels(): Promise { + // Stub - will be implemented in #520 + return ['claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku']; +} + +/** + * Start daemon + * TODO: Implement in cursor-executor.ts (#520) + */ +async function startDaemon( + port: number, + ghostMode: boolean +): Promise<{ success: boolean; message: string }> { + // Stub - will be implemented in #520 + return { + success: false, + message: `Daemon start not implemented (port: ${port}, ghost: ${ghostMode})`, + }; +} + +/** + * Stop daemon + * TODO: Implement in cursor-executor.ts (#520) + */ +async function stopDaemon(): Promise<{ success: boolean; message: string }> { + // Stub - will be implemented in #520 + return { success: false, message: 'Daemon stop not implemented' }; +} + +/** + * GET /api/cursor/status - Get Cursor status (auth + daemon) + */ +router.get('/status', async (_req: Request, res: Response): Promise => { + try { + const config = loadOrCreateUnifiedConfig(); + const cursorConfig = config.cursor ?? DEFAULT_CURSOR_CONFIG; + const authStatus = checkAuthStatus(); + const daemonStatus = await getDaemonStatus(cursorConfig.port); + + res.json({ + authenticated: authStatus.authenticated, + daemon_running: daemonStatus.running, + port: cursorConfig.port, + auto_start: cursorConfig.auto_start, + ghost_mode: cursorConfig.ghost_mode, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/auth/import - Import Cursor token manually + */ +router.post('/auth/import', async (req: Request, res: Response): Promise => { + try { + const { accessToken, machineId } = req.body; + if (!accessToken || !machineId) { + res.status(400).json({ error: 'Missing accessToken or machineId' }); + return; + } + + // Validate token format + if (!validateToken(accessToken, machineId)) { + res.status(400).json({ error: 'Invalid token or machine ID format' }); + return; + } + + // Save credentials + saveCredentials({ + accessToken, + machineId, + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + res.json({ success: true, message: 'Token imported successfully' }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/auth/auto-detect - Auto-detect token from SQLite + */ +router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise => { + try { + const result = autoDetectTokens(); + + if (!result.found) { + res.status(404).json({ error: result.error }); + return; + } + + // Save credentials + saveCredentials({ + accessToken: result.accessToken!, + machineId: result.machineId!, + authMethod: 'auto-detect', + importedAt: new Date().toISOString(), + }); + + res.json({ success: true, message: 'Token auto-detected and imported' }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cursor/models - List available models + */ +router.get('/models', async (_req: Request, res: Response): Promise => { + try { + const models = await getAvailableModels(); + res.json({ models }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/start - Start cursor proxy daemon + */ +router.post('/start', async (_req: Request, res: Response): Promise => { + try { + const config = loadOrCreateUnifiedConfig(); + const cursorConfig = config.cursor ?? DEFAULT_CURSOR_CONFIG; + const result = await startDaemon(cursorConfig.port, cursorConfig.ghost_mode); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/cursor/stop - Stop cursor proxy daemon + */ +router.post('/stop', async (_req: Request, res: Response): Promise => { + try { + const result = await stopDaemon(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/cursor-settings-routes.ts b/src/web-server/routes/cursor-settings-routes.ts new file mode 100644 index 00000000..03edb708 --- /dev/null +++ b/src/web-server/routes/cursor-settings-routes.ts @@ -0,0 +1,127 @@ +/** + * Cursor Settings Routes - Settings editor and raw settings for Cursor IDE + */ + +import type { Router, Request, Response } from 'express'; +import { Router as ExpressRouter } from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir } from '../../utils/config-manager'; +import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types'; +import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../config/unified-config-loader'; + +const router: Router = ExpressRouter(); + +/** + * GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode) + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const config = loadOrCreateUnifiedConfig(); + const cursorConfig = config.cursor ?? DEFAULT_CURSOR_CONFIG; + res.json(cursorConfig); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cursor/settings - Update cursor config + */ +router.put('/', (req: Request, res: Response): void => { + try { + const updates = req.body; + const config = loadOrCreateUnifiedConfig(); + + // Merge updates with existing config + config.cursor = { + port: updates.port ?? config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port, + auto_start: + updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start, + ghost_mode: + updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode, + }; + + saveUnifiedConfig(config); + res.json({ success: true, cursor: config.cursor }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cursor/settings/raw - Get raw cursor.settings.json + * Returns the raw JSON content for editing in the code editor + */ +router.get('/raw', (_req: Request, res: Response): void => { + try { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const config = loadOrCreateUnifiedConfig(); + const cursorConfig = config.cursor ?? DEFAULT_CURSOR_CONFIG; + + // If file doesn't exist, return default structure + if (!fs.existsSync(settingsPath)) { + // Create settings structure matching Cursor pattern + // Use 127.0.0.1 instead of localhost for more reliable local connections + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`, + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + }, + }; + + res.json({ + settings: defaultSettings, + mtime: Date.now(), + path: `~/.ccs/cursor.settings.json`, + exists: false, + }); + return; + } + + const content = fs.readFileSync(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + const stat = fs.statSync(settingsPath); + + res.json({ + settings, + mtime: stat.mtimeMs, + path: `~/.ccs/cursor.settings.json`, + exists: true, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/cursor/settings/raw - Save raw cursor.settings.json + * Saves the raw JSON content from the code editor + */ +router.put('/raw', (req: Request, res: Response): void => { + try { + const { settings, expectedMtime } = req.body; + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + + // Check for conflict if file exists and expectedMtime provided + if (fs.existsSync(settingsPath) && expectedMtime) { + const stat = fs.statSync(settingsPath); + if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) { + res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs }); + return; + } + } + + // Write settings file atomically + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + const stat = fs.statSync(settingsPath); + res.json({ success: true, mtime: stat.mtimeMs }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index c488a9c9..f9517635 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -20,6 +20,7 @@ import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; import copilotRoutes from './copilot-routes'; +import cursorRoutes from './cursor-routes'; import miscRoutes from './misc-routes'; import cliproxyServerRoutes from './proxy-routes'; import authRoutes from './auth-routes'; @@ -63,6 +64,9 @@ apiRoutes.use('/websearch', websearchRoutes); // ==================== Copilot ==================== apiRoutes.use('/copilot', copilotRoutes); +// ==================== Cursor ==================== +apiRoutes.use('/cursor', cursorRoutes); + // ==================== CLIProxy Server Settings ==================== apiRoutes.use('/cliproxy-server', cliproxyServerRoutes);