@bordoni/claudep 0.1.1 → 0.2.1

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 CHANGED
@@ -6,6 +6,38 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-09-08
10
+
11
+ ### Changed
12
+
13
+ - `rules/`, `output-styles/`, `themes/` and `workflows/` are shared with every profile, matching what Claude Code 2.1.263 reads from the user config directory. A custom theme named in the shared `settings.json` now resolves inside a profile. Existing profiles pick the links up on their next `claudep init <name>`.
14
+ - `teams/`, `uploads/`, `usage-data/`, `mcp-discovery-cache/`, `mcp-skill-archives/`, `daemon.json`, `launch.json`, `scheduled_tasks.json` and `seed-admin` are known-private, so `claudep doctor` no longer reports them as unclassified.
15
+ - The README notes that `claude --bg` sessions and `claude daemon install` do not work under a profile in Claude Code 2.1.263.
16
+
17
+ ## [0.2.0] - 2026-09-07
18
+
19
+ ### Added
20
+
21
+ - Windows support, from PowerShell 5.1, PowerShell 7 and Git Bash, with a native `claude.exe` or an npm-installed `claude.cmd`. Install with `bun add -g @bordoni/claudep`. cmd.exe is not a target. The README has a Windows section.
22
+ - `claudep init` on Windows stops with instructions to turn on Developer Mode when Windows refuses to create a symlink, and finishes the profile on the next run. Symlinks are always created with their kind, which Windows needs and other systems ignore.
23
+ - `claudep shell-init powershell` prints a hook for `$PROFILE` that follows `.claudep` pins by wrapping `prompt`. `shell-init` with no argument picks the shell from `$SHELL`, or PowerShell on Windows outside Git Bash.
24
+ - `claudep env` prints `$env:` syntax when run from PowerShell, so `claudep env work | Invoke-Expression` works there and `eval "$(claudep env work)"` keeps working in Git Bash and elsewhere.
25
+ - `claudep alias` on Windows writes `<command>.cmd`, which PowerShell finds through `PATHEXT`, next to the sh shim Git Bash runs.
26
+ - An npm-installed `claude.cmd` is launched through cmd.exe; `claude.exe` is preferred when both are on PATH, and `claudep doctor` says which one it found.
27
+ - `claudep current` and `claudep doctor` warn on Windows when `CLAUDE_CONFIG_DIR` is a POSIX-style path that `claude.exe` cannot read.
28
+ - CI runs the suite on Windows as well as macOS and Linux, including the shell hook under Git Bash, `pwsh` and Windows PowerShell 5.1.
29
+
30
+ ### Changed
31
+
32
+ - `claudep doctor` on Linux and Windows checks that the profile has a `.credentials.json` instead of printing `keychain check skipped`. `Thumbs.db` and `desktop.ini` are known-private.
33
+ - Path comparisons for pins, the `claudep rm` safety check and `~` shortening accept `~\`, drive letters and MSYS `/c/` paths, and are case-insensitive on Windows only. No behaviour change on macOS or Linux.
34
+ - `claudep rm` unlinks the shared items itself before deleting the profile directory.
35
+ - The bash hook under Git Bash exports the native `C:\` path for the pinned profile while still walking the POSIX `$PWD`.
36
+
37
+ ### Fixed
38
+
39
+ - The shell hook reads `.claudep` pin files with CRLF line endings. The `\r` used to become part of the profile name.
40
+
9
41
  ## [0.1.1] - 2026-09-03
10
42
 
11
43
  ### Fixed
@@ -31,6 +63,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
31
63
  - Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
32
64
  - 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.
33
65
 
34
- [Unreleased]: https://github.com/bordoni/claudep/compare/0.1.1...HEAD
66
+ [Unreleased]: https://github.com/bordoni/claudep/compare/0.2.1...HEAD
67
+ [0.2.1]: https://github.com/bordoni/claudep/compare/0.2.0...0.2.1
68
+ [0.2.0]: https://github.com/bordoni/claudep/compare/0.1.1...0.2.0
35
69
  [0.1.1]: https://github.com/bordoni/claudep/compare/0.1.0...0.1.1
36
70
  [0.1.0]: https://github.com/bordoni/claudep/releases/tag/0.1.0
package/README.md CHANGED
@@ -34,6 +34,21 @@ git clone git@github.com:bordoni/claudep.git ~/workspace/claudep
34
34
  ln -s ~/workspace/claudep/claudep.ts ~/.local/bin/claudep
35
35
  ```
36
36
 
37
+ ### Windows
38
+
39
+ claudep runs natively on Windows from PowerShell 5.1, PowerShell 7 and Git Bash. Install it with bun, which writes `claudep.exe` into `%USERPROFILE%\.bun\bin`:
40
+
41
+ ```powershell
42
+ bun add -g @bordoni/claudep
43
+ ```
44
+
45
+ Two things differ from macOS:
46
+
47
+ - Profiles share configuration through symlinks, and Windows lets a normal user create symlinks only with Developer Mode on (Settings > For developers > Developer Mode). `claudep init` stops and says so when it is off. Turn it on, open a new terminal and run the same command again.
48
+ - There is no keychain. Claude Code keeps each profile's login in `<profile>\.credentials.json`, and `claudep doctor` checks that the file is there.
49
+
50
+ `claudep env` prints PowerShell syntax when run from PowerShell and sh syntax from Git Bash, so `claudep env work | Invoke-Expression` and `eval "$(claudep env work)"` both work. Git Bash otherwise behaves like bash elsewhere: put `eval "$(claudep shell-init bash)"` in `~/.bashrc`. An npm-installed `claude.cmd` runs through cmd.exe; the native installer's `claude.exe` avoids that hop and is what `claudep doctor` recommends. cmd.exe itself is not supported.
51
+
37
52
  ## Usage
38
53
 
39
54
  ```
@@ -48,7 +63,7 @@ claudep doctor [name] verify symlinks, keychain entry, unclassifi
48
63
  claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
49
64
  claudep local <name> | --remove pin the current directory tree to a profile (see below)
50
65
  claudep resolve [dir] print the profile pinned for a directory
51
- claudep shell-init [zsh|bash] print the hook that applies pins on cd
66
+ claudep shell-init [zsh|bash|powershell] print the hook that applies pins on cd
52
67
  claudep --version
53
68
  ```
54
69
 
@@ -63,16 +78,22 @@ cd ~/work/acme
63
78
  claudep local enterprise # writes ./.claudep containing "enterprise"
64
79
  ```
65
80
 
66
- Shells apply pins through a hook. Add one line to `~/.zshrc` (or `~/.bashrc` with `bash`):
81
+ Shells apply pins through a hook. Add one line to `~/.zshrc` (or `~/.bashrc` with `bash`, including Git Bash on Windows):
67
82
 
68
83
  ```sh
69
84
  eval "$(claudep shell-init zsh)"
70
85
  ```
71
86
 
72
- 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:
87
+ In PowerShell the line goes in `$PROFILE`:
88
+
89
+ ```powershell
90
+ claudep shell-init powershell | Out-String | Invoke-Expression
91
+ ```
92
+
93
+ 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 (cmdlets only in PowerShell), so it costs nothing at the prompt. The rules:
73
94
 
74
95
  - The nearest `.claudep` file upward from the current directory wins. An empty one cancels a parent pin.
75
- - 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)"`.
96
+ - 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)"` (`claudep env work | Invoke-Expression` in PowerShell) or a plain `export` stays put until you `eval "$(claudep env --unset)"`.
76
97
  - A pin that names a profile you have not created prints one warning per directory change and sets nothing.
77
98
 
