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
73 changes: 70 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as NodeOS from "node:os";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import {
Expand All @@ -25,7 +26,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
expect(yield* resolveClaudeHomePath({ homePath: resolved })).toBe(resolved);
expect(yield* makeClaudeEnvironment({ homePath: "" })).toBe(process.env);

const key = `claude:home:${resolved}`;
const key = `claude:projects:${path.join(resolved, "projects")}`;
expect(yield* makeClaudeContinuationGroupKey({ homePath: "" })).toBe(key);
expect(yield* makeClaudeContinuationGroupKey({ homePath: "~/.claude" })).toBe(key);
expect(yield* makeClaudeContinuationGroupKey({ homePath: resolved })).toBe(key);
Expand All @@ -40,7 +41,9 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {

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* makeClaudeContinuationGroupKey({ homePath })).toBe(
`claude:projects:${path.join(resolved, "projects")}`,
);
expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe(
`claude\0${resolved}\0`,
);
Expand All @@ -55,7 +58,7 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {

expect(yield* resolveClaudeHomePath({ homePath: "" }, environment)).toBe(inherited);
expect(yield* makeClaudeContinuationGroupKey({ homePath: "" }, environment)).toBe(
`claude:home:${inherited}`,
`claude:projects:${path.join(inherited, "projects")}`,
);

const explicit = path.resolve(NodeOS.homedir(), ".claude-work");
Expand All @@ -65,6 +68,70 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
}),
);

const makeClaudeHomes = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.realPath(
yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-home-" }),
);
const home = (name: string) => path.join(root, name);
const linkProjects = (name: string, target: string) =>
fileSystem
.makeDirectory(home(name))
.pipe(Effect.andThen(fileSystem.symlink(target, path.join(home(name), "projects"))));

yield* fileSystem.makeDirectory(path.join(home("shared"), "projects"), { recursive: true });
yield* fileSystem.makeDirectory(path.join(home("separate"), "projects"), { recursive: true });
yield* fileSystem.makeDirectory(home("fresh"));
yield* linkProjects("overlay", path.join(home("shared"), "projects"));
yield* linkProjects("chained", path.join(home("overlay"), "projects"));
yield* linkProjects("dangling", path.join(home("missing"), "projects"));
yield* fileSystem.symlink(home("shared"), home("alias"));
for (const account of ["personal", "work"]) {
yield* fileSystem.makeDirectory(path.join(home("transcripts"), account), {
recursive: true,
});
yield* linkProjects(account, path.join(home("transcripts"), account));
}
return home;
});
const keyFor = (homePath: string, environment?: NodeJS.ProcessEnv) =>
makeClaudeContinuationGroupKey({ homePath }, environment);
const ownProjectsKey = (homePath: string) =>
Effect.map(Path.Path, (path) => `claude:projects:${path.join(homePath, "projects")}`);

it.effect("groups auth-overlay homes with the home their projects symlink points at", () =>
Effect.gen(function* () {
const home = yield* makeClaudeHomes;
const sharedKey = yield* ownProjectsKey(home("shared"));

expect(yield* keyFor(home("shared"))).toBe(sharedKey);
expect(yield* keyFor(home("overlay"))).toBe(sharedKey);
expect(yield* keyFor(home("chained"))).toBe(sharedKey);
expect(yield* keyFor(home("alias"))).toBe(sharedKey);
expect(yield* keyFor("", { CLAUDE_CONFIG_DIR: home("overlay") })).toBe(sharedKey);
expect(yield* keyFor(home("separate"))).toBe(yield* ownProjectsKey(home("separate")));
}),
);

it.effect("keeps a home without resolvable projects in its own group", () =>
Effect.gen(function* () {
const home = yield* makeClaudeHomes;

expect(yield* keyFor(home("fresh"))).toBe(yield* ownProjectsKey(home("fresh")));
expect(yield* keyFor(home("dangling"))).toBe(yield* ownProjectsKey(home("dangling")));
expect(yield* keyFor(home("missing"))).toBe(yield* ownProjectsKey(home("missing")));
}),
);

it.effect("keeps homes whose projects link to sibling directories apart", () =>
Effect.gen(function* () {
const home = yield* makeClaudeHomes;

expect(yield* keyFor(home("personal"))).not.toBe(yield* keyFor(home("work")));
}),
);

it("points the signed-out hint at the configured Claude home", () => {
expect(claudeSignedOutMessage({ configDir: undefined, cwd: "/synthetic" })).toContain(
"run `claude auth login`",
Expand Down
13 changes: 10 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as NodeOS from "node:os";

import type { ClaudeSettings } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

Expand Down Expand Up @@ -57,9 +58,15 @@ export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationG
function* (
config: Pick<ClaudeSettings, "homePath">,
environment?: NodeJS.ProcessEnv,
): Effect.fn.Return<string, never, Path.Path> {
const resolvedHomePath = yield* resolveClaudeHomePath(config, environment);
return `claude:home:${resolvedHomePath}`;
): Effect.fn.Return<string, never, FileSystem.FileSystem | Path.Path> {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const projectsPath = path.join(yield* resolveClaudeHomePath(config, environment), "projects");
// Auth-overlay homes symlink `projects` to a shared store and resume its transcripts.
const transcriptsPath = yield* fileSystem
.realPath(projectsPath)
.pipe(Effect.orElseSucceed(() => projectsPath));
return `claude:projects:${transcriptsPath}`;
},
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
expect(claudeSnapshot.driver).toBe(claudeDriverKind);
expect(claudeSnapshot.enabled).toBe(false);
expect(claudeSnapshot.continuation?.groupKey).toBe(
`claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`,
`claude:projects:${(yield* Path.Path).resolve("/home/julius/.claude-work/projects")}`,
);

const cursorSnapshot = yield* cursor!.snapshot.getSnapshot;
Expand Down
Loading