Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
12 changes: 12 additions & 0 deletions src/engine/tools/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
5 changes: 3 additions & 2 deletions src/engine/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,6 +18,7 @@ export interface LaunchContext {
models: readonly ZroModel[];
extraArgs: string[];
modelAliases?: Record<string, string>;
opencodeMajor?: number;
homeDir: string;
cwd: string;
tempDir: string;
Expand Down Expand Up @@ -53,7 +54,7 @@ export interface ToolModule {
export interface SpawnOptions {
cwd: string;
env: NodeJS.ProcessEnv;
stdio: "inherit";
stdio: StdioOptions;
windowsVerbatimArguments?: boolean;
}

Expand Down
30 changes: 30 additions & 0 deletions src/install.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -73,6 +74,35 @@ export async function commandExists(
return false;
}

export async function commandVersion(
command: string,
io: RunIo,
env: NodeJS.ProcessEnv,
): Promise<string | undefined> {
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<number | null>((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")
Expand Down
10 changes: 9 additions & 1 deletion src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> | undefined;
for (const [slot, modelId] of Object.entries(request.aliases ?? {})) {
if (request.tool !== "claude") {
Expand Down Expand Up @@ -262,6 +269,7 @@ async function launch(
models: catalog.models,
extraArgs: request.extraArgs,
modelAliases,
opencodeMajor,
homeDir: io.homeDir,
cwd: io.cwd,
tempDir,
Expand Down
44 changes: 44 additions & 0 deletions tests/launch-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,50 @@ describe("zro <tool> --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<string, string>;
};
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 <tool> with a missing harness", () => {
function ttyIo(home: string, spawn: SpawnProcess, env: NodeJS.ProcessEnv, stdout: PassThrough) {
const stdin = new PassThrough() as PassThrough & {
Expand Down
29 changes: 28 additions & 1 deletion tests/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
Expand Down
Loading