mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-21 04:24:13 +00:00
chore: sync with dev after PR #527 merge
This commit is contained in:
@@ -129,6 +129,7 @@ bun run validate # Step 3: Final check (must pass)
|
||||
| `ccs cliproxy --help` | `src/commands/cliproxy-command.ts` → `showHelp()` |
|
||||
| `ccs config --help` | `src/commands/config-command.ts` → `showHelp()` |
|
||||
| `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` |
|
||||
| `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` |
|
||||
| `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` |
|
||||
| `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` |
|
||||
| `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.43.0-dev.2",
|
||||
"version": "7.43.0-dev.3",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
+8
-9
@@ -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 = [
|
||||
@@ -553,15 +561,6 @@ async function main(): Promise<void> {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
// Special case: cursor command (Cursor IDE integration)
|
||||
// Route all cursor args to handler — handler deals with unknown subcommands
|
||||
// Note: cursor does not have enable/disable — it uses daemon start/stop instead
|
||||
if (firstArg === 'cursor') {
|
||||
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');
|
||||
|
||||
@@ -1,15 +1,264 @@
|
||||
/**
|
||||
* Cursor Command Handler - Cursor IDE integration commands
|
||||
* This is a stub file - the actual implementation is in task #520
|
||||
* 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 command routing
|
||||
* @param _args - Command arguments (unused in stub)
|
||||
* @returns Exit code
|
||||
* Handle cursor subcommand.
|
||||
*/
|
||||
export async function handleCursorCommand(_args: string[]): Promise<number> {
|
||||
console.error('[!] Cursor command not yet implemented (task #520)');
|
||||
console.error(' Full implementation coming in task #520.');
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Unit tests for Cursor daemon module
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
getPidFromFile,
|
||||
writePidToFile,
|
||||
removePidFile,
|
||||
isDaemonRunning,
|
||||
getDaemonStatus,
|
||||
stopDaemon,
|
||||
startDaemon,
|
||||
} from '../../../src/cursor/cursor-daemon';
|
||||
import { getCcsDir } from '../../../src/utils/config-manager';
|
||||
import { handleCursorCommand } from '../../../src/commands/cursor-command';
|
||||
|
||||
// Test isolation
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-daemon-test-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
// Cleanup temp directory
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
// Use getCcsDir() for consistent path resolution with production code
|
||||
const getTestCursorDir = () => path.join(getCcsDir(), 'cursor');
|
||||
|
||||
describe('getPidFromFile', () => {
|
||||
it('returns null when no PID file exists', () => {
|
||||
expect(getPidFromFile()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns PID when valid PID file exists', () => {
|
||||
const dir = getTestCursorDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'daemon.pid'), '12345');
|
||||
|
||||
expect(getPidFromFile()).toBe(12345);
|
||||
});
|
||||
|
||||
it('returns null when PID file contains invalid content', () => {
|
||||
const dir = getTestCursorDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'daemon.pid'), 'not-a-number');
|
||||
|
||||
expect(getPidFromFile()).toBeNull();
|
||||
});
|
||||
|
||||
it('trims whitespace from PID file content', () => {
|
||||
const dir = getTestCursorDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'daemon.pid'), ' 42 \n');
|
||||
|
||||
expect(getPidFromFile()).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writePidToFile', () => {
|
||||
it('creates PID file with correct content', () => {
|
||||
writePidToFile(12345);
|
||||
|
||||
const pidFile = path.join(getTestCursorDir(), 'daemon.pid');
|
||||
expect(fs.existsSync(pidFile)).toBe(true);
|
||||
expect(fs.readFileSync(pidFile, 'utf8')).toBe('12345');
|
||||
});
|
||||
|
||||
it('creates cursor directory if it does not exist', () => {
|
||||
const dir = getTestCursorDir();
|
||||
expect(fs.existsSync(dir)).toBe(false);
|
||||
|
||||
writePidToFile(999);
|
||||
|
||||
expect(fs.existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it('overwrites existing PID file', () => {
|
||||
writePidToFile(111);
|
||||
writePidToFile(222);
|
||||
|
||||
const pidFile = path.join(getTestCursorDir(), 'daemon.pid');
|
||||
expect(fs.readFileSync(pidFile, 'utf8')).toBe('222');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePidFile', () => {
|
||||
it('removes existing PID file', () => {
|
||||
writePidToFile(12345);
|
||||
const pidFile = path.join(getTestCursorDir(), 'daemon.pid');
|
||||
expect(fs.existsSync(pidFile)).toBe(true);
|
||||
|
||||
removePidFile();
|
||||
|
||||
expect(fs.existsSync(pidFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw when PID file does not exist', () => {
|
||||
expect(() => removePidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startDaemon', () => {
|
||||
it('rejects invalid port (0)', async () => {
|
||||
const result = await startDaemon({ port: 0, model: 'test' });
|
||||
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' });
|
||||
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' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid port');
|
||||
});
|
||||
|
||||
it(
|
||||
'starts and stops daemon successfully',
|
||||
async () => {
|
||||
const port = 10000 + Math.floor(Math.random() * 50000);
|
||||
const result = await startDaemon({ port, model: 'test' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.pid).toBeDefined();
|
||||
|
||||
// Verify health
|
||||
const running = await isDaemonRunning(port);
|
||||
expect(running).toBe(true);
|
||||
|
||||
// Stop
|
||||
const stopResult = await stopDaemon();
|
||||
expect(stopResult.success).toBe(true);
|
||||
|
||||
// Verify stopped
|
||||
const stillRunning = await isDaemonRunning(port);
|
||||
expect(stillRunning).toBe(false);
|
||||
},
|
||||
35000
|
||||
);
|
||||
});
|
||||
|
||||
describe('isDaemonRunning', () => {
|
||||
it('returns false when no daemon is running on port', async () => {
|
||||
// Use a port that should not have anything running
|
||||
const result = await isDaemonRunning(19999);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDaemonStatus', () => {
|
||||
it('returns status with running=false when no daemon running', async () => {
|
||||
const status = await getDaemonStatus(19999);
|
||||
expect(status.running).toBe(false);
|
||||
expect(status.port).toBe(19999);
|
||||
expect(status.pid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns status with pid when PID file exists but daemon not running', async () => {
|
||||
writePidToFile(99999);
|
||||
const status = await getDaemonStatus(19999);
|
||||
expect(status.running).toBe(false);
|
||||
expect(status.port).toBe(19999);
|
||||
expect(status.pid).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopDaemon', () => {
|
||||
it('returns success when no PID file exists', async () => {
|
||||
const result = await stopDaemon();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns success when PID refers to non-existent process', async () => {
|
||||
// Write a PID that doesn't exist
|
||||
writePidToFile(999999);
|
||||
const result = await stopDaemon();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
// PID file should be removed
|
||||
const pidFile = path.join(getTestCursorDir(), 'daemon.pid');
|
||||
expect(fs.existsSync(pidFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCursorCommand', () => {
|
||||
it('returns exit code 1 for unknown subcommand', async () => {
|
||||
const exitCode = await handleCursorCommand(['nonexistent']);
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Unit tests for Cursor models module
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
DEFAULT_CURSOR_MODELS,
|
||||
DEFAULT_CURSOR_PORT,
|
||||
DEFAULT_CURSOR_MODEL,
|
||||
getDefaultModel,
|
||||
detectProvider,
|
||||
formatModelName,
|
||||
fetchModelsFromDaemon,
|
||||
} from '../../../src/cursor/cursor-models';
|
||||
|
||||
describe('DEFAULT_CURSOR_MODELS', () => {
|
||||
it('contains models from multiple providers', () => {
|
||||
const providers = new Set(DEFAULT_CURSOR_MODELS.map((m) => m.provider));
|
||||
expect(providers.has('anthropic')).toBe(true);
|
||||
expect(providers.has('openai')).toBe(true);
|
||||
expect(providers.has('google')).toBe(true);
|
||||
});
|
||||
|
||||
it('has exactly one default model', () => {
|
||||
const defaults = DEFAULT_CURSOR_MODELS.filter((m) => m.isDefault);
|
||||
expect(defaults).toHaveLength(1);
|
||||
expect(defaults[0].id).toBe(DEFAULT_CURSOR_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_CURSOR_PORT', () => {
|
||||
it('is 4242', () => {
|
||||
expect(DEFAULT_CURSOR_PORT).toBe(4242);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_CURSOR_MODEL', () => {
|
||||
it('is gpt-4.1', () => {
|
||||
expect(DEFAULT_CURSOR_MODEL).toBe('gpt-4.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultModel', () => {
|
||||
it('returns the default model constant', () => {
|
||||
expect(getDefaultModel()).toBe(DEFAULT_CURSOR_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProvider', () => {
|
||||
it('detects anthropic models', () => {
|
||||
expect(detectProvider('claude-sonnet-4')).toBe('anthropic');
|
||||
expect(detectProvider('claude-opus-4')).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('detects openai models', () => {
|
||||
expect(detectProvider('gpt-4.1')).toBe('openai');
|
||||
expect(detectProvider('gpt-5-mini')).toBe('openai');
|
||||
expect(detectProvider('o3-mini')).toBe('openai');
|
||||
});
|
||||
|
||||
it('detects o1 and o4 models as openai', () => {
|
||||
expect(detectProvider('o1')).toBe('openai');
|
||||
expect(detectProvider('o1-preview')).toBe('openai');
|
||||
expect(detectProvider('o4-mini')).toBe('openai');
|
||||
});
|
||||
|
||||
it('detects google models', () => {
|
||||
expect(detectProvider('gemini-2.5-pro')).toBe('google');
|
||||
});
|
||||
|
||||
it('detects cursor models', () => {
|
||||
expect(detectProvider('cursor-small')).toBe('cursor');
|
||||
});
|
||||
|
||||
it('defaults to unknown for unrecognized models', () => {
|
||||
expect(detectProvider('unknown-model')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatModelName', () => {
|
||||
it('returns catalog name for known models', () => {
|
||||
expect(formatModelName('claude-sonnet-4')).toBe('Claude Sonnet 4');
|
||||
expect(formatModelName('gpt-4.1')).toBe('GPT-4.1');
|
||||
});
|
||||
|
||||
it('converts kebab-case to title case for unknown models', () => {
|
||||
expect(formatModelName('my-custom-model')).toBe('My Custom Model');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchModelsFromDaemon', () => {
|
||||
it('falls back to DEFAULT_CURSOR_MODELS when daemon is unreachable', async () => {
|
||||
// Use a port that nothing is listening on
|
||||
const unreachablePort = 9999;
|
||||
const models = await fetchModelsFromDaemon(unreachablePort);
|
||||
|
||||
expect(models).toEqual(DEFAULT_CURSOR_MODELS);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user