From d3947d246c98dc82ebed400741478d1bf2caf878 Mon Sep 17 00:00:00 2001 From: Guoyu Su Date: Fri, 17 Jul 2026 01:17:06 +0800 Subject: [PATCH] feat(ai): add Claude Code CLI provider --- apps/desktop/public/icons/ai/claudecode.svg | 1 + .../src/components/editor/AiAssistant.vue | 26 +- .../editor/EditorSettingsDialog.vue | 340 ++++--- apps/desktop/src/i18n/locales/en.ts | 16 + apps/desktop/src/i18n/locales/es.ts | 16 + apps/desktop/src/i18n/locales/it.ts | 16 + apps/desktop/src/i18n/locales/ja.ts | 16 + apps/desktop/src/i18n/locales/pt-BR.ts | 16 + apps/desktop/src/i18n/locales/zh-CN.ts | 16 + apps/desktop/src/i18n/locales/zh-TW.ts | 16 + .../lib/__tests__/ai/aiModelEffort.spec.ts | 29 + .../lib/ai/__tests__/aiConfigOrdering.spec.ts | 33 + apps/desktop/src/lib/ai/aiConfigOrdering.ts | 15 + apps/desktop/src/lib/ai/aiModelEffort.ts | 28 + apps/desktop/src/lib/backend/tauri.ts | 4 +- apps/desktop/src/stores/settingsStore.ts | 20 +- apps/desktop/src/types/ai.ts | 15 +- crates/dbx-core/src/agent_loop.rs | 37 +- crates/dbx-core/src/ai.rs | 126 ++- crates/dbx-core/src/ai_claude_code_cli.rs | 855 ++++++++++++++++++ crates/dbx-core/src/ai_cli_agent.rs | 156 +++- crates/dbx-core/src/ai_codex_cli.rs | 7 +- crates/dbx-core/src/cloud_sync.rs | 2 + crates/dbx-core/src/lib.rs | 1 + crates/dbx-core/src/storage.rs | 25 +- crates/dbx-web/src/routes/ai.rs | 6 +- packages/app-tests/settingsStore.test.ts | 30 + src-tauri/src/commands/ai.rs | 31 +- 28 files changed, 1723 insertions(+), 176 deletions(-) create mode 100644 apps/desktop/public/icons/ai/claudecode.svg create mode 100644 apps/desktop/src/lib/__tests__/ai/aiModelEffort.spec.ts create mode 100644 apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts create mode 100644 apps/desktop/src/lib/ai/aiConfigOrdering.ts create mode 100644 apps/desktop/src/lib/ai/aiModelEffort.ts create mode 100644 crates/dbx-core/src/ai_claude_code_cli.rs diff --git a/apps/desktop/public/icons/ai/claudecode.svg b/apps/desktop/public/icons/ai/claudecode.svg new file mode 100644 index 000000000..d55522ced --- /dev/null +++ b/apps/desktop/public/icons/ai/claudecode.svg @@ -0,0 +1 @@ +Claude Code diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 9e9ea7006..7c04c9182 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -54,6 +54,8 @@ import { useQueryStore } from "@/stores/queryStore"; import { useToast } from "@/composables/useToast"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, defaultActionForMode, type AiAction, type AiAssistantMode, type AiSqlFileContext } from "@/lib/ai/ai"; +import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; +import { normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort"; import type { AgentEvent } from "@/lib/backend/tauri"; import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan"; @@ -229,12 +231,14 @@ const modelSearchQuery = ref(""); // Configured providers for quick switching - get from aiConfigs const configuredProviders = computed(() => { - const providers = settings.aiConfigs.filter((c) => { - // Check directly if config has required fields - const preset = AI_PROVIDER_PRESETS[c.provider]; - if (c.provider === "codex-cli") return true; - return !!c.endpoint?.trim() && !!c.model?.trim() && (!preset.requiresApiKey || !!c.apiKey?.trim()); - }); + const providers = orderAiConfigsForDisplay( + settings.aiConfigs.filter((c) => { + // Check directly if config has required fields + const preset = AI_PROVIDER_PRESETS[c.provider]; + if (c.provider === "codex-cli" || c.provider === "claude-code-cli") return true; + return !!c.endpoint?.trim() && !!c.model?.trim() && (!preset.requiresApiKey || !!c.apiKey?.trim()); + }), + ); // Apply search filter - hide providers with no matching models if (modelSearchQuery.value.trim()) { const query = modelSearchQuery.value.trim().toLowerCase(); @@ -250,7 +254,15 @@ const activeFullConfig = computed(() => { if (!settings.activeModel) return null; const item = settings.aiConfigs.find((c) => c.id === settings.activeModel!.configId); if (!item) return null; - return normalizeAiConfig({ ...item, model: settings.activeModel!.modelId }); + const modelId = settings.activeModel.modelId; + const config = normalizeAiConfig({ ...item, model: modelId }); + if (config.provider === "claude-code-cli") { + config.reasoningLevel = normalizeClaudeCodeReasoningLevel( + config.reasoningLevel, + item.models?.find((model) => model.name === modelId), + ); + } + return config; }); function getModelsForConfig(configId: string): string[] { diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index bfc235473..df5ccd4b3 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -32,6 +32,8 @@ import { type AiProvider, type AiApiStyle, type AiAuthMethod, + type AiConfiguredModel, + type AiEffortLevel, type AiReasoningLevel, type EditorTheme, type DesktopIconTheme, @@ -44,6 +46,8 @@ import { type CustomTheme, } from "@/stores/settingsStore"; import { createRunStatementButtonDom, loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; +import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; +import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort"; import ThemeCustomizerDialog from "./ThemeCustomizerDialog.vue"; import TunnelProfileManager from "@/components/connection/TunnelProfileManager.vue"; import DangerConfirmDialog from "./DangerConfirmDialog.vue"; @@ -74,6 +78,7 @@ import { webdavSyncTest, webdavSyncUpload, type AppSupportInfo, + type AiModelInfo, type McpServerStatus, type SnippetProvider, type SnippetSyncConfig, @@ -1821,7 +1826,7 @@ watch( await refreshSnippetTokenStatus(); syncAiEditState(); if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus(); - if (!isWeb && activeSettingsTab.value === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus(); + if (!isWeb && activeSettingsTab.value === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus(); if (activeSettingsTab.value === "about") void refreshAppSupportInfo(); await scrollToInitialSettingsSection(); } @@ -1865,7 +1870,7 @@ watch(snippetProvider, (provider) => { watch(activeSettingsTab, (tab) => { if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus(); - if (tab === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus(); + if (tab === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus(); if (tab === "about" && !appSupportInfo.value) void refreshAppSupportInfo(); if (tab === "appearance") { checkLayoutDescTruncation(); @@ -1925,22 +1930,24 @@ async function changePassword() { const aiConfigListMode = ref<"list" | "edit">("list"); const aiEditConfigName = ref(""); const aiEditConfigId = ref(null); +const displayedAiConfigs = computed(() => orderAiConfigsForDisplay(settingsStore.aiConfigs)); // AI Config Delete Confirmation const aiDeleteConfirmOpen = ref(false); const aiDeleteConfigId = ref(null); // Model list management -const aiEditModels = ref>([]); +const aiEditModels = ref([]); const aiModelListLoading = ref(false); let aiModelListRequestToken = 0; // AI Model Multi-Select const aiModelMultiSelectOpen = ref(false); const aiModelMultiSelectSearch = ref(""); -const aiFetchedModels = ref>([]); +const aiFetchedModels = ref([]); -const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || provider.provider !== "codex-cli")); +const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli"]); +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]); const aiEditProvider = ref("claude"); @@ -1956,18 +1963,27 @@ const aiEditReasoningLevel = ref("default"); const aiEditContextWindow = ref(undefined); const aiEditCodexCliPath = ref(""); const aiEditCodexCliEnvRows = ref([]); +const aiEditClaudeCodeCliPath = ref(""); +const aiEditClaudeCodeCliEnvRows = ref([]); const aiModelError = ref(""); const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions"); const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages"); -const aiReasoningLevelOptions: Array<{ value: AiReasoningLevel; labelKey: string }> = [ +const codexReasoningLevelOptions: Array<{ value: AiReasoningLevel; labelKey: string }> = [ { value: "default", labelKey: "ai.reasoningLevelDefault" }, { value: "minimal", labelKey: "ai.reasoningLevelMinimal" }, { value: "low", labelKey: "ai.reasoningLevelLow" }, { value: "medium", labelKey: "ai.reasoningLevelMedium" }, { value: "high", labelKey: "ai.reasoningLevelHigh" }, ]; +const effortLevelLabelKeys: Record = { + low: "ai.reasoningLevelLow", + medium: "ai.reasoningLevelMedium", + high: "ai.reasoningLevelHigh", + xhigh: "ai.reasoningLevelXhigh", + max: "ai.reasoningLevelMax", +}; const aiTesting = ref(false); const aiTestResult = ref<"" | "success" | "error">(""); @@ -1975,8 +1991,24 @@ const aiTestError = ref(""); const aiTestLatency = ref(null); const aiTestErrorCopied = ref(false); const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli"); -watch(aiIsCodexCli, (isCodex) => { - if (isCodex) void ensureCodexMcpStatus(); +const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli"); +const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value)); +const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label); +const aiCliCommandName = computed(() => (aiIsClaudeCodeCli.value ? "claude" : "codex")); +const aiCliLoginCommand = computed(() => (aiIsClaudeCodeCli.value ? "claude auth login" : "codex login")); +const aiEditCliPath = computed({ + get: () => (aiIsClaudeCodeCli.value ? aiEditClaudeCodeCliPath.value : aiEditCodexCliPath.value), + set: (value: string) => { + if (aiIsClaudeCodeCli.value) { + aiEditClaudeCodeCliPath.value = value; + } else { + aiEditCodexCliPath.value = value; + } + }, +}); +const aiEditCliEnvRows = computed(() => (aiIsClaudeCodeCli.value ? aiEditClaudeCodeCliEnvRows.value : aiEditCodexCliEnvRows.value)); +watch(aiIsCliProvider, (isCliProvider) => { + if (isCliProvider) void ensureCliMcpStatus(); }); const aiRequiresApiKey = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value].requiresApiKey); const aiSupportsAuthMethod = computed(() => aiEditProvider.value === "claude" || (aiEditProvider.value === "custom" && aiAnthropicMessagesMode.value)); @@ -2004,35 +2036,35 @@ const aiEndpointHint = computed(() => { } return ""; }); -const aiSupportsApiStyle = computed(() => !aiIsCodexCli.value && (aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom")); +const aiSupportsApiStyle = computed(() => !aiIsCliProvider.value && (aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom")); const aiSupportsAnthropicApiStyle = computed(() => aiEditProvider.value === "custom"); -const aiCodexMcpNeedsInstall = computed(() => aiIsCodexCli.value && (!mcpStatus.value || !mcpStatus.value.installed)); -const aiCodexMcpCanInstall = computed(() => { +const aiCliMcpNeedsInstall = computed(() => aiIsCliProvider.value && (!mcpStatus.value || !mcpStatus.value.installed)); +const aiCliMcpCanInstall = computed(() => { const status = mcpStatus.value; return !mcpInstalling.value && !!status?.npm_available && (!status.installed || status.update_available); }); -const aiCodexMcpActionLabel = computed(() => { +const aiCliMcpActionLabel = computed(() => { if (!mcpStatus.value?.installed) return t("settings.mcpInstallButton"); if (mcpStatus.value.update_available) return t("settings.mcpUpdateButton"); return t("settings.mcpUpToDate"); }); const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini"); -const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCodexCli.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim())); -const aiCodexEnvError = computed(() => codexEnvValidationError()); -const aiCodexPathError = computed(() => { - const path = aiEditCodexCliPath.value.trim(); +const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCliProvider.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim())); +const aiCliEnvError = computed(() => cliEnvValidationError()); +const aiCliPathError = computed(() => { + const path = aiEditCliPath.value.trim(); const firstToken = path.split(/\s+/)[0] || ""; - return /^[A-Za-z_][A-Za-z0-9_]*=/.test(firstToken) ? t("ai.codexCliPathEnvError") : ""; + return /^[A-Za-z_][A-Za-z0-9_]*=/.test(firstToken) ? t("ai.cliPathEnvError", { provider: aiCliProviderLabel.value }) : ""; }); -const aiCodexValidationError = computed(() => (aiIsCodexCli.value ? aiCodexPathError.value || aiCodexEnvError.value : "")); +const aiCliValidationError = computed(() => (aiIsCliProvider.value ? aiCliPathError.value || aiCliEnvError.value : "")); function aiEnvRowsFromConfig(env: unknown): AiEnvRow[] { return Object.entries(normalizeAiEnv(env)).map(([key, value]) => ({ id: uuid(), key, value })); } -function codexEnvFromRows(): Record { +function cliEnvFromRows(rows = aiEditCliEnvRows.value): Record { const result: Record = {}; - for (const row of aiEditCodexCliEnvRows.value) { + for (const row of rows) { const key = row.key.trim(); if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.toUpperCase().startsWith("DBX_MCP_")) continue; result[key] = row.value; @@ -2040,24 +2072,29 @@ function codexEnvFromRows(): Record { return result; } -function codexEnvValidationError(): string { - for (const row of aiEditCodexCliEnvRows.value) { +function cliEnvValidationError(): string { + for (const row of aiEditCliEnvRows.value) { const key = row.key.trim(); - if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.codexCliEnvInvalidName", { name: key }); - if (key.toUpperCase().startsWith("DBX_MCP_")) return t("ai.codexCliEnvReservedName", { name: key }); + if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key }); + if (key.toUpperCase().startsWith("DBX_MCP_")) return t("ai.cliEnvReservedName", { name: key }); } return ""; } -function addCodexEnvRow() { - aiEditCodexCliEnvRows.value.push({ id: uuid(), key: "", value: "" }); +function addCliEnvRow() { + aiEditCliEnvRows.value.push({ id: uuid(), key: "", value: "" }); } -function removeCodexEnvRow(id: string) { - aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); +function removeCliEnvRow(id: string) { + if (aiIsClaudeCodeCli.value) { + aiEditClaudeCodeCliEnvRows.value = aiEditClaudeCodeCliEnvRows.value.filter((row) => row.id !== id); + } else { + aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); + } } function currentAiEditConfig() { + const reasoningLevel = aiIsClaudeCodeCli.value ? normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value) : aiEditReasoningLevel.value; return { provider: aiEditProvider.value, apiKey: aiEditApiKey.value, @@ -2068,10 +2105,12 @@ function currentAiEditConfig() { proxyEnabled: aiEditProxyEnabled.value, proxyUrl: aiEditProxyUrl.value, enableThinking: aiEditEnableThinking.value, - reasoningLevel: aiEditReasoningLevel.value, + reasoningLevel, contextWindow: aiEditContextWindow.value || undefined, codexCliPath: aiEditCodexCliPath.value.trim() || undefined, - codexCliEnv: aiIsCodexCli.value ? codexEnvFromRows() : {}, + codexCliEnv: aiIsCodexCli.value ? cliEnvFromRows(aiEditCodexCliEnvRows.value) : {}, + claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim() || undefined, + claudeCodeCliEnv: aiIsClaudeCodeCli.value ? cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value) : {}, }; } @@ -2083,7 +2122,7 @@ function syncAiEditState() { } function aiSelectProvider(provider: AiProvider) { - if (isWeb && provider === "codex-cli") return; + if (isWeb && CLI_AI_PROVIDERS.has(provider)) return; if (provider === aiEditProvider.value) return; // Apply new provider's preset defaults to edit state @@ -2094,9 +2133,14 @@ function aiSelectProvider(provider: AiProvider) { aiEditEndpoint.value = preset.endpoint; aiEditModel.value = preset.model; aiEditApiStyle.value = preset.apiStyle; + aiEditReasoningLevel.value = "default"; aiEditModels.value = []; aiFetchedModels.value = []; - if (provider === "codex-cli") void ensureCodexMcpStatus(); + aiLastModelFetchSignature = ""; + aiModelListRequestToken += 1; + aiModelListLoading.value = false; + if (CLI_AI_PROVIDERS.has(provider)) void ensureCliMcpStatus(); + if (provider === "claude-code-cli") void aiFetchModelList(); } function aiSelectApiStyle(style: AiApiStyle) { @@ -2132,7 +2176,9 @@ function aiEnterEditMode(configId?: string) { aiEditContextWindow.value = config.contextWindow; aiEditCodexCliPath.value = config.codexCliPath ?? ""; aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(config.codexCliEnv); - aiEditModels.value = config.models ? [...config.models] : []; + aiEditClaudeCodeCliPath.value = config.claudeCodeCliPath ?? ""; + aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv); + aiEditModels.value = config.models ? config.models.map((model) => ({ ...model, supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined })) : []; } } else { aiEditConfigName.value = ""; @@ -2150,7 +2196,12 @@ function aiEnterEditMode(configId?: string) { aiEditContextWindow.value = undefined; aiEditCodexCliPath.value = ""; aiEditCodexCliEnvRows.value = []; + aiEditClaudeCodeCliPath.value = ""; + aiEditClaudeCodeCliEnvRows.value = []; } + aiFetchedModels.value = []; + aiLastModelFetchSignature = ""; + if (aiIsClaudeCodeCli.value) void aiFetchModelList(); } function aiAddModel() { @@ -2165,6 +2216,7 @@ async function aiFetchModelList() { if (aiModelListLoading.value) return; if (!aiCanListModels.value) return; const token = ++aiModelListRequestToken; + const signature = aiModelFetchSignature.value; aiModelListLoading.value = true; aiModelError.value = ""; try { @@ -2181,18 +2233,26 @@ async function aiFetchModelList() { reasoningLevel: aiEditReasoningLevel.value, contextWindow: aiEditContextWindow.value, codexCliPath: aiEditCodexCliPath.value, - codexCliEnv: codexEnvFromRows(), + codexCliEnv: cliEnvFromRows(aiEditCodexCliEnvRows.value), + claudeCodeCliPath: aiEditClaudeCodeCliPath.value, + claudeCodeCliEnv: cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value), }); if (token !== aiModelListRequestToken) return; - const seen = new Set(); - aiFetchedModels.value = models - .filter((m: any) => { - const id = m.id?.trim(); - if (!id || seen.has(id)) return false; - seen.add(id); - return true; - }) - .map((m: any) => ({ id: m.id.trim(), displayName: m.displayName?.trim() || undefined })); + aiFetchedModels.value = normalizeAiModelOptions(models); + aiLastModelFetchSignature = signature; + const fetchedById = new Map(aiFetchedModels.value.map((model) => [model.id, model])); + aiEditModels.value = aiEditModels.value.map((model) => { + const fetched = fetchedById.get(model.name); + if (!fetched) return model; + return { + name: model.name, + label: fetched.displayName || model.label, + supportedEffortLevels: fetched.supportedEffortLevels, + }; + }); + if (aiIsClaudeCodeCli.value) { + aiEditReasoningLevel.value = normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value); + } } catch (e: any) { if (token === aiModelListRequestToken) { aiModelError.value = e?.message || String(e); @@ -2206,12 +2266,16 @@ function aiIsModelSelected(modelId: string): boolean { return aiEditModels.value.some((m) => m.name === modelId); } -function aiToggleModel(model: { id: string; displayName?: string }) { +function aiToggleModel(model: AiModelInfo) { const index = aiEditModels.value.findIndex((m) => m.name === model.id); if (index >= 0) { aiEditModels.value.splice(index, 1); } else { - aiEditModels.value.push({ name: model.id, label: model.displayName || model.id }); + aiEditModels.value.push({ + name: model.id, + label: model.displayName || model.id, + supportedEffortLevels: normalizeAiModelEffortLevels(model.supportedEffortLevels), + }); } } @@ -2228,16 +2292,97 @@ const aiModelFetchSignature = computed(() => apiKey: aiEditApiKey.value.trim(), authMethod: aiEditAuthMethod.value, apiStyle: aiEditApiStyle.value, + codexCliPath: aiEditCodexCliPath.value.trim(), + codexCliEnv: cliEnvFromRows(aiEditCodexCliEnvRows.value), + claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim(), + claudeCodeCliEnv: cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value), }), ); let aiLastModelFetchSignature = ""; +function normalizeAiModelOptions(models: AiModelInfo[]): AiModelInfo[] { + const seen = new Set(); + const normalized: AiModelInfo[] = []; + for (const model of models) { + const id = model.id?.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + const supportedEffortLevels = normalizeAiModelEffortLevels(model.supportedEffortLevels); + normalized.push({ + id, + displayName: model.displayName?.trim() || undefined, + supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined, + }); + } + return normalized; +} + +const aiSelectedModelInfo = computed(() => { + if (aiModelFetchSignature.value === aiLastModelFetchSignature) { + const fetched = aiFetchedModels.value.find((model) => model.id === aiEditModel.value); + if (fetched) return fetched; + } + const configured = aiEditModels.value.find((model) => model.name === aiEditModel.value); + if (!configured) return undefined; + return { + id: configured.name, + displayName: configured.label, + supportedEffortLevels: configured.supportedEffortLevels, + }; +}); +const aiClaudeCodeEffortLevels = computed(() => normalizeAiModelEffortLevels(aiSelectedModelInfo.value?.supportedEffortLevels)); +const aiReasoningLevelOptions = computed>(() => { + if (!aiIsClaudeCodeCli.value) return codexReasoningLevelOptions; + return [{ value: "default", labelKey: "ai.reasoningLevelDefault" }, ...aiClaudeCodeEffortLevels.value.map((value) => ({ value, labelKey: effortLevelLabelKeys[value] }))]; +}); +const aiReasoningLevelDisabled = computed(() => aiIsClaudeCodeCli.value && aiClaudeCodeEffortLevels.value.length === 0); +const aiReasoningLevelHint = computed(() => { + if (!aiIsClaudeCodeCli.value) return t("ai.reasoningLevelHint"); + if (aiModelListLoading.value && aiClaudeCodeEffortLevels.value.length === 0) return t("ai.loadingModels"); + return aiClaudeCodeEffortLevels.value.length ? t("ai.claudeCodeEffortHint") : t("ai.claudeCodeEffortUnavailable"); +}); + +watch([aiIsClaudeCodeCli, aiSelectedModelInfo], () => { + if (!aiIsClaudeCodeCli.value) return; + aiEditReasoningLevel.value = normalizeClaudeCodeReasoningLevel(aiEditReasoningLevel.value, aiSelectedModelInfo.value); +}); + +function aiModelsForSave(): AiConfiguredModel[] | undefined { + const models = aiEditModels.value + .map((model) => { + const supportedEffortLevels = normalizeAiModelEffortLevels(model.supportedEffortLevels); + return { + name: model.name.trim(), + label: model.label?.trim() || undefined, + supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined, + }; + }) + .filter((model) => model.name); + + if (aiIsClaudeCodeCli.value && aiEditModel.value.trim() && aiSelectedModelInfo.value) { + const modelId = aiEditModel.value.trim(); + const supportedEffortLevels = normalizeAiModelEffortLevels(aiSelectedModelInfo.value.supportedEffortLevels); + const configuredModel = models.find((model) => model.name === modelId); + if (configuredModel) { + configuredModel.label = aiSelectedModelInfo.value.displayName || configuredModel.label; + configuredModel.supportedEffortLevels = supportedEffortLevels.length ? supportedEffortLevels : undefined; + } else { + models.unshift({ + name: modelId, + label: aiSelectedModelInfo.value.displayName, + supportedEffortLevels: supportedEffortLevels.length ? supportedEffortLevels : undefined, + }); + } + } + + return models.length ? models : undefined; +} + function aiOnModelPopoverOpen(open: boolean) { if (!open) return; if (aiModelFetchSignature.value !== aiLastModelFetchSignature) { aiFetchedModels.value = []; - aiLastModelFetchSignature = aiModelFetchSignature.value; if (aiCanListModels.value) void aiFetchModelList(); } else if (aiFetchedModels.value.length === 0 && aiCanListModels.value) { void aiFetchModelList(); @@ -2255,23 +2400,14 @@ async function aiSaveConfig() { return; } + const editConfig = currentAiEditConfig(); + aiEditReasoningLevel.value = editConfig.reasoningLevel; + const models = aiModelsForSave(); const config: AiConfigItem = { id: aiEditConfigId.value || generateId(), name: aiEditConfigName.value, - provider: aiEditProvider.value, - apiKey: aiEditApiKey.value, - authMethod: aiEditAuthMethod.value, - endpoint: aiEditEndpoint.value, - model: aiEditModel.value, - models: aiEditModels.value.length > 0 ? aiEditModels.value.filter((m) => m.name.trim()) : undefined, - apiStyle: aiEditApiStyle.value, - proxyEnabled: aiEditProxyEnabled.value, - proxyUrl: aiEditProxyUrl.value, - enableThinking: aiEditEnableThinking.value, - reasoningLevel: aiEditReasoningLevel.value, - contextWindow: aiEditContextWindow.value, - codexCliPath: aiEditCodexCliPath.value, - codexCliEnv: codexEnvFromRows(), + ...editConfig, + models, }; try { @@ -2318,10 +2454,10 @@ async function aiSetDefaultConfig(id: string) { } async function aiTestConn() { - if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCodexCli.value && !aiEditEndpoint.value.trim()) || (!aiIsCodexCli.value && !aiEditModel.value.trim())) return; - if (aiCodexValidationError.value) { + if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCliProvider.value && !aiEditEndpoint.value.trim()) || (!aiIsCliProvider.value && !aiEditModel.value.trim())) return; + if (aiCliValidationError.value) { aiTestResult.value = "error"; - aiTestError.value = aiCodexValidationError.value; + aiTestError.value = aiCliValidationError.value; return; } aiTesting.value = true; @@ -2350,8 +2486,8 @@ async function copyAiTestError() { }, 1500); } -async function ensureCodexMcpStatus() { - if (isWeb || activeSettingsTab.value !== "ai" || !aiIsCodexCli.value || mcpStatus.value || mcpStatusLoading.value) return; +async function ensureCliMcpStatus() { + if (isWeb || activeSettingsTab.value !== "ai" || !aiIsCliProvider.value || mcpStatus.value || mcpStatusLoading.value) return; await refreshMcpStatus(); } @@ -4293,7 +4429,7 @@ onUnmounted(cleanupPreviewEditor);
-
+
@@ -4354,21 +4490,21 @@ onUnmounted(cleanupPreviewEditor);
- -
+ +
- + - {{ t("ai.codexMcpRequiredTitle") }} + {{ t("ai.cliMcpRequiredTitle") }} {{ mcpStatusLabel }}

- {{ t("ai.codexMcpRequiredDescription") }} + {{ t("ai.cliMcpRequiredDescription", { provider: aiCliProviderLabel }) }}

{{ mcpStatusError || mcpStatus?.error }} @@ -4380,16 +4516,16 @@ onUnmounted(cleanupPreviewEditor); {{ t("settings.mcpRefresh") }} -

-
+
@@ -4417,35 +4553,35 @@ onUnmounted(cleanupPreviewEditor);
- -
- + +
+
- -

{{ t("ai.codexCliPathHint") }}

-

{{ aiCodexPathError }}

+ +

{{ t("ai.cliPathHint", { command: aiCliCommandName, loginCommand: aiCliLoginCommand }) }}

+

{{ aiCliPathError }}

- -
- + +
+
-
- - -
- -

{{ aiCodexEnvError }}

-

{{ t("ai.codexCliEnvHint") }}

+

{{ aiCliEnvError }}

+

{{ t("ai.cliEnvHint", { provider: aiCliProviderLabel }) }}

@@ -4506,10 +4642,10 @@ onUnmounted(cleanupPreviewEditor);
-
+
- @@ -4519,7 +4655,7 @@ onUnmounted(cleanupPreviewEditor); -

{{ t("ai.reasoningLevelHint") }}

+

{{ aiReasoningLevelHint }}

@@ -4534,7 +4670,7 @@ onUnmounted(cleanupPreviewEditor);
-
+
-
+
@@ -4562,7 +4698,7 @@ onUnmounted(cleanupPreviewEditor);
-
+
-
+
@@ -4958,7 +5094,7 @@ onUnmounted(cleanupPreviewEditor); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index c6e2038ce..9367196c6 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1466,6 +1466,18 @@ export default { codexCliEnvValuePlaceholder: "http://proxy:9800", codexCliEnvInvalidName: "Invalid environment variable name: {name}", codexCliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.", + cliMcpRequiredTitle: "DBX MCP Server required", + cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.", + cliPath: "{provider} Path", + cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.", + cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.", + cliEnv: "Environment variables", + cliEnvAdd: "Add variable", + cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.", + cliEnvKeyPlaceholder: "HTTPS_PROXY", + cliEnvValuePlaceholder: "http://proxy:9800", + cliEnvInvalidName: "Invalid environment variable name: {name}", + cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.", codexMcpRequiredTitle: "DBX MCP Server required", codexMcpRequiredDescription: "Codex CLI uses the DBX MCP Server to access database schema and query tools. Install it before using Codex in the AI assistant.", reasoningLevel: "Reasoning level", @@ -1474,7 +1486,11 @@ export default { reasoningLevelLow: "Low", reasoningLevelMedium: "Medium", reasoningLevelHigh: "High", + reasoningLevelXhigh: "Extra high", + reasoningLevelMax: "Maximum", reasoningLevelHint: "Controls Codex CLI model_reasoning_effort. Default uses your Codex config.", + claudeCodeEffortHint: "Available levels are reported by the selected model through your local Claude Code CLI.", + claudeCodeEffortUnavailable: "The selected model does not report configurable effort levels. Claude Code will use its default.", actions: { general: "General", generate: "Generate SQL", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 502f1c472..be7578e31 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1325,13 +1325,29 @@ export default withEnglishFallback({ codexCliEnvValuePlaceholder: "http://proxy:9800", codexCliEnvInvalidName: "Nombre de variable de entorno no válido: {name}", codexCliEnvReservedName: "{name} lo administra DBX para MCP Server y no se puede configurar aquí.", + cliMcpRequiredTitle: "DBX MCP Server required", + cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.", + cliPath: "{provider} Path", + cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.", + cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.", + cliEnv: "Environment variables", + cliEnvAdd: "Add variable", + cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.", + cliEnvKeyPlaceholder: "HTTPS_PROXY", + cliEnvValuePlaceholder: "http://proxy:9800", + cliEnvInvalidName: "Invalid environment variable name: {name}", + cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.", reasoningLevel: "Nivel de razonamiento", reasoningLevelDefault: "Predeterminado", reasoningLevelMinimal: "Mínimo", reasoningLevelLow: "Bajo", reasoningLevelMedium: "Medio", reasoningLevelHigh: "Alto", + reasoningLevelXhigh: "Muy alto", + reasoningLevelMax: "Máximo", reasoningLevelHint: "Controla model_reasoning_effort de Codex CLI. Predeterminado usa tu configuración de Codex.", + claudeCodeEffortHint: "Los niveles disponibles los proporciona dinámicamente tu Claude Code CLI local para el modelo seleccionado.", + claudeCodeEffortUnavailable: "El modelo seleccionado no informa niveles de esfuerzo configurables. Claude Code usará su valor predeterminado.", run: "Ejecutar", readingSchema: "Leyendo esquema", noConnection: "No hay una conexión disponible para esta pestaña", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 847fa5a4e..98fe931d3 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1406,6 +1406,18 @@ export default withEnglishFallback({ codexCliEnvValuePlaceholder: "http://proxy:9800", codexCliEnvInvalidName: "Nome variabile d'ambiente non valido: {name}", codexCliEnvReservedName: "{name} è gestita da DBX per MCP Server e non può essere impostata qui.", + cliMcpRequiredTitle: "DBX MCP Server required", + cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.", + cliPath: "{provider} Path", + cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.", + cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.", + cliEnv: "Environment variables", + cliEnvAdd: "Add variable", + cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.", + cliEnvKeyPlaceholder: "HTTPS_PROXY", + cliEnvValuePlaceholder: "http://proxy:9800", + cliEnvInvalidName: "Invalid environment variable name: {name}", + cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.", codexMcpRequiredTitle: "DBX MCP Server richiesto", codexMcpRequiredDescription: "Codex CLI usa il DBX MCP Server per accedere allo schema del database e agli strumenti di query. Installalo prima di usare Codex nell'assistente AI.", reasoningLevel: "Livello di ragionamento", @@ -1414,7 +1426,11 @@ export default withEnglishFallback({ reasoningLevelLow: "Basso", reasoningLevelMedium: "Medio", reasoningLevelHigh: "Alto", + reasoningLevelXhigh: "Molto alto", + reasoningLevelMax: "Massimo", reasoningLevelHint: "Controlla model_reasoning_effort di Codex CLI. Predefinito usa la configurazione Codex.", + claudeCodeEffortHint: "I livelli disponibili vengono forniti dinamicamente dal Claude Code CLI locale per il modello selezionato.", + claudeCodeEffortUnavailable: "Il modello selezionato non segnala livelli di effort configurabili. Claude Code userà il valore predefinito.", actions: { general: "Generale", generate: "Genera SQL", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 5b5499c3b..7851868f3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1324,6 +1324,18 @@ export default withEnglishFallback({ codexCliEnvValuePlaceholder: "http://proxy:9800", codexCliEnvInvalidName: "無効な環境変数名: {name}", codexCliEnvReservedName: "{name}はMCP Server用にDBXが管理しているため、ここでは設定できません。", + cliMcpRequiredTitle: "DBX MCP Server required", + cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.", + cliPath: "{provider} Path", + cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.", + cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.", + cliEnv: "Environment variables", + cliEnvAdd: "Add variable", + cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.", + cliEnvKeyPlaceholder: "HTTPS_PROXY", + cliEnvValuePlaceholder: "http://proxy:9800", + cliEnvInvalidName: "Invalid environment variable name: {name}", + cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.", contextWindow: "コンテキストウィンドウ", contextWindowAuto: "自動(モデル名から検出)", contextWindowHint: "トークン数。空欄で自動検出。ローカル/カスタムモデルは手動設定してください。", @@ -1335,7 +1347,11 @@ export default withEnglishFallback({ reasoningLevelLow: "低", reasoningLevelMedium: "中", reasoningLevelHigh: "高", + reasoningLevelXhigh: "非常に高い", + reasoningLevelMax: "最大", reasoningLevelHint: "Codex CLIのmodel_reasoning_effortを制御します。デフォルトではCodex設定を使用します。", + claudeCodeEffortHint: "利用可能なレベルは、選択したモデルについてローカルのClaude Code CLIから動的に取得されます。", + claudeCodeEffortUnavailable: "選択したモデルは設定可能なeffortレベルを返していません。Claude Codeのデフォルトを使用します。", run: "実行", readingSchema: "スキーマを読み取り中", noConnection: "このタブに利用可能な接続がありません", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 2f53450ab..145dd8aae 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1327,13 +1327,29 @@ export default withEnglishFallback({ codexCliEnvValuePlaceholder: "http://proxy:9800", codexCliEnvInvalidName: "Nome de variável de ambiente inválido: {name}", codexCliEnvReservedName: "{name} é gerenciada pelo DBX para o MCP Server e não pode ser definida aqui.", + cliMcpRequiredTitle: "DBX MCP Server required", + cliMcpRequiredDescription: "{provider} uses the DBX MCP Server to access database schema and query tools. Install it before using this provider in the AI assistant.", + cliPath: "{provider} Path", + cliPathHint: "Leave empty to use {command} from PATH. Sign in separately with {loginCommand}.", + cliPathEnvError: "{provider} path should contain only the executable path. Add environment variables below.", + cliEnv: "Environment variables", + cliEnvAdd: "Add variable", + cliEnvHint: "Optional variables passed to {provider}, such as HTTPS_PROXY, NO_PROXY, or SSL_CERT_FILE.", + cliEnvKeyPlaceholder: "HTTPS_PROXY", + cliEnvValuePlaceholder: "http://proxy:9800", + cliEnvInvalidName: "Invalid environment variable name: {name}", + cliEnvReservedName: "{name} is managed by DBX for the MCP server and cannot be set here.", reasoningLevel: "Nível de raciocínio", reasoningLevelDefault: "Padrão", reasoningLevelMinimal: "Mínimo", reasoningLevelLow: "Baixo", reasoningLevelMedium: "Médio", reasoningLevelHigh: "Alto", + reasoningLevelXhigh: "Muito alto", + reasoningLevelMax: "Máximo", reasoningLevelHint: "Controla model_reasoning_effort do Codex CLI. Padrão usa sua configuração do Codex.", + claudeCodeEffortHint: "Os níveis disponíveis são informados dinamicamente pelo Claude Code CLI local para o modelo selecionado.", + claudeCodeEffortUnavailable: "O modelo selecionado não informa níveis de effort configuráveis. O Claude Code usará o padrão.", run: "Executar", readingSchema: "Lendo schema", noConnection: "Nenhuma conexão disponível para esta aba", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index fa573b15b..2af3a3490 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1466,6 +1466,18 @@ export default withEnglishFallback({ codexCliEnvValuePlaceholder: "http://proxy:9800", codexCliEnvInvalidName: "环境变量名称无效:{name}", codexCliEnvReservedName: "{name} 由 DBX 用于 MCP Server,不能在这里设置。", + cliMcpRequiredTitle: "需要 DBX MCP Server", + cliMcpRequiredDescription: "{provider} 会通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用该提供商。", + cliPath: "{provider} 路径", + cliPathHint: "留空则使用 PATH 中的 {command}。请先在终端执行 {loginCommand} 登录。", + cliPathEnvError: "{provider} 路径只能填写可执行文件路径。请在下方添加环境变量。", + cliEnv: "环境变量", + cliEnvAdd: "添加变量", + cliEnvHint: "传递给 {provider} 的可选变量,例如 HTTPS_PROXY、NO_PROXY 或 SSL_CERT_FILE。", + cliEnvKeyPlaceholder: "HTTPS_PROXY", + cliEnvValuePlaceholder: "http://proxy:9800", + cliEnvInvalidName: "环境变量名称无效:{name}", + cliEnvReservedName: "{name} 由 DBX 用于 MCP Server,不能在这里设置。", codexMcpRequiredTitle: "需要 DBX MCP Server", codexMcpRequiredDescription: "Codex CLI 通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用 Codex。", reasoningLevel: "推理级别", @@ -1474,7 +1486,11 @@ export default withEnglishFallback({ reasoningLevelLow: "低", reasoningLevelMedium: "中", reasoningLevelHigh: "高", + reasoningLevelXhigh: "极高", + reasoningLevelMax: "最高", reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。默认使用你的 Codex 配置。", + claudeCodeEffortHint: "可用级别由本地 Claude Code CLI 根据所选模型动态提供。", + claudeCodeEffortUnavailable: "所选模型未提供可配置的推理级别,将使用 Claude Code 的默认设置。", actions: { general: "通用问答", generate: "生成 SQL", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 934ab84cc..0a5288039 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1410,13 +1410,29 @@ export default withEnglishFallback({ codexCliEnvValuePlaceholder: "http://proxy:9800", codexCliEnvInvalidName: "環境變數名稱無效:{name}", codexCliEnvReservedName: "{name} 由 DBX 用於 MCP Server,不能在這裡設定。", + cliMcpRequiredTitle: "需要 DBX MCP Server", + cliMcpRequiredDescription: "{provider} 会通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用该提供商。", + cliPath: "{provider} 路径", + cliPathHint: "留空则使用 PATH 中的 {command}。请先在终端执行 {loginCommand} 登录。", + cliPathEnvError: "{provider} 路径只能填写可执行文件路径。请在下方添加环境变量。", + cliEnv: "环境变量", + cliEnvAdd: "添加变量", + cliEnvHint: "传递给 {provider} 的可选变量,例如 HTTPS_PROXY、NO_PROXY 或 SSL_CERT_FILE。", + cliEnvKeyPlaceholder: "HTTPS_PROXY", + cliEnvValuePlaceholder: "http://proxy:9800", + cliEnvInvalidName: "环境变量名称无效:{name}", + cliEnvReservedName: "{name} 由 DBX 用于 MCP Server,不能在这里设置。", reasoningLevel: "推理層級", reasoningLevelDefault: "預設", reasoningLevelMinimal: "最小", reasoningLevelLow: "低", reasoningLevelMedium: "中", reasoningLevelHigh: "高", + reasoningLevelXhigh: "極高", + reasoningLevelMax: "最高", reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。預設會使用你的 Codex 設定。", + claudeCodeEffortHint: "可用層級由本機 Claude Code CLI 根據所選模型動態提供。", + claudeCodeEffortUnavailable: "所選模型未提供可設定的推理層級,將使用 Claude Code 的預設設定。", actions: { general: "通用問答", generate: "產生 SQL", diff --git a/apps/desktop/src/lib/__tests__/ai/aiModelEffort.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiModelEffort.spec.ts new file mode 100644 index 000000000..da2dc9abc --- /dev/null +++ b/apps/desktop/src/lib/__tests__/ai/aiModelEffort.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { normalizeAiModelEffortLevels, normalizeClaudeCodeReasoningLevel } from "@/lib/ai/aiModelEffort"; +import type { AiModelInfo } from "@/lib/backend/tauri"; + +describe("normalizeAiModelEffortLevels", () => { + it("preserves the CLI order while removing duplicates and unknown levels", () => { + expect(normalizeAiModelEffortLevels(["low", "high", "xhigh", "high", "future", null, "max"])).toEqual(["low", "high", "xhigh", "max"]); + }); + + it("returns no configurable levels when metadata is unavailable", () => { + expect(normalizeAiModelEffortLevels(undefined)).toEqual([]); + }); +}); + +describe("normalizeClaudeCodeReasoningLevel", () => { + const model: AiModelInfo = { + id: "claude-sonnet-5", + supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"], + }; + + it("keeps an effort level reported by the selected model", () => { + expect(normalizeClaudeCodeReasoningLevel("xhigh", model)).toBe("xhigh"); + }); + + it("falls back to default for unsupported or missing model metadata", () => { + expect(normalizeClaudeCodeReasoningLevel("minimal", model)).toBe("default"); + expect(normalizeClaudeCodeReasoningLevel("max", { id: "default" })).toBe("default"); + }); +}); diff --git a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts new file mode 100644 index 000000000..4fc71f620 --- /dev/null +++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { orderAiConfigsForDisplay } from "@/lib/ai/aiConfigOrdering"; +import type { AiProvider } from "@/types/ai"; + +interface TestConfig { + id: string; + provider: AiProvider; +} + +describe("orderAiConfigsForDisplay", () => { + it("matches the canonical provider order", () => { + const configs: TestConfig[] = [ + { id: "claude-code-1", provider: "claude-code-cli" }, + { id: "claude", provider: "claude" }, + { id: "openai", provider: "openai" }, + { id: "codex", provider: "codex-cli" }, + { id: "custom", provider: "custom" }, + ]; + + expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "claude-code-1", "codex", "custom"]); + }); + + it("preserves creation order for configs from the same provider", () => { + const configs: TestConfig[] = [ + { id: "codex-1", provider: "codex-cli" }, + { id: "claude-code-1", provider: "claude-code-cli" }, + { id: "codex-2", provider: "codex-cli" }, + { id: "claude-code-2", provider: "claude-code-cli" }, + ]; + + expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude-code-1", "claude-code-2", "codex-1", "codex-2"]); + }); +}); diff --git a/apps/desktop/src/lib/ai/aiConfigOrdering.ts b/apps/desktop/src/lib/ai/aiConfigOrdering.ts new file mode 100644 index 000000000..1ee6198e2 --- /dev/null +++ b/apps/desktop/src/lib/ai/aiConfigOrdering.ts @@ -0,0 +1,15 @@ +import { AI_PROVIDER_PRESETS } from "@/stores/settingsStore"; +import type { AiProvider } from "@/types/ai"; + +const AI_PROVIDER_DISPLAY_RANK = new Map((Object.keys(AI_PROVIDER_PRESETS) as AiProvider[]).map((provider, index) => [provider, index])); + +/** + * Match configured-provider lists to the canonical provider picker order while + * preserving creation order between configs that use the same provider. + */ +export function orderAiConfigsForDisplay(configs: readonly T[]): T[] { + return configs + .map((config, index) => ({ config, index })) + .sort((a, b) => (AI_PROVIDER_DISPLAY_RANK.get(a.config.provider) ?? Number.MAX_SAFE_INTEGER) - (AI_PROVIDER_DISPLAY_RANK.get(b.config.provider) ?? Number.MAX_SAFE_INTEGER) || a.index - b.index) + .map(({ config }) => config); +} diff --git a/apps/desktop/src/lib/ai/aiModelEffort.ts b/apps/desktop/src/lib/ai/aiModelEffort.ts new file mode 100644 index 000000000..2016de91d --- /dev/null +++ b/apps/desktop/src/lib/ai/aiModelEffort.ts @@ -0,0 +1,28 @@ +import type { AiEffortLevel, AiReasoningLevel } from "@/types/ai"; + +interface AiModelEffortMetadata { + supportedEffortLevels?: unknown; +} + +const AI_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); + +export function normalizeAiModelEffortLevels(value: unknown): AiEffortLevel[] { + if (!Array.isArray(value)) return []; + + const seen = new Set(); + const levels: AiEffortLevel[] = []; + for (const level of value) { + if (typeof level !== "string" || !AI_EFFORT_LEVELS.has(level as AiEffortLevel)) continue; + const normalized = level as AiEffortLevel; + if (seen.has(normalized)) continue; + seen.add(normalized); + levels.push(normalized); + } + return levels; +} + +export function normalizeClaudeCodeReasoningLevel(reasoningLevel: AiReasoningLevel | undefined, model: AiModelEffortMetadata | undefined): AiReasoningLevel { + if (!reasoningLevel || reasoningLevel === "default") return "default"; + const supported = normalizeAiModelEffortLevels(model?.supportedEffortLevels); + return supported.includes(reasoningLevel as AiEffortLevel) ? reasoningLevel : "default"; +} diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 310fd5cac..63b69a479 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -41,8 +41,7 @@ import type { import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo"; import type { CollectionInfo } from "@/types/database"; import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities"; -import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore"; -import type { AiConfigItem } from "@/types/ai"; +import type { AiConfig, AiConfigItem, AiEffortLevel, AiTestConnectionResult } from "@/types/ai"; import type { QueryEditability } from "@/lib/sql/sqlAnalysis"; import { isTerminalTransferProgress } from "@/lib/backend/transferProgress"; import type { @@ -336,6 +335,7 @@ export interface AiCompletionRequest { export interface AiModelInfo { id: string; displayName?: string; + supportedEffortLevels?: AiEffortLevel[]; } export async function aiComplete(request: AiCompletionRequest): Promise { diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index dee6e3731..0c938f82b 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -15,9 +15,9 @@ import { setDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates"; import { DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts"; import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage"; -import type { AiProvider, AiApiStyle, AiAuthMethod, AiReasoningLevel, AiConfig, AiTestConnectionResult, AiConfigItem } from "@/types/ai"; +import type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem } from "@/types/ai"; -export type { AiProvider, AiApiStyle, AiAuthMethod, AiReasoningLevel, AiConfig, AiTestConnectionResult, AiConfigItem }; +export type { AiProvider, AiApiStyle, AiAuthMethod, AiEffortLevel, AiReasoningLevel, AiConfiguredModel, AiConfig, AiTestConnectionResult, AiConfigItem }; export interface DesktopSettings { show_tray_icon: boolean; @@ -164,6 +164,16 @@ export const AI_PROVIDER_PRESETS: Record = { authMethod: "bearer", requiresApiKey: true, }, + "claude-code-cli": { + label: "Claude Code CLI", + iconSlug: "claudecode", + provider: "claude-code-cli", + endpoint: "", + model: "default", + apiStyle: "completions", + authMethod: "bearer", + requiresApiKey: false, + }, "codex-cli": { label: "Codex CLI", iconSlug: "codex", @@ -192,7 +202,7 @@ const defaultConfigs: Record> = Object.from }), ) as Record>; -const AI_REASONING_LEVELS: AiReasoningLevel[] = ["default", "minimal", "low", "medium", "high"]; +const AI_REASONING_LEVELS: AiReasoningLevel[] = ["default", "minimal", "low", "medium", "high", "xhigh", "max"]; const AI_ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; function normalizeAiReasoningLevel(value: unknown): AiReasoningLevel { @@ -226,6 +236,8 @@ export function normalizeAiConfig(config: Partial | null | undefined): contextWindow: config?.contextWindow ?? undefined, codexCliPath: config?.codexCliPath?.trim() || undefined, codexCliEnv: normalizeAiEnv(config?.codexCliEnv), + claudeCodeCliPath: config?.claudeCodeCliPath?.trim() || undefined, + claudeCodeCliEnv: normalizeAiEnv(config?.claudeCodeCliEnv), }; } @@ -1022,7 +1034,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") return true; + if (config.provider === "codex-cli" || config.provider === "claude-code-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 5db8f4c0b..9b2e4ae4b 100644 --- a/apps/desktop/src/types/ai.ts +++ b/apps/desktop/src/types/ai.ts @@ -1,7 +1,14 @@ -export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "codex-cli" | "custom"; +export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "claude-code-cli" | "codex-cli" | "custom"; export type AiApiStyle = "completions" | "responses" | "anthropic-messages"; export type AiAuthMethod = "api-key" | "bearer"; -export type AiReasoningLevel = "default" | "minimal" | "low" | "medium" | "high"; +export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; +export type AiReasoningLevel = "default" | "minimal" | AiEffortLevel; + +export interface AiConfiguredModel { + name: string; + label?: string; + supportedEffortLevels?: AiEffortLevel[]; +} export interface AiConfig { provider: AiProvider; @@ -9,7 +16,7 @@ export interface AiConfig { authMethod: AiAuthMethod; endpoint: string; model: string; - models?: Array<{ name: string; label?: string }>; + models?: AiConfiguredModel[]; apiStyle: AiApiStyle; proxyEnabled?: boolean; proxyUrl?: string; @@ -18,6 +25,8 @@ export interface AiConfig { contextWindow?: number; codexCliPath?: string | null; codexCliEnv?: Record; + claudeCodeCliPath?: string | null; + claudeCodeCliEnv?: Record; } export interface AiTestConnectionResult { diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs index fe408d898..f6e957b1d 100644 --- a/crates/dbx-core/src/agent_loop.rs +++ b/crates/dbx-core/src/agent_loop.rs @@ -101,7 +101,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) { + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { let connection_name = { let configs = agent_ctx.state.configs.read().await; configs @@ -109,24 +109,27 @@ pub async fn run_agent_loop( .map(|config| config.name.clone()) .unwrap_or_else(|| agent_ctx.connection_id.clone()) }; + let options = crate::ai_cli_agent::CliAgentRunOptions { + connection_id: agent_ctx.connection_id.clone(), + connection_name, + database: agent_ctx.database.clone(), + agent_mode: is_agent_mode, + allow_writes: agent_ctx.sql_permissions.allow_writes, + allow_dangerous: agent_ctx.sql_permissions.allow_dangerous, + mcp_server_command: agent_ctx.cli_mcp_server_command.clone(), + }; + if matches!(config.provider, AiProvider::ClaudeCodeCli) { + let prompt = crate::ai_claude_code_cli::build_claude_code_prompt( + system_prompt, + messages, + agent_ctx.sql_permissions.allow_writes, + ); + return crate::ai_claude_code_cli::run_claude_code_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, - crate::ai_codex_cli::CodexRunOptions { - connection_id: agent_ctx.connection_id.clone(), - connection_name, - database: agent_ctx.database.clone(), - agent_mode: is_agent_mode, - allow_writes: agent_ctx.sql_permissions.allow_writes, - allow_dangerous: agent_ctx.sql_permissions.allow_dangerous, - mcp_server_command: agent_ctx.cli_mcp_server_command.clone(), - }, - cancelled, - on_event, - ) - .await; + return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await; } // Auto-degrade: providers without function calling fall back to text-only completion. diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index e032625f8..631c98a87 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -61,6 +61,8 @@ pub enum AiProvider { OpenaiCompatible, #[serde(rename = "codex-cli")] CodexCli, + #[serde(rename = "claude-code-cli")] + ClaudeCodeCli, Custom, } @@ -74,6 +76,7 @@ impl AiProvider { AiProvider::Qwen => "qwen", AiProvider::Ollama => "ollama", AiProvider::OpenaiCompatible => "openai-compatible", + AiProvider::ClaudeCodeCli => "claude-code-cli", AiProvider::CodexCli => "codex-cli", AiProvider::Custom => "custom", } @@ -107,6 +110,8 @@ pub enum AiReasoningLevel { Low, Medium, High, + Xhigh, + Max, } impl AiReasoningLevel { @@ -117,6 +122,43 @@ impl AiReasoningLevel { AiReasoningLevel::Low => Some("low"), AiReasoningLevel::Medium => Some("medium"), AiReasoningLevel::High => Some("high"), + AiReasoningLevel::Xhigh | AiReasoningLevel::Max => None, + } + } + + pub fn as_claude_code_effort(&self) -> Option<&'static str> { + match self { + AiReasoningLevel::Default | AiReasoningLevel::Minimal => None, + AiReasoningLevel::Low => Some("low"), + AiReasoningLevel::Medium => Some("medium"), + AiReasoningLevel::High => Some("high"), + AiReasoningLevel::Xhigh => Some("xhigh"), + AiReasoningLevel::Max => Some("max"), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum AiEffortLevel { + Low, + Medium, + High, + Xhigh, + Max, +} + +impl std::str::FromStr for AiEffortLevel { + type Err = (); + + fn from_str(value: &str) -> Result { + match value { + "low" => Ok(Self::Low), + "medium" => Ok(Self::Medium), + "high" => Ok(Self::High), + "xhigh" => Ok(Self::Xhigh), + "max" => Ok(Self::Max), + _ => Err(()), } } } @@ -144,6 +186,8 @@ pub struct AiModelListItem { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub label: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub supported_effort_levels: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -176,6 +220,10 @@ pub struct AiConfig { pub codex_cli_path: Option, #[serde(default)] pub codex_cli_env: HashMap, + #[serde(default)] + pub claude_code_cli_path: Option, + #[serde(default)] + pub claude_code_cli_env: HashMap, } fn default_enable_thinking() -> bool { @@ -273,6 +321,14 @@ pub struct AiModelInfo { pub id: String, #[serde(skip_serializing_if = "Option::is_none")] pub display_name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub supported_effort_levels: Vec, +} + +impl AiModelInfo { + pub fn new(id: impl Into, display_name: Option) -> Self { + Self { id: id.into(), display_name, supported_effort_levels: Vec::new() } + } } /// Result of an AI connection test (mirrors CC-Switch's StreamCheckResult). @@ -358,7 +414,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String { format!("{base}/chat/completions") } } - AiProvider::Claude | AiProvider::CodexCli | AiProvider::Gemini => unreachable!(), + AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::Gemini => unreachable!(), } } @@ -746,7 +802,7 @@ fn emit_responses_function_call_item( // --------------------------------------------------------------------------- fn validate_config(config: &AiConfig) -> Result<(), String> { - if matches!(config.provider, AiProvider::CodexCli) { + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { return Ok(()); } if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() { @@ -762,7 +818,7 @@ fn validate_config(config: &AiConfig) -> Result<(), String> { } fn validate_model_list_config(config: &AiConfig) -> Result<(), String> { - if matches!(config.provider, AiProvider::CodexCli) { + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { return Ok(()); } if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() { @@ -854,7 +910,7 @@ fn parse_model_list_response(data: &serde_json::Value) -> Result Result, Str if matches!(config.provider, AiProvider::CodexCli) { return crate::ai_codex_cli::list_codex_models(config).await; } + if matches!(config.provider, AiProvider::ClaudeCodeCli) { + return crate::ai_claude_code_cli::list_claude_code_models(config).await; + } validate_model_list_config(config)?; let client = build_ai_http_client(config, 30)?; @@ -919,7 +978,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str list_openai_compatible_models(&client, config).await } } - AiProvider::CodexCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(), AiProvider::Gemini => { Err("Model listing is only supported for OpenAI-compatible and Claude providers".to_string()) } @@ -1133,6 +1192,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result &'static str { pub async fn complete(request: &AiCompletionRequest) -> Result { validate_config(&request.config)?; - if matches!(request.config.provider, AiProvider::CodexCli) { - return Err("Codex CLI provider is only supported in DBX AI agent mode".to_string()); + if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + return Err("CLI providers are only supported in DBX AI agent mode".to_string()); } let client = build_ai_http_client(&request.config, 60)?; @@ -1317,7 +1379,7 @@ pub async fn complete(request: &AiCompletionRequest) -> Result { match request.config.provider { AiProvider::Claude => call_claude(&client, request.clone()).await, AiProvider::Gemini => call_gemini(&client, request.clone()).await, - AiProvider::CodexCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(), AiProvider::Openai | AiProvider::Deepseek | AiProvider::Qwen @@ -1353,8 +1415,8 @@ pub async fn stream( ) -> Result<(), String> { validate_config(&request.config)?; - if matches!(request.config.provider, AiProvider::CodexCli) { - return Err("Codex CLI provider is only supported in DBX AI agent mode".to_string()); + if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + return Err("CLI providers are only supported in DBX AI agent mode".to_string()); } let stream_timeout = if request.config.enable_thinking { 600 } else { 120 }; @@ -1363,7 +1425,7 @@ pub async fn stream( match request.config.provider { AiProvider::Claude => stream_claude(&client, session_id, request, cancelled, &on_chunk).await, AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await, - AiProvider::CodexCli => unreachable!(), + AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(), AiProvider::Openai | AiProvider::Deepseek | AiProvider::Qwen @@ -2422,6 +2484,9 @@ 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) { + return Err("CLI providers are only supported through the DBX AI agent loop".to_string()); + } let stream_timeout = if config.enable_thinking { 600 } else { 120 }; let client = build_ai_http_client(config, stream_timeout)?; @@ -2606,6 +2671,8 @@ mod tests { assert_eq!(config.proxy_url, ""); assert!(config.enable_thinking); assert_eq!(config.auth_method, AiAuthMethod::ApiKey); + assert!(config.claude_code_cli_path.is_none()); + assert!(config.claude_code_cli_env.is_empty()); assert!(config.codex_cli_env.is_empty()); } @@ -2626,6 +2693,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; let err = build_ai_http_client(&config, 1).unwrap_err(); @@ -2650,6 +2719,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -2672,6 +2743,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; build_ai_http_client(&config, 1).unwrap(); @@ -2694,6 +2767,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; assert_eq!( @@ -2720,6 +2795,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions"); @@ -2743,6 +2820,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models"); @@ -2761,6 +2840,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); } @@ -2782,6 +2863,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; assert!(uses_anthropic_messages_api(&config)); @@ -2827,6 +2910,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_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"); @@ -2884,6 +2969,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; let api_key_headers = claude_headers(&config).unwrap(); @@ -2923,11 +3010,8 @@ mod tests { assert_eq!( parse_model_list_response(&data).unwrap(), vec![ - AiModelInfo { id: "gpt-4o-mini".to_string(), display_name: None }, - AiModelInfo { - id: "claude-sonnet-4-20250514".to_string(), - display_name: Some("Claude Sonnet 4".to_string()) - }, + AiModelInfo::new("gpt-4o-mini", None), + AiModelInfo::new("claude-sonnet-4-20250514", Some("Claude Sonnet 4".to_string())), ] ); } @@ -3173,6 +3257,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; let mut body = serde_json::json!({ @@ -3239,6 +3325,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, @@ -3270,6 +3358,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -3307,6 +3397,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model }); @@ -3338,6 +3430,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }; let mut body = serde_json::json!({ "model": &config.model, diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs new file mode 100644 index 000000000..2747989f2 --- /dev/null +++ b/crates/dbx-core/src/ai_claude_code_cli.rs @@ -0,0 +1,855 @@ +use crate::agent_events::AgentEvent; +use crate::ai::{AiConfig, AiEffortLevel, 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::io::AsyncWriteExt; +use tokio::sync::Notify; + +const DEFAULT_CLAUDE_CODE_MODELS: &[&str] = &["default", "sonnet", "opus", "fable"]; +const CLAUDE_CODE_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10); +const CLAUDE_CODE_SETTING_SOURCES: &str = "user"; + +pub type ClaudeCodeRunOptions = CliAgentRunOptions; +pub type ClaudeCodeCommandSpec = CliAgentCommandSpec; + +struct ClaudeCodeIsolatedCwd { + path: PathBuf, +} + +impl ClaudeCodeIsolatedCwd { + fn create() -> Result { + let path = env::temp_dir().join(format!("dbx-claude-code-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&path).map_err(|error| { + format!("[claudeCodeRunFailed] Failed to create isolated Claude Code directory: {error}") + })?; + Ok(Self { path }) + } +} + +impl Drop for ClaudeCodeIsolatedCwd { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +fn append_claude_code_isolation_args(args: &mut Vec) { + // User settings retain authentication and preferences without loading project hooks or local overrides. + args.extend(["--setting-sources".to_string(), CLAUDE_CODE_SETTING_SOURCES.to_string()]); +} + +fn claude_code_program(config: &AiConfig) -> String { + config + .claude_code_cli_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .unwrap_or("claude") + .to_string() +} + +fn claude_code_process_env( + config: &AiConfig, + command: &ClaudeCodeCommandSpec, +) -> Result, String> { + let mut env = BTreeMap::from_iter(claude_code_cli_env(config)?); + if let Some(dir) = command_parent_dir(command) { + let user_path = env.get("PATH").map(String::as_str); + env.insert("PATH".to_string(), merged_path_with_dir(&dir, user_path)); + } + Ok(env.into_iter().collect()) +} + +fn command_parent_dir(command: &ClaudeCodeCommandSpec) -> Option { + Path::new(&command.program) + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(|parent| parent.to_string_lossy().to_string()) +} + +fn merged_path_with_dir(dir: &str, user_path: Option<&str>) -> String { + let mut seen = BTreeSet::new(); + let mut dirs = vec![PathBuf::from(dir)]; + if let Some(path) = user_path { + dirs.extend(env::split_paths(path)); + } + dirs.extend(common_executable_dirs()); + let paths = dirs.into_iter().filter(|path| seen.insert(path.clone())).collect::>(); + env::join_paths(paths).unwrap_or_default().to_string_lossy().to_string() +} + +fn common_executable_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Ok(path) = env::var("PATH") { + dirs.extend(env::split_paths(&path)); + } + #[cfg(windows)] + { + if let Ok(app_data) = env::var("APPDATA") { + dirs.push(PathBuf::from(app_data).join("npm")); + } + } + #[cfg(not(windows))] + { + dirs.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + PathBuf::from("/usr/sbin"), + PathBuf::from("/sbin"), + ]); + } + dirs +} + +fn validate_claude_code_program(config: &AiConfig) -> Result { + let program = claude_code_program(config); + if starts_with_env_assignment(&program) { + return Err("[claudeCodeCliPathInvalid] Claude Code CLI path should contain only the executable path. Add environment variables in the Claude Code 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, "claude").ok_or_else(|| { + "[claudeCodeCliPathInvalid] Claude Code CLI path should point to the claude executable or a directory containing claude." + .to_string() + }); + } + return Ok(expanded); + } + Ok(program) +} + +fn launchable_program_in_dir(dir: &Path, program: &str) -> Option { + program_path_candidates(dir, program) + .into_iter() + .find(|candidate| is_launchable_program_path(candidate) && candidate.is_file()) + .map(|path| path.to_string_lossy().to_string()) +} + +#[cfg(not(windows))] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + vec![dir.join(program)] +} + +#[cfg(windows)] +fn program_path_candidates(dir: &Path, program: &str) -> Vec { + let path = Path::new(program); + if path.extension().is_some() { + return vec![dir.join(program)]; + } + [".cmd", ".exe", ".bat", ".com", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect() +} + +#[cfg(not(windows))] +fn is_launchable_program_path(_path: &Path) -> bool { + true +} + +#[cfg(windows)] +fn is_launchable_program_path(path: &Path) -> bool { + matches!( + path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(), + Some("exe" | "cmd" | "bat" | "com") + ) +} + +fn is_path_like_program(program: &str) -> bool { + program.contains('/') || program.contains('\\') || program.starts_with('~') +} + +fn starts_with_env_assignment(program: &str) -> bool { + let Some(first_token) = program.split_whitespace().next() else { + return false; + }; + let Some((key, _)) = first_token.split_once('=') else { + return false; + }; + is_env_var_name(key) +} + +fn is_env_var_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn is_reserved_dbx_mcp_env_name(name: &str) -> bool { + name.to_ascii_uppercase().starts_with("DBX_MCP_") +} + +pub fn claude_code_cli_env(config: &AiConfig) -> Result, String> { + let mut env = BTreeMap::new(); + for (key, value) in &config.claude_code_cli_env { + let key = key.trim(); + if key.is_empty() { + continue; + } + if !is_env_var_name(key) { + return Err(format!( + "[claudeCodeEnvInvalid] Invalid Claude Code CLI environment variable name `{key}`. Use names like HTTPS_PROXY." + )); + } + if is_reserved_dbx_mcp_env_name(key) { + return Err(format!( + "[claudeCodeEnvReserved] `{key}` is managed by DBX for the scoped MCP server and cannot be set here." + )); + } + env.insert(key.to_string(), value.clone()); + } + Ok(env.into_iter().collect()) +} + +pub fn claude_code_enabled_tools(agent_mode: bool) -> Vec { + dbx_mcp_enabled_tools(agent_mode).into_iter().map(|tool| format!("mcp__dbx__{tool}")).collect() +} + +fn claude_code_mcp_config(options: &ClaudeCodeRunOptions) -> 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("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_claude_code_command( + config: &AiConfig, + _prompt: &str, + options: &ClaudeCodeRunOptions, +) -> ClaudeCodeCommandSpec { + let enabled_tools = claude_code_enabled_tools(options.agent_mode); + let mut args = vec![ + "--print".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + "--input-format".to_string(), + "text".to_string(), + "--no-session-persistence".to_string(), + "--permission-mode".to_string(), + "dontAsk".to_string(), + "--mcp-config".to_string(), + claude_code_mcp_config(options), + "--strict-mcp-config".to_string(), + ]; + append_claude_code_isolation_args(&mut args); + args.push("--tools".to_string()); + args.extend(enabled_tools.iter().cloned()); + args.push("--allowedTools".to_string()); + args.extend(enabled_tools); + + let model = config.model.trim(); + if !model.is_empty() && !model.eq_ignore_ascii_case("default") { + args.push("--model".to_string()); + args.push(model.to_string()); + } + if let Some(effort) = config.reasoning_level.as_claude_code_effort() { + args.push("--effort".to_string()); + args.push(effort.to_string()); + } + + ClaudeCodeCommandSpec { program: claude_code_program(config), args } +} + +pub fn build_claude_code_prompt( + system_prompt: &str, + messages: &[crate::ai::AiMessage], + allow_write_sql: bool, +) -> String { + build_cli_agent_prompt("Claude Code", system_prompt, messages, allow_write_sql) +} + +pub async fn list_claude_code_models(config: &AiConfig) -> Result, String> { + let program = validate_claude_code_program(config)?; + Ok(discover_claude_code_models(config, program).await.unwrap_or_else(|| model_infos(DEFAULT_CLAUDE_CODE_MODELS))) +} + +async fn discover_claude_code_models(config: &AiConfig, program: String) -> Option> { + let mut command = ClaudeCodeCommandSpec { + program, + args: vec![ + "--print".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), + "--verbose".to_string(), + ], + }; + append_claude_code_isolation_args(&mut command.args); + let env = claude_code_process_env(config, &command).ok()?; + let isolated_cwd = ClaudeCodeIsolatedCwd::create().ok()?; + let mut process = cli_command(&command.program); + process + .args(command.args.iter().map(String::as_str)) + .envs(env.iter().map(|(key, value)| (key.as_str(), value.as_str()))) + .env_remove("CLAUDECODE") + .current_dir(&isolated_cwd.path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = process.spawn().ok()?; + let mut stdin = child.stdin.take()?; + let mut request = serde_json::to_vec(&json!({ + "type": "control_request", + "request_id": "dbx_model_discovery", + "request": { "subtype": "initialize" } + })) + .ok()?; + request.push(b'\n'); + stdin.write_all(&request).await.ok()?; + drop(stdin); + + let output = + tokio::time::timeout(CLAUDE_CODE_MODEL_DISCOVERY_TIMEOUT, child.wait_with_output()).await.ok()?.ok()?; + parse_claude_code_models(&String::from_utf8_lossy(&output.stdout)) +} + +fn parse_claude_code_models(stdout: &str) -> Option> { + for line in stdout.lines() { + let Ok(event) = serde_json::from_str::(line.trim()) else { + continue; + }; + if event.get("type").and_then(Value::as_str) != Some("control_response") { + continue; + } + + let Some(response) = event.get("response") else { + continue; + }; + let data = response.get("response").unwrap_or(response); + let Some(models) = data.get("models").and_then(Value::as_array) else { + continue; + }; + let mut seen = BTreeSet::new(); + let mut result = Vec::new(); + for model in models { + let Some(id) = model + .get("value") + .and_then(Value::as_str) + .or_else(|| model.get("id").and_then(Value::as_str)) + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + continue; + }; + if !seen.insert(id.to_string()) { + continue; + } + let display_name = model + .get("displayName") + .and_then(Value::as_str) + .or_else(|| model.get("display_name").and_then(Value::as_str)) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToString::to_string); + let mut info = AiModelInfo::new(id, display_name); + info.supported_effort_levels = parse_claude_code_effort_levels(model); + result.push(info); + } + + if result.is_empty() { + return None; + } + if seen.insert("default".to_string()) { + result.insert(0, AiModelInfo::new("default", Some("Default".to_string()))); + } + return Some(result); + } + + None +} + +fn parse_claude_code_effort_levels(model: &Value) -> Vec { + if model.get("supportsEffort").or_else(|| model.get("supports_effort")).and_then(Value::as_bool) == Some(false) { + return Vec::new(); + } + let Some(levels) = + model.get("supportedEffortLevels").or_else(|| model.get("supported_effort_levels")).and_then(Value::as_array) + else { + return Vec::new(); + }; + + let mut seen = BTreeSet::new(); + levels + .iter() + .filter_map(Value::as_str) + .filter_map(|level| level.parse::().ok()) + .filter(|level| seen.insert(*level)) + .collect() +} + +pub async fn test_claude_code_connection(config: &AiConfig) -> Result { + let start = Instant::now(); + let claude_command = ClaudeCodeCommandSpec { program: validate_claude_code_program(config)?, args: Vec::new() }; + let mut command = cli_command(&claude_command.program); + command.args(claude_command.args.iter().map(String::as_str)); + command.args(["auth", "status"]); + command.envs( + claude_code_process_env(config, &claude_command)?.iter().map(|(key, value)| (key.as_str(), value.as_str())), + ); + + let output = command.output().await.map_err(|e| classify_claude_code_spawn_error(&e.to_string()))?; + + if output.status.success() { + 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, + }) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let message = + [stderr.trim(), stdout.trim()].into_iter().filter(|part| !part.is_empty()).collect::>().join("\n"); + Err(classify_claude_code_run_error(&message)) + } +} + +fn classify_claude_code_spawn_error(message: &str) -> String { + if message.contains("No such file") || message.contains("not found") { + "[claudeCodeNotInstalled] Claude Code CLI was not found. Install Claude Code or set the Claude Code CLI path in DBX AI settings." + .to_string() + } else if is_command_line_too_long_error(message) { + "[claudeCodeCommandLineTooLong] Claude Code CLI command line is too long.".to_string() + } else { + format!("[claudeCodeRunFailed] Failed to start Claude Code CLI: {message}") + } +} + +fn is_command_line_too_long_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + message.contains("os error 206") + || message.contains("文件名或扩展名太长") + || lower.contains("filename or extension is too long") + || lower.contains("the filename or extension is too long") +} + +fn classify_claude_code_run_error(stderr: &str) -> String { + let lower = stderr.to_ascii_lowercase(); + if lower.contains("not authenticated") || lower.contains("login") || lower.contains("auth") { + format!( + "[claudeCodeNotAuthenticated] Claude Code CLI is not authenticated. Run `claude auth login` and try again. {stderr}" + ) + } else if lower.contains("dbx-mcp-server") || lower.contains("enoent") { + format!("[dbxMcpMissing] DBX MCP server was not found. Install @dbx-app/mcp-server and try again. {stderr}") + } else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) { + format!("[claudeCodeMcpStartupFailed] Claude Code could not start the DBX MCP server. {stderr}") + } else { + format!("[claudeCodeRunFailed] Claude Code CLI failed. {stderr}") + } +} + +pub fn parse_claude_code_jsonl_event(line: &str) -> Option> { + parse_cli_jsonl_event(line, CliAgentJsonlDialect::ClaudeCodePrint) +} + +pub async fn run_claude_code_agent( + config: &AiConfig, + prompt: &str, + options: ClaudeCodeRunOptions, + cancelled: &Notify, + on_event: impl Fn(AgentEvent) + Send + Sync + 'static, +) -> Result { + let program = validate_claude_code_program(config)?; + let mut command = build_claude_code_command(config, prompt, &options); + command.program = program; + let env = claude_code_process_env(config, &command)?; + let isolated_cwd = ClaudeCodeIsolatedCwd::create()?; + let result = run_cli_jsonl_agent( + CliAgentProcessSpec { + command, + env, + current_dir: Some(isolated_cwd.path.clone()), + stdin: Some(prompt.to_string()), + dialect: CliAgentJsonlDialect::ClaudeCodePrint, + classify_spawn_error: classify_claude_code_spawn_error, + classify_run_error: classify_claude_code_run_error, + }, + cancelled, + on_event, + ) + .await; + result +} + +#[cfg(test)] +mod tests { + use super::{ + build_claude_code_command, claude_code_cli_env, claude_code_enabled_tools, parse_claude_code_jsonl_event, + parse_claude_code_models, validate_claude_code_program, ClaudeCodeRunOptions, DEFAULT_CLAUDE_CODE_MODELS, + }; + #[cfg(unix)] + use super::{list_claude_code_models, run_claude_code_agent}; + use crate::agent_events::AgentEvent; + use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiEffortLevel, AiModelInfo, AiProvider, AiReasoningLevel}; + use crate::ai_cli_agent::{model_infos, CliAgentCommandSpec}; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + #[cfg(unix)] + use tokio::sync::Notify; + + fn claude_code_config(model: &str) -> AiConfig { + AiConfig { + provider: AiProvider::ClaudeCodeCli, + 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, + context_window: None, + codex_cli_path: None, + codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), + } + } + + fn run_options() -> ClaudeCodeRunOptions { + ClaudeCodeRunOptions { + connection_id: "conn-1".to_string(), + connection_name: "local".to_string(), + database: "demo".to_string(), + agent_mode: true, + allow_writes: false, + allow_dangerous: false, + mcp_server_command: None, + } + } + + #[cfg(unix)] + fn isolated_cli_test_config() -> (AiConfig, std::path::PathBuf, std::path::PathBuf) { + let project_dir = std::env::temp_dir().join(format!("dbx-claude-project-test-{}", uuid::Uuid::new_v4())); + let claude_dir = project_dir.join(".claude"); + let user_config_dir = project_dir.join("user-config"); + let hook_marker = project_dir.join("project-hook-loaded"); + std::fs::create_dir_all(&claude_dir).unwrap(); + std::fs::create_dir_all(&user_config_dir).unwrap(); + std::fs::write(user_config_dir.join("auth-marker"), "authenticated").unwrap(); + std::fs::write( + claude_dir.join("settings.json"), + serde_json::to_vec(&serde_json::json!({ + "model": "project-model-must-not-load", + "hooks": { + "SessionStart": [{ + "matcher": "", + "hooks": [{ + "type": "command", + "command": format!("printf loaded > {}", hook_marker.display()) + }] + }] + } + })) + .unwrap(), + ) + .unwrap(); + + let executable = project_dir.join("claude"); + std::fs::write( + &executable, + r#"#!/bin/sh +user_settings=false +previous="" +for arg in "$@"; do + if [ "$previous" = "--setting-sources" ] && [ "$arg" = "user" ]; then + user_settings=true + fi + previous="$arg" +done +if [ "$user_settings" != "true" ] || [ "$PWD" = "$DBX_TEST_PROJECT_DIR" ]; then + printf loaded > "$DBX_TEST_HOOK_MARKER" +fi +if [ ! -f "$CLAUDE_CONFIG_DIR/auth-marker" ]; then + exit 9 +fi +input=$(cat) +case " $* " in + *" --input-format stream-json "*) + printf '%s\n' '{"type":"control_response","response":{"response":{"models":[{"value":"claude-user-model","displayName":"User Model"}]}}}' + ;; + *) + printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"isolated execution"}]}}' + printf '%s\n' '{"type":"result","subtype":"success"}' + ;; +esac +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&executable, permissions).unwrap(); + + let mut config = claude_code_config("default"); + config.claude_code_cli_path = Some(executable.to_string_lossy().to_string()); + config + .claude_code_cli_env + .insert("CLAUDE_CONFIG_DIR".to_string(), user_config_dir.to_string_lossy().to_string()); + config + .claude_code_cli_env + .insert("DBX_TEST_PROJECT_DIR".to_string(), project_dir.to_string_lossy().to_string()); + config + .claude_code_cli_env + .insert("DBX_TEST_HOOK_MARKER".to_string(), hook_marker.to_string_lossy().to_string()); + + (config, project_dir, hook_marker) + } + + #[test] + fn builds_claude_code_command_with_scoped_mcp_and_default_model() { + let spec = build_claude_code_command(&claude_code_config("default"), "hello", &run_options()); + + assert_eq!(spec.program, "claude"); + assert!(spec.args.contains(&"--print".to_string())); + assert!(spec.args.contains(&"stream-json".to_string())); + assert!(spec.args.contains(&"--mcp-config".to_string())); + assert!(spec.args.windows(2).any(|args| args == ["--setting-sources", "user"])); + assert!( + spec.args.iter().position(|arg| arg == "--setting-sources") + < spec.args.iter().position(|arg| arg == "--tools") + ); + assert!(!spec.args.contains(&"hello".to_string())); + assert!(!spec.args.contains(&"--model".to_string())); + assert!(!spec.args.contains(&"--effort".to_string())); + assert!(spec.args.iter().any(|arg| arg.contains("\"command\":\"dbx-mcp-server\""))); + assert!(spec.args.iter().any(|arg| arg.contains("\"DBX_MCP_ALLOW_WRITES\":\"0\""))); + assert!(spec.args.iter().any(|arg| arg.contains("\"DBX_MCP_SCOPE_CONNECTION_ID\":\"conn-1\""))); + assert!(spec.args.iter().any(|arg| arg.contains("mcp__dbx__dbx_execute_query"))); + } + + #[cfg(unix)] + #[tokio::test] + async fn model_discovery_uses_user_settings_from_an_isolated_directory() { + let (config, project_dir, hook_marker) = isolated_cli_test_config(); + + let models = list_claude_code_models(&config).await.unwrap(); + + assert!(models.iter().any(|model| model.id == "claude-user-model")); + assert!(!hook_marker.exists()); + let _ = std::fs::remove_dir_all(project_dir); + } + + #[cfg(unix)] + #[tokio::test] + async fn execution_uses_user_settings_from_an_isolated_directory() { + let (config, project_dir, hook_marker) = isolated_cli_test_config(); + + let output = run_claude_code_agent(&config, "hello", run_options(), &Notify::new(), |_| {}).await.unwrap(); + + assert_eq!(output, "isolated execution"); + assert!(!hook_marker.exists()); + let _ = std::fs::remove_dir_all(project_dir); + } + + #[test] + fn builds_claude_code_command_with_custom_mcp_server_and_ask_tools() { + let mut options = run_options(); + options.agent_mode = false; + options.mcp_server_command = Some(CliAgentCommandSpec { + program: "/opt/dbx/bin/dbx-mcp-server".to_string(), + args: vec!["--stdio".to_string()], + }); + let spec = build_claude_code_command(&claude_code_config("sonnet"), "hello", &options); + + let model_pos = spec.args.iter().position(|arg| arg == "--model").unwrap(); + assert_eq!(spec.args[model_pos + 1], "sonnet"); + assert!(spec.args.iter().any(|arg| arg.contains("\"command\":\"/opt/dbx/bin/dbx-mcp-server\""))); + assert!(spec.args.iter().any(|arg| arg.contains("\"args\":[\"--stdio\"]"))); + assert!(!claude_code_enabled_tools(false).iter().any(|tool| tool == "mcp__dbx__dbx_execute_query")); + assert!(!spec.args.iter().any(|arg| arg.contains("mcp__dbx__dbx_execute_query"))); + } + + #[test] + fn builds_claude_code_command_with_supported_effort() { + let mut config = claude_code_config("sonnet"); + config.reasoning_level = AiReasoningLevel::Xhigh; + + let spec = build_claude_code_command(&config, "hello", &run_options()); + + let effort_pos = spec.args.iter().position(|arg| arg == "--effort").unwrap(); + assert_eq!(spec.args[effort_pos + 1], "xhigh"); + + config.reasoning_level = AiReasoningLevel::Minimal; + let spec = build_claude_code_command(&config, "hello", &run_options()); + assert!(!spec.args.contains(&"--effort".to_string())); + } + + #[test] + fn default_model_list_matches_supported_aliases() { + assert_eq!(model_infos(DEFAULT_CLAUDE_CODE_MODELS), model_infos(&["default", "sonnet", "opus", "fable"])); + } + + #[test] + fn parses_discovered_claude_code_models() { + let stdout = concat!( + "not json\n", + r#"{"type":"system","subtype":"init"}"#, + "\n", + r#"{"type":"control_response","response":{"subtype":"success","response":{"commands":[]}}}"#, + "\n", + r#"{"type":"control_response","response":{"subtype":"success","request_id":"dbx_model_discovery","response":{"models":[{"value":"default","displayName":"Default","resolvedModel":"claude-sonnet"},{"value":"claude-sonnet-4-6","displayName":"Sonnet 4.6","supportsEffort":true,"supportedEffortLevels":["low","medium","high","max","high","future"]},{"value":"claude-sonnet-4-6","displayName":"Duplicate"},{"value":"claude-opus-4-8","display_name":"Opus 4.8","supports_effort":false,"supported_effort_levels":["low"]}]}}}"# + ); + + let models = parse_claude_code_models(stdout).unwrap(); + + assert_eq!( + models, + vec![ + AiModelInfo::new("default", Some("Default".to_string())), + AiModelInfo { + id: "claude-sonnet-4-6".to_string(), + display_name: Some("Sonnet 4.6".to_string()), + supported_effort_levels: vec![ + AiEffortLevel::Low, + AiEffortLevel::Medium, + AiEffortLevel::High, + AiEffortLevel::Max + ], + }, + AiModelInfo::new("claude-opus-4-8", Some("Opus 4.8".to_string())), + ] + ); + } + + #[test] + fn adds_default_to_discovered_claude_code_models_when_missing() { + let stdout = r#"{"type":"control_response","response":{"models":[{"id":"claude-haiku-4-5","displayName":"Haiku 4.5"}]}}"#; + + let models = parse_claude_code_models(stdout).unwrap(); + + assert_eq!(models[0].id, "default"); + assert_eq!(models[1].id, "claude-haiku-4-5"); + } + + #[test] + fn rejects_claude_code_initialize_response_without_models() { + let stdout = r#"{"type":"control_response","response":{"subtype":"success","response":{"commands":[]}}}"#; + + assert!(parse_claude_code_models(stdout).is_none()); + } + + #[test] + fn normalizes_claude_code_cli_env() { + let mut config = claude_code_config("default"); + config.claude_code_cli_env.insert(" HTTPS_PROXY ".to_string(), "http://proxy:9800".to_string()); + config.claude_code_cli_env.insert("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string()); + + let env = claude_code_cli_env(&config).unwrap(); + + assert_eq!( + env, + vec![ + ("HTTPS_PROXY".to_string(), "http://proxy:9800".to_string()), + ("NO_PROXY".to_string(), "localhost,127.0.0.1".to_string()) + ] + ); + } + + #[test] + fn rejects_reserved_dbx_mcp_env_name() { + let mut config = claude_code_config("default"); + config.claude_code_cli_env.insert("DBX_MCP_SCOPE_DATABASE".to_string(), "main".to_string()); + + let err = claude_code_cli_env(&config).unwrap_err(); + + assert!(err.contains("[claudeCodeEnvReserved]")); + } + + #[test] + fn rejects_shell_style_env_prefix_in_claude_code_cli_path() { + let mut config = claude_code_config("default"); + config.claude_code_cli_path = Some("HTTPS_PROXY=http://proxy:9800 /opt/homebrew/bin/claude".to_string()); + + let err = validate_claude_code_program(&config).unwrap_err(); + + assert!(err.contains("[claudeCodeCliPathInvalid]")); + assert!(err.contains("environment variables section")); + } + + #[test] + fn resolves_claude_code_executable_from_configured_directory() { + let dir = std::env::temp_dir().join(format!("dbx-claude-code-dir-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let executable = dir.join(if cfg!(windows) { "claude.cmd" } else { "claude" }); + std::fs::write(&executable, "").unwrap(); + let mut config = claude_code_config("default"); + config.claude_code_cli_path = Some(dir.to_string_lossy().to_string()); + + let resolved = validate_claude_code_program(&config).unwrap(); + + assert_eq!(resolved, executable.to_string_lossy()); + let _ = std::fs::remove_file(executable); + let _ = std::fs::remove_dir(dir); + } + + #[test] + fn parses_claude_code_jsonl_events() { + let started = parse_claude_code_jsonl_event( + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool-1","name":"mcp__dbx__dbx_list_tables","input":{"schema":"public"}}]}}"#, + ) + .unwrap(); + assert!( + matches!(&started[0], AgentEvent::ToolCallStart { tool_name, args, .. } if tool_name == "mcp__dbx__dbx_list_tables" && args["schema"] == "public") + ); + + let text = parse_claude_code_jsonl_event( + r#"{"type":"assistant","message":{"content":[{"type":"text","text":"Done"}]}}"#, + ) + .unwrap(); + assert!(matches!(&text[0], AgentEvent::TextDelta { delta } if delta == "Done")); + + let tool_result = parse_claude_code_jsonl_event( + r#"{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"users"}],"is_error":false}]}}"#, + ) + .unwrap(); + assert!( + matches!(&tool_result[0], AgentEvent::ToolCallEnd { tool_call_id, result, is_error, .. } if tool_call_id == "tool-1" && result[0]["text"] == "users" && !is_error) + ); + + let done = parse_claude_code_jsonl_event( + r#"{"type":"result","subtype":"success","usage":{"input_tokens":12,"output_tokens":3}}"#, + ) + .unwrap(); + assert!(matches!(&done[0], AgentEvent::AgentEnd { input_tokens: Some(12), output_tokens: Some(3) })); + + let failed = parse_claude_code_jsonl_event( + r#"{"type":"result","subtype":"error_max_turns","message":"too many turns"}"#, + ) + .unwrap(); + assert!(matches!(&failed[0], AgentEvent::Error { message } if message == "too many turns")); + } +} diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs index f81eaa06d..97f137475 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -3,6 +3,7 @@ use crate::ai::{AiMessage, AiModelInfo}; use crate::token_usage::TokenUsage; use serde_json::Value; use std::ffi::OsStr; +use std::path::PathBuf; use std::process::Stdio; use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; @@ -28,11 +29,13 @@ pub struct CliAgentCommandSpec { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CliAgentJsonlDialect { CodexExec, + ClaudeCodePrint, } pub struct CliAgentProcessSpec { pub command: CliAgentCommandSpec, pub env: Vec<(String, String)>, + pub current_dir: Option, pub stdin: Option, pub dialect: CliAgentJsonlDialect, pub classify_spawn_error: fn(&str) -> String, @@ -109,7 +112,7 @@ pub fn build_cli_agent_prompt( } pub fn model_infos(ids: &[&str]) -> Vec { - ids.iter().map(|id| AiModelInfo { id: (*id).to_string(), display_name: None }).collect() + ids.iter().map(|id| AiModelInfo::new(*id, None)).collect() } pub fn cli_command(program: impl AsRef) -> Command { @@ -143,8 +146,8 @@ pub async fn list_json_models_or_default( if models.is_empty() { Ok(model_infos(default_models)) } else { - let mut result = vec![AiModelInfo { id: "default".to_string(), display_name: None }]; - result.extend(models.into_iter().map(|id| AiModelInfo { id, display_name: None })); + let mut result = vec![AiModelInfo::new("default", None)]; + result.extend(models.into_iter().map(|id| AiModelInfo::new(id, None))); Ok(result) } } @@ -190,6 +193,7 @@ pub fn parse_cli_jsonl_event(line: &str, dialect: CliAgentJsonlDialect) -> Optio fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliAgentEvent { match dialect { CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line), + CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line), } } @@ -351,15 +355,154 @@ fn codex_error_message(value: &Value) -> String { .to_string() } +fn parse_claude_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() { + "assistant" => parse_claude_code_assistant(&value), + "user" => parse_claude_code_user(&value), + "result" => parse_claude_code_result(&value), + "error" => { + let message = claude_code_error_message(&value); + ParsedCliAgentEvent { + error: Some(message.clone()), + events: vec![AgentEvent::Error { message }], + ..Default::default() + } + } + _ => ParsedCliAgentEvent::default(), + } +} + +fn parse_claude_code_assistant(value: &Value) -> ParsedCliAgentEvent { + let content = value.get("message").and_then(|message| message.get("content")).or_else(|| value.get("content")); + let Some(content) = content else { + return ParsedCliAgentEvent::default(); + }; + + let mut events = Vec::new(); + let mut final_text = String::new(); + for block in claude_content_blocks(content) { + match block.get("type").and_then(Value::as_str).unwrap_or_default() { + "text" => { + if let Some(text) = block.get("text").and_then(Value::as_str).filter(|text| !text.is_empty()) { + final_text.push_str(text); + events.push(AgentEvent::TextDelta { delta: text.to_string() }); + } + } + "thinking" => { + if let Some(text) = block.get("thinking").and_then(Value::as_str).filter(|text| !text.is_empty()) { + events.push(AgentEvent::ReasoningDelta { delta: text.to_string() }); + } + } + "tool_use" => { + events.push(AgentEvent::ToolCallStart { + tool_call_id: block + .get("id") + .and_then(Value::as_str) + .unwrap_or("claude-code-tool-call") + .to_string(), + tool_name: block.get("name").and_then(Value::as_str).unwrap_or("claude_code_tool").to_string(), + args: block.get("input").cloned().unwrap_or_else(|| Value::Object(Default::default())), + }); + } + _ => {} + } + } + + ParsedCliAgentEvent { final_text: (!final_text.is_empty()).then_some(final_text), events, ..Default::default() } +} + +fn parse_claude_code_user(value: &Value) -> ParsedCliAgentEvent { + let content = value.get("message").and_then(|message| message.get("content")).or_else(|| value.get("content")); + let Some(content) = content else { + return ParsedCliAgentEvent::default(); + }; + + let mut events = Vec::new(); + for block in claude_content_blocks(content) { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + events.push(AgentEvent::ToolCallEnd { + tool_call_id: block + .get("tool_use_id") + .and_then(Value::as_str) + .or_else(|| block.get("id").and_then(Value::as_str)) + .unwrap_or("claude-code-tool-call") + .to_string(), + tool_name: "mcp_tool".to_string(), + result: block.get("content").cloned().unwrap_or(Value::Null), + is_error: block.get("is_error").and_then(Value::as_bool).unwrap_or(false), + }); + } + + ParsedCliAgentEvent { events, ..Default::default() } +} + +fn parse_claude_code_result(value: &Value) -> ParsedCliAgentEvent { + let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or("success"); + if subtype != "success" { + let message = claude_code_error_message(value); + return ParsedCliAgentEvent { + error: Some(message.clone()), + events: vec![AgentEvent::Error { message }], + ..Default::default() + }; + } + + let usage = value.get("usage").and_then(|usage| { + let input = + usage.get("input_tokens").or_else(|| usage.get("prompt_tokens")).and_then(Value::as_u64).unwrap_or(0) + as u32; + let output = + usage.get("output_tokens").or_else(|| usage.get("completion_tokens")).and_then(Value::as_u64).unwrap_or(0) + as u32; + (input > 0 || output > 0).then_some(TokenUsage { input_tokens: input, output_tokens: output }) + }); + + ParsedCliAgentEvent { + events: vec![AgentEvent::AgentEnd { + input_tokens: usage.as_ref().and_then(|u| (u.input_tokens > 0).then_some(u.input_tokens)), + output_tokens: usage.as_ref().and_then(|u| (u.output_tokens > 0).then_some(u.output_tokens)), + }], + ..Default::default() + } +} + +fn claude_content_blocks(content: &Value) -> Vec { + match content { + Value::Array(blocks) => blocks.clone(), + Value::String(text) => vec![serde_json::json!({ "type": "text", "text": text })], + Value::Object(_) => vec![content.clone()], + _ => Vec::new(), + } +} + +fn claude_code_error_message(value: &Value) -> String { + value + .get("error") + .and_then(Value::as_str) + .or_else(|| value.get("message").and_then(Value::as_str)) + .or_else(|| value.get("error").and_then(|error| error.get("message")).and_then(Value::as_str)) + .or_else(|| value.get("result").and_then(Value::as_str)) + .unwrap_or("Claude Code CLI failed") + .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()))); + if let Some(current_dir) = &spec.current_dir { + command.current_dir(current_dir); + } let mut child = command - .args(&spec.command.args) - .envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str()))) .stdin(if spec.stdin.is_some() { Stdio::piped() } else { Stdio::null() }) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -476,6 +619,7 @@ mod tests { ], }, env: vec![("DBX_TEST_ENV".to_string(), "from-env".to_string())], + current_dir: None, stdin: None, dialect: CliAgentJsonlDialect::CodexExec, classify_spawn_error, @@ -498,6 +642,7 @@ mod tests { ], }, env: Vec::new(), + current_dir: None, stdin: Some("prompt from stdin".to_string()), dialect: CliAgentJsonlDialect::CodexExec, classify_spawn_error, @@ -524,6 +669,7 @@ mod tests { let spec = CliAgentProcessSpec { command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] }, env: Vec::new(), + current_dir: None, stdin: None, dialect: CliAgentJsonlDialect::CodexExec, classify_spawn_error, diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs index d3efccc9f..d791341c1 100644 --- a/crates/dbx-core/src/ai_codex_cli.rs +++ b/crates/dbx-core/src/ai_codex_cli.rs @@ -463,7 +463,7 @@ fn parse_codex_models(stdout: &str) -> Option> { let data = serde_json::from_str::(&stdout[json_start..]).ok()?; let models = data.get("models").and_then(Value::as_array)?; - let mut result = vec![AiModelInfo { id: "default".to_string(), display_name: Some("Default".to_string()) }]; + let mut result = vec![AiModelInfo::new("default", Some("Default".to_string()))]; for model in models { let Some(id) = model .get("slug") @@ -483,7 +483,7 @@ fn parse_codex_models(stdout: &str) -> Option> { .map(str::trim) .filter(|name| !name.is_empty()) .map(ToString::to_string); - result.push(AiModelInfo { id: id.to_string(), display_name }); + result.push(AiModelInfo::new(id, display_name)); } (result.len() > 1).then_some(result) @@ -568,6 +568,7 @@ pub async fn run_codex_agent( CliAgentProcessSpec { command, env, + current_dir: None, stdin: Some(prompt.to_string()), dialect: CliAgentJsonlDialect::CodexExec, classify_spawn_error: classify_codex_spawn_error, @@ -614,6 +615,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), } } diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs index 0bf1adea5..c00226962 100644 --- a/crates/dbx-core/src/cloud_sync.rs +++ b/crates/dbx-core/src/cloud_sync.rs @@ -1074,6 +1074,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), }, } } diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 683bcaade..e84039bca 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod agent_runtime; pub mod agent_service; pub mod agent_tools; pub mod ai; +pub mod ai_claude_code_cli; pub mod ai_cli_agent; pub mod ai_codex_cli; pub mod changelog; diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index 440246f3a..a24bd4618 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -3752,7 +3752,9 @@ mod tests { // ---- AI Config tests ---- - use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem, AiProvider, AiReasoningLevel}; + use crate::ai::{ + AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem, AiEffortLevel, AiModelListItem, AiProvider, AiReasoningLevel, + }; fn make_ai_config(name: &str, is_default: bool) -> AiConfigItem { AiConfigItem { @@ -3774,6 +3776,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: std::collections::HashMap::new(), + claude_code_cli_path: None, + claude_code_cli_env: std::collections::HashMap::new(), }, } } @@ -3783,7 +3787,15 @@ mod tests { let db = temp_db_path("ai-roundtrip"); let storage = Storage::open(&db).await.unwrap(); - let cfg = make_ai_config("test-config", true); + let mut cfg = make_ai_config("test-config", true); + cfg.config.provider = AiProvider::ClaudeCodeCli; + cfg.config.model = "claude-sonnet-4-6".to_string(); + cfg.config.reasoning_level = AiReasoningLevel::Xhigh; + cfg.config.models = vec![AiModelListItem { + name: "claude-sonnet-4-6".to_string(), + label: Some("Sonnet 4.6".to_string()), + supported_effort_levels: vec![AiEffortLevel::Low, AiEffortLevel::High, AiEffortLevel::Xhigh], + }]; storage.save_ai_config_item(&cfg).await.unwrap(); let loaded = storage.load_ai_configs().await.unwrap(); @@ -3791,7 +3803,14 @@ mod tests { assert_eq!(loaded[0].id, "cfg-test-config"); assert_eq!(loaded[0].name, "test-config"); assert!(loaded[0].is_default); - assert_eq!(loaded[0].config.model, "gpt-4o"); + assert_eq!(loaded[0].config.model, "claude-sonnet-4-6"); + assert_eq!(loaded[0].config.reasoning_level, AiReasoningLevel::Xhigh); + assert_eq!(loaded[0].config.models.len(), 1); + assert_eq!(loaded[0].config.models[0].name, "claude-sonnet-4-6"); + assert_eq!( + loaded[0].config.models[0].supported_effort_levels, + vec![AiEffortLevel::Low, AiEffortLevel::High, AiEffortLevel::Xhigh] + ); std::fs::remove_file(&db).ok(); } diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs index e7713232b..bab2115f6 100644 --- a/crates/dbx-web/src/routes/ai.rs +++ b/crates/dbx-web/src/routes/ai.rs @@ -93,8 +93,8 @@ fn default_agent_mode() -> String { } fn reject_web_unsupported_ai_provider(config: &AiConfig) -> Result<(), AppError> { - if matches!(config.provider, AiProvider::CodexCli) { - return Err(AppError::bad_request("Codex CLI provider is only supported in DBX Desktop.")); + if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) { + return Err(AppError::bad_request("CLI providers are only supported in DBX Desktop.")); } Ok(()) } @@ -412,6 +412,8 @@ mod tests { context_window: None, codex_cli_path: None, codex_cli_env: Default::default(), + claude_code_cli_path: None, + claude_code_cli_env: Default::default(), } } diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index f9d6c27d8..e63886e49 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -395,6 +395,10 @@ test("AI provider presets include common hosted and local providers", () => { assert.equal(AI_PROVIDER_PRESETS.openai.authMethod, "bearer"); assert.equal(AI_PROVIDER_PRESETS.openai.iconSlug, "openai"); assert.equal(AI_PROVIDER_PRESETS.deepseek.iconSlug, "deepseek"); + 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.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli")); }); test("normalizes legacy AI config and fills provider defaults", () => { @@ -418,6 +422,32 @@ test("normalizes legacy AI config and fills provider defaults", () => { const claudeToken = normalizeAiConfig({ provider: "claude", apiKey: "token", authMethod: "bearer" } as any); assert.equal(claudeToken.authMethod, "bearer"); + + const claudeCode = normalizeAiConfig({ + provider: "claude-code-cli", + claudeCodeCliPath: " /opt/homebrew/bin/claude ", + claudeCodeCliEnv: { HTTPS_PROXY: "http://proxy:9800" }, + reasoningLevel: "xhigh", + models: [ + { + name: "claude-sonnet-4-6", + label: "Sonnet 4.6", + supportedEffortLevels: ["low", "high", "xhigh"], + }, + ], + } as any); + assert.equal(claudeCode.claudeCodeCliPath, "/opt/homebrew/bin/claude"); + assert.deepEqual(claudeCode.claudeCodeCliEnv, { HTTPS_PROXY: "http://proxy:9800" }); + assert.equal(claudeCode.reasoningLevel, "xhigh"); + assert.deepEqual(claudeCode.models, [ + { + name: "claude-sonnet-4-6", + label: "Sonnet 4.6", + supportedEffortLevels: ["low", "high", "xhigh"], + }, + ]); + assert.equal(normalizeAiConfig({ provider: "claude-code-cli", reasoningLevel: "max" } as any).reasoningLevel, "max"); + assert.equal(normalizeAiConfig({ provider: "claude-code-cli", reasoningLevel: "future" } as any).reasoningLevel, "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 8c4fd2318..32a0d974e 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -7,13 +7,13 @@ pub use dbx_core::ai::*; #[tauri::command] pub async fn ai_test_connection(config: AiConfig) -> Result { - let config = resolve_codex_cli_config(config); + let config = resolve_cli_provider_config(config); dbx_core::ai::test_connection_core(&config).await } #[tauri::command] pub async fn ai_list_models(config: AiConfig) -> Result, String> { - let config = resolve_codex_cli_config(config); + let config = resolve_cli_provider_config(config); dbx_core::ai::list_models_core(&config).await } @@ -88,12 +88,12 @@ pub async fn ai_agent_stream( mode: Option, allow_write_sql: Option, ) -> Result { - let request = resolve_codex_cli_request(request); + let request = resolve_cli_provider_request(request); let parsed_db_type: DatabaseType = serde_json::from_str(&format!("\"{}\"", db_type)).map_err(|_| format!("Unknown database type: {db_type}"))?; - let cli_mcp_server_command = if matches!(request.config.provider, AiProvider::CodexCli) { + let cli_mcp_server_command = if is_cli_provider(&request.config.provider) { let (program, args) = super::mcp::resolve_mcp_server_command().await?; Some(CliAgentCommandSpec { program, args }) } else { @@ -142,27 +142,32 @@ pub async fn ai_agent_stream( result } -fn resolve_codex_cli_request(mut request: AiCompletionRequest) -> AiCompletionRequest { - request.config = resolve_codex_cli_config(request.config); +fn resolve_cli_provider_request(mut request: AiCompletionRequest) -> AiCompletionRequest { + request.config = resolve_cli_provider_config(request.config); request } -fn resolve_codex_cli_config(mut config: AiConfig) -> AiConfig { - if !matches!(config.provider, AiProvider::CodexCli) { - return config; - } - - let command = config.codex_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("codex"); +fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig { + let (path_slot, default_command) = match config.provider { + AiProvider::CodexCli => (&mut config.codex_cli_path, "codex"), + AiProvider::ClaudeCodeCli => (&mut config.claude_code_cli_path, "claude"), + _ => return config, + }; + let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command); if is_explicit_cli_path(command) { return config; } if let Some(path) = super::mcp::locate_command(command) { - config.codex_cli_path = Some(path); + *path_slot = Some(path); } config } +fn is_cli_provider(provider: &AiProvider) -> bool { + matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) +} + fn is_explicit_cli_path(command: &str) -> bool { let path = Path::new(command); path.is_absolute() || command.contains('/') || command.contains('\\')