feat(cursor): complete daemon wiring and add dedicated dashboard page

This commit is contained in:
Tam Nhu Tran
2026-02-14 06:22:28 +07:00
parent 6c2083949f
commit be9d7cf73e
18 changed files with 1581 additions and 212 deletions
+2
View File
@@ -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)
+115
View File
@@ -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 <subcommand> [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 <t> --machine-id <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');
}
+136 -126
View File
@@ -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<number> {
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<number> {
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<number> {
}
}
/**
* Show help for cursor commands.
*/
function handleHelp(): number {
console.log('Cursor IDE Integration');
console.log('');
console.log('Usage: ccs cursor <subcommand>');
console.log('');
console.log('Subcommands:');
console.log(' auth Import Cursor IDE authentication token');
console.log(' status Show authentication and daemon status');
console.log(' models List available models');
console.log(' start Start cursor daemon');
console.log(' stop Stop cursor daemon');
console.log(' help Show this help message');
console.log('');
console.log('Quick start:');
console.log(' 1. ccs cursor auth # Import Cursor IDE token');
console.log(' 2. ccs cursor start # Start daemon');
console.log(' 3. Use cursor models # Via daemon on configured port');
console.log('');
console.log('Or use the web UI: ccs config → Cursor tab');
console.log('');
return 0;
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<number> {
async function handleAuth(args: string[]): Promise<number> {
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 <token> --machine-id <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<number> {
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 <token> --machine-id <machine-id>');
console.log('');
// For now, just show instructions
// Manual import flow will be implemented when needed
console.error(fail('Manual import not yet implemented'));
console.error('');
console.error('Use auto-detection for now or wait for manual import feature.');
return 1;
}
/**
* Handle status subcommand.
*/
async function handleStatus(): Promise<number> {
// TODO: Load from unified config when #521 is complete
const cursorConfig = DEFAULT_CURSOR_CONFIG;
const 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<number> {
// TODO: Load from unified config when #521 is complete
const cursorConfig = DEFAULT_CURSOR_CONFIG;
console.log('Available Cursor Models');
console.log('───────────────────────');
console.log('');
const 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<number> {
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
// 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<number> {
return 1;
}
}
/**
* Handle enable subcommand.
*/
async function handleEnable(): Promise<number> {
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<number> {
const config = loadOrCreateUnifiedConfig();
if (config.cursor) {
config.cursor.enabled = false;
saveUnifiedConfig(config);
}
console.log(ok('Cursor integration disabled'));
return 0;
}
+3
View File
@@ -232,10 +232,13 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
[
['ccs cursor <cmd>', 'Use Cursor IDE integration'],
['ccs cursor auth', 'Import Cursor token'],
['ccs cursor auth --manual --token <t> --machine-id <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'],
]
);
+283
View File
@@ -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<unknown> {
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<string, unknown>;
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<void> {
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<Uint8Array>);
await new Promise<void>((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);
}
+29 -25
View File
@@ -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,
});
+1 -1
View File
@@ -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';
+8 -1
View File
@@ -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 {
+3 -4
View File
@@ -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;
}
/**
+16 -46
View File
@@ -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<string[]> {
// 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<void> => {
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<v
*/
router.get('/models', async (_req: Request, res: Response): Promise<void> => {
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<void> => {
router.post('/daemon/start', async (_req: Request, res: Response): Promise<void> => {
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 });
@@ -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 });
+37 -4
View File
@@ -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);
});
});
+2 -2
View File
@@ -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);
});
});
+9
View File
@@ -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() {
</Suspense>
}
/>
<Route
path="/cursor"
element={
<Suspense fallback={<PageLoader />}>
<CursorPage />
</Suspense>
}
/>
<Route
path="/accounts"
element={
+2
View File
@@ -11,6 +11,7 @@ import {
BarChart3,
Gauge,
Github,
Bot,
} from 'lucide-react';
import {
Sidebar,
@@ -63,6 +64,7 @@ const navGroups = [
],
},
{ path: '/copilot', icon: Github, label: 'GitHub Copilot' },
{ path: '/cursor', icon: Bot, label: 'Cursor IDE' },
{
path: '/accounts',
icon: Users,
+283
View File
@@ -0,0 +1,283 @@
/**
* Cursor API Hook
*
* React hook for managing Cursor integration state.
*/
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
export interface CursorStatus {
enabled: boolean;
authenticated: boolean;
auth_method: 'auto-detect' | 'manual' | null;
token_age: number | null;
token_expired: boolean;
daemon_running: boolean;
port: number;
auto_start: boolean;
ghost_mode: boolean;
}
export interface CursorConfig {
enabled: boolean;
port: number;
auto_start: boolean;
ghost_mode: boolean;
}
export interface CursorModel {
id: string;
name: string;
provider: string;
isDefault?: boolean;
}
export interface CursorRawSettings {
settings: {
env?: Record<string, string>;
};
mtime: number;
path: string;
exists: boolean;
}
interface CursorModelsResponse {
models: CursorModel[];
current: string;
}
interface CursorAuthResult {
success: boolean;
message: string;
}
async function fetchCursorStatus(): Promise<CursorStatus> {
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<CursorConfig> {
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<CursorModelsResponse> {
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<CursorRawSettings> {
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<CursorConfig>
): 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<CursorAuthResult> {
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<CursorAuthResult> {
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,
]
);
}
+620
View File
@@ -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 (
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-3 py-2">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm">{label}</p>
</div>
<div className="flex items-center gap-1.5">
{ok ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
<span
className={cn('text-xs', ok ? 'text-green-500' : 'text-muted-foreground')}
title={detail}
>
{detail}
</span>
</div>
</div>
);
}
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<string>('{}');
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 ? <Badge>Enabled</Badge> : <Badge variant="secondary">Disabled</Badge>),
[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<string, string> },
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 (
<>
<div className="h-[calc(100vh-100px)] flex">
<div className="w-80 border-r flex flex-col bg-muted/30 shrink-0">
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Bot className="w-5 h-5 text-primary" />
<h1 className="font-semibold">Cursor</h1>
{integrationBadge}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => refetchStatus()}
disabled={statusLoading}
>
<RefreshCw className={cn('w-4 h-4', statusLoading && 'animate-spin')} />
</Button>
</div>
<p className="text-xs text-muted-foreground">Dedicated Cursor integration controls</p>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-4">
<div className="space-y-2">
<StatusItem
icon={ShieldCheck}
label="Integration"
ok={Boolean(status?.enabled)}
detail={status?.enabled ? 'Enabled' : 'Disabled'}
/>
<StatusItem
icon={Key}
label="Authentication"
ok={Boolean(status?.authenticated && !status?.token_expired)}
detail={
status?.authenticated
? status?.token_expired
? 'Expired'
: (status.auth_method ?? 'Connected')
: 'Not connected'
}
/>
<StatusItem
icon={Server}
label="Daemon"
ok={Boolean(status?.daemon_running)}
detail={status?.daemon_running ? 'Running' : 'Stopped'}
/>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Actions
</p>
{status?.enabled ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleToggleEnabled(false)}
disabled={isUpdatingConfig}
>
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
Disable Integration
</Button>
) : (
<Button
size="sm"
className="w-full"
onClick={() => handleToggleEnabled(true)}
disabled={isUpdatingConfig}
>
<Power className="w-3.5 h-3.5 mr-1.5" />
Enable Integration
</Button>
)}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleAutoDetectAuth}
disabled={isAutoDetectingAuth}
>
{isAutoDetectingAuth ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
) : (
<Key className="w-3.5 h-3.5 mr-1.5" />
)}
Auto-detect Auth
</Button>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setManualAuthOpen(true)}
>
<Key className="w-3.5 h-3.5 mr-1.5" />
Manual Auth Import
</Button>
{status?.daemon_running ? (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleStopDaemon}
disabled={isStoppingDaemon}
>
{isStoppingDaemon ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
) : (
<PowerOff className="w-3.5 h-3.5 mr-1.5" />
)}
Stop Daemon
</Button>
) : (
<Button
size="sm"
className="w-full"
onClick={handleStartDaemon}
disabled={!canStart}
>
{isStartingDaemon ? (
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />
) : (
<Play className="w-3.5 h-3.5 mr-1.5" />
)}
Start Daemon
</Button>
)}
</div>
</div>
</ScrollArea>
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>Port</span>
<span>{status?.port ?? config?.port ?? 20129}</span>
</div>
</div>
</div>
<div className="flex-1 min-w-0 bg-background overflow-hidden">
<div className="h-full overflow-auto p-4 md:p-6">
<Tabs defaultValue="config" className="space-y-4">
<TabsList className="grid grid-cols-3 w-full max-w-md">
<TabsTrigger value="config">Config</TabsTrigger>
<TabsTrigger value="models">Models</TabsTrigger>
<TabsTrigger value="raw">Raw Settings</TabsTrigger>
</TabsList>
<TabsContent value="config" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Cursor Runtime Config</CardTitle>
<CardDescription>
Controls daemon behavior for local Cursor OpenAI-compatible endpoint.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<Label htmlFor="cursor-port">Port</Label>
<Input
id="cursor-port"
type="number"
min={1}
max={65535}
value={effectivePort}
onChange={(e) => {
setConfigDirty(true);
setConfigDraft((prev) => ({ ...prev, port: e.target.value }));
}}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="cursor-auto-start">Auto-start</Label>
<p className="text-xs text-muted-foreground">
Start cursor daemon automatically when integration is used.
</p>
</div>
<Switch
id="cursor-auto-start"
checked={effectiveAutoStart}
onCheckedChange={(value) => {
setConfigDirty(true);
setConfigDraft((prev) => ({ ...prev, auto_start: value }));
}}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="cursor-ghost-mode">Ghost Mode</Label>
<p className="text-xs text-muted-foreground">
Requests `x-ghost-mode` to reduce telemetry.
</p>
</div>
<Switch
id="cursor-ghost-mode"
checked={effectiveGhostMode}
onCheckedChange={(value) => {
setConfigDirty(true);
setConfigDraft((prev) => ({ ...prev, ghost_mode: value }));
}}
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveConfig} disabled={isUpdatingConfig}>
{isUpdatingConfig ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
Save Config
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="models" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Available Models</CardTitle>
<CardDescription>
Cursor forwards the requested model from each client request.
</CardDescription>
</CardHeader>
<CardContent>
{modelsLoading ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
Loading models...
</div>
) : (
<div className="space-y-2">
{models.map((model) => (
<div
key={model.id}
className="rounded-lg border px-3 py-2 flex items-center justify-between"
>
<div>
<p className="text-sm font-medium">{model.id}</p>
<p className="text-xs text-muted-foreground">
{model.name} {model.provider}
</p>
</div>
<div className="flex items-center gap-2">
{model.id === currentModel && <Badge>Default</Badge>}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="raw" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-primary" />
cursor.settings.json
</CardTitle>
<CardDescription>
{rawSettings?.path ?? '~/.ccs/cursor.settings.json'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<CodeEditor
value={effectiveRawConfigText}
onChange={(value) => {
setRawConfigDirty(true);
setRawConfigText(value);
}}
language="json"
/>
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => {
setRawConfigDirty(false);
refetchRawSettings();
}}
disabled={rawSettingsLoading}
>
<RefreshCw
className={cn('w-4 h-4 mr-2', rawSettingsLoading && 'animate-spin')}
/>
Refresh
</Button>
<Button onClick={handleSaveRawSettings} disabled={isSavingRawSettings}>
{isSavingRawSettings ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
Save Raw Settings
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>
</div>
<Dialog open={manualAuthOpen} onOpenChange={setManualAuthOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Manual Cursor Auth Import</DialogTitle>
<DialogDescription>
Provide Cursor access token and machine ID if auto-detect is unavailable.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="cursor-manual-token">Access Token</Label>
<Input
id="cursor-manual-token"
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
placeholder="Paste Cursor access token"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cursor-manual-machine-id">Machine ID</Label>
<Input
id="cursor-manual-machine-id"
value={manualMachineId}
onChange={(e) => setManualMachineId(e.target.value)}
placeholder="32-char hex (UUID without hyphens)"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setManualAuthOpen(false)}>
Cancel
</Button>
<Button onClick={handleManualAuthImport} disabled={isImportingManualAuth}>
{isImportingManualAuth ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Key className="w-4 h-4 mr-2" />
)}
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+2
View File
@@ -13,3 +13,5 @@ export { HealthPage } from './health';
export { SharedPage } from './shared';
export { AnalyticsPage } from './analytics';
export { CursorPage } from './cursor';