diff --git a/bin/cli/commands/setup-opencode.mjs b/bin/cli/commands/setup-opencode.mjs index cddc867233..f2d5889f22 100644 --- a/bin/cli/commands/setup-opencode.mjs +++ b/bin/cli/commands/setup-opencode.mjs @@ -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/`. * * 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/ "..." (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 ", "Local OmniRoute port (ignored when --remote is set)", "20128") diff --git a/changelog.d/fixes/pending-opencode-jsonc-config.md b/changelog.d/fixes/pending-opencode-jsonc-config.md new file mode 100644 index 0000000000..0951ae78f2 --- /dev/null +++ b/changelog.d/fixes/pending-opencode-jsonc-config.md @@ -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 diff --git a/src/app/api/cli-tools/apply/route.ts b/src/app/api/cli-tools/apply/route.ts index fc5a066615..b0235df1ee 100644 --- a/src/app/api/cli-tools/apply/route.ts +++ b/src/app/api/cli-tools/apply/route.ts @@ -17,7 +17,6 @@ const applySchema = z.object({ const TOOL_CONFIG_PATHS: Record = { 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 }); } diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index 002ee40113..b0487d304b 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -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, { diff --git a/src/lib/cli-helper/config-generator/index.ts b/src/lib/cli-helper/config-generator/index.ts index 24eac4f41a..343ef26a78 100644 --- a/src/lib/cli-helper/config-generator/index.ts +++ b/src/lib/cli-helper/config-generator/index.ts @@ -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 = { 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); diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts index 3a5f900826..c2915e5eb7 100644 --- a/src/lib/cli-helper/config-generator/opencode.ts +++ b/src/lib/cli-helper/config-generator/opencode.ts @@ -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 { - const result: Pick = {}; + const result: Pick = + {}; // 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, + 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 | 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; @@ -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); } /** diff --git a/src/lib/cli-helper/tool-detector.ts b/src/lib/cli-helper/tool-detector.ts index 48b03bb555..59a6030b8c 100644 --- a/src/lib/cli-helper/tool-detector.ts +++ b/src/lib/cli-helper/tool-detector.ts @@ -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 { 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 = { diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 41bed13270..d66927f96a 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -281,7 +281,7 @@ export const CLI_TOOLS: Record = { 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", diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 6cc590ee47..bf148aa87c 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -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(); diff --git a/src/shared/services/opencodeConfig.ts b/src/shared/services/opencodeConfig.ts index 89cac6a6f6..c597bea4d5 100644 --- a/src/shared/services/opencodeConfig.ts +++ b/src/shared/services/opencodeConfig.ts @@ -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; diff --git a/src/shared/services/opencodeConfigPath.ts b/src/shared/services/opencodeConfigPath.ts new file mode 100644 index 0000000000..ce5eca8451 --- /dev/null +++ b/src/shared/services/opencodeConfigPath.ts @@ -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; +}; diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index d993df449d..c20a91c51b 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -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(); + } + }); }); }); diff --git a/tests/unit/cli-helper/tool-detector-opencode-jsonc-10227.test.ts b/tests/unit/cli-helper/tool-detector-opencode-jsonc-10227.test.ts new file mode 100644 index 0000000000..f2039e55ef --- /dev/null +++ b/tests/unit/cli-helper/tool-detector-opencode-jsonc-10227.test.ts @@ -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 }); + } +}); diff --git a/tests/unit/cli-runtime-detection.test.ts b/tests/unit/cli-runtime-detection.test.ts index bd996616f8..1e1e7fedd0 100644 --- a/tests/unit/cli-runtime-detection.test.ts +++ b/tests/unit/cli-runtime-detection.test.ts @@ -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); + }); }); diff --git a/tests/unit/cli-tools-apply-opencode-jsonc.test.ts b/tests/unit/cli-tools-apply-opencode-jsonc.test.ts new file mode 100644 index 0000000000..f530607bc6 --- /dev/null +++ b/tests/unit/cli-tools-apply-opencode-jsonc.test.ts @@ -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(); + +async function createAuthCookie(): Promise { + 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 { + 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)); +}); diff --git a/tests/unit/cli/setup-opencode.test.ts b/tests/unit/cli/setup-opencode.test.ts index bf78728144..a25cae6e97 100644 --- a/tests/unit/cli/setup-opencode.test.ts +++ b/tests/unit/cli/setup-opencode.test.ts @@ -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"); diff --git a/tests/unit/guide-settings-route.test.ts b/tests/unit/guide-settings-route.test.ts index 8537cbc56c..735240dc1e 100644 --- a/tests/unit/guide-settings-route.test.ts +++ b/tests/unit/guide-settings-route.test.ts @@ -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)); +}); diff --git a/tests/unit/t40-opencode-cli-tools-integration.test.ts b/tests/unit/t40-opencode-cli-tools-integration.test.ts index feaacf8256..7a7c305bac 100644 --- a/tests/unit/t40-opencode-cli-tools-integration.test.ts +++ b/tests/unit/t40-opencode-cli-tools-integration.test.ts @@ -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%/);