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
87 changes: 87 additions & 0 deletions packages/cli/src/lib/opencode-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { detectOpenCodeInstallations } from "./opencode-detect";
import {
describeOpenCodeInstallations,
getAvailableModels,
getOpenCodeCommandInvocation,
getOpenCodeVersion,
OPENCODE_VERSION_PROBE_TIMEOUT_MS,
} from "./opencode-helpers";
Expand All @@ -14,9 +15,15 @@ import {
// #196 follow-up: a stock CLI not on PATH must still enumerate). POSIX-only:
// the test writes an executable shell stub, which CI runs on Linux/macOS.
const isPosix = process.platform !== "win32";
const originalComSpec = process.env.ComSpec;
const originalPathExpansionProbe = process.env.MC_OPENCODE_TEST_PATH;
const tempDirs: string[] = [];

afterEach(() => {
if (originalComSpec === undefined) delete process.env.ComSpec;
else process.env.ComSpec = originalComSpec;
if (originalPathExpansionProbe === undefined) delete process.env.MC_OPENCODE_TEST_PATH;
else process.env.MC_OPENCODE_TEST_PATH = originalPathExpansionProbe;
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});

Expand All @@ -39,6 +46,86 @@ function fakeHomeOpencode(body: string): { home: string; bin: string } {
return { home, bin };
}

function fakeOpenCodeCommandShim(): string {
const dir = mkdtempSync(join(tmpdir(), "mc %MC_OPENCODE_TEST_PATH% & shim "));
tempDirs.push(dir);
const shim = join(dir, "opencode.cmd");

if (process.platform === "win32") {
writeFileSync(
shim,
[
"@echo off",
'if "%~1"=="--version" (',
" echo 1.18.7",
') else if "%~1"=="models" (',
" echo anthropic/claude-opus-4-8",
" echo openai/gpt-5.5",
")",
].join("\r\n"),
);
} else {
const comSpec = join(dir, "fake-cmd");
writeFileSync(
comSpec,
'#!/bin/sh\ncase "$5" in\n *--version*) echo "1.18.7" ;;\n *models*) printf "anthropic/claude-opus-4-8\\nopenai/gpt-5.5\\n" ;;\nesac\n',
);
chmodSync(comSpec, 0o755);
process.env.ComSpec = comSpec;
}

return shim;
}

describe("OpenCode command execution", () => {
it("routes cmd and bat shims through ComSpec", () => {
process.env.ComSpec = "custom-cmd.exe";

expect(getOpenCodeCommandInvocation("C:\\npm\\opencode.CMD", ["--version"])).toEqual({
command: "custom-cmd.exe",
args: [
"/d",
"/s",
"/v:off",
"/c",
'""%MAGIC_CONTEXT_OPENCODE_BINARY%" "--version""',
],
env: { MAGIC_CONTEXT_OPENCODE_BINARY: "C:\\npm\\opencode.CMD" },
windowsVerbatimArguments: true,
});
expect(getOpenCodeCommandInvocation("C:\\npm\\opencode.bat", ["models"])).toEqual({
command: "custom-cmd.exe",
args: [
"/d",
"/s",
"/v:off",
"/c",
'""%MAGIC_CONTEXT_OPENCODE_BINARY%" "models""',
],
env: { MAGIC_CONTEXT_OPENCODE_BINARY: "C:\\npm\\opencode.bat" },
windowsVerbatimArguments: true,
});
});

it("invokes native executables directly", () => {
expect(getOpenCodeCommandInvocation("/usr/local/bin/opencode", ["--version"])).toEqual({
command: "/usr/local/bin/opencode",
args: ["--version"],
});
});

it("executes a cmd shim for version and model probes", () => {
process.env.MC_OPENCODE_TEST_PATH = "expanded-to-the-wrong-path";
const shim = fakeOpenCodeCommandShim();

expect(getOpenCodeVersion(shim)).toBe("1.18.7");
expect(getAvailableModels(shim)).toEqual([
"anthropic/claude-opus-4-8",
"openai/gpt-5.5",
]);
});
});

describe.if(isPosix)("opencode helpers with a resolved binary path", () => {
it("getAvailableModels invokes the given absolute binary", () => {
const bin = fakeOpencode(
Expand Down
46 changes: 44 additions & 2 deletions packages/cli/src/lib/opencode-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,59 @@
import { execFileSync, execSync } from "node:child_process";
import { extname } from "node:path";
import type { OpenCodeInstallation } from "./opencode-detect";

export interface OpenCodeCommandInvocation {
command: string;
args: string[];
env?: Record<string, string>;
windowsVerbatimArguments?: true;
}

const OPENCODE_BINARY_ENV = "MAGIC_CONTEXT_OPENCODE_BINARY";

export function getOpenCodeCommandInvocation(
binary: string,
args: string[],
): OpenCodeCommandInvocation {
const extension = extname(binary).toLowerCase();
if (extension !== ".cmd" && extension !== ".bat") {
return { command: binary, args };
}

const command = process.env.ComSpec?.trim() || process.env.COMSPEC?.trim() || "cmd.exe";
// cmd.exe needs the /c payload as one outer-quoted command string. Pass the
// binary through the child environment so percent signs in a valid path are
// not treated as another variable expansion. Current callers only supply the
// fixed `--version` and `models` arguments.
const commandLine = [`%${OPENCODE_BINARY_ENV}%`, ...args]
.map((part) => `"${part}"`)
.join(" ");
return {
command,
args: ["/d", "/s", "/v:off", "/c", `"${commandLine}"`],
env: { [OPENCODE_BINARY_ENV]: binary },
windowsVerbatimArguments: true,
};
}

/**
* Run `opencode <args>`. If a `binary` path is given (an absolute path resolved
* for a stock `~/.opencode/bin` install or a version-manager shim that is not on
* PATH), call that exact path via execFile; otherwise fall back to a bare
* PATH), invoke that exact path; otherwise fall back to a bare
* `opencode` on PATH.
*/
function runOpenCode(args: string[], binary?: string | null, timeoutMs?: number): string | null {
try {
const options = { stdio: "pipe" as const, ...(timeoutMs ? { timeout: timeoutMs } : {}) };
if (binary) {
return execFileSync(binary, args, options).toString().trim();
const invocation = getOpenCodeCommandInvocation(binary, args);
return execFileSync(invocation.command, invocation.args, {
...options,
...(invocation.env ? { env: { ...process.env, ...invocation.env } } : {}),
...(invocation.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
})
.toString()
.trim();
}
return execSync(`opencode ${args.join(" ")}`, options)
.toString()
Expand Down