Merge pull request #568 from kaitranntt/fix/555-cursor-e2e-recovery

feat(cursor): recover lost Cursor dashboard and model-mapping work
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-02-14 17:50:57 +07:00
commit 16aeddfb11
11 files changed
+1056 -242

No files matched your search

+6
View File
@@ -306,6 +306,10 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
model: partial.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model: partial.cursor?.opus_model,
sonnet_model: partial.cursor?.sonnet_model,
haiku_model: partial.cursor?.haiku_model,
},
// Global env - injected into all non-Claude subscription profiles
global_env: {
@@ -603,6 +607,8 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('# port: Port for cursor proxy daemon (default: 20129)');
lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)');
lines.push('# ghost_mode: Disable telemetry for privacy (default: true)');
lines.push('# model: Default model ID (used for ANTHROPIC_MODEL)');
lines.push('# opus_model/sonnet_model/haiku_model: Optional tier model mapping');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
+9
View File
@@ -304,6 +304,14 @@ export interface CursorConfig {
auto_start: boolean;
/** Enable ghost mode to disable telemetry (default: true) */
ghost_mode: boolean;
/** Default model ID used by Cursor integration */
model: string;
/** Optional tier mapping for Claude-compatible model routing */
opus_model?: string;
/** Optional tier mapping for Claude-compatible model routing */
sonnet_model?: string;
/** Optional tier mapping for Claude-compatible model routing */
haiku_model?: string;
}
/**
@@ -723,6 +731,7 @@ export const DEFAULT_CURSOR_CONFIG: CursorConfig = {
port: 20129,
auto_start: false,
ghost_mode: true,
model: 'gpt-5.3-codex',
};
/**
+106 -23
View File
@@ -13,61 +13,143 @@ import { isDaemonRunning } from './cursor-daemon';
export const DEFAULT_CURSOR_PORT = 20129;
/** Default model ID */
export const DEFAULT_CURSOR_MODEL = 'gpt-4.1';
export const DEFAULT_CURSOR_MODEL = 'gpt-5.3-codex';
/**
* Default models available through Cursor IDE.
* Used as fallback when daemon is not reachable.
* Source: Cursor IDE supported models (Feb 2025)
* Source: Cursor docs model catalog (Feb 2026)
*/
export const DEFAULT_CURSOR_MODELS: CursorModel[] = [
// Anthropic Models
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
id: 'claude-4.6-opus',
name: 'Claude 4.6 Opus',
provider: 'anthropic',
},
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
id: 'claude-4.6-opus-fast-mode',
name: 'Claude 4.6 Opus (Fast mode)',
provider: 'anthropic',
},
{
id: 'claude-opus-4',
name: 'Claude Opus 4',
id: 'claude-4.5-sonnet',
name: 'Claude 4.5 Sonnet',
provider: 'anthropic',
},
{
id: 'claude-4.5-opus',
name: 'Claude 4.5 Opus',
provider: 'anthropic',
},
{
id: 'claude-4.5-haiku',
name: 'Claude 4.5 Haiku',
provider: 'anthropic',
},
{
id: 'claude-4-sonnet',
name: 'Claude 4 Sonnet',
provider: 'anthropic',
},
{
id: 'claude-4-sonnet-1m',
name: 'Claude 4 Sonnet 1M',
provider: 'anthropic',
},
// Cursor Models
{
id: 'composer-1.5',
name: 'Composer 1.5',
provider: 'cursor',
},
{
id: 'composer-1',
name: 'Composer 1',
provider: 'cursor',
},
// OpenAI Models
{
id: 'gpt-4.1',
name: 'GPT-4.1',
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
provider: 'openai',
isDefault: true,
},
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
provider: 'openai',
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
provider: 'openai',
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
provider: 'openai',
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
provider: 'openai',
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
provider: 'openai',
},
{
id: 'gpt-5-codex',
name: 'GPT-5-Codex',
provider: 'openai',
},
{
id: 'gpt-5',
name: 'GPT-5',
provider: 'openai',
},
{
id: 'gpt-5-fast',
name: 'GPT-5 Fast',
provider: 'openai',
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
provider: 'openai',
},
{
id: 'o3-mini',
name: 'O3 Mini',
provider: 'openai',
},
// Google Models
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
id: 'gemini-3-pro',
name: 'Gemini 3 Pro',
provider: 'google',
},
{
id: 'gemini-3-pro-image-preview',
name: 'Gemini 3 Pro Image Preview',
provider: 'google',
},
{
id: 'gemini-3-flash',
name: 'Gemini 3 Flash',
provider: 'google',
},
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash',
provider: 'google',
},
// Cursor Custom Models
// xAI Models
{
id: 'cursor-small',
name: 'Cursor Small',
provider: 'cursor',
id: 'grok-code',
name: 'Grok Code',
provider: 'xai',
},
];
@@ -154,7 +236,7 @@ export async function getAvailableModels(port: number): Promise<CursorModel[]> {
/**
* Get the default model.
* Uses gpt-4.1 as it's commonly available.
* Uses GPT-5.3 Codex as default.
*/
export function getDefaultModel(): string {
return DEFAULT_CURSOR_MODEL;
@@ -167,7 +249,8 @@ export function detectProvider(modelId: string): string {
if (modelId.includes('claude')) return 'anthropic';
if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai';
if (modelId.includes('gemini')) return 'google';
if (modelId.includes('cursor')) return 'cursor';
if (modelId.includes('cursor') || modelId.includes('composer')) return 'cursor';
if (modelId.includes('grok')) return 'xai';
return 'unknown';
}
+1 -2
View File
@@ -7,7 +7,6 @@ import { Router } from 'express';
import {
getDaemonStatus,
getAvailableModels,
getDefaultModel,
startDaemon,
stopDaemon,
checkAuthStatus,
@@ -150,7 +149,7 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
try {
const cursorConfig = getCursorConfig();
const models = await getAvailableModels(cursorConfig.port);
res.json({ models, current: getDefaultModel() });
res.json({ models, current: cursorConfig.model });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
+132 -18
View File
@@ -5,6 +5,7 @@
import type { Request, Response } from 'express';
import { Router } from 'express';
import * as fs from 'fs';
import { promises as fsp } from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { DEFAULT_CURSOR_CONFIG } from '../../config/unified-config-types';
@@ -13,6 +14,7 @@ import {
saveUnifiedConfig,
getCursorConfig,
} from '../../config/unified-config-loader';
import type { CursorConfig } from '../../config/unified-config-types';
const router = Router();
@@ -34,8 +36,76 @@ function parseLocalCursorPort(settings: unknown): number | null {
}
}
function parseRequiredModel(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function parseOptionalModel(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function getDefaultCursorSettings(cursorConfig: CursorConfig): { env: Record<string, string> } {
const model = cursorConfig.model || DEFAULT_CURSOR_CONFIG.model;
return {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
ANTHROPIC_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL: cursorConfig.opus_model || model,
ANTHROPIC_DEFAULT_SONNET_MODEL: cursorConfig.sonnet_model || model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: cursorConfig.haiku_model || model,
},
};
}
async function syncRawSettingsFromCursorConfig(cursorConfig: CursorConfig): Promise<void> {
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
let settings: Record<string, unknown> = {};
try {
const parsed = JSON.parse(await fsp.readFile(settingsPath, 'utf-8'));
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
settings = parsed as Record<string, unknown>;
}
} catch {
settings = {};
}
const envSource = settings.env;
const env =
typeof envSource === 'object' && envSource !== null && !Array.isArray(envSource)
? { ...(envSource as Record<string, string>) }
: {};
const model = cursorConfig.model || DEFAULT_CURSOR_CONFIG.model;
const localPort = parseLocalCursorPort({ env });
if (!env.ANTHROPIC_BASE_URL || localPort !== null) {
env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${cursorConfig.port}`;
}
if (!env.ANTHROPIC_AUTH_TOKEN) {
env.ANTHROPIC_AUTH_TOKEN = 'cursor-managed';
}
env.ANTHROPIC_MODEL = model;
env.ANTHROPIC_DEFAULT_OPUS_MODEL = cursorConfig.opus_model || model;
env.ANTHROPIC_DEFAULT_SONNET_MODEL = cursorConfig.sonnet_model || model;
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = cursorConfig.haiku_model || model;
const nextSettings = {
...settings,
env,
};
const tempPath = settingsPath + '.tmp';
await fsp.writeFile(tempPath, JSON.stringify(nextSettings, null, 2) + '\n');
await fsp.rename(tempPath, settingsPath);
}
/**
* GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode)
* GET /api/cursor/settings - Get cursor config
*/
router.get('/', (_req: Request, res: Response): void => {
try {
@@ -49,7 +119,7 @@ router.get('/', (_req: Request, res: Response): void => {
/**
* PUT /api/cursor/settings - Update cursor config
*/
router.put('/', (req: Request, res: Response): void => {
router.put('/', async (req: Request, res: Response): Promise<void> => {
try {
const updates = req.body;
@@ -82,8 +152,40 @@ router.put('/', (req: Request, res: Response): void => {
res.status(400).json({ error: 'ghost_mode must be a boolean' });
return;
}
if ('model' in updates && !parseRequiredModel(updates.model)) {
res.status(400).json({ error: 'model must be a non-empty string' });
return;
}
if (
'opus_model' in updates &&
updates.opus_model !== undefined &&
updates.opus_model !== null &&
typeof updates.opus_model !== 'string'
) {
res.status(400).json({ error: 'opus_model must be a string' });
return;
}
if (
'sonnet_model' in updates &&
updates.sonnet_model !== undefined &&
updates.sonnet_model !== null &&
typeof updates.sonnet_model !== 'string'
) {
res.status(400).json({ error: 'sonnet_model must be a string' });
return;
}
if (
'haiku_model' in updates &&
updates.haiku_model !== undefined &&
updates.haiku_model !== null &&
typeof updates.haiku_model !== 'string'
) {
res.status(400).json({ error: 'haiku_model must be a string' });
return;
}
const config = loadOrCreateUnifiedConfig();
const normalizedModel = parseRequiredModel(updates.model);
// Merge updates with existing config
// Only known fields are merged — unknown properties are ignored
@@ -94,9 +196,23 @@ router.put('/', (req: Request, res: Response): void => {
updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode:
updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
model: normalizedModel ?? config.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model:
'opus_model' in updates
? parseOptionalModel(updates.opus_model)
: config.cursor?.opus_model,
sonnet_model:
'sonnet_model' in updates
? parseOptionalModel(updates.sonnet_model)
: config.cursor?.sonnet_model,
haiku_model:
'haiku_model' in updates
? parseOptionalModel(updates.haiku_model)
: config.cursor?.haiku_model,
};
saveUnifiedConfig(config);
await syncRawSettingsFromCursorConfig(config.cursor);
res.json({ success: true, cursor: config.cursor });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
@@ -114,14 +230,7 @@ router.get('/raw', (_req: Request, res: Response): void => {
// If file doesn't exist, return default structure
if (!fs.existsSync(settingsPath)) {
// Create settings structure matching Cursor pattern
// Use 127.0.0.1 instead of localhost for more reliable local connections
const defaultSettings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
},
};
const defaultSettings = getDefaultCursorSettings(cursorConfig);
res.json({
settings: defaultSettings,
@@ -186,14 +295,19 @@ router.put('/raw', (req: Request, res: Response): void => {
// Keep unified config aligned with raw settings edits (parity with Copilot raw editor).
const parsedPort = parseLocalCursorPort(settings);
if (parsedPort) {
const config = loadOrCreateUnifiedConfig();
config.cursor = {
...(config.cursor ?? DEFAULT_CURSOR_CONFIG),
port: parsedPort,
};
saveUnifiedConfig(config);
}
const config = loadOrCreateUnifiedConfig();
const env = (settings as { env?: Record<string, unknown> }).env ?? {};
const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model;
config.cursor = {
...(config.cursor ?? DEFAULT_CURSOR_CONFIG),
...(parsedPort !== null ? { port: parsedPort } : {}),
...(model ? { model } : {}),
opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL),
sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL),
haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL),
};
saveUnifiedConfig(config);
const stat = fs.statSync(settingsPath);
res.json({ success: true, mtime: stat.mtimeMs });