From 309e3aef5e85da8472f3e8040c52d496a2e4636b Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Fri, 7 Aug 2026 17:06:03 +0800 Subject: [PATCH] feat(ai): add OpenCode CLI provider --- apps/desktop/public/icons/ai/opencode.svg | 1 + .../editor/EditorSettingsDialog.vue | 24 +- .../src/components/icons/AiProviderLogo.vue | 2 +- .../__tests__/useAiModelCatalog.spec.ts | 24 + .../src/composables/useAiModelCatalog.ts | 2 + apps/desktop/src/i18n/backend-errors.ts | 9 + apps/desktop/src/i18n/locales/en.ts | 9 + apps/desktop/src/i18n/locales/es.ts | 9 + apps/desktop/src/i18n/locales/it.ts | 9 + apps/desktop/src/i18n/locales/ja.ts | 9 + apps/desktop/src/i18n/locales/ko.ts | 9 + apps/desktop/src/i18n/locales/pt-BR.ts | 9 + apps/desktop/src/i18n/locales/zh-CN.ts | 9 + apps/desktop/src/i18n/locales/zh-TW.ts | 9 + .../__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 | 10 +- crates/dbx-core/src/ai.rs | 101 ++- crates/dbx-core/src/ai_claude_code_cli.rs | 3 + crates/dbx-core/src/ai_cli_agent.rs | 158 +++- crates/dbx-core/src/ai_codex_cli.rs | 3 + crates/dbx-core/src/ai_effort.rs | 17 +- crates/dbx-core/src/ai_model_filter.rs | 1 + crates/dbx-core/src/ai_opencode_cli.rs | 675 ++++++++++++++++++ crates/dbx-core/src/ai_pi_agent_cli.rs | 2 + crates/dbx-core/src/cloud_sync.rs | 15 +- crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/storage.rs | 29 + crates/dbx-web/src/routes/ai.rs | 8 +- packages/app-tests/aiCliErrorI18n.test.ts | 12 +- packages/app-tests/settingsStore.test.ts | 14 + src-tauri/src/commands/ai.rs | 3 + 36 files changed, 1197 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/public/icons/ai/opencode.svg create mode 100644 crates/dbx-core/src/ai_opencode_cli.rs diff --git a/apps/desktop/public/icons/ai/opencode.svg b/apps/desktop/public/icons/ai/opencode.svg new file mode 100644 index 000000000..d3664de25 --- /dev/null +++ b/apps/desktop/public/icons/ai/opencode.svg @@ -0,0 +1 @@ +OpenCode diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 29a09b19b..26a882614 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -2553,7 +2553,7 @@ async function saveMaxAgentTurnsSetting() { } // Max Retries (global). Default 2, range 0–10. Applied to all API-backed -// AI providers. CLI providers (claude-code, codex, pi) are unaffected +// AI providers. CLI providers (Claude Code, Codex, OpenCode, Pi) are unaffected // because they use their own retry logic. const editMaxRetries = ref(undefined); const maxRetriesSaving = ref(false); @@ -2604,7 +2604,8 @@ function normalizeMaxRetries(value: number | undefined): number { const aiDeleteConfirmOpen = ref(false); const aiDeleteConfigId = ref(null); -const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "pi-agent-cli", "codex-cli"]); +const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli"]); +const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]); const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider))); const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]); @@ -2626,6 +2627,8 @@ const aiEditClaudeCodeCliPath = ref(""); const aiEditClaudeCodeCliEnvRows = ref([]); const aiEditPiAgentCliPath = ref(""); const aiEditPiAgentCliEnvRows = ref([]); +const aiEditOpenCodeCliPath = ref(""); +const aiEditOpenCodeCliEnvRows = ref([]); const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages"); @@ -2656,22 +2659,26 @@ const aiTestErrorDisplay = computed(() => [aiTestErrorPresentation.value.summary const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli"); const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli"); const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli"); +const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli"); const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value)); const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label); const aiCliCommandName = computed(() => { if (aiIsClaudeCodeCli.value) return "claude"; if (aiIsPiAgentCli.value) return "pi"; + if (aiIsOpenCodeCli.value) return "opencode"; return "codex"; }); const aiCliLoginCommand = computed(() => { if (aiIsClaudeCodeCli.value) return "claude auth login"; if (aiIsPiAgentCli.value) return "pi"; + if (aiIsOpenCodeCli.value) return "opencode auth login"; return "codex login"; }); const aiEditCliPath = computed({ get: () => { if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliPath.value; if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value; + if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value; return aiEditCodexCliPath.value; }, set: (value: string) => { @@ -2679,6 +2686,8 @@ const aiEditCliPath = computed({ aiEditClaudeCodeCliPath.value = value; } else if (aiIsPiAgentCli.value) { aiEditPiAgentCliPath.value = value; + } else if (aiIsOpenCodeCli.value) { + aiEditOpenCodeCliPath.value = value; } else { aiEditCodexCliPath.value = value; } @@ -2687,6 +2696,7 @@ const aiEditCliPath = computed({ const aiEditCliEnvRows = computed(() => { if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliEnvRows.value; if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value; + if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value; return aiEditCodexCliEnvRows.value; }); watch(aiIsCliProvider, (isCliProvider) => { @@ -2763,7 +2773,7 @@ function cliEnvValidationError(): string { const key = row.key.trim(); if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key }); const upper = key.toUpperCase(); - if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_"))) { + if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_")) || (aiIsOpenCodeCli.value && OPENCODE_CONTROL_ENV.has(upper))) { return t("ai.cliEnvReservedName", { name: key }); } } @@ -2779,6 +2789,8 @@ function removeCliEnvRow(id: string) { aiEditClaudeCodeCliEnvRows.value = aiEditClaudeCodeCliEnvRows.value.filter((row) => row.id !== id); } else if (aiIsPiAgentCli.value) { aiEditPiAgentCliEnvRows.value = aiEditPiAgentCliEnvRows.value.filter((row) => row.id !== id); + } else if (aiIsOpenCodeCli.value) { + aiEditOpenCodeCliEnvRows.value = aiEditOpenCodeCliEnvRows.value.filter((row) => row.id !== id); } else { aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); } @@ -2807,6 +2819,8 @@ function currentAiEditConfig() { claudeCodeCliEnv: aiIsClaudeCodeCli.value ? cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value) : {}, piAgentCliPath: aiEditPiAgentCliPath.value.trim() || undefined, piAgentCliEnv: aiIsPiAgentCli.value ? cliEnvFromRows(aiEditPiAgentCliEnvRows.value) : {}, + opencodeCliPath: aiEditOpenCodeCliPath.value.trim() || undefined, + opencodeCliEnv: aiIsOpenCodeCli.value ? cliEnvFromRows(aiEditOpenCodeCliEnvRows.value) : {}, }; } @@ -2876,6 +2890,8 @@ function aiEnterEditMode(configId?: string) { aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv); aiEditPiAgentCliPath.value = config.piAgentCliPath ?? ""; aiEditPiAgentCliEnvRows.value = aiEnvRowsFromConfig(config.piAgentCliEnv); + aiEditOpenCodeCliPath.value = config.opencodeCliPath ?? ""; + aiEditOpenCodeCliEnvRows.value = aiEnvRowsFromConfig(config.opencodeCliEnv); } } else { aiEditConfigName.value = ""; @@ -2897,6 +2913,8 @@ function aiEnterEditMode(configId?: string) { aiEditClaudeCodeCliEnvRows.value = []; aiEditPiAgentCliPath.value = ""; aiEditPiAgentCliEnvRows.value = []; + aiEditOpenCodeCliPath.value = ""; + aiEditOpenCodeCliEnvRows.value = []; } } diff --git a/apps/desktop/src/components/icons/AiProviderLogo.vue b/apps/desktop/src/components/icons/AiProviderLogo.vue index dc2e4d4cd..148768ecc 100644 --- a/apps/desktop/src/components/icons/AiProviderLogo.vue +++ b/apps/desktop/src/components/icons/AiProviderLogo.vue @@ -21,7 +21,7 @@ watch( }, ); -const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible"); +const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli"); const localIconUrl = computed(() => { if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg"); return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : ""; diff --git a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts index 105751f4d..bec4eb42e 100644 --- a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts +++ b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts @@ -94,6 +94,30 @@ describe("useAiModelCatalog", () => { expect(apiMock.aiResolveModelEffort).toHaveBeenCalledTimes(2); }); + it("tracks OpenCode executable and environment changes without depending on environment key order", async () => { + const initial: AiConfigItem = { + ...config(), + provider: "opencode-cli", + endpoint: "", + apiKey: "", + model: "default", + opencodeCliPath: "/opt/homebrew/bin/opencode", + opencodeCliEnv: { 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, + opencodeCliEnv: { NO_PROXY: "localhost", HTTPS_PROXY: "http://127.0.0.1:7890" }, + }), + ).resolves.toEqual([{ id: "first" }]); + await expect(catalog.loadModels({ ...initial, opencodeCliPath: "/usr/local/bin/opencode" })).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 ca51970a2..2aa0a7d11 100644 --- a/apps/desktop/src/composables/useAiModelCatalog.ts +++ b/apps/desktop/src/composables/useAiModelCatalog.ts @@ -57,6 +57,7 @@ function configSignature(config: AiConfigItem): string { codexCliPath: config.codexCliPath ?? null, claudeCodeCliPath: config.claudeCodeCliPath ?? null, piAgentCliPath: config.piAgentCliPath ?? null, + opencodeCliPath: config.opencodeCliPath ?? null, connectionFingerprint: fingerprint( JSON.stringify({ apiKey: config.apiKey, @@ -65,6 +66,7 @@ function configSignature(config: AiConfigItem): string { codexCliEnv: sortedRecord(config.codexCliEnv), claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv), piAgentCliEnv: sortedRecord(config.piAgentCliEnv), + opencodeCliEnv: sortedRecord(config.opencodeCliEnv), }), ), }); diff --git a/apps/desktop/src/i18n/backend-errors.ts b/apps/desktop/src/i18n/backend-errors.ts index 382a733fd..f3f299d91 100644 --- a/apps/desktop/src/i18n/backend-errors.ts +++ b/apps/desktop/src/i18n/backend-errors.ts @@ -33,6 +33,15 @@ const taggedAiCliErrorKeys: Record = { piAgentProtocolError: "ai.cliErrors.piAgentProtocolError", piAgentModelInvalid: "ai.cliErrors.piAgentModelInvalid", piAgentRunFailed: "ai.cliErrors.piAgentRunFailed", + openCodeNotInstalled: "ai.cliErrors.openCodeNotInstalled", + openCodeCliPathInvalid: "ai.cliErrors.openCodeCliPathInvalid", + openCodeEnvInvalid: "ai.cliErrors.openCodeEnvInvalid", + openCodeEnvReserved: "ai.cliErrors.openCodeEnvReserved", + openCodeNotAuthenticated: "ai.cliErrors.openCodeNotAuthenticated", + openCodeMcpStartupFailed: "ai.cliErrors.openCodeMcpStartupFailed", + openCodeTimeout: "ai.cliErrors.openCodeTimeout", + openCodeProtocolError: "ai.cliErrors.openCodeProtocolError", + openCodeRunFailed: "ai.cliErrors.openCodeRunFailed", }; const exactMessageKeys: Record = { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 902f461a6..6c12c8f8a 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2121,6 +2121,15 @@ export default { piAgentProtocolError: "Pi Coding Agent returned an invalid RPC response. Check the Pi version and diagnostics below.", piAgentModelInvalid: "The selected Pi model identifier is invalid. Refresh the model list and select a provider/model entry.", piAgentRunFailed: "Pi Coding Agent exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", + openCodeNotInstalled: "OpenCode CLI was not found. Install OpenCode or set its executable path in Settings > AI.", + openCodeCliPathInvalid: "The OpenCode CLI path is invalid. Select only the OpenCode executable and configure environment variables separately.", + openCodeEnvInvalid: "An OpenCode CLI environment variable name is invalid. Use names such as HTTPS_PROXY.", + openCodeEnvReserved: "An OpenCode or DBX-managed environment variable was overridden. Remove OPENCODE control variables and DBX_MCP_* variables from the provider configuration.", + openCodeNotAuthenticated: "OpenCode has no authenticated model provider. Run `opencode auth login` or configure a provider in OpenCode, then try again.", + openCodeMcpStartupFailed: "OpenCode could not start the scoped DBX MCP server. Check Settings > MCP and the diagnostics below.", + openCodeTimeout: "OpenCode CLI did not respond before the operation timed out.", + openCodeProtocolError: "OpenCode CLI returned an invalid JSON event stream. Check the OpenCode version and diagnostics below.", + openCodeRunFailed: "OpenCode CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", }, actions: { general: "General", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ddad5d63c..7030606aa 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1960,6 +1960,15 @@ export default withEnglishFallback({ piAgentProtocolError: "Pi Coding Agent devolvió una respuesta RPC no válida. Comprueba la versión de Pi y los detalles siguientes.", piAgentModelInvalid: "El identificador del modelo Pi no es válido. Actualiza la lista y selecciona una entrada provider/model.", piAgentRunFailed: "Pi Coding Agent terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.", + openCodeNotInstalled: "No se encontró OpenCode CLI. Instala OpenCode o configura la ruta del ejecutable en Ajustes > IA.", + openCodeCliPathInvalid: "La ruta de OpenCode CLI no es válida. Selecciona solo el ejecutable de OpenCode y configura las variables de entorno por separado.", + openCodeEnvInvalid: "El nombre de una variable de entorno de OpenCode CLI no es válido. Usa nombres como HTTPS_PROXY.", + openCodeEnvReserved: "Se intentó sobrescribir una variable administrada por OpenCode o DBX. Elimina las variables de control OPENCODE y DBX_MCP_* de la configuración.", + openCodeNotAuthenticated: "OpenCode no tiene un proveedor de modelos autenticado. Ejecuta `opencode auth login` o configura un proveedor en OpenCode y vuelve a intentarlo.", + openCodeMcpStartupFailed: "OpenCode no pudo iniciar el servidor DBX MCP con ámbito restringido. Revisa Ajustes > MCP y los detalles siguientes.", + openCodeTimeout: "OpenCode CLI no respondió antes de que finalizara el tiempo de espera.", + openCodeProtocolError: "OpenCode CLI devolvió un flujo de eventos JSON no válido. Revisa la versión de OpenCode y los detalles siguientes.", + openCodeRunFailed: "OpenCode CLI terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.", }, run: "Ejecutar", readingSchema: "Leyendo esquema", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 5dc36bcbc..2a521630d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2098,6 +2098,15 @@ export default withEnglishFallback({ piAgentProtocolError: "Pi Coding Agent ha restituito una risposta RPC non valida. Controlla la versione di Pi e i dettagli seguenti.", piAgentModelInvalid: "L'identificatore del modello Pi non è valido. Aggiorna l'elenco e seleziona una voce provider/model.", piAgentRunFailed: "Pi Coding Agent è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.", + openCodeNotInstalled: "OpenCode CLI non è stato trovato. Installa OpenCode o imposta il percorso dell'eseguibile in Impostazioni > AI.", + openCodeCliPathInvalid: "Il percorso di OpenCode CLI non è valido. Seleziona solo l'eseguibile OpenCode e configura separatamente le variabili d'ambiente.", + openCodeEnvInvalid: "Il nome di una variabile d'ambiente di OpenCode CLI non è valido. Usa nomi come HTTPS_PROXY.", + openCodeEnvReserved: "È stata sovrascritta una variabile gestita da OpenCode o DBX. Rimuovi le variabili di controllo OPENCODE e DBX_MCP_* dalla configurazione.", + openCodeNotAuthenticated: "OpenCode non dispone di un provider di modelli autenticato. Esegui `opencode auth login` o configura un provider in OpenCode, quindi riprova.", + openCodeMcpStartupFailed: "OpenCode non ha potuto avviare il server DBX MCP con ambito limitato. Controlla Impostazioni > MCP e i dettagli seguenti.", + openCodeTimeout: "OpenCode CLI non ha risposto prima della scadenza dell'operazione.", + openCodeProtocolError: "OpenCode CLI ha restituito un flusso di eventi JSON non valido. Controlla la versione di OpenCode e i dettagli seguenti.", + openCodeRunFailed: "OpenCode CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.", }, actions: { general: "Generale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 18749dcb2..1fd3e8e67 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1994,6 +1994,15 @@ export default withEnglishFallback({ piAgentProtocolError: "Pi Coding Agentが無効なRPC応答を返しました。Piのバージョンと以下の診断詳細を確認してください。", piAgentModelInvalid: "選択したPiモデルIDが無効です。モデル一覧を更新してprovider/model項目を選択してください。", piAgentRunFailed: "Pi Coding Agentが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。", + openCodeNotInstalled: "OpenCode CLIが見つかりません。OpenCodeをインストールするか、設定 > AIで実行ファイルのパスを指定してください。", + openCodeCliPathInvalid: "OpenCode CLIのパスが無効です。OpenCodeの実行ファイルだけを選択し、環境変数は別に設定してください。", + openCodeEnvInvalid: "OpenCode CLIの環境変数名が無効です。HTTPS_PROXYのような名前を使用してください。", + openCodeEnvReserved: "OpenCodeまたはDBXが管理する環境変数が上書きされています。設定からOPENCODE制御変数とDBX_MCP_*を削除してください。", + openCodeNotAuthenticated: "OpenCodeに認証済みのモデルプロバイダーがありません。`opencode auth login`を実行するかOpenCodeでプロバイダーを設定して、再試行してください。", + openCodeMcpStartupFailed: "OpenCodeはスコープ付きDBX MCPサーバーを起動できませんでした。設定 > MCPと以下の診断詳細を確認してください。", + openCodeTimeout: "OpenCode CLIはタイムアウトまでに応答しませんでした。", + openCodeProtocolError: "OpenCode CLIが無効なJSONイベントストリームを返しました。OpenCodeのバージョンと以下の診断詳細を確認してください。", + openCodeRunFailed: "OpenCode CLIが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。", }, run: "実行", readingSchema: "スキーマを読み取り中", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 599a2a533..82af6404e 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2001,6 +2001,15 @@ export default withEnglishFallback({ piAgentProtocolError: "Pi Coding Agent가 잘못된 RPC 응답을 반환했습니다. Pi 버전과 아래 진단을 확인하세요.", piAgentModelInvalid: "선택한 Pi 모델 식별자가 잘못되었습니다. 모델 목록을 새로고침하고 공급자/모델 항목을 선택하세요.", piAgentRunFailed: "Pi Coding Agent가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.", + openCodeNotInstalled: "OpenCode CLI를 찾을 수 없습니다. OpenCode를 설치하거나 설정 > AI에서 실행 파일 경로를 지정하세요.", + openCodeCliPathInvalid: "OpenCode CLI 경로가 올바르지 않습니다. OpenCode 실행 파일만 선택하고 환경 변수는 별도로 설정하세요.", + openCodeEnvInvalid: "OpenCode CLI 환경 변수 이름이 올바르지 않습니다. HTTPS_PROXY와 같은 이름을 사용하세요.", + openCodeEnvReserved: "OpenCode 또는 DBX가 관리하는 환경 변수를 덮어썼습니다. 공급자 설정에서 OPENCODE 제어 변수와 DBX_MCP_* 변수를 제거하세요.", + openCodeNotAuthenticated: "OpenCode에 인증된 모델 공급자가 없습니다. `opencode auth login`을 실행하거나 OpenCode에서 공급자를 구성한 후 다시 시도하세요.", + openCodeMcpStartupFailed: "OpenCode가 범위가 제한된 DBX MCP 서버를 시작하지 못했습니다. 설정 > MCP와 아래 진단을 확인하세요.", + openCodeTimeout: "OpenCode CLI가 제한 시간 안에 응답하지 않았습니다.", + openCodeProtocolError: "OpenCode CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. OpenCode 버전과 아래 진단을 확인하세요.", + openCodeRunFailed: "OpenCode 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 6d21b5571..bba33ad03 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1962,6 +1962,15 @@ export default withEnglishFallback({ piAgentProtocolError: "O Pi Coding Agent retornou uma resposta RPC inválida. Verifique a versão do Pi e os detalhes abaixo.", piAgentModelInvalid: "O identificador do modelo Pi é inválido. Atualize a lista e selecione uma entrada provider/model.", piAgentRunFailed: "O Pi Coding Agent foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.", + openCodeNotInstalled: "O OpenCode CLI não foi encontrado. Instale o OpenCode ou defina o caminho do executável em Configurações > IA.", + openCodeCliPathInvalid: "O caminho do OpenCode CLI é inválido. Selecione apenas o executável do OpenCode e configure as variáveis de ambiente separadamente.", + openCodeEnvInvalid: "O nome de uma variável de ambiente do OpenCode CLI é inválido. Use nomes como HTTPS_PROXY.", + openCodeEnvReserved: "Uma variável gerenciada pelo OpenCode ou DBX foi sobrescrita. Remova as variáveis de controle OPENCODE e DBX_MCP_* da configuração.", + openCodeNotAuthenticated: "O OpenCode não tem um provedor de modelos autenticado. Execute `opencode auth login` ou configure um provedor no OpenCode e tente novamente.", + openCodeMcpStartupFailed: "O OpenCode não conseguiu iniciar o servidor DBX MCP com escopo restrito. Verifique Configurações > MCP e os detalhes abaixo.", + openCodeTimeout: "O OpenCode CLI não respondeu antes do tempo limite da operação.", + openCodeProtocolError: "O OpenCode CLI retornou um fluxo de eventos JSON inválido. Verifique a versão do OpenCode e os detalhes abaixo.", + openCodeRunFailed: "O OpenCode CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.", }, 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 bae2054ae..a7d32e0df 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2121,6 +2121,15 @@ export default withEnglishFallback({ piAgentProtocolError: "Pi Coding Agent 返回了无效的 RPC 响应。请检查 Pi 版本和下方诊断详情。", piAgentModelInvalid: "所选 Pi 模型标识无效。请刷新模型列表并选择 provider/model 条目。", piAgentRunFailed: "Pi Coding Agent 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", + openCodeNotInstalled: "未找到 OpenCode CLI。请安装 OpenCode,或在 设置 > AI 中填写其可执行文件路径。", + openCodeCliPathInvalid: "OpenCode CLI 路径无效。请只选择 OpenCode 可执行文件,环境变量需单独配置。", + openCodeEnvInvalid: "OpenCode CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。", + openCodeEnvReserved: "配置覆盖了由 OpenCode 或 DBX 管理的环境变量。请移除 OPENCODE 控制变量和 DBX_MCP_* 变量。", + openCodeNotAuthenticated: "OpenCode 尚未配置已认证的模型提供商。请执行 `opencode auth login` 或在 OpenCode 中配置提供商后重试。", + openCodeMcpStartupFailed: "OpenCode 无法启动受限范围的 DBX MCP Server。请检查 设置 > MCP 和下方诊断详情。", + openCodeTimeout: "OpenCode CLI 未能在操作超时前响应。", + openCodeProtocolError: "OpenCode CLI 返回了无效的 JSON 事件流。请检查 OpenCode 版本和下方诊断详情。", + openCodeRunFailed: "OpenCode CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", }, actions: { general: "通用问答", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1b60acda1..4cff12f76 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2101,6 +2101,15 @@ export default withEnglishFallback({ piAgentProtocolError: "Pi Coding Agent 傳回無效的 RPC 回應。請檢查 Pi 版本和下方診斷詳情。", piAgentModelInvalid: "所選 Pi 模型識別碼無效。請重新整理模型清單並選擇 provider/model 項目。", piAgentRunFailed: "Pi Coding Agent 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。", + openCodeNotInstalled: "找不到 OpenCode CLI。請安裝 OpenCode,或在 設定 > AI 中填寫其執行檔路徑。", + openCodeCliPathInvalid: "OpenCode CLI 路徑無效。請只選擇 OpenCode 執行檔,環境變數需另外設定。", + openCodeEnvInvalid: "OpenCode CLI 環境變數名稱無效。請使用 HTTPS_PROXY 這類合法名稱。", + openCodeEnvReserved: "設定覆寫了由 OpenCode 或 DBX 管理的環境變數。請移除 OPENCODE 控制變數和 DBX_MCP_* 變數。", + openCodeNotAuthenticated: "OpenCode 尚未設定已驗證的模型供應商。請執行 `opencode auth login` 或在 OpenCode 中設定供應商後重試。", + openCodeMcpStartupFailed: "OpenCode 無法啟動受限範圍的 DBX MCP Server。請檢查 設定 > MCP 和下方診斷詳情。", + openCodeTimeout: "OpenCode CLI 未能在操作逾時前回應。", + openCodeProtocolError: "OpenCode CLI 傳回了無效的 JSON 事件串流。請檢查 OpenCode 版本和下方診斷詳情。", + openCodeRunFailed: "OpenCode 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 f9a45f83d..8622163a3 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", "pi-agent-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => { + it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli"] 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 8b69e8380..010b13c76 100644 --- a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts @@ -21,11 +21,12 @@ describe("orderAiConfigsForDisplay", () => { { id: "ollama", provider: "ollama" }, { id: "openai-compatible", provider: "openai-compatible" }, { id: "codex", provider: "codex-cli" }, + { id: "opencode", provider: "opencode-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", "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", "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 b10b94734..79289a973 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", "pi-agent-cli"]); +const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-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 02c4386ec..425ab648f 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -416,6 +416,22 @@ describe("settingsStore AI API key normalization", () => { it("trims API keys when normalizing loaded configurations", () => { expect(normalizeAiConfig({ provider: "openai", apiKey: " secret " }).apiKey).toBe("secret"); }); + + it("normalizes OpenCode CLI path and environment settings", () => { + expect( + normalizeAiConfig({ + provider: "opencode-cli", + opencodeCliPath: " /opt/homebrew/bin/opencode ", + opencodeCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: null as unknown as string }, + }), + ).toMatchObject({ + provider: "opencode-cli", + endpoint: "", + model: "default", + opencodeCliPath: "/opt/homebrew/bin/opencode", + opencodeCliEnv: { 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 42c535a49..6c3c0d863 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -232,6 +232,16 @@ export const AI_PROVIDER_PRESETS: Record = { authMethod: "bearer", requiresApiKey: false, }, + "opencode-cli": { + label: "OpenCode CLI", + iconSlug: "opencode", + provider: "opencode-cli", + endpoint: "", + model: "default", + apiStyle: "completions", + authMethod: "bearer", + requiresApiKey: false, + }, "pi-agent-cli": { label: "Pi Coding Agent", iconSlug: "pi", @@ -298,6 +308,8 @@ export function normalizeAiConfig(config: Partial | null | undefined): claudeCodeCliEnv: normalizeAiEnv(config?.claudeCodeCliEnv), piAgentCliPath: config?.piAgentCliPath?.trim() || undefined, piAgentCliEnv: normalizeAiEnv(config?.piAgentCliEnv), + opencodeCliPath: config?.opencodeCliPath?.trim() || undefined, + opencodeCliEnv: normalizeAiEnv(config?.opencodeCliEnv), }; } @@ -1390,7 +1402,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") return true; + if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-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 26593435b..fd7c1effc 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" | "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" | "custom"; export type AiApiStyle = "completions" | "responses" | "anthropic-messages"; export type AiAuthMethod = "api-key" | "bearer"; export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; @@ -46,6 +46,8 @@ export interface AiConfig { claudeCodeCliEnv?: Record; piAgentCliPath?: string | null; piAgentCliEnv?: Record; + opencodeCliPath?: string | null; + opencodeCliEnv?: Record; runtimeEffort?: AiEffortSelection | null; } diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index 1e52dfacf..45de02215 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -139,7 +139,7 @@ pub async fn run_agent_loop( let contract_system_prompt = augment_system_prompt_with_task_contract(system_prompt, task_contract, is_agent_mode); let system_prompt = contract_system_prompt.as_str(); - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { + if crate::ai::is_cli_provider(&config.provider) { let connection_name = { let configs = agent_ctx.state.configs.read().await; configs @@ -175,6 +175,14 @@ pub async fn run_agent_loop( ); return crate::ai_pi_agent_cli::run_pi_agent(config, &prompt, options, cancelled, on_event).await; } + if matches!(config.provider, AiProvider::OpenCodeCli) { + let prompt = crate::ai_opencode_cli::build_opencode_prompt( + system_prompt, + messages, + agent_ctx.sql_permissions.allow_writes, + ); + return crate::ai_opencode_cli::run_opencode_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 329488582..e8d30134b 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -79,6 +79,8 @@ pub enum AiProvider { ClaudeCodeCli, #[serde(rename = "pi-agent-cli")] PiAgentCli, + #[serde(rename = "opencode-cli")] + OpenCodeCli, Custom, } @@ -96,6 +98,7 @@ impl AiProvider { AiProvider::OpenaiCompatible => "openai-compatible", AiProvider::ClaudeCodeCli => "claude-code-cli", AiProvider::PiAgentCli => "pi-agent-cli", + AiProvider::OpenCodeCli => "opencode-cli", AiProvider::CodexCli => "codex-cli", AiProvider::Custom => "custom", } @@ -373,6 +376,10 @@ pub struct AiConfig { pub pi_agent_cli_path: Option, #[serde(default)] pub pi_agent_cli_env: HashMap, + #[serde(default)] + pub opencode_cli_path: Option, + #[serde(default)] + pub opencode_cli_env: HashMap, } fn default_enable_thinking() -> bool { @@ -380,10 +387,13 @@ fn default_enable_thinking() -> bool { } /// Whether the provider is a CLI-based provider that goes through its own -/// executable (claude-code, codex, pi) rather than through `with_retry` / +/// executable (claude-code, codex, opencode, pi) rather than through `with_retry` / /// `with_stream_retry`. pub fn is_cli_provider(provider: &AiProvider) -> bool { - matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) + matches!( + provider, + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli + ) } /// Merge the global `max_retries` setting into an `AiConfig`. @@ -612,6 +622,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String { | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli | AiProvider::Gemini => unreachable!(), } } @@ -1064,7 +1075,7 @@ fn normalized_api_key(config: &AiConfig) -> &str { fn validate_config(config: &AiConfig) -> Result<(), String> { crate::ai_effort::validate_runtime_effort(config)?; - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { + if is_cli_provider(&config.provider) { return Ok(()); } if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() { @@ -1080,7 +1091,7 @@ fn validate_config(config: &AiConfig) -> Result<(), String> { } fn validate_model_list_config(config: &AiConfig) -> Result<(), String> { - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { + if is_cli_provider(&config.provider) { return Ok(()); } if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() { @@ -1459,6 +1470,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str AiProvider::CodexCli => crate::ai_codex_cli::list_codex_models(config).await?, AiProvider::ClaudeCodeCli => crate::ai_claude_code_cli::list_claude_code_models(config).await?, AiProvider::PiAgentCli => crate::ai_pi_agent_cli::list_pi_agent_models(config).await?, + AiProvider::OpenCodeCli => crate::ai_opencode_cli::list_opencode_models(config).await?, _ => { validate_model_list_config(config)?; let client = build_ai_http_client(config, 30)?; @@ -1481,7 +1493,9 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str list_openai_compatible_models(&client, config).await? } } - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => { + unreachable!() + } } } }; @@ -1500,6 +1514,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res return crate::ai_pi_agent_cli::resolve_pi_agent_model_effort(config, model_id).await; } + if matches!(config.provider, AiProvider::OpenCodeCli) { + return crate::ai_opencode_cli::resolve_opencode_model_effort(config, model_id).await; + } + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { let models = list_models_core(config).await?; return Ok(models @@ -2028,6 +2046,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result Result { validate_config(&request.config)?; - if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { + if is_cli_provider(&request.config.provider) { return Err("CLI providers are only supported in DBX AI agent mode".to_string()); } @@ -2400,7 +2421,9 @@ pub async fn complete(request: &AiCompletionRequest) -> Result { match request.config.provider { AiProvider::Gemini => call_gemini(&client, request).await, - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => { + unreachable!() + } AiProvider::Openai | AiProvider::Deepseek | AiProvider::Qwen @@ -2439,7 +2462,7 @@ pub async fn stream( ) -> Result<(), String> { validate_config(&request.config)?; - if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { + if is_cli_provider(&request.config.provider) { return Err("CLI providers are only supported in DBX AI agent mode".to_string()); } @@ -2452,7 +2475,9 @@ pub async fn stream( match request.config.provider { AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await, - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => { + unreachable!() + } AiProvider::Openai | AiProvider::Deepseek | AiProvider::Qwen @@ -3736,7 +3761,7 @@ pub async fn stream_with_tools( on_chunk: impl Fn(AiStreamChunk), ) -> Result<(Vec, Option), String> { validate_config(config)?; - if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) { + if is_cli_provider(&config.provider) { return Err("CLI providers are only supported through the DBX AI agent loop".to_string()); } @@ -4088,6 +4113,8 @@ mod tests { 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(), }, system_prompt: "Be concise.".to_string(), messages: vec![AiMessage { @@ -4669,6 +4696,8 @@ mod tests { assert!(config.codex_cli_env.is_empty()); assert!(config.pi_agent_cli_path.is_none()); assert!(config.pi_agent_cli_env.is_empty()); + assert!(config.opencode_cli_path.is_none()); + assert!(config.opencode_cli_env.is_empty()); } #[test] @@ -4694,6 +4723,8 @@ mod tests { 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(), }; let err = build_ai_http_client(&config, 1).unwrap_err(); @@ -4724,6 +4755,8 @@ mod tests { 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(), }; build_ai_http_client(&config, 1).unwrap(); @@ -4752,6 +4785,8 @@ mod tests { 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(), }; build_ai_http_client(&config, 1).unwrap(); @@ -4780,6 +4815,8 @@ mod tests { 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(), }; assert_eq!( @@ -4812,6 +4849,8 @@ mod tests { 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(), }; assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions"); @@ -4841,6 +4880,8 @@ mod tests { 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(), }; for provider in @@ -4889,6 +4930,8 @@ mod tests { 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(), }; assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models"); @@ -4913,6 +4956,8 @@ mod tests { 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(), }; assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); } @@ -4940,6 +4985,8 @@ mod tests { 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(), }; assert!(uses_anthropic_messages_api(&config)); @@ -5019,6 +5066,8 @@ mod tests { 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(), }; assert!(!uses_anthropic_messages_api(&config)); @@ -5058,6 +5107,8 @@ mod tests { 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(), }; 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"); @@ -5121,6 +5172,8 @@ mod tests { 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(), }; assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses"); @@ -5156,6 +5209,8 @@ mod tests { 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(), }; let api_key_headers = claude_headers(&config).unwrap(); @@ -5271,6 +5326,8 @@ mod tests { 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(), }; assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show"); @@ -5303,6 +5360,8 @@ mod tests { 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(), }; assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true)); @@ -5384,6 +5443,8 @@ mod tests { 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(), }; let models = vec![ AiModelInfo::new("qwen3:0.6b", None), @@ -5645,6 +5706,8 @@ mod tests { 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(), }; let mut body = serde_json::json!({ @@ -5856,6 +5919,8 @@ mod tests { 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(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -5893,6 +5958,8 @@ mod tests { 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(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -5936,6 +6003,8 @@ mod tests { 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(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -5973,6 +6042,8 @@ mod tests { 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(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6006,6 +6077,8 @@ mod tests { 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(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6070,6 +6143,8 @@ mod tests { 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(), }; let request = AiCompletionRequest { config: config.clone(), @@ -6128,6 +6203,8 @@ mod tests { 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(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -6654,7 +6731,9 @@ mod tests { #[test] fn merge_global_max_retries_skips_cli_providers() { - for provider in [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli] { + for provider in + [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli, AiProvider::OpenCodeCli] + { let mut config = test_config(provider.clone()); config.max_retries = None; merge_global_max_retries(&mut config, 0); diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs index 6b9019d38..c718466df 100644 --- a/crates/dbx-core/src/ai_claude_code_cli.rs +++ b/crates/dbx-core/src/ai_claude_code_cli.rs @@ -525,6 +525,7 @@ pub async fn run_claude_code_agent( CliAgentProcessSpec { command, env, + env_remove: Vec::new(), current_dir: Some(isolated_cwd.path.clone()), stdin: Some(prompt.to_string()), dialect: CliAgentJsonlDialect::ClaudeCodePrint, @@ -580,6 +581,8 @@ mod tests { 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(), } } diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs index e483ba9c7..6f07ba026 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -34,11 +34,13 @@ pub struct CliAgentCommandSpec { pub enum CliAgentJsonlDialect { CodexExec, ClaudeCodePrint, + OpenCodeRun, } pub struct CliAgentProcessSpec { pub command: CliAgentCommandSpec, pub env: Vec<(String, String)>, + pub env_remove: Vec, pub current_dir: Option, pub stdin: Option, pub dialect: CliAgentJsonlDialect, @@ -217,6 +219,7 @@ struct ParsedCliAgentEvent { events: Vec, final_text: Option, error: Option, + usage_delta: Option, } pub fn parse_cli_jsonl_event(line: &str, dialect: CliAgentJsonlDialect) -> Option> { @@ -232,6 +235,7 @@ fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliA match dialect { CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line), CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line), + CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line), } } @@ -530,13 +534,108 @@ fn claude_code_error_message(value: &Value) -> String { .to_string() } +fn parse_open_code_jsonl_line(line: &str) -> ParsedCliAgentEvent { + let Ok(value) = serde_json::from_str::(line) else { + return ParsedCliAgentEvent::default(); + }; + + match value.get("type").and_then(Value::as_str).unwrap_or_default() { + "text" => { + let Some(text) = value.pointer("/part/text").and_then(Value::as_str).filter(|text| !text.is_empty()) else { + return ParsedCliAgentEvent::default(); + }; + ParsedCliAgentEvent { + events: vec![AgentEvent::TextDelta { delta: text.to_string() }], + final_text: Some(text.to_string()), + ..Default::default() + } + } + "reasoning" => { + let Some(text) = value.pointer("/part/text").and_then(Value::as_str).filter(|text| !text.is_empty()) else { + return ParsedCliAgentEvent::default(); + }; + ParsedCliAgentEvent { + events: vec![AgentEvent::ReasoningDelta { delta: text.to_string() }], + ..Default::default() + } + } + "tool_use" => parse_open_code_tool(&value), + "step_finish" => { + let input = value.pointer("/part/tokens/input").and_then(Value::as_u64).unwrap_or(0) as u32; + let output = value.pointer("/part/tokens/output").and_then(Value::as_u64).unwrap_or(0) as u32; + ParsedCliAgentEvent { + usage_delta: (input > 0 || output > 0) + .then_some(TokenUsage { input_tokens: input, output_tokens: output }), + ..Default::default() + } + } + "error" => { + let message = open_code_error_message(&value); + ParsedCliAgentEvent { + error: Some(message.clone()), + events: vec![AgentEvent::Error { message }], + ..Default::default() + } + } + _ => ParsedCliAgentEvent::default(), + } +} + +fn parse_open_code_tool(value: &Value) -> ParsedCliAgentEvent { + let part = &value["part"]; + let state = &part["state"]; + let status = state.get("status").and_then(Value::as_str).unwrap_or_default(); + if status != "completed" && status != "error" { + return ParsedCliAgentEvent::default(); + } + + let tool_call_id = part + .get("callID") + .and_then(Value::as_str) + .or_else(|| part.get("call_id").and_then(Value::as_str)) + .or_else(|| part.get("id").and_then(Value::as_str)) + .unwrap_or("opencode-tool-call") + .to_string(); + let tool_name = part.get("tool").and_then(Value::as_str).unwrap_or("opencode_tool").to_string(); + let args = state.get("input").cloned().unwrap_or_else(|| Value::Object(Default::default())); + let result = state + .get("output") + .filter(|value| !value.is_null()) + .or_else(|| state.get("error").filter(|value| !value.is_null())) + .cloned() + .unwrap_or(Value::Null); + + ParsedCliAgentEvent { + events: vec![ + AgentEvent::ToolCallStart { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), args }, + AgentEvent::ToolCallEnd { tool_call_id, tool_name, result, is_error: status == "error" }, + ], + ..Default::default() + } +} + +fn open_code_error_message(value: &Value) -> String { + value + .pointer("/error/data/message") + .and_then(Value::as_str) + .or_else(|| value.pointer("/error/message").and_then(Value::as_str)) + .or_else(|| value.get("message").and_then(Value::as_str)) + .or_else(|| value.get("error").and_then(Value::as_str)) + .unwrap_or("OpenCode CLI failed") + .to_string() +} + pub async fn run_cli_jsonl_agent( spec: CliAgentProcessSpec, cancelled: &Notify, on_event: impl Fn(AgentEvent) + Send + Sync + 'static, ) -> Result { let mut command = cli_command(&spec.command.program); - command.args(&spec.command.args).envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str()))); + command.args(&spec.command.args); + for key in &spec.env_remove { + command.env_remove(key); + } + command.envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str()))); if let Some(current_dir) = &spec.current_dir { command.current_dir(current_dir); } @@ -544,6 +643,7 @@ pub async fn run_cli_jsonl_agent( .stdin(if spec.stdin.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(Stdio::piped()) .stderr(Stdio::piped()) + .kill_on_drop(true) .spawn() .map_err(|e| (spec.classify_spawn_error)(&e.to_string()))?; @@ -565,6 +665,7 @@ pub async fn run_cli_jsonl_agent( let mut final_text = String::new(); let mut saw_agent_end = false; + let mut total_usage = TokenUsage::default(); let mut terminal_error: Option = None; loop { @@ -583,6 +684,9 @@ pub async fn run_cli_jsonl_agent( if let Some(text) = parsed.final_text { final_text.push_str(&text); } + if let Some(usage) = parsed.usage_delta { + total_usage.add(&usage); + } for event in parsed.events { if matches!(event, AgentEvent::AgentEnd { .. }) { saw_agent_end = true; @@ -590,7 +694,7 @@ pub async fn run_cli_jsonl_agent( on_event(event); } if let Some(error) = parsed.error { - terminal_error = Some(error); + terminal_error = Some((spec.classify_run_error)(&error)); let _ = child.kill().await; break; } @@ -615,7 +719,10 @@ pub async fn run_cli_jsonl_agent( } if !saw_agent_end { - on_event(AgentEvent::AgentEnd { input_tokens: None, output_tokens: None }); + on_event(AgentEvent::AgentEnd { + input_tokens: (total_usage.input_tokens > 0).then_some(total_usage.input_tokens), + output_tokens: (total_usage.output_tokens > 0).then_some(total_usage.output_tokens), + }); } Ok(final_text) @@ -625,6 +732,7 @@ pub async fn run_cli_jsonl_agent( mod tests { use super::*; use std::process::Command as StdCommand; + use std::sync::{Arc, Mutex}; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::time::{sleep, timeout, Duration}; @@ -657,6 +765,7 @@ mod tests { ], }, env: vec![("DBX_TEST_ENV".to_string(), "from-env".to_string())], + env_remove: Vec::new(), current_dir: None, stdin: None, dialect: CliAgentJsonlDialect::CodexExec, @@ -680,6 +789,7 @@ mod tests { ], }, env: Vec::new(), + env_remove: Vec::new(), current_dir: None, stdin: Some("prompt from stdin".to_string()), dialect: CliAgentJsonlDialect::CodexExec, @@ -692,6 +802,47 @@ mod tests { assert_eq!(result, "prompt from stdin"); } + #[tokio::test] + async fn opencode_agent_aggregates_step_usage_and_emits_one_agent_end() { + let spec = CliAgentProcessSpec { + command: CliAgentCommandSpec { + program: "sh".to_string(), + args: vec![ + "-c".to_string(), + concat!( + "printf '%s\\n' ", + "'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":10,\"output\":2}}}' ", + "'{\"type\":\"text\",\"part\":{\"text\":\"hello\"}}' ", + "'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":3,\"output\":4}}}'", + ) + .to_string(), + ], + }, + env: Vec::new(), + env_remove: Vec::new(), + current_dir: None, + stdin: None, + dialect: CliAgentJsonlDialect::OpenCodeRun, + classify_spawn_error, + classify_run_error, + }; + let events = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&events); + + let result = run_cli_jsonl_agent(spec, &Notify::new(), move |event| { + captured.lock().unwrap().push(event); + }) + .await + .unwrap(); + + assert_eq!(result, "hello"); + let events = events.lock().unwrap(); + assert_eq!(events.iter().filter(|event| matches!(event, AgentEvent::AgentEnd { .. })).count(), 1); + assert!(events + .iter() + .any(|event| { matches!(event, AgentEvent::AgentEnd { input_tokens: Some(13), output_tokens: Some(6) }) })); + } + #[tokio::test] async fn jsonl_error_kills_and_waits_for_child() { let pid_file = std::env::temp_dir().join(format!( @@ -707,6 +858,7 @@ mod tests { let spec = CliAgentProcessSpec { command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] }, env: Vec::new(), + env_remove: Vec::new(), current_dir: None, stdin: None, dialect: CliAgentJsonlDialect::CodexExec, diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index f3fbe9505..e09eeaa7a 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -970,6 +970,7 @@ pub async fn run_codex_agent( CliAgentProcessSpec { command, env, + env_remove: Vec::new(), current_dir: None, stdin: Some(prompt.to_string()), dialect: CliAgentJsonlDialect::CodexExec, @@ -1037,6 +1038,8 @@ mod tests { 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(), } } diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs index faef395d3..205801d95 100644 --- a/crates/dbx-core/src/ai_effort.rs +++ b/crates/dbx-core/src/ai_effort.rs @@ -82,7 +82,11 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option { Some(AiEffortCapability::FreeText { placeholder: None, source: AiCapabilitySource::Custom }) } - AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => None, + AiProvider::Claude + | AiProvider::CodexCli + | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli => None, } } @@ -187,6 +191,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> { | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli | AiProvider::Custom => None, } } @@ -201,7 +206,11 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> { if matches!( config.provider, - AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli + AiProvider::Claude + | AiProvider::CodexCli + | AiProvider::ClaudeCodeCli + | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli ) { return match selection { AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()), @@ -254,7 +263,7 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) { apply_openai_effort(object, &config.api_style, selection); } } - AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => {} + AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {} } } @@ -408,6 +417,8 @@ mod tests { claude_code_cli_env: HashMap::new(), pi_agent_cli_path: None, pi_agent_cli_env: Default::default(), + opencode_cli_path: None, + opencode_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 8ed6bd9a2..9dff6d517 100644 --- a/crates/dbx-core/src/ai_model_filter.rs +++ b/crates/dbx-core/src/ai_model_filter.rs @@ -96,6 +96,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli + | AiProvider::OpenCodeCli | 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 new file mode 100644 index 000000000..938215e7b --- /dev/null +++ b/crates/dbx-core/src/ai_opencode_cli.rs @@ -0,0 +1,675 @@ +use crate::agent_events::AgentEvent; +use crate::ai::{AiCapabilitySource, AiConfig, AiEffortCapability, AiModelInfo, AiTestConnectionResult}; +use crate::ai_cli_agent::{ + build_cli_agent_prompt, cli_command, 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::time::{Duration, Instant}; +use tokio::sync::Notify; + +const OPENCODE_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10); +const OPENCODE_CONTROL_ENV: &[&str] = &[ + "OPENCODE_CONFIG", + "OPENCODE_CONFIG_CONTENT", + "OPENCODE_CONFIG_DIR", + "OPENCODE_DB", + "OPENCODE_PERMISSION", + "OPENCODE_DISABLE_PROJECT_CONFIG", +]; + +pub type OpenCodeRunOptions = CliAgentRunOptions; +pub type OpenCodeCommandSpec = CliAgentCommandSpec; + +struct OpenCodeIsolatedCwd { + path: PathBuf, +} + +impl OpenCodeIsolatedCwd { + fn create() -> Result { + let path = env::temp_dir().join(format!("dbx-opencode-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&path) + .map_err(|error| format!("[openCodeRunFailed] Failed to create isolated OpenCode directory: {error}"))?; + Ok(Self { path }) + } +} + +impl Drop for OpenCodeIsolatedCwd { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn opencode_program(config: &AiConfig) -> String { + config + .opencode_cli_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("opencode") + .to_string() +} + +fn resolve_opencode_command(config: &AiConfig) -> Result { + let program = opencode_program(config); + if starts_with_env_assignment(&program) { + return Err("[openCodeCliPathInvalid] OpenCode CLI path should contain only the executable path. Add environment variables in the OpenCode CLI environment variables section.".to_string()); + } + + let program = if is_path_like_program(&program) { crate::path_utils::expand_tilde(&program) } else { program }; + let path = Path::new(&program); + if path.is_dir() { + return launchable_program_in_dir(path, "opencode") + .map(opencode_command_for_program) + .ok_or_else(|| { + "[openCodeCliPathInvalid] OpenCode CLI path should point to the opencode executable or a directory containing opencode." + .to_string() + }); + } + if is_path_like_program(&program) && !path.is_file() { + return Err("[openCodeCliPathInvalid] OpenCode CLI executable does not exist.".to_string()); + } + Ok(opencode_command_for_program(program)) +} + +fn opencode_command_for_program(program: String) -> OpenCodeCommandSpec { + #[cfg(windows)] + if let Some(command) = windows_npm_opencode_shim_command(&program) { + return command; + } + + OpenCodeCommandSpec { program, args: Vec::new() } +} + +#[cfg(windows)] +fn windows_npm_opencode_shim_command(program: &str) -> Option { + let path = Path::new(program); + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + if extension != "cmd" && extension != "bat" { + return None; + } + let executable = path.parent()?.join("node_modules").join("opencode-ai").join("bin").join("opencode.exe"); + executable + .is_file() + .then(|| OpenCodeCommandSpec { program: executable.to_string_lossy().to_string(), args: Vec::new() }) +} + +fn launchable_program_in_dir(dir: &Path, program: &str) -> Option { + program_path_candidates(dir, program) + .into_iter() + .find(|candidate| candidate.is_file()) + .map(|path| path.to_string_lossy().to_string()) +} + +#[cfg(not(windows))] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + vec![dir.join(program)] +} + +#[cfg(windows)] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + [".exe", ".cmd", ".bat", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect() +} + +fn is_path_like_program(program: &str) -> bool { + program.contains('/') || program.contains('\\') || program.starts_with('~') +} + +fn starts_with_env_assignment(program: &str) -> bool { + let Some(first_token) = program.split_whitespace().next() else { + return false; + }; + let Some((key, _)) = first_token.split_once('=') else { + return false; + }; + is_env_var_name(key) +} + +fn is_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn is_reserved_env_name(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + upper.starts_with("DBX_MCP_") || OPENCODE_CONTROL_ENV.iter().any(|reserved| upper == *reserved) +} + +pub fn opencode_cli_env(config: &AiConfig) -> Result, String> { + let mut env = BTreeMap::new(); + for (key, value) in &config.opencode_cli_env { + let key = key.trim(); + if key.is_empty() { + continue; + } + if !is_env_var_name(key) { + return Err(format!( + "[openCodeEnvInvalid] Invalid OpenCode CLI environment variable name `{key}`. Use names like HTTPS_PROXY." + )); + } + if is_reserved_env_name(key) { + return Err(format!( + "[openCodeEnvReserved] `{key}` is managed by DBX for the isolated OpenCode session and cannot be set here." + )); + } + env.insert(key.to_string(), value.clone()); + } + Ok(env.into_iter().collect()) +} + +fn opencode_process_env(config: &AiConfig, runtime_config: Value) -> Result, String> { + let mut env = BTreeMap::from_iter(opencode_cli_env(config)?); + env.insert("OPENCODE_DB".to_string(), ":memory:".to_string()); + env.insert("OPENCODE_DISABLE_PROJECT_CONFIG".to_string(), "1".to_string()); + env.insert("OPENCODE_CONFIG_CONTENT".to_string(), runtime_config.to_string()); + Ok(env.into_iter().collect()) +} + +fn opencode_runtime_config(options: Option<&OpenCodeRunOptions>) -> Value { + let mut config = Map::new(); + config.insert("permission".to_string(), json!({ "*": "deny", "dbx_*": "allow" })); + + if let Some(options) = options { + let command = options + .mcp_server_command + .as_ref() + .cloned() + .unwrap_or_else(|| CliAgentCommandSpec { program: "dbx-mcp-server".to_string(), args: Vec::new() }); + let mut command_parts = vec![command.program]; + command_parts.extend(command.args); + let environment = dbx_mcp_scope_env(options) + .into_iter() + .map(|(name, value)| (name.to_string(), Value::String(value))) + .collect::>(); + config.insert( + "mcp".to_string(), + json!({ + "dbx": { + "type": "local", + "command": command_parts, + "enabled": true, + "environment": environment + } + }), + ); + } + + Value::Object(config) +} + +fn opencode_selection_args(config: &AiConfig) -> Vec { + let mut args = Vec::new(); + let model = config.model.trim(); + if !model.is_empty() && !model.eq_ignore_ascii_case("default") { + args.extend(["--model".to_string(), model.to_string()]); + } + if let Some(variant) = config.runtime_effort.as_ref().and_then(|selection| selection.cli_value()) { + args.extend(["--variant".to_string(), variant]); + } + args +} + +pub fn build_opencode_command(config: &AiConfig) -> OpenCodeCommandSpec { + let mut command = OpenCodeCommandSpec { program: opencode_program(config), args: Vec::new() }; + command.args.extend(["run".to_string(), "--format".to_string(), "json".to_string(), "--pure".to_string()]); + command.args.extend(opencode_selection_args(config)); + command +} + +fn resolved_models_command(config: &AiConfig) -> Result { + let mut command = resolve_opencode_command(config)?; + command.args.extend(["models".to_string(), "--verbose".to_string(), "--pure".to_string()]); + Ok(command) +} + +fn resolved_run_command(config: &AiConfig) -> Result { + let resolved = resolve_opencode_command(config)?; + let mut command = build_opencode_command(config); + command.program = resolved.program; + command.args.splice(0..0, resolved.args); + Ok(command) +} + +pub fn build_opencode_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String { + build_cli_agent_prompt("OpenCode", system_prompt, messages, allow_write_sql) +} + +pub async fn list_opencode_models(config: &AiConfig) -> Result, String> { + let command = resolved_models_command(config)?; + let runtime = OpenCodeIsolatedCwd::create()?; + let mut process = cli_command(&command.program); + process.args(&command.args); + for key in OPENCODE_CONTROL_ENV { + process.env_remove(key); + } + process + .envs(opencode_process_env(config, opencode_runtime_config(None))?) + .current_dir(&runtime.path) + .kill_on_drop(true); + let output = tokio::time::timeout(OPENCODE_MODEL_DISCOVERY_TIMEOUT, process.output()) + .await + .map_err(|_| "[openCodeTimeout] OpenCode model discovery timed out".to_string())? + .map_err(|error| classify_opencode_spawn_error(&error.to_string()))?; + + if !output.status.success() { + return Err(classify_opencode_run_error(&combined_output(&output.stderr, &output.stdout))); + } + parse_opencode_models(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| { + "[openCodeNotAuthenticated] OpenCode returned no configured models. Sign in or configure a provider in OpenCode, then retry." + .to_string() + }) +} + +fn parse_opencode_models(stdout: &str) -> Option> { + let mut models = Vec::new(); + let mut seen = BTreeSet::new(); + let mut pending_id: Option = None; + let mut json_buffer = String::new(); + + for line in stdout.lines() { + let trimmed = line.trim(); + if json_buffer.is_empty() { + if trimmed.starts_with('{') { + json_buffer.push_str(line); + json_buffer.push('\n'); + } else if !trimmed.is_empty() { + pending_id = Some(trimmed.to_string()); + } + } else { + json_buffer.push_str(line); + json_buffer.push('\n'); + } + + if json_buffer.is_empty() { + continue; + } + match serde_json::from_str::(&json_buffer) { + Ok(metadata) => { + if let Some(info) = opencode_model_info(pending_id.take(), &metadata) { + if seen.insert(info.id.clone()) { + models.push(info); + } + } + json_buffer.clear(); + } + Err(error) if error.is_eof() => {} + Err(_) => { + json_buffer.clear(); + pending_id = None; + } + } + } + + if models.is_empty() { + return None; + } + if seen.insert("default".to_string()) { + models.insert(0, AiModelInfo::new("default", Some("Default".to_string()))); + } + Some(models) +} + +fn opencode_model_info(reported_id: Option, metadata: &Value) -> Option { + let id = reported_id.or_else(|| { + let provider = metadata.get("providerID").and_then(Value::as_str)?; + let model = metadata.get("id").and_then(Value::as_str)?; + Some(format!("{provider}/{model}")) + })?; + let id = id.trim(); + if id.is_empty() || !id.contains('/') { + return None; + } + + let display_name = metadata + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string); + let variants = metadata + .get("variants") + .and_then(Value::as_object) + .map(|variants| variants.keys().map(String::as_str).collect::>()) + .unwrap_or_default(); + let mut info = AiModelInfo::new(id, display_name); + info.supported_effort_levels = variants.iter().filter_map(|variant| variant.parse().ok()).collect(); + info.effort_capability = crate::ai_effort::dynamic_enum_capability(variants, AiCapabilitySource::LocalCli); + Some(info) +} + +pub async fn resolve_opencode_model_effort(config: &AiConfig, model_id: &str) -> Result { + Ok(list_opencode_models(config) + .await? + .into_iter() + .find(|model| model.id == model_id) + .and_then(|model| model.effort_capability) + .unwrap_or(AiEffortCapability::Unsupported)) +} + +pub async fn test_opencode_connection(config: &AiConfig) -> Result { + let start = Instant::now(); + list_opencode_models(config).await?; + Ok(AiTestConnectionResult { + success: true, + message: format!("OK - {}ms", start.elapsed().as_millis()), + latency_ms: Some(start.elapsed().as_millis() as u64), + model_used: config.model.trim().to_string(), + error_category: None, + }) +} + +fn combined_output(stderr: &[u8], stdout: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr); + let stdout = String::from_utf8_lossy(stdout); + [stderr.trim(), stdout.trim()].into_iter().filter(|part| !part.is_empty()).collect::>().join("\n") +} + +fn classify_opencode_spawn_error(message: &str) -> String { + let lower = message.to_ascii_lowercase(); + if lower.contains("no such file") || lower.contains("not found") || lower.contains("cannot find") { + format!("[openCodeNotInstalled] {message}") + } else { + format!("[openCodeRunFailed] {message}") + } +} + +fn classify_opencode_run_error(message: &str) -> String { + if message.starts_with("[openCode") || message.starts_with("[dbxMcpMissing]") { + return message.to_string(); + } + let lower = message.to_ascii_lowercase(); + if lower.contains("not authenticated") + || lower.contains("authentication required") + || lower.contains("unauthorized") + || lower.contains("please login") + || lower.contains("please sign in") + { + format!("[openCodeNotAuthenticated] {message}") + } else if lower.contains("dbx-mcp-server") || lower.contains("enoent") { + format!("[dbxMcpMissing] {message}") + } else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) { + format!("[openCodeMcpStartupFailed] {message}") + } else if lower.contains("json") || lower.contains("protocol") { + format!("[openCodeProtocolError] {message}") + } else { + format!("[openCodeRunFailed] {message}") + } +} + +pub fn parse_opencode_jsonl_event(line: &str) -> Option> { + parse_cli_jsonl_event(line, CliAgentJsonlDialect::OpenCodeRun) +} + +pub async fn run_opencode_agent( + config: &AiConfig, + prompt: &str, + options: OpenCodeRunOptions, + cancelled: &Notify, + on_event: impl Fn(AgentEvent) + Send + Sync + 'static, +) -> Result { + let runtime = OpenCodeIsolatedCwd::create()?; + let result = run_cli_jsonl_agent( + CliAgentProcessSpec { + command: resolved_run_command(config)?, + env: opencode_process_env(config, opencode_runtime_config(Some(&options)))?, + env_remove: OPENCODE_CONTROL_ENV.iter().map(|value| (*value).to_string()).collect(), + current_dir: Some(runtime.path.clone()), + stdin: Some(prompt.to_string()), + dialect: CliAgentJsonlDialect::OpenCodeRun, + classify_spawn_error: classify_opencode_spawn_error, + classify_run_error: classify_opencode_run_error, + }, + cancelled, + on_event, + ) + .await?; + if result.trim().is_empty() { + Err("[openCodeProtocolError] OpenCode completed without a text response".to_string()) + } else { + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai::{AiApiStyle, AiAuthMethod, AiEffortSelection, AiProvider, AiReasoningLevel}; + use std::sync::{Arc, Mutex}; + + fn config(model: &str) -> AiConfig { + AiConfig { + provider: AiProvider::OpenCodeCli, + 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(), + } + } + + fn options() -> OpenCodeRunOptions { + OpenCodeRunOptions { + connection_id: "conn-1".to_string(), + connection_name: "Local".to_string(), + database: "demo".to_string(), + schema: Some("public".to_string()), + agent_mode: true, + allow_writes: false, + allow_dangerous: false, + confirmed_write_sql: None, + mcp_server_command: Some(CliAgentCommandSpec { + program: "/opt/dbx/dbx-mcp-server".to_string(), + args: vec!["--stdio".to_string()], + }), + } + } + + fn live_config() -> AiConfig { + let mut config = config( + &std::env::var("DBX_LIVE_OPENCODE_MODEL") + .expect("set DBX_LIVE_OPENCODE_MODEL to an authenticated provider/model"), + ); + config.opencode_cli_path = std::env::var("DBX_LIVE_OPENCODE_PATH").ok(); + config + } + + #[test] + fn builds_run_command_with_model_and_variant() { + let mut config = config("openai/gpt-5.4"); + config.runtime_effort = Some(AiEffortSelection::Enum("high".to_string())); + + let command = build_opencode_command(&config); + + assert_eq!(command.program, "opencode"); + assert_eq!( + command.args, + ["run", "--format", "json", "--pure", "--model", "openai/gpt-5.4", "--variant", "high"] + ); + } + + #[test] + fn default_model_and_effort_are_omitted() { + let command = build_opencode_command(&config("default")); + + assert_eq!(command.args, ["run", "--format", "json", "--pure"]); + } + + #[test] + fn connection_check_uses_model_discovery_without_a_model_request() { + let command = resolved_models_command(&config("default")).unwrap(); + + assert_eq!(command.program, "opencode"); + assert_eq!(command.args, ["models", "--verbose", "--pure"]); + } + + #[test] + fn runtime_config_scopes_mcp_and_denies_other_tools() { + let runtime = opencode_runtime_config(Some(&options())); + + assert_eq!(runtime.pointer("/permission/*").and_then(Value::as_str), Some("deny")); + assert_eq!(runtime.pointer("/permission/dbx_*").and_then(Value::as_str), Some("allow")); + assert_eq!(runtime.pointer("/mcp/dbx/command/0").and_then(Value::as_str), Some("/opt/dbx/dbx-mcp-server")); + assert_eq!(runtime.pointer("/mcp/dbx/command/1").and_then(Value::as_str), Some("--stdio")); + assert_eq!( + runtime.pointer("/mcp/dbx/environment/DBX_MCP_SCOPE_CONNECTION_ID").and_then(Value::as_str), + Some("conn-1") + ); + assert_eq!( + runtime.pointer("/mcp/dbx/environment/DBX_MCP_SCOPE_SCHEMA").and_then(Value::as_str), + Some("public") + ); + } + + #[test] + fn rejects_reserved_or_invalid_environment_names() { + let mut invalid = config("default"); + invalid.opencode_cli_env.insert("BAD-NAME".to_string(), "1".to_string()); + assert!(opencode_cli_env(&invalid).unwrap_err().starts_with("[openCodeEnvInvalid]")); + + let mut reserved = config("default"); + reserved.opencode_cli_env.insert("OPENCODE_DB".to_string(), "file.db".to_string()); + assert!(opencode_cli_env(&reserved).unwrap_err().starts_with("[openCodeEnvReserved]")); + + reserved.opencode_cli_env.clear(); + reserved.opencode_cli_env.insert("DBX_MCP_ALLOW_WRITES".to_string(), "1".to_string()); + assert!(opencode_cli_env(&reserved).unwrap_err().starts_with("[openCodeEnvReserved]")); + } + + #[test] + fn parses_verbose_models_and_preserves_variant_order() { + let stdout = concat!( + "openai/gpt-5.4\n", + "{\n", + " \"id\": \"gpt-5.4\",\n", + " \"providerID\": \"openai\",\n", + " \"name\": \"GPT-5.4\",\n", + " \"variants\": {\"none\": {}, \"low\": {}, \"high\": {}, \"future\": {}}\n", + "}\n", + "custom/model\n", + "{\"id\":\"model\",\"providerID\":\"custom\",\"name\":\"Custom\",\"variants\":{}}\n" + ); + + let models = parse_opencode_models(stdout).unwrap(); + + assert_eq!( + models.iter().map(|model| model.id.as_str()).collect::>(), + ["default", "openai/gpt-5.4", "custom/model"] + ); + assert_eq!(models[1].display_name.as_deref(), Some("GPT-5.4")); + let AiEffortCapability::Enum { options, .. } = models[1].effort_capability.as_ref().unwrap() else { + panic!("expected dynamic effort options"); + }; + assert_eq!( + options.iter().map(|option| option.id.as_str()).collect::>(), + ["none", "low", "high", "future"] + ); + assert!(models[2].effort_capability.is_none()); + } + + #[test] + fn parses_text_reasoning_tool_error_and_usage_events() { + let text = parse_opencode_jsonl_event(r#"{"type":"text","part":{"type":"text","text":"hello"}}"#).unwrap(); + assert!(matches!(&text[0], AgentEvent::TextDelta { delta } if delta == "hello")); + + let reasoning = + parse_opencode_jsonl_event(r#"{"type":"reasoning","part":{"type":"reasoning","text":"thinking"}}"#) + .unwrap(); + assert!(matches!(&reasoning[0], AgentEvent::ReasoningDelta { delta } if delta == "thinking")); + + let tool = parse_opencode_jsonl_event( + r#"{"type":"tool_use","part":{"id":"part-1","callID":"call-1","tool":"dbx_dbx_list_connections","state":{"status":"completed","input":{},"output":"ok"}}}"#, + ) + .unwrap(); + assert_eq!(tool.len(), 2); + assert!( + matches!(&tool[0], AgentEvent::ToolCallStart { tool_call_id, tool_name, .. } if tool_call_id == "call-1" && tool_name == "dbx_dbx_list_connections") + ); + assert!(matches!(&tool[1], AgentEvent::ToolCallEnd { result, is_error: false, .. } if result == "ok")); + + let error = parse_opencode_jsonl_event( + r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"bad model"}}}"#, + ) + .unwrap(); + assert!(matches!(&error[0], AgentEvent::Error { message } if message == "bad model")); + + assert!( + parse_opencode_jsonl_event(r#"{"type":"step_finish","part":{"tokens":{"input":10,"output":3}}}"#).is_none() + ); + } + + #[tokio::test] + #[ignore = "requires an installed, authenticated OpenCode CLI and DBX_LIVE_OPENCODE_MODEL"] + async fn live_model_discovery_and_connection_test() { + let config = live_config(); + + let models = list_opencode_models(&config).await.unwrap(); + assert!(models.iter().any(|model| model.id == config.model)); + let result = test_opencode_connection(&config).await.unwrap(); + assert!(result.success); + assert!(result.latency_ms.is_some()); + } + + #[tokio::test] + #[ignore = "requires OpenCode, 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 = OpenCodeRunOptions { + 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_opencode_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("OpenCode MCP smoke test timed out") + .unwrap(); + + assert!(result.contains(&connection_name)); + assert!(events.lock().unwrap().iter().any(|event| { + matches!(event, AgentEvent::ToolCallStart { tool_name, .. } if tool_name.contains("dbx_list_connections")) + })); + } +} diff --git a/crates/dbx-core/src/ai_pi_agent_cli.rs b/crates/dbx-core/src/ai_pi_agent_cli.rs index 8ea32688f..f029ae1e0 100644 --- a/crates/dbx-core/src/ai_pi_agent_cli.rs +++ b/crates/dbx-core/src/ai_pi_agent_cli.rs @@ -755,6 +755,8 @@ mod tests { claude_code_cli_env: HashMap::new(), pi_agent_cli_path: None, pi_agent_cli_env: HashMap::new(), + opencode_cli_path: None, + opencode_cli_env: HashMap::new(), } } diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index 6ac9ae6af..fdf00e712 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -1416,6 +1416,8 @@ mod tests { 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(), }, } } @@ -2238,7 +2240,11 @@ mod tests { async fn sensitive_payload_ai_configs_some_saves_configs() { let storage = Storage::open(&temp_db_path("ai-cfg-some")).await.unwrap(); - let cfg = make_test_config("synced", true); + let mut cfg = make_test_config("synced", true); + cfg.config.provider = crate::ai::AiProvider::OpenCodeCli; + cfg.config.model = "openai/gpt-5.4-mini".to_string(); + cfg.config.opencode_cli_path = Some("/opt/homebrew/bin/opencode".to_string()); + cfg.config.opencode_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string()); let payload = SensitiveSyncPayload { connection_secrets: vec![], ai_configs: Some(vec![cfg]), @@ -2249,6 +2255,13 @@ mod tests { let loaded = storage.load_ai_configs().await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].name, "synced"); + assert!(matches!(loaded[0].config.provider, crate::ai::AiProvider::OpenCodeCli)); + assert_eq!(loaded[0].config.model, "openai/gpt-5.4-mini"); + assert_eq!(loaded[0].config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode")); + assert_eq!( + loaded[0].config.opencode_cli_env.get("HTTPS_PROXY").map(String::as_str), + Some("http://127.0.0.1:7890") + ); } #[tokio::test] diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 5e0aa3d0d..8006f87ee 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod ai_cli_agent; pub mod ai_codex_cli; pub mod ai_effort; mod ai_model_filter; +pub mod ai_opencode_cli; pub mod ai_pi_agent_cli; pub mod backend_error; pub mod changelog; diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index b129033f7..c93faf81a 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -5467,6 +5467,8 @@ mod tests { claude_code_cli_env: std::collections::HashMap::new(), pi_agent_cli_path: None, pi_agent_cli_env: std::collections::HashMap::new(), + opencode_cli_path: None, + opencode_cli_env: std::collections::HashMap::new(), }, } } @@ -5504,6 +5506,33 @@ mod tests { std::fs::remove_file(&db).ok(); } + #[tokio::test] + async fn opencode_cli_ai_config_roundtrip() { + let db = temp_db_path("opencode-cli-ai-roundtrip"); + let storage = Storage::open(&db).await.unwrap(); + + let mut cfg = make_ai_config("opencode-cli", true); + cfg.config.provider = AiProvider::OpenCodeCli; + cfg.config.api_key.clear(); + cfg.config.endpoint.clear(); + cfg.config.model = "openai/gpt-5.4-mini".to_string(); + cfg.config.opencode_cli_path = Some("/opt/homebrew/bin/opencode".to_string()); + cfg.config.opencode_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string()); + 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::OpenCodeCli)); + assert_eq!(loaded[0].config.model, "openai/gpt-5.4-mini"); + assert_eq!(loaded[0].config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode")); + assert_eq!( + loaded[0].config.opencode_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 78315ed00..eab98182d 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -527,12 +527,16 @@ mod tests { 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(), } } #[test] fn rejects_local_cli_providers_single() { - for provider in [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli] { + for provider in + [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli, AiProvider::OpenCodeCli] + { let config = make_config(provider); assert!(reject_web_unsupported_ai_provider(&config).is_err()); } @@ -614,6 +618,8 @@ mod tests { 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(), }; let body = super::AiTestConnectionRequest { config }; diff --git a/packages/app-tests/aiCliErrorI18n.test.ts b/packages/app-tests/aiCliErrorI18n.test.ts index e8401e7e9..c7f601e53 100644 --- a/packages/app-tests/aiCliErrorI18n.test.ts +++ b/packages/app-tests/aiCliErrorI18n.test.ts @@ -6,6 +6,7 @@ import en from "../../apps/desktop/src/i18n/locales/en"; import es from "../../apps/desktop/src/i18n/locales/es"; import it from "../../apps/desktop/src/i18n/locales/it"; import ja from "../../apps/desktop/src/i18n/locales/ja"; +import ko from "../../apps/desktop/src/i18n/locales/ko"; import ptBR from "../../apps/desktop/src/i18n/locales/pt-BR"; import zhCN from "../../apps/desktop/src/i18n/locales/zh-CN"; import zhTW from "../../apps/desktop/src/i18n/locales/zh-TW"; @@ -31,6 +32,15 @@ const errorCodes = [ "piAgentProtocolError", "piAgentModelInvalid", "piAgentRunFailed", + "openCodeNotInstalled", + "openCodeCliPathInvalid", + "openCodeEnvInvalid", + "openCodeEnvReserved", + "openCodeNotAuthenticated", + "openCodeMcpStartupFailed", + "openCodeTimeout", + "openCodeProtocolError", + "openCodeRunFailed", ] as const; test("Claude Code CLI errors are localized while retaining their stable code and raw diagnostics", () => { @@ -57,7 +67,7 @@ test("Claude Code CLI errors are localized while retaining their stable code and }); test("every current locale defines all AI CLI diagnostic messages", () => { - const locales = { en, es, it, ja, ptBR, zhCN, zhTW } as const; + const locales = { en, es, it, ja, ko, ptBR, zhCN, zhTW } as const; for (const [localeName, locale] of Object.entries(locales)) { assert.equal(typeof locale.ai.requestFailed, "string", `${localeName}.ai.requestFailed`); diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 9c2077a37..6cc748266 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -538,6 +538,9 @@ test("AI provider presets include common hosted and local providers", () => { assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].iconSlug, "claudecode"); assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].requiresApiKey, false); + assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].model, "default"); + assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].iconSlug, "opencode"); + assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].requiresApiKey, false); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi"); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].requiresApiKey, false); @@ -546,6 +549,8 @@ test("AI provider presets include common hosted and local providers", () => { assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("minimax") < Object.keys(AI_PROVIDER_PRESETS).indexOf("ollama")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); }); @@ -614,6 +619,15 @@ test("normalizes legacy AI config and fills provider defaults", () => { assert.equal(piAgent.piAgentCliPath, "/opt/homebrew/bin/pi"); assert.deepEqual(piAgent.piAgentCliEnv, { HTTPS_PROXY: "http://proxy:9800" }); assert.equal(piAgent.model, "default"); + + const openCode = normalizeAiConfig({ + provider: "opencode-cli", + opencodeCliPath: " /opt/homebrew/bin/opencode ", + opencodeCliEnv: { HTTPS_PROXY: "http://proxy:9800" }, + } as any); + assert.equal(openCode.opencodeCliPath, "/opt/homebrew/bin/opencode"); + assert.deepEqual(openCode.opencodeCliEnv, { HTTPS_PROXY: "http://proxy:9800" }); + assert.equal(openCode.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 369717751..35bc5cb32 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -239,6 +239,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig { AiProvider::CodexCli => (&mut config.codex_cli_path, "codex"), AiProvider::ClaudeCodeCli => (&mut config.claude_code_cli_path, "claude"), AiProvider::PiAgentCli => (&mut config.pi_agent_cli_path, "pi"), + AiProvider::OpenCodeCli => (&mut config.opencode_cli_path, "opencode"), _ => return config, }; let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command); @@ -324,6 +325,8 @@ mod tests { 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(), } }