Skip to content

Commit 3979d90

Browse files
committed
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
1 parent 2e99d5b commit 3979d90

File tree

2 files changed

+367
-3
lines changed

2 files changed

+367
-3
lines changed
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"
2+
3+
// Mock i18n system
4+
vi.mock("../../../i18n", () => ({
5+
t: vi.fn((key: string, options?: Record<string, any>) => {
6+
if (key === "errors.claudeCode.notFound") {
7+
const claudePath = options?.claudePath || "claude"
8+
const installationUrl = options?.installationUrl || "https://docs.anthropic.com/en/docs/claude-code/setup"
9+
const originalError = options?.originalError || "spawn claude ENOENT"
10+
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}`
11+
}
12+
return key
13+
}),
14+
}))
15+
16+
// Mock os module
17+
const mockPlatform = vi.fn()
18+
vi.mock("os", () => ({
19+
platform: mockPlatform,
20+
}))
21+
22+
// Mock vscode workspace
23+
vi.mock("vscode", () => ({
24+
workspace: {
25+
workspaceFolders: [
26+
{
27+
uri: {
28+
fsPath: "/test/workspace",
29+
},
30+
},
31+
],
32+
},
33+
}))
34+
35+
// Mock execa
36+
const mockExeca = vi.fn()
37+
const mockStdin = {
38+
write: vi.fn((data, encoding, callback) => {
39+
if (callback) callback(null)
40+
}),
41+
end: vi.fn(),
42+
}
43+
44+
const createMockProcess = () => {
45+
let resolveProcess: (value: { exitCode: number }) => void
46+
const processPromise = new Promise<{ exitCode: number }>((resolve) => {
47+
resolveProcess = resolve
48+
})
49+
50+
const mockProcess = {
51+
stdin: mockStdin,
52+
stdout: {
53+
on: vi.fn(),
54+
},
55+
stderr: {
56+
on: vi.fn(),
57+
},
58+
on: vi.fn((event, callback) => {
59+
if (event === "close") {
60+
setTimeout(() => {
61+
callback(0)
62+
resolveProcess({ exitCode: 0 })
63+
}, 10)
64+
}
65+
}),
66+
killed: false,
67+
kill: vi.fn(),
68+
then: processPromise.then.bind(processPromise),
69+
catch: processPromise.catch.bind(processPromise),
70+
finally: processPromise.finally.bind(processPromise),
71+
}
72+
return mockProcess
73+
}
74+
75+
vi.mock("execa", () => ({
76+
execa: mockExeca,
77+
}))
78+
79+
// Mock readline
80+
let mockReadlineInterface: any = null
81+
82+
vi.mock("readline", () => ({
83+
default: {
84+
createInterface: vi.fn(() => {
85+
mockReadlineInterface = {
86+
async *[Symbol.asyncIterator]() {
87+
yield '{"type":"text","text":"PowerShell test response"}'
88+
return
89+
},
90+
close: vi.fn(),
91+
}
92+
return mockReadlineInterface
93+
}),
94+
},
95+
}))
96+
97+
describe("runClaudeCode - PowerShell Script Support", () => {
98+
beforeEach(() => {
99+
vi.clearAllMocks()
100+
mockExeca.mockReturnValue(createMockProcess())
101+
vi.spyOn(global, "setImmediate").mockImplementation((callback: any) => {
102+
callback()
103+
return {} as any
104+
})
105+
vi.resetModules()
106+
})
107+
108+
afterEach(() => {
109+
vi.restoreAllMocks()
110+
})
111+
112+
test("should execute .ps1 files through PowerShell on Windows", async () => {
113+
mockPlatform.mockReturnValue("win32")
114+
115+
const { runClaudeCode } = await import("../run")
116+
const options = {
117+
systemPrompt: "You are a helpful assistant",
118+
messages: [{ role: "user" as const, content: "Hello" }],
119+
path: "C:\\Users\\test\\AppData\\Local\\fnm_multishells\\52480_1754403777187\\claude.ps1",
120+
}
121+
122+
const generator = runClaudeCode(options)
123+
const results = []
124+
for await (const chunk of generator) {
125+
results.push(chunk)
126+
}
127+
128+
// Verify PowerShell was called with correct arguments
129+
const [executablePath, args] = mockExeca.mock.calls[0]
130+
expect(executablePath).toBe("powershell.exe")
131+
expect(args).toContain("-NoProfile")
132+
expect(args).toContain("-ExecutionPolicy")
133+
expect(args).toContain("Bypass")
134+
expect(args).toContain("-File")
135+
expect(args).toContain("C:\\Users\\test\\AppData\\Local\\fnm_multishells\\52480_1754403777187\\claude.ps1")
136+
expect(args).toContain("-p")
137+
138+
// Verify the response was received (as parsed object, not string)
139+
expect(results).toContainEqual({ type: "text", text: "PowerShell test response" })
140+
})
141+
142+
test("should execute .PS1 files (uppercase) through PowerShell on Windows", async () => {
143+
mockPlatform.mockReturnValue("win32")
144+
145+
const { runClaudeCode } = await import("../run")
146+
const options = {
147+
systemPrompt: "You are a helpful assistant",
148+
messages: [{ role: "user" as const, content: "Hello" }],
149+
path: "C:\\Program Files\\Claude\\claude.PS1",
150+
}
151+
152+
const generator = runClaudeCode(options)
153+
await generator.next()
154+
await generator.return(undefined)
155+
156+
const [executablePath, args] = mockExeca.mock.calls[0]
157+
expect(executablePath).toBe("powershell.exe")
158+
expect(args).toContain("-File")
159+
expect(args).toContain("C:\\Program Files\\Claude\\claude.PS1")
160+
})
161+
162+
test("should not use PowerShell for .ps1 files on non-Windows platforms", async () => {
163+
mockPlatform.mockReturnValue("darwin")
164+
165+
const { runClaudeCode } = await import("../run")
166+
const options = {
167+
systemPrompt: "You are a helpful assistant",
168+
messages: [{ role: "user" as const, content: "Hello" }],
169+
path: "/usr/local/bin/claude.ps1",
170+
}
171+
172+
const generator = runClaudeCode(options)
173+
await generator.next()
174+
await generator.return(undefined)
175+
176+
// On non-Windows, should execute the file directly
177+
const [executablePath] = mockExeca.mock.calls[0]
178+
expect(executablePath).toBe("/usr/local/bin/claude.ps1")
179+
expect(executablePath).not.toBe("powershell.exe")
180+
})
181+
182+
test("should not use PowerShell for non-.ps1 files on Windows", async () => {
183+
mockPlatform.mockReturnValue("win32")
184+
185+
const { runClaudeCode } = await import("../run")
186+
const options = {
187+
systemPrompt: "You are a helpful assistant",
188+
messages: [{ role: "user" as const, content: "Hello" }],
189+
path: "C:\\Program Files\\Claude\\claude.exe",
190+
}
191+
192+
const generator = runClaudeCode(options)
193+
await generator.next()
194+
await generator.return(undefined)
195+
196+
// Should execute the .exe directly
197+
const [executablePath] = mockExeca.mock.calls[0]
198+
expect(executablePath).toBe("C:\\Program Files\\Claude\\claude.exe")
199+
expect(executablePath).not.toBe("powershell.exe")
200+
})
201+
202+
test("should handle PowerShell not found error gracefully", async () => {
203+
mockPlatform.mockReturnValue("win32")
204+
205+
// Mock PowerShell not found error
206+
const powershellError = new Error("spawn powershell.exe ENOENT")
207+
;(powershellError as any).code = "ENOENT"
208+
mockExeca.mockImplementationOnce(() => {
209+
throw powershellError
210+
})
211+
212+
const { runClaudeCode } = await import("../run")
213+
const options = {
214+
systemPrompt: "You are a helpful assistant",
215+
messages: [{ role: "user" as const, content: "Hello" }],
216+
path: "C:\\Users\\test\\claude.ps1",
217+
}
218+
219+
const generator = runClaudeCode(options)
220+
221+
// Should throw a helpful error about PowerShell not being available
222+
await expect(generator.next()).rejects.toThrow("PowerShell is not available or not in PATH")
223+
})
224+
225+
test("should handle .ps1 script not found error with helpful message", async () => {
226+
mockPlatform.mockReturnValue("win32")
227+
228+
// Mock script not found error (after PowerShell is found)
229+
const mockProcessWithError = createMockProcess()
230+
const scriptError = new Error("The system cannot find the file specified")
231+
232+
mockProcessWithError.on = vi.fn((event, callback) => {
233+
if (event === "error") {
234+
// This would happen if PowerShell runs but can't find the script
235+
const enhancedError = new Error("spawn ENOENT")
236+
;(enhancedError as any).code = "ENOENT"
237+
callback(enhancedError)
238+
}
239+
})
240+
241+
// Mock readline to close immediately when there's an error
242+
const mockReadlineForError = {
243+
[Symbol.asyncIterator]() {
244+
return {
245+
async next() {
246+
return { done: true, value: undefined }
247+
},
248+
}
249+
},
250+
close: vi.fn(),
251+
}
252+
253+
const readline = await import("readline")
254+
vi.mocked(readline.default.createInterface).mockReturnValueOnce(mockReadlineForError as any)
255+
mockExeca.mockReturnValueOnce(mockProcessWithError)
256+
257+
const { runClaudeCode } = await import("../run")
258+
const options = {
259+
systemPrompt: "You are a helpful assistant",
260+
messages: [{ role: "user" as const, content: "Hello" }],
261+
path: "C:\\Users\\test\\nonexistent.ps1",
262+
}
263+
264+
const generator = runClaudeCode(options)
265+
266+
// Should throw the standard Claude Code not found error
267+
await expect(generator.next()).rejects.toThrow(/Claude Code executable.*not found/)
268+
})
269+
270+
test("should pass model parameter correctly with PowerShell scripts", async () => {
271+
mockPlatform.mockReturnValue("win32")
272+
273+
const { runClaudeCode } = await import("../run")
274+
const options = {
275+
systemPrompt: "You are a helpful assistant",
276+
messages: [{ role: "user" as const, content: "Hello" }],
277+
path: "C:\\claude.ps1",
278+
modelId: "claude-3-5-sonnet-20241022",
279+
}
280+
281+
const generator = runClaudeCode(options)
282+
await generator.next()
283+
await generator.return(undefined)
284+
285+
const [, args] = mockExeca.mock.calls[0]
286+
expect(args).toContain("--model")
287+
expect(args).toContain("claude-3-5-sonnet-20241022")
288+
})
289+
290+
test("should pass maxOutputTokens correctly with PowerShell scripts", async () => {
291+
mockPlatform.mockReturnValue("win32")
292+
293+
const { runClaudeCode } = await import("../run")
294+
const options = {
295+
systemPrompt: "You are a helpful assistant",
296+
messages: [{ role: "user" as const, content: "Hello" }],
297+
path: "C:\\claude.ps1",
298+
maxOutputTokens: 8192,
299+
}
300+
301+
const generator = runClaudeCode(options)
302+
await generator.next()
303+
await generator.return(undefined)
304+
305+
const [, , execOptions] = mockExeca.mock.calls[0]
306+
expect(execOptions.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe("8192")
307+
})
308+
309+
test("should handle stdin correctly with PowerShell scripts on Windows", async () => {
310+
mockPlatform.mockReturnValue("win32")
311+
312+
const { runClaudeCode } = await import("../run")
313+
const messages = [{ role: "user" as const, content: "Test message" }]
314+
const systemPrompt = "Test prompt"
315+
const options = {
316+
systemPrompt,
317+
messages,
318+
path: "C:\\claude.ps1",
319+
}
320+
321+
const generator = runClaudeCode(options)
322+
await generator.next()
323+
await generator.return(undefined)
324+
325+
// On Windows with PowerShell, should pass both system prompt and messages via stdin
326+
const expectedStdinData = JSON.stringify({ systemPrompt, messages })
327+
expect(mockStdin.write).toHaveBeenCalledWith(expectedStdinData, "utf8", expect.any(Function))
328+
329+
// Should NOT have --system-prompt in args (passed via stdin instead)
330+
const [, args] = mockExeca.mock.calls[0]
331+
expect(args).not.toContain("--system-prompt")
332+
})
333+
})

src/integrations/claude-code/run.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export async function* runClaudeCode(
3737
} catch (error: any) {
3838
// Handle ENOENT errors immediately when spawning the process
3939
if (error.code === "ENOENT" || error.message?.includes("ENOENT")) {
40+
// Check if this is a PowerShell script error on Windows
41+
const isWindows = os.platform() === "win32"
42+
const isPowerShellScript = isWindows && claudePath.toLowerCase().endsWith(".ps1")
43+
if (isPowerShellScript && error.message?.includes("powershell.exe")) {
44+
// PowerShell itself is not found
45+
throw new Error(`PowerShell is not available or not in PATH. Original error: ${error.message}`)
46+
}
4047
throw createClaudeCodeNotFoundError(claudePath, error)
4148
}
4249
throw error
@@ -65,7 +72,17 @@ export async function* runClaudeCode(
6572
process.on("error", (err) => {
6673
// Enhance ENOENT errors with helpful installation guidance
6774
if (err.message.includes("ENOENT") || (err as any).code === "ENOENT") {
68-
processState.error = createClaudeCodeNotFoundError(claudePath, err)
75+
// Check if this is a PowerShell script error on Windows
76+
const isWindows = os.platform() === "win32"
77+
const isPowerShellScript = isWindows && claudePath.toLowerCase().endsWith(".ps1")
78+
if (isPowerShellScript && err.message?.includes("powershell.exe")) {
79+
// PowerShell itself is not found
80+
processState.error = new Error(
81+
`PowerShell is not available or not in PATH. Original error: ${err.message}`,
82+
)
83+
} else {
84+
processState.error = createClaudeCodeNotFoundError(claudePath, err)
85+
}
6986
} else {
7087
processState.error = err
7188
}
@@ -153,8 +170,22 @@ function runProcess({
153170
const claudePath = path || "claude"
154171
const isWindows = os.platform() === "win32"
155172

173+
// Check if the path is a PowerShell script on Windows
174+
const isPowerShellScript = isWindows && claudePath.toLowerCase().endsWith(".ps1")
175+
156176
// Build args based on platform
157-
const args = ["-p"]
177+
let executablePath: string
178+
let args: string[]
179+
180+
if (isPowerShellScript) {
181+
// For PowerShell scripts on Windows, execute through PowerShell
182+
executablePath = "powershell.exe"
183+
args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", claudePath, "-p"]
184+
} else {
185+
// For regular executables
186+
executablePath = claudePath
187+
args = ["-p"]
188+
}
158189

159190
// Pass system prompt as flag on non-Windows, via stdin on Windows (avoids cmd length limits)
160191
if (!isWindows) {
@@ -176,7 +207,7 @@ function runProcess({
176207
args.push("--model", modelId)
177208
}
178209

179-
const child = execa(claudePath, args, {
210+
const child = execa(executablePath, args, {
180211
stdin: "pipe",
181212
stdout: "pipe",
182213
stderr: "pipe",

0 commit comments

Comments
 (0)