From 5f771f0783cd1cc2e9cd835afc3d6097eeefa354 Mon Sep 17 00:00:00 2001 From: Kangyu Zhu Date: Thu, 30 Jul 2026 11:52:23 -0700 Subject: [PATCH] Fix Windows ENOENT/uv_spawn when spawning Agent CLIs Bun.spawn performs no PATHEXT-style resolution and executes the literal command string it is given. npm installs a cross-platform executable as sibling files sharing one base name: an extension-less POSIX shell script, a `.cmd` batch wrapper, and sometimes a `.ps1` wrapper. Both CodexAgent and ClaudeAgent handed Bun.spawn the bare, extension-less command name, which native Windows process creation cannot execute directly, raising `ENOENT: no such file or directory, uv_spawn`. Both agents now resolve the configured command through resolveExecutable(), which prefers a `.exe`/`.cmd`/`.bat` sibling on win32 before falling back to the bare name, and pass the resolved path to Bun.spawn instead. Fixes #4. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 ++ src/agents/claude-agent.ts | 15 +++-- src/agents/codex-agent.ts | 19 ++++-- src/agents/resolve-executable.ts | 55 +++++++++++++++ tests/agents/resolve-executable.test.ts | 89 +++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/agents/resolve-executable.ts create mode 100644 tests/agents/resolve-executable.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a49f0d..e6220a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Fixed `ENOENT: no such file or directory, uv_spawn` when running + `CodexAgent` or `ClaudeAgent` on Windows. Both now resolve the configured + command through its `.exe`/`.cmd`/`.bat` sibling before spawning, instead of + handing `Bun.spawn` the bare, extension-less npm shim name it cannot + execute directly. - Removed the general-purpose `deer-workflow agent` CLI command. Agent runtime selection remains available on `deer-workflow create`. diff --git a/src/agents/claude-agent.ts b/src/agents/claude-agent.ts index eda58ac..deae5fe 100644 --- a/src/agents/claude-agent.ts +++ b/src/agents/claude-agent.ts @@ -1,5 +1,6 @@ import { isAbsolute, resolve } from "node:path"; +import { resolveExecutable } from "./resolve-executable"; import type { Agent, AgentOptions, @@ -120,9 +121,9 @@ export class ClaudeAgent implements Agent { } options.signal?.throwIfAborted(); - this.#assertCommandAvailable(); + const resolvedCommand = this.#resolveCommandOrThrow(); - const command = this.#buildCommand(options); + const command = this.#buildCommand(options, resolvedCommand); const cwd = resolve(options.cwd ?? this.#config.cwd ?? process.cwd()); const subprocess = Bun.spawn(command, { cwd, @@ -188,10 +189,12 @@ export class ClaudeAgent implements Agent { } } - #assertCommandAvailable(): void { - if (Bun.which(this.#config.command) === null) { + #resolveCommandOrThrow(): string { + const resolved = resolveExecutable(this.#config.command); + if (resolved === null) { throw new ClaudeCliNotFoundError(this.#config.command); } + return resolved; } #parseResultMessage( @@ -208,9 +211,9 @@ export class ClaudeAgent implements Agent { } } - #buildCommand(options: AgentOptions): string[] { + #buildCommand(options: AgentOptions, resolvedCommand: string): string[] { const command = [ - this.#config.command, + resolvedCommand, ...this.#config.commandArgs, "--print", "--output-format", diff --git a/src/agents/codex-agent.ts b/src/agents/codex-agent.ts index 4db79f6..d484778 100644 --- a/src/agents/codex-agent.ts +++ b/src/agents/codex-agent.ts @@ -2,6 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; +import { resolveExecutable } from "./resolve-executable"; import type { Agent, AgentOptions, @@ -123,7 +124,7 @@ export class CodexAgent implements Agent { } options.signal?.throwIfAborted(); - this.#assertCommandAvailable(); + const resolvedCommand = this.#resolveCommandOrThrow(); const temporaryDirectory = await mkdtemp( join(tmpdir(), "deer-workflow-codex-"), @@ -132,7 +133,12 @@ export class CodexAgent implements Agent { const schemaPath = join(temporaryDirectory, "output-schema.json"); try { - const command = this.#buildCommand(options, outputPath, schemaPath); + const command = this.#buildCommand( + options, + resolvedCommand, + outputPath, + schemaPath, + ); if (options.schema) { await writeFile( @@ -202,19 +208,22 @@ export class CodexAgent implements Agent { } } - #assertCommandAvailable(): void { - if (Bun.which(this.#config.command) === null) { + #resolveCommandOrThrow(): string { + const resolved = resolveExecutable(this.#config.command); + if (resolved === null) { throw new CodexCliNotFoundError(this.#config.command); } + return resolved; } #buildCommand( options: AgentOptions, + resolvedCommand: string, outputPath: string, schemaPath: string, ): string[] { const command = [ - this.#config.command, + resolvedCommand, ...this.#config.commandArgs, "exec", "--color", diff --git a/src/agents/resolve-executable.ts b/src/agents/resolve-executable.ts new file mode 100644 index 0000000..ce228c5 --- /dev/null +++ b/src/agents/resolve-executable.ts @@ -0,0 +1,55 @@ +import { posix, win32 } from "node:path"; + +/** + * Extensions checked, in priority order, when resolving a bare command name + * on Windows. + * + * @remarks + * npm installs a cross-platform executable as a set of sibling files sharing + * one base name: an extension-less POSIX shell script, a `.cmd` batch + * wrapper, and (for some packages) a `.ps1` PowerShell wrapper. `Bun.spawn` + * performs no PATHEXT-style resolution and executes the literal string it is + * given, so handing it the bare, extension-less name on Windows raises + * `ENOENT: no such file or directory, uv_spawn`. Resolving to the `.cmd` + * sibling first lets the underlying process-creation call land on a file + * Windows can execute directly. + */ +const WINDOWS_EXECUTABLE_EXTENSIONS = [".exe", ".cmd", ".bat"]; + +/** + * `Bun.which`-compatible executable resolver. + */ +export type ExecutableResolver = (command: string) => string | null; + +/** + * Resolves the actual path to spawn for a configured command name, working + * around `Bun.spawn`'s lack of Windows PATHEXT resolution. + * + * @param command - Configured executable name or path. + * @param platform - Current `process.platform`. Injectable so behavior can be + * exercised for `win32` from tests running on any host OS. + * @param which - `Bun.which`-compatible resolver. Injectable for tests. + * @returns The resolved path to spawn, or `null` when nothing resolves. + */ +export function resolveExecutable( + command: string, + platform: NodeJS.Platform = process.platform, + which: ExecutableResolver = Bun.which, +): string | null { + if (platform !== "win32") { + return which(command); + } + + if (win32.isAbsolute(command) || posix.isAbsolute(command)) { + return which(command); + } + + for (const extension of WINDOWS_EXECUTABLE_EXTENSIONS) { + const resolved = which(`${command}${extension}`); + if (resolved !== null) { + return resolved; + } + } + + return which(command); +} diff --git a/tests/agents/resolve-executable.test.ts b/tests/agents/resolve-executable.test.ts new file mode 100644 index 0000000..323340e --- /dev/null +++ b/tests/agents/resolve-executable.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; + +import { resolveExecutable } from "../../src/agents/resolve-executable"; + +describe("resolveExecutable", () => { + test("on non-Windows platforms, resolves the bare command as-is", () => { + const which = (command: string): string | null => + command === "claude" ? "/usr/local/bin/claude" : null; + + expect(resolveExecutable("claude", "darwin", which)).toBe( + "/usr/local/bin/claude", + ); + }); + + test("on non-Windows platforms, does not probe Windows extensions", () => { + const seen: string[] = []; + const which = (command: string): string | null => { + seen.push(command); + return null; + }; + + resolveExecutable("claude", "linux", which); + + expect(seen).toEqual(["claude"]); + }); + + test("on Windows, prefers a resolved .exe over the bare npm shim name", () => { + const which = (command: string): string | null => { + if (command === "claude.exe") return "C:\\bin\\claude.exe"; + if (command === "claude") return "C:\\bin\\claude"; + return null; + }; + + expect(resolveExecutable("claude", "win32", which)).toBe( + "C:\\bin\\claude.exe", + ); + }); + + test("on Windows, falls back to .cmd when no .exe is present", () => { + const which = (command: string): string | null => { + if (command === "claude.cmd") return "C:\\bin\\claude.cmd"; + if (command === "claude") return "C:\\bin\\claude"; + return null; + }; + + expect(resolveExecutable("claude", "win32", which)).toBe( + "C:\\bin\\claude.cmd", + ); + }); + + test("on Windows, falls back to the bare name when no Windows-executable extension resolves", () => { + const which = (command: string): string | null => + command === "claude" ? "C:\\bin\\claude" : null; + + expect(resolveExecutable("claude", "win32", which)).toBe("C:\\bin\\claude"); + }); + + test("on Windows, returns null when nothing resolves at all", () => { + const which = (): string | null => null; + + expect(resolveExecutable("claude", "win32", which)).toBeNull(); + }); + + test("on Windows, an absolute Windows path bypasses extension probing", () => { + const seen: string[] = []; + const which = (command: string): string | null => { + seen.push(command); + return command; + }; + + const result = resolveExecutable("C:\\tools\\claude", "win32", which); + + expect(result).toBe("C:\\tools\\claude"); + expect(seen).toEqual(["C:\\tools\\claude"]); + }); + + test("on Windows, an absolute POSIX-style path bypasses extension probing", () => { + const seen: string[] = []; + const which = (command: string): string | null => { + seen.push(command); + return command; + }; + + const result = resolveExecutable("/tools/claude", "win32", which); + + expect(result).toBe("/tools/claude"); + expect(seen).toEqual(["/tools/claude"]); + }); +});