authorgravatar for nsxdavid@gmail.comDavid Whatley <nsxdavid@gmail.com> 2026-07-20 03:31:13-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 08:31:13+00:00
log3795dbb01723d7474db8ddf805b1f64d5b56919c
tree8c0bd686e37d6a513630fd45c0dcfef6adba45ad
parent2640e6dcfcb6a5ba2f01f20295ce8bacd2fdb05e
signature Signed by PGP key B5690EEEBB952194

fix(server): resolve Claude SDK executable path on Windows npm installs (#3740)


4 files changed, 226 insertions(+), 2 deletions(-)

apps/server/src/provider/Drivers/ClaudeExecutable.test.ts created+124
......@@ -0,0 +1,124 @@
1import { describe, expect, it } from "@effect/vitest";
2import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
3import { SpawnExecutableResolution } from "@t3tools/shared/shell";
4import * as Effect from "effect/Effect";
5
6import { ClaudeExecutableFileCheck, resolveClaudeSdkExecutablePath } from "./ClaudeExecutable.ts";
7
8const NPM_DIR = "C:\\Users\\dev\\AppData\\Roaming\\npm";
9const NPM_SHIM = `${NPM_DIR}\\claude.cmd`;
10const NPM_PACKAGE_EXE = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`;
11const NPM_PACKAGE_CLI = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`;
12
13function withWindowsResolution(input: {
14 readonly resolvedCommand: string | undefined;
15 readonly existingFiles?: ReadonlyArray<string>;
16}) {
17 const existing = new Set(input.existingFiles ?? []);
18 return <A, E, R>(effect: Effect.Effect<A, E, R>) =>
19 effect.pipe(
20 Effect.provideService(HostProcessPlatform, "win32"),
21 Effect.provideService(SpawnExecutableResolution, () => input.resolvedCommand),
22 Effect.provideService(ClaudeExecutableFileCheck, (filePath) => existing.has(filePath)),
23 );
24}
25
26describe("resolveClaudeSdkExecutablePath", () => {
27 it.effect("returns the configured path unchanged on non-Windows platforms", () =>
28 Effect.gen(function* () {
29 expect(
30 yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
31 Effect.provideService(HostProcessPlatform, "darwin"),
32 Effect.provideService(SpawnExecutableResolution, () => {
33 throw new Error("must not resolve on non-Windows platforms");
34 }),
35 ),
36 ).toBe("claude");
37 }),
38 );
39
40 it.effect("returns the resolved absolute path for native Windows executables", () =>
41 Effect.gen(function* () {
42 const nativeBinary = "C:\\Users\\dev\\.local\\bin\\claude.exe";
43 expect(
44 yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
45 withWindowsResolution({ resolvedCommand: nativeBinary }),
46 ),
47 ).toBe(nativeBinary);
48 }),
49 );
50
51 it.effect("follows an npm launcher shim to the packaged native binary", () =>
52 Effect.gen(function* () {
53 expect(
54 yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
55 withWindowsResolution({
56 resolvedCommand: NPM_SHIM,
57 existingFiles: [NPM_PACKAGE_EXE, NPM_PACKAGE_CLI],
58 }),
59 ),
60 ).toBe(NPM_PACKAGE_EXE);
61 }),
62 );
63
64 it.effect("follows .bat and .ps1 launcher shims the same way", () =>
65 Effect.gen(function* () {
66 for (const shim of [`${NPM_DIR}\\claude.bat`, `${NPM_DIR}\\claude.ps1`]) {
67 expect(
68 yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
69 withWindowsResolution({
70 resolvedCommand: shim,
71 existingFiles: [NPM_PACKAGE_EXE],
72 }),
73 ),
74 ).toBe(NPM_PACKAGE_EXE);
75 }
76 }),
77 );
78
79 it.effect("normalizes mixed-case shim extensions before matching", () =>
80 Effect.gen(function* () {
81 expect(
82 yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
83 withWindowsResolution({
84 resolvedCommand: `${NPM_DIR}\\claude.CMD`,
85 existingFiles: [NPM_PACKAGE_EXE],
86 }),
87 ),
88 ).toBe(NPM_PACKAGE_EXE);
89 }),
90 );
91
92 it.effect("falls back to cli.js when the package ships no native binary", () =>
93 Effect.gen(function* () {
94 expect(
95 yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
96 withWindowsResolution({
97 resolvedCommand: NPM_SHIM,
98 existingFiles: [NPM_PACKAGE_CLI],
99 }),
100 ),
101 ).toBe(NPM_PACKAGE_CLI);
102 }),
103 );
104
105 it.effect("returns the configured path when a shim has no known package entry", () =>
106 Effect.gen(function* () {
107 expect(
108 yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
109 withWindowsResolution({ resolvedCommand: NPM_SHIM }),
110 ),
111 ).toBe("claude");
112 }),
113 );
114
115 it.effect("returns the configured path when command resolution finds nothing", () =>
116 Effect.gen(function* () {
117 expect(
118 yield* resolveClaudeSdkExecutablePath("claude", {}).pipe(
119 withWindowsResolution({ resolvedCommand: undefined }),
120 ),
121 ).toBe("claude");
122 }),
123 );
124});
apps/server/src/provider/Drivers/ClaudeExecutable.ts created+90
......@@ -0,0 +1,90 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
6import { SpawnExecutableResolution } from "@t3tools/shared/shell";
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9
10/**
11 * Windows launcher-script extensions that Node cannot spawn without a shell
12 * (`spawn EINVAL` since Node 20.12) and that the Claude Agent SDK therefore
13 * cannot use as `pathToClaudeCodeExecutable`.
14 */
15const WINDOWS_SHIM_EXTENSIONS: ReadonlySet<string> = new Set([".cmd", ".bat", ".ps1"]);
16
17/**
18 * Entry points of the npm `@anthropic-ai/claude-code` package relative to the
19 * global `node_modules` directory that sits next to the npm launcher shim.
20 * Newer package versions ship a native `bin/claude.exe`; older versions only
21 * ship `cli.js`, which the SDK runs with a JavaScript runtime.
22 */
23const NPM_PACKAGE_ENTRY_CANDIDATES = [
24 ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"],
25 ["node_modules", "@anthropic-ai", "claude-code", "cli.js"],
26] as const;
27
28export type ExecutableFileCheck = (filePath: string) => boolean;
29
30function isExistingFile(filePath: string): boolean {
31 try {
32 return NodeFS.statSync(filePath).isFile();
33 } catch {
34 return false;
35 }
36}
37
38/** Injectable file-existence check so tests can run against a fake filesystem. */
39export const ClaudeExecutableFileCheck = Context.Reference<ExecutableFileCheck>(
40 "server/provider/Drivers/ClaudeExecutableFileCheck",
41 {
42 defaultValue: () => isExistingFile,
43 },
44);
45
46/**
47 * Resolves the configured Claude binary path into a value the Claude Agent
48 * SDK can spawn directly via `pathToClaudeCodeExecutable`.
49 *
50 * The SDK spawns the given path without a shell and without Windows PATH /
51 * PATHEXT resolution, so a bare command name like `claude` fails with
52 * "native binary not found" and an npm `claude.cmd` shim fails with
53 * `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, which can
54 * fall back to `shell: true`; the SDK offers no such escape hatch.
55 *
56 * On Windows this resolves the command against PATH/PATHEXT and, when the
57 * result is an npm launcher shim, follows it to the real package entry
58 * (`bin/claude.exe`, or `cli.js` for older package versions). On other
59 * platforms the configured value is returned unchanged.
60 */
61export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")(
62 function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return<string> {
63 const platform = yield* HostProcessPlatform;
64 if (platform !== "win32") {
65 return binaryPath;
66 }
67
68 const resolveExecutable = yield* SpawnExecutableResolution;
69 const isFile = yield* ClaudeExecutableFileCheck;
70 const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath;
71 const extension = NodePath.win32.extname(resolved).toLowerCase();
72 if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) {
73 return resolved;
74 }
75
76 const shimDirectory = NodePath.win32.dirname(resolved);
77 for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) {
78 const candidate = NodePath.win32.join(shimDirectory, ...entrySegments);
79 if (isFile(candidate)) {
80 return candidate;
81 }
82 }
83
84 yield* Effect.logWarning(
85 "Claude launcher shim resolved but no known package entry was found next to it; the Claude Agent SDK cannot spawn launcher scripts directly.",
86 { binaryPath, resolvedShimPath: resolved },
87 );
88 return binaryPath;
89 },
90);
apps/server/src/provider/Layers/ClaudeAdapter.ts+6-1
......@@ -70,6 +70,7 @@ import * as Stream from "effect/Stream";
7070import { resolveAttachmentPath } from "../../attachmentStore.ts";
7171import { ServerConfig } from "../../config.ts";
7272import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
73import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
7374import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
7475import {
7576 getClaudeModelCapabilities,
......@@ -1347,6 +1348,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
13471348 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe(
13481349 Effect.provideService(Path.Path, path),
13491350 );
1351 const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath(
1352 claudeSettings.binaryPath,
1353 claudeEnvironment,
1354 );
13501355 const nativeEventLogger =
13511356 options?.nativeEventLogger ??
13521357 (options?.nativeEventLogPath !== undefined
......@@ -3405,7 +3410,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
34053410 const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
34063411 runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
34073412
3408 const claudeBinaryPath = claudeSettings.binaryPath;
3413 const claudeBinaryPath = claudeSdkExecutablePath;
34093414 const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags;
34103415 const modelSelection =
34113416 input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined;
apps/server/src/provider/Layers/ClaudeProvider.ts+6-1
......@@ -37,6 +37,7 @@ import {
3737 spawnAndCollect,
3838 type ServerProviderDraft,
3939} from "../providerSnapshot.ts";
40import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
4041import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
4142
4243const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
......@@ -588,6 +589,10 @@ const probeClaudeCapabilities = (
588589 const abort = new AbortController();
589590 return Effect.gen(function* () {
590591 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
592 const executablePath = yield* resolveClaudeSdkExecutablePath(
593 claudeSettings.binaryPath,
594 claudeEnvironment,
595 );
591596 return yield* Effect.tryPromise(async () => {
592597 const q = claudeQuery({
593598 // Never yield — we only need initialization data, not a conversation.
......@@ -598,7 +603,7 @@ const probeClaudeCapabilities = (
598603 })(),
599604 options: {
600605 persistSession: false,
601 pathToClaudeCodeExecutable: claudeSettings.binaryPath,
606 pathToClaudeCodeExecutable: executablePath,
602607 abortController: abort,
603608 settingSources: ["user", "project", "local"],
604609 allowedTools: [],