78
99
  `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.
@@ -85,10 +106,10 @@ From then on, `cd` into a pinned tree sets `CLAUDE_CONFIG_DIR` for that profile
85
106
  |---|---|
86
107
  | `CLAUDE.md` and other top-level `*.md` | `.claude.json` (login identity, user-scope MCP servers, folder trust) |
87
108
  | `settings.json`, `keybindings.json`, `statusline-command.sh` | org-pushed `remote-settings.json`, `policy-limits.json` |
88
- | `hooks/`, `skills/`, `commands/`, `agents/`, `plugins/`, `plans/` | `history.jsonl`, `todos/`, `sessions/`, caches, telemetry |
89
- | `projects/` (session transcripts and auto-memory) | credentials |
109
+ | `hooks/`, `skills/`, `commands/`, `agents/`, `rules/`, `output-styles/`, `themes/`, `workflows/` | `history.jsonl`, `todos/`, `sessions/`, `teams/`, caches, telemetry |
110
+ | `plugins/`, `plans/`, `projects/` (session transcripts and auto-memory) | credentials |
90
111
 
91
- 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.
112
+ On macOS, credentials never touch the profile directory: 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. On Linux and Windows the login is `<profile>/.credentials.json`, a real file inside the profile that is never shared.
92
113
 
93
114
  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.
94
115
 
@@ -96,6 +117,7 @@ The shared list is an allowlist, so an account-specific file cannot leak across
96
117
 
97
118
  - 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.
98
119
  - Two profiles running at the same time write the same `settings.json` and `plugins/`. That is the same situation as two terminals today.
120
+ - Background sessions and the daemon are tied to `~/.claude`. As of Claude Code 2.1.263, `claude daemon install` refuses to run with `CLAUDE_CONFIG_DIR` set, and `claude --bg` under a profile runs without the daemon.
99
121
  - 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.
100
122
  - Never put `CLAUDE_CONFIG_DIR` in a `settings.json` `env` block. Claude Code detects that mismatch and disables features.
101
123
 
package/claudep.ts CHANGED
@@ -24,13 +24,15 @@ import {
24
24
  readFileSync,
25
25
  readlinkSync,
26
26
  realpathSync,
27
+ rmdirSync,
27
28
  rmSync,
28
29
  statSync,
29
30
  symlinkSync,
31
+ unlinkSync,
30
32
  writeFileSync,
31
33
  } from "node:fs";
32
34
  import { homedir, userInfo } from "node:os";
33
- import { dirname, join, resolve } from "node:path";
35
+ import { dirname, join, posix, resolve, win32 } from "node:path";
34
36
 
35
37
  // ---------------------------------------------------------------------------
36
38
  // Layout
@@ -38,21 +40,94 @@ import { dirname, join, resolve } from "node:path";
38
40
 
39
41
  export type Env = Record<string, string | undefined>;
40
42
 
43
+ /** The path module for a platform. Every helper that must be exercised for
44
+ * win32 on a macOS or Linux host takes `platform` as a parameter and goes
45
+ * through this instead of the ambient `node:path`. */
46
+ export function pathApi(platform: NodeJS.Platform = process.platform): typeof posix | typeof win32 {
47
+ return platform === "win32" ? win32 : posix;
48
+ }
49
+
50
+ /** An MSYS or Git Bash style path such as /c/Users/me. */
51
+ export function isMsysPath(p: string): boolean {
52
+ return /^\/[a-zA-Z](\/|$)/.test(p);
53
+ }
54
+
55
+ /** win32 only: drop the \\?\ and \\?\UNC\ prefixes readlink and realpath can
56
+ * add, and rewrite an MSYS /c/x path to C:/x. A pure string operation; the
57
+ * result still goes through resolve() to pick the separator. */
58
+ export function toNativePath(p: string, platform: NodeJS.Platform = process.platform): string {
59
+ if (platform !== "win32") return p;
60
+ let out = p;
61
+ if (out.startsWith("\\\\?\\UNC\\")) out = `\\\\${out.slice(8)}`;
62
+ else if (out.startsWith("\\\\?\\")) out = out.slice(4);
63
+ if (isMsysPath(out)) out = `${(out[1] as string).toUpperCase()}:${out.slice(2) || "/"}`;
64
+ return out;
65
+ }
66
+
41
67
  /** 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 {
68
+ * os.homedir() reads getpwuid() and ignores the variable. On Windows
69
+ * USERPROFILE is the native home; Git Bash sets HOME to a POSIX-style path
70
+ * that claude.exe would not understand. */
71
+ export function homeDir(env: Env = process.env, platform: NodeJS.Platform = process.platform): string {
72
+ if (platform === "win32") return toNativePath(env.USERPROFILE || env.HOME || homedir(), platform);
44
73
  return env.HOME || homedir();
45
74
  }
46
75
 
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");
76
+ /** Canonical form of a config dir: absolute, no trailing separator, NFC, and
77
+ * on Windows an upper-case drive letter and backslashes. Claude Code hashes
78
+ * the *literal* CLAUDE_CONFIG_DIR string for the keychain service name and
79
+ * compares it literally elsewhere, so the same profile must always produce
80
+ * the same string. */
81
+ export function canon(p: string, home: string = homeDir(), platform: NodeJS.Platform = process.platform): string {
82
+ const P = pathApi(platform);
83
+ const tilde = p.startsWith("~/") || (platform === "win32" && p.startsWith("~\\"));
84
+ const expanded = tilde ? P.join(home, p.slice(2)) : toNativePath(p, platform);
85
+ let out = P.resolve(expanded);
86
+ if (platform === "win32" && /^[a-z]:/.test(out)) out = `${(out[0] as string).toUpperCase()}${out.slice(1)}`;
87
+ return out.normalize("NFC");
88
+ }
89
+
90
+ /** Comparison key for a path. Identity on POSIX, where two spellings are two
91
+ * directories. On Windows the file system is case-insensitive and readlink
92
+ * may add a \\?\ prefix or use forward slashes, so the key folds all of that. */
93
+ export function pathKey(p: string, platform: NodeJS.Platform = process.platform): string {
94
+ if (platform !== "win32") return p;
95
+ return toNativePath(p, platform).replace(/\//g, "\\").replace(/\\+$/, "").toLowerCase();
96
+ }
97
+
98
+ export function samePath(a: string, b: string, platform: NodeJS.Platform = process.platform): boolean {
99
+ return pathKey(a, platform) === pathKey(b, platform);
100
+ }
101
+
102
+ /** True when `child` is strictly inside `parent`. Never use startsWith for
103
+ * this: "/r" is not a parent of "/rx", and on Windows case must not matter. */
104
+ export function isInside(parent: string, child: string, platform: NodeJS.Platform = process.platform): boolean {
105
+ const sep = pathApi(platform).sep;
106
+ const pk = pathKey(parent, platform);
107
+ const prefix = pk.endsWith(sep) ? pk : `${pk}${sep}`;
108
+ return pathKey(child, platform).startsWith(prefix);
109
+ }
110
+
111
+ /** `~` plus the tail of `p` when it lives under `home`, otherwise `p` unchanged. */
112
+ export function shortHome(p: string, home: string, platform: NodeJS.Platform = process.platform): string {
113
+ const np = toNativePath(p, platform);
114
+ if (samePath(np, home, platform)) return "~";
115
+ return isInside(home, np, platform) ? `~${np.slice(home.length)}` : np;
116
+ }
117
+
118
+ /** Delete a variable from an env copy. Windows environments are
119
+ * case-insensitive, so every spelling of the name goes. */
120
+ export function deleteEnv(env: Env, name: string, platform: NodeJS.Platform = process.platform): void {
121
+ if (platform !== "win32") {
122
+ delete env[name];
123
+ return;
124
+ }
125
+ const want = name.toUpperCase();
126
+ for (const k of Object.keys(env)) if (k.toUpperCase() === want) delete env[k];
53
127
  }
54
128
 
55
129
  export type Layout = {
130
+ platform: NodeJS.Platform;
56
131
  home: string;
57
132
  /** CLAUDE_CONFIG_DIR as set in the caller's shell, if any. */
58
133
  callerConfigDir: string | undefined;
@@ -69,25 +144,27 @@ export type Layout = {
69
144
  profilesRoot: string;
70
145
  };
71
146
 
72
- export function layout(env: Env = process.env): Layout {
73
- const home = homeDir(env);
147
+ export function layout(env: Env = process.env, platform: NodeJS.Platform = process.platform): Layout {
148
+ const P = pathApi(platform);
149
+ const home = homeDir(env, platform);
74
150
  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}/`);
151
+ const profilesRoot = canon(env.CLAUDE_PROFILES_DIR ?? P.join(home, ".claudep"), home, platform);
152
+ const callerCanon = callerConfigDir !== undefined ? canon(callerConfigDir, home, platform) : undefined;
153
+ const managed = callerCanon !== undefined && isInside(profilesRoot, callerCanon, platform);
78
154
  const tail = managed && callerCanon !== undefined ? callerCanon.slice(profilesRoot.length + 1) : undefined;
