diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 50fc685b..16cd06f6 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 2fb49f77..91f2cc90 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,13 @@ 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: { + enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, + 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, @@ -559,6 +568,23 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Cursor section (Cursor IDE proxy daemon) + if (config.cursor) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Cursor: Cursor IDE proxy daemon'); + lines.push('# Enables Cursor IDE integration via local proxy daemon.'); + lines.push('#'); + lines.push('# enabled: Enable/disable Cursor integration (default: false)'); + lines.push('# port: Port for cursor proxy daemon (default: 20129)'); + lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)'); + lines.push('# ghost_mode: Disable telemetry for privacy (default: true)'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + // Global env section if (config.global_env) { lines.push('# ----------------------------------------------------------------------------'); @@ -900,3 +926,12 @@ 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 config.cursor ?? { ...DEFAULT_CURSOR_CONFIG }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1cdc945f..0a00de5f 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -231,6 +231,21 @@ export interface CopilotConfig { haiku_model?: string; } +/** + * Cursor IDE integration configuration. + * Enables Cursor IDE usage via cursor proxy daemon. + */ +export interface CursorConfig { + /** Enable Cursor integration (default: false) */ + enabled: boolean; + /** 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. @@ -610,6 +625,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+) */ @@ -637,6 +654,17 @@ 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 = { + enabled: false, + port: 20129, + auto_start: false, + ghost_mode: true, +}; + /** * Default CLIProxy server configuration. * Local mode by default - remote must be explicitly enabled. @@ -709,6 +737,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..5f7d6a27 --- /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 { Request, Response } from 'express'; +import { Router } from 'express'; +import { + checkAuthStatus, + autoDetectTokens, + saveCredentials, + validateToken, +} from '../../cursor/cursor-auth'; +import { getCursorConfig } from '../../config/unified-config-loader'; +import cursorSettingsRoutes from './cursor-settings-routes'; + +const router = Router(); + +// 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 []; // TODO: populated by cursor-models.ts (#520) +} + +/** + * 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 cursorConfig = getCursorConfig(); + const authStatus = checkAuthStatus(); + const daemonStatus = await getDaemonStatus(cursorConfig.port); + + res.json({ + enabled: cursorConfig.enabled, + 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 || !result.accessToken || !result.machineId) { + res.status(404).json({ error: result.error ?? 'Token not found' }); + 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/daemon/start - Start cursor proxy daemon + * Path matches copilot convention: /api/{provider}/daemon/{action} + */ +router.post('/daemon/start', async (_req: Request, res: Response): Promise => { + try { + const cursorConfig = getCursorConfig(); + 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/daemon/stop - Stop cursor proxy daemon + * Path matches copilot convention: /api/{provider}/daemon/{action} + */ +router.post('/daemon/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..685b643c --- /dev/null +++ b/src/web-server/routes/cursor-settings-routes.ts @@ -0,0 +1,170 @@ +/** + * Cursor Settings Routes - Settings editor and raw settings for Cursor IDE + */ + +import type { Request, Response } from 'express'; +import { Router } 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, + getCursorConfig, +} from '../../config/unified-config-loader'; + +const router = Router(); + +/** + * GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode) + */ +router.get('/', (_req: Request, res: Response): void => { + try { + const cursorConfig = getCursorConfig(); + 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; + + // Reject non-object bodies + if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { + res.status(400).json({ error: 'Request body must be a JSON object' }); + return; + } + + // Validate input types + if ('port' in updates) { + if (typeof updates.port !== 'number' || !Number.isInteger(updates.port)) { + res.status(400).json({ error: 'port must be an integer' }); + return; + } + if (updates.port < 1 || updates.port > 65535) { + res.status(400).json({ error: 'port must be between 1 and 65535' }); + return; + } + } + if ('enabled' in updates && typeof updates.enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + if ('auto_start' in updates && typeof updates.auto_start !== 'boolean') { + res.status(400).json({ error: 'auto_start must be a boolean' }); + return; + } + if ('ghost_mode' in updates && typeof updates.ghost_mode !== 'boolean') { + res.status(400).json({ error: 'ghost_mode must be a boolean' }); + return; + } + + const config = loadOrCreateUnifiedConfig(); + + // Merge updates with existing config + // Only known fields are merged — unknown properties are ignored + config.cursor = { + enabled: updates.enabled ?? config.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled, + 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 cursorConfig = getCursorConfig(); + + // 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: settingsPath, + 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: settingsPath, + 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; + + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + res.status(400).json({ error: 'settings must be a JSON object' }); + return; + } + + 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); + + // TODO: Sync raw settings back to unified config when cursor-daemon is integrated (#520) + + 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); diff --git a/tests/unit/web-server/cursor-settings-routes.test.ts b/tests/unit/web-server/cursor-settings-routes.test.ts new file mode 100644 index 00000000..13a05dfa --- /dev/null +++ b/tests/unit/web-server/cursor-settings-routes.test.ts @@ -0,0 +1,300 @@ +/** + * Cursor Settings Routes Tests + * Tests for Cursor configuration API endpoints. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Setup test environment BEFORE any imports +const TEST_CCS_DIR = path.join(os.tmpdir(), `ccs-test-cursor-settings-${Date.now()}`); +process.env.CCS_HOME = TEST_CCS_DIR; + +// Import after setting env var +import type { CursorConfig } from '../../../src/config/unified-config-types'; +import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { getCcsDir } from '../../../src/utils/config-manager'; + +describe('Cursor Settings Routes Logic', () => { + beforeEach(() => { + // Ensure test directory exists + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + fs.mkdirSync(ccsDir, { recursive: true }); + } + }); + + afterEach(() => { + // Clean up test directory + if (fs.existsSync(TEST_CCS_DIR)) { + fs.rmSync(TEST_CCS_DIR, { recursive: true, force: true }); + } + }); + + describe('PUT /settings validation logic', () => { + it('validates null body', () => { + const updates = null; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates non-object body', () => { + const updates = 'string'; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates array body', () => { + const updates = [1, 2, 3]; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(false); + }); + + it('validates valid object', () => { + const updates = { port: 4000 }; + const isValid = !!(updates && typeof updates === 'object' && !Array.isArray(updates)); + expect(isValid).toBe(true); + }); + + it('validates integer port', () => { + const port = 4000; + const isInteger = typeof port === 'number' && Number.isInteger(port); + expect(isInteger).toBe(true); + }); + + it('rejects non-integer port', () => { + const port = 3.14; + const isInteger = typeof port === 'number' && Number.isInteger(port); + expect(isInteger).toBe(false); + }); + + it('validates port range (valid)', () => { + const port = 3000; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(true); + }); + + it('validates port range (below)', () => { + const port = 0; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(false); + }); + + it('validates port range (above)', () => { + const port = 65536; + const inRange = port >= 1 && port <= 65535; + expect(inRange).toBe(false); + }); + + it('validates boolean auto_start', () => { + const auto_start = true; + const isBoolean = typeof auto_start === 'boolean'; + expect(isBoolean).toBe(true); + }); + + it('rejects non-boolean auto_start', () => { + const auto_start = 'yes'; + const isBoolean = typeof auto_start === 'boolean'; + expect(isBoolean).toBe(false); + }); + + it('validates boolean ghost_mode', () => { + const ghost_mode = false; + const isBoolean = typeof ghost_mode === 'boolean'; + expect(isBoolean).toBe(true); + }); + + it('rejects non-boolean ghost_mode', () => { + const ghost_mode = 1; + const isBoolean = typeof ghost_mode === 'boolean'; + expect(isBoolean).toBe(false); + }); + }); + + describe('PUT /settings whitelist merge pattern', () => { + it('merges known fields only (ignores unknown)', () => { + const config = loadOrCreateUnifiedConfig(); + const updates = { + port: 5000, + malicious_key: 'should be ignored', + another_unknown: true, + }; + + // Simulate the whitelist merge from the route + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(5000); + expect(cursorConfig).not.toHaveProperty('malicious_key'); + expect(cursorConfig).not.toHaveProperty('another_unknown'); + }); + + it('updates port only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { port: 4000 }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(4000); + expect(cursorConfig.auto_start).toBe(false); + expect(cursorConfig.ghost_mode).toBe(false); + }); + + it('updates auto_start only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { auto_start: true }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(3000); + expect(cursorConfig.auto_start).toBe(true); + expect(cursorConfig.ghost_mode).toBe(false); + }); + + it('updates ghost_mode only', () => { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false }; + saveUnifiedConfig(config); + + const updates = { ghost_mode: true }; + const cursorConfig: CursorConfig = { + enabled: updates.enabled ?? config.cursor?.enabled ?? false, + port: updates.port ?? config.cursor?.port ?? 3000, + auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false, + ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false, + }; + + expect(cursorConfig.port).toBe(3000); + expect(cursorConfig.auto_start).toBe(false); + expect(cursorConfig.ghost_mode).toBe(true); + }); + }); + + describe('GET /settings/raw logic', () => { + it('returns defaults when file does not exist', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const exists = fs.existsSync(settingsPath); + + expect(exists).toBe(false); + + const config = loadOrCreateUnifiedConfig(); + const cursorPort = config.cursor?.port ?? 3000; + const defaultSettings = { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorPort}`, + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + }, + }; + + expect(defaultSettings.env.ANTHROPIC_BASE_URL).toContain('http://127.0.0.1:'); + expect(defaultSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed'); + }); + + it('reads existing file', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const testSettings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:4000', + ANTHROPIC_AUTH_TOKEN: 'test-token', + }, + }; + + fs.writeFileSync(settingsPath, JSON.stringify(testSettings, null, 2)); + const exists = fs.existsSync(settingsPath); + + expect(exists).toBe(true); + + const content = fs.readFileSync(settingsPath, 'utf-8'); + const parsed = JSON.parse(content); + + expect(parsed).toEqual(testSettings); + }); + }); + + describe('PUT /settings/raw validation logic', () => { + it('validates missing settings field', () => { + const body: { expectedMtime: number; settings?: unknown } = { expectedMtime: Date.now() }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(false); + }); + + it('validates non-object settings', () => { + const body = { settings: 'not an object' }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(false); + }); + + it('validates valid settings', () => { + const body = { settings: { env: { test: 'value' } } }; + const isValid = !!(body.settings && typeof body.settings === 'object'); + expect(isValid).toBe(true); + }); + + it('writes settings file atomically', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const testSettings = { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:5000', + ANTHROPIC_AUTH_TOKEN: 'new-token', + }, + }; + + // Simulate atomic write + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(testSettings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); + + const exists = fs.existsSync(settingsPath); + expect(exists).toBe(true); + + const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + expect(written).toEqual(testSettings); + }); + + it('detects mtime conflict', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const initialSettings = { env: { test: 'initial' } }; + + fs.writeFileSync(settingsPath, JSON.stringify(initialSettings)); + const stat = fs.statSync(settingsPath); + + const expectedMtime = stat.mtimeMs - 5000; // 5 seconds in the past + const hasConflict = Math.abs(stat.mtimeMs - expectedMtime) > 1000; + + expect(hasConflict).toBe(true); + }); + + it('allows write when mtime matches', () => { + const settingsPath = path.join(getCcsDir(), 'cursor.settings.json'); + const initialSettings = { env: { test: 'initial' } }; + + fs.writeFileSync(settingsPath, JSON.stringify(initialSettings)); + const stat = fs.statSync(settingsPath); + + const expectedMtime = stat.mtimeMs; + const hasConflict = Math.abs(stat.mtimeMs - expectedMtime) > 1000; + + expect(hasConflict).toBe(false); + }); + }); +});