feat(cursor): add config integration and dashboard routes

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