79
155
  const activeProfile = tail !== undefined && NAME_RE.test(tail) ? tail : undefined;
80
156
  // A custom CLAUDE_CONFIG_DIR outside the profiles root is the user's real base.
81
157
  // One inside it is a claudep profile and must never be treated as the base.
82
158
  const customBase = callerCanon !== undefined && !managed;
83
- const base = customBase && callerCanon !== undefined ? callerCanon : canon(join(home, ".claude"), home);
159
+ const base = customBase && callerCanon !== undefined ? callerCanon : canon(P.join(home, ".claude"), home, platform);
84
160
  return {
161
+ platform,
85
162
  home,
86
163
  callerConfigDir,
87
164
  managed,
88
165
  activeProfile,
89
166
  base,
90
- baseGlobalJson: customBase ? join(base, ".claude.json") : join(home, ".claude.json"),
167
+ baseGlobalJson: customBase ? P.join(base, ".claude.json") : P.join(home, ".claude.json"),
91
168
  profilesRoot,
92
169
  };
93
170
  }
@@ -140,9 +217,9 @@ export type Current = {
140
217
  export function currentProfile(L: Layout, env: Env = process.env): Current {
141
218
  const cfg = env.CLAUDE_CONFIG_DIR;
142
219
  if (!cfg) return { kind: "base", name: undefined, dir: L.base, setBy: "none" };
143
- const dir = canon(cfg, L.home);
220
+ const dir = canon(cfg, L.home, L.platform);
144
221
  const auto = env.CLAUDEP_AUTO;
145
- const setBy = auto !== undefined && canon(auto, L.home) === dir ? "hook" : "manual";
222
+ const setBy = auto !== undefined && samePath(canon(auto, L.home, L.platform), dir, L.platform) ? "hook" : "manual";
146
223
  if (L.activeProfile !== undefined) return { kind: "profile", name: L.activeProfile, dir, setBy };
147
224
  return { kind: "custom", name: undefined, dir, setBy };
148
225
  }
@@ -159,7 +236,19 @@ export function version(): string {
159
236
 
160
237
  /** Top-level base items symlinked into every profile (only if they exist). */
161
238
  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;
239
+ export const SHARED_DIRS = [
240
+ "hooks",
241
+ "skills",
242
+ "commands",
243
+ "agents",
244
+ "plugins",
245
+ "plans",
246
+ "projects",
247
+ "rules",
248
+ "output-styles",
249
+ "themes",
250
+ "workflows",
251
+ ] as const;
163
252
 
164
253
  /** Per-profile state. First block is Claude Code's own runtime-state list;
165
254
  * the rest are observed extras. Never shared. */
@@ -196,11 +285,22 @@ export const KNOWN_PRIVATE = new Set<string>([
196
285
  "local",
197
286
  "settings.local.json",
198
287
  ".DS_Store",
288
+ "Thumbs.db",
289
+ "desktop.ini",
199
290
  ".config.json",
200
291
  ".last-cleanup",
201
292
  ".last-update-result.json",
202
293
  "daemon-auth-cooldown",
203
294
  "daemon-auth-status.json",
295
+ "teams",
296
+ "uploads",
297
+ "usage-data",
298
+ "mcp-discovery-cache",
299
+ "mcp-skill-archives",
300
+ "daemon.json",
301
+ "launch.json",
302
+ "scheduled_tasks.json",
303
+ "seed-admin",
204
304
  ]);
205
305
 
206
306
  /** Keys copied from the base .claude.json into a fresh profile so first-run
@@ -266,11 +366,11 @@ export function die(msg: string, code = 1): never {
266
366
  export function profileDir(L: Layout, name: string): string {
267
367
  if (!NAME_RE.test(name)) die(`invalid profile name "${name}" (use [a-z0-9_-], starting with a letter or digit)`);
268
368
  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);
369
+ return canon(pathApi(L.platform).join(L.profilesRoot, name), L.home, L.platform);
270
370
  }
271
371
 
272
372
  export function profileExists(L: Layout, name: string): boolean {
273
- return NAME_RE.test(name) && !RESERVED.has(name) && existsSync(join(L.profilesRoot, name));
373
+ return NAME_RE.test(name) && !RESERVED.has(name) && existsSync(pathApi(L.platform).join(L.profilesRoot, name));
274
374
  }
275
375
 
276
376
  export function listProfileNames(L: Layout): string[] {
@@ -309,34 +409,107 @@ export function sharedItems(base: string): SharedItem[] {
309
409
  return items;
310
410
  }
311
411
 
312
- export type LinkResult = "linked" | "ok" | "wrong-target" | "conflict";
412
+ /** The two file-system calls link() makes, injectable so the tests can record
413
+ * the symlink type and simulate Windows refusing to create one. */
414
+ export type LinkDeps = {
415
+ platform: NodeJS.Platform;
416
+ symlink: (target: string, dest: string, type: SharedItem["kind"]) => void;
417
+ readlink: (p: string) => string;
418
+ };
419
+
420
+ export const defaultLinkDeps = (): LinkDeps => ({
421
+ platform: process.platform,
422
+ symlink: (target, dest, type) => symlinkSync(target, dest, type),
423
+ readlink: (p) => readlinkSync(p),
424
+ });
425
+
426
+ export type LinkResult = "linked" | "ok" | "wrong-target" | "conflict" | "denied";
313
427
 
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);
428
+ /** Remove a symlink. On Windows a directory symlink is removed with rmdir. */
429
+ function removeLink(p: string): void {
430
+ try {
431
+ unlinkSync(p);
432
+ } catch {
433
+ rmdirSync(p);
434
+ }
435
+ }
436
+
437
+ /** false when the OS refused with EPERM, which on Windows means Developer
438
+ * Mode is off. Anything else propagates. */
439
+ function trySymlink(deps: LinkDeps, target: string, dest: string, type: SharedItem["kind"]): boolean {
440
+ try {
441
+ deps.symlink(target, dest, type);
442
+ return true;
443
+ } catch (e) {
444
+ if ((e as { code?: string }).code === "EPERM") return false;
445
+ throw e;
446
+ }
447
+ }
448
+
449
+ /** Idempotent symlink profile/<name> -> base/<name>. Never overwrites real
450
+ * files. The type is always passed: POSIX ignores it, Windows needs it. */
451
+ export function link(
452
+ base: string,
453
+ dir: string,
454
+ item: SharedItem,
455
+ force: boolean,
456
+ deps: LinkDeps = defaultLinkDeps(),
457
+ ): LinkResult {
458
+ const target = join(base, item.name);
459
+ const dest = join(dir, item.name);
318
460
  let st: ReturnType<typeof lstatSync> | undefined;
319
461
  try {
320
462
  st = lstatSync(dest);
321
463
  } catch {
322
464
  st = undefined;
323
465
  }
324
- if (!st) {
325
- symlinkSync(target, dest);
326
- return "linked";
327
- }
466
+ if (!st) return trySymlink(deps, target, dest, item.kind) ? "linked" : "denied";
328
467
  if (st.isSymbolicLink()) {
329
- if (readlinkSync(dest) === target) return "ok";
468
+ if (samePath(deps.readlink(dest), target, deps.platform)) return "ok";
330
469
  if (force) {
331
- rmSync(dest);
332
- symlinkSync(target, dest);
333
- return "linked";
470
+ removeLink(dest);
471
+ return trySymlink(deps, target, dest, item.kind) ? "linked" : "denied";
334
472
  }
335
473
  return "wrong-target";
336
474
  }
337
475
  return "conflict";
338
476
  }
339
477
 
478
+ export type LinkState = "ok" | "missing" | "shadowed" | "wrong-target" | "broken";
479
+
480
+ /** What doctor reports for one shared item. Read-only twin of link(). */
481
+ export function linkState(
482
+ base: string,
483
+ dir: string,
484
+ item: SharedItem,
485
+ deps: Pick<LinkDeps, "platform" | "readlink"> = defaultLinkDeps(),
486
+ ): LinkState {
487
+ const dest = join(dir, item.name);
488
+ let st: ReturnType<typeof lstatSync> | undefined;
489
+ try {
490
+ st = lstatSync(dest);
491
+ } catch {
492
+ st = undefined;
493
+ }
494
+ if (!st) return "missing";
495
+ if (!st.isSymbolicLink()) return "shadowed";
496
+ if (!samePath(deps.readlink(dest), join(base, item.name), deps.platform)) return "wrong-target";
497
+ return existsSync(dest) ? "ok" : "broken";
498
+ }
499
+
500
+ /** What to tell the user when the OS refused to create a symlink. */
501
+ export function symlinkDeniedHint(platform: NodeJS.Platform, name: string): string {
502
+ if (platform === "win32")
503
+ return `Windows refused to create a symlink. Turn on Developer Mode (Settings > For developers > Developer Mode), open a new terminal and run: claudep init ${name}`;
504
+ return `the OS refused to create a symlink (EPERM). Check the permissions on the profiles root, then run: claudep init ${name}`;
505
+ }
506
+
507
+ /** True when the profile has Claude Code's file credential store. Existence
508
+ * only; the file is never read. This is the login check everywhere but macOS. */
509
+ export function credentialsFileHas(dir: string, exists: (p: string) => boolean = existsSync): boolean {
510
+ return exists(join(dir, ".credentials.json"));
511
+ }
512
+
340
513
  export type JsonObject = Record<string, unknown>;
341
514
 
342
515
  export async function readJson(path: string): Promise<JsonObject | undefined> {
@@ -366,17 +539,114 @@ export async function seedGlobalJson(baseGlobalJson: string, dir: string, copyMc
366
539
  // Running claude
367
540
  // ---------------------------------------------------------------------------
368
541
 
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;
542
+ /** How `claude` is installed. A native binary or npm's Windows shims. */
543
+ export type ClaudeLaunch = { bin: string; kind: "exe" | "cmd" | "ps1" };
544
+
545
+ export type FindDeps = {
546
+ platform: NodeJS.Platform;
547
+ pathVar: string;
548
+ exists: (p: string) => boolean;
549
+ which: (name: string) => string | null;
550
+ };
551
+
552
+ export const defaultFindDeps = (): FindDeps => ({
553
+ platform: process.platform,
554
+ pathVar: process.env.PATH ?? "",
555
+ exists: existsSync,
556
+ which: (name) => Bun.which(name),
557
+ });
558
+
559
+ /** Locate claude. POSIX asks which(). Windows walks PATH one directory at a
560
+ * time so the first directory wins even when a later one has claude.exe,
561
+ * and prefers claude.exe over the npm shims inside a directory. */
562
+ export function findClaude(deps: FindDeps = defaultFindDeps()): ClaudeLaunch | undefined {
563
+ if (deps.platform !== "win32") {
564
+ const bin = deps.which("claude");
565
+ return bin ? { bin, kind: "exe" } : undefined;
566
+ }
567
+ const candidates: [string, ClaudeLaunch["kind"]][] = [
568
+ ["claude.exe", "exe"],
569
+ ["claude.cmd", "cmd"],
570
+ ["claude.bat", "cmd"],
571
+ ["claude.ps1", "ps1"],
572
+ ];
573
+ for (const dir of splitPathVar(deps.pathVar, "win32")) {
574
+ for (const [file, kind] of candidates) {
575
+ const bin = win32.join(dir, file);
576
+ if (deps.exists(bin)) return { bin, kind };
577
+ }
578
+ }
579
+ return undefined;
580
+ }
581
+
582
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
583
+
584
+ /** The command word for `cmd.exe /d /s /c "..."`, the way cross-spawn does
585
+ * it: caret-escape cmd's metacharacters, spaces included, and no quotes. */
586
+ export function cmdExeCommand(cmd: string): string {
587
+ return cmd.replace(CMD_META, "^$1");
588
+ }
589
+
590
+ /** One argument for the same line: double backslashes before quotes, escape
591
+ * quotes, wrap in quotes, then caret-escape every metacharacter. Twice when
592
+ * the target is a .cmd shim, because its own cmd.exe parses `%*` again. */
593
+ export function cmdExeQuote(arg: string, doubleEscape = true): string {
594
+ let out = arg.replace(/(?=(\\+?)?)"/g, '$1$1\\"');
595
+ out = out.replace(/(\\+)$/, "$1$1");
596
+ out = `"${out}"`.replace(CMD_META, "^$1");
597
+ if (doubleEscape) out = out.replace(CMD_META, "^$1");
598
+ return out;
599
+ }
600
+
601
+ /** argv and spawn option for a launch. A .cmd shim cannot be executed
602
+ * directly; it runs through cmd.exe with one pre-quoted command line. */
603
+ export function claudeSpawn(launch: ClaudeLaunch, args: string[]): { cmd: string[]; verbatim: boolean } {
604
+ if (launch.kind !== "cmd") return { cmd: [launch.bin, ...args], verbatim: false };
605
+ const line = [cmdExeCommand(launch.bin), ...args.map((a) => cmdExeQuote(a))].join(" ");
606
+ return { cmd: ["cmd.exe", "/d", "/s", "/c", `"${line}"`], verbatim: true };
607
+ }
608
+
609
+ /** Signals the wrapper ignores (so it survives to report the child's exit
610
+ * code; the terminal delivers Ctrl+C to the child itself) and forwards.
611
+ * Windows has no SIGHUP. */
612
+ export function wrapperSignals(platform: NodeJS.Platform): { ignore: NodeJS.Signals[]; forward: NodeJS.Signals[] } {
613
+ if (platform === "win32") return { ignore: ["SIGINT"], forward: ["SIGTERM"] };
614
+ return { ignore: ["SIGINT"], forward: ["SIGTERM", "SIGHUP"] };
615
+ }
616
+
617
+ function claudeLaunch(): ClaudeLaunch {
618
+ const launch = findClaude();
619
+ if (!launch) die("`claude` not found on PATH. Install Claude Code first: https://code.claude.com/docs/en/setup");
620
+ if (launch.kind === "ps1")
621
+ die(
622
+ `only ${launch.bin} was found. Install Claude Code with the native installer or npm so claude.exe or claude.cmd exists: https://code.claude.com/docs/en/setup`,
623
+ );
624
+ return launch;
625
+ }
626
+
627
+ function spawnClaude(L: Layout, dir: string | undefined, args: string[], io: "inherit" | "pipe") {
628
+ const { cmd, verbatim } = claudeSpawn(claudeLaunch(), args);
629
+ return Bun.spawn(cmd, {
630
+ env: claudeEnv(L, dir),
631
+ stdin: io === "inherit" ? "inherit" : "ignore",
632
+ stdout: io,
633
+ stderr: io,
634
+ windowsVerbatimArguments: verbatim,
635
+ });
373
636
  }
374
637
 
375
638
  /** Environment for a profile. `undefined` dir means "the base", i.e. leave the
376
639
  * caller's CLAUDE_CONFIG_DIR exactly as it is (set or unset). */
377
- export function envFor(dir: string | undefined, env: Env = process.env): Env {
640
+ export function envFor(
641
+ dir: string | undefined,
642
+ env: Env = process.env,
643
+ platform: NodeJS.Platform = process.platform,
644
+ ): Env {
378
645
  const out: Env = { ...env };
379
- if (dir !== undefined) out.CLAUDE_CONFIG_DIR = dir;
646
+ if (dir !== undefined) {
647
+ deleteEnv(out, "CLAUDE_CONFIG_DIR", platform);
648
+ out.CLAUDE_CONFIG_DIR = dir;
649
+ }
380
650
  return out;
381
651
  }
382
652
 
@@ -386,28 +656,23 @@ export function envFor(dir: string | undefined, env: Env = process.env): Env {
386
656
  export function baseEnv(L: Layout, env: Env = process.env): Env {
387
657
  const out: Env = { ...env };
388
658
  if (L.managed) {
389
- delete out.CLAUDE_CONFIG_DIR;
390
- delete out.CLAUDEP_AUTO;
659
+ deleteEnv(out, "CLAUDE_CONFIG_DIR", L.platform);
660
+ deleteEnv(out, "CLAUDEP_AUTO", L.platform);
391
661
  }
392
662
  return out;
393
663
  }
394
664
 
395
665
  function claudeEnv(L: Layout, dir: string | undefined): Env {
396
- return dir === undefined ? baseEnv(L) : envFor(dir);
666
+ return dir === undefined ? baseEnv(L) : envFor(dir, process.env, L.platform);
397
667
  }
398
668
 
399
669
  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
- });
670
+ const proc = spawnClaude(L, dir, args, "inherit");
406
671
  // Ctrl+C reaches the child through the terminal; keep the wrapper alive so
407
672
  // 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"));
673
+ const signals = wrapperSignals(L.platform);
674
+ for (const s of signals.ignore) process.on(s, () => {});
675
+ for (const s of signals.forward) process.on(s, () => proc.kill(s));
411
676
  await proc.exited;
412
677
  process.exit(proc.exitCode ?? 1);
413
678
  }
@@ -417,12 +682,7 @@ async function captureClaude(
417
682
  dir: string | undefined,
418
683
  args: string[],
419
684
  ): 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
- });
685
+ const proc = spawnClaude(L, dir, args, "pipe");
426
686
  const out = await new Response(proc.stdout).text();
427
687
  const code = await proc.exited;
428
688
  return { code, out };
@@ -528,9 +788,10 @@ async function cmdInit(L: Layout, args: string[]): Promise<void> {
528
788
 
529
789
  let conflicts = 0;
530
790
  for (const item of sharedItems(L.base)) {
531
- const r = link(L.base, dir, item.name, f.bools.has("--force"));
791
+ const r = link(L.base, dir, item, f.bools.has("--force"));
532
792
  if (r === "linked") ok(`${item.name} → shared`);
533
793
  else if (r === "ok") console.log(`${c.dim("·")} ${item.name} ${c.dim("already shared")}`);
794
+ else if (r === "denied") die(symlinkDeniedHint(L.platform, name));
534
795
  else if (r === "wrong-target") {
535
796
  warn(`${item.name} is a symlink to somewhere else (re-run with --force to relink)`);
536
797
  conflicts++;
@@ -547,7 +808,7 @@ async function cmdInit(L: Layout, args: string[]): Promise<void> {
547
808
  else warn(`could not read ${L.baseGlobalJson}; Claude Code will run its first-time onboarding`);
548
809
 
549
810
  const alias = f.strs.get("--alias");
550
- if (alias !== undefined) writeAlias(name, alias);
811
+ if (alias !== undefined) writeAlias(L, name, alias);
551
812
 
552
813
  if (conflicts) warn(`${conflicts} item(s) need attention, see above`);
553
814
 
@@ -575,42 +836,75 @@ export function aliasDir(): string {
575
836
  return onPathBin ? dirname(onPathBin) : scriptDir();
576
837
  }
577
838
 
839
+ /** PATH entries. `:` on POSIX, `;` on Windows, where `:` would split every
840
+ * entry at its drive letter. */
841
+ export function splitPathVar(pathVar: string, platform: NodeJS.Platform = process.platform): string[] {
842
+ return pathVar.split(pathApi(platform).delimiter).filter((p) => p !== "");
843
+ }
844
+
578
845
  /** True when some PATH entry resolves (through symlinks) to `dir`. */
579
- export function onPath(dir: string, pathVar: string = process.env.PATH ?? ""): boolean {
846
+ export function onPath(
847
+ dir: string,
848
+ pathVar: string = process.env.PATH ?? "",
849
+ platform: NodeJS.Platform = process.platform,
850
+ ): boolean {
580
851
  const want = realpathSync(dir);
581
- return pathVar.split(":").some((p) => {
852
+ return splitPathVar(pathVar, platform).some((p) => {
582
853
  try {
583
- return realpathSync(canon(p)) === want;
854
+ return samePath(realpathSync(canon(p, undefined, platform)), want, platform);
584
855
  } catch {
585
856
  return false;
586
857
  }
587
858
  });
588
859
  }
589
860
 
590
- /** Write a tiny shim `<cmd>` next to the claudep on PATH: `exec claudep run <name> -- "$@"`. */
591
- function writeAlias(name: string, cmd: string): void {
861
+ export type AliasKind = "sh" | "cmd";
862
+
863
+ /** The shim text. Both say "generated by claudep" and ` run <name> -- ` so
864
+ * `claudep rm` can find them. */
865
+ export function aliasShim(kind: AliasKind, self: string, name: string): string {
866
+ if (kind === "cmd")
867
+ return `@echo off\r\nREM generated by claudep. Runs Claude Code under the "${name}" profile\r\nbun "${self}" run ${name} -- %*\r\n`;
868
+ return `#!/bin/sh\n# generated by claudep. Runs Claude Code under the "${name}" profile\nexec bun "${self}" run ${name} -- "$@"\n`;
869
+ }
870
+
871
+ /** Where the shims for `<cmd>` go. One sh file on POSIX. On Windows a .cmd
872
+ * file, which PowerShell finds through PATHEXT, plus the sh file for Git Bash. */
873
+ export function aliasFiles(
874
+ dir: string,
875
+ cmd: string,
876
+ platform: NodeJS.Platform = process.platform,
877
+ ): { path: string; kind: AliasKind }[] {
878
+ const P = pathApi(platform);
879
+ const sh = { path: P.join(dir, cmd), kind: "sh" as const };
880
+ if (platform !== "win32") return [sh];
881
+ return [{ path: P.join(dir, `${cmd}.cmd`), kind: "cmd" as const }, sh];
882
+ }
883
+
884
+ /** Write the shim(s) for `<cmd>` next to the claudep on PATH: `claudep run <name> -- "$@"`. */
885
+ function writeAlias(L: Layout, name: string, cmd: string): void {
592
886
  if (!NAME_RE.test(cmd)) die(`invalid alias command name "${cmd}"`);
593
- const dest = join(aliasDir(), cmd);
594
887
  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;
888
+ for (const { path: dest, kind } of aliasFiles(aliasDir(), cmd, L.platform)) {
889
+ if (existsSync(dest)) {
890
+ const current = Bun.file(dest);
891
+ if (!lstatSync(dest).isSymbolicLink() && current.size > 0) {
892
+ warn(`${dest} already exists. Not overwriting; remove it first to regenerate`);
893
+ continue;
894
+ }
600
895
  }
896
+ writeFileSync(dest, aliasShim(kind, self, name));
897
+ if (kind === "sh") chmodSync(dest, 0o755);
898
+ ok(`alias ${c.bold(cmd)} → profile ${name} (${dest})`);
601
899
  }
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`);
900
+ if (!onPath(aliasDir(), undefined, L.platform)) warn(`${aliasDir()} is not on your PATH`);
607
901
  }
608
902
 
609
903
  async function cmdAlias(L: Layout, args: string[]): Promise<void> {
610
904
  const [name, cmd] = args;
611
905
  if (!name || !cmd) die("usage: claudep alias <profile> <command> e.g. claudep alias enterprise eclaude");
612
906
  if (!profileExists(L, name)) die(`profile "${name}" does not exist. Run: claudep init ${name}`);
613
- writeAlias(name, cmd);
907
+ writeAlias(L, name, cmd);
614
908
  }
615
909
 
616
910
  async function cmdRun(L: Layout, args: string[]): Promise<never> {
@@ -635,7 +929,7 @@ async function collectRows(L: Layout, names: string[]): Promise<Row[]> {
635
929
  );
636
930
  }
637
931
 
638
- export function formatTable(rows: Row[], home: string): string[] {
932
+ export function formatTable(rows: Row[], home: string, platform: NodeJS.Platform = process.platform): string[] {
639
933
  const cols = ["PROFILE", "LOGIN", "EMAIL", "ORG", "PLAN", "DIR"];
640
934
  const data = rows.map((r) => [
641
935
  r.name,
@@ -643,15 +937,15 @@ export function formatTable(rows: Row[], home: string): string[] {
643
937
  r.status.email ?? "-",
644
938
  r.status.orgName ?? "-",
645
939
  r.status.subscriptionType ?? "-",
646
- r.dir.startsWith(home) ? `~${r.dir.slice(home.length)}` : r.dir,
940
+ shortHome(r.dir, home, platform),
647
941
  ]);
648
942
  const widths = cols.map((h, i) => Math.max(h.length, ...data.map((d) => (d[i] as string).length)));
649
943
  const fmt = (cells: string[]) => cells.map((v, i) => v.padEnd(widths[i] as number)).join(" ");
650
944
  return [fmt(cols), ...data.map((d) => fmt(d))];
651
945
  }
652
946
 
653
- function printTable(rows: Row[], home: string): void {
654
- const [header, ...lines] = formatTable(rows, home);
947
+ function printTable(rows: Row[], home: string, platform: NodeJS.Platform): void {
948
+ const [header, ...lines] = formatTable(rows, home, platform);
655
949
  console.log(c.bold(header as string));
656
950
  lines.forEach((line, i) => {
657
951
  console.log(rows[i]?.status.loggedIn ? line : c.dim(line));
@@ -660,7 +954,7 @@ function printTable(rows: Row[], home: string): void {
660
954
 
661
955
  async function cmdList(L: Layout): Promise<void> {
662
956
  const names = ["default", ...listProfileNames(L)];
663
- printTable(await collectRows(L, names), L.home);
957
+ printTable(await collectRows(L, names), L.home, L.platform);
664
958
  if (names.length === 1) console.log(c.dim("\nNo profiles yet. Create one: claudep init <name>"));
665
959
  const cur = currentProfile(L);
666
960
  if (cur.kind !== "base") console.log(c.dim(`\nactive in this shell: ${describeCurrent(cur)}`));
@@ -674,21 +968,25 @@ async function cmdStatus(L: Layout, args: string[]): Promise<void> {
674
968
  const [row] = await collectRows(L, [name]);
675
969
  if (!row) return;
676
970
  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);
971
+ else printTable([row], L.home, L.platform);
678
972
  }
679
973
 
680
974
  function cmdEnv(L: Layout, args: string[]): void {
681
975
  const name = args[0];
682
- if (!name) die('usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)"');
976
+ const syntax = shellSyntax(process.env, L.platform);
977
+ if (!name)
978
+ die(
979
+ syntax === "powershell"
980
+ ? "usage: claudep env <name> | Invoke-Expression or claudep env --unset | Invoke-Expression"
981
+ : 'usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)"',
982
+ );
683
983
  if (name === "--unset") {
684
- console.log("unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO");
984
+ process.stdout.write(envScript(undefined, syntax));
685
985
  return;
686
986
  }
687
987
  const dir = profileDir(L, name);
688
988
  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");
989
+ process.stdout.write(envScript(dir, syntax));
692
990
  }
693
991
 
694
992
  function describeCurrent(cur: Current): string {
@@ -697,10 +995,6 @@ function describeCurrent(cur: Current): string {
697
995
  return `${label} (${how})`;
698
996
  }
699
997
 
700
- function shortHome(p: string, home: string): string {
701
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
702
- }
703
-
704
998
  function cmdCurrent(L: Layout, args: string[]): void {
705
999
  const f = parseFlags(args, ["--json"], []);
706
1000
  const cur = currentProfile(L);
@@ -710,16 +1004,18 @@ function cmdCurrent(L: Layout, args: string[]): void {
710
1004
  return;
711
1005
  }
712
1006
  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))}`);
1007
+ console.log(`${c.bold(label)} ${c.dim(shortHome(cur.dir, L.home, L.platform))}`);
714
1008
  if (cur.setBy === "hook")
715
- console.log(`set by: shell hook${pin ? ` (${PIN_FILE} in ${shortHome(pin.dir, L.home)})` : ""}`);
1009
+ console.log(`set by: shell hook${pin ? ` (${PIN_FILE} in ${shortHome(pin.dir, L.home, L.platform)})` : ""}`);
716
1010
  else if (cur.setBy === "manual") console.log("set by: manual pin (claudep env or export CLAUDE_CONFIG_DIR)");
717
1011
  else console.log("set by: nothing pinned; this is ~/.claude");
718
1012
  if (cur.kind === "custom") console.log(c.dim("CLAUDE_CONFIG_DIR points outside the claudep profiles root"));
1013
+ const msysWarning = msysConfigDirWarning(L);
1014
+ if (msysWarning) console.log(c.yellow(msysWarning));
719
1015
  if (pin && pin.name !== "" && pin.name !== cur.name) {
720
1016
  console.log(
721
1017
  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)"`,
1018
+ `pinned here: ${pin.name} (${shortHome(pin.file, L.home, L.platform)}), but this shell is on ${label}. Load the hook: ${hookHint(defaultShell(process.env, L.platform))}`,
723
1019
  ),
724
1020
  );
725
1021
  }
@@ -749,28 +1045,129 @@ function cmdLocal(L: Layout, args: string[]): void {
749
1045
  if (!name) {
750
1046
  const pin = resolvePin(process.cwd());
751
1047
  if (!pin || pin.name === "") {
752
- console.log(`no ${PIN_FILE} pin from ${shortHome(process.cwd(), L.home)} upward`);
1048
+ console.log(`no ${PIN_FILE} pin from ${shortHome(process.cwd(), L.home, L.platform)} upward`);
753
1049
  process.exit(1);
754
1050
  }
755
- console.log(`${pin.name} ${c.dim(shortHome(pin.file, L.home))}`);
1051
+ console.log(`${pin.name} ${c.dim(shortHome(pin.file, L.home, L.platform))}`);
756
1052
  return;
757
1053
  }
758
1054
  if (!NAME_RE.test(name)) die(`invalid profile name "${name}"`);
759
1055
  if (!f.bools.has("--force") && !profileExists(L, name))
760
1056
  die(`profile "${name}" does not exist. Run: claudep init ${name} (or pass --force to pin it anyway)`);
761
1057
  writeFileSync(file, `${name}\n`);
762
- ok(`${shortHome(file, L.home)} pins this directory tree to ${c.bold(name)}`);
1058
+ ok(`${shortHome(file, L.home, L.platform)} pins this directory tree to ${c.bold(name)}`);
763
1059
  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)"`));
1060
+ console.log(c.dim(`Shells load pins through the hook: ${hookHint(defaultShell(process.env, L.platform))}`));
765
1061
  }
766
1062
 
767
1063
  /** The shell hook. Pure parameter expansion and builtins: it runs on every
768
1064
  * directory change (zsh chpwd) or prompt (bash PROMPT_COMMAND), so no
769
1065
  * subprocess is allowed here. Logic mirrors resolvePin(). */
770
- export function shellInit(shell: "zsh" | "bash", profilesRoot: string): string {
1066
+ export type Shell = "zsh" | "bash" | "powershell";
1067
+ export const SHELLS: readonly Shell[] = ["zsh", "bash", "powershell"];
1068
+
1069
+ /** The shell to name in hints and to default `shell-init` to. */
1070
+ export function defaultShell(env: Env = process.env, platform: NodeJS.Platform = process.platform): Shell {
1071
+ if (platform === "win32") return env.MSYSTEM ? "bash" : "powershell";
1072
+ const name = env.SHELL ? posix.basename(env.SHELL) : "";
1073
+ if (name === "zsh" || name === "bash") return name;
1074
+ return platform === "darwin" ? "zsh" : "bash";
1075
+ }
1076
+
1077
+ /** The one line that loads the hook in a shell's rc file. */
1078
+ export function hookHint(shell: Shell): string {
1079
+ if (shell === "powershell") return "claudep shell-init powershell | Out-String | Invoke-Expression";
1080
+ return `eval "$(claudep shell-init ${shell})"`;
1081
+ }
1082
+
1083
+ export type EnvSyntax = "sh" | "powershell";
1084
+
1085
+ /** What `claudep env` prints. PowerShell syntax only on Windows outside Git Bash. */
1086
+ export function shellSyntax(env: Env = process.env, platform: NodeJS.Platform = process.platform): EnvSyntax {
1087
+ return platform === "win32" && !env.MSYSTEM ? "powershell" : "sh";
1088
+ }
1089
+
1090
+ /** The `claudep env` script: pin the shell to `dir`, or clear the pin. */
1091
+ export function envScript(dir: string | undefined, syntax: EnvSyntax): string {
1092
+ if (syntax === "powershell") {
1093
+ if (dir === undefined) return "Remove-Item Env:CLAUDE_CONFIG_DIR, Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue\n";
1094
+ // Clearing CLAUDEP_AUTO turns this into a manual pin the shell hook will not touch.
1095
+ return `$env:CLAUDE_CONFIG_DIR = '${dir.replace(/'/g, "''")}'\nRemove-Item Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue\n`;
1096
+ }
1097
+ if (dir === undefined) return "unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO\n";
1098
+ return `export CLAUDE_CONFIG_DIR='${dir.replace(/'/g, `'\\''`)}'\nunset CLAUDEP_AUTO\n`;
1099
+ }
1100
+
1101
+ export function shellInit(shell: Shell, profilesRoot: string, platform: NodeJS.Platform = process.platform): string {
1102
+ return shell === "powershell" ? powershellHook(profilesRoot) : shHook(shell, profilesRoot, platform);
1103
+ }
1104
+
1105
+ /** The PowerShell hook, for Windows PowerShell 5.1 and PowerShell 7. Wraps
1106
+ * `prompt` because there is no chpwd and LocationChangedAction is 7 only.
1107
+ * Cmdlets and builtins only, ASCII only, and every value it manages lives
1108
+ * in $global: because the profile dot-sources what Invoke-Expression ran.
1109
+ * The parent step is .NET GetDirectoryName: Split-Path cannot combine
1110
+ * -LiteralPath with -Parent, and -Path would expand wildcards. */
1111
+ function powershellHook(profilesRoot: string): string {
1112
+ const q = profilesRoot.replace(/'/g, "''");
1113
+ return `# claudep shell hook. Load it from your $PROFILE: ${hookHint("powershell")}
1114
+ $global:_claudep_root = '${q}'
1115
+ function global:_claudep_auto {
1116
+ $here = $ExecutionContext.SessionState.Path.CurrentFileSystemLocation.ProviderPath
1117
+ if ($here -ceq $global:_claudep_last_pwd) { return }
1118
+ $global:_claudep_last_pwd = $here
1119
+ # Only manage a CLAUDE_CONFIG_DIR this hook set itself. A manual pin wins.
1120
+ if ($env:CLAUDE_CONFIG_DIR -and ($env:CLAUDE_CONFIG_DIR -cne $env:CLAUDEP_AUTO)) { return }
1121
+ $dir = $here
1122
+ $name = ''
1123
+ $found = ''
1124
+ while ($true) {
1125
+ $pin = Join-Path $dir '${PIN_FILE}'
1126
+ if (Test-Path -LiteralPath $pin -PathType Leaf) {
1127
+ $found = $dir
1128
+ foreach ($line in @(Get-Content -LiteralPath $pin)) {
1129
+ $t = ([string]$line).Trim()
1130
+ if ($t -eq '' -or $t.StartsWith('#')) { continue }
1131
+ $name = $t
1132
+ break
1133
+ }
1134
+ break
1135
+ }
1136
+ $parent = [System.IO.Path]::GetDirectoryName($dir)
1137
+ if (-not $parent -or ($parent -ceq $dir)) { break }
1138
+ $dir = $parent
1139
+ }
1140
+ if ($name -eq '') {
1141
+ # No pin here: hand the shell back to the base account.
1142
+ if ($env:CLAUDEP_AUTO) { Remove-Item Env:CLAUDE_CONFIG_DIR, Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue }
1143
+ return
1144
+ }
1145
+ $target = Join-Path $global:_claudep_root $name
1146
+ if (-not (Test-Path -LiteralPath $target -PathType Container)) {
1147
+ if ($env:CLAUDEP_AUTO) { Remove-Item Env:CLAUDE_CONFIG_DIR, Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue }
1148
+ [Console]::Error.WriteLine('claudep: ' + (Join-Path $found '${PIN_FILE}') + ' names profile "' + $name + '", which does not exist. Run: claudep init ' + $name)
1149
+ return
1150
+ }
1151
+ $env:CLAUDE_CONFIG_DIR = $target
1152
+ $env:CLAUDEP_AUTO = $target
1153
+ }
1154
+ if (-not $global:_claudep_prompt_orig) {
1155
+ $global:_claudep_prompt_orig = if (Test-Path Function:\\prompt) { $function:prompt } else { { 'PS> ' } }
1156
+ function global:prompt { _claudep_auto; & $global:_claudep_prompt_orig }
1157
+ }
1158
+ _claudep_auto
1159
+ `;
1160
+ }
1161
+
1162
+ function shHook(shell: "zsh" | "bash", profilesRoot: string, platform: NodeJS.Platform): string {
771
1163
  const q = profilesRoot.replace(/'/g, `'\\''`);
1164
+ // Under Git Bash $PWD is POSIX-style but the exported value must be the
1165
+ // native path claude.exe reads, so the root and its separator are embedded
1166
+ // as the native bun saw them and only the walk uses $PWD.
1167
+ const sep = platform === "win32" ? "\\" : "/";
772
1168
  const core = `# claudep shell hook. Load it from your rc file: eval "$(claudep shell-init ${shell})"
773
1169
  _claudep_root='${q}'
1170
+ _claudep_sep='${sep}'
774
1171
  _claudep_auto() {
775
1172
  [ "$PWD" = "\${_claudep_last_pwd:-}" ] && return 0
776
1173
  _claudep_last_pwd="$PWD"
@@ -783,6 +1180,7 @@ _claudep_auto() {
783
1180
  if [ -f "\${_claudep_dir%/}/${PIN_FILE}" ]; then
784
1181
  _claudep_found="\${_claudep_dir:-/}"
785
1182
  while read -r _claudep_line || [ -n "$_claudep_line" ]; do
1183
+ _claudep_line="\${_claudep_line%$'\\r'}"
786
1184
  case "$_claudep_line" in "" | "#"*) continue ;; esac
787
1185
  _claudep_name="$_claudep_line"
788
1186
  break
@@ -797,12 +1195,12 @@ _claudep_auto() {
797
1195
  [ -n "\${CLAUDEP_AUTO:-}" ] && unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO
798
1196
  return 0
799
1197
  fi
800
- if [ ! -d "$_claudep_root/$_claudep_name" ]; then
1198
+ if [ ! -d "$_claudep_root$_claudep_sep$_claudep_name" ]; then
801
1199
  [ -n "\${CLAUDEP_AUTO:-}" ] && unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO
802
1200
  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
1201
  return 0
804
1202
  fi
805
- export CLAUDE_CONFIG_DIR="$_claudep_root/$_claudep_name" CLAUDEP_AUTO="$_claudep_root/$_claudep_name"
1203
+ export CLAUDE_CONFIG_DIR="$_claudep_root$_claudep_sep$_claudep_name" CLAUDEP_AUTO="$_claudep_root$_claudep_sep$_claudep_name"
806
1204
  }
807
1205
  `;
808
1206
  const tail =
@@ -818,19 +1216,33 @@ _claudep_auto
818
1216
  }
819
1217
 
820
1218
  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));
1219
+ const shell = args[0] ?? defaultShell(process.env, L.platform);
1220
+ if (!SHELLS.includes(shell as Shell)) die(`unsupported shell "${shell}". Use zsh, bash or powershell`);
1221
+ process.stdout.write(shellInit(shell as Shell, L.profilesRoot, L.platform));
1222
+ }
1223
+
1224
+ /** A POSIX-style CLAUDE_CONFIG_DIR on Windows is one claude.exe cannot read. */
1225
+ function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undefined {
1226
+ const cfg = env.CLAUDE_CONFIG_DIR;
1227
+ if (L.platform !== "win32" || !cfg || !isMsysPath(cfg)) return undefined;
1228
+ return `CLAUDE_CONFIG_DIR is a POSIX-style path (${cfg}); claude.exe will not read it. Set it with: eval "$(claudep env <name>)"`;
824
1229
  }
825
1230
 
826
1231
  async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
827
1232
  const names = args[0] ? [args[0]] : listProfileNames(L);
828
- const bin = Bun.which("claude");
829
- if (bin) {
1233
+ const launch = findClaude();
1234
+ if (launch && launch.kind !== "ps1") {
830
1235
  const { out } = await captureClaude(L, undefined, ["--version"]);
831
- ok(`claude: ${bin} (${out.trim() || "version unknown"})`);
832
- } else bad("claude binary not found on PATH");
1236
+ ok(`claude: ${launch.bin} (${out.trim() || "version unknown"})`);
1237
+ if (launch.kind === "cmd")
1238
+ console.log(
1239
+ `${c.dim("·")} claude is the npm cmd shim and runs through cmd.exe; the native installer's claude.exe avoids that hop`,
1240
+ );
1241
+ } else if (launch) bad(`only ${launch.bin} found; claudep needs claude.exe or claude.cmd`);
1242
+ else bad("claude binary not found on PATH");
833
1243
  ok(`base: ${L.base}${L.callerConfigDir && !L.managed ? c.yellow(" (from CLAUDE_CONFIG_DIR in your shell)") : ""}`);
1244
+ const msysWarning = msysConfigDirWarning(L);
1245
+ if (msysWarning) warn(msysWarning);
834
1246
  ok(`profiles root: ${L.profilesRoot}`);
835
1247
 
836
1248
  const shared = sharedItems(L.base);
@@ -853,36 +1265,32 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
853
1265
  problems++;
854
1266
  continue;
855
1267
  }
1268
+ let missing = 0;
856
1269
  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) {
1270
+ const state = linkState(L.base, dir, item);
1271
+ if (state === "ok") continue;
1272
+ problems++;
1273
+ if (state === "missing") {
1274
+ missing++;
865
1275
  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
- }
1276
+ } else if (state === "shadowed") warn(`${item.name}: real ${item.kind} shadows the shared one`);
1277
+ else if (state === "wrong-target")
1278
+ bad(`${item.name}: symlink points elsewhere (${readlinkSync(join(dir, item.name))})`);
1279
+ else bad(`${item.name}: broken symlink`);
877
1280
  }
1281
+ if (missing && L.platform === "win32")
1282
+ console.log(
1283
+ `${c.dim("·")} Windows creates symlinks only with Developer Mode on; claudep init ${name} says so when it is off`,
1284
+ );
878
1285
  ok(`${shared.length} shared item(s) checked`);
879
1286
  const strays = readdirSync(dir).filter((n) => !sharedNames.has(n) && !KNOWN_PRIVATE.has(n) && !n.endsWith(".md"));
880
1287
  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)`);
1288
+ if (L.platform === "darwin") {
1289
+ const svc = keychainService(dir);
1290
+ if (await keychainHas(svc)) ok(`keychain item "${svc}" present`);
1291
+ else warn(`no keychain item "${svc}". Not logged in yet (claudep ${name} auth login)`);
1292
+ } else if (credentialsFileHas(dir)) ok(".credentials.json present");
1293
+ else warn(`no .credentials.json in the profile. Not logged in yet (claudep ${name} auth login)`);
886
1294
  const s = await authStatus(L, dir);
887
1295
  if (s.loggedIn) ok(`logged in as ${s.email ?? "?"} (${s.orgName ?? "?"}, ${s.subscriptionType ?? "?"})`);
888
1296
  else warn("not logged in");
@@ -901,7 +1309,11 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
901
1309
  const dir = profileDir(L, name);
902
1310
  if (!existsSync(dir)) die(`profile "${name}" does not exist`);
903
1311
  const real = realpathSync(dir);
904
- if (!real.startsWith(`${realpathSync(L.profilesRoot)}/`) || real === realpathSync(L.base) || real === L.home)
1312
+ if (
1313
+ !isInside(realpathSync(L.profilesRoot), real, L.platform) ||
1314
+ samePath(real, realpathSync(L.base), L.platform) ||
1315
+ samePath(real, L.home, L.platform)
1316
+ )
905
1317
  die(`refusing to remove ${real}: not inside ${L.profilesRoot}`);
906
1318
  if (!f.bools.has("--yes")) {
907
1319
  const yes = confirm(`Remove profile "${name}" (${dir})? Shared items are only unlinked; ${L.base} is untouched.`);
@@ -911,15 +1323,16 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
911
1323
  }
912
1324
  }
913
1325
  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)");
1326
+ const proc = spawnClaude(L, dir, ["auth", "logout"], "inherit");
1327
+ if ((await proc.exited) === 0) ok("logged out (token revoked, credentials removed)");
921
1328
  else warn("logout failed or was not logged in. Continuing");
922
1329
  }
1330
+ // Unlink the shared items first so no recursive delete ever looks through
1331
+ // a symlink into the base, whatever the platform's rm does with them.
1332
+ for (const entry of readdirSync(dir)) {
1333
+ const p = join(dir, entry);
1334
+ if (lstatSync(p).isSymbolicLink()) removeLink(p);
1335
+ }
923
1336
  rmSync(dir, { recursive: true, force: true });
924
1337
  ok(`removed ${dir}`);
925
1338
  const shims = aliasDir();
@@ -937,7 +1350,7 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
937
1350
  }
938
1351
 
939
1352
  function help(L: Layout): void {
940
- const root = L.profilesRoot.startsWith(L.home) ? `~${L.profilesRoot.slice(L.home.length)}` : L.profilesRoot;
1353
+ const root = shortHome(L.profilesRoot, L.home, L.platform);
941
1354
  console.log(`${c.bold("claudep")} ${c.dim(version())}: run Claude Code under separate accounts on one machine
