Skip to content

Commit 47ca8f6

Browse files
committed
fix(cli): execute OpenCode command shims on Windows
1 parent 113f3e4 commit 47ca8f6

2 files changed

Lines changed: 131 additions & 2 deletions

File tree

packages/cli/src/lib/opencode-helpers.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { detectOpenCodeInstallations } from "./opencode-detect";
66
import {
77
describeOpenCodeInstallations,
88
getAvailableModels,
9+
getOpenCodeCommandInvocation,
910
getOpenCodeVersion,
1011
OPENCODE_VERSION_PROBE_TIMEOUT_MS,
1112
} from "./opencode-helpers";
@@ -14,9 +15,15 @@ import {
1415
// #196 follow-up: a stock CLI not on PATH must still enumerate). POSIX-only:
1516
// the test writes an executable shell stub, which CI runs on Linux/macOS.
1617
const isPosix = process.platform !== "win32";
18+
const originalComSpec = process.env.ComSpec;
19+
const originalPathExpansionProbe = process.env.MC_OPENCODE_TEST_PATH;
1720
const tempDirs: string[] = [];
1821

1922
afterEach(() => {
23+
if (originalComSpec === undefined) delete process.env.ComSpec;
24+
else process.env.ComSpec = originalComSpec;
25+
if (originalPathExpansionProbe === undefined) delete process.env.MC_OPENCODE_TEST_PATH;
26+
else process.env.MC_OPENCODE_TEST_PATH = originalPathExpansionProbe;
2027
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
2128
});
2229

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

49+
function fakeOpenCodeCommandShim(): string {
50+
const dir = mkdtempSync(join(tmpdir(), "mc %MC_OPENCODE_TEST_PATH% & shim "));
51+
tempDirs.push(dir);
52+
const shim = join(dir, "opencode.cmd");
53+
54+
if (process.platform === "win32") {
55+
writeFileSync(
56+
shim,
57+
[
58+
"@echo off",
59+
'if "%~1"=="--version" (',
60+
" echo 1.18.7",
61+
') else if "%~1"=="models" (',
62+
" echo anthropic/claude-opus-4-8",
63+
" echo openai/gpt-5.5",
64+
")",
65+
].join("\r\n"),
66+
);
67+
} else {
68+
const comSpec = join(dir, "fake-cmd");
69+
writeFileSync(
70+
comSpec,
71+
'#!/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',
72+
);
73+
chmodSync(comSpec, 0o755);
74+
process.env.ComSpec = comSpec;
75+
}
76+
77+
return shim;
78+
}
79+
80+
describe("OpenCode command execution", () => {
81+
it("routes cmd and bat shims through ComSpec", () => {
82+
process.env.ComSpec = "custom-cmd.exe";
83+
84+
expect(getOpenCodeCommandInvocation("C:\\npm\\opencode.CMD", ["--version"])).toEqual({
85+
command: "custom-cmd.exe",
86+
args: [
87+
"/d",
88+
"/s",
89+
"/v:off",
90+
"/c",
91+
'""%MAGIC_CONTEXT_OPENCODE_BINARY%" "--version""',
92+
],
93+
env: { MAGIC_CONTEXT_OPENCODE_BINARY: "C:\\npm\\opencode.CMD" },
94+
windowsVerbatimArguments: true,
95+
});
96+
expect(getOpenCodeCommandInvocation("C:\\npm\\opencode.bat", ["models"])).toEqual({
97+
command: "custom-cmd.exe",
98+
args: [
99+
"/d",
100+
"/s",
101+
"/v:off",
102+
"/c",
103+
'""%MAGIC_CONTEXT_OPENCODE_BINARY%" "models""',
104+
],
105+
env: { MAGIC_CONTEXT_OPENCODE_BINARY: "C:\\npm\\opencode.bat" },
106+
windowsVerbatimArguments: true,
107+
});
108+
});
109+
110+
it("invokes native executables directly", () => {
111+
expect(getOpenCodeCommandInvocation("/usr/local/bin/opencode", ["--version"])).toEqual({
112+
command: "/usr/local/bin/opencode",
113+
args: ["--version"],
114+
});
115+
});
116+
117+
it("executes a cmd shim for version and model probes", () => {
118+
process.env.MC_OPENCODE_TEST_PATH = "expanded-to-the-wrong-path";
119+
const shim = fakeOpenCodeCommandShim();
120+
121+
expect(getOpenCodeVersion(shim)).toBe("1.18.7");
122+
expect(getAvailableModels(shim)).toEqual([
123+
"anthropic/claude-opus-4-8",
124+
"openai/gpt-5.5",
125+
]);
126+
});
127+
});
128+
42129
describe.if(isPosix)("opencode helpers with a resolved binary path", () => {
43130
it("getAvailableModels invokes the given absolute binary", () => {
44131
const bin = fakeOpencode(

packages/cli/src/lib/opencode-helpers.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,59 @@
11
import { execFileSync, execSync } from "node:child_process";
2+
import { extname } from "node:path";
23
import type { OpenCodeInstallation } from "./opencode-detect";
34

5+
export interface OpenCodeCommandInvocation {
6+
command: string;
7+
args: string[];
8+
env?: Record<string, string>;
9+
windowsVerbatimArguments?: true;
10+
}
11+
12+
const OPENCODE_BINARY_ENV = "MAGIC_CONTEXT_OPENCODE_BINARY";
13+
14+
export function getOpenCodeCommandInvocation(
15+
binary: string,
16+
args: string[],
17+
): OpenCodeCommandInvocation {
18+
const extension = extname(binary).toLowerCase();
19+
if (extension !== ".cmd" && extension !== ".bat") {
20+
return { command: binary, args };
21+
}
22+
23+
const command = process.env.ComSpec?.trim() || process.env.COMSPEC?.trim() || "cmd.exe";
24+
// cmd.exe needs the /c payload as one outer-quoted command string. Pass the
25+
// binary through the child environment so percent signs in a valid path are
26+
// not treated as another variable expansion. Current callers only supply the
27+
// fixed `--version` and `models` arguments.
28+
const commandLine = [`%${OPENCODE_BINARY_ENV}%`, ...args]
29+
.map((part) => `"${part}"`)
30+
.join(" ");
31+
return {
32+
command,
33+
args: ["/d", "/s", "/v:off", "/c", `"${commandLine}"`],
34+
env: { [OPENCODE_BINARY_ENV]: binary },
35+
windowsVerbatimArguments: true,
36+
};
37+
}
38+
439
/**
540
* Run `opencode <args>`. If a `binary` path is given (an absolute path resolved
641
* for a stock `~/.opencode/bin` install or a version-manager shim that is not on
7-
* PATH), call that exact path via execFile; otherwise fall back to a bare
42+
* PATH), invoke that exact path; otherwise fall back to a bare
843
* `opencode` on PATH.
944
*/
1045
function runOpenCode(args: string[], binary?: string | null, timeoutMs?: number): string | null {
1146
try {
1247
const options = { stdio: "pipe" as const, ...(timeoutMs ? { timeout: timeoutMs } : {}) };
1348
if (binary) {
14-
return execFileSync(binary, args, options).toString().trim();
49+
const invocation = getOpenCodeCommandInvocation(binary, args);
50+
return execFileSync(invocation.command, invocation.args, {
51+
...options,
52+
...(invocation.env ? { env: { ...process.env, ...invocation.env } } : {}),
53+
...(invocation.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
54+
})
55+
.toString()
56+
.trim();
1557
}
1658
return execSync(`opencode ${args.join(" ")}`, options)
1759
.toString()

0 commit comments

Comments
 (0)