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 @@
+
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(),
}
}