diff --git a/README.md b/README.md index 31d4cc0b..22f06a44 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ The dashboard provides visual management for all account types: | **Gemini** | OAuth | `ccs gemini` | Zero-config, fast iteration | | **Codex** | OAuth | `ccs codex` | Code generation | | **Copilot** | OAuth | `ccs copilot` or `ccs ghcp` | GitHub Copilot models | +| **Cursor IDE** | Local Token | `ccs cursor` | Cursor subscription models via local daemon | | **Kiro** | OAuth | `ccs kiro` | AWS CodeWhisperer (Claude-powered) | | **Antigravity** | OAuth | `ccs agy` | Alternative routing | | **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API | @@ -132,6 +133,7 @@ The dashboard provides visual management for all account types: ccs # Default Claude session ccs gemini # Gemini (OAuth) ccs codex # OpenAI Codex (OAuth) +ccs cursor # Cursor IDE integration (token import + local daemon) ccs kiro # Kiro/AWS CodeWhisperer (OAuth) ccs ghcp # GitHub Copilot (OAuth device flow) ccs agy # Antigravity (OAuth) diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts new file mode 100644 index 00000000..34260d88 --- /dev/null +++ b/src/commands/cursor-command-display.ts @@ -0,0 +1,115 @@ +import type { CursorAuthStatus, CursorDaemonStatus, CursorModel } from '../cursor/types'; +import type { CursorConfig } from '../config/unified-config-types'; +import { color } from '../utils/ui'; + +function printLines(lines: string[]): void { + for (const line of lines) { + console.log(line); + } +} + +export function renderCursorHelp(): number { + printLines([ + 'Cursor IDE Integration', + '', + 'Usage: ccs cursor [options]', + '', + 'Subcommands:', + ' auth Import Cursor IDE authentication token', + ' status Show integration, authentication, and daemon status', + ' models List available models', + ' start Start cursor daemon', + ' stop Stop cursor daemon', + ' enable Enable cursor integration in unified config', + ' disable Disable cursor integration in unified config', + ' help Show this help message', + '', + 'Auth options:', + ' ccs cursor auth # Auto-detect from Cursor SQLite', + ' ccs cursor auth --manual --token --machine-id ', + '', + 'Quick start:', + ' 1. ccs cursor enable # Enable integration', + ' 2. ccs cursor auth # Import Cursor IDE token', + ' 3. ccs cursor start # Start daemon', + '', + 'Or use the web UI: ccs config -> Cursor page', + '', + ]); + + return 0; +} + +export function renderCursorStatus( + cursorConfig: CursorConfig, + authStatus: CursorAuthStatus, + daemonStatus: CursorDaemonStatus +): void { + console.log('Cursor IDE Status'); + console.log('─────────────────'); + console.log(''); + + const enabledIcon = cursorConfig.enabled ? color('[OK]', 'success') : color('[X]', 'error'); + console.log(`Integration: ${enabledIcon} ${cursorConfig.enabled ? 'Enabled' : 'Disabled'}`); + + const authIcon = authStatus.authenticated ? color('[OK]', 'success') : color('[X]', 'error'); + const expiredSuffix = authStatus.authenticated && authStatus.expired ? ' (expired)' : ''; + const authText = authStatus.authenticated ? `Authenticated${expiredSuffix}` : 'Not authenticated'; + console.log(`Authentication: ${authIcon} ${authText}`); + + if (authStatus.authenticated && authStatus.tokenAge !== undefined) { + console.log(` Token age: ${authStatus.tokenAge} hours`); + if (authStatus.credentials?.authMethod) { + console.log(` Method: ${authStatus.credentials.authMethod}`); + } + } + + const daemonIcon = daemonStatus.running ? color('[OK]', 'success') : color('[X]', 'error'); + console.log(`Daemon: ${daemonIcon} ${daemonStatus.running ? 'Running' : 'Not running'}`); + if (daemonStatus.pid) { + console.log(` PID: ${daemonStatus.pid}`); + } + + console.log(''); + console.log('Configuration:'); + console.log(` Port: ${cursorConfig.port}`); + console.log(` Auto-start: ${cursorConfig.auto_start ? 'Yes' : 'No'}`); + console.log(` Ghost mode: ${cursorConfig.ghost_mode ? 'On' : 'Off'}`); + console.log(''); + + if ( + cursorConfig.enabled && + authStatus.authenticated && + !authStatus.expired && + daemonStatus.running + ) { + return; + } + + console.log('Next steps:'); + if (!cursorConfig.enabled) { + console.log(' - Enable: ccs cursor enable'); + } + if (!authStatus.authenticated || authStatus.expired) { + console.log(' - Auth: ccs cursor auth'); + } + if (!daemonStatus.running) { + console.log(' - Start: ccs cursor start'); + } +} + +export function renderCursorModels(models: CursorModel[], defaultModel: string): void { + console.log('Available Cursor Models'); + console.log('───────────────────────'); + console.log(''); + + for (const model of models) { + const defaultMark = model.id === defaultModel ? ' [DEFAULT]' : ''; + console.log(` ${model.id}${defaultMark}`); + console.log(` Provider: ${model.provider}`); + } + + console.log(''); + console.log('Model selection is request-driven by the calling client.'); + console.log('Dashboard: ccs config -> Cursor page'); +} diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index ad6ce59a..585c395e 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -6,22 +6,23 @@ import { autoDetectTokens, + validateToken, saveCredentials, checkAuthStatus, startDaemon, stopDaemon, getDaemonStatus, getAvailableModels, - DEFAULT_CURSOR_PORT, - DEFAULT_CURSOR_MODEL, + getDefaultModel, } 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, -}; +import { + getCursorConfig, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../config/unified-config-loader'; +import { DEFAULT_CURSOR_CONFIG } from '../config/unified-config-types'; +import { renderCursorHelp, renderCursorModels, renderCursorStatus } from './cursor-command-display'; +import { ok, fail, info } from '../utils/ui'; /** Valid cursor subcommands — imported by ccs.ts for routing */ export const CURSOR_SUBCOMMANDS = [ @@ -30,6 +31,8 @@ export const CURSOR_SUBCOMMANDS = [ 'models', 'start', 'stop', + 'enable', + 'disable', 'help', '--help', '-h', @@ -43,7 +46,7 @@ export async function handleCursorCommand(args: string[]): Promise { switch (subcommand) { case 'auth': - return handleAuth(); + return handleAuth(args.slice(1)); case 'status': return handleStatus(); case 'models': @@ -52,6 +55,10 @@ export async function handleCursorCommand(args: string[]): Promise { return handleStart(); case 'stop': return handleStop(); + case 'enable': + return handleEnable(); + case 'disable': + return handleDisable(); case undefined: case 'help': case '--help': @@ -65,41 +72,70 @@ export async function handleCursorCommand(args: string[]): Promise { } } -/** - * 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; + return renderCursorHelp(); +} + +function parseOptionValue(args: string[], key: string): string | undefined { + const exactIndex = args.findIndex((arg) => arg === key); + if (exactIndex !== -1 && args[exactIndex + 1]) { + return args[exactIndex + 1]; + } + + const prefix = `${key}=`; + const withEquals = args.find((arg) => arg.startsWith(prefix)); + if (withEquals) { + return withEquals.slice(prefix.length); + } + + return undefined; } /** * Handle auth subcommand. */ -async function handleAuth(): Promise { +async function handleAuth(args: string[]): Promise { + const manual = args.includes('--manual'); + + if (manual) { + const accessToken = + parseOptionValue(args, '--token') ?? parseOptionValue(args, '--access-token') ?? ''; + const machineId = + parseOptionValue(args, '--machine-id') ?? parseOptionValue(args, '--machineId') ?? ''; + + if (!accessToken || !machineId) { + console.error( + fail( + 'Manual auth requires both token and machine ID.\n\nExample:\n ccs cursor auth --manual --token --machine-id ' + ) + ); + return 1; + } + + if (!validateToken(accessToken, machineId)) { + console.error(fail('Invalid token or machine ID format')); + return 1; + } + + saveCredentials({ + accessToken, + machineId, + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + console.log(ok('Cursor credentials imported (manual mode)')); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Enable integration: ccs cursor enable'); + console.log(' 2. Start daemon: ccs cursor start'); + return 0; + } + 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) { @@ -109,113 +145,39 @@ async function handleAuth(): Promise { 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'); + console.log(' 1. Enable integration: ccs cursor enable'); + console.log(' 2. Start daemon: ccs cursor start'); + console.log(' 3. 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.error(fail(`Auto-detection failed: ${autoResult.error ?? 'Unknown error'}`)); 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('Manual fallback:'); + console.log(' ccs cursor auth --manual --token --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 cursorConfig = getCursorConfig(); 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'); - } - } - + renderCursorStatus(cursorConfig, authStatus, daemonStatus); 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 cursorConfig = getCursorConfig(); 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)'); - + const defaultModel = getDefaultModel(); + renderCursorModels(models, defaultModel); return 0; } @@ -223,19 +185,29 @@ async function handleModels(): Promise { * Handle start subcommand. */ async function handleStart(): Promise { - // TODO: Load from unified config when #521 is complete - const cursorConfig = DEFAULT_CURSOR_CONFIG; + const cursorConfig = getCursorConfig(); + + if (!cursorConfig.enabled) { + console.error(fail('Cursor integration is disabled. Run: ccs cursor enable')); + return 1; + } - // Check auth first const authStatus = checkAuthStatus(); if (!authStatus.authenticated) { console.error(fail('Not authenticated. Run: ccs cursor auth')); return 1; } + if (authStatus.expired) { + console.error(fail('Credentials expired. Run: ccs cursor auth')); + return 1; + } console.log(info(`Starting cursor daemon on port ${cursorConfig.port}...`)); - const result = await startDaemon(cursorConfig); + const result = await startDaemon({ + port: cursorConfig.port, + ghost_mode: cursorConfig.ghost_mode, + }); if (result.success) { console.log(ok(`Daemon started (PID: ${result.pid})`)); @@ -262,3 +234,41 @@ async function handleStop(): Promise { return 1; } } + +/** + * Handle enable subcommand. + */ +async function handleEnable(): Promise { + const config = loadOrCreateUnifiedConfig(); + + if (!config.cursor) { + config.cursor = { ...DEFAULT_CURSOR_CONFIG }; + } + + config.cursor.enabled = true; + saveUnifiedConfig(config); + + console.log(ok('Cursor integration enabled')); + console.log(''); + console.log('Next steps:'); + console.log(' 1. Authenticate: ccs cursor auth'); + console.log(' 2. Start daemon: ccs cursor start'); + console.log(' 3. Check status: ccs cursor status'); + + return 0; +} + +/** + * Handle disable subcommand. + */ +async function handleDisable(): Promise { + const config = loadOrCreateUnifiedConfig(); + + if (config.cursor) { + config.cursor.enabled = false; + saveUnifiedConfig(config); + } + + console.log(ok('Cursor integration disabled')); + return 0; +} diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 16cd06f6..699192c9 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -232,10 +232,13 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); [ ['ccs cursor ', 'Use Cursor IDE integration'], ['ccs cursor auth', 'Import Cursor token'], + ['ccs cursor auth --manual --token --machine-id ', 'Manual token import'], ['ccs cursor status', 'Show connection status'], ['ccs cursor models', 'List available models'], ['ccs cursor start', 'Start proxy daemon'], ['ccs cursor stop', 'Stop proxy daemon'], + ['ccs cursor enable', 'Enable cursor integration'], + ['ccs cursor disable', 'Disable cursor integration'], ] ); diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts new file mode 100644 index 00000000..4d7fd284 --- /dev/null +++ b/src/cursor/cursor-daemon-entry.ts @@ -0,0 +1,283 @@ +/** + * Cursor Daemon Entry + * + * Dedicated child-process entrypoint for local OpenAI-compatible Cursor proxy. + */ + +import * as http from 'http'; +import { Readable } from 'stream'; +import { CursorExecutor } from './cursor-executor'; +import { checkAuthStatus } from './cursor-auth'; +import { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS } from './cursor-models'; +import type { CursorTool } from './cursor-protobuf-schema'; + +interface DaemonRuntimeOptions { + port: number; + ghostMode: boolean; +} + +interface OpenAIMessage { + role: string; + content?: string | Array<{ type: string; text?: string }> | null; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; +} + +interface NormalizedOpenAIMessage { + role: string; + content: string | Array<{ type: string; text?: string }>; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; +} + +interface OpenAIChatRequest { + model?: string; + stream?: boolean; + reasoning_effort?: string; + tools?: CursorTool[]; + messages?: OpenAIMessage[]; +} + +const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB + +function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void { + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +function readJsonBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_BODY_SIZE) { + req.destroy(); + reject(new Error('Request body too large (max 10MB)')); + return; + } + chunks.push(chunk); + }); + + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) { + resolve({}); + return; + } + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error('Invalid JSON in request body')); + } + }); + + req.on('error', reject); + }); +} + +function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] { + if (!Array.isArray(raw)) { + throw new Error('messages must be an array'); + } + + return raw.map((message, index) => { + if (typeof message !== 'object' || message === null) { + throw new Error(`messages[${index}] must be an object`); + } + + const m = message as Record; + if (typeof m.role !== 'string' || !m.role) { + throw new Error(`messages[${index}].role must be a non-empty string`); + } + + const content = m.content; + if ( + content !== undefined && + content !== null && + typeof content !== 'string' && + !Array.isArray(content) + ) { + throw new Error(`messages[${index}].content must be string, array, or null`); + } + + return { + role: m.role, + content: (content ?? '') as NormalizedOpenAIMessage['content'], + name: typeof m.name === 'string' ? m.name : undefined, + tool_call_id: typeof m.tool_call_id === 'string' ? m.tool_call_id : undefined, + tool_calls: Array.isArray(m.tool_calls) + ? (m.tool_calls as NormalizedOpenAIMessage['tool_calls']) + : undefined, + }; + }); +} + +async function pipeWebResponseToNode(response: Response, res: http.ServerResponse): Promise { + res.statusCode = response.status; + response.headers.forEach((value, key) => { + res.setHeader(key, value); + }); + + if (!response.body) { + res.end(); + return; + } + + const nodeStream = Readable.fromWeb(response.body as unknown as ReadableStream); + + await new Promise((resolve, reject) => { + nodeStream.on('error', reject); + nodeStream.on('end', resolve); + nodeStream.pipe(res); + }); +} + +function parseArgs(argv: string[]): DaemonRuntimeOptions { + let port = 20129; + let ghostMode = true; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--port' && argv[i + 1]) { + const parsed = parseInt(argv[i + 1], 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + port = parsed; + } + i++; + continue; + } + + if (arg === '--ghost-mode' && argv[i + 1]) { + const value = argv[i + 1]; + ghostMode = value !== 'false' && value !== '0'; + i++; + continue; + } + } + + return { port, ghostMode }; +} + +export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Server { + const executor = new CursorExecutor(); + + const server = http.createServer(async (req, res) => { + try { + const method = req.method || 'GET'; + const requestUrl = req.url || '/'; + + if (method === 'GET' && requestUrl === '/health') { + writeJson(res, 200, { ok: true, service: 'cursor-daemon' }); + return; + } + + if (method === 'GET' && requestUrl === '/v1/models') { + const data = DEFAULT_CURSOR_MODELS.map((model) => ({ + id: model.id, + object: 'model', + created: 0, + owned_by: model.provider, + })); + writeJson(res, 200, { object: 'list', data }); + return; + } + + if (method !== 'POST' || requestUrl !== '/v1/chat/completions') { + writeJson(res, 404, { error: 'Not found' }); + return; + } + + const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest; + const messages = normalizeMessages(parsedBody.messages); + const model = + typeof parsedBody.model === 'string' && parsedBody.model + ? parsedBody.model + : DEFAULT_CURSOR_MODEL; + const stream = parsedBody.stream === true; + + const authStatus = checkAuthStatus(); + if (!authStatus.authenticated || !authStatus.credentials) { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message: 'Cursor credentials not found. Run `ccs cursor auth` first.', + }, + }); + return; + } + + if (authStatus.expired) { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message: 'Cursor credentials expired. Run `ccs cursor auth` again.', + }, + }); + return; + } + + const abortController = new AbortController(); + req.on('close', () => { + if (!res.writableEnded) { + abortController.abort(); + } + }); + + const result = await executor.execute({ + model, + stream, + signal: abortController.signal, + credentials: { + accessToken: authStatus.credentials.accessToken, + machineId: authStatus.credentials.machineId, + ghostMode: options.ghostMode, + }, + body: { + messages, + tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined, + reasoning_effort: + typeof parsedBody.reasoning_effort === 'string' + ? parsedBody.reasoning_effort + : undefined, + }, + }); + + await pipeWebResponseToNode(result.response, res); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + writeJson(res, 400, { + error: { + type: 'invalid_request_error', + message, + }, + }); + } + }); + + server.listen(options.port, '127.0.0.1'); + return server; +} + +if (require.main === module) { + const options = parseArgs(process.argv.slice(2)); + const server = startCursorDaemonServer(options); + + const shutdown = () => { + server.close(() => process.exit(0)); + }; + + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); +} diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index 99fef7b2..d7a7fa58 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -9,7 +9,7 @@ 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 type { CursorDaemonConfig, CursorDaemonStatus } from './types'; import { getCcsDir } from '../utils/config-manager'; /** @@ -27,6 +27,16 @@ function getPidFilePath(): string { return path.join(getCursorDir(), 'daemon.pid'); } +/** + * Resolve daemon entrypoint script path for the current runtime. + * - Dist runtime: cursor-daemon-entry.js + * - Bun source runtime (tests/dev): cursor-daemon-entry.ts + */ +function resolveDaemonEntrypoint(): string { + const isBunRuntime = process.execPath.toLowerCase().includes('bun'); + return path.join(__dirname, isBunRuntime ? 'cursor-daemon-entry.ts' : 'cursor-daemon-entry.js'); +} + /** * Check if cursor daemon is running on the specified port. * Uses 127.0.0.1 instead of localhost for more reliable local connections. @@ -128,7 +138,7 @@ export function removePidFile(): void { * @returns Promise that resolves when daemon is ready */ export async function startDaemon( - config: CursorConfig + config: CursorDaemonConfig ): Promise<{ success: boolean; pid?: number; error?: string }> { // Check if already running if (await isDaemonRunning(config.port)) { @@ -140,8 +150,16 @@ export async function startDaemon( 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 + const daemonEntry = resolveDaemonEntrypoint(); + try { + await fs.promises.access(daemonEntry, fs.constants.R_OK); + } catch { + return { + success: false, + error: 'Cursor daemon entrypoint not found. Run `bun run build` and retry.', + }; + } + return new Promise((resolve) => { let proc: ChildProcess; let resolved = false; @@ -157,30 +175,16 @@ export async function startDaemon( 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'); - `, + daemonEntry, + '--port', + String(config.port), + '--ghost-mode', + String(config.ghost_mode !== false), + '--ccs-daemon', ]; - // Append --ccs-daemon marker for PID validation in stopDaemon - proc = spawn(process.execPath, [...args, '--ccs-daemon'], { + proc = spawn(process.execPath, args, { stdio: 'ignore', detached: true, }); diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index cc74cb1e..de878cdc 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -10,7 +10,7 @@ import type { CursorModel } from './types'; import { isDaemonRunning } from './cursor-daemon'; /** Default daemon port */ -export const DEFAULT_CURSOR_PORT = 4242; +export const DEFAULT_CURSOR_PORT = 20129; /** Default model ID */ export const DEFAULT_CURSOR_MODEL = 'gpt-4.1'; diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 3647a612..59fd6d74 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -8,7 +8,14 @@ export * from './types'; // Auth -export { autoDetectTokens, saveCredentials, loadCredentials, checkAuthStatus } from './cursor-auth'; +export { + autoDetectTokens, + validateToken, + saveCredentials, + loadCredentials, + deleteCredentials, + checkAuthStatus, +} from './cursor-auth'; // Daemon export { diff --git a/src/cursor/types.ts b/src/cursor/types.ts index c16150cc..df9c152f 100644 --- a/src/cursor/types.ts +++ b/src/cursor/types.ts @@ -5,12 +5,11 @@ */ /** - * Cursor daemon configuration. - * Temporary interface until #521 adds cursor to unified config. + * Cursor daemon runtime configuration. */ -export interface CursorConfig { +export interface CursorDaemonConfig { port: number; - model: string; + ghost_mode?: boolean; } /** diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index 5f7d6a27..8e75aecf 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -5,11 +5,16 @@ import type { Request, Response } from 'express'; import { Router } from 'express'; import { + getDaemonStatus, + getAvailableModels, + getDefaultModel, + startDaemon, + stopDaemon, checkAuthStatus, autoDetectTokens, saveCredentials, validateToken, -} from '../../cursor/cursor-auth'; +} from '../../cursor'; import { getCursorConfig } from '../../config/unified-config-loader'; import cursorSettingsRoutes from './cursor-settings-routes'; @@ -18,48 +23,6 @@ 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) */ @@ -72,6 +35,9 @@ router.get('/status', async (_req: Request, res: Response): Promise => { res.json({ enabled: cursorConfig.enabled, authenticated: authStatus.authenticated, + auth_method: authStatus.credentials?.authMethod ?? null, + token_age: authStatus.tokenAge ?? null, + token_expired: authStatus.expired ?? false, daemon_running: daemonStatus.running, port: cursorConfig.port, auto_start: cursorConfig.auto_start, @@ -144,8 +110,9 @@ router.post('/auth/auto-detect', async (_req: Request, res: Response): Promise => { try { - const models = await getAvailableModels(); - res.json({ models }); + const cursorConfig = getCursorConfig(); + const models = await getAvailableModels(cursorConfig.port); + res.json({ models, current: getDefaultModel() }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -158,7 +125,10 @@ router.get('/models', async (_req: Request, res: Response): Promise => { router.post('/daemon/start', async (_req: Request, res: Response): Promise => { try { const cursorConfig = getCursorConfig(); - const result = await startDaemon(cursorConfig.port, cursorConfig.ghost_mode); + const result = await startDaemon({ + port: cursorConfig.port, + ghost_mode: cursorConfig.ghost_mode, + }); res.json(result); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/src/web-server/routes/cursor-settings-routes.ts b/src/web-server/routes/cursor-settings-routes.ts index 685b643c..2c918477 100644 --- a/src/web-server/routes/cursor-settings-routes.ts +++ b/src/web-server/routes/cursor-settings-routes.ts @@ -16,6 +16,24 @@ import { const router = Router(); +function parseLocalCursorPort(settings: unknown): number | null { + if (typeof settings !== 'object' || settings === null) return null; + const env = (settings as { env?: unknown }).env; + if (typeof env !== 'object' || env === null) return null; + const baseUrl = (env as { ANTHROPIC_BASE_URL?: unknown }).ANTHROPIC_BASE_URL; + if (typeof baseUrl !== 'string' || !baseUrl) return null; + + try { + const parsed = new URL(baseUrl); + if (parsed.hostname !== '127.0.0.1' && parsed.hostname !== 'localhost') return null; + const port = Number(parsed.port); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + return port; + } catch { + return null; + } +} + /** * GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode) */ @@ -108,7 +126,7 @@ router.get('/raw', (_req: Request, res: Response): void => { res.json({ settings: defaultSettings, mtime: Date.now(), - path: settingsPath, + path: '~/.ccs/cursor.settings.json', exists: false, }); return; @@ -121,7 +139,7 @@ router.get('/raw', (_req: Request, res: Response): void => { res.json({ settings, mtime: stat.mtimeMs, - path: settingsPath, + path: '~/.ccs/cursor.settings.json', exists: true, }); } catch (error) { @@ -158,7 +176,16 @@ router.put('/raw', (req: Request, res: Response): void => { 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) + // Keep unified config aligned with raw settings edits (parity with Copilot raw editor). + const parsedPort = parseLocalCursorPort(settings); + if (parsedPort) { + const config = loadOrCreateUnifiedConfig(); + config.cursor = { + ...(config.cursor ?? DEFAULT_CURSOR_CONFIG), + port: parsedPort, + }; + saveUnifiedConfig(config); + } const stat = fs.statSync(settingsPath); res.json({ success: true, mtime: stat.mtimeMs }); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index e6480b9d..92b9fa3b 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -17,6 +17,7 @@ import { } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; +import { loadCredentials } from '../../../src/cursor/cursor-auth'; // Test isolation let originalCcsHome: string | undefined; @@ -121,19 +122,19 @@ describe('removePidFile', () => { describe('startDaemon', () => { it('rejects invalid port (0)', async () => { - const result = await startDaemon({ port: 0, model: 'test' }); + const result = await startDaemon({ port: 0 }); 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' }); + const result = await startDaemon({ port: 65536 }); 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' }); + const result = await startDaemon({ port: 3.14 }); expect(result.success).toBe(false); expect(result.error).toContain('Invalid port'); }); @@ -142,7 +143,7 @@ describe('startDaemon', () => { 'starts and stops daemon successfully', async () => { const port = 10000 + Math.floor(Math.random() * 50000); - const result = await startDaemon({ port, model: 'test' }); + const result = await startDaemon({ port, ghost_mode: true }); expect(result.success).toBe(true); expect(result.pid).toBeDefined(); @@ -150,6 +151,17 @@ describe('startDaemon', () => { const running = await isDaemonRunning(port); expect(running).toBe(true); + // Verify chat endpoint exists (requires auth, should not be 404) + const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(chatResponse.status).toBe(401); + // Stop const stopResult = await stopDaemon(); expect(stopResult.success).toBe(true); @@ -212,4 +224,25 @@ describe('handleCursorCommand', () => { const exitCode = await handleCursorCommand(['nonexistent']); expect(exitCode).toBe(1); }); + + it('supports manual auth import from CLI flags', async () => { + const token = 'a'.repeat(60); + const machineId = '1234567890abcdef1234567890abcdef'; + + const exitCode = await handleCursorCommand([ + 'auth', + '--manual', + '--token', + token, + '--machine-id', + machineId, + ]); + + expect(exitCode).toBe(0); + + const credentials = loadCredentials(); + expect(credentials).not.toBeNull(); + expect(credentials?.authMethod).toBe('manual'); + expect(credentials?.machineId).toBe(machineId); + }); }); diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index f998d2b6..e510a590 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -29,8 +29,8 @@ describe('DEFAULT_CURSOR_MODELS', () => { }); describe('DEFAULT_CURSOR_PORT', () => { - it('is 4242', () => { - expect(DEFAULT_CURSOR_PORT).toBe(4242); + it('is 20129', () => { + expect(DEFAULT_CURSOR_PORT).toBe(20129); }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 846e3ead..66ff5a40 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -26,6 +26,7 @@ const CliproxyControlPanelPage = lazy(() => import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage })) ); const CopilotPage = lazy(() => import('@/pages/copilot').then((m) => ({ default: m.CopilotPage }))); +const CursorPage = lazy(() => import('@/pages/cursor').then((m) => ({ default: m.CursorPage }))); const AccountsPage = lazy(() => import('@/pages/accounts').then((m) => ({ default: m.AccountsPage })) ); @@ -99,6 +100,14 @@ export default function App() { } /> + }> + + + } + /> ; + }; + mtime: number; + path: string; + exists: boolean; +} + +interface CursorModelsResponse { + models: CursorModel[]; + current: string; +} + +interface CursorAuthResult { + success: boolean; + message: string; +} + +async function fetchCursorStatus(): Promise { + const res = await fetch(`${API_BASE}/cursor/status`); + if (!res.ok) throw new Error('Failed to fetch cursor status'); + return res.json(); +} + +async function fetchCursorConfig(): Promise { + const res = await fetch(`${API_BASE}/cursor/settings`); + if (!res.ok) throw new Error('Failed to fetch cursor config'); + return res.json(); +} + +async function fetchCursorModels(): Promise { + const res = await fetch(`${API_BASE}/cursor/models`); + if (!res.ok) throw new Error('Failed to fetch cursor models'); + return res.json(); +} + +async function fetchCursorRawSettings(): Promise { + const res = await fetch(`${API_BASE}/cursor/settings/raw`); + if (!res.ok) throw new Error('Failed to fetch cursor raw settings'); + return res.json(); +} + +async function updateCursorConfig( + updates: Partial +): Promise<{ success: boolean; cursor: CursorConfig }> { + const res = await fetch(`${API_BASE}/cursor/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }); + if (!res.ok) throw new Error('Failed to update cursor config'); + return res.json(); +} + +async function saveCursorRawSettings(data: { + settings: CursorRawSettings['settings']; + expectedMtime?: number; +}): Promise<{ success: boolean; mtime: number }> { + const res = await fetch(`${API_BASE}/cursor/settings/raw`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.status === 409) throw new Error('CONFLICT'); + if (!res.ok) throw new Error('Failed to save cursor raw settings'); + return res.json(); +} + +async function autoDetectCursorAuth(): Promise { + const res = await fetch(`${API_BASE}/cursor/auth/auto-detect`, { method: 'POST' }); + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Auto-detect failed' })); + throw new Error(error.error || 'Auto-detect failed'); + } + return res.json(); +} + +async function importCursorAuthManual(data: { + accessToken: string; + machineId: string; +}): Promise { + const res = await fetch(`${API_BASE}/cursor/auth/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Manual import failed' })); + throw new Error(error.error || 'Manual import failed'); + } + return res.json(); +} + +async function startCursorDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> { + const res = await fetch(`${API_BASE}/cursor/daemon/start`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to start cursor daemon'); + return res.json(); +} + +async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }> { + const res = await fetch(`${API_BASE}/cursor/daemon/stop`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to stop cursor daemon'); + return res.json(); +} + +export function useCursor() { + const queryClient = useQueryClient(); + + const statusQuery = useQuery({ + queryKey: ['cursor-status'], + queryFn: fetchCursorStatus, + refetchInterval: 5000, + }); + + const configQuery = useQuery({ + queryKey: ['cursor-config'], + queryFn: fetchCursorConfig, + }); + + const modelsQuery = useQuery({ + queryKey: ['cursor-models'], + queryFn: fetchCursorModels, + }); + + const rawSettingsQuery = useQuery({ + queryKey: ['cursor-raw-settings'], + queryFn: fetchCursorRawSettings, + }); + + const invalidateCursorQueries = () => { + queryClient.invalidateQueries({ queryKey: ['cursor-status'] }); + queryClient.invalidateQueries({ queryKey: ['cursor-config'] }); + queryClient.invalidateQueries({ queryKey: ['cursor-models'] }); + queryClient.invalidateQueries({ queryKey: ['cursor-raw-settings'] }); + }; + + const updateConfigMutation = useMutation({ + mutationFn: updateCursorConfig, + onSuccess: invalidateCursorQueries, + }); + + const saveRawSettingsMutation = useMutation({ + mutationFn: saveCursorRawSettings, + onSuccess: invalidateCursorQueries, + }); + + const autoDetectAuthMutation = useMutation({ + mutationFn: autoDetectCursorAuth, + onSuccess: invalidateCursorQueries, + }); + + const manualAuthMutation = useMutation({ + mutationFn: importCursorAuthManual, + onSuccess: invalidateCursorQueries, + }); + + const startDaemonMutation = useMutation({ + mutationFn: startCursorDaemon, + onSuccess: invalidateCursorQueries, + }); + + const stopDaemonMutation = useMutation({ + mutationFn: stopCursorDaemon, + onSuccess: invalidateCursorQueries, + }); + + return useMemo( + () => ({ + status: statusQuery.data, + statusLoading: statusQuery.isLoading, + statusError: statusQuery.error, + refetchStatus: statusQuery.refetch, + + config: configQuery.data, + configLoading: configQuery.isLoading, + + models: modelsQuery.data?.models ?? [], + currentModel: modelsQuery.data?.current ?? null, + modelsLoading: modelsQuery.isLoading, + + rawSettings: rawSettingsQuery.data, + rawSettingsLoading: rawSettingsQuery.isLoading, + refetchRawSettings: rawSettingsQuery.refetch, + + updateConfig: updateConfigMutation.mutate, + updateConfigAsync: updateConfigMutation.mutateAsync, + isUpdatingConfig: updateConfigMutation.isPending, + + saveRawSettings: saveRawSettingsMutation.mutate, + saveRawSettingsAsync: saveRawSettingsMutation.mutateAsync, + isSavingRawSettings: saveRawSettingsMutation.isPending, + + autoDetectAuth: autoDetectAuthMutation.mutate, + autoDetectAuthAsync: autoDetectAuthMutation.mutateAsync, + isAutoDetectingAuth: autoDetectAuthMutation.isPending, + autoDetectAuthResult: autoDetectAuthMutation.data, + + importManualAuth: manualAuthMutation.mutate, + importManualAuthAsync: manualAuthMutation.mutateAsync, + isImportingManualAuth: manualAuthMutation.isPending, + manualAuthResult: manualAuthMutation.data, + + startDaemon: startDaemonMutation.mutate, + startDaemonAsync: startDaemonMutation.mutateAsync, + isStartingDaemon: startDaemonMutation.isPending, + + stopDaemon: stopDaemonMutation.mutate, + stopDaemonAsync: stopDaemonMutation.mutateAsync, + isStoppingDaemon: stopDaemonMutation.isPending, + }), + [ + statusQuery.data, + statusQuery.isLoading, + statusQuery.error, + statusQuery.refetch, + configQuery.data, + configQuery.isLoading, + modelsQuery.data, + modelsQuery.isLoading, + rawSettingsQuery.data, + rawSettingsQuery.isLoading, + rawSettingsQuery.refetch, + updateConfigMutation.mutate, + updateConfigMutation.mutateAsync, + updateConfigMutation.isPending, + saveRawSettingsMutation.mutate, + saveRawSettingsMutation.mutateAsync, + saveRawSettingsMutation.isPending, + autoDetectAuthMutation.mutate, + autoDetectAuthMutation.mutateAsync, + autoDetectAuthMutation.isPending, + autoDetectAuthMutation.data, + manualAuthMutation.mutate, + manualAuthMutation.mutateAsync, + manualAuthMutation.isPending, + manualAuthMutation.data, + startDaemonMutation.mutate, + startDaemonMutation.mutateAsync, + startDaemonMutation.isPending, + stopDaemonMutation.mutate, + stopDaemonMutation.mutateAsync, + stopDaemonMutation.isPending, + ] + ); +} diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx new file mode 100644 index 00000000..880e2685 --- /dev/null +++ b/ui/src/pages/cursor.tsx @@ -0,0 +1,620 @@ +/** + * Cursor Page + * Dedicated dashboard surface for Cursor integration. + */ + +import { useMemo, useState, type ElementType } from 'react'; +import { toast } from 'sonner'; +import { + Bot, + CheckCircle2, + FileCode, + Key, + Loader2, + Play, + Power, + PowerOff, + RefreshCw, + Save, + Server, + ShieldCheck, + XCircle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useCursor } from '@/hooks/use-cursor'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Badge } from '@/components/ui/badge'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { CodeEditor } from '@/components/shared/code-editor'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +function StatusItem({ + icon: Icon, + label, + ok, + detail, +}: { + icon: ElementType; + label: string; + ok: boolean; + detail: string; +}) { + return ( +
+ +
+

