fix(cli): preserve OpenCode JSONC configs (#10246)

This commit is contained in:
Yahoo
2026-08-14 11:57:28 +08:00
committed by GitHub
parent 8417ace4b3
commit ed48328c7c
18 changed files with 544 additions and 63 deletions

View File

@@ -2,7 +2,7 @@
* omniroute setup-opencode — Remote-aware OpenCode provider generator
* (openai-compatible). Distinct from `omniroute setup opencode` (which wires the
* @omniroute/opencode-plugin). This writes the `omniroute` provider into
* ~/.config/opencode/opencode.json with every catalog model, so you can run
* the active OpenCode JSON/JSONC config with every catalog model, so you can run
* `opencode -m omniroute/<model>`.
*
* Reuses the proven server-side generator (config-generator/opencode.ts) for the
@@ -10,12 +10,13 @@
*/
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { basename, dirname } from "node:path";
import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}";
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 };
/** Resolve baseUrl + (literal) apiKey from flags → active context → localhost. */
export function resolveOpencodeTarget(opts = {}) {
@@ -29,7 +30,8 @@ export function resolveOpencodeTarget(opts = {}) {
} catch {
/* no context */
}
if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
if (!baseUrl)
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
let apiKey = opts.apiKey ?? opts["api-key"];
@@ -48,32 +50,61 @@ export function resolveOpencodeTarget(opts = {}) {
/**
* Post-process the generator output: reference the API key by env var (keep the
* secret off disk) and optionally keep only models whose id matches `only`.
* Pure + testable. Returns the final JSON string.
* Pure + testable. Returns the final JSONC string while preserving comments
* outside the OmniRoute-managed fields.
*
* @param {string} rawJson output of generateOpencodeConfig
* @param {{ only?: string[] }} [opts]
* @returns {{ json: string, modelCount: number }}
*/
export function postProcessOpencodeConfig(rawJson, opts = {}) {
const config = JSON.parse(rawJson);
const prov = config.provider?.omniroute;
if (prov?.options) prov.options.apiKey = ENV_KEY_REF;
const errors = [];
const config = parse(rawJson, errors, { allowTrailingComma: true, disallowComments: false });
if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
const details = errors
.map((error) => `${printParseErrorCode(error.error)} at offset ${error.offset}`)
.join(", ");
throw new Error(`Failed to parse generated OpenCode config${details ? `: ${details}` : ""}`);
}
const prov = config.provider?.omniroute;
let json = rawJson;
if (prov?.options) {
json = applyEdits(
json,
modify(json, ["provider", "omniroute", "options", "apiKey"], ENV_KEY_REF, {
formattingOptions: JSON_FORMATTING_OPTIONS,
})
);
}
let models = prov?.models;
if (opts.only && opts.only.length && prov?.models) {
const kept = {};
for (const [id, entry] of Object.entries(prov.models)) {
if (opts.only.some((f) => id.includes(f))) kept[id] = entry;
}
prov.models = kept;
models = kept;
json = applyEdits(
json,
modify(json, ["provider", "omniroute", "models"], kept, {
formattingOptions: JSON_FORMATTING_OPTIONS,
})
);
}
const modelCount = prov?.models ? Object.keys(prov.models).length : 0;
return { json: JSON.stringify(config, null, 2) + "\n", modelCount };
const modelCount = models ? Object.keys(models).length : 0;
return { json: json.endsWith("\n") ? json : `${json}\n`, modelCount };
}
export async function runSetupOpencodeCommand(opts = {}) {
const { baseUrl, apiKey } = resolveOpencodeTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
const only = opts.only
? opts.only
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
printHeading("OmniRoute → OpenCode provider (openai-compatible)");
printInfo(`Connecting to ${baseUrl}`);
@@ -81,20 +112,28 @@ export async function runSetupOpencodeCommand(opts = {}) {
// Deferred import: opencode.ts is TypeScript; tsx is registered by
// bin/omniroute.mjs before any command runs, so importing here is safe.
let raw;
let configPath;
try {
const { generateOpencodeConfig } = await import(
"../../../src/lib/cli-helper/config-generator/opencode.ts"
);
raw = await generateOpencodeConfig({ baseUrl, apiKey, model: opts.model, providerId: "omniroute" });
const { generateOpencodeConfig } =
await import("../../../src/lib/cli-helper/config-generator/opencode.ts");
const { resolveOpencodeConfigPath } =
await import("../../../src/shared/services/opencodeConfigPath.ts");
configPath = resolveOpencodeConfigPath();
raw = await generateOpencodeConfig({
baseUrl,
apiKey,
model: opts.model,
providerId: "omniroute",
configPath,
});
} catch (err) {
printError(`Failed to generate opencode.json: ${err?.message || err}`);
printError(`Failed to generate OpenCode config: ${err?.message || err}`);
printInfo("Make sure OmniRoute is running and --remote/--api-key are correct.");
return 1;
}
const { json, modelCount } = postProcessOpencodeConfig(raw, { only });
const configDir = join(os.homedir(), ".config", "opencode");
const configPath = join(configDir, "opencode.json");
const configDir = dirname(configPath);
if (dryRun) {
console.log(json.length > 4000 ? json.slice(0, 4000) + "\n… (truncated)" : json);
@@ -104,7 +143,9 @@ export async function runSetupOpencodeCommand(opts = {}) {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
writeFileSync(configPath, json, "utf8");
printSuccess(`opencode.json updated at ${configPath} (${modelCount} models under 'omniroute')`);
printSuccess(
`${basename(configPath)} updated at ${configPath} (${modelCount} models under 'omniroute')`
);
printInfo('Use it: opencode -m omniroute/<model> "..." (export OMNIROUTE_API_KEY first)');
return 0;
}
@@ -113,7 +154,7 @@ export function registerSetupOpencode(program) {
program
.command("setup-opencode")
.description(
"Generate the OmniRoute openai-compatible provider in ~/.config/opencode/opencode.json " +
"Generate the OmniRoute openai-compatible provider in the active OpenCode config " +
"from the live model catalog (local or remote VPS)"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")

View File

@@ -0,0 +1 @@
- **fix(cli):** recognize native `opencode.jsonc` files in OpenCode detection, generated-provider setup, and dashboard save/apply flows; preserve unrelated JSONC comments and provider settings, write updates back to the selected file, and refuse to overwrite invalid config ([#10227](https://github.com/diegosouzapw/OmniRoute/issues/10227)) — thanks @tito13kfm

View File

@@ -17,7 +17,6 @@ const applySchema = z.object({
const TOOL_CONFIG_PATHS: Record<string, string> = {
claude: path.join(os.homedir(), ".claude", "settings.json"),
codex: path.join(os.homedir(), ".codex", "config.yaml"),
opencode: path.join(os.homedir(), ".config", "opencode", "opencode.json"),
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
continue: path.join(os.homedir(), ".continue", "config.yaml"),
@@ -65,7 +64,7 @@ export async function POST(request: Request) {
});
}
const configPath = TOOL_CONFIG_PATHS[toolId];
const configPath = toolId === "opencode" ? result.configPath : TOOL_CONFIG_PATHS[toolId];
if (!configPath) {
return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 });
}

View File

@@ -64,7 +64,7 @@ export async function POST(request, { params }) {
return await saveContinueConfig({ baseUrl, apiKey, model });
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.
// OpenCode reads opencode.jsonc/opencode.json — update the active native config.
return await saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels });
case "hermes":
return await saveHermesConfig({ baseUrl, apiKey, model });
@@ -161,7 +161,7 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
}
/**
* Save OpenCode config to ~/.config/opencode/opencode.json on ALL platforms
* Save OpenCode config to the active opencode.jsonc/opencode.json on ALL platforms
* (XDG_CONFIG_HOME aware). OpenCode uses XDG `~/.config` even on Windows
* (%USERPROFILE%\.config), NOT %APPDATA% (#3330).
*
@@ -182,8 +182,9 @@ async function saveOpenCodeConfig({ baseUrl, apiKey, model, models, modelLabels
let existingConfigText = "";
try {
existingConfigText = await fs.readFile(configPath, "utf-8");
} catch {
// File doesn't exist — start fresh
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
// File doesn't exist — start fresh.
}
const nextConfigText = mergeOpenCodeConfigText(existingConfigText, {

View File

@@ -10,6 +10,7 @@ import { generateHermesConfig } from "./hermes";
import { generateHermesAgentConfig, type HermesAgentConfigPayload } from "./hermes-agent";
import { generateKilocodeConfig } from "./kilocode";
import { generateOpencodeConfig } from "./opencode";
import { resolveOpencodeConfigPath } from "../../../shared/services/opencodeConfigPath";
export interface GenerateOptions {
baseUrl: string;
@@ -42,7 +43,6 @@ function expandHome(p: string): string {
const STATIC_TOOL_CONFIG_PATHS: Record<string, string> = {
claude: path.join(os.homedir(), ".claude", "settings.json"),
codex: path.join(os.homedir(), ".codex", "config.yaml"),
opencode: path.join(os.homedir(), ".config", "opencode", "opencode.json"),
cline: path.join(os.homedir(), ".cline", "data", "globalState.json"),
kilocode: path.join(os.homedir(), ".config", "kilocode", "settings.json"),
continue: path.join(os.homedir(), ".continue", "config.yaml"),
@@ -58,6 +58,9 @@ function getToolConfigPath(toolId: string): string {
if (toolId === "hermes" || toolId === "hermes-agent") {
return getHermesConfigPath();
}
if (toolId === "opencode") {
return resolveOpencodeConfigPath();
}
return STATIC_TOOL_CONFIG_PATHS[toolId] ?? "";
}
@@ -95,8 +98,11 @@ export async function generateConfig(
if (!generate) {
return { success: false, configPath: "", error: `Unknown tool: ${toolId}` };
}
const content = await generate(options);
const configPath = getToolConfigPath(toolId);
const content =
toolId === "opencode"
? await generateOpencodeConfig({ ...options, configPath })
: await generate(options);
return { success: true, configPath, content };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);

View File

@@ -1,13 +1,13 @@
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
import {
parseOutboundUrl,
isCloudMetadataHost,
OutboundUrlGuardError,
} from "../../../shared/network/outboundUrlGuard";
import { resolveOpencodeConfigPath } from "../../../shared/services/opencodeConfigPath";
const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json");
const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 } as const;
/**
* SSRF guard for the catalog fetch (CodeQL js/request-forgery #326). The catalog
@@ -185,7 +185,8 @@ function deriveOpenCodeCapabilities(
catalog: CatalogModelEntry | undefined,
existing: ExistingModelEntry | undefined
): Pick<ExistingModelEntry, "attachment" | "reasoning" | "temperature" | "tool_call"> {
const result: Pick<ExistingModelEntry, "attachment" | "reasoning" | "temperature" | "tool_call"> = {};
const result: Pick<ExistingModelEntry, "attachment" | "reasoning" | "temperature" | "tool_call"> =
{};
// attachment: explicit user flag wins, then catalog attachment, then vision, then image modality.
if (typeof existing?.attachment === "boolean") {
@@ -324,26 +325,81 @@ function buildModelEntry(
}
/**
* Load the user's current opencode.json (if any) so we can preserve names,
* capability flags, and explicit `limit.context` overrides. JSONC comments
* are not supported — we parse as plain JSON. If parsing fails, we fall
* back to an empty config; the resulting write will lose comments, but
* that matches the existing CLI behavior of `config set opencode`.
* Load the user's current OpenCode config so we can preserve names,
* capability flags, explicit `limit.context` overrides, and JSONC source text.
* Existing invalid files are a hard stop: replacing one with a regenerated
* document would silently lose comments, unrelated providers, and settings.
*/
function loadExistingConfig(): ExistingConfig {
function loadExistingConfig(configPath: string): { config: ExistingConfig; source: string | null } {
if (!fs.existsSync(configPath)) return { config: {}, source: null };
let source: string;
try {
if (!fs.existsSync(CONFIG_PATH)) return {};
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
return JSON.parse(raw) as ExistingConfig;
} catch {
return {};
source = fs.readFileSync(configPath, "utf8");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to read existing OpenCode config at ${configPath}: ${message}`);
}
const errors: ParseError[] = [];
const parsed = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
if (errors.length > 0 || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
const detail = errors[0] ? printParseErrorCode(errors[0].error) : "root must be an object";
throw new Error(
`Existing OpenCode config at ${configPath} is invalid JSONC (${detail}); refusing to overwrite it.`
);
}
return { config: parsed as ExistingConfig, source };
}
/**
* Patch the generated values into an existing JSONC document without replacing
* its comments or unrelated keys. The generated object is authoritative for
* the fields the generator manages; everything else remains byte-for-byte
* unless jsonc-parser must adjust nearby whitespace for an edit.
*/
function mergeGeneratedConfigText(
existingSource: string | null,
existingConfig: ExistingConfig,
generatedConfig: Record<string, unknown>,
providerId: string
): string {
if (existingSource === null) return JSON.stringify(generatedConfig, null, 2);
let nextText = existingSource;
const schemaEdits = modify(nextText, ["$schema"], generatedConfig.$schema, {
formattingOptions: JSON_FORMATTING_OPTIONS,
});
nextText = applyEdits(nextText, schemaEdits);
const generatedProvider = (
generatedConfig.provider as Record<string, ExistingProviderEntry> | undefined
)?.[providerId];
const providerEdits = modify(nextText, ["provider", providerId], generatedProvider, {
formattingOptions: JSON_FORMATTING_OPTIONS,
});
nextText = applyEdits(nextText, providerEdits);
if (generatedConfig.model !== existingConfig.model) {
const modelEdits = modify(nextText, ["model"], generatedConfig.model, {
formattingOptions: JSON_FORMATTING_OPTIONS,
});
nextText = applyEdits(nextText, modelEdits);
}
return nextText;
}
export interface GenerateOpencodeOptions {
baseUrl: string;
apiKey: string;
model?: string;
/**
* Pre-resolved destination used by the API generator/apply pipeline so the
* file read for merging is guaranteed to be the file later written.
*/
configPath?: string;
/**
* Override the default `provider.id` used in the generated config.
* Defaults to `"omniroute"`.
@@ -389,6 +445,8 @@ export async function generateOpencodeConfig(options: GenerateOpencodeOptions):
const providerId = options.providerId?.trim() || "omniroute";
const fetchCatalog = options.fetchCatalog !== false;
const timeoutMs = options.catalogTimeoutMs ?? 5_000;
const configPath = options.configPath ?? resolveOpencodeConfigPath();
const { config: existing, source: existingSource } = loadExistingConfig(configPath);
// Fetch live catalog. The catalog is the source of truth — if it fails,
// we refuse to write an opencode.json that could mislead OpenCode into
@@ -407,7 +465,6 @@ export async function generateOpencodeConfig(options: GenerateOpencodeOptions):
// Load existing config so we preserve names, capability flags, and any
// explicit `limit.context` overrides the user has set.
const existing = loadExistingConfig();
const existingProvider = existing.provider?.[providerId];
const existingModels = (existingProvider?.models ?? {}) as Record<string, ExistingModelEntry>;
@@ -465,7 +522,7 @@ export async function generateOpencodeConfig(options: GenerateOpencodeOptions):
config.small_model = existing.small_model;
}
return JSON.stringify(config, null, 2);
return mergeGeneratedConfigText(existingSource, existing, config, providerId);
}
/**

View File

@@ -8,6 +8,7 @@ import {
locateCommand,
shouldUseShellForCommand,
} from "../../shared/services/cliRuntime";
import { resolveOpencodeConfigPath } from "../../shared/services/opencodeConfigPath";
const execFileAsync = promisify(execFile);
let execFileImpl = execFileAsync;
@@ -44,7 +45,7 @@ export interface DetectedTool {
const TOOLS = [
{ id: "claude", name: "Claude Code", configPath: "~/.claude/settings.json" },
{ id: "codex", name: "Codex CLI", configPath: "~/.codex/config.yaml" },
{ id: "opencode", name: "OpenCode", configPath: "~/.config/opencode/opencode.json" },
{ id: "opencode", name: "OpenCode", configPath: resolveOpencodeConfigPath },
{ id: "cline", name: "Cline", configPath: "~/.cline/data/globalState.json" },
{ id: "kilocode", name: "Kilo Code", configPath: "~/.config/kilocode/settings.json" },
{ id: "continue", name: "Continue", configPath: "~/.continue/config.yaml" },
@@ -149,8 +150,9 @@ export async function detectTool(id: string): Promise<DetectedTool | null> {
if (!tool) return null;
const { installed, version } = await detectBinary(tool.id);
const configPath = expandHome(tool.configPath);
const configContents = await readConfigFile(tool.configPath);
const configPath =
typeof tool.configPath === "function" ? tool.configPath() : expandHome(tool.configPath);
const configContents = await readConfigFile(configPath);
const configured = !!configContents && isConfigured(configContents, "http://localhost:20128");
const result: DetectedTool = {

View File

@@ -281,7 +281,7 @@ export const CLI_TOOLS: Record<string, CliCatalogEntry> = {
notes: [
{
type: "warning",
text: "Config path: ~/.config/opencode/opencode.json on all platforms (Windows: %USERPROFILE%\\\\.config\\\\opencode\\\\opencode.json)",
text: "Config paths: ~/.config/opencode/opencode.jsonc (preferred when present) or opencode.json on all platforms (Windows: %USERPROFILE%\\\\.config\\\\opencode\\\\opencode.jsonc or opencode.json)",
},
{
type: "warning",

View File

@@ -9,6 +9,10 @@ import { withSettingsFallback } from "./cliInstallFallback";
import { GROK_BUILD_RUNTIME_ENTRY, AMP_RUNTIME_ENTRY } from "./cliRuntimeGrokBuild";
import { isLocationTrusted, findKnownPathMatch } from "./cliRuntimeKnownPath";
import { buildHealthcheckPath } from "./cliRuntimeHealthcheckPath";
import {
resolveOpencodeConfigDir as resolveOpenCodeConfigDir,
resolveOpencodeConfigPath as resolveOpenCodeConfigPath,
} from "./opencodeConfigPath";
const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]);
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
@@ -979,15 +983,14 @@ export const resolveOpencodeConfigDir = (
// `%APPDATA%`. Writing to %APPDATA% on Windows put the file where OpenCode
// never looks, so dashboard-saved config silently had no effect. `_platform`
// is kept in the signature for call-site/test compatibility.
const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
return xdgConfigHome || path.join(homeDir, ".config");
return path.dirname(resolveOpenCodeConfigDir(env, homeDir));
};
export const resolveOpencodeConfigPath = (
platform = process.platform,
_platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
homeDir = os.homedir()
) => path.join(resolveOpencodeConfigDir(platform, env, homeDir), "opencode", "opencode.json");
) => resolveOpenCodeConfigPath(env, homeDir);
export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath();

View File

@@ -1,4 +1,4 @@
import { applyEdits, modify, parse } from "jsonc-parser";
import { applyEdits, modify, parse, printParseErrorCode, type ParseError } from "jsonc-parser";
type OpenCodeConfigInput = {
baseUrl?: string;
@@ -113,11 +113,16 @@ export const mergeOpenCodeConfigText = (
return JSON.stringify(buildOpenCodeConfigDocument(input), null, 2);
}
const errors: { error: number }[] = [];
const errors: ParseError[] = [];
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);
const detail = errors[0]
? `${printParseErrorCode(errors[0].error)} at offset ${errors[0].offset}`
: "root must be an object";
throw new Error(
`Existing OpenCode config is invalid JSONC (${detail}); refusing to overwrite it.`
);
}
let nextText = content;

View File

@@ -0,0 +1,37 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export const OPENCODE_JSON_FILENAME = "opencode.json";
export const OPENCODE_JSONC_FILENAME = "opencode.jsonc";
export const resolveOpencodeConfigDir = (
env: NodeJS.ProcessEnv = process.env,
homeDir = os.homedir()
): string => {
const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
return path.join(xdgConfigHome || path.join(homeDir, ".config"), "opencode");
};
/**
* Resolve the global OpenCode config file that a write should update.
*
* OpenCode treats `opencode.jsonc` as the preferred writable global config and
* merges it after `opencode.json` when both exist. Match that precedence so an
* existing JSONC document is never shadowed by a newly-created JSON file. Keep
* `opencode.json` as OmniRoute's creation default for backwards compatibility
* when neither native filename exists.
*/
export const resolveOpencodeConfigPath = (
env: NodeJS.ProcessEnv = process.env,
homeDir = os.homedir()
): string => {
const configDir = resolveOpencodeConfigDir(env, homeDir);
const jsoncPath = path.join(configDir, OPENCODE_JSONC_FILENAME);
if (fs.existsSync(jsoncPath)) return jsoncPath;
const jsonPath = path.join(configDir, OPENCODE_JSON_FILENAME);
if (fs.existsSync(jsonPath)) return jsonPath;
return jsonPath;
};

View File

@@ -2,6 +2,7 @@ import { describe, it, mock } from "node:test";
import assert from "node:assert";
import fs, { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { parse } from "jsonc-parser";
import * as generator from "../../../src/lib/cli-helper/config-generator/index.ts";
// The UI's HERMES_ROLES catalog (HermesAgentToolCard.tsx) is a "use client" component
@@ -261,7 +262,7 @@ describe("config-generator", () => {
{ role: "delegation", model: "claude-3-5-sonnet" },
{ role: "vision", model: "gpt-4o" },
],
});
} as any);
assert.ok(!result.error);
assert.ok(typeof result.yaml === "string");
@@ -291,7 +292,7 @@ describe("config-generator", () => {
const result = await hermesAgent.generateHermesAgentConfig({
baseUrl: "",
selections: [{ role: "default", model: "x" }],
} as any);
});
assert.ok(result.error);
assert.ok(result.error.includes("baseUrl"));
@@ -513,9 +514,8 @@ describe("config-generator", () => {
])
);
try {
const { generateOpencodeConfig } = await import(
"../../../src/lib/cli-helper/config-generator/opencode.ts"
);
const { generateOpencodeConfig } =
await import("../../../src/lib/cli-helper/config-generator/opencode.ts");
const out = await generateOpencodeConfig({
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
@@ -653,5 +653,70 @@ describe("config-generator", () => {
mock.restoreAll();
}
});
it("loads comments and trailing commas from opencode.jsonc and returns its real path (#10227)", async () => {
const existingJsonc = `{
// preserve this native OpenCode file instead of ignoring it
"$schema": "https://opencode.ai/config.json",
"provider": {
"custom": {
// keep comments inside unrelated providers too
"name": "Custom Provider"
},
"omniroute": {
"models": {
"manual-model": { "name": "Manual", "limit": { "context": 77777, }, },
},
},
},
}\n`;
let readPath = "";
mock.method(fs, "existsSync", (candidate) => String(candidate).endsWith("opencode.jsonc"));
mock.method(fs, "readFileSync", (candidate) => {
readPath = String(candidate);
return existingJsonc;
});
const stub = stubFetchOnce(
makeCatalogResponse([{ id: "manual-model", context_length: 131072 }])
);
try {
const result = await generator.generateConfig("opencode", {
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
});
assert.strictEqual(result.success, true);
assert.match(result.configPath, /opencode\.jsonc$/);
assert.strictEqual(readPath, result.configPath);
assert.match(result.content || "", /preserve this native OpenCode file/);
assert.match(result.content || "", /keep comments inside unrelated providers too/);
const config = parse(result.content || "");
assert.deepStrictEqual(config.provider.custom, { name: "Custom Provider" });
assert.strictEqual(config.provider.omniroute.models["manual-model"].limit.context, 77777);
} finally {
stub.restore();
mock.restoreAll();
}
});
it("refuses to replace an invalid existing opencode.jsonc (#10227)", async () => {
mock.method(fs, "existsSync", (candidate) => String(candidate).endsWith("opencode.jsonc"));
mock.method(fs, "readFileSync", () => "{ invalid jsonc");
const stub = stubFetchOnce(makeCatalogResponse([{ id: "catalog-model", context_length: 8 }]));
try {
const result = await generator.generateConfig("opencode", {
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
});
assert.strictEqual(result.success, false);
assert.match(result.error || "", /invalid.*JSONC|refus/i);
} finally {
stub.restore();
mock.restoreAll();
}
});
});
});

View File

@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as toolDetector from "../../../src/lib/cli-helper/tool-detector.ts";
test("detectTool reports an existing opencode.jsonc as the real config path (#10227)", async () => {
const xdgRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-detector-jsonc-"));
const configDir = path.join(xdgRoot, "opencode");
const configPath = path.join(configDir, "opencode.jsonc");
const previousXdg = process.env.XDG_CONFIG_HOME;
toolDetector.__setExecFileImpl(async () => ({ stdout: "v1.0.0\n", stderr: "" }));
try {
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
configPath,
`{
// OpenCode accepts JSONC
"provider": {
"omniroute": { "options": { "baseURL": "http://localhost:20128/v1" } },
},
}\n`
);
process.env.XDG_CONFIG_HOME = xdgRoot;
const result = await toolDetector.detectTool("opencode");
assert.ok(result !== null);
assert.equal(result.configPath, configPath);
assert.equal(result.configured, true);
assert.match(result.configContents || "", /OpenCode accepts JSONC/);
} finally {
if (previousXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = previousXdg;
fs.rmSync(xdgRoot, { recursive: true, force: true });
}
});

View File

@@ -322,4 +322,38 @@ describe("resolveOpencodeConfigPath — cross-platform", () => {
);
assert.equal(result, path.join("D:\\xdg", "opencode", "opencode.json"));
});
it("selects an existing opencode.jsonc instead of inventing opencode.json (#10227)", () => {
const xdgRoot = createTempDir();
const configDir = path.join(xdgRoot, "opencode");
const jsoncPath = path.join(configDir, "opencode.jsonc");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(jsoncPath, "{\n // native OpenCode config\n}\n");
const result = resolveOpencodeConfigPathFn(
process.platform,
{ XDG_CONFIG_HOME: xdgRoot },
os.homedir()
);
assert.equal(result, jsoncPath);
});
it("prefers opencode.jsonc when both native filenames exist (#10227)", () => {
const xdgRoot = createTempDir();
const configDir = path.join(xdgRoot, "opencode");
const jsonPath = path.join(configDir, "opencode.json");
const jsoncPath = path.join(configDir, "opencode.jsonc");
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(jsonPath, "{}\n");
fs.writeFileSync(jsoncPath, "{}\n");
const result = resolveOpencodeConfigPathFn(
process.platform,
{ XDG_CONFIG_HOME: xdgRoot },
os.homedir()
);
assert.equal(result, jsoncPath);
});
});

View File

@@ -0,0 +1,139 @@
import test from "node:test";
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 originalDataDir = process.env.DATA_DIR;
const databaseRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-apply-jsonc-db-"));
process.env.DATA_DIR = databaseRoot;
const applyRoute = await import("../../src/app/api/cli-tools/apply/route.ts");
const originalFetch = globalThis.fetch;
const originalJwtSecret = process.env.JWT_SECRET;
const originalApiKeySecret = process.env.API_KEY_SECRET;
const originalXdg = process.env.XDG_CONFIG_HOME;
const testRoots = new Set<string>();
async function createAuthCookie(): Promise<string> {
process.env.JWT_SECRET = "test-cli-tools-apply-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}`;
}
async function postApply(): Promise<Response> {
const cookie = await createAuthCookie();
return applyRoute.POST(
new Request("http://localhost/api/cli-tools/apply", {
method: "POST",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify({
toolId: "opencode",
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
model: "catalog-model",
}),
})
);
}
test.beforeEach(async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-apply-jsonc-"));
testRoots.add(root);
process.env.XDG_CONFIG_HOME = root;
process.env.API_KEY_SECRET = "test-secret";
globalThis.fetch = async () =>
new Response(
JSON.stringify({
data: [
{
id: "catalog-model",
context_length: 131072,
max_output_tokens: 8192,
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
});
test.afterEach(async () => {
globalThis.fetch = originalFetch;
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;
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = originalXdg;
for (const root of testRoots) await fs.rm(root, { recursive: true, force: true });
testRoots.clear();
});
test.after(async () => {
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
await fs.rm(databaseRoot, { recursive: true, force: true });
});
test("apply writes back to the selected opencode.jsonc and does not create opencode.json (#10227)", async () => {
const configDir = path.join(process.env.XDG_CONFIG_HOME!, "opencode");
const jsoncPath = path.join(configDir, "opencode.jsonc");
const jsonPath = path.join(configDir, "opencode.json");
const original = `{
// this comment must survive the merge
"$schema": "https://opencode.ai/config.json",
"provider": {
"custom": {
// keep comments inside unrelated providers too
"name": "Custom Provider"
},
},
}\n`;
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(jsoncPath, original, "utf-8");
const response = await postApply();
const body = (await response.json()) as {
success?: boolean;
configPath?: string;
backupPath?: string;
};
assert.equal(response.status, 200);
assert.equal(body.success, true);
assert.equal(body.configPath, jsoncPath);
assert.equal(body.backupPath, path.join(configDir, ".omniroute.bak", "opencode.jsonc.bak"));
await assert.rejects(fs.access(jsonPath));
const updatedText = await fs.readFile(jsoncPath, "utf-8");
assert.match(updatedText, /this comment must survive the merge/);
assert.match(updatedText, /keep comments inside unrelated providers too/);
const updated = parse(updatedText);
assert.deepEqual(updated.provider.custom, { name: "Custom Provider" });
assert.equal(updated.provider.omniroute.models["catalog-model"].limit.context, 131072);
assert.equal(await fs.readFile(body.backupPath!, "utf-8"), original);
});
test("apply leaves an invalid opencode.jsonc untouched instead of overwriting it (#10227)", async () => {
const configDir = path.join(process.env.XDG_CONFIG_HOME!, "opencode");
const jsoncPath = path.join(configDir, "opencode.jsonc");
const jsonPath = path.join(configDir, "opencode.json");
const invalid = "{ invalid jsonc\n";
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(jsoncPath, invalid, "utf-8");
const response = await postApply();
const body = (await response.json()) as { error?: string };
assert.equal(response.status, 400);
assert.match(body.error || "", /invalid.*JSONC|refus/i);
assert.equal(await fs.readFile(jsoncPath, "utf-8"), invalid);
await assert.rejects(fs.access(jsonPath));
});

View File

@@ -1,5 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parse } from "jsonc-parser";
import {
postProcessOpencodeConfig,
resolveOpencodeTarget,
@@ -50,6 +51,31 @@ test("postProcessOpencodeConfig preserves $schema, provider name and npm", () =>
assert.equal(cfg.provider.omniroute.npm, "@ai-sdk/openai-compatible");
});
test("postProcessOpencodeConfig preserves JSONC comments outside managed fields", () => {
const rawJsonc = `{
// top-level user comment
"provider": {
"custom": {
// nested provider comment
"npm": "@ai-sdk/custom",
},
"omniroute": {
"options": { "apiKey": "sk-secret-literal" },
"models": { "openai/gpt-4o": { "name": "GPT-4o" } },
},
},
}`;
const { json, modelCount } = postProcessOpencodeConfig(rawJsonc);
assert.match(json, /\/\/ top-level user comment/);
assert.match(json, /\/\/ nested provider comment/);
const config = parse(json);
assert.equal(config.provider.custom.npm, "@ai-sdk/custom");
assert.equal(config.provider.omniroute.options.apiKey, "{env:OMNIROUTE_API_KEY}");
assert.equal(modelCount, 1);
});
test("resolveOpencodeTarget: --remote wins and trailing slashes are trimmed", () => {
const { baseUrl } = resolveOpencodeTarget({ remote: "http://vps:20128/" });
assert.equal(baseUrl, "http://vps:20128");

View File

@@ -11,6 +11,7 @@ const guideSettingsRoute =
const DUMMY_HOME = path.join(os.tmpdir(), "omniroute-guide-settings-test-" + Date.now());
const OPENCODE_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "opencode", "opencode.json");
const OPENCODE_JSONC_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "opencode", "opencode.jsonc");
// cliRuntime.ts hermes entry maps to .config/hermes/config.json (not .hermes/config.yaml)
const HERMES_CONFIG_PATH = path.join(DUMMY_HOME, ".config", "hermes", "config.json");
const originalXDG = process.env.XDG_CONFIG_HOME;
@@ -201,3 +202,24 @@ test("guide-settings POST preserves existing OpenCode config fields while only u
"opencode-go/kimi-k2.6": { name: "Kimi K2.6" },
});
});
test("guide-settings POST refuses to overwrite an invalid opencode.jsonc (#10227)", async () => {
const invalidJsonc = "{ invalid jsonc\n";
await fs.mkdir(path.dirname(OPENCODE_JSONC_CONFIG_PATH), { recursive: true });
await fs.writeFile(OPENCODE_JSONC_CONFIG_PATH, invalidJsonc, "utf-8");
const req = await buildRequest("opencode", {
baseUrl: "http://my-omni/v1",
apiKey: "sk-123",
models: ["cx/gpt-5.6-sol"],
});
const response = (await guideSettingsRoute.POST(req, {
params: { toolId: "opencode" },
})) as Response;
const data = (await response.json()) as { error?: string };
assert.equal(response.status, 500);
assert.match(data.error || "", /invalid JSONC.*refusing to overwrite/i);
assert.equal(await fs.readFile(OPENCODE_JSONC_CONFIG_PATH, "utf-8"), invalidJsonc);
await assert.rejects(fs.access(OPENCODE_CONFIG_PATH));
});

View File

@@ -20,7 +20,9 @@ test("T40: OpenCode card documents config paths and --variant usage", () => {
.join(" ")
.toLowerCase();
assert.match(notesText, /\.config\/opencode\/opencode\.json/);
assert.match(notesText, /or opencode\.json\b/);
assert.match(notesText, /\.config\/opencode\/opencode\.jsonc/);
assert.match(notesText, /preferred when present/);
// #3330: OpenCode uses ~/.config on all platforms (incl. Windows) — the note
// must no longer point Windows users at %APPDATA%.
assert.doesNotMatch(notesText, /%appdata%/);