fix(cli-tools): preserve opencode config and raw key copy (#1626)

- Use jsonc-parser to update only provider.omniroute in opencode.json,
  preserving MCP servers, comments, and other provider entries
- Add /api/cli-tools/keys endpoint returning raw keys for CLI tools UI
  (session-auth protected via requireCliToolsAuth)
- Fix OpenCode guide step 3 to use ICU-style {baseUrl} placeholder
  instead of broken {{baseUrl}} across all 40+ locales
- Restore valid OpenCode light/dark SVG logos (were broken HTML downloads)
- Add customCliTab translation key to en.json
- Add modelLabels support for human-readable model names in config
- Fix 9 additional locale files (bn, fa, gu, in, mr, sw, ta, te, ur)
  that were added after the original PR and still had double-braces

Co-authored-by: JasonLandbridge <JasonLandbridge@users.noreply.github.com>
This commit is contained in:
diegosouzapw
2026-04-26 14:26:34 -03:00
parent accb58f5b2
commit dd67b25df1
56 changed files with 522 additions and 98 deletions

7
package-lock.json generated
View File

@@ -28,6 +28,7 @@
"https-proxy-agent": "^9.0.0",
"jose": "^6.1.3",
"js-yaml": "^4.1.0",
"jsonc-parser": "^3.3.1",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
@@ -9164,6 +9165,12 @@
"node": ">=6"
}
},
"node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"license": "MIT"
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",

View File

@@ -121,6 +121,7 @@
"https-proxy-agent": "^9.0.0",
"jose": "^6.1.3",
"js-yaml": "^4.1.0",
"jsonc-parser": "^3.3.1",
"lowdb": "^7.0.1",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",

View File

@@ -0,0 +1 @@
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86283)'><mask id='mask0_1401_86283' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86283)'><path d='M180 240H60V120H180V240Z' fill='#4B4646'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#F1ECEC'/></g></g><defs><clipPath id='clip0_1401_86283'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 577 B

View File

@@ -0,0 +1 @@
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 577 B

View File

