diff --git a/CHANGELOG.md b/CHANGELOG.md index d86b587..e71d24e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Releases still mark tagged builds. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Tools + +- **OpenCode 2 support.** `zro install opencode2` (aliases `oc2` and `opencode-v2`) launches the + OpenCode 2 client/server agent with isolated config, `--standalone`, Zro models, and MCP. +- OpenCode and OpenCode 2 config copies now skip symlinks and credential files + (`service.json`, `auth.json`, `mcp-auth.json`, `.env*`). + ## [0.4.1] - 2026-10-28 ### Added diff --git a/README.md b/README.md index a463ce8..3cbc62c 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ unavailable in this mode. | [Kilo Code](https://kilocode.ai/) | `zro install kilo` | Kilo's terminal coding agent | | [Oh My Pi](https://ohmy.ai) | `zro install omp` | Power-user terminal agent | | [OpenCode](https://opencode.ai) | `zro install opencode` | Open-source coding agent | +| [OpenCode 2](https://opencode.ai) | `zro install opencode2` | OpenCode 2.0 client/server agent | | [Grok Build](https://x.ai) | `zro install grok` | xAI's coding agent | | [Hermes](https://hermes-agent.nousresearch.com) | `zro install hermes` | Nous Research's agent | | [OpenClaw](https://github.com/openclaw/openclaw) | `zro install openclaw` | Personal AI assistant | diff --git a/package.json b/package.json index affe1a3..f83238e 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "omp", "openclaw", "opencode", + "opencode2", "pi", "prime-agent", "prime-intellect" diff --git a/scripts/ci/check-clients.mjs b/scripts/ci/check-clients.mjs index 43d4421..72d5cc6 100644 --- a/scripts/ci/check-clients.mjs +++ b/scripts/ci/check-clients.mjs @@ -14,7 +14,7 @@ if (!apiKey || apiKey === "ci-fake-key") { } const zroBin = process.env.ZRO_BIN || "zro"; const reportPath = process.env.ZRO_REPORT || path.resolve("artifacts/zro-models-report.json"); -const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "pi", "hermes", "openclaw", "prime"]; +const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "opencode2", "pi", "hermes", "openclaw", "prime"]; const requestedHarness = process.argv[2] || "all"; if (requestedHarness !== "all" && !supportedHarnesses.includes(requestedHarness)) { throw new Error(`Unknown harness ${requestedHarness}. Expected one of: ${supportedHarnesses.join(", ")}`); @@ -68,6 +68,7 @@ try { kilo: ["kilo", "--version"], omp: ["omp", "--version"], opencode: ["opencode", "--version"], + opencode2: ["opencode2", "--version"], pi: ["pi", "--version"], hermes: ["hermes", "--version"], openclaw: ["openclaw", "--version"], @@ -123,6 +124,32 @@ try { passed("OpenCode lists all Zro models"); } + if (harnesses.includes("opencode2")) { + const openCode2Output = run( + zroBin, + ["launch", "opencode2", "--model", selectedId, "--", "models"], + "OpenCode 2 model list" + ); + const listed = openCode2Output + .trim() + .split(/\r?\n/) + .filter((line) => line.startsWith(`${PROVIDER_ID}/`)) + .sort(); + assert.deepEqual(listed, expectedModelIds); + passed("OpenCode 2 lists all Zro models"); + + const configPath = run( + zroBin, + ["launch", "opencode2", "--model", selectedId, "--", "debug", "paths", "config"], + "OpenCode 2 config path" + ).trim(); + assert.ok( + configPath.includes(`${path.sep}zro${path.sep}sessions${path.sep}session-`), + `OpenCode 2 config escaped the zro session: ${configPath}` + ); + passed("OpenCode 2 keeps its config isolated"); + } + if (harnesses.includes("kilo")) { const kiloOutput = run( zroBin, diff --git a/scripts/ci/check-live.mjs b/scripts/ci/check-live.mjs index d0fc12a..e52201c 100644 --- a/scripts/ci/check-live.mjs +++ b/scripts/ci/check-live.mjs @@ -23,7 +23,7 @@ if (!apiKey || apiKey === "ci-fake-key") { const zroBin = process.env.ZRO_BIN || "zro"; const reportPath = process.env.ZRO_REPORT || path.resolve("artifacts/zro-live-report.json"); const runId = (process.env.ZRO_CI_RUN_ID || `${Date.now()}`).replace(/[^A-Za-z0-9_.-]/g, "-"); -const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "pi", "hermes", "openclaw", "prime"]; +const supportedHarnesses = ["claude", "codex", "grok", "kilo", "omp", "opencode", "opencode2", "pi", "hermes", "openclaw", "prime"]; const requestedHarness = process.argv[2] || "all"; if (requestedHarness !== "all" && !supportedHarnesses.includes(requestedHarness)) { throw new Error(`Unknown harness ${requestedHarness}. Expected one of: ${supportedHarnesses.join(", ")}`); @@ -119,6 +119,7 @@ try { else if (harness === "kilo") await checkKilo(); else if (harness === "omp") await checkOmp(); else if (harness === "opencode") await checkOpenCode(); + else if (harness === "opencode2") await checkOpenCode2(); else if (harness === "pi") await checkPi(); else if (harness === "prime") await checkPrime(); else if (SKIPPED_LIVE_HARNESSES.has(harness)) skipped(harness); @@ -155,6 +156,7 @@ async function collectVersions() { kilo: ["kilo", "--version"], omp: ["omp", "--version"], opencode: ["opencode", "--version"], + opencode2: ["opencode2", "--version"], pi: ["pi", "--version"], hermes: ["hermes", "--version"], openclaw: ["openclaw", "--version"], @@ -318,6 +320,45 @@ async function checkOpenCode() { passed("opencode.reasoning.max", { model: liveModel, reasoningTokens: max.tokens.reasoning }); } +async function checkOpenCode2() { + const marker = "ZRO_OPENCODE2_CACHE_OK"; + const prompt = `Live cache probe ${runId}. Reply with exactly ${marker}.`; + const warmArgs = [ + "launch", "opencode2", "--model", liveModel, "--", "run", + "--format", "json", "--auto", + "--model", `zro/${liveModel}#${liveLevels.offId}`, prompt + ]; + const readArgs = [ + "launch", "opencode2", "--model", liveModel, "--", "run", "--continue", + "--format", "json", "--auto", + "--model", `zro/${liveModel}#${liveLevels.offId}`, prompt + ]; + + const { first, second } = await probeCache({ + warmArgs, + readArgs, + parse: (turn) => openCode2Turn(turn, marker), + readTokens: (turn) => turn.tokens.cache.read, + label: "OpenCode 2" + }); + passed("opencode2.cache", { + model: liveModel, + effort: liveLevels.offId, + firstCacheRead: first.tokens.cache.read, + secondCacheRead: second.tokens.cache.read + }); + + const reasoningMarker = "ZRO_OPENCODE2_MAX_OK"; + const max = await retryProbe("OpenCode 2 max reasoning", 3, async () => openCode2Turn(await runZro([ + "launch", "opencode2", "--model", liveModel, "--", "run", + "--format", "json", "--auto", + "--model", `zro/${liveModel}#${liveLevels.maxId}`, + `Think briefly, then include ${reasoningMarker} in the answer.` + ], "OpenCode 2 max reasoning", REASONING_TIMEOUT_MS), reasoningMarker)); + assert.ok(max.tokens.reasoning > 0, "OpenCode 2 max effort reported no reasoning tokens"); + passed("opencode2.reasoning.max", { model: liveModel, reasoningTokens: max.tokens.reasoning }); +} + async function checkKilo() { const marker = "ZRO_KILO_CACHE_OK"; const prompt = `Live cache probe ${runId}. Reply with exactly ${marker}.`; @@ -519,6 +560,25 @@ function openCodeTurn(result, marker) { return completed.part; } +async function openCode2Turn(result, marker) { + const events = jsonLines(result.stdout); + const text = events + .filter((event) => event.type === "text") + .map((event) => event.part?.text || "") + .join(""); + assert.match(text, new RegExp(marker)); + const sessionID = events.find((event) => typeof event.sessionID === "string")?.sessionID; + assert.ok(sessionID, "OpenCode 2 emitted no session ID"); + const output = await runZro([ + "launch", "opencode2", "--model", liveModel, "--", + "api", "GET", `/api/session/${sessionID}/message` + ], "OpenCode 2 session messages"); + const messages = JSON.parse(output.stdout); + const assistant = messages.data?.findLast((message) => message.type === "assistant" && message.tokens); + assert.ok(assistant?.tokens, "OpenCode 2 session emitted no assistant token usage"); + return assistant; +} + function kiloTurn(result, marker) { const events = jsonLines(result.stdout); const text = events @@ -614,9 +674,9 @@ async function probeCache({ warmArgs, readArgs, parse, readTokens, label, attemp for (let attempt = 1; attempt <= attempts; attempt++) { try { const suffix = attempt > 1 ? ` (attempt ${attempt}/${attempts})` : ""; - first = parse(await runZro(warmArgs, `${label} cache warm-up${suffix}`)); + first = await parse(await runZro(warmArgs, `${label} cache warm-up${suffix}`)); await afterWarm(); - second = parse(await runZro(readArgs, `${label} cache read${suffix}`)); + second = await parse(await runZro(readArgs, `${label} cache read${suffix}`)); if (readTokens(second) > 0) return { first, second }; console.log(` ${label}: cache miss (${attempt}/${attempts}), re-warming...`); } catch (error) { diff --git a/src/catalog.ts b/src/catalog.ts index 7b3de64..6a657b4 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -21,6 +21,7 @@ export const TOOLS: readonly ToolDescriptor[] = [ { id: "kilo", name: "Kilo Code", hint: "Kilo's terminal coding agent", aliases: ["kc", "kilocode"], executable: "kilo", package: "@kilocode/cli@latest" }, { id: "omp", name: "Oh My Pi", hint: "Power-user terminal coding agent", aliases: ["oh-my-pi", "ohmypi"], executable: "omp", installer: { url: "https://omp.sh/install", args: ["--binary"], binDir: ".local/bin" } }, { id: "opencode", name: "OpenCode", hint: "Open source coding agent", aliases: ["oc"], executable: "opencode", package: "opencode-ai@latest" }, + { id: "opencode2", name: "OpenCode 2", hint: "OpenCode 2.0 client/server agent", aliases: ["oc2", "opencode-v2"], executable: "opencode2", installer: { url: "https://opencode.ai/v2/install", binDir: ".opencode/bin" } }, { id: "grok", name: "Grok Build", hint: "xAI's coding agent", aliases: ["gr"], executable: "grok", installer: { url: "https://x.ai/cli/install.sh", binDir: ".local/bin" } }, { id: "hermes", name: "Hermes", hint: "Nous Research's agent", aliases: ["h"], executable: "hermes", installer: { url: "https://hermes-agent.nousresearch.com/install.sh", args: ["--skip-setup"], binDir: ".local/bin" } }, { id: "openclaw", name: "OpenClaw", hint: "Personal AI assistant", aliases: ["claw"], executable: "openclaw", package: "openclaw@latest" }, diff --git a/src/engine/constants.ts b/src/engine/constants.ts index 97e9a13..aa7d1ff 100644 --- a/src/engine/constants.ts +++ b/src/engine/constants.ts @@ -41,4 +41,4 @@ export function supportsAttachments(model: Pick): boolea return model.modalities.input.some((modality) => modality !== "text"); } -export const SUPPORTED_TOOLS = ["claude", "codex", "codex-app", "grok", "kilo", "omp", "opencode", "hermes", "openclaw", "pi", "prime"] as const; +export const SUPPORTED_TOOLS = ["claude", "codex", "codex-app", "grok", "kilo", "omp", "opencode", "opencode2", "hermes", "openclaw", "pi", "prime"] as const; diff --git a/src/engine/tools/opencode-shared.ts b/src/engine/tools/opencode-shared.ts new file mode 100644 index 0000000..50d3fb2 --- /dev/null +++ b/src/engine/tools/opencode-shared.ts @@ -0,0 +1,120 @@ +import fs from "node:fs/promises"; +import type { Dirent, Stats } from "node:fs"; +import path from "node:path"; +import { json5Serializer } from "../serializers.js"; +import type { LaunchFile } from "../types.js"; +import { asPlainObject } from "./helpers.js"; + +export const OPENCODE_CONFIG_FILES = ["config.json", "opencode.json", "opencode.jsonc"] as const; +const GENERATED_DIRECTORIES = new Set([".git", "node_modules"]); +const PRIVATE_FILES = new Set(["service.json", "auth.json", "mcp-auth.json"]); + +function isPrivateFile(name: string): boolean { + return PRIVATE_FILES.has(name) || name === ".env" || name.startsWith(".env."); +} + +export async function readOpenCodeConfig(configRoot: string): Promise> { + let config: Record = {}; + for (const fileName of OPENCODE_CONFIG_FILES) { + const contents = await readOptionalFile(path.join(configRoot, fileName), "utf8"); + if (contents === undefined) continue; + const parsed = json5Serializer.parse(contents) as Record; + config = mergeConfig(config, parsed); + } + return config; +} + +export function mergeConfig( + base: Record, + override: Record +): Record { + const merged = { ...base }; + for (const [key, value] of Object.entries(override)) { + const baseObject = asPlainObject(merged[key]); + const overrideObject = asPlainObject(value); + merged[key] = baseObject && overrideObject + ? mergeConfig(baseObject, overrideObject) + : value; + } + return merged; +} + +export async function collectUserConfigFiles(srcRoot: string, destRoot: string): Promise { + const files: LaunchFile[] = []; + const entries = await readOptionalDirectory(srcRoot); + if (!entries) return files; + + const configFiles = new Set(OPENCODE_CONFIG_FILES); + const ancestors = new Set([await fs.realpath(srcRoot)]); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (configFiles.has(entry.name) || GENERATED_DIRECTORIES.has(entry.name) || isPrivateFile(entry.name)) continue; + await collectPath( + path.join(srcRoot, entry.name), + path.join(destRoot, entry.name), + files, + ancestors + ); + } + return files; +} + +async function collectPath( + srcPath: string, + destPath: string, + files: LaunchFile[], + ancestorDirectories: Set +): Promise { + const stats = await readOptionalStats(srcPath); + if (!stats || stats.isSymbolicLink()) return; + if (stats.isFile()) { + files.push({ path: destPath, contents: await fs.readFile(srcPath) }); + return; + } + if (!stats.isDirectory()) return; + + const realPath = await fs.realpath(srcPath); + if (ancestorDirectories.has(realPath)) return; + const nextAncestors = new Set(ancestorDirectories).add(realPath); + const entries = await fs.readdir(srcPath, { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (GENERATED_DIRECTORIES.has(entry.name) || isPrivateFile(entry.name)) continue; + await collectPath( + path.join(srcPath, entry.name), + path.join(destPath, entry.name), + files, + nextAncestors + ); + } +} + +async function readOptionalDirectory(dirPath: string): Promise { + try { + return await fs.readdir(dirPath, { withFileTypes: true }); + } catch (error) { + if (isMissingPathError(error)) return undefined; + throw error; + } +} + +async function readOptionalFile(filePath: string, encoding: "utf8"): Promise { + try { + return await fs.readFile(filePath, encoding); + } catch (error) { + if (isMissingPathError(error)) return undefined; + throw error; + } +} + +async function readOptionalStats(filePath: string): Promise { + try { + return await fs.lstat(filePath); + } catch (error) { + if (isMissingPathError(error)) return undefined; + throw error; + } +} + +function isMissingPathError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR"); +} diff --git a/src/engine/tools/opencode.ts b/src/engine/tools/opencode.ts index 60c50e3..f43cb5e 100644 --- a/src/engine/tools/opencode.ts +++ b/src/engine/tools/opencode.ts @@ -1,13 +1,9 @@ -import fs from "node:fs/promises"; -import type { Dirent, Stats } from "node:fs"; import path from "node:path"; import { BASE_URL, MCP_URL, PROVIDER_ID, PROVIDER_NAME, ZRO_ENV_KEY, supportsAttachments, type ZroModel } from "../constants.js"; -import { json5Serializer, jsonSerializer } from "../serializers.js"; -import type { LaunchFile, ToolModule } from "../types.js"; +import { jsonSerializer } from "../serializers.js"; +import type { ToolModule } from "../types.js"; import { asPlainObject, objectAt } from "./helpers.js"; - -const CONFIG_FILES = ["config.json", "opencode.json", "opencode.jsonc"] as const; -const GENERATED_DIRECTORIES = new Set([".git", "node_modules"]); +import { collectUserConfigFiles, readOpenCodeConfig } from "./opencode-shared.js"; export const opencodeTool: ToolModule = { id: "opencode", @@ -47,112 +43,6 @@ export const opencodeTool: ToolModule = { } }; -async function readOpenCodeConfig(configRoot: string): Promise> { - let config: Record = {}; - for (const fileName of CONFIG_FILES) { - const contents = await readOptionalFile(path.join(configRoot, fileName), "utf8"); - if (contents === undefined) continue; - const parsed = json5Serializer.parse(contents) as Record; - config = mergeConfig(config, parsed); - } - return config; -} - -function mergeConfig( - base: Record, - override: Record -): Record { - const merged = { ...base }; - for (const [key, value] of Object.entries(override)) { - const baseObject = asPlainObject(merged[key]); - const overrideObject = asPlainObject(value); - merged[key] = baseObject && overrideObject - ? mergeConfig(baseObject, overrideObject) - : value; - } - return merged; -} - -async function collectUserConfigFiles(srcRoot: string, destRoot: string): Promise { - const files: LaunchFile[] = []; - const entries = await readOptionalDirectory(srcRoot); - if (!entries) return files; - - const configFiles = new Set(CONFIG_FILES); - const ancestors = new Set([await fs.realpath(srcRoot)]); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (configFiles.has(entry.name) || GENERATED_DIRECTORIES.has(entry.name)) continue; - await collectPath( - path.join(srcRoot, entry.name), - path.join(destRoot, entry.name), - files, - ancestors - ); - } - return files; -} - -async function collectPath( - srcPath: string, - destPath: string, - files: LaunchFile[], - ancestorDirectories: Set -): Promise { - const stats = await readOptionalStats(srcPath); - if (!stats) return; - if (stats.isFile()) { - files.push({ path: destPath, contents: await fs.readFile(srcPath) }); - return; - } - if (!stats.isDirectory()) return; - - const realPath = await fs.realpath(srcPath); - if (ancestorDirectories.has(realPath)) return; - const nextAncestors = new Set(ancestorDirectories).add(realPath); - const entries = await fs.readdir(srcPath, { withFileTypes: true }); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (GENERATED_DIRECTORIES.has(entry.name)) continue; - await collectPath( - path.join(srcPath, entry.name), - path.join(destPath, entry.name), - files, - nextAncestors - ); - } -} - -async function readOptionalDirectory(dirPath: string): Promise { - try { - return await fs.readdir(dirPath, { withFileTypes: true }); - } catch (error) { - if (isMissingPathError(error)) return undefined; - throw error; - } -} - -async function readOptionalFile(filePath: string, encoding: "utf8"): Promise { - try { - return await fs.readFile(filePath, encoding); - } catch (error) { - if (isMissingPathError(error)) return undefined; - throw error; - } -} - -async function readOptionalStats(filePath: string): Promise { - try { - return await fs.stat(filePath); - } catch (error) { - if (isMissingPathError(error)) return undefined; - throw error; - } -} - -function isMissingPathError(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error && - (error.code === "ENOENT" || error.code === "ENOTDIR"); -} - export function buildOpenCodeConfig( existing: Record, apiKey: string, diff --git a/src/engine/tools/opencode2.ts b/src/engine/tools/opencode2.ts new file mode 100644 index 0000000..7b55048 --- /dev/null +++ b/src/engine/tools/opencode2.ts @@ -0,0 +1,148 @@ +import path from "node:path"; +import { BASE_URL, MCP_URL, PROVIDER_ID, PROVIDER_NAME, ZRO_ENV_KEY, type ZroModel } from "../constants.js"; +import { jsonSerializer } from "../serializers.js"; +import type { ToolModule } from "../types.js"; +import { asPlainObject, objectAt } from "./helpers.js"; +import { collectUserConfigFiles, readOpenCodeConfig } from "./opencode-shared.js"; + +export const opencode2Tool: ToolModule = { + id: "opencode2", + label: "OpenCode 2", + async launch(ctx) { + const configRoot = path.join(ctx.tempDir, "xdg"); + const opencodeRoot = path.join(configRoot, "opencode"); + const filePath = path.join(opencodeRoot, "opencode.json"); + const userConfigRoot = ctx.env.XDG_CONFIG_HOME ?? path.join(ctx.homeDir, ".config"); + const userOpencodeRoot = path.join(userConfigRoot, "opencode"); + const existing = await readOpenCodeConfig(userOpencodeRoot); + const nextConfig = buildOpenCode2Config(existing, ctx.apiKey, ctx.models, true, { + baseUrl: ctx.inferenceBaseUrl, + mcpUrl: ctx.mcpUrl, + noAuth: ctx.noAuth + }); + nextConfig.model = `${PROVIDER_ID}/${ctx.model}`; + const userFiles = await collectUserConfigFiles(userOpencodeRoot, opencodeRoot); + const isDebug = ctx.extraArgs[0] === "debug"; + const hasServerFlag = ctx.extraArgs.includes("--standalone") || ctx.extraArgs.includes("--server"); + const args = isDebug || hasServerFlag + ? [...ctx.extraArgs] + : ["--standalone", ...ctx.extraArgs]; + + return { + tool: "opencode2", + label: "OpenCode 2", + model: ctx.model, + command: "opencode2", + args, + env: { + XDG_CONFIG_HOME: configRoot, + [ZRO_ENV_KEY]: ctx.apiKey, + DO_NOT_TRACK: "1", + OPENCODE_DISABLE_AUTOUPDATE: "1" + }, + files: [ + { path: filePath, contents: jsonSerializer.stringify(nextConfig) }, + ...userFiles + ], + message: "Launch OpenCode 2 with Zro" + }; + } +}; + +export function buildOpenCode2Config( + existing: Record, + apiKey: string, + modelSpecs: readonly ZroModel[], + useEnvReference = false, + endpoints: { baseUrl?: string; mcpUrl?: string; noAuth?: boolean } = {} +): Record { + const next = { ...existing }; + if (typeof next.$schema !== "string") { + next.$schema = "https://opencode.ai/config.json"; + } + + const providers = objectAt(next, "providers"); + const existingProvider = asPlainObject(providers[PROVIDER_ID]) ?? {}; + const settings = asPlainObject(existingProvider.settings) ?? {}; + const models = { ...(asPlainObject(existingProvider.models) ?? {}) }; + for (const model of modelSpecs) { + const existingModel = asPlainObject(models[model.id]) ?? {}; + models[model.id] = { + ...existingModel, + name: model.id, + capabilities: { + ...(asPlainObject(existingModel.capabilities) ?? {}), + tools: true, + input: [...model.modalities.input], + output: [...model.modalities.output] + }, + limit: { + ...(asPlainObject(existingModel.limit) ?? {}), + context: model.contextWindow, + output: model.maxOutputTokens + }, + variants: mergeVariants(existingModel.variants, model) + }; + } + + const headers = asPlainObject(existingProvider.headers) ?? {}; + providers[PROVIDER_ID] = { + ...existingProvider, + name: PROVIDER_NAME, + package: "@opencode/ai/providers/openai-compatible", + settings: { + ...settings, + baseURL: endpoints.baseUrl ?? BASE_URL, + apiKey: useEnvReference ? `{env:${ZRO_ENV_KEY}}` : apiKey + }, + ...(Object.keys(headers).length ? { headers } : {}), + models + }; + + const legacyProvider = asPlainObject(next.provider); + if (legacyProvider) { + delete legacyProvider[PROVIDER_ID]; + if (Object.keys(legacyProvider).length === 0) delete next.provider; + } + + const mcp = objectAt(next, "mcp"); + delete mcp[PROVIDER_ID]; + const servers = objectAt(mcp, "servers"); + if (endpoints.noAuth) { + delete servers[PROVIDER_ID]; + } else { + servers[PROVIDER_ID] = { + type: "remote", + url: endpoints.mcpUrl ?? MCP_URL, + oauth: false, + headers: { + Authorization: `Bearer {env:${ZRO_ENV_KEY}}` + } + }; + } + return next; +} + +function mergeVariants(existing: unknown, model: ZroModel): Record[] { + const variants = Array.isArray(existing) + ? existing + .map((entry) => asPlainObject(entry)) + .filter((entry): entry is Record => typeof entry?.id === "string") + .map((entry) => ({ ...entry })) + : []; + for (const level of model.reasoning.levels) { + const index = variants.findIndex((variant) => variant.id === level.id); + const existingVariant = index >= 0 ? variants[index] : {}; + const next = { + ...existingVariant, + id: level.id, + settings: { + ...(asPlainObject(existingVariant.settings) ?? {}), + ...level.openCodeOptions + } + }; + if (index >= 0) variants[index] = next; + else variants.push(next); + } + return variants; +} diff --git a/src/run.ts b/src/run.ts index 3a6f7b2..aea4a6a 100644 --- a/src/run.ts +++ b/src/run.ts @@ -30,6 +30,7 @@ import { kiloTool } from "./engine/tools/kilo.js"; import { ompTool } from "./engine/tools/omp.js"; import { openClawTool } from "./engine/tools/openclaw.js"; import { opencodeTool } from "./engine/tools/opencode.js"; +import { opencode2Tool } from "./engine/tools/opencode2.js"; import { piTool } from "./engine/tools/pi.js"; import { primeTool } from "./engine/tools/prime.js"; import type { LaunchPlan, SpawnProcess, ToolId, ToolModule } from "./engine/types.js"; @@ -59,6 +60,7 @@ const tools: Record = { omp: ompTool, openclaw: openClawTool, opencode: opencodeTool, + opencode2: opencode2Tool, pi: piTool, prime: primeTool }; diff --git a/tests/adapters.test.ts b/tests/adapters.test.ts index 457f5c9..2265038 100644 --- a/tests/adapters.test.ts +++ b/tests/adapters.test.ts @@ -12,6 +12,7 @@ const adapters = [ ["hermes", "hermes"], ["openclaw", "openclaw"], ["opencode", "opencode"], + ["opencode2", "opencode2"], ["pi", "pi"], ["prime", "prime-agent"] ] as const; diff --git a/tests/args.test.ts b/tests/args.test.ts index 4f606e5..e1c6ca0 100644 --- a/tests/args.test.ts +++ b/tests/args.test.ts @@ -40,6 +40,11 @@ describe("the direct command language", () => { tool: "opencode", dryRun: true }); + expect(parseArgs(["oc2", "--dry-run"])).toMatchObject({ + command: "launch", + tool: "opencode2", + dryRun: true + }); expect(parseArgs(["kc", "--dry-run"])).toMatchObject({ command: "launch", tool: "kilo", diff --git a/tests/launch-install.test.ts b/tests/launch-install.test.ts index c7618ed..b356e59 100644 --- a/tests/launch-install.test.ts +++ b/tests/launch-install.test.ts @@ -110,6 +110,21 @@ describe("zro --install", () => { expect(calls.at(-1)?.command).toBe("grok"); }); + it("installs and launches OpenCode 2 through its official installer", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-install-opencode2-")); + const { spawn, calls } = harness(); + + const code = await run( + ["opencode2", "--install", "--api-key", "sk-test"], + launchIo(home, spawn, { PATH: "" }), + ); + + expect(code).toBe(0); + expect(calls[0].command).toBe("bash"); + expect(calls[0].args.join(" ")).toContain("https://opencode.ai/v2/install"); + expect(calls.at(-1)?.command).toBe("opencode2"); + }); + it("installs and launches Kilo Code through its npm package", async () => { const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-install-kilo-")); const { spawn, calls } = harness(); diff --git a/tests/no-auth.test.ts b/tests/no-auth.test.ts index a6e0431..c5081f6 100644 --- a/tests/no-auth.test.ts +++ b/tests/no-auth.test.ts @@ -15,6 +15,7 @@ import { kiloTool } from "../src/engine/tools/kilo.js"; import { ompTool } from "../src/engine/tools/omp.js"; import { openClawTool } from "../src/engine/tools/openclaw.js"; import { opencodeTool } from "../src/engine/tools/opencode.js"; +import { opencode2Tool } from "../src/engine/tools/opencode2.js"; import { piTool } from "../src/engine/tools/pi.js"; import { primeTool } from "../src/engine/tools/prime.js"; import { run } from "../src/run.js"; @@ -33,6 +34,7 @@ const tools: readonly ToolModule[] = [ ompTool, openClawTool, opencodeTool, + opencode2Tool, piTool, primeTool, ]; diff --git a/tests/opencode.test.ts b/tests/opencode.test.ts new file mode 100644 index 0000000..2d29996 --- /dev/null +++ b/tests/opencode.test.ts @@ -0,0 +1,65 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { BASE_URL, MCP_URL } from "../src/engine/constants.js"; +import { opencodeTool } from "../src/engine/tools/opencode.js"; +import type { LaunchContext } from "../src/engine/types.js"; +import { TEST_MODELS } from "./fixtures.js"; + +describe("OpenCode adapter", () => { + it("copies safe user files but skips credentials, env files, and symlinks", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-opencode-home-")); + const tempDir = path.join(home, "session"); + const userRoot = path.join(home, "config", "opencode"); + await fs.mkdir(path.join(userRoot, "agents"), { recursive: true }); + await fs.writeFile(path.join(userRoot, "agents", "helper.md"), "Helpful agent\n"); + await fs.writeFile(path.join(userRoot, "auth.json"), '{"token":"do-not-copy"}\n'); + await fs.writeFile(path.join(userRoot, "service.json"), '{"password":"do-not-copy"}\n'); + await fs.writeFile(path.join(userRoot, ".env"), "TOKEN=do-not-copy\n"); + const outside = path.join(home, "outside-secret.md"); + await fs.writeFile(outside, "do-not-copy-outside\n"); + if (process.platform !== "win32") { + await fs.symlink(outside, path.join(userRoot, "agents", "linked.md")); + } + + const plan = await opencodeTool.launch( + context(home, tempDir, { XDG_CONFIG_HOME: path.join(home, "config") }), + ); + + expect(plan).toMatchObject({ tool: "opencode", command: "opencode", model: "glm-5.3" }); + expect( + plan.files?.some((file) => file.path === path.join(tempDir, "xdg", "opencode", "agents", "helper.md")), + ).toBe(true); + for (const name of ["auth.json", "service.json", ".env", "linked.md"]) { + expect(plan.files?.some((file) => path.basename(file.path) === name)).toBe(false); + } + const copied = (plan.files ?? []) + .map((file) => typeof file.contents === "string" ? file.contents : Buffer.from(file.contents).toString()) + .join("\n"); + expect(copied).not.toContain("do-not-copy"); + }); +}); + +function context(homeDir: string, tempDir: string, env: NodeJS.ProcessEnv = {}): LaunchContext { + return { + apiKey: "sk-opencode-secret", + apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: BASE_URL, + mcpUrl: MCP_URL, + noAuth: false, + env, + model: "glm-5.3", + models: TEST_MODELS, + extraArgs: [], + homeDir, + cwd: homeDir, + tempDir, + platform: "linux", + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }; +} diff --git a/tests/opencode2.test.ts b/tests/opencode2.test.ts new file mode 100644 index 0000000..6007389 --- /dev/null +++ b/tests/opencode2.test.ts @@ -0,0 +1,292 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { + BASE_URL, + MCP_URL, + PROVIDER_ID, + PROVIDER_NAME, + ZRO_ENV_KEY, +} from "../src/engine/constants.js"; +import { buildOpenCode2Config, opencode2Tool } from "../src/engine/tools/opencode2.js"; +import type { LaunchContext } from "../src/engine/types.js"; +import { TEST_MODELS } from "./fixtures.js"; + +describe("OpenCode 2 config", () => { + it("builds the native v2 provider, model, and MCP shape", () => { + const config = buildOpenCode2Config({}, "sk-zro", TEST_MODELS, true); + const providers = config.providers as Record; + const provider = providers[PROVIDER_ID]; + + expect(config.$schema).toBe("https://opencode.ai/config.json"); + expect(provider).toMatchObject({ + name: PROVIDER_NAME, + package: "@opencode/ai/providers/openai-compatible", + settings: { + baseURL: BASE_URL, + apiKey: `{env:${ZRO_ENV_KEY}}`, + }, + }); + expect(provider).not.toHaveProperty("headers"); + + const glm = provider.models["glm-5.3"]; + expect(glm.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }); + expect(glm.limit).toEqual({ + context: TEST_MODELS[0].contextWindow, + output: TEST_MODELS[0].maxOutputTokens, + }); + expect(glm.variants).toEqual( + TEST_MODELS[0].reasoning.levels.map((level) => ({ + id: level.id, + settings: level.openCodeOptions, + })), + ); + + const kimi = provider.models["kimi-k3"]; + expect(kimi.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] }); + + expect(config.mcp).toEqual({ + servers: { + [PROVIDER_ID]: { + type: "remote", + url: MCP_URL, + oauth: false, + headers: { Authorization: `Bearer {env:${ZRO_ENV_KEY}}` }, + }, + }, + }); + }); + + it("writes the literal key when env references are disabled", () => { + const config = buildOpenCode2Config({}, "sk-zro", TEST_MODELS, false); + const provider = (config.providers as Record)[PROVIDER_ID]; + expect(provider.settings.apiKey).toBe("sk-zro"); + }); + + it("honors gateway endpoints and skips the MCP server in no-auth mode", () => { + const gateway = "https://gateway.example/v1"; + const mcp = "https://gateway.example/mcp/zro"; + const config = buildOpenCode2Config({}, "sk-zro", TEST_MODELS, true, { + baseUrl: gateway, + mcpUrl: mcp, + }); + const provider = (config.providers as Record)[PROVIDER_ID]; + expect(provider.settings.baseURL).toBe(gateway); + expect((config.mcp as Record).servers[PROVIDER_ID].url).toBe(mcp); + + const noAuth = buildOpenCode2Config( + { mcp: { servers: { [PROVIDER_ID]: { type: "remote", url: "https://old.invalid" } } } }, + "sk-zro", + TEST_MODELS, + true, + { noAuth: true }, + ); + expect((noAuth.mcp as Record).servers[PROVIDER_ID]).toBeUndefined(); + }); + + it("preserves existing provider headers", () => { + const config = buildOpenCode2Config( + { providers: { [PROVIDER_ID]: { headers: { "x-custom": "1" } } } }, + "sk-zro", + TEST_MODELS, + true, + ); + expect((config.providers as Record)[PROVIDER_ID].headers).toEqual({ "x-custom": "1" }); + }); + + it("preserves other providers and MCP servers", () => { + const existing = { + providers: { other: { name: "Other", settings: { baseURL: "https://other.invalid" } } }, + mcp: { servers: { other: { type: "local", command: ["other"] } } }, + }; + const config = buildOpenCode2Config(existing, "sk-zro", TEST_MODELS, true); + expect((config.providers as Record).other).toEqual(existing.providers.other); + expect((config.mcp as Record).servers.other).toEqual(existing.mcp.servers.other); + }); + + it("drops legacy v1 zro entries so only the native v2 config remains", () => { + const existing = { + provider: { + [PROVIDER_ID]: { npm: "@ai-sdk/openai-compatible", options: { apiKey: "old" } }, + other: { npm: "@ai-sdk/openai-compatible" }, + }, + mcp: { + [PROVIDER_ID]: { type: "remote", url: "https://old.invalid" }, + servers: { other: { type: "local", command: ["other"] } }, + }, + }; + const config = buildOpenCode2Config(existing, "sk-zro", TEST_MODELS, true); + + const provider = config.provider as Record; + expect(provider[PROVIDER_ID]).toBeUndefined(); + expect(provider.other).toEqual(existing.provider.other); + + const mcp = config.mcp as Record; + expect(mcp[PROVIDER_ID]).toBeUndefined(); + expect(mcp.servers.other).toEqual(existing.mcp.servers.other); + expect(mcp.servers[PROVIDER_ID]).toBeDefined(); + }); + + it("removes an empty legacy provider map", () => { + const config = buildOpenCode2Config( + { provider: { [PROVIDER_ID]: { npm: "@ai-sdk/openai-compatible" } } }, + "sk-zro", + TEST_MODELS, + true, + ); + expect(config).not.toHaveProperty("provider"); + }); + + it("merges v2 variant arrays by id and ignores v1 object variants", () => { + const existing = { + providers: { + [PROVIDER_ID]: { + models: { + "glm-5.3": { + variants: [ + { id: "high", settings: { textVerbosity: "low" }, keep: true }, + { id: "custom", settings: { foo: "bar" } }, + ], + }, + }, + }, + }, + }; + const config = buildOpenCode2Config(existing, "sk-zro", TEST_MODELS, true); + const variants = (config.providers as Record)[PROVIDER_ID].models["glm-5.3"].variants; + expect(Array.isArray(variants)).toBe(true); + expect(variants).toContainEqual({ + id: "high", + settings: { reasoningEffort: "high", textVerbosity: "low" }, + keep: true, + }); + expect(variants).toContainEqual({ id: "custom", settings: { foo: "bar" } }); + + const v1 = buildOpenCode2Config( + { + providers: { + [PROVIDER_ID]: { + models: { "glm-5.3": { variants: { high: { reasoningEffort: "low" } } } }, + }, + }, + }, + "sk-zro", + TEST_MODELS, + true, + ); + const v1Variants = (v1.providers as Record)[PROVIDER_ID].models["glm-5.3"].variants; + expect(Array.isArray(v1Variants)).toBe(true); + expect(v1Variants).not.toContainEqual({ id: "high", settings: { reasoningEffort: "low" } }); + }); +}); + +describe("OpenCode 2 launch plan", () => { + it("isolates config, uses --standalone, and copies user files", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-opencode2-home-")); + const tempDir = path.join(home, "session"); + const userRoot = path.join(home, "config", "opencode"); + await fs.mkdir(path.join(userRoot, "agents"), { recursive: true }); + await fs.writeFile(path.join(userRoot, "agents", "helper.md"), "Helpful agent\n"); + await fs.writeFile(path.join(userRoot, "service.json"), '{"password":"do-not-copy"}\n'); + await fs.writeFile(path.join(userRoot, "auth.json"), '{"token":"do-not-copy"}\n'); + await fs.writeFile(path.join(userRoot, ".env.local"), "TOKEN=do-not-copy\n"); + await fs.writeFile(path.join(userRoot, "agents", ".env.local"), "TOKEN=do-not-copy\n"); + const outside = path.join(home, "outside-secret.md"); + await fs.writeFile(outside, "do-not-copy-outside\n"); + if (process.platform !== "win32") { + await fs.symlink(outside, path.join(userRoot, "agents", "linked.md")); + } + await fs.writeFile( + path.join(userRoot, "opencode.json"), + JSON.stringify({ mcp: { servers: { other: { type: "local", command: ["other"] } } } }), + ); + + const plan = await opencode2Tool.launch( + context(home, tempDir, { XDG_CONFIG_HOME: path.join(home, "config") }), + ); + + expect(plan).toMatchObject({ + tool: "opencode2", + label: "OpenCode 2", + command: "opencode2", + model: "glm-5.3", + message: "Launch OpenCode 2 with Zro", + }); + expect(plan.args[0]).toBe("--standalone"); + expect(plan.env?.XDG_CONFIG_HOME).toBe(path.join(tempDir, "xdg")); + expect(plan.env?.XDG_CONFIG_HOME?.startsWith(tempDir)).toBe(true); + expect(plan.env).toMatchObject({ + [ZRO_ENV_KEY]: "sk-opencode2-secret", + DO_NOT_TRACK: "1", + OPENCODE_DISABLE_AUTOUPDATE: "1", + }); + + const configPath = path.join(tempDir, "xdg", "opencode", "opencode.json"); + const configFile = plan.files?.find((file) => file.path === configPath); + expect(configFile).toBeDefined(); + const config = JSON.parse(String(configFile!.contents)) as Record; + expect(config.model).toBe("zro/glm-5.3"); + expect(config.providers.zro.package).toBe("@opencode/ai/providers/openai-compatible"); + expect(config.mcp.servers.other).toEqual({ type: "local", command: ["other"] }); + + expect( + plan.files?.some((file) => file.path === path.join(tempDir, "xdg", "opencode", "agents", "helper.md")), + ).toBe(true); + + for (const name of ["service.json", "auth.json", ".env.local", "linked.md"]) { + expect(plan.files?.some((file) => path.basename(file.path) === name)).toBe(false); + } + const copied = (plan.files ?? []) + .map((file) => typeof file.contents === "string" ? file.contents : Buffer.from(file.contents).toString()) + .join("\n"); + expect(copied).not.toContain("do-not-copy"); + }); + + it("does not duplicate --standalone or add it alongside --server", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-opencode2-args-")); + + const standalone = await opencode2Tool.launch( + context(home, path.join(home, "one"), {}, ["--standalone", "run"]), + ); + expect(standalone.args).toEqual(["--standalone", "run"]); + + const server = await opencode2Tool.launch( + context(home, path.join(home, "two"), {}, ["--server", "http://127.0.0.1:4096"]), + ); + expect(server.args).toEqual(["--server", "http://127.0.0.1:4096"]); + + const debug = await opencode2Tool.launch( + context(home, path.join(home, "three"), {}, ["debug", "paths", "config"]), + ); + expect(debug.args).toEqual(["debug", "paths", "config"]); + }); +}); + +function context( + homeDir: string, + tempDir: string, + env: NodeJS.ProcessEnv = {}, + extraArgs: string[] = [], +): LaunchContext { + return { + apiKey: "sk-opencode2-secret", + apiKeySource: "env", + inferenceRoot: "https://zro.moonmath.ai", + inferenceBaseUrl: BASE_URL, + mcpUrl: MCP_URL, + noAuth: false, + env, + model: "glm-5.3", + models: TEST_MODELS, + extraArgs, + homeDir, + cwd: homeDir, + tempDir, + platform: "linux", + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }; +}