Skip to content
175 changes: 127 additions & 48 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,141 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import { expect, it } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as Path from "effect/Path";

import {
makeClaudeCapabilitiesCacheKey,
makeClaudeContinuationGroupKey,
makeClaudeEnvironment,
resolveClaudeCodeExecutable,
resolveClaudeHomePath,
} from "./ClaudeHome.ts";

it.layer(NodeServices.layer)("ClaudeHome", (it) => {
describe("Claude home resolution", () => {
it.effect("uses the process home when no Claude home override is configured", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const resolved = path.resolve(NodeOS.homedir());

expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved);
expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env);
}),
);

it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const homePath = "~/.claude-work";
const resolved = path.resolve(NodeOS.homedir(), ".claude-work");

expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved);
expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved);
expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`);
expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe(
`claude\0${resolved}\0`,
);
}),
);

it.effect("separates capability probes by cwd", () =>
Effect.gen(function* () {
const config = { binaryPath: "claude", homePath: "" };
const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a");
const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b");
expect(first).not.toBe(second);
}),
);

it.effect("keeps continuation compatible across instances with the same Claude HOME", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const resolved = path.resolve(NodeOS.homedir());

expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe(
`claude:home:${resolved}`,
);
}),
);
});
const nodeServicesIt = it.layer(NodeServices.layer);

nodeServicesIt("ClaudeHome", (it) => {
it.effect("unwraps Windows npm shims to the package native binary", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const prefix = yield* Effect.sync(() =>
NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-npm-")),
);
const shimPath = path.join(prefix, "claude.cmd");
const nativeBinary = path.join(
prefix,
"node_modules",
"@anthropic-ai",
"claude-code",
"bin",
"claude.exe",
);
yield* Effect.sync(() => {
NodeFS.mkdirSync(path.dirname(nativeBinary), { recursive: true });
NodeFS.writeFileSync(shimPath, "@ECHO off\r\n");
NodeFS.writeFileSync(nativeBinary, "");
});

const resolved = yield* resolveClaudeCodeExecutable(shimPath).pipe(
Effect.provideService(HostProcessPlatform, "win32"),
);
expect(resolved).toBe(nativeBinary);
}),
);

it.effect("keeps an explicit native executable path", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const prefix = yield* Effect.sync(() =>
NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-native-")),
);
const nativeBinary = path.join(prefix, "claude.exe");
yield* Effect.sync(() => {
NodeFS.writeFileSync(nativeBinary, "");
});

const resolved = yield* resolveClaudeCodeExecutable(nativeBinary).pipe(
Effect.provideService(HostProcessPlatform, "win32"),
);
expect(resolved).toBe(nativeBinary);
}),
);

it.effect("does not replace a custom exe when a sibling npm package binary exists", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const prefix = yield* Effect.sync(() =>
NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-custom-")),
);
const customBinary = path.join(prefix, "claude.exe");
const npmNativeBinary = path.join(
prefix,
"node_modules",
"@anthropic-ai",
"claude-code",
"bin",
"claude.exe",
);
yield* Effect.sync(() => {
NodeFS.mkdirSync(path.dirname(npmNativeBinary), { recursive: true });
NodeFS.writeFileSync(customBinary, "");
NodeFS.writeFileSync(npmNativeBinary, "");
});

const resolved = yield* resolveClaudeCodeExecutable(customBinary).pipe(
Effect.provideService(HostProcessPlatform, "win32"),
);
expect(resolved).toBe(customBinary);
}),
);

it.effect("uses the process home when no Claude home override is configured", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const resolved = path.resolve(NodeOS.homedir());

expect(yield* resolveClaudeHomePath({ homePath: "" })).toBe(resolved);
expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env);
}),
);

it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const homePath = "~/.claude-work";
const resolved = path.resolve(NodeOS.homedir(), ".claude-work");

expect(yield* resolveClaudeHomePath({ homePath })).toBe(resolved);
expect((yield* makeClaudeEnvironment({ homePath })).CLAUDE_CONFIG_DIR).toBe(resolved);
expect(yield* makeClaudeContinuationGroupKey({ homePath })).toBe(`claude:home:${resolved}`);
expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe(
`claude\0${resolved}\0`,
);
}),
);

it.effect("separates capability probes by cwd", () =>
Effect.gen(function* () {
const config = { binaryPath: "claude", homePath: "" };
const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a");
const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b");
expect(first).not.toBe(second);
}),
);

it.effect("keeps continuation compatible across instances with the same Claude HOME", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const resolved = path.resolve(NodeOS.homedir());

expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe(
`claude:home:${resolved}`,
);
}),
);
});
66 changes: 66 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,77 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

import type { ClaudeSettings } from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { resolveCommandPath } from "@t3tools/shared/shell";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import { expandHomePath } from "../../pathExpansion.ts";

const NPM_CLAUDE_PACKAGE_BIN = ["node_modules", "@anthropic-ai", "claude-code", "bin"] as const;

function isWindowsScriptShim(filePath: string): boolean {
const extension = NodePath.win32.extname(filePath).toLowerCase();
return extension === ".cmd" || extension === ".bat" || extension === ".ps1" || extension === "";
}

/**
* Resolve a Claude Code binary path suitable for `pathToClaudeCodeExecutable`.
*
* The Claude Agent SDK expects a native binary, not an npm PATH shim. On Windows,
* `npm i -g @anthropic-ai/claude-code` installs `claude.cmd` / a shell wrapper that
* point at `node_modules/@anthropic-ai/claude-code/bin/claude.exe` — unwrap that.
*/
export const resolveClaudeCodeExecutable = Effect.fn("resolveClaudeCodeExecutable")(function* (
binaryPath: string,
environment?: NodeJS.ProcessEnv,
): Effect.fn.Return<string, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const configured = binaryPath.trim() || "claude";
const nativeBinaryName = platform === "win32" ? "claude.exe" : "claude";

const resolved = yield* resolveCommandPath(
configured,
environment ? { env: environment } : {},
).pipe(Effect.catchTags({ CommandResolutionError: () => Effect.succeed(configured) }));

// Only unwrap npm shims — never replace an explicit native/custom executable
// just because a sibling node_modules/@anthropic-ai/claude-code layout exists.
const shouldUnwrapNpmShim =
platform === "win32"
? isWindowsScriptShim(resolved)
: // Bare `claude` on PATH is typically the npm/posix wrapper script.
configured === "claude" || configured === nativeBinaryName;

if (shouldUnwrapNpmShim) {
// npm global prefix: <prefix>/claude(.cmd) → <prefix>/node_modules/@anthropic-ai/claude-code/bin/claude[.exe]
const npmNativeBinary = path.join(
path.dirname(resolved),
...NPM_CLAUDE_PACKAGE_BIN,
nativeBinaryName,
);
if (yield* fileSystem.exists(npmNativeBinary).pipe(Effect.orElseSucceed(() => false))) {
return npmNativeBinary;
}
}

const resolvedExists = yield* fileSystem.exists(resolved).pipe(Effect.orElseSucceed(() => false));
if (resolvedExists) {
// Don't hand Windows script shims to the Agent SDK — it needs the .exe.
if (platform === "win32" && isWindowsScriptShim(resolved)) {
return configured;
}
return resolved;
}

return configured;
});

export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* (
config: Pick<ClaudeSettings, "homePath">,
): Effect.fn.Return<string, never, Path.Path> {
Expand Down
Loading
Loading