Merge pull request #891 from kaitranntt/kai/fix/855-cursor-dual-mode-runtime-dev-refresh

fix(cursor): make Cursor a first-class runtime profile and harden integration
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-03 00:03:45 -04:00
committed by GitHub
24 changed files with 1712 additions and 142 deletions
+38 -1
View File
@@ -16,11 +16,12 @@ import { Config, Settings, ProfileMetadata } from '../types';
import {
UnifiedConfig,
CopilotConfig,
CursorConfig,
CLIProxyVariantConfig,
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';
@@ -49,6 +50,8 @@ export interface ProfileDetectionResult {
env?: Record<string, string>;
/** 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,34 @@ class ProfileDetector {
};
}
// Priority 0.75: Check Cursor profile - local Cursor daemon runtime
if (profileName === 'cursor') {
const cursorConfig = getCursorConfig();
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 +484,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) {
+48
View File
@@ -457,6 +457,20 @@ async function main(): Promise<void> {
}
}
// 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
@@ -894,6 +908,40 @@ async function main(): Promise<void> {
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
const imageAnalysisMcpReady =
+29 -7
View File
@@ -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 {
@@ -12,7 +13,7 @@ export function renderCursorHelp(): number {
printLines([
'Cursor IDE Integration',
'',
'Usage: ccs cursor <subcommand> [options]',
'Usage: ccs cursor <subcommand>',
'',
'Subcommands:',
' auth Import Cursor IDE authentication token',
@@ -24,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 <t> --machine-id <id>',
@@ -32,6 +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 "task" # Run Claude through Cursor',
' 5. ccs cursor status # Inspect auth/daemon wiring',
'',
'Or use the web UI: ccs config -> Cursor page',
'',
@@ -45,6 +51,11 @@ export function renderCursorStatus(
authStatus: CursorAuthStatus,
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;
console.log('Cursor IDE Status');
console.log('─────────────────');
console.log('');
@@ -77,15 +88,25 @@ 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: ${dirDisplay}/cursor.settings.json`);
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;
}
console.log('');
console.log('Next steps:');
if (!cursorConfig.enabled) {
console.log(' - Enable: ccs cursor enable');
@@ -96,6 +117,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 {
+8 -1
View File
@@ -1,7 +1,7 @@
/**
* Cursor CLI Command
*
* Handles `ccs cursor <subcommand>` commands.
* Handles `ccs cursor [subcommand]` commands.
*/
import {
@@ -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,6 +62,7 @@ export async function handleCursorCommand(args: string[]): Promise<number> {
case 'disable':
return handleDisable();
case undefined:
return handleHelp();
case 'help':
case '--help':
case '-h':
+3 -4
View File
@@ -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 <cmd>', 'Use Cursor IDE integration'],
['ccs cursor', 'Run Claude via Cursor local proxy'],
['ccs cursor auth', 'Import Cursor token'],
['ccs cursor auth --manual --token <t> --machine-id <id>', 'Manual token import'],
['ccs cursor status', 'Show connection status'],
-7
View File
@@ -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<boolean> {
+190
View File
@@ -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<string, string>;
warning: string | null;
}
export function generateCursorEnv(
config: CursorConfig,
claudeConfigDir?: string
): Record<string, string> {
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<CursorImageAnalysisResolution> {
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<number> {
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);
});
});
}
+368 -84
View File
@@ -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<string, unknown>;
}
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,375 @@ 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 = '{}';
const TOOL_CALL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
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'], separator = ''): string {
if (typeof content === 'string') {
return content;
}
const parts: string[] = [];
for (const part of content) {
if (isTextPart(part) && part.text) {
parts.push(part.text);
}
}
return parts.join(separator);
}
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 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 {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function buildToolResultBlock(toolName: string, toolCallId: string, resultText: string): string {
// Truncate raw tool output before XML escaping so the cap reflects original content.
const cleanResult = escapeXml(truncateToolResultText(sanitizeToolResultText(resultText)));
return [
'<tool_result>',
`<tool_name>${escapeXml(toolName || 'tool')}</tool_name>`,
`<tool_call_id>${escapeXml(toolCallId)}</tool_call_id>`,
`<result>${cleanResult}</result>`,
'</tool_result>',
].join('\n');
}
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 '';
}
if (typeof content === 'string') {
return content;
}
if (Array.isArray(content)) {
return content
.filter(isTextPart)
.map((part) => part.text || '')
.join('\n');
}
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 {
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<OpenAIMessage['tool_calls']>,
messageIndex: number
): NonNullable<OpenAIMessage['tool_calls']> {
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(
toolCallMetaMap: Map<string, { name: string }>,
toolCalls: NonNullable<OpenAIMessage['tool_calls']>
): 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<string, { name: string }>,
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<OpenAIMessage['tool_calls']> {
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<OpenAIMessage['tool_calls']>,
secondaryToolCalls: NonNullable<OpenAIMessage['tool_calls']>
): NonNullable<OpenAIMessage['tool_calls']> {
const merged: NonNullable<OpenAIMessage['tool_calls']> = [];
const seenIds = new Set<string>();
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, { name: string }>,
messageIndex: number
): string {
if (typeof content === 'string') {
return content;
}
const parts: string[] = [];
let textBuffer = '';
for (let partIndex = 0; partIndex < content.length; partIndex++) {
const part = content[partIndex];
if (isTextPart(part) && part.text) {
textBuffer += part.text;
continue;
}
if (!isToolResultPart(part)) {
continue;
}
if (textBuffer) {
parts.push(textBuffer);
textBuffer = '';
}
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';
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<string, { name: string }>();
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 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;
const toolName = msg.name || rememberedToolName || 'tool';
const toolName = msg.name || 'tool';
const toolCallId = msg.tool_call_id || '';
// 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, '\n')),
});
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 normalizedToolCalls = normalizeAssistantToolCalls(msg.tool_calls || [], messageIndex);
const assistantToolCalls = mergeAssistantToolCalls(
normalizedToolCalls,
extractToolCallsFromContent(msg.content, messageIndex)
);
if (normalizedToolCalls.length > 0) {
rememberToolCallMeta(toolCallMetaMap, normalizedToolCalls);
}
}
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, messageIndex);
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 +423,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 +432,8 @@ export function buildCursorRequest(
messages: CursorMessage[];
tools?: CursorTool[];
} {
const messages = convertMessages(body.messages || []);
return {
...body,
messages,
messages: convertMessages(body.messages || []),
};
}
+1
View File
@@ -45,3 +45,4 @@ export {
// Executor
export { CursorExecutor } from './cursor-executor';
export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor';
+46 -26
View File
@@ -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.`);
}
@@ -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 };
}
+1 -1
View File
@@ -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';
+9
View File
@@ -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.
*/
@@ -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 {