diff --git a/core/tools/definitions/runTerminalCommand.ts b/core/tools/definitions/runTerminalCommand.ts index ec7e29466b9..e950a193fe0 100644 --- a/core/tools/definitions/runTerminalCommand.ts +++ b/core/tools/definitions/runTerminalCommand.ts @@ -5,6 +5,7 @@ import { evaluateTerminalCommandSecurity, ToolPolicy, } from "@continuedev/terminal-security"; +import { getPowerShellCommand } from "../../util/shell.js"; /** * Get the preferred shell for the current platform @@ -14,7 +15,7 @@ function getPreferredShell(): string { const platform = os.platform(); if (platform === "win32") { - return "powershell.exe"; + return getPowerShellCommand(); } else if (platform === "darwin") { return process.env.SHELL || "/bin/zsh"; } else { diff --git a/core/tools/implementations/runTerminalCommand.ts b/core/tools/implementations/runTerminalCommand.ts index 8f6201bd12c..60cfa27e9f0 100644 --- a/core/tools/implementations/runTerminalCommand.ts +++ b/core/tools/implementations/runTerminalCommand.ts @@ -2,6 +2,7 @@ import iconv from "iconv-lite"; import childProcess from "node:child_process"; import os from "node:os"; import { ContinueError, ContinueErrorReason } from "../../util/errors"; +import { getPowerShellCommand } from "../../util/shell.js"; // Default timeout for terminal commands (2 minutes) const DEFAULT_TOOL_TIMEOUT_MS = 120_000; @@ -26,7 +27,7 @@ function getShellCommand(command: string): { shell: string; args: string[] } { if (process.platform === "win32") { // Windows: Use PowerShell return { - shell: "powershell.exe", + shell: getPowerShellCommand(), args: ["-NoLogo", "-ExecutionPolicy", "Bypass", "-Command", command], }; } else { diff --git a/core/util/index.ts b/core/util/index.ts index 76b319bddf0..cbc0a4818b5 100644 --- a/core/util/index.ts +++ b/core/util/index.ts @@ -1,3 +1,5 @@ +export { getPowerShellCommand } from "./shell.js"; + export function removeQuotesAndEscapes(input: string): string { let output = input.trim(); diff --git a/core/util/shell.ts b/core/util/shell.ts new file mode 100644 index 00000000000..7cae4881b9e --- /dev/null +++ b/core/util/shell.ts @@ -0,0 +1,36 @@ +import { spawnSync } from "node:child_process"; +import os from "node:os"; + +let cachedPowerShellCommand: string | undefined; + +/** + * Get the appropriate PowerShell command for the current system. + * Prefers 'pwsh' (PowerShell Core 6+) if available, otherwise falls back to 'powershell' (legacy). + * This function is synchronous to support callers during module initialization. + */ +export function getPowerShellCommand(): string { + if (os.platform() !== "win32") { + return "pwsh"; + } + + if (cachedPowerShellCommand) { + return cachedPowerShellCommand; + } + + try { + // Check if pwsh is available and get its version + const result = spawnSync("pwsh", ["--version"], { encoding: "utf8" }); + + // PowerShell Core version string is typically "PowerShell 7.x.x" + if (result.status === 0 && result.stdout && result.stdout.startsWith("PowerShell")) { + cachedPowerShellCommand = "pwsh"; + } else { + cachedPowerShellCommand = "powershell"; + } + } catch (error) { + // If pwsh fails to execute, fall back to powershell + cachedPowerShellCommand = "powershell"; + } + + return cachedPowerShellCommand; +} diff --git a/core/util/tts.ts b/core/util/tts.ts index aace86bf9dc..b4aba3b01d6 100644 --- a/core/util/tts.ts +++ b/core/util/tts.ts @@ -2,6 +2,7 @@ import { exec, ChildProcess } from "child_process"; import os from "node:os"; import { removeCodeBlocksAndTrim } from "."; +import { getPowerShellCommand } from "./shell.js"; import type { IMessenger } from "../protocol/messenger"; import type { FromCoreProtocol, ToCoreProtocol } from "../protocol"; @@ -54,7 +55,7 @@ export class TTS { case "win32": // Replace single quotes on windows TTS.handle = exec( - `powershell -Command "Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${message.replace( + `${getPowerShellCommand()} -Command "Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${message.replace( /'/g, "''", )}')"`, diff --git a/extensions/cli/src/tools/runTerminalCommand.ts b/extensions/cli/src/tools/runTerminalCommand.ts index 6b640c6a1c3..20e970763db 100644 --- a/extensions/cli/src/tools/runTerminalCommand.ts +++ b/extensions/cli/src/tools/runTerminalCommand.ts @@ -20,6 +20,7 @@ import { truncateOutputFromStart, } from "../util/truncateOutput.js"; +import { getPowerShellCommand } from "core/util/shell.js"; import { Tool, ToolRunContext } from "./types.js"; // Output truncation defaults @@ -67,7 +68,7 @@ function getShellCommand(command: string): { shell: string; args: string[] } { if (process.platform === "win32") { // Windows: Use PowerShell return { - shell: "powershell.exe", + shell: getPowerShellCommand(), args: ["-NoLogo", "-ExecutionPolicy", "Bypass", "-Command", command], }; } diff --git a/extensions/cli/src/util/clipboard.test.ts b/extensions/cli/src/util/clipboard.test.ts index b8bb7ace8a6..8ac7318e1cc 100644 --- a/extensions/cli/src/util/clipboard.test.ts +++ b/extensions/cli/src/util/clipboard.test.ts @@ -48,12 +48,14 @@ vi.mock("./logger.js", () => ({ }, })); +// Mock core/util/index.js (Prerequisite 0b and Issue 2 alignment) +vi.mock("core/util/index.js", () => ({ + getPowerShellCommand: vi.fn(), +})); + // Import after mocks are set up import { checkClipboardForImage, getClipboardImage } from "./clipboard.js"; - -// Get the mock exec async function -const getMockExecAsync = () => - (vi.mocked(import("util")) as any).__mockExecAsync; +import { getPowerShellCommand } from "core/util/index.js"; describe("clipboard utilities", () => { let mockExecAsync: any; @@ -93,18 +95,40 @@ describe("clipboard utilities", () => { expect(result).toBe(false); }); - it("should detect image on Windows when count > 0", async () => { + it("should detect image on Windows using pwsh if available", async () => { + const os = await import("os"); + vi.mocked(os.default.platform).mockReturnValue("win32"); + vi.mocked(getPowerShellCommand).mockReturnValue("pwsh"); + + // Mock the actual clipboard check + mockExecAsync.mockResolvedValue({ stdout: "1\n", stderr: "" }); + + const result = await checkClipboardForImage(); + expect(result).toBe(true); + expect(mockExecAsync).toHaveBeenCalledWith(expect.stringContaining("pwsh")); + }); + + it("should detect image on Windows using powershell if pwsh is not available", async () => { const os = await import("os"); vi.mocked(os.default.platform).mockReturnValue("win32"); + vi.mocked(getPowerShellCommand).mockReturnValue("powershell"); + + // Mock the actual clipboard check using powershell mockExecAsync.mockResolvedValue({ stdout: "1\n", stderr: "" }); const result = await checkClipboardForImage(); expect(result).toBe(true); + expect(mockExecAsync).toHaveBeenCalledWith( + expect.stringContaining("powershell"), + ); }); it("should detect no image on Windows when count is 0", async () => { const os = await import("os"); vi.mocked(os.default.platform).mockReturnValue("win32"); + vi.mocked(getPowerShellCommand).mockReturnValue("pwsh"); + + // Mock clipboard count mockExecAsync.mockResolvedValue({ stdout: "0\n", stderr: "" }); const result = await checkClipboardForImage(); @@ -161,7 +185,29 @@ describe("clipboard utilities", () => { expect(fs.unlink).toHaveBeenCalled(); }); - it("should get image from clipboard on Windows", async () => { + it("should get image from clipboard on Windows using pwsh if available", async () => { + const os = await import("os"); + const path = await import("path"); + const fs = await import("fs/promises"); + + vi.mocked(os.default.platform).mockReturnValue("win32"); + vi.mocked(os.default.tmpdir).mockReturnValue("C:\\temp"); + vi.mocked(path.default.join).mockReturnValue( + "C:\\temp\\continue-clipboard-123.png", + ); + vi.mocked(getPowerShellCommand).mockReturnValue("pwsh"); + + mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" }); + vi.mocked(fs.readFile).mockResolvedValue(mockImageBuffer); + vi.mocked(fs.unlink).mockResolvedValue(undefined); + + const result = await getClipboardImage(); + expect(result).toEqual(mockImageBuffer); + expect(mockExecAsync).toHaveBeenCalledWith(expect.stringContaining("pwsh")); + expect(fs.unlink).toHaveBeenCalled(); + }); + + it("should get image from clipboard on Windows using powershell if pwsh not available", async () => { const os = await import("os"); const path = await import("path"); const fs = await import("fs/promises"); @@ -171,12 +217,17 @@ describe("clipboard utilities", () => { vi.mocked(path.default.join).mockReturnValue( "C:\\temp\\continue-clipboard-123.png", ); + vi.mocked(getPowerShellCommand).mockReturnValue("powershell"); + mockExecAsync.mockResolvedValue({ stdout: "", stderr: "" }); vi.mocked(fs.readFile).mockResolvedValue(mockImageBuffer); vi.mocked(fs.unlink).mockResolvedValue(undefined); const result = await getClipboardImage(); expect(result).toEqual(mockImageBuffer); + expect(mockExecAsync).toHaveBeenCalledWith( + expect.stringContaining("powershell"), + ); expect(fs.unlink).toHaveBeenCalled(); }); diff --git a/extensions/cli/src/util/clipboard.ts b/extensions/cli/src/util/clipboard.ts index 571802eee5c..67ff3b585f0 100644 --- a/extensions/cli/src/util/clipboard.ts +++ b/extensions/cli/src/util/clipboard.ts @@ -4,6 +4,7 @@ import os from "os"; import path from "path"; import { promisify } from "util"; +import { getPowerShellCommand } from "core/util/index.js"; import { logger } from "./logger.js"; const execAsync = promisify(exec); @@ -26,8 +27,9 @@ export async function checkClipboardForImage(): Promise { ); } else if (platform === "win32") { // Windows: Use PowerShell to check clipboard + const psCommand = getPowerShellCommand(); const { stdout } = await execAsync( - 'powershell -command "Get-Clipboard -Format Image | Measure-Object | Select-Object -ExpandProperty Count"', + `${psCommand} -Command "Add-Type -AssemblyName System.Windows.Forms; [int][System.Windows.Forms.Clipboard]::ContainsImage()"`, ); return parseInt(stdout.trim()) > 0; } else if (platform === "linux") { @@ -69,8 +71,9 @@ export async function getClipboardImage(): Promise { ); } else if (platform === "win32") { // Windows: Use PowerShell to save clipboard image + const psCommand = getPowerShellCommand(); await execAsync( - `powershell -command "$image = Get-Clipboard -Format Image; if ($image) { $image.Save('${tempImagePath}') }"`, + `${psCommand} -STA -Command "Add-Type -AssemblyName System.Windows.Forms, System.Drawing; $image = [System.Windows.Forms.Clipboard]::GetImage(); if ($image) { $image.Save('${tempImagePath}', [System.Drawing.Imaging.ImageFormat]::Png) }"`, ); } else if (platform === "linux") { // Linux: Use xclip to save clipboard image diff --git a/extensions/cli/vitest.config.ts b/extensions/cli/vitest.config.ts index 7bf2f2b722c..2bb418bb86e 100644 --- a/extensions/cli/vitest.config.ts +++ b/extensions/cli/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ resolve: { alias: { src: path.resolve(__dirname, "src"), + core: path.resolve(__dirname, "../../core/src"), }, extensions: [".js", ".ts", ".tsx", ".json"], },