diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index 3903ea648..586f2bd7b 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -7,7 +7,13 @@ * - Session ID tracking * - Versions directory detection * - * Spawns the cursor-agent CLI with --output-format stream-json for streaming responses. + * CLI shape differs from OpenAI Codex (`codex exec … --json` + stdin + `-`): + * Cursor Agent requires `--print` for non-interactive use; `--output-format` and + * `--stream-partial-output` only apply with `--print` (see Cursor CLI parameters). + * On most platforms the user prompt is the final positional argument. On Windows + * when the subprocess runs with `shell: true` (see platform `spawnJSONLProcess`, + * e.g. `.cmd` shims or `npx`), the prompt is sent via stdin with `-` as the final + * argv element to avoid cmd.exe metacharacter interpretation and command-line length limits. */ import { execSync } from 'child_process'; @@ -42,7 +48,7 @@ import { CURSOR_MODEL_MAP, } from '@automaker/types'; import { createLogger, isAbortError } from '@automaker/utils'; -import { spawnJSONLProcess, execInWsl } from '@automaker/platform'; +import { spawnJSONLProcess, execInWsl, type SubprocessOptions } from '@automaker/platform'; // Create logger for this module const logger = createLogger('CursorProvider'); @@ -400,8 +406,18 @@ export class CursorProvider extends CliProvider { } /** - * Extract prompt text from ExecuteOptions - * Used to pass prompt via stdin instead of CLI args to avoid shell escaping issues + * True when `spawnJSONLProcess` will use `shell: true` on Windows (see platform + * subprocess: `.cmd`, `npx`, `npm`). In that case the prompt must not be a raw argv tail. + */ + private useStdinForPrompt(): boolean { + if (process.platform !== 'win32') return false; + if (this.detectedStrategy === 'npx') return true; + if (!this.cliPath) return false; + return this.cliPath.toLowerCase().endsWith('.cmd'); + } + + /** + * Extract prompt text from ExecuteOptions for the cursor-agent positional prompt argument. */ private extractPromptText(options: ExecuteOptions): string { if (typeof options.prompt === 'string') { @@ -420,9 +436,8 @@ export class CursorProvider extends CliProvider { // Model is already bare (no prefix) - validated by executeQuery const model = options.model || 'auto'; - // Build CLI arguments for cursor-agent - // NOTE: Prompt is NOT included here - it's passed via stdin to avoid - // shell escaping issues when content contains $(), backticks, etc. + // Build CLI arguments for cursor-agent. Prompt is the final positional argument + // (spawn passes argv directly; no shell interpolation on typical native/WSL paths). const cliArgs: string[] = []; // If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand @@ -431,10 +446,10 @@ export class CursorProvider extends CliProvider { } cliArgs.push( - '-p', // Print mode (non-interactive) + '--print', // Required: --output-format / --stream-partial-output only work with --print '--output-format', 'stream-json', - '--stream-partial-output' // Real-time streaming + '--stream-partial-output' ); // In read-only mode, use --mode ask for Q&A style (no tools) @@ -455,12 +470,30 @@ export class CursorProvider extends CliProvider { cliArgs.push('--resume', options.sdkSessionId); } - // Use '-' to indicate reading prompt from stdin - cliArgs.push('-'); + if (this.useStdinForPrompt()) { + cliArgs.push('-'); + } else { + cliArgs.push(this.extractPromptText(options)); + } return cliArgs; } + /** + * Pass prompt on stdin when Windows spawns with a shell; otherwise same as base. + */ + protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions { + const subprocessOptions = super.buildSubprocessOptions(options, cliArgs); + if (!this.useStdinForPrompt()) { + return subprocessOptions; + } + const effectiveOptions = this.embedSystemPromptIntoPrompt(options); + return { + ...subprocessOptions, + stdinData: this.extractPromptText(effectiveOptions), + }; + } + /** * Convert Cursor event to AutoMaker ProviderMessage format * Made public as required by CliProvider abstract method @@ -870,16 +903,9 @@ export class CursorProvider extends CliProvider { // Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages) const effectiveOptions = this.embedSystemPromptIntoPrompt(options); - // Extract prompt text to pass via stdin (avoids shell escaping issues) - const promptText = this.extractPromptText(effectiveOptions); - const cliArgs = this.buildCliArgs(effectiveOptions); const subprocessOptions = this.buildSubprocessOptions(options, cliArgs); - // Pass prompt via stdin to avoid shell interpretation of special characters - // like $(), backticks, etc. that may appear in file content - subprocessOptions.stdinData = promptText; - let sessionId: string | undefined; // Dedup state for Cursor-specific text block handling diff --git a/apps/server/tests/unit/lib/model-resolver.test.ts b/apps/server/tests/unit/lib/model-resolver.test.ts index 65e3115df..e4d4a2781 100644 --- a/apps/server/tests/unit/lib/model-resolver.test.ts +++ b/apps/server/tests/unit/lib/model-resolver.test.ts @@ -89,14 +89,14 @@ describe('model-resolver.ts', () => { describe('Cursor models', () => { it('should pass through cursor-prefixed models unchanged', () => { - const result = resolveModelString('cursor-composer-1'); - expect(result).toBe('cursor-composer-1'); + const result = resolveModelString('cursor-composer-2'); + expect(result).toBe('cursor-composer-2'); expect(consoleSpy.log).toHaveBeenCalledWith(expect.stringContaining('Using Cursor model')); }); it('should add cursor- prefix to bare Cursor model IDs', () => { const result = resolveModelString('composer-1'); - expect(result).toBe('cursor-composer-1'); + expect(result).toBe('cursor-composer-2'); }); it('should handle cursor-auto model', () => { diff --git a/apps/server/tests/unit/providers/cursor-provider.test.ts b/apps/server/tests/unit/providers/cursor-provider.test.ts index 846ac69be..d0503f90e 100644 --- a/apps/server/tests/unit/providers/cursor-provider.test.ts +++ b/apps/server/tests/unit/providers/cursor-provider.test.ts @@ -36,6 +36,104 @@ describe('cursor-provider.ts', () => { expect(args).not.toContain('--resume'); }); + + it('passes the prompt as the final positional argument', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const prompt = 'Implement the feature'; + const args = provider.buildCliArgs({ + prompt, + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args[args.length - 1]).toBe(prompt); + expect(args).not.toContain('-'); + }); + + it('joins array prompt text blocks with newlines as the final positional', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: [ + { type: 'text', text: 'First line' }, + { type: 'text', text: 'Second line' }, + ], + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args[args.length - 1]).toBe('First line\nSecond line'); + }); + + it('preserves shell-like characters in the positional prompt (argv, not shell)', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const prompt = 'Run `echo $HOME` and $(date)'; + const args = provider.buildCliArgs({ + prompt, + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args[args.length - 1]).toBe(prompt); + }); + + it('uses stdin placeholder as final arg on Windows when npx strategy', () => { + const origPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + detectedStrategy?: string; + }; + provider.cliPath = 'C:\\npx'; + provider.detectedStrategy = 'npx'; + + const prompt = 'Large or special prompt'; + const args = provider.buildCliArgs({ + prompt, + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args[args.length - 1]).toBe('-'); + } finally { + Object.defineProperty(process, 'platform', { value: origPlatform }); + } + }); + + it('uses stdin placeholder as final arg on Windows when CLI is a .cmd shim', () => { + const origPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + detectedStrategy?: string; + }; + provider.cliPath = 'C:\\Users\\u\\AppData\\Roaming\\npm\\cursor-agent.cmd'; + provider.detectedStrategy = 'native'; + + const args = provider.buildCliArgs({ + prompt: 'x', + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args[args.length - 1]).toBe('-'); + } finally { + Object.defineProperty(process, 'platform', { value: origPlatform }); + } + }); }); describe('normalizeEvent - result error handling', () => { diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index f59f66655..9ddc75e97 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -2000,7 +2000,9 @@ export function PhaseModelSelector({ ? 'Compute Level' : group.variantType === 'thinking' ? 'Reasoning Mode' - : 'Capacity Options'; + : group.variantType === 'speed' + ? 'Speed' + : 'Capacity Options'; // On mobile, render inline expansion instead of nested popover if (isMobile) { diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index 877fcafcf..b4793d941 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -10,7 +10,7 @@ * - Handles multiple model sources with priority * * With canonical model IDs: - * - Cursor: cursor-auto, cursor-composer-1, cursor-gpt-5.2 + * - Cursor: cursor-auto, cursor-composer-2, cursor-gpt-5.2 * - OpenCode: opencode-big-pickle, opencode-kimi-k2.5-free * - Copilot: copilot-gpt-5.1, copilot-claude-sonnet-4.5, copilot-gemini-3-pro-preview * - Gemini: gemini-2.5-flash, gemini-2.5-pro @@ -45,9 +45,9 @@ const OPENAI_O_SERIES_ALLOWED_MODELS = new Set(); * * Handles both canonical prefixed IDs and legacy aliases: * - Canonical: cursor-auto, cursor-gpt-5.2, opencode-big-pickle, claude-sonnet - * - Legacy: auto, composer-1, sonnet, opus + * - Legacy: auto, composer-1 (→ cursor-composer-2), sonnet, opus * - * @param modelKey - Model key (e.g., "claude-opus", "cursor-composer-1", "sonnet") + * @param modelKey - Model key (e.g., "claude-opus", "cursor-composer-2", "sonnet") * @param defaultModel - Fallback model if modelKey is undefined * @returns Full model string */ @@ -71,7 +71,7 @@ export function resolveModelString( console.log(`[ModelResolver] Migrated legacy ID: "${modelKey}" -> "${canonicalKey}"`); } - // Cursor model with explicit prefix (e.g., "cursor-auto", "cursor-composer-1") + // Cursor model with explicit prefix (e.g., "cursor-auto", "cursor-composer-2") // Pass through unchanged - provider will extract bare ID for CLI if (canonicalKey.startsWith(PROVIDER_PREFIXES.cursor)) { console.log(`[ModelResolver] Using Cursor model: ${canonicalKey}`); diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts index 0a3fa0b44..e86f68469 100644 --- a/libs/model-resolver/tests/resolver.test.ts +++ b/libs/model-resolver/tests/resolver.test.ts @@ -114,12 +114,21 @@ describe('model-resolver', () => { describe('with Cursor models', () => { it('should pass through cursor-prefixed model unchanged', () => { - const result = resolveModelString('cursor-composer-1'); + const result = resolveModelString('cursor-composer-2'); - expect(result).toBe('cursor-composer-1'); + expect(result).toBe('cursor-composer-2'); expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Using Cursor model')); }); + it('should migrate retired cursor-composer-1 to cursor-composer-2', () => { + const result = resolveModelString('cursor-composer-1'); + + expect(result).toBe('cursor-composer-2'); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Migrated legacy ID: "cursor-composer-1" -> "cursor-composer-2"') + ); + }); + it('should handle cursor-auto model', () => { const result = resolveModelString('cursor-auto'); @@ -135,10 +144,21 @@ describe('model-resolver', () => { it('should add cursor- prefix to bare Cursor model IDs', () => { const result = resolveModelString('composer-1'); - expect(result).toBe('cursor-composer-1'); + expect(result).toBe('cursor-composer-2'); // Legacy bare IDs are migrated to canonical prefixed format expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining('Migrated legacy ID: "composer-1" -> "cursor-composer-1"') + expect.stringContaining('Migrated legacy ID: "composer-1" -> "cursor-composer-2"') + ); + }); + + it.each([ + ['composer-2', 'cursor-composer-2'], + ['composer-2-fast', 'cursor-composer-2-fast'], + ['kimi-k2.5', 'cursor-kimi-k2.5'], + ] as const)('migrates bare Cursor id %s -> %s', (input, expected) => { + expect(resolveModelString(input)).toBe(expected); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining(`Migrated legacy ID: "${input}" -> "${expected}"`) ); }); @@ -509,7 +529,7 @@ describe('model-resolver', () => { const entry: PhaseModelEntry = { model: 'composer-1', thinkingLevel: 'high' }; const result = resolvePhaseModel(entry); - expect(result.model).toBe('cursor-composer-1'); + expect(result.model).toBe('cursor-composer-2'); expect(result.thinkingLevel).toBe('high'); }); diff --git a/libs/types/src/cursor-models.ts b/libs/types/src/cursor-models.ts index a48a791f5..4aa093806 100644 --- a/libs/types/src/cursor-models.ts +++ b/libs/types/src/cursor-models.ts @@ -7,7 +7,8 @@ */ export type CursorModelId = | 'cursor-auto' // Auto-select best model - | 'cursor-composer-1' // Cursor Composer agent model + | 'cursor-composer-2' // Cursor Composer 2 agent model + | 'cursor-composer-2-fast' // Cursor Composer 2 fast agent model | 'cursor-sonnet-4.6' // Claude Sonnet 4.6 | 'cursor-sonnet-4.6-thinking' // Claude Sonnet 4.6 with extended thinking | 'cursor-sonnet-4.5' // Claude Sonnet 4.5 @@ -29,14 +30,19 @@ export type CursorModelId = | 'cursor-gpt-5.2-codex-high' // GPT-5.2 Codex High via Cursor | 'cursor-gpt-5.2-codex-max' // GPT-5.2 Codex Max via Cursor | 'cursor-gpt-5.2-codex-max-high' // GPT-5.2 Codex Max High via Cursor - | 'cursor-grok'; // Grok + | 'cursor-grok' // Grok + | 'cursor-kimi-k2.5'; // Kimi K2.5 via Cursor /** * Legacy Cursor model IDs (without prefix) for migration support */ export type LegacyCursorModelId = | 'auto' + /** @deprecated Composer 1 removed; migrates to cursor-composer-2 */ | 'composer-1' + | 'composer-2' + | 'composer-2-fast' + | 'kimi-k2.5' | 'sonnet-4.6' | 'sonnet-4.6-thinking' | 'sonnet-4.5' @@ -72,12 +78,20 @@ export const CURSOR_MODEL_MAP: Record = { hasThinking: false, supportsVision: false, // Vision not yet supported by Cursor CLI }, - 'cursor-composer-1': { - id: 'cursor-composer-1', - label: 'Composer 1', - description: 'Cursor Composer agent model optimized for multi-file edits', - hasThinking: false, - supportsVision: false, + 'cursor-composer-2': { + id: 'cursor-composer-2', + label: 'Composer 2', + description: 'Cursor Composer 2 agent model optimized for thinking and writing code', + hasThinking: true, + supportsVision: false, // Cursor CLI does not pass images; matches other Cursor models + }, + 'cursor-composer-2-fast': { + id: 'cursor-composer-2-fast', + label: 'Composer 2 Fast', + description: + 'Cursor Composer 2 fast agent model optimized for thinking and writing code, faster', + hasThinking: true, + supportsVision: false, // Cursor CLI does not pass images; matches other Cursor models }, 'cursor-sonnet-4.6': { id: 'cursor-sonnet-4.6', @@ -233,6 +247,13 @@ export const CURSOR_MODEL_MAP: Record = { hasThinking: false, supportsVision: false, }, + 'cursor-kimi-k2.5': { + id: 'cursor-kimi-k2.5', + label: 'Kimi K2.5', + description: 'Kimi K2.5 via Cursor', + hasThinking: true, + supportsVision: false, // Cursor CLI does not pass images; matches other Cursor models + }, }; /** @@ -240,7 +261,10 @@ export const CURSOR_MODEL_MAP: Record = { */ export const LEGACY_CURSOR_MODEL_MAP: Record = { auto: 'cursor-auto', - 'composer-1': 'cursor-composer-1', + 'composer-1': 'cursor-composer-2', + 'composer-2': 'cursor-composer-2', + 'composer-2-fast': 'cursor-composer-2-fast', + 'kimi-k2.5': 'cursor-kimi-k2.5', 'sonnet-4.6': 'cursor-sonnet-4.6', 'sonnet-4.6-thinking': 'cursor-sonnet-4.6-thinking', 'sonnet-4.5': 'cursor-sonnet-4.5', @@ -253,6 +277,13 @@ export const LEGACY_CURSOR_MODEL_MAP: Record grok: 'cursor-grok', }; +/** + * Retired Cursor canonical IDs (older releases) → current replacement + */ +export const RETIRED_CURSOR_MODEL_MAP = { + 'cursor-composer-1': 'cursor-composer-2', +} as const satisfies Record; + /** * Helper: Check if model has thinking capability */ @@ -282,7 +313,7 @@ export function getAllCursorModelIds(): CursorModelId[] { /** * Type of variant options available for grouped models */ -export type VariantType = 'compute' | 'thinking' | 'capacity'; +export type VariantType = 'compute' | 'thinking' | 'capacity' | 'speed'; /** * A single variant option within a grouped model @@ -446,6 +477,17 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [ }, ], }, + // Composer 2 group (Standard vs faster responses) + { + baseId: 'cursor-composer-2-group', + label: 'Composer 2', + description: 'Cursor Composer 2 agent model optimized for thinking and writing code', + variantType: 'speed', + variants: [ + { id: 'cursor-composer-2', label: 'Standard', description: 'Standard responses' }, + { id: 'cursor-composer-2-fast', label: 'Fast', description: 'Faster responses' }, + ], + }, ]; /** @@ -454,11 +496,11 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [ */ export const STANDALONE_CURSOR_MODELS: CursorModelId[] = [ 'cursor-auto', - 'cursor-composer-1', 'cursor-opus-4.1', 'cursor-gemini-3-pro', 'cursor-gemini-3-flash', 'cursor-grok', + 'cursor-kimi-k2.5', ]; /** diff --git a/libs/types/src/model-migration.ts b/libs/types/src/model-migration.ts index b42833f77..91e70bd39 100644 --- a/libs/types/src/model-migration.ts +++ b/libs/types/src/model-migration.ts @@ -6,7 +6,11 @@ */ import type { CursorModelId, LegacyCursorModelId } from './cursor-models.js'; -import { LEGACY_CURSOR_MODEL_MAP, CURSOR_MODEL_MAP } from './cursor-models.js'; +import { + LEGACY_CURSOR_MODEL_MAP, + CURSOR_MODEL_MAP, + RETIRED_CURSOR_MODEL_MAP, +} from './cursor-models.js'; import type { OpencodeModelId, LegacyOpencodeModelId } from './opencode-models.js'; import { LEGACY_OPENCODE_MODEL_MAP, @@ -55,6 +59,12 @@ export function migrateModelId(legacyId: string | undefined | null): string { return legacyId as string; } + const retiredReplacement = + RETIRED_CURSOR_MODEL_MAP[legacyId as keyof typeof RETIRED_CURSOR_MODEL_MAP]; + if (retiredReplacement) { + return retiredReplacement; + } + // Already has cursor- prefix and is in the map - it's canonical if (legacyId.startsWith('cursor-') && legacyId in CURSOR_MODEL_MAP) { return legacyId; @@ -105,25 +115,32 @@ export function migrateCursorModelIds(ids: string[]): CursorModelId[] { return []; } - return ids.map((id) => { - // Already canonical - if (id.startsWith('cursor-') && id in CURSOR_MODEL_MAP) { - return id as CursorModelId; - } + return ids + .map((id) => { + const retired = RETIRED_CURSOR_MODEL_MAP[id as keyof typeof RETIRED_CURSOR_MODEL_MAP]; + if (retired) { + return retired; + } + + // Already canonical + if (id.startsWith('cursor-') && id in CURSOR_MODEL_MAP) { + return id as CursorModelId; + } - // Legacy ID - if (isLegacyCursorModelId(id)) { - return LEGACY_CURSOR_MODEL_MAP[id]; - } + // Legacy ID + if (isLegacyCursorModelId(id)) { + return LEGACY_CURSOR_MODEL_MAP[id]; + } - // Unknown - assume it might be a valid cursor model with prefix - if (id.startsWith('cursor-')) { - return id as CursorModelId; - } + // Unknown - assume it might be a valid cursor model with prefix + if (id.startsWith('cursor-')) { + return id as CursorModelId; + } - // Add prefix if not present - return `cursor-${id}` as CursorModelId; - }); + // Add prefix if not present + return `cursor-${id}` as CursorModelId; + }) + .filter((id, index, self) => self.indexOf(id) === index); } /** @@ -200,7 +217,7 @@ export function migratePhaseModelEntry( * * When calling provider CLIs, we need to strip the provider prefix: * - 'cursor-auto' -> 'auto' (for Cursor CLI) - * - 'cursor-composer-1' -> 'composer-1' (for Cursor CLI) + * - 'cursor-composer-2' -> 'composer-2' (for Cursor CLI) * - 'opencode-big-pickle' -> 'big-pickle' (for OpenCode CLI) * * Note: GPT models via Cursor keep the gpt- part: 'cursor-gpt-5.2' -> 'gpt-5.2'