diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 10a7fc50b0a..dd1726a924e 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -121,6 +121,7 @@ export default defineConfig({ { label: "Codex App Model Picker", translations: { ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー" }, slug: "guides/codex-app-models" }, { label: "Model Ordering", translations: { ko: "모델 정렬에 관하여", "zh-CN": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順" }, slug: "guides/model-ordering" }, { label: "Claude Code", translations: { ko: "Claude Code", "zh-CN": "Claude Code", ru: "Claude Code", ja: "Claude Code" }, slug: "guides/claude-code" }, + { label: "opencode", translations: { ko: "opencode", "zh-CN": "opencode", ru: "opencode", ja: "opencode" }, slug: "guides/opencode" }, { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" }, { label: "Web Dashboard", translations: { ko: "웹 대시보드", "zh-CN": "网页控制台", ru: "Веб-дашборд", ja: "ウェブダッシュボード" }, slug: "guides/web-dashboard" }, { label: "Sub-agent Surface", translations: { ko: "서브에이전트 서피스", "zh-CN": "子代理界面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス" }, slug: "guides/sub-agent-surface" }, diff --git a/docs-site/src/content/docs/guides/opencode.md b/docs-site/src/content/docs/guides/opencode.md new file mode 100644 index 00000000000..cea53b73340 --- /dev/null +++ b/docs-site/src/content/docs/guides/opencode.md @@ -0,0 +1,95 @@ +--- +title: opencode +description: Use any routed model from opencode — opencodex generates a provider config and points OPENCODE_CONFIG at it, leaving your own opencode.json untouched. +--- + +opencode reads its providers from a JSON config rather than environment variables, so there is +no `ANTHROPIC_BASE_URL`-style slot to inject. `ocx opencode` bridges that gap: it generates a +provider block from the proxy's visible catalog and points `OPENCODE_CONFIG` at the result. + +## Quickstart + +```bash +ocx opencode +``` + +This ensures the proxy is running, writes `~/.opencodex/opencode-config.json`, and launches +opencode with `OPENCODE_CONFIG` set to that file. Extra arguments pass through: +`ocx opencode run "hello"`. + +Routed models appear in the picker under the `opencodex` provider: + +``` +opencodex/kiro/glm-5 +opencodex/gpt-5.6-sol # native slugs stay unprefixed +``` + +## Your own config is never modified + +The launcher does not write to `~/.config/opencode/opencode.json`. Instead it reads your +effective config, merges it forward into the generated copy, and overwrites only the +`opencodex` provider key. Every other provider, plus unrelated top-level fields (`model`, +`agents`, `keybinds`, `mcp`, …), is preserved verbatim. + +| Source | Behavior | +| --- | --- | +| `OPENCODE_CONFIG` already exported | Used as the base config, then superseded by the generated path | +| `~/.config/opencode/opencode.json` (or `$XDG_CONFIG_HOME`) | Used as the base config when no explicit export exists | +| Neither exists | A standalone config containing only the `opencodex` provider | +| Base config is malformed | The launch is refused rather than silently dropping your settings | + +Comments and trailing commas are fine — opencode documents `opencode.json` as JSONC, and the +launcher parses the same syntax the client accepts. + +Carrying your config forward means the command behaves identically whether opencode merges the +`OPENCODE_CONFIG` layer or replaces it. + +### Project configs still win + +opencode loads a project-level `opencode.json` *after* the `OPENCODE_CONFIG` layer. If your +project config defines `provider.opencodex`, it overrides the generated block and the child may +talk to a stale base URL. The launcher cannot outrank that layer without writing to one of your +files, so it prints a warning instead. Rename the project-level key to resolve it. + +## The admission key is not written to disk + +When the proxy requires an API key, the generated config carries opencode's `{env:…}` reference +rather than the secret: + +```json +"options": { + "baseURL": "http://127.0.0.1:10100/v1", + "apiKey": "{env:OPENCODEX_OPENCODE_API_KEY}" +} +``` + +The real value is passed only through the child process environment. `OPENCODEX_API_AUTH_TOKEN` +takes precedence over a configured API key, which is what a non-loopback bind requires. + +## Reverting + +Nothing to undo — the generated file lives in the opencodex config dir and is only consulted +when the launcher sets `OPENCODE_CONFIG` for the child process. Run plain `opencode` and it +reads your own config exactly as before. + +## Model limits + +`limit.context` is written only when the catalog reports an authoritative context window; when it +does not, the whole `limit` block is omitted and opencode keeps its own defaults. + +opencode's schema rejects a `limit` block carrying `context` without `output`, and the catalog has +no authoritative per-model output field, so an `output` budget of `32000` is emitted alongside it, +clamped down to the context window so a small-context model is never given `output > context`. +That figure exists to satisfy the schema — it is not a claim about any specific model's true +maximum. + +The `opencodex` provider block is regenerated on every launch, so per-model tweaks made inside it +will not survive. Keep custom entries under a provider key of your own instead. + +## Requirements + +opencode must be installed and on `PATH`: + +```bash +npm install -g opencode-ai +``` diff --git a/src/cli/help.ts b/src/cli/help.ts index edc578575d0..c6fcc3a0107 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -127,6 +127,18 @@ const helpEntries: Record = { "User-exported ANTHROPIC_* variables always take precedence.", ], }, + opencode: { + usage: "ocx opencode [opencode args...]", + summary: "Launch opencode wired to the proxy (generated provider config).", + details: [ + "Ensures the proxy is running, then execs `opencode` with OPENCODE_CONFIG pointed at a", + "generated config in the opencodex config dir. Your own opencode.json is never modified —", + "its settings are merged forward into the generated copy, and only the `opencodex`", + "provider key is overwritten.", + "Routed models appear in the model picker as opencodex//.", + "Stop using `ocx opencode` and plain `opencode` behaves exactly as before.", + ], + }, restart: { usage: "ocx restart", summary: "Stop the proxy and restart it (background). Equivalent to stop + ensure.", @@ -192,6 +204,7 @@ Usage: ocx account Accounts/keys (list|current|use|refresh|auto-switch|remove|add-key) ocx models List models; manage custom models (add|remove|list-custom) ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) + ocx opencode [args...] Launch opencode wired to the proxy (generated provider config) ocx help [command] Show help ocx --version | -v Print version diff --git a/src/cli/index.ts b/src/cli/index.ts index e1c89e2a2b8..2b762bda99d 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -810,6 +810,10 @@ switch (command) { break; } process.exit(await cmdClaude(args.slice(1))); + } + case "opencode": { + const { cmdOpencode } = await import("./opencode"); + process.exit(await cmdOpencode(args.slice(1))); } case "help": case "--help": diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts new file mode 100644 index 00000000000..ef74e4f3e83 --- /dev/null +++ b/src/cli/opencode.ts @@ -0,0 +1,455 @@ +/** + * `ocx opencode [opencode args...]` — launch opencode wired to the local proxy. + * + * Mirrors `ocx claude` (src/cli/claude.ts): ensure the proxy is running, then exec the + * client with stdio inherited. The wiring channel differs — opencode reads providers + * from a JSON config file rather than env slots, so there is no ANTHROPIC_BASE_URL + * analog to inject. + * + * The launcher never writes to the user's own opencode config. It merges their + * effective config with a generated `opencodex` provider block into + * `/opencode-config.json` and points OPENCODE_CONFIG at that copy, so the + * launch is fully reversible: stop using `ocx opencode` and plain `opencode` behaves + * exactly as before. Carrying the user's config forward (rather than relying only on + * OPENCODE_CONFIG's documented global < custom < project precedence) keeps their other + * providers, agents and keybinds working regardless of whether opencode merges or + * replaces that layer. + * + * The admission key is never serialized into the generated file. The provider block + * carries opencode's documented `{env:VAR}` reference and the real value is passed + * only through the child process environment. + */ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { getConfigDir, loadConfig } from "../config"; +import { commandInvocation } from "../lib/win-exec"; +import { findLiveProxy } from "../server/proxy-liveness"; +import type { OcxConfig } from "../types"; + +export interface OpencodeLaunchEnv { + [key: string]: string | undefined; +} + +/** One proxy-routed model destined for the generated provider block. */ +export interface OpencodeRoutedModel { + provider: string; + id: string; + /** Authoritative context window (CatalogModel.contextWindow); optional. */ + contextWindow?: number; + /** Authoritative display label (CatalogModel.displayName); optional. */ + displayName?: string; +} + +export interface OpencodeModelEntry { + name: string; + limit?: { context: number; output: number }; +} + +export interface OpencodeProviderBlock { + npm: string; + name: string; + options: { baseURL: string; apiKey: string }; + models: Record; +} + +export interface OpencodeGeneratedConfig { + $schema: string; + provider: Record; +} + +/** Provider key owned by this launcher; the only key it ever overwrites on merge. */ +export const OPENCODE_PROVIDER_ID = "opencodex"; + +const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json"; + +/** + * The proxy speaks the OpenAI-compatible shape at /v1, which opencode reaches through + * the AI SDK's openai-compatible package (the same wiring users hand-write today). + */ +const OPENCODE_PROVIDER_NPM = "@ai-sdk/openai-compatible"; + +/** + * Env var carrying the proxy admission key to the child. The generated config only ever + * holds the `{env:...}` reference, so the secret never lands on disk (AGENTS.md treats + * token serialization as a release blocker). opencode substitutes it at load time. + */ +export const OPENCODE_API_KEY_ENV = "OPENCODEX_OPENCODE_API_KEY"; + +/** + * opencode's config schema rejects a `limit` block that carries `context` without + * `output`, but CatalogModel has no authoritative per-model output field. Dropping + * `limit` entirely would also throw away the authoritative context window we DO have, + * so the block is emitted with this budget standing in for the missing half. + * + * The value matches REASONING_MAX_TOKENS_CEILING in src/adapters/anthropic.ts — the + * project's existing "safe ceiling across current models" figure. It is a ceiling for + * schema validity, NOT a claim about any specific model's true maximum, and it is + * clamped to the context window so a small-context model can never be emitted with + * output > context. + */ +export const SCHEMA_REQUIRED_OUTPUT_BUDGET = 32_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Strip `//` and block comments outside string literals. Escape-aware so a quote inside + * an escaped sequence cannot flip string state and expose config text to the stripper. + */ +function stripJsonComments(text: string): string { + let out = ""; + let inString = false; + let inLine = false; + let inBlock = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + const next = text[i + 1]; + if (inLine) { + if (ch === "\n") { + inLine = false; + out += ch; + } + continue; + } + if (inBlock) { + // Newlines are preserved so JSON.parse error positions stay meaningful. + if (ch === "\n") out += ch; + else if (ch === "*" && next === "/") { inBlock = false; i++; } + continue; + } + if (inString) { + out += ch; + if (ch === "\\") { + const escaped = text[i + 1]; + if (escaped !== undefined) { out += escaped; i++; } + continue; + } + if (ch === "\"") inString = false; + continue; + } + if (ch === "\"") { inString = true; out += ch; continue; } + if (ch === "/" && next === "/") { inLine = true; i++; continue; } + if (ch === "/" && next === "*") { inBlock = true; i++; continue; } + out += ch; + } + return out; +} + +/** Drop commas that sit directly before `}` or `]`, ignoring string contents. */ +function stripTrailingCommas(text: string): string { + let out = ""; + let inString = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (inString) { + out += ch; + if (ch === "\\") { + const escaped = text[i + 1]; + if (escaped !== undefined) { out += escaped; i++; } + continue; + } + if (ch === "\"") inString = false; + continue; + } + if (ch === "\"") { inString = true; out += ch; continue; } + if (ch === ",") { + let j = i + 1; + while (j < text.length && /\s/.test(text[j]!)) j++; + if (text[j] === "}" || text[j] === "]") continue; + } + out += ch; + } + return out; +} + +/** + * opencode documents opencode.json as JSONC, so a valid user config may carry comments + * or trailing commas. Strict JSON.parse runs first and untouched — the tolerant path is + * only attempted when that throws, keeping well-formed configs away from the stripper. + */ +export function parseJsonc(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return JSON.parse(stripTrailingCommas(stripJsonComments(text))); + } +} + +/** + * Resolve the user's global opencode config path. opencode uses the XDG layout on every + * platform (including Windows, where it is %USERPROFILE%\.config\opencode). + */ +export function opencodeGlobalConfigPath( + env: OpencodeLaunchEnv = process.env, + home: string = homedir(), +): string { + const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0 ? env.XDG_CONFIG_HOME : join(home, ".config"); + return join(xdg, "opencode", "opencode.json"); +} + +/** Path of the generated config this launcher owns and is free to overwrite. */ +export function generatedOpencodeConfigPath(): string { + return join(getConfigDir(), "opencode-config.json"); +} + +/** + * Model key as the proxy routes it: `provider/id` for routed models, bare slug for + * native OpenAI entries (which the proxy accepts unprefixed). + */ +export function opencodeModelKey(provider: string, id: string): string { + return provider === "native" ? id : `${provider}/${id}`; +} + +/** + * Build the `opencodex` provider block from the proxy's visible catalog. + * + * `limit.context` is emitted ONLY from an authoritative context window — never guessed. + * When none is available the whole `limit` block is dropped and opencode keeps its own + * defaults; when one is present, `limit.output` rides along (opencode's schema requires + * the pair) clamped to the context window. + * + * `nativeContextWindow` supplies authoritative windows for native slugs — pass + * `nativeOpenAiContextWindow` from the catalog; the default keeps this function pure for + * tests. + */ +export function buildOpencodeProviderBlock( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, +): OpencodeProviderBlock { + const models: Record = {}; + const candidates: OpencodeRoutedModel[] = [ + ...nativeSlugs.map(id => ({ provider: "native", id, contextWindow: nativeContextWindow(id) })), + ...routedModels, + ]; + for (const { provider, id, contextWindow, displayName } of candidates) { + const key = opencodeModelKey(provider, id); + if (models[key]) continue; // first entry wins; native slugs are registered first + const entry: OpencodeModelEntry = { + name: displayName && displayName.length > 0 ? `${displayName} (${provider})` : `${id} (${provider})`, + }; + if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) { + const context = Math.floor(contextWindow); + entry.limit = { context, output: Math.min(SCHEMA_REQUIRED_OUTPUT_BUDGET, context) }; + } + models[key] = entry; + } + return { + npm: OPENCODE_PROVIDER_NPM, + name: "OpenCodex", + options: { baseURL: `http://127.0.0.1:${port}/v1`, apiKey: `{env:${OPENCODE_API_KEY_ENV}}` }, + models, + }; +} + +/** Complete generated config carrying only the provider block this launcher owns. */ +export function buildOpencodeConfig( + port: number, + nativeSlugs: readonly string[], + routedModels: readonly OpencodeRoutedModel[], + nativeContextWindow: (slug: string) => number | undefined = () => undefined, +): OpencodeGeneratedConfig { + return { + $schema: OPENCODE_CONFIG_SCHEMA, + provider: { [OPENCODE_PROVIDER_ID]: buildOpencodeProviderBlock(port, nativeSlugs, routedModels, nativeContextWindow) }, + }; +} + +/** + * Merge the generated block into a copy of the user's config. Only the `opencodex` + * provider key is overwritten — every other provider, and every unrelated top-level + * field (model, agents, keybinds, mcp, …), is preserved verbatim. + */ +export function mergeOpencodeConfig( + base: Record | null, + generated: OpencodeGeneratedConfig, +): Record { + const merged: Record = { ...(base ?? {}) }; + if (typeof merged.$schema !== "string") merged.$schema = generated.$schema; + const baseProviders = isRecord(merged.provider) ? merged.provider : {}; + merged.provider = { ...baseProviders, [OPENCODE_PROVIDER_ID]: generated.provider[OPENCODE_PROVIDER_ID] }; + return merged; +} + +/** + * Read the user's effective base config: an explicitly exported OPENCODE_CONFIG wins + * over the global path (matching opencode's own precedence). A missing file is not an + * error — it just means there is nothing to carry forward. A genuinely unparseable file + * IS surfaced, because silently dropping a user's settings would be worse than failing. + */ +export function readBaseOpencodeConfig( + env: OpencodeLaunchEnv = process.env, + generatedPath: string = generatedOpencodeConfigPath(), +): { config: Record | null; sourcePath: string | null; error?: string } { + const explicit = env.OPENCODE_CONFIG && env.OPENCODE_CONFIG.length > 0 ? env.OPENCODE_CONFIG : null; + // Never re-read our own generated file as a base; that would compound stale entries. + const candidate = explicit && explicit !== generatedPath ? explicit : opencodeGlobalConfigPath(env); + if (!existsSync(candidate)) return { config: null, sourcePath: null }; + try { + const parsed = parseJsonc(readFileSync(candidate, "utf8")); + if (!isRecord(parsed)) return { config: null, sourcePath: candidate, error: "config root is not an object" }; + return { config: parsed, sourcePath: candidate }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { config: null, sourcePath: candidate, error: reason }; + } +} + +/** + * Detect a project-level opencode.json that defines our provider key. opencode loads the + * project layer AFTER the OPENCODE_CONFIG layer, so such a block wins over the generated + * one and the child may talk to a stale base URL. The launcher cannot outrank that layer + * without writing to a user file, so it warns instead. + */ +export function projectConfigOverridesProvider(cwd: string): string | null { + const candidate = join(cwd, "opencode.json"); + if (!existsSync(candidate)) return null; + try { + const parsed = parseJsonc(readFileSync(candidate, "utf8")); + if (!isRecord(parsed) || !isRecord(parsed.provider)) return null; + return OPENCODE_PROVIDER_ID in parsed.provider ? candidate : null; + } catch { + return null; // opencode will report its own parse failure; not this command's business + } +} + +/** + * Env assembly (unit-tested). OPENCODE_CONFIG is always pointed at the generated file: + * the launcher has already absorbed whatever the user's own config said, so honouring a + * pre-existing export here would silently drop the provider block the command exists to + * install. The admission key travels here rather than in the generated file. + */ +export function buildOpencodeEnv(configPath: string, apiKey: string, base: OpencodeLaunchEnv): OpencodeLaunchEnv { + return { ...base, OPENCODE_CONFIG: configPath, [OPENCODE_API_KEY_ENV]: apiKey }; +} + +/** + * Admission key for the proxy. The environment token wins over a configured API key — + * a non-loopback bind requires OPENCODEX_API_AUTH_TOKEN and may have no apiKeys at all, + * in which case a placeholder would 401 every request. Same precedence as + * fetchClaudeContextWindows in src/cli/claude.ts. + */ +export function opencodeApiKey(config: OcxConfig, env: OpencodeLaunchEnv = process.env): string { + return env.OPENCODEX_API_AUTH_TOKEN || config.apiKeys?.[0]?.key || "ocx"; +} + +async function ensureProxyForOpencode(config: OcxConfig): Promise { + const live = await findLiveProxy(); + if (live) return live.port; + const cfgPort = config.port; + const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; + const child = spawn(process.execPath, [process.argv[1], "start", "--port", String(pinPort)], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { ...process.env, OCX_SERVICE: "1" }, + }); + // Without a listener an 'error' (bad argv[1], EMFILE, AV denial) throws synchronously + // and kills this process; the health poll below already reports the failure properly. + child.on("error", () => { /* handled by the deadline loop returning null */ }); + child.unref(); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + const started = await findLiveProxy(); + if (started) return started.port; + await new Promise(resolve => setTimeout(resolve, 250)); + } + return null; +} + +const OPENCODE_INSTALL_HINT = "❌ `opencode` CLI not found. Install it first: npm install -g opencode-ai"; + +/** + * cmd.exe reports command-not-found as exit 9009 (the win32 launcher routes `.cmd` + * shims through cmd.exe, so ENOENT never fires there). Signal exits are not hints. + * Same contract as claudeNotFoundHint (devlog 260715_cross_platform_audit/020). + */ +export function opencodeNotFoundHint( + code: number | null, + signal: NodeJS.Signals | null, + platform: NodeJS.Platform = process.platform, +): string | null { + return platform === "win32" && code === 9009 && !signal ? OPENCODE_INSTALL_HINT : null; +} + +/** Write the generated config atomically so a concurrent launch cannot read a torn file. */ +function writeGeneratedConfig(path: string, contents: string): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tmp = `${path}.${process.pid}.tmp`; + try { + writeFileSync(tmp, contents, { encoding: "utf8", mode: 0o600 }); + renameSync(tmp, path); + } catch (error) { + rmSync(tmp, { force: true }); + throw error; + } +} + +export async function cmdOpencode(args: string[]): Promise { + const config = loadConfig(); + const port = await ensureProxyForOpencode(config); + if (!port) { + console.error("❌ Proxy did not become healthy after starting."); + return 1; + } + + const { fetchAllModels } = await import("../server/management-api"); + const { filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } = await import("../codex/catalog"); + const allModels = await fetchAllModels(config); + const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ + provider: m.provider, + id: m.id, + contextWindow: m.contextWindow, + displayName: m.displayName, + })); + const nativeSlugs = [...visibleNativeSlugs(config)]; + + const generatedPath = generatedOpencodeConfigPath(); + const base = readBaseOpencodeConfig(process.env, generatedPath); + if (base.error) { + console.error(`❌ Could not read the existing opencode config at ${base.sourcePath}: ${base.error}`); + console.error(" Fix or move that file — refusing to launch, because generating a config would drop its settings."); + return 1; + } + const generated = buildOpencodeConfig(port, nativeSlugs, routed, nativeOpenAiContextWindow); + const merged = mergeOpencodeConfig(base.config, generated); + try { + writeGeneratedConfig(generatedPath, JSON.stringify(merged, null, 2) + "\n"); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`❌ Could not write the generated opencode config at ${generatedPath}: ${reason}`); + return 1; + } + + const modelCount = nativeSlugs.length + routed.length; + console.error(`✅ opencode wired to http://127.0.0.1:${port}/v1 — ${modelCount} model(s) under provider \`${OPENCODE_PROVIDER_ID}\`.`); + if (base.sourcePath) console.error(` Base config carried forward from ${base.sourcePath} (left untouched).`); + console.error(` Generated config: ${generatedPath}`); + const projectOverride = projectConfigOverridesProvider(process.cwd()); + if (projectOverride) { + console.error(`⚠ ${projectOverride} also defines provider.${OPENCODE_PROVIDER_ID}; opencode loads the project layer last, so it wins over the generated block.`); + } + + const env = buildOpencodeEnv(generatedPath, opencodeApiKey(config), process.env); + return await new Promise(resolve => { + const inv = commandInvocation("opencode", args); + const child = spawn(inv.file, inv.args, { stdio: "inherit", env: env as NodeJS.ProcessEnv, ...inv.options }); + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") { + console.error(OPENCODE_INSTALL_HINT); + } else { + console.error(`❌ Failed to launch opencode: ${err.message}`); + } + resolve(1); + }); + child.on("exit", (code, signal) => { + const hint = opencodeNotFoundHint(code, signal); + if (hint) console.error(hint); + resolve(signal ? 1 : code ?? 0); + }); + }); +} diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts new file mode 100644 index 00000000000..1e06cf2acd0 --- /dev/null +++ b/tests/opencode-cli.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + OPENCODE_API_KEY_ENV, + OPENCODE_PROVIDER_ID, + SCHEMA_REQUIRED_OUTPUT_BUDGET, + buildOpencodeConfig, + buildOpencodeEnv, + buildOpencodeProviderBlock, + mergeOpencodeConfig, + opencodeApiKey, + opencodeGlobalConfigPath, + opencodeModelKey, + opencodeNotFoundHint, + parseJsonc, + projectConfigOverridesProvider, + readBaseOpencodeConfig, +} from "../src/cli/opencode"; +import type { OcxConfig } from "../src/types"; + +function cfg(extra?: Partial): OcxConfig { + return { + port: 10100, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://x/v1" } }, + ...extra, + } as OcxConfig; +} + +describe("ocx opencode provider block", () => { + test("points at the live proxy port over the OpenAI-compatible surface", () => { + const block = buildOpencodeProviderBlock(10123, [], []); + expect(block.options.baseURL).toBe("http://127.0.0.1:10123/v1"); + expect(block.npm).toBe("@ai-sdk/openai-compatible"); + }); + + // The generated file is written to disk and outlives the child, so the admission key + // must never be serialized into it (AGENTS.md: token serialization is a blocker). + test("apiKey is an env reference, never a literal secret", () => { + const block = buildOpencodeProviderBlock(10100, [], []); + expect(block.options.apiKey).toBe(`{env:${OPENCODE_API_KEY_ENV}}`); + expect(JSON.stringify(block)).not.toContain("sk-"); + }); + + test("routed models key on provider/id, native slugs stay bare", () => { + const block = buildOpencodeProviderBlock(10100, ["gpt-5.6-sol"], [ + { provider: "kiro", id: "glm-5" }, + ]); + expect(Object.keys(block.models).sort()).toEqual(["gpt-5.6-sol", "kiro/glm-5"]); + }); + + test("limit.context is emitted only from an authoritative contextWindow — never guessed", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "with-window", contextWindow: 200_000 }, + { provider: "kiro", id: "no-window" }, + { provider: "kiro", id: "zero-window", contextWindow: 0 }, + ]); + expect(block.models["kiro/with-window"]?.limit?.context).toBe(200_000); + // No authoritative window -> no limit at all, so opencode keeps its own default. + expect(block.models["kiro/no-window"]?.limit).toBeUndefined(); + expect(block.models["kiro/zero-window"]?.limit).toBeUndefined(); + }); + + // Regression: opencode rejects the whole config with "Missing key …limit.output" when a + // limit block carries context alone, so the pair must always be emitted together. + test("limit.output rides along with context because opencode's schema requires the pair", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "m", contextWindow: 200_000 }, + ]); + expect(block.models["kiro/m"]?.limit).toEqual({ context: 200_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); + }); + + // A model whose whole context is smaller than the placeholder budget would otherwise be + // emitted with output > context, which is an impossible model definition. + test("limit.output is clamped to the context window for small-context models", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "local", id: "tiny", contextWindow: 8_192 }, + ]); + expect(block.models["local/tiny"]?.limit).toEqual({ context: 8_192, output: 8_192 }); + }); + + test("native slugs pick up authoritative context windows from the resolver", () => { + const block = buildOpencodeProviderBlock(10100, ["gpt-5.4", "unknown-native"], [], slug => + slug === "gpt-5.4" ? 1_000_000 : undefined); + expect(block.models["gpt-5.4"]?.limit).toEqual({ context: 1_000_000, output: SCHEMA_REQUIRED_OUTPUT_BUDGET }); + expect(block.models["unknown-native"]?.limit).toBeUndefined(); + }); + + test("displayName is used for the label when the catalog provides one", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "glm-5", displayName: "GLM-5" }, + { provider: "kiro", id: "qwen3-coder-next" }, + ]); + expect(block.models["kiro/glm-5"]?.name).toBe("GLM-5 (kiro)"); + expect(block.models["kiro/qwen3-coder-next"]?.name).toBe("qwen3-coder-next (kiro)"); + }); + + test("duplicate keys keep the first entry instead of throwing", () => { + const block = buildOpencodeProviderBlock(10100, [], [ + { provider: "kiro", id: "dup", displayName: "First" }, + { provider: "kiro", id: "dup", displayName: "Second" }, + ]); + expect(block.models["kiro/dup"]?.name).toBe("First (kiro)"); + }); + + test("model key helper distinguishes native from routed", () => { + expect(opencodeModelKey("native", "gpt-5.6-sol")).toBe("gpt-5.6-sol"); + expect(opencodeModelKey("kiro", "glm-5")).toBe("kiro/glm-5"); + }); +}); + +describe("ocx opencode config merge", () => { + test("only the opencodex provider key is overwritten; everything else survives", () => { + const base = { + $schema: "https://opencode.ai/config.json", + model: "kirocc/claude-opus-5", + keybinds: { leader: "ctrl+x" }, + provider: { + kirocc: { npm: "@ai-sdk/anthropic", options: { baseURL: "http://127.0.0.1:3456/v1" } }, + [OPENCODE_PROVIDER_ID]: { npm: "stale", name: "stale", options: {}, models: { old: {} } }, + }, + }; + const merged = mergeOpencodeConfig(base, buildOpencodeConfig(10100, [], [{ provider: "kiro", id: "glm-5" }])); + // User's own default model, keybinds and hand-wired providers are untouched. + expect(merged.model).toBe("kirocc/claude-opus-5"); + expect(merged.keybinds).toEqual({ leader: "ctrl+x" }); + const providers = merged.provider as Record }>; + expect(providers.kirocc).toEqual(base.provider.kirocc); + // Ours is replaced wholesale, so removed models do not linger. + expect(providers[OPENCODE_PROVIDER_ID]?.models).toEqual({ "kiro/glm-5": { name: "glm-5 (kiro)" } }); + }); + + test("a null base yields a standalone config", () => { + const merged = mergeOpencodeConfig(null, buildOpencodeConfig(10100, [], [])); + expect(merged.$schema).toBe("https://opencode.ai/config.json"); + expect(Object.keys(merged.provider as object)).toEqual([OPENCODE_PROVIDER_ID]); + }); + + test("a base config with no provider map is handled", () => { + const merged = mergeOpencodeConfig({ model: "x" }, buildOpencodeConfig(10100, [], [])); + expect(merged.model).toBe("x"); + expect(Object.keys(merged.provider as object)).toEqual([OPENCODE_PROVIDER_ID]); + }); +}); + +describe("ocx opencode JSONC parsing", () => { + test("plain JSON parses unchanged", () => { + expect(parseJsonc('{"a":1}')).toEqual({ a: 1 }); + }); + + // opencode documents opencode.json as JSONC, so these are valid user configs. + test("line and block comments are accepted", () => { + expect(parseJsonc('{\n // lead\n "a": 1 /* trail */\n}')).toEqual({ a: 1 }); + }); + + test("trailing commas are accepted", () => { + expect(parseJsonc('{"a":[1,2,],"b":2,}')).toEqual({ a: [1, 2], b: 2 }); + }); + + test("comment-like and comma-like text inside strings is preserved", () => { + expect(parseJsonc('{"url":"http://x/v1","note":"a // b /* c */","t":"x,"}')) + .toEqual({ url: "http://x/v1", note: "a // b /* c */", t: "x," }); + }); + + test("escaped quotes do not break string tracking", () => { + expect(parseJsonc('{"a":"he said \\"hi\\" // not a comment"}')) + .toEqual({ a: 'he said "hi" // not a comment' }); + }); + + test("genuinely malformed input still throws", () => { + expect(() => parseJsonc("{ not json")).toThrow(); + }); +}); + +describe("ocx opencode base config discovery", () => { + test("missing global config is not an error — there is simply nothing to carry forward", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-")); + const result = readBaseOpencodeConfig({ XDG_CONFIG_HOME: dir }, join(dir, "generated.json")); + expect(result.config).toBeNull(); + expect(result.error).toBeUndefined(); + }); + + test("existing global config is read and reported", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-")); + mkdirSync(join(dir, "opencode"), { recursive: true }); + writeFileSync(join(dir, "opencode", "opencode.json"), JSON.stringify({ model: "kirocc/claude-opus-5" })); + const result = readBaseOpencodeConfig({ XDG_CONFIG_HOME: dir }, join(dir, "generated.json")); + expect(result.config).toEqual({ model: "kirocc/claude-opus-5" }); + expect(result.sourcePath).toBe(join(dir, "opencode", "opencode.json")); + }); + + test("a commented (JSONC) global config launches instead of being rejected", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-")); + mkdirSync(join(dir, "opencode"), { recursive: true }); + writeFileSync(join(dir, "opencode", "opencode.json"), '{\n // my default\n "model": "kirocc/claude-opus-5",\n}'); + const result = readBaseOpencodeConfig({ XDG_CONFIG_HOME: dir }, join(dir, "generated.json")); + expect(result.config).toEqual({ model: "kirocc/claude-opus-5" }); + expect(result.error).toBeUndefined(); + }); + + test("malformed config surfaces an error instead of silently dropping user settings", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-")); + mkdirSync(join(dir, "opencode"), { recursive: true }); + writeFileSync(join(dir, "opencode", "opencode.json"), "{ not json"); + const result = readBaseOpencodeConfig({ XDG_CONFIG_HOME: dir }, join(dir, "generated.json")); + expect(result.config).toBeNull(); + expect(result.error).toBeDefined(); + }); + + test("the generated file is never used as its own base (stale entries would compound)", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-")); + const generated = join(dir, "generated.json"); + writeFileSync(generated, JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { models: { stale: {} } } } })); + const result = readBaseOpencodeConfig({ OPENCODE_CONFIG: generated, XDG_CONFIG_HOME: dir }, generated); + expect(result.config).toBeNull(); + }); + + test("an explicitly exported OPENCODE_CONFIG is preferred over the global path", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-")); + const explicit = join(dir, "mine.json"); + writeFileSync(explicit, JSON.stringify({ model: "explicit" })); + mkdirSync(join(dir, "opencode"), { recursive: true }); + writeFileSync(join(dir, "opencode", "opencode.json"), JSON.stringify({ model: "global" })); + const result = readBaseOpencodeConfig({ OPENCODE_CONFIG: explicit, XDG_CONFIG_HOME: dir }, join(dir, "generated.json")); + expect(result.config).toEqual({ model: "explicit" }); + }); + + test("global path follows XDG_CONFIG_HOME when set", () => { + expect(opencodeGlobalConfigPath({ XDG_CONFIG_HOME: "/xdg" }, "/home/u")).toBe(join("/xdg", "opencode", "opencode.json")); + expect(opencodeGlobalConfigPath({}, "/home/u")).toBe(join("/home/u", ".config", "opencode", "opencode.json")); + }); +}); + +describe("ocx opencode project-layer detection", () => { + // opencode loads the project layer after OPENCODE_CONFIG, so a project-level + // provider.opencodex silently outranks the generated block; the launcher warns. + test("detects a project config that redefines our provider key", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { [OPENCODE_PROVIDER_ID]: { npm: "x" } } })); + expect(projectConfigOverridesProvider(dir)).toBe(join(dir, "opencode.json")); + }); + + test("ignores a project config that defines other providers", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + writeFileSync(join(dir, "opencode.json"), JSON.stringify({ provider: { other: { npm: "x" } } })); + expect(projectConfigOverridesProvider(dir)).toBeNull(); + }); + + test("no project config is not a warning", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-opencode-proj-")); + expect(projectConfigOverridesProvider(dir)).toBeNull(); + }); +}); + +describe("ocx opencode env assembly", () => { + test("OPENCODE_CONFIG always points at the generated file", () => { + // The launcher has already absorbed the user's own config, so honouring a + // pre-existing export here would drop the provider block this command installs. + const env = buildOpencodeEnv("/gen/opencode-config.json", "sk-ocx-123", { OPENCODE_CONFIG: "/user/mine.json", PATH: "/bin" }); + expect(env.OPENCODE_CONFIG).toBe("/gen/opencode-config.json"); + expect(env.PATH).toBe("/bin"); + }); + + test("the admission key travels in the child env, matching the config's {env:…} reference", () => { + const env = buildOpencodeEnv("/gen/c.json", "sk-ocx-123", {}); + expect(env[OPENCODE_API_KEY_ENV]).toBe("sk-ocx-123"); + }); +}); + +describe("ocx opencode admission key", () => { + // A non-loopback bind requires OPENCODEX_API_AUTH_TOKEN and may have no apiKeys at + // all; emitting the placeholder there would 401 every request. + test("the environment token wins over a configured API key", () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeApiKey(config, { OPENCODEX_API_AUTH_TOKEN: "sk-env" })).toBe("sk-env"); + }); + + test("falls back to the configured proxy API key", () => { + const config = cfg({ apiKeys: [{ id: "1", name: "main", key: "sk-cfg", createdAt: "2026-01-01" }] }); + expect(opencodeApiKey(config, {})).toBe("sk-cfg"); + }); + + test("falls back to a placeholder on an open loopback proxy", () => { + expect(opencodeApiKey(cfg(), {})).toBe("ocx"); + }); +}); + +describe("ocx opencode not-found hint", () => { + test("cmd.exe reports command-not-found as 9009", () => { + expect(opencodeNotFoundHint(9009, null, "win32")).toContain("npm install -g opencode-ai"); + }); + + test("signal exits and other platforms are not hints", () => { + expect(opencodeNotFoundHint(9009, "SIGTERM", "win32")).toBeNull(); + expect(opencodeNotFoundHint(9009, null, "linux")).toBeNull(); + expect(opencodeNotFoundHint(0, null, "win32")).toBeNull(); + }); +});