mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
fix: handle PowerShell scripts (.ps1) on Windows for Claude Code integration
- Detect .ps1 files on Windows and execute them through PowerShell - Add proper PowerShell invocation with bypass execution policy - Improve error messages for PowerShell-specific failures - Add comprehensive tests for PowerShell script handling Fixes #7393
This commit is contained in:
parent
2e99d5bf1b
commit
3979d906c6
2 changed files with 367 additions and 3 deletions
333
src/integrations/claude-code/__tests__/run-powershell.spec.ts
Normal file
333
src/integrations/claude-code/__tests__/run-powershell.spec.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
|
||||
// Mock i18n system
|
||||
vi.mock("../../../i18n", () => ({
|
||||
t: vi.fn((key: string, options?: Record<string, any>) => {
|
||||
if (key === "errors.claudeCode.notFound") {
|
||||
const claudePath = options?.claudePath || "claude"
|
||||
const installationUrl = options?.installationUrl || "https://docs.anthropic.com/en/docs/claude-code/setup"
|
||||
const originalError = options?.originalError || "spawn claude ENOENT"
|
||||
return `Claude Code executable '${claudePath}' not found.\n\nPlease install Claude Code CLI:\n1. Visit ${installationUrl} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: ${originalError}`
|
||||
}
|
||||
return key
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock os module
|
||||
const mockPlatform = vi.fn()
|
||||
vi.mock("os", () => ({
|
||||
platform: mockPlatform,
|
||||
}))
|
||||
|
||||
// Mock vscode workspace
|
||||
vi.mock("vscode", () => ({
|
||||
workspace: {
|
||||
workspaceFolders: [
|
||||
{
|
||||
uri: {
|
||||
fsPath: "/test/workspace",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock execa
|
||||
const mockExeca = vi.fn()
|
||||
const mockStdin = {
|
||||
write: vi.fn((data, encoding, callback) => {
|
||||
if (callback) callback(null)
|
||||
}),
|
||||
end: vi.fn(),
|
||||
}
|
||||
|
||||
const createMockProcess = () => {
|
||||
let resolveProcess: (value: { exitCode: number }) => void
|
||||
const processPromise = new Promise<{ exitCode: number }>((resolve) => {
|
||||
resolveProcess = resolve
|
||||
})
|
||||
|
||||
const mockProcess = {
|
||||
stdin: mockStdin,
|
||||
stdout: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
stderr: {
|
||||
on: vi.fn(),
|
||||
},
|
||||
on: vi.fn((event, callback) => {
|
||||
if (event === "close") {
|
||||
setTimeout(() => {
|
||||
callback(0)
|
||||
resolveProcess({ exitCode: 0 })
|
||||
}, 10)
|
||||
}
|
||||
}),
|
||||
killed: false,
|
||||
kill: vi.fn(),
|
||||
then: processPromise.then.bind(processPromise),
|
||||
catch: processPromise.catch.bind(processPromise),
|
||||
finally: processPromise.finally.bind(processPromise),
|
||||
}
|
||||
return mockProcess
|
||||
}
|
||||
|
||||
vi.mock("execa", () => ({
|
||||
execa: mockExeca,
|
||||
}))
|
||||
|
||||
// Mock readline
|
||||
let mockReadlineInterface: any = null
|
||||
|
||||
vi.mock("readline", () => ({
|
||||
default: {
|
||||
createInterface: vi.fn(() => {
|
||||
mockReadlineInterface = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield '{"type":"text","text":"PowerShell test response"}'
|
||||
return
|
||||
},
|
||||
close: vi.fn(),
|
||||
}
|
||||
return mockReadlineInterface
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("runClaudeCode - PowerShell Script Support", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExeca.mockReturnValue(createMockProcess())
|
||||
vi.spyOn(global, "setImmediate").mockImplementation((callback: any) => {
|
||||
callback()
|
||||
return {} as any
|
||||
})
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test("should execute .ps1 files through PowerShell on Windows", async () => {
|
||||
mockPlatform.mockReturnValue("win32")
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const options = {
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
path: "C:\\Users\\test\\AppData\\Local\\fnm_multishells\\52480_1754403777187\\claude.ps1",
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
const results = []
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Verify PowerShell was called with correct arguments
|
||||
const [executablePath, args] = mockExeca.mock.calls[0]
|
||||
expect(executablePath).toBe("powershell.exe")
|
||||
expect(args).toContain("-NoProfile")
|
||||
expect(args).toContain("-ExecutionPolicy")
|
||||
expect(args).toContain("Bypass")
|
||||
expect(args).toContain("-File")
|
||||
expect(args).toContain("C:\\Users\\test\\AppData\\Local\\fnm_multishells\\52480_1754403777187\\claude.ps1")
|
||||
expect(args).toContain("-p")
|
||||
|
||||
// Verify the response was received (as parsed object, not string)
|
||||
expect(results).toContainEqual({ type: "text", text: "PowerShell test response" })
|
||||
})
|
||||
|
||||
test("should execute .PS1 files (uppercase) through PowerShell on Windows", async () => {
|
||||
mockPlatform.mockReturnValue("win32")
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const options = {
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
path: "C:\\Program Files\\Claude\\claude.PS1",
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
await generator.next()
|
||||
await generator.return(undefined)
|
||||
|
||||
const [executablePath, args] = mockExeca.mock.calls[0]
|
||||
expect(executablePath).toBe("powershell.exe")
|
||||
expect(args).toContain("-File")
|
||||
expect(args).toContain("C:\\Program Files\\Claude\\claude.PS1")
|
||||
})
|
||||
|
||||
test("should not use PowerShell for .ps1 files on non-Windows platforms", async () => {
|
||||
mockPlatform.mockReturnValue("darwin")
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const options = {
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
path: "/usr/local/bin/claude.ps1",
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
await generator.next()
|
||||
await generator.return(undefined)
|
||||
|
||||
// On non-Windows, should execute the file directly
|
||||
const [executablePath] = mockExeca.mock.calls[0]
|
||||
expect(executablePath).toBe("/usr/local/bin/claude.ps1")
|
||||
expect(executablePath).not.toBe("powershell.exe")
|
||||
})
|
||||
|
||||
test("should not use PowerShell for non-.ps1 files on Windows", async () => {
|
||||
mockPlatform.mockReturnValue("win32")
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const options = {
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
path: "C:\\Program Files\\Claude\\claude.exe",
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
await generator.next()
|
||||
await generator.return(undefined)
|
||||
|
||||
// Should execute the .exe directly
|
||||
const [executablePath] = mockExeca.mock.calls[0]
|
||||
expect(executablePath).toBe("C:\\Program Files\\Claude\\claude.exe")
|
||||
expect(executablePath).not.toBe("powershell.exe")
|
||||
})
|
||||
|
||||
test("should handle PowerShell not found error gracefully", async () => {
|
||||
mockPlatform.mockReturnValue("win32")
|
||||
|
||||
// Mock PowerShell not found error
|
||||
const powershellError = new Error("spawn powershell.exe ENOENT")
|
||||
;(powershellError as any).code = "ENOENT"
|
||||
mockExeca.mockImplementationOnce(() => {
|
||||
throw powershellError
|
||||
})
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const options = {
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
path: "C:\\Users\\test\\claude.ps1",
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
|
||||
// Should throw a helpful error about PowerShell not being available
|
||||
await expect(generator.next()).rejects.toThrow("PowerShell is not available or not in PATH")
|
||||
})
|
||||
|
||||
test("should handle .ps1 script not found error with helpful message", async () => {
|
||||
mockPlatform.mockReturnValue("win32")
|
||||
|
||||
// Mock script not found error (after PowerShell is found)
|
||||
const mockProcessWithError = createMockProcess()
|
||||
const scriptError = new Error("The system cannot find the file specified")
|
||||
|
||||
mockProcessWithError.on = vi.fn((event, callback) => {
|
||||
if (event === "error") {
|
||||
// This would happen if PowerShell runs but can't find the script
|
||||
const enhancedError = new Error("spawn ENOENT")
|
||||
;(enhancedError as any).code = "ENOENT"
|
||||
callback(enhancedError)
|
||||
}
|
||||
})
|
||||
|
||||
// Mock readline to close immediately when there's an error
|
||||
const mockReadlineForError = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
async next() {
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
}
|
||||
},
|
||||
close: vi.fn(),
|
||||
}
|
||||
|
||||
const readline = await import("readline")
|
||||
vi.mocked(readline.default.createInterface).mockReturnValueOnce(mockReadlineForError as any)
|
||||
mockExeca.mockReturnValueOnce(mockProcessWithError)
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const options = {
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
path: "C:\\Users\\test\\nonexistent.ps1",
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
|
||||
// Should throw the standard Claude Code not found error
|
||||
await expect(generator.next()).rejects.toThrow(/Claude Code executable.*not found/)
|
||||
})
|
||||
|
||||
test("should pass model parameter correctly with PowerShell scripts", async () => {
|
||||
mockPlatform.mockReturnValue("win32")
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const options = {
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
path: "C:\\claude.ps1",
|
||||
modelId: "claude-3-5-sonnet-20241022",
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
await generator.next()
|
||||
await generator.return(undefined)
|
||||
|
||||
const [, args] = mockExeca.mock.calls[0]
|
||||
expect(args).toContain("--model")
|
||||
expect(args).toContain("claude-3-5-sonnet-20241022")
|
||||
})
|
||||
|
||||
test("should pass maxOutputTokens correctly with PowerShell scripts", async () => {
|
||||
mockPlatform.mockReturnValue("win32")
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const options = {
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
messages: [{ role: "user" as const, content: "Hello" }],
|
||||
path: "C:\\claude.ps1",
|
||||
maxOutputTokens: 8192,
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
await generator.next()
|
||||
await generator.return(undefined)
|
||||
|
||||
const [, , execOptions] = mockExeca.mock.calls[0]
|
||||
expect(execOptions.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe("8192")
|
||||
})
|
||||
|
||||
test("should handle stdin correctly with PowerShell scripts on Windows", async () => {
|
||||
mockPlatform.mockReturnValue("win32")
|
||||
|
||||
const { runClaudeCode } = await import("../run")
|
||||
const messages = [{ role: "user" as const, content: "Test message" }]
|
||||
const systemPrompt = "Test prompt"
|
||||
const options = {
|
||||
systemPrompt,
|
||||
messages,
|
||||
path: "C:\\claude.ps1",
|
||||
}
|
||||
|
||||
const generator = runClaudeCode(options)
|
||||
await generator.next()
|
||||
await generator.return(undefined)
|
||||
|
||||
// On Windows with PowerShell, should pass both system prompt and messages via stdin
|
||||
const expectedStdinData = JSON.stringify({ systemPrompt, messages })
|
||||
expect(mockStdin.write).toHaveBeenCalledWith(expectedStdinData, "utf8", expect.any(Function))
|
||||
|
||||
// Should NOT have --system-prompt in args (passed via stdin instead)
|
||||
const [, args] = mockExeca.mock.calls[0]
|
||||
expect(args).not.toContain("--system-prompt")
|
||||
})
|
||||
})
|
||||
|
|
@ -37,6 +37,13 @@ export async function* runClaudeCode(
|
|||
} catch (error: any) {
|
||||
// Handle ENOENT errors immediately when spawning the process
|
||||
if (error.code === "ENOENT" || error.message?.includes("ENOENT")) {
|
||||
// Check if this is a PowerShell script error on Windows
|
||||
const isWindows = os.platform() === "win32"
|
||||
const isPowerShellScript = isWindows && claudePath.toLowerCase().endsWith(".ps1")
|
||||
if (isPowerShellScript && error.message?.includes("powershell.exe")) {
|
||||
// PowerShell itself is not found
|
||||
throw new Error(`PowerShell is not available or not in PATH. Original error: ${error.message}`)
|
||||
}
|
||||
throw createClaudeCodeNotFoundError(claudePath, error)
|
||||
}
|
||||
throw error
|
||||
|
|
@ -65,7 +72,17 @@ export async function* runClaudeCode(
|
|||
process.on("error", (err) => {
|
||||
// Enhance ENOENT errors with helpful installation guidance
|
||||
if (err.message.includes("ENOENT") || (err as any).code === "ENOENT") {
|
||||
processState.error = createClaudeCodeNotFoundError(claudePath, err)
|
||||
// Check if this is a PowerShell script error on Windows
|
||||
const isWindows = os.platform() === "win32"
|
||||
const isPowerShellScript = isWindows && claudePath.toLowerCase().endsWith(".ps1")
|
||||
if (isPowerShellScript && err.message?.includes("powershell.exe")) {
|
||||
// PowerShell itself is not found
|
||||
processState.error = new Error(
|
||||
`PowerShell is not available or not in PATH. Original error: ${err.message}`,
|
||||
)
|
||||
} else {
|
||||
processState.error = createClaudeCodeNotFoundError(claudePath, err)
|
||||
}
|
||||
} else {
|
||||
processState.error = err
|
||||
}
|
||||
|
|
@ -153,8 +170,22 @@ function runProcess({
|
|||
const claudePath = path || "claude"
|
||||
const isWindows = os.platform() === "win32"
|
||||
|
||||
// Check if the path is a PowerShell script on Windows
|
||||
const isPowerShellScript = isWindows && claudePath.toLowerCase().endsWith(".ps1")
|
||||
|
||||
// Build args based on platform
|
||||
const args = ["-p"]
|
||||
let executablePath: string
|
||||
let args: string[]
|
||||
|
||||
if (isPowerShellScript) {
|
||||
// For PowerShell scripts on Windows, execute through PowerShell
|
||||
executablePath = "powershell.exe"
|
||||
args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", claudePath, "-p"]
|
||||
} else {
|
||||
// For regular executables
|
||||
executablePath = claudePath
|
||||
args = ["-p"]
|
||||
}
|
||||
|
||||
// Pass system prompt as flag on non-Windows, via stdin on Windows (avoids cmd length limits)
|
||||
if (!isWindows) {
|
||||
|
|
@ -176,7 +207,7 @@ function runProcess({
|
|||
args.push("--model", modelId)
|
||||
}
|
||||
|
||||
const child = execa(claudePath, args, {
|
||||
const child = execa(executablePath, args, {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue