1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4
5import * as HostProcess 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 * Whether `filePath` names a Windows launcher script that the Claude Agent SDK
19 * cannot spawn directly as `pathToClaudeCodeExecutable`.
20 */
21export const isWindowsClaudeLauncherShimPath = (filePath: string): boolean =>
22 WINDOWS_SHIM_EXTENSIONS.has(NodePath.win32.extname(filePath).toLowerCase());
23
24/**
25 * Entry points of the npm `@anthropic-ai/claude-code` package relative to the
26 * global `node_modules` directory that sits next to the npm launcher shim.
27 * Newer package versions ship a native `bin/claude.exe`; older versions only
28 * ship `cli.js`, which the SDK runs with a JavaScript runtime.
29 */
30const NPM_PACKAGE_ENTRY_CANDIDATES = [
31 ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"],
32 ["node_modules", "@anthropic-ai", "claude-code", "cli.js"],
33] as const;
34
35export type ExecutableFileCheck = (filePath: string) => boolean;
36
37function isExistingFile(filePath: string): boolean {
38 try {
39 return NodeFS.statSync(filePath).isFile();
40 } catch {
41 return false;
42 }
43}
44
45/** Injectable file-existence check so tests can run against a fake filesystem. */
46export const ClaudeExecutableFileCheck = Context.Reference<ExecutableFileCheck>(
47 "server/provider/Drivers/ClaudeExecutableFileCheck",
48 {
49 defaultValue: () => isExistingFile,
50 },
51);
52
53/**
54 * Resolves the configured Claude binary path into a value the Claude Agent
55 * SDK can spawn directly via `pathToClaudeCodeExecutable`.
56 *
57 * The SDK spawns the given path without a shell and without Windows PATH /
58 * PATHEXT resolution, so a bare command name like `claude` fails with
59 * "native binary not found" and an npm `claude.cmd` shim fails with
60 * `spawn EINVAL`. CLI probes avoid this via `resolveSpawnCommand`, which can
61 * fall back to `shell: true`; the SDK offers no such escape hatch.
62 *
63 * On Windows this resolves the command against PATH/PATHEXT and, when the
64 * result is an npm launcher shim, follows it to the real package entry
65 * (`bin/claude.exe`, or `cli.js` for older package versions). On other
66 * platforms the configured value is returned unchanged.
67 */
68export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecutablePath")(
69 function* (binaryPath: string, environment: NodeJS.ProcessEnv): Effect.fn.Return<string> {
70 const platform = yield* HostProcess.Platform;
71 if (platform !== "win32") {
72 return binaryPath;
73 }
74
75 const resolveExecutable = yield* SpawnExecutableResolution;
76 const isFile = yield* ClaudeExecutableFileCheck;
77 const resolved = resolveExecutable(binaryPath, platform, environment) ?? binaryPath;
78 const extension = NodePath.win32.extname(resolved).toLowerCase();
79 if (!WINDOWS_SHIM_EXTENSIONS.has(extension)) {
80 return resolved;
81 }
82
83 const shimDirectory = NodePath.win32.dirname(resolved);
84 for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) {
85 const candidate = NodePath.win32.join(shimDirectory, ...entrySegments);
86 if (isFile(candidate)) {
87 return candidate;
88 }
89 }
90
91 yield* Effect.logWarning(
92 "Claude launcher shim resolved but no known package entry was found next to it; the Claude Agent SDK cannot spawn launcher scripts directly.",
93 { binaryPath, resolvedShimPath: resolved },
94 );
95 return binaryPath;
96 },
97);