feat(ai): add Codex CLI as a no-API-key AI provider for Desktop

* codex cli provider

* fixes

* mcp check

* reasoning

* simplify

* minor fixes
This commit is contained in:
Oleksandr Liakhevych
2026-06-19 08:51:10 +08:00
committed by GitHub
parent 93a6a65707
commit 4f5d09aaa8
22 changed files with 1401 additions and 101 deletions
+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path d="M19.503 0H4.496A4.496 4.496 0 000 4.496v15.007A4.496 4.496 0 004.496 24h15.007A4.496 4.496 0 0024 19.503V4.496A4.496 4.496 0 0019.503 0z" fill="#fff"></path><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#lobe-icons-codex-_R_0_)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-codex-_R_0_" x1="12" x2="12" y1="3" y2="21"><stop stop-color="#B1A7FF"></stop><stop offset=".5" stop-color="#7A9DFF"></stop><stop offset="1" stop-color="#3941FF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -405,7 +405,7 @@ function agentEventToStep(event: AgentEvent, index: number): AiAgentStepItem | u
if (event.type !== "tool_call_start" && event.type !== "tool_call_end") return undefined;
const isExecuteQuery = event.tool_name === "execute_query";
const isExecuteQuery = event.tool_name === "execute_query" || event.tool_name === "dbx_execute_query";
const labelKey = event.type === "tool_call_start" ? "ai.agentSteps.callingTool" : isExecuteQuery ? (event.is_error ? "ai.agentSteps.executeBlocked" : "ai.agentSteps.executeSafe") : event.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone";
const tone = (event.type === "tool_call_start" ? "active" : event.is_error ? "danger" : "success") as AiAgentStepTone;
@@ -28,6 +28,7 @@ import {
type AiProvider,
type AiApiStyle,
type AiAuthMethod,
type AiReasoningLevel,
type EditorTheme,
type DesktopIconTheme,
type InterfaceLayout,
@@ -1018,6 +1019,7 @@ watch(
await refreshWebDavPasswordStatus();
syncAiEditState();
if (!isWeb && activeSettingsTab.value === "mcp") void refreshMcpStatus();
if (!isWeb && activeSettingsTab.value === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus();
}
},
{ immediate: true },
@@ -1032,6 +1034,7 @@ watch(webdavRememberPassword, (val) => {
watch(activeSettingsTab, (tab) => {
if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus();
if (tab === "ai" && aiIsCodexCli.value) void ensureCodexMcpStatus();
if (tab === "appearance") {
checkLayoutDescTruncation();
checkIconThemeDescTruncation();
@@ -1085,7 +1088,7 @@ async function changePassword() {
}
// ---------- AI Settings ----------
const aiProviderOptions = Object.values(AI_PROVIDER_PRESETS);
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || provider.provider !== "codex-cli"));
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
const aiEditProvider = ref<AiProvider>(settingsStore.aiConfig.provider);
@@ -1097,7 +1100,9 @@ const aiEditApiStyle = ref<AiApiStyle>(settingsStore.aiConfig.apiStyle || "compl
const aiEditProxyEnabled = ref(!!settingsStore.aiConfig.proxyEnabled);
const aiEditProxyUrl = ref(settingsStore.aiConfig.proxyUrl || "");
const aiEditEnableThinking = ref(settingsStore.aiConfig.enableThinking ?? true);
const aiEditReasoningLevel = ref<AiReasoningLevel>(settingsStore.aiConfig.reasoningLevel || "default");
const aiEditContextWindow = ref<number | undefined>(settingsStore.aiConfig.contextWindow);
const aiEditCodexCliPath = ref(settingsStore.aiConfig.codexCliPath || "");
const aiModelOptions = ref<AiModelInfo[]>([]);
const aiModelLoading = ref(false);
@@ -1106,11 +1111,23 @@ const aiModelLoadedSignature = ref("");
let aiModelRequestToken = 0;
const aiCompletionsMode = computed(() => aiEditApiStyle.value === "completions");
const aiReasoningLevelOptions: 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 aiTesting = ref(false);
const aiTestResult = ref<"" | "success" | "error">("");
const aiTestError = ref("");
const aiTestLatency = ref<number | null>(null);
const aiTestErrorCopied = ref(false);
const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli");
watch(aiIsCodexCli, (isCodex) => {
if (isCodex) void ensureCodexMcpStatus();
});
const aiRequiresApiKey = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value].requiresApiKey);
const aiSupportsAuthMethod = computed(() => aiEditProvider.value === "claude");
const aiCredentialLabel = computed(() => (aiSupportsAuthMethod.value && aiEditAuthMethod.value === "bearer" ? "Auth Token" : "API Key"));
@@ -1131,9 +1148,19 @@ const aiEndpointHint = computed(() => {
}
return "";
});
const aiSupportsApiStyle = computed(() => aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom");
const aiSupportsApiStyle = computed(() => !aiIsCodexCli.value && (aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom"));
const aiCodexMcpNeedsInstall = computed(() => aiIsCodexCli.value && (!mcpStatus.value || !mcpStatus.value.installed));
const aiCodexMcpCanInstall = computed(() => {
const status = mcpStatus.value;
return !mcpInstalling.value && !!status?.npm_available && (!status.installed || status.update_available);
});
const aiCodexMcpActionLabel = 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 && !!aiEditEndpoint.value.trim() && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
const aiCanListModels = computed(() => aiModelListSupported.value && (aiIsCodexCli.value || !!aiEditEndpoint.value.trim()) && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim()));
const aiModelOptionIds = computed(() => aiModelOptions.value.map((model) => model.id));
const aiModelEmptyText = computed(() => {
if (aiModelError.value) return aiModelError.value;
@@ -1157,6 +1184,7 @@ function aiModelConfigSignature() {
authMethod: aiEditAuthMethod.value,
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value.trim(),
codexCliPath: aiEditCodexCliPath.value.trim(),
});
}
@@ -1171,7 +1199,9 @@ function currentAiEditConfig() {
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
enableThinking: aiEditEnableThinking.value,
reasoningLevel: aiEditReasoningLevel.value,
contextWindow: aiEditContextWindow.value || undefined,
codexCliPath: aiEditCodexCliPath.value.trim() || undefined,
};
}
@@ -1197,7 +1227,7 @@ async function aiRefreshModels() {
aiModelError.value = t("ai.modelListUnsupported");
return;
}
if (!aiEditEndpoint.value.trim()) {
if (!aiIsCodexCli.value && !aiEditEndpoint.value.trim()) {
aiModelError.value = t("ai.modelListEndpointRequired");
return;
}
@@ -1236,33 +1266,42 @@ function aiSelectModel(modelId: string) {
}
function syncAiEditState() {
aiEditProvider.value = settingsStore.aiConfig.provider;
const provider = isWeb && settingsStore.aiConfig.provider === "codex-cli" ? "claude" : settingsStore.aiConfig.provider;
aiEditProvider.value = provider;
aiEditApiKey.value = settingsStore.aiConfig.apiKey;
aiEditAuthMethod.value = settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[settingsStore.aiConfig.provider].authMethod;
aiEditEndpoint.value = settingsStore.aiConfig.endpoint;
aiEditModel.value = settingsStore.aiConfig.model;
aiEditApiStyle.value = settingsStore.aiConfig.apiStyle || "completions";
aiEditAuthMethod.value = settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[provider].authMethod;
aiEditEndpoint.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.endpoint : AI_PROVIDER_PRESETS[provider].endpoint;
aiEditModel.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.model : AI_PROVIDER_PRESETS[provider].model;
aiEditApiStyle.value = provider === settingsStore.aiConfig.provider ? settingsStore.aiConfig.apiStyle || "completions" : AI_PROVIDER_PRESETS[provider].apiStyle;
aiEditProxyEnabled.value = !!settingsStore.aiConfig.proxyEnabled;
aiEditProxyUrl.value = settingsStore.aiConfig.proxyUrl || "";
aiEditEnableThinking.value = settingsStore.aiConfig.enableThinking ?? true;
aiEditReasoningLevel.value = settingsStore.aiConfig.reasoningLevel || "default";
aiEditContextWindow.value = settingsStore.aiConfig.contextWindow;
aiEditCodexCliPath.value = settingsStore.aiConfig.codexCliPath || "";
aiTestResult.value = "";
aiTestError.value = "";
aiTestLatency.value = null;
aiTestErrorCopied.value = false;
clearAiModelOptions();
}
function aiSelectProvider(provider: AiProvider) {
if (isWeb && provider === "codex-cli") return;
aiEditProvider.value = provider;
aiEditEndpoint.value = AI_PROVIDER_PRESETS[provider].endpoint;
aiEditModel.value = AI_PROVIDER_PRESETS[provider].model;
aiEditApiStyle.value = AI_PROVIDER_PRESETS[provider].apiStyle;
aiEditAuthMethod.value = AI_PROVIDER_PRESETS[provider].authMethod;
aiEditReasoningLevel.value = "default";
if (!AI_PROVIDER_PRESETS[provider].requiresApiKey) aiEditApiKey.value = "";
aiEditCodexCliPath.value = "";
aiTestResult.value = "";
aiTestError.value = "";
aiTestLatency.value = null;
aiTestErrorCopied.value = false;
clearAiModelOptions();
if (provider === "codex-cli") void ensureCodexMcpStatus();
}
function aiHasChanges(): boolean {
@@ -1276,7 +1315,9 @@ function aiHasChanges(): boolean {
aiEditProxyEnabled.value !== !!settingsStore.aiConfig.proxyEnabled ||
aiEditProxyUrl.value !== (settingsStore.aiConfig.proxyUrl || "") ||
aiEditEnableThinking.value !== (settingsStore.aiConfig.enableThinking ?? true) ||
aiEditContextWindow.value !== settingsStore.aiConfig.contextWindow
aiEditReasoningLevel.value !== (settingsStore.aiConfig.reasoningLevel || "default") ||
aiEditContextWindow.value !== settingsStore.aiConfig.contextWindow ||
aiEditCodexCliPath.value !== (settingsStore.aiConfig.codexCliPath || "")
);
}
@@ -1285,11 +1326,12 @@ function aiApplySettings() {
}
async function aiTestConn() {
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || !aiEditEndpoint.value.trim() || !aiEditModel.value.trim()) return;
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCodexCli.value && !aiEditEndpoint.value.trim()) || (!aiIsCodexCli.value && !aiEditModel.value.trim())) return;
aiTesting.value = true;
aiTestResult.value = "";
aiTestError.value = "";
aiTestLatency.value = null;
aiTestErrorCopied.value = false;
try {
const result = await aiTestConnection(currentAiEditConfig());
aiTestResult.value = "success";
@@ -1302,6 +1344,20 @@ async function aiTestConn() {
}
}
async function copyAiTestError() {
if (!aiTestError.value) return;
await copyToClipboard(aiTestError.value);
aiTestErrorCopied.value = true;
window.setTimeout(() => {
aiTestErrorCopied.value = false;
}, 1500);
}
async function ensureCodexMcpStatus() {
if (isWeb || activeSettingsTab.value !== "ai" || !aiIsCodexCli.value || mcpStatus.value || mcpStatusLoading.value) return;
await refreshMcpStatus();
}
// ---------- CodeMirror preview ----------
const previewRef = ref<HTMLDivElement>();
const previewView = shallowRef<EditorViewType | null>(null);
@@ -2358,7 +2414,40 @@ watch(
</Select>
</div>
<div v-if="aiSupportsAuthMethod" class="grid grid-cols-3 items-center gap-3">
<div v-if="aiIsCodexCli && !isWeb" class="rounded-md border px-3 py-2.5 text-xs" :class="aiCodexMcpNeedsInstall ? 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300'">
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 space-y-1">
<div class="flex min-w-0 items-center gap-2 font-medium">
<Loader2 v-if="mcpStatusLoading" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<AlertTriangle v-else-if="aiCodexMcpNeedsInstall || mcpStatus?.error || mcpStatusError" class="h-3.5 w-3.5 shrink-0" />
<CheckCircle2 v-else class="h-3.5 w-3.5 shrink-0" />
<span>{{ t("ai.codexMcpRequiredTitle") }}</span>
<Badge variant="outline" class="h-5 shrink-0 rounded-md border-current/30 px-1.5 text-[11px] font-normal">
{{ mcpStatusLabel }}
</Badge>
</div>
<p class="leading-relaxed">
{{ t("ai.codexMcpRequiredDescription") }}
</p>
<p v-if="mcpStatus?.error || mcpStatusError" class="select-text leading-relaxed">
{{ mcpStatusError || mcpStatus?.error }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button type="button" size="sm" variant="outline" class="h-7 bg-background/80 px-2 text-xs" :disabled="mcpStatusLoading" @click="refreshMcpStatus">
<Loader2 v-if="mcpStatusLoading" class="mr-1 h-3 w-3 animate-spin" />
<RefreshCw v-else class="mr-1 h-3 w-3" />
{{ t("settings.mcpRefresh") }}
</Button>
<Button v-if="aiCodexMcpNeedsInstall || mcpStatus?.update_available" type="button" size="sm" class="h-7 px-2 text-xs" :disabled="!aiCodexMcpCanInstall" @click="installMcp">
<Loader2 v-if="mcpInstalling" class="mr-1 h-3 w-3 animate-spin" />
{{ mcpInstalling ? t("settings.mcpInstalling") : aiCodexMcpActionLabel }}
</Button>
</div>
</div>
</div>
<div v-if="!aiIsCodexCli && aiSupportsAuthMethod" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">Authentication</Label>
<Select v-model="aiEditAuthMethod">
<SelectTrigger class="col-span-2" inputClass="h-8 text-xs">
@@ -2371,12 +2460,12 @@ watch(
</Select>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ aiCredentialLabel }}</Label>
<PasswordInput v-model="aiEditApiKey" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" :placeholder="aiCredentialPlaceholder" />
</div>
<div class="grid grid-cols-3 items-start gap-3">
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">Endpoint</Label>
<div class="col-span-2 space-y-1.5">
<Input v-model="aiEditEndpoint" :placeholder="aiEndpointPlaceholder" autocomplete="off" class="h-8 text-xs" />
@@ -2384,6 +2473,14 @@ watch(
</div>
</div>
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.codexCliPath") }}</Label>
<div class="col-span-2 space-y-1.5">
<Input v-model="aiEditCodexCliPath" autocomplete="off" class="h-8 text-xs" placeholder="codex" />
<p class="text-[11px] text-muted-foreground">{{ t("ai.codexCliPathHint") }}</p>
</div>
</div>
<div class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.model") }}</Label>
<div class="col-span-2 space-y-1.5">
@@ -2425,6 +2522,23 @@ watch(
</div>
</div>
<div v-if="aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.reasoningLevel") }}</Label>
<div class="col-span-2 space-y-1.5">
<Select v-model="aiEditReasoningLevel">
<SelectTrigger inputClass="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in aiReasoningLevelOptions" :key="option.value" :value="option.value">
{{ t(option.labelKey) }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-[11px] text-muted-foreground">{{ t("ai.reasoningLevelHint") }}</p>
</div>
</div>
<div v-if="aiSupportsApiStyle" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">API</Label>
<div class="col-span-2 flex gap-2">
@@ -2433,7 +2547,7 @@ watch(
</div>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.enableThinking") }}</Label>
<div class="col-span-2 flex items-center gap-2">
<label class="flex items-center gap-2 text-xs text-muted-foreground">
@@ -2451,7 +2565,7 @@ watch(
</div>
</div>
<div class="grid grid-cols-3 items-start gap-3">
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-start gap-3">
<Label class="text-right text-xs">{{ t("ai.contextWindow") }}</Label>
<div class="col-span-2">
<Input v-model.number="aiEditContextWindow" type="number" min="1000" step="1000" class="h-8 text-xs" :placeholder="t('ai.contextWindowAuto')" />
@@ -2459,7 +2573,7 @@ watch(
</div>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxy") }}</Label>
<label class="col-span-2 flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="aiEditProxyEnabled" type="checkbox" class="h-4 w-4 shrink-0 accent-primary" />
@@ -2467,7 +2581,7 @@ watch(
</label>
</div>
<div class="grid grid-cols-3 items-center gap-3">
<div v-if="!aiIsCodexCli" class="grid grid-cols-3 items-center gap-3">
<Label class="text-right text-xs">{{ t("ai.proxyUrl") }}</Label>
<Input v-model="aiEditProxyUrl" autocomplete="off" class="col-span-2" inputClass="h-8 text-xs" placeholder="socks5://127.0.0.1:7890" :disabled="!aiEditProxyEnabled" />
</div>
@@ -2737,7 +2851,7 @@ watch(
<DialogFooter v-else-if="activeSettingsTab === 'ai'" class="mx-0 mb-0 shrink-0 rounded-none border-t border-border/60 bg-transparent px-0 pb-0 pt-3 gap-3 sm:gap-3">
<div class="flex flex-1 items-center gap-2">
<Button size="sm" variant="outline" :disabled="aiTesting || (aiRequiresApiKey && !aiEditApiKey?.trim()) || !aiEditEndpoint?.trim() || !aiEditModel?.trim()" @click="aiTestConn">
<Button size="sm" variant="outline" :disabled="aiTesting || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCodexCli && !aiEditEndpoint?.trim()) || (!aiIsCodexCli && !aiEditModel?.trim())" @click="aiTestConn">
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
{{ t("connection.test") }}
</Button>
@@ -2745,8 +2859,20 @@ watch(
<span>{{ t("connection.testSuccess") }}</span>
<span v-if="aiTestLatency != null" class="text-green-500/70">{{ aiTestLatency }}ms</span>
</span>
<span v-else-if="aiTestResult === 'error'" class="text-xs text-destructive truncate max-w-[200px]" :title="aiTestError">
{{ aiTestError }}
<span v-else-if="aiTestResult === 'error'" class="min-w-0 max-w-[360px] flex items-center gap-1.5 text-xs text-destructive">
<span class="select-text truncate" :title="aiTestError">{{ aiTestError }}</span>
<Button
type="button"
variant="ghost"
size="icon"
class="h-6 w-6 shrink-0 text-destructive/80 hover:text-destructive"
:title="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
:aria-label="aiTestErrorCopied ? t('ai.copied') : t('ai.copyTestResult')"
@click="copyAiTestError"
>
<CheckCircle2 v-if="aiTestErrorCopied" class="h-3.5 w-3.5" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</span>
</div>
<Button variant="outline" @click="emit('update:open', false)">{{ t("common.close") }}</Button>
+11
View File
@@ -1020,6 +1020,17 @@ export default {
contextWindow: "Context Window",
contextWindowAuto: "Auto (detect from model name)",
contextWindowHint: "Tokens. Leave empty to auto-detect. Set manually for local/custom models.",
codexCliPath: "Codex CLI Path",
codexCliPathHint: "Leave empty to use codex from PATH. Sign in separately with codex login.",
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",
reasoningLevelDefault: "Default",
reasoningLevelMinimal: "Minimal",
reasoningLevelLow: "Low",
reasoningLevelMedium: "Medium",
reasoningLevelHigh: "High",
reasoningLevelHint: "Controls Codex CLI model_reasoning_effort. Default uses your Codex config.",
actions: {
generate: "Generate SQL",
explain: "Explain SQL",
+9
View File
@@ -827,6 +827,15 @@ export default {
modelListUnsupported: "Este proveedor no admite obtener una lista de modelos.",
modelListEndpointRequired: "Ingresa un endpoint antes de cargar modelos.",
modelListApiKeyRequired: "Ingresa una clave API antes de cargar modelos.",
codexMcpRequiredTitle: "Se requiere DBX MCP Server",
codexMcpRequiredDescription: "Codex CLI usa DBX MCP Server para acceder al esquema de la base de datos y a las herramientas de consulta. Instálalo antes de usar Codex en el asistente de IA.",
reasoningLevel: "Nivel de razonamiento",
reasoningLevelDefault: "Predeterminado",
reasoningLevelMinimal: "Mínimo",
reasoningLevelLow: "Bajo",
reasoningLevelMedium: "Medio",
reasoningLevelHigh: "Alto",
reasoningLevelHint: "Controla model_reasoning_effort de Codex CLI. Predeterminado usa tu configuración de Codex.",
run: "Ejecutar",
readingSchema: "Leyendo esquema",
noConnection: "No hay una conexión disponible para esta pestaña",
+9
View File
@@ -888,6 +888,15 @@ export default {
modelListUnsupported: "Questo provider non supporta il recupero di un elenco di modelli.",
modelListEndpointRequired: "Inserisci un endpoint prima di caricare i modelli.",
modelListApiKeyRequired: "Inserisci una chiave API prima di caricare i modelli.",
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",
reasoningLevelDefault: "Predefinito",
reasoningLevelMinimal: "Minimo",
reasoningLevelLow: "Basso",
reasoningLevelMedium: "Medio",
reasoningLevelHigh: "Alto",
reasoningLevelHint: "Controlla model_reasoning_effort di Codex CLI. Predefinito usa la configurazione Codex.",
run: "Esegui",
readingSchema: "Lettura schema",
noConnection: "Nessuna connessione disponibile per questa scheda",
+9
View File
@@ -940,6 +940,15 @@ export default {
modelListUnsupported: "このプロバイダーはモデル一覧の取得をサポートしていません。",
modelListEndpointRequired: "モデルを読み込む前にエンドポイントを入力してください。",
modelListApiKeyRequired: "モデルを読み込む前にAPIキーを入力してください。",
codexMcpRequiredTitle: "DBX MCP Serverが必要です",
codexMcpRequiredDescription: "Codex CLIはDBX MCP Server経由でデータベーススキーマとクエリツールにアクセスします。AIアシスタントでCodexを使う前にインストールしてください。",
reasoningLevel: "推論レベル",
reasoningLevelDefault: "デフォルト",
reasoningLevelMinimal: "最小",
reasoningLevelLow: "低",
reasoningLevelMedium: "中",
reasoningLevelHigh: "高",
reasoningLevelHint: "Codex CLIのmodel_reasoning_effortを制御します。デフォルトではCodex設定を使用します。",
run: "実行",
readingSchema: "スキーマを読み取り中",
noConnection: "このタブに利用可能な接続がありません",
+9
View File
@@ -888,6 +888,15 @@ export default {
modelListUnsupported: "Este provedor não oferece suporte à obtenção de uma lista de modelos.",
modelListEndpointRequired: "Informe um endpoint antes de carregar os modelos.",
modelListApiKeyRequired: "Informe uma API key antes de carregar os modelos.",
codexMcpRequiredTitle: "DBX MCP Server obrigatório",
codexMcpRequiredDescription: "O Codex CLI usa o DBX MCP Server para acessar o schema do banco e as ferramentas de consulta. Instale-o antes de usar o Codex no assistente de AI.",
reasoningLevel: "Nível de raciocínio",
reasoningLevelDefault: "Padrão",
reasoningLevelMinimal: "Mínimo",
reasoningLevelLow: "Baixo",
reasoningLevelMedium: "Médio",
reasoningLevelHigh: "Alto",
reasoningLevelHint: "Controla model_reasoning_effort do Codex CLI. Padrão usa sua configuração do Codex.",
run: "Executar",
readingSchema: "Lendo schema",
noConnection: "Nenhuma conexão disponível para esta aba",
+11
View File
@@ -1019,6 +1019,17 @@ export default {
contextWindow: "上下文窗口",
contextWindowAuto: "自动(根据模型名称推断)",
contextWindowHint: "单位:token。留空自动推断,本地/自定义模型建议手动设置。",
codexCliPath: "Codex CLI 路径",
codexCliPathHint: "留空则使用 PATH 中的 codex。请先在终端执行 codex login 登录。",
codexMcpRequiredTitle: "需要 DBX MCP Server",
codexMcpRequiredDescription: "Codex CLI 通过 DBX MCP Server 访问数据库结构和查询工具。请先安装后再在 AI 助手中使用 Codex。",
reasoningLevel: "推理级别",
reasoningLevelDefault: "默认",
reasoningLevelMinimal: "最小",
reasoningLevelLow: "低",
reasoningLevelMedium: "中",
reasoningLevelHigh: "高",
reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。默认使用你的 Codex 配置。",
actions: {
generate: "生成 SQL",
explain: "解释 SQL",
+9
View File
@@ -939,6 +939,15 @@ export default {
enableThinkingOn: "已啟用",
enableThinkingOff: "已停用",
enableThinkingHint: "此選項僅對 /chat/completions API 且部分支援的模型生效。設為停用後可大幅節省 token,但產生結果品質可能會略微下降。",
codexMcpRequiredTitle: "需要 DBX MCP Server",
codexMcpRequiredDescription: "Codex CLI 會透過 DBX MCP Server 存取資料庫結構與查詢工具。請先安裝後再於 AI 助理中使用 Codex。",
reasoningLevel: "推理層級",
reasoningLevelDefault: "預設",
reasoningLevelMinimal: "最小",
reasoningLevelLow: "低",
reasoningLevelMedium: "中",
reasoningLevelHigh: "高",
reasoningLevelHint: "控制 Codex CLI 的 model_reasoning_effort。預設會使用你的 Codex 設定。",
actions: {
generate: "產生 SQL",
explain: "解釋 SQL",
+23 -1
View File
@@ -11,9 +11,10 @@ import type { SqlSnippet } from "@/types/database";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sqlCompletion";
import { setDebugLoggingEnabled } from "@/lib/debugLog";
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "custom";
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "codex-cli" | "custom";
export type AiApiStyle = "completions" | "responses";
export type AiAuthMethod = "api-key" | "bearer";
export type AiReasoningLevel = "default" | "minimal" | "low" | "medium" | "high";
export interface AiConfig {
provider: AiProvider;
@@ -25,7 +26,9 @@ export interface AiConfig {
proxyEnabled?: boolean;
proxyUrl?: string;
enableThinking?: boolean;
reasoningLevel?: AiReasoningLevel;
contextWindow?: number;
codexCliPath?: string | null;
}
export interface AiTestConnectionResult {
@@ -156,6 +159,16 @@ export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
authMethod: "bearer",
requiresApiKey: true,
},
"codex-cli": {
label: "Codex CLI",
iconSlug: "codex",
provider: "codex-cli",
endpoint: "",
model: "default",
apiStyle: "completions",
authMethod: "bearer",
requiresApiKey: false,
},
custom: {
label: "Custom",
provider: "custom",
@@ -174,6 +187,12 @@ const defaultConfigs: Record<AiProvider, Omit<AiConfig, "apiKey">> = Object.from
}),
) as Record<AiProvider, Omit<AiConfig, "apiKey">>;
const AI_REASONING_LEVELS: AiReasoningLevel[] = ["default", "minimal", "low", "medium", "high"];
function normalizeAiReasoningLevel(value: unknown): AiReasoningLevel {
return typeof value === "string" && AI_REASONING_LEVELS.includes(value as AiReasoningLevel) ? (value as AiReasoningLevel) : "default";
}
export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined): AiConfig {
const provider = config?.provider && config.provider in AI_PROVIDER_PRESETS ? config.provider : inferAiProviderFromConfig(config);
return {
@@ -186,7 +205,9 @@ export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined):
proxyEnabled: !!config?.proxyEnabled,
proxyUrl: config?.proxyUrl ?? "",
enableThinking: config?.enableThinking ?? true,
reasoningLevel: normalizeAiReasoningLevel(config?.reasoningLevel),
contextWindow: config?.contextWindow ?? undefined,
codexCliPath: config?.codexCliPath?.trim() || undefined,
};
}
@@ -669,6 +690,7 @@ export const useSettingsStore = defineStore("settings", () => {
function isConfigured(): boolean {
const preset = AI_PROVIDER_PRESETS[aiConfig.value.provider];
if (aiConfig.value.provider === "codex-cli") return true;
return !!aiConfig.value.endpoint && !!aiConfig.value.model && (!preset.requiresApiKey || !!aiConfig.value.apiKey);
}
+27
View File
@@ -9,6 +9,7 @@ use tokio::sync::Notify;
use crate::agent_events::{AgentEvent, ToolCall, ToolDefinition, ToolResult};
use crate::agent_tools;
use crate::ai::{self, AiCompletionRequest, AiConfig, AiMessage, AiProvider, AiStreamChunk};
use crate::ai_cli_agent::CliAgentCommandSpec;
use crate::connection::AppState;
use crate::models::connection::DatabaseType;
use crate::token_usage::TokenUsage;
@@ -27,6 +28,7 @@ pub struct AgentLoopContext {
pub connection_id: String,
pub database: String,
pub db_type: DatabaseType,
pub cli_mcp_server_command: Option<CliAgentCommandSpec>,
}
/// Check if the provider supports function calling / tool use.
@@ -59,6 +61,31 @@ pub async fn run_agent_loop(
temperature: Option<f32>,
is_agent_mode: bool,
) -> Result<String, String> {
if matches!(config.provider, AiProvider::CodexCli) {
let connection_name = {
let configs = agent_ctx.state.configs.read().await;
configs
.get(&agent_ctx.connection_id)
.map(|config| config.name.clone())
.unwrap_or_else(|| agent_ctx.connection_id.clone())
};
let prompt = crate::ai_codex_cli::build_codex_prompt(system_prompt, messages);
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,
mcp_server_command: agent_ctx.cli_mcp_server_command.clone(),
},
cancelled,
on_event,
)
.await;
}
// Auto-degrade: providers without function calling fall back to text-only completion.
if !provider_supports_function_calling(config) {
return run_agent_loop_text_only(
+74 -1
View File
@@ -50,6 +50,8 @@ pub enum AiProvider {
Ollama,
#[serde(rename = "openai-compatible")]
OpenaiCompatible,
#[serde(rename = "codex-cli")]
CodexCli,
Custom,
}
@@ -69,6 +71,29 @@ pub enum AiAuthMethod {
Bearer,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum AiReasoningLevel {
#[default]
Default,
Minimal,
Low,
Medium,
High,
}
impl AiReasoningLevel {
pub fn as_codex_effort(&self) -> Option<&'static str> {
match self {
AiReasoningLevel::Default => None,
AiReasoningLevel::Minimal => Some("minimal"),
AiReasoningLevel::Low => Some("low"),
AiReasoningLevel::Medium => Some("medium"),
AiReasoningLevel::High => Some("high"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AiConfig {
@@ -90,7 +115,11 @@ pub struct AiConfig {
#[serde(default = "default_enable_thinking")]
pub enable_thinking: bool,
#[serde(default)]
pub reasoning_level: AiReasoningLevel,
#[serde(default)]
pub context_window: Option<u32>,
#[serde(default)]
pub codex_cli_path: Option<String>,
}
fn default_enable_thinking() -> bool {
@@ -234,6 +263,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
}
match config.provider {
AiProvider::Claude => format!("{ep}/messages"),
AiProvider::CodexCli => unreachable!(),
AiProvider::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@@ -465,6 +495,9 @@ pub fn build_responses_input(system_prompt: &str, messages: &[AiMessage]) -> ser
// ---------------------------------------------------------------------------
fn validate_config(config: &AiConfig) -> Result<(), String> {
if matches!(config.provider, AiProvider::CodexCli) {
return Ok(());
}
if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() {
return Err("API key is required".to_string());
}
@@ -478,6 +511,9 @@ fn validate_config(config: &AiConfig) -> Result<(), String> {
}
fn validate_model_list_config(config: &AiConfig) -> Result<(), String> {
if matches!(config.provider, AiProvider::CodexCli) {
return Ok(());
}
if !matches!(config.provider, AiProvider::Ollama) && config.api_key.trim().is_empty() {
return Err("API key is required".to_string());
}
@@ -611,6 +647,9 @@ async fn list_openai_compatible_models(
}
pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
if matches!(config.provider, AiProvider::CodexCli) {
return crate::ai_codex_cli::list_codex_models(config).await;
}
validate_model_list_config(config)?;
let client = build_ai_http_client(config, 30)?;
@@ -623,6 +662,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
| AiProvider::Ollama
| AiProvider::OpenaiCompatible
| AiProvider::Custom => list_openai_compatible_models(&client, config).await,
AiProvider::CodexCli => unreachable!(),
AiProvider::Gemini => {
Err("Model listing is only supported for OpenAI-compatible and Claude providers".to_string())
}
@@ -844,6 +884,9 @@ fn claude_system_prompt(system_prompt: &str) -> &str {
}
pub async fn test_connection_core(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
if matches!(config.provider, AiProvider::CodexCli) {
return crate::ai_codex_cli::test_codex_connection(config).await;
}
validate_config(config)?;
let client = build_ai_http_client(config, 15)?;
@@ -973,11 +1016,16 @@ fn classify_error(msg: &str) -> &'static str {
pub async fn complete(request: &AiCompletionRequest) -> Result<String, 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());
}
let client = build_ai_http_client(&request.config, 60)?;
match request.config.provider {
AiProvider::Claude => call_claude(&client, request.clone()).await,
AiProvider::Gemini => call_gemini(&client, request.clone()).await,
AiProvider::CodexCli => unreachable!(),
AiProvider::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@@ -1005,12 +1053,17 @@ 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());
}
let stream_timeout = if request.config.enable_thinking { 600 } else { 120 };
let client = build_ai_http_client(&request.config, stream_timeout)?;
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::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@@ -2025,7 +2078,7 @@ mod tests {
build_ai_http_client, claude_headers, claude_system_prompt, gemini_text, is_kimi_model, openai_response_text,
openai_stream_reasoning, openai_stream_text, parse_model_list_response, resolve_endpoint,
resolve_model_list_endpoint, responses_max_output_tokens, responses_text, supports_temperature,
validate_config, AiApiStyle, AiAuthMethod, AiConfig, AiModelInfo, AiProvider, AUTHORIZATION,
validate_config, AiApiStyle, AiAuthMethod, AiConfig, AiModelInfo, AiProvider, AiReasoningLevel, AUTHORIZATION,
CLAUDE_DEFAULT_SYSTEM,
};
@@ -2058,7 +2111,9 @@ mod tests {
proxy_enabled: true,
proxy_url: "not a proxy url".to_string(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
let err = build_ai_http_client(&config, 1).unwrap_err();
@@ -2078,7 +2133,9 @@ mod tests {
proxy_enabled: true,
proxy_url: "127.0.0.1:7890".to_string(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
build_ai_http_client(&config, 1).unwrap();
@@ -2096,7 +2153,9 @@ mod tests {
proxy_enabled: true,
proxy_url: "not a proxy url".to_string(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
build_ai_http_client(&config, 1).unwrap();
@@ -2114,7 +2173,9 @@ mod tests {
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
assert_eq!(
@@ -2132,7 +2193,9 @@ mod tests {
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
@@ -2151,7 +2214,9 @@ mod tests {
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
@@ -2165,7 +2230,9 @@ mod tests {
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
}
@@ -2183,7 +2250,9 @@ mod tests {
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
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");
@@ -2236,7 +2305,9 @@ mod tests {
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
let api_key_headers = claude_headers(&config).unwrap();
@@ -2305,7 +2376,9 @@ mod tests {
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
context_window: None,
codex_cli_path: None,
};
assert!(!supports_temperature(&config));
+457
View File
@@ -0,0 +1,457 @@
use crate::agent_events::AgentEvent;
use crate::ai::{AiMessage, AiModelInfo};
use crate::token_usage::TokenUsage;
use serde_json::Value;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::Notify;
#[derive(Debug, Clone)]
pub struct CliAgentRunOptions {
pub connection_id: String,
pub connection_name: String,
pub database: String,
pub agent_mode: bool,
pub mcp_server_command: Option<CliAgentCommandSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliAgentCommandSpec {
pub program: String,
pub args: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CliAgentJsonlDialect {
CodexExec,
}
pub struct CliAgentProcessSpec {
pub command: CliAgentCommandSpec,
pub dialect: CliAgentJsonlDialect,
pub classify_spawn_error: fn(&str) -> String,
pub classify_run_error: fn(&str) -> String,
}
pub fn toml_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
pub fn toml_string_array(values: &[&str]) -> String {
let items = values.iter().map(|value| toml_string(value)).collect::<Vec<_>>().join(", ");
format!("[{items}]")
}
pub fn dbx_mcp_enabled_tools(agent_mode: bool) -> Vec<&'static str> {
let mut tools = vec!["dbx_list_connections", "dbx_list_tables", "dbx_describe_table", "dbx_get_schema_context"];
if agent_mode {
tools.push("dbx_execute_query");
}
tools
}
pub fn dbx_mcp_scope_env(options: &CliAgentRunOptions) -> Vec<(&'static str, String)> {
vec![
("DBX_MCP_ALLOW_WRITES", "0".to_string()),
("DBX_MCP_SCOPE_CONNECTION_ID", options.connection_id.clone()),
("DBX_MCP_SCOPE_CONNECTION_NAME", options.connection_name.clone()),
("DBX_MCP_SCOPE_DATABASE", options.database.clone()),
]
}
pub fn append_config_overrides(args: &mut Vec<String>, overrides: impl IntoIterator<Item = String>) {
for override_arg in overrides {
args.push("-c".to_string());
args.push(override_arg);
}
}
pub fn build_cli_agent_prompt(provider_label: &str, system_prompt: &str, messages: &[AiMessage]) -> String {
let mut sections = vec![
format!("You are running inside DBX Desktop as the {provider_label} CLI provider."),
"Use the DBX MCP tools when you need live database schema or read-only query results.".to_string(),
"Do not modify files or run shell commands. The DBX MCP server is the only intended tool surface.".to_string(),
String::new(),
"## System instructions".to_string(),
system_prompt.to_string(),
String::new(),
"## Conversation".to_string(),
];
for message in messages {
sections.push(format!("### {}", message.role));
sections.push(message.content.clone());
if !message.tool_calls.is_empty() {
sections.push(format!("[Previous tool calls omitted from CLI replay: {}]", message.tool_calls.len()));
}
sections.push(String::new());
}
sections.join("\n")
}
pub fn model_infos(ids: &[&str]) -> Vec<AiModelInfo> {
ids.iter().map(|id| AiModelInfo { id: (*id).to_string(), display_name: None }).collect()
}
pub async fn list_json_models_or_default(
program: String,
args: impl IntoIterator<Item = String>,
default_models: &[&str],
) -> Result<Vec<AiModelInfo>, String> {
let output = Command::new(program).args(args).output().await;
let Ok(output) = output else {
return Ok(model_infos(default_models));
};
if !output.status.success() {
return Ok(model_infos(default_models));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let Ok(data) = serde_json::from_str::<Value>(&stdout) else {
return Ok(model_infos(default_models));
};
let mut models = Vec::new();
collect_model_ids(&data, &mut models);
models.sort();
models.dedup();
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 }));
Ok(result)
}
}
fn collect_model_ids(value: &Value, out: &mut Vec<String>) {
match value {
Value::Object(map) => {
if let Some(id) = map.get("id").and_then(Value::as_str).or_else(|| map.get("name").and_then(Value::as_str))
{
if !id.trim().is_empty() {
out.push(id.to_string());
}
}
for child in map.values() {
collect_model_ids(child, out);
}
}
Value::Array(items) => {
for item in items {
collect_model_ids(item, out);
}
}
_ => {}
}
}
#[derive(Default)]
struct ParsedCliAgentEvent {
events: Vec<AgentEvent>,
final_text: Option<String>,
error: Option<String>,
}
pub fn parse_cli_jsonl_event(line: &str, dialect: CliAgentJsonlDialect) -> Option<Vec<AgentEvent>> {
let parsed = parse_cli_jsonl_line(line, dialect);
if parsed.events.is_empty() {
None
} else {
Some(parsed.events)
}
}
fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliAgentEvent {
match dialect {
CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line),
}
}
fn parse_codex_jsonl_line(line: &str) -> ParsedCliAgentEvent {
let Ok(value) = serde_json::from_str::<Value>(line) else {
return ParsedCliAgentEvent::default();
};
let event_type = value.get("type").and_then(Value::as_str).unwrap_or_default();
match event_type {
"item.started" => parse_codex_item_started(&value),
"item.completed" => parse_codex_item_completed(&value),
"turn.completed" => parse_codex_turn_completed(&value),
"turn.failed" | "error" => {
let message = codex_error_message(&value);
ParsedCliAgentEvent {
error: Some(message.clone()),
events: vec![AgentEvent::Error { message }],
..Default::default()
}
}
_ => ParsedCliAgentEvent::default(),
}
}
fn parse_codex_item_started(value: &Value) -> ParsedCliAgentEvent {
let item = &value["item"];
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if is_mcp_tool_item(item_type) {
ParsedCliAgentEvent {
events: vec![AgentEvent::ToolCallStart {
tool_call_id: cli_item_id(item),
tool_name: cli_tool_name(item),
args: cli_tool_args(item),
}],
..Default::default()
}
} else {
ParsedCliAgentEvent::default()
}
}
fn parse_codex_item_completed(value: &Value) -> ParsedCliAgentEvent {
let item = &value["item"];
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if item_type == "agent_message" {
if let Some(text) = cli_text(item) {
return ParsedCliAgentEvent {
final_text: Some(text.clone()),
events: vec![AgentEvent::TextDelta { delta: text }],
..Default::default()
};
}
}
if item_type == "reasoning" {
if let Some(text) = cli_text(item) {
return ParsedCliAgentEvent {
events: vec![AgentEvent::ReasoningDelta { delta: text }],
..Default::default()
};
}
}
if is_mcp_tool_item(item_type) {
return ParsedCliAgentEvent {
events: vec![AgentEvent::ToolCallEnd {
tool_call_id: cli_item_id(item),
tool_name: cli_tool_name(item),
result: cli_tool_result(item),
is_error: item.get("status").and_then(Value::as_str).map(|status| status == "failed").unwrap_or(false),
}],
..Default::default()
};
}
ParsedCliAgentEvent::default()
}
fn parse_codex_turn_completed(value: &Value) -> ParsedCliAgentEvent {
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 is_mcp_tool_item(item_type: &str) -> bool {
item_type == "mcp_tool_call" || item_type == "mcp_tool" || item_type == "tool_call"
}
fn cli_item_id(item: &Value) -> String {
item.get("id")
.and_then(Value::as_str)
.or_else(|| item.get("call_id").and_then(Value::as_str))
.unwrap_or("cli-tool-call")
.to_string()
}
fn cli_tool_name(item: &Value) -> String {
item.get("tool_name")
.and_then(Value::as_str)
.or_else(|| item.get("name").and_then(Value::as_str))
.or_else(|| item.get("server_tool_name").and_then(Value::as_str))
.unwrap_or("cli_tool")
.to_string()
}
fn cli_tool_args(item: &Value) -> Value {
item.get("arguments")
.or_else(|| item.get("args"))
.or_else(|| item.get("input"))
.cloned()
.unwrap_or(Value::Object(Default::default()))
}
fn cli_tool_result(item: &Value) -> Value {
item.get("result").or_else(|| item.get("output")).or_else(|| item.get("content")).cloned().unwrap_or(Value::Null)
}
fn cli_text(item: &Value) -> Option<String> {
item.get("text")
.and_then(Value::as_str)
.or_else(|| item.get("message").and_then(Value::as_str))
.or_else(|| item.get("content").and_then(Value::as_str))
.map(ToString::to_string)
}
fn codex_error_message(value: &Value) -> String {
value
.get("message")
.and_then(Value::as_str)
.or_else(|| value.get("error").and_then(Value::as_str))
.or_else(|| value.get("error").and_then(|e| e.get("message")).and_then(Value::as_str))
.unwrap_or("CLI agent failed")
.to_string()
}
pub async fn run_cli_jsonl_agent(
spec: CliAgentProcessSpec,
cancelled: &Notify,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<String, String> {
let mut child = Command::new(&spec.command.program)
.args(&spec.command.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| (spec.classify_spawn_error)(&e.to_string()))?;
let stdout = child.stdout.take().ok_or_else(|| "Failed to capture CLI agent stdout".to_string())?;
let stderr = child.stderr.take().ok_or_else(|| "Failed to capture CLI agent stderr".to_string())?;
let mut lines = BufReader::new(stdout).lines();
let stderr_task = tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
let mut buf = String::new();
let _ = reader.read_to_string(&mut buf).await;
buf
});
let mut final_text = String::new();
let mut saw_agent_end = false;
let mut terminal_error: Option<String> = None;
loop {
tokio::select! {
line = lines.next_line() => {
let line = match line {
Ok(Some(line)) => line,
Ok(None) => break,
Err(e) => {
terminal_error = Some(format!("CLI agent stream read failed: {e}"));
let _ = child.kill().await;
break;
}
};
let parsed = parse_cli_jsonl_line(&line, spec.dialect);
if let Some(text) = parsed.final_text {
final_text.push_str(&text);
}
for event in parsed.events {
if matches!(event, AgentEvent::AgentEnd { .. }) {
saw_agent_end = true;
}
on_event(event);
}
if let Some(error) = parsed.error {
terminal_error = Some(error);
let _ = child.kill().await;
break;
}
}
_ = cancelled.notified() => {
terminal_error = Some("Agent loop cancelled".to_string());
let _ = child.kill().await;
break;
}
}
}
let status = child.wait().await.map_err(|e| format!("CLI agent wait failed: {e}"))?;
let stderr = stderr_task.await.unwrap_or_default();
if let Some(error) = terminal_error {
return Err(error);
}
if !status.success() {
return Err((spec.classify_run_error)(&stderr));
}
if !saw_agent_end {
on_event(AgentEvent::AgentEnd { input_tokens: None, output_tokens: None });
}
Ok(final_text)
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::process::Command as StdCommand;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{sleep, timeout, Duration};
fn classify_spawn_error(message: &str) -> String {
message.to_string()
}
fn classify_run_error(message: &str) -> String {
message.to_string()
}
fn process_is_alive(pid: &str) -> bool {
StdCommand::new("kill")
.args(["-0", pid])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
#[tokio::test]
async fn jsonl_error_kills_and_waits_for_child() {
let pid_file = std::env::temp_dir().join(format!(
"dbx-cli-agent-error-{}-{}",
std::process::id(),
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
));
let script = format!(
"echo $$ > {}; printf '%s\\n' '{{\"type\":\"turn.failed\",\"message\":\"boom\"}}'; exec sleep 30",
pid_file.display()
);
let spec = CliAgentProcessSpec {
command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] },
dialect: CliAgentJsonlDialect::CodexExec,
classify_spawn_error,
classify_run_error,
};
let result = timeout(Duration::from_secs(3), run_cli_jsonl_agent(spec, &Notify::new(), |_| {}))
.await
.expect("runner should return after JSONL error");
assert_eq!(result.unwrap_err(), "boom");
sleep(Duration::from_millis(100)).await;
let pid = std::fs::read_to_string(&pid_file).expect("child pid should be captured");
assert!(!process_is_alive(pid.trim()));
let _ = std::fs::remove_file(pid_file);
}
}
+346
View File
@@ -0,0 +1,346 @@
use crate::agent_events::AgentEvent;
use crate::ai::{AiConfig, AiModelInfo, AiTestConnectionResult};
use crate::ai_cli_agent::{
append_config_overrides, build_cli_agent_prompt, dbx_mcp_enabled_tools, dbx_mcp_scope_env, model_infos,
parse_cli_jsonl_event, run_cli_jsonl_agent, toml_string, toml_string_array, CliAgentCommandSpec,
CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
};
use serde_json::Value;
use std::time::Instant;
use tokio::process::Command;
use tokio::sync::Notify;
const DEFAULT_CODEX_MODELS: &[&str] = &["default", "gpt-5.5", "gpt-5.4-mini"];
pub type CodexRunOptions = CliAgentRunOptions;
pub type CodexCommandSpec = CliAgentCommandSpec;
fn codex_program(config: &AiConfig) -> String {
config.codex_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("codex").to_string()
}
pub fn codex_enabled_tools(agent_mode: bool) -> Vec<&'static str> {
dbx_mcp_enabled_tools(agent_mode)
}
fn codex_mcp_config_overrides(options: &CodexRunOptions) -> Vec<String> {
let mcp_command =
options.mcp_server_command.as_ref().map(|command| command.program.as_str()).unwrap_or("dbx-mcp-server");
let mut overrides = vec![
format!("mcp_servers.dbx.command={}", toml_string(mcp_command)),
"mcp_servers.dbx.required=true".to_string(),
"mcp_servers.dbx.startup_timeout_sec=20".to_string(),
"mcp_servers.dbx.tool_timeout_sec=120".to_string(),
"mcp_servers.dbx.default_tools_approval_mode=\"auto\"".to_string(),
format!("mcp_servers.dbx.enabled_tools={}", toml_string_array(&dbx_mcp_enabled_tools(options.agent_mode))),
];
if let Some(command) = options.mcp_server_command.as_ref().filter(|command| !command.args.is_empty()) {
let args = command.args.iter().map(String::as_str).collect::<Vec<_>>();
overrides.push(format!("mcp_servers.dbx.args={}", toml_string_array(&args)));
}
overrides.extend(
dbx_mcp_scope_env(options)
.into_iter()
.map(|(name, value)| format!("mcp_servers.dbx.env.{name}={}", toml_string(&value))),
);
overrides
}
pub fn build_codex_exec_command(config: &AiConfig, prompt: &str, options: &CodexRunOptions) -> CodexCommandSpec {
let mut args = vec![
"exec".to_string(),
"--json".to_string(),
"--skip-git-repo-check".to_string(),
"--sandbox".to_string(),
"read-only".to_string(),
];
let mut config_overrides = vec!["features.shell_tool=false".to_string(), "web_search=\"disabled\"".to_string()];
if let Some(reasoning_effort) = config.reasoning_level.as_codex_effort() {
config_overrides.push(format!("model_reasoning_effort={}", toml_string(reasoning_effort)));
}
append_config_overrides(&mut args, config_overrides.into_iter().chain(codex_mcp_config_overrides(options)));
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());
}
args.push(prompt.to_string());
CodexCommandSpec { program: codex_program(config), args }
}
pub fn build_codex_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage]) -> String {
build_cli_agent_prompt("Codex", system_prompt, messages)
}
pub async fn list_codex_models(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
let output = Command::new(codex_program(config)).args(["debug", "models"]).output().await;
let Ok(output) = output else {
return Ok(model_infos(DEFAULT_CODEX_MODELS));
};
if !output.status.success() {
return Ok(model_infos(DEFAULT_CODEX_MODELS));
}
Ok(parse_codex_models(&String::from_utf8_lossy(&output.stdout))
.unwrap_or_else(|| model_infos(DEFAULT_CODEX_MODELS)))
}
fn parse_codex_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
let json_start = stdout.find('{')?;
let data = serde_json::from_str::<Value>(&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()) }];
for model in models {
let Some(id) = model
.get("slug")
.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 result.iter().any(|existing| existing.id == id) {
continue;
}
let display_name = model
.get("display_name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string);
result.push(AiModelInfo { id: id.to_string(), display_name });
}
(result.len() > 1).then_some(result)
}
pub async fn test_codex_connection(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
let start = Instant::now();
let mut command = Command::new(codex_program(config));
command.args(["exec", "--json", "--skip-git-repo-check", "--sandbox", "read-only"]);
let model = config.model.trim();
if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
command.args(["--model", model]);
}
let output = command
.arg("Reply with exactly: DBX Codex OK")
.output()
.await
.map_err(|e| classify_codex_spawn_error(&e.to_string()))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("DBX Codex OK") {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"[codexRunFailed] Codex CLI smoke test returned unexpected output. stdout: {stdout} stderr: {stderr}"
));
}
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);
Err(classify_codex_run_error(&stderr))
}
}
fn classify_codex_spawn_error(message: &str) -> String {
if message.contains("No such file") || message.contains("not found") {
"[codexNotInstalled] Codex CLI was not found. Install Codex CLI or set the Codex CLI path in DBX AI settings."
.to_string()
} else {
format!("[codexRunFailed] Failed to start Codex CLI: {message}")
}
}
fn classify_codex_run_error(stderr: &str) -> String {
let lower = stderr.to_ascii_lowercase();
if lower.contains("not authenticated") || lower.contains("login") || lower.contains("auth") {
format!("[codexNotAuthenticated] Codex CLI is not authenticated. Run `codex 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!("[codexMcpStartupFailed] Codex could not start the DBX MCP server. {stderr}")
} else {
format!("[codexRunFailed] Codex CLI failed. {stderr}")
}
}
pub fn parse_codex_jsonl_event(line: &str) -> Option<Vec<AgentEvent>> {
parse_cli_jsonl_event(line, CliAgentJsonlDialect::CodexExec)
}
pub async fn run_codex_agent(
config: &AiConfig,
prompt: &str,
options: CodexRunOptions,
cancelled: &Notify,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<String, String> {
let command = build_codex_exec_command(config, prompt, &options);
run_cli_jsonl_agent(
CliAgentProcessSpec {
command,
dialect: CliAgentJsonlDialect::CodexExec,
classify_spawn_error: classify_codex_spawn_error,
classify_run_error: classify_codex_run_error,
},
cancelled,
on_event,
)
.await
}
#[cfg(test)]
mod tests {
use super::{
build_codex_exec_command, codex_enabled_tools, parse_codex_jsonl_event, parse_codex_models, CodexRunOptions,
DEFAULT_CODEX_MODELS,
};
use crate::agent_events::AgentEvent;
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiProvider, AiReasoningLevel};
use crate::ai_cli_agent::{model_infos, CliAgentCommandSpec};
fn codex_config(model: &str) -> AiConfig {
AiConfig {
provider: AiProvider::CodexCli,
api_key: String::new(),
auth_method: AiAuthMethod::Bearer,
endpoint: String::new(),
model: model.to_string(),
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,
}
}
fn run_options() -> CodexRunOptions {
CodexRunOptions {
connection_id: "conn-1".to_string(),
connection_name: "local".to_string(),
database: "demo".to_string(),
agent_mode: true,
mcp_server_command: None,
}
}
#[test]
fn builds_codex_command_with_scoped_mcp_and_omits_default_model() {
let spec = build_codex_exec_command(&codex_config("default"), "hello", &run_options());
assert_eq!(spec.program, "codex");
assert!(spec.args.contains(&"--json".to_string()));
assert!(!spec.args.contains(&"--model".to_string()));
assert!(!spec.args.contains(&"--ask-for-approval".to_string()));
assert!(spec.args.contains(&"mcp_servers.dbx.command=\"dbx-mcp-server\"".to_string()));
assert!(spec.args.contains(&"mcp_servers.dbx.env.DBX_MCP_ALLOW_WRITES=\"0\"".to_string()));
assert!(spec.args.contains(&"mcp_servers.dbx.env.DBX_MCP_SCOPE_CONNECTION_ID=\"conn-1\"".to_string()));
assert!(spec.args.iter().any(|arg| arg.contains("dbx_execute_query")));
}
#[test]
fn builds_codex_command_with_non_default_model_and_ask_tools() {
let mut options = run_options();
options.agent_mode = false;
let spec = build_codex_exec_command(&codex_config("gpt-5.5"), "hello", &options);
let model_pos = spec.args.iter().position(|arg| arg == "--model").unwrap();
assert_eq!(spec.args[model_pos + 1], "gpt-5.5");
assert!(!codex_enabled_tools(false).contains(&"dbx_execute_query"));
assert!(spec.args.iter().any(|arg| arg.contains("dbx_get_schema_context")));
assert!(!spec.args.iter().any(|arg| arg.contains("dbx_execute_query")));
}
#[test]
fn builds_codex_command_with_resolved_mcp_server_path() {
let mut options = run_options();
options.mcp_server_command = Some(CliAgentCommandSpec {
program: "/opt/dbx/bin/dbx-mcp-server".to_string(),
args: vec!["--stdio".to_string()],
});
let spec = build_codex_exec_command(&codex_config("default"), "hello", &options);
assert!(spec.args.contains(&"mcp_servers.dbx.command=\"/opt/dbx/bin/dbx-mcp-server\"".to_string()));
assert!(spec.args.contains(&"mcp_servers.dbx.args=[\"--stdio\"]".to_string()));
}
#[test]
fn builds_codex_command_with_reasoning_effort_override() {
let mut config = codex_config("default");
config.reasoning_level = AiReasoningLevel::High;
let spec = build_codex_exec_command(&config, "hello", &run_options());
assert!(!spec.args.contains(&"--reasoning-effort".to_string()));
assert!(spec.args.contains(&"model_reasoning_effort=\"high\"".to_string()));
}
#[test]
fn default_model_list_matches_plan() {
assert_eq!(model_infos(DEFAULT_CODEX_MODELS), model_infos(&["default", "gpt-5.5", "gpt-5.4-mini"]));
}
#[test]
fn parses_codex_model_catalog_without_service_tiers() {
let models = parse_codex_models(
r#"{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5","service_tiers":[{"id":"priority","name":"Priority"}]},{"slug":"gpt-5.4-mini","display_name":"GPT-5.4 mini"}]}"#,
)
.unwrap();
assert_eq!(
models.iter().map(|model| model.id.as_str()).collect::<Vec<_>>(),
vec!["default", "gpt-5.5", "gpt-5.4-mini"]
);
assert_eq!(models[1].display_name.as_deref(), Some("GPT-5.5"));
assert!(!models.iter().any(|model| model.id == "priority" || model.id == "Priority"));
}
#[test]
fn parses_codex_jsonl_events() {
assert!(parse_codex_jsonl_event(r#"{"type":"thread.started","thread_id":"t"}"#).is_none());
let started = parse_codex_jsonl_event(
r#"{"type":"item.started","item":{"id":"tool-1","type":"mcp_tool_call","tool_name":"dbx_list_tables","arguments":{"schema":"public"}}}"#,
)
.unwrap();
assert!(matches!(&started[0], AgentEvent::ToolCallStart { tool_name, .. } if tool_name == "dbx_list_tables"));
let completed = parse_codex_jsonl_event(
r#"{"type":"item.completed","item":{"id":"msg-1","type":"agent_message","text":"Done"}}"#,
)
.unwrap();
assert!(matches!(&completed[0], AgentEvent::TextDelta { delta } if delta == "Done"));
let tool_done = parse_codex_jsonl_event(
r#"{"type":"item.completed","item":{"id":"tool-1","type":"mcp_tool_call","tool_name":"dbx_list_tables","result":{"content":"users"}}}"#,
)
.unwrap();
assert!(
matches!(&tool_done[0], AgentEvent::ToolCallEnd { tool_name, is_error, .. } if tool_name == "dbx_list_tables" && !is_error)
);
let turn_done =
parse_codex_jsonl_event(r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3}}"#)
.unwrap();
assert!(matches!(&turn_done[0], AgentEvent::AgentEnd { input_tokens: Some(12), output_tokens: Some(3) }));
let failed = parse_codex_jsonl_event(r#"{"type":"turn.failed","message":"boom"}"#).unwrap();
assert!(matches!(&failed[0], AgentEvent::Error { message } if message == "boom"));
}
}
+2
View File
@@ -8,6 +8,8 @@ pub mod agent_runtime;
pub mod agent_service;
pub mod agent_tools;
pub mod ai;
pub mod ai_cli_agent;
pub mod ai_codex_cli;
pub mod cloud_sync;
pub mod connection;
pub mod connection_secrets;
+17 -1
View File
@@ -8,7 +8,9 @@ use serde::Deserialize;
use dbx_core::agent_events::AgentEvent;
use dbx_core::agent_loop::{run_agent_loop, AgentLoopContext};
use dbx_core::ai::{AiCompletionRequest, AiConfig, AiConversation, AiModelInfo, AiStreamChunk, AiTestConnectionResult};
use dbx_core::ai::{
AiCompletionRequest, AiConfig, AiConversation, AiModelInfo, AiProvider, AiStreamChunk, AiTestConnectionResult,
};
use dbx_core::models::connection::DatabaseType;
use crate::error::AppError;
@@ -79,6 +81,13 @@ fn default_agent_mode() -> String {
"ask".to_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."));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
@@ -87,6 +96,7 @@ pub async fn save_ai_config(
State(state): State<Arc<WebState>>,
Json(body): Json<SaveAiConfigRequest>,
) -> Result<Json<()>, AppError> {
reject_web_unsupported_ai_provider(&body.config)?;
state.app.storage.save_ai_config(&body.config).await.map_err(AppError)?;
Ok(Json(()))
}
@@ -126,6 +136,7 @@ pub async fn delete_ai_conversation(
// ---------------------------------------------------------------------------
pub async fn ai_complete(Json(body): Json<AiCompleteRequest>) -> Result<Json<String>, AppError> {
reject_web_unsupported_ai_provider(&body.request.config)?;
let result = dbx_core::ai::complete(&body.request).await.map_err(AppError)?;
Ok(Json(result))
}
@@ -137,11 +148,13 @@ pub async fn ai_complete(Json(body): Json<AiCompleteRequest>) -> Result<Json<Str
pub async fn ai_test_connection(
Json(body): Json<AiTestConnectionRequest>,
) -> Result<Json<AiTestConnectionResult>, AppError> {
reject_web_unsupported_ai_provider(&body.config)?;
let result = dbx_core::ai::test_connection_core(&body.config).await.map_err(AppError)?;
Ok(Json(result))
}
pub async fn ai_list_models(Json(body): Json<AiListModelsRequest>) -> Result<Json<Vec<AiModelInfo>>, AppError> {
reject_web_unsupported_ai_provider(&body.config)?;
let result = dbx_core::ai::list_models_core(&body.config).await.map_err(AppError)?;
Ok(Json(result))
}
@@ -164,6 +177,7 @@ pub async fn ai_stream(
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
let session_id = body.session_id;
let request = body.request;
reject_web_unsupported_ai_provider(&request.config)?;
let cancelled = dbx_core::ai::register_stream(&session_id).await;
let (tx, rx) = tokio::sync::broadcast::channel::<String>(256);
@@ -205,6 +219,7 @@ pub async fn ai_agent_stream(
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
let session_id = body.session_id;
let request = body.request;
reject_web_unsupported_ai_provider(&request.config)?;
let cancelled = dbx_core::ai::register_stream(&session_id).await;
let (tx, rx) = tokio::sync::broadcast::channel::<String>(256);
@@ -217,6 +232,7 @@ pub async fn ai_agent_stream(
connection_id: body.connection_id,
database: body.database,
db_type: parsed_db_type,
cli_mcp_server_command: None,
};
let sid = session_id.clone();
+128 -75
View File
@@ -49,15 +49,65 @@ export const DBX_CONNECTION_TYPE_DESCRIPTION =
"Database type: postgres, mysql, sqlite, rqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, etcd, doris, starrocks, manticoresearch, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, questdb, gbase, h2, snowflake, trino, hive, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, iotdb, xugu, jdbc, access, mq";
const FILE_CAPABLE_CONNECTION_TYPES = new Set(["sqlite", "duckdb", "access", "h2"]);
interface McpScope {
connectionId?: string;
connectionName?: string;
database?: string;
}
function scopedValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function mcpScopeFromEnv(): McpScope {
return {
connectionId: scopedValue(process.env.DBX_MCP_SCOPE_CONNECTION_ID),
connectionName: scopedValue(process.env.DBX_MCP_SCOPE_CONNECTION_NAME),
database: scopedValue(process.env.DBX_MCP_SCOPE_DATABASE),
};
}
function scopeEnabled(scope: McpScope): boolean {
return !!(scope.connectionId || scope.connectionName);
}
function connectionMatchesScope(config: ConnectionConfig, scope: McpScope): boolean {
return (!!scope.connectionId && config.id === scope.connectionId) || (!!scope.connectionName && config.name === scope.connectionName);
}
async function loadScopedConnections(backend: Backend, scope: McpScope): Promise<ConnectionConfig[]> {
const connections = await backend.loadConnections();
if (!scopeEnabled(scope)) return connections;
return connections.filter((config) => connectionMatchesScope(config, scope));
}
async function resolveConnection(backend: Backend, scope: McpScope, requestedName?: string): Promise<{ config?: ConnectionConfig; error?: ReturnType<typeof toolError> }> {
if (!scopeEnabled(scope)) {
if (!requestedName?.trim()) return { error: toolError("CONNECTION_NOT_FOUND", "Connection name is required.") };
const config = await backend.findConnection(requestedName);
return config ? { config } : { error: toolError("CONNECTION_NOT_FOUND", `Connection "${requestedName}" not found.`) };
}
const [scopedConfig] = await loadScopedConnections(backend, scope);
if (!scopedConfig) return { error: toolError("CONNECTION_NOT_FOUND", "Scoped DBX connection was not found.") };
if (requestedName?.trim() && requestedName !== scopedConfig.name && requestedName !== scopedConfig.id) {
return { error: toolError("CONNECTION_OUT_OF_SCOPE", `Connection "${requestedName}" is outside this DBX AI session scope.`) };
}
return { config: scopedConfig };
}
export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer {
const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL;
const scope = mcpScopeFromEnv();
const scoped = scopeEnabled(scope);
const server = new McpServer({
name: "dbx",
version: DBX_MCP_PACKAGE_VERSION,
});
server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => {
const connections = await backend.loadConnections();
const connections = await loadScopedConnections(backend, scope);
if (connections.length === 0) return text("No connections configured in DBX.");
const rows = connections.map((c) => [c.name, c.db_type, c.host, String(c.port), c.database || ""]);
return text(mdTable(["Name", "Type", "Host", "Port", "Database"], rows));
@@ -67,14 +117,14 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
"dbx_list_tables",
"List tables and views for a database connection",
{
connection_name: z.string().describe("Name of the DBX connection"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
database: z.string().optional().describe("Database name"),
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"),
},
async ({ connection_name, database, schema }) => {
const config = await backend.findConnection(connection_name);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
const tables = await backend.listTables(withDatabase(config, database), schema);
const { config, error } = await resolveConnection(backend, scope, connection_name);
if (error) return error;
const tables = await backend.listTables(withDatabase(config!, database ?? scope.database), schema);
if (tables.length === 0) return text("No tables found.");
const rows = tables.map((t) => [t.name, t.type]);
return text(mdTable(["Table", "Type"], rows));
@@ -85,15 +135,15 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
"dbx_describe_table",
"Get column definitions for a table",
{
connection_name: z.string().describe("Name of the DBX connection"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
table: z.string().describe("Table name"),
database: z.string().optional().describe("Database name"),
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"),
},
async ({ connection_name, table, database, schema }) => {
const config = await backend.findConnection(connection_name);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
const columns = await backend.describeTable(withDatabase(config, database), table, schema);
const { config, error } = await resolveConnection(backend, scope, connection_name);
if (error) return error;
const columns = await backend.describeTable(withDatabase(config!, database ?? scope.database), table, schema);
if (columns.length === 0) return text("No columns found.");
const rows = columns.map((c) => [c.is_primary_key ? `${c.name} (PK)` : c.name, c.data_type, c.is_nullable ? "YES" : "NO", c.column_default ?? "", c.comment ?? ""]);
return text(mdTable(["Column", "Type", "Nullable", "Default", "Comment"], rows));
@@ -104,24 +154,25 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
"dbx_execute_query",
"Execute a SQL query on a database connection (max 100 rows returned)",
{
connection_name: z.string().describe("Name of the DBX connection"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
database: z.string().optional().describe("Database name"),
sql: z.string().describe("SQL query to execute"),
},
async ({ connection_name, database, sql }) => {
const config = await backend.findConnection(connection_name);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
if (config.db_type !== "mongodb") {
const { config, error } = await resolveConnection(backend, scope, connection_name);
if (error) return error;
const scopedConfig = config!;
if (scopedConfig.db_type !== "mongodb") {
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true });
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
}
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
// (node-core executeQuery) applies command-aware read/write gating.
try {
const statements = config.db_type === "mongodb" ? [sql] : splitSqlStatements(sql);
const statements = scopedConfig.db_type === "mongodb" ? [sql] : splitSqlStatements(sql);
const results = [];
for (const statement of statements) {
results.push(await backend.executeQuery(withDatabase(config, database), statement));
results.push(await backend.executeQuery(withDatabase(scopedConfig, database ?? scope.database), statement));
}
if (results.length === 1) return formatQueryToolResult(results[0]);
return text(results.map((result, index) => formatQueryToolResult(result, `Statement ${index + 1}`).content[0].text).join("\n\n"));
@@ -136,16 +187,16 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
"dbx_get_schema_context",
"Get compact table and column context for writing SQL",
{
connection_name: z.string().describe("Name of the DBX connection"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
database: z.string().optional().describe("Database name"),
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"),
tables: z.array(z.string()).optional().describe("Specific table names to include"),
max_tables: z.number().int().min(1).max(20).default(8).describe("Maximum number of tables to include"),
},
async ({ connection_name, database, schema, tables, max_tables }) => {
const config = await backend.findConnection(connection_name);
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
const context = await buildSchemaContext(backend, withDatabase(config, database), {
const { config, error } = await resolveConnection(backend, scope, connection_name);
if (error) return error;
const context = await buildSchemaContext(backend, withDatabase(config!, database ?? scope.database), {
schema,
tables,
maxTables: max_tables,
@@ -155,65 +206,67 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
},
);
server.tool(
"dbx_add_connection",
"Add a new database connection to DBX",
{
name: z.string().describe("Connection name"),
db_type: z.string().describe(DBX_CONNECTION_TYPE_DESCRIPTION),
host: z.string().describe("Database host"),
port: z.number().optional().describe("Database port (TDengine defaults to 6041, IoTDB defaults to 6667, XuguDB defaults to 5138)"),
username: z.string().default("").describe("Username"),
password: z.string().default("").describe("Password"),
database: z.string().optional().describe("Default database name"),
ssl: z.boolean().default(false).describe("Enable SSL"),
driver_profile: z.string().optional().describe("Driver profile (e.g. 'gbase8a', 'gbase8s')"),
},
async ({ name, db_type, host, port, username, password, database, ssl, driver_profile }) => {
const existing = await backend.findConnection(name);
if (existing) return text(`Connection "${name}" already exists.`);
const DEFAULT_PORTS: Record<string, number> = {
kwdb: 26257,
rqlite: 4001,
tdengine: 6041,
iotdb: 6667,
xugu: 5138,
};
const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_CAPABLE_CONNECTION_TYPES.has(db_type) ? 0 : undefined);
if (resolvedPort === undefined) return text("Port is required for this database type.");
const config = await backend.addConnection({
name,
db_type,
host,
port: resolvedPort,
username,
password,
database,
ssl,
driver_profile,
ssh_enabled: false,
} as Omit<ConnectionConfig, "id">);
await notifyReload();
return text(`Connection "${config.name}" added (id: ${config.id}).`);
},
);
if (!scoped) {
server.tool(
"dbx_add_connection",
"Add a new database connection to DBX",
{
name: z.string().describe("Connection name"),
db_type: z.string().describe(DBX_CONNECTION_TYPE_DESCRIPTION),
host: z.string().describe("Database host"),
port: z.number().optional().describe("Database port (TDengine defaults to 6041, IoTDB defaults to 6667, XuguDB defaults to 5138)"),
username: z.string().default("").describe("Username"),
password: z.string().default("").describe("Password"),
database: z.string().optional().describe("Default database name"),
ssl: z.boolean().default(false).describe("Enable SSL"),
driver_profile: z.string().optional().describe("Driver profile (e.g. 'gbase8a', 'gbase8s')"),
},
async ({ name, db_type, host, port, username, password, database, ssl, driver_profile }) => {
const existing = await backend.findConnection(name);
if (existing) return text(`Connection "${name}" already exists.`);
const DEFAULT_PORTS: Record<string, number> = {
kwdb: 26257,
rqlite: 4001,
tdengine: 6041,
iotdb: 6667,
xugu: 5138,
};
const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_CAPABLE_CONNECTION_TYPES.has(db_type) ? 0 : undefined);
if (resolvedPort === undefined) return text("Port is required for this database type.");
const config = await backend.addConnection({
name,
db_type,
host,
port: resolvedPort,
username,
password,
database,
ssl,
driver_profile,
ssh_enabled: false,
} as Omit<ConnectionConfig, "id">);
await notifyReload();
return text(`Connection "${config.name}" added (id: ${config.id}).`);
},
);
server.tool(
"dbx_remove_connection",
"Remove a database connection from DBX",
{
connection_name: z.string().describe("Name of the connection to remove"),
},
async ({ connection_name }) => {
const removed = await backend.removeConnection(connection_name);
if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
await notifyReload();
return text(`Connection "${connection_name}" removed.`);
},
);
server.tool(
"dbx_remove_connection",
"Remove a database connection from DBX",
{
connection_name: z.string().describe("Name of the connection to remove"),
},
async ({ connection_name }) => {
const removed = await backend.removeConnection(connection_name);
if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
await notifyReload();
return text(`Connection "${connection_name}" removed.`);
},
);
}
// Desktop-only tools: open table and execute-and-show require the Tauri bridge
if (!isWebMode) {
if (!isWebMode && !scoped) {
server.tool(
"dbx_open_table",
"Open a table in DBX desktop app UI. Requires DBX to be running.",
+85
View File
@@ -29,6 +29,22 @@ const backend: Backend = {
executeQuery: async () => ({ columns: ["total"], rows: [{ total: 1 }], row_count: 1 }),
};
async function withScopedEnv<T>(env: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
const oldValues = new Map<string, string | undefined>();
for (const key of Object.keys(env)) {
oldValues.set(key, process.env[key]);
process.env[key] = env[key];
}
try {
return await fn();
} finally {
for (const [key, value] of oldValues) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
test("creates an MCP server without starting stdio transport", () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
@@ -107,6 +123,75 @@ test("execute query reports the blocked statement number for unsafe multi-statem
assert.match(result.content[0].text, /WHERE/);
});
test("scoped MCP lists only the active connection", async () => {
const other: ConnectionConfig = { ...connection, id: "2", name: "other", database: "other_db" };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connection, other],
};
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
return (server as any)._registeredTools.dbx_list_connections.handler({});
});
assert.match(result.content[0].text, /local/);
assert.doesNotMatch(result.content[0].text, /other/);
});
test("scoped MCP rejects out-of-scope connection tool calls", async () => {
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
return (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "other" });
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /CONNECTION_OUT_OF_SCOPE:/);
});
test("scoped MCP defaults connection-taking tools to the active connection and database", async () => {
let usedDatabase = "";
const scopedBackend: Backend = {
...backend,
listTables: async (config) => {
usedDatabase = config.database || "";
return [{ name: "users", type: "BASE TABLE" }];
},
};
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_SCOPE_DATABASE: "scoped_db" }, () => {
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
return (server as any)._registeredTools.dbx_list_tables.handler({});
});
assert.match(result.content[0].text, /users/);
assert.equal(usedDatabase, "scoped_db");
});
test("scoped MCP does not register mutation or desktop bridge tools", async () => {
await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
const server = createDbxMcpServer(backend, { isWebMode: false });
const tools = (server as any)._registeredTools;
assert.equal(tools.dbx_add_connection, undefined);
assert.equal(tools.dbx_remove_connection, undefined);
assert.equal(tools.dbx_open_table, undefined);
assert.equal(tools.dbx_execute_and_show, undefined);
});
});
test("scoped MCP with writes disabled blocks write SQL", async () => {
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_ALLOW_WRITES: "0" }, () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
return (server as any)._registeredTools.dbx_execute_query.handler({
sql: "update users set name = 'x' where id = 1",
});
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /SQL_BLOCKED:/);
});
test("mongodb list tables returns collections from the selected database", async () => {
let usedDatabase = "";
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb", database: "admin" };
+1
View File
@@ -6,6 +6,7 @@
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "src",
"declaration": true
+13 -1
View File
@@ -44,6 +44,7 @@ pub async fn ai_stream(app: AppHandle, session_id: String, request: AiCompletion
use dbx_core::agent_events::AgentEvent;
use dbx_core::agent_loop::{run_agent_loop, AgentLoopContext};
use dbx_core::ai_cli_agent::CliAgentCommandSpec;
use dbx_core::models::connection::DatabaseType;
#[tauri::command]
@@ -68,7 +69,18 @@ pub async fn ai_agent_stream(
let parsed_db_type: DatabaseType =
serde_json::from_str(&format!("\"{}\"", db_type)).map_err(|_| format!("Unknown database type: {db_type}"))?;
let agent_ctx = AgentLoopContext { state: state.inner().clone(), connection_id, database, db_type: parsed_db_type };
let cli_mcp_server_command = if matches!(request.config.provider, AiProvider::CodexCli) {
super::mcp::resolve_mcp_server_command().map(|(program, args)| CliAgentCommandSpec { program, args })
} else {
None
};
let agent_ctx = AgentLoopContext {
state: state.inner().clone(),
connection_id,
database,
db_type: parsed_db_type,
cli_mcp_server_command,
};
let is_agent_mode = mode.as_deref() == Some("agent");
let result = run_agent_loop(
+12
View File
@@ -114,6 +114,12 @@ fn installed_mcp_version() -> Option<String> {
value.get("version")?.as_str().map(ToOwned::to_owned)
}
pub(crate) fn resolve_mcp_server_command() -> Option<(String, Vec<String>)> {
locate_mcp_bin()
.map(|path| (path, Vec::new()))
.or_else(|| installed_mcp_bin_script().map(|script| ("node".to_string(), vec![script])))
}
fn locate_mcp_bin() -> Option<String> {
#[cfg(windows)]
{
@@ -125,6 +131,12 @@ fn locate_mcp_bin() -> Option<String> {
}
}
fn installed_mcp_bin_script() -> Option<String> {
let root = command_stdout("npm", &["root", "-g"]).ok()?;
let script = Path::new(root.trim()).join(MCP_PACKAGE_NAME).join("dist").join("index.js");
script.is_file().then(|| script.to_string_lossy().to_string())
}
#[cfg(windows)]
fn locate_windows_command(command: &str) -> Option<String> {
command_stdout("where", &[command]).ok().and_then(first_non_empty_line).or_else(|| {