From 1a3645a04d65cc9d5a8de214108c572fd039c2d1 Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Mon, 10 Aug 2026 18:27:37 +0800 Subject: [PATCH] feat(ai): add CodeBuddy Code CLI provider --- apps/desktop/public/icons/ai/codebuddy.svg | 1 + .../editor/EditorSettingsDialog.vue | 19 +- .../__tests__/useAiModelCatalog.spec.ts | 24 + .../src/composables/useAiModelCatalog.ts | 2 + apps/desktop/src/i18n/backend-errors.ts | 10 + apps/desktop/src/i18n/locales/en.ts | 10 + apps/desktop/src/i18n/locales/es.ts | 10 + apps/desktop/src/i18n/locales/it.ts | 10 + apps/desktop/src/i18n/locales/ja.ts | 10 + apps/desktop/src/i18n/locales/ko.ts | 10 + apps/desktop/src/i18n/locales/pt-BR.ts | 10 + apps/desktop/src/i18n/locales/zh-CN.ts | 10 + apps/desktop/src/i18n/locales/zh-TW.ts | 10 + .../__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 | 79 +- crates/dbx-core/src/ai_claude_code_cli.rs | 2 + crates/dbx-core/src/ai_cli_agent.rs | 18 +- crates/dbx-core/src/ai_codebuddy_cli.rs | 1348 +++++++++++++++++ crates/dbx-core/src/ai_codex_cli.rs | 2 + crates/dbx-core/src/ai_cursor_cli.rs | 2 + crates/dbx-core/src/ai_effort.rs | 10 +- crates/dbx-core/src/ai_grok_cli.rs | 2 + 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 | 2 + crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/storage.rs | 29 + crates/dbx-web/src/routes/ai.rs | 5 + packages/app-tests/aiCliErrorI18n.test.ts | 10 + packages/app-tests/settingsStore.test.ts | 13 + src-tauri/src/commands/ai.rs | 3 + 38 files changed, 1697 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/public/icons/ai/codebuddy.svg create mode 100644 crates/dbx-core/src/ai_codebuddy_cli.rs diff --git a/apps/desktop/public/icons/ai/codebuddy.svg b/apps/desktop/public/icons/ai/codebuddy.svg new file mode 100644 index 0000000000..446ed6713e --- /dev/null +++ b/apps/desktop/public/icons/ai/codebuddy.svg @@ -0,0 +1 @@ +CodeBuddy Code diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index e465db37ba..060d73096e 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -2640,7 +2640,7 @@ 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", "cursor-cli", "grok-cli"]); +const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-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))); @@ -2670,6 +2670,8 @@ const aiEditCursorCliPath = ref(""); const aiEditCursorCliEnvRows = ref([]); const aiEditGrokCliPath = ref(""); const aiEditGrokCliEnvRows = ref([]); +const aiEditCodeBuddyCliPath = ref(""); +const aiEditCodeBuddyCliEnvRows = ref([]); const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages"); @@ -2703,6 +2705,7 @@ const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli"); const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli"); const aiIsCursorCli = computed(() => aiEditProvider.value === "cursor-cli"); const aiIsGrokCli = computed(() => aiEditProvider.value === "grok-cli"); +const aiIsCodeBuddyCli = computed(() => aiEditProvider.value === "codebuddy-cli"); const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value)); const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label); const aiCliCommandName = computed(() => { @@ -2711,6 +2714,7 @@ const aiCliCommandName = computed(() => { if (aiIsOpenCodeCli.value) return "opencode"; if (aiIsCursorCli.value) return "agent"; if (aiIsGrokCli.value) return "grok"; + if (aiIsCodeBuddyCli.value) return "codebuddy"; return "codex"; }); const aiCliLoginCommand = computed(() => { @@ -2719,6 +2723,7 @@ const aiCliLoginCommand = computed(() => { if (aiIsOpenCodeCli.value) return "opencode auth login"; if (aiIsCursorCli.value) return "agent login"; if (aiIsGrokCli.value) return "grok login"; + if (aiIsCodeBuddyCli.value) return "codebuddy"; return "codex login"; }); const aiEditCliPath = computed({ @@ -2728,6 +2733,7 @@ const aiEditCliPath = computed({ if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value; if (aiIsCursorCli.value) return aiEditCursorCliPath.value; if (aiIsGrokCli.value) return aiEditGrokCliPath.value; + if (aiIsCodeBuddyCli.value) return aiEditCodeBuddyCliPath.value; return aiEditCodexCliPath.value; }, set: (value: string) => { @@ -2741,6 +2747,8 @@ const aiEditCliPath = computed({ aiEditCursorCliPath.value = value; } else if (aiIsGrokCli.value) { aiEditGrokCliPath.value = value; + } else if (aiIsCodeBuddyCli.value) { + aiEditCodeBuddyCliPath.value = value; } else { aiEditCodexCliPath.value = value; } @@ -2752,6 +2760,7 @@ const aiEditCliEnvRows = computed(() => { if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value; if (aiIsCursorCli.value) return aiEditCursorCliEnvRows.value; if (aiIsGrokCli.value) return aiEditGrokCliEnvRows.value; + if (aiIsCodeBuddyCli.value) return aiEditCodeBuddyCliEnvRows.value; return aiEditCodexCliEnvRows.value; }); watch(aiIsCliProvider, (isCliProvider) => { @@ -2850,6 +2859,8 @@ function removeCliEnvRow(id: string) { aiEditCursorCliEnvRows.value = aiEditCursorCliEnvRows.value.filter((row) => row.id !== id); } else if (aiIsGrokCli.value) { aiEditGrokCliEnvRows.value = aiEditGrokCliEnvRows.value.filter((row) => row.id !== id); + } else if (aiIsCodeBuddyCli.value) { + aiEditCodeBuddyCliEnvRows.value = aiEditCodeBuddyCliEnvRows.value.filter((row) => row.id !== id); } else { aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); } @@ -2884,6 +2895,8 @@ function currentAiEditConfig() { cursorCliEnv: aiIsCursorCli.value ? cliEnvFromRows(aiEditCursorCliEnvRows.value) : {}, grokCliPath: aiEditGrokCliPath.value.trim() || undefined, grokCliEnv: aiIsGrokCli.value ? cliEnvFromRows(aiEditGrokCliEnvRows.value) : {}, + codebuddyCliPath: aiEditCodeBuddyCliPath.value.trim() || undefined, + codebuddyCliEnv: aiIsCodeBuddyCli.value ? cliEnvFromRows(aiEditCodeBuddyCliEnvRows.value) : {}, }; } @@ -2959,6 +2972,8 @@ function aiEnterEditMode(configId?: string) { aiEditCursorCliEnvRows.value = aiEnvRowsFromConfig(config.cursorCliEnv); aiEditGrokCliPath.value = config.grokCliPath ?? ""; aiEditGrokCliEnvRows.value = aiEnvRowsFromConfig(config.grokCliEnv); + aiEditCodeBuddyCliPath.value = config.codebuddyCliPath ?? ""; + aiEditCodeBuddyCliEnvRows.value = aiEnvRowsFromConfig(config.codebuddyCliEnv); } } else { aiEditConfigName.value = ""; @@ -2986,6 +3001,8 @@ function aiEnterEditMode(configId?: string) { aiEditCursorCliEnvRows.value = []; aiEditGrokCliPath.value = ""; aiEditGrokCliEnvRows.value = []; + aiEditCodeBuddyCliPath.value = ""; + aiEditCodeBuddyCliEnvRows.value = []; } } diff --git a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts index 5f9a95a163..7482c56a7d 100644 --- a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts +++ b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts @@ -142,6 +142,30 @@ describe("useAiModelCatalog", () => { expect(apiMock.aiListModels).toHaveBeenCalledTimes(2); }); + it("tracks CodeBuddy executable and environment changes without depending on environment key order", async () => { + const initial: AiConfigItem = { + ...config(), + provider: "codebuddy-cli", + endpoint: "", + apiKey: "", + model: "default", + codebuddyCliPath: "/opt/homebrew/bin/codebuddy", + codebuddyCliEnv: { 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, + codebuddyCliEnv: { NO_PROXY: "localhost", HTTPS_PROXY: "http://127.0.0.1:7890" }, + }), + ).resolves.toEqual([{ id: "first" }]); + await expect(catalog.loadModels({ ...initial, codebuddyCliPath: "/usr/local/bin/codebuddy" })).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 1db9ed6dae..3c4284b60c 100644 --- a/apps/desktop/src/composables/useAiModelCatalog.ts +++ b/apps/desktop/src/composables/useAiModelCatalog.ts @@ -59,6 +59,7 @@ function configSignature(config: AiConfigItem): string { piAgentCliPath: config.piAgentCliPath ?? null, opencodeCliPath: config.opencodeCliPath ?? null, cursorCliPath: config.cursorCliPath ?? null, + codebuddyCliPath: config.codebuddyCliPath ?? null, connectionFingerprint: fingerprint( JSON.stringify({ apiKey: config.apiKey, @@ -69,6 +70,7 @@ function configSignature(config: AiConfigItem): string { piAgentCliEnv: sortedRecord(config.piAgentCliEnv), opencodeCliEnv: sortedRecord(config.opencodeCliEnv), cursorCliEnv: sortedRecord(config.cursorCliEnv), + codebuddyCliEnv: sortedRecord(config.codebuddyCliEnv), }), ), }); diff --git a/apps/desktop/src/i18n/backend-errors.ts b/apps/desktop/src/i18n/backend-errors.ts index 340aff57a3..562490200b 100644 --- a/apps/desktop/src/i18n/backend-errors.ts +++ b/apps/desktop/src/i18n/backend-errors.ts @@ -52,6 +52,16 @@ const taggedAiCliErrorKeys: Record = { grokCliMcpStartupFailed: "ai.cliErrors.grokCliMcpStartupFailed", grokCliCommandLineTooLong: "ai.cliErrors.grokCliCommandLineTooLong", grokCliRunFailed: "ai.cliErrors.grokCliRunFailed", + codeBuddyNotInstalled: "ai.cliErrors.codeBuddyNotInstalled", + codeBuddyCliPathInvalid: "ai.cliErrors.codeBuddyCliPathInvalid", + codeBuddyEnvInvalid: "ai.cliErrors.codeBuddyEnvInvalid", + codeBuddyEnvReserved: "ai.cliErrors.codeBuddyEnvReserved", + codeBuddyNotAuthenticated: "ai.cliErrors.codeBuddyNotAuthenticated", + codeBuddyTimeout: "ai.cliErrors.codeBuddyTimeout", + codeBuddyMcpConfigInvalid: "ai.cliErrors.codeBuddyMcpConfigInvalid", + codeBuddyMcpStartupFailed: "ai.cliErrors.codeBuddyMcpStartupFailed", + codeBuddyProtocolError: "ai.cliErrors.codeBuddyProtocolError", + codeBuddyRunFailed: "ai.cliErrors.codeBuddyRunFailed", cursorEnvInvalid: "ai.cliErrors.cursorEnvInvalid", cursorEnvReserved: "ai.cliErrors.cursorEnvReserved", cursorNotAuthenticated: "ai.cliErrors.cursorNotAuthenticated", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 7b2488fe72..106d3fe117 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2362,6 +2362,16 @@ export default { grokCliMcpStartupFailed: "Grok loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.", grokCliCommandLineTooLong: "Windows rejected the Grok CLI command because it was too long. Update DBX and retry using the generated prompt file.", grokCliRunFailed: "Grok CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", + codeBuddyNotInstalled: "CodeBuddy Code CLI was not found. Install CodeBuddy Code or set its executable path in Settings > AI.", + codeBuddyCliPathInvalid: "The CodeBuddy Code CLI path is invalid. Select only the codebuddy executable and configure environment variables separately.", + codeBuddyEnvInvalid: "A CodeBuddy environment variable name is invalid. Use names such as HTTPS_PROXY.", + codeBuddyEnvReserved: "A DBX-managed MCP environment variable was overridden. Remove DBX_MCP_* variables from the provider configuration.", + codeBuddyNotAuthenticated: "CodeBuddy Code is not authenticated. Open `codebuddy` in a terminal, sign in, and try again.", + codeBuddyTimeout: "CodeBuddy Code CLI did not respond before the operation timed out.", + codeBuddyMcpConfigInvalid: "CodeBuddy Code could not read the temporary DBX MCP configuration. Check the diagnostics below.", + codeBuddyMcpStartupFailed: "CodeBuddy Code loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.", + codeBuddyProtocolError: "CodeBuddy Code returned an invalid control response or JSON event stream. Check the CodeBuddy Code version and diagnostics below.", + codeBuddyRunFailed: "CodeBuddy Code CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", 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.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a9e3fb4871..c95892e955 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2208,6 +2208,16 @@ export default withEnglishFallback({ grokCliMcpStartupFailed: "Grok cargó la configuración MCP pero no pudo iniciar DBX MCP Server. Revisa Ajustes > MCP y los diagnósticos siguientes.", grokCliCommandLineTooLong: "Windows rechazó el comando de Grok CLI porque era demasiado largo. Actualiza DBX y vuelve a intentarlo con el archivo de prompt generado.", grokCliRunFailed: "Grok CLI terminó de forma inesperada. Usa el código de error y los diagnósticos siguientes para identificar el ejecutable o la salida fallida.", + codeBuddyNotInstalled: "No se encontró CodeBuddy Code CLI. Instala CodeBuddy Code o configura la ruta del ejecutable en Ajustes > AI.", + codeBuddyCliPathInvalid: "La ruta de CodeBuddy Code CLI no es válida. Selecciona solo el ejecutable codebuddy y configura las variables de entorno por separado.", + codeBuddyEnvInvalid: "El nombre de una variable de entorno de CodeBuddy no es válido. Usa nombres como HTTPS_PROXY.", + codeBuddyEnvReserved: "Se sobrescribió una variable MCP gestionada por DBX. Elimina las variables DBX_MCP_* de la configuración del proveedor.", + codeBuddyNotAuthenticated: "CodeBuddy Code no está autenticado. Abre `codebuddy` en una terminal, inicia sesión y vuelve a intentarlo.", + codeBuddyTimeout: "CodeBuddy Code CLI no respondió antes de que se agotara el tiempo de la operación.", + codeBuddyMcpConfigInvalid: "CodeBuddy Code no pudo leer la configuración temporal de DBX MCP. Revisa los diagnósticos siguientes.", + codeBuddyMcpStartupFailed: "CodeBuddy Code cargó la configuración MCP pero no pudo iniciar DBX MCP Server. Revisa Ajustes > MCP y los diagnósticos siguientes.", + codeBuddyProtocolError: "CodeBuddy Code devolvió una respuesta de control o un flujo de eventos JSON no válido. Revisa la versión de CodeBuddy Code y los diagnósticos siguientes.", + codeBuddyRunFailed: "CodeBuddy Code CLI terminó de forma inesperada. Usa el código de error y los diagnósticos siguientes para identificar el ejecutable o la salida fallida.", }, run: "Ejecutar", readingSchema: "Leyendo esquema", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b753546b8f..1857862c1c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2348,6 +2348,16 @@ export default withEnglishFallback({ grokCliMcpStartupFailed: "Grok ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.", grokCliCommandLineTooLong: "Windows ha rifiutato il comando Grok CLI perché troppo lungo. Aggiorna DBX e riprova usando il file di prompt generato.", grokCliRunFailed: "Grok CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.", + codeBuddyNotInstalled: "CodeBuddy Code CLI non è stato trovato. Installa CodeBuddy Code o imposta il percorso dell'eseguibile in Impostazioni > AI.", + codeBuddyCliPathInvalid: "Il percorso di CodeBuddy Code CLI non è valido. Seleziona solo l'eseguibile codebuddy e configura separatamente le variabili d'ambiente.", + codeBuddyEnvInvalid: "Il nome di una variabile d'ambiente di CodeBuddy non è valido. Usa nomi come HTTPS_PROXY.", + codeBuddyEnvReserved: "È stata sovrascritta una variabile MCP gestita da DBX. Rimuovi le variabili DBX_MCP_* dalla configurazione del provider.", + codeBuddyNotAuthenticated: "CodeBuddy Code non è autenticato. Apri `codebuddy` in un terminale, accedi e riprova.", + codeBuddyTimeout: "CodeBuddy Code CLI non ha risposto prima della scadenza dell'operazione.", + codeBuddyMcpConfigInvalid: "CodeBuddy Code non ha potuto leggere la configurazione temporanea di DBX MCP. Controlla i dettagli seguenti.", + codeBuddyMcpStartupFailed: "CodeBuddy Code ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.", + codeBuddyProtocolError: "CodeBuddy Code ha restituito una risposta di controllo o un flusso di eventi JSON non valido. Controlla la versione di CodeBuddy Code e i dettagli seguenti.", + codeBuddyRunFailed: "CodeBuddy Code CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.", }, actions: { general: "Generale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 0a95ebc61a..1ad959a235 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2242,6 +2242,16 @@ export default withEnglishFallback({ grokCliMcpStartupFailed: "GrokはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断詳細を確認してください。", grokCliCommandLineTooLong: "Windowsが長すぎるGrok CLIコマンドを拒否しました。DBXを更新し、生成されたプロンプトファイルで再試行してください。", grokCliRunFailed: "Grok CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。", + codeBuddyNotInstalled: "CodeBuddy Code CLIが見つかりません。CodeBuddy Codeをインストールするか、設定 > AIで実行ファイルのパスを指定してください。", + codeBuddyCliPathInvalid: "CodeBuddy Code CLIのパスが無効です。codebuddy実行ファイルのみを選択し、環境変数は別に設定してください。", + codeBuddyEnvInvalid: "CodeBuddyの環境変数名が無効です。HTTPS_PROXYのような有効な名前を使用してください。", + codeBuddyEnvReserved: "DBXが管理するMCP環境変数が上書きされています。プロバイダー設定からDBX_MCP_*変数を削除してください。", + codeBuddyNotAuthenticated: "CodeBuddy Codeにログインしていません。ターミナルで`codebuddy`を開いてログインし、再試行してください。", + codeBuddyTimeout: "CodeBuddy Code CLIは操作のタイムアウト前に応答しませんでした。", + codeBuddyMcpConfigInvalid: "CodeBuddy Codeは一時的なDBX MCP設定を読み込めませんでした。以下の診断情報を確認してください。", + codeBuddyMcpStartupFailed: "CodeBuddy CodeはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断情報を確認してください。", + codeBuddyProtocolError: "CodeBuddy Codeが無効な制御応答またはJSONイベントストリームを返しました。CodeBuddy Codeのバージョンと以下の診断情報を確認してください。", + codeBuddyRunFailed: "CodeBuddy Code CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。", }, run: "実行", readingSchema: "スキーマを読み取り中", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 36d15dac20..62a990d3e5 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2226,6 +2226,16 @@ export default withEnglishFallback({ grokCliMcpStartupFailed: "Grok이 MCP 구성을 불러왔지만 DBX MCP 서버를 시작할 수 없습니다. 설정 > MCP와 아래 진단을 확인하세요.", grokCliCommandLineTooLong: "명령이 너무 길어 Windows가 Grok CLI 명령을 거부했습니다. DBX를 업데이트하고 생성된 프롬프트 파일을 사용하여 다시 시도하세요.", grokCliRunFailed: "Grok CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.", + codeBuddyNotInstalled: "CodeBuddy Code CLI를 찾을 수 없습니다. CodeBuddy Code를 설치하거나 설정 > AI에서 실행 파일 경로를 지정하세요.", + codeBuddyCliPathInvalid: "CodeBuddy Code CLI 경로가 잘못되었습니다. codebuddy 실행 파일만 선택하고 환경 변수는 별도로 구성하세요.", + codeBuddyEnvInvalid: "CodeBuddy 환경 변수 이름이 잘못되었습니다. HTTPS_PROXY 같은 유효한 이름을 사용하세요.", + codeBuddyEnvReserved: "DBX가 관리하는 MCP 환경 변수가 재정의되었습니다. 공급자 구성에서 DBX_MCP_* 변수를 제거하세요.", + codeBuddyNotAuthenticated: "CodeBuddy Code에 로그인되어 있지 않습니다. 터미널에서 `codebuddy`를 열어 로그인한 후 다시 시도하세요.", + codeBuddyTimeout: "CodeBuddy Code CLI가 작업 제한 시간 전에 응답하지 않았습니다.", + codeBuddyMcpConfigInvalid: "CodeBuddy Code가 임시 DBX MCP 구성을 읽지 못했습니다. 아래 진단을 확인하세요.", + codeBuddyMcpStartupFailed: "CodeBuddy Code가 MCP 구성을 불러왔지만 DBX MCP Server를 시작하지 못했습니다. 설정 > MCP와 아래 진단을 확인하세요.", + codeBuddyProtocolError: "CodeBuddy Code가 잘못된 제어 응답 또는 JSON 이벤트 스트림을 반환했습니다. CodeBuddy Code 버전과 아래 진단을 확인하세요.", + codeBuddyRunFailed: "CodeBuddy Code CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.", }, actions: { general: "일반", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 07f6550ae5..c40a0aff44 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2210,6 +2210,16 @@ export default withEnglishFallback({ grokCliMcpStartupFailed: "O Grok carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.", grokCliCommandLineTooLong: "O Windows rejeitou o comando do Grok CLI porque era muito longo. Atualize o DBX e tente novamente com o arquivo de prompt gerado.", grokCliRunFailed: "Grok CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.", + codeBuddyNotInstalled: "O CodeBuddy Code CLI não foi encontrado. Instale o CodeBuddy Code ou defina o caminho do executável em Configurações > AI.", + codeBuddyCliPathInvalid: "O caminho do CodeBuddy Code CLI é inválido. Selecione apenas o executável codebuddy e configure as variáveis de ambiente separadamente.", + codeBuddyEnvInvalid: "O nome de uma variável de ambiente do CodeBuddy é inválido. Use nomes como HTTPS_PROXY.", + codeBuddyEnvReserved: "Uma variável MCP gerenciada pelo DBX foi sobrescrita. Remova as variáveis DBX_MCP_* da configuração do provedor.", + codeBuddyNotAuthenticated: "O CodeBuddy Code não está autenticado. Abra `codebuddy` em um terminal, entre na conta e tente novamente.", + codeBuddyTimeout: "O CodeBuddy Code CLI não respondeu antes do tempo limite da operação.", + codeBuddyMcpConfigInvalid: "O CodeBuddy Code não conseguiu ler a configuração temporária do DBX MCP. Verifique os detalhes abaixo.", + codeBuddyMcpStartupFailed: "O CodeBuddy Code carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.", + codeBuddyProtocolError: "O CodeBuddy Code retornou uma resposta de controle ou um fluxo de eventos JSON inválido. Verifique a versão do CodeBuddy Code e os detalhes abaixo.", + codeBuddyRunFailed: "O CodeBuddy Code CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.", }, run: "Executar", readingSchema: "Lendo schema", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index da4f89aae9..a8531b3393 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2287,6 +2287,16 @@ export default withEnglishFallback({ grokCliMcpStartupFailed: "Grok 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 与下方诊断信息。", grokCliCommandLineTooLong: "命令行过长,Windows 拒绝执行 Grok CLI。请更新 DBX 并使用生成的 prompt 文件重试。", grokCliRunFailed: "Grok CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", + codeBuddyNotInstalled: "未找到 CodeBuddy Code CLI。请安装 CodeBuddy Code,或在 设置 > AI 中指定可执行文件路径。", + codeBuddyCliPathInvalid: "CodeBuddy Code CLI 路径无效。请只选择 codebuddy 可执行文件,并单独配置环境变量。", + codeBuddyEnvInvalid: "CodeBuddy 环境变量名称无效。请使用类似 HTTPS_PROXY 的名称。", + codeBuddyEnvReserved: "覆盖了 DBX 管理的 MCP 环境变量。请从提供商配置中移除 DBX_MCP_* 变量。", + codeBuddyNotAuthenticated: "CodeBuddy Code 尚未登录。请在终端打开 `codebuddy`,完成登录后重试。", + codeBuddyTimeout: "CodeBuddy Code CLI 未在操作超时前响应。", + codeBuddyMcpConfigInvalid: "CodeBuddy Code 无法读取临时 DBX MCP 配置。请检查下方诊断信息。", + codeBuddyMcpStartupFailed: "CodeBuddy Code 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 与下方诊断信息。", + codeBuddyProtocolError: "CodeBuddy Code 返回了无效的控制响应或 JSON 事件流。请检查 CodeBuddy Code 版本和下方诊断信息。", + codeBuddyRunFailed: "CodeBuddy Code CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", cursorEnvInvalid: "Cursor CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。", cursorEnvReserved: "配置覆盖了由 Cursor 或 DBX 管理的环境变量。请移除 CURSOR_CONFIG_DIR、CURSOR_DATA_DIR 和 DBX_MCP_* 变量。", cursorNotAuthenticated: "Cursor CLI 尚未登录。请在终端执行 `agent login` 后重试。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index b2e6b9859a..d10695f9eb 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2351,6 +2351,16 @@ export default withEnglishFallback({ grokCliMcpStartupFailed: "Grok 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 與下方診斷資訊。", grokCliCommandLineTooLong: "命令列過長,Windows 拒絕執行 Grok CLI。請更新 DBX 並使用產生的 prompt 檔案重試。", grokCliRunFailed: "Grok CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。", + codeBuddyNotInstalled: "找不到 CodeBuddy Code CLI。請安裝 CodeBuddy Code,或在 設定 > AI 中指定執行檔路徑。", + codeBuddyCliPathInvalid: "CodeBuddy Code CLI 路徑無效。請只選擇 codebuddy 執行檔,並另外設定環境變數。", + codeBuddyEnvInvalid: "CodeBuddy 環境變數名稱無效。請使用類似 HTTPS_PROXY 的名稱。", + codeBuddyEnvReserved: "覆寫了 DBX 管理的 MCP 環境變數。請從供應商設定中移除 DBX_MCP_* 變數。", + codeBuddyNotAuthenticated: "CodeBuddy Code 尚未登入。請在終端機開啟 `codebuddy`,完成登入後重試。", + codeBuddyTimeout: "CodeBuddy Code CLI 未在操作逾時前回應。", + codeBuddyMcpConfigInvalid: "CodeBuddy Code 無法讀取暫存 DBX MCP 設定。請檢查下方診斷資訊。", + codeBuddyMcpStartupFailed: "CodeBuddy Code 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 與下方診斷資訊。", + codeBuddyProtocolError: "CodeBuddy Code 回傳了無效的控制回應或 JSON 事件串流。請檢查 CodeBuddy Code 版本和下方診斷資訊。", + codeBuddyRunFailed: "CodeBuddy Code 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 34947d95d8..96b2f8c672 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", "cursor-cli", "grok-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", "grok-cli", "codebuddy-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 36a0a7e3c3..5dccf21ccd 100644 --- a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts @@ -23,12 +23,13 @@ describe("orderAiConfigsForDisplay", () => { { id: "codex", provider: "codex-cli" }, { id: "opencode", provider: "opencode-cli" }, { id: "cursor", provider: "cursor-cli" }, + { id: "codebuddy", provider: "codebuddy-cli" }, { id: "grok", provider: "grok-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", "cursor", "grok", "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", "codebuddy", "grok", "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 c1af7bbdeb..2786e50278 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", "cursor-cli", "grok-cli"]); +const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-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 459850a7b2..9d135e847e 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -488,6 +488,22 @@ describe("settingsStore AI API key normalization", () => { cursorCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: "" }, }); }); + + it("normalizes CodeBuddy CLI path and environment settings", () => { + expect( + normalizeAiConfig({ + provider: "codebuddy-cli", + codebuddyCliPath: " ~/.local/bin/codebuddy ", + codebuddyCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: null as unknown as string }, + }), + ).toMatchObject({ + provider: "codebuddy-cli", + endpoint: "", + model: "default", + codebuddyCliPath: "~/.local/bin/codebuddy", + codebuddyCliEnv: { 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 1ffe71eb10..b02cd7603a 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -253,6 +253,16 @@ export const AI_PROVIDER_PRESETS: Record = { authMethod: "bearer", requiresApiKey: false, }, + "codebuddy-cli": { + label: "CodeBuddy Code", + iconSlug: "codebuddy", + provider: "codebuddy-cli", + endpoint: "", + model: "default", + apiStyle: "completions", + authMethod: "bearer", + requiresApiKey: false, + }, "grok-cli": { label: "Grok CLI", iconSlug: "grok", @@ -335,6 +345,8 @@ export function normalizeAiConfig(config: Partial | null | undefined): cursorCliEnv: normalizeAiEnv(config?.cursorCliEnv), grokCliPath: config?.grokCliPath?.trim() || undefined, grokCliEnv: normalizeAiEnv(config?.grokCliEnv), + codebuddyCliPath: config?.codebuddyCliPath?.trim() || undefined, + codebuddyCliEnv: normalizeAiEnv(config?.codebuddyCliEnv), }; } @@ -1497,7 +1509,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" || config.provider === "cursor-cli" || config.provider === "grok-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" || config.provider === "grok-cli" || config.provider === "codebuddy-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 ff5e0f3d82..bfc65b93f6 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" | "cursor-cli" | "grok-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" | "grok-cli" | "codebuddy-cli" | "custom"; export type AiApiStyle = "completions" | "responses" | "anthropic-messages"; export type AiAuthMethod = "api-key" | "bearer"; export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; @@ -53,6 +53,8 @@ export interface AiConfig { cursorCliEnv?: Record; grokCliPath?: string | null; grokCliEnv?: Record; + codebuddyCliPath?: string | null; + codebuddyCliEnv?: Record; runtimeEffort?: AiEffortSelection | null; } diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index 96ffdcf4f1..f67ba70bbb 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -196,6 +196,14 @@ pub async fn run_agent_loop( crate::ai_grok_cli::build_grok_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes); return crate::ai_grok_cli::run_grok_agent(config, &prompt, options, cancelled, on_event).await; } + if matches!(config.provider, AiProvider::CodeBuddyCli) { + let prompt = crate::ai_codebuddy_cli::build_codebuddy_prompt( + system_prompt, + messages, + agent_ctx.sql_permissions.allow_writes, + ); + return crate::ai_codebuddy_cli::run_codebuddy_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 cace8ba0ab..9da61901b3 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -85,6 +85,8 @@ pub enum AiProvider { CursorCli, #[serde(rename = "grok-cli")] GrokCli, + #[serde(rename = "codebuddy-cli")] + CodeBuddyCli, Custom, } @@ -105,6 +107,7 @@ impl AiProvider { AiProvider::OpenCodeCli => "opencode-cli", AiProvider::CursorCli => "cursor-cli", AiProvider::GrokCli => "grok-cli", + AiProvider::CodeBuddyCli => "codebuddy-cli", AiProvider::CodexCli => "codex-cli", AiProvider::Custom => "custom", } @@ -408,6 +411,10 @@ pub struct AiConfig { pub grok_cli_path: Option, #[serde(default)] pub grok_cli_env: HashMap, + #[serde(default)] + pub codebuddy_cli_path: Option, + #[serde(default)] + pub codebuddy_cli_env: HashMap, } fn default_enable_thinking() -> bool { @@ -426,6 +433,7 @@ pub fn is_cli_provider(provider: &AiProvider) -> bool { | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli + | AiProvider::CodeBuddyCli ) } @@ -658,6 +666,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String { | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli + | AiProvider::CodeBuddyCli | AiProvider::Gemini => unreachable!(), } } @@ -1508,6 +1517,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str AiProvider::OpenCodeCli => crate::ai_opencode_cli::list_opencode_models(config).await?, AiProvider::CursorCli => crate::ai_cursor_cli::list_cursor_models(config).await?, AiProvider::GrokCli => crate::ai_grok_cli::list_grok_models(config).await?, + AiProvider::CodeBuddyCli => crate::ai_codebuddy_cli::list_codebuddy_models(config).await?, _ => { validate_model_list_config(config)?; let client = build_ai_http_client(config, 30)?; @@ -1535,7 +1545,8 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str | AiProvider::PiAgentCli | AiProvider::OpenCodeCli | AiProvider::CursorCli - | AiProvider::GrokCli => { + | AiProvider::GrokCli + | AiProvider::CodeBuddyCli => { unreachable!() } } @@ -1560,6 +1571,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::CodeBuddyCli) { + return crate::ai_codebuddy_cli::resolve_codebuddy_model_effort(config, model_id).await; + } + if matches!(config.provider, AiProvider::CursorCli) { return Ok(AiEffortCapability::Unsupported); } @@ -2102,6 +2117,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result Result { | AiProvider::PiAgentCli | AiProvider::OpenCodeCli | AiProvider::CursorCli - | AiProvider::GrokCli => { + | AiProvider::GrokCli + | AiProvider::CodeBuddyCli => { unreachable!() } AiProvider::Openai @@ -2538,7 +2557,8 @@ pub async fn stream( | AiProvider::PiAgentCli | AiProvider::OpenCodeCli | AiProvider::CursorCli - | AiProvider::GrokCli => { + | AiProvider::GrokCli + | AiProvider::CodeBuddyCli => { unreachable!() } AiProvider::Openai @@ -4200,6 +4220,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }, system_prompt: "Be concise.".to_string(), messages: vec![AiMessage { @@ -4785,6 +4807,8 @@ mod tests { assert!(config.opencode_cli_env.is_empty()); assert!(config.cursor_cli_path.is_none()); assert!(config.cursor_cli_env.is_empty()); + assert!(config.codebuddy_cli_path.is_none()); + assert!(config.codebuddy_cli_env.is_empty()); } #[test] @@ -4816,6 +4840,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let err = build_ai_http_client(&config, 1).unwrap_err(); @@ -4852,6 +4878,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -4886,6 +4914,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -4920,6 +4950,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert_eq!( @@ -4958,6 +4990,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions"); @@ -4993,6 +5027,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; for provider in @@ -5047,6 +5083,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models"); @@ -5077,6 +5115,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); } @@ -5110,6 +5150,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert!(uses_anthropic_messages_api(&config)); @@ -5195,6 +5237,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert!(!uses_anthropic_messages_api(&config)); @@ -5240,6 +5284,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_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"); @@ -5309,6 +5355,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses"); @@ -5350,6 +5398,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let api_key_headers = claude_headers(&config).unwrap(); @@ -5471,6 +5521,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show"); @@ -5509,6 +5561,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true)); @@ -5596,6 +5650,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let models = vec![ AiModelInfo::new("qwen3:0.6b", None), @@ -5863,6 +5919,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let mut body = serde_json::json!({ @@ -6080,6 +6138,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -6123,6 +6183,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6172,6 +6234,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6215,6 +6279,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6254,6 +6320,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6324,6 +6392,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let request = AiCompletionRequest { config: config.clone(), @@ -6388,6 +6458,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -6921,6 +6993,7 @@ mod tests { AiProvider::OpenCodeCli, AiProvider::CursorCli, AiProvider::GrokCli, + AiProvider::CodeBuddyCli, ] { let mut config = test_config(provider.clone()); config.max_retries = None; diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs index 4e937e860b..ac68d84a58 100644 --- a/crates/dbx-core/src/ai_claude_code_cli.rs +++ b/crates/dbx-core/src/ai_claude_code_cli.rs @@ -587,6 +587,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_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 a47a43c5a9..7cd103c109 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -34,6 +34,7 @@ pub struct CliAgentCommandSpec { pub enum CliAgentJsonlDialect { CodexExec, ClaudeCodePrint, + CodeBuddyPrint, OpenCodeRun, CursorPrint, /// Grok Build headless `--output-format streaming-json` (ACP-derived NDJSON). @@ -237,7 +238,8 @@ pub fn parse_cli_jsonl_event(line: &str, dialect: CliAgentJsonlDialect) -> Optio fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliAgentEvent { match dialect { CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line), - CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line), + CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_compatible_jsonl_line(line, "Claude Code CLI failed"), + CliAgentJsonlDialect::CodeBuddyPrint => parse_claude_compatible_jsonl_line(line, "CodeBuddy Code CLI failed"), CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line), CliAgentJsonlDialect::CursorPrint => parse_cursor_jsonl_line(line), CliAgentJsonlDialect::GrokStreamingJson => parse_grok_streaming_json_line(line), @@ -402,7 +404,7 @@ fn codex_error_message(value: &Value) -> String { .to_string() } -fn parse_claude_code_jsonl_line(line: &str) -> ParsedCliAgentEvent { +fn parse_claude_compatible_jsonl_line(line: &str, fallback_error: &str) -> ParsedCliAgentEvent { let Ok(value) = serde_json::from_str::(line) else { return ParsedCliAgentEvent::default(); }; @@ -410,9 +412,9 @@ fn parse_claude_code_jsonl_line(line: &str) -> ParsedCliAgentEvent { match value.get("type").and_then(Value::as_str).unwrap_or_default() { "assistant" => parse_claude_code_assistant(&value), "user" => parse_claude_code_user(&value), - "result" => parse_claude_code_result(&value), + "result" => parse_claude_code_result(&value, fallback_error), "error" => { - let message = claude_code_error_message(&value); + let message = claude_code_error_message(&value, fallback_error); ParsedCliAgentEvent { error: Some(message.clone()), events: vec![AgentEvent::Error { message }], @@ -489,10 +491,10 @@ fn parse_claude_code_user(value: &Value) -> ParsedCliAgentEvent { ParsedCliAgentEvent { events, ..Default::default() } } -fn parse_claude_code_result(value: &Value) -> ParsedCliAgentEvent { +fn parse_claude_code_result(value: &Value, fallback_error: &str) -> ParsedCliAgentEvent { let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or("success"); if subtype != "success" { - let message = claude_code_error_message(value); + let message = claude_code_error_message(value, fallback_error); return ParsedCliAgentEvent { error: Some(message.clone()), events: vec![AgentEvent::Error { message }], @@ -528,14 +530,14 @@ fn claude_content_blocks(content: &Value) -> Vec { } } -fn claude_code_error_message(value: &Value) -> String { +fn claude_code_error_message(value: &Value, fallback_error: &str) -> String { value .get("error") .and_then(Value::as_str) .or_else(|| value.get("message").and_then(Value::as_str)) .or_else(|| value.get("error").and_then(|error| error.get("message")).and_then(Value::as_str)) .or_else(|| value.get("result").and_then(Value::as_str)) - .unwrap_or("Claude Code CLI failed") + .unwrap_or(fallback_error) .to_string() } diff --git a/crates/dbx-core/src/ai_codebuddy_cli.rs b/crates/dbx-core/src/ai_codebuddy_cli.rs new file mode 100644 index 0000000000..80c0884a4b --- /dev/null +++ b/crates/dbx-core/src/ai_codebuddy_cli.rs @@ -0,0 +1,1348 @@ +use crate::agent_events::AgentEvent; +use crate::ai::{ + AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortLevel, AiEffortOption, AiEffortSelection, 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, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}; +use tokio::process::{Child, ChildStdin, ChildStdout}; +use tokio::sync::Notify; + +const CODEBUDDY_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); +const CODEBUDDY_ACP_TIMEOUT: Duration = Duration::from_secs(20); +const CODEBUDDY_SETTING_SOURCES: &str = "user"; +const CODEBUDDY_EXECUTABLE_NAMES: &[&str] = &["codebuddy", "cbc"]; + +pub type CodeBuddyRunOptions = CliAgentRunOptions; +pub type CodeBuddyCommandSpec = CliAgentCommandSpec; + +struct CodeBuddyIsolatedCwd { + path: PathBuf, +} + +impl CodeBuddyIsolatedCwd { + fn create() -> Result { + let path = env::temp_dir().join(format!("dbx-codebuddy-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&path).map_err(|error| { + format!("[codeBuddyRunFailed] Failed to create isolated CodeBuddy Code directory: {error}") + })?; + Ok(Self { path }) + } + + fn write_mcp_config(&self, config: &str) -> Result { + let path = self.path.join("mcp.json"); + std::fs::write(&path, config).map_err(|error| { + format!("[codeBuddyRunFailed] Failed to write temporary CodeBuddy MCP configuration: {error}") + })?; + Ok(path) + } +} + +impl Drop for CodeBuddyIsolatedCwd { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +#[derive(Debug)] +struct CodeBuddyInitializeResult { + models: Vec, + authenticated: bool, + current_model_id: Option, +} + +struct CodeBuddyAcpProcess { + child: Child, + stdin: ChildStdin, + stdout: Lines>, + next_request_id: u64, +} + +impl CodeBuddyAcpProcess { + async fn request(&mut self, method: &str, params: Value) -> Result { + let request_id = self.next_request_id; + self.next_request_id += 1; + let mut request = serde_json::to_vec(&json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + })) + .map_err(|error| format!("[codeBuddyProtocolError] Failed to serialize ACP request: {error}"))?; + request.push(b'\n'); + self.stdin + .write_all(&request) + .await + .map_err(|error| format!("[codeBuddyRunFailed] Failed to write CodeBuddy ACP request: {error}"))?; + self.stdin + .flush() + .await + .map_err(|error| format!("[codeBuddyRunFailed] Failed to flush CodeBuddy ACP request: {error}"))?; + + while let Some(line) = self + .stdout + .next_line() + .await + .map_err(|error| format!("[codeBuddyRunFailed] Failed to read CodeBuddy ACP response: {error}"))? + { + let Ok(response) = serde_json::from_str::(line.trim()) else { + continue; + }; + if response.get("id").and_then(Value::as_u64) != Some(request_id) { + continue; + } + if let Some(error) = response.get("error") { + let message = error + .get("message") + .and_then(Value::as_str) + .map(sanitize_diagnostic) + .filter(|message| !message.is_empty()) + .unwrap_or_else(|| "CodeBuddy ACP request failed".to_string()); + return Err(format!("[codeBuddyProtocolError] {message}")); + } + return response + .get("result") + .cloned() + .ok_or_else(|| "[codeBuddyProtocolError] CodeBuddy ACP response omitted its result.".to_string()); + } + + Err("[codeBuddyProtocolError] CodeBuddy ACP process ended before returning a response.".to_string()) + } + + async fn stop(mut self) { + drop(self.stdin); + let _ = self.child.start_kill(); + let _ = self.child.wait().await; + } +} + +fn append_codebuddy_isolation_args(args: &mut Vec) { + // User settings retain CodeBuddy authentication and account preferences, + // while project/local instruction and hook sources stay excluded. + args.extend(["--setting-sources".to_string(), CODEBUDDY_SETTING_SOURCES.to_string()]); +} + +fn codebuddy_program(config: &AiConfig) -> String { + config + .codebuddy_cli_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .unwrap_or("codebuddy") + .to_string() +} + +fn codebuddy_process_env(config: &AiConfig, command: &CodeBuddyCommandSpec) -> Result, String> { + let mut env = BTreeMap::from_iter(codebuddy_cli_env(config)?); + if let Some(dir) = command_parent_dir(command) { + let user_path = env.get("PATH").map(String::as_str); + env.insert("PATH".to_string(), merged_path_with_dir(&dir, user_path)); + } + Ok(env.into_iter().collect()) +} + +fn command_parent_dir(command: &CodeBuddyCommandSpec) -> Option { + Path::new(&command.program) + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(|parent| parent.to_string_lossy().to_string()) +} + +fn merged_path_with_dir(dir: &str, user_path: Option<&str>) -> String { + let mut seen = BTreeSet::new(); + let mut dirs = vec![PathBuf::from(dir)]; + if let Some(path) = user_path { + dirs.extend(env::split_paths(path)); + } + dirs.extend(common_executable_dirs()); + let paths = dirs.into_iter().filter(|path| seen.insert(path.clone())).collect::>(); + env::join_paths(paths).unwrap_or_default().to_string_lossy().to_string() +} + +fn common_executable_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Ok(path) = env::var("PATH") { + dirs.extend(env::split_paths(&path)); + } + #[cfg(windows)] + { + if let Ok(app_data) = env::var("APPDATA") { + dirs.push(PathBuf::from(app_data).join("npm")); + } + } + #[cfg(not(windows))] + { + dirs.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + PathBuf::from("/usr/sbin"), + PathBuf::from("/sbin"), + ]); + } + dirs +} + +fn validate_codebuddy_program(config: &AiConfig) -> Result { + let program = codebuddy_program(config); + if starts_with_env_assignment(&program) { + return Err("[codeBuddyCliPathInvalid] CodeBuddy Code CLI path should contain only the executable path. Add environment variables in the CodeBuddy environment variables section.".to_string()); + } + if is_path_like_program(&program) { + let expanded = crate::path_utils::expand_tilde(&program); + let path = Path::new(&expanded); + if path.is_dir() { + return CODEBUDDY_EXECUTABLE_NAMES + .iter() + .find_map(|name| launchable_program_in_dir(path, name)) + .ok_or_else(|| { + "[codeBuddyCliPathInvalid] CodeBuddy Code CLI path should point to the codebuddy executable or a directory containing codebuddy." + .to_string() + }); + } + return Ok(expanded); + } + Ok(program) +} + +fn launchable_program_in_dir(dir: &Path, program: &str) -> Option { + program_path_candidates(dir, program) + .into_iter() + .find(|candidate| is_launchable_program_path(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 { + let path = Path::new(program); + if path.extension().is_some() { + return vec![dir.join(program)]; + } + [".cmd", ".exe", ".bat", ".com", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect() +} + +#[cfg(not(windows))] +fn is_launchable_program_path(_path: &Path) -> bool { + true +} + +#[cfg(windows)] +fn is_launchable_program_path(path: &Path) -> bool { + matches!( + path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(), + Some("exe" | "cmd" | "bat" | "com") + ) +} + +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_dbx_mcp_env_name(name: &str) -> bool { + name.to_ascii_uppercase().starts_with("DBX_MCP_") +} + +pub fn codebuddy_cli_env(config: &AiConfig) -> Result, String> { + let mut env = BTreeMap::new(); + for (key, value) in &config.codebuddy_cli_env { + let key = key.trim(); + if key.is_empty() { + continue; + } + if !is_env_var_name(key) { + return Err(format!( + "[codeBuddyEnvInvalid] Invalid CodeBuddy environment variable name `{key}`. Use names like HTTPS_PROXY." + )); + } + if is_reserved_dbx_mcp_env_name(key) { + return Err(format!( + "[codeBuddyEnvReserved] `{key}` is managed by DBX for the scoped MCP server and cannot be set here." + )); + } + env.insert(key.to_string(), value.clone()); + } + Ok(env.into_iter().collect()) +} + +pub fn codebuddy_enabled_tools(agent_mode: bool) -> Vec { + dbx_mcp_enabled_tools(agent_mode).into_iter().map(|tool| format!("mcp__dbx__{tool}")).collect() +} + +fn codebuddy_mcp_config(options: &CodeBuddyRunOptions) -> String { + let mcp_command = + options.mcp_server_command.as_ref().map(|command| command.program.as_str()).unwrap_or("dbx-mcp-server"); + let mut server = Map::new(); + server.insert("type".to_string(), Value::String("stdio".to_string())); + server.insert("command".to_string(), Value::String(mcp_command.to_string())); + if let Some(command) = options.mcp_server_command.as_ref().filter(|command| !command.args.is_empty()) { + server.insert("args".to_string(), json!(command.args)); + } + let env = dbx_mcp_scope_env(options) + .into_iter() + .map(|(name, value)| (name.to_string(), Value::String(value))) + .collect::>(); + server.insert("env".to_string(), Value::Object(env)); + json!({ "mcpServers": { "dbx": Value::Object(server) } }).to_string() +} + +pub fn build_codebuddy_command( + config: &AiConfig, + _prompt: &str, + options: &CodeBuddyRunOptions, +) -> CodeBuddyCommandSpec { + build_codebuddy_command_with_mcp_arg(config, options, codebuddy_mcp_config(options)) +} + +fn build_codebuddy_command_with_mcp_arg( + config: &AiConfig, + options: &CodeBuddyRunOptions, + mcp_config_arg: String, +) -> CodeBuddyCommandSpec { + let enabled_tools = codebuddy_enabled_tools(options.agent_mode); + let mut args = vec![ + "--print".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--input-format".to_string(), + "text".to_string(), + "--no-session-persistence".to_string(), + "--permission-mode".to_string(), + "dontAsk".to_string(), + "--mcp-config".to_string(), + mcp_config_arg, + "--strict-mcp-config".to_string(), + "--settings".to_string(), + json!({ "enabledMcpjsonServers": ["dbx"] }).to_string(), + ]; + append_codebuddy_isolation_args(&mut args); + // Supplying only NoDefer(MCP tool) entries keeps built-in file/shell tools + // out of the model context while making the scoped DBX tools immediately + // available after the temporary server connects. + args.push("--tools".to_string()); + args.push(enabled_tools.iter().map(|tool| format!("NoDefer({tool})")).collect::>().join(",")); + args.push("--allowedTools".to_string()); + args.extend(enabled_tools); + + let model = config.model.trim(); + if !model.is_empty() && !model.eq_ignore_ascii_case("default") { + args.push("--model".to_string()); + args.push(model.to_string()); + } + if let Some(effort) = config.runtime_effort.as_ref().and_then(|effort| effort.cli_value()) { + args.push("--effort".to_string()); + args.push(effort); + } + + CodeBuddyCommandSpec { program: codebuddy_program(config), args } +} + +pub fn build_codebuddy_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String { + build_cli_agent_prompt("CodeBuddy Code", system_prompt, messages, allow_write_sql) +} + +pub async fn list_codebuddy_models(config: &AiConfig) -> Result, String> { + let initialized = initialize_codebuddy(config).await?; + if !initialized.authenticated { + return Err("[codeBuddyNotAuthenticated] CodeBuddy Code is not authenticated. Open `codebuddy` in a terminal, sign in, and try again.".to_string()); + } + if initialized.models.is_empty() { + return Err("[codeBuddyProtocolError] CodeBuddy initialization returned no usable models.".to_string()); + } + Ok(initialized.models) +} + +async fn initialize_codebuddy(config: &AiConfig) -> Result { + let program = validate_codebuddy_program(config)?; + let mut command = CodeBuddyCommandSpec { + program, + args: vec![ + "--print".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--no-session-persistence".to_string(), + ], + }; + append_codebuddy_isolation_args(&mut command.args); + let env = codebuddy_process_env(config, &command)?; + let isolated_cwd = CodeBuddyIsolatedCwd::create()?; + let mut process = cli_command(&command.program); + process + .args(command.args.iter().map(String::as_str)) + .envs(env.iter().map(|(key, value)| (key.as_str(), value.as_str()))) + .env_remove("CLAUDECODE") + .current_dir(&isolated_cwd.path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = process.spawn().map_err(|error| classify_codebuddy_spawn_error(&error.to_string()))?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "[codeBuddyProtocolError] CodeBuddy initialization stdin is unavailable.".to_string())?; + let mut request = serde_json::to_vec(&json!({ + "type": "control_request", + "request_id": "dbx_model_discovery", + "request": { "subtype": "initialize" } + })) + .map_err(|error| format!("[codeBuddyProtocolError] Failed to serialize initialization request: {error}"))?; + request.push(b'\n'); + stdin + .write_all(&request) + .await + .map_err(|error| format!("[codeBuddyRunFailed] Failed to write CodeBuddy initialization request: {error}"))?; + stdin + .shutdown() + .await + .map_err(|error| format!("[codeBuddyRunFailed] Failed to close CodeBuddy initialization stdin: {error}"))?; + drop(stdin); + + let output = tokio::time::timeout(CODEBUDDY_INITIALIZE_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| "[codeBuddyTimeout] CodeBuddy initialization timed out.".to_string())? + .map_err(|error| format!("[codeBuddyRunFailed] Failed to wait for CodeBuddy initialization: {error}"))?; + if !output.status.success() { + return Err(classify_codebuddy_run_error(&sanitize_diagnostic(&String::from_utf8_lossy(&output.stderr)))); + } + + parse_codebuddy_initialize(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| { + "[codeBuddyProtocolError] CodeBuddy initialization returned no valid control response.".to_string() + }) +} + +fn parse_codebuddy_initialize(stdout: &str) -> Option { + for line in stdout.lines() { + let Ok(event) = serde_json::from_str::(line.trim()) else { + continue; + }; + if event.get("type").and_then(Value::as_str) != Some("control_response") { + continue; + } + let Some(response) = event.get("response") else { + continue; + }; + let data = response.get("response").unwrap_or(response); + let models = parse_codebuddy_models(data.get("models").and_then(Value::as_array).map(Vec::as_slice)); + let authenticated = parse_account_auth_state(data.get("account")).unwrap_or(!models.is_empty()); + let current_model_id = data + .get("currentModelId") + .or_else(|| data.get("current_model_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(normalize_codebuddy_model_id); + return Some(CodeBuddyInitializeResult { models, authenticated, current_model_id }); + } + None +} + +fn parse_codebuddy_models(models: Option<&[Value]>) -> Vec { + let Some(models) = models else { + return Vec::new(); + }; + let mut seen = BTreeSet::new(); + let mut result = Vec::new(); + for model in models { + let Some(raw_id) = model + .get("value") + .and_then(Value::as_str) + .or_else(|| model.get("id").and_then(Value::as_str)) + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + continue; + }; + let id = normalize_codebuddy_model_id(raw_id); + if !seen.insert(id.clone()) { + continue; + } + let display_name = model + .get("displayName") + .and_then(Value::as_str) + .or_else(|| model.get("display_name").and_then(Value::as_str)) + .or_else(|| model.get("name").and_then(Value::as_str)) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string); + let mut info = AiModelInfo::new(&id, display_name); + let effort_levels = parse_codebuddy_effort_level_strings(model); + info.effort_capability = + if model.get("supportsEffort").or_else(|| model.get("supports_effort")).and_then(Value::as_bool) + == Some(false) + { + Some(AiEffortCapability::Unsupported) + } else { + crate::ai_effort::dynamic_enum_capability(effort_levels, AiCapabilitySource::LocalCli) + }; + info.supported_effort_levels = codebuddy_legacy_effort_levels(info.effort_capability.as_ref()); + result.push(info); + } + if !result.is_empty() && seen.insert("default".to_string()) { + result.insert(0, AiModelInfo::new("default", Some("Default".to_string()))); + } + result +} + +pub async fn resolve_codebuddy_model_effort(config: &AiConfig, model_id: &str) -> Result { + let model_id = model_id.trim(); + if model_id.is_empty() { + return Err("Model is required".to_string()); + } + + tokio::time::timeout(CODEBUDDY_ACP_TIMEOUT, resolve_codebuddy_model_effort_inner(config, model_id)) + .await + .map_err(|_| "[codeBuddyTimeout] CodeBuddy effort discovery timed out.".to_string())? +} + +async fn resolve_codebuddy_model_effort_inner(config: &AiConfig, model_id: &str) -> Result { + let isolated_cwd = CodeBuddyIsolatedCwd::create()?; + let mcp_config_path = isolated_cwd.write_mcp_config(r#"{"mcpServers":{}}"#)?; + let mut acp = spawn_codebuddy_acp(config, &isolated_cwd.path, &mcp_config_path)?; + let result = async { + acp.request( + "initialize", + json!({ + "protocolVersion": 1, + "clientCapabilities": { + "fs": { "readTextFile": false, "writeTextFile": false }, + "terminal": false, + }, + "clientInfo": { "name": "DBX", "version": env!("CARGO_PKG_VERSION") }, + }), + ) + .await?; + let session = acp + .request( + "session/new", + json!({ + "cwd": isolated_cwd.path.to_string_lossy(), + "mcpServers": [], + }), + ) + .await?; + + let config_result = if model_id.eq_ignore_ascii_case("default") { + session + } else { + let session_id = session + .get("sessionId") + .or_else(|| session.get("session_id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) + .ok_or_else(|| { + "[codeBuddyProtocolError] CodeBuddy ACP session response omitted its session ID.".to_string() + })?; + acp.request( + "session/set_config_option", + json!({ + "sessionId": session_id, + "configId": "model", + "value": model_id, + }), + ) + .await? + }; + + Ok(parse_codebuddy_acp_effort_capability(&config_result)) + } + .await; + acp.stop().await; + result +} + +fn spawn_codebuddy_acp(config: &AiConfig, cwd: &Path, mcp_config_path: &Path) -> Result { + let program = validate_codebuddy_program(config)?; + let mut command = CodeBuddyCommandSpec { + program, + args: vec![ + "--acp".to_string(), + "--acp-transport".to_string(), + "stdio".to_string(), + "--no-session-persistence".to_string(), + "--mcp-config".to_string(), + mcp_config_path.to_string_lossy().to_string(), + "--strict-mcp-config".to_string(), + ], + }; + append_codebuddy_isolation_args(&mut command.args); + let env = codebuddy_process_env(config, &command)?; + let mut process = cli_command(&command.program); + process + .args(command.args.iter().map(String::as_str)) + .envs(env.iter().map(|(key, value)| (key.as_str(), value.as_str()))) + .env_remove("CLAUDECODE") + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = process.spawn().map_err(|error| classify_codebuddy_spawn_error(&error.to_string()))?; + let stdin = + child.stdin.take().ok_or_else(|| "[codeBuddyProtocolError] CodeBuddy ACP stdin is unavailable.".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "[codeBuddyProtocolError] CodeBuddy ACP stdout is unavailable.".to_string())?; + Ok(CodeBuddyAcpProcess { child, stdin, stdout: BufReader::new(stdout).lines(), next_request_id: 0 }) +} + +fn parse_codebuddy_acp_effort_capability(result: &Value) -> AiEffortCapability { + let Some(options) = result + .get("configOptions") + .or_else(|| result.get("config_options")) + .and_then(Value::as_array) + .and_then(|options| { + options.iter().find(|option| { + option + .get("id") + .or_else(|| option.get("configId")) + .or_else(|| option.get("config_id")) + .and_then(Value::as_str) + .is_some_and(|id| id.eq_ignore_ascii_case("thought_level")) + }) + }) + .and_then(|option| option.get("options")) + .and_then(Value::as_array) + else { + return AiEffortCapability::Unsupported; + }; + + let mut seen = BTreeSet::new(); + let options = options + .iter() + .filter_map(|option| { + let id = option.get("value").or_else(|| option.get("id")).and_then(Value::as_str)?.trim(); + if id.is_empty() + || matches!(id.to_ascii_lowercase().as_str(), "enabled" | "disabled") + || !seen.insert(id.to_string()) + { + return None; + } + let label = option + .get("name") + .or_else(|| option.get("label")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|label| !label.is_empty()) + .unwrap_or(id) + .to_string(); + let description = option + .get("description") + .and_then(Value::as_str) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(ToString::to_string); + Some(AiEffortOption { + id: id.to_string(), + label, + description, + selection: AiEffortSelection::Enum(id.to_string()), + }) + }) + .collect::>(); + + if options.is_empty() { + AiEffortCapability::Unsupported + } else { + AiEffortCapability::Enum { + options, + default: AiEffortSelection::ProviderDefault, + source: AiCapabilitySource::LocalCli, + } + } +} + +fn codebuddy_legacy_effort_levels(capability: Option<&AiEffortCapability>) -> Vec { + let Some(AiEffortCapability::Enum { options, .. }) = capability else { + return Vec::new(); + }; + options.iter().filter_map(|option| option.id.parse::().ok()).collect() +} + +fn normalize_codebuddy_model_id(id: &str) -> String { + if id.eq_ignore_ascii_case("default-model") { + "default".to_string() + } else { + id.to_string() + } +} + +fn parse_codebuddy_effort_level_strings(model: &Value) -> Vec { + if model.get("supportsEffort").or_else(|| model.get("supports_effort")).and_then(Value::as_bool) == Some(false) { + return Vec::new(); + } + let Some(levels) = + model.get("supportedEffortLevels").or_else(|| model.get("supported_effort_levels")).and_then(Value::as_array) + else { + return Vec::new(); + }; + let mut seen = BTreeSet::new(); + levels + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|level| !level.is_empty()) + .filter(|level| seen.insert((*level).to_string())) + .map(ToString::to_string) + .collect() +} + +fn parse_account_auth_state(account: Option<&Value>) -> Option { + match account? { + Value::Null => Some(false), + Value::Bool(value) => Some(*value), + Value::Object(fields) => fields + .get("authenticated") + .or_else(|| fields.get("isAuthenticated")) + .or_else(|| fields.get("is_authenticated")) + .or_else(|| fields.get("loggedIn")) + .or_else(|| fields.get("logged_in")) + .and_then(Value::as_bool) + .or_else(|| Some(!fields.is_empty())), + _ => None, + } +} + +pub async fn test_codebuddy_connection(config: &AiConfig) -> Result { + let start = Instant::now(); + let initialized = initialize_codebuddy(config).await?; + if !initialized.authenticated { + return Err("[codeBuddyNotAuthenticated] CodeBuddy Code is not authenticated. Open `codebuddy` in a terminal, sign in, and try again.".to_string()); + } + let elapsed = start.elapsed().as_millis() as u64; + let model_used = initialized.current_model_id.unwrap_or_else(|| config.model.trim().to_string()).trim().to_string(); + Ok(AiTestConnectionResult { + success: true, + message: format!("OK - {elapsed}ms"), + latency_ms: Some(elapsed), + model_used, + error_category: None, + }) +} + +fn classify_codebuddy_spawn_error(message: &str) -> String { + if message.contains("No such file") || message.contains("not found") || message.contains("os error 2") { + format!("[codeBuddyNotInstalled] CodeBuddy Code CLI was not found. Install it or set its executable path in DBX AI settings. {message}") + } else { + format!("[codeBuddyRunFailed] Failed to start CodeBuddy Code CLI: {message}") + } +} + +fn classify_codebuddy_run_error(diagnostic: &str) -> String { + let diagnostic = sanitize_diagnostic(diagnostic); + let lower = diagnostic.to_ascii_lowercase(); + if lower.contains("invalid mcp configuration") || lower.contains("mcp config file not found") { + format!("[codeBuddyMcpConfigInvalid] {diagnostic}") + } else if lower.contains("not authenticated") + || lower.contains("authentication required") + || lower.contains("please login") + || lower.contains("please log in") + || lower.contains("unauthorized") + { + format!("[codeBuddyNotAuthenticated] {diagnostic}") + } else if lower.contains("dbx-mcp-server") || lower.contains("enoent") { + format!("[dbxMcpMissing] {diagnostic}") + } else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) { + format!("[codeBuddyMcpStartupFailed] {diagnostic}") + } else if lower.contains("protocol") || lower.contains("invalid json") || lower.contains("parse") { + format!("[codeBuddyProtocolError] {diagnostic}") + } else { + format!("[codeBuddyRunFailed] {diagnostic}") + } +} + +fn sanitize_diagnostic(value: &str) -> String { + let value = value.trim(); + if value.is_empty() { + return "CodeBuddy Code CLI exited without diagnostics.".to_string(); + } + value.chars().take(2_000).collect() +} + +pub fn parse_codebuddy_jsonl_event(line: &str) -> Option> { + parse_cli_jsonl_event(line, CliAgentJsonlDialect::CodeBuddyPrint) +} + +pub async fn run_codebuddy_agent( + config: &AiConfig, + prompt: &str, + options: CodeBuddyRunOptions, + cancelled: &Notify, + on_event: impl Fn(AgentEvent) + Send + Sync + 'static, +) -> Result { + let program = validate_codebuddy_program(config)?; + let isolated_cwd = CodeBuddyIsolatedCwd::create()?; + let mcp_config_path = isolated_cwd.write_mcp_config(&codebuddy_mcp_config(&options))?; + let mut command = + build_codebuddy_command_with_mcp_arg(config, &options, mcp_config_path.to_string_lossy().to_string()); + command.program = program; + let env = codebuddy_process_env(config, &command)?; + run_cli_jsonl_agent( + CliAgentProcessSpec { + command, + env, + env_remove: vec!["CLAUDECODE".to_string()], + current_dir: Some(isolated_cwd.path.clone()), + stdin: Some(prompt.to_string()), + dialect: CliAgentJsonlDialect::CodeBuddyPrint, + classify_spawn_error: classify_codebuddy_spawn_error, + classify_run_error: classify_codebuddy_run_error, + }, + cancelled, + on_event, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::{ + build_codebuddy_command, classify_codebuddy_run_error, codebuddy_cli_env, codebuddy_enabled_tools, + parse_codebuddy_acp_effort_capability, parse_codebuddy_initialize, parse_codebuddy_jsonl_event, + validate_codebuddy_program, CodeBuddyRunOptions, + }; + #[cfg(unix)] + use super::{ + list_codebuddy_models, resolve_codebuddy_model_effort, run_codebuddy_agent, test_codebuddy_connection, + }; + use crate::agent_events::AgentEvent; + use crate::ai::{ + AiApiStyle, AiAuthMethod, AiConfig, AiEffortCapability, AiEffortSelection, AiProvider, AiReasoningLevel, + }; + use crate::ai_cli_agent::CliAgentCommandSpec; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use std::sync::{Arc, Mutex}; + #[cfg(unix)] + use std::time::Duration; + #[cfg(unix)] + use tokio::sync::Notify; + + fn codebuddy_config(model: &str) -> AiConfig { + AiConfig { + provider: AiProvider::CodeBuddyCli, + 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(), + grok_cli_path: None, + grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), + } + } + + fn run_options() -> CodeBuddyRunOptions { + CodeBuddyRunOptions { + connection_id: "conn-1".to_string(), + connection_name: "local".to_string(), + database: "demo".to_string(), + schema: None, + agent_mode: true, + allow_writes: false, + allow_dangerous: false, + confirmed_write_sql: None, + mcp_server_command: None, + } + } + + #[test] + fn builds_scoped_non_persistent_command() { + let spec = build_codebuddy_command(&codebuddy_config("default"), "hello", &run_options()); + assert_eq!(spec.program, "codebuddy"); + assert!(spec.args.windows(2).any(|args| args == ["--output-format", "stream-json"])); + assert!(spec.args.windows(2).any(|args| args == ["--setting-sources", "user"])); + assert!(spec.args.windows(2).any(|args| args == ["--permission-mode", "dontAsk"])); + assert!(spec.args.contains(&"--no-session-persistence".to_string())); + assert!(spec.args.contains(&"--strict-mcp-config".to_string())); + assert!(spec.args.windows(2).any(|args| args[0] == "--settings" && args[1].contains("enabledMcpjsonServers"))); + assert!(!spec.args.contains(&"hello".to_string())); + assert!(!spec.args.contains(&"--model".to_string())); + assert!(!spec.args.contains(&"--effort".to_string())); + assert!(spec.args.iter().any(|arg| arg.contains("\"DBX_MCP_SCOPE_CONNECTION_ID\":\"conn-1\""))); + assert!(spec.args.iter().any(|arg| arg.contains("\"type\":\"stdio\""))); + let tools_index = spec.args.iter().position(|arg| arg == "--tools").unwrap(); + assert!(spec.args[tools_index + 1].contains("NoDefer(mcp__dbx__dbx_execute_query)")); + assert!(!spec.args[tools_index + 1].contains("Bash")); + let allowed_tools_index = spec.args.iter().position(|arg| arg == "--allowedTools").unwrap(); + assert!(spec.args[allowed_tools_index + 1..].iter().any(|arg| arg == "mcp__dbx__dbx_execute_query")); + } + + #[test] + fn builds_explicit_model_effort_and_ask_tool_policy() { + let mut config = codebuddy_config("kimi-k2.5"); + config.runtime_effort = Some(AiEffortSelection::Enum("high".to_string())); + let mut options = run_options(); + options.agent_mode = false; + options.mcp_server_command = Some(CliAgentCommandSpec { + program: "/opt/dbx/bin/dbx-mcp-server".to_string(), + args: vec!["--stdio".to_string()], + }); + let spec = build_codebuddy_command(&config, "hello", &options); + assert!(spec.args.windows(2).any(|args| args == ["--model", "kimi-k2.5"])); + assert!(spec.args.windows(2).any(|args| args == ["--effort", "high"])); + assert!(spec.args.iter().any(|arg| arg.contains("\"command\":\"/opt/dbx/bin/dbx-mcp-server\""))); + assert!(!codebuddy_enabled_tools(false).iter().any(|tool| tool == "mcp__dbx__dbx_execute_query")); + } + + #[test] + fn validates_path_and_environment() { + let mut config = codebuddy_config("default"); + config.codebuddy_cli_path = Some("HTTPS_PROXY=http://proxy:1 codebuddy".to_string()); + assert!(validate_codebuddy_program(&config).unwrap_err().contains("[codeBuddyCliPathInvalid]")); + config.codebuddy_cli_path = None; + config.codebuddy_cli_env.insert("HTTPS_PROXY".to_string(), "http://proxy:9800".to_string()); + assert_eq!( + codebuddy_cli_env(&config).unwrap(), + vec![("HTTPS_PROXY".to_string(), "http://proxy:9800".to_string())] + ); + config.codebuddy_cli_env.insert("DBX_MCP_ALLOW_WRITES".to_string(), "1".to_string()); + assert!(codebuddy_cli_env(&config).unwrap_err().contains("[codeBuddyEnvReserved]")); + } + + #[test] + fn parses_nested_models_without_retaining_account_payload() { + let result = parse_codebuddy_initialize( + r#"{"type":"control_response","response":{"response":{"models":[{"id":"default-model","name":"Default model"},{"id":"kimi-k2.5","name":"Kimi K2.5","supportsEffort":true,"supportedEffortLevels":["low","future","low"]},{"id":"kimi-k2.5","name":"duplicate"},{"id":" "}],"commands":[{"name":"/effort","description":"Set model effort"}],"account":{"authenticated":true,"opaqueField":"must-not-escape"},"currentModelId":"kimi-k2.5"}}}"#, + ) + .unwrap(); + assert!(result.authenticated); + assert_eq!(result.current_model_id.as_deref(), Some("kimi-k2.5")); + assert_eq!(result.models.iter().map(|model| model.id.as_str()).collect::>(), ["default", "kimi-k2.5"]); + assert_eq!(result.models[0].effort_capability, None); + let capability = result.models[1].effort_capability.as_ref().unwrap(); + assert!( + matches!(capability, AiEffortCapability::Enum { options, .. } if options.iter().map(|option| option.id.as_str()).collect::>() == ["low", "future"]) + ); + } + + #[test] + fn parses_flat_response_and_explicit_signed_out_state() { + let result = parse_codebuddy_initialize( + r#"{"type":"control_response","response":{"models":[{"value":"model-a","displayName":"Model A"}],"commands":["effort"],"account":{"loggedIn":false}}}"#, + ) + .unwrap(); + assert!(!result.authenticated); + assert_eq!(result.models.iter().map(|model| model.id.as_str()).collect::>(), ["default", "model-a"]); + assert!(result.models.iter().all(|model| model.effort_capability.is_none())); + } + + #[test] + fn only_uses_explicit_effort_metadata_from_print_initialize() { + let result = parse_codebuddy_initialize( + r#"{"type":"control_response","response":{"models":[{"id":"model-a"},{"id":"model-b","supportsEffort":false}],"commands":[{"command":"/EFFORT"}],"account":true}}"#, + ) + .unwrap(); + assert_eq!(result.models[0].effort_capability, None); + assert_eq!(result.models[1].effort_capability, None); + assert_eq!(result.models[2].effort_capability, Some(AiEffortCapability::Unsupported)); + } + + #[test] + fn parses_dynamic_acp_effort_options_without_synthetic_values() { + let capability = parse_codebuddy_acp_effort_capability(&serde_json::json!({ + "configOptions": [ + { + "id": "model", + "options": [{ "value": "model-a", "name": "Model A" }] + }, + { + "id": "thought_level", + "options": [ + { "value": "enabled", "name": "Auto" }, + { "value": "disabled", "name": "Off" }, + { "value": "minimal", "name": "Minimal", "description": "Small budget" }, + { "value": "future", "name": "Future" }, + { "value": "minimal", "name": "Duplicate" } + ] + } + ] + })); + assert!(matches!( + capability, + AiEffortCapability::Enum { options, default: AiEffortSelection::ProviderDefault, source: crate::ai::AiCapabilitySource::LocalCli } + if options.iter().map(|option| option.id.as_str()).collect::>() == ["minimal", "future"] + && options[0].label == "Minimal" + && options[0].description.as_deref() == Some("Small budget") + )); + } + + #[test] + fn treats_missing_or_non_executable_acp_effort_options_as_unsupported() { + assert_eq!( + parse_codebuddy_acp_effort_capability(&serde_json::json!({ "configOptions": [] })), + AiEffortCapability::Unsupported + ); + assert_eq!( + parse_codebuddy_acp_effort_capability(&serde_json::json!({ + "configOptions": [{ + "id": "thought_level", + "options": [{ "value": "enabled" }, { "value": "disabled" }] + }] + })), + AiEffortCapability::Unsupported + ); + } + + #[test] + fn does_not_invent_default_model_for_an_empty_catalog() { + let result = parse_codebuddy_initialize( + r#"{"type":"control_response","response":{"models":[],"account":{"authenticated":true}}}"#, + ) + .unwrap(); + assert!(result.models.is_empty()); + } + + #[test] + fn reuses_claude_compatible_stream_events() { + let events = parse_codebuddy_jsonl_event( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"done"},{"type":"tool_use","id":"call-1","name":"mcp__dbx__dbx_list_tables","input":{}}]}}"#, + ) + .unwrap(); + assert!(matches!(&events[0], AgentEvent::TextDelta { delta } if delta == "done")); + assert!(matches!(&events[1], AgentEvent::ToolCallStart { tool_call_id, .. } if tool_call_id == "call-1")); + + let error = parse_codebuddy_jsonl_event(r#"{"type":"error"}"#).unwrap(); + assert!(matches!(&error[0], AgentEvent::Error { message } if message == "CodeBuddy Code CLI failed")); + } + + #[test] + fn classifies_codebuddy_specific_runtime_failures() { + assert!(classify_codebuddy_run_error("invalid MCP configuration").starts_with("[codeBuddyMcpConfigInvalid]")); + assert!(classify_codebuddy_run_error("Please log in first").starts_with("[codeBuddyNotAuthenticated]")); + assert!(classify_codebuddy_run_error("dbx-mcp-server: ENOENT").starts_with("[dbxMcpMissing]")); + assert!( + classify_codebuddy_run_error("MCP dbx server failed to connect").starts_with("[codeBuddyMcpStartupFailed]") + ); + assert!(classify_codebuddy_run_error("invalid JSON protocol response").starts_with("[codeBuddyProtocolError]")); + assert!(classify_codebuddy_run_error("").starts_with("[codeBuddyRunFailed]")); + } + + #[cfg(unix)] + fn isolated_cli_test_config() -> (AiConfig, std::path::PathBuf, std::path::PathBuf) { + let project_dir = std::env::temp_dir().join(format!("dbx-codebuddy-project-test-{}", uuid::Uuid::new_v4())); + let project_settings = project_dir.join(".codebuddy"); + let auth_dir = project_dir.join("user-config"); + let hook_marker = project_dir.join("project-hook-loaded"); + std::fs::create_dir_all(&project_settings).unwrap(); + std::fs::create_dir_all(&auth_dir).unwrap(); + std::fs::write(auth_dir.join("auth-marker"), "authenticated").unwrap(); + std::fs::write(project_settings.join("project-marker"), "must-not-load").unwrap(); + + let executable = project_dir.join("codebuddy"); + std::fs::write( + &executable, + r#"#!/bin/sh +user_settings=false +mcp_config="" +previous="" +for arg in "$@"; do + if [ "$previous" = "--setting-sources" ] && [ "$arg" = "user" ]; then + user_settings=true + elif [ "$previous" = "--mcp-config" ]; then + mcp_config="$arg" + fi + previous="$arg" +done +if [ "$user_settings" != "true" ] || [ "$PWD" = "$DBX_TEST_PROJECT_DIR" ]; then + printf loaded > "$DBX_TEST_HOOK_MARKER" +fi +if [ ! -f "$CODEBUDDY_CONFIG_DIR/auth-marker" ]; then + printf '%s\n' 'not authenticated' >&2 + exit 9 +fi +case " $* " in + *" --acp "*) + if [ ! -f "$mcp_config" ] || ! grep -q '"mcpServers"' "$mcp_config"; then + printf '%s\n' 'invalid MCP configuration' >&2 + exit 10 + fi + while IFS= read -r line; do + case "$line" in + *'"method":"initialize"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1}}' + ;; + *'"method":"session/new"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"session-1","configOptions":[{"id":"model","options":[{"value":"codebuddy-user-model","name":"User Model"}]},{"id":"thought_level","options":[{"value":"enabled","name":"Auto"},{"value":"low","name":"Low"},{"value":"high","name":"High"}]}]}}' + ;; + *'"method":"session/set_config_option"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"configOptions":[{"id":"model","currentValue":"codebuddy-user-model"},{"id":"thought_level","options":[{"value":"enabled","name":"Auto"},{"value":"medium","name":"Medium"},{"value":"xhigh","name":"Extra high"}]}]}}' + ;; + esac + done + exit 0 + ;; +esac +input=$(cat) +case " $* " in + *" --input-format stream-json "*) + printf '%s\n' '{"type":"control_response","response":{"response":{"models":[{"id":"default-model","name":"Default"},{"id":"codebuddy-user-model","name":"User Model"}],"commands":[{"name":"/effort"}],"account":{"authenticated":true},"currentModelId":"codebuddy-user-model"}}}' + ;; + *) + if [ ! -f "$mcp_config" ] || ! grep -q '"mcpServers"' "$mcp_config"; then + printf '%s\n' 'invalid MCP configuration' >&2 + exit 10 + fi + printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"isolated execution"}]}}' + printf '%s\n' '{"type":"result","subtype":"success"}' + ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + + let mut config = codebuddy_config("default"); + config.codebuddy_cli_path = Some(executable.to_string_lossy().to_string()); + config.codebuddy_cli_env.insert("CODEBUDDY_CONFIG_DIR".to_string(), auth_dir.to_string_lossy().to_string()); + config.codebuddy_cli_env.insert("DBX_TEST_PROJECT_DIR".to_string(), project_dir.to_string_lossy().to_string()); + config.codebuddy_cli_env.insert("DBX_TEST_HOOK_MARKER".to_string(), hook_marker.to_string_lossy().to_string()); + (config, project_dir, hook_marker) + } + + #[cfg(unix)] + #[test] + fn resolves_codebuddy_executable_from_a_configured_directory() { + let (mut config, project_dir, _) = isolated_cli_test_config(); + config.codebuddy_cli_path = Some(project_dir.to_string_lossy().to_string()); + assert_eq!(validate_codebuddy_program(&config).unwrap(), project_dir.join("codebuddy").to_string_lossy()); + let _ = std::fs::remove_dir_all(project_dir); + } + + #[cfg(unix)] + #[tokio::test] + async fn discovery_and_connection_test_use_initialize_without_inference() { + let (config, project_dir, hook_marker) = isolated_cli_test_config(); + let models = list_codebuddy_models(&config).await.unwrap(); + assert_eq!( + models.iter().map(|model| model.id.as_str()).collect::>(), + ["default", "codebuddy-user-model"] + ); + assert!(models.iter().all(|model| model.effort_capability.is_none())); + let result = test_codebuddy_connection(&config).await.unwrap(); + assert!(result.success); + assert_eq!(result.model_used, "codebuddy-user-model"); + let default_effort = resolve_codebuddy_model_effort(&config, "default").await.unwrap(); + assert!(matches!( + default_effort, + AiEffortCapability::Enum { options, default: AiEffortSelection::ProviderDefault, .. } + if options.iter().map(|option| option.id.as_str()).collect::>() == ["low", "high"] + )); + let explicit_effort = crate::ai::resolve_model_effort_core(&config, "codebuddy-user-model").await.unwrap(); + assert!(matches!( + explicit_effort, + AiEffortCapability::Enum { options, default: AiEffortSelection::ProviderDefault, .. } + if options.iter().map(|option| option.id.as_str()).collect::>() == ["medium", "xhigh"] + )); + assert!(!hook_marker.exists()); + let _ = std::fs::remove_dir_all(project_dir); + } + + #[cfg(unix)] + #[tokio::test] + async fn execution_uses_temporary_mcp_config_from_isolated_directory() { + let (config, project_dir, hook_marker) = isolated_cli_test_config(); + let output = run_codebuddy_agent(&config, "hello", run_options(), &Notify::new(), |_| {}).await.unwrap(); + assert_eq!(output, "isolated execution"); + assert!(!hook_marker.exists()); + let _ = std::fs::remove_dir_all(project_dir); + } + + #[cfg(unix)] + fn live_config() -> AiConfig { + let mut config = + codebuddy_config(&std::env::var("DBX_LIVE_CODEBUDDY_MODEL").unwrap_or_else(|_| "default".to_string())); + config.codebuddy_cli_path = std::env::var("DBX_LIVE_CODEBUDDY_PATH").ok(); + config + } + + #[cfg(unix)] + #[tokio::test] + #[ignore = "requires an installed and authenticated CodeBuddy Code CLI"] + async fn live_model_discovery_and_connection_test() { + let config = live_config(); + let models = list_codebuddy_models(&config).await.unwrap(); + assert!(models.iter().any(|model| model.id == "default")); + let default_effort = crate::ai::resolve_model_effort_core(&config, "default").await.unwrap(); + assert!(matches!( + default_effort, + AiEffortCapability::Enum { ref options, default: AiEffortSelection::ProviderDefault, source: crate::ai::AiCapabilitySource::LocalCli } + if !options.is_empty() + )); + if let Some(model) = models.iter().find(|model| model.id != "default") { + let explicit_effort = crate::ai::resolve_model_effort_core(&config, &model.id).await.unwrap(); + assert!(matches!( + explicit_effort, + AiEffortCapability::Enum { ref options, default: AiEffortSelection::ProviderDefault, source: crate::ai::AiCapabilitySource::LocalCli } + if !options.is_empty() + )); + } + let result = test_codebuddy_connection(&config).await.unwrap(); + assert!(result.success); + assert!(result.latency_ms.is_some()); + } + + #[cfg(unix)] + #[tokio::test] + #[ignore = "requires CodeBuddy Code, 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 = CodeBuddyRunOptions { + 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(CliAgentCommandSpec { 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_codebuddy_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("CodeBuddy MCP smoke test timed out") + .unwrap(); + + let events = events.lock().unwrap(); + assert!( + events.iter().any( + |event| matches!(event, AgentEvent::ToolCallStart { tool_name, .. } if tool_name.contains("dbx_list_connections")) + ), + "CodeBuddy did not emit a DBX tool-call start; result={result:?}, events={events:#?}" + ); + assert!( + events.iter().any(|event| matches!(event, AgentEvent::ToolCallEnd { is_error: false, .. })), + "CodeBuddy did not emit a successful DBX tool-call end; result={result:?}, events={events:#?}" + ); + assert!( + result.contains(&connection_name), + "CodeBuddy did not include the scoped connection name in its final response: {result:?}" + ); + } + + #[cfg(unix)] + #[tokio::test] + #[ignore = "requires CodeBuddy Code, a running DBX MCP bridge, and a seeded DBX_LIVE_MCP connection"] + async fn live_scoped_dbx_mcp_query() { + 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 expected = std::env::var("DBX_LIVE_MCP_EXPECTED_VALUE").expect("set DBX_LIVE_MCP_EXPECTED_VALUE"); + let mcp_command = std::env::var("DBX_LIVE_MCP_COMMAND").unwrap_or_else(|_| "dbx-mcp-server".to_string()); + let options = CodeBuddyRunOptions { + connection_id: String::new(), + connection_name, + database, + schema: None, + agent_mode: true, + allow_writes: false, + allow_dangerous: false, + confirmed_write_sql: None, + mcp_server_command: Some(CliAgentCommandSpec { 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_codebuddy_agent( + &config, + "Use the DBX MCP execute-query tool to run exactly this read-only SQL: SELECT metric_value FROM codebuddy_e2e_metrics WHERE metric_name = 'verified_rows'. Reply with the returned numeric value only.", + options, + &Notify::new(), + move |event| captured.lock().unwrap().push(event), + ), + ) + .await + .expect("CodeBuddy database-query smoke test timed out") + .unwrap(); + + let events = events.lock().unwrap(); + assert!( + events.iter().any( + |event| matches!(event, AgentEvent::ToolCallStart { tool_name, .. } if tool_name.contains("dbx_execute_query")) + ), + "CodeBuddy did not invoke DBX execute-query; result={result:?}, events={events:#?}" + ); + assert!(events.iter().any(|event| matches!(event, AgentEvent::ToolCallEnd { is_error: false, .. }))); + assert!(result.contains(&expected), "CodeBuddy did not return the seeded value; result={result:?}"); + } +} diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index 44896a1ab1..b948656622 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -1044,6 +1044,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_cursor_cli.rs b/crates/dbx-core/src/ai_cursor_cli.rs index e7057d28d4..0158a6a09b 100644 --- a/crates/dbx-core/src/ai_cursor_cli.rs +++ b/crates/dbx-core/src/ai_cursor_cli.rs @@ -424,6 +424,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs index 783d3af2c6..7832f7668c 100644 --- a/crates/dbx-core/src/ai_effort.rs +++ b/crates/dbx-core/src/ai_effort.rs @@ -88,7 +88,8 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option None, + | AiProvider::GrokCli + | AiProvider::CodeBuddyCli => None, } } @@ -196,6 +197,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> { | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli + | AiProvider::CodeBuddyCli | AiProvider::Custom => None, } } @@ -217,6 +219,7 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> { | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli + | AiProvider::CodeBuddyCli ) { return match selection { AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()), @@ -274,7 +277,8 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) { | AiProvider::PiAgentCli | AiProvider::OpenCodeCli | AiProvider::CursorCli - | AiProvider::GrokCli => {} + | AiProvider::GrokCli + | AiProvider::CodeBuddyCli => {} } } @@ -434,6 +438,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_grok_cli.rs b/crates/dbx-core/src/ai_grok_cli.rs index aee02921ac..23248e4ba2 100644 --- a/crates/dbx-core/src/ai_grok_cli.rs +++ b/crates/dbx-core/src/ai_grok_cli.rs @@ -571,6 +571,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_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 7010c03737..3e9a72db29 100644 --- a/crates/dbx-core/src/ai_model_filter.rs +++ b/crates/dbx-core/src/ai_model_filter.rs @@ -99,6 +99,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli + | AiProvider::CodeBuddyCli | 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 acab375639..da9c6dd05c 100644 --- a/crates/dbx-core/src/ai_opencode_cli.rs +++ b/crates/dbx-core/src/ai_opencode_cli.rs @@ -470,6 +470,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_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 1fa4cd0986..d199959c3b 100644 --- a/crates/dbx-core/src/ai_pi_agent_cli.rs +++ b/crates/dbx-core/src/ai_pi_agent_cli.rs @@ -878,6 +878,8 @@ mod tests { cursor_cli_env: HashMap::new(), grok_cli_path: None, grok_cli_env: HashMap::new(), + codebuddy_cli_path: None, + codebuddy_cli_env: HashMap::new(), } } diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index 9228d3834d..932ceec8a5 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -1594,6 +1594,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }, } } diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index f86db4af11..4a97804dc2 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod agent_tools; pub mod ai; pub mod ai_claude_code_cli; pub mod ai_cli_agent; +pub mod ai_codebuddy_cli; pub mod ai_codex_cli; pub mod ai_cursor_cli; pub mod ai_effort; diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index db70ad49a1..590c1f9ee3 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -5478,6 +5478,8 @@ mod tests { cursor_cli_env: std::collections::HashMap::new(), grok_cli_path: None, grok_cli_env: std::collections::HashMap::new(), + codebuddy_cli_path: None, + codebuddy_cli_env: std::collections::HashMap::new(), }, } } @@ -5593,6 +5595,33 @@ mod tests { std::fs::remove_file(&db).ok(); } + #[tokio::test] + async fn codebuddy_cli_ai_config_roundtrip() { + let db = temp_db_path("codebuddy-cli-ai-roundtrip"); + let storage = Storage::open(&db).await.unwrap(); + + let mut cfg = make_ai_config("codebuddy-cli", true); + cfg.config.provider = AiProvider::CodeBuddyCli; + cfg.config.api_key.clear(); + cfg.config.endpoint.clear(); + cfg.config.model = "kimi-k2.5".to_string(); + cfg.config.codebuddy_cli_path = Some("/opt/homebrew/bin/codebuddy".to_string()); + cfg.config.codebuddy_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::CodeBuddyCli)); + assert_eq!(loaded[0].config.model, "kimi-k2.5"); + assert_eq!(loaded[0].config.codebuddy_cli_path.as_deref(), Some("/opt/homebrew/bin/codebuddy")); + assert_eq!( + loaded[0].config.codebuddy_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 c630ebc57d..0a44c30ee6 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -533,6 +533,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), } } @@ -545,6 +547,7 @@ mod tests { AiProvider::OpenCodeCli, AiProvider::CursorCli, AiProvider::GrokCli, + AiProvider::CodeBuddyCli, ] { let config = make_config(provider); assert!(reject_web_unsupported_ai_provider(&config).is_err()); @@ -633,6 +636,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), }; let body = super::AiTestConnectionRequest { config }; diff --git a/packages/app-tests/aiCliErrorI18n.test.ts b/packages/app-tests/aiCliErrorI18n.test.ts index c7f601e53c..e575597697 100644 --- a/packages/app-tests/aiCliErrorI18n.test.ts +++ b/packages/app-tests/aiCliErrorI18n.test.ts @@ -41,6 +41,16 @@ const errorCodes = [ "openCodeTimeout", "openCodeProtocolError", "openCodeRunFailed", + "codeBuddyNotInstalled", + "codeBuddyCliPathInvalid", + "codeBuddyEnvInvalid", + "codeBuddyEnvReserved", + "codeBuddyNotAuthenticated", + "codeBuddyTimeout", + "codeBuddyMcpConfigInvalid", + "codeBuddyMcpStartupFailed", + "codeBuddyProtocolError", + "codeBuddyRunFailed", ] as const; test("Claude Code CLI errors are localized while retaining their stable code and raw diagnostics", () => { diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 8898077d07..efe39c2cb9 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -544,6 +544,9 @@ test("AI provider presets include common hosted and local providers", () => { 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["codebuddy-cli"].model, "default"); + assert.equal(AI_PROVIDER_PRESETS["codebuddy-cli"].iconSlug, "codebuddy"); + assert.equal(AI_PROVIDER_PRESETS["codebuddy-cli"].requiresApiKey, false); assert.equal(AI_PROVIDER_PRESETS["grok-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["grok-cli"].iconSlug, "grok"); assert.equal(AI_PROVIDER_PRESETS["grok-cli"].requiresApiKey, false); @@ -559,6 +562,8 @@ test("AI provider presets include common hosted and local providers", () => { 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("cursor-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codebuddy-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codebuddy-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("grok-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("grok-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); }); @@ -645,6 +650,14 @@ test("normalizes legacy AI config and fills provider defaults", () => { assert.equal(grokCli.grokCliPath, "/Users/me/.grok/bin/grok"); assert.deepEqual(grokCli.grokCliEnv, { HTTPS_PROXY: "http://proxy:9800" }); assert.equal(grokCli.model, "default"); + const codeBuddy = normalizeAiConfig({ + provider: "codebuddy-cli", + codebuddyCliPath: " /opt/homebrew/bin/codebuddy ", + codebuddyCliEnv: { HTTPS_PROXY: "http://proxy:9800" }, + }); + assert.equal(codeBuddy.codebuddyCliPath, "/opt/homebrew/bin/codebuddy"); + assert.deepEqual(codeBuddy.codebuddyCliEnv, { HTTPS_PROXY: "http://proxy:9800" }); + assert.equal(codeBuddy.model, "default"); }); test("infers legacy AI provider from saved endpoint and model", () => { diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 1e1d846ebd..ab00ce9d1c 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -242,6 +242,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig { AiProvider::OpenCodeCli => (&mut config.opencode_cli_path, "opencode"), AiProvider::CursorCli => (&mut config.cursor_cli_path, "agent"), AiProvider::GrokCli => (&mut config.grok_cli_path, "grok"), + AiProvider::CodeBuddyCli => (&mut config.codebuddy_cli_path, "codebuddy"), _ => return config, }; let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command); @@ -333,6 +334,8 @@ mod tests { cursor_cli_env: Default::default(), grok_cli_path: None, grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), } }