mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
feat(cursor): complete daemon wiring and add dedicated dashboard page
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,3 +13,5 @@ export { HealthPage } from './health';
|
||||
export { SharedPage } from './shared';
|
||||
|
||||
export { AnalyticsPage } from './analytics';
|
||||
|
||||
export { CursorPage } from './cursor';
|
||||
|
||||
Reference in New Issue
Block a user