Skip to content
62 changes: 44 additions & 18 deletions apps/server/src/providers/cursor-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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') {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
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
98 changes: 98 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,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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
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
30 changes: 25 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,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"')
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

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}"`)
);
});

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

Expand Down
Loading
Loading