Skip to content

Commit f40c8fa

Browse files
Merge pull request #31 from ScriptedAlchemy/feat/claude-shared-profile
feat(claude): extra Claude accounts share your Claude profile
2 parents 81a929c + e89a952 commit f40c8fa

25 files changed

Lines changed: 1338 additions & 406 deletions

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ import { spawnClaudeProcess } from "../../provider/Drivers/ClaudeProcess.ts";
150150
import {
151151
claudeSignedOutMessage,
152152
claudeAccountSettings,
153+
parseClaudeAccountDir,
153154
resolveClaudeHomePath,
154155
type ClaudeRuntime,
155156
} from "../../provider/Drivers/ClaudeHome.ts";
@@ -1484,7 +1485,10 @@ export function makeClaudeQueryOptions(input: {
14841485
...(typeof querySettings === "object" && querySettings !== null ? querySettings : {}),
14851486
autoCompactWindow: Number(input.settings.autoCompactWindow),
14861487
} as ClaudeSdkSettings);
1487-
const accountSettings = input.settings ? claudeAccountSettings(input.settings) : undefined;
1488+
const accountSettings =
1489+
input.settings?.setupMode === "cli"
1490+
? claudeAccountSettings(parseClaudeAccountDir(input.settings.homePath))
1491+
: undefined;
14881492
// A settings file path cannot take extra keys, so it goes through unchanged.
14891493
const effectiveQuerySettings =
14901494
input.toolSearchOverride === undefined || typeof compactQuerySettings === "string"
@@ -1543,7 +1547,11 @@ export function makeClaudeQueryOptions(input: {
15431547
},
15441548
},
15451549
spawnClaudeCodeProcess: (options) =>
1546-
spawnClaudeProcess(options, input.platform, input.settings!.homePath),
1550+
spawnClaudeProcess(
1551+
options,
1552+
input.platform,
1553+
parseClaudeAccountDir(input.settings!.homePath),
1554+
),
15471555
}),
15481556
...(input.onUserDialog === undefined ? {} : { onUserDialog: input.onUserDialog }),
15491557
...(input.supportedDialogKinds === undefined
@@ -11237,8 +11245,6 @@ export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")(
1123711245
const queryRunner = yield* ClaudeAgentSdkQueryRunner;
1123811246
const serverConfig = yield* ServerConfig.ServerConfig;
1123911247
const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
11240-
if (runtime.mode === "cli")
11241-
yield* fileSystem.makeDirectory(runtime.configDir, { recursive: true, mode: 0o700 });
1124211248
const path = yield* Path.Path;
1124311249
const crypto = yield* Crypto.Crypto;
1124411250
const binaryPath = yield* resolveClaudeSdkExecutablePath(

‎apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { vi } from "vite-plus/test";
2222
import * as HostProcess from "@t3tools/shared/HostProcess";
2323
import { AgentScope } from "@t3tools/shared/AgentScope";
2424
import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers";
25+
import { parseClaudeAccountDir } from "../../provider/Drivers/ClaudeHome.ts";
2526
import { spawnClaudeProcess } from "../../provider/Drivers/ClaudeProcess.ts";
2627
import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
2728
import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
@@ -474,7 +475,7 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro
474475
signal: AbortSignal.any([]),
475476
},
476477
HostProcess.Platform.defaultValue(),
477-
mode === "cli" ? dir : undefined,
478+
mode === "cli" ? parseClaudeAccountDir(dir) : undefined,
478479
) as NodeChildProcess.ChildProcess;
479480
yield* Effect.addFinalizer(() =>
480481
Effect.sync(() => {

‎apps/server/src/provider/ClaudeCliAuth.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export const makeClaudeCliAuth = Effect.fn("makeClaudeCliAuth")(function* (optio
2727
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2828
const fileSystem = yield* FileSystem.FileSystem;
2929
const { runtime, instanceId } = options;
30-
const settings = encodeSettings(claudeAccountSettings(runtime.config));
30+
const settings = encodeSettings(
31+
claudeAccountSettings(runtime.mode === "cli" ? runtime.configDir : undefined),
32+
);
3133
const spawn = Effect.fnUntraced(function* (args: ReadonlyArray<string>) {
3234
yield* fileSystem.makeDirectory(runtime.configDir, { recursive: true, mode: 0o700 });
3335
const environment = { ...runtime.environment, BROWSER: "false" };

‎apps/server/src/provider/ClaudeProvider.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import { resolveClaudeSdkExecutablePath } from "./Drivers/ClaudeExecutable.ts";
4040
import {
4141
claudeAccountSettings,
42+
parseClaudeAccountDir,
4243
hasClaudeManagedMcpConfig,
4344
makeClaudeEnvironment,
4445
} from "./Drivers/ClaudeHome.ts";
@@ -223,12 +224,18 @@ export function buildClaudeCapabilitiesProbeQueryOptions(input: {
223224
// SessionStart hooks would run on every health check.
224225
settings: {
225226
disableAllHooks: true,
226-
...(input.config ? claudeAccountSettings(input.config) : {}),
227+
...(input.config?.setupMode === "cli"
228+
? claudeAccountSettings(parseClaudeAccountDir(input.config.homePath))
229+
: {}),
227230
},
228231
...(input.config?.setupMode === "cli"
229232
? {
230233
spawnClaudeCodeProcess: (options: Parameters<typeof spawnClaudeProcess>[0]) =>
231-
spawnClaudeProcess(options, input.platform, input.config!.homePath),
234+
spawnClaudeProcess(
235+
options,
236+
input.platform,
237+
parseClaudeAccountDir(input.config!.homePath),
238+
),
232239
}
233240
: {}),
234241
allowedTools: [],

‎apps/server/src/provider/Drivers/ClaudeDriver.test.ts‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
55
import { expect, it } from "@effect/vitest";
66
import { vi } from "vite-plus/test";
77
import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
8+
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
89
import { ProviderInstanceId } from "@t3tools/contracts";
910
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
1011
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
@@ -126,8 +127,16 @@ it.effect(
126127
instanceId,
127128
enabled: true,
128129
displayName: "Private",
129-
environment: [{ name: "T3_TEST_EMAIL", value: "after@example.test", sensitive: false }],
130-
config: { ...ClaudeDriver.defaultConfig(), setupMode: "cli", binaryPath },
130+
environment: [
131+
{ name: "T3_TEST_EMAIL", value: "after@example.test", sensitive: false },
132+
{ name: "CLAUDE_CONFIG_DIR", value: path.join(root, "shared"), sensitive: false },
133+
],
134+
config: {
135+
...ClaudeDriver.defaultConfig(),
136+
setupMode: "cli",
137+
binaryPath,
138+
shareProfile: false,
139+
},
131140
});
132141
expect((yield* instance.snapshot.refresh).auth.email).toBe("before@example.test");
133142
expect((yield* instance.snapshot.refresh).auth.email).toBe("before@example.test");
@@ -161,3 +170,44 @@ it.effect(
161170
expect(query.mock.calls).toHaveLength(2);
162171
}).pipe(Effect.scoped, Effect.provide(layerTest)),
163172
);
173+
174+
for (const conflict of [false, true]) {
175+
it.effect.skipIf(!symlinksSupported)(
176+
`materializes the profile at create with conflict=${conflict}`,
177+
() =>
178+
Effect.gen(function* () {
179+
const fs = yield* FileSystem.FileSystem;
180+
const path = yield* Path.Path;
181+
const { stateDir } = yield* ServerConfig.ServerConfig;
182+
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-driver-overlay-" });
183+
const shared = path.join(root, "shared");
184+
const instanceId = ProviderInstanceId.make("overlay");
185+
const account = path.join(stateDir, "providers", "claude", instanceId, "cli");
186+
yield* fs.makeDirectory(shared);
187+
if (conflict) {
188+
yield* fs.makeDirectory(account, { recursive: true });
189+
yield* fs.writeFileString(path.join(account, "settings.json"), "account");
190+
}
191+
const create = ClaudeDriver.create({
192+
instanceId,
193+
enabled: false,
194+
displayName: "Overlay",
195+
environment: [{ name: "CLAUDE_CONFIG_DIR", value: shared, sensitive: false }],
196+
config: { ...ClaudeDriver.defaultConfig(), setupMode: "cli" },
197+
});
198+
if (conflict) {
199+
const error = yield* create.pipe(Effect.flip);
200+
expect(error).toMatchObject({ _tag: "ProviderDriverError" });
201+
expect(error.detail).toContain(path.join(account, "settings.json"));
202+
expect(yield* fs.readFileString(path.join(account, "settings.json"))).toBe("account");
203+
} else {
204+
const instance = yield* create;
205+
expect(yield* fs.readLink(path.join(account, "settings.json"))).toBe(
206+
path.join(shared, "settings.json"),
207+
);
208+
expect(instance.continuationIdentity.continuationKey).toBe(`claude:home:${account}`);
209+
expect(instance.auth?.credentialBinding).toEqual({ owner: "provider", key: account });
210+
}
211+
}).pipe(Effect.scoped, Effect.provide(layerTest)),
212+
);
213+
}

‎apps/server/src/provider/Drivers/ClaudeDriver.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
} from "@t3tools/provider-core/server/snapshotSettings";
6060
import {
6161
makeClaudeCapabilitiesCacheKey,
62+
materializeClaudeHome,
6263
makeClaudeContinuationGroupKey,
6364
resolveClaudeRuntime,
6465
} from "./ClaudeHome.ts";
@@ -151,6 +152,22 @@ export const ClaudeDriver: ProviderDriver<
151152
stateDir,
152153
});
153154
const { config: effectiveConfig, environment: processEnv, configDir } = runtime;
155+
yield* materializeClaudeHome(runtime).pipe(
156+
Effect.mapError(
157+
(cause) =>
158+
new ProviderDriverError({
159+
driver: DRIVER_KIND,
160+
instanceId,
161+
detail:
162+
cause._tag === "PlatformError"
163+
? `Could not create Claude account directory '${configDir}'. Check its permissions, or turn off Use your Claude profile.`
164+
: cause._tag === "HomeOverlayFileSystemError"
165+
? `${cause.message} Check filesystem permissions and symlink support, or turn off Use your Claude profile for this account.`
166+
: `${cause.message} Move the conflicting entry away, or turn off Use your Claude profile for this account.`,
167+
cause,
168+
}),
169+
),
170+
);
154171
const auth =
155172
runtime.mode === "cli" ? yield* makeClaudeCliAuth({ instanceId, runtime }) : undefined;
156173
const withAccess = <A, E, R>(task: Effect.Effect<A, E, R>) =>

‎apps/server/src/provider/Drivers/ClaudeHome.test.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
44
import { describe, expect, it } from "@effect/vitest";
55
import * as Effect from "effect/Effect";
66
import * as Path from "effect/Path";
7+
import * as FileSystem from "effect/FileSystem";
8+
import * as Schema from "effect/Schema";
9+
import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
10+
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
711

812
import {
913
makeClaudeCapabilitiesCacheKey,
1014
makeClaudeContinuationGroupKey,
1115
makeClaudeEnvironment,
16+
resolveClaudeRuntime,
17+
materializeClaudeHome,
1218
resolveClaudeHomePath,
1319
} from "./ClaudeHome.ts";
1420

21+
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
22+
1523
it.layer(NodeServices.layer)("ClaudeHome", (it) => {
1624
describe("Claude home resolution", () => {
1725
it.effect("resolves configured Claude HOME and stamps continuation/cache keys with it", () =>
@@ -47,6 +55,105 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
4755
}),
4856
);
4957

58+
for (const shareProfile of [true, false]) {
59+
it.effect.skipIf(!symlinksSupported)(
60+
`keeps private accounts separate with shareProfile=${shareProfile}`,
61+
() =>
62+
Effect.gen(function* () {
63+
const fs = yield* FileSystem.FileSystem;
64+
const path = yield* Path.Path;
65+
const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-home-" });
66+
const shared = path.join(root, "shared");
67+
const runtime = yield* resolveClaudeRuntime({
68+
instanceId: ProviderInstanceId.make("private"),
69+
stateDir: root,
70+
config: decodeClaudeSettings({
71+
setupMode: "cli",
72+
homePath: "/ignored",
73+
shareProfile,
74+
}),
75+
environment: {
76+
CLAUDE_CONFIG_DIR: shared,
77+
ANTHROPIC_API_KEY: "host",
78+
CLAUDE_SECURESTORAGE_CONFIG_DIR: "",
79+
},
80+
});
81+
const accountDir = path.join(root, "providers", "claude", "private", "cli");
82+
expect(runtime.config.homePath).toBe(accountDir);
83+
expect(runtime.environment).toEqual({
84+
CLAUDE_CONFIG_DIR: accountDir,
85+
CLAUDE_SECURESTORAGE_CONFIG_DIR: accountDir,
86+
});
87+
expect(yield* makeClaudeContinuationGroupKey(runtime.config, runtime.environment)).toBe(
88+
`claude:home:${accountDir}`,
89+
);
90+
// Skills bind to an account; plugins can sync before the account is bound.
91+
const buckets = {
92+
skills: "11111111-1111-1111-1111-111111111111_22222222-2222-2222-2222-222222222222",
93+
plugins: "11111111-1111-1111-1111-111111111111_unbound",
94+
} as const;
95+
for (const name of ["plugins", "skills"] as const) {
96+
const bucket = buckets[name];
97+
yield* fs.makeDirectory(path.join(accountDir, name, "synced", bucket), {
98+
recursive: true,
99+
});
100+
yield* fs.writeFileString(
101+
path.join(accountDir, name, "synced", bucket, "skill.md"),
102+
"skill",
103+
);
104+
yield* fs.writeFileString(
105+
path.join(accountDir, name, "synced", `.bucket-${bucket}`),
106+
"marker",
107+
);
108+
}
109+
// Only a profile directory that exists gets a link; agents stays absent.
110+
for (const name of ["commands", "output-styles", "rules", "hooks"]) {
111+
yield* fs.makeDirectory(path.join(shared, name), { recursive: true });
112+
}
113+
yield* materializeClaudeHome(runtime);
114+
if (shareProfile) {
115+
expect(yield* fs.exists(path.join(accountDir, "agents"))).toBe(false);
116+
expect(yield* fs.exists(path.join(shared, "agents"))).toBe(false);
117+
for (const name of [
118+
"plugins",
119+
"skills",
120+
"commands",
121+
"output-styles",
122+
"rules",
123+
"hooks",
124+
"settings.json",
125+
"CLAUDE.md",
126+
"keybindings.json",
127+
]) {
128+
expect(yield* fs.readLink(path.join(accountDir, name))).toBe(
129+
path.join(shared, name),
130+
);
131+
}
132+
expect(yield* fs.exists(path.join(shared, "settings.json"))).toBe(false);
133+
for (const name of ["plugins", "skills"] as const) {
134+
const bucket = buckets[name];
135+
expect(
136+
yield* fs.readFileString(path.join(shared, name, "synced", bucket, "skill.md")),
137+
).toBe("skill");
138+
expect(
139+
yield* fs.readFileString(path.join(shared, name, "synced", `.bucket-${bucket}`)),
140+
).toBe("marker");
141+
}
142+
if (runtime.mode !== "cli") throw new Error("Expected CLI runtime");
143+
yield* materializeClaudeHome({
144+
...runtime,
145+
overlay: { ...runtime.overlay, active: false },
146+
});
147+
expect(yield* fs.readDirectory(accountDir)).toEqual([]);
148+
}
149+
expect(yield* fs.exists(path.join(accountDir, "projects"))).toBe(false);
150+
expect(yield* fs.readDirectory(accountDir)).toEqual(
151+
shareProfile ? [] : ["plugins", "skills"],
152+
);
153+
}),
154+
);
155+
}
156+
50157
it.effect("separates capability probes by cwd", () =>
51158
Effect.gen(function* () {
52159
const config = { binaryPath: "claude", homePath: "" };

0 commit comments

Comments
 (0)