From 36e5fa4f4ca61ca4655da47603bfb88519287300 Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Sat, 8 Aug 2026 11:08:18 +0800 Subject: [PATCH] feat(ai): add Cursor CLI provider --- apps/desktop/public/icons/ai/cursor.svg | 1 + .../editor/EditorSettingsDialog.vue | 24 +- .../src/components/icons/AiProviderLogo.vue | 2 +- .../__tests__/useAiModelCatalog.spec.ts | 24 + .../src/composables/useAiModelCatalog.ts | 2 + apps/desktop/src/i18n/backend-errors.ts | 9 + apps/desktop/src/i18n/locales/en.ts | 11 +- apps/desktop/src/i18n/locales/es.ts | 11 +- apps/desktop/src/i18n/locales/it.ts | 11 +- apps/desktop/src/i18n/locales/ja.ts | 11 +- apps/desktop/src/i18n/locales/ko.ts | 9 + apps/desktop/src/i18n/locales/pt-BR.ts | 11 +- apps/desktop/src/i18n/locales/zh-CN.ts | 11 +- apps/desktop/src/i18n/locales/zh-TW.ts | 11 +- .../__tests__/ai/aiConfigCandidates.spec.ts | 2 +- .../lib/ai/__tests__/aiConfigOrdering.spec.ts | 3 +- apps/desktop/src/lib/ai/aiConfigCandidates.ts | 2 +- .../stores/__tests__/settingsStore.spec.ts | 16 + apps/desktop/src/stores/settingsStore.ts | 14 +- apps/desktop/src/types/ai.ts | 4 +- crates/dbx-core/src/agent_loop.rs | 8 + crates/dbx-core/src/ai.rs | 104 ++- crates/dbx-core/src/ai_claude_code_cli.rs | 2 + crates/dbx-core/src/ai_cli_agent.rs | 210 ++++++ crates/dbx-core/src/ai_codex_cli.rs | 2 + crates/dbx-core/src/ai_cursor_cli.rs | 598 ++++++++++++++++++ crates/dbx-core/src/ai_effort.rs | 13 +- crates/dbx-core/src/ai_model_filter.rs | 1 + crates/dbx-core/src/ai_opencode_cli.rs | 2 + crates/dbx-core/src/ai_pi_agent_cli.rs | 2 + crates/dbx-core/src/cloud_sync.rs | 26 +- crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/storage.rs | 29 + crates/dbx-web/src/routes/ai.rs | 14 +- packages/app-tests/settingsStore.test.ts | 5 + src-tauri/src/commands/ai.rs | 3 + 36 files changed, 1173 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/public/icons/ai/cursor.svg create mode 100644 crates/dbx-core/src/ai_cursor_cli.rs diff --git a/apps/desktop/public/icons/ai/cursor.svg b/apps/desktop/public/icons/ai/cursor.svg new file mode 100644 index 000000000..7c7540bc6 --- /dev/null +++ b/apps/desktop/public/icons/ai/cursor.svg @@ -0,0 +1 @@ +Cursor diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 26a882614..91859353e 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -2553,7 +2553,7 @@ async function saveMaxAgentTurnsSetting() { } // Max Retries (global). Default 2, range 0–10. Applied to all API-backed -// AI providers. CLI providers (Claude Code, Codex, OpenCode, Pi) are unaffected +// AI providers. CLI providers are unaffected // because they use their own retry logic. const editMaxRetries = ref(undefined); const maxRetriesSaving = ref(false); @@ -2604,8 +2604,9 @@ function normalizeMaxRetries(value: number | undefined): number { const aiDeleteConfirmOpen = ref(false); const aiDeleteConfigId = ref(null); -const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli"]); +const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli"]); const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]); +const CURSOR_CONTROL_ENV = new Set(["CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR"]); const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider))); const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]); @@ -2629,6 +2630,8 @@ const aiEditPiAgentCliPath = ref(""); const aiEditPiAgentCliEnvRows = ref([]); const aiEditOpenCodeCliPath = ref(""); const aiEditOpenCodeCliEnvRows = ref([]); +const aiEditCursorCliPath = ref(""); +const aiEditCursorCliEnvRows = ref([]); const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages"); @@ -2660,18 +2663,21 @@ const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli"); const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli"); const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli"); const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli"); +const aiIsCursorCli = computed(() => aiEditProvider.value === "cursor-cli"); const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value)); const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label); const aiCliCommandName = computed(() => { if (aiIsClaudeCodeCli.value) return "claude"; if (aiIsPiAgentCli.value) return "pi"; if (aiIsOpenCodeCli.value) return "opencode"; + if (aiIsCursorCli.value) return "agent"; return "codex"; }); const aiCliLoginCommand = computed(() => { if (aiIsClaudeCodeCli.value) return "claude auth login"; if (aiIsPiAgentCli.value) return "pi"; if (aiIsOpenCodeCli.value) return "opencode auth login"; + if (aiIsCursorCli.value) return "agent login"; return "codex login"; }); const aiEditCliPath = computed({ @@ -2679,6 +2685,7 @@ const aiEditCliPath = computed({ if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliPath.value; if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value; if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value; + if (aiIsCursorCli.value) return aiEditCursorCliPath.value; return aiEditCodexCliPath.value; }, set: (value: string) => { @@ -2688,6 +2695,8 @@ const aiEditCliPath = computed({ aiEditPiAgentCliPath.value = value; } else if (aiIsOpenCodeCli.value) { aiEditOpenCodeCliPath.value = value; + } else if (aiIsCursorCli.value) { + aiEditCursorCliPath.value = value; } else { aiEditCodexCliPath.value = value; } @@ -2697,6 +2706,7 @@ const aiEditCliEnvRows = computed(() => { if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliEnvRows.value; if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value; if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value; + if (aiIsCursorCli.value) return aiEditCursorCliEnvRows.value; return aiEditCodexCliEnvRows.value; }); watch(aiIsCliProvider, (isCliProvider) => { @@ -2773,7 +2783,7 @@ function cliEnvValidationError(): string { const key = row.key.trim(); if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key }); const upper = key.toUpperCase(); - if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_")) || (aiIsOpenCodeCli.value && OPENCODE_CONTROL_ENV.has(upper))) { + if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_")) || (aiIsOpenCodeCli.value && OPENCODE_CONTROL_ENV.has(upper)) || (aiIsCursorCli.value && CURSOR_CONTROL_ENV.has(upper))) { return t("ai.cliEnvReservedName", { name: key }); } } @@ -2791,6 +2801,8 @@ function removeCliEnvRow(id: string) { aiEditPiAgentCliEnvRows.value = aiEditPiAgentCliEnvRows.value.filter((row) => row.id !== id); } else if (aiIsOpenCodeCli.value) { aiEditOpenCodeCliEnvRows.value = aiEditOpenCodeCliEnvRows.value.filter((row) => row.id !== id); + } else if (aiIsCursorCli.value) { + aiEditCursorCliEnvRows.value = aiEditCursorCliEnvRows.value.filter((row) => row.id !== id); } else { aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); } @@ -2821,6 +2833,8 @@ function currentAiEditConfig() { piAgentCliEnv: aiIsPiAgentCli.value ? cliEnvFromRows(aiEditPiAgentCliEnvRows.value) : {}, opencodeCliPath: aiEditOpenCodeCliPath.value.trim() || undefined, opencodeCliEnv: aiIsOpenCodeCli.value ? cliEnvFromRows(aiEditOpenCodeCliEnvRows.value) : {}, + cursorCliPath: aiEditCursorCliPath.value.trim() || undefined, + cursorCliEnv: aiIsCursorCli.value ? cliEnvFromRows(aiEditCursorCliEnvRows.value) : {}, }; } @@ -2892,6 +2906,8 @@ function aiEnterEditMode(configId?: string) { aiEditPiAgentCliEnvRows.value = aiEnvRowsFromConfig(config.piAgentCliEnv); aiEditOpenCodeCliPath.value = config.opencodeCliPath ?? ""; aiEditOpenCodeCliEnvRows.value = aiEnvRowsFromConfig(config.opencodeCliEnv); + aiEditCursorCliPath.value = config.cursorCliPath ?? ""; + aiEditCursorCliEnvRows.value = aiEnvRowsFromConfig(config.cursorCliEnv); } } else { aiEditConfigName.value = ""; @@ -2915,6 +2931,8 @@ function aiEnterEditMode(configId?: string) { aiEditPiAgentCliEnvRows.value = []; aiEditOpenCodeCliPath.value = ""; aiEditOpenCodeCliEnvRows.value = []; + aiEditCursorCliPath.value = ""; + aiEditCursorCliEnvRows.value = []; } } diff --git a/apps/desktop/src/components/icons/AiProviderLogo.vue b/apps/desktop/src/components/icons/AiProviderLogo.vue index 148768ecc..d18418772 100644 --- a/apps/desktop/src/components/icons/AiProviderLogo.vue +++ b/apps/desktop/src/components/icons/AiProviderLogo.vue @@ -21,7 +21,7 @@ watch( }, ); -const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli"); +const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli" || props.provider === "cursor-cli"); const localIconUrl = computed(() => { if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg"); return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : ""; diff --git a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts index bec4eb42e..5f9a95a16 100644 --- a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts +++ b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts @@ -118,6 +118,30 @@ describe("useAiModelCatalog", () => { expect(apiMock.aiListModels).toHaveBeenCalledTimes(2); }); + it("tracks Cursor executable and environment changes without depending on environment key order", async () => { + const initial: AiConfigItem = { + ...config(), + provider: "cursor-cli", + endpoint: "", + apiKey: "", + model: "default", + cursorCliPath: "~/.local/bin/agent", + cursorCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", NO_PROXY: "localhost" }, + }; + apiMock.aiListModels.mockResolvedValueOnce([{ id: "first" }]).mockResolvedValueOnce([{ id: "second" }]); + + await expect(catalog.loadModels(initial)).resolves.toEqual([{ id: "first" }]); + await expect( + catalog.loadModels({ + ...initial, + cursorCliEnv: { NO_PROXY: "localhost", HTTPS_PROXY: "http://127.0.0.1:7890" }, + }), + ).resolves.toEqual([{ id: "first" }]); + await expect(catalog.loadModels({ ...initial, cursorCliPath: "/usr/local/bin/agent" })).resolves.toEqual([{ id: "second" }]); + + expect(apiMock.aiListModels).toHaveBeenCalledTimes(2); + }); + it("does not let a stale request overwrite a newer provider catalog", async () => { let resolveInitial: ((models: { id: string }[]) => void) | undefined; apiMock.aiListModels diff --git a/apps/desktop/src/composables/useAiModelCatalog.ts b/apps/desktop/src/composables/useAiModelCatalog.ts index 2aa0a7d11..1db9ed6da 100644 --- a/apps/desktop/src/composables/useAiModelCatalog.ts +++ b/apps/desktop/src/composables/useAiModelCatalog.ts @@ -58,6 +58,7 @@ function configSignature(config: AiConfigItem): string { claudeCodeCliPath: config.claudeCodeCliPath ?? null, piAgentCliPath: config.piAgentCliPath ?? null, opencodeCliPath: config.opencodeCliPath ?? null, + cursorCliPath: config.cursorCliPath ?? null, connectionFingerprint: fingerprint( JSON.stringify({ apiKey: config.apiKey, @@ -67,6 +68,7 @@ function configSignature(config: AiConfigItem): string { claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv), piAgentCliEnv: sortedRecord(config.piAgentCliEnv), opencodeCliEnv: sortedRecord(config.opencodeCliEnv), + cursorCliEnv: sortedRecord(config.cursorCliEnv), }), ), }); diff --git a/apps/desktop/src/i18n/backend-errors.ts b/apps/desktop/src/i18n/backend-errors.ts index f3f299d91..f457f58d8 100644 --- a/apps/desktop/src/i18n/backend-errors.ts +++ b/apps/desktop/src/i18n/backend-errors.ts @@ -42,6 +42,15 @@ const taggedAiCliErrorKeys: Record = { openCodeTimeout: "ai.cliErrors.openCodeTimeout", openCodeProtocolError: "ai.cliErrors.openCodeProtocolError", openCodeRunFailed: "ai.cliErrors.openCodeRunFailed", + cursorNotInstalled: "ai.cliErrors.cursorNotInstalled", + cursorCliPathInvalid: "ai.cliErrors.cursorCliPathInvalid", + cursorEnvInvalid: "ai.cliErrors.cursorEnvInvalid", + cursorEnvReserved: "ai.cliErrors.cursorEnvReserved", + cursorNotAuthenticated: "ai.cliErrors.cursorNotAuthenticated", + cursorMcpStartupFailed: "ai.cliErrors.cursorMcpStartupFailed", + cursorTimeout: "ai.cliErrors.cursorTimeout", + cursorProtocolError: "ai.cliErrors.cursorProtocolError", + cursorRunFailed: "ai.cliErrors.cursorRunFailed", }; const exactMessageKeys: Record = { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 63275c44b..6b6303c08 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2059,7 +2059,7 @@ export default { maxRetries: "Max Retries", maxRetriesHint: "Retry on rate limits, timeouts, and temporary network errors. 0 = never, max 10.", maxRetriesGlobal: "Max Retries", - maxRetriesGlobalDescription: "Number of automatic retries on transient API errors (rate limits, timeouts, network blips). Applies to all API-backed AI providers. CLI providers (claude-code, codex, pi) are unaffected.", + maxRetriesGlobalDescription: "Number of automatic retries on transient API errors (rate limits, timeouts, network blips). Applies to all API-backed AI providers. CLI providers are unaffected.", maxRetriesRange: "{min}–{max}, default {default}", maxRetriesSaved: "Max retries saved", codexCliPath: "Codex CLI Path", @@ -2130,6 +2130,15 @@ export default { openCodeTimeout: "OpenCode CLI did not respond before the operation timed out.", openCodeProtocolError: "OpenCode CLI returned an invalid JSON event stream. Check the OpenCode version and diagnostics below.", openCodeRunFailed: "OpenCode CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", + cursorNotInstalled: "Cursor CLI was not found. Install Cursor CLI or set its executable path in Settings > AI.", + cursorCliPathInvalid: "The Cursor CLI path is invalid. Select only the agent executable and configure environment variables separately.", + cursorEnvInvalid: "A Cursor CLI environment variable name is invalid. Use names such as HTTPS_PROXY.", + cursorEnvReserved: "A Cursor or DBX-managed environment variable was overridden. Remove CURSOR_CONFIG_DIR, CURSOR_DATA_DIR, and DBX_MCP_* variables from the provider configuration.", + cursorNotAuthenticated: "Cursor CLI is not authenticated. Run `agent login` in a terminal and try again.", + cursorMcpStartupFailed: "Cursor could not start the scoped DBX MCP server. Check Settings > MCP and the diagnostics below.", + cursorTimeout: "Cursor CLI did not respond before the operation timed out.", + cursorProtocolError: "Cursor CLI returned an invalid JSON event stream. Check the Cursor CLI version and diagnostics below.", + cursorRunFailed: "Cursor CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", }, actions: { general: "General", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f82c62f7f..b164610ca 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1969,6 +1969,15 @@ export default withEnglishFallback({ openCodeTimeout: "OpenCode CLI no respondió antes de que finalizara el tiempo de espera.", openCodeProtocolError: "OpenCode CLI devolvió un flujo de eventos JSON no válido. Revisa la versión de OpenCode y los detalles siguientes.", openCodeRunFailed: "OpenCode CLI terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.", + cursorNotInstalled: "No se encontró Cursor CLI. Instálalo o configura la ruta del ejecutable en Ajustes > IA.", + cursorCliPathInvalid: "La ruta de Cursor CLI no es válida. Selecciona solo el ejecutable agent y configura las variables de entorno por separado.", + cursorEnvInvalid: "El nombre de una variable de entorno de Cursor CLI no es válido. Usa nombres como HTTPS_PROXY.", + cursorEnvReserved: "Se intentó sobrescribir una variable administrada por Cursor o DBX. Elimina CURSOR_CONFIG_DIR, CURSOR_DATA_DIR y DBX_MCP_* de la configuración.", + cursorNotAuthenticated: "Cursor CLI no está autenticado. Ejecuta `agent login` en una terminal y vuelve a intentarlo.", + cursorMcpStartupFailed: "Cursor no pudo iniciar el servidor DBX MCP con ámbito restringido. Revisa Ajustes > MCP y los detalles siguientes.", + cursorTimeout: "Cursor CLI no respondió antes de que finalizara el tiempo de espera.", + cursorProtocolError: "Cursor CLI devolvió un flujo de eventos JSON no válido. Revisa la versión de Cursor CLI y los detalles siguientes.", + cursorRunFailed: "Cursor CLI terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.", }, run: "Ejecutar", readingSchema: "Leyendo esquema", @@ -2105,7 +2114,7 @@ export default withEnglishFallback({ contextWindowAuto: "Auto (detectar desde el nombre del modelo)", contextWindowHint: "Tokens. Déjalo vacío para detección automática. Configúralo manualmente para modelos locales/personalizados.", maxRetriesGlobal: "Reintentos máximos", - maxRetriesGlobalDescription: "Número de reintentos automáticos en errores transitorios de API (límite de tasa, timeout, fallos de red). Se aplica a todos los proveedores de IA por API. Los proveedores CLI (claude-code, codex, pi) no se ven afectados.", + maxRetriesGlobalDescription: "Número de reintentos automáticos en errores transitorios de API (límite de tasa, timeout, fallos de red). Se aplica a todos los proveedores de IA por API. Los proveedores CLI no se ven afectados.", maxRetriesRange: "{min}–{max}, predeterminado {default}", maxRetriesSaved: "Reintentos máximos guardados", codexCliPath: "Ruta de Codex CLI", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 8e0384524..f9fd9dc7d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2036,7 +2036,7 @@ export default withEnglishFallback({ contextWindowAuto: "Auto (rileva dal nome del modello)", contextWindowHint: "Token. Lascia vuoto per il rilevamento automatico. Imposta manualmente per modelli locali/personalizzati.", maxRetriesGlobal: "Tentativi massimi", - maxRetriesGlobalDescription: "Numero di tentativi automatici su errori API transitori (limite di velocità, timeout, problemi di rete). Si applica a tutti i provider IA via API. I provider CLI (claude-code, codex, pi) non sono interessati.", + maxRetriesGlobalDescription: "Numero di tentativi automatici su errori API transitori (limite di velocità, timeout, problemi di rete). Si applica a tutti i provider IA via API. I provider CLI non sono interessati.", maxRetriesRange: "{min}–{max}, predefinito {default}", maxRetriesSaved: "Tentativi massimi salvati", codexCliPath: "Percorso Codex CLI", @@ -2107,6 +2107,15 @@ export default withEnglishFallback({ openCodeTimeout: "OpenCode CLI non ha risposto prima della scadenza dell'operazione.", openCodeProtocolError: "OpenCode CLI ha restituito un flusso di eventi JSON non valido. Controlla la versione di OpenCode e i dettagli seguenti.", openCodeRunFailed: "OpenCode CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.", + cursorNotInstalled: "Cursor CLI non è stato trovato. Installalo o imposta il percorso dell'eseguibile in Impostazioni > AI.", + cursorCliPathInvalid: "Il percorso di Cursor CLI non è valido. Seleziona solo l'eseguibile agent e configura separatamente le variabili d'ambiente.", + cursorEnvInvalid: "Il nome di una variabile d'ambiente di Cursor CLI non è valido. Usa nomi come HTTPS_PROXY.", + cursorEnvReserved: "È stata sovrascritta una variabile gestita da Cursor o DBX. Rimuovi CURSOR_CONFIG_DIR, CURSOR_DATA_DIR e DBX_MCP_* dalla configurazione.", + cursorNotAuthenticated: "Cursor CLI non è autenticato. Esegui `agent login` nel terminale e riprova.", + cursorMcpStartupFailed: "Cursor non ha potuto avviare il server DBX MCP con ambito limitato. Controlla Impostazioni > MCP e i dettagli seguenti.", + cursorTimeout: "Cursor CLI non ha risposto prima della scadenza dell'operazione.", + cursorProtocolError: "Cursor CLI ha restituito un flusso di eventi JSON non valido. Controlla la versione di Cursor CLI e i dettagli seguenti.", + cursorRunFailed: "Cursor CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.", }, actions: { general: "Generale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 657e6db15..78ef94ade 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1954,7 +1954,7 @@ export default withEnglishFallback({ contextWindowAuto: "自動(モデル名から検出)", contextWindowHint: "トークン数。空欄で自動検出。ローカル/カスタムモデルは手動設定してください。", maxRetriesGlobal: "最大再試行回数", - maxRetriesGlobalDescription: "一時的なAPIエラー(レート制限、タイムアウト、ネットワーク障害)発生時の自動再試行回数。すべてのAPI対応AIプロバイダーに適用されます。CLIプロバイダー(claude-code、codex、pi)は影響を受けません。", + maxRetriesGlobalDescription: "一時的なAPIエラー(レート制限、タイムアウト、ネットワーク障害)発生時の自動再試行回数。すべてのAPI対応AIプロバイダーに適用されます。CLIプロバイダーは影響を受けません。", maxRetriesRange: "{min}–{max}、デフォルト {default}", maxRetriesSaved: "最大再試行回数を保存しました", codexCliPath: "Codex CLI パス", @@ -2003,6 +2003,15 @@ export default withEnglishFallback({ openCodeTimeout: "OpenCode CLIはタイムアウトまでに応答しませんでした。", openCodeProtocolError: "OpenCode CLIが無効なJSONイベントストリームを返しました。OpenCodeのバージョンと以下の診断詳細を確認してください。", openCodeRunFailed: "OpenCode CLIが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。", + cursorNotInstalled: "Cursor CLIが見つかりません。Cursor CLIをインストールするか、設定 > AIで実行ファイルのパスを指定してください。", + cursorCliPathInvalid: "Cursor CLIのパスが無効です。agent実行ファイルだけを選択し、環境変数は別に設定してください。", + cursorEnvInvalid: "Cursor CLIの環境変数名が無効です。HTTPS_PROXYのような名前を使用してください。", + cursorEnvReserved: "CursorまたはDBXが管理する環境変数が上書きされています。CURSOR_CONFIG_DIR、CURSOR_DATA_DIR、DBX_MCP_*を設定から削除してください。", + cursorNotAuthenticated: "Cursor CLIにログインしていません。ターミナルで`agent login`を実行してから再試行してください。", + cursorMcpStartupFailed: "Cursorはスコープ付きDBX MCPサーバーを起動できませんでした。設定 > MCPと以下の診断詳細を確認してください。", + cursorTimeout: "Cursor CLIはタイムアウトまでに応答しませんでした。", + cursorProtocolError: "Cursor CLIが無効なJSONイベントストリームを返しました。Cursor CLIのバージョンと以下の診断詳細を確認してください。", + cursorRunFailed: "Cursor CLIが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。", }, run: "実行", readingSchema: "スキーマを読み取り中", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 37def09dd..bd41e1655 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2010,6 +2010,15 @@ export default withEnglishFallback({ openCodeTimeout: "OpenCode CLI가 제한 시간 안에 응답하지 않았습니다.", openCodeProtocolError: "OpenCode CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. OpenCode 버전과 아래 진단을 확인하세요.", openCodeRunFailed: "OpenCode CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.", + cursorNotInstalled: "Cursor CLI를 찾을 수 없습니다. Cursor CLI를 설치하거나 설정 > AI에서 실행 파일 경로를 지정하세요.", + cursorCliPathInvalid: "Cursor CLI 경로가 올바르지 않습니다. agent 실행 파일만 선택하고 환경 변수는 별도로 설정하세요.", + cursorEnvInvalid: "Cursor CLI 환경 변수 이름이 올바르지 않습니다. HTTPS_PROXY와 같은 이름을 사용하세요.", + cursorEnvReserved: "Cursor 또는 DBX가 관리하는 환경 변수를 덮어썼습니다. CURSOR_CONFIG_DIR, CURSOR_DATA_DIR 및 DBX_MCP_* 변수를 제거하세요.", + cursorNotAuthenticated: "Cursor CLI에 로그인되어 있지 않습니다. 터미널에서 `agent login`을 실행한 후 다시 시도하세요.", + cursorMcpStartupFailed: "Cursor가 범위가 제한된 DBX MCP 서버를 시작하지 못했습니다. 설정 > MCP와 아래 진단을 확인하세요.", + cursorTimeout: "Cursor CLI가 제한 시간 안에 응답하지 않았습니다.", + cursorProtocolError: "Cursor CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. Cursor CLI 버전과 아래 진단을 확인하세요.", + cursorRunFailed: "Cursor CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패 원인을 확인하세요.", }, actions: { general: "일반", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index be510f1f5..53ae48468 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1971,6 +1971,15 @@ export default withEnglishFallback({ openCodeTimeout: "O OpenCode CLI não respondeu antes do tempo limite da operação.", openCodeProtocolError: "O OpenCode CLI retornou um fluxo de eventos JSON inválido. Verifique a versão do OpenCode e os detalhes abaixo.", openCodeRunFailed: "O OpenCode CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.", + cursorNotInstalled: "O Cursor CLI não foi encontrado. Instale-o ou defina o caminho do executável em Configurações > IA.", + cursorCliPathInvalid: "O caminho do Cursor CLI é inválido. Selecione apenas o executável agent e configure as variáveis de ambiente separadamente.", + cursorEnvInvalid: "O nome de uma variável de ambiente do Cursor CLI é inválido. Use nomes como HTTPS_PROXY.", + cursorEnvReserved: "Uma variável gerenciada pelo Cursor ou DBX foi sobrescrita. Remova CURSOR_CONFIG_DIR, CURSOR_DATA_DIR e DBX_MCP_* da configuração.", + cursorNotAuthenticated: "O Cursor CLI não está autenticado. Execute `agent login` no terminal e tente novamente.", + cursorMcpStartupFailed: "O Cursor não conseguiu iniciar o servidor DBX MCP com escopo restrito. Verifique Configurações > MCP e os detalhes abaixo.", + cursorTimeout: "O Cursor CLI não respondeu antes do tempo limite da operação.", + cursorProtocolError: "O Cursor CLI retornou um fluxo de eventos JSON inválido. Verifique a versão do Cursor CLI e os detalhes abaixo.", + cursorRunFailed: "O Cursor CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.", }, run: "Executar", readingSchema: "Lendo schema", @@ -2103,7 +2112,7 @@ export default withEnglishFallback({ contextWindowAuto: "Automático (detectar pelo nome do modelo)", contextWindowHint: "Tokens. Deixe vazio para detecção automática. Defina manualmente para modelos locais/personalizados.", maxRetriesGlobal: "Máximo de Tentativas", - maxRetriesGlobalDescription: "Número de tentativas automáticas em erros transitórios de API (limite de taxa, timeout, falhas de rede). Aplica-se a todos os provedores de IA via API. Provedores CLI (claude-code, codex, pi) não são afetados.", + maxRetriesGlobalDescription: "Número de tentativas automáticas em erros transitórios de API (limite de taxa, timeout, falhas de rede). Aplica-se a todos os provedores de IA via API. Provedores CLI não são afetados.", maxRetriesRange: "{min}–{max}, padrão {default}", maxRetriesSaved: "Máximo de tentativas salvo", enableThinking: "Raciocínio", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 31d942ac6..88cf4860c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2059,7 +2059,7 @@ export default withEnglishFallback({ maxRetries: "最大重试次数", maxRetriesHint: "遇到限流、超时或临时网络错误时自动重试。0 = 不重试,最大 10。", maxRetriesGlobal: "最大重试次数", - maxRetriesGlobalDescription: "遇到临时 API 错误(限流、超时、网络波动)时的自动重试次数。适用于所有 API 模式 AI 供应商。CLI 供应商(claude-code、codex、pi)不受影响。", + maxRetriesGlobalDescription: "遇到临时 API 错误(限流、超时、网络波动)时的自动重试次数。适用于所有 API 模式 AI 供应商。CLI 供应商不受影响。", maxRetriesRange: "{min}–{max},默认 {default}", maxRetriesSaved: "最大重试次数已保存", codexCliPath: "Codex CLI 路径", @@ -2130,6 +2130,15 @@ export default withEnglishFallback({ openCodeTimeout: "OpenCode CLI 未能在操作超时前响应。", openCodeProtocolError: "OpenCode CLI 返回了无效的 JSON 事件流。请检查 OpenCode 版本和下方诊断详情。", openCodeRunFailed: "OpenCode CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", + cursorNotInstalled: "未找到 Cursor CLI。请安装 Cursor CLI,或在 设置 > AI 中填写其可执行文件路径。", + cursorCliPathInvalid: "Cursor CLI 路径无效。请只选择 agent 可执行文件,环境变量需单独配置。", + cursorEnvInvalid: "Cursor CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。", + cursorEnvReserved: "配置覆盖了由 Cursor 或 DBX 管理的环境变量。请移除 CURSOR_CONFIG_DIR、CURSOR_DATA_DIR 和 DBX_MCP_* 变量。", + cursorNotAuthenticated: "Cursor CLI 尚未登录。请在终端执行 `agent login` 后重试。", + cursorMcpStartupFailed: "Cursor 无法启动受限范围的 DBX MCP Server。请检查 设置 > MCP 和下方诊断详情。", + cursorTimeout: "Cursor CLI 未能在操作超时前响应。", + cursorProtocolError: "Cursor CLI 返回了无效的 JSON 事件流。请检查 Cursor CLI 版本和下方诊断详情。", + cursorRunFailed: "Cursor CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", }, actions: { general: "通用问答", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index f122e9264..480592da6 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2039,7 +2039,7 @@ export default withEnglishFallback({ maxRetries: "最大重試次數", maxRetriesHint: "遇到限流、超時或暫時性網路錯誤時自動重試。0 = 不重試,最大 10。", maxRetriesGlobal: "最大重試次數", - maxRetriesGlobalDescription: "遇到暫時性 API 錯誤(限流、超時、網路波動)時的自動重試次數。適用於所有 API 模式 AI 供應商。CLI 供應商(claude-code、codex、pi)不受影響。", + maxRetriesGlobalDescription: "遇到暫時性 API 錯誤(限流、超時、網路波動)時的自動重試次數。適用於所有 API 模式 AI 供應商。CLI 供應商不受影響。", maxRetriesRange: "{min}–{max},預設 {default}", maxRetriesSaved: "最大重試次數已儲存", codexCliPath: "Codex CLI 路徑", @@ -2110,6 +2110,15 @@ export default withEnglishFallback({ openCodeTimeout: "OpenCode CLI 未能在操作逾時前回應。", openCodeProtocolError: "OpenCode CLI 傳回了無效的 JSON 事件串流。請檢查 OpenCode 版本和下方診斷詳情。", openCodeRunFailed: "OpenCode CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 CLI 輸出。", + cursorNotInstalled: "找不到 Cursor CLI。請安裝 Cursor CLI,或在 設定 > AI 中填寫其執行檔路徑。", + cursorCliPathInvalid: "Cursor CLI 路徑無效。請只選擇 agent 執行檔,環境變數需另外設定。", + cursorEnvInvalid: "Cursor CLI 環境變數名稱無效。請使用 HTTPS_PROXY 這類合法名稱。", + cursorEnvReserved: "設定覆寫了由 Cursor 或 DBX 管理的環境變數。請移除 CURSOR_CONFIG_DIR、CURSOR_DATA_DIR 和 DBX_MCP_* 變數。", + cursorNotAuthenticated: "Cursor CLI 尚未登入。請在終端執行 `agent login` 後重試。", + cursorMcpStartupFailed: "Cursor 無法啟動受限範圍的 DBX MCP Server。請檢查 設定 > MCP 和下方診斷詳情。", + cursorTimeout: "Cursor CLI 未能在操作逾時前回應。", + cursorProtocolError: "Cursor CLI 傳回了無效的 JSON 事件串流。請檢查 Cursor CLI 版本和下方診斷詳情。", + cursorRunFailed: "Cursor CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 CLI 輸出。", }, actions: { general: "通用問答", diff --git a/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts index 8622163a3..975fa0e56 100644 --- a/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts +++ b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts @@ -24,7 +24,7 @@ describe("isAiConfigModelCandidate", () => { expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false); }); - it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => { + it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => { expect( isAiConfigModelCandidate( config({ diff --git a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts index 010b13c76..8a63faeb9 100644 --- a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts @@ -22,11 +22,12 @@ describe("orderAiConfigsForDisplay", () => { { id: "openai-compatible", provider: "openai-compatible" }, { id: "codex", provider: "codex-cli" }, { id: "opencode", provider: "opencode-cli" }, + { id: "cursor", provider: "cursor-cli" }, { id: "pi", provider: "pi-agent-cli" }, { id: "custom", provider: "custom" }, ]; - expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "pi", "custom"]); + expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "cursor", "pi", "custom"]); }); it("preserves creation order for configs from the same provider", () => { diff --git a/apps/desktop/src/lib/ai/aiConfigCandidates.ts b/apps/desktop/src/lib/ai/aiConfigCandidates.ts index 79289a973..0b2540c7b 100644 --- a/apps/desktop/src/lib/ai/aiConfigCandidates.ts +++ b/apps/desktop/src/lib/ai/aiConfigCandidates.ts @@ -1,6 +1,6 @@ import type { AiConfig } from "@/types/ai"; -const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli"]); +const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli"]); export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean { // CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass. diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 425ab648f..8eaae3ad6 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -432,6 +432,22 @@ describe("settingsStore AI API key normalization", () => { opencodeCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: "" }, }); }); + + it("normalizes Cursor CLI path and environment settings", () => { + expect( + normalizeAiConfig({ + provider: "cursor-cli", + cursorCliPath: " ~/.local/bin/agent ", + cursorCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: null as unknown as string }, + }), + ).toMatchObject({ + provider: "cursor-cli", + endpoint: "", + model: "default", + cursorCliPath: "~/.local/bin/agent", + cursorCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: "" }, + }); + }); }); describe("settingsStore MCP policy persistence", () => { diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 6c3c0d863..afed0429e 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -242,6 +242,16 @@ export const AI_PROVIDER_PRESETS: Record = { authMethod: "bearer", requiresApiKey: false, }, + "cursor-cli": { + label: "Cursor CLI", + iconSlug: "cursor", + provider: "cursor-cli", + endpoint: "", + model: "default", + apiStyle: "completions", + authMethod: "bearer", + requiresApiKey: false, + }, "pi-agent-cli": { label: "Pi Coding Agent", iconSlug: "pi", @@ -310,6 +320,8 @@ export function normalizeAiConfig(config: Partial | null | undefined): piAgentCliEnv: normalizeAiEnv(config?.piAgentCliEnv), opencodeCliPath: config?.opencodeCliPath?.trim() || undefined, opencodeCliEnv: normalizeAiEnv(config?.opencodeCliEnv), + cursorCliPath: config?.cursorCliPath?.trim() || undefined, + cursorCliEnv: normalizeAiEnv(config?.cursorCliEnv), }; } @@ -1402,7 +1414,7 @@ export const useSettingsStore = defineStore("settings", () => { const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId); if (!config) return false; const preset = AI_PROVIDER_PRESETS[config.provider]; - if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-cli") return true; + if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-cli" || config.provider === "cursor-cli") return true; return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey); }); diff --git a/apps/desktop/src/types/ai.ts b/apps/desktop/src/types/ai.ts index fd7c1effc..1db0fa6b1 100644 --- a/apps/desktop/src/types/ai.ts +++ b/apps/desktop/src/types/ai.ts @@ -1,4 +1,4 @@ -export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "custom"; +export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "cursor-cli" | "custom"; export type AiApiStyle = "completions" | "responses" | "anthropic-messages"; export type AiAuthMethod = "api-key" | "bearer"; export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; @@ -48,6 +48,8 @@ export interface AiConfig { piAgentCliEnv?: Record; opencodeCliPath?: string | null; opencodeCliEnv?: Record; + cursorCliPath?: string | null; + cursorCliEnv?: Record; runtimeEffort?: AiEffortSelection | null; } diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index 45de02215..bf6279c4d 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -183,6 +183,14 @@ pub async fn run_agent_loop( ); return crate::ai_opencode_cli::run_opencode_agent(config, &prompt, options, cancelled, on_event).await; } + if matches!(config.provider, AiProvider::CursorCli) { + let prompt = crate::ai_cursor_cli::build_cursor_prompt( + system_prompt, + messages, + agent_ctx.sql_permissions.allow_writes, + ); + return crate::ai_cursor_cli::run_cursor_agent(config, &prompt, options, cancelled, on_event).await; + } let prompt = crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes); return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await; diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index e8d30134b..616d2f676 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -81,6 +81,8 @@ pub enum AiProvider { PiAgentCli, #[serde(rename = "opencode-cli")] OpenCodeCli, + #[serde(rename = "cursor-cli")] + CursorCli, Custom, } @@ -99,6 +101,7 @@ impl AiProvider { AiProvider::ClaudeCodeCli => "claude-code-cli", AiProvider::PiAgentCli => "pi-agent-cli", AiProvider::OpenCodeCli => "opencode-cli", + AiProvider::CursorCli => "cursor-cli", AiProvider::CodexCli => "codex-cli", AiProvider::Custom => "custom", } @@ -380,6 +383,10 @@ pub struct AiConfig { pub opencode_cli_path: Option, #[serde(default)] pub opencode_cli_env: HashMap, + #[serde(default)] + pub cursor_cli_path: Option, + #[serde(default)] + pub cursor_cli_env: HashMap, } fn default_enable_thinking() -> bool { @@ -387,12 +394,16 @@ fn default_enable_thinking() -> bool { } /// Whether the provider is a CLI-based provider that goes through its own -/// executable (claude-code, codex, opencode, pi) rather than through `with_retry` / +/// executable (claude-code, codex, cursor, opencode, pi) rather than through `with_retry` / /// `with_stream_retry`. pub fn is_cli_provider(provider: &AiProvider) -> bool { matches!( provider, - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli + AiProvider::CodexCli + | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli + | AiProvider::CursorCli ) } @@ -623,6 +634,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String { | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli + | AiProvider::CursorCli | AiProvider::Gemini => unreachable!(), } } @@ -1471,6 +1483,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str AiProvider::ClaudeCodeCli => crate::ai_claude_code_cli::list_claude_code_models(config).await?, AiProvider::PiAgentCli => crate::ai_pi_agent_cli::list_pi_agent_models(config).await?, AiProvider::OpenCodeCli => crate::ai_opencode_cli::list_opencode_models(config).await?, + AiProvider::CursorCli => crate::ai_cursor_cli::list_cursor_models(config).await?, _ => { validate_model_list_config(config)?; let client = build_ai_http_client(config, 30)?; @@ -1493,7 +1506,11 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str list_openai_compatible_models(&client, config).await? } } - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => { + AiProvider::CodexCli + | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli + | AiProvider::CursorCli => { unreachable!() } } @@ -1518,6 +1535,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res return crate::ai_opencode_cli::resolve_opencode_model_effort(config, model_id).await; } + if matches!(config.provider, AiProvider::CursorCli) { + return Ok(AiEffortCapability::Unsupported); + } + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { let models = list_models_core(config).await?; return Ok(models @@ -2049,6 +2070,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result Result { match request.config.provider { AiProvider::Gemini => call_gemini(&client, request).await, - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => { + AiProvider::CodexCli + | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli + | AiProvider::CursorCli => { unreachable!() } AiProvider::Openai @@ -2475,7 +2503,11 @@ pub async fn stream( match request.config.provider { AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await, - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => { + AiProvider::CodexCli + | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli + | AiProvider::CursorCli => { unreachable!() } AiProvider::Openai @@ -4115,6 +4147,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }, system_prompt: "Be concise.".to_string(), messages: vec![AiMessage { @@ -4698,6 +4732,8 @@ mod tests { assert!(config.pi_agent_cli_env.is_empty()); assert!(config.opencode_cli_path.is_none()); assert!(config.opencode_cli_env.is_empty()); + assert!(config.cursor_cli_path.is_none()); + assert!(config.cursor_cli_env.is_empty()); } #[test] @@ -4725,6 +4761,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let err = build_ai_http_client(&config, 1).unwrap_err(); @@ -4757,6 +4795,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -4787,6 +4827,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -4817,6 +4859,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert_eq!( @@ -4851,6 +4895,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions"); @@ -4882,6 +4928,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; for provider in @@ -4932,6 +4980,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models"); @@ -4958,6 +5008,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); } @@ -4987,6 +5039,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert!(uses_anthropic_messages_api(&config)); @@ -5068,6 +5122,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert!(!uses_anthropic_messages_api(&config)); @@ -5109,6 +5165,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions"); assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models"); @@ -5174,6 +5232,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses"); @@ -5211,6 +5271,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let api_key_headers = claude_headers(&config).unwrap(); @@ -5328,6 +5390,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show"); @@ -5362,6 +5426,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true)); @@ -5445,6 +5511,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let models = vec![ AiModelInfo::new("qwen3:0.6b", None), @@ -5708,6 +5776,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let mut body = serde_json::json!({ @@ -5921,6 +5991,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -5960,6 +6032,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6005,6 +6079,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6044,6 +6120,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6079,6 +6157,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6145,6 +6225,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let request = AiCompletionRequest { config: config.clone(), @@ -6205,6 +6287,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -6731,9 +6815,13 @@ mod tests { #[test] fn merge_global_max_retries_skips_cli_providers() { - for provider in - [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli, AiProvider::OpenCodeCli] - { + for provider in [ + AiProvider::CodexCli, + AiProvider::ClaudeCodeCli, + AiProvider::PiAgentCli, + AiProvider::OpenCodeCli, + AiProvider::CursorCli, + ] { let mut config = test_config(provider.clone()); config.max_retries = None; merge_global_max_retries(&mut config, 0); diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs index c718466df..3ae0b5867 100644 --- a/crates/dbx-core/src/ai_claude_code_cli.rs +++ b/crates/dbx-core/src/ai_claude_code_cli.rs @@ -583,6 +583,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs index 6f07ba026..e5ffea3d8 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -35,6 +35,7 @@ pub enum CliAgentJsonlDialect { CodexExec, ClaudeCodePrint, OpenCodeRun, + CursorPrint, } pub struct CliAgentProcessSpec { @@ -236,6 +237,7 @@ fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliA CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line), CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line), CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line), + CliAgentJsonlDialect::CursorPrint => parse_cursor_jsonl_line(line), } } @@ -625,6 +627,171 @@ fn open_code_error_message(value: &Value) -> String { .to_string() } +fn parse_cursor_jsonl_line(line: &str) -> ParsedCliAgentEvent { + let Ok(value) = serde_json::from_str::(line) else { + return ParsedCliAgentEvent::default(); + }; + + match value.get("type").and_then(Value::as_str).unwrap_or_default() { + "assistant" => parse_cursor_assistant(&value), + "thinking" => parse_cursor_thinking(&value), + "tool_call" => parse_cursor_tool_call(&value), + "result" => parse_cursor_result(&value), + "error" => { + let message = cursor_error_message(&value); + ParsedCliAgentEvent { + error: Some(message.clone()), + events: vec![AgentEvent::Error { message }], + ..Default::default() + } + } + _ => ParsedCliAgentEvent::default(), + } +} + +fn parse_cursor_assistant(value: &Value) -> ParsedCliAgentEvent { + // Cursor emits timestamped partial assistant messages followed by one + // un-timestamped buffered message. Only partials are deltas; consuming the + // buffered copy would duplicate the entire response. + if value.get("timestamp_ms").or_else(|| value.get("timestampMs")).is_none() { + return ParsedCliAgentEvent::default(); + } + let Some(content) = value.pointer("/message/content").or_else(|| value.get("content")) else { + return ParsedCliAgentEvent::default(); + }; + let mut text = String::new(); + for block in claude_content_blocks(content) { + if block.get("type").and_then(Value::as_str) == Some("text") { + if let Some(delta) = block.get("text").and_then(Value::as_str).filter(|delta| !delta.is_empty()) { + text.push_str(delta); + } + } + } + if text.is_empty() { + return ParsedCliAgentEvent::default(); + } + ParsedCliAgentEvent { + events: vec![AgentEvent::TextDelta { delta: text.clone() }], + final_text: Some(text), + ..Default::default() + } +} + +fn parse_cursor_thinking(value: &Value) -> ParsedCliAgentEvent { + if value.get("subtype").and_then(Value::as_str) != Some("delta") { + return ParsedCliAgentEvent::default(); + } + let Some(text) = value.get("text").and_then(Value::as_str).filter(|text| !text.is_empty()) else { + return ParsedCliAgentEvent::default(); + }; + ParsedCliAgentEvent { events: vec![AgentEvent::ReasoningDelta { delta: text.to_string() }], ..Default::default() } +} + +fn parse_cursor_tool_call(value: &Value) -> ParsedCliAgentEvent { + let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or_default(); + let call = value.get("tool_call").or_else(|| value.get("toolCall")).unwrap_or(&Value::Null); + let payload = call.as_object().and_then(|object| object.values().next()).unwrap_or(call); + let call_kind = call.as_object().and_then(|object| object.keys().next()).map(String::as_str); + let tool_call_id = value + .get("call_id") + .or_else(|| value.get("callId")) + .and_then(Value::as_str) + .or_else(|| payload.get("call_id").and_then(Value::as_str)) + .or_else(|| payload.get("callId").and_then(Value::as_str)) + .unwrap_or("cursor-tool-call") + .to_string(); + let tool_name = payload + .get("tool_name") + .or_else(|| payload.get("toolName")) + .or_else(|| payload.get("name")) + .or_else(|| payload.get("tool")) + .or_else(|| payload.pointer("/args/toolName")) + .or_else(|| payload.pointer("/args/tool_name")) + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| call_kind.map(cursor_tool_name_from_kind)) + .unwrap_or_else(|| "cursor_tool".to_string()); + let args = payload + .get("args") + .or_else(|| payload.get("arguments")) + .or_else(|| payload.get("input")) + .cloned() + .unwrap_or_else(|| Value::Object(Default::default())); + + match subtype { + "started" => ParsedCliAgentEvent { + events: vec![AgentEvent::ToolCallStart { tool_call_id, tool_name, args }], + ..Default::default() + }, + "completed" => { + let result = payload + .get("result") + .or_else(|| payload.get("output")) + .or_else(|| payload.get("error")) + .cloned() + .unwrap_or(Value::Null); + let is_error = payload + .get("is_error") + .or_else(|| payload.get("isError")) + .and_then(Value::as_bool) + .unwrap_or_else(|| payload.get("error").is_some_and(|error| !error.is_null())); + ParsedCliAgentEvent { + events: vec![AgentEvent::ToolCallEnd { tool_call_id, tool_name, result, is_error }], + ..Default::default() + } + } + _ => ParsedCliAgentEvent::default(), + } +} + +fn cursor_tool_name_from_kind(kind: &str) -> String { + let stem = kind.strip_suffix("ToolCall").unwrap_or(kind); + let mut name = String::new(); + for (index, ch) in stem.chars().enumerate() { + if ch.is_ascii_uppercase() && index > 0 { + name.push('_'); + } + name.push(ch.to_ascii_lowercase()); + } + name +} + +fn parse_cursor_result(value: &Value) -> ParsedCliAgentEvent { + let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or("success"); + let is_error = value.get("is_error").or_else(|| value.get("isError")).and_then(Value::as_bool).unwrap_or(false); + if subtype != "success" || is_error { + let message = cursor_error_message(value); + return ParsedCliAgentEvent { + error: Some(message.clone()), + events: vec![AgentEvent::Error { message }], + ..Default::default() + }; + } + let usage = value.get("usage"); + let input_tokens = usage + .and_then(|usage| usage.get("inputTokens").or_else(|| usage.get("input_tokens"))) + .and_then(Value::as_u64) + .filter(|value| *value > 0) + .map(|value| value as u32); + let output_tokens = usage + .and_then(|usage| usage.get("outputTokens").or_else(|| usage.get("output_tokens"))) + .and_then(Value::as_u64) + .filter(|value| *value > 0) + .map(|value| value as u32); + ParsedCliAgentEvent { events: vec![AgentEvent::AgentEnd { input_tokens, output_tokens }], ..Default::default() } +} + +fn cursor_error_message(value: &Value) -> String { + value + .get("error") + .and_then(Value::as_str) + .or_else(|| value.get("message").and_then(Value::as_str)) + .or_else(|| value.get("result").and_then(Value::as_str)) + .or_else(|| value.pointer("/error/message").and_then(Value::as_str)) + .unwrap_or("Cursor CLI failed") + .to_string() +} + pub async fn run_cli_jsonl_agent( spec: CliAgentProcessSpec, cancelled: &Notify, @@ -876,4 +1043,47 @@ mod tests { assert!(!process_is_alive(pid.trim())); let _ = std::fs::remove_file(pid_file); } + + #[tokio::test] + async fn jsonl_cancellation_kills_and_waits_for_child() { + let pid_file = std::env::temp_dir().join(format!( + "dbx-cli-agent-cancel-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + let script = format!("echo $$ > {}; exec sleep 30", pid_file.display()); + + let spec = CliAgentProcessSpec { + command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] }, + env: Vec::new(), + env_remove: Vec::new(), + current_dir: None, + stdin: None, + dialect: CliAgentJsonlDialect::CodexExec, + classify_spawn_error, + classify_run_error, + }; + let cancelled = Arc::new(Notify::new()); + let runner_cancelled = Arc::clone(&cancelled); + let runner = tokio::spawn(async move { run_cli_jsonl_agent(spec, runner_cancelled.as_ref(), |_| {}).await }); + + timeout(Duration::from_secs(3), async { + while !pid_file.exists() { + sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child should start before cancellation"); + cancelled.notify_waiters(); + + let result = timeout(Duration::from_secs(3), runner) + .await + .expect("runner should return after cancellation") + .expect("runner task should not panic"); + assert_eq!(result.unwrap_err(), "Agent loop cancelled"); + sleep(Duration::from_millis(100)).await; + let pid = std::fs::read_to_string(&pid_file).expect("child pid should be captured"); + assert!(!process_is_alive(pid.trim())); + let _ = std::fs::remove_file(pid_file); + } } diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index e09eeaa7a..8fa5e3479 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -1040,6 +1040,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_cursor_cli.rs b/crates/dbx-core/src/ai_cursor_cli.rs new file mode 100644 index 000000000..dea8c0b4f --- /dev/null +++ b/crates/dbx-core/src/ai_cursor_cli.rs @@ -0,0 +1,598 @@ +use crate::agent_events::AgentEvent; +use crate::ai::{AiConfig, AiEffortCapability, AiModelInfo, AiTestConnectionResult}; +use crate::ai_cli_agent::{ + build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, parse_cli_jsonl_event, + run_cli_jsonl_agent, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions, +}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tokio::sync::Notify; + +const CURSOR_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); +const CURSOR_CONTROL_ENV: &[&str] = &["CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR"]; + +pub type CursorRunOptions = CliAgentRunOptions; +pub type CursorCommandSpec = CliAgentCommandSpec; + +struct CursorIsolatedRuntime { + root: PathBuf, + workspace: PathBuf, + config: PathBuf, + data: PathBuf, +} + +impl CursorIsolatedRuntime { + fn create(options: Option<&CursorRunOptions>) -> Result { + let root = env::temp_dir().join(format!("dbx-cursor-{}", uuid::Uuid::new_v4())); + let workspace = root.join("workspace"); + let cursor_dir = workspace.join(".cursor"); + let config = root.join("config"); + let data = root.join("data"); + for path in [&workspace, &cursor_dir, &config, &data] { + std::fs::create_dir_all(path) + .map_err(|error| format!("[cursorRunFailed] Failed to create isolated Cursor directory: {error}"))?; + } + + if let Some(options) = options { + Self::write_mcp_config(&cursor_dir, options)?; + Self::write_permission_config(&cursor_dir, options.agent_mode)?; + } + + Ok(Self { root, workspace, config, data }) + } + + fn write_mcp_config(cursor_dir: &Path, options: &CursorRunOptions) -> Result<(), String> { + let command = options + .mcp_server_command + .as_ref() + .cloned() + .unwrap_or_else(|| CursorCommandSpec { program: "dbx-mcp-server".to_string(), args: Vec::new() }); + let env = dbx_mcp_scope_env(options).into_iter().collect::>(); + let config = json!({ + "mcpServers": { + "dbx": { + "command": command.program, + "args": command.args, + "env": env + } + } + }); + write_json_file(&cursor_dir.join("mcp.json"), &config, "Cursor MCP") + } + + fn write_permission_config(cursor_dir: &Path, agent_mode: bool) -> Result<(), String> { + let allow = + dbx_mcp_enabled_tools(agent_mode).into_iter().map(|tool| format!("Mcp(dbx:{tool})")).collect::>(); + let config = json!({ + "permissions": { + "allow": allow, + "deny": ["Shell(*)", "Read(*)", "Write(*)", "WebFetch(*)"] + } + }); + write_json_file(&cursor_dir.join("cli.json"), &config, "Cursor permission") + } + + fn process_env(&self, config: &AiConfig) -> Result, String> { + let mut values = BTreeMap::from_iter(cursor_cli_env(config)?); + values.insert("CURSOR_CONFIG_DIR".to_string(), self.config.to_string_lossy().to_string()); + values.insert("CURSOR_DATA_DIR".to_string(), self.data.to_string_lossy().to_string()); + Ok(values.into_iter().collect()) + } +} + +impl Drop for CursorIsolatedRuntime { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn write_json_file(path: &Path, value: &Value, label: &str) -> Result<(), String> { + let content = serde_json::to_vec_pretty(value) + .map_err(|error| format!("[cursorRunFailed] Failed to serialize {label} configuration: {error}"))?; + std::fs::write(path, content) + .map_err(|error| format!("[cursorRunFailed] Failed to write {label} configuration: {error}")) +} + +fn cursor_program(config: &AiConfig) -> String { + config.cursor_cli_path.as_deref().map(str::trim).filter(|value| !value.is_empty()).unwrap_or("agent").to_string() +} + +fn resolve_cursor_command(config: &AiConfig) -> Result { + let program = cursor_program(config); + if starts_with_env_assignment(&program) { + return Err("[cursorCliPathInvalid] Cursor CLI path should contain only the executable path. Add environment variables in the Cursor CLI environment variables section.".to_string()); + } + + let program = if is_path_like_program(&program) { crate::path_utils::expand_tilde(&program) } else { program }; + let path = Path::new(&program); + if path.is_dir() { + return ["agent", "cursor-agent"] + .into_iter() + .find_map(|name| launchable_program_in_dir(path, name)) + .map(|program| CursorCommandSpec { program, args: Vec::new() }) + .ok_or_else(|| { + "[cursorCliPathInvalid] Cursor CLI path should point to the agent executable or a directory containing agent." + .to_string() + }); + } + if is_path_like_program(&program) && !path.is_file() { + return Err("[cursorCliPathInvalid] Cursor CLI executable does not exist.".to_string()); + } + Ok(CursorCommandSpec { program, args: Vec::new() }) +} + +fn launchable_program_in_dir(dir: &Path, program: &str) -> Option { + program_path_candidates(dir, program) + .into_iter() + .find(|candidate| candidate.is_file()) + .map(|path| path.to_string_lossy().to_string()) +} + +#[cfg(not(windows))] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + vec![dir.join(program)] +} + +#[cfg(windows)] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + [".exe", ".cmd", ".bat", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect() +} + +fn is_path_like_program(program: &str) -> bool { + program.contains('/') || program.contains('\\') || program.starts_with('~') +} + +fn starts_with_env_assignment(program: &str) -> bool { + let Some(first_token) = program.split_whitespace().next() else { + return false; + }; + let Some((key, _)) = first_token.split_once('=') else { + return false; + }; + is_env_var_name(key) +} + +fn is_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn is_reserved_env_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + upper.starts_with("DBX_MCP_") || CURSOR_CONTROL_ENV.iter().any(|reserved| upper == *reserved) +} + +pub fn cursor_cli_env(config: &AiConfig) -> Result, String> { + let mut values = BTreeMap::new(); + for (key, value) in &config.cursor_cli_env { + let key = key.trim(); + if key.is_empty() { + continue; + } + if !is_env_var_name(key) { + return Err(format!( + "[cursorEnvInvalid] Invalid Cursor CLI environment variable name `{key}`. Use names like HTTPS_PROXY." + )); + } + if is_reserved_env_name(key) { + return Err(format!( + "[cursorEnvReserved] `{key}` is managed by DBX for the isolated Cursor session and cannot be set here." + )); + } + values.insert(key.to_string(), value.clone()); + } + Ok(values.into_iter().collect()) +} + +fn cursor_selection_args(config: &AiConfig) -> Vec { + let model = config.model.trim(); + if model.is_empty() || model.eq_ignore_ascii_case("default") { + Vec::new() + } else { + vec!["--model".to_string(), model.to_string()] + } +} + +pub fn build_cursor_command(config: &AiConfig, workspace: &Path) -> CursorCommandSpec { + let mut command = CursorCommandSpec { program: cursor_program(config), args: Vec::new() }; + command.args.extend([ + "-p".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--stream-partial-output".to_string(), + "--approve-mcps".to_string(), + "--trust".to_string(), + "--workspace".to_string(), + workspace.to_string_lossy().to_string(), + ]); + command.args.extend(cursor_selection_args(config)); + command +} + +fn resolved_run_command(config: &AiConfig, workspace: &Path) -> Result { + let resolved = resolve_cursor_command(config)?; + let mut command = build_cursor_command(config, workspace); + command.program = resolved.program; + command.args.splice(0..0, resolved.args); + Ok(command) +} + +pub fn build_cursor_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String { + build_cli_agent_prompt("Cursor", system_prompt, messages, allow_write_sql) +} + +pub async fn list_cursor_models(config: &AiConfig) -> Result, String> { + let runtime = CursorIsolatedRuntime::create(None)?; + let command = resolve_cursor_command(config)?; + let mut process = cli_command(&command.program); + process.args(&command.args).arg("--list-models"); + for key in CURSOR_CONTROL_ENV { + process.env_remove(key); + } + process.envs(runtime.process_env(config)?).current_dir(&runtime.workspace).kill_on_drop(true); + let output = tokio::time::timeout(CURSOR_COMMAND_TIMEOUT, process.output()) + .await + .map_err(|_| "[cursorTimeout] Cursor model discovery timed out".to_string())? + .map_err(|error| classify_cursor_spawn_error(&error.to_string()))?; + if !output.status.success() { + return Err(classify_cursor_run_error(&combined_output(&output.stderr, &output.stdout))); + } + parse_cursor_models(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| { + "[cursorProtocolError] Cursor returned no models. Check the Cursor CLI version and authentication state." + .to_string() + }) +} + +fn parse_cursor_models(stdout: &str) -> Option> { + let mut models = Vec::new(); + let mut seen = BTreeSet::new(); + for line in stdout.lines().map(str::trim).filter(|line| !line.is_empty()) { + if line.eq_ignore_ascii_case("available models") { + continue; + } + let Some((reported_id, label)) = line.split_once(" - ") else { + continue; + }; + let reported_id = reported_id.trim(); + if reported_id.is_empty() { + continue; + } + let id = if reported_id.eq_ignore_ascii_case("auto") { "default" } else { reported_id }; + if !seen.insert(id.to_string()) { + continue; + } + let label = label.trim().trim_end_matches(" (current)").trim(); + let display_name = (!label.is_empty()).then(|| label.to_string()); + let mut info = AiModelInfo::new(id, display_name); + info.effort_capability = Some(AiEffortCapability::Unsupported); + models.push(info); + } + (!models.is_empty()).then_some(models) +} + +pub async fn test_cursor_connection(config: &AiConfig) -> Result { + let start = Instant::now(); + let runtime = CursorIsolatedRuntime::create(None)?; + let command = resolve_cursor_command(config)?; + let mut process = cli_command(&command.program); + process.args(&command.args).args(["status", "--format", "json"]); + for key in CURSOR_CONTROL_ENV { + process.env_remove(key); + } + process.envs(runtime.process_env(config)?).current_dir(&runtime.workspace).kill_on_drop(true); + let output = tokio::time::timeout(CURSOR_COMMAND_TIMEOUT, process.output()) + .await + .map_err(|_| "[cursorTimeout] Cursor authentication check timed out".to_string())? + .map_err(|error| classify_cursor_spawn_error(&error.to_string()))?; + if !output.status.success() { + return Err(classify_cursor_run_error(&combined_output(&output.stderr, &output.stdout))); + } + let status: Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("[cursorProtocolError] Cursor returned invalid status JSON: {error}"))?; + let authenticated = status + .get("isAuthenticated") + .or_else(|| status.get("is_authenticated")) + .and_then(Value::as_bool) + .or_else(|| { + status.get("status").and_then(Value::as_str).map(|status| status.eq_ignore_ascii_case("authenticated")) + }) + .unwrap_or(false); + if !authenticated { + return Err( + "[cursorNotAuthenticated] Cursor CLI is not authenticated. Run `agent login`, then retry.".to_string() + ); + } + let elapsed = start.elapsed(); + Ok(AiTestConnectionResult { + success: true, + message: format!("OK - {}ms", elapsed.as_millis()), + latency_ms: Some(elapsed.as_millis() as u64), + model_used: config.model.trim().to_string(), + error_category: None, + }) +} + +fn combined_output(stderr: &[u8], stdout: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr); + let stdout = String::from_utf8_lossy(stdout); + [stderr.trim(), stdout.trim()].into_iter().filter(|part| !part.is_empty()).collect::>().join("\n") +} + +fn classify_cursor_spawn_error(message: &str) -> String { + let lower = message.to_ascii_lowercase(); + if lower.contains("no such file") || lower.contains("not found") || lower.contains("cannot find") { + format!("[cursorNotInstalled] {message}") + } else { + format!("[cursorRunFailed] {message}") + } +} + +fn classify_cursor_run_error(message: &str) -> String { + if message.starts_with("[cursor") || message.starts_with("[dbxMcpMissing]") { + return message.to_string(); + } + let lower = message.to_ascii_lowercase(); + if lower.contains("not authenticated") + || lower.contains("authentication required") + || lower.contains("unauthorized") + || lower.contains("please login") + || lower.contains("please sign in") + { + format!("[cursorNotAuthenticated] {message}") + } else if lower.contains("dbx-mcp-server") || lower.contains("enoent") { + format!("[dbxMcpMissing] {message}") + } else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) { + format!("[cursorMcpStartupFailed] {message}") + } else if lower.contains("json") || lower.contains("protocol") { + format!("[cursorProtocolError] {message}") + } else { + format!("[cursorRunFailed] {message}") + } +} + +pub fn parse_cursor_jsonl_event(line: &str) -> Option> { + parse_cli_jsonl_event(line, CliAgentJsonlDialect::CursorPrint) +} + +pub async fn run_cursor_agent( + config: &AiConfig, + prompt: &str, + options: CursorRunOptions, + cancelled: &Notify, + on_event: impl Fn(AgentEvent) + Send + Sync + 'static, +) -> Result { + let runtime = CursorIsolatedRuntime::create(Some(&options))?; + let result = run_cli_jsonl_agent( + CliAgentProcessSpec { + command: resolved_run_command(config, &runtime.workspace)?, + env: runtime.process_env(config)?, + env_remove: CURSOR_CONTROL_ENV.iter().map(|value| (*value).to_string()).collect(), + current_dir: Some(runtime.workspace.clone()), + stdin: Some(prompt.to_string()), + dialect: CliAgentJsonlDialect::CursorPrint, + classify_spawn_error: classify_cursor_spawn_error, + classify_run_error: classify_cursor_run_error, + }, + cancelled, + on_event, + ) + .await?; + if result.trim().is_empty() { + Err("[cursorProtocolError] Cursor completed without a text response".to_string()) + } else { + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai::{AiApiStyle, AiAuthMethod, AiProvider, AiReasoningLevel}; + use std::sync::{Arc, Mutex}; + + fn config(model: &str) -> AiConfig { + AiConfig { + provider: AiProvider::CursorCli, + api_key: String::new(), + auth_method: AiAuthMethod::Bearer, + endpoint: String::new(), + model: model.to_string(), + models: Vec::new(), + api_style: AiApiStyle::Completions, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: true, + reasoning_level: AiReasoningLevel::Default, + runtime_effort: None, + context_window: None, + max_retries: None, + codex_cli_path: None, + codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), + opencode_cli_path: None, + opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), + } + } + + #[test] + fn default_command_uses_stream_json_and_stdin_mode() { + let workspace = Path::new("/tmp/dbx-cursor-workspace"); + let command = build_cursor_command(&config("default"), workspace); + assert_eq!(command.program, "agent"); + assert_eq!( + command.args, + [ + "-p", + "--output-format", + "stream-json", + "--stream-partial-output", + "--approve-mcps", + "--trust", + "--workspace", + "/tmp/dbx-cursor-workspace" + ] + ); + } + + #[test] + fn explicit_model_is_forwarded() { + let command = build_cursor_command(&config("composer-2.5"), Path::new("/tmp/workspace")); + assert!(command.args.ends_with(&["--model".to_string(), "composer-2.5".to_string()])); + } + + #[test] + fn models_map_auto_to_default_and_strip_current_marker() { + let models = + parse_cursor_models("Available models\n\nauto - Auto (default)\ncomposer-2.5 - Composer 2.5 (current)\n") + .unwrap(); + assert_eq!(models.iter().map(|model| model.id.as_str()).collect::>(), ["default", "composer-2.5"]); + assert_eq!(models[1].display_name.as_deref(), Some("Composer 2.5")); + assert!(models.iter().all(|model| model.effort_capability == Some(AiEffortCapability::Unsupported))); + } + + #[test] + fn cursor_env_rejects_runtime_and_mcp_overrides() { + let mut invalid = config("default"); + invalid.cursor_cli_env.insert("CURSOR_DATA_DIR".to_string(), "/tmp/shared".to_string()); + assert!(cursor_cli_env(&invalid).unwrap_err().starts_with("[cursorEnvReserved]")); + + invalid.cursor_cli_env.clear(); + invalid.cursor_cli_env.insert("DBX_MCP_ALLOW_WRITES".to_string(), "1".to_string()); + assert!(cursor_cli_env(&invalid).unwrap_err().starts_with("[cursorEnvReserved]")); + } + + #[test] + fn cursor_stream_parser_ignores_buffered_assistant_copy() { + let partial = parse_cursor_jsonl_event( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]},"timestamp_ms":1}"#, + ) + .unwrap(); + assert!(matches!(&partial[0], AgentEvent::TextDelta { delta } if delta == "hello")); + assert!(parse_cursor_jsonl_event( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"}]}}"# + ) + .is_none()); + } + + #[test] + fn cursor_stream_parser_maps_usage_and_tool_events() { + let start = parse_cursor_jsonl_event( + r#"{"type":"tool_call","subtype":"started","call_id":"call-1","tool_call":{"mcpToolCall":{"args":{"toolName":"dbx_list_tables","args":{"database":"main"}}}}}"#, + ) + .unwrap(); + assert!( + matches!(&start[0], AgentEvent::ToolCallStart { tool_call_id, tool_name, .. } if tool_call_id == "call-1" && tool_name == "dbx_list_tables") + ); + + let end = parse_cursor_jsonl_event( + r#"{"type":"result","subtype":"success","is_error":false,"usage":{"inputTokens":10,"outputTokens":4}}"#, + ) + .unwrap(); + assert!(matches!(&end[0], AgentEvent::AgentEnd { input_tokens: Some(10), output_tokens: Some(4) })); + } + + #[test] + fn isolated_runtime_writes_scoped_mcp_and_permissions() { + let options = CursorRunOptions { + connection_id: "sqlite-1".to_string(), + connection_name: "SQLite".to_string(), + database: "main".to_string(), + schema: None, + agent_mode: false, + allow_writes: false, + allow_dangerous: false, + confirmed_write_sql: None, + mcp_server_command: Some(CursorCommandSpec { + program: "node".to_string(), + args: vec!["server.js".to_string()], + }), + }; + let runtime = CursorIsolatedRuntime::create(Some(&options)).unwrap(); + let mcp: Value = + serde_json::from_slice(&std::fs::read(runtime.workspace.join(".cursor/mcp.json")).unwrap()).unwrap(); + assert_eq!(mcp.pointer("/mcpServers/dbx/command").and_then(Value::as_str), Some("node")); + assert_eq!( + mcp.pointer("/mcpServers/dbx/env/DBX_MCP_SCOPE_CONNECTION_ID").and_then(Value::as_str), + Some("sqlite-1") + ); + let permissions: Value = + serde_json::from_slice(&std::fs::read(runtime.workspace.join(".cursor/cli.json")).unwrap()).unwrap(); + let allow = permissions.pointer("/permissions/allow").and_then(Value::as_array).unwrap(); + assert!(allow.iter().any(|value| value == "Mcp(dbx:dbx_list_connections)")); + assert!(!allow.iter().any(|value| value == "Mcp(dbx:dbx_execute_query)")); + } + + fn live_config() -> AiConfig { + let mut config = config("default"); + config.cursor_cli_path = std::env::var("DBX_LIVE_CURSOR_PATH").ok(); + config + } + + #[tokio::test] + #[ignore = "requires an installed and authenticated Cursor CLI"] + async fn live_model_discovery_and_connection_test() { + let config = live_config(); + let models = list_cursor_models(&config).await.unwrap(); + assert!(models.iter().any(|model| model.id == "default")); + let result = test_cursor_connection(&config).await.unwrap(); + assert!(result.success); + assert!(result.latency_ms.is_some()); + } + + #[tokio::test] + #[ignore = "requires Cursor, a running DBX MCP bridge, and DBX_LIVE_MCP_CONNECTION_NAME/DATABASE"] + async fn live_scoped_dbx_mcp_agent_run() { + let config = live_config(); + let connection_name = std::env::var("DBX_LIVE_MCP_CONNECTION_NAME") + .expect("set DBX_LIVE_MCP_CONNECTION_NAME to a saved DBX connection"); + let database = std::env::var("DBX_LIVE_MCP_DATABASE").expect("set DBX_LIVE_MCP_DATABASE to a visible database"); + let mcp_command = std::env::var("DBX_LIVE_MCP_COMMAND").unwrap_or_else(|_| "dbx-mcp-server".to_string()); + let options = CursorRunOptions { + connection_id: String::new(), + connection_name: connection_name.clone(), + database, + schema: None, + agent_mode: false, + allow_writes: false, + allow_dangerous: false, + confirmed_write_sql: None, + mcp_server_command: Some(CursorCommandSpec { program: mcp_command, args: Vec::new() }), + }; + let events = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&events); + + let result = tokio::time::timeout( + Duration::from_secs(90), + run_cursor_agent( + &config, + "You must use the DBX MCP list-connections tool. Reply with the visible connection name only.", + options, + &Notify::new(), + move |event| captured.lock().unwrap().push(event), + ), + ) + .await + .expect("Cursor MCP smoke test timed out") + .unwrap(); + + assert!(result.contains(&connection_name)); + let events = events.lock().unwrap(); + assert!( + events.iter().any(|event| { + matches!(event, AgentEvent::ToolCallStart { tool_name, .. } if tool_name.contains("dbx_list_connections")) + }), + "Cursor events did not expose the expected DBX tool name: {events:#?}" + ); + } +} diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs index 205801d95..72ab30721 100644 --- a/crates/dbx-core/src/ai_effort.rs +++ b/crates/dbx-core/src/ai_effort.rs @@ -86,7 +86,8 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option None, + | AiProvider::OpenCodeCli + | AiProvider::CursorCli => None, } } @@ -192,6 +193,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> { | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli + | AiProvider::CursorCli | AiProvider::Custom => None, } } @@ -211,6 +213,7 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> { | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli + | AiProvider::CursorCli ) { return match selection { AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()), @@ -263,7 +266,11 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) { apply_openai_effort(object, &config.api_style, selection); } } - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {} + AiProvider::CodexCli + | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli + | AiProvider::CursorCli => {} } } @@ -419,6 +426,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_model_filter.rs b/crates/dbx-core/src/ai_model_filter.rs index 9dff6d517..f84d67e58 100644 --- a/crates/dbx-core/src/ai_model_filter.rs +++ b/crates/dbx-core/src/ai_model_filter.rs @@ -97,6 +97,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli + | AiProvider::CursorCli | AiProvider::MiniMax | AiProvider::Custom => true, } diff --git a/crates/dbx-core/src/ai_opencode_cli.rs b/crates/dbx-core/src/ai_opencode_cli.rs index 938215e7b..3e6c7e822 100644 --- a/crates/dbx-core/src/ai_opencode_cli.rs +++ b/crates/dbx-core/src/ai_opencode_cli.rs @@ -466,6 +466,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_pi_agent_cli.rs b/crates/dbx-core/src/ai_pi_agent_cli.rs index f029ae1e0..c1d123240 100644 --- a/crates/dbx-core/src/ai_pi_agent_cli.rs +++ b/crates/dbx-core/src/ai_pi_agent_cli.rs @@ -757,6 +757,8 @@ mod tests { pi_agent_cli_env: HashMap::new(), opencode_cli_path: None, opencode_cli_env: HashMap::new(), + cursor_cli_path: None, + cursor_cli_env: HashMap::new(), } } diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index fdf00e712..f40eda393 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -1418,6 +1418,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }, } } @@ -2245,23 +2247,33 @@ mod tests { cfg.config.model = "openai/gpt-5.4-mini".to_string(); cfg.config.opencode_cli_path = Some("/opt/homebrew/bin/opencode".to_string()); cfg.config.opencode_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string()); + let mut cursor_cfg = make_test_config("cursor-synced", false); + cursor_cfg.config.provider = crate::ai::AiProvider::CursorCli; + cursor_cfg.config.model = "composer-2.5".to_string(); + cursor_cfg.config.cursor_cli_path = Some("~/.local/bin/agent".to_string()); + cursor_cfg.config.cursor_cli_env.insert("NO_PROXY".to_string(), "localhost".to_string()); let payload = SensitiveSyncPayload { connection_secrets: vec![], - ai_configs: Some(vec![cfg]), + ai_configs: Some(vec![cfg, cursor_cfg]), ai_config: None, tunnel_profiles: None, }; apply_sensitive_payload(&storage, &payload).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].name, "synced"); - assert!(matches!(loaded[0].config.provider, crate::ai::AiProvider::OpenCodeCli)); - assert_eq!(loaded[0].config.model, "openai/gpt-5.4-mini"); - assert_eq!(loaded[0].config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode")); + assert_eq!(loaded.len(), 2); + let opencode = loaded.iter().find(|item| item.name == "synced").unwrap(); + assert!(matches!(opencode.config.provider, crate::ai::AiProvider::OpenCodeCli)); + assert_eq!(opencode.config.model, "openai/gpt-5.4-mini"); + assert_eq!(opencode.config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode")); assert_eq!( - loaded[0].config.opencode_cli_env.get("HTTPS_PROXY").map(String::as_str), + opencode.config.opencode_cli_env.get("HTTPS_PROXY").map(String::as_str), Some("http://127.0.0.1:7890") ); + let cursor = loaded.iter().find(|item| item.name == "cursor-synced").unwrap(); + assert!(matches!(cursor.config.provider, crate::ai::AiProvider::CursorCli)); + assert_eq!(cursor.config.model, "composer-2.5"); + assert_eq!(cursor.config.cursor_cli_path.as_deref(), Some("~/.local/bin/agent")); + assert_eq!(cursor.config.cursor_cli_env.get("NO_PROXY").map(String::as_str), Some("localhost")); } #[tokio::test] diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 8006f87ee..5915bc559 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod ai; pub mod ai_claude_code_cli; pub mod ai_cli_agent; pub mod ai_codex_cli; +pub mod ai_cursor_cli; pub mod ai_effort; mod ai_model_filter; pub mod ai_opencode_cli; diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index c93faf81a..451ea9445 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -5469,6 +5469,8 @@ mod tests { pi_agent_cli_env: std::collections::HashMap::new(), opencode_cli_path: None, opencode_cli_env: std::collections::HashMap::new(), + cursor_cli_path: None, + cursor_cli_env: std::collections::HashMap::new(), }, } } @@ -5533,6 +5535,33 @@ mod tests { std::fs::remove_file(&db).ok(); } + #[tokio::test] + async fn cursor_cli_ai_config_roundtrip() { + let db = temp_db_path("cursor-cli-ai-roundtrip"); + let storage = Storage::open(&db).await.unwrap(); + + let mut cfg = make_ai_config("cursor-cli", true); + cfg.config.provider = AiProvider::CursorCli; + cfg.config.api_key.clear(); + cfg.config.endpoint.clear(); + cfg.config.model = "composer-2.5".to_string(); + cfg.config.cursor_cli_path = Some("~/.local/bin/agent".to_string()); + cfg.config.cursor_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string()); + storage.save_ai_config_item(&cfg).await.unwrap(); + + let loaded = storage.load_ai_configs().await.unwrap(); + assert_eq!(loaded.len(), 1); + assert!(matches!(loaded[0].config.provider, AiProvider::CursorCli)); + assert_eq!(loaded[0].config.model, "composer-2.5"); + assert_eq!(loaded[0].config.cursor_cli_path.as_deref(), Some("~/.local/bin/agent")); + assert_eq!( + loaded[0].config.cursor_cli_env.get("HTTPS_PROXY").map(String::as_str), + Some("http://127.0.0.1:7890") + ); + + std::fs::remove_file(&db).ok(); + } + #[tokio::test] async fn anthropic_compatible_ai_config_roundtrip() { let db = temp_db_path("anthropic-compatible-ai-roundtrip"); diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index eab98182d..4bd10f986 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -529,14 +529,20 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), } } #[test] fn rejects_local_cli_providers_single() { - for provider in - [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli, AiProvider::OpenCodeCli] - { + for provider in [ + AiProvider::CodexCli, + AiProvider::ClaudeCodeCli, + AiProvider::PiAgentCli, + AiProvider::OpenCodeCli, + AiProvider::CursorCli, + ] { let config = make_config(provider); assert!(reject_web_unsupported_ai_provider(&config).is_err()); } @@ -620,6 +626,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), }; let body = super::AiTestConnectionRequest { config }; diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 6cc748266..db12ad188 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -541,6 +541,9 @@ test("AI provider presets include common hosted and local providers", () => { assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].iconSlug, "opencode"); assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].requiresApiKey, false); + assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].model, "default"); + assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].iconSlug, "cursor"); + assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].requiresApiKey, false); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi"); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].requiresApiKey, false); @@ -551,6 +554,8 @@ test("AI provider presets include common hosted and local providers", () => { assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("cursor-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("cursor-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); }); diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 35bc5cb32..7ce0f4f7f 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -240,6 +240,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig { AiProvider::ClaudeCodeCli => (&mut config.claude_code_cli_path, "claude"), AiProvider::PiAgentCli => (&mut config.pi_agent_cli_path, "pi"), AiProvider::OpenCodeCli => (&mut config.opencode_cli_path, "opencode"), + AiProvider::CursorCli => (&mut config.cursor_cli_path, "agent"), _ => return config, }; let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command); @@ -327,6 +328,8 @@ mod tests { pi_agent_cli_env: Default::default(), opencode_cli_path: None, opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), } }