diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 8fdba78999..d73102099b 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -57,6 +57,7 @@ import { useToast } from "@/composables/useToast"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { buildAiContext, resolveAiDatabaseTarget, resolveAiNamespaceSelection, resolveDefaultAiSchema, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext, type CustomPromptContext } from "@/lib/ai/ai"; import { isAiConfigModelCandidate } from "@/lib/ai/aiConfigCandidates"; +import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { addConfiguredAiModel, aiModelOptions } from "@/lib/ai/aiConfigList"; import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; import { effortSelectionEquals, runtimeEffortFromPreference } from "@/lib/ai/aiEffortPreference"; @@ -105,6 +106,7 @@ const { openTableTarget } = useNavigationTargets({ }); const { toast } = useToast(); const { isDark } = useTheme(); +const supportsCliProviders = isTauriRuntime(); type AiMessageMention = | { @@ -299,7 +301,7 @@ function submitEdit(visibleIndex: number) { const actualIndex = visibleToActualIndex(messages.value, visibleIndex); if (actualIndex < 0) return; if (!props.connection || !props.tab) return; - if (!settings.isConfigured) { + if (!activeFullConfig.value) { toast(t("ai.noConfig")); return; } @@ -339,7 +341,7 @@ const { catalogs: modelCatalogs, effortCatalogs, loadModels, resolveEffort, effo // Configured providers for quick switching - get from aiConfigs const configuredProviders = computed(() => { - const providers = orderAiConfigsForDisplay(settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey))); + const providers = orderAiConfigsForDisplay(settings.aiConfigs.filter(isModelCandidate)); if (modelSearchQuery.value.trim()) { const query = modelSearchQuery.value.trim().toLowerCase(); return providers.filter((c) => { @@ -354,11 +356,15 @@ const configuredProviders = computed(() => { const activeFullConfig = computed(() => { if (!settings.activeModel) return null; const item = settings.aiConfigs.find((c) => c.id === settings.activeModel!.configId); - if (!item) return null; + if (!item || !isModelCandidate(item)) return null; const modelId = settings.activeModel.modelId; return normalizeAiConfig({ ...item, model: modelId, runtimeEffort: runtimeEffortFromPreference(settings.activeEffort) }); }); +function isModelCandidate(config: AiConfigItem): boolean { + return isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey, supportsCliProviders); +} + function getModelsForConfig(configId: string) { const config = settings.aiConfigs.find((item) => item.id === configId); if (!config) return []; @@ -392,7 +398,7 @@ function toggleModelConfig(configId: string) { } async function loadConfiguredModelCatalogs(force = false) { - const configs = settings.aiConfigs.filter((config) => isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey)); + const configs = settings.aiConfigs.filter(isModelCandidate); const queue = [...configs]; const workers = Array.from({ length: Math.min(3, queue.length) }, async () => { while (queue.length) { @@ -1707,7 +1713,8 @@ async function send() { clearPendingWriteGrant(); return; } - if (!settings.isConfigured) { + const activeConfig = activeFullConfig.value; + if (!activeConfig) { clearPendingWriteGrant(); toast(t("ai.noConfig")); return; @@ -1820,7 +1827,7 @@ async function send() { const history: AiMessage[] = messagesForAgentHistory(messages.value.slice(0, -2)); await runAgentStream( { - config: activeFullConfig.value!, + config: activeConfig, action: requestedAction, mode: requestedMode, instruction: modelInstruction, diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 847fa67104..8915c2ad6c 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -2668,7 +2668,7 @@ function normalizeMaxRetries(value: number | undefined): number { const aiDeleteConfirmOpen = ref(false); const aiDeleteConfigId = ref(null); -const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli"]); +const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"]); const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]); const CURSOR_CONTROL_ENV = new Set(["CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR"]); const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider))); @@ -2700,6 +2700,8 @@ const aiEditGrokCliPath = ref(""); const aiEditGrokCliEnvRows = ref([]); const aiEditCodeBuddyCliPath = ref(""); const aiEditCodeBuddyCliEnvRows = ref([]); +const aiEditQoderCliPath = ref(""); +const aiEditQoderCliEnvRows = ref([]); const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages"); @@ -2734,6 +2736,7 @@ const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli"); const aiIsCursorCli = computed(() => aiEditProvider.value === "cursor-cli"); const aiIsGrokCli = computed(() => aiEditProvider.value === "grok-cli"); const aiIsCodeBuddyCli = computed(() => aiEditProvider.value === "codebuddy-cli"); +const aiIsQoderCli = computed(() => aiEditProvider.value === "qoder-cli"); const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value)); const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label); const aiCliCommandName = computed(() => { @@ -2743,6 +2746,7 @@ const aiCliCommandName = computed(() => { if (aiIsCursorCli.value) return "agent"; if (aiIsGrokCli.value) return "grok"; if (aiIsCodeBuddyCli.value) return "codebuddy"; + if (aiIsQoderCli.value) return "qodercli"; return "codex"; }); const aiCliLoginCommand = computed(() => { @@ -2752,6 +2756,7 @@ const aiCliLoginCommand = computed(() => { if (aiIsCursorCli.value) return "agent login"; if (aiIsGrokCli.value) return "grok login"; if (aiIsCodeBuddyCli.value) return "codebuddy"; + if (aiIsQoderCli.value) return "qodercli login"; return "codex login"; }); const aiEditCliPath = computed({ @@ -2762,6 +2767,7 @@ const aiEditCliPath = computed({ if (aiIsCursorCli.value) return aiEditCursorCliPath.value; if (aiIsGrokCli.value) return aiEditGrokCliPath.value; if (aiIsCodeBuddyCli.value) return aiEditCodeBuddyCliPath.value; + if (aiIsQoderCli.value) return aiEditQoderCliPath.value; return aiEditCodexCliPath.value; }, set: (value: string) => { @@ -2777,6 +2783,8 @@ const aiEditCliPath = computed({ aiEditGrokCliPath.value = value; } else if (aiIsCodeBuddyCli.value) { aiEditCodeBuddyCliPath.value = value; + } else if (aiIsQoderCli.value) { + aiEditQoderCliPath.value = value; } else { aiEditCodexCliPath.value = value; } @@ -2789,6 +2797,7 @@ const aiEditCliEnvRows = computed(() => { if (aiIsCursorCli.value) return aiEditCursorCliEnvRows.value; if (aiIsGrokCli.value) return aiEditGrokCliEnvRows.value; if (aiIsCodeBuddyCli.value) return aiEditCodeBuddyCliEnvRows.value; + if (aiIsQoderCli.value) return aiEditQoderCliEnvRows.value; return aiEditCodexCliEnvRows.value; }); watch(aiIsCliProvider, (isCliProvider) => { @@ -2889,6 +2898,8 @@ function removeCliEnvRow(id: string) { aiEditGrokCliEnvRows.value = aiEditGrokCliEnvRows.value.filter((row) => row.id !== id); } else if (aiIsCodeBuddyCli.value) { aiEditCodeBuddyCliEnvRows.value = aiEditCodeBuddyCliEnvRows.value.filter((row) => row.id !== id); + } else if (aiIsQoderCli.value) { + aiEditQoderCliEnvRows.value = aiEditQoderCliEnvRows.value.filter((row) => row.id !== id); } else { aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); } @@ -2925,6 +2936,8 @@ function currentAiEditConfig() { grokCliEnv: aiIsGrokCli.value ? cliEnvFromRows(aiEditGrokCliEnvRows.value) : {}, codebuddyCliPath: aiEditCodeBuddyCliPath.value.trim() || undefined, codebuddyCliEnv: aiIsCodeBuddyCli.value ? cliEnvFromRows(aiEditCodeBuddyCliEnvRows.value) : {}, + qoderCliPath: aiEditQoderCliPath.value.trim() || undefined, + qoderCliEnv: aiIsQoderCli.value ? cliEnvFromRows(aiEditQoderCliEnvRows.value) : {}, }; } @@ -3002,6 +3015,8 @@ function aiEnterEditMode(configId?: string) { aiEditGrokCliEnvRows.value = aiEnvRowsFromConfig(config.grokCliEnv); aiEditCodeBuddyCliPath.value = config.codebuddyCliPath ?? ""; aiEditCodeBuddyCliEnvRows.value = aiEnvRowsFromConfig(config.codebuddyCliEnv); + aiEditQoderCliPath.value = config.qoderCliPath ?? ""; + aiEditQoderCliEnvRows.value = aiEnvRowsFromConfig(config.qoderCliEnv); } } else { aiEditConfigName.value = ""; @@ -3031,6 +3046,8 @@ function aiEnterEditMode(configId?: string) { aiEditGrokCliEnvRows.value = []; aiEditCodeBuddyCliPath.value = ""; aiEditCodeBuddyCliEnvRows.value = []; + aiEditQoderCliPath.value = ""; + aiEditQoderCliEnvRows.value = []; } } diff --git a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts index 7482c56a7d..c19187e04c 100644 --- a/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts +++ b/apps/desktop/src/composables/__tests__/useAiModelCatalog.spec.ts @@ -166,6 +166,30 @@ describe("useAiModelCatalog", () => { expect(apiMock.aiListModels).toHaveBeenCalledTimes(2); }); + it("tracks Qoder executable and environment changes without depending on environment key order", async () => { + const initial: AiConfigItem = { + ...config(), + provider: "qoder-cli", + endpoint: "", + apiKey: "", + model: "default", + qoderCliPath: "/opt/homebrew/bin/qodercli", + qoderCliEnv: { 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, + qoderCliEnv: { NO_PROXY: "localhost", HTTPS_PROXY: "http://127.0.0.1:7890" }, + }), + ).resolves.toEqual([{ id: "first" }]); + await expect(catalog.loadModels({ ...initial, qoderCliPath: "/usr/local/bin/qodercli" })).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 3c4284b60c..b176998256 100644 --- a/apps/desktop/src/composables/useAiModelCatalog.ts +++ b/apps/desktop/src/composables/useAiModelCatalog.ts @@ -60,6 +60,7 @@ function configSignature(config: AiConfigItem): string { opencodeCliPath: config.opencodeCliPath ?? null, cursorCliPath: config.cursorCliPath ?? null, codebuddyCliPath: config.codebuddyCliPath ?? null, + qoderCliPath: config.qoderCliPath ?? null, connectionFingerprint: fingerprint( JSON.stringify({ apiKey: config.apiKey, @@ -71,6 +72,7 @@ function configSignature(config: AiConfigItem): string { opencodeCliEnv: sortedRecord(config.opencodeCliEnv), cursorCliEnv: sortedRecord(config.cursorCliEnv), codebuddyCliEnv: sortedRecord(config.codebuddyCliEnv), + qoderCliEnv: sortedRecord(config.qoderCliEnv), }), ), }); diff --git a/apps/desktop/src/i18n/backend-errors.ts b/apps/desktop/src/i18n/backend-errors.ts index 562490200b..1b3200d22d 100644 --- a/apps/desktop/src/i18n/backend-errors.ts +++ b/apps/desktop/src/i18n/backend-errors.ts @@ -62,6 +62,16 @@ const taggedAiCliErrorKeys: Record = { codeBuddyMcpStartupFailed: "ai.cliErrors.codeBuddyMcpStartupFailed", codeBuddyProtocolError: "ai.cliErrors.codeBuddyProtocolError", codeBuddyRunFailed: "ai.cliErrors.codeBuddyRunFailed", + qoderNotInstalled: "ai.cliErrors.qoderNotInstalled", + qoderCliPathInvalid: "ai.cliErrors.qoderCliPathInvalid", + qoderEnvInvalid: "ai.cliErrors.qoderEnvInvalid", + qoderEnvReserved: "ai.cliErrors.qoderEnvReserved", + qoderNotAuthenticated: "ai.cliErrors.qoderNotAuthenticated", + qoderTimeout: "ai.cliErrors.qoderTimeout", + qoderMcpConfigInvalid: "ai.cliErrors.qoderMcpConfigInvalid", + qoderMcpStartupFailed: "ai.cliErrors.qoderMcpStartupFailed", + qoderProtocolError: "ai.cliErrors.qoderProtocolError", + qoderRunFailed: "ai.cliErrors.qoderRunFailed", cursorEnvInvalid: "ai.cliErrors.cursorEnvInvalid", cursorEnvReserved: "ai.cliErrors.cursorEnvReserved", cursorNotAuthenticated: "ai.cliErrors.cursorNotAuthenticated", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 7ff1264630..861777a7e6 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2417,6 +2417,16 @@ export default { codeBuddyMcpStartupFailed: "CodeBuddy Code loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.", codeBuddyProtocolError: "CodeBuddy Code returned an invalid control response or JSON event stream. Check the CodeBuddy Code version and diagnostics below.", codeBuddyRunFailed: "CodeBuddy Code CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", + qoderNotInstalled: "Qoder CLI was not found. Install Qoder CLI or set its executable path in Settings > AI.", + qoderCliPathInvalid: "The Qoder CLI path is invalid. Select only the qodercli executable and configure environment variables separately.", + qoderEnvInvalid: "A Qoder CLI environment variable name is invalid. Use names such as HTTPS_PROXY or QODER_PERSONAL_ACCESS_TOKEN.", + qoderEnvReserved: "A DBX-managed MCP environment variable was overridden. Remove DBX_MCP_* variables from the provider configuration.", + qoderNotAuthenticated: "Qoder CLI is not authenticated. Run `qodercli login` in a terminal or configure QODER_PERSONAL_ACCESS_TOKEN, then try again.", + qoderTimeout: "Qoder CLI did not respond before the operation timed out.", + qoderMcpConfigInvalid: "Qoder CLI could not read the temporary DBX MCP configuration. Check the Qoder CLI version and diagnostics below.", + qoderMcpStartupFailed: "Qoder CLI loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.", + qoderProtocolError: "Qoder CLI returned an invalid JSON event stream. Check the Qoder CLI version and diagnostics below.", + qoderRunFailed: "Qoder CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.", cursorEnvInvalid: "A Cursor CLI environment variable name is invalid. Use names such as HTTPS_PROXY.", cursorEnvReserved: "A Cursor or DBX-managed environment variable was overridden. Remove CURSOR_CONFIG_DIR, CURSOR_DATA_DIR, and DBX_MCP_* variables from the provider configuration.", cursorNotAuthenticated: "Cursor CLI is not authenticated. Run `agent login` in a terminal and try again.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 2dd3dba910..c223dee3cd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2263,6 +2263,16 @@ export default withEnglishFallback({ codeBuddyMcpStartupFailed: "CodeBuddy Code cargó la configuración MCP pero no pudo iniciar DBX MCP Server. Revisa Ajustes > MCP y los diagnósticos siguientes.", codeBuddyProtocolError: "CodeBuddy Code devolvió una respuesta de control o un flujo de eventos JSON no válido. Revisa la versión de CodeBuddy Code y los diagnósticos siguientes.", codeBuddyRunFailed: "CodeBuddy Code CLI terminó de forma inesperada. Usa el código de error y los diagnósticos siguientes para identificar el ejecutable o la salida fallida.", + qoderNotInstalled: "No se encontró Qoder CLI. Instálalo o configura la ruta de su ejecutable en Configuración > IA.", + qoderCliPathInvalid: "La ruta de Qoder CLI no es válida. Selecciona únicamente el ejecutable qodercli y configura las variables de entorno por separado.", + qoderEnvInvalid: "El nombre de una variable de entorno de Qoder CLI no es válido. Usa nombres como HTTPS_PROXY o QODER_PERSONAL_ACCESS_TOKEN.", + qoderEnvReserved: "Se sobrescribió una variable de entorno MCP administrada por DBX. Elimina las variables DBX_MCP_* de la configuración del proveedor.", + qoderNotAuthenticated: "Qoder CLI no está autenticado. Ejecuta `qodercli login` en una terminal o configura QODER_PERSONAL_ACCESS_TOKEN y vuelve a intentarlo.", + qoderTimeout: "Qoder CLI no respondió antes de que se agotara el tiempo de espera.", + qoderMcpConfigInvalid: "Qoder CLI no pudo leer la configuración MCP temporal de DBX. Comprueba la versión de Qoder CLI y los diagnósticos siguientes.", + qoderMcpStartupFailed: "Qoder CLI cargó la configuración MCP, pero no pudo iniciar DBX MCP Server. Comprueba Configuración > MCP y los diagnósticos siguientes.", + qoderProtocolError: "Qoder CLI devolvió un flujo de eventos JSON no válido. Comprueba la versión de Qoder CLI y los diagnósticos siguientes.", + qoderRunFailed: "Qoder CLI terminó de forma inesperada. Usa el código de error y los diagnósticos siguientes para identificar el ejecutable o la salida fallida.", }, run: "Ejecutar", readingSchema: "Leyendo esquema", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index bf727db071..0666daa700 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2403,6 +2403,16 @@ export default withEnglishFallback({ codeBuddyMcpStartupFailed: "CodeBuddy Code ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.", codeBuddyProtocolError: "CodeBuddy Code ha restituito una risposta di controllo o un flusso di eventi JSON non valido. Controlla la versione di CodeBuddy Code e i dettagli seguenti.", codeBuddyRunFailed: "CodeBuddy Code CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.", + qoderNotInstalled: "Qoder CLI non è stato trovato. Installalo oppure configura il percorso dell'eseguibile in Impostazioni > IA.", + qoderCliPathInvalid: "Il percorso di Qoder CLI non è valido. Seleziona solo l'eseguibile qodercli e configura separatamente le variabili di ambiente.", + qoderEnvInvalid: "Il nome di una variabile di ambiente di Qoder CLI non è valido. Usa nomi come HTTPS_PROXY o QODER_PERSONAL_ACCESS_TOKEN.", + qoderEnvReserved: "È stata sovrascritta una variabile di ambiente MCP gestita da DBX. Rimuovi le variabili DBX_MCP_* dalla configurazione del provider.", + qoderNotAuthenticated: "Qoder CLI non è autenticato. Esegui `qodercli login` in un terminale oppure configura QODER_PERSONAL_ACCESS_TOKEN, quindi riprova.", + qoderTimeout: "Qoder CLI non ha risposto prima della scadenza dell'operazione.", + qoderMcpConfigInvalid: "Qoder CLI non ha potuto leggere la configurazione MCP temporanea di DBX. Controlla la versione di Qoder CLI e i dettagli seguenti.", + qoderMcpStartupFailed: "Qoder CLI ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.", + qoderProtocolError: "Qoder CLI ha restituito un flusso di eventi JSON non valido. Controlla la versione di Qoder CLI e i dettagli seguenti.", + qoderRunFailed: "Qoder CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.", }, actions: { general: "Generale", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1ab9d5a031..eb61b9d1de 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2297,6 +2297,16 @@ export default withEnglishFallback({ codeBuddyMcpStartupFailed: "CodeBuddy CodeはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断情報を確認してください。", codeBuddyProtocolError: "CodeBuddy Codeが無効な制御応答またはJSONイベントストリームを返しました。CodeBuddy Codeのバージョンと以下の診断情報を確認してください。", codeBuddyRunFailed: "CodeBuddy Code CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。", + qoderNotInstalled: "Qoder CLIが見つかりません。Qoder CLIをインストールするか、設定 > AIで実行ファイルのパスを指定してください。", + qoderCliPathInvalid: "Qoder CLIのパスが無効です。qodercli実行ファイルのみを選択し、環境変数は別途設定してください。", + qoderEnvInvalid: "Qoder CLIの環境変数名が無効です。HTTPS_PROXYやQODER_PERSONAL_ACCESS_TOKENなどの名前を使用してください。", + qoderEnvReserved: "DBXが管理するMCP環境変数が上書きされました。プロバイダー設定からDBX_MCP_*変数を削除してください。", + qoderNotAuthenticated: "Qoder CLIにログインしていません。ターミナルで`qodercli login`を実行するかQODER_PERSONAL_ACCESS_TOKENを設定して、再試行してください。", + qoderTimeout: "Qoder CLIは操作のタイムアウトまでに応答しませんでした。", + qoderMcpConfigInvalid: "Qoder CLIが一時的なDBX MCP設定を読み取れませんでした。Qoder CLIのバージョンと以下の診断詳細を確認してください。", + qoderMcpStartupFailed: "Qoder CLIはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断詳細を確認してください。", + qoderProtocolError: "Qoder CLIが無効なJSONイベントストリームを返しました。Qoder CLIのバージョンと以下の診断詳細を確認してください。", + qoderRunFailed: "Qoder CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。", }, run: "実行", readingSchema: "スキーマを読み取り中", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 15348a91a2..d3ef223afd 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2281,6 +2281,16 @@ export default withEnglishFallback({ codeBuddyMcpStartupFailed: "CodeBuddy Code가 MCP 구성을 불러왔지만 DBX MCP Server를 시작하지 못했습니다. 설정 > MCP와 아래 진단을 확인하세요.", codeBuddyProtocolError: "CodeBuddy Code가 잘못된 제어 응답 또는 JSON 이벤트 스트림을 반환했습니다. CodeBuddy Code 버전과 아래 진단을 확인하세요.", codeBuddyRunFailed: "CodeBuddy Code CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.", + qoderNotInstalled: "Qoder CLI를 찾을 수 없습니다. Qoder CLI를 설치하거나 설정 > AI에서 실행 파일 경로를 지정하세요.", + qoderCliPathInvalid: "Qoder CLI 경로가 올바르지 않습니다. qodercli 실행 파일만 선택하고 환경 변수는 별도로 구성하세요.", + qoderEnvInvalid: "Qoder CLI 환경 변수 이름이 올바르지 않습니다. HTTPS_PROXY 또는 QODER_PERSONAL_ACCESS_TOKEN 같은 이름을 사용하세요.", + qoderEnvReserved: "DBX에서 관리하는 MCP 환경 변수가 재정의되었습니다. 공급자 구성에서 DBX_MCP_* 변수를 제거하세요.", + qoderNotAuthenticated: "Qoder CLI가 인증되지 않았습니다. 터미널에서 `qodercli login`을 실행하거나 QODER_PERSONAL_ACCESS_TOKEN을 구성한 후 다시 시도하세요.", + qoderTimeout: "Qoder CLI가 작업 제한 시간 내에 응답하지 않았습니다.", + qoderMcpConfigInvalid: "Qoder CLI가 임시 DBX MCP 구성을 읽을 수 없습니다. Qoder CLI 버전과 아래 진단을 확인하세요.", + qoderMcpStartupFailed: "Qoder CLI가 MCP 구성을 불러왔지만 DBX MCP Server를 시작할 수 없습니다. 설정 > MCP와 아래 진단을 확인하세요.", + qoderProtocolError: "Qoder CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. Qoder CLI 버전과 아래 진단을 확인하세요.", + qoderRunFailed: "Qoder 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 bb5816a350..77384d22f0 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2265,6 +2265,16 @@ export default withEnglishFallback({ codeBuddyMcpStartupFailed: "O CodeBuddy Code carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.", codeBuddyProtocolError: "O CodeBuddy Code retornou uma resposta de controle ou um fluxo de eventos JSON inválido. Verifique a versão do CodeBuddy Code e os detalhes abaixo.", codeBuddyRunFailed: "O CodeBuddy Code CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.", + qoderNotInstalled: "O Qoder CLI não foi encontrado. Instale-o ou configure o caminho do executável em Configurações > IA.", + qoderCliPathInvalid: "O caminho do Qoder CLI é inválido. Selecione somente o executável qodercli e configure as variáveis de ambiente separadamente.", + qoderEnvInvalid: "O nome de uma variável de ambiente do Qoder CLI é inválido. Use nomes como HTTPS_PROXY ou QODER_PERSONAL_ACCESS_TOKEN.", + qoderEnvReserved: "Uma variável de ambiente MCP gerenciada pelo DBX foi sobrescrita. Remova as variáveis DBX_MCP_* da configuração do provedor.", + qoderNotAuthenticated: "O Qoder CLI não está autenticado. Execute `qodercli login` em um terminal ou configure QODER_PERSONAL_ACCESS_TOKEN e tente novamente.", + qoderTimeout: "O Qoder CLI não respondeu antes do tempo limite da operação.", + qoderMcpConfigInvalid: "O Qoder CLI não conseguiu ler a configuração MCP temporária do DBX. Verifique a versão do Qoder CLI e os detalhes abaixo.", + qoderMcpStartupFailed: "O Qoder CLI carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.", + qoderProtocolError: "O Qoder CLI retornou um fluxo de eventos JSON inválido. Verifique a versão do Qoder CLI e os detalhes abaixo.", + qoderRunFailed: "O Qoder CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.", }, run: "Executar", readingSchema: "Lendo schema", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 47f96e8e63..8b34b3343a 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2342,6 +2342,16 @@ export default withEnglishFallback({ codeBuddyMcpStartupFailed: "CodeBuddy Code 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 与下方诊断信息。", codeBuddyProtocolError: "CodeBuddy Code 返回了无效的控制响应或 JSON 事件流。请检查 CodeBuddy Code 版本和下方诊断信息。", codeBuddyRunFailed: "CodeBuddy Code CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", + qoderNotInstalled: "未找到 Qoder CLI。请安装 Qoder CLI,或在 设置 > AI 中指定可执行文件路径。", + qoderCliPathInvalid: "Qoder CLI 路径无效。请只选择 qodercli 可执行文件,并单独配置环境变量。", + qoderEnvInvalid: "Qoder CLI 环境变量名称无效。请使用 HTTPS_PROXY 或 QODER_PERSONAL_ACCESS_TOKEN 这类合法名称。", + qoderEnvReserved: "配置覆盖了由 DBX 管理的 MCP 环境变量。请从供应商配置中移除 DBX_MCP_* 变量。", + qoderNotAuthenticated: "Qoder CLI 尚未登录。请在终端执行 `qodercli login`,或配置 QODER_PERSONAL_ACCESS_TOKEN 后重试。", + qoderTimeout: "Qoder CLI 未在操作超时前响应。", + qoderMcpConfigInvalid: "Qoder CLI 无法读取临时 DBX MCP 配置。请检查 Qoder CLI 版本和下方诊断信息。", + qoderMcpStartupFailed: "Qoder CLI 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 和下方诊断信息。", + qoderProtocolError: "Qoder CLI 返回了无效的 JSON 事件流。请检查 Qoder CLI 版本和下方诊断信息。", + qoderRunFailed: "Qoder CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", cursorEnvInvalid: "Cursor CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。", cursorEnvReserved: "配置覆盖了由 Cursor 或 DBX 管理的环境变量。请移除 CURSOR_CONFIG_DIR、CURSOR_DATA_DIR 和 DBX_MCP_* 变量。", cursorNotAuthenticated: "Cursor CLI 尚未登录。请在终端执行 `agent login` 后重试。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 77b5444c36..ac27f9e047 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2406,6 +2406,16 @@ export default withEnglishFallback({ codeBuddyMcpStartupFailed: "CodeBuddy Code 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 與下方診斷資訊。", codeBuddyProtocolError: "CodeBuddy Code 回傳了無效的控制回應或 JSON 事件串流。請檢查 CodeBuddy Code 版本和下方診斷資訊。", codeBuddyRunFailed: "CodeBuddy Code CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 CLI 輸出。", + qoderNotInstalled: "找不到 Qoder CLI。請安裝 Qoder CLI,或在 設定 > AI 中指定可執行檔路徑。", + qoderCliPathInvalid: "Qoder CLI 路徑無效。請只選擇 qodercli 可執行檔,並另外設定環境變數。", + qoderEnvInvalid: "Qoder CLI 環境變數名稱無效。請使用 HTTPS_PROXY 或 QODER_PERSONAL_ACCESS_TOKEN 這類合法名稱。", + qoderEnvReserved: "設定覆蓋了由 DBX 管理的 MCP 環境變數。請從供應商設定中移除 DBX_MCP_* 變數。", + qoderNotAuthenticated: "Qoder CLI 尚未登入。請在終端機執行 `qodercli login`,或設定 QODER_PERSONAL_ACCESS_TOKEN 後重試。", + qoderTimeout: "Qoder CLI 未在操作逾時前回應。", + qoderMcpConfigInvalid: "Qoder CLI 無法讀取暫時的 DBX MCP 設定。請檢查 Qoder CLI 版本和下方診斷詳情。", + qoderMcpStartupFailed: "Qoder CLI 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 和下方診斷詳情。", + qoderProtocolError: "Qoder CLI 傳回了無效的 JSON 事件串流。請檢查 Qoder CLI 版本和下方診斷詳情。", + qoderRunFailed: "Qoder 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 96b2f8c672..76d7f989c2 100644 --- a/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts +++ b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts @@ -24,7 +24,7 @@ describe("isAiConfigModelCandidate", () => { expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false); }); - it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => { + it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => { expect( isAiConfigModelCandidate( config({ @@ -38,4 +38,19 @@ describe("isAiConfigModelCandidate", () => { ), ).toBe(true); }); + + it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"] as const)("filters existing %s configs when local CLI providers are unavailable", (provider) => { + expect( + isAiConfigModelCandidate( + config({ + provider, + endpoint: "", + apiKey: "", + model: "default", + }), + false, + false, + ), + ).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts index 5dccf21ccd..aa7ed97690 100644 --- a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts @@ -24,12 +24,13 @@ describe("orderAiConfigsForDisplay", () => { { id: "opencode", provider: "opencode-cli" }, { id: "cursor", provider: "cursor-cli" }, { id: "codebuddy", provider: "codebuddy-cli" }, + { id: "qoder", provider: "qoder-cli" }, { id: "grok", provider: "grok-cli" }, { id: "pi", provider: "pi-agent-cli" }, { id: "custom", provider: "custom" }, ]; - expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "cursor", "codebuddy", "grok", "pi", "custom"]); + expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "cursor", "codebuddy", "qoder", "grok", "pi", "custom"]); }); it("preserves creation order for configs from the same provider", () => { diff --git a/apps/desktop/src/lib/ai/aiConfigCandidates.ts b/apps/desktop/src/lib/ai/aiConfigCandidates.ts index 2786e50278..048a70bd32 100644 --- a/apps/desktop/src/lib/ai/aiConfigCandidates.ts +++ b/apps/desktop/src/lib/ai/aiConfigCandidates.ts @@ -1,9 +1,9 @@ import type { AiConfig } from "@/types/ai"; -const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli"]); +const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli", "codebuddy-cli", "qoder-cli"]); -export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean { +export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean, supportsCliProviders = true): boolean { // CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass. - if (CLI_PROVIDERS.has(config.provider)) return true; + if (CLI_PROVIDERS.has(config.provider)) return supportsCliProviders; return !!config.endpoint?.trim() && (!requiresApiKey || !!config.apiKey?.trim()); } diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 9d135e847e..fd7b9baa14 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -504,6 +504,22 @@ describe("settingsStore AI API key normalization", () => { codebuddyCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: "" }, }); }); + + it("normalizes Qoder CLI path and environment settings", () => { + expect( + normalizeAiConfig({ + provider: "qoder-cli", + qoderCliPath: " ~/.local/bin/qodercli ", + qoderCliEnv: { QODER_PERSONAL_ACCESS_TOKEN: "token", EMPTY: null as unknown as string }, + }), + ).toMatchObject({ + provider: "qoder-cli", + endpoint: "", + model: "default", + qoderCliPath: "~/.local/bin/qodercli", + qoderCliEnv: { QODER_PERSONAL_ACCESS_TOKEN: "token", EMPTY: "" }, + }); + }); }); describe("settingsStore MCP policy persistence", () => { diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index e29a6666d8..fe1b73c071 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -264,6 +264,15 @@ export const AI_PROVIDER_PRESETS: Record = { authMethod: "bearer", requiresApiKey: false, }, + "qoder-cli": { + label: "Qoder CLI", + provider: "qoder-cli", + endpoint: "", + model: "default", + apiStyle: "completions", + authMethod: "bearer", + requiresApiKey: false, + }, "grok-cli": { label: "Grok CLI", iconSlug: "grok", @@ -348,6 +357,8 @@ export function normalizeAiConfig(config: Partial | null | undefined): grokCliEnv: normalizeAiEnv(config?.grokCliEnv), codebuddyCliPath: config?.codebuddyCliPath?.trim() || undefined, codebuddyCliEnv: normalizeAiEnv(config?.codebuddyCliEnv), + qoderCliPath: config?.qoderCliPath?.trim() || undefined, + qoderCliEnv: normalizeAiEnv(config?.qoderCliEnv), }; } @@ -1517,7 +1528,17 @@ export const useSettingsStore = defineStore("settings", () => { const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId); if (!config) return false; const preset = AI_PROVIDER_PRESETS[config.provider]; - if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-cli" || config.provider === "cursor-cli" || config.provider === "grok-cli" || config.provider === "codebuddy-cli") return true; + if ( + config.provider === "codex-cli" || + config.provider === "claude-code-cli" || + config.provider === "pi-agent-cli" || + config.provider === "opencode-cli" || + config.provider === "cursor-cli" || + config.provider === "grok-cli" || + config.provider === "codebuddy-cli" || + config.provider === "qoder-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 bfc65b93f6..6c9efcf47a 100644 --- a/apps/desktop/src/types/ai.ts +++ b/apps/desktop/src/types/ai.ts @@ -1,4 +1,4 @@ -export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "cursor-cli" | "grok-cli" | "codebuddy-cli" | "custom"; +export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "cursor-cli" | "grok-cli" | "codebuddy-cli" | "qoder-cli" | "custom"; export type AiApiStyle = "completions" | "responses" | "anthropic-messages"; export type AiAuthMethod = "api-key" | "bearer"; export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; @@ -55,6 +55,8 @@ export interface AiConfig { grokCliEnv?: Record; codebuddyCliPath?: string | null; codebuddyCliEnv?: Record; + qoderCliPath?: string | null; + qoderCliEnv?: Record; runtimeEffort?: AiEffortSelection | null; } diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index f67ba70bbb..34f710af51 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -204,6 +204,14 @@ pub async fn run_agent_loop( ); return crate::ai_codebuddy_cli::run_codebuddy_agent(config, &prompt, options, cancelled, on_event).await; } + if matches!(config.provider, AiProvider::QoderCli) { + let prompt = crate::ai_qoder_cli::build_qoder_prompt( + system_prompt, + messages, + agent_ctx.sql_permissions.allow_writes, + ); + return crate::ai_qoder_cli::run_qoder_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 9da61901b3..1ad7b01d6a 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -87,6 +87,8 @@ pub enum AiProvider { GrokCli, #[serde(rename = "codebuddy-cli")] CodeBuddyCli, + #[serde(rename = "qoder-cli")] + QoderCli, Custom, } @@ -108,6 +110,7 @@ impl AiProvider { AiProvider::CursorCli => "cursor-cli", AiProvider::GrokCli => "grok-cli", AiProvider::CodeBuddyCli => "codebuddy-cli", + AiProvider::QoderCli => "qoder-cli", AiProvider::CodexCli => "codex-cli", AiProvider::Custom => "custom", } @@ -415,6 +418,10 @@ pub struct AiConfig { pub codebuddy_cli_path: Option, #[serde(default)] pub codebuddy_cli_env: HashMap, + #[serde(default)] + pub qoder_cli_path: Option, + #[serde(default)] + pub qoder_cli_env: HashMap, } fn default_enable_thinking() -> bool { @@ -434,6 +441,7 @@ pub fn is_cli_provider(provider: &AiProvider) -> bool { | AiProvider::CursorCli | AiProvider::GrokCli | AiProvider::CodeBuddyCli + | AiProvider::QoderCli ) } @@ -667,6 +675,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String { | AiProvider::CursorCli | AiProvider::GrokCli | AiProvider::CodeBuddyCli + | AiProvider::QoderCli | AiProvider::Gemini => unreachable!(), } } @@ -1518,6 +1527,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str AiProvider::CursorCli => crate::ai_cursor_cli::list_cursor_models(config).await?, AiProvider::GrokCli => crate::ai_grok_cli::list_grok_models(config).await?, AiProvider::CodeBuddyCli => crate::ai_codebuddy_cli::list_codebuddy_models(config).await?, + AiProvider::QoderCli => crate::ai_qoder_cli::list_qoder_models(config).await?, _ => { validate_model_list_config(config)?; let client = build_ai_http_client(config, 30)?; @@ -1546,7 +1556,8 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli - | AiProvider::CodeBuddyCli => { + | AiProvider::CodeBuddyCli + | AiProvider::QoderCli => { unreachable!() } } @@ -1575,6 +1586,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res return crate::ai_codebuddy_cli::resolve_codebuddy_model_effort(config, model_id).await; } + if matches!(config.provider, AiProvider::QoderCli) { + return crate::ai_qoder_cli::resolve_qoder_model_effort(config, model_id).await; + } + if matches!(config.provider, AiProvider::CursorCli) { return Ok(AiEffortCapability::Unsupported); } @@ -2120,6 +2135,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result Result { | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli - | AiProvider::CodeBuddyCli => { + | AiProvider::CodeBuddyCli + | AiProvider::QoderCli => { unreachable!() } AiProvider::Openai @@ -2558,7 +2577,8 @@ pub async fn stream( | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli - | AiProvider::CodeBuddyCli => { + | AiProvider::CodeBuddyCli + | AiProvider::QoderCli => { unreachable!() } AiProvider::Openai @@ -4222,6 +4242,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }, system_prompt: "Be concise.".to_string(), messages: vec![AiMessage { @@ -4809,6 +4831,8 @@ mod tests { assert!(config.cursor_cli_env.is_empty()); assert!(config.codebuddy_cli_path.is_none()); assert!(config.codebuddy_cli_env.is_empty()); + assert!(config.qoder_cli_path.is_none()); + assert!(config.qoder_cli_env.is_empty()); } #[test] @@ -4842,6 +4866,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let err = build_ai_http_client(&config, 1).unwrap_err(); @@ -4880,6 +4906,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -4916,6 +4944,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -4952,6 +4982,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert_eq!( @@ -4992,6 +5024,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions"); @@ -5029,6 +5063,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; for provider in @@ -5085,6 +5121,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models"); @@ -5117,6 +5155,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); } @@ -5152,6 +5192,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert!(uses_anthropic_messages_api(&config)); @@ -5239,6 +5281,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert!(!uses_anthropic_messages_api(&config)); @@ -5286,6 +5330,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_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"); @@ -5357,6 +5403,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses"); @@ -5400,6 +5448,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let api_key_headers = claude_headers(&config).unwrap(); @@ -5523,6 +5573,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show"); @@ -5563,6 +5615,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true)); @@ -5652,6 +5706,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let models = vec![ AiModelInfo::new("qwen3:0.6b", None), @@ -5921,6 +5977,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let mut body = serde_json::json!({ @@ -6140,6 +6198,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -6185,6 +6245,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6236,6 +6298,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6281,6 +6345,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6322,6 +6388,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -6394,6 +6462,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let request = AiCompletionRequest { config: config.clone(), @@ -6460,6 +6530,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -6994,6 +7066,7 @@ mod tests { AiProvider::CursorCli, AiProvider::GrokCli, AiProvider::CodeBuddyCli, + AiProvider::QoderCli, ] { let mut config = test_config(provider.clone()); config.max_retries = None; diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs index ac68d84a58..5aa2db0e49 100644 --- a/crates/dbx-core/src/ai_claude_code_cli.rs +++ b/crates/dbx-core/src/ai_claude_code_cli.rs @@ -589,6 +589,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_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 7cd103c109..96f2231da2 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -35,6 +35,7 @@ pub enum CliAgentJsonlDialect { CodexExec, ClaudeCodePrint, CodeBuddyPrint, + QoderPrint, OpenCodeRun, CursorPrint, /// Grok Build headless `--output-format streaming-json` (ACP-derived NDJSON). @@ -240,12 +241,81 @@ fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliA CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line), CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_compatible_jsonl_line(line, "Claude Code CLI failed"), CliAgentJsonlDialect::CodeBuddyPrint => parse_claude_compatible_jsonl_line(line, "CodeBuddy Code CLI failed"), + CliAgentJsonlDialect::QoderPrint => parse_qoder_jsonl_line(line), CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line), CliAgentJsonlDialect::CursorPrint => parse_cursor_jsonl_line(line), CliAgentJsonlDialect::GrokStreamingJson => parse_grok_streaming_json_line(line), } } +fn parse_qoder_jsonl_line(line: &str) -> ParsedCliAgentEvent { + let value = serde_json::from_str::(line).ok(); + if let Some(value) = value.as_ref().filter(|value| { + value.get("type").and_then(Value::as_str) == Some("result") + && value.get("subtype").and_then(Value::as_str).unwrap_or("success") != "success" + }) { + let message = value + .get("errors") + .and_then(Value::as_array) + .map(|errors| { + errors + .iter() + .filter_map(Value::as_str) + .filter(|error| !error.trim().is_empty()) + .collect::>() + .join("\n") + }) + .filter(|message| !message.is_empty()) + .unwrap_or_else(|| claude_code_error_message(value, "Qoder CLI failed")); + return ParsedCliAgentEvent { + error: Some(message.clone()), + events: vec![AgentEvent::Error { message }], + ..Default::default() + }; + } + + let compatible = parse_claude_compatible_jsonl_line(line, "Qoder CLI failed"); + if !compatible.events.is_empty() + || compatible.final_text.is_some() + || compatible.error.is_some() + || compatible.usage_delta.is_some() + { + return compatible; + } + + let Some(value) = value else { + return ParsedCliAgentEvent::default(); + }; + if value.get("type").and_then(Value::as_str) != Some("stream_event") { + return ParsedCliAgentEvent::default(); + } + let Some(delta) = value.get("event").and_then(|event| event.get("delta")) else { + return ParsedCliAgentEvent::default(); + }; + match delta.get("type").and_then(Value::as_str).unwrap_or_default() { + "text_delta" => { + let Some(text) = delta.get("text").and_then(Value::as_str).filter(|text| !text.is_empty()) else { + return ParsedCliAgentEvent::default(); + }; + ParsedCliAgentEvent { + final_text: Some(text.to_string()), + events: vec![AgentEvent::TextDelta { delta: text.to_string() }], + ..Default::default() + } + } + "thinking_delta" => { + let Some(text) = delta.get("thinking").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() + } + } + _ => ParsedCliAgentEvent::default(), + } +} + fn parse_codex_jsonl_line(line: &str) -> ParsedCliAgentEvent { let Ok(value) = serde_json::from_str::(line) else { return ParsedCliAgentEvent::default(); diff --git a/crates/dbx-core/src/ai_codebuddy_cli.rs b/crates/dbx-core/src/ai_codebuddy_cli.rs index 80c0884a4b..07ac30e8bf 100644 --- a/crates/dbx-core/src/ai_codebuddy_cli.rs +++ b/crates/dbx-core/src/ai_codebuddy_cli.rs @@ -888,6 +888,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index 78ee255ab7..8caab5e7e8 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -1049,6 +1049,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_cursor_cli.rs b/crates/dbx-core/src/ai_cursor_cli.rs index 0158a6a09b..735697a8f7 100644 --- a/crates/dbx-core/src/ai_cursor_cli.rs +++ b/crates/dbx-core/src/ai_cursor_cli.rs @@ -426,6 +426,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs index 7832f7668c..d1665c99fa 100644 --- a/crates/dbx-core/src/ai_effort.rs +++ b/crates/dbx-core/src/ai_effort.rs @@ -89,7 +89,8 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option None, + | AiProvider::CodeBuddyCli + | AiProvider::QoderCli => None, } } @@ -198,6 +199,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> { | AiProvider::CursorCli | AiProvider::GrokCli | AiProvider::CodeBuddyCli + | AiProvider::QoderCli | AiProvider::Custom => None, } } @@ -220,6 +222,7 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> { | AiProvider::CursorCli | AiProvider::GrokCli | AiProvider::CodeBuddyCli + | AiProvider::QoderCli ) { return match selection { AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()), @@ -278,7 +281,8 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) { | AiProvider::OpenCodeCli | AiProvider::CursorCli | AiProvider::GrokCli - | AiProvider::CodeBuddyCli => {} + | AiProvider::CodeBuddyCli + | AiProvider::QoderCli => {} } } @@ -440,6 +444,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_grok_cli.rs b/crates/dbx-core/src/ai_grok_cli.rs index 9eaa257331..21c8befd53 100644 --- a/crates/dbx-core/src/ai_grok_cli.rs +++ b/crates/dbx-core/src/ai_grok_cli.rs @@ -806,6 +806,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_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 3e9a72db29..8479a87ef3 100644 --- a/crates/dbx-core/src/ai_model_filter.rs +++ b/crates/dbx-core/src/ai_model_filter.rs @@ -100,6 +100,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st | AiProvider::CursorCli | AiProvider::GrokCli | AiProvider::CodeBuddyCli + | AiProvider::QoderCli | AiProvider::MiniMax | AiProvider::Custom => true, } diff --git a/crates/dbx-core/src/ai_opencode_cli.rs b/crates/dbx-core/src/ai_opencode_cli.rs index da9c6dd05c..6cca558baf 100644 --- a/crates/dbx-core/src/ai_opencode_cli.rs +++ b/crates/dbx-core/src/ai_opencode_cli.rs @@ -472,6 +472,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_pi_agent_cli.rs b/crates/dbx-core/src/ai_pi_agent_cli.rs index d199959c3b..c8aea7cfe2 100644 --- a/crates/dbx-core/src/ai_pi_agent_cli.rs +++ b/crates/dbx-core/src/ai_pi_agent_cli.rs @@ -880,6 +880,8 @@ mod tests { grok_cli_env: HashMap::new(), codebuddy_cli_path: None, codebuddy_cli_env: HashMap::new(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/ai_qoder_cli.rs b/crates/dbx-core/src/ai_qoder_cli.rs new file mode 100644 index 0000000000..4e87783d6d --- /dev/null +++ b/crates/dbx-core/src/ai_qoder_cli.rs @@ -0,0 +1,689 @@ +use crate::agent_events::AgentEvent; +use crate::ai::{AiConfig, AiEffortCapability, AiModelInfo, AiTestConnectionResult}; +use crate::ai_cli_agent::{ + build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, model_infos, parse_cli_jsonl_event, + run_cli_jsonl_agent, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions, +}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::sync::Notify; + +const QODER_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(15); +const QODER_SETTING_SOURCES: &str = "user"; +const DEFAULT_QODER_MODELS: &[&str] = &["default"]; + +pub type QoderRunOptions = CliAgentRunOptions; +pub type QoderCommandSpec = CliAgentCommandSpec; + +struct QoderIsolatedCwd { + path: PathBuf, +} + +impl QoderIsolatedCwd { + fn create() -> Result { + let path = env::temp_dir().join(format!("dbx-qoder-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&path) + .map_err(|error| format!("[qoderRunFailed] Failed to create isolated Qoder directory: {error}"))?; + Ok(Self { path }) + } + + fn write_mcp_config(&self, config: &str) -> Result { + let path = self.path.join("mcp.json"); + std::fs::write(&path, config) + .map_err(|error| format!("[qoderRunFailed] Failed to write temporary Qoder MCP config: {error}"))?; + Ok(path) + } +} + +impl Drop for QoderIsolatedCwd { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn qoder_program(config: &AiConfig) -> String { + config.qoder_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("qodercli").to_string() +} + +fn validate_qoder_program(config: &AiConfig) -> Result { + let program = qoder_program(config); + if starts_with_env_assignment(&program) { + return Err("[qoderCliPathInvalid] Qoder CLI path should contain only the executable path. Add environment variables in the Qoder CLI environment variables section.".to_string()); + } + if is_path_like_program(&program) { + let expanded = crate::path_utils::expand_tilde(&program); + let path = Path::new(&expanded); + if path.is_dir() { + return launchable_program_in_dir(path, "qodercli").ok_or_else(|| { + "[qoderCliPathInvalid] Qoder CLI path should point to the qodercli executable or a directory containing qodercli." + .to_string() + }); + } + return Ok(expanded); + } + Ok(program) +} + +fn launchable_program_in_dir(dir: &Path, program: &str) -> Option { + program_path_candidates(dir, program) + .into_iter() + .find(|candidate| is_launchable_program_path(candidate) && candidate.is_file()) + .map(|path| path.to_string_lossy().to_string()) +} + +#[cfg(not(windows))] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + vec![dir.join(program)] +} + +#[cfg(windows)] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + let path = Path::new(program); + if path.extension().is_some() { + return vec![dir.join(program)]; + } + [".cmd", ".exe", ".bat", ".com", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect() +} + +#[cfg(not(windows))] +fn is_launchable_program_path(_path: &Path) -> bool { + true +} + +#[cfg(windows)] +fn is_launchable_program_path(path: &Path) -> bool { + matches!( + path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(), + Some("exe" | "cmd" | "bat" | "com") + ) +} + +fn is_path_like_program(program: &str) -> bool { + program.contains('/') || program.contains('\\') || program.starts_with('~') +} + +fn starts_with_env_assignment(program: &str) -> bool { + let Some(first_token) = program.split_whitespace().next() else { + return false; + }; + let Some((key, _)) = first_token.split_once('=') else { + return false; + }; + is_env_var_name(key) +} + +fn is_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +pub fn qoder_cli_env(config: &AiConfig) -> Result, String> { + let mut env = BTreeMap::new(); + for (key, value) in &config.qoder_cli_env { + let key = key.trim(); + if key.is_empty() { + continue; + } + if !is_env_var_name(key) { + return Err(format!( + "[qoderEnvInvalid] Invalid Qoder CLI environment variable name `{key}`. Use names like HTTPS_PROXY or QODER_PERSONAL_ACCESS_TOKEN." + )); + } + if key.to_ascii_uppercase().starts_with("DBX_MCP_") { + return Err(format!( + "[qoderEnvReserved] `{key}` is managed by DBX for the scoped MCP server and cannot be set here." + )); + } + env.insert(key.to_string(), value.clone()); + } + Ok(env.into_iter().collect()) +} + +fn qoder_process_env(config: &AiConfig, command: &QoderCommandSpec) -> Result, String> { + let current_path = env::var("PATH").ok(); + qoder_process_env_with_path(config, command, current_path.as_deref()) +} + +fn qoder_process_env_with_path( + config: &AiConfig, + command: &QoderCommandSpec, + current_path: Option<&str>, +) -> Result, String> { + let mut process_env = BTreeMap::from_iter(qoder_cli_env(config)?); + let mut paths = Vec::new(); + if let Some(dir) = + Path::new(&command.program).parent().filter(|parent| !parent.as_os_str().is_empty()).map(Path::to_path_buf) + { + paths.push(dir); + } + if let Some(path) = process_env.get("PATH") { + paths.extend(env::split_paths(path)); + } + if let Some(path) = current_path { + paths.extend(env::split_paths(path)); + } + #[cfg(windows)] + if let Ok(app_data) = env::var("APPDATA") { + paths.push(PathBuf::from(app_data).join("npm")); + } + #[cfg(not(windows))] + paths.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + PathBuf::from("/usr/sbin"), + PathBuf::from("/sbin"), + ]); + let mut seen = BTreeSet::new(); + paths.retain(|path| seen.insert(path.clone())); + if let Ok(path) = env::join_paths(paths) { + process_env.insert("PATH".to_string(), path.to_string_lossy().to_string()); + } + Ok(process_env.into_iter().collect()) +} + +pub fn qoder_enabled_tools(agent_mode: bool) -> Vec { + dbx_mcp_enabled_tools(agent_mode).into_iter().map(|tool| format!("mcp__dbx__{tool}")).collect() +} + +fn qoder_mcp_config(options: &QoderRunOptions) -> String { + let mcp_command = + options.mcp_server_command.as_ref().map(|command| command.program.as_str()).unwrap_or("dbx-mcp-server"); + let mut server = Map::new(); + server.insert("type".to_string(), Value::String("stdio".to_string())); + server.insert("command".to_string(), Value::String(mcp_command.to_string())); + if let Some(command) = options.mcp_server_command.as_ref().filter(|command| !command.args.is_empty()) { + server.insert("args".to_string(), json!(command.args)); + } + let env = dbx_mcp_scope_env(options) + .into_iter() + .map(|(name, value)| (name.to_string(), Value::String(value))) + .collect::>(); + server.insert("env".to_string(), Value::Object(env)); + json!({ "mcpServers": { "dbx": Value::Object(server) } }).to_string() +} + +pub fn build_qoder_command(config: &AiConfig, _prompt: &str, options: &QoderRunOptions) -> QoderCommandSpec { + build_qoder_command_with_mcp_arg(config, options, qoder_mcp_config(options)) +} + +fn build_qoder_command_with_mcp_arg( + config: &AiConfig, + options: &QoderRunOptions, + mcp_config_arg: String, +) -> QoderCommandSpec { + let enabled_tools = qoder_enabled_tools(options.agent_mode); + let tool_list = enabled_tools.join(","); + let mut args = vec![ + "--print".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--input-format".to_string(), + "text".to_string(), + "--no-session-persistence".to_string(), + "--permission-mode".to_string(), + "dont_ask".to_string(), + "--mcp-config".to_string(), + mcp_config_arg, + "--strict-mcp-config".to_string(), + "--allowed-mcp-server-names".to_string(), + "dbx".to_string(), + "--setting-sources".to_string(), + QODER_SETTING_SOURCES.to_string(), + "--tools".to_string(), + tool_list.clone(), + "--allowed-tools".to_string(), + tool_list, + ]; + + let model = config.model.trim(); + if !model.is_empty() && !model.eq_ignore_ascii_case("default") { + args.push("--model".to_string()); + args.push(model.to_string()); + } + if let Some(effort) = config.runtime_effort.as_ref().and_then(|effort| effort.cli_value()) { + args.push("--reasoning-effort".to_string()); + args.push(effort); + } + if let Some(context_window) = config.context_window.filter(|value| *value > 0) { + args.push("--context-window".to_string()); + args.push(context_window.to_string()); + } + + QoderCommandSpec { program: qoder_program(config), args } +} + +pub fn build_qoder_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String { + build_cli_agent_prompt("Qoder", system_prompt, messages, allow_write_sql) +} + +pub async fn list_qoder_models(config: &AiConfig) -> Result, String> { + let output = run_qoder_model_listing(config).await?; + Ok(parse_qoder_models(&String::from_utf8_lossy(&output.stdout))) +} + +async fn run_qoder_model_listing(config: &AiConfig) -> Result { + let program = validate_qoder_program(config)?; + let command = QoderCommandSpec { + program, + args: vec!["--list-models".to_string(), "--setting-sources".to_string(), QODER_SETTING_SOURCES.to_string()], + }; + let isolated_cwd = QoderIsolatedCwd::create()?; + let mut process = cli_command(&command.program); + process + .args(command.args.iter().map(String::as_str)) + .envs(qoder_process_env(config, &command)?.iter().map(|(key, value)| (key.as_str(), value.as_str()))) + .current_dir(&isolated_cwd.path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(QODER_MODEL_DISCOVERY_TIMEOUT, process.output()) + .await + .map_err(|_| "[qoderTimeout] Qoder model discovery timed out.".to_string())? + .map_err(|error| classify_qoder_spawn_error(&error.to_string()))?; + if output.status.success() { + Ok(output) + } else { + let diagnostic = [String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout)] + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect::>() + .join("\n"); + Err(classify_qoder_run_error(&diagnostic)) + } +} + +fn parse_qoder_models(stdout: &str) -> Vec { + let mut ids = Vec::new(); + if let Ok(value) = serde_json::from_str::(stdout.trim()) { + collect_qoder_model_ids(&value, &mut ids); + } else { + for line in stdout.lines() { + let Ok(value) = serde_json::from_str::(line.trim()) else { + continue; + }; + collect_qoder_model_ids(&value, &mut ids); + } + } + + // `--list-models` is documented as a human-readable table. Accept model + // identifiers only from table cells or one-item bullet rows so labels and + // descriptions cannot accidentally become selectable model IDs. + let mut in_single_model_column = false; + for line in stdout.lines() { + if line.trim().eq_ignore_ascii_case("model") { + in_single_model_column = true; + continue; + } + collect_qoder_table_model_ids(line, in_single_model_column, &mut ids); + } + + let mut seen = BTreeSet::new(); + let ids = ids + .into_iter() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty() && seen.insert(id.to_ascii_lowercase())) + .collect::>(); + let mut models = model_infos(DEFAULT_QODER_MODELS); + models + .extend(ids.into_iter().filter(|id| !id.eq_ignore_ascii_case("default")).map(|id| AiModelInfo::new(id, None))); + models +} + +fn collect_qoder_table_model_ids(line: &str, allow_plain_name: bool, ids: &mut Vec) { + let line = line.trim(); + if line.is_empty() || line.chars().all(is_qoder_table_border) { + return; + } + + let cells = if line.contains('│') || line.contains('|') { + line.split(['│', '|']).map(str::trim).filter(|cell| !cell.is_empty()).collect::>() + } else if line.contains('\t') { + line.split('\t').map(str::trim).filter(|cell| !cell.is_empty()).collect::>() + } else { + vec![line.trim_start_matches(['-', '*', '•']).trim()] + }; + + if let Some(id) = cells.iter().find_map(|cell| qoder_model_id_from_cell(cell, allow_plain_name)) { + ids.push(id); + } +} + +fn is_qoder_table_border(ch: char) -> bool { + ch.is_whitespace() + || matches!(ch, '-' | '=' | '+' | '─' | '━' | '┌' | '┐' | '└' | '┘' | '├' | '┤' | '┬' | '┴' | '┼' | '│') +} + +fn qoder_model_id_from_cell(cell: &str, allow_plain_name: bool) -> Option { + let candidate = cell.trim().trim_matches(['`', '*']); + if candidate.is_empty() || candidate.chars().any(char::is_whitespace) { + return None; + } + let lower = candidate.to_ascii_lowercase(); + if ["auto", "ultimate", "performance", "efficient", "lite"].contains(&lower.as_str()) { + return Some(lower); + } + if matches!(lower.as_str(), "model" | "model-id" | "model_id" | "id" | "name" | "type" | "tier") + || !candidate.chars().next().is_some_and(|ch| ch.is_ascii_alphabetic()) + || !allow_plain_name && !candidate.chars().any(|ch| ch.is_ascii_digit()) + || !candidate.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '@')) + { + return None; + } + Some(candidate.to_string()) +} + +fn collect_qoder_model_ids(value: &Value, ids: &mut Vec) { + match value { + Value::Array(items) => { + for item in items { + match item { + Value::String(id) => ids.push(id.to_string()), + Value::Object(fields) => { + if let Some(id) = fields + .get("id") + .or_else(|| fields.get("value")) + .or_else(|| fields.get("model")) + .and_then(Value::as_str) + { + ids.push(id.to_string()); + } + } + _ => {} + } + } + } + Value::Object(fields) => { + for key in ["models", "data", "items"] { + if let Some(models) = fields.get(key) { + collect_qoder_model_ids(models, ids); + } + } + } + _ => {} + } +} + +pub async fn resolve_qoder_model_effort(_config: &AiConfig, _model_id: &str) -> Result { + // Qoder model capabilities are account-dependent and `--list-models` does + // not currently document a stable machine-readable effort schema. + Ok(AiEffortCapability::Unsupported) +} + +pub async fn test_qoder_connection(config: &AiConfig) -> Result { + let start = Instant::now(); + let _ = run_qoder_model_listing(config).await?; + let elapsed = start.elapsed().as_millis() as u64; + Ok(AiTestConnectionResult { + success: true, + message: format!("OK - {elapsed}ms"), + latency_ms: Some(elapsed), + model_used: config.model.trim().to_string(), + error_category: None, + }) +} + +fn classify_qoder_spawn_error(message: &str) -> String { + if message.contains("No such file") || message.contains("not found") || message.contains("os error 2") { + format!("[qoderNotInstalled] Qoder CLI was not found. Install qodercli or set its executable path in DBX AI settings. {message}") + } else { + format!("[qoderRunFailed] Failed to start Qoder CLI: {message}") + } +} + +fn classify_qoder_run_error(diagnostic: &str) -> String { + let diagnostic = sanitize_diagnostic(diagnostic); + let lower = diagnostic.to_ascii_lowercase(); + if lower.contains("not authenticated") + || lower.contains("authentication required") + || lower.contains("please login") + || lower.contains("please log in") + || lower.contains("unauthorized") + || lower.contains("access token") && (lower.contains("invalid") || lower.contains("expired")) + { + format!("[qoderNotAuthenticated] {diagnostic}") + } else if lower.contains("invalid mcp") || lower.contains("mcp config") && lower.contains("invalid") { + format!("[qoderMcpConfigInvalid] {diagnostic}") + } else if lower.contains("dbx-mcp-server") || lower.contains("enoent") { + format!("[dbxMcpMissing] {diagnostic}") + } else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) { + format!("[qoderMcpStartupFailed] {diagnostic}") + } else if lower.contains("protocol") || lower.contains("invalid json") || lower.contains("parse") { + format!("[qoderProtocolError] {diagnostic}") + } else { + format!("[qoderRunFailed] {diagnostic}") + } +} + +fn sanitize_diagnostic(value: &str) -> String { + let value = value.trim(); + if value.is_empty() { + return "Qoder CLI exited without diagnostics.".to_string(); + } + value.chars().take(2_000).collect() +} + +pub fn parse_qoder_jsonl_event(line: &str) -> Option> { + parse_cli_jsonl_event(line, CliAgentJsonlDialect::QoderPrint) +} + +pub async fn run_qoder_agent( + config: &AiConfig, + prompt: &str, + options: QoderRunOptions, + cancelled: &Notify, + on_event: impl Fn(AgentEvent) + Send + Sync + 'static, +) -> Result { + let program = validate_qoder_program(config)?; + let isolated_cwd = QoderIsolatedCwd::create()?; + let mcp_config_path = isolated_cwd.write_mcp_config(&qoder_mcp_config(&options))?; + let mut command = build_qoder_command_with_mcp_arg(config, &options, mcp_config_path.to_string_lossy().to_string()); + command.program = program; + let env = qoder_process_env(config, &command)?; + run_cli_jsonl_agent( + CliAgentProcessSpec { + command, + env, + env_remove: Vec::new(), + current_dir: Some(isolated_cwd.path.clone()), + stdin: Some(prompt.to_string()), + dialect: CliAgentJsonlDialect::QoderPrint, + classify_spawn_error: classify_qoder_spawn_error, + classify_run_error: classify_qoder_run_error, + }, + cancelled, + on_event, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::{ + build_qoder_command, classify_qoder_run_error, parse_qoder_jsonl_event, parse_qoder_models, qoder_cli_env, + qoder_process_env_with_path, QoderCommandSpec, QoderRunOptions, + }; + use crate::agent_events::AgentEvent; + use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiEffortSelection, AiProvider, AiReasoningLevel}; + + fn config(model: &str) -> AiConfig { + AiConfig { + provider: AiProvider::QoderCli, + api_key: String::new(), + auth_method: AiAuthMethod::Bearer, + endpoint: String::new(), + model: model.to_string(), + models: Vec::new(), + api_style: AiApiStyle::Completions, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: true, + reasoning_level: AiReasoningLevel::Default, + runtime_effort: None, + context_window: None, + max_retries: None, + codex_cli_path: None, + codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), + pi_agent_cli_path: None, + pi_agent_cli_env: Default::default(), + opencode_cli_path: None, + opencode_cli_env: Default::default(), + cursor_cli_path: None, + cursor_cli_env: Default::default(), + grok_cli_path: None, + grok_cli_env: Default::default(), + codebuddy_cli_path: None, + codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), + } + } + + fn options() -> QoderRunOptions { + QoderRunOptions { + 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: None, + } + } + + #[test] + fn builds_scoped_headless_qoder_command() { + let mut config = config("performance"); + config.runtime_effort = Some(AiEffortSelection::Enum("high".to_string())); + config.context_window = Some(400_000); + let command = build_qoder_command(&config, "prompt stays on stdin", &options()); + assert_eq!(command.program, "qodercli"); + assert!(command.args.windows(2).any(|args| args == ["--output-format", "stream-json"])); + assert!(command.args.windows(2).any(|args| args == ["--permission-mode", "dont_ask"])); + assert!(command.args.windows(2).any(|args| args == ["--allowed-mcp-server-names", "dbx"])); + assert!(command.args.windows(2).any(|args| args == ["--setting-sources", "user"])); + assert!(command.args.windows(2).any(|args| args == ["--model", "performance"])); + assert!(command.args.windows(2).any(|args| args == ["--reasoning-effort", "high"])); + assert!(command.args.windows(2).any(|args| args == ["--context-window", "400000"])); + assert!(!command.args.iter().any(|arg| arg == "prompt stays on stdin")); + let tools = command.args[command.args.iter().position(|arg| arg == "--tools").unwrap() + 1].clone(); + assert!(tools.contains("mcp__dbx__dbx_execute_query")); + assert!(!tools.contains("Bash")); + } + + #[cfg(not(windows))] + #[test] + fn bare_qoder_command_uses_configured_gui_and_common_paths() { + let mut config = config("default"); + config.qoder_cli_env.insert("PATH".to_string(), "/configured/bin".to_string()); + let command = QoderCommandSpec { program: "qodercli".to_string(), args: Vec::new() }; + + let process_env = qoder_process_env_with_path(&config, &command, Some("/restricted/gui/bin")).unwrap(); + let path = process_env.iter().find(|(key, _)| key == "PATH").map(|(_, value)| value).unwrap(); + let paths = std::env::split_paths(path).collect::>(); + + assert!(paths.contains(&std::path::PathBuf::from("/configured/bin"))); + assert!(paths.contains(&std::path::PathBuf::from("/restricted/gui/bin"))); + assert!(paths.contains(&std::path::PathBuf::from("/opt/homebrew/bin"))); + assert!(paths.contains(&std::path::PathBuf::from("/usr/local/bin"))); + } + + #[test] + fn keeps_default_model_implicit() { + let command = build_qoder_command(&config("default"), "hello", &options()); + assert!(!command.args.contains(&"--model".to_string())); + } + + #[test] + fn validates_environment_and_reserves_dbx_scope() { + let mut config = config("default"); + config.qoder_cli_env.insert("QODER_PERSONAL_ACCESS_TOKEN".to_string(), "secret".to_string()); + assert_eq!( + qoder_cli_env(&config).unwrap(), + vec![("QODER_PERSONAL_ACCESS_TOKEN".to_string(), "secret".to_string())] + ); + config.qoder_cli_env.insert("DBX_MCP_SCOPE_DATABASE".to_string(), "other".to_string()); + assert!(qoder_cli_env(&config).unwrap_err().starts_with("[qoderEnvReserved]")); + } + + #[test] + fn qoder_provider_config_roundtrips_with_cli_credentials() { + let mut config = config("performance"); + config.qoder_cli_path = Some("/opt/homebrew/bin/qodercli".to_string()); + config.qoder_cli_env.insert("QODER_PERSONAL_ACCESS_TOKEN".to_string(), "token".to_string()); + + let decoded: AiConfig = serde_json::from_value(serde_json::to_value(config).unwrap()).unwrap(); + + assert!(matches!(decoded.provider, AiProvider::QoderCli)); + assert_eq!(decoded.qoder_cli_path.as_deref(), Some("/opt/homebrew/bin/qodercli")); + assert_eq!(decoded.qoder_cli_env.get("QODER_PERSONAL_ACCESS_TOKEN").map(String::as_str), Some("token")); + } + + #[test] + fn parses_json_and_human_readable_model_lists() { + let models = parse_qoder_models(r#"{"models":[{"id":"auto"},{"value":"custom/model"}]}"#); + assert_eq!( + models.iter().map(|model| model.id.as_str()).collect::>(), + ["default", "auto", "custom/model"] + ); + let models = parse_qoder_models( + "MODEL\nAuto\nUltimate\nPerformance\nEfficient\nLite\nCantus\nQwen3.8-Max-Preview\nDeepSeek-V4-Pro", + ); + assert_eq!( + models.iter().map(|model| model.id.as_str()).collect::>(), + [ + "default", + "auto", + "ultimate", + "performance", + "efficient", + "lite", + "Cantus", + "Qwen3.8-Max-Preview", + "DeepSeek-V4-Pro" + ] + ); + } + + #[test] + fn classifies_authentication_and_mcp_failures() { + assert!(classify_qoder_run_error("Please login first").starts_with("[qoderNotAuthenticated]")); + assert!(classify_qoder_run_error("MCP server dbx failed to start").starts_with("[qoderMcpStartupFailed]")); + } + + #[test] + fn parses_qoder_assistant_and_stream_deltas() { + let assistant = parse_qoder_jsonl_event( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"hello"},{"type":"thinking","thinking":"plan"}]}}"#, + ) + .unwrap(); + assert!(assistant.iter().any(|event| matches!(event, AgentEvent::TextDelta { delta } if delta == "hello"))); + assert!(assistant.iter().any(|event| matches!(event, AgentEvent::ReasoningDelta { delta } if delta == "plan"))); + + let delta = parse_qoder_jsonl_event( + r#"{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"partial"}}}"#, + ) + .unwrap(); + assert!(matches!(&delta[0], AgentEvent::TextDelta { delta } if delta == "partial")); + + let error = parse_qoder_jsonl_event( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"errors":["Network attempt failed"]}"#, + ) + .unwrap(); + assert!(matches!(&error[0], AgentEvent::Error { message } if message == "Network attempt failed")); + } +} diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index 4e03a2aace..1384360744 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -1637,6 +1637,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }, } } diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 4a97804dc2..bb3a3b083a 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod ai_grok_cli; mod ai_model_filter; pub mod ai_opencode_cli; pub mod ai_pi_agent_cli; +pub mod ai_qoder_cli; pub mod backend_error; pub mod changelog; pub mod cloud_sync; diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 590c1f9ee3..b02431a332 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -5480,6 +5480,8 @@ mod tests { grok_cli_env: std::collections::HashMap::new(), codebuddy_cli_path: None, codebuddy_cli_env: std::collections::HashMap::new(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }, } } diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index 0a44c30ee6..4f400354ca 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -535,6 +535,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), } } @@ -548,6 +550,7 @@ mod tests { AiProvider::CursorCli, AiProvider::GrokCli, AiProvider::CodeBuddyCli, + AiProvider::QoderCli, ] { let config = make_config(provider); assert!(reject_web_unsupported_ai_provider(&config).is_err()); @@ -638,6 +641,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), }; let body = super::AiTestConnectionRequest { config }; diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 9158c46f55..d67868d70c 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -559,6 +559,8 @@ test("AI provider presets include common hosted and local providers", () => { assert.equal(AI_PROVIDER_PRESETS["codebuddy-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["codebuddy-cli"].iconSlug, "codebuddy"); assert.equal(AI_PROVIDER_PRESETS["codebuddy-cli"].requiresApiKey, false); + assert.equal(AI_PROVIDER_PRESETS["qoder-cli"].model, "default"); + assert.equal(AI_PROVIDER_PRESETS["qoder-cli"].requiresApiKey, false); assert.equal(AI_PROVIDER_PRESETS["grok-cli"].model, "default"); assert.equal(AI_PROVIDER_PRESETS["grok-cli"].iconSlug, "grok"); assert.equal(AI_PROVIDER_PRESETS["grok-cli"].requiresApiKey, false); @@ -576,6 +578,8 @@ test("AI provider presets include common hosted and local providers", () => { assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("cursor-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("cursor-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codebuddy-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codebuddy-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("grok-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codebuddy-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("qoder-cli")); + assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("qoder-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("grok-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("grok-cli")); assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); }); @@ -670,6 +674,14 @@ test("normalizes legacy AI config and fills provider defaults", () => { assert.equal(codeBuddy.codebuddyCliPath, "/opt/homebrew/bin/codebuddy"); assert.deepEqual(codeBuddy.codebuddyCliEnv, { HTTPS_PROXY: "http://proxy:9800" }); assert.equal(codeBuddy.model, "default"); + const qoder = normalizeAiConfig({ + provider: "qoder-cli", + qoderCliPath: " /opt/homebrew/bin/qodercli ", + qoderCliEnv: { QODER_PERSONAL_ACCESS_TOKEN: "token" }, + }); + assert.equal(qoder.qoderCliPath, "/opt/homebrew/bin/qodercli"); + assert.deepEqual(qoder.qoderCliEnv, { QODER_PERSONAL_ACCESS_TOKEN: "token" }); + assert.equal(qoder.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 ab00ce9d1c..12991bb47f 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -243,6 +243,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig { AiProvider::CursorCli => (&mut config.cursor_cli_path, "agent"), AiProvider::GrokCli => (&mut config.grok_cli_path, "grok"), AiProvider::CodeBuddyCli => (&mut config.codebuddy_cli_path, "codebuddy"), + AiProvider::QoderCli => (&mut config.qoder_cli_path, "qodercli"), _ => return config, }; let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command); @@ -336,6 +337,8 @@ mod tests { grok_cli_env: Default::default(), codebuddy_cli_path: None, codebuddy_cli_env: Default::default(), + qoder_cli_path: None, + qoder_cli_env: Default::default(), } }