942
1355
 
943
1356
  ${c.bold("USAGE")}
@@ -946,7 +1359,7 @@ ${c.bold("USAGE")}
946
1359
  claudep list show every profile and who it is logged in as
947
1360
  claudep status <name> [--json] login state for one profile ("default" = ~/.claude)
948
1361
  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
1362
+ claudep env <name> | --unset print the CLAUDE_CONFIG_DIR export (or the unset) for eval / Invoke-Expression
950
1363
  claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
951
1364
  claudep doctor [name] verify symlinks, keychain entry, unclassified files
952
1365
  claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
@@ -956,7 +1369,7 @@ ${c.bold("DIRECTORY PINS")}
956
1369
  claudep local <name> [--force] write ./${PIN_FILE} so this tree uses <name>; --remove deletes it
957
1370
  claudep local show the pin that applies to the current directory
958
1371
  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
1372
+ claudep shell-init [zsh|bash|powershell] print the hook that applies pins on cd; load it from your rc file
960
1373
 
961
1374
  ${c.bold("INIT OPTIONS")}
962
1375
  --sso force the SSO login flow (Enterprise orgs)
@@ -973,8 +1386,10 @@ ${c.bold("EXAMPLES")}
973
1386
  claude # Claude Code as whatever ~/.claude is logged in as
