From f625e84d010b327105c0884a45ccd906a8a5ecf1 Mon Sep 17 00:00:00 2001 From: LeonHibnik Date: Sat, 31 Oct 2026 23:30:24 +0200 Subject: [PATCH] Launch OpenCode 2 setup from zro opencode when v2 is installed Detect the installed OpenCode major version and reuse the OpenCode 2 launch plan (standalone environment and provider config) for v2, while zro opencode2 keeps working. --- CHANGELOG.md | 7 ++++++ README.md | 4 ++-- src/engine/tools/opencode.ts | 12 ++++++++++ src/engine/types.ts | 5 ++-- src/install.ts | 30 ++++++++++++++++++++++++ src/run.ts | 10 +++++++- tests/launch-install.test.ts | 44 ++++++++++++++++++++++++++++++++++++ tests/opencode.test.ts | 29 +++++++++++++++++++++++- 8 files changed, 135 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abff3b..fc7b4ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Releases still mark tagged builds. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Tools + +- `zro opencode` now checks the installed OpenCode version and launches OpenCode 2 with its + `--standalone` environment when v2 is installed. `zro opencode2` still works. + ## [0.4.2] - 2026-10-29 ### Tools diff --git a/README.md b/README.md index 3cbc62c..ecff664 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,8 @@ unavailable in this mode. | [Codex App](https://developers.openai.com/codex/) | `zro install codex-app` | OpenAI's desktop app | | [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 | +| [OpenCode](https://opencode.ai) | `zro install opencode` | Open-source coding agent; picks the OpenCode 2 setup when v2 is installed | +| [OpenCode 2](https://opencode.ai) | `zro install opencode2` | OpenCode 2.0 client/server agent (or use `zro opencode`) | | [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/src/engine/tools/opencode.ts b/src/engine/tools/opencode.ts index f43cb5e..039b337 100644 --- a/src/engine/tools/opencode.ts +++ b/src/engine/tools/opencode.ts @@ -4,11 +4,23 @@ import { jsonSerializer } from "../serializers.js"; import type { ToolModule } from "../types.js"; import { asPlainObject, objectAt } from "./helpers.js"; import { collectUserConfigFiles, readOpenCodeConfig } from "./opencode-shared.js"; +import { opencode2Tool } from "./opencode2.js"; export const opencodeTool: ToolModule = { id: "opencode", label: "opencode", async launch(ctx) { + if ((ctx.opencodeMajor ?? 1) >= 2) { + const plan = await opencode2Tool.launch(ctx); + return { + ...plan, + tool: "opencode", + label: "opencode", + command: "opencode", + message: "Launch opencode with Zro" + }; + } + const configRoot = path.join(ctx.tempDir, "xdg"); const opencodeRoot = path.join(configRoot, "opencode"); const filePath = path.join(opencodeRoot, "opencode.json"); diff --git a/src/engine/types.ts b/src/engine/types.ts index 96069c9..dc2ac1e 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -1,5 +1,5 @@ import type { Readable, Writable } from "node:stream"; -import type { ChildProcess } from "node:child_process"; +import type { ChildProcess, StdioOptions } from "node:child_process"; import type { SUPPORTED_TOOLS } from "./constants.js"; import type { ZroModel } from "./constants.js"; @@ -18,6 +18,7 @@ export interface LaunchContext { models: readonly ZroModel[]; extraArgs: string[]; modelAliases?: Record; + opencodeMajor?: number; homeDir: string; cwd: string; tempDir: string; @@ -53,7 +54,7 @@ export interface ToolModule { export interface SpawnOptions { cwd: string; env: NodeJS.ProcessEnv; - stdio: "inherit"; + stdio: StdioOptions; windowsVerbatimArguments?: boolean; } diff --git a/src/install.ts b/src/install.ts index bf4a7c2..3f7ea4f 100644 --- a/src/install.ts +++ b/src/install.ts @@ -1,4 +1,5 @@ import { spawn as nodeSpawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { resolveTool, TOOLS, type ToolDescriptor } from "./catalog.js"; @@ -73,6 +74,35 @@ export async function commandExists( return false; } +export async function commandVersion( + command: string, + io: RunIo, + env: NodeJS.ProcessEnv, +): Promise { + const spawn = io.spawn ?? (nodeSpawn as SpawnProcess); + let child: ChildProcess; + try { + child = spawnCommand(spawn, io.platform ?? process.platform, command, ["--version"], { + cwd: io.cwd, + env, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return undefined; + } + + let output = ""; + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + output += chunk; + }); + const exitCode = await new Promise((resolve) => { + child.once("error", () => resolve(null)); + child.once("exit", (code) => resolve(code)); + }); + return exitCode === 0 && output.trim() ? output.trim() : undefined; +} + function executableExtensions(command: string, pathExt: string | undefined): string[] { if (path.extname(command)) return [""]; return (pathExt || ".COM;.EXE;.BAT;.CMD") diff --git a/src/run.ts b/src/run.ts index aea4a6a..3faac8c 100644 --- a/src/run.ts +++ b/src/run.ts @@ -37,7 +37,7 @@ import type { LaunchPlan, SpawnProcess, ToolId, ToolModule } from "./engine/type import { parseArgs } from "./args.js"; import { isNoAuth, resolveControlPlaneRoot, resolveInference, resolveMcpUrl } from "./endpoints.js"; import { describeTool } from "./catalog.js"; -import { commandExists, ensureHarnessInstalled, install } from "./install.js"; +import { commandExists, commandVersion, ensureHarnessInstalled, install } from "./install.js"; import { readPreferences, writePreferences } from "./preferences.js"; import { CatalogAuthenticationError, @@ -227,6 +227,13 @@ async function launch( if (!ready) return 1; } + let opencodeMajor: number | undefined; + if (request.tool === "opencode" && await commandExists(tool.executable, env, io.platform)) { + const version = await commandVersion(tool.executable, io, env); + const major = version?.match(/(\d+)\./)?.[1]; + if (major) opencodeMajor = Number(major); + } + let modelAliases: Record | undefined; for (const [slot, modelId] of Object.entries(request.aliases ?? {})) { if (request.tool !== "claude") { @@ -262,6 +269,7 @@ async function launch( models: catalog.models, extraArgs: request.extraArgs, modelAliases, + opencodeMajor, homeDir: io.homeDir, cwd: io.cwd, tempDir, diff --git a/tests/launch-install.test.ts b/tests/launch-install.test.ts index b356e59..027892b 100644 --- a/tests/launch-install.test.ts +++ b/tests/launch-install.test.ts @@ -173,6 +173,50 @@ describe("zro --install", () => { }); }); +describe("zro opencode version detection", () => { + it("uses the OpenCode 2 environment when the installed binary reports version 2", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-opencode-v2-")); + const bin = path.join(home, "bin"); + await fs.mkdir(bin); + await fs.writeFile(path.join(bin, "opencode"), "#!/bin/sh\n"); + await fs.chmod(path.join(bin, "opencode"), 0o755); + + const calls: Array<{ command: string; args: string[] }> = []; + const spawn: SpawnProcess = (command, args) => { + calls.push({ command, args }); + const child = new EventEmitter() as ChildProcess & { stdout?: PassThrough }; + child.stdout = new PassThrough(); + process.nextTick(() => { + if (args[0] === "--version") { + child.stdout!.on("end", () => child.emit("exit", 0, null)); + child.stdout!.end("opencode v2.1.2\n"); + } else { + child.emit("exit", 0, null); + } + }); + return child; + }; + const stdout = new PassThrough(); + + const code = await run( + ["opencode", "--json", "--api-key", "sk-test"], + { ...launchIo(home, spawn, { PATH: bin, ZRO_API_KEY: "sk-test" }), stdout }, + ); + + expect(code).toBe(0); + const plan = JSON.parse(await streamText(stdout)) as { + tool: string; + command: string; + args: string[]; + environment: Record; + }; + expect(plan).toMatchObject({ tool: "opencode", command: "opencode" }); + expect(plan.args[0]).toBe("--standalone"); + expect(plan.environment.OPENCODE_DISABLE_AUTOUPDATE).toBe("1"); + expect(calls).toContainEqual({ command: "opencode", args: ["--version"] }); + }); +}); + describe("zro with a missing harness", () => { function ttyIo(home: string, spawn: SpawnProcess, env: NodeJS.ProcessEnv, stdout: PassThrough) { const stdin = new PassThrough() as PassThrough & { diff --git a/tests/opencode.test.ts b/tests/opencode.test.ts index 2d29996..2bde227 100644 --- a/tests/opencode.test.ts +++ b/tests/opencode.test.ts @@ -40,9 +40,35 @@ describe("OpenCode adapter", () => { .join("\n"); expect(copied).not.toContain("do-not-copy"); }); + + it("uses the OpenCode 2 environment when the installed binary is version 2", async () => { + const home = await fs.mkdtemp(path.join(os.tmpdir(), "zro-opencode-v2-home-")); + const tempDir = path.join(home, "session"); + + const plan = await opencodeTool.launch(context(home, tempDir, {}, 2)); + + expect(plan).toMatchObject({ + tool: "opencode", + label: "opencode", + command: "opencode", + message: "Launch opencode with Zro", + }); + expect(plan.args[0]).toBe("--standalone"); + expect(plan.env).toMatchObject({ OPENCODE_DISABLE_AUTOUPDATE: "1" }); + + const configPath = path.join(tempDir, "xdg", "opencode", "opencode.json"); + const config = JSON.parse(String(plan.files?.find((file) => file.path === configPath)?.contents)); + expect(config.providers.zro.package).toBe("@opencode/ai/providers/openai-compatible"); + expect(config.provider).toBeUndefined(); + }); }); -function context(homeDir: string, tempDir: string, env: NodeJS.ProcessEnv = {}): LaunchContext { +function context( + homeDir: string, + tempDir: string, + env: NodeJS.ProcessEnv = {}, + opencodeMajor?: number, +): LaunchContext { return { apiKey: "sk-opencode-secret", apiKeySource: "env", @@ -54,6 +80,7 @@ function context(homeDir: string, tempDir: string, env: NodeJS.ProcessEnv = {}): model: "glm-5.3", models: TEST_MODELS, extraArgs: [], + opencodeMajor, homeDir, cwd: homeDir, tempDir,