diff --git a/server/cursor-cli.js b/server/cursor-cli.js index ffd20c3..1f1da9c 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -1,257 +1,324 @@ import { spawn } from 'child_process'; import crossSpawn from 'cross-spawn'; -import { promises as fs } from 'fs'; -import path from 'path'; -import os from 'os'; // Use cross-spawn on Windows for better command execution const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; let activeCursorProcesses = new Map(); // Track active processes by session ID +const WORKSPACE_TRUST_PATTERNS = [ + /workspace trust required/i, + /do you trust the contents of this directory/i, + /working with untrusted contents/i, + /pass --trust,\s*--yolo,\s*or -f/i +]; + +function isWorkspaceTrustPrompt(text = '') { + if (!text || typeof text !== 'string') { + return false; + } + + return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text)); +} + async function spawnCursor(command, options = {}, ws) { return new Promise(async (resolve, reject) => { - const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options; + const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event let messageBuffer = ''; // Buffer for accumulating assistant messages - + let hasRetriedWithTrust = false; + let settled = false; + // Use tools settings passed from frontend, or defaults const settings = toolsSettings || { allowedShellCommands: [], skipPermissions: false }; - + // Build Cursor CLI command - const args = []; - + const baseArgs = []; + // Build flags allowing both resume and prompt together (reply in existing session) // Treat presence of sessionId as intention to resume, regardless of resume flag if (sessionId) { - args.push('--resume=' + sessionId); + baseArgs.push('--resume=' + sessionId); } if (command && command.trim()) { // Provide a prompt (works for both new and resumed sessions) - args.push('-p', command); + baseArgs.push('-p', command); // Add model flag if specified (only meaningful for new sessions; harmless on resume) if (!sessionId && model) { - args.push('--model', model); + baseArgs.push('--model', model); } // Request streaming JSON when we are providing a prompt - args.push('--output-format', 'stream-json'); + baseArgs.push('--output-format', 'stream-json'); } - + // Add skip permissions flag if enabled if (skipPermissions || settings.skipPermissions) { - args.push('-f'); - console.log('⚠️ Using -f flag (skip permissions)'); + baseArgs.push('-f'); + console.log('Using -f flag (skip permissions)'); } - + // Use cwd (actual project directory) instead of projectPath const workingDir = cwd || projectPath || process.cwd(); - - console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' ')); - console.log('Working directory:', workingDir); - console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); - - const cursorProcess = spawnFunction('cursor-agent', args, { - cwd: workingDir, - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env } // Inherit all environment variables - }); - + // Store process reference for potential abort const processKey = capturedSessionId || Date.now().toString(); - activeCursorProcesses.set(processKey, cursorProcess); - - // Handle stdout (streaming JSON responses) - cursorProcess.stdout.on('data', (data) => { - const rawOutput = data.toString(); - console.log('📤 Cursor CLI stdout:', rawOutput); - - const lines = rawOutput.split('\n').filter(line => line.trim()); - - for (const line of lines) { - try { - const response = JSON.parse(line); - console.log('📄 Parsed JSON response:', response); - - // Handle different message types - switch (response.type) { - case 'system': - if (response.subtype === 'init') { - // Capture session ID - if (response.session_id && !capturedSessionId) { - capturedSessionId = response.session_id; - console.log('📝 Captured session ID:', capturedSessionId); - - // Update process key with captured session ID - if (processKey !== capturedSessionId) { - activeCursorProcesses.delete(processKey); - activeCursorProcesses.set(capturedSessionId, cursorProcess); - } - - // Set session ID on writer (for API endpoint compatibility) - if (ws.setSessionId && typeof ws.setSessionId === 'function') { - ws.setSessionId(capturedSessionId); + + const settleOnce = (callback) => { + if (settled) { + return; + } + settled = true; + callback(); + }; + + const runCursorProcess = (args, runReason = 'initial') => { + const isTrustRetry = runReason === 'trust-retry'; + let runSawWorkspaceTrustPrompt = false; + + if (isTrustRetry) { + console.log('Retrying Cursor CLI with --trust after workspace trust prompt'); + } + + console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' ')); + console.log('Working directory:', workingDir); + console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume); + + const cursorProcess = spawnFunction('cursor-agent', args, { + cwd: workingDir, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env } // Inherit all environment variables + }); + + activeCursorProcesses.set(processKey, cursorProcess); + + const shouldSuppressForTrustRetry = (text) => { + if (hasRetriedWithTrust || args.includes('--trust')) { + return false; + } + if (!isWorkspaceTrustPrompt(text)) { + return false; + } + + runSawWorkspaceTrustPrompt = true; + return true; + }; + + // Handle stdout (streaming JSON responses) + cursorProcess.stdout.on('data', (data) => { + const rawOutput = data.toString(); + console.log('Cursor CLI stdout:', rawOutput); + + const lines = rawOutput.split('\n').filter((line) => line.trim()); + + for (const line of lines) { + try { + const response = JSON.parse(line); + console.log('Parsed JSON response:', response); + + // Handle different message types + switch (response.type) { + case 'system': + if (response.subtype === 'init') { + // Capture session ID + if (response.session_id && !capturedSessionId) { + capturedSessionId = response.session_id; + console.log('Captured session ID:', capturedSessionId); + + // Update process key with captured session ID + if (processKey !== capturedSessionId) { + activeCursorProcesses.delete(processKey); + activeCursorProcesses.set(capturedSessionId, cursorProcess); + } + + // Set session ID on writer (for API endpoint compatibility) + if (ws.setSessionId && typeof ws.setSessionId === 'function') { + ws.setSessionId(capturedSessionId); + } + + // Send session-created event only once for new sessions + if (!sessionId && !sessionCreatedSent) { + sessionCreatedSent = true; + ws.send({ + type: 'session-created', + sessionId: capturedSessionId, + model: response.model, + cwd: response.cwd + }); + } } - // Send session-created event only once for new sessions - if (!sessionId && !sessionCreatedSent) { - sessionCreatedSent = true; - ws.send({ - type: 'session-created', - sessionId: capturedSessionId, - model: response.model, - cwd: response.cwd - }); - } + // Send system info to frontend + ws.send({ + type: 'cursor-system', + data: response, + sessionId: capturedSessionId || sessionId || null + }); } - - // Send system info to frontend + break; + + case 'user': + // Forward user message ws.send({ - type: 'cursor-system', + type: 'cursor-user', data: response, sessionId: capturedSessionId || sessionId || null }); - } - break; - - case 'user': - // Forward user message - ws.send({ - type: 'cursor-user', - data: response, - sessionId: capturedSessionId || sessionId || null - }); - break; - - case 'assistant': - // Accumulate assistant message chunks - if (response.message && response.message.content && response.message.content.length > 0) { - const textContent = response.message.content[0].text; - messageBuffer += textContent; - - // Send as Claude-compatible format for frontend + break; + + case 'assistant': + // Accumulate assistant message chunks + if (response.message && response.message.content && response.message.content.length > 0) { + const textContent = response.message.content[0].text; + messageBuffer += textContent; + + // Send as Claude-compatible format for frontend + ws.send({ + type: 'claude-response', + data: { + type: 'content_block_delta', + delta: { + type: 'text_delta', + text: textContent + } + }, + sessionId: capturedSessionId || sessionId || null + }); + } + break; + + case 'result': + // Session complete + console.log('Cursor session result:', response); + + // Send final message if we have buffered content + if (messageBuffer) { + ws.send({ + type: 'claude-response', + data: { + type: 'content_block_stop' + }, + sessionId: capturedSessionId || sessionId || null + }); + } + + // Send completion event ws.send({ - type: 'claude-response', - data: { - type: 'content_block_delta', - delta: { - type: 'text_delta', - text: textContent - } - }, + type: 'cursor-result', + sessionId: capturedSessionId || sessionId, + data: response, + success: response.subtype === 'success' + }); + break; + + default: + // Forward any other message types + ws.send({ + type: 'cursor-response', + data: response, sessionId: capturedSessionId || sessionId || null }); - } - break; - - case 'result': - // Session complete - console.log('Cursor session result:', response); - - // Send final message if we have buffered content - if (messageBuffer) { - ws.send({ - type: 'claude-response', - data: { - type: 'content_block_stop' - }, - sessionId: capturedSessionId || sessionId || null - }); - } - - // Send completion event - ws.send({ - type: 'cursor-result', - sessionId: capturedSessionId || sessionId, - data: response, - success: response.subtype === 'success' - }); - break; - - default: - // Forward any other message types - ws.send({ - type: 'cursor-response', - data: response, - sessionId: capturedSessionId || sessionId || null - }); + } + } catch (parseError) { + console.log('Non-JSON response:', line); + + if (shouldSuppressForTrustRetry(line)) { + continue; + } + + // If not JSON, send as raw text + ws.send({ + type: 'cursor-output', + data: line, + sessionId: capturedSessionId || sessionId || null + }); } - } catch (parseError) { - console.log('📄 Non-JSON response:', line); - // If not JSON, send as raw text - ws.send({ - type: 'cursor-output', - data: line, - sessionId: capturedSessionId || sessionId || null - }); } - } - }); - - // Handle stderr - cursorProcess.stderr.on('data', (data) => { - console.error('Cursor CLI stderr:', data.toString()); - ws.send({ - type: 'cursor-error', - error: data.toString(), - sessionId: capturedSessionId || sessionId || null - }); - }); - - // Handle process completion - cursorProcess.on('close', async (code) => { - console.log(`Cursor CLI process exited with code ${code}`); - - // Clean up process reference - const finalSessionId = capturedSessionId || sessionId || processKey; - activeCursorProcesses.delete(finalSessionId); - - ws.send({ - type: 'claude-complete', - sessionId: finalSessionId, - exitCode: code, - isNewSession: !sessionId && !!command // Flag to indicate this was a new session - }); - - if (code === 0) { - resolve(); - } else { - reject(new Error(`Cursor CLI exited with code ${code}`)); - } - }); - - // Handle process errors - cursorProcess.on('error', (error) => { - console.error('Cursor CLI process error:', error); - - // Clean up process reference on error - const finalSessionId = capturedSessionId || sessionId || processKey; - activeCursorProcesses.delete(finalSessionId); - - ws.send({ - type: 'cursor-error', - error: error.message, - sessionId: capturedSessionId || sessionId || null }); - reject(error); - }); - - // Close stdin since Cursor doesn't need interactive input - cursorProcess.stdin.end(); + // Handle stderr + cursorProcess.stderr.on('data', (data) => { + const stderrText = data.toString(); + console.error('Cursor CLI stderr:', stderrText); + + if (shouldSuppressForTrustRetry(stderrText)) { + return; + } + + ws.send({ + type: 'cursor-error', + error: stderrText, + sessionId: capturedSessionId || sessionId || null + }); + }); + + // Handle process completion + cursorProcess.on('close', async (code) => { + console.log(`Cursor CLI process exited with code ${code}`); + + const finalSessionId = capturedSessionId || sessionId || processKey; + activeCursorProcesses.delete(finalSessionId); + + if ( + runSawWorkspaceTrustPrompt && + code !== 0 && + !hasRetriedWithTrust && + !args.includes('--trust') + ) { + hasRetriedWithTrust = true; + runCursorProcess([...args, '--trust'], 'trust-retry'); + return; + } + + ws.send({ + type: 'claude-complete', + sessionId: finalSessionId, + exitCode: code, + isNewSession: !sessionId && !!command // Flag to indicate this was a new session + }); + + if (code === 0) { + settleOnce(() => resolve()); + } else { + settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`))); + } + }); + + // Handle process errors + cursorProcess.on('error', (error) => { + console.error('Cursor CLI process error:', error); + + // Clean up process reference on error + const finalSessionId = capturedSessionId || sessionId || processKey; + activeCursorProcesses.delete(finalSessionId); + + ws.send({ + type: 'cursor-error', + error: error.message, + sessionId: capturedSessionId || sessionId || null + }); + + settleOnce(() => reject(error)); + }); + + // Close stdin since Cursor doesn't need interactive input + cursorProcess.stdin.end(); + }; + + runCursorProcess(baseArgs, 'initial'); }); } function abortCursorSession(sessionId) { const process = activeCursorProcesses.get(sessionId); if (process) { - console.log(`🛑 Aborting Cursor session: ${sessionId}`); + console.log(`Aborting Cursor session: ${sessionId}`); process.kill('SIGTERM'); activeCursorProcesses.delete(sessionId); return true;