@bordoni/claudep 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ All notable changes to claudep are recorded here. Add a line under **Unreleased** in the same change that a user could notice; `bun run release` moves that block under a version heading and tags it.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the version numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-09-03
10
+
11
+ ### Added
12
+
13
+ - Profiles under `~/.claudep/<name>`, each a thin directory Claude Code is pointed at through `CLAUDE_CONFIG_DIR`. `~/.claude` stays untouched and remains the default.
14
+ - Shared configuration through a symlink allowlist: `CLAUDE.md` and other top-level `*.md`, `settings.json`, `keybindings.json`, `statusline-command.sh`, `hooks/`, `skills/`, `commands/`, `agents/`, `plugins/`, `plans/` and `projects/`. Login identity, `.claude.json`, org-pushed settings and runtime state stay per profile.
15
+ - `claudep init <name>` with `--sso`, `--email`, `--console`, `--copy-mcp`, `--alias`, `--no-login` and `--force`. Seeds `.claude.json` so first-run onboarding does not repeat.
16
+ - `claudep <name> [args]` and `claudep run` to launch Claude Code under a profile, `claudep list`, `claudep status --json`, `claudep env <name>` and `claudep env --unset`.
17
+ - `claudep alias <name> <command>` writes a shim such as `eclaude` next to the `claudep` on PATH.
18
+ - `claudep doctor` checks every symlink, reports base files that are neither shared nor known-private, and confirms the macOS Keychain item for each profile.
19
+ - `claudep rm` logs out first so the token is revoked and the Keychain item removed, then deletes only what lives under the profiles root.
20
+ - `claudep current` reports which profile the shell is on and how it was set.
21
+ - Directory pins: a `.claudep` file names the profile for a directory tree. `claudep local`, `claudep resolve` and `claudep shell-init zsh|bash` manage and apply pins; the hook only touches a `CLAUDE_CONFIG_DIR` it set itself.
22
+ - `claudep --version`.
23
+ - Published as `@bordoni/claudep` on npm and GitHub Packages; the installed command is `claudep`.
24
+ - Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
25
+ - Test suite with `bun test`, a sandboxed `$HOME`, a fake `claude` and `security` on PATH, and a real-shell test for the hook. CI runs typecheck, lint and tests on macOS and Linux.
26
+
27
+ [Unreleased]: https://github.com/bordoni/claudep/compare/v0.1.0...HEAD
28
+ [0.1.0]: https://github.com/bordoni/claudep/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Bordoni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # claudep
2
+
3
+ [![CI](https://github.com/bordoni/claudep/actions/workflows/ci.yml/badge.svg)](https://github.com/bordoni/claudep/actions/workflows/ci.yml)
4
+
5
+ Run [Claude Code](https://code.claude.com) under separate accounts on one machine, for example a company Enterprise org and a personal Max plan, with fully isolated logins and shared configuration.
6
+
7
+ Claude Code has no account switching of its own. `claudep` is one [bun](https://bun.sh) script with no dependencies. It manages named **profiles** and points Claude Code at them through `CLAUDE_CONFIG_DIR`.
8
+
9
+ ```
10
+ claudep init enterprise --sso --email you@company.com --alias eclaude
11
+ eclaude # Claude Code as the enterprise account
12
+ claude # Claude Code as whatever ~/.claude is logged in as
13
+ ```
14
+
15
+ ## Install
16
+
17
+ Requires [bun](https://bun.sh) and an existing Claude Code install (`claude` on your PATH).
18
+
19
+ ```sh
20
+ bun add -g @bordoni/claudep # or: npm install -g @bordoni/claudep (bun is still what runs it)
21
+ bunx @bordoni/claudep --help # try it without installing
22
+ ```
23
+
24
+ The package name is `@bordoni/claudep` and the command it installs is `claudep`. The same package is mirrored to GitHub Packages, which needs a token with `read:packages` even for public installs; `.ref/releasing.md` has the `.npmrc` lines.
25
+
26
+ Or clone and symlink it anywhere on your PATH:
27
+
28
+ ```sh
29
+ git clone git@github.com:bordoni/claudep.git ~/workspace/claudep
30
+ ln -s ~/workspace/claudep/claudep.ts ~/.local/bin/claudep
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```
36
+ claudep <name> [claude args…] run claude with profile <name>
37
+ claudep init <name> [options] create/update a profile and log in
38
+ claudep list every profile and who it is logged in as
39
+ claudep status <name> [--json] login state for one profile ("default" = ~/.claude)
40
+ claudep env <name> print "export CLAUDE_CONFIG_DIR=…" for eval
41
+ claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
42
+ claudep current [--json] which profile this shell is on, and why
43
+ claudep doctor [name] verify symlinks, keychain entry, unclassified files
44
+ claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
45
+ claudep local <name> | --remove pin the current directory tree to a profile (see below)
46
+ claudep resolve [dir] print the profile pinned for a directory
47
+ claudep shell-init [zsh|bash] print the hook that applies pins on cd
48
+ claudep --version
49
+ ```
50
+
51
+ `init` options: `--sso`, `--email <addr>`, `--console`, `--copy-mcp`, `--alias <command>`, `--no-login`, `--force`. Run `claudep help` for the full text.
52
+
53
+ ## Pin a profile to a directory
54
+
55
+ A file named `.claudep` at the top of a repo names the profile every hooked shell should use inside that tree, the way `.nvmrc` names a Node version. Commit it and the whole team inherits the pin.
56
+
57
+ ```sh
58
+ cd ~/work/acme
59
+ claudep local enterprise # writes ./.claudep containing "enterprise"
60
+ ```
61
+
62
+ Shells apply pins through a hook. Add one line to `~/.zshrc` (or `~/.bashrc` with `bash`):
63
+
64
+ ```sh
65
+ eval "$(claudep shell-init zsh)"
66
+ ```
67
+
68
+ From then on, `cd` into a pinned tree sets `CLAUDE_CONFIG_DIR` for that profile and `cd` out of it returns the shell to `~/.claude`. The hook is pure shell with no subprocess, so it costs nothing at the prompt. The rules:
69
+
70
+ - The nearest `.claudep` file upward from the current directory wins. An empty one cancels a parent pin.
71
+ - The hook only changes a `CLAUDE_CONFIG_DIR` it set itself. It tracks that in `CLAUDEP_AUTO`, so a manual pin from `eval "$(claudep env work)"` or a plain `export` stays put until you `eval "$(claudep env --unset)"`.
72
+ - A pin that names a profile you have not created prints one warning per directory change and sets nothing.
73
+
74
+ `claudep current` tells you which profile the shell is on and how it got there (hook, manual pin, or nothing). `claudep list` adds the same line at the bottom.
75
+
76
+ ## How it works
77
+
78
+ `~/.claude` is left exactly as it is and stays the **default** profile. Each named profile is a thin directory under `~/.claudep/<name>`. Inside it, shared configuration is a symlink back into `~/.claude`:
79
+
80
+ | Shared (symlinked) | Per profile (real files) |
81
+ |---|---|
82
+ | `CLAUDE.md` and other top-level `*.md` | `.claude.json` (login identity, user-scope MCP servers, folder trust) |
83
+ | `settings.json`, `keybindings.json`, `statusline-command.sh` | org-pushed `remote-settings.json`, `policy-limits.json` |
84
+ | `hooks/`, `skills/`, `commands/`, `agents/`, `plugins/`, `plans/` | `history.jsonl`, `todos/`, `sessions/`, caches, telemetry |
85
+ | `projects/` (session transcripts and auto-memory) | credentials |
86
+
87
+ Credentials never touch the profile directory. On macOS, Claude Code stores them in the Keychain under `Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)[0:8]>`, so every profile has its own login and refresh token and they cannot overwrite each other. I verified this against Claude Code 2.1.259. An older bug where every config dir shared one Keychain entry no longer applies.
88
+
89
+ The shared list is an allowlist, so an account-specific file cannot leak across profiles by accident. `claudep doctor` reports any base file that is neither shared nor known-private. That is how you notice when a new Claude Code version adds something.
90
+
91
+ ## Notes
92
+
93
+ - The first time you open a repository under a new profile you will re-accept folder trust, and claude.ai connectors need their OAuth redone in that profile.
94
+ - Two profiles running at the same time write the same `settings.json` and `plugins/`. That is the same situation as two terminals today.
95
+ - Set `CLAUDE_PROFILES_DIR` to move the profiles root. Keep it out of iCloud or Dropbox; `.claude.json` is rewritten constantly and sync tools create conflict copies.
96
+ - Never put `CLAUDE_CONFIG_DIR` in a `settings.json` `env` block. Claude Code detects that mismatch and disables features.
97
+
98
+ ## Releases
99
+
100
+ Versions, dates and changes are in [`CHANGELOG.md`](./CHANGELOG.md). Releases are tagged `vX.Y.Z` and published to npm with provenance by GitHub Actions.
101
+
102
+ ## Development
103
+
104
+ ```sh
105
+ bun install # dev tooling only; nothing ships with the tool
106
+ bun run check # typecheck, lint, tests. Same as CI
107
+ ```
108
+
109
+ To work on the script, start with [`AGENTS.md`](./AGENTS.md). The detail lives in [`.ref/`](./.ref/).
110
+
111
+ ## License
112
+
113
+ MIT
package/claudep.ts ADDED
@@ -0,0 +1,1054 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * claudep: run Claude Code under separate accounts on one machine.
4
+ *
5
+ * Keeps ~/.claude as the untouched "default" profile and creates thin overlay
6
+ * profiles under ~/.claudep/<name>. Shared config (CLAUDE.md, settings,
7
+ * skills, plugins, hooks, agents, sessions/memory) is symlinked back into the
8
+ * base; credentials, .claude.json and runtime state are per profile.
9
+ *
10
+ * Claude Code namespaces its macOS Keychain entry by CLAUDE_CONFIG_DIR
11
+ * ("Claude Code-credentials-<sha256(dir)[0:8]>"), so each profile is a fully
12
+ * separate login. Run `claudep help` for usage.
13
+ *
14
+ * Zero runtime dependencies. Requires bun >= 1.1 and a `claude` binary on PATH.
15
+ */
16
+
17
+ import { createHash } from "node:crypto";
18
+ import {
19
+ chmodSync,
20
+ existsSync,
21
+ lstatSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ readFileSync,
25
+ readlinkSync,
26
+ realpathSync,
27
+ rmSync,
28
+ statSync,
29
+ symlinkSync,
30
+ writeFileSync,
31
+ } from "node:fs";
32
+ import { homedir, userInfo } from "node:os";
33
+ import { dirname, join, resolve } from "node:path";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Layout
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type Env = Record<string, string | undefined>;
40
+
41
+ /** Home directory. $HOME wins so tests and containers can redirect it; bun's
42
+ * os.homedir() reads getpwuid() and ignores the variable. */
43
+ export function homeDir(env: Env = process.env): string {
44
+ return env.HOME || homedir();
45
+ }
46
+
47
+ /** Canonical form of a config dir: absolute, no trailing slash, NFC.
48
+ * Claude Code hashes the *literal* CLAUDE_CONFIG_DIR string for the keychain
49
+ * service name, so the same profile must always produce the same string. */
50
+ export function canon(p: string, home: string = homeDir()): string {
51
+ const expanded = p.startsWith("~/") ? join(home, p.slice(2)) : p;
52
+ return resolve(expanded).replace(/\/+$/, "").normalize("NFC");
53
+ }
54
+
55
+ export type Layout = {
56
+ home: string;
57
+ /** CLAUDE_CONFIG_DIR as set in the caller's shell, if any. */
58
+ callerConfigDir: string | undefined;
59
+ /** True when the caller's CLAUDE_CONFIG_DIR points inside the profiles root,
60
+ * i.e. a claudep profile is active in this shell (pinned by hand or by the hook). */
61
+ managed: boolean;
62
+ /** The active profile name when `managed` and the dir is a valid profile. */
63
+ activeProfile: string | undefined;
64
+ /** The base config dir every profile links back into. */
65
+ base: string;
66
+ /** Global state file for the base. Without CLAUDE_CONFIG_DIR it lives at
67
+ * ~/.claude.json; with it, inside the config dir. */
68
+ baseGlobalJson: string;
69
+ profilesRoot: string;
70
+ };
71
+
72
+ export function layout(env: Env = process.env): Layout {
73
+ const home = homeDir(env);
74
+ const callerConfigDir = env.CLAUDE_CONFIG_DIR;
75
+ const profilesRoot = canon(env.CLAUDE_PROFILES_DIR ?? join(home, ".claudep"), home);
76
+ const callerCanon = callerConfigDir !== undefined ? canon(callerConfigDir, home) : undefined;
77
+ const managed = callerCanon !== undefined && callerCanon.startsWith(`${profilesRoot}/`);
78
+ const tail = managed && callerCanon !== undefined ? callerCanon.slice(profilesRoot.length + 1) : undefined;
79
+ const activeProfile = tail !== undefined && NAME_RE.test(tail) ? tail : undefined;
80
+ // A custom CLAUDE_CONFIG_DIR outside the profiles root is the user's real base.
81
+ // One inside it is a claudep profile and must never be treated as the base.
82
+ const customBase = callerCanon !== undefined && !managed;
83
+ const base = customBase && callerCanon !== undefined ? callerCanon : canon(join(home, ".claude"), home);
84
+ return {
85
+ home,
86
+ callerConfigDir,
87
+ managed,
88
+ activeProfile,
89
+ base,
90
+ baseGlobalJson: customBase ? join(base, ".claude.json") : join(home, ".claude.json"),
91
+ profilesRoot,
92
+ };
93
+ }
94
+
95
+ /** The directory-pin file. A repo (or any directory tree) that contains one
96
+ * names the profile every hooked shell should use inside that tree. */
97
+ export const PIN_FILE = ".claudep";
98
+
99
+ export type Pin = { name: string; file: string; dir: string };
100
+
101
+ /** First non-empty, non-comment line of a pin file, trimmed. Empty means "no pin". */
102
+ export function readPinName(file: string): string {
103
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
104
+ const line = raw.trim();
105
+ if (line === "" || line.startsWith("#")) continue;
106
+ return line;
107
+ }
108
+ return "";
109
+ }
110
+
111
+ /** Nearest PIN_FILE walking upward from startDir. Only regular files count
112
+ * (~/.claudep is a directory and is skipped). The nearest file wins even when
113
+ * it is empty, which is how a subtree cancels a parent pin. The shell hook
114
+ * from `shell-init` mirrors this exactly. */
115
+ export function resolvePin(startDir: string): Pin | undefined {
116
+ let dir = resolve(startDir);
117
+ for (;;) {
118
+ const file = join(dir, PIN_FILE);
119
+ let isFile = false;
120
+ try {
121
+ isFile = statSync(file).isFile();
122
+ } catch {
123
+ isFile = false;
124
+ }
125
+ if (isFile) return { name: readPinName(file), file, dir };
126
+ const parent = dirname(dir);
127
+ if (parent === dir) return undefined;
128
+ dir = parent;
129
+ }
130
+ }
131
+
132
+ export type Current = {
133
+ kind: "base" | "profile" | "custom";
134
+ name: string | undefined;
135
+ dir: string;
136
+ setBy: "none" | "hook" | "manual";
137
+ };
138
+
139
+ /** What this shell is running Claude Code as, and how it got that way. */
140
+ export function currentProfile(L: Layout, env: Env = process.env): Current {
141
+ const cfg = env.CLAUDE_CONFIG_DIR;
142
+ if (!cfg) return { kind: "base", name: undefined, dir: L.base, setBy: "none" };
143
+ const dir = canon(cfg, L.home);
144
+ const auto = env.CLAUDEP_AUTO;
145
+ const setBy = auto !== undefined && canon(auto, L.home) === dir ? "hook" : "manual";
146
+ if (L.activeProfile !== undefined) return { kind: "profile", name: L.activeProfile, dir, setBy };
147
+ return { kind: "custom", name: undefined, dir, setBy };
148
+ }
149
+
150
+ /** Version from the package.json next to this file. */
151
+ export function version(): string {
152
+ try {
153
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")) as { version?: unknown };
154
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
155
+ } catch {
156
+ return "unknown";
157
+ }
158
+ }
159
+
160
+ /** Top-level base items symlinked into every profile (only if they exist). */
161
+ export const SHARED_FILES = ["CLAUDE.md", "settings.json", "keybindings.json", "statusline-command.sh"] as const;
162
+ export const SHARED_DIRS = ["hooks", "skills", "commands", "agents", "plugins", "plans", "projects"] as const;
163
+
164
+ /** Per-profile state. First block is Claude Code's own runtime-state list;
165
+ * the rest are observed extras. Never shared. */
166
+ export const KNOWN_PRIVATE = new Set<string>([
167
+ ".claude.json",
168
+ ".claude.json.backup",
169
+ ".credentials.json",
170
+ "sessions",
171
+ "todos",
172
+ "shell-snapshots",
173
+ "statsig",
174
+ "file-history",
175
+ "history.jsonl",
176
+ "ide",
177
+ "logs",
178
+ "backups",
179
+ ".session_ingress_token",
180
+ "remote-settings.json",
181
+ "policy-limits.json",
182
+ "stats-cache.json",
183
+ "mcp-needs-auth-cache.json",
184
+ "telemetry",
185
+ "debug",
186
+ "cache",
187
+ "daemon",
188
+ "daemon.log",
189
+ "tasks",
190
+ "jobs",
191
+ "session-env",
192
+ "paste-cache",
193
+ "scheduled-tasks",
194
+ "chrome",
195
+ "feedback",
196
+ "local",
197
+ "settings.local.json",
198
+ ".DS_Store",
199
+ ".config.json",
200
+ ".last-cleanup",
201
+ ".last-update-result.json",
202
+ "daemon-auth-cooldown",
203
+ "daemon-auth-status.json",
204
+ ]);
205
+
206
+ /** Keys copied from the base .claude.json into a fresh profile so first-run
207
+ * onboarding does not repeat. Account identity is deliberately excluded. */
208
+ export const SEED_KEYS = [
209
+ "hasCompletedOnboarding",
210
+ "lastOnboardingVersion",
211
+ "theme",
212
+ "editorMode",
213
+ "preferredNotifChannel",
214
+ "shiftEnterKeyBindingInstalled",
215
+ "autoUpdates",
216
+ "installMethod",
217
+ ] as const;
218
+
219
+ export const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
220
+ export const RESERVED = new Set([
221
+ "default",
222
+ "base",
223
+ "init",
224
+ "run",
225
+ "list",
226
+ "ls",
227
+ "status",
228
+ "env",
229
+ "doctor",
230
+ "rm",
231
+ "remove",
232
+ "alias",
233
+ "help",
234
+ "current",
235
+ "local",
236
+ "resolve",
237
+ "shell-init",
238
+ "version",
239
+ ]);
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Output helpers
243
+ // ---------------------------------------------------------------------------
244
+
245
+ const tty = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
246
+ const c = {
247
+ bold: (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s),
248
+ dim: (s: string) => (tty ? `\x1b[2m${s}\x1b[0m` : s),
249
+ green: (s: string) => (tty ? `\x1b[32m${s}\x1b[0m` : s),
250
+ yellow: (s: string) => (tty ? `\x1b[33m${s}\x1b[0m` : s),
251
+ red: (s: string) => (tty ? `\x1b[31m${s}\x1b[0m` : s),
252
+ };
253
+ const ok = (msg: string) => console.log(`${c.green("✓")} ${msg}`);
254
+ const warn = (msg: string) => console.log(`${c.yellow("!")} ${msg}`);
255
+ const bad = (msg: string) => console.log(`${c.red("✗")} ${msg}`);
256
+
257
+ export function die(msg: string, code = 1): never {
258
+ console.error(`${c.red("error:")} ${msg}`);
259
+ process.exit(code);
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Profile helpers
264
+ // ---------------------------------------------------------------------------
265
+
266
+ export function profileDir(L: Layout, name: string): string {
267
+ if (!NAME_RE.test(name)) die(`invalid profile name "${name}" (use [a-z0-9_-], starting with a letter or digit)`);
268
+ if (RESERVED.has(name)) die(`"${name}" is a reserved word and cannot be a profile name`);
269
+ return canon(join(L.profilesRoot, name), L.home);
270
+ }
271
+
272
+ export function profileExists(L: Layout, name: string): boolean {
273
+ return NAME_RE.test(name) && !RESERVED.has(name) && existsSync(join(L.profilesRoot, name));
274
+ }
275
+
276
+ export function listProfileNames(L: Layout): string[] {
277
+ if (!existsSync(L.profilesRoot)) return [];
278
+ return readdirSync(L.profilesRoot, { withFileTypes: true })
279
+ .filter((d) => d.isDirectory() && NAME_RE.test(d.name) && !RESERVED.has(d.name))
280
+ .map((d) => d.name)
281
+ .sort();
282
+ }
283
+
284
+ /** Keychain service name Claude Code uses for a non-default config dir. */
285
+ export function keychainService(dir: string): string {
286
+ const hash = createHash("sha256").update(dir).digest("hex").slice(0, 8);
287
+ return `Claude Code-credentials-${hash}`;
288
+ }
289
+
290
+ export type SharedItem = { name: string; kind: "file" | "dir" };
291
+
292
+ /** Shared items that actually exist in the base right now. */
293
+ export function sharedItems(base: string): SharedItem[] {
294
+ const items: SharedItem[] = [];
295
+ const seen = new Set<string>();
296
+ const push = (name: string, kind: SharedItem["kind"]) => {
297
+ if (seen.has(name)) return;
298
+ if (!existsSync(join(base, name))) return;
299
+ seen.add(name);
300
+ items.push({ name, kind });
301
+ };
302
+ for (const f of SHARED_FILES) push(f, "file");
303
+ if (existsSync(base)) {
304
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
305
+ if (entry.name.endsWith(".md") && !entry.isDirectory()) push(entry.name, "file");
306
+ }
307
+ }
308
+ for (const d of SHARED_DIRS) push(d, "dir");
309
+ return items;
310
+ }
311
+
312
+ export type LinkResult = "linked" | "ok" | "wrong-target" | "conflict";
313
+
314
+ /** Idempotent symlink profile/<name> -> base/<name>. Never overwrites real files. */
315
+ export function link(base: string, dir: string, name: string, force: boolean): LinkResult {
316
+ const target = join(base, name);
317
+ const dest = join(dir, name);
318
+ let st: ReturnType<typeof lstatSync> | undefined;
319
+ try {
320
+ st = lstatSync(dest);
321
+ } catch {
322
+ st = undefined;
323
+ }
324
+ if (!st) {
325
+ symlinkSync(target, dest);
326
+ return "linked";
327
+ }
328
+ if (st.isSymbolicLink()) {
329
+ if (readlinkSync(dest) === target) return "ok";
330
+ if (force) {
331
+ rmSync(dest);
332
+ symlinkSync(target, dest);
333
+ return "linked";
334
+ }
335
+ return "wrong-target";
336
+ }
337
+ return "conflict";
338
+ }
339
+
340
+ export type JsonObject = Record<string, unknown>;
341
+
342
+ export async function readJson(path: string): Promise<JsonObject | undefined> {
343
+ try {
344
+ const parsed: unknown = JSON.parse(await Bun.file(path).text());
345
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as JsonObject) : undefined;
346
+ } catch {
347
+ return undefined;
348
+ }
349
+ }
350
+
351
+ export type SeedResult = "seeded" | "exists" | "no-base";
352
+
353
+ export async function seedGlobalJson(baseGlobalJson: string, dir: string, copyMcp: boolean): Promise<SeedResult> {
354
+ const dest = join(dir, ".claude.json");
355
+ if (existsSync(dest)) return "exists";
356
+ const base = await readJson(baseGlobalJson);
357
+ if (!base) return "no-base";
358
+ const seed: JsonObject = {};
359
+ for (const k of SEED_KEYS) if (k in base) seed[k] = base[k];
360
+ if (copyMcp && base.mcpServers && typeof base.mcpServers === "object") seed.mcpServers = base.mcpServers;
361
+ await Bun.write(dest, `${JSON.stringify(seed, null, 2)}\n`);
362
+ return "seeded";
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Running claude
367
+ // ---------------------------------------------------------------------------
368
+
369
+ function claudeBin(): string {
370
+ const bin = Bun.which("claude");
371
+ if (!bin) die("`claude` not found on PATH. Install Claude Code first: https://code.claude.com/docs/en/setup");
372
+ return bin;
373
+ }
374
+
375
+ /** Environment for a profile. `undefined` dir means "the base", i.e. leave the
376
+ * caller's CLAUDE_CONFIG_DIR exactly as it is (set or unset). */
377
+ export function envFor(dir: string | undefined, env: Env = process.env): Env {
378
+ const out: Env = { ...env };
379
+ if (dir !== undefined) out.CLAUDE_CONFIG_DIR = dir;
380
+ return out;
381
+ }
382
+
383
+ /** Environment for running claude against the base. If a claudep profile is
384
+ * active in this shell (hook or manual pin), strip it so "default" really is
385
+ * the base and not whatever the current directory pinned. */
386
+ export function baseEnv(L: Layout, env: Env = process.env): Env {
387
+ const out: Env = { ...env };
388
+ if (L.managed) {
389
+ delete out.CLAUDE_CONFIG_DIR;
390
+ delete out.CLAUDEP_AUTO;
391
+ }
392
+ return out;
393
+ }
394
+
395
+ function claudeEnv(L: Layout, dir: string | undefined): Env {
396
+ return dir === undefined ? baseEnv(L) : envFor(dir);
397
+ }
398
+
399
+ async function execClaude(L: Layout, dir: string | undefined, args: string[]): Promise<never> {
400
+ const proc = Bun.spawn([claudeBin(), ...args], {
401
+ env: claudeEnv(L, dir),
402
+ stdin: "inherit",
403
+ stdout: "inherit",
404
+ stderr: "inherit",
405
+ });
406
+ // Ctrl+C reaches the child through the terminal; keep the wrapper alive so
407
+ // it can report the child's real exit code.
408
+ process.on("SIGINT", () => {});
409
+ process.on("SIGTERM", () => proc.kill("SIGTERM"));
410
+ process.on("SIGHUP", () => proc.kill("SIGHUP"));
411
+ await proc.exited;
412
+ process.exit(proc.exitCode ?? 1);
413
+ }
414
+
415
+ async function captureClaude(
416
+ L: Layout,
417
+ dir: string | undefined,
418
+ args: string[],
419
+ ): Promise<{ code: number; out: string }> {
420
+ const proc = Bun.spawn([claudeBin(), ...args], {
421
+ env: claudeEnv(L, dir),
422
+ stdin: "ignore",
423
+ stdout: "pipe",
424
+ stderr: "pipe",
425
+ });
426
+ const out = await new Response(proc.stdout).text();
427
+ const code = await proc.exited;
428
+ return { code, out };
429
+ }
430
+
431
+ export type AuthStatus = {
432
+ loggedIn: boolean;
433
+ email?: string;
434
+ orgName?: string;
435
+ subscriptionType?: string;
436
+ authMethod?: string;
437
+ };
438
+
439
+ export function parseAuthStatus(text: string): AuthStatus {
440
+ try {
441
+ const j: unknown = JSON.parse(text);
442
+ if (!j || typeof j !== "object") return { loggedIn: false };
443
+ const o = j as JsonObject;
444
+ const str = (k: string) => (typeof o[k] === "string" ? (o[k] as string) : undefined);
445
+ return {
446
+ loggedIn: o.loggedIn === true,
447
+ email: str("email"),
448
+ orgName: str("orgName"),
449
+ subscriptionType: str("subscriptionType"),
450
+ authMethod: str("authMethod"),
451
+ };
452
+ } catch {
453
+ return { loggedIn: false };
454
+ }
455
+ }
456
+
457
+ async function authStatus(L: Layout, dir: string | undefined): Promise<AuthStatus> {
458
+ const { out } = await captureClaude(L, dir, ["auth", "status", "--json"]);
459
+ return parseAuthStatus(out);
460
+ }
461
+
462
+ /** Minimal spawner shape so the keychain check can be exercised without a real `security`. */
463
+ export type Spawner = (cmd: string[]) => { exited: Promise<number> };
464
+
465
+ export type KeychainDeps = { platform: NodeJS.Platform; username: string; spawn: Spawner };
466
+
467
+ const defaultKeychainDeps = (): KeychainDeps => ({
468
+ platform: process.platform,
469
+ username: userInfo().username,
470
+ spawn: (cmd) => Bun.spawn(cmd, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }),
471
+ });
472
+
473
+ /** true/false on macOS, undefined elsewhere. Exit code only; the secret is never read. */
474
+ export async function keychainHas(
475
+ service: string,
476
+ deps: KeychainDeps = defaultKeychainDeps(),
477
+ ): Promise<boolean | undefined> {
478
+ if (deps.platform !== "darwin") return undefined;
479
+ const proc = deps.spawn(["security", "find-generic-password", "-s", service, "-a", deps.username]);
480
+ return (await proc.exited) === 0;
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Flag parsing
485
+ // ---------------------------------------------------------------------------
486
+
487
+ export type Flags = { bools: Set<string>; strs: Map<string, string>; rest: string[] };
488
+
489
+ export function parseFlags(args: string[], boolNames: readonly string[], strNames: readonly string[]): Flags {
490
+ const flags: Flags = { bools: new Set(), strs: new Map(), rest: [] };
491
+ for (let i = 0; i < args.length; i++) {
492
+ const a = args[i] as string;
493
+ if (boolNames.includes(a)) {
494
+ flags.bools.add(a);
495
+ } else if (strNames.includes(a)) {
496
+ const v = args[i + 1];
497
+ if (v === undefined) die(`${a} requires a value`);
498
+ flags.strs.set(a, v);
499
+ i++;
500
+ } else if (a.startsWith("--") && strNames.some((s) => a.startsWith(`${s}=`))) {
501
+ const eq = a.indexOf("=");
502
+ flags.strs.set(a.slice(0, eq), a.slice(eq + 1));
503
+ } else if (a.startsWith("-")) {
504
+ die(`unknown flag ${a}`);
505
+ } else {
506
+ flags.rest.push(a);
507
+ }
508
+ }
509
+ return flags;
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // Commands
514
+ // ---------------------------------------------------------------------------
515
+
516
+ async function cmdInit(L: Layout, args: string[]): Promise<void> {
517
+ const f = parseFlags(args, ["--copy-mcp", "--no-login", "--sso", "--console", "--force"], ["--email", "--alias"]);
518
+ const name = f.rest[0];
519
+ if (!name)
520
+ die(
521
+ "usage: claudep init <name> [--copy-mcp] [--no-login] [--sso] [--email <e>] [--console] [--alias <cmd>] [--force]",
522
+ );
523
+ if (!existsSync(L.base)) die(`base config dir ${L.base} does not exist. Run \`claude\` once first`);
524
+ const dir = profileDir(L, name);
525
+ const fresh = !existsSync(dir);
526
+ mkdirSync(dir, { recursive: true });
527
+ console.log(`${c.bold(fresh ? "Creating" : "Updating")} profile ${c.bold(name)} at ${dir}`);
528
+
529
+ let conflicts = 0;
530
+ for (const item of sharedItems(L.base)) {
531
+ const r = link(L.base, dir, item.name, f.bools.has("--force"));
532
+ if (r === "linked") ok(`${item.name} → shared`);
533
+ else if (r === "ok") console.log(`${c.dim("·")} ${item.name} ${c.dim("already shared")}`);
534
+ else if (r === "wrong-target") {
535
+ warn(`${item.name} is a symlink to somewhere else (re-run with --force to relink)`);
536
+ conflicts++;
537
+ } else {
538
+ warn(`${item.name} is a real file or dir inside the profile. Left alone; move it away to share it`);
539
+ conflicts++;
540
+ }
541
+ }
542
+
543
+ const seeded = await seedGlobalJson(L.baseGlobalJson, dir, f.bools.has("--copy-mcp"));
544
+ if (seeded === "seeded")
545
+ ok(`.claude.json seeded${f.bools.has("--copy-mcp") ? " (with user-scope MCP servers)" : ""}`);
546
+ else if (seeded === "exists") console.log(`${c.dim("·")} .claude.json ${c.dim("already present")}`);
547
+ else warn(`could not read ${L.baseGlobalJson}; Claude Code will run its first-time onboarding`);
548
+
549
+ const alias = f.strs.get("--alias");
550
+ if (alias !== undefined) writeAlias(name, alias);
551
+
552
+ if (conflicts) warn(`${conflicts} item(s) need attention, see above`);
553
+
554
+ if (f.bools.has("--no-login")) {
555
+ console.log(`\nNext: ${c.bold(`claudep ${name} auth login`)}`);
556
+ return;
557
+ }
558
+ const loginArgs = ["auth", "login"];
559
+ if (f.bools.has("--sso")) loginArgs.push("--sso");
560
+ if (f.bools.has("--console")) loginArgs.push("--console");
561
+ const email = f.strs.get("--email");
562
+ if (email !== undefined) loginArgs.push("--email", email);
563
+ console.log(`\n${c.bold("Logging in")} to profile ${name} …`);
564
+ await execClaude(L, dir, loginArgs);
565
+ }
566
+
567
+ function scriptDir(): string {
568
+ return dirname(realpathSync(Bun.main));
569
+ }
570
+
571
+ /** Directory where alias shims are written: next to the `claudep` command on
572
+ * PATH (usually a symlink into the repo), falling back to the script dir. */
573
+ export function aliasDir(): string {
574
+ const onPathBin = Bun.which("claudep");
575
+ return onPathBin ? dirname(onPathBin) : scriptDir();
576
+ }
577
+
578
+ /** True when some PATH entry resolves (through symlinks) to `dir`. */
579
+ export function onPath(dir: string, pathVar: string = process.env.PATH ?? ""): boolean {
580
+ const want = realpathSync(dir);
581
+ return pathVar.split(":").some((p) => {
582
+ try {
583
+ return realpathSync(canon(p)) === want;
584
+ } catch {
585
+ return false;
586
+ }
587
+ });
588
+ }
589
+
590
+ /** Write a tiny shim `<cmd>` next to the claudep on PATH: `exec claudep run <name> -- "$@"`. */
591
+ function writeAlias(name: string, cmd: string): void {
592
+ if (!NAME_RE.test(cmd)) die(`invalid alias command name "${cmd}"`);
593
+ const dest = join(aliasDir(), cmd);
594
+ const self = realpathSync(Bun.main);
595
+ if (existsSync(dest)) {
596
+ const current = Bun.file(dest);
597
+ if (!lstatSync(dest).isSymbolicLink() && current.size > 0) {
598
+ warn(`${dest} already exists. Not overwriting; remove it first to regenerate`);
599
+ return;
600
+ }
601
+ }
602
+ const shim = `#!/bin/sh\n# generated by claudep. Runs Claude Code under the "${name}" profile\nexec bun "${self}" run ${name} -- "$@"\n`;
603
+ writeFileSync(dest, shim);
604
+ chmodSync(dest, 0o755);
605
+ ok(`alias ${c.bold(cmd)} → profile ${name} (${dest})`);
606
+ if (!onPath(aliasDir())) warn(`${aliasDir()} is not on your PATH`);
607
+ }
608
+
609
+ async function cmdAlias(L: Layout, args: string[]): Promise<void> {
610
+ const [name, cmd] = args;
611
+ if (!name || !cmd) die("usage: claudep alias <profile> <command> e.g. claudep alias enterprise eclaude");
612
+ if (!profileExists(L, name)) die(`profile "${name}" does not exist. Run: claudep init ${name}`);
613
+ writeAlias(name, cmd);
614
+ }
615
+
616
+ async function cmdRun(L: Layout, args: string[]): Promise<never> {
617
+ const name = args[0];
618
+ if (!name) die("usage: claudep run <name> [claude args…]");
619
+ const rest = args.slice(1);
620
+ if (rest[0] === "--") rest.shift();
621
+ if (name === "default" || name === "base") return execClaude(L, undefined, rest);
622
+ const dir = profileDir(L, name);
623
+ if (!existsSync(dir)) die(`profile "${name}" does not exist. Run: claudep init ${name}`);
624
+ return execClaude(L, dir, rest);
625
+ }
626
+
627
+ export type Row = { name: string; dir: string; status: AuthStatus };
628
+
629
+ async function collectRows(L: Layout, names: string[]): Promise<Row[]> {
630
+ return Promise.all(
631
+ names.map(async (name) => {
632
+ const dir = name === "default" ? L.base : profileDir(L, name);
633
+ return { name, dir, status: await authStatus(L, name === "default" ? undefined : dir) };
634
+ }),
635
+ );
636
+ }
637
+
638
+ export function formatTable(rows: Row[], home: string): string[] {
639
+ const cols = ["PROFILE", "LOGIN", "EMAIL", "ORG", "PLAN", "DIR"];
640
+ const data = rows.map((r) => [
641
+ r.name,
642
+ r.status.loggedIn ? "yes" : "no",
643
+ r.status.email ?? "-",
644
+ r.status.orgName ?? "-",
645
+ r.status.subscriptionType ?? "-",
646
+ r.dir.startsWith(home) ? `~${r.dir.slice(home.length)}` : r.dir,
647
+ ]);
648
+ const widths = cols.map((h, i) => Math.max(h.length, ...data.map((d) => (d[i] as string).length)));
649
+ const fmt = (cells: string[]) => cells.map((v, i) => v.padEnd(widths[i] as number)).join(" ");
650
+ return [fmt(cols), ...data.map((d) => fmt(d))];
651
+ }
652
+
653
+ function printTable(rows: Row[], home: string): void {
654
+ const [header, ...lines] = formatTable(rows, home);
655
+ console.log(c.bold(header as string));
656
+ lines.forEach((line, i) => {
657
+ console.log(rows[i]?.status.loggedIn ? line : c.dim(line));
658
+ });
659
+ }
660
+
661
+ async function cmdList(L: Layout): Promise<void> {
662
+ const names = ["default", ...listProfileNames(L)];
663
+ printTable(await collectRows(L, names), L.home);
664
+ if (names.length === 1) console.log(c.dim("\nNo profiles yet. Create one: claudep init <name>"));
665
+ const cur = currentProfile(L);
666
+ if (cur.kind !== "base") console.log(c.dim(`\nactive in this shell: ${describeCurrent(cur)}`));
667
+ }
668
+
669
+ async function cmdStatus(L: Layout, args: string[]): Promise<void> {
670
+ const f = parseFlags(args, ["--json"], []);
671
+ const name = f.rest[0];
672
+ if (!name) die("usage: claudep status <name> [--json]");
673
+ if (name !== "default" && !profileExists(L, name)) die(`profile "${name}" does not exist`);
674
+ const [row] = await collectRows(L, [name]);
675
+ if (!row) return;
676
+ if (f.bools.has("--json")) console.log(JSON.stringify({ name: row.name, dir: row.dir, ...row.status }, null, 2));
677
+ else printTable([row], L.home);
678
+ }
679
+
680
+ function cmdEnv(L: Layout, args: string[]): void {
681
+ const name = args[0];
682
+ if (!name) die('usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)"');
683
+ if (name === "--unset") {
684
+ console.log("unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO");
685
+ return;
686
+ }
687
+ const dir = profileDir(L, name);
688
+ if (!existsSync(dir)) die(`profile "${name}" does not exist`);
689
+ // Clearing CLAUDEP_AUTO turns this into a manual pin the shell hook will not touch.
690
+ console.log(`export CLAUDE_CONFIG_DIR='${dir.replace(/'/g, `'\\''`)}'`);
691
+ console.log("unset CLAUDEP_AUTO");
692
+ }
693
+
694
+ function describeCurrent(cur: Current): string {
695
+ const label = cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
696
+ const how = cur.setBy === "hook" ? "shell hook" : cur.setBy === "manual" ? "manual pin" : "nothing pinned";
697
+ return `${label} (${how})`;
698
+ }
699
+
700
+ function shortHome(p: string, home: string): string {
701
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
702
+ }
703
+
704
+ function cmdCurrent(L: Layout, args: string[]): void {
705
+ const f = parseFlags(args, ["--json"], []);
706
+ const cur = currentProfile(L);
707
+ const pin = resolvePin(process.cwd());
708
+ if (f.bools.has("--json")) {
709
+ console.log(JSON.stringify({ ...cur, pin: pin ?? null }, null, 2));
710
+ return;
711
+ }
712
+ const label = cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
713
+ console.log(`${c.bold(label)} ${c.dim(shortHome(cur.dir, L.home))}`);
714
+ if (cur.setBy === "hook")
715
+ console.log(`set by: shell hook${pin ? ` (${PIN_FILE} in ${shortHome(pin.dir, L.home)})` : ""}`);
716
+ else if (cur.setBy === "manual") console.log("set by: manual pin (claudep env or export CLAUDE_CONFIG_DIR)");
717
+ else console.log("set by: nothing pinned; this is ~/.claude");
718
+ if (cur.kind === "custom") console.log(c.dim("CLAUDE_CONFIG_DIR points outside the claudep profiles root"));
719
+ if (pin && pin.name !== "" && pin.name !== cur.name) {
720
+ console.log(
721
+ c.yellow(
722
+ `pinned here: ${pin.name} (${shortHome(pin.file, L.home)}), but this shell is on ${label}. Load the hook: eval "$(claudep shell-init zsh)"`,
723
+ ),
724
+ );
725
+ }
726
+ }
727
+
728
+ function cmdResolve(L: Layout, args: string[]): void {
729
+ const f = parseFlags(args, ["--json"], []);
730
+ const start = f.rest[0] ?? process.cwd();
731
+ const pin = resolvePin(start);
732
+ if (!pin || pin.name === "") process.exit(1);
733
+ if (!NAME_RE.test(pin.name)) die(`${pin.file} names an invalid profile "${pin.name}"`);
734
+ if (f.bools.has("--json"))
735
+ console.log(JSON.stringify({ ...pin, dir: pin.dir, profileDir: join(L.profilesRoot, pin.name) }, null, 2));
736
+ else console.log(pin.name);
737
+ }
738
+
739
+ function cmdLocal(L: Layout, args: string[]): void {
740
+ const f = parseFlags(args, ["--remove", "--force"], []);
741
+ const file = join(process.cwd(), PIN_FILE);
742
+ if (f.bools.has("--remove")) {
743
+ if (!existsSync(file)) die(`no ${PIN_FILE} in ${process.cwd()}`);
744
+ rmSync(file);
745
+ ok(`removed ${file}`);
746
+ return;
747
+ }
748
+ const name = f.rest[0];
749
+ if (!name) {
750
+ const pin = resolvePin(process.cwd());
751
+ if (!pin || pin.name === "") {
752
+ console.log(`no ${PIN_FILE} pin from ${shortHome(process.cwd(), L.home)} upward`);
753
+ process.exit(1);
754
+ }
755
+ console.log(`${pin.name} ${c.dim(shortHome(pin.file, L.home))}`);
756
+ return;
757
+ }
758
+ if (!NAME_RE.test(name)) die(`invalid profile name "${name}"`);
759
+ if (!f.bools.has("--force") && !profileExists(L, name))
760
+ die(`profile "${name}" does not exist. Run: claudep init ${name} (or pass --force to pin it anyway)`);
761
+ writeFileSync(file, `${name}\n`);
762
+ ok(`${shortHome(file, L.home)} pins this directory tree to ${c.bold(name)}`);
763
+ if (!process.env.CLAUDEP_AUTO && !process.env.CLAUDE_CONFIG_DIR)
764
+ console.log(c.dim(`Shells load pins through the hook: eval "$(claudep shell-init zsh)"`));
765
+ }
766
+
767
+ /** The shell hook. Pure parameter expansion and builtins: it runs on every
768
+ * directory change (zsh chpwd) or prompt (bash PROMPT_COMMAND), so no
769
+ * subprocess is allowed here. Logic mirrors resolvePin(). */
770
+ export function shellInit(shell: "zsh" | "bash", profilesRoot: string): string {
771
+ const q = profilesRoot.replace(/'/g, `'\\''`);
772
+ const core = `# claudep shell hook. Load it from your rc file: eval "$(claudep shell-init ${shell})"
773
+ _claudep_root='${q}'
774
+ _claudep_auto() {
775
+ [ "$PWD" = "\${_claudep_last_pwd:-}" ] && return 0
776
+ _claudep_last_pwd="$PWD"
777
+ # Only manage a CLAUDE_CONFIG_DIR this hook set itself. A manual pin wins.
778
+ if [ -n "\${CLAUDE_CONFIG_DIR:-}" ] && [ "$CLAUDE_CONFIG_DIR" != "\${CLAUDEP_AUTO:-}" ]; then return 0; fi
779
+ _claudep_dir="$PWD"
780
+ _claudep_name=""
781
+ _claudep_found=""
782
+ while :; do
783
+ if [ -f "\${_claudep_dir%/}/${PIN_FILE}" ]; then
784
+ _claudep_found="\${_claudep_dir:-/}"
785
+ while read -r _claudep_line || [ -n "$_claudep_line" ]; do
786
+ case "$_claudep_line" in "" | "#"*) continue ;; esac
787
+ _claudep_name="$_claudep_line"
788
+ break
789
+ done < "\${_claudep_dir%/}/${PIN_FILE}"
790
+ break
791
+ fi
792
+ if [ -z "$_claudep_dir" ] || [ "$_claudep_dir" = "/" ]; then break; fi
793
+ _claudep_dir="\${_claudep_dir%/*}"
794
+ done
795
+ if [ -z "$_claudep_name" ]; then
796
+ # No pin here: hand the shell back to the base account.
797
+ [ -n "\${CLAUDEP_AUTO:-}" ] && unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO
798
+ return 0
799
+ fi
800
+ if [ ! -d "$_claudep_root/$_claudep_name" ]; then
801
+ [ -n "\${CLAUDEP_AUTO:-}" ] && unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO
802
+ printf 'claudep: %s/${PIN_FILE} names profile "%s", which does not exist. Run: claudep init %s\\n' "$_claudep_found" "$_claudep_name" "$_claudep_name" >&2
803
+ return 0
804
+ fi
805
+ export CLAUDE_CONFIG_DIR="$_claudep_root/$_claudep_name" CLAUDEP_AUTO="$_claudep_root/$_claudep_name"
806
+ }
807
+ `;
808
+ const tail =
809
+ shell === "zsh"
810
+ ? `autoload -Uz add-zsh-hook
811
+ add-zsh-hook chpwd _claudep_auto
812
+ _claudep_auto
813
+ `
814
+ : `case ";\${PROMPT_COMMAND:-};" in *";_claudep_auto;"*) ;; *) PROMPT_COMMAND="_claudep_auto\${PROMPT_COMMAND:+;$PROMPT_COMMAND}" ;; esac
815
+ _claudep_auto
816
+ `;
817
+ return core + tail;
818
+ }
819
+
820
+ function cmdShellInit(L: Layout, args: string[]): void {
821
+ const shell = args[0] ?? "zsh";
822
+ if (shell !== "zsh" && shell !== "bash") die(`unsupported shell "${shell}". Use zsh or bash`);
823
+ process.stdout.write(shellInit(shell, L.profilesRoot));
824
+ }
825
+
826
+ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
827
+ const names = args[0] ? [args[0]] : listProfileNames(L);
828
+ const bin = Bun.which("claude");
829
+ if (bin) {
830
+ const { out } = await captureClaude(L, undefined, ["--version"]);
831
+ ok(`claude: ${bin} (${out.trim() || "version unknown"})`);
832
+ } else bad("claude binary not found on PATH");
833
+ ok(`base: ${L.base}${L.callerConfigDir && !L.managed ? c.yellow(" (from CLAUDE_CONFIG_DIR in your shell)") : ""}`);
834
+ ok(`profiles root: ${L.profilesRoot}`);
835
+
836
+ const shared = sharedItems(L.base);
837
+ const sharedNames = new Set(shared.map((s) => s.name));
838
+ const unclassified = existsSync(L.base)
839
+ ? readdirSync(L.base)
840
+ .filter((n) => !sharedNames.has(n) && !KNOWN_PRIVATE.has(n) && !n.endsWith(".md"))
841
+ .sort()
842
+ : [];
843
+ if (unclassified.length) {
844
+ warn(`base items neither shared nor known-private (they stay per-profile): ${unclassified.join(", ")}`);
845
+ }
846
+
847
+ let problems = 0;
848
+ for (const name of names) {
849
+ const dir = profileDir(L, name);
850
+ console.log(`\n${c.bold(name)} ${c.dim(dir)}`);
851
+ if (!existsSync(dir)) {
852
+ bad("profile dir missing");
853
+ problems++;
854
+ continue;
855
+ }
856
+ for (const item of shared) {
857
+ const dest = join(dir, item.name);
858
+ let st: ReturnType<typeof lstatSync> | undefined;
859
+ try {
860
+ st = lstatSync(dest);
861
+ } catch {
862
+ st = undefined;
863
+ }
864
+ if (!st) {
865
+ warn(`${item.name}: not linked (run: claudep init ${name})`);
866
+ problems++;
867
+ } else if (!st.isSymbolicLink()) {
868
+ warn(`${item.name}: real ${item.kind} shadows the shared one`);
869
+ problems++;
870
+ } else if (readlinkSync(dest) !== join(L.base, item.name)) {
871
+ bad(`${item.name}: symlink points elsewhere (${readlinkSync(dest)})`);
872
+ problems++;
873
+ } else if (!existsSync(dest)) {
874
+ bad(`${item.name}: broken symlink`);
875
+ problems++;
876
+ }
877
+ }
878
+ ok(`${shared.length} shared item(s) checked`);
879
+ const strays = readdirSync(dir).filter((n) => !sharedNames.has(n) && !KNOWN_PRIVATE.has(n) && !n.endsWith(".md"));
880
+ if (strays.length) warn(`unexpected private items: ${strays.join(", ")}`);
881
+ const svc = keychainService(dir);
882
+ const has = await keychainHas(svc);
883
+ if (has === undefined) console.log(`${c.dim("·")} keychain check skipped (not macOS)`);
884
+ else if (has) ok(`keychain item "${svc}" present`);
885
+ else warn(`no keychain item "${svc}". Not logged in yet (claudep ${name} auth login)`);
886
+ const s = await authStatus(L, dir);
887
+ if (s.loggedIn) ok(`logged in as ${s.email ?? "?"} (${s.orgName ?? "?"}, ${s.subscriptionType ?? "?"})`);
888
+ else warn("not logged in");
889
+ }
890
+ if (!names.length) console.log(c.dim("\nNo profiles to check."));
891
+ if (problems) {
892
+ console.log(`\n${c.red(`${problems} problem(s)`)}`);
893
+ process.exit(1);
894
+ }
895
+ }
896
+
897
+ async function cmdRm(L: Layout, args: string[]): Promise<void> {
898
+ const f = parseFlags(args, ["--keep-login", "--yes"], []);
899
+ const name = f.rest[0];
900
+ if (!name) die("usage: claudep rm <name> [--keep-login] [--yes]");
901
+ const dir = profileDir(L, name);
902
+ if (!existsSync(dir)) die(`profile "${name}" does not exist`);
903
+ const real = realpathSync(dir);
904
+ if (!real.startsWith(`${realpathSync(L.profilesRoot)}/`) || real === realpathSync(L.base) || real === L.home)
905
+ die(`refusing to remove ${real}: not inside ${L.profilesRoot}`);
906
+ if (!f.bools.has("--yes")) {
907
+ const yes = confirm(`Remove profile "${name}" (${dir})? Shared items are only unlinked; ${L.base} is untouched.`);
908
+ if (!yes) {
909
+ console.log("aborted");
910
+ return;
911
+ }
912
+ }
913
+ if (!f.bools.has("--keep-login")) {
914
+ const proc = Bun.spawn([claudeBin(), "auth", "logout"], {
915
+ env: envFor(dir),
916
+ stdin: "inherit",
917
+ stdout: "inherit",
918
+ stderr: "inherit",
919
+ });
920
+ if ((await proc.exited) === 0) ok("logged out (token revoked, keychain item removed)");
921
+ else warn("logout failed or was not logged in. Continuing");
922
+ }
923
+ rmSync(dir, { recursive: true, force: true });
924
+ ok(`removed ${dir}`);
925
+ const shims = aliasDir();
926
+ for (const entry of readdirSync(shims)) {
927
+ const p = join(shims, entry);
928
+ try {
929
+ if (!lstatSync(p).isFile()) continue;
930
+ const txt = await Bun.file(p).text();
931
+ if (txt.includes("generated by claudep") && txt.includes(` run ${name} -- `))
932
+ warn(`alias shim ${p} still points at this profile. Remove it if unused`);
933
+ } catch {
934
+ /* ignore unreadable entries */
935
+ }
936
+ }
937
+ }
938
+
939
+ function help(L: Layout): void {
940
+ const root = L.profilesRoot.startsWith(L.home) ? `~${L.profilesRoot.slice(L.home.length)}` : L.profilesRoot;
941
+ console.log(`${c.bold("claudep")} ${c.dim(version())}: run Claude Code under separate accounts on one machine
942
+
943
+ ${c.bold("USAGE")}
944
+ claudep <name> [claude args…] run claude with profile <name> (alias for "run")
945
+ claudep init <name> [options] create/update a profile and log in
946
+ claudep list show every profile and who it is logged in as
947
+ claudep status <name> [--json] login state for one profile ("default" = ~/.claude)
948
+ claudep current [--json] which profile this shell is on, and why
949
+ claudep env <name> | --unset print "export CLAUDE_CONFIG_DIR=…" (or the unset) for eval
950
+ claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
951
+ claudep doctor [name] verify symlinks, keychain entry, unclassified files
952
+ claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
953
+ claudep --version print the version
954
+
955
+ ${c.bold("DIRECTORY PINS")}
956
+ claudep local <name> [--force] write ./${PIN_FILE} so this tree uses <name>; --remove deletes it
957
+ claudep local show the pin that applies to the current directory
958
+ claudep resolve [dir] [--json] print the profile pinned for a directory (exit 1 when none)
959
+ claudep shell-init [zsh|bash] print the hook that applies pins on cd; eval it in your rc file
960
+
961
+ ${c.bold("INIT OPTIONS")}
962
+ --sso force the SSO login flow (Enterprise orgs)
963
+ --email <addr> pre-fill the login page
964
+ --console log in with an Anthropic Console (API billing) account instead of a subscription
965
+ --copy-mcp copy user-scope MCP servers from the base .claude.json
966
+ --alias <command> also create a shim command, e.g. --alias eclaude
967
+ --no-login set up files only; log in later with: claudep <name> auth login
968
+ --force relink shared items whose symlinks point elsewhere
969
+
970
+ ${c.bold("EXAMPLES")}
971
+ claudep init enterprise --sso --email you@company.com --alias eclaude
972
+ eclaude # Claude Code as the enterprise account
973
+ claude # Claude Code as whatever ~/.claude is logged in as
974
+ claudep enterprise -p "summarize this repo"
975
+ eval "$(claudep env enterprise)" # pin the whole shell to a profile
976
+ claudep local enterprise # pin this repo; commit the ${PIN_FILE} file for the team
977
+ eval "$(claudep shell-init zsh)" # in .zshrc: shells follow ${PIN_FILE} pins on cd
978
+
979
+ ${c.bold("HOW IT WORKS")}
980
+ ~/.claude stays exactly as it is and remains the "default" profile. Each named profile is a
981
+ thin directory under ${root}/<name> that Claude Code is pointed at via
982
+ CLAUDE_CONFIG_DIR. Inside it, shared config is a symlink back into ~/.claude:
983
+ ${[...SHARED_FILES, "*.md", ...SHARED_DIRS].join(" ")}
984
+ Everything account-specific is real and per profile: .claude.json (login identity, MCP
985
+ servers, folder trust), org-pushed remote-settings.json, history, todos, caches.
986
+ Credentials never touch the profile dir: Claude Code stores them in the macOS Keychain under
987
+ "Claude Code-credentials-<sha256(CLAUDE_CONFIG_DIR)[0:8]>", so every profile has its own
988
+ login and refresh token and they cannot clobber each other.
989
+
990
+ ${c.bold("PIN RULES")}
991
+ The nearest ${PIN_FILE} file upward from the current directory wins; an empty one cancels a
992
+ parent pin. The hook only changes a CLAUDE_CONFIG_DIR it set itself (tracked in CLAUDEP_AUTO),
993
+ so a manual pin from "claudep env" stays put. Leaving every pinned tree returns the shell to
994
+ ~/.claude.
995
+
996
+ ${c.bold("ENVIRONMENT")}
997
+ CLAUDE_PROFILES_DIR where profiles live (default ~/.claudep; keep it out of iCloud/Dropbox)
998
+ CLAUDEP_AUTO set by the hook next to CLAUDE_CONFIG_DIR; marks the pin as hook-managed
999
+ `);
1000
+ }
1001
+
1002
+ // ---------------------------------------------------------------------------
1003
+ // Entry
1004
+ // ---------------------------------------------------------------------------
1005
+
1006
+ export async function main(argv: string[]): Promise<void> {
1007
+ const L = layout();
1008
+ const [cmd, ...args] = argv;
1009
+ switch (cmd) {
1010
+ case undefined:
1011
+ case "help":
1012
+ case "-h":
1013
+ case "--help":
1014
+ help(L);
1015
+ return;
1016
+ case "init":
1017
+ return cmdInit(L, args);
1018
+ case "run":
1019
+ return cmdRun(L, args);
1020
+ case "list":
1021
+ case "ls":
1022
+ return cmdList(L);
1023
+ case "status":
1024
+ return cmdStatus(L, args);
1025
+ case "env":
1026
+ return cmdEnv(L, args);
1027
+ case "current":
1028
+ return cmdCurrent(L, args);
1029
+ case "local":
1030
+ return cmdLocal(L, args);
1031
+ case "resolve":
1032
+ return cmdResolve(L, args);
1033
+ case "shell-init":
1034
+ return cmdShellInit(L, args);
1035
+ case "version":
1036
+ case "--version":
1037
+ case "-v":
1038
+ console.log(version());
1039
+ return;
1040
+ case "alias":
1041
+ return cmdAlias(L, args);
1042
+ case "doctor":
1043
+ return cmdDoctor(L, args);
1044
+ case "rm":
1045
+ case "remove":
1046
+ return cmdRm(L, args);
1047
+ default:
1048
+ if (cmd === "default" || cmd === "base" || profileExists(L, cmd)) return cmdRun(L, [cmd, ...args]);
1049
+ if (NAME_RE.test(cmd)) die(`no profile "${cmd}". Create it with: claudep init ${cmd}`);
1050
+ die(`unknown command "${cmd}". Try: claudep help`);
1051
+ }
1052
+ }
1053
+
1054
+ if (import.meta.main) await main(Bun.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@bordoni/claudep",
3
+ "version": "0.1.0",
4
+ "description": "Run Claude Code under separate accounts on one machine (work + personal) with isolated logins and shared config.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Gustavo Bordoni",
8
+ "homepage": "https://github.com/bordoni/claudep#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/bordoni/claudep.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/bordoni/claudep/issues"
15
+ },
16
+ "keywords": [
17
+ "claude",
18
+ "claude-code",
19
+ "cli",
20
+ "bun",
21
+ "profiles",
22
+ "accounts"
23
+ ],
24
+ "bin": {
25
+ "claudep": "./claudep.ts"
26
+ },
27
+ "files": [
28
+ "claudep.ts",
29
+ "README.md",
30
+ "LICENSE",
31
+ "CHANGELOG.md"
32
+ ],
33
+ "engines": {
34
+ "bun": ">=1.1"
35
+ },
36
+ "packageManager": "bun@1.4.0",
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "test": "bun test --timeout 20000",
42
+ "test:watch": "bun test --watch",
43
+ "test:coverage": "bun test --coverage",
44
+ "typecheck": "tsc --noEmit",
45
+ "lint": "biome check .",
46
+ "lint:fix": "biome check --write .",
47
+ "check": "bun run typecheck && bun run lint && bun run test",
48
+ "pack:check": "bun pm pack --dry-run",
49
+ "changelog": "bun scripts/changelog.ts",
50
+ "release": "bun scripts/release.ts"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^2.5.11",
54
+ "@types/bun": "^1.4.0",
55
+ "typescript": "^7.0.2"
56
+ }
57
+ }