From d7b907ed9fb16668694e09cc43ed8eada67a4a2a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 20:28:50 -0400 Subject: [PATCH 1/4] fix(cursor): make bare cursor command useful and harden tool-result translation --- README.md | 2 +- docs/cursor-integration.md | 7 + src/commands/cursor-command-display.ts | 28 +- src/commands/cursor-command.ts | 1 + src/cursor/cursor-translator.ts | 401 +++++++++++++++++----- tests/unit/cursor/cursor-daemon.test.ts | 132 +++++++ tests/unit/cursor/cursor-protobuf.test.ts | 305 +++++++++++++++- 7 files changed, 781 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index c10672d9..fa1346e7 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The dashboard provides visual management for all account types: ccs # Default Claude session ccs gemini # Gemini (OAuth) ccs codex # OpenAI Codex (OAuth) -ccs cursor # Cursor IDE integration (token import + local daemon) +ccs cursor # Cursor status + local daemon connection details ccs kiro # Kiro/AWS CodeWhisperer (OAuth) ccs ghcp # GitHub Copilot (OAuth device flow) ccs agy # Antigravity (OAuth) diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 8278544e..5e190e8c 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -49,6 +49,13 @@ ccs cursor start ccs cursor status ``` +Or use the bare command to see the same status view plus the local endpoint details +needed for OpenAI-compatible and Anthropic-compatible clients: + +```bash +ccs cursor +``` + ### 5) Stop daemon ```bash diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts index 34260d88..5e1daf57 100644 --- a/src/commands/cursor-command-display.ts +++ b/src/commands/cursor-command-display.ts @@ -12,7 +12,7 @@ export function renderCursorHelp(): number { printLines([ 'Cursor IDE Integration', '', - 'Usage: ccs cursor [options]', + 'Usage: ccs cursor [subcommand] [options]', '', 'Subcommands:', ' auth Import Cursor IDE authentication token', @@ -32,6 +32,7 @@ export function renderCursorHelp(): number { ' 1. ccs cursor enable # Enable integration', ' 2. ccs cursor auth # Import Cursor IDE token', ' 3. ccs cursor start # Start daemon', + ' 4. ccs cursor # Show status and runtime connection details', '', 'Or use the web UI: ccs config -> Cursor page', '', @@ -45,6 +46,10 @@ export function renderCursorStatus( authStatus: CursorAuthStatus, daemonStatus: CursorDaemonStatus ): void { + const localBaseUrl = `http://127.0.0.1:${cursorConfig.port}`; + const isReady = + cursorConfig.enabled && authStatus.authenticated && !authStatus.expired && daemonStatus.running; + console.log('Cursor IDE Status'); console.log('─────────────────'); console.log(''); @@ -77,15 +82,23 @@ export function renderCursorStatus( console.log(` Ghost mode: ${cursorConfig.ghost_mode ? 'On' : 'Off'}`); console.log(''); - if ( - cursorConfig.enabled && - authStatus.authenticated && - !authStatus.expired && - daemonStatus.running - ) { + console.log('Runtime:'); + console.log(` OpenAI base: ${localBaseUrl}/v1`); + console.log(` Anthropic base: ${localBaseUrl}`); + console.log(` Chat route: ${localBaseUrl}/v1/chat/completions`); + console.log(` Messages route: ${localBaseUrl}/v1/messages`); + console.log(` Models route: ${localBaseUrl}/v1/models`); + console.log(''); + console.log('Client setup:'); + console.log(' Raw settings: ~/.ccs/cursor.settings.json'); + console.log(' Subcommands: ccs cursor help'); + + if (isReady) { return; } + console.log(''); + console.log('Next steps:'); if (!cursorConfig.enabled) { console.log(' - Enable: ccs cursor enable'); @@ -96,6 +109,7 @@ export function renderCursorStatus( if (!daemonStatus.running) { console.log(' - Start: ccs cursor start'); } + console.log(' - Help: ccs cursor help'); } export function renderCursorModels(models: CursorModel[], defaultModel: string): void { diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index ae8d5363..05a9245b 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -56,6 +56,7 @@ export async function handleCursorCommand(args: string[]): Promise { case 'disable': return handleDisable(); case undefined: + return handleStatus(); case 'help': case '--help': case '-h': diff --git a/src/cursor/cursor-translator.ts b/src/cursor/cursor-translator.ts index f709ab43..fa6617c7 100644 --- a/src/cursor/cursor-translator.ts +++ b/src/cursor/cursor-translator.ts @@ -1,14 +1,42 @@ /** * OpenAI to Cursor Request Translator - * Converts OpenAI messages to Cursor format + * Converts OpenAI messages to Cursor format. */ -import type { CursorMessage, CursorToolResult, CursorTool } from './cursor-protobuf-schema.js'; +import type { CursorMessage, CursorTool } from './cursor-protobuf-schema.js'; + +interface OpenAITextPart { + type: 'text'; + text?: string; +} + +interface OpenAIToolUsePart { + type: 'tool_use'; + id?: string; + name?: string; + input?: Record; +} + +interface OpenAIToolResultPart { + type: 'tool_result'; + tool_use_id?: string; + content?: unknown; +} + +interface OpenAIUnknownPart { + type: string; + [key: string]: unknown; +} + +type OpenAIContentPart = + | OpenAITextPart + | OpenAIToolUsePart + | OpenAIToolResultPart + | OpenAIUnknownPart; -/** OpenAI message format */ interface OpenAIMessage { role: string; - content: string | Array<{ type: string; text?: string }>; + content: string | OpenAIContentPart[]; name?: string; tool_call_id?: string; tool_calls?: Array<{ @@ -18,113 +46,326 @@ interface OpenAIMessage { }>; } -/** OpenAI request body */ interface OpenAIRequestBody { messages: OpenAIMessage[]; tools?: CursorTool[]; reasoning_effort?: string; } +const MAX_TOOL_RESULT_CHARS = 12_000; +const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]'; +const TOOL_USE_ARGUMENTS_FALLBACK = '{}'; + +function isTextPart(part: OpenAIContentPart): part is OpenAITextPart { + return part.type === 'text'; +} + +function isToolUsePart(part: OpenAIContentPart): part is OpenAIToolUsePart { + return part.type === 'tool_use'; +} + +function isToolResultPart(part: OpenAIContentPart): part is OpenAIToolResultPart { + return part.type === 'tool_result'; +} + +function extractTextContent(content: OpenAIMessage['content']): string { + if (typeof content === 'string') { + return content; + } + + let text = ''; + for (const part of content) { + if (isTextPart(part) && part.text) { + text += part.text; + } + } + + return text; +} + +function stringifyUnknown(value: unknown, fallback = ''): string { + try { + const serialized = JSON.stringify(value); + return typeof serialized === 'string' ? serialized : fallback; + } catch { + return fallback; + } +} + +function sanitizeToolResultText(text: string): string { + return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ''); +} + +function truncateToolResultText(text: string): string { + if (text.length <= MAX_TOOL_RESULT_CHARS) { + return text; + } + + let suffix = '\n[truncated]'; + let keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0); + let omittedChars = text.length - keepLength; + + suffix = `\n[truncated ${omittedChars} chars]`; + keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0); + omittedChars = text.length - keepLength; + suffix = `\n[truncated ${omittedChars} chars]`; + + return `${text.slice(0, keepLength)}${suffix}`; +} + +function escapeXml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string { + const cleanResult = truncateToolResultText(escapeXml(sanitizeToolResultText(resultText))); + + return [ + '', + `${escapeXml(toolName || 'tool')}`, + `${escapeXml(toolCallId)}`, + `${cleanResult}`, + '', + ].join('\n'); +} + +function normalizeToolCallId(toolCallId: string | undefined): string { + return typeof toolCallId === 'string' ? toolCallId.split('\n')[0] : ''; +} + +function extractToolResultText(content: unknown): string { + if (content === undefined) { + return ''; + } + + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + return content + .filter(isTextPart) + .map((part) => part.text || '') + .join(''); + } + + return stringifyUnknown(content, TOOL_RESULT_SERIALIZATION_FALLBACK); +} + +function createFallbackToolUseId(messageIndex: number, partIndex: number): string { + return `toolu_cursor_fallback_${messageIndex}_${partIndex}`; +} + +function resolveToolUseId( + part: OpenAIToolUsePart, + messageIndex: number, + partIndex: number +): string { + return typeof part.id === 'string' && part.id.length > 0 + ? part.id + : createFallbackToolUseId(messageIndex, partIndex); +} + +function rememberToolCallMeta( + toolCallMetaMap: Map, + toolCalls: NonNullable +): void { + for (const toolCall of toolCalls) { + const toolCallId = toolCall.id || ''; + const toolName = toolCall.function?.name || 'tool'; + if (!toolCallId) { + continue; + } + + toolCallMetaMap.set(toolCallId, { name: toolName }); + + const normalizedId = normalizeToolCallId(toolCallId); + if (normalizedId && normalizedId !== toolCallId) { + toolCallMetaMap.set(normalizedId, { name: toolName }); + } + } +} + +function rememberToolUseParts( + toolCallMetaMap: Map, + content: OpenAIMessage['content'], + messageIndex: number +): void { + if (!Array.isArray(content)) { + return; + } + + for (let partIndex = 0; partIndex < content.length; partIndex++) { + const part = content[partIndex]; + if (!isToolUsePart(part)) { + continue; + } + + const toolCallId = resolveToolUseId(part, messageIndex, partIndex); + const toolName = part.name || 'tool'; + toolCallMetaMap.set(toolCallId, { name: toolName }); + + const normalizedId = normalizeToolCallId(toolCallId); + if (normalizedId && normalizedId !== toolCallId) { + toolCallMetaMap.set(normalizedId, { name: toolName }); + } + } +} + +function extractToolCallsFromContent( + content: OpenAIMessage['content'], + messageIndex: number +): NonNullable { + if (!Array.isArray(content)) { + return []; + } + + return content.flatMap((part, partIndex) => { + if (!isToolUsePart(part)) { + return []; + } + + return [ + { + id: resolveToolUseId(part, messageIndex, partIndex), + type: 'function', + function: { + name: part.name || 'tool', + arguments: stringifyUnknown(part.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK), + }, + }, + ]; + }); +} + +function mergeAssistantToolCalls( + primaryToolCalls: NonNullable, + secondaryToolCalls: NonNullable +): NonNullable { + const merged: NonNullable = []; + const seenIds = new Set(); + + for (const toolCall of [...primaryToolCalls, ...secondaryToolCalls]) { + if (seenIds.has(toolCall.id)) { + continue; + } + + seenIds.add(toolCall.id); + merged.push(toolCall); + } + + return merged; +} + +function renderUserContent( + content: OpenAIMessage['content'], + toolCallMetaMap: Map +): string { + if (typeof content === 'string') { + return content; + } + + const parts: string[] = []; + let textBuffer = ''; + for (const part of content) { + if (isTextPart(part) && part.text) { + textBuffer += part.text; + continue; + } + + if (!isToolResultPart(part)) { + continue; + } + + if (textBuffer) { + parts.push(textBuffer); + textBuffer = ''; + } + + const toolCallId = part.tool_use_id || ''; + const normalizedId = normalizeToolCallId(toolCallId); + const toolName = + toolCallMetaMap.get(toolCallId)?.name || toolCallMetaMap.get(normalizedId)?.name || 'tool'; + parts.push(buildToolResultBlock(toolName, toolCallId, extractToolResultText(part.content))); + } + + if (textBuffer) { + parts.push(textBuffer); + } + + return parts.join('\n'); +} + /** - * Convert OpenAI messages to Cursor format with native tool_results support + * Convert OpenAI messages to Cursor format with a safer tool-result strategy. * - system → user with [System Instructions] prefix - * - tool → accumulate into tool_results array for next user/assistant message - * - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively) + * - tool → flatten into a structured user text block for Cursor compatibility + * - assistant with tool_calls → keep tool_calls in the translated shape for metadata recovery */ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] { const result: CursorMessage[] = []; - let pendingToolResults: CursorToolResult[] = []; - - for (let i = 0; i < messages.length; i++) { - const msg = messages[i]; + const toolCallMetaMap = new Map(); + for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { + const msg = messages[messageIndex]; if (msg.role === 'system') { - let content = ''; - if (typeof msg.content === 'string') { - content = msg.content; - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'text' && part.text) content += part.text; - } - } result.push({ role: 'user', - content: `[System Instructions]\n${content}`, + content: `[System Instructions]\n${extractTextContent(msg.content)}`, }); continue; } if (msg.role === 'tool') { - let toolContent = ''; - if (typeof msg.content === 'string') { - toolContent = msg.content; - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'text' && part.text) { - toolContent += part.text; - } - } - } - - const toolName = msg.name || 'tool'; const toolCallId = msg.tool_call_id || ''; + const normalizedToolCallId = normalizeToolCallId(toolCallId); + const rememberedToolName = + toolCallMetaMap.get(toolCallId)?.name || toolCallMetaMap.get(normalizedToolCallId)?.name; + const toolName = msg.name || rememberedToolName || 'tool'; - // Accumulate tool result - pendingToolResults.push({ - tool_call_id: toolCallId, - name: toolName, - index: pendingToolResults.length, - raw_args: toolContent, + result.push({ + role: 'user', + content: buildToolResultBlock(toolName, toolCallId, extractTextContent(msg.content)), }); continue; } if (msg.role === 'user' || msg.role === 'assistant') { - let content = ''; - - if (typeof msg.content === 'string') { - content = msg.content; - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'text' && part.text) { - content += part.text; - } + if (msg.role === 'assistant') { + const assistantToolCalls = mergeAssistantToolCalls( + msg.tool_calls || [], + extractToolCallsFromContent(msg.content, messageIndex) + ); + if (msg.tool_calls?.length) { + rememberToolCallMeta(toolCallMetaMap, msg.tool_calls); } - } + rememberToolUseParts(toolCallMetaMap, msg.content, messageIndex); - // Keep tool_calls structure for assistant messages - if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) { - const assistantMsg: CursorMessage = { role: 'assistant', content: '' }; + const content = extractTextContent(msg.content); + if (assistantToolCalls.length > 0) { + result.push({ + role: 'assistant', + content, + tool_calls: assistantToolCalls, + }); + } else if (content) { + result.push({ + role: 'assistant', + content, + }); + } + } else { + const content = renderUserContent(msg.content, toolCallMetaMap); if (content) { - assistantMsg.content = content; + result.push({ + role: 'user', + content, + }); } - assistantMsg.tool_calls = msg.tool_calls; - - // Attach pending tool results to assistant message with tool_calls - if (pendingToolResults.length > 0) { - assistantMsg.tool_results = pendingToolResults; - pendingToolResults = []; - } - - result.push(assistantMsg); - } else if (content || pendingToolResults.length > 0) { - const msgObj: CursorMessage = { - role: msg.role, - content: content || '', - }; - - // Attach pending tool results to this message - if (pendingToolResults.length > 0) { - msgObj.tool_results = pendingToolResults; - pendingToolResults = []; - } - - result.push(msgObj); } continue; } - // Unknown role - skip with debug warning if (process.env.CCS_DEBUG) { console.error(`[cursor] Unknown message role: ${msg.role}, skipping`); } @@ -133,10 +374,6 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] { return result; } -/** - * Transform OpenAI request to Cursor format - * Returns modified body with converted messages - */ export function buildCursorRequest( _model: string, body: OpenAIRequestBody, @@ -146,10 +383,8 @@ export function buildCursorRequest( messages: CursorMessage[]; tools?: CursorTool[]; } { - const messages = convertMessages(body.messages || []); - return { ...body, - messages, + messages: convertMessages(body.messages || []), }; } diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 5a2f15bb..8e17e2d0 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -19,7 +19,12 @@ import { } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; +import { + renderCursorHelp, + renderCursorStatus, +} from '../../../src/commands/cursor-command-display'; import { loadCredentials } from '../../../src/cursor/cursor-auth'; +import { DEFAULT_CURSOR_CONFIG } from '../../../src/config/unified-config-types'; // Test isolation let originalCcsHome: string | undefined; @@ -271,6 +276,34 @@ describe('stopDaemon', () => { }); describe('handleCursorCommand', () => { + it('treats bare ccs cursor as a status entrypoint instead of help', async () => { + const originalLog = console.log; + const originalError = console.error; + const logs: string[] = []; + const errors: string[] = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + console.error = (...args: unknown[]) => { + errors.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + const exitCode = await handleCursorCommand([]); + + expect(exitCode).toBe(0); + expect(errors).toHaveLength(0); + expect(logs.some((line) => line.includes('Cursor IDE Status'))).toBe(true); + expect(logs.some((line) => line.includes('Usage: ccs cursor [options]'))).toBe( + false + ); + } finally { + console.log = originalLog; + console.error = originalError; + } + }); + it('returns exit code 1 for unknown subcommand', async () => { const exitCode = await handleCursorCommand(['nonexistent']); expect(exitCode).toBe(1); @@ -297,3 +330,102 @@ describe('handleCursorCommand', () => { expect(credentials?.machineId).toBe(machineId); }); }); + +describe('renderCursorStatus', () => { + it('shows runtime endpoint guidance when Cursor is ready', () => { + const originalLog = console.log; + const logs: string[] = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + renderCursorStatus( + { ...DEFAULT_CURSOR_CONFIG, enabled: true, port: 20129 }, + { + authenticated: true, + expired: false, + tokenAge: 0, + credentials: { + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: new Date().toISOString(), + }, + }, + { running: true, port: 20129, pid: 1234 } + ); + + expect(logs.some((line) => line.includes('OpenAI base: http://127.0.0.1:20129/v1'))).toBe( + true + ); + expect( + logs.some((line) => line.includes('Chat route: http://127.0.0.1:20129/v1/chat/completions')) + ).toBe(true); + expect( + logs.some((line) => line.includes('Anthropic base: http://127.0.0.1:20129')) + ).toBe(true); + expect(logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json'))).toBe( + true + ); + } finally { + console.log = originalLog; + } + }); + + it('shows runtime guidance even when setup is incomplete', () => { + const originalLog = console.log; + const logs: string[] = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + renderCursorStatus( + { ...DEFAULT_CURSOR_CONFIG, enabled: false, port: 20129 }, + { + authenticated: false, + expired: false, + tokenAge: undefined, + credentials: undefined, + }, + { running: false, port: 20129, pid: undefined } + ); + + expect(logs.some((line) => line.includes('OpenAI base: http://127.0.0.1:20129/v1'))).toBe( + true + ); + expect(logs.some((line) => line.includes('Next steps:'))).toBe(true); + expect(logs.some((line) => line.includes(' - Help: ccs cursor help'))).toBe(true); + } finally { + console.log = originalLog; + } + }); +}); + +describe('renderCursorHelp', () => { + it('shows bare ccs cursor as an optional entrypoint', () => { + const originalLog = console.log; + const logs: string[] = []; + + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + const exitCode = renderCursorHelp(); + + expect(exitCode).toBe(0); + expect(logs.some((line) => line.includes('Usage: ccs cursor [subcommand] [options]'))).toBe( + true + ); + expect( + logs.some((line) => line.includes('4. ccs cursor # Show status and runtime connection details')) + ).toBe(true); + } finally { + console.log = originalLog; + } + }); +}); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index 70d9e973..e2af6027 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -13,6 +13,7 @@ import { import { decodeVarint, decodeField, + decodeMessage, parseConnectRPCFrame, } from '../../../src/cursor/cursor-protobuf-decoder'; import { buildCursorRequest } from '../../../src/cursor/cursor-translator'; @@ -251,11 +252,256 @@ describe('Message Translation', () => { {} ); + expect(result.messages).toHaveLength(3); + expect(result.messages[1].role).toBe('user'); + expect(result.messages[1].content).toContain(''); + expect(result.messages[1].content).toContain('get_weather'); + expect(result.messages[1].content).toContain('call_123'); + expect(result.messages[2]).toEqual({ role: 'user', content: 'What is the weather?' }); + }); + + it('should recover tool names for tool results without a name field', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_456', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ], + }, + { + role: 'tool', + content: 'done', + tool_call_id: 'call_456', + }, + ], + }, + false, + {} + ); + expect(result.messages).toHaveLength(2); - // Tool result should be attached to next message - expect(result.messages[1].tool_results).toBeDefined(); - expect(result.messages[1].tool_results).toHaveLength(1); - expect(result.messages[1].tool_results![0].tool_call_id).toBe('call_123'); + expect(result.messages[1].content).toContain('search_docs'); + expect(result.messages[1].tool_results).toBeUndefined(); + }); + + it('should flatten user content arrays that include tool_result blocks', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_789', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ], + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Tool finished.' }, + { + type: 'tool_result', + tool_use_id: 'call_789', + content: { answer: 'Cursor integration ready' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[1].role).toBe('user'); + expect(result.messages[1].content).toContain('Tool finished.'); + expect(result.messages[1].content).toContain('search_docs'); + expect(result.messages[1].content).toContain('{"answer":"Cursor integration ready"}'); + }); + + it('should convert assistant tool_use content blocks into tool_calls', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Inspecting the workspace.' }, + { + type: 'tool_use', + id: 'call_999', + name: 'list_files', + input: { path: '/tmp' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe('assistant'); + expect(result.messages[0].content).toBe('Inspecting the workspace.'); + expect(result.messages[0].tool_calls).toEqual([ + { + id: 'call_999', + type: 'function', + function: { name: 'list_files', arguments: '{"path":"/tmp"}' }, + }, + ]); + }); + + it('should synthesize fallback ids for tool_use blocks that omit them', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'search_docs', + input: { q: 'cursor' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].tool_calls).toHaveLength(1); + expect(result.messages[0].tool_calls![0].id).toBe('toolu_cursor_fallback_0_0'); + }); + + it('should dedupe tool calls when assistant messages contain both tool_calls and tool_use blocks', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_dupe', + name: 'search_docs', + input: { q: 'cursor' }, + }, + ], + tool_calls: [ + { + id: 'call_dupe', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].tool_calls).toEqual([ + { + id: 'call_dupe', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ]); + }); + + it('should truncate oversized tool result payloads', () => { + const oversizedResult = '&'.repeat(12_050); + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_big', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"big.txt"}' }, + }, + ], + }, + { + role: 'tool', + content: oversizedResult, + tool_call_id: 'call_big', + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[1].content).toContain('[truncated '); + expect(result.messages[1].content.length).toBeLessThan(12_250); + }); + + it('should mark unserializable structured tool results explicitly', () => { + const circular: Record = {}; + circular.self = circular; + + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_bad', + type: 'function', + function: { name: 'read_json', arguments: '{"path":"bad.json"}' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_bad', + content: circular, + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[1].content).toContain('[unserializable content]'); }); it('should handle array content format', () => { @@ -344,6 +590,57 @@ describe('Request Encoding', () => { expect(result).toBeInstanceOf(Uint8Array); expect(result.length).toBeGreaterThan(0); }); + + it('should preserve flattened tool_result blocks through protobuf encoding', () => { + const translated = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_wire', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"notes.txt"}' }, + }, + ], + }, + { + role: 'tool', + content: 'workspace snapshot', + tool_call_id: 'call_wire', + }, + ], + }, + false, + {} + ); + + const body = generateCursorBody(translated.messages, 'gpt-4', [], null); + const frame = parseConnectRPCFrame(Buffer.from(body)); + expect(frame).not.toBeNull(); + + const topLevel = decodeMessage(frame!.payload); + const requestPayload = topLevel.get(FIELD.Request.REQUEST)?.[0]?.value as Uint8Array; + const chatRequest = decodeMessage(requestPayload); + const encodedMessages = (chatRequest.get(FIELD.Chat.MESSAGES) || []).map((entry) => + decodeMessage(entry.value as Uint8Array) + ); + const decoder = new TextDecoder(); + const contents = encodedMessages.map((message) => + decoder.decode(message.get(FIELD.Message.CONTENT)?.[0]?.value as Uint8Array) + ); + + expect(contents.some((content) => content.includes(''))).toBe(true); + expect(contents.some((content) => content.includes('read_file'))).toBe( + true + ); + expect( + contents.some((content) => content.includes('call_wire')) + ).toBe(true); + }); }); describe('Edge cases', () => { From 27f6a675be6f97d6ae41e2729f2146c9b5c8ad9f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 20:57:19 -0400 Subject: [PATCH 2/4] fix(cursor): address review feedback and translator edge cases --- src/commands/cursor-command-display.ts | 4 +- src/commands/cursor-command.ts | 2 +- src/commands/help-command.ts | 7 +- src/cursor/cursor-translator.ts | 102 ++++++++--- src/utils/config-manager.ts | 9 + .../unit/commands/help-command-parity.test.ts | 9 + tests/unit/cursor/cursor-daemon.test.ts | 75 +++++++- tests/unit/cursor/cursor-protobuf.test.ts | 168 +++++++++++++++++- 8 files changed, 338 insertions(+), 38 deletions(-) diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts index 5e1daf57..5bc1f4d8 100644 --- a/src/commands/cursor-command-display.ts +++ b/src/commands/cursor-command-display.ts @@ -1,5 +1,6 @@ import type { CursorAuthStatus, CursorDaemonStatus, CursorModel } from '../cursor/types'; import type { CursorConfig } from '../config/unified-config-types'; +import { getCcsDirDisplay } from '../utils/config-manager'; import { color } from '../utils/ui'; function printLines(lines: string[]): void { @@ -47,6 +48,7 @@ export function renderCursorStatus( daemonStatus: CursorDaemonStatus ): void { const localBaseUrl = `http://127.0.0.1:${cursorConfig.port}`; + const dirDisplay = getCcsDirDisplay(); const isReady = cursorConfig.enabled && authStatus.authenticated && !authStatus.expired && daemonStatus.running; @@ -90,7 +92,7 @@ export function renderCursorStatus( console.log(` Models route: ${localBaseUrl}/v1/models`); console.log(''); console.log('Client setup:'); - console.log(' Raw settings: ~/.ccs/cursor.settings.json'); + console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`); console.log(' Subcommands: ccs cursor help'); if (isReady) { diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index 05a9245b..b103b735 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -1,7 +1,7 @@ /** * Cursor CLI Command * - * Handles `ccs cursor ` commands. + * Handles `ccs cursor [subcommand]` commands. */ import { diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index a30e6829..494f6779 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui'; import { isUnifiedMode } from '../config/unified-config-loader'; -import { getCcsDir, getCcsDirSource } from '../utils/config-manager'; +import { getCcsDirDisplay } from '../utils/config-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { getOfficialChannelsSupportMessage } from '../channels/official-channels-runtime'; @@ -129,8 +129,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); writeLine(''); // Resolve display path for dynamic sections - const [dirSource] = getCcsDirSource(); - const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir(); + const dirDisplay = getCcsDirDisplay(); // Usage section writeLine(subheader('Usage:')); @@ -288,7 +287,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Auto-detects token from Cursor installation', ], [ - ['ccs cursor ', 'Use Cursor IDE integration'], + ['ccs cursor', 'Cursor status + local daemon connection details'], ['ccs cursor auth', 'Import Cursor token'], ['ccs cursor auth --manual --token --machine-id ', 'Manual token import'], ['ccs cursor status', 'Show connection status'], diff --git a/src/cursor/cursor-translator.ts b/src/cursor/cursor-translator.ts index fa6617c7..72112568 100644 --- a/src/cursor/cursor-translator.ts +++ b/src/cursor/cursor-translator.ts @@ -55,6 +55,7 @@ interface OpenAIRequestBody { const MAX_TOOL_RESULT_CHARS = 12_000; const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]'; const TOOL_USE_ARGUMENTS_FALLBACK = '{}'; +const TOOL_CALL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; function isTextPart(part: OpenAIContentPart): part is OpenAITextPart { return part.type === 'text'; @@ -68,19 +69,19 @@ function isToolResultPart(part: OpenAIContentPart): part is OpenAIToolResultPart return part.type === 'tool_result'; } -function extractTextContent(content: OpenAIMessage['content']): string { +function extractTextContent(content: OpenAIMessage['content'], separator = ''): string { if (typeof content === 'string') { return content; } - let text = ''; + const parts: string[] = []; for (const part of content) { if (isTextPart(part) && part.text) { - text += part.text; + parts.push(part.text); } } - return text; + return parts.join(separator); } function stringifyUnknown(value: unknown, fallback = ''): string { @@ -101,16 +102,16 @@ function truncateToolResultText(text: string): string { return text; } - let suffix = '\n[truncated]'; - let keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0); - let omittedChars = text.length - keepLength; - - suffix = `\n[truncated ${omittedChars} chars]`; - keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0); - omittedChars = text.length - keepLength; - suffix = `\n[truncated ${omittedChars} chars]`; - - return `${text.slice(0, keepLength)}${suffix}`; + let omittedChars = text.length - MAX_TOOL_RESULT_CHARS; + while (true) { + const suffix = `\n[truncated ${omittedChars} chars]`; + const keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0); + const nextOmittedChars = text.length - keepLength; + if (nextOmittedChars === omittedChars) { + return `${text.slice(0, keepLength)}${suffix}`; + } + omittedChars = nextOmittedChars; + } } function escapeXml(text: string): string { @@ -133,6 +134,19 @@ function normalizeToolCallId(toolCallId: string | undefined): string { return typeof toolCallId === 'string' ? toolCallId.split('\n')[0] : ''; } +function sanitizeToolCallId(toolCallId: string | undefined): string { + const normalizedId = normalizeToolCallId(toolCallId).trim(); + if (!normalizedId) { + return ''; + } + + if (TOOL_CALL_ID_PATTERN.test(normalizedId)) { + return normalizedId; + } + + return normalizedId.replace(/[^a-zA-Z0-9_-]/g, ''); +} + function extractToolResultText(content: unknown): string { if (content === undefined) { return ''; @@ -146,7 +160,7 @@ function extractToolResultText(content: unknown): string { return content .filter(isTextPart) .map((part) => part.text || '') - .join(''); + .join('\n'); } return stringifyUnknown(content, TOOL_RESULT_SERIALIZATION_FALLBACK); @@ -161,9 +175,34 @@ function resolveToolUseId( messageIndex: number, partIndex: number ): string { - return typeof part.id === 'string' && part.id.length > 0 - ? part.id - : createFallbackToolUseId(messageIndex, partIndex); + const sanitizedId = sanitizeToolCallId(part.id); + return sanitizedId || createFallbackToolUseId(messageIndex, partIndex); +} + +function requireToolResultId(toolCallId: string | undefined, location: string): string { + const sanitizedId = sanitizeToolCallId(toolCallId); + if (sanitizedId) { + return sanitizedId; + } + + throw new Error(`${location} must include a valid tool result id`); +} + +function normalizeAssistantToolCalls( + toolCalls: NonNullable, + messageIndex: number +): NonNullable { + return toolCalls.map((toolCall, toolCallIndex) => ({ + ...toolCall, + id: sanitizeToolCallId(toolCall.id) || createFallbackToolUseId(messageIndex, toolCallIndex), + function: { + name: toolCall.function?.name || 'tool', + arguments: + typeof toolCall.function?.arguments === 'string' + ? toolCall.function.arguments + : stringifyUnknown(toolCall.function?.arguments ?? {}, TOOL_USE_ARGUMENTS_FALLBACK), + }, + })); } function rememberToolCallMeta( @@ -259,7 +298,8 @@ function mergeAssistantToolCalls( function renderUserContent( content: OpenAIMessage['content'], - toolCallMetaMap: Map + toolCallMetaMap: Map, + messageIndex: number ): string { if (typeof content === 'string') { return content; @@ -267,7 +307,8 @@ function renderUserContent( const parts: string[] = []; let textBuffer = ''; - for (const part of content) { + for (let partIndex = 0; partIndex < content.length; partIndex++) { + const part = content[partIndex]; if (isTextPart(part) && part.text) { textBuffer += part.text; continue; @@ -282,7 +323,10 @@ function renderUserContent( textBuffer = ''; } - const toolCallId = part.tool_use_id || ''; + const toolCallId = requireToolResultId( + part.tool_use_id, + `messages[${messageIndex}].content[${partIndex}]` + ); const normalizedId = normalizeToolCallId(toolCallId); const toolName = toolCallMetaMap.get(toolCallId)?.name || toolCallMetaMap.get(normalizedId)?.name || 'tool'; @@ -317,7 +361,10 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] { } if (msg.role === 'tool') { - const toolCallId = msg.tool_call_id || ''; + const toolCallId = requireToolResultId( + msg.tool_call_id, + `messages[${messageIndex}].tool_call_id` + ); const normalizedToolCallId = normalizeToolCallId(toolCallId); const rememberedToolName = toolCallMetaMap.get(toolCallId)?.name || toolCallMetaMap.get(normalizedToolCallId)?.name; @@ -325,19 +372,20 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] { result.push({ role: 'user', - content: buildToolResultBlock(toolName, toolCallId, extractTextContent(msg.content)), + content: buildToolResultBlock(toolName, toolCallId, extractTextContent(msg.content, '\n')), }); continue; } if (msg.role === 'user' || msg.role === 'assistant') { if (msg.role === 'assistant') { + const normalizedToolCalls = normalizeAssistantToolCalls(msg.tool_calls || [], messageIndex); const assistantToolCalls = mergeAssistantToolCalls( - msg.tool_calls || [], + normalizedToolCalls, extractToolCallsFromContent(msg.content, messageIndex) ); - if (msg.tool_calls?.length) { - rememberToolCallMeta(toolCallMetaMap, msg.tool_calls); + if (normalizedToolCalls.length > 0) { + rememberToolCallMeta(toolCallMetaMap, normalizedToolCalls); } rememberToolUseParts(toolCallMetaMap, msg.content, messageIndex); @@ -355,7 +403,7 @@ function convertMessages(messages: OpenAIMessage[]): CursorMessage[] { }); } } else { - const content = renderUserContent(msg.content, toolCallMetaMap); + const content = renderUserContent(msg.content, toolCallMetaMap, messageIndex); if (content) { result.push({ role: 'user', diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index e20e3660..f32b83a5 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -102,6 +102,15 @@ export function getCcsDirSource(): [string, string] { return [r.source, r.dir]; } +/** + * Get the CCS directory as a user-facing display path. + * Keeps the default path concise while preserving explicit overrides. + */ +export function getCcsDirDisplay(): string { + const [source, dir] = getCcsDirSource(); + return source === 'default' ? '~/.ccs' : dir; +} + /** * Cloud sync folder patterns for security warning. */ diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 08397323..913fe14f 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -88,6 +88,15 @@ describe('help command parity', () => { ).toBe(true); }); + test('root help documents bare cursor as the status entrypoint', async () => { + const lines: string[] = []; + await handleHelpCommand((line) => lines.push(line)); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('ccs cursor ')).toBe(false); + expect(rendered.includes('Cursor status + local daemon connection details')).toBe(true); + }); + test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => { const lines: string[] = []; await handleHelpCommand((line) => lines.push(line)); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 8e17e2d0..1cf151ed 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -2,7 +2,7 @@ * Unit tests for Cursor daemon module */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -273,6 +273,33 @@ describe('stopDaemon', () => { } } }); + + it('refuses to stop when daemon ownership cannot be verified', async () => { + const killSpy = spyOn(process, 'kill').mockImplementation( + ((pid: number, signal?: NodeJS.Signals | number) => { + if (pid === process.pid && signal === 0) { + const err = new Error('EPERM') as NodeJS.ErrnoException; + err.code = 'EPERM'; + throw err; + } + + return true; + }) as typeof process.kill + ); + + writePidToFile(process.pid); + + try { + const result = await stopDaemon(); + + expect(result.success).toBe(false); + expect(result.error).toContain('unable to verify daemon ownership'); + expect(fs.existsSync(path.join(getTestCursorDir(), 'daemon.pid'))).toBe(true); + } finally { + killSpy.mockRestore(); + removePidFile(); + } + }); }); describe('handleCursorCommand', () => { @@ -366,9 +393,9 @@ describe('renderCursorStatus', () => { expect( logs.some((line) => line.includes('Anthropic base: http://127.0.0.1:20129')) ).toBe(true); - expect(logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json'))).toBe( - true - ); + expect( + logs.some((line) => line.includes(`Raw settings: ${getCcsDir()}/cursor.settings.json`)) + ).toBe(true); } finally { console.log = originalLog; } @@ -403,6 +430,46 @@ describe('renderCursorStatus', () => { console.log = originalLog; } }); + + it('falls back to ~/.ccs in status output when no CCS override is active', () => { + const originalLog = console.log; + const originalCcsHomeValue = process.env.CCS_HOME; + const logs: string[] = []; + + delete process.env.CCS_HOME; + console.log = (...args: unknown[]) => { + logs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + renderCursorStatus( + { ...DEFAULT_CURSOR_CONFIG, enabled: true, port: 20129 }, + { + authenticated: true, + expired: false, + tokenAge: 0, + credentials: { + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: new Date().toISOString(), + }, + }, + { running: true, port: 20129, pid: 1234 } + ); + + expect(logs.some((line) => line.includes('Raw settings: ~/.ccs/cursor.settings.json'))).toBe( + true + ); + } finally { + if (originalCcsHomeValue !== undefined) { + process.env.CCS_HOME = originalCcsHomeValue; + } else { + delete process.env.CCS_HOME; + } + console.log = originalLog; + } + }); }); describe('renderCursorHelp', () => { diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index e2af6027..d3cdf709 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -223,7 +223,7 @@ describe('Message Translation', () => { expect(result.messages[0].tool_calls![0].function.name).toBe('get_weather'); }); - it('should accumulate tool results', () => { + it('should flatten tool results into user tool_result blocks', () => { const result = buildCursorRequest( 'gpt-4', { @@ -260,6 +260,56 @@ describe('Message Translation', () => { expect(result.messages[2]).toEqual({ role: 'user', content: 'What is the weather?' }); }); + it('should preserve consecutive tool messages as separate user turns', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_one', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + { + id: 'call_two', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"README.md"}' }, + }, + ], + }, + { + role: 'tool', + content: 'first result', + tool_call_id: 'call_one', + }, + { + role: 'tool', + content: 'second result', + tool_call_id: 'call_two', + }, + { role: 'user', content: 'Summarize both results.' }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(4); + expect(result.messages[1]).toEqual({ + role: 'user', + content: expect.stringContaining('call_one'), + }); + expect(result.messages[2]).toEqual({ + role: 'user', + content: expect.stringContaining('call_two'), + }); + expect(result.messages[3]).toEqual({ role: 'user', content: 'Summarize both results.' }); + }); + it('should recover tool names for tool results without a name field', () => { const result = buildCursorRequest( 'gpt-4', @@ -332,6 +382,45 @@ describe('Message Translation', () => { expect(result.messages[1].content).toContain('{"answer":"Cursor integration ready"}'); }); + it('should preserve line breaks for multipart tool_result text content', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_lines', + type: 'function', + function: { name: 'read_file', arguments: '{"path":"notes.txt"}' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_lines', + content: [ + { type: 'text', text: 'first line' }, + { type: 'text', text: 'second line' }, + ], + }, + ], + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[1].content).toContain('first line\nsecond line'); + }); + it('should convert assistant tool_use content blocks into tool_calls', () => { const result = buildCursorRequest( 'gpt-4', @@ -393,6 +482,38 @@ describe('Message Translation', () => { expect(result.messages[0].tool_calls![0].id).toBe('toolu_cursor_fallback_0_0'); }); + it('should normalize invalid assistant tool call ids before emitting tool results', () => { + const result = buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_bad\nline two', + type: 'function', + function: { name: 'search_docs', arguments: '{"q":"cursor"}' }, + }, + ], + }, + { + role: 'tool', + content: 'done', + tool_call_id: 'call_bad\nline two', + }, + ], + }, + false, + {} + ); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0].tool_calls![0].id).toBe('call_bad'); + expect(result.messages[1].content).toContain('call_bad'); + }); + it('should dedupe tool calls when assistant messages contain both tool_calls and tool_use blocks', () => { const result = buildCursorRequest( 'gpt-4', @@ -463,6 +584,10 @@ describe('Message Translation', () => { expect(result.messages).toHaveLength(2); expect(result.messages[1].content).toContain('[truncated '); expect(result.messages[1].content.length).toBeLessThan(12_250); + + const resultMatch = result.messages[1].content.match(/([\s\S]*)<\/result>/); + expect(resultMatch).not.toBeNull(); + expect(resultMatch?.[1].length).toBeLessThanOrEqual(12_000); }); it('should mark unserializable structured tool results explicitly', () => { @@ -504,6 +629,47 @@ describe('Message Translation', () => { expect(result.messages[1].content).toContain('[unserializable content]'); }); + it('should reject tool_result blocks without a valid tool_use_id', () => { + expect(() => + buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + content: 'done', + }, + ], + }, + ], + }, + false, + {} + ) + ).toThrow('messages[0].content[0] must include a valid tool result id'); + }); + + it('should reject tool role messages without a valid tool_call_id', () => { + expect(() => + buildCursorRequest( + 'gpt-4', + { + messages: [ + { + role: 'tool', + content: 'done', + }, + ], + }, + false, + {} + ) + ).toThrow('messages[0].tool_call_id must include a valid tool result id'); + }); + it('should handle array content format', () => { const result = buildCursorRequest( 'gpt-4', From 2d67f40175705eafba2476256f171413696a89a2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 1 Apr 2026 21:39:08 -0400 Subject: [PATCH 3/4] fix(cursor): route bare cursor through runtime profile --- README.md | 5 +- docs/cursor-integration.md | 16 +- src/auth/profile-detector.ts | 38 ++++ src/ccs.ts | 48 +++++ src/commands/cursor-command-display.ts | 12 +- src/commands/cursor-command.ts | 8 +- src/commands/help-command.ts | 2 +- src/commands/root-command-router.ts | 7 - src/cursor/cursor-profile-executor.ts | 190 ++++++++++++++++++ src/cursor/index.ts | 1 + src/shared/claude-extension-setup.ts | 72 ++++--- src/targets/target-runtime-compatibility.ts | 7 + src/types/profile.ts | 2 +- tests/unit/auth/profile-detector.test.ts | 53 +++++ .../unit/commands/help-command-parity.test.ts | 4 +- .../unit/commands/root-command-router.test.ts | 9 + tests/unit/cursor/cursor-daemon.test.ts | 16 +- .../cursor/cursor-profile-executor.test.ts | 89 ++++++++ .../target-runtime-compatibility.test.ts | 29 ++- 19 files changed, 549 insertions(+), 59 deletions(-) create mode 100644 src/cursor/cursor-profile-executor.ts create mode 100644 tests/unit/cursor/cursor-profile-executor.test.ts diff --git a/README.md b/README.md index fa1346e7..8ad79ecf 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ The dashboard provides visual management for all account types: | **Gemini** | OAuth | `ccs gemini` | Zero-config, fast iteration | | **Codex** | OAuth | `ccs codex` | Code generation | | **Copilot** | OAuth | `ccs copilot` or `ccs ghcp` | GitHub Copilot models | -| **Cursor IDE** | Local Token | `ccs cursor` | Cursor subscription models via local daemon | +| **Cursor IDE** | Local Token | `ccs cursor` | Run Claude through Cursor models via local daemon | | **Kiro** | OAuth (AWS default) | `ccs kiro` | AWS CodeWhisperer (Claude-powered) | | **Antigravity** | OAuth | `ccs agy` | Alternative routing | | **OpenRouter** | API Key | `ccs openrouter` | 300+ models, unified API | @@ -184,7 +184,7 @@ The dashboard provides visual management for all account types: ccs # Default Claude session ccs gemini # Gemini (OAuth) ccs codex # OpenAI Codex (OAuth) -ccs cursor # Cursor status + local daemon connection details +ccs cursor # Run Claude through Cursor local proxy ccs kiro # Kiro/AWS CodeWhisperer (OAuth) ccs ghcp # GitHub Copilot (OAuth device flow) ccs agy # Antigravity (OAuth) @@ -379,6 +379,7 @@ Dashboard parity: `ccs config` -> Accounts -> Add Kiro account -> choose `Auth M ccs cursor enable ccs cursor auth ccs cursor start +ccs cursor "explain this repo" ccs cursor status ``` diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 5e190e8c..79caee5a 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -43,20 +43,26 @@ ccs cursor auth --manual --token --machine-id ccs cursor start ``` -### 4) Verify status +### 4) Run Cursor-backed Claude + +```bash +ccs cursor "explain this repo" +``` + +### 5) Verify status ```bash ccs cursor status ``` -Or use the bare command to see the same status view plus the local endpoint details -needed for OpenAI-compatible and Anthropic-compatible clients: +Use `ccs cursor` with bare or normal Claude args to run through the local Cursor proxy. +The admin namespace remains available for setup and inspection: ```bash -ccs cursor +ccs cursor help ``` -### 5) Stop daemon +### 6) Stop daemon ```bash ccs cursor stop diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 04b132e6..d21e187c 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -16,6 +16,7 @@ import { Config, Settings, ProfileMetadata } from '../types'; import { UnifiedConfig, CopilotConfig, + CursorConfig, CLIProxyVariantConfig, CompositeVariantConfig, CompositeTierConfig, @@ -49,6 +50,8 @@ export interface ProfileDetectionResult { env?: Record; /** For copilot profile: the copilot config */ copilotConfig?: CopilotConfig; + /** For cursor profile: the cursor config */ + cursorConfig?: CursorConfig; /** For composite variants: true when variant mixes providers per tier */ isComposite?: boolean; /** For composite variants: which tier is the default */ @@ -252,6 +255,7 @@ class ProfileDetector { * Priority order: * 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen) * 0.5. Copilot profile (if enabled in config) + * 0.75. Cursor profile (if enabled in config) * 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1) * 2. User-defined CLIProxy variants (config.cliproxy section) [legacy] * 3. Settings-based profiles (config.profiles section) [legacy] @@ -302,6 +306,35 @@ class ProfileDetector { }; } + // Priority 0.75: Check Cursor profile - local Cursor daemon runtime + if (profileName === 'cursor') { + const unifiedConfig = this.readUnifiedConfig(); + const cursorConfig = unifiedConfig?.cursor; + + if (!cursorConfig?.enabled) { + const error = new Error( + 'Cursor profile is not enabled.\n\n' + + 'To enable Cursor integration:\n' + + ' 1. Run: ccs cursor enable\n' + + ' 2. Import auth: ccs cursor auth\n' + + ' 3. Start daemon: ccs cursor start\n\n' + + 'Or manually edit ~/.ccs/config.yaml:\n' + + ' cursor:\n' + + ' enabled: true' + ) as ProfileNotFoundError; + error.profileName = profileName; + error.suggestions = []; + error.availableProfiles = this.listAvailableProfiles(); + throw error; + } + + return { + type: 'cursor', + name: 'cursor', + cursorConfig, + }; + } + // Priority 1: Try unified config if available const unifiedConfig = this.readUnifiedConfig(); if (unifiedConfig) { @@ -452,6 +485,11 @@ class ProfileDetector { lines.push(` - copilot (model: ${currentCopilotModel})`); } + if (unifiedConfig.cursor?.enabled) { + lines.push('Cursor local proxy:'); + lines.push(` - cursor (model: ${unifiedConfig.cursor.model})`); + } + // CLIProxy variants from unified config const variants = Object.keys(unifiedConfig.cliproxy?.variants || {}); if (variants.length > 0) { diff --git a/src/ccs.ts b/src/ccs.ts index aafaee74..c3298493 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -450,6 +450,20 @@ async function main(): Promise { } } + // Special case: cursor command (Cursor local proxy integration) + // Route known admin subcommands to the command handler, keep all other args as profile passthrough. + if (firstArg === 'cursor' && args.length > 1) { + const { isCursorSubcommandToken, handleCursorCommand } = await import( + './commands/cursor-command' + ); + const cursorToken = args[1]; + + if (isCursorSubcommandToken(cursorToken)) { + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + } + } + // First-time install: offer setup wizard for interactive users // Check independently of recovery status (user may have empty config.yaml) // Skip if headless, CI, or non-TTY environment @@ -880,6 +894,40 @@ async function main(): Promise { claudeCli ); process.exit(exitCode); + } else if (profileInfo.type === 'cursor') { + // CURSOR FLOW: local Cursor daemon profile + ensureWebSearchMcpOrThrow(); + installImageAnalyzerHook(); + ensureImageAnalyzerHooks({ + profileName: profileInfo.name, + profileType: profileInfo.type, + }); + + const { executeCursorProfile } = await import('./cursor'); + const cursorConfig = profileInfo.cursorConfig; + if (!cursorConfig) { + console.error(fail('Cursor configuration not found')); + process.exit(1); + } + const continuityInheritance = await resolveProfileContinuityInheritance({ + profileName: profileInfo.name, + profileType: profileInfo.type, + target: resolvedTarget, + }); + if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { + console.error( + info( + `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` + ) + ); + } + const exitCode = await executeCursorProfile( + cursorConfig, + remainingArgs, + continuityInheritance.claudeConfigDir, + claudeCli + ); + process.exit(exitCode); } else if (profileInfo.type === 'settings') { // Settings-based profiles (glm, glmt) are third-party providers if (resolvedTarget === 'claude') { diff --git a/src/commands/cursor-command-display.ts b/src/commands/cursor-command-display.ts index 5bc1f4d8..e63c109a 100644 --- a/src/commands/cursor-command-display.ts +++ b/src/commands/cursor-command-display.ts @@ -13,7 +13,7 @@ export function renderCursorHelp(): number { printLines([ 'Cursor IDE Integration', '', - 'Usage: ccs cursor [subcommand] [options]', + 'Usage: ccs cursor ', '', 'Subcommands:', ' auth Import Cursor IDE authentication token', @@ -25,6 +25,9 @@ export function renderCursorHelp(): number { ' disable Disable cursor integration in unified config', ' help Show this help message', '', + 'Runtime entry:', + ' ccs cursor [claude args] # Run Claude via the local Cursor proxy', + '', 'Auth options:', ' ccs cursor auth # Auto-detect from Cursor SQLite', ' ccs cursor auth --manual --token --machine-id ', @@ -33,7 +36,8 @@ export function renderCursorHelp(): number { ' 1. ccs cursor enable # Enable integration', ' 2. ccs cursor auth # Import Cursor IDE token', ' 3. ccs cursor start # Start daemon', - ' 4. ccs cursor # Show status and runtime connection details', + ' 4. ccs cursor "task" # Run Claude through Cursor', + ' 5. ccs cursor status # Inspect auth/daemon wiring', '', 'Or use the web UI: ccs config -> Cursor page', '', @@ -93,7 +97,9 @@ export function renderCursorStatus( console.log(''); console.log('Client setup:'); console.log(` Raw settings: ${dirDisplay}/cursor.settings.json`); - console.log(' Subcommands: ccs cursor help'); + console.log(' Runtime entry: ccs cursor [claude args]'); + console.log(' Status command: ccs cursor status'); + console.log(' Help command: ccs cursor help'); if (isReady) { return; diff --git a/src/commands/cursor-command.ts b/src/commands/cursor-command.ts index b103b735..4c918f65 100644 --- a/src/commands/cursor-command.ts +++ b/src/commands/cursor-command.ts @@ -34,6 +34,12 @@ export const CURSOR_SUBCOMMANDS = [ '-h', ] as const; +export function isCursorSubcommandToken(token?: string): boolean { + return ( + Boolean(token) && CURSOR_SUBCOMMANDS.includes(token as (typeof CURSOR_SUBCOMMANDS)[number]) + ); +} + /** * Handle cursor subcommand. */ @@ -56,7 +62,7 @@ export async function handleCursorCommand(args: string[]): Promise { case 'disable': return handleDisable(); case undefined: - return handleStatus(); + return handleHelp(); case 'help': case '--help': case '-h': diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 494f6779..023b149d 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -287,7 +287,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Auto-detects token from Cursor installation', ], [ - ['ccs cursor', 'Cursor status + local daemon connection details'], + ['ccs cursor', 'Run Claude via Cursor local proxy'], ['ccs cursor auth', 'Import Cursor token'], ['ccs cursor auth --manual --token --machine-id ', 'Manual token import'], ['ccs cursor status', 'Show connection status'], diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index 98f4a178..450dec5b 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -172,13 +172,6 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ await handleSetupCommand(args); }, }, - { - name: 'cursor', - handle: async (args) => { - const { handleCursorCommand } = await import('./cursor-command'); - process.exit(await handleCursorCommand(args)); - }, - }, ]; export async function tryHandleRootCommand(args: string[]): Promise { diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts new file mode 100644 index 00000000..74900445 --- /dev/null +++ b/src/cursor/cursor-profile-executor.ts @@ -0,0 +1,190 @@ +import { spawn } from 'child_process'; + +import type { CursorConfig } from '../config/unified-config-types'; +import { getGlobalEnvConfig } from '../config/unified-config-loader'; +import { ensureCliproxyService } from '../cliproxy'; +import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +import { fail, info, ok } from '../utils/ui'; +import { + appendThirdPartyWebSearchToolArgs, + createWebSearchTraceContext, + getWebSearchHookEnv, + syncWebSearchMcpToConfigDir, +} from '../utils/websearch-manager'; +import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../utils/hooks'; +import { stripClaudeCodeEnv } from '../utils/shell-executor'; +import { checkAuthStatus } from './cursor-auth'; +import { isDaemonRunning, startDaemon } from './cursor-daemon'; + +interface CursorImageAnalysisResolution { + env: Record; + warning: string | null; +} + +export function generateCursorEnv( + config: CursorConfig, + claudeConfigDir?: string +): Record { + const opusModel = config.opus_model || config.model; + const sonnetModel = config.sonnet_model || config.model; + const haikuModel = config.haiku_model || config.model; + + return { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`, + ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + ANTHROPIC_MODEL: config.model, + ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, + ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, + ANTHROPIC_SMALL_FAST_MODEL: haikuModel, + ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel, + DISABLE_NON_ESSENTIAL_MODEL_CALLS: '1', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), + }; +} + +export async function resolveCursorImageAnalysisEnv( + verbose = false +): Promise { + const env = getImageAnalysisHookEnv({ + profileName: 'cursor', + profileType: 'cursor', + }); + const provider = env['CCS_CURRENT_PROVIDER']; + if (env['CCS_IMAGE_ANALYSIS_SKIP'] === '1' || !provider) { + return { env, warning: null }; + } + + const status = await resolveImageAnalysisRuntimeStatus({ + profileName: 'cursor', + profileType: 'cursor', + }); + + if (status.effectiveRuntimeMode === 'native-read') { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `${status.effectiveRuntimeReason || `Image analysis via ${provider} is unavailable.`} This session will use native Read.`, + }; + } + + if (status.proxyReadiness === 'stopped') { + const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose); + if (!ensureServiceResult.started) { + return { + env: { + ...env, + CCS_CURRENT_PROVIDER: '', + CCS_IMAGE_ANALYSIS_SKIP: '1', + }, + warning: `Image analysis via ${provider} is unavailable because CCS could not start the local CLIProxy service. This session will use native Read.`, + }; + } + } + + return { env, warning: null }; +} + +export async function executeCursorProfile( + config: CursorConfig, + claudeArgs: string[], + claudeConfigDir?: string, + claudeCliPath = 'claude' +): Promise { + if (!config.enabled) { + console.error(fail('Cursor integration is not enabled.')); + console.error(''); + console.error('Enable it first: ccs cursor enable'); + return 1; + } + + const authStatus = checkAuthStatus(); + if (!authStatus.authenticated) { + console.error(fail('Cursor credentials not found.')); + console.error(''); + console.error('Authenticate first: ccs cursor auth'); + return 1; + } + if (authStatus.expired) { + console.error(fail('Cursor credentials have expired.')); + console.error(''); + console.error('Refresh them with: ccs cursor auth'); + return 1; + } + + let daemonRunning = await isDaemonRunning(config.port); + if (!daemonRunning) { + if (config.auto_start) { + console.log(info('Starting cursor daemon...')); + const result = await startDaemon({ + port: config.port, + ghost_mode: config.ghost_mode, + }); + if (!result.success) { + console.error(fail(`Failed to start cursor daemon: ${result.error}`)); + return 1; + } + console.log(ok(`Daemon started on port ${config.port}`)); + daemonRunning = true; + } else { + console.error(fail('Cursor daemon is not running.')); + console.error(''); + console.error('Start the daemon:'); + console.error(' ccs cursor start'); + console.error('Or enable auto_start in the Cursor config section.'); + return 1; + } + } + + const cursorEnv = generateCursorEnv(config, claudeConfigDir); + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + const webSearchEnv = getWebSearchHookEnv(); + const { env: imageAnalysisEnv, warning: imageAnalysisWarning } = + await resolveCursorImageAnalysisEnv(); + const env = stripClaudeCodeEnv({ + ...process.env, + ...globalEnv, + ...cursorEnv, + ...webSearchEnv, + ...imageAnalysisEnv, + CCS_PROFILE_TYPE: 'cursor', + }); + + console.log(info(`Using Cursor proxy (model: ${config.model})`)); + if (imageAnalysisWarning) { + console.log(info(imageAnalysisWarning)); + } + console.log(''); + + syncWebSearchMcpToConfigDir(claudeConfigDir); + + return new Promise((resolve) => { + const launchArgs = appendThirdPartyWebSearchToolArgs(claudeArgs); + const traceEnv = createWebSearchTraceContext({ + launcher: 'cursor.executor', + args: launchArgs, + profile: 'cursor', + profileType: 'cursor', + claudeConfigDir, + }); + + const proc = spawn(claudeCliPath, launchArgs, { + stdio: 'inherit', + env: { ...env, ...traceEnv }, + shell: process.platform === 'win32', + }); + + proc.on('close', (code) => { + resolve(code ?? 0); + }); + + proc.on('error', (err) => { + console.error(fail(`Failed to start Claude: ${err.message}`)); + resolve(1); + }); + }); +} diff --git a/src/cursor/index.ts b/src/cursor/index.ts index 0322083c..42e42860 100644 --- a/src/cursor/index.ts +++ b/src/cursor/index.ts @@ -45,3 +45,4 @@ export { // Executor export { CursorExecutor } from './cursor-executor'; +export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor'; diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index 9f6a9385..cf04b0fc 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -11,6 +11,7 @@ import { import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; import { getProxyTarget } from '../cliproxy/proxy-target-resolver'; import { generateCopilotEnv } from '../copilot/copilot-executor'; +import { generateCursorEnv } from '../cursor'; import InstanceManager from '../management/instance-manager'; import SharedManager from '../management/shared-manager'; import { expandPath } from '../utils/helpers'; @@ -97,6 +98,7 @@ function describeProfile(profileName: string, result: ProfileDetectionResult): s if (result.type === 'account') return 'Claude account instance isolated through CLAUDE_CONFIG_DIR.'; if (result.type === 'copilot') return 'GitHub Copilot profile routed through copilot-api.'; + if (result.type === 'cursor') return 'Cursor profile routed through the local cursor daemon.'; return 'Native Claude profile resolution.'; } @@ -129,6 +131,12 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] { } catch { // Copilot disabled; skip from setup UI. } + try { + detector.detectProfileType('cursor'); + deduped.push('cursor'); + } catch { + // Cursor disabled; skip from setup UI. + } return deduped .map((profileName) => createProfileOption(profileName, detector.detectProfileType(profileName))) @@ -201,40 +209,47 @@ async function resolveExtensionEnv( } return generateCopilotEnv(result.copilotConfig, continuity.claudeConfigDir); })() - : (() => { - if (!result.provider) { - throw new Error( - `Profile "${requestedProfile}" is missing CLIProxy provider metadata.` - ); - } - const proxyTarget = getProxyTarget(); - const port = result.port || CLIPROXY_DEFAULT_PORT; - if (proxyTarget.isRemote) { + : result.type === 'cursor' + ? (() => { + if (!result.cursorConfig) { + throw new Error(`Profile "${requestedProfile}" is missing cursor configuration.`); + } + return generateCursorEnv(result.cursorConfig, continuity.claudeConfigDir); + })() + : (() => { + if (!result.provider) { + throw new Error( + `Profile "${requestedProfile}" is missing CLIProxy provider metadata.` + ); + } + const proxyTarget = getProxyTarget(); + const port = result.port || CLIPROXY_DEFAULT_PORT; + if (proxyTarget.isRemote) { + warnings.push( + `CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.` + ); + return result.isComposite && result.compositeTiers && result.compositeDefaultTier + ? getCompositeEnvVars( + result.compositeTiers, + result.compositeDefaultTier, + port, + result.settingsPath, + proxyTarget + ) + : getRemoteEnvVars(result.provider, proxyTarget, result.settingsPath); + } warnings.push( - `CLIProxy is configured for remote routing via ${proxyTarget.protocol}://${proxyTarget.host}:${proxyTarget.port}.` + 'CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.' ); return result.isComposite && result.compositeTiers && result.compositeDefaultTier ? getCompositeEnvVars( result.compositeTiers, result.compositeDefaultTier, port, - result.settingsPath, - proxyTarget + result.settingsPath ) - : getRemoteEnvVars(result.provider, proxyTarget, result.settingsPath); - } - warnings.push( - 'CLIProxy-backed profiles require the local or remote proxy endpoint to be reachable.' - ); - return result.isComposite && result.compositeTiers && result.compositeDefaultTier - ? getCompositeEnvVars( - result.compositeTiers, - result.compositeDefaultTier, - port, - result.settingsPath - ) - : getEffectiveEnvVars(result.provider, port, result.settingsPath); - })(); + : getEffectiveEnvVars(result.provider, port, result.settingsPath); + })(); if (result.type === 'settings' && isDeprecatedGlmtProfileName(requestedProfile)) { const normalized = normalizeDeprecatedGlmtEnv(sortEnvRecord(env)); @@ -257,6 +272,11 @@ async function resolveExtensionEnv( 'copilot-api must stay reachable for this profile to work inside the IDE extension.' ); } + if (result.type === 'cursor') { + warnings.push( + 'The local Cursor daemon must stay reachable for this profile to work inside the IDE extension.' + ); + } if (Object.keys(env).length === 0) { throw new Error(`Profile "${requestedProfile}" has no extension environment to export.`); } diff --git a/src/targets/target-runtime-compatibility.ts b/src/targets/target-runtime-compatibility.ts index 9c9a83ca..627479cf 100644 --- a/src/targets/target-runtime-compatibility.ts +++ b/src/targets/target-runtime-compatibility.ts @@ -37,6 +37,9 @@ export function evaluateTargetRuntimeCompatibility( if (input.profileType === 'copilot') { return unsupported('Factory Droid does not support Copilot profiles.'); } + if (input.profileType === 'cursor') { + return unsupported('Factory Droid does not support Cursor local-proxy profiles.'); + } return { supported: true }; } @@ -51,6 +54,10 @@ export function evaluateTargetRuntimeCompatibility( return unsupported('Codex CLI does not support Copilot profiles.'); } + if (input.profileType === 'cursor') { + return unsupported('Codex CLI does not support Cursor local-proxy profiles.'); + } + if (input.profileType === 'default') { return { supported: true }; } diff --git a/src/types/profile.ts b/src/types/profile.ts index 75e3ff44..9d2472d8 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -1,4 +1,4 @@ /** * Profile mode types used across routing and target adapters. */ -export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'default'; +export type ProfileType = 'settings' | 'account' | 'cliproxy' | 'copilot' | 'cursor' | 'default'; diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index c8a2f324..f46f2319 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -195,5 +195,58 @@ describe('ProfileDetector', () => { existsSyncSpy.mockRestore(); } }); + + it('should detect cursor as a first-class runtime profile when enabled', () => { + const mockUnifiedConfig = { + version: 2, + cursor: { + enabled: true, + port: 20129, + auto_start: true, + ghost_mode: true, + model: 'gpt-5.3-codex', + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + const result = detector.detectProfileType('cursor'); + expect(result.type).toBe('cursor'); + expect(result.name).toBe('cursor'); + expect(result.cursorConfig?.auto_start).toBe(true); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should throw a helpful error when cursor profile is disabled', () => { + const mockUnifiedConfig = { + version: 2, + cursor: { + enabled: false, + port: 20129, + auto_start: false, + ghost_mode: true, + model: 'gpt-5.3-codex', + }, + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( + mockUnifiedConfig as any + ); + + try { + expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); }); }); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 913fe14f..7ad072ae 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -88,13 +88,13 @@ describe('help command parity', () => { ).toBe(true); }); - test('root help documents bare cursor as the status entrypoint', async () => { + test('root help documents bare cursor as the runtime entrypoint', async () => { const lines: string[] = []; await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs cursor ')).toBe(false); - expect(rendered.includes('Cursor status + local daemon connection details')).toBe(true); + expect(rendered.includes('Run Claude via Cursor local proxy')).toBe(true); }); test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => { diff --git a/tests/unit/commands/root-command-router.test.ts b/tests/unit/commands/root-command-router.test.ts index 50314f95..7e32630e 100644 --- a/tests/unit/commands/root-command-router.test.ts +++ b/tests/unit/commands/root-command-router.test.ts @@ -74,6 +74,15 @@ describe('root-command-router', () => { expect(calls).toEqual([]); }); + it('does not capture cursor so bare cursor can fall through to profile routing', async () => { + const tryHandleRootCommand = await loadTryHandleRootCommand(); + + await expect(tryHandleRootCommand(['cursor'])).resolves.toBe(false); + await expect(tryHandleRootCommand(['cursor', 'status'])).resolves.toBe(false); + + expect(calls).toEqual([]); + }); + it('prints update help without invoking the updater', async () => { const tryHandleRootCommand = await loadTryHandleRootCommand(); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 1cf151ed..bcfc301a 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -303,7 +303,7 @@ describe('stopDaemon', () => { }); describe('handleCursorCommand', () => { - it('treats bare ccs cursor as a status entrypoint instead of help', async () => { + it('shows help when invoked without an admin subcommand', async () => { const originalLog = console.log; const originalError = console.error; const logs: string[] = []; @@ -321,10 +321,8 @@ describe('handleCursorCommand', () => { expect(exitCode).toBe(0); expect(errors).toHaveLength(0); - expect(logs.some((line) => line.includes('Cursor IDE Status'))).toBe(true); - expect(logs.some((line) => line.includes('Usage: ccs cursor [options]'))).toBe( - false - ); + expect(logs.some((line) => line.includes('Cursor IDE Integration'))).toBe(true); + expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); } finally { console.log = originalLog; console.error = originalError; @@ -473,7 +471,7 @@ describe('renderCursorStatus', () => { }); describe('renderCursorHelp', () => { - it('shows bare ccs cursor as an optional entrypoint', () => { + it('shows bare ccs cursor as the runtime entrypoint', () => { const originalLog = console.log; const logs: string[] = []; @@ -485,11 +483,9 @@ describe('renderCursorHelp', () => { const exitCode = renderCursorHelp(); expect(exitCode).toBe(0); - expect(logs.some((line) => line.includes('Usage: ccs cursor [subcommand] [options]'))).toBe( - true - ); + expect(logs.some((line) => line.includes('Usage: ccs cursor '))).toBe(true); expect( - logs.some((line) => line.includes('4. ccs cursor # Show status and runtime connection details')) + logs.some((line) => line.includes('ccs cursor [claude args]')) ).toBe(true); } finally { console.log = originalLog; diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts new file mode 100644 index 00000000..e655c882 --- /dev/null +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + executeCursorProfile, + generateCursorEnv, +} from '../../../src/cursor/cursor-profile-executor'; +import { saveCredentials } from '../../../src/cursor/cursor-auth'; +import type { CursorConfig } from '../../../src/config/unified-config-types'; + +const BASE_CONFIG: CursorConfig = { + enabled: true, + port: 20129, + auto_start: false, + ghost_mode: true, + model: 'gpt-5.3-codex', +}; + +describe('cursor-profile-executor', () => { + let originalCcsHome: string | undefined; + let tempDir: string; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cursor-profile-executor-')); + process.env.CCS_HOME = tempDir; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('builds Cursor env for Claude runtime', () => { + const env = generateCursorEnv( + { + ...BASE_CONFIG, + opus_model: 'cursor-opus', + sonnet_model: 'cursor-sonnet', + haiku_model: 'cursor-haiku', + }, + '/tmp/claude-config' + ); + + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:20129'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('cursor-opus'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('cursor-haiku'); + expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/claude-config'); + }); + + it('fails fast when Cursor integration is disabled', async () => { + const exitCode = await executeCursorProfile({ ...BASE_CONFIG, enabled: false }, []); + expect(exitCode).toBe(1); + }); + + it('fails when credentials are missing', async () => { + const exitCode = await executeCursorProfile(BASE_CONFIG, []); + expect(exitCode).toBe(1); + }); + + it('fails with actionable guidance when daemon is down and auto_start is false', async () => { + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + const exitCode = await executeCursorProfile( + { + ...BASE_CONFIG, + port: 29991, + auto_start: false, + }, + [] + ); + + expect(exitCode).toBe(1); + }); +}); diff --git a/tests/unit/targets/target-runtime-compatibility.test.ts b/tests/unit/targets/target-runtime-compatibility.test.ts index e75a0600..60a2d1fa 100644 --- a/tests/unit/targets/target-runtime-compatibility.test.ts +++ b/tests/unit/targets/target-runtime-compatibility.test.ts @@ -3,6 +3,27 @@ import { describe, expect, test } from 'bun:test'; import { evaluateTargetRuntimeCompatibility } from '../../../src/targets/target-runtime-compatibility'; describe('evaluateTargetRuntimeCompatibility', () => { + test('rejects account, copilot, and cursor profiles on Droid target', () => { + expect( + evaluateTargetRuntimeCompatibility({ + target: 'droid', + profileType: 'account', + }).supported + ).toBe(false); + expect( + evaluateTargetRuntimeCompatibility({ + target: 'droid', + profileType: 'copilot', + }).supported + ).toBe(false); + expect( + evaluateTargetRuntimeCompatibility({ + target: 'droid', + profileType: 'cursor', + }).supported + ).toBe(false); + }); + test('supports native Codex default sessions', () => { expect( evaluateTargetRuntimeCompatibility({ @@ -69,7 +90,7 @@ describe('evaluateTargetRuntimeCompatibility', () => { expect(genericSettingsCompatibility.reason).toMatch(/currently supports native default sessions/); }); - test('rejects account and copilot profiles on Codex target', () => { + test('rejects account, copilot, and cursor profiles on Codex target', () => { expect( evaluateTargetRuntimeCompatibility({ target: 'codex', @@ -82,5 +103,11 @@ describe('evaluateTargetRuntimeCompatibility', () => { profileType: 'copilot', }).supported ).toBe(false); + expect( + evaluateTargetRuntimeCompatibility({ + target: 'codex', + profileType: 'cursor', + }).supported + ).toBe(false); }); }); From f7ddad6c19353ae3b32e82541526a2149160ee36 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 2 Apr 2026 03:21:38 -0400 Subject: [PATCH 4/4] fix(cursor): harden runtime follow-up regressions --- src/auth/profile-detector.ts | 5 +- src/cursor/cursor-translator.ts | 3 +- .../hooks/image-analysis-backend-resolver.ts | 10 +++ tests/unit/auth/profile-detector.test.ts | 68 ++++++++++++------- .../cursor/cursor-profile-executor.test.ts | 9 +++ tests/unit/cursor/cursor-protobuf.test.ts | 25 ++++++- .../image-analysis-backend-resolver.test.ts | 38 +++++++++++ 7 files changed, 126 insertions(+), 32 deletions(-) diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index d21e187c..a67f8d98 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -21,7 +21,7 @@ import { CompositeVariantConfig, CompositeTierConfig, } from '../config/unified-config-types'; -import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; +import { loadUnifiedConfig, isUnifiedMode, getCursorConfig } from '../config/unified-config-loader'; import { getCcsDir } from '../utils/config-manager'; import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import type { CLIProxyProvider } from '../cliproxy/types'; @@ -308,8 +308,7 @@ class ProfileDetector { // Priority 0.75: Check Cursor profile - local Cursor daemon runtime if (profileName === 'cursor') { - const unifiedConfig = this.readUnifiedConfig(); - const cursorConfig = unifiedConfig?.cursor; + const cursorConfig = getCursorConfig(); if (!cursorConfig?.enabled) { const error = new Error( diff --git a/src/cursor/cursor-translator.ts b/src/cursor/cursor-translator.ts index 72112568..c3c078da 100644 --- a/src/cursor/cursor-translator.ts +++ b/src/cursor/cursor-translator.ts @@ -119,7 +119,8 @@ function escapeXml(text: string): string { } function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string { - const cleanResult = truncateToolResultText(escapeXml(sanitizeToolResultText(resultText))); + // Truncate raw tool output before XML escaping so the cap reflects original content. + const cleanResult = escapeXml(truncateToolResultText(sanitizeToolResultText(resultText))); return [ '', diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index ec7921e4..2acae204 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -396,6 +396,16 @@ function resolveBackend( }; } + if (profileType === 'cursor' || profileName === 'cursor') { + return { + backendId: null, + backendDisplayName: null, + resolutionSource: 'unresolved', + reason: + 'Cursor image analysis does not inherit the global fallback backend. Set image_analysis.profile_backends.cursor to an explicit provider to enable transformer-backed image analysis.', + }; + } + if (profileType === 'copilot' || profileName === 'copilot') { const backendId = normalizeImageAnalysisBackendId('ghcp', Object.keys(config.provider_models)); return { diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index f46f2319..84d57415 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -197,21 +197,14 @@ describe('ProfileDetector', () => { }); it('should detect cursor as a first-class runtime profile when enabled', () => { - const mockUnifiedConfig = { - version: 2, - cursor: { - enabled: true, - port: 20129, - auto_start: true, - ghost_mode: true, - model: 'gpt-5.3-codex', - }, - }; - const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); - const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( - mockUnifiedConfig as any - ); + const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ + enabled: true, + port: 20129, + auto_start: true, + ghost_mode: true, + model: 'gpt-5.3-codex', + }); try { const result = detector.detectProfileType('cursor'); @@ -220,32 +213,55 @@ describe('ProfileDetector', () => { expect(result.cursorConfig?.auto_start).toBe(true); } finally { isUnifiedModeSpy.mockRestore(); - loadUnifiedConfigSpy.mockRestore(); + getCursorConfigSpy.mockRestore(); } }); - it('should throw a helpful error when cursor profile is disabled', () => { - const mockUnifiedConfig = { - version: 2, - cursor: { - enabled: false, + it('should merge default cursor fields when enabled via partial unified config', () => { + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempDir; + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'cursor:', ' enabled: true'].join('\n') + ); + + try { + const localDetector = new ProfileDetector(); + const result = localDetector.detectProfileType('cursor'); + expect(result.type).toBe('cursor'); + expect(result.cursorConfig).toEqual({ + enabled: true, port: 20129, auto_start: false, ghost_mode: true, model: 'gpt-5.3-codex', - }, - }; + }); + } finally { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + } + }); + it('should throw a helpful error when cursor profile is disabled', () => { const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); - const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue( - mockUnifiedConfig as any - ); + const getCursorConfigSpy = spyOn(unifiedConfigLoader, 'getCursorConfig').mockReturnValue({ + enabled: false, + port: 20129, + auto_start: false, + ghost_mode: true, + model: 'gpt-5.3-codex', + }); try { expect(() => detector.detectProfileType('cursor')).toThrow(/Cursor profile is not enabled/); } finally { isUnifiedModeSpy.mockRestore(); - loadUnifiedConfigSpy.mockRestore(); + getCursorConfigSpy.mockRestore(); } }); }); diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts index e655c882..c0c2a1fc 100644 --- a/tests/unit/cursor/cursor-profile-executor.test.ts +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import { executeCursorProfile, generateCursorEnv, + resolveCursorImageAnalysisEnv, } from '../../../src/cursor/cursor-profile-executor'; import { saveCredentials } from '../../../src/cursor/cursor-auth'; import type { CursorConfig } from '../../../src/config/unified-config-types'; @@ -57,6 +58,14 @@ describe('cursor-profile-executor', () => { expect(env.CLAUDE_CONFIG_DIR).toBe('/tmp/claude-config'); }); + it('skips image-analysis provider routing for cursor unless explicitly mapped', async () => { + const { env, warning } = await resolveCursorImageAnalysisEnv(); + + expect(env.CCS_CURRENT_PROVIDER).toBe(''); + expect(env.CCS_IMAGE_ANALYSIS_SKIP).toBe('1'); + expect(warning).toBeNull(); + }); + it('fails fast when Cursor integration is disabled', async () => { const exitCode = await executeCursorProfile({ ...BASE_CONFIG, enabled: false }, []); expect(exitCode).toBe(1); diff --git a/tests/unit/cursor/cursor-protobuf.test.ts b/tests/unit/cursor/cursor-protobuf.test.ts index d3cdf709..c2bdbd3b 100644 --- a/tests/unit/cursor/cursor-protobuf.test.ts +++ b/tests/unit/cursor/cursor-protobuf.test.ts @@ -22,6 +22,25 @@ import { CursorExecutor } from '../../../src/cursor/cursor-executor'; import { WIRE_TYPE, FIELD } from '../../../src/cursor/cursor-protobuf-schema'; import { StreamingFrameParser, decompressPayload } from '../../../src/cursor/cursor-stream-parser'; +const MAX_TOOL_RESULT_CHARS = 12_000; + +function computeExpectedToolResultOmittedChars(textLength: number): number { + if (textLength <= MAX_TOOL_RESULT_CHARS) { + return 0; + } + + let omittedChars = textLength - MAX_TOOL_RESULT_CHARS; + while (true) { + const suffix = `\n[truncated ${omittedChars} chars]`; + const keepLength = Math.max(MAX_TOOL_RESULT_CHARS - suffix.length, 0); + const nextOmittedChars = textLength - keepLength; + if (nextOmittedChars === omittedChars) { + return omittedChars; + } + omittedChars = nextOmittedChars; + } +} + describe('Protobuf Encoding/Decoding', () => { describe('encodeVarint / decodeVarint round-trip', () => { it('should encode and decode 0', () => { @@ -555,6 +574,8 @@ describe('Message Translation', () => { it('should truncate oversized tool result payloads', () => { const oversizedResult = '&'.repeat(12_050); + const omittedChars = computeExpectedToolResultOmittedChars(oversizedResult.length); + const preservedChars = oversizedResult.length - omittedChars; const result = buildCursorRequest( 'gpt-4', { @@ -583,11 +604,11 @@ describe('Message Translation', () => { expect(result.messages).toHaveLength(2); expect(result.messages[1].content).toContain('[truncated '); - expect(result.messages[1].content.length).toBeLessThan(12_250); + expect(result.messages[1].content).toContain(`[truncated ${omittedChars} chars]`); const resultMatch = result.messages[1].content.match(/([\s\S]*)<\/result>/); expect(resultMatch).not.toBeNull(); - expect(resultMatch?.[1].length).toBeLessThanOrEqual(12_000); + expect((resultMatch?.[1].match(/&/g) ?? []).length).toBe(preservedChars); }); it('should mark unserializable structured tool results explicitly', () => { diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index 84d1ffdb..7525e7d3 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -44,6 +44,44 @@ describe('image-analysis-backend-resolver', () => { expect(status.resolutionSource).toBe('copilot-alias'); }); + it('does not route cursor image analysis through the fallback backend by default', () => { + const status = resolveImageAnalysisStatus( + { + profileName: 'cursor', + profileType: 'cursor', + }, + DEFAULT_IMAGE_ANALYSIS_CONFIG + ); + + expect(status.supported).toBe(false); + expect(status.backendId).toBeNull(); + expect(status.status).toBe('skipped'); + expect(status.resolutionSource).toBe('unresolved'); + expect(status.reason).toContain('profile_backends.cursor'); + }); + + it('allows cursor image analysis only when explicitly mapped to a backend', () => { + const config: ImageAnalysisConfig = { + ...DEFAULT_IMAGE_ANALYSIS_CONFIG, + profile_backends: { + cursor: 'ghcp', + }, + }; + + const status = resolveImageAnalysisStatus( + { + profileName: 'cursor', + profileType: 'cursor', + }, + config + ); + + expect(status.supported).toBe(true); + expect(status.backendId).toBe('ghcp'); + expect(status.status).toBe('mapped'); + expect(status.resolutionSource).toBe('profile-backend'); + }); + it('uses the fallback backend for an unmapped third-party settings profile', () => { const status = resolveImageAnalysisStatus( {