Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
15 changes: 9 additions & 6 deletions src/agents/claude-agent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isAbsolute, resolve } from "node:path";

import { resolveExecutable } from "./resolve-executable";
import type {
Agent,
AgentOptions,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand Down
19 changes: 14 additions & 5 deletions src/agents/codex-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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-"),
Expand All @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
55 changes: 55 additions & 0 deletions src/agents/resolve-executable.ts
Original file line number Diff line number Diff line change
@@ -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);
}
89 changes: 89 additions & 0 deletions tests/agents/resolve-executable.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});