diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 29089106d904..e4f17029a59a 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -163,6 +163,17 @@ function info(file: string): Item { } } +// Windows PowerShell 5.1 encodes its own output with the legacy OEM code page +// (e.g. CP936 on zh-CN systems) when stdout is redirected, while opencode +// decodes process output as UTF-8. Force UTF-8 before the user command runs. +const PS_UTF8_PREAMBLE = + "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new(); $OutputEncoding = [System.Text.UTF8Encoding]::new(); " + +export function psCommand(command: string) { + if (process.platform !== "win32") return command + return PS_UTF8_PREAMBLE + command +} + export function args(file: string, command: string, cwd: string) { const n = name(file) if (n === "nu" || n === "fish") return ["-c", command] @@ -195,7 +206,7 @@ export function args(file: string, command: string, cwd: string) { ] } if (n === "cmd") return ["/c", command] - if (ps(file)) return ["-NoProfile", "-Command", command] + if (ps(file)) return ["-NoProfile", "-Command", psCommand(command)] return ["-c", command] } diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index 1cc47a79f6cd..94aa36d57e3f 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -63,6 +63,23 @@ describe("shell", () => { expect(zsh.at(-1)).toBe("/tmp") }) + test("psCommand wraps command with UTF-8 preamble on Windows", () => { + const result = Shell.psCommand("echo hi") + if (process.platform === "win32") { + expect(result.startsWith("[Console]::OutputEncoding")).toBe(true) + expect(result.endsWith("echo hi")).toBe(true) + } else { + expect(result).toBe("echo hi") + } + }) + + test("applies psCommand to PowerShell args", () => { + const psArgs = Shell.args("powershell", "echo hi", "C:\\tmp") + expect(psArgs[0]).toBe("-NoProfile") + expect(psArgs[1]).toBe("-Command") + expect(String(psArgs.at(-1))).toContain("echo hi") + }) + if (process.platform === "win32") { test("rejects blacklisted shells case-insensitively", async () => { await withShell("NU.EXE", async () => { diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..fd83715ad234 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -292,12 +292,16 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { if (process.platform === "win32" && Shell.ps(shell)) { - return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { - cwd, - env, - stdin: "ignore", - detached: false, - }) + return ChildProcess.make( + shell, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", Shell.psCommand(command)], + { + cwd, + env, + stdin: "ignore", + detached: false, + }, + ) } return ChildProcess.make(command, [], {