Skip to content
27 changes: 10 additions & 17 deletions apps/server/src/providers/cursor-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
* - 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).
* The user prompt is passed as the final positional argument, not via stdin.
*/

import { execSync } from 'child_process';
Expand Down Expand Up @@ -400,8 +403,7 @@ 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
* Extract prompt text from ExecuteOptions for the cursor-agent positional prompt argument.
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
Expand All @@ -420,9 +422,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
Expand All @@ -431,10 +432,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)
Expand All @@ -455,8 +456,7 @@ export class CursorProvider extends CliProvider {
cliArgs.push('--resume', options.sdkSessionId);
}

// Use '-' to indicate reading prompt from stdin
cliArgs.push('-');
cliArgs.push(this.extractPromptText(options));
Comment thread
richerarc marked this conversation as resolved.
Outdated

return cliArgs;
}
Expand Down Expand Up @@ -870,16 +870,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
Expand Down
6 changes: 3 additions & 3 deletions apps/server/tests/unit/lib/model-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
51 changes: 51 additions & 0 deletions apps/server/tests/unit/providers/cursor-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,57 @@ 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);
});
});

describe('normalizeEvent - result error handling', () => {
Expand Down
8 changes: 4 additions & 4 deletions libs/model-resolver/src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,9 +45,9 @@ const OPENAI_O_SERIES_ALLOWED_MODELS = new Set<string>();
*
* 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
*/
Expand All @@ -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}`);
Expand Down
19 changes: 14 additions & 5 deletions libs/model-resolver/tests/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -135,10 +144,10 @@ 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"')
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

Expand Down Expand Up @@ -509,7 +518,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');
});

Expand Down
56 changes: 46 additions & 10 deletions libs/types/src/cursor-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,13 +30,15 @@ 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'
| 'sonnet-4.6'
| 'sonnet-4.6-thinking'
Expand Down Expand Up @@ -72,12 +75,20 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
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: true,
Comment thread
richerarc marked this conversation as resolved.
Outdated
},
'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: true,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
'cursor-sonnet-4.6': {
id: 'cursor-sonnet-4.6',
Expand Down Expand Up @@ -233,14 +244,21 @@ export const CURSOR_MODEL_MAP: Record<CursorModelId, CursorModelConfig> = {
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: true,
},
};

/**
* Map from legacy model IDs to canonical prefixed IDs
*/
export const LEGACY_CURSOR_MODEL_MAP: Record<LegacyCursorModelId, CursorModelId> = {
auto: 'cursor-auto',
'composer-1': 'cursor-composer-1',
'composer-1': 'cursor-composer-2',
'sonnet-4.6': 'cursor-sonnet-4.6',
'sonnet-4.6-thinking': 'cursor-sonnet-4.6-thinking',
'sonnet-4.5': 'cursor-sonnet-4.5',
Expand All @@ -253,6 +271,13 @@ export const LEGACY_CURSOR_MODEL_MAP: Record<LegacyCursorModelId, CursorModelId>
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<string, CursorModelId>;

/**
* Helper: Check if model has thinking capability
*/
Expand Down Expand Up @@ -446,6 +471,17 @@ export const CURSOR_MODEL_GROUPS: GroupedModel[] = [
},
],
},
// Composer 2 group (thinking mode)
{
baseId: 'cursor-composer-2-group',
label: 'Composer 2',
description: 'Cursor Composer 2 agent model optimized for thinking and writing code',
variantType: 'thinking',
variants: [
{ id: 'cursor-composer-2', label: 'Standard', description: 'Standard responses' },
{ id: 'cursor-composer-2-fast', label: 'Fast', description: 'Faster responses' },
],
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
];

/**
Expand All @@ -454,11 +490,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',
];

/**
Expand Down
19 changes: 17 additions & 2 deletions libs/types/src/model-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -106,6 +116,11 @@ export function migrateCursorModelIds(ids: string[]): 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;
Expand Down Expand Up @@ -200,7 +215,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'
Expand Down