@@ -90,7 +90,7 @@ export default function CLIToolsPageClient({ machineId: _machineId }) {
const fetchApiKeys = async () => {
try {
const res = await fetch("/api/keys");
const res = await fetch("/api/cli-tools/keys");
if (res.ok) {
const data = await res.json();
setApiKeys(data.keys || []);

View File

@@ -6,6 +6,7 @@ import Image from "next/image";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig";
import { useTheme } from "@/shared/hooks/useTheme";
export default function DefaultToolCard({
toolId,
@@ -37,6 +38,7 @@ export default function DefaultToolCard({
const [message, setMessage] = useState(null);
const [saving, setSaving] = useState(false);
const runtimeFetchStartedRef = useRef(false);
const { isDark } = useTheme();
// (#523) Initialize state with key *id* instead of masked key string
const [selectedApiKeyId, setSelectedApiKeyId] = useState(() =>
@@ -44,6 +46,54 @@ export default function DefaultToolCard({
);
const isMultiModelTool = tool.modelSelectionMode === "multiple";
const usesOpenCodePreview = tool.previewConfigMode === "opencode";
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
const resolveApiKeyValue = useCallback(
() => selectedKeyObj?.rawKey || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder")),
[cloudEnabled, selectedKeyObj?.rawKey, t]
);
const getSelectedModelEntries = useCallback(() => {
const selectedValues = isMultiModelTool
? modelValues.length > 0
? modelValues
: modelValue
? [modelValue]
: []
: modelValue
? [modelValue]
: [];
const availableModels = Array.isArray(activeProviders)
? activeProviders.flatMap((provider) => provider?.models || [])
: [];
const modelMap = new Map(
availableModels.filter((model) => model?.value).map((model) => [model.value, model])
);
return selectedValues.map((value) => {
const matched = modelMap.get(value);
return {
value,
label: matched?.name || matched?.label || value,
};
});
}, [activeProviders, isMultiModelTool, modelValue, modelValues]);
const getSelectedModelLabels = useCallback(
() => getSelectedModelEntries().map((entry) => entry.label),
[getSelectedModelEntries]
);
const getSelectedModelLabelMap = useCallback(
() => Object.fromEntries(getSelectedModelEntries().map((entry) => [entry.value, entry.label])),
[getSelectedModelEntries]
);
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
? normalizedBaseUrl
: `${normalizedBaseUrl}/v1`;
// Persist and restore model selection per tool via localStorage
useEffect(() => {
@@ -75,7 +125,9 @@ export default function DefaultToolCard({
const prefix = savedKey.slice(0, 8);
const suffix = savedKey.slice(-4);
const matchedKey = apiKeys.find(
(k) => k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix)
(k) =>
(k.rawKey && k.rawKey.startsWith(prefix) && k.rawKey.endsWith(suffix)) ||
(k.key && k.key.startsWith(prefix) && k.key.endsWith(suffix))
);
if (matchedKey) setSelectedApiKeyId(matchedKey.id);
}
@@ -134,23 +186,14 @@ export default function DefaultToolCard({
const replaceVars = useCallback(
(text) => {
// (#523) Look up the key object by id to get the masked display value.
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
let keyToUse =
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder"));
if (keyToUse.includes("***")) keyToUse = "<YOUR_API_KEY>";
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
? normalizedBaseUrl
: `${normalizedBaseUrl}/v1`;
const keyToUse = resolveApiKeyValue();
return text
.replace(/\{\{baseUrl\}\}/g, baseUrlWithV1)
.replace(/\{\{apiKey\}\}/g, keyToUse)
.replace(/\{\{model\}\}/g, modelValue || t("modelPlaceholder"));
.replace(/\{\{model\}\}/g, getSelectedModelLabels()[0] || t("modelPlaceholder"));
},
[apiKeys, baseUrl, cloudEnabled, modelValue, selectedApiKeyId, t]
[baseUrl, getSelectedModelLabels, resolveApiKeyValue, t]
);
const handleCopy = async (text, field) => {
@@ -168,33 +211,24 @@ export default function DefaultToolCard({
if (!tool.codeBlock?.code) return "";
if (!usesOpenCodePreview) return replaceVars(tool.codeBlock.code);
const selectedKeyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
let keyToUse =
selectedKeyObj?.key || (!cloudEnabled ? "sk_omniroute" : t("yourApiKeyPlaceholder"));
if (keyToUse.includes("***")) keyToUse = "<YOUR_API_KEY>";
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
? normalizedBaseUrl
: `${normalizedBaseUrl}/v1`;
const keyToUse = resolveApiKeyValue();
return JSON.stringify(
buildOpenCodeConfigDocument({
baseUrl: baseUrlWithV1,
apiKey: keyToUse,
models: getSelectedModels(),
model: getSelectedModels()[0],
modelLabels: getSelectedModelLabelMap(),
}),
null,
2
);
}, [
apiKeys,
baseUrl,
cloudEnabled,
getSelectedModels,
getSelectedModelLabelMap,
replaceVars,
selectedApiKeyId,
t,
resolveApiKeyValue,
tool.codeBlock?.code,
usesOpenCodePreview,
]);
@@ -229,11 +263,6 @@ export default function DefaultToolCard({
// (#523) Prefer keyId lookup so the backend writes the real key to disk.
const selectedKeyId = selectedApiKeyId?.trim() || null;
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
? normalizedBaseUrl
: `${normalizedBaseUrl}/v1`;
const res = await fetch(`/api/cli-tools/guide-settings/${toolId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -243,6 +272,7 @@ export default function DefaultToolCard({
keyId: selectedKeyId,
model: modelValue,
models: isMultiModelTool ? getSelectedModels() : undefined,
modelLabels: getSelectedModelLabelMap(),
}),
});
const data = await res.json();
@@ -284,9 +314,7 @@ export default function DefaultToolCard({
</select>
<button
onClick={() => {
// (#523) Look up the masked key by id for clipboard copy
const keyObj = apiKeys?.find((k) => k.id === selectedApiKeyId);
handleCopy(keyObj?.key || selectedApiKeyId, "apiKey");
handleCopy(resolveApiKeyValue(), "apiKey");
}}
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
>
@@ -305,7 +333,9 @@ export default function DefaultToolCard({
};
const renderModelSelector = () => {
const displayValue = isMultiModelTool ? getSelectedModels().join(", ") : modelValue;
const displayValue = isMultiModelTool
? getSelectedModelLabels().join(", ")
: getSelectedModelLabels()[0] || "";
return (
<div className="mt-2 flex items-center gap-2">
@@ -484,7 +514,9 @@ export default function DefaultToolCard({
</p>
{item.desc && (
<p className="text-sm text-text-muted mt-0.5">
{translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc)}
{translateOrFallback(`guides.${toolId}.steps.${item.step}.desc`, item.desc, {
baseUrl: baseUrlWithV1,
})}
</p>
)}
{item.type === "apiKeySelector" && renderApiKeySelector()}
@@ -601,6 +633,24 @@ export default function DefaultToolCard({
/>
);
}
if (tool.imageLight || tool.imageDark) {
const themedSrc = isDark
? tool.imageDark || tool.imageLight
: tool.imageLight || tool.imageDark;
return (
<Image
src={themedSrc}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
);
}
if (tool.icon) {
return (
<span className="material-symbols-outlined text-xl" style={{ color: tool.color }}>

View File

@@ -5,7 +5,7 @@ import os from "os";
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { getOpenCodeConfigPath } from "@/shared/services/cliRuntime";
import { mergeOpenCodeConfig } from "@/shared/services/opencodeConfig";
import { mergeOpenCodeConfigText } from "@/shared/services/opencodeConfig";
import { guideSettingsSaveSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
@@ -40,7 +40,7 @@ export async function POST(request, { params }) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { baseUrl, model, models } = validation.data;
const { baseUrl, model, models, modelLabels } = validation.data;
// (#523) Extract keyId BEFORE validation — Zod strips unknown fields!
const apiKeyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
const apiKey = await resolveApiKey(apiKeyId, validation.data.apiKey);
@@ -52,7 +52,7 @@ export async function POST(request, { params }) {
case "opencode":
// (#524) OpenCode config was never saved because only 'continue' was handled here.
// OpenCode reads ~/.config/opencode/opencode.json — write the OmniRoute settings there.
return await saveOpenCodeConfig({ baseUrl, apiKey, model, models });
return await saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels });
case "qwen":
return await saveQwenConfig({ baseUrl, apiKey, model });
default:
@@ -149,7 +149,7 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
*
* (#524) OpenCode was silently failing because this handler was missing.
*/
async function saveOpenCodeConfig({ baseUrl, apiKey, model, models }) {
async function saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels }) {
const configPath = getOpenCodeConfigPath();
const configDir = path.dirname(configPath);
@@ -160,23 +160,23 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model, models }) {
.trim()
.replace(/\/+$/, "");
// Read existing JSON to preserve other provider entries
let existingConfig: Record<string, any> = {};
// Read existing JSONC/JSON text to preserve unrelated config formatting and fields.
let existingConfigText = "";
try {
const raw = await fs.readFile(configPath, "utf-8");
existingConfig = JSON.parse(raw);
existingConfigText = await fs.readFile(configPath, "utf-8");
} catch {
// File doesn't exist or invalid JSON — start fresh
// File doesn't exist — start fresh
}
const nextConfig = mergeOpenCodeConfig(existingConfig, {
const nextConfigText = mergeOpenCodeConfigText(existingConfigText, {
baseUrl: normalizedBaseUrl,
apiKey,
model,
models,
modelLabels,
});
await fs.writeFile(configPath, JSON.stringify(nextConfig, null, 2), "utf-8");
await fs.writeFile(configPath, nextConfigText, "utf-8");
return NextResponse.json({
success: true,

View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
import { getApiKeys } from "@/lib/localDb";
import { maskStoredApiKey } from "@/lib/apiKeyExposure";
// GET /api/cli-tools/keys - List API keys with raw values for authenticated CLI tools UI only
export async function GET(request: Request) {
const authError = await requireCliToolsAuth(request);
if (authError) return authError;
try {
const keys = await getApiKeys();
const cliToolKeys = keys.map((key) => ({
...key,
rawKey: key.key,
key: maskStoredApiKey(key.key),
}));
return NextResponse.json({ keys: cliToolKeys, total: cliToolKeys.length });
} catch (error) {
console.log("Error fetching CLI tool keys:", error);
return NextResponse.json({ error: "Failed to fetch CLI tool keys" }, { status: 500 });
}
}

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Nastavit základní URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Vybrat model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"
@@ -1339,6 +1339,7 @@
"allToolsTab": "All Tools",
"guidedClientsTab": "Guided Clients",
"mitmClientsTab": "MITM Clients",
"customCliTab": "Custom CLI",
"toolCategories": "Tool Categories",
"visibleToolsCount": "{count} tools available"
},

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1293,7 +1293,7 @@
},
"3": {
"title": "Definir URL base",
"desc": "conjunto de configuração opencode baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Selecione o modelo"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Задайте базовый URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Выберите модель"
@@ -1330,7 +1330,7 @@
"guides.opencode.steps.1.desc": "Установите через npm: npm install -g opencode-ai",
"guides.opencode.steps.2.title": "API-ключ",
"guides.opencode.steps.3.title": "Задайте базовый URL",
"guides.opencode.steps.3.desc": "opencode config set baseUrl {{baseUrl}}",
"guides.opencode.steps.3.desc": "opencode config set baseUrl {baseUrl}",
"guides.opencode.steps.4.title": "Выберите модель",
"guides.opencode.steps.5.title": "Используйте вариант мышления",
"guides.opencode.steps.5.desc": "Для thinking-моделей запускайте с --variant high/low/max (пример команды ниже).",

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Temel URL'yi Ayarla",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Modeli Seçin"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1265,7 +1265,7 @@
},
"3": {
"title": "Set Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "Select Model"

View File

@@ -1273,7 +1273,7 @@
},
"3": {
"title": "设置 Base URL",
"desc": "opencode config set baseUrl {{baseUrl}}"
"desc": "opencode config set baseUrl {baseUrl}"
},
"4": {
"title": "选择模型"

View File

@@ -242,7 +242,8 @@ export const CLI_TOOLS = {
opencode: {
id: "opencode",
name: "OpenCode",
image: "/providers/opencode.svg",
imageLight: "/providers/opencode-light.svg",
imageDark: "/providers/opencode-dark.svg",
icon: "terminal",
color: "#FF6B35",
description: "OpenCode AI coding agent (Terminal)",

View File

@@ -1,8 +1,11 @@
import { applyEdits, modify, parse } from "jsonc-parser";
type OpenCodeConfigInput = {
baseUrl?: string;
apiKey?: string;
model?: string;
models?: string[];
modelLabels?: Record<string, string>;
};
const OPENCODE_DEFAULT_MODELS = [
@@ -22,17 +25,32 @@ const normalizeModels = (models: unknown): string[] => {
return [...new Set(models.map((model) => normalizeValue(model)).filter(Boolean))];
};
const normalizeModelLabels = (labels: unknown): Record<string, string> => {
if (!labels || typeof labels !== "object" || Array.isArray(labels)) return {};
return Object.fromEntries(
Object.entries(labels)
.map(([key, value]) => [normalizeValue(key), String(value || "").trim()])
.filter(([key, value]) => key && value)
);
};
const getModelEntryName = (modelId: string, labels: Record<string, string>) =>
labels[modelId] || modelId;
export const buildOpenCodeProviderConfig = ({
baseUrl,
apiKey,
model,
models,
modelLabels,
}: OpenCodeConfigInput): Record<string, any> => {
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
const normalizedModel = normalizeValue(model);
const normalizedModels = normalizeModels(models);
const normalizedLabels = normalizeModelLabels(modelLabels);
const uniqueModels =
normalizedModels.length > 0
@@ -42,7 +60,7 @@ export const buildOpenCodeProviderConfig = ({
const modelsRecord: Record<string, { name: string }> = {};
for (const m of uniqueModels) {
if (m) {
modelsRecord[m] = { name: m };
modelsRecord[m] = { name: getModelEntryName(m, normalizedLabels) };
}
}
@@ -82,3 +100,41 @@ export const mergeOpenCodeConfig = (
},
};
};
export const mergeOpenCodeConfigText = (
existingText: string | null | undefined,
input: OpenCodeConfigInput
) => {
const providerConfig = buildOpenCodeProviderConfig(input);
const content = typeof existingText === "string" ? existingText : "";
const trimmedContent = content.trim();
if (!trimmedContent) {
return JSON.stringify(buildOpenCodeConfigDocument(input), null, 2);
}
const errors: { error: number }[] = [];
const parsed = parse(content, errors, { allowTrailingComma: true, disallowComments: false });
if (errors.length > 0 || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return JSON.stringify(mergeOpenCodeConfig({}, input), null, 2);
}
let nextText = content;
const schemaEdits = modify(
nextText,
["$schema"],
parsed.$schema || "https://opencode.ai/config.json",
{
formattingOptions: { insertSpaces: true, tabSize: 2 },
}
);
nextText = applyEdits(nextText, schemaEdits);
const providerEdits = modify(nextText, ["provider", "omniroute"], providerConfig, {
formattingOptions: { insertSpaces: true, tabSize: 2 },
});
return applyEdits(nextText, providerEdits);
};

View File

@@ -1729,6 +1729,7 @@ export const guideSettingsSaveSchema = z
apiKey: z.string().optional(),
model: z.string().trim().min(1, "Model is required").optional(),
models: z.array(z.string().trim().min(1, "Models must be non-empty")).min(1).optional(),
modelLabels: z.record(z.string(), z.string().trim().min(1)).optional(),
})
.refine((data) => !!data.model || !!data.models?.length, {
message: "Model is required",

View File

@@ -0,0 +1,34 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import fs from "node:fs/promises";
import path from "node:path";
const require = createRequire(import.meta.url);
const en = require("../../src/i18n/messages/en.json");
test("CLI tools English messages include custom tab label and keep OpenCode baseUrl command literal", () => {
assert.equal(en.cliTools?.customCliTab, "Custom CLI");
assert.equal(
en.cliTools?.guides?.opencode?.steps?.[3]?.desc,
"opencode config set baseUrl {baseUrl}",
"OpenCode baseUrl command must use injectable ICU syntax"
);
});
test("CLI tools locale messages use ICU baseUrl placeholder for OpenCode step 3", async () => {
const messagesDir = path.join(process.cwd(), "src/i18n/messages");
const files = (await fs.readdir(messagesDir)).filter((file) => file.endsWith(".json"));
for (const file of files) {
const content = JSON.parse(await fs.readFile(path.join(messagesDir, file), "utf-8"));
const desc = content?.cliTools?.guides?.opencode?.steps?.["3"]?.desc;
if (typeof desc !== "string") continue;
assert.equal(
desc.includes("{baseUrl}") && !desc.includes("{{baseUrl}}"),
true,
`${file} should use ICU-style {baseUrl} placeholder`
);
}
});

View File

@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SignJWT } from "jose";
const keysRoute = await import("../../src/app/api/keys/route.ts");
const cliToolsKeysRoute = await import("../../src/app/api/cli-tools/keys/route.ts");
const { createApiKey, deleteApiKey } = await import("../../src/lib/db/apiKeys.ts");
const originalJwtSecret = process.env.JWT_SECRET;
const originalApiKeySecret = process.env.API_KEY_SECRET;
async function createAuthCookie() {
process.env.JWT_SECRET = "test-cli-tools-keys-secret";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ sub: "test-user" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${token}`;
}
test.afterEach(() => {
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
else process.env.JWT_SECRET = originalJwtSecret;
if (originalApiKeySecret === undefined) delete process.env.API_KEY_SECRET;
else process.env.API_KEY_SECRET = originalApiKeySecret;
});
test("CLI tools key list can return unmasked keys for authenticated internal consumers", async () => {
process.env.API_KEY_SECRET = "test-api-key-secret";
const created = await createApiKey("CLI Tools Test Key", "test-machine-cli-tools");
try {
const cookie = await createAuthCookie();
const request = new Request("http://localhost/api/cli-tools/keys", {
headers: {
cookie,
},
});
const response = await cliToolsKeysRoute.GET(request);
assert.equal(response.status, 200);
const payload = await response.json();
const key = payload.keys.find((entry) => entry.id === created.id);
assert.ok(key, "created key should be present");
assert.notEqual(key.key, created.key);
assert.match(key.key, /^.{8}\*\*\*\*.*$/);
assert.equal(key.rawKey, created.key);
} finally {
await deleteApiKey(created.id);
}
});
test("general keys route stays masked for non-CLI consumers", async () => {
process.env.API_KEY_SECRET = "test-api-key-secret";
const created = await createApiKey("Masked Key Test", "test-machine-masked");
try {
const cookie = await createAuthCookie();
const request = new Request("http://localhost/api/keys", {
headers: { cookie },
});
const response = await keysRoute.GET(request);
assert.equal(response.status, 200);
const payload = await response.json();
const key = payload.keys.find((entry) => entry.id === created.id);
assert.ok(key, "created key should be present");
assert.notEqual(key.key, created.key);
assert.match(key.key, /^.{8}\*\*\*\*.*$/);
} finally {
await deleteApiKey(created.id);
}
});

View File

@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
import { parse } from "jsonc-parser";
const guideSettingsRoute =
await import("../../src/app/api/cli-tools/guide-settings/[toolId]/route.ts");
@@ -11,6 +13,19 @@ const QWEN_CONFIG_PATH = path.join(DUMMY_HOME, ".qwen", "settings.json");
const QWEN_ENV_PATH = path.join(DUMMY_HOME, ".qwen", ".env");
const OPENCODE_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "opencode", "opencode.json");
const originalXDG = process.env.XDG_CONFIG_HOME;
const originalJwtSecret = process.env.JWT_SECRET;
async function createAuthCookie() {
process.env.JWT_SECRET = "test-cli-tools-secret";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ sub: "test-user" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${token}`;
}
type QwenProviderEntry = {
id?: string;
@@ -21,10 +36,11 @@ type QwenProviderEntry = {
};
};
function buildRequest(body: any) {
async function buildRequest(body: any) {
const cookie = await createAuthCookie();
return new Request("http://localhost/api/cli-tools/guide-settings/qwen", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify(body),
});
}
@@ -42,10 +58,16 @@ test.afterEach(async () => {
await fs.rm(DUMMY_HOME, { recursive: true, force: true }).catch(() => {});
if (originalXDG === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = originalXDG;
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
else process.env.JWT_SECRET = originalJwtSecret;
});
test("guide-settings POST creates new qwen settings.json if it doesn't exist", async () => {
const req = buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "qwen-max" });
const req = await buildRequest({
baseUrl: "http://my-omni",
apiKey: "sk-123",
model: "qwen-max",
});
const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response;
const data = (await response.json()) as any;
@@ -82,7 +104,7 @@ test("guide-settings POST merges into existing qwen settings.json", async () =>
"utf-8"
);
const req = buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "auto" });
const req = await buildRequest({ baseUrl: "http://my-omni", apiKey: "sk-123", model: "auto" });
const response = (await guideSettingsRoute.POST(req, { params: { toolId: "qwen" } })) as Response;
assert.equal(response.status, 200);
@@ -124,9 +146,10 @@ test("guide-settings POST writes OpenCode config with current schema and multi-m
"utf-8"
);
const cookie = await createAuthCookie();
const req = new Request("http://localhost/api/cli-tools/guide-settings/opencode", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify({
baseUrl: "http://my-omni/v1",
apiKey: "sk-123",
@@ -139,7 +162,7 @@ test("guide-settings POST writes OpenCode config with current schema and multi-m
})) as Response;
assert.equal(response.status, 200);
const content = JSON.parse(await fs.readFile(OPENCODE_CONFIG_PATH, "utf-8"));
const content = parse(await fs.readFile(OPENCODE_CONFIG_PATH, "utf-8"));
assert.equal(content.$schema, "https://opencode.ai/config.json");
assert.ok(content.provider.custom);
assert.equal(content.provider.omniroute.npm, "@ai-sdk/openai-compatible");
@@ -151,3 +174,76 @@ test("guide-settings POST writes OpenCode config with current schema and multi-m
]);
assert.equal(content.providers, undefined);
});
test("guide-settings POST preserves existing OpenCode config fields while only updating provider.omniroute", async () => {
await fs.mkdir(path.dirname(OPENCODE_CONFIG_PATH), { recursive: true });
await fs.writeFile(
OPENCODE_CONFIG_PATH,
`{
// existing config should survive
"$schema": "https://opencode.ai/config.json",
"provider": {
"custom": {
"name": "Custom Provider"
},
"omniroute": {
"npm": "old-package",
"name": "Old OmniRoute",
"options": {
"baseURL": "http://old-host/v1",
"apiKey": "old-key"
},
"models": {
"old/model": { "name": "Old Model" }
}
}
},
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
}
}
}`,
"utf-8"
);
const cookie = await createAuthCookie();
const req = new Request("http://localhost/api/cli-tools/guide-settings/opencode", {
method: "POST",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify({
baseUrl: "http://my-omni/v1",
apiKey: "sk-123",
models: ["cx/gpt-5.4", "opencode-go/kimi-k2.6"],
modelLabels: {
"cx/gpt-5.4": "GPT-5.4",
"opencode-go/kimi-k2.6": "Kimi K2.6",
},
}),
});
const response = (await guideSettingsRoute.POST(req, {
params: { toolId: "opencode" },
})) as Response;
assert.equal(response.status, 200);
const content = parse(await fs.readFile(OPENCODE_CONFIG_PATH, "utf-8"));
assert.equal(content.$schema, "https://opencode.ai/config.json");
assert.deepEqual(content.mcpServers, {
filesystem: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
});
assert.deepEqual(content.provider.custom, {
name: "Custom Provider",
});
assert.equal(content.provider.omniroute.npm, "@ai-sdk/openai-compatible");
assert.equal(content.provider.omniroute.options.baseURL, "http://my-omni/v1");
assert.equal(content.provider.omniroute.options.apiKey, "sk-123");
assert.deepEqual(content.provider.omniroute.models, {
"cx/gpt-5.4": { name: "GPT-5.4" },
"opencode-go/kimi-k2.6": { name: "Kimi K2.6" },
});
});

View File

@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
const { CLI_TOOLS } = await import("../../src/shared/constants/cliTools.ts");
@@ -74,6 +75,10 @@ test("T40: OpenCode config document uses current provider schema", () => {
baseUrl: "http://localhost:20128/v1/",
apiKey: "sk_test_opencode",
models: ["cc/claude-sonnet-4-20250514", "gg/gemini-2.5-pro"],
modelLabels: {
"cc/claude-sonnet-4-20250514": "Claude Sonnet 4.5",
"gg/gemini-2.5-pro": "Gemini 2.5 Pro",
},
});
assert.equal(configDocument.$schema, "https://opencode.ai/config.json");
@@ -85,6 +90,14 @@ test("T40: OpenCode config document uses current provider schema", () => {
"cc/claude-sonnet-4-20250514",
"gg/gemini-2.5-pro",
]);
assert.equal(
configDocument.provider.omniroute.models["cc/claude-sonnet-4-20250514"].name,
"Claude Sonnet 4.5"
);
assert.equal(
configDocument.provider.omniroute.models["gg/gemini-2.5-pro"].name,
"Gemini 2.5 Pro"
);
assert.equal(configDocument.providers, undefined);
});
@@ -93,10 +106,71 @@ test("T40: OpenCode explicit multi-model selection overrides fallback defaults",
baseUrl: "http://localhost:20128/v1/",
apiKey: "sk_test_opencode",
models: ["custom/provider-a", "custom/provider-b"],
modelLabels: {
"custom/provider-a": "Provider A",
"custom/provider-b": "Provider B",
},
});
assert.deepEqual(Object.keys(providerConfig.models), ["custom/provider-a", "custom/provider-b"]);
assert.equal(providerConfig.models["claude-sonnet-4-5-thinking"], undefined);
assert.equal(providerConfig.models["custom/provider-a"].name, "Provider A");
assert.equal(providerConfig.models["custom/provider-b"].name, "Provider B");
});
test("T40: OpenCode merge preserves unrelated config and updates only provider.omniroute", () => {
const mergedConfig = mergeOpenCodeConfig(
{
$schema: "https://opencode.ai/config.json",
provider: {
custom: { name: "Custom Provider" },
omniroute: {
name: "Old OmniRoute",
options: { baseURL: "http://old-host/v1", apiKey: "old-key" },
},
},
mcpServers: {
github: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
},
},
{
baseUrl: "http://localhost:20128/v1",
apiKey: "sk_test_opencode",
models: ["cx/gpt-5.4"],
modelLabels: { "cx/gpt-5.4": "GPT-5.4" },
}
);
assert.deepEqual(mergedConfig.provider.custom, { name: "Custom Provider" });
assert.deepEqual(mergedConfig.mcpServers, {
github: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] },
});
assert.deepEqual(mergedConfig.provider.omniroute.models, {
"cx/gpt-5.4": { name: "GPT-5.4" },
});
});
test("T40: OpenCode tool card references theme-aware brand assets", () => {
const opencode = CLI_TOOLS.opencode;
assert.equal(opencode.image, undefined);
assert.equal(opencode.imageLight, "/providers/opencode-light.svg");
assert.equal(opencode.imageDark, "/providers/opencode-dark.svg");
});
test("T40: OpenCode light/dark provider assets are valid SVG files", async () => {
const light = await fs.readFile(
path.join(process.cwd(), "public/providers/opencode-light.svg"),
"utf-8"
);
const dark = await fs.readFile(
path.join(process.cwd(), "public/providers/opencode-dark.svg"),
"utf-8"
);
assert.match(light, /^<svg[\s>]/);
assert.match(dark, /^<svg[\s>]/);
assert.doesNotMatch(light, /<html/i);
assert.doesNotMatch(dark, /<html/i);
});
test("T40: Windsurf card documents current official limitations honestly", () => {