chore(merge): resolve conflicts with origin/dev

This commit is contained in:
Tam Nhu Tran committed 2026-02-12 16:25:25 +07:00
commit 2816c6652d
37 files changed
+3939 -92

No files matched your search

+6 -13
View File
@@ -22,21 +22,14 @@ import {
} from '../config/unified-config-types';
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
import { getCcsDir } from '../utils/config-manager';
import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default';
/** CLIProxy profile names (OAuth-based, zero config) */
export const CLIPROXY_PROFILES = [
'gemini',
'codex',
'agy',
'qwen',
'iflow',
'kiro',
'ghcp',
'claude',
] as const;
export type CLIProxyProfileName = (typeof CLIPROXY_PROFILES)[number];
export const CLIPROXY_PROFILES: readonly CLIProxyProvider[] = CLIPROXY_PROVIDER_IDS;
export type CLIProxyProfileName = CLIProxyProvider;
export interface ProfileDetectionResult {
type: ProfileType;
@@ -250,11 +243,11 @@ class ProfileDetector {
}
// Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config
if (CLIPROXY_PROFILES.includes(profileName as CLIProxyProfileName)) {
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName as CLIProxyProfileName,
provider: profileName,
};
}
+8
View File
@@ -532,6 +532,14 @@ async function main(): Promise<void> {
return;
}
// Special case: cursor command (Cursor IDE integration)
// All `ccs cursor *` routes to cursor command handler — cursor has no profile-switching mode
if (firstArg === 'cursor') {
const { handleCursorCommand } = await import('./commands/cursor-command');
const exitCode = await handleCursorCommand(args.slice(1));
process.exit(exitCode);
}
// Special case: copilot command (GitHub Copilot integration)
// Only route to command handler for known subcommands, otherwise treat as profile
const COPILOT_SUBCOMMANDS = [
+113
View File
@@ -0,0 +1,113 @@
import type { CLIProxyProvider } from './types';
export type OAuthFlowType = 'authorization_code' | 'device_code';
export interface ProviderCapabilities {
displayName: string;
oauthFlow: OAuthFlowType;
callbackPort: number | null;
/**
* Alternative provider names used by CLIProxyAPI or stats endpoints.
* These aliases normalize external names to canonical CCS provider IDs.
*/
aliases: readonly string[];
}
export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilities> = {
gemini: {
displayName: 'Google Gemini',
oauthFlow: 'authorization_code',
callbackPort: 8085,
aliases: ['gemini-cli'],
},
codex: {
displayName: 'Codex',
oauthFlow: 'authorization_code',
callbackPort: 1455,
aliases: [],
},
agy: {
displayName: 'AntiGravity',
oauthFlow: 'authorization_code',
callbackPort: 51121,
aliases: ['antigravity'],
},
qwen: {
displayName: 'Qwen',
oauthFlow: 'device_code',
callbackPort: null,
aliases: [],
},
iflow: {
displayName: 'iFlow',
oauthFlow: 'authorization_code',
callbackPort: 11451,
aliases: [],
},
kiro: {
displayName: 'Kiro (AWS)',
oauthFlow: 'authorization_code',
callbackPort: 9876,
aliases: ['codewhisperer'],
},
ghcp: {
displayName: 'GitHub Copilot (OAuth)',
oauthFlow: 'device_code',
callbackPort: null,
aliases: ['github-copilot', 'copilot'],
},
claude: {
displayName: 'Claude',
oauthFlow: 'authorization_code',
callbackPort: 54545,
aliases: ['anthropic'],
},
};
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[]
);
const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS);
const PROVIDER_ALIAS_MAP: ReadonlyMap<string, CLIProxyProvider> = (() => {
const entries: Array<[string, CLIProxyProvider]> = [];
for (const provider of CLIPROXY_PROVIDER_IDS) {
entries.push([provider, provider]);
for (const alias of PROVIDER_CAPABILITIES[provider].aliases) {
entries.push([alias.toLowerCase(), provider]);
}
}
return new Map(entries);
})();
export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider {
return PROVIDER_ID_SET.has(provider as CLIProxyProvider);
}
export function getProviderCapabilities(provider: CLIProxyProvider): ProviderCapabilities {
return PROVIDER_CAPABILITIES[provider];
}
export function getProviderDisplayName(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].displayName;
}
export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvider[] {
return CLIPROXY_PROVIDER_IDS.filter(
(provider) => PROVIDER_CAPABILITIES[provider].oauthFlow === flowType
);
}
export function getOAuthFlowType(provider: CLIProxyProvider): OAuthFlowType {
return PROVIDER_CAPABILITIES[provider].oauthFlow;
}
export function getOAuthCallbackPort(provider: CLIProxyProvider): number | null {
return PROVIDER_CAPABILITIES[provider].callbackPort;
}
export function mapExternalProviderName(providerName: string): CLIProxyProvider | null {
const normalized = providerName.toLowerCase();
return PROVIDER_ALIAS_MAP.get(normalized) ?? null;
}
+5 -29
View File
@@ -9,6 +9,8 @@ import {
buildManagementHeaders,
ProxyTarget,
} from './proxy-target-resolver';
import { getProviderDisplayName, mapExternalProviderName } from './provider-capabilities';
import type { CLIProxyProvider } from './types';
/** Timeout for remote fetch requests (ms) */
const REMOTE_FETCH_TIMEOUT_MS = 5000;
@@ -43,32 +45,6 @@ export interface RemoteAuthStatus {
source: 'remote';
}
/** Map CLIProxyAPI provider names to CCS internal names */
const PROVIDER_MAP: Record<string, string> = {
gemini: 'gemini',
'gemini-cli': 'gemini', // CLIProxyAPI uses 'gemini-cli' for Gemini CLI auth
antigravity: 'agy',
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
kiro: 'kiro',
codewhisperer: 'kiro', // CLIProxyAPI may use 'codewhisperer' for Kiro
ghcp: 'ghcp',
'github-copilot': 'ghcp',
copilot: 'ghcp',
};
/** Display names for providers */
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
gemini: 'Google Gemini',
agy: 'AntiGravity',
codex: 'Codex',
qwen: 'Qwen',
iflow: 'iFlow',
kiro: 'Kiro (AWS)',
ghcp: 'GitHub Copilot (OAuth)',
};
/**
* Fetch auth status from remote CLIProxyAPI
* @throws Error if remote is unreachable or returns error
@@ -124,10 +100,10 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
* @param files Array of auth files from remote API
*/
function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
const byProvider = new Map<string, RemoteAuthFile[]>();
const byProvider = new Map<CLIProxyProvider, RemoteAuthFile[]>();
for (const file of files) {
const provider = PROVIDER_MAP[file.provider.toLowerCase()];
const provider = mapExternalProviderName(file.provider);
if (!provider) {
// Unknown provider, skip (could add logging in debug mode)
continue;
@@ -154,7 +130,7 @@ function transformRemoteAuthFiles(files: RemoteAuthFile[]): RemoteAuthStatus[] {
result.push({
provider,
displayName: PROVIDER_DISPLAY_NAMES[provider] || provider,
displayName: getProviderDisplayName(provider),
authenticated: activeFiles.length > 0,
tokenFiles: providerFiles.length,
accounts,
@@ -0,0 +1,30 @@
/**
* Command Execution Contract
*
* Standardized lifecycle: parse -> validate -> execute -> render
* for CLI command handlers.
*/
export interface CommandExecutionContract<TParsedArgs, TExecutionResult> {
parse(rawArgs: string[]): TParsedArgs;
validate(parsedArgs: TParsedArgs): void | Promise<void>;
execute(parsedArgs: TParsedArgs): Promise<TExecutionResult> | TExecutionResult;
render(
result: TExecutionResult,
context: { rawArgs: string[]; parsedArgs: TParsedArgs }
): Promise<void> | void;
}
/**
* Run a command through the standard lifecycle.
*/
export async function runCommandWithContract<TParsedArgs, TExecutionResult>(
rawArgs: string[],
contract: CommandExecutionContract<TParsedArgs, TExecutionResult>
): Promise<{ parsedArgs: TParsedArgs; result: TExecutionResult }> {
const parsedArgs = contract.parse(rawArgs);
await contract.validate(parsedArgs);
const result = await contract.execute(parsedArgs);
await contract.render(result, { rawArgs, parsedArgs });
return { parsedArgs, result };
}
+264
View File
@@ -0,0 +1,264 @@
/**
* Cursor CLI Command
*
* Handles `ccs cursor <subcommand>` commands.
*/
import {
autoDetectTokens,
saveCredentials,
checkAuthStatus,
startDaemon,
stopDaemon,
getDaemonStatus,
getAvailableModels,
DEFAULT_CURSOR_PORT,
DEFAULT_CURSOR_MODEL,
} 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,
};
/** Valid cursor subcommands — imported by ccs.ts for routing */
export const CURSOR_SUBCOMMANDS = [
'auth',
'status',
'models',
'start',
'stop',
'help',
'--help',
'-h',
] as const;
/**
* Handle cursor subcommand.
*/
export async function handleCursorCommand(args: string[]): Promise<number> {
const subcommand = args[0];
switch (subcommand) {
case 'auth':
return handleAuth();
case 'status':
return handleStatus();
case 'models':
return handleModels();
case 'start':
return handleStart();
case 'stop':
return handleStop();
case undefined:
case 'help':
case '--help':
case '-h':
return handleHelp();
default:
console.error(fail(`Unknown subcommand: ${subcommand}`));
console.error('');
void handleHelp(); // Print help but keep exit code 1
return 1;
}
}
/**
* 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;
}
/**
* Handle auth subcommand.
*/
async function handleAuth(): Promise<number> {
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) {
saveCredentials({
accessToken: autoResult.accessToken,
machineId: autoResult.machineId,
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');
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.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('');
// 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 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');
}
}
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 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)');
return 0;
}
/**
* Handle start subcommand.
*/
async function handleStart(): Promise<number> {
// TODO: Load from unified config when #521 is complete
const cursorConfig = DEFAULT_CURSOR_CONFIG;
// Check auth first
const authStatus = checkAuthStatus();
if (!authStatus.authenticated) {
console.error(fail('Not authenticated. Run: ccs cursor auth'));
return 1;
}
console.log(info(`Starting cursor daemon on port ${cursorConfig.port}...`));
const result = await startDaemon(cursorConfig);
if (result.success) {
console.log(ok(`Daemon started (PID: ${result.pid})`));
return 0;
} else {
console.error(fail(result.error || 'Failed to start daemon'));
return 1;
}
}
/**
* Handle stop subcommand.
*/
async function handleStop(): Promise<number> {
console.log(info('Stopping cursor daemon...'));
const result = await stopDaemon();
if (result.success) {
console.log(ok('Daemon stopped'));
return 0;
} else {
console.error(fail(result.error || 'Failed to stop daemon'));
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 <cmd>', '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)
// ═══════════════════════════════════════════════════════════════════════════
+68 -30
View File
@@ -5,6 +5,72 @@
*/
import { initUI, header, ok, fail, color } from '../utils/ui';
import {
runCommandWithContract,
type CommandExecutionContract,
} from './command-execution-contract';
type ShellTarget = 'bash' | 'zsh' | 'fish' | 'powershell' | null;
interface ShellCompletionParsedArgs {
targetShell: ShellTarget;
force: boolean;
}
interface ShellCompletionInstallResult {
success: boolean;
alreadyInstalled?: boolean;
message?: string;
reload?: string;
}
interface ShellCompletionInstallerLike {
install(shell: ShellTarget, options: { force: boolean }): ShellCompletionInstallResult;
}
export function parseShellCompletionArgs(args: string[]): ShellCompletionParsedArgs {
let targetShell: ShellTarget = null;
const force = args.includes('--force') || args.includes('-f');
if (args.includes('--bash')) targetShell = 'bash';
else if (args.includes('--zsh')) targetShell = 'zsh';
else if (args.includes('--fish')) targetShell = 'fish';
else if (args.includes('--powershell')) targetShell = 'powershell';
return { targetShell, force };
}
export function createShellCompletionCommandContract(
installer: ShellCompletionInstallerLike
): CommandExecutionContract<ShellCompletionParsedArgs, ShellCompletionInstallResult> {
return {
parse: parseShellCompletionArgs,
validate: () => {
// No validation at this stage to preserve existing behavior exactly.
},
execute: (parsed) => installer.install(parsed.targetShell, { force: parsed.force }),
render: (result, context) => {
if (result.alreadyInstalled && !context.parsedArgs.force) {
console.log(ok('Shell completion already installed'));
console.log(` Use ${color('--force', 'warning')} to reinstall`);
console.log('');
return;
}
console.log(ok('Shell completion installed successfully!'));
console.log('');
console.log(result.message);
console.log('');
console.log(color('To activate:', 'info'));
console.log(` ${result.reload}`);
console.log('');
console.log(color('Then test:', 'info'));
console.log(' ccs <TAB> # See available profiles');
console.log(' ccs auth <TAB> # See auth subcommands');
console.log('');
},
};
}
/**
* Handle shell completion command
@@ -16,38 +82,10 @@ export async function handleShellCompletionCommand(args: string[]): Promise<void
console.log(header('Shell Completion Installer'));
console.log('');
// Parse flags
let targetShell: string | null = null;
const force = args.includes('--force') || args.includes('-f');
if (args.includes('--bash')) targetShell = 'bash';
else if (args.includes('--zsh')) targetShell = 'zsh';
else if (args.includes('--fish')) targetShell = 'fish';
else if (args.includes('--powershell')) targetShell = 'powershell';
try {
const installer = new ShellCompletionInstaller();
const result = installer.install(targetShell as 'bash' | 'zsh' | 'fish' | 'powershell' | null, {
force,
});
if (result.alreadyInstalled && !force) {
console.log(ok('Shell completion already installed'));
console.log(` Use ${color('--force', 'warning')} to reinstall`);
console.log('');
return;
}
console.log(ok('Shell completion installed successfully!'));
console.log('');
console.log(result.message);
console.log('');
console.log(color('To activate:', 'info'));
console.log(` ${result.reload}`);
console.log('');
console.log(color('Then test:', 'info'));
console.log(' ccs <TAB> # See available profiles');
console.log(' ccs auth <TAB> # See auth subcommands');
console.log('');
const contract = createShellCompletionCommandContract(installer);
await runCommandWithContract(args, contract);
} catch (error) {
const err = error as Error;
console.error(fail(`Error: ${err.message}`));
+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',
+35
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,
@@ -26,6 +27,7 @@ import {
DashboardAuthConfig,
ImageAnalysisConfig,
CLIPROXY_SUPPORTED_PROVIDERS,
CursorConfig,
} from './unified-config-types';
import { isUnifiedConfigEnabled } from './feature-flags';
@@ -313,6 +315,13 @@ 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: {
enabled: partial.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
},
// Global env - injected into all non-Claude subscription profiles
global_env: {
enabled: partial.global_env?.enabled ?? true,
@@ -599,6 +608,23 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('');
}
// Cursor section (Cursor IDE proxy daemon)
if (config.cursor) {
lines.push('# ----------------------------------------------------------------------------');
lines.push('# Cursor: Cursor IDE proxy daemon');
lines.push('# Enables Cursor IDE integration via local proxy daemon.');
lines.push('#');
lines.push('# enabled: Enable/disable Cursor integration (default: false)');
lines.push('# port: Port for cursor proxy daemon (default: 20129)');
lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)');
lines.push('# ghost_mode: Disable telemetry for privacy (default: true)');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
);
lines.push('');
}
// Global env section
if (config.global_env) {
lines.push('# ----------------------------------------------------------------------------');
@@ -940,3 +966,12 @@ export function getImageAnalysisConfig(): ImageAnalysisConfig {
config.image_analysis?.provider_models ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models,
};
}
/**
* Get cursor configuration.
* Returns defaults if not configured.
*/
export function getCursorConfig(): CursorConfig {
const config = loadOrCreateUnifiedConfig();
return config.cursor ?? { ...DEFAULT_CURSOR_CONFIG };
}
+29
View File
@@ -291,6 +291,21 @@ export interface CopilotConfig {
haiku_model?: string;
}
/**
* Cursor IDE integration configuration.
* Enables Cursor IDE usage via cursor proxy daemon.
*/
export interface CursorConfig {
/** Enable Cursor integration (default: false) */
enabled: boolean;
/** Port for cursor proxy daemon (default: 20129) */
port: number;
/** Auto-start daemon when CCS starts (default: false) */
auto_start: boolean;
/** Enable ghost mode to disable telemetry (default: true) */
ghost_mode: boolean;
}
/**
* Remote proxy configuration.
* Connect to a remote CLIProxyAPI instance instead of spawning local binary.
@@ -670,6 +685,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+) */
@@ -697,6 +714,17 @@ export const DEFAULT_COPILOT_CONFIG: CopilotConfig = {
model: 'gpt-4.1', // Free tier compatible
};
/**
* Default Cursor configuration.
* Disabled by default, ghost mode enabled for privacy.
*/
export const DEFAULT_CURSOR_CONFIG: CursorConfig = {
enabled: false,
port: 20129,
auto_start: false,
ghost_mode: true,
};
/**
* Default CLIProxy server configuration.
* Local mode by default - remote must be explicitly enabled.
@@ -769,6 +797,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 },
+320
View File
@@ -0,0 +1,320 @@
/**
* Cursor Daemon Manager
*
* Manages the cursor daemon lifecycle (start/stop/status).
* Uses CursorExecutor for OpenAI-compatible API proxy to Cursor backend.
*/
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 { getCcsDir } from '../utils/config-manager';
/**
* Get Cursor directory path.
*/
function getCursorDir(): string {
return path.join(getCcsDir(), 'cursor');
}
/**
* Get PID file path.
* Computed at runtime to respect CCS_HOME changes (e.g., in tests).
*/
function getPidFilePath(): string {
return path.join(getCursorDir(), 'daemon.pid');
}
/**
* Check if cursor daemon is running on the specified port.
* Uses 127.0.0.1 instead of localhost for more reliable local connections.
*/
export async function isDaemonRunning(port: number): Promise<boolean> {
return new Promise((resolve) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: '/health',
method: 'GET',
timeout: 3000,
},
(res) => {
res.resume(); // Drain response body
resolve(res.statusCode === 200);
}
);
req.on('error', () => {
resolve(false);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
/**
* Get daemon status.
*/
export async function getDaemonStatus(port: number): Promise<CursorDaemonStatus> {
const running = await isDaemonRunning(port);
const pid = getPidFromFile();
return {
running,
port,
pid: running ? (pid ?? undefined) : undefined,
};
}
/**
* Read PID from file.
*/
export function getPidFromFile(): number | null {
const pidFile = getPidFilePath();
try {
if (fs.existsSync(pidFile)) {
const content = fs.readFileSync(pidFile, 'utf8').trim();
const pid = parseInt(content, 10);
return isNaN(pid) ? null : pid;
}
} catch {
// Ignore errors
}
return null;
}
/**
* Write PID to file.
*/
export function writePidToFile(pid: number): void {
const pidFile = getPidFilePath();
try {
const dir = path.dirname(pidFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(pidFile, pid.toString(), { mode: 0o600 });
} catch {
// Ignore errors
}
}
/**
* Remove PID file.
*/
export function removePidFile(): void {
const pidFile = getPidFilePath();
try {
if (fs.existsSync(pidFile)) {
fs.unlinkSync(pidFile);
}
} catch {
// Ignore errors
}
}
/**
* Start the cursor daemon.
*
* @param config Cursor configuration
* @returns Promise that resolves when daemon is ready
*/
export async function startDaemon(
config: CursorConfig
): Promise<{ success: boolean; pid?: number; error?: string }> {
// Check if already running
if (await isDaemonRunning(config.port)) {
return { success: true, pid: getPidFromFile() ?? undefined };
}
// Validate port before interpolation (prevents injection)
if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) {
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
return new Promise((resolve) => {
let proc: ChildProcess;
let resolved = false;
const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => {
if (resolved) return;
resolved = true;
if (checkTimeout) clearTimeout(checkTimeout);
if (!result.success) removePidFile();
resolve(result);
};
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');
`,
];
// Append --ccs-daemon marker for PID validation in stopDaemon
proc = spawn(process.execPath, [...args, '--ccs-daemon'], {
stdio: 'ignore',
detached: true,
});
// Unref so parent can exit
proc.unref();
if (proc.pid) {
writePidToFile(proc.pid);
}
// Wait for daemon to be ready (poll for up to 30 seconds)
let attempts = 0;
const maxAttempts = 30;
const pollHealth = async () => {
attempts++;
if (await isDaemonRunning(config.port)) {
safeResolve({ success: true, pid: proc.pid });
} else if (attempts >= maxAttempts) {
// Kill orphaned process
if (proc.pid) {
try {
process.kill(proc.pid, 'SIGTERM');
} catch {
/* already dead */
}
}
safeResolve({
success: false,
error: 'Daemon did not start within 30 seconds',
});
} else {
checkTimeout = setTimeout(pollHealth, 1000);
}
};
checkTimeout = setTimeout(pollHealth, 1000);
proc.on('error', (err) => {
safeResolve({
success: false,
error: `Failed to start daemon: ${err.message}`,
});
});
proc.on('exit', (code, signal) => {
if (code === null) {
safeResolve({
success: false,
error: `Daemon process was killed by signal ${signal}`,
});
} else if (code === 0) {
safeResolve({
success: false,
error: 'Daemon process exited unexpectedly with code 0',
});
} else {
safeResolve({
success: false,
error: `Daemon process exited with code ${code}`,
});
}
});
} catch (err) {
safeResolve({
success: false,
error: `Failed to spawn daemon: ${(err as Error).message}`,
});
}
});
}
/**
* Stop the cursor daemon.
*/
export async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
const pid = getPidFromFile();
if (!pid) {
// No PID file — daemon is not running or was already stopped
return { success: true };
}
try {
// Verify the PID belongs to our daemon before signaling
try {
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
if (!cmdline.includes('--ccs-daemon')) {
// PID was reused by an unrelated process
removePidFile();
return { success: true };
}
} catch {
// /proc not available (macOS/Windows) or process gone — proceed with kill
}
// Send SIGTERM to the process
process.kill(pid, 'SIGTERM');
// Wait for process to exit (up to 5 seconds)
let attempts = 0;
while (attempts < 10) {
await new Promise((resolve) => setTimeout(resolve, 500));
try {
// Check if process still exists (kill(pid, 0) throws if not)
process.kill(pid, 0);
attempts++;
} catch {
// Process no longer exists
break;
}
}
// Escalate to SIGKILL only if SIGTERM wait loop exhausted
if (attempts >= 10) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// Already dead — good
}
}
removePidFile();
return { success: true };
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error.code === 'ESRCH') {
// Process doesn't exist
removePidFile();
return { success: true };
}
return {
success: false,
error: `Failed to stop daemon: ${error.message}`,
};
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Cursor Model Catalog
*
* Manages available models from Cursor IDE.
* Based on Cursor's supported models catalog.
*/
import * as http from 'http';
import type { CursorModel } from './types';
import { isDaemonRunning } from './cursor-daemon';
/** Default daemon port */
export const DEFAULT_CURSOR_PORT = 4242;
/** Default model ID */
export const DEFAULT_CURSOR_MODEL = 'gpt-4.1';
/**
* Default models available through Cursor IDE.
* Used as fallback when daemon is not reachable.
* Source: Cursor IDE supported models (Feb 2025)
*/
export const DEFAULT_CURSOR_MODELS: CursorModel[] = [
// Anthropic Models
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
provider: 'anthropic',
},
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
provider: 'anthropic',
},
{
id: 'claude-opus-4',
name: 'Claude Opus 4',
provider: 'anthropic',
},
// OpenAI Models
{
id: 'gpt-4.1',
name: 'GPT-4.1',
provider: 'openai',
isDefault: true,
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
provider: 'openai',
},
{
id: 'o3-mini',
name: 'O3 Mini',
provider: 'openai',
},
// Google Models
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
provider: 'google',
},
// Cursor Custom Models
{
id: 'cursor-small',
name: 'Cursor Small',
provider: 'cursor',
},
];
/**
* Fetch available models from running cursor daemon.
*
* @param port The port cursor daemon is running on
* @returns List of available models
*/
export async function fetchModelsFromDaemon(port: number): Promise<CursorModel[]> {
return new Promise((resolve) => {
let resolved = false;
const safeResolve = (models: CursorModel[]) => {
if (resolved) return;
resolved = true;
resolve(models);
};
const req = http.request(
{
// Use 127.0.0.1 instead of localhost for more reliable local connections
hostname: '127.0.0.1',
port,
path: '/v1/models',
method: 'GET',
timeout: 5000,
},
(res) => {
const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit
let data = '';
res.on('data', (chunk) => {
data += chunk;
if (data.length > MAX_BODY_SIZE) {
req.destroy();
safeResolve(DEFAULT_CURSOR_MODELS);
}
});
res.on('end', () => {
try {
const response = JSON.parse(data) as { data?: Array<{ id: string }> };
if (response.data && Array.isArray(response.data)) {
const models: CursorModel[] = response.data.map((m) => ({
id: m.id,
name: formatModelName(m.id),
provider: detectProvider(m.id),
isDefault: m.id === DEFAULT_CURSOR_MODEL,
}));
safeResolve(models.length > 0 ? models : DEFAULT_CURSOR_MODELS);
} else {
safeResolve(DEFAULT_CURSOR_MODELS);
}
} catch {
safeResolve(DEFAULT_CURSOR_MODELS);
}
});
}
);
req.on('error', () => {
safeResolve(DEFAULT_CURSOR_MODELS);
});
req.on('timeout', () => {
req.destroy();
safeResolve(DEFAULT_CURSOR_MODELS);
});
req.end();
});
}
/**
* Get available models (from daemon or defaults).
* Checks daemon health first to avoid 5s timeout when daemon is not running.
*/
export async function getAvailableModels(port: number): Promise<CursorModel[]> {
if (!(await isDaemonRunning(port))) {
return DEFAULT_CURSOR_MODELS;
}
return fetchModelsFromDaemon(port);
}
/**
* Get the default model.
* Uses gpt-4.1 as it's commonly available.
*/
export function getDefaultModel(): string {
return DEFAULT_CURSOR_MODEL;
}
/**
* Detect provider from model ID.
*/
export function detectProvider(modelId: string): string {
if (modelId.includes('claude')) return 'anthropic';
if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai';
if (modelId.includes('gemini')) return 'google';
if (modelId.includes('cursor')) return 'cursor';
return 'unknown';
}
/**
* Format model ID to human-readable name.
*/
export function formatModelName(modelId: string): string {
// Find model in catalog for metadata
const model = DEFAULT_CURSOR_MODELS.find((m) => m.id === modelId);
if (model) {
return model.name;
}
// Fallback: convert kebab-case to title case
return modelId
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Cursor Module Index
*
* Central exports for Cursor IDE integration.
*/
// Types
export * from './types';
// Auth
export { autoDetectTokens, saveCredentials, loadCredentials, checkAuthStatus } from './cursor-auth';
// Daemon
export {
isDaemonRunning,
getDaemonStatus,
startDaemon,
stopDaemon,
getPidFromFile,
writePidToFile,
removePidFile,
} from './cursor-daemon';
// Models
export {
DEFAULT_CURSOR_MODELS,
DEFAULT_CURSOR_PORT,
DEFAULT_CURSOR_MODEL,
fetchModelsFromDaemon,
getAvailableModels,
getDefaultModel,
detectProvider,
formatModelName,
} from './cursor-models';
// Executor
export { CursorExecutor } from './cursor-executor';
+36 -1
View File
@@ -1,9 +1,18 @@
/**
* Cursor IDE Type Definitions
*
* TypeScript interfaces for the Cursor auth module.
* TypeScript interfaces for the Cursor module.
*/
/**
* Cursor daemon configuration.
* Temporary interface until #521 adds cursor to unified config.
*/
export interface CursorConfig {
port: number;
model: string;
}
/**
* Cursor authentication credentials
*/
@@ -49,3 +58,29 @@ export interface AutoDetectResult {
/** Error message (if detection failed) */
error?: string;
}
/**
* Cursor daemon/process status
*/
export interface CursorDaemonStatus {
/** Whether daemon is running */
running: boolean;
/** Port number daemon is listening on */
port: number;
/** Process ID (if available) */
pid?: number;
}
/**
* Cursor AI model
*/
export interface CursorModel {
/** Model ID */
id: string;
/** Display name */
name: string;
/** Provider (e.g., 'openai', 'anthropic') */
provider: string;
/** Whether this is the default model */
isDefault?: boolean;
}
+1 -1
View File
@@ -146,7 +146,7 @@ export async function parseProjectDirectory(projectDir: string): Promise<RawUsag
const projectPath = path.basename(projectDir).replace(/-/g, '/');
try {
const files = fs.readdirSync(projectDir);
const files = await fs.promises.readdir(projectDir);
const jsonlFiles = files.filter((f) => f.endsWith('.jsonl'));
// Parse files sequentially within a project to avoid too many open handles
+6 -14
View File
@@ -17,28 +17,20 @@ import {
soloAccount,
} from '../../cliproxy/account-manager';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
const router = Router();
const registry = new ProfileRegistry();
/** Valid CLIProxy providers - derived from canonical CLIPROXY_PROFILES */
const VALID_PROVIDERS: CLIProxyProvider[] = [...CLIPROXY_PROFILES];
/** Check if provider is valid */
function isValidProvider(provider: string): provider is CLIProxyProvider {
return VALID_PROVIDERS.includes(provider as CLIProxyProvider);
}
/** Parse CLIProxy account key format: "provider:accountId" */
function parseCliproxyKey(key: string): { provider: CLIProxyProvider; accountId: string } | null {
const colonIndex = key.indexOf(':');
if (colonIndex === -1) return null;
const provider = key.slice(0, colonIndex) as CLIProxyProvider;
const provider = key.slice(0, colonIndex);
const accountId = key.slice(colonIndex + 1);
if (!isValidProvider(provider) || !accountId) return null;
if (!isCLIProxyProvider(provider) || !accountId) return null;
return { provider, accountId };
}
@@ -239,7 +231,7 @@ router.post('/bulk-pause', (req: Request, res: Response): void => {
return;
}
if (!isValidProvider(provider)) {
if (!isCLIProxyProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
@@ -276,7 +268,7 @@ router.post('/bulk-resume', (req: Request, res: Response): void => {
return;
}
if (!isValidProvider(provider)) {
if (!isCLIProxyProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
@@ -313,7 +305,7 @@ router.post('/solo', async (req: Request, res: Response): Promise<void> => {
return;
}
if (!isValidProvider(provider)) {
if (!isCLIProxyProvider(provider)) {
res.status(400).json({ error: `Invalid provider: ${provider}` });
return;
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Cursor Routes - Cursor IDE integration via cursor proxy daemon
*/
import type { Request, Response } from 'express';
import { Router } from 'express';
import {
checkAuthStatus,
autoDetectTokens,
saveCredentials,
validateToken,
} from '../../cursor/cursor-auth';
import { getCursorConfig } from '../../config/unified-config-loader';
import cursorSettingsRoutes from './cursor-settings-routes';
const router = Router();
// Mount settings sub-routes
router.use('/settings', cursorSettingsRoutes);
/**
* Get daemon status
* TODO: Implement in cursor-executor.ts (#520)
*/
async function getDaemonStatus(port: number): Promise<{ running: boolean; port?: number }> {
// Stub - will be implemented in #520
return { running: false, port };
}
/**
* Get available models
* TODO: Implement in cursor-executor.ts (#520)
*/
async function getAvailableModels(): Promise<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)
*/
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try {
const cursorConfig = getCursorConfig();
const authStatus = checkAuthStatus();
const daemonStatus = await getDaemonStatus(cursorConfig.port);
res.json({
enabled: cursorConfig.enabled,
authenticated: authStatus.authenticated,
daemon_running: daemonStatus.running,
port: cursorConfig.port,
auto_start: cursorConfig.auto_start,
ghost_mode: cursorConfig.ghost_mode,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/cursor/auth/import - Import Cursor token manually
*/
router.post('/auth/import', async (req: Request, res: Response): Promise<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 || !result.accessToken || !result.machineId) {
res.status(404).json({ error: result.error ?? 'Token not found' });
return;
}
// Save credentials
saveCredentials({
accessToken: result.accessToken,
machineId: result.machineId,
authMethod: 'auto-detect',
importedAt: new Date().toISOString(),
});
res.json({ success: true, message: 'Token auto-detected and imported' });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cursor/models - List available models
*/
router.get('/models', async (_req: Request, res: Response): Promise<void> => {
try {
const models = await getAvailableModels();
res.json({ models });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/cursor/daemon/start - Start cursor proxy daemon
* Path matches copilot convention: /api/{provider}/daemon/{action}
*/
router.post('/daemon/start', async (_req: Request, res: Response): Promise<void> => {
try {
const cursorConfig = getCursorConfig();
const result = await startDaemon(cursorConfig.port, cursorConfig.ghost_mode);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/cursor/daemon/stop - Stop cursor proxy daemon
* Path matches copilot convention: /api/{provider}/daemon/{action}
*/
router.post('/daemon/stop', async (_req: Request, res: Response): Promise<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,170 @@
/**
* Cursor Settings Routes - Settings editor and raw settings for Cursor IDE
*/
import type { Request, Response } from 'express';
import { Router } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
getCursorConfig,
} from '../../config/unified-config-loader';
const router = Router();
/**
* GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode)
*/
router.get('/', (_req: Request, res: Response): void => {
try {
const cursorConfig = getCursorConfig();
res.json(cursorConfig);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/cursor/settings - Update cursor config
*/
router.put('/', (req: Request, res: Response): void => {
try {
const updates = req.body;
// Reject non-object bodies
if (!updates || typeof updates !== 'object' || Array.isArray(updates)) {
res.status(400).json({ error: 'Request body must be a JSON object' });
return;
}
// Validate input types
if ('port' in updates) {
if (typeof updates.port !== 'number' || !Number.isInteger(updates.port)) {
res.status(400).json({ error: 'port must be an integer' });
return;
}
if (updates.port < 1 || updates.port > 65535) {
res.status(400).json({ error: 'port must be between 1 and 65535' });
return;
}
}
if ('enabled' in updates && typeof updates.enabled !== 'boolean') {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
if ('auto_start' in updates && typeof updates.auto_start !== 'boolean') {
res.status(400).json({ error: 'auto_start must be a boolean' });
return;
}
if ('ghost_mode' in updates && typeof updates.ghost_mode !== 'boolean') {
res.status(400).json({ error: 'ghost_mode must be a boolean' });
return;
}
const config = loadOrCreateUnifiedConfig();
// Merge updates with existing config
// Only known fields are merged — unknown properties are ignored
config.cursor = {
enabled: updates.enabled ?? config.cursor?.enabled ?? DEFAULT_CURSOR_CONFIG.enabled,
port: updates.port ?? config.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start:
updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode:
updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
};
saveUnifiedConfig(config);
res.json({ success: true, cursor: config.cursor });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cursor/settings/raw - Get raw cursor.settings.json
* Returns the raw JSON content for editing in the code editor
*/
router.get('/raw', (_req: Request, res: Response): void => {
try {
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
const cursorConfig = getCursorConfig();
// If file doesn't exist, return default structure
if (!fs.existsSync(settingsPath)) {
// Create settings structure matching Cursor pattern
// Use 127.0.0.1 instead of localhost for more reliable local connections
const defaultSettings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
},
};
res.json({
settings: defaultSettings,
mtime: Date.now(),
path: settingsPath,
exists: false,
});
return;
}
const content = fs.readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(content);
const stat = fs.statSync(settingsPath);
res.json({
settings,
mtime: stat.mtimeMs,
path: settingsPath,
exists: true,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/cursor/settings/raw - Save raw cursor.settings.json
* Saves the raw JSON content from the code editor
*/
router.put('/raw', (req: Request, res: Response): void => {
try {
const { settings, expectedMtime } = req.body;
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
res.status(400).json({ error: 'settings must be a JSON object' });
return;
}
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
// Check for conflict if file exists and expectedMtime provided
if (fs.existsSync(settingsPath) && expectedMtime) {
const stat = fs.statSync(settingsPath);
if (Math.abs(stat.mtimeMs - expectedMtime) > 1000) {
res.status(409).json({ error: 'File modified externally', mtime: stat.mtimeMs });
return;
}
}
// Write settings file atomically
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
// TODO: Sync raw settings back to unified config when cursor-daemon is integrated (#520)
const stat = fs.statSync(settingsPath);
res.json({ success: true, mtime: stat.mtimeMs });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;
+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);