mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
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:
@@ -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 |
|
||||
@@ -185,7 +185,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 # Run Claude through Cursor local proxy
|
||||
ccs kiro # Kiro/AWS CodeWhisperer (OAuth)
|
||||
ccs ghcp # GitHub Copilot (OAuth device flow)
|
||||
ccs agy # Antigravity (OAuth)
|
||||
@@ -380,6 +380,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
|
||||
```
|
||||
|
||||
|
||||
@@ -43,13 +43,26 @@ ccs cursor auth --manual --token <token> --machine-id <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
|
||||
```
|
||||
|
||||
### 5) Stop daemon
|
||||
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 help
|
||||
```
|
||||
|
||||
### 6) Stop daemon
|
||||
|
||||
```bash
|
||||
ccs cursor stop
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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 || []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,3 +45,4 @@ export {
|
||||
|
||||
// Executor
|
||||
export { CursorExecutor } from './cursor-executor';
|
||||
export { executeCursorProfile, generateCursorEnv } from './cursor-profile-executor';
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -195,5 +195,74 @@ describe('ProfileDetector', () => {
|
||||
existsSyncSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect cursor as a first-class runtime profile when enabled', () => {
|
||||
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
|
||||
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');
|
||||
expect(result.type).toBe('cursor');
|
||||
expect(result.name).toBe('cursor');
|
||||
expect(result.cursorConfig?.auto_start).toBe(true);
|
||||
} finally {
|
||||
isUnifiedModeSpy.mockRestore();
|
||||
getCursorConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
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 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();
|
||||
getCursorConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,15 @@ describe('help command parity', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
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 <cmd>')).toBe(false);
|
||||
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 () => {
|
||||
const lines: string[] = [];
|
||||
await handleHelpCommand((line) => lines.push(line));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -268,9 +273,62 @@ 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', () => {
|
||||
it('shows help when invoked without an admin subcommand', 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 Integration'))).toBe(true);
|
||||
expect(logs.some((line) => line.includes('Usage: ccs cursor <subcommand>'))).toBe(true);
|
||||
} 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 +355,140 @@ 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: ${getCcsDir()}/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;
|
||||
}
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('shows bare ccs cursor as the runtime 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>'))).toBe(true);
|
||||
expect(
|
||||
logs.some((line) => line.includes('ccs cursor [claude args]'))
|
||||
).toBe(true);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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,
|
||||
resolveCursorImageAnalysisEnv,
|
||||
} 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('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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
decodeVarint,
|
||||
decodeField,
|
||||
decodeMessage,
|
||||
parseConnectRPCFrame,
|
||||
} from '../../../src/cursor/cursor-protobuf-decoder';
|
||||
import { buildCursorRequest } from '../../../src/cursor/cursor-translator';
|
||||
@@ -21,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', () => {
|
||||
@@ -222,7 +242,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',
|
||||
{
|
||||
@@ -251,11 +271,424 @@ describe('Message Translation', () => {
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.messages).toHaveLength(3);
|
||||
expect(result.messages[1].role).toBe('user');
|
||||
expect(result.messages[1].content).toContain('<tool_result>');
|
||||
expect(result.messages[1].content).toContain('<tool_name>get_weather</tool_name>');
|
||||
expect(result.messages[1].content).toContain('<tool_call_id>call_123</tool_call_id>');
|
||||
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('<tool_call_id>call_one</tool_call_id>'),
|
||||
});
|
||||
expect(result.messages[2]).toEqual({
|
||||
role: 'user',
|
||||
content: expect.stringContaining('<tool_call_id>call_two</tool_call_id>'),
|
||||
});
|
||||
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',
|
||||
{
|
||||
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('<tool_name>search_docs</tool_name>');
|
||||
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('<tool_name>search_docs</tool_name>');
|
||||
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('<result>first line\nsecond line</result>');
|
||||
});
|
||||
|
||||
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 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('<tool_call_id>call_bad</tool_call_id>');
|
||||
});
|
||||
|
||||
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 omittedChars = computeExpectedToolResultOmittedChars(oversizedResult.length);
|
||||
const preservedChars = oversizedResult.length - omittedChars;
|
||||
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).toContain(`[truncated ${omittedChars} chars]`);
|
||||
|
||||
const resultMatch = result.messages[1].content.match(/<result>([\s\S]*)<\/result>/);
|
||||
expect(resultMatch).not.toBeNull();
|
||||
expect((resultMatch?.[1].match(/&/g) ?? []).length).toBe(preservedChars);
|
||||
});
|
||||
|
||||
it('should mark unserializable structured tool results explicitly', () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
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 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', () => {
|
||||
@@ -344,6 +777,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('<tool_result>'))).toBe(true);
|
||||
expect(contents.some((content) => content.includes('<tool_name>read_file</tool_name>'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
contents.some((content) => content.includes('<tool_call_id>call_wire</tool_call_id>'))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user