| 1 | import type { ClaudeSettings } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | |
| 7 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 8 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 9 | |
| 10 | const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); |
| 11 | |
| 12 | /** |
| 13 | * Resolve the Claude config directory the CLI would use: the instance's |
| 14 | * `homePath` (exported as `CLAUDE_CONFIG_DIR`), then an inherited |
| 15 | * `CLAUDE_CONFIG_DIR`, then Claude's default `~/.claude`. Empty must not |
| 16 | * fall back to bare `$HOME` — that leftover from the old HOME override |
| 17 | * produced a different continuation group than an explicit `~/.claude`. |
| 18 | */ |
| 19 | export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function* ( |
| 20 | config: Pick<ClaudeSettings, "homePath">, |
| 21 | environment?: NodeJS.ProcessEnv, |
| 22 | ): Effect.fn.Return<string, never, Path.Path> { |
| 23 | const path = yield* Path.Path; |
| 24 | const home = yield* HostProcess.HomeDirectory; |
| 25 | const homePath = config.homePath.trim(); |
| 26 | if (homePath.length > 0) { |
| 27 | return path.resolve(expandHomePath(homePath, home)); |
| 28 | } |
| 29 | // Inherited env vars are not shell-expanded, so a literal `~` stays literal. |
| 30 | const inherited = environment?.CLAUDE_CONFIG_DIR?.trim() ?? ""; |
| 31 | if (inherited.length > 0) { |
| 32 | return path.resolve(inherited); |
| 33 | } |
| 34 | return path.resolve(path.join(home, ".claude")); |
| 35 | }); |
| 36 | |
| 37 | export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* ( |
| 38 | config: Pick<ClaudeSettings, "homePath">, |
| 39 | baseEnv?: NodeJS.ProcessEnv, |
| 40 | ): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> { |
| 41 | const resolvedBaseEnv = baseEnv ?? process.env; |
| 42 | const homePath = config.homePath.trim(); |
| 43 | if (homePath.length === 0) return resolvedBaseEnv; |
| 44 | const resolvedHomePath = yield* resolveClaudeHomePath(config); |
| 45 | return { |
| 46 | ...resolvedBaseEnv, |
| 47 | // Isolate this instance's config via CLAUDE_CONFIG_DIR rather than HOME. |
| 48 | // Overriding HOME also relocates the macOS login keychain lookup |
| 49 | // ($HOME/Library/Keychains), so the spawned CLI can't find its stored |
| 50 | // OAuth credentials and reports "Not logged in". CLAUDE_CONFIG_DIR points |
| 51 | // Claude Code at its config dir directly while leaving HOME (and the |
| 52 | // keychain) intact. |
| 53 | CLAUDE_CONFIG_DIR: resolvedHomePath, |
| 54 | }; |
| 55 | }); |
| 56 | |
| 57 | /** |
| 58 | * Whether an administrator installed Claude Code's `managed-mcp.json`. While it |
| 59 | * exists Claude refuses `--strict-mcp-config` and exits before initializing, |
| 60 | * but it already ignores user, project and `--mcp-config` servers on its own, |
| 61 | * so callers that pass the flag for isolation can drop it. Paths match the |
| 62 | * ones Claude Code checks. |
| 63 | */ |
| 64 | export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")(function* () { |
| 65 | const fileSystem = yield* FileSystem.FileSystem; |
| 66 | const platform = yield* HostProcess.Platform; |
| 67 | const managedMcpConfigPath = |
| 68 | platform === "darwin" |
| 69 | ? "/Library/Application Support/ClaudeCode/managed-mcp.json" |
| 70 | : platform === "win32" |
| 71 | ? "C:\\Program Files\\ClaudeCode\\managed-mcp.json" |
| 72 | : "/etc/claude-code/managed-mcp.json"; |
| 73 | return yield* fileSystem.exists(managedMcpConfigPath).pipe(Effect.orElseSucceed(() => false)); |
| 74 | }); |
| 75 | |
| 76 | export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( |
| 77 | function* ( |
| 78 | config: Pick<ClaudeSettings, "homePath">, |
| 79 | environment?: NodeJS.ProcessEnv, |
| 80 | ): Effect.fn.Return<string, never, Path.Path> { |
| 81 | const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); |
| 82 | return `claude:home:${resolvedHomePath}`; |
| 83 | }, |
| 84 | ); |
| 85 | |
| 86 | export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( |
| 87 | function* ( |
| 88 | config: Pick<ClaudeSettings, "binaryPath" | "homePath">, |
| 89 | cwd?: string, |
| 90 | environment?: NodeJS.ProcessEnv, |
| 91 | ): Effect.fn.Return<string, never, Path.Path> { |
| 92 | const resolvedHomePath = yield* resolveClaudeHomePath(config, environment); |
| 93 | return `${config.binaryPath}\0${resolvedHomePath}\0${cwd ?? ""}`; |
| 94 | }, |
| 95 | ); |
| 96 | |
| 97 | /** |
| 98 | * Describe the spawned CLI's environment separately from the login command so |
| 99 | * paths remain literal on every shell, including relative inherited values. |
| 100 | */ |
| 101 | export const claudeSignedOutMessage = (input: { |
| 102 | readonly configDir: string | undefined; |
| 103 | readonly cwd: string; |
| 104 | }): string => { |
| 105 | const configuration = |
| 106 | input.configDir !== undefined |
| 107 | ? ` from ${quotePath(input.cwd)}, with CLAUDE_CONFIG_DIR set to ${quotePath(input.configDir)}` |
| 108 | : ""; |
| 109 | return `Claude could not authenticate. For subscription login, run \`claude auth login\` on this environment's machine${configuration}, then start a new thread. For API-key authentication, check this instance's configured credentials.`; |
| 110 | }; |