diff --git a/README.md b/README.md index fa1346e7..8ad79ecf 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ The dashboard provides visual management for all account types: | **Gemini** | OAuth | `ccs gemini` | Zero-config, fast iteration | | **Codex** | OAuth | `ccs codex` | Code generation | | **Copilot** | OAuth | `ccs copilot` or `ccs ghcp` | GitHub Copilot models | -| **Cursor IDE** | Local Token | `ccs cursor` | Cursor subscription models via local daemon | +| **Cursor IDE** | Local Token | `ccs cursor` | Run Claude through Cursor models via local daemon | | **Kiro** | OAuth (AWS default) | `ccs kiro` | AWS CodeWhisperer (Claude-powered) | | **Antigravity** | OAuth | `ccs agy` | Alternative routing | | **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API | @@ -184,7 +184,7 @@ The dashboard provides visual management for all account types: ccs # Default Claude session ccs gemini # Gemini (OAuth) ccs codex # OpenAI Codex (OAuth) -ccs cursor # Cursor status + local daemon connection details +ccs cursor # Run Claude through Cursor local proxy ccs kiro # Kiro/AWS CodeWhisperer (OAuth) ccs ghcp # GitHub Copilot (OAuth device flow) ccs agy # Antigravity (OAuth) @@ -379,6 +379,7 @@ Dashboard parity: `ccs config` -> Accounts -> Add Kiro account -> choose `Auth M ccs cursor enable ccs cursor auth ccs cursor start +ccs cursor "explain this repo" ccs cursor status ``` diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 5e190e8c..79caee5a 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -43,20 +43,26 @@ ccs cursor auth --manual --token --machine-id ccs cursor start ``` -### 4) Verify status +### 4) Run Cursor-backed Claude + +```bash +ccs cursor "explain this repo" +``` + +### 5) Verify status ```bash ccs cursor status ``` -Or use the bare command to see the same status view plus the local endpoint details -needed for OpenAI-compatible and Anthropic-compatible clients: +Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy. +The admin namespace remains available for setup and inspection: ```bash -ccs cursor +ccs cursor help ``` -### 5) Stop daemon +### 6) Stop daemon ```bash ccs cursor stop diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 04b132e6..d21e187c 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -16,6 +16,7 @@ import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig, + CursorConfig, CLIProxyVariantConfig, CompositeVariantConfig, CompositeTierConfig, @@ -49,6 +50,8 @@ export interface ProfileDetectionResult { env?: Record; /** For copilot profile: the copilot config */ copilotConfig?: CopilotConfig; + /** For cursor profile: the cursor config */ + cursorConfig?: CursorConfig; /** For composite variants: true when variant mixes providers per tier */ isComposite?: boolean; /** For composite variants: which tier is the default */ @@ -252,6 +255,7 @@ class ProfileDetector { * Priority order: * 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen) * 0.5. Copilot profile (if enabled in config) + * 0.75. Cursor profile (if enabled in config) * 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1) * 2. User-defined CLIProxy variants (config.cliproxy section) [legacy] * 3. Settings-based profiles (config.profiles section) [legacy] @@ -302,6 +306,35 @@ class ProfileDetector { }; } + // Priority 0.75: Check Cursor profile - local Cursor daemon runtime + if (profileName === 'cursor') { + const unifiedConfig = this.readUnifiedConfig(); + const cursorConfig = unifiedConfig?.cursor; + + if (!cursorConfig?.enabled) { + const error = new Error( + 'Cursor profile is not enabled.\n\n' + + 'To enable Cursor integration:\n' + + ' 1. Run: ccs cursor enable\n' + + ' 2. Import auth: ccs cursor auth\n' + + ' 3. Start daemon: ccs cursor start\n\n' + + 'Or manually edit ~/.ccs/config.yaml:\n' + + ' cursor:\n' + + ' enabled: true' + ) as ProfileNotFoundError; + error.profileName = profileName; + error.suggestions = []; + error.availableProfiles = this.listAvailableProfiles(); + throw error; + } + + return { + type: 'cursor', + name: 'cursor', + cursorConfig, + }; + } + // Priority 1: Try unified config if available const unifiedConfig = this.readUnifiedConfig(); if (unifiedConfig) { @@ -452,6 +485,11 @@ class ProfileDetector { lines.push(` - copilot (model: ${currentCopilotModel})`); } + if (unifiedConfig.cursor?.enabled) { + lines.push('Cursor local proxy:'); + lines.push(` - cursor (model: ${unifiedConfig.cursor.model})`); + } + // CLIProxy variants from unified config const variants = Object.keys(unifiedConfig.cliproxy?.variants || {}); if (variants.length > 0) { diff --git a/src/ccs.ts b/src/ccs.ts index aafaee74..c3298493 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -450,6 +450,20 @@ async function main(): Promise { } } + // Special case: cursor command (Cursor local proxy integration) + // Route known admin subcommands to the command handler, keep all other args as profile passthrough. + if (firstArg === 'cursor' && args.length > 1) { + const { isCursorSubcommandToken, handleCursorCommand } = await import( + './commands/cursor-command' + ); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken)) { + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + // First-time install: offer setup wizard for interactive users // Check independently of recovery status (user may have empty config.yaml) // Skip if headless, CI, or non-TTY environment @@ -880,6 +894,40 @@ async function main(): Promise { claudeCli ); process.exit(exitCode); + } else if (profileInfo.type === 'cursor') { + // CURSOR FLOW: local Cursor daemon profile + ensureWebSearchMcpOrThrow(); + installImageAnalyzerHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + }); + + const { executeCursorProfile } = await import('./cursor'); + const cursorConfig = profileInfo.cursorConfig; + if (!cursorConfig) { + console.error(fail('Cursor configuration not found')); + process.exit(1); + } + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCursorProfile( + cursorConfig, + remainingArgs, + continuityInheritance.claudeConfigDir, + claudeCli + ); + process.exit(exitCode); } else if (profileInfo.type === 'settings') { // Settings-based profiles (glm, glmt) are third-party providers if (resolvedTarget === 'claude') { diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts index 5bc1f4d8..e63c109a 100644 --- a/src/commands/cursor-command-display.ts +++ b/src/commands/cursor-command-display.ts @@ -13,7 +13,7 @@ export function renderCursorHelp(): number { printLines([ 'Cursor IDE Integration', '', - 'Usage: ccs cursor [subcommand] [options]', + 'Usage: ccs cursor ', '', 'Subcommands:', ' auth Import Cursor IDE authentication token', @@ -25,6 +25,9 @@ export function renderCursorHelp(): number { ' disable Disable cursor integration in unified config', ' help Show this help message', '', + 'Runtime entry:', + ' ccs cursor [claude args] # Run Claude via the local Cursor proxy', + '', 'Auth options:', ' ccs cursor auth # Auto-detect from Cursor SQLite', ' ccs cursor auth --manual --token --machine-id ', @@ -33,7 +36,8 @@ export function renderCursorHelp(): number { ' 1. ccs cursor enable # Enable integration', ' 2. ccs cursor auth # Import Cursor IDE token', ' 3. ccs cursor start # Start daemon', - ' 4. ccs cursor # Show status and runtime connection details', + ' 4. ccs cursor "task" # Run Claude through Cursor', + ' 5. ccs cursor status # Inspect auth/daemon wiring', '', 'Or use the web UI: ccs config -> Cursor page', '', @@ -93,7 +97,9 @@ export function renderCursorStatus( console.log(''); console.log('Client setup:'); console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`); - console.log(' Subcommands: ccs cursor help'); + console.log(' Runtime entry: ccs cursor [claude args]'); + console.log(' Status command: ccs cursor status'); + console.log(' Help command: ccs cursor help'); if (isReady) { return; diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index b103b735..4c918f65 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -34,6 +34,12 @@ export const CURSOR_SUBCOMMANDS = [ '-h', ] as const; +export function isCursorSubcommandToken(token?: string): boolean { + return ( + Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number]) + ); +} + /** * Handle cursor subcommand. */ @@ -56,7 +62,7 @@ export async function handleCursorCommand(args: string[]): Promise { case 'disable': return handleDisable(); case undefined: - return handleStatus(); + return handleHelp(); case 'help': case '--help': case '-h': diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 494f6779..023b149d 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -287,7 +287,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Auto-detects token from Cursor installation', ], [ - ['ccs cursor', 'Cursor status + local daemon connection details'], + ['ccs cursor', 'Run Claude via Cursor local proxy'], ['ccs cursor auth', 'Import Cursor token'], ['ccs cursor auth --manual --token --machine-id ', 'Manual token import'], ['ccs cursor status', 'Show connection status'], diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index 98f4a178..450dec5b 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -172,13 +172,6 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ await handleSetupCommand(args); }, }, - { - name: 'cursor', - handle: async (args) => { - const { handleCursorCommand } = await import('./cursor-command'); - process.exit(await handleCursorCommand(args)); - }, - }, ]; export async function tryHandleRootCommand(args: string[]): Promise { diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts new file mode 100644 index 00000000..74900445 --- /dev/null +++ b/src/cursor/cursor-profile-executor.ts @@ -0,0 +1,190 @@ +import { spawn } from 'child_process'; + +import type { CursorConfig } from '../config/unified-config-types'; +import { getGlobalEnvConfig } from '../config/unified-config-loader'; +import { ensureCliproxyService } from '../cliproxy'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +import { fail, info, ok } from '../utils/ui'; +import { + appendThirdPartyWebSearchToolArgs, + createWebSearchTraceContext, + getWebSearchHookEnv, + syncWebSearchMcpToConfigDir, +} from '../utils/websearch-manager'; +import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks'; +import { stripClaudeCodeEnv } from '../utils/shell-executor'; +import { checkAuthStatus } from './cursor-auth'; +import { isDaemonRunning, startDaemon } from './cursor-daemon'; + +interface CursorImageAnalysisResolution { + env: Record; + warning: string | null; +} + +export function generateCursorEnv( + config: CursorConfig, + claudeConfigDir?: string +): Record { + const opusModel = config.opus_model || config.model; + const sonnetModel = config.sonnet_model || config.model; + const haikuModel = config.haiku_model || config.model; + + return { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`, + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + ANTHROPIC_MODEL: config.model, + ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, + ANTHROPIC_SMALL_FAST_MODEL: haikuModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel, + DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), + }; +} + +export async function resolveCursorImageAnalysisEnv( + verbose = false +): Promise { + const env = getImageAnalysisHookEnv({ + profileName: 'cursor', + profileType: 'cursor', + }); + const provider = env['CCS_CURRENT_PROVIDER']; + if (env['CCS_IMAGE_ANALYSIS_SKIP'] === '1' || !provider) { + return { env, warning: null }; + } + + const status = await resolveImageAnalysisRuntimeStatus({ + profileName: 'cursor', + profileType: 'cursor', + }); + + if (status.effectiveRuntimeMode === 'native-read') { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `${status.effectiveRuntimeReason || `Image analysis via ${provider} is unavailable.`} This session will use native Read.`, + }; + } + + if (status.proxyReadiness === 'stopped') { + const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose); + if (!ensureServiceResult.started) { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `Image analysis via ${provider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`, + }; + } + } + + return { env, warning: null }; +} + +export async function executeCursorProfile( + config: CursorConfig, + claudeArgs: string[], + claudeConfigDir?: string, + claudeCliPath = 'claude' +): Promise { + if (!config.enabled) { + console.error(fail('Cursor integration is not enabled.')); + console.error(''); + console.error('Enable it first: ccs cursor enable'); + return 1; + } + + const authStatus = checkAuthStatus(); + if (!authStatus.authenticated) { + console.error(fail('Cursor credentials not found.')); + console.error(''); + console.error('Authenticate first: ccs cursor auth'); + return 1; + } + if (authStatus.expired) { + console.error(fail('Cursor credentials have expired.')); + console.error(''); + console.error('Refresh them with: ccs cursor auth'); + return 1; + } + + let daemonRunning = await isDaemonRunning(config.port); + if (!daemonRunning) { + if (config.auto_start) { + console.log(info('Starting cursor daemon...')); + const result = await startDaemon({ + port: config.port, + ghost_mode: config.ghost_mode, + }); + if (!result.success) { + console.error(fail(`Failed to start cursor daemon: ${result.error}`)); + return 1; + } + console.log(ok(`Daemon started on port ${config.port}`)); + daemonRunning = true; + } else { + console.error(fail('Cursor daemon is not running.')); + console.error(''); + console.error('Start the daemon:'); + console.error(' ccs cursor start'); + console.error('Or enable auto_start in the Cursor config section.'); + return 1; + } + } + + const cursorEnv = generateCursorEnv(config, claudeConfigDir); + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + const webSearchEnv = getWebSearchHookEnv(); + const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = + await resolveCursorImageAnalysisEnv(); + const env = stripClaudeCodeEnv({ + ...process.env, + ...globalEnv, + ...cursorEnv, + ...webSearchEnv, + ...imageAnalysisEnv, + CCS_PROFILE_TYPE: 'cursor', + }); + + console.log(info(`Using Cursor proxy (model: ${config.model})`)); + if (imageAnalysisWarning) { + console.log(info(imageAnalysisWarning)); + } + console.log(''); + + syncWebSearchMcpToConfigDir(claudeConfigDir); + + return new Promise((resolve) => { + const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs); + const traceEnv = createWebSearchTraceContext({ + launcher: 'cursor.executor', + args: launchArgs, + profile: 'cursor', + profileType: 'cursor', + claudeConfigDir, + }); + + const proc = spawn(claudeCliPath, launchArgs, { + stdio: 'inherit', + env: { ...env, ...traceEnv }, + shell: process.platform === 'win32', + }); + + proc.on('close', (code) => { + resolve(code ?? 0); + }); + + proc.on('error', (err) => { + console.error(fail(`Failed to start Claude: ${err.message}`)); + resolve(1); + }); + }); +} diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 0322083c..42e42860 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -45,3 +45,4 @@ export { // Executor export { CursorExecutor } from './cursor-executor'; +export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor'; diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index 9f6a9385..cf04b0fc 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -11,6 +11,7 @@ import { import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { getProxyTarget } from '../cliproxy/proxy-target-resolver'; import { generateCopilotEnv } from '../copilot/copilot-executor'; +import { generateCursorEnv } from '../cursor'; import InstanceManager from '../management/instance-manager'; import SharedManager from '../management/shared-manager'; import { expandPath } from '../utils/helpers'; @@ -97,6 +98,7 @@ function describeProfile(profileName: string, result: ProfileDetectionResult): s if (result.type === 'account') return 'Claude account instance isolated through CLAUDE_CONFIG_DIR.'; if (result.type === 'copilot') return 'GitHub Copilot profile routed through copilot-api.'; + if (result.type === 'cursor') return 'Cursor profile routed through the local cursor daemon.'; return 'Native Claude profile resolution.'; } @@ -129,6 +131,12 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] { } catch { // Copilot disabled; skip from setup UI. } + try { + detector.detectProfileType('cursor'); + deduped.push('cursor'); + } catch { + // Cursor disabled; skip from setup UI. + } return deduped .map((profileName) => createProfileOption(profileName, detector.detectProfileType(profileName))) @@ -201,40 +209,47 @@ async function resolveExtensionEnv( } return generateCopilotEnv(result.copilotConfig, continuity.claudeConfigDir); })() - : (() => { - if (!result.provider) { - throw new Error( - `Profile "${requestedProfile}" is missing CLIProxy provider metadata.` - ); - } - const proxyTarget = getProxyTarget(); - const port = result.port || CLIPROXY_DEFAULT_PORT; - if (proxyTarget.isRemote) { + : result.type === 'cursor' + ? (() => { + if (!result.cursorConfig) { + throw new Error(`Profile "${requestedProfile}" is missing cursor configuration.`); + } + return generateCursorEnv(result.cursorConfig, continuity.claudeConfigDir); + })() + : (() => { + if (!result.provider) { + throw new Error( + `Profile "${requestedProfile}" is missing CLIProxy provider metadata.` + ); + } + const proxyTarget = getProxyTarget(); + const port = result.port || CLIPROXY_DEFAULT_PORT; + if (proxyTarget.isRemote) { + warnings.push( + `CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.` + ); + return result.isComposite && result.compositeTiers && result.compositeDefaultTier + ? getCompositeEnvVars( + result.compositeTiers, + result.compositeDefaultTier, + port, + result.settingsPath, + proxyTarget + ) + : getRemoteEnvVars(result.provider, proxyTarget, result.settingsPath); + } warnings.push( - `CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.` + 'CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.' ); return result.isComposite && result.compositeTiers && result.compositeDefaultTier ? getCompositeEnvVars( result.compositeTiers, result.compositeDefaultTier, port, - result.settingsPath, - proxyTarget + result.settingsPath ) - : getRemoteEnvVars(result.provider, proxyTarget, result.settingsPath); - } - warnings.push( - 'CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.' - ); - return result.isComposite && result.compositeTiers && result.compositeDefaultTier - ? getCompositeEnvVars( - result.compositeTiers, - result.compositeDefaultTier, - port, - result.settingsPath - ) - : getEffectiveEnvVars(result.provider, port, result.settingsPath); - })(); + : getEffectiveEnvVars(result.provider, port, result.settingsPath); + })(); if (result.type === 'settings' && isDeprecatedGlmtProfileName(requestedProfile)) { const normalized = normalizeDeprecatedGlmtEnv(sortEnvRecord(env)); @@ -257,6 +272,11 @@ async function resolveExtensionEnv( 'copilot-api must stay reachable for this profile to work inside the IDE extension.' ); } + if (result.type === 'cursor') { + warnings.push( + 'The local Cursor daemon must stay reachable for this profile to work inside the IDE extension.' + ); + } if (Object.keys(env).length === 0) { throw new Error(`Profile "${requestedProfile}" has no extension environment to export.`); } diff --git a/src/targets/target-runtime-compatibility.ts b/src/targets/target-runtime-compatibility.ts index 9c9a83ca..627479cf 100644 --- a/src/targets/target-runtime-compatibility.ts +++ b/src/targets/target-runtime-compatibility.ts @@ -37,6 +37,9 @@ export function evaluateTargetRuntimeCompatibility( if (input.profileType === 'copilot') { return unsupported('Factory Droid does not support Copilot profiles.'); } + if (input.profileType === 'cursor') { + return unsupported('Factory Droid does not support Cursor local-proxy profiles.'); + } return { supported: true }; } @@ -51,6 +54,10 @@ export function evaluateTargetRuntimeCompatibility( return unsupported('Codex CLI does not support Copilot profiles.'); } + if (input.profileType === 'cursor') { + return unsupported('Codex CLI does not support Cursor local-proxy profiles.'); + } + if (input.profileType === 'default') { return { supported: true }; } diff --git a/src/types/profile.ts b/src/types/profile.ts index 75e3ff44..9d2472d8 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -1,4 +1,4 @@ /** * Profile mode types used across routing and target adapters. */ -export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; +export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'cursor' | 'default'; diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index c8a2f324..f46f2319 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -195,5 +195,58 @@ describe('ProfileDetector', () => { existsSyncSpy.mockRestore(); } }); + + it('should detect cursor as a first-class runtime profile when enabled', () => { + const mockUnifiedConfig = { + version: 2, + cursor: { + enabled: true, + port: 20129, + auto_start: true, + ghost_mode: true, + model: 'gpt-5.3-codex', + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + const result = detector.detectProfileType('cursor'); + expect(result.type).toBe('cursor'); + expect(result.name).toBe('cursor'); + expect(result.cursorConfig?.auto_start).toBe(true); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should throw a helpful error when cursor profile is disabled', () => { + const mockUnifiedConfig = { + version: 2, + cursor: { + enabled: false, + port: 20129, + auto_start: false, + ghost_mode: true, + model: 'gpt-5.3-codex', + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); }); }); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 913fe14f..7ad072ae 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -88,13 +88,13 @@ describe('help command parity', () => { ).toBe(true); }); - test('root help documents bare cursor as the status entrypoint', async () => { + test('root help documents bare cursor as the runtime entrypoint', async () => { const lines: string[] = []; await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs cursor ')).toBe(false); - expect(rendered.includes('Cursor status + local daemon connection details')).toBe(true); + expect(rendered.includes('Run Claude via Cursor local proxy')).toBe(true); }); test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => { diff --git a/tests/unit/commands/root-command-router.test.ts b/tests/unit/commands/root-command-router.test.ts index 50314f95..7e32630e 100644 --- a/tests/unit/commands/root-command-router.test.ts +++ b/tests/unit/commands/root-command-router.test.ts @@ -74,6 +74,15 @@ describe('root-command-router', () => { expect(calls).toEqual([]); }); + it('does not capture cursor so bare cursor can fall through to profile routing', async () => { + const tryHandleRootCommand = await loadTryHandleRootCommand(); + + await expect(tryHandleRootCommand(['cursor'])).resolves.toBe(false); + await expect(tryHandleRootCommand(['cursor', 'status'])).resolves.toBe(false); + + expect(calls).toEqual([]); + }); + it('prints update help without invoking the updater', async () => { const tryHandleRootCommand = await loadTryHandleRootCommand(); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 1cf151ed..bcfc301a 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -303,7 +303,7 @@ describe('stopDaemon', () => { }); describe('handleCursorCommand', () => { - it('treats bare ccs cursor as a status entrypoint instead of help', async () => { + it('shows help when invoked without an admin subcommand', async () => { const originalLog = console.log; const originalError = console.error; const logs: string[] = []; @@ -321,10 +321,8 @@ describe('handleCursorCommand', () => { expect(exitCode).toBe(0); expect(errors).toHaveLength(0); - expect(logs.some((line) => line.includes('Cursor IDE Status'))).toBe(true); - expect(logs.some((line) => line.includes('Usage: ccs cursor [options]'))).toBe( - false - ); + expect(logs.some((line) => line.includes('Cursor IDE Integration'))).toBe(true); + expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); } finally { console.log = originalLog; console.error = originalError; @@ -473,7 +471,7 @@ describe('renderCursorStatus', () => { }); describe('renderCursorHelp', () => { - it('shows bare ccs cursor as an optional entrypoint', () => { + it('shows bare ccs cursor as the runtime entrypoint', () => { const originalLog = console.log; const logs: string[] = []; @@ -485,11 +483,9 @@ describe('renderCursorHelp', () => { const exitCode = renderCursorHelp(); expect(exitCode).toBe(0); - expect(logs.some((line) => line.includes('Usage: ccs cursor [subcommand] [options]'))).toBe( - true - ); + expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); expect( - logs.some((line) => line.includes('4. ccs cursor # Show status and runtime connection details')) + logs.some((line) => line.includes('ccs cursor [claude args]')) ).toBe(true); } finally { console.log = originalLog; diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts new file mode 100644 index 00000000..e655c882 --- /dev/null +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + executeCursorProfile, + generateCursorEnv, +} from '../../../src/cursor/cursor-profile-executor'; +import { saveCredentials } from '../../../src/cursor/cursor-auth'; +import type { CursorConfig } from '../../../src/config/unified-config-types'; + +const BASE_CONFIG: CursorConfig = { + enabled: true, + port: 20129, + auto_start: false, + ghost_mode: true, + model: 'gpt-5.3-codex', +}; + +describe('cursor-profile-executor', () => { + let originalCcsHome: string | undefined; + let tempDir: string; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-profile-executor-')); + process.env.CCS_HOME = tempDir; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('builds Cursor env for Claude runtime', () => { + const env = generateCursorEnv( + { + ...BASE_CONFIG, + opus_model: 'cursor-opus', + sonnet_model: 'cursor-sonnet', + haiku_model: 'cursor-haiku', + }, + '/tmp/claude-config' + ); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:20129'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('cursor-opus'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('cursor-haiku'); + expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/claude-config'); + }); + + it('fails fast when Cursor integration is disabled', async () => { + const exitCode = await executeCursorProfile({ ...BASE_CONFIG, enabled: false }, []); + expect(exitCode).toBe(1); + }); + + it('fails when credentials are missing', async () => { + const exitCode = await executeCursorProfile(BASE_CONFIG, []); + expect(exitCode).toBe(1); + }); + + it('fails with actionable guidance when daemon is down and auto_start is false', async () => { + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + const exitCode = await executeCursorProfile( + { + ...BASE_CONFIG, + port: 29991, + auto_start: false, + }, + [] + ); + + expect(exitCode).toBe(1); + }); +}); diff --git a/tests/unit/targets/target-runtime-compatibility.test.ts b/tests/unit/targets/target-runtime-compatibility.test.ts index e75a0600..60a2d1fa 100644 --- a/tests/unit/targets/target-runtime-compatibility.test.ts +++ b/tests/unit/targets/target-runtime-compatibility.test.ts @@ -3,6 +3,27 @@ import { describe, expect, test } from 'bun:test'; import { evaluateTargetRuntimeCompatibility } from '../../../src/targets/target-runtime-compatibility'; describe('evaluateTargetRuntimeCompatibility', () => { + test('rejects account, copilot, and cursor profiles on Droid target', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'droid', + profileType: 'account', + }).supported + ).toBe(false); + expect( + evaluateTargetRuntimeCompatibility({ + target: 'droid', + profileType: 'copilot', + }).supported + ).toBe(false); + expect( + evaluateTargetRuntimeCompatibility({ + target: 'droid', + profileType: 'cursor', + }).supported + ).toBe(false); + }); + test('supports native Codex default sessions', () => { expect( evaluateTargetRuntimeCompatibility({ @@ -69,7 +90,7 @@ describe('evaluateTargetRuntimeCompatibility', () => { expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/); }); - test('rejects account and copilot profiles on Codex target', () => { + test('rejects account, copilot, and cursor profiles on Codex target', () => { expect( evaluateTargetRuntimeCompatibility({ target: 'codex', @@ -82,5 +103,11 @@ describe('evaluateTargetRuntimeCompatibility', () => { profileType: 'copilot', }).supported ).toBe(false); + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'cursor', + }).supported + ).toBe(false); }); });