974
1387
  claudep enterprise -p "summarize this repo"
975
1388
  eval "$(claudep env enterprise)" # pin the whole shell to a profile
1389
+ claudep env enterprise | Invoke-Expression # the same from PowerShell
976
1390
  claudep local enterprise # pin this repo; commit the ${PIN_FILE} file for the team
977
1391
  eval "$(claudep shell-init zsh)" # in .zshrc: shells follow ${PIN_FILE} pins on cd
1392
+ ${hookHint("powershell")} # the same line for $PROFILE
978
1393
 
979
1394
  ${c.bold("HOW IT WORKS")}
980
1395
  ~/.claude stays exactly as it is and remains the "default" profile. Each named profile is a
@@ -983,9 +1398,10 @@ ${c.bold("HOW IT WORKS")}
983
1398
  ${[...SHARED_FILES, "*.md", ...SHARED_DIRS].join(" ")}
984
1399
  Everything account-specific is real and per profile: .claude.json (login identity, MCP
985
1400
  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
1401
+ On macOS credentials never touch the profile dir: Claude Code stores them in the Keychain under
987
1402
  "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.
1403
+ login and refresh token and they cannot clobber each other. On Linux and Windows the login is
1404
+ <profile>/.credentials.json, a real file inside the profile that is never shared.
989
1405
 
990
1406
  ${c.bold("PIN RULES")}
991
1407
  The nearest ${PIN_FILE} file upward from the current directory wins; an empty one cancels a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bordoni/claudep",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Unofficial, not affiliated with Anthropic. Run Claude Code under separate accounts on one machine (work + personal) with isolated logins and shared config.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "access": "public"
39
39
  },
40
40
  "scripts": {
41
- "test": "bun test --timeout 20000",
41
+ "test": "bun test --timeout 30000",
42
42
  "test:watch": "bun test --watch",
43
43
  "test:coverage": "bun test --coverage",
44
44
  "typecheck": "tsc --noEmit",