{label}

+
+
+ {ok ? ( + + ) : ( + + )} + + {detail} + +
+
+ ); +} + +export function CursorPage() { + const { + status, + statusLoading, + refetchStatus, + config, + updateConfigAsync, + isUpdatingConfig, + models, + modelsLoading, + currentModel, + rawSettings, + rawSettingsLoading, + refetchRawSettings, + saveRawSettingsAsync, + isSavingRawSettings, + autoDetectAuthAsync, + isAutoDetectingAuth, + importManualAuthAsync, + isImportingManualAuth, + startDaemonAsync, + isStartingDaemon, + stopDaemonAsync, + isStoppingDaemon, + } = useCursor(); + + const [configDraft, setConfigDraft] = useState<{ + port: string; + auto_start: boolean; + ghost_mode: boolean; + }>({ + port: '20129', + auto_start: false, + ghost_mode: true, + }); + const [configDirty, setConfigDirty] = useState(false); + const [rawConfigText, setRawConfigText] = useState('{}'); + const [rawConfigDirty, setRawConfigDirty] = useState(false); + const [manualAuthOpen, setManualAuthOpen] = useState(false); + const [manualToken, setManualToken] = useState(''); + const [manualMachineId, setManualMachineId] = useState(''); + + const effectivePort = configDirty ? configDraft.port : String(config?.port ?? 20129); + const effectiveAutoStart = configDirty ? configDraft.auto_start : Boolean(config?.auto_start); + const effectiveGhostMode = configDirty + ? configDraft.ghost_mode + : Boolean(config?.ghost_mode ?? true); + const effectiveRawConfigText = rawConfigDirty + ? rawConfigText + : JSON.stringify(rawSettings?.settings ?? {}, null, 2); + + const canStart = Boolean(status?.enabled && status?.authenticated && !status?.token_expired); + const integrationBadge = useMemo( + () => (status?.enabled ? Enabled : Disabled), + [status?.enabled] + ); + + const handleSaveConfig = async () => { + const parsedPort = Number(effectivePort); + if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { + toast.error('Port must be an integer between 1 and 65535'); + return; + } + + try { + await updateConfigAsync({ + port: parsedPort, + auto_start: effectiveAutoStart, + ghost_mode: effectiveGhostMode, + }); + setConfigDirty(false); + setConfigDraft({ + port: String(parsedPort), + auto_start: effectiveAutoStart, + ghost_mode: effectiveGhostMode, + }); + toast.success('Cursor config saved'); + } catch (error) { + toast.error((error as Error).message || 'Failed to save config'); + } + }; + + const handleToggleEnabled = async (enabled: boolean) => { + try { + await updateConfigAsync({ enabled }); + toast.success(enabled ? 'Cursor integration enabled' : 'Cursor integration disabled'); + } catch (error) { + toast.error((error as Error).message || 'Failed to update integration state'); + } + }; + + const handleAutoDetectAuth = async () => { + try { + await autoDetectAuthAsync(); + toast.success('Cursor credentials imported'); + } catch (error) { + toast.error((error as Error).message || 'Auto-detect failed'); + } + }; + + const handleManualAuthImport = async () => { + if (!manualToken.trim() || !manualMachineId.trim()) { + toast.error('Token and machine ID are required'); + return; + } + + try { + await importManualAuthAsync({ + accessToken: manualToken.trim(), + machineId: manualMachineId.trim(), + }); + toast.success('Cursor credentials imported'); + setManualAuthOpen(false); + setManualToken(''); + setManualMachineId(''); + } catch (error) { + toast.error((error as Error).message || 'Manual import failed'); + } + }; + + const handleStartDaemon = async () => { + try { + const result = await startDaemonAsync(); + if (!result.success) { + toast.error(result.error || 'Failed to start daemon'); + return; + } + toast.success(`Daemon started${result.pid ? ` (PID: ${result.pid})` : ''}`); + } catch (error) { + toast.error((error as Error).message || 'Failed to start daemon'); + } + }; + + const handleStopDaemon = async () => { + try { + const result = await stopDaemonAsync(); + if (!result.success) { + toast.error(result.error || 'Failed to stop daemon'); + return; + } + toast.success('Daemon stopped'); + } catch (error) { + toast.error((error as Error).message || 'Failed to stop daemon'); + } + }; + + const handleSaveRawSettings = async () => { + let parsed: unknown; + try { + parsed = JSON.parse(effectiveRawConfigText || '{}'); + } catch (error) { + toast.error((error as Error).message || 'Invalid JSON'); + return; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + toast.error('Raw settings must be a JSON object'); + return; + } + + try { + await saveRawSettingsAsync({ + settings: parsed as { env?: Record }, + expectedMtime: rawSettings?.mtime, + }); + setRawConfigDirty(false); + toast.success('Raw settings saved'); + } catch (error) { + const message = (error as Error).message || 'Failed to save raw settings'; + if (message === 'CONFLICT') { + toast.error('Raw settings changed externally. Refresh and retry.'); + } else { + toast.error(message); + } + } + }; + + return ( + <> +
+
+
+
+
+ +

Cursor

+ {integrationBadge} +
+ +
+

Dedicated Cursor integration controls

+
+ + +
+
+ + + +
+ +
+

+ Actions +

+ + {status?.enabled ? ( + + ) : ( + + )} + + + + + + {status?.daemon_running ? ( + + ) : ( + + )} +
+
+
+ +
+
+ Port + {status?.port ?? config?.port ?? 20129} +
+
+
+ +
+
+ + + Config + Models + Raw Settings + + + + + + Cursor Runtime Config + + Controls daemon behavior for local Cursor OpenAI-compatible endpoint. + + + +
+ + { + setConfigDirty(true); + setConfigDraft((prev) => ({ ...prev, port: e.target.value })); + }} + /> +
+ +
+
+ +

+ Start cursor daemon automatically when integration is used. +

+
+ { + setConfigDirty(true); + setConfigDraft((prev) => ({ ...prev, auto_start: value })); + }} + /> +
+ +
+
+ +

+ Requests `x-ghost-mode` to reduce telemetry. +

+
+ { + setConfigDirty(true); + setConfigDraft((prev) => ({ ...prev, ghost_mode: value })); + }} + /> +
+ +
+ +
+
+
+
+ + + + + Available Models + + Cursor forwards the requested model from each client request. + + + + {modelsLoading ? ( +
+ + Loading models... +
+ ) : ( +
+ {models.map((model) => ( +
+
+

{model.id}

+

+ {model.name} • {model.provider} +

+
+
+ {model.id === currentModel && Default} +
+
+ ))} +
+ )} +
+
+
+ + + + + + + cursor.settings.json + + + {rawSettings?.path ?? '~/.ccs/cursor.settings.json'} + + + + { + setRawConfigDirty(true); + setRawConfigText(value); + }} + language="json" + /> + +
+ + +
+
+
+
+
+
+
+
+ + + + + Manual Cursor Auth Import + + Provide Cursor access token and machine ID if auto-detect is unavailable. + + + +
+
+ + setManualToken(e.target.value)} + placeholder="Paste Cursor access token" + /> +
+
+ + setManualMachineId(e.target.value)} + placeholder="32-char hex (UUID without hyphens)" + /> +
+
+ + + + + +
+
+ + ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index d8d447d3..03ba32b3 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -13,3 +13,5 @@ export { HealthPage } from './health'; export { SharedPage } from './shared'; export { AnalyticsPage } from './analytics'; + +export { CursorPage } from './cursor';