Files
automaker/apps/server/src/providers/cursor-provider.ts
gsxdsm 0196911d59 Bug fixes and stability improvements (#815)
* fix(copilot): correct tool.execution_complete event handling

The CopilotProvider was using incorrect event type and data structure
for tool execution completion events from the @github/copilot-sdk,
causing tool call outputs to be empty.

Changes:
- Update event type from 'tool.execution_end' to 'tool.execution_complete'
- Fix data structure to use nested result.content instead of flat result
- Fix error structure to use error.message instead of flat error
- Add success field to match SDK event structure
- Add tests for empty and missing result handling

This aligns with the official @github/copilot-sdk v0.1.16 types
defined in session-events.d.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(copilot): improve error handling and code quality

Code review improvements:
- Extract magic string '[ERROR]' to TOOL_ERROR_PREFIX constant
- Add null-safe error handling with direct error variable assignment
- Include error codes in error messages for better debugging
- Add JSDoc documentation for tool.execution_complete handler
- Update tests to verify error codes are displayed
- Add missing tool_use_id assertion in error test

These changes improve:
- Code maintainability (no magic strings)
- Debugging experience (error codes now visible)
- Type safety (explicit null checks)
- Test coverage (verify error code formatting)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* fix: Handle detached HEAD state in worktree discovery and recovery

* fix: Remove unused isDevServerStarting prop and md: breakpoint classes

* fix: Add missing dependency and sanitize persisted cache data

* feat: Ensure NODE_ENV is set to test in vitest configs

* feat: Configure Playwright to run only E2E tests

* fix: Improve PR tracking and dev server lifecycle management

* feat: Add settings-based defaults for planning mode, model config, and custom providers. Fixes #816

* feat: Add worktree and branch selector to graph view

* fix: Add timeout and error handling for worktree HEAD ref resolution

* fix: use absolute icon path and place icon outside asar on Linux

The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use linux.desktop.entry for custom desktop Icon field

electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: set desktop name on Linux so taskbar uses the correct app icon

Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: memory and context views mobile friendly (#818)

* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Improve test reliability and localhost handling

* chore: Use explicit TEST_USE_EXTERNAL_BACKEND env var for server cleanup

* feat: Add E2E/CI mock mode for provider factory and auth verification

* feat: Add remoteBranch parameter to pull and rebase operations

* chore: Enhance E2E testing setup with worker isolation and auth state management

- Updated .gitignore to include worker-specific test fixtures.
- Modified e2e-tests.yml to implement test sharding for improved CI performance.
- Refactored global setup to authenticate once and save session state for reuse across tests.
- Introduced worker-isolated fixture paths to prevent conflicts during parallel test execution.
- Improved test navigation and loading handling for better reliability.
- Updated various test files to utilize new auth state management and fixture paths.

* fix: Update Playwright configuration and improve test reliability

- Increased the number of workers in Playwright configuration for better parallelism in CI environments.
- Enhanced the board background persistence test to ensure dropdown stability by waiting for the list to populate before interaction, improving test reliability.

* chore: Simplify E2E test configuration and enhance mock implementations

- Updated e2e-tests.yml to run tests in a single shard for streamlined CI execution.
- Enhanced unit tests for worktree list handling by introducing a mock for execGitCommand, improving test reliability and coverage.
- Refactored setup functions to better manage command mocks for git operations in tests.
- Improved error handling in mkdirSafe function to account for undefined stats in certain environments.

* refactor: Improve test configurations and enhance error handling

- Updated Playwright configuration to clear VITE_SERVER_URL, ensuring the frontend uses the Vite proxy and preventing cookie domain mismatches.
- Enhanced MergeRebaseDialog logic to normalize selectedBranch for better handling of various ref formats.
- Improved global setup with a more robust backend health check, throwing an error if the backend is not healthy after retries.
- Refactored project creation tests to handle file existence checks more reliably.
- Added error handling for missing E2E source fixtures to guide setup process.
- Enhanced memory navigation to handle sandbox dialog visibility more effectively.

* refactor: Enhance Git command execution and improve test configurations

- Updated Git command execution to merge environment paths correctly, ensuring proper command execution context.
- Refactored the Git initialization process to handle errors more gracefully and ensure user configuration is set before creating the initial commit.
- Improved test configurations by updating Playwright test identifiers for better clarity and consistency across different project states.
- Enhanced cleanup functions in tests to handle directory removal more robustly, preventing errors during test execution.

* fix: Resolve React hooks errors from duplicate instances in dependency tree

* style: Format alias configuration for improved readability

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: DhanushSantosh <dhanushsantoshs05@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-27 17:03:29 -08:00

1259 lines
39 KiB
TypeScript

/**
* Cursor Provider - Executes queries using cursor-agent CLI
*
* Extends CliProvider with Cursor-specific:
* - Event normalization for Cursor's JSONL format
* - Text block deduplication (Cursor sends duplicates)
* - Session ID tracking
* - Versions directory detection
*
* Spawns the cursor-agent CLI with --output-format stream-json for streaming responses.
*/
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { findCliInWsl, isWslAvailable } from '@automaker/platform';
import {
CliProvider,
type CliSpawnConfig,
type CliDetectionResult,
type CliErrorInfo,
} from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
ContentBlock,
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { validateApiKey } from '../lib/auth-utils.js';
import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js';
import {
type CursorStreamEvent,
type CursorSystemEvent,
type CursorAssistantEvent,
type CursorToolCallEvent,
type CursorResultEvent,
type CursorAuthStatus,
CURSOR_MODEL_MAP,
} from '@automaker/types';
import { createLogger, isAbortError } from '@automaker/utils';
import { spawnJSONLProcess, execInWsl } from '@automaker/platform';
// Create logger for this module
const logger = createLogger('CursorProvider');
// =============================================================================
// Cursor Tool Handler Registry
// =============================================================================
/**
* Tool handler definition for mapping Cursor tool calls to normalized format
*/
interface CursorToolHandler<TArgs = unknown, TResult = unknown> {
/** The normalized tool name (e.g., 'Read', 'Write') */
name: string;
/** Extract and normalize input from Cursor's args format */
mapInput: (args: TArgs) => unknown;
/** Format the result content for display (optional) */
formatResult?: (result: TResult, args?: TArgs) => string;
/** Format rejected result (optional) */
formatRejected?: (reason: string) => string;
}
/**
* Registry of Cursor tool handlers
* Each handler knows how to normalize its specific tool call type
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
readToolCall: {
name: 'Read',
mapInput: (args: { path: string }) => ({ file_path: args.path }),
formatResult: (result: { content: string }) => result.content,
},
writeToolCall: {
name: 'Write',
mapInput: (args: { path: string; fileText: string }) => ({
file_path: args.path,
content: args.fileText,
}),
formatResult: (result: { linesCreated: number; path: string }) =>
`Wrote ${result.linesCreated} lines to ${result.path}`,
},
editToolCall: {
name: 'Edit',
mapInput: (args: { path: string; oldText?: string; newText?: string }) => ({
file_path: args.path,
old_string: args.oldText,
new_string: args.newText,
}),
formatResult: (_result: unknown, args?: { path: string }) => `Edited file: ${args?.path}`,
},
shellToolCall: {
name: 'Bash',
mapInput: (args: { command: string }) => ({ command: args.command }),
formatResult: (result: { exitCode: number; stdout?: string; stderr?: string }) => {
let content = `Exit code: ${result.exitCode}`;
if (result.stdout) content += `\n${result.stdout}`;
if (result.stderr) content += `\nStderr: ${result.stderr}`;
return content;
},
formatRejected: (reason: string) => `Rejected: ${reason}`,
},
deleteToolCall: {
name: 'Delete',
mapInput: (args: { path: string }) => ({ file_path: args.path }),
formatResult: (_result: unknown, args?: { path: string }) => `Deleted: ${args?.path}`,
formatRejected: (reason: string) => `Delete rejected: ${reason}`,
},
grepToolCall: {
name: 'Grep',
mapInput: (args: { pattern: string; path?: string }) => ({
pattern: args.pattern,
path: args.path,
}),
formatResult: (result: { matchedLines: number }) =>
`Found ${result.matchedLines} matching lines`,
},
lsToolCall: {
name: 'Ls',
mapInput: (args: { path: string }) => ({ path: args.path }),
formatResult: (result: { childrenFiles: number; childrenDirs: number }) =>
`Found ${result.childrenFiles} files, ${result.childrenDirs} directories`,
},
globToolCall: {
name: 'Glob',
mapInput: (args: { globPattern: string; targetDirectory?: string }) => ({
pattern: args.globPattern,
path: args.targetDirectory,
}),
formatResult: (result: { totalFiles: number }) => `Found ${result.totalFiles} matching files`,
},
semSearchToolCall: {
name: 'SemanticSearch',
mapInput: (args: { query: string; targetDirectories?: string[]; explanation?: string }) => ({
query: args.query,
targetDirectories: args.targetDirectories,
explanation: args.explanation,
}),
formatResult: (result: { results: string; codeResults?: unknown[] }) => {
const resultCount = result.codeResults?.length || 0;
return resultCount > 0
? `Found ${resultCount} semantic search result(s)`
: result.results || 'No results found';
},
},
readLintsToolCall: {
name: 'ReadLints',
mapInput: (args: { paths: string[] }) => ({ paths: args.paths }),
formatResult: (result: { totalDiagnostics: number; totalFiles: number }) =>
`Found ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)`,
},
};
/**
* Process a Cursor tool call using the handler registry
* Returns { toolName, toolInput } or null if tool type is unknown
*/
function processCursorToolCall(
toolCall: CursorToolCallEvent['tool_call']
): { toolName: string; toolInput: unknown } | null {
// Check each registered handler
for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) {
const toolData = toolCall[key as keyof typeof toolCall] as { args?: unknown } | undefined;
if (toolData) {
// Skip if args not yet populated (partial streaming event)
if (!toolData.args) return null;
return {
toolName: handler.name,
toolInput: handler.mapInput(toolData.args),
};
}
}
// Handle generic function call (fallback)
if (toolCall.function) {
let toolInput: unknown;
try {
toolInput = JSON.parse(toolCall.function.arguments || '{}');
} catch {
toolInput = { raw: toolCall.function.arguments };
}
return {
toolName: toolCall.function.name,
toolInput,
};
}
return null;
}
/**
* Format the result content for a completed Cursor tool call
*/
function formatCursorToolResult(toolCall: CursorToolCallEvent['tool_call']): string {
for (const [key, handler] of Object.entries(CURSOR_TOOL_HANDLERS)) {
const toolData = toolCall[key as keyof typeof toolCall] as
| {
args?: unknown;
result?: { success?: unknown; rejected?: { reason: string } };
}
| undefined;
if (toolData?.result) {
if (toolData.result.success && handler.formatResult) {
return handler.formatResult(toolData.result.success, toolData.args);
}
if (toolData.result.rejected && handler.formatRejected) {
return handler.formatRejected(toolData.result.rejected.reason);
}
}
}
return '';
}
// =============================================================================
// Error Codes
// =============================================================================
/**
* Cursor-specific error codes for detailed error handling
*/
export enum CursorErrorCode {
NOT_INSTALLED = 'CURSOR_NOT_INSTALLED',
NOT_AUTHENTICATED = 'CURSOR_NOT_AUTHENTICATED',
RATE_LIMITED = 'CURSOR_RATE_LIMITED',
MODEL_UNAVAILABLE = 'CURSOR_MODEL_UNAVAILABLE',
NETWORK_ERROR = 'CURSOR_NETWORK_ERROR',
PROCESS_CRASHED = 'CURSOR_PROCESS_CRASHED',
TIMEOUT = 'CURSOR_TIMEOUT',
UNKNOWN = 'CURSOR_UNKNOWN_ERROR',
}
export interface CursorError extends Error {
code: CursorErrorCode;
recoverable: boolean;
suggestion?: string;
}
/**
* CursorProvider - Integrates cursor-agent CLI as an AI provider
*
* Extends CliProvider with Cursor-specific behavior:
* - WSL required on Windows (cursor-agent has no native Windows build)
* - Versions directory detection for cursor-agent installations
* - Session ID tracking for conversation continuity
* - Text block deduplication (Cursor sends duplicate chunks)
*/
export class CursorProvider extends CliProvider {
/**
* Version data directory where cursor-agent stores versions
* The install script creates versioned folders like:
* ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent
*/
private static VERSIONS_DIR = path.join(os.homedir(), '.local/share/cursor-agent/versions');
constructor(config: ProviderConfig = {}) {
super(config);
// Trigger CLI detection on construction (eager for Cursor)
this.ensureCliDetected();
}
// ==========================================================================
// CliProvider Abstract Method Implementations
// ==========================================================================
getName(): string {
return 'cursor';
}
getCliName(): string {
return 'cursor-agent';
}
getSpawnConfig(): CliSpawnConfig {
return {
windowsStrategy: 'direct',
commonPaths: {
linux: [
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
'/usr/local/bin/cursor-agent',
],
darwin: [path.join(os.homedir(), '.local/bin/cursor-agent'), '/usr/local/bin/cursor-agent'],
win32: [
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor-agent.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'resources',
'app',
'bin',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'Cursor',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor-agent.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'resources',
'app',
'bin',
'cursor.exe'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'Programs',
'cursor',
'cursor.exe'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'npm',
'cursor-agent.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'npm',
'cursor.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'.npm-global',
'bin',
'cursor-agent.cmd'
),
path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'.npm-global',
'bin',
'cursor.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'pnpm',
'cursor-agent.cmd'
),
path.join(
process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'),
'pnpm',
'cursor.cmd'
),
],
},
};
}
/**
* Extract prompt text from ExecuteOptions
* Used to pass prompt via stdin instead of CLI args to avoid shell escaping issues
*/
private extractPromptText(options: ExecuteOptions): string {
if (typeof options.prompt === 'string') {
return options.prompt;
} else if (Array.isArray(options.prompt)) {
return options.prompt
.filter((p) => p.type === 'text' && p.text)
.map((p) => p.text)
.join('\n');
} else {
throw new Error('Invalid prompt format');
}
}
buildCliArgs(options: ExecuteOptions): string[] {
// 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.
const cliArgs: string[] = [];
// If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand
if (this.cliPath && !this.cliPath.includes('cursor-agent')) {
cliArgs.push('agent');
}
cliArgs.push(
'-p', // Print mode (non-interactive)
'--output-format',
'stream-json',
'--stream-partial-output' // Real-time streaming
);
// In read-only mode, use --mode ask for Q&A style (no tools)
// Otherwise, add --force to allow file edits
if (options.readOnly) {
cliArgs.push('--mode', 'ask');
} else {
cliArgs.push('--force');
}
// Add model if not auto
if (model !== 'auto') {
cliArgs.push('--model', model);
}
// Resume an existing chat when a provider session ID is available
if (options.sdkSessionId) {
cliArgs.push('--resume', options.sdkSessionId);
}
// Use '-' to indicate reading prompt from stdin
cliArgs.push('-');
return cliArgs;
}
/**
* Convert Cursor event to AutoMaker ProviderMessage format
* Made public as required by CliProvider abstract method
*/
normalizeEvent(event: unknown): ProviderMessage | null {
const cursorEvent = event as CursorStreamEvent;
switch (cursorEvent.type) {
case 'system':
// System init - we capture session_id but don't yield a message
return null;
case 'user':
// User message - already handled by caller
return null;
case 'assistant': {
const assistantEvent = cursorEvent as CursorAssistantEvent;
return {
type: 'assistant',
session_id: assistantEvent.session_id,
message: {
role: 'assistant',
content: assistantEvent.message.content.map((c) => ({
type: 'text' as const,
text: c.text,
})),
},
};
}
case 'tool_call': {
const toolEvent = cursorEvent as CursorToolCallEvent;
const toolCall = toolEvent.tool_call;
// Use the tool handler registry to process the tool call
const processed = processCursorToolCall(toolCall);
if (!processed) {
// Log unrecognized tool call structure for debugging
const toolCallKeys = Object.keys(toolCall);
logger.warn(
`[UNHANDLED TOOL_CALL] Unknown tool call structure. Keys: ${toolCallKeys.join(', ')}. ` +
`Full tool_call: ${JSON.stringify(toolCall).substring(0, 500)}`
);
return null;
}
const { toolName, toolInput } = processed;
// For started events, emit tool_use
if (toolEvent.subtype === 'started') {
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: toolName,
tool_use_id: toolEvent.call_id,
input: toolInput,
},
],
},
};
}
// For completed events, emit both tool_use and tool_result
if (toolEvent.subtype === 'completed') {
const resultContent = formatCursorToolResult(toolCall);
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: toolName,
tool_use_id: toolEvent.call_id,
input: toolInput,
},
{
type: 'tool_result',
tool_use_id: toolEvent.call_id,
content: resultContent,
},
],
},
};
}
return null;
}
case 'result': {
const resultEvent = cursorEvent as CursorResultEvent;
if (resultEvent.is_error) {
const errorText = resultEvent.error || resultEvent.result || '';
const enrichedError =
errorText ||
`Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`;
return {
type: 'error',
session_id: resultEvent.session_id,
error: enrichedError,
};
}
return {
type: 'result',
subtype: 'success',
session_id: resultEvent.session_id,
result: resultEvent.result,
};
}
default:
return null;
}
}
// ==========================================================================
// CliProvider Overrides
// ==========================================================================
/**
* Override CLI detection to add Cursor-specific checks:
* 1. Versions directory for cursor-agent installations
* 2. Cursor IDE with 'cursor agent' subcommand support
*/
protected detectCli(): CliDetectionResult {
if (process.platform === 'win32') {
const findInPath = (command: string): string | null => {
try {
const result = execSync(`where ${command}`, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
})
.trim()
.split(/\r?\n/)[0];
if (result && fs.existsSync(result)) {
return result;
}
} catch {
// Not in PATH
}
return null;
};
const isCursorAgentBinary = (cliPath: string) =>
cliPath.toLowerCase().includes('cursor-agent');
const supportsCursorAgentSubcommand = (cliPath: string) => {
try {
execSync(`"${cliPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
windowsHide: true,
});
return true;
} catch {
return false;
}
};
const pathResult = findInPath('cursor-agent') || findInPath('cursor');
if (pathResult) {
if (isCursorAgentBinary(pathResult) || supportsCursorAgentSubcommand(pathResult)) {
return {
cliPath: pathResult,
useWsl: false,
strategy: pathResult.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
};
}
}
const config = this.getSpawnConfig();
for (const candidate of config.commonPaths.win32 || []) {
const resolved = candidate;
if (!fs.existsSync(resolved)) {
continue;
}
if (isCursorAgentBinary(resolved) || supportsCursorAgentSubcommand(resolved)) {
return {
cliPath: resolved,
useWsl: false,
strategy: resolved.toLowerCase().endsWith('.cmd') ? 'cmd' : 'direct',
};
}
}
const wslLogger = (msg: string) => logger.debug(msg);
if (isWslAvailable({ logger: wslLogger })) {
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
if (wslResult) {
logger.debug(
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
);
return {
cliPath: 'wsl.exe',
useWsl: true,
wslCliPath: wslResult.wslPath,
wslDistribution: wslResult.distribution,
strategy: 'wsl',
};
}
}
logger.debug('cursor-agent not found on Windows');
return { cliPath: null, useWsl: false, strategy: 'direct' };
}
// First try standard detection (PATH, common paths, WSL)
const result = super.detectCli();
if (result.cliPath) {
return result;
}
// Cursor-specific: Check versions directory for any installed version
// This handles cases where cursor-agent is installed but not in PATH
if (fs.existsSync(CursorProvider.VERSIONS_DIR)) {
try {
const versions = fs
.readdirSync(CursorProvider.VERSIONS_DIR)
.filter((v) => !v.startsWith('.'))
.sort()
.reverse(); // Most recent first
for (const version of versions) {
const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, 'cursor-agent');
if (fs.existsSync(versionPath)) {
logger.debug(`Found cursor-agent version ${version} at: ${versionPath}`);
return {
cliPath: versionPath,
useWsl: false,
strategy: 'native',
};
}
}
} catch {
// Ignore directory read errors
}
}
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
// The Cursor IDE includes the agent as a subcommand: cursor agent
const cursorPaths = [
'/usr/bin/cursor',
'/usr/local/bin/cursor',
path.join(os.homedir(), '.local/bin/cursor'),
'/opt/cursor/cursor',
];
for (const cursorPath of cursorPaths) {
if (fs.existsSync(cursorPath)) {
// Verify cursor agent subcommand works
try {
execSync(`"${cursorPath}" agent --version`, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
});
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
// Return cursor path but we'll use 'cursor agent' subcommand
return {
cliPath: cursorPath,
useWsl: false,
strategy: 'native',
};
} catch {
// cursor agent subcommand doesn't work, try next path
}
}
}
return result;
}
/**
* Override error mapping for Cursor-specific error codes
*/
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
const lower = stderr.toLowerCase();
if (
lower.includes('not authenticated') ||
lower.includes('please log in') ||
lower.includes('unauthorized')
) {
return {
code: CursorErrorCode.NOT_AUTHENTICATED,
message: 'Cursor CLI is not authenticated',
recoverable: true,
suggestion: 'Run "cursor-agent login" to authenticate with your browser',
};
}
if (
lower.includes('rate limit') ||
lower.includes('too many requests') ||
lower.includes('429')
) {
return {
code: CursorErrorCode.RATE_LIMITED,
message: 'Cursor API rate limit exceeded',
recoverable: true,
suggestion: 'Wait a few minutes and try again, or upgrade to Cursor Pro',
};
}
if (
lower.includes('model not available') ||
lower.includes('invalid model') ||
lower.includes('unknown model')
) {
return {
code: CursorErrorCode.MODEL_UNAVAILABLE,
message: 'Requested model is not available',
recoverable: true,
suggestion: 'Try using "auto" mode or select a different model',
};
}
if (
lower.includes('network') ||
lower.includes('connection') ||
lower.includes('econnrefused') ||
lower.includes('timeout')
) {
return {
code: CursorErrorCode.NETWORK_ERROR,
message: 'Network connection error',
recoverable: true,
suggestion: 'Check your internet connection and try again',
};
}
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
return {
code: CursorErrorCode.PROCESS_CRASHED,
message: 'Cursor agent process was terminated',
recoverable: true,
suggestion: 'The process may have run out of memory. Try a simpler task.',
};
}
return {
code: CursorErrorCode.UNKNOWN,
message: stderr || `Cursor agent exited with code ${exitCode}`,
recoverable: false,
};
}
/**
* Override install instructions for Cursor-specific guidance
*/
protected getInstallInstructions(): string {
if (process.platform === 'win32') {
return 'cursor-agent requires WSL on Windows. Install WSL, then run in WSL: curl https://cursor.com/install -fsS | bash';
}
return 'Install with: curl https://cursor.com/install -fsS | bash';
}
/**
* Execute a prompt using Cursor CLI with streaming
*
* Overrides base class to add:
* - Session ID tracking from system init events
* - Text block deduplication (Cursor sends duplicate chunks)
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
this.ensureCliDetected();
// Validate that model doesn't have a provider prefix (except cursor- which should already be stripped)
// AgentService should strip prefixes before passing to providers
// Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-"
validateBareModelId(options.model, 'CursorProvider', 'cursor');
if (!this.cliPath) {
throw this.createError(
CursorErrorCode.NOT_INSTALLED,
'Cursor CLI is not installed',
true,
this.getInstallInstructions()
);
}
// MCP servers are not yet supported by Cursor CLI - log warning but continue
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
const serverCount = Object.keys(options.mcpServers).length;
logger.warn(
`MCP servers configured (${serverCount}) but not yet supported by Cursor CLI in AutoMaker. ` +
`MCP support for Cursor will be added in a future release. ` +
`The configured MCP servers will be ignored for this execution.`
);
}
// 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
let lastTextBlock = '';
let accumulatedText = '';
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
// Get effective permissions for this project and detect the active profile
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
const activeProfile = detectProfile(effectivePermissions);
logger.debug(
`Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}`
);
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
const debugRawEvents =
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === '1';
try {
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
const event = rawEvent as CursorStreamEvent;
// Log raw event for debugging
if (debugRawEvents) {
const subtype = 'subtype' in event ? (event.subtype as string) : 'none';
logger.info(`[RAW EVENT] type=${event.type} subtype=${subtype}`);
if (event.type === 'tool_call') {
const toolEvent = event as CursorToolCallEvent;
const tc = toolEvent.tool_call;
const toolTypes =
[
tc.readToolCall && 'read',
tc.writeToolCall && 'write',
tc.editToolCall && 'edit',
tc.shellToolCall && 'shell',
tc.deleteToolCall && 'delete',
tc.grepToolCall && 'grep',
tc.lsToolCall && 'ls',
tc.globToolCall && 'glob',
tc.function && `function:${tc.function.name}`,
]
.filter(Boolean)
.join(',') || 'unknown';
logger.info(
`[RAW TOOL_CALL] call_id=${toolEvent.call_id} types=[${toolTypes}]` +
(tc.shellToolCall ? ` cmd="${tc.shellToolCall.args?.command}"` : '') +
(tc.writeToolCall ? ` path="${tc.writeToolCall.args?.path}"` : '')
);
}
}
// Capture session ID from system init
if (event.type === 'system' && (event as CursorSystemEvent).subtype === 'init') {
sessionId = event.session_id;
logger.debug(`Session started: ${sessionId}`);
}
// Normalize and yield the event
const normalized = this.normalizeEvent(event);
if (!normalized && debugRawEvents) {
logger.info(`[DROPPED EVENT] type=${event.type} - normalizeEvent returned null`);
}
if (normalized) {
// Ensure session_id is always set
if (!normalized.session_id && sessionId) {
normalized.session_id = sessionId;
}
// Apply Cursor-specific dedup for assistant text messages
if (normalized.type === 'assistant' && normalized.message?.content) {
const dedupedContent = this.deduplicateTextBlocks(
normalized.message.content,
lastTextBlock,
accumulatedText
);
if (dedupedContent.content.length === 0) {
// All blocks were duplicates, skip this message
continue;
}
// Update state
lastTextBlock = dedupedContent.lastBlock;
accumulatedText = dedupedContent.accumulated;
// Update the message with deduped content
normalized.message.content = dedupedContent.content;
}
yield normalized;
}
}
} catch (error) {
if (isAbortError(error)) {
logger.debug('Query aborted');
return;
}
// Map CLI errors to CursorError
if (error instanceof Error && 'stderr' in error) {
const errorInfo = this.mapError(
(error as { stderr?: string }).stderr || error.message,
(error as { exitCode?: number | null }).exitCode ?? null
);
throw this.createError(
errorInfo.code as CursorErrorCode,
errorInfo.message,
errorInfo.recoverable,
errorInfo.suggestion
);
}
throw error;
}
}
// ==========================================================================
// Cursor-Specific Methods
// ==========================================================================
/**
* Create a CursorError with details
*/
private createError(
code: CursorErrorCode,
message: string,
recoverable: boolean = false,
suggestion?: string
): CursorError {
const error = new Error(message) as CursorError;
error.code = code;
error.recoverable = recoverable;
error.suggestion = suggestion;
error.name = 'CursorError';
return error;
}
/**
* Deduplicate text blocks in Cursor assistant messages
*
* Cursor often sends:
* 1. Duplicate consecutive text blocks (same text twice in a row)
* 2. A final accumulated block containing ALL previous text
*
* This method filters out these duplicates to prevent UI stuttering.
*/
private deduplicateTextBlocks(
content: ContentBlock[],
lastTextBlock: string,
accumulatedText: string
): { content: ContentBlock[]; lastBlock: string; accumulated: string } {
const filtered: ContentBlock[] = [];
let newLastBlock = lastTextBlock;
let newAccumulated = accumulatedText;
for (const block of content) {
if (block.type !== 'text' || !block.text) {
filtered.push(block);
continue;
}
const text = block.text;
// Skip empty text
if (!text.trim()) continue;
// Skip duplicate consecutive text blocks
if (text === newLastBlock) {
continue;
}
// Skip final accumulated text block
// Cursor sends one large block containing ALL previous text at the end
if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) {
const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim();
const normalizedNew = text.replace(/\s+/g, ' ').trim();
if (normalizedNew.includes(normalizedAccum.slice(0, 100))) {
// This is the final accumulated block, skip it
continue;
}
}
// This is a valid new text block
newLastBlock = text;
newAccumulated += text;
filtered.push(block);
}
return {
content: filtered,
lastBlock: newLastBlock,
accumulated: newAccumulated,
};
}
/**
* Get Cursor CLI version
*/
async getVersion(): Promise<string | null> {
this.ensureCliDetected();
if (!this.cliPath) return null;
try {
if (this.useWsl && this.wslCliPath) {
const result = execInWsl(`${this.wslCliPath} --version`, {
timeout: 5000,
distribution: this.wslDistribution,
});
return result;
}
// If using Cursor IDE, use 'cursor agent --version'
const versionCmd = this.cliPath.includes('cursor-agent')
? `"${this.cliPath}" --version`
: `"${this.cliPath}" agent --version`;
const result = execSync(versionCmd, {
encoding: 'utf8',
timeout: 5000,
stdio: 'pipe',
}).trim();
return result;
} catch {
return null;
}
}
/**
* Check authentication status
*/
async checkAuth(): Promise<CursorAuthStatus> {
this.ensureCliDetected();
if (!this.cliPath) {
return { authenticated: false, method: 'none' };
}
// Check for API key in environment with validation
if (process.env.CURSOR_API_KEY) {
const validation = validateApiKey(process.env.CURSOR_API_KEY, 'cursor');
if (!validation.isValid) {
logger.warn('Cursor API key validation failed:', validation.error);
return { authenticated: false, method: 'api_key', error: validation.error };
}
return { authenticated: true, method: 'api_key' };
}
// For WSL mode, check credentials inside WSL
if (this.useWsl && this.wslCliPath) {
const wslOpts = { timeout: 5000, distribution: this.wslDistribution };
// Check for credentials file inside WSL
const wslCredPaths = [
'$HOME/.cursor/credentials.json',
'$HOME/.config/cursor/credentials.json',
];
for (const credPath of wslCredPaths) {
const content = execInWsl(`sh -c "cat ${credPath} 2>/dev/null || echo ''"`, wslOpts);
if (content && content.trim()) {
try {
const creds = JSON.parse(content);
if (creds.accessToken || creds.token) {
return { authenticated: true, method: 'login', hasCredentialsFile: true };
}
} catch {
// Invalid credentials file
}
}
}
// Try running --version to check if CLI works
const versionResult = execInWsl(`${this.wslCliPath} --version`, {
timeout: 10000,
distribution: this.wslDistribution,
});
if (versionResult) {
return { authenticated: true, method: 'login' };
}
return { authenticated: false, method: 'none' };
}
// Native mode (Linux/macOS) - check local credentials
const credentialPaths = [
path.join(os.homedir(), '.cursor', 'credentials.json'),
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
];
for (const credPath of credentialPaths) {
if (fs.existsSync(credPath)) {
try {
const content = fs.readFileSync(credPath, 'utf8');
const creds = JSON.parse(content);
if (creds.accessToken || creds.token) {
return { authenticated: true, method: 'login', hasCredentialsFile: true };
}
} catch {
// Invalid credentials file
}
}
}
// Try running a simple command to check auth
try {
execSync(`"${this.cliPath}" --version`, {
encoding: 'utf8',
timeout: 10000,
env: { ...process.env },
});
return { authenticated: true, method: 'login' };
} catch (error: unknown) {
const execError = error as { stderr?: string };
if (execError.stderr?.includes('not authenticated') || execError.stderr?.includes('log in')) {
return { authenticated: false, method: 'none' };
}
}
return { authenticated: false, method: 'none' };
}
/**
* Detect installation status (required by BaseProvider)
*/
async detectInstallation(): Promise<InstallationStatus> {
const installed = await this.isInstalled();
const version = installed ? await this.getVersion() : undefined;
const auth = await this.checkAuth();
// Determine the display path - for WSL, show the WSL path with distribution
const displayPath =
this.useWsl && this.wslCliPath
? `(WSL${this.wslDistribution ? `:${this.wslDistribution}` : ''}) ${this.wslCliPath}`
: this.cliPath || undefined;
return {
installed,
version: version || undefined,
path: displayPath,
method: this.useWsl ? 'wsl' : 'cli',
hasApiKey: !!process.env.CURSOR_API_KEY,
authenticated: auth.authenticated,
};
}
/**
* Get the detected CLI path (public accessor for status endpoints)
*/
getCliPath(): string | null {
this.ensureCliDetected();
return this.cliPath;
}
/**
* Get available Cursor models
*/
getAvailableModels(): ModelDefinition[] {
return Object.entries(CURSOR_MODEL_MAP).map(([id, config]) => ({
id: `cursor-${id}`,
name: config.label,
modelString: id,
provider: 'cursor',
description: config.description,
supportsTools: true,
supportsVision: config.supportsVision,
}));
}
/**
* Check if a feature is supported
*/
supportsFeature(feature: string): boolean {
const supported = ['tools', 'text', 'streaming'];
return supported.includes(feature);
}
}