diff --git a/src/hooks/non-interactive-env/index.test.ts b/src/hooks/non-interactive-env/index.test.ts index ecf5e06a9..0582a3853 100644 --- a/src/hooks/non-interactive-env/index.test.ts +++ b/src/hooks/non-interactive-env/index.test.ts @@ -13,6 +13,7 @@ describe("non-interactive-env hook", () => { SHELL: process.env.SHELL, PSModulePath: process.env.PSModulePath, MSYSTEM: process.env.MSYSTEM, + ComSpec: process.env.ComSpec, CI: process.env.CI, OPENCODE_NON_INTERACTIVE: process.env.OPENCODE_NON_INTERACTIVE, } @@ -251,7 +252,7 @@ describe("non-interactive-env hook", () => { expect(cmd).toContain("; git commit") }) - test("#given Windows with PowerShell env #when bash tool git command executes #then uses powershell syntax", async () => { + test("#given Windows cmd environment with PSModulePath #when bash tool git command executes #then uses cmd syntax", async () => { delete process.env.SHELL delete process.env.MSYSTEM process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" @@ -267,10 +268,80 @@ describe("non-interactive-env hook", () => { output ) + const cmd = output.args.command as string + expect(cmd).toStartWith("set ") + expect(cmd).toContain(" && git status") + expect(cmd).toContain('GIT_EDITOR=":"') + expect(cmd).not.toContain("$env:") + expect(cmd).not.toContain("export ") + }) + + test("#given Windows SHELL=cmd.exe #when bash tool git command executes #then uses cmd syntax", async () => { + process.env.SHELL = "C:\\Windows\\System32\\cmd.exe" + delete process.env.MSYSTEM + process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" + Object.defineProperty(process, "platform", { value: "win32" }) + + const hook = createNonInteractiveEnvHook(mockCtx) + const output: { args: Record; message?: string } = { + args: { command: "git status" }, + } + + await hook["tool.execute.before"]( + { tool: "bash", sessionID: "test", callID: "1" }, + output + ) + + const cmd = output.args.command as string + expect(cmd).toStartWith("set ") + expect(cmd).toContain(" && git status") + expect(cmd).not.toContain("$env:") + expect(cmd).not.toContain("export ") + }) + + test("#given Windows ComSpec=pwsh.exe without SHELL #when bash tool git command executes #then uses powershell syntax", async () => { + delete process.env.SHELL + delete process.env.MSYSTEM + process.env.ComSpec = "C:\\Program Files\\PowerShell\\7\\pwsh.exe" + process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" + Object.defineProperty(process, "platform", { value: "win32" }) + + const hook = createNonInteractiveEnvHook(mockCtx) + const output: { args: Record; message?: string } = { + args: { command: "git status" }, + } + + await hook["tool.execute.before"]( + { tool: "bash", sessionID: "test", callID: "1" }, + output + ) + + const cmd = output.args.command as string + expect(cmd).toStartWith("$env:") + expect(cmd).toContain("; git status") + expect(cmd).not.toContain("set ") + expect(cmd).not.toContain("export ") + }) + + test("#given Windows SHELL=pwsh.exe #when bash tool git command executes #then uses powershell syntax", async () => { + process.env.SHELL = "C:\\Program Files\\PowerShell\\7\\pwsh.exe" + delete process.env.MSYSTEM + process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" + Object.defineProperty(process, "platform", { value: "win32" }) + + const hook = createNonInteractiveEnvHook(mockCtx) + const output: { args: Record; message?: string } = { + args: { command: "git status" }, + } + + await hook["tool.execute.before"]( + { tool: "bash", sessionID: "test", callID: "1" }, + output + ) + const cmd = output.args.command as string expect(cmd).toStartWith("$env:") expect(cmd).toContain("; git status") - expect(cmd).toContain("$env:GIT_EDITOR=':'") expect(cmd).not.toContain("set ") expect(cmd).not.toContain("export ") }) diff --git a/src/hooks/non-interactive-env/non-interactive-env-hook.ts b/src/hooks/non-interactive-env/non-interactive-env-hook.ts index 91c54b536..6fc42aea9 100644 --- a/src/hooks/non-interactive-env/non-interactive-env-hook.ts +++ b/src/hooks/non-interactive-env/non-interactive-env-hook.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants" import { log, buildEnvPrefix } from "../../shared" -import { detectShellType } from "../../shared/shell-env" +import { detectShellType, type ShellType } from "../../shared/shell-env" export * from "./constants" export * from "./detector" @@ -20,6 +20,41 @@ function detectBannedCommand(command: string): string | undefined { return undefined } +function detectWindowsShellType(shellPath: string | undefined): ShellType | undefined { + if (!shellPath) { + return undefined + } + + const shellName = shellPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase() + if (shellName === "cmd" || shellName === "cmd.exe") { + return "cmd" + } + if ( + shellName === "powershell" || + shellName === "powershell.exe" || + shellName === "pwsh" || + shellName === "pwsh.exe" + ) { + return "powershell" + } + return undefined +} + +function detectCommandShellType(): ShellType { + if (process.platform === "win32" && process.env.SHELL) { + const shellType = detectWindowsShellType(process.env.SHELL) + if (shellType) { + return shellType + } + } + + if (process.platform === "win32" && !process.env.SHELL && !process.env.MSYSTEM) { + return detectWindowsShellType(process.env.ComSpec) ?? "cmd" + } + + return detectShellType() +} + export function createNonInteractiveEnvHook(_ctx: PluginInput) { return { "tool.execute.before": async ( @@ -53,7 +88,7 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) { // The env vars (GIT_EDITOR=:, EDITOR=:, etc.) must ALWAYS be injected // for git commands to prevent interactive prompts. - const shellType = detectShellType() + const shellType = detectCommandShellType() const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType) // Check if the command already starts with the prefix to avoid stacking.