Skip to content

Commit 313666f

Browse files
committed
fix: improve Claude Code ENOENT error handling and add comprehensive tests
- Fixed variable scoping issue with claudePath in runClaudeCode function - Added comprehensive test coverage for ENOENT error handling scenarios - Improved error message formatting for better readability - Added tests for both process spawn and execution ENOENT errors - Added tests for custom path handling and error prioritization - Ensured non-ENOENT errors are preserved correctly
1 parent 83486e1 commit 313666f

File tree

2 files changed

+188
-21
lines changed

2 files changed

+188
-21
lines changed

src/integrations/claude-code/__tests__/run.spec.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,4 +289,176 @@ describe("runClaudeCode", () => {
289289
consoleErrorSpy.mockRestore()
290290
await generator.return(undefined)
291291
})
292+
293+
test("should handle ENOENT errors during process spawn with helpful error message", async () => {
294+
const { runClaudeCode } = await import("../run")
295+
296+
// Mock execa to throw ENOENT error
297+
const enoentError = new Error("spawn claude ENOENT")
298+
;(enoentError as any).code = "ENOENT"
299+
mockExeca.mockImplementationOnce(() => {
300+
throw enoentError
301+
})
302+
303+
const options = {
304+
systemPrompt: "You are a helpful assistant",
305+
messages: [{ role: "user" as const, content: "Hello" }],
306+
}
307+
308+
const generator = runClaudeCode(options)
309+
310+
// Should throw enhanced ENOENT error
311+
await expect(generator.next()).rejects.toThrow(/Claude Code executable 'claude' not found/)
312+
await expect(generator.next()).rejects.toThrow(/Please install Claude Code CLI/)
313+
await expect(generator.next()).rejects.toThrow(/Original error: spawn claude ENOENT/)
314+
})
315+
316+
test("should handle ENOENT errors during process execution with helpful error message", async () => {
317+
const { runClaudeCode } = await import("../run")
318+
319+
// Create a mock process that emits ENOENT error
320+
const mockProcessWithError = createMockProcess()
321+
const enoentError = new Error("spawn claude ENOENT")
322+
;(enoentError as any).code = "ENOENT"
323+
324+
mockProcessWithError.on = vi.fn((event, callback) => {
325+
if (event === "error") {
326+
// Emit ENOENT error
327+
setTimeout(() => callback(enoentError), 5)
328+
} else if (event === "close") {
329+
// Don't emit close event in this test
330+
}
331+
})
332+
333+
mockExeca.mockReturnValueOnce(mockProcessWithError)
334+
335+
const options = {
336+
systemPrompt: "You are a helpful assistant",
337+
messages: [{ role: "user" as const, content: "Hello" }],
338+
}
339+
340+
const generator = runClaudeCode(options)
341+
342+
// Should throw enhanced ENOENT error
343+
await expect(generator.next()).rejects.toThrow(/Claude Code executable 'claude' not found/)
344+
await expect(generator.next()).rejects.toThrow(/Please install Claude Code CLI/)
345+
await expect(generator.next()).rejects.toThrow(/Original error: spawn claude ENOENT/)
346+
})
347+
348+
test("should handle ENOENT errors with custom claude path", async () => {
349+
const { runClaudeCode } = await import("../run")
350+
351+
const customPath = "/custom/path/to/claude"
352+
const enoentError = new Error(`spawn ${customPath} ENOENT`)
353+
;(enoentError as any).code = "ENOENT"
354+
mockExeca.mockImplementationOnce(() => {
355+
throw enoentError
356+
})
357+
358+
const options = {
359+
systemPrompt: "You are a helpful assistant",
360+
messages: [{ role: "user" as const, content: "Hello" }],
361+
path: customPath,
362+
}
363+
364+
const generator = runClaudeCode(options)
365+
366+
// Should throw enhanced ENOENT error with custom path
367+
await expect(generator.next()).rejects.toThrow(`Claude Code executable '${customPath}' not found`)
368+
})
369+
370+
test("should preserve non-ENOENT errors during process spawn", async () => {
371+
const { runClaudeCode } = await import("../run")
372+
373+
// Mock execa to throw non-ENOENT error
374+
const otherError = new Error("Permission denied")
375+
mockExeca.mockImplementationOnce(() => {
376+
throw otherError
377+
})
378+
379+
const options = {
380+
systemPrompt: "You are a helpful assistant",
381+
messages: [{ role: "user" as const, content: "Hello" }],
382+
}
383+
384+
const generator = runClaudeCode(options)
385+
386+
// Should throw original error, not enhanced ENOENT error
387+
await expect(generator.next()).rejects.toThrow("Permission denied")
388+
await expect(generator.next()).rejects.not.toThrow(/Claude Code executable/)
389+
})
390+
391+
test("should preserve non-ENOENT errors during process execution", async () => {
392+
const { runClaudeCode } = await import("../run")
393+
394+
// Create a mock process that emits non-ENOENT error
395+
const mockProcessWithError = createMockProcess()
396+
const otherError = new Error("Permission denied")
397+
398+
mockProcessWithError.on = vi.fn((event, callback) => {
399+
if (event === "error") {
400+
// Emit non-ENOENT error
401+
setTimeout(() => callback(otherError), 5)
402+
} else if (event === "close") {
403+
// Don't emit close event in this test
404+
}
405+
})
406+
407+
mockExeca.mockReturnValueOnce(mockProcessWithError)
408+
409+
const options = {
410+
systemPrompt: "You are a helpful assistant",
411+
messages: [{ role: "user" as const, content: "Hello" }],
412+
}
413+
414+
const generator = runClaudeCode(options)
415+
416+
// Should throw original error, not enhanced ENOENT error
417+
await expect(generator.next()).rejects.toThrow("Permission denied")
418+
await expect(generator.next()).rejects.not.toThrow(/Claude Code executable/)
419+
})
420+
421+
test("should prioritize ClaudeCodeNotFoundError over generic exit code errors", async () => {
422+
const { runClaudeCode } = await import("../run")
423+
424+
// Create a mock process that emits ENOENT error and then exits with non-zero code
425+
const mockProcessWithError = createMockProcess()
426+
const enoentError = new Error("spawn claude ENOENT")
427+
;(enoentError as any).code = "ENOENT"
428+
429+
let resolveProcess: (value: { exitCode: number }) => void
430+
const processPromise = new Promise<{ exitCode: number }>((resolve) => {
431+
resolveProcess = resolve
432+
})
433+
434+
mockProcessWithError.on = vi.fn((event, callback) => {
435+
if (event === "error") {
436+
// Emit ENOENT error
437+
setTimeout(() => callback(enoentError), 5)
438+
} else if (event === "close") {
439+
// Emit non-zero exit code
440+
setTimeout(() => {
441+
callback(1)
442+
resolveProcess({ exitCode: 1 })
443+
}, 10)
444+
}
445+
})
446+
447+
mockProcessWithError.then = processPromise.then.bind(processPromise)
448+
mockProcessWithError.catch = processPromise.catch.bind(processPromise)
449+
mockProcessWithError.finally = processPromise.finally.bind(processPromise)
450+
451+
mockExeca.mockReturnValueOnce(mockProcessWithError)
452+
453+
const options = {
454+
systemPrompt: "You are a helpful assistant",
455+
messages: [{ role: "user" as const, content: "Hello" }],
456+
}
457+
458+
const generator = runClaudeCode(options)
459+
460+
// Should throw ClaudeCodeNotFoundError, not generic exit code error
461+
await expect(generator.next()).rejects.toThrow(/Claude Code executable 'claude' not found/)
462+
await expect(generator.next()).rejects.not.toThrow(/process exited with code/)
463+
})
292464
})

src/integrations/claude-code/run.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type ProcessState = {
2525
export async function* runClaudeCode(
2626
options: ClaudeCodeOptions & { maxOutputTokens?: number },
2727
): AsyncGenerator<ClaudeCodeMessage | string> {
28+
const claudePath = options.path || "claude"
2829
const process = runProcess(options)
2930

3031
const rl = readline.createInterface({
@@ -248,27 +249,21 @@ function attemptParseChunk(data: string): ClaudeCodeMessage | null {
248249
* Creates a user-friendly error message for Claude Code ENOENT errors
249250
*/
250251
function createClaudeCodeNotFoundError(claudePath: string, originalError: Error): Error {
251-
const platform = os.platform()
252-
253-
let suggestion: string
254-
switch (platform) {
255-
case "darwin": // macOS
256-
case "win32": // Windows
257-
case "linux":
258-
default:
259-
suggestion = "Please install Claude Code CLI:\n" +
260-
"1. Visit https://claude.ai/download to download Claude Code\n" +
261-
"2. Follow the installation instructions for your operating system\n" +
262-
"3. Ensure the 'claude' command is available in your PATH\n" +
263-
"4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'"
264-
break
265-
}
266-
267-
const errorMessage = `Claude Code executable '${claudePath}' not found.
268-
269-
${suggestion}
270-
271-
Original error: ${originalError.message}`
252+
const suggestion = [
253+
"Please install Claude Code CLI:",
254+
"1. Visit https://claude.ai/download to download Claude Code",
255+
"2. Follow the installation instructions for your operating system",
256+
"3. Ensure the 'claude' command is available in your PATH",
257+
"4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'"
258+
].join("\n")
259+
260+
const errorMessage = [
261+
`Claude Code executable '${claudePath}' not found.`,
262+
"",
263+
suggestion,
264+
"",
265+
`Original error: ${originalError.message}`
266+
].join("\n")
272267

273268
const error = new Error(errorMessage)
274269
error.name = "ClaudeCodeNotFoundError"

0 commit comments

Comments
 (0)