chore: sync with dev after PR #527 merge

This commit is contained in:
Tam Nhu Tran
2026-02-12 11:01:10 +07:00
10 changed files with 1163 additions and 19 deletions
+1
View File
@@ -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
View File
@@ -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
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 = [
@@ -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');
+257 -8
View File
@@ -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;
}
}
+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;
}
+215
View File
@@ -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);
});
});
+99
View File
@@ -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);
});
});