@bordoni/claudep 0.1.1 → 0.2.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 CHANGED
@@ -6,6 +6,30 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-09-07
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+ - `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.
15
+ - `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.
16
+ - `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.
17
+ - `claudep alias` on Windows writes `<command>.cmd`, which PowerShell finds through `PATHEXT`, next to the sh shim Git Bash runs.
18
+ - 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.
19
+ - `claudep current` and `claudep doctor` warn on Windows when `CLAUDE_CONFIG_DIR` is a POSIX-style path that `claude.exe` cannot read.
20
+ - 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.
21
+
22
+ ### Changed
23
+
24
+ - `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.
25
+ - 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.
26
+ - `claudep rm` unlinks the shared items itself before deleting the profile directory.
27
+ - The bash hook under Git Bash exports the native `C:\` path for the pinned profile while still walking the POSIX `$PWD`.
28
+
29
+ ### Fixed
30
+
31
+ - The shell hook reads `.claudep` pin files with CRLF line endings. The `\r` used to become part of the profile name.
32
+
9
33
  ## [0.1.1] - 2026-09-03
10
34
 
11
35
  ### Fixed
@@ -31,6 +55,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
31
55
  - Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
32
56
  - 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
57
 
34
- [Unreleased]: https://github.com/bordoni/claudep/compare/0.1.1...HEAD
58
+ [Unreleased]: https://github.com/bordoni/claudep/compare/0.2.0...HEAD
59
+ [0.2.0]: https://github.com/bordoni/claudep/compare/0.1.1...0.2.0
35
60
  [0.1.1]: https://github.com/bordoni/claudep/compare/0.1.0...0.1.1
36
61
  [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.
@@ -88,7 +109,7 @@ From then on, `cd` into a pinned tree sets `CLAUDE_CONFIG_DIR` for that profile
88
109
  | `hooks/`, `skills/`, `commands/`, `agents/`, `plugins/`, `plans/` | `history.jsonl`, `todos/`, `sessions/`, caches, telemetry |
89
110
  | `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
 
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
  }
@@ -196,6 +273,8 @@ export const KNOWN_PRIVATE = new Set<string>([
196
273
  "local",
197
274
  "settings.local.json",
198
275
  ".DS_Store",
276
+ "Thumbs.db",
277
+ "desktop.ini",
199
278
  ".config.json",
200
279
  ".last-cleanup",
201
280
  ".last-update-result.json",
@@ -266,11 +345,11 @@ export function die(msg: string, code = 1): never {
266
345
  export function profileDir(L: Layout, name: string): string {
267
346
  if (!NAME_RE.test(name)) die(`invalid profile name "${name}" (use [a-z0-9_-], starting with a letter or digit)`);
268
347
  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);
348
+ return canon(pathApi(L.platform).join(L.profilesRoot, name), L.home, L.platform);
270
349
  }
271
350
 
272
351
  export function profileExists(L: Layout, name: string): boolean {
273
- return NAME_RE.test(name) && !RESERVED.has(name) && existsSync(join(L.profilesRoot, name));
352
+ return NAME_RE.test(name) && !RESERVED.has(name) && existsSync(pathApi(L.platform).join(L.profilesRoot, name));
274
353
  }
275
354
 
276
355
  export function listProfileNames(L: Layout): string[] {
@@ -309,34 +388,107 @@ export function sharedItems(base: string): SharedItem[] {
309
388
  return items;
310
389
  }
311
390
 
312
- export type LinkResult = "linked" | "ok" | "wrong-target" | "conflict";
391
+ /** The two file-system calls link() makes, injectable so the tests can record
392
+ * the symlink type and simulate Windows refusing to create one. */
393
+ export type LinkDeps = {
394
+ platform: NodeJS.Platform;
395
+ symlink: (target: string, dest: string, type: SharedItem["kind"]) => void;
396
+ readlink: (p: string) => string;
397
+ };
398
+
399
+ export const defaultLinkDeps = (): LinkDeps => ({
400
+ platform: process.platform,
401
+ symlink: (target, dest, type) => symlinkSync(target, dest, type),
402
+ readlink: (p) => readlinkSync(p),
403
+ });
404
+
405
+ export type LinkResult = "linked" | "ok" | "wrong-target" | "conflict" | "denied";
406
+
407
+ /** Remove a symlink. On Windows a directory symlink is removed with rmdir. */
408
+ function removeLink(p: string): void {
409
+ try {
410
+ unlinkSync(p);
411
+ } catch {
412
+ rmdirSync(p);
413
+ }
414
+ }
415
+
416
+ /** false when the OS refused with EPERM, which on Windows means Developer
417
+ * Mode is off. Anything else propagates. */
418
+ function trySymlink(deps: LinkDeps, target: string, dest: string, type: SharedItem["kind"]): boolean {
419
+ try {
420
+ deps.symlink(target, dest, type);
421
+ return true;
422
+ } catch (e) {
423
+ if ((e as { code?: string }).code === "EPERM") return false;
424
+ throw e;
425
+ }
426
+ }
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
+ /** Idempotent symlink profile/<name> -> base/<name>. Never overwrites real
429
+ * files. The type is always passed: POSIX ignores it, Windows needs it. */
430
+ export function link(
431
+ base: string,
432
+ dir: string,
433
+ item: SharedItem,
434
+ force: boolean,
435
+ deps: LinkDeps = defaultLinkDeps(),
436
+ ): LinkResult {
437
+ const target = join(base, item.name);
438
+ const dest = join(dir, item.name);
318
439
  let st: ReturnType<typeof lstatSync> | undefined;
319
440
  try {
320
441
  st = lstatSync(dest);
321
442
  } catch {
322
443
  st = undefined;
323
444
  }
324
- if (!st) {
325
- symlinkSync(target, dest);
326
- return "linked";
327
- }
445
+ if (!st) return trySymlink(deps, target, dest, item.kind) ? "linked" : "denied";
328
446
  if (st.isSymbolicLink()) {
329
- if (readlinkSync(dest) === target) return "ok";
447
+ if (samePath(deps.readlink(dest), target, deps.platform)) return "ok";
330
448
  if (force) {
331
- rmSync(dest);
332
- symlinkSync(target, dest);
333
- return "linked";
449
+ removeLink(dest);
450
+ return trySymlink(deps, target, dest, item.kind) ? "linked" : "denied";
334
451
  }
335
452
  return "wrong-target";
336
453
  }
337
454
  return "conflict";
338
455
  }
339
456
 
457
+ export type LinkState = "ok" | "missing" | "shadowed" | "wrong-target" | "broken";
458
+
459
+ /** What doctor reports for one shared item. Read-only twin of link(). */
460
+ export function linkState(
461
+ base: string,
462
+ dir: string,
463
+ item: SharedItem,
464
+ deps: Pick<LinkDeps, "platform" | "readlink"> = defaultLinkDeps(),
465
+ ): LinkState {
466
+ const dest = join(dir, item.name);
467
+ let st: ReturnType<typeof lstatSync> | undefined;
468
+ try {
469
+ st = lstatSync(dest);
470
+ } catch {
471
+ st = undefined;
472
+ }
473
+ if (!st) return "missing";
474
+ if (!st.isSymbolicLink()) return "shadowed";
475
+ if (!samePath(deps.readlink(dest), join(base, item.name), deps.platform)) return "wrong-target";
476
+ return existsSync(dest) ? "ok" : "broken";
477
+ }
478
+
479
+ /** What to tell the user when the OS refused to create a symlink. */
480
+ export function symlinkDeniedHint(platform: NodeJS.Platform, name: string): string {
481
+ if (platform === "win32")
482
+ 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}`;
483
+ return `the OS refused to create a symlink (EPERM). Check the permissions on the profiles root, then run: claudep init ${name}`;
484
+ }
485
+
486
+ /** True when the profile has Claude Code's file credential store. Existence
487
+ * only; the file is never read. This is the login check everywhere but macOS. */
488
+ export function credentialsFileHas(dir: string, exists: (p: string) => boolean = existsSync): boolean {
489
+ return exists(join(dir, ".credentials.json"));
490
+ }
491
+
340
492
  export type JsonObject = Record<string, unknown>;
341
493
 
342
494
  export async function readJson(path: string): Promise<JsonObject | undefined> {
@@ -366,17 +518,114 @@ export async function seedGlobalJson(baseGlobalJson: string, dir: string, copyMc
366
518
  // Running claude
367
519
  // ---------------------------------------------------------------------------
368
520
 
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;
521
+ /** How `claude` is installed. A native binary or npm's Windows shims. */
522
+ export type ClaudeLaunch = { bin: string; kind: "exe" | "cmd" | "ps1" };
523
+
524
+ export type FindDeps = {
525
+ platform: NodeJS.Platform;
526
+ pathVar: string;
527
+ exists: (p: string) => boolean;
528
+ which: (name: string) => string | null;
529
+ };
530
+
531
+ export const defaultFindDeps = (): FindDeps => ({
532
+ platform: process.platform,
533
+ pathVar: process.env.PATH ?? "",
534
+ exists: existsSync,
535
+ which: (name) => Bun.which(name),
536
+ });
537
+
538
+ /** Locate claude. POSIX asks which(). Windows walks PATH one directory at a
539
+ * time so the first directory wins even when a later one has claude.exe,
540
+ * and prefers claude.exe over the npm shims inside a directory. */
541
+ export function findClaude(deps: FindDeps = defaultFindDeps()): ClaudeLaunch | undefined {
542
+ if (deps.platform !== "win32") {
543
+ const bin = deps.which("claude");
544
+ return bin ? { bin, kind: "exe" } : undefined;
545
+ }
546
+ const candidates: [string, ClaudeLaunch["kind"]][] = [
547
+ ["claude.exe", "exe"],
548
+ ["claude.cmd", "cmd"],
549
+ ["claude.bat", "cmd"],
550
+ ["claude.ps1", "ps1"],
551
+ ];
552
+ for (const dir of splitPathVar(deps.pathVar, "win32")) {
553
+ for (const [file, kind] of candidates) {
554
+ const bin = win32.join(dir, file);
555
+ if (deps.exists(bin)) return { bin, kind };
556
+ }
557
+ }
558
+ return undefined;
559
+ }
560
+
561
+ const CMD_META = /([()\][%!^"`<>&|;, *?])/g;
562
+
563
+ /** The command word for `cmd.exe /d /s /c "..."`, the way cross-spawn does
564
+ * it: caret-escape cmd's metacharacters, spaces included, and no quotes. */
565
+ export function cmdExeCommand(cmd: string): string {
566
+ return cmd.replace(CMD_META, "^$1");
567
+ }
568
+
569
+ /** One argument for the same line: double backslashes before quotes, escape
570
+ * quotes, wrap in quotes, then caret-escape every metacharacter. Twice when
571
+ * the target is a .cmd shim, because its own cmd.exe parses `%*` again. */
572
+ export function cmdExeQuote(arg: string, doubleEscape = true): string {
573
+ let out = arg.replace(/(?=(\\+?)?)"/g, '$1$1\\"');
574
+ out = out.replace(/(\\+)$/, "$1$1");
575
+ out = `"${out}"`.replace(CMD_META, "^$1");
576
+ if (doubleEscape) out = out.replace(CMD_META, "^$1");
577
+ return out;
578
+ }
579
+
580
+ /** argv and spawn option for a launch. A .cmd shim cannot be executed
581
+ * directly; it runs through cmd.exe with one pre-quoted command line. */
582
+ export function claudeSpawn(launch: ClaudeLaunch, args: string[]): { cmd: string[]; verbatim: boolean } {
583
+ if (launch.kind !== "cmd") return { cmd: [launch.bin, ...args], verbatim: false };
584
+ const line = [cmdExeCommand(launch.bin), ...args.map((a) => cmdExeQuote(a))].join(" ");
585
+ return { cmd: ["cmd.exe", "/d", "/s", "/c", `"${line}"`], verbatim: true };
586
+ }
587
+
588
+ /** Signals the wrapper ignores (so it survives to report the child's exit
589
+ * code; the terminal delivers Ctrl+C to the child itself) and forwards.
590
+ * Windows has no SIGHUP. */
591
+ export function wrapperSignals(platform: NodeJS.Platform): { ignore: NodeJS.Signals[]; forward: NodeJS.Signals[] } {
592
+ if (platform === "win32") return { ignore: ["SIGINT"], forward: ["SIGTERM"] };
593
+ return { ignore: ["SIGINT"], forward: ["SIGTERM", "SIGHUP"] };
594
+ }
595
+
596
+ function claudeLaunch(): ClaudeLaunch {
597
+ const launch = findClaude();
598
+ if (!launch) die("`claude` not found on PATH. Install Claude Code first: https://code.claude.com/docs/en/setup");
599
+ if (launch.kind === "ps1")
600
+ die(
601
+ `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`,
602
+ );
603
+ return launch;
604
+ }
605
+
606
+ function spawnClaude(L: Layout, dir: string | undefined, args: string[], io: "inherit" | "pipe") {
607
+ const { cmd, verbatim } = claudeSpawn(claudeLaunch(), args);
608
+ return Bun.spawn(cmd, {
609
+ env: claudeEnv(L, dir),
610
+ stdin: io === "inherit" ? "inherit" : "ignore",
611
+ stdout: io,
612
+ stderr: io,
613
+ windowsVerbatimArguments: verbatim,
614
+ });
373
615
  }
374
616
 
375
617
  /** Environment for a profile. `undefined` dir means "the base", i.e. leave the
376
618
  * caller's CLAUDE_CONFIG_DIR exactly as it is (set or unset). */
377
- export function envFor(dir: string | undefined, env: Env = process.env): Env {
619
+ export function envFor(
620
+ dir: string | undefined,
621
+ env: Env = process.env,
622
+ platform: NodeJS.Platform = process.platform,
623
+ ): Env {
378
624
  const out: Env = { ...env };
379
- if (dir !== undefined) out.CLAUDE_CONFIG_DIR = dir;
625
+ if (dir !== undefined) {
626
+ deleteEnv(out, "CLAUDE_CONFIG_DIR", platform);
627
+ out.CLAUDE_CONFIG_DIR = dir;
628
+ }
380
629
  return out;
381
630
  }
382
631
 
@@ -386,28 +635,23 @@ export function envFor(dir: string | undefined, env: Env = process.env): Env {
386
635
  export function baseEnv(L: Layout, env: Env = process.env): Env {
387
636
  const out: Env = { ...env };
388
637
  if (L.managed) {
389
- delete out.CLAUDE_CONFIG_DIR;
390
- delete out.CLAUDEP_AUTO;
638
+ deleteEnv(out, "CLAUDE_CONFIG_DIR", L.platform);
639
+ deleteEnv(out, "CLAUDEP_AUTO", L.platform);
391
640
  }
392
641
  return out;
393
642
  }
394
643
 
395
644
  function claudeEnv(L: Layout, dir: string | undefined): Env {
396
- return dir === undefined ? baseEnv(L) : envFor(dir);
645
+ return dir === undefined ? baseEnv(L) : envFor(dir, process.env, L.platform);
397
646
  }
398
647
 
399
648
  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
- });
649
+ const proc = spawnClaude(L, dir, args, "inherit");
406
650
  // Ctrl+C reaches the child through the terminal; keep the wrapper alive so
407
651
  // 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"));
652
+ const signals = wrapperSignals(L.platform);
653
+ for (const s of signals.ignore) process.on(s, () => {});
654
+ for (const s of signals.forward) process.on(s, () => proc.kill(s));
411
655
  await proc.exited;
412
656
  process.exit(proc.exitCode ?? 1);
413
657
  }
@@ -417,12 +661,7 @@ async function captureClaude(
417
661
  dir: string | undefined,
418
662
  args: string[],
419
663
  ): 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
- });
664
+ const proc = spawnClaude(L, dir, args, "pipe");
426
665
  const out = await new Response(proc.stdout).text();
427
666
  const code = await proc.exited;
428
667
  return { code, out };
@@ -528,9 +767,10 @@ async function cmdInit(L: Layout, args: string[]): Promise<void> {
528
767
 
529
768
  let conflicts = 0;
530
769
  for (const item of sharedItems(L.base)) {
531
- const r = link(L.base, dir, item.name, f.bools.has("--force"));
770
+ const r = link(L.base, dir, item, f.bools.has("--force"));
532
771
  if (r === "linked") ok(`${item.name} → shared`);
533
772
  else if (r === "ok") console.log(`${c.dim("·")} ${item.name} ${c.dim("already shared")}`);
773
+ else if (r === "denied") die(symlinkDeniedHint(L.platform, name));
534
774
  else if (r === "wrong-target") {
535
775
  warn(`${item.name} is a symlink to somewhere else (re-run with --force to relink)`);
536
776
  conflicts++;
@@ -547,7 +787,7 @@ async function cmdInit(L: Layout, args: string[]): Promise<void> {
547
787
  else warn(`could not read ${L.baseGlobalJson}; Claude Code will run its first-time onboarding`);
548
788
 
549
789
  const alias = f.strs.get("--alias");
550
- if (alias !== undefined) writeAlias(name, alias);
790
+ if (alias !== undefined) writeAlias(L, name, alias);
551
791
 
552
792
  if (conflicts) warn(`${conflicts} item(s) need attention, see above`);
553
793
 
@@ -575,42 +815,75 @@ export function aliasDir(): string {
575
815
  return onPathBin ? dirname(onPathBin) : scriptDir();
576
816
  }
577
817
 
818
+ /** PATH entries. `:` on POSIX, `;` on Windows, where `:` would split every
819
+ * entry at its drive letter. */
820
+ export function splitPathVar(pathVar: string, platform: NodeJS.Platform = process.platform): string[] {
821
+ return pathVar.split(pathApi(platform).delimiter).filter((p) => p !== "");
822
+ }
823
+
578
824
  /** True when some PATH entry resolves (through symlinks) to `dir`. */
579
- export function onPath(dir: string, pathVar: string = process.env.PATH ?? ""): boolean {
825
+ export function onPath(
826
+ dir: string,
827
+ pathVar: string = process.env.PATH ?? "",
828
+ platform: NodeJS.Platform = process.platform,
829
+ ): boolean {
580
830
  const want = realpathSync(dir);
581
- return pathVar.split(":").some((p) => {
831
+ return splitPathVar(pathVar, platform).some((p) => {
582
832
  try {
583
- return realpathSync(canon(p)) === want;
833
+ return samePath(realpathSync(canon(p, undefined, platform)), want, platform);
584
834
  } catch {
585
835
  return false;
586
836
  }
587
837
  });
588
838
  }
589
839
 
590
- /** Write a tiny shim `<cmd>` next to the claudep on PATH: `exec claudep run <name> -- "$@"`. */
591
- function writeAlias(name: string, cmd: string): void {
840
+ export type AliasKind = "sh" | "cmd";
841
+
842
+ /** The shim text. Both say "generated by claudep" and ` run <name> -- ` so
843
+ * `claudep rm` can find them. */
844
+ export function aliasShim(kind: AliasKind, self: string, name: string): string {
845
+ if (kind === "cmd")
846
+ return `@echo off\r\nREM generated by claudep. Runs Claude Code under the "${name}" profile\r\nbun "${self}" run ${name} -- %*\r\n`;
847
+ return `#!/bin/sh\n# generated by claudep. Runs Claude Code under the "${name}" profile\nexec bun "${self}" run ${name} -- "$@"\n`;
848
+ }
849
+
850
+ /** Where the shims for `<cmd>` go. One sh file on POSIX. On Windows a .cmd
851
+ * file, which PowerShell finds through PATHEXT, plus the sh file for Git Bash. */
852
+ export function aliasFiles(
853
+ dir: string,
854
+ cmd: string,
855
+ platform: NodeJS.Platform = process.platform,
856
+ ): { path: string; kind: AliasKind }[] {
857
+ const P = pathApi(platform);
858
+ const sh = { path: P.join(dir, cmd), kind: "sh" as const };
859
+ if (platform !== "win32") return [sh];
860
+ return [{ path: P.join(dir, `${cmd}.cmd`), kind: "cmd" as const }, sh];
861
+ }
862
+
863
+ /** Write the shim(s) for `<cmd>` next to the claudep on PATH: `claudep run <name> -- "$@"`. */
864
+ function writeAlias(L: Layout, name: string, cmd: string): void {
592
865
  if (!NAME_RE.test(cmd)) die(`invalid alias command name "${cmd}"`);
593
- const dest = join(aliasDir(), cmd);
594
866
  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;
867
+ for (const { path: dest, kind } of aliasFiles(aliasDir(), cmd, L.platform)) {
868
+ if (existsSync(dest)) {
869
+ const current = Bun.file(dest);
870
+ if (!lstatSync(dest).isSymbolicLink() && current.size > 0) {
871
+ warn(`${dest} already exists. Not overwriting; remove it first to regenerate`);
872
+ continue;
873
+ }
600
874
  }
875
+ writeFileSync(dest, aliasShim(kind, self, name));
876
+ if (kind === "sh") chmodSync(dest, 0o755);
877
+ ok(`alias ${c.bold(cmd)} → profile ${name} (${dest})`);
601
878
  }
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`);
879
+ if (!onPath(aliasDir(), undefined, L.platform)) warn(`${aliasDir()} is not on your PATH`);
607
880
  }
608
881
 
609
882
  async function cmdAlias(L: Layout, args: string[]): Promise<void> {
610
883
  const [name, cmd] = args;
611
884
  if (!name || !cmd) die("usage: claudep alias <profile> <command> e.g. claudep alias enterprise eclaude");
612
885
  if (!profileExists(L, name)) die(`profile "${name}" does not exist. Run: claudep init ${name}`);
613
- writeAlias(name, cmd);
886
+ writeAlias(L, name, cmd);
614
887
  }
615
888
 
616
889
  async function cmdRun(L: Layout, args: string[]): Promise<never> {
@@ -635,7 +908,7 @@ async function collectRows(L: Layout, names: string[]): Promise<Row[]> {
635
908
  );
636
909
  }
637
910
 
638
- export function formatTable(rows: Row[], home: string): string[] {
911
+ export function formatTable(rows: Row[], home: string, platform: NodeJS.Platform = process.platform): string[] {
639
912
  const cols = ["PROFILE", "LOGIN", "EMAIL", "ORG", "PLAN", "DIR"];
640
913
  const data = rows.map((r) => [
641
914
  r.name,
@@ -643,15 +916,15 @@ export function formatTable(rows: Row[], home: string): string[] {
643
916
  r.status.email ?? "-",
644
917
  r.status.orgName ?? "-",
645
918
  r.status.subscriptionType ?? "-",
646
- r.dir.startsWith(home) ? `~${r.dir.slice(home.length)}` : r.dir,
919
+ shortHome(r.dir, home, platform),
647
920
  ]);
648
921
  const widths = cols.map((h, i) => Math.max(h.length, ...data.map((d) => (d[i] as string).length)));
649
922
  const fmt = (cells: string[]) => cells.map((v, i) => v.padEnd(widths[i] as number)).join(" ");
650
923
  return [fmt(cols), ...data.map((d) => fmt(d))];
651
924
  }
652
925
 
653
- function printTable(rows: Row[], home: string): void {
654
- const [header, ...lines] = formatTable(rows, home);
926
+ function printTable(rows: Row[], home: string, platform: NodeJS.Platform): void {
927
+ const [header, ...lines] = formatTable(rows, home, platform);
655
928
  console.log(c.bold(header as string));
656
929
  lines.forEach((line, i) => {
657
930
  console.log(rows[i]?.status.loggedIn ? line : c.dim(line));
@@ -660,7 +933,7 @@ function printTable(rows: Row[], home: string): void {
660
933
 
661
934
  async function cmdList(L: Layout): Promise<void> {
662
935
  const names = ["default", ...listProfileNames(L)];
663
- printTable(await collectRows(L, names), L.home);
936
+ printTable(await collectRows(L, names), L.home, L.platform);
664
937
  if (names.length === 1) console.log(c.dim("\nNo profiles yet. Create one: claudep init <name>"));
665
938
  const cur = currentProfile(L);
666
939
  if (cur.kind !== "base") console.log(c.dim(`\nactive in this shell: ${describeCurrent(cur)}`));
@@ -674,21 +947,25 @@ async function cmdStatus(L: Layout, args: string[]): Promise<void> {
674
947
  const [row] = await collectRows(L, [name]);
675
948
  if (!row) return;
676
949
  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);
950
+ else printTable([row], L.home, L.platform);
678
951
  }
679
952
 
680
953
  function cmdEnv(L: Layout, args: string[]): void {
681
954
  const name = args[0];
682
- if (!name) die('usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)"');
955
+ const syntax = shellSyntax(process.env, L.platform);
956
+ if (!name)
957
+ die(
958
+ syntax === "powershell"
959
+ ? "usage: claudep env <name> | Invoke-Expression or claudep env --unset | Invoke-Expression"
960
+ : 'usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)"',
961
+ );
683
962
  if (name === "--unset") {
684
- console.log("unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO");
963
+ process.stdout.write(envScript(undefined, syntax));
685
964
  return;
686
965
  }
687
966
  const dir = profileDir(L, name);
688
967
  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");
968
+ process.stdout.write(envScript(dir, syntax));
692
969
  }
693
970
 
694
971
  function describeCurrent(cur: Current): string {
@@ -697,10 +974,6 @@ function describeCurrent(cur: Current): string {
697
974
  return `${label} (${how})`;
698
975
  }
699
976
 
700
- function shortHome(p: string, home: string): string {
701
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
702
- }
703
-
704
977
  function cmdCurrent(L: Layout, args: string[]): void {
705
978
  const f = parseFlags(args, ["--json"], []);
706
979
  const cur = currentProfile(L);
@@ -710,16 +983,18 @@ function cmdCurrent(L: Layout, args: string[]): void {
710
983
  return;
711
984
  }
712
985
  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))}`);
986
+ console.log(`${c.bold(label)} ${c.dim(shortHome(cur.dir, L.home, L.platform))}`);
714
987
  if (cur.setBy === "hook")
715
- console.log(`set by: shell hook${pin ? ` (${PIN_FILE} in ${shortHome(pin.dir, L.home)})` : ""}`);
988
+ console.log(`set by: shell hook${pin ? ` (${PIN_FILE} in ${shortHome(pin.dir, L.home, L.platform)})` : ""}`);
716
989
  else if (cur.setBy === "manual") console.log("set by: manual pin (claudep env or export CLAUDE_CONFIG_DIR)");
717
990
  else console.log("set by: nothing pinned; this is ~/.claude");
718
991
  if (cur.kind === "custom") console.log(c.dim("CLAUDE_CONFIG_DIR points outside the claudep profiles root"));
992
+ const msysWarning = msysConfigDirWarning(L);
993
+ if (msysWarning) console.log(c.yellow(msysWarning));
719
994
  if (pin && pin.name !== "" && pin.name !== cur.name) {
720
995
  console.log(
721
996
  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)"`,
997
+ `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
998
  ),
724
999
  );
725
1000
  }
@@ -749,28 +1024,129 @@ function cmdLocal(L: Layout, args: string[]): void {
749
1024
  if (!name) {
750
1025
  const pin = resolvePin(process.cwd());
751
1026
  if (!pin || pin.name === "") {
752
- console.log(`no ${PIN_FILE} pin from ${shortHome(process.cwd(), L.home)} upward`);
1027
+ console.log(`no ${PIN_FILE} pin from ${shortHome(process.cwd(), L.home, L.platform)} upward`);
753
1028
  process.exit(1);
754
1029
  }
755
- console.log(`${pin.name} ${c.dim(shortHome(pin.file, L.home))}`);
1030
+ console.log(`${pin.name} ${c.dim(shortHome(pin.file, L.home, L.platform))}`);
756
1031
  return;
757
1032
  }
758
1033
  if (!NAME_RE.test(name)) die(`invalid profile name "${name}"`);
759
1034
  if (!f.bools.has("--force") && !profileExists(L, name))
760
1035
  die(`profile "${name}" does not exist. Run: claudep init ${name} (or pass --force to pin it anyway)`);
761
1036
  writeFileSync(file, `${name}\n`);
762
- ok(`${shortHome(file, L.home)} pins this directory tree to ${c.bold(name)}`);
1037
+ ok(`${shortHome(file, L.home, L.platform)} pins this directory tree to ${c.bold(name)}`);
763
1038
  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)"`));
1039
+ console.log(c.dim(`Shells load pins through the hook: ${hookHint(defaultShell(process.env, L.platform))}`));
765
1040
  }
766
1041
 
767
1042
  /** The shell hook. Pure parameter expansion and builtins: it runs on every
768
1043
  * directory change (zsh chpwd) or prompt (bash PROMPT_COMMAND), so no
769
1044
  * subprocess is allowed here. Logic mirrors resolvePin(). */
770
- export function shellInit(shell: "zsh" | "bash", profilesRoot: string): string {
1045
+ export type Shell = "zsh" | "bash" | "powershell";
1046
+ export const SHELLS: readonly Shell[] = ["zsh", "bash", "powershell"];
1047
+
1048
+ /** The shell to name in hints and to default `shell-init` to. */
1049
+ export function defaultShell(env: Env = process.env, platform: NodeJS.Platform = process.platform): Shell {
1050
+ if (platform === "win32") return env.MSYSTEM ? "bash" : "powershell";
1051
+ const name = env.SHELL ? posix.basename(env.SHELL) : "";
1052
+ if (name === "zsh" || name === "bash") return name;
1053
+ return platform === "darwin" ? "zsh" : "bash";
1054
+ }
1055
+
1056
+ /** The one line that loads the hook in a shell's rc file. */
1057
+ export function hookHint(shell: Shell): string {
1058
+ if (shell === "powershell") return "claudep shell-init powershell | Out-String | Invoke-Expression";
1059
+ return `eval "$(claudep shell-init ${shell})"`;
1060
+ }
1061
+
1062
+ export type EnvSyntax = "sh" | "powershell";
1063
+
1064
+ /** What `claudep env` prints. PowerShell syntax only on Windows outside Git Bash. */
1065
+ export function shellSyntax(env: Env = process.env, platform: NodeJS.Platform = process.platform): EnvSyntax {
1066
+ return platform === "win32" && !env.MSYSTEM ? "powershell" : "sh";
1067
+ }
1068
+
1069
+ /** The `claudep env` script: pin the shell to `dir`, or clear the pin. */
1070
+ export function envScript(dir: string | undefined, syntax: EnvSyntax): string {
1071
+ if (syntax === "powershell") {
1072
+ if (dir === undefined) return "Remove-Item Env:CLAUDE_CONFIG_DIR, Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue\n";
1073
+ // Clearing CLAUDEP_AUTO turns this into a manual pin the shell hook will not touch.
1074
+ return `$env:CLAUDE_CONFIG_DIR = '${dir.replace(/'/g, "''")}'\nRemove-Item Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue\n`;
1075
+ }
1076
+ if (dir === undefined) return "unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO\n";
1077
+ return `export CLAUDE_CONFIG_DIR='${dir.replace(/'/g, `'\\''`)}'\nunset CLAUDEP_AUTO\n`;
1078
+ }
1079
+
1080
+ export function shellInit(shell: Shell, profilesRoot: string, platform: NodeJS.Platform = process.platform): string {
1081
+ return shell === "powershell" ? powershellHook(profilesRoot) : shHook(shell, profilesRoot, platform);
1082
+ }
1083
+
1084
+ /** The PowerShell hook, for Windows PowerShell 5.1 and PowerShell 7. Wraps
1085
+ * `prompt` because there is no chpwd and LocationChangedAction is 7 only.
1086
+ * Cmdlets and builtins only, ASCII only, and every value it manages lives
1087
+ * in $global: because the profile dot-sources what Invoke-Expression ran.
1088
+ * The parent step is .NET GetDirectoryName: Split-Path cannot combine
1089
+ * -LiteralPath with -Parent, and -Path would expand wildcards. */
1090
+ function powershellHook(profilesRoot: string): string {
1091
+ const q = profilesRoot.replace(/'/g, "''");
1092
+ return `# claudep shell hook. Load it from your $PROFILE: ${hookHint("powershell")}
1093
+ $global:_claudep_root = '${q}'
1094
+ function global:_claudep_auto {
1095
+ $here = $ExecutionContext.SessionState.Path.CurrentFileSystemLocation.ProviderPath
1096
+ if ($here -ceq $global:_claudep_last_pwd) { return }
1097
+ $global:_claudep_last_pwd = $here
1098
+ # Only manage a CLAUDE_CONFIG_DIR this hook set itself. A manual pin wins.
1099
+ if ($env:CLAUDE_CONFIG_DIR -and ($env:CLAUDE_CONFIG_DIR -cne $env:CLAUDEP_AUTO)) { return }
1100
+ $dir = $here
1101
+ $name = ''
1102
+ $found = ''
1103
+ while ($true) {
1104
+ $pin = Join-Path $dir '${PIN_FILE}'
1105
+ if (Test-Path -LiteralPath $pin -PathType Leaf) {
1106
+ $found = $dir
1107
+ foreach ($line in @(Get-Content -LiteralPath $pin)) {
1108
+ $t = ([string]$line).Trim()
1109
+ if ($t -eq '' -or $t.StartsWith('#')) { continue }
1110
+ $name = $t
1111
+ break
1112
+ }
1113
+ break
1114
+ }
1115
+ $parent = [System.IO.Path]::GetDirectoryName($dir)
1116
+ if (-not $parent -or ($parent -ceq $dir)) { break }
1117
+ $dir = $parent
1118
+ }
1119
+ if ($name -eq '') {
1120
+ # No pin here: hand the shell back to the base account.
1121
+ if ($env:CLAUDEP_AUTO) { Remove-Item Env:CLAUDE_CONFIG_DIR, Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue }
1122
+ return
1123
+ }
1124
+ $target = Join-Path $global:_claudep_root $name
1125
+ if (-not (Test-Path -LiteralPath $target -PathType Container)) {
1126
+ if ($env:CLAUDEP_AUTO) { Remove-Item Env:CLAUDE_CONFIG_DIR, Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue }
1127
+ [Console]::Error.WriteLine('claudep: ' + (Join-Path $found '${PIN_FILE}') + ' names profile "' + $name + '", which does not exist. Run: claudep init ' + $name)
1128
+ return
1129
+ }
1130
+ $env:CLAUDE_CONFIG_DIR = $target
1131
+ $env:CLAUDEP_AUTO = $target
1132
+ }
1133
+ if (-not $global:_claudep_prompt_orig) {
1134
+ $global:_claudep_prompt_orig = if (Test-Path Function:\\prompt) { $function:prompt } else { { 'PS> ' } }
1135
+ function global:prompt { _claudep_auto; & $global:_claudep_prompt_orig }
1136
+ }
1137
+ _claudep_auto
1138
+ `;
1139
+ }
1140
+
1141
+ function shHook(shell: "zsh" | "bash", profilesRoot: string, platform: NodeJS.Platform): string {
771
1142
  const q = profilesRoot.replace(/'/g, `'\\''`);
1143
+ // Under Git Bash $PWD is POSIX-style but the exported value must be the
1144
+ // native path claude.exe reads, so the root and its separator are embedded
1145
+ // as the native bun saw them and only the walk uses $PWD.
1146
+ const sep = platform === "win32" ? "\\" : "/";
772
1147
  const core = `# claudep shell hook. Load it from your rc file: eval "$(claudep shell-init ${shell})"
773
1148
  _claudep_root='${q}'
1149
+ _claudep_sep='${sep}'
774
1150
  _claudep_auto() {
775
1151
  [ "$PWD" = "\${_claudep_last_pwd:-}" ] && return 0
776
1152
  _claudep_last_pwd="$PWD"
@@ -783,6 +1159,7 @@ _claudep_auto() {
783
1159
  if [ -f "\${_claudep_dir%/}/${PIN_FILE}" ]; then
784
1160
  _claudep_found="\${_claudep_dir:-/}"
785
1161
  while read -r _claudep_line || [ -n "$_claudep_line" ]; do
1162
+ _claudep_line="\${_claudep_line%$'\\r'}"
786
1163
  case "$_claudep_line" in "" | "#"*) continue ;; esac
787
1164
  _claudep_name="$_claudep_line"
788
1165
  break
@@ -797,12 +1174,12 @@ _claudep_auto() {
797
1174
  [ -n "\${CLAUDEP_AUTO:-}" ] && unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO
798
1175
  return 0
799
1176
  fi
800
- if [ ! -d "$_claudep_root/$_claudep_name" ]; then
1177
+ if [ ! -d "$_claudep_root$_claudep_sep$_claudep_name" ]; then
801
1178
  [ -n "\${CLAUDEP_AUTO:-}" ] && unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO
802
1179
  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
1180
  return 0
804
1181
  fi
805
- export CLAUDE_CONFIG_DIR="$_claudep_root/$_claudep_name" CLAUDEP_AUTO="$_claudep_root/$_claudep_name"
1182
+ export CLAUDE_CONFIG_DIR="$_claudep_root$_claudep_sep$_claudep_name" CLAUDEP_AUTO="$_claudep_root$_claudep_sep$_claudep_name"
806
1183
  }
807
1184
  `;
808
1185
  const tail =
@@ -818,19 +1195,33 @@ _claudep_auto
818
1195
  }
819
1196
 
820
1197
  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));
1198
+ const shell = args[0] ?? defaultShell(process.env, L.platform);
1199
+ if (!SHELLS.includes(shell as Shell)) die(`unsupported shell "${shell}". Use zsh, bash or powershell`);
1200
+ process.stdout.write(shellInit(shell as Shell, L.profilesRoot, L.platform));
1201
+ }
1202
+
1203
+ /** A POSIX-style CLAUDE_CONFIG_DIR on Windows is one claude.exe cannot read. */
1204
+ function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undefined {
1205
+ const cfg = env.CLAUDE_CONFIG_DIR;
1206
+ if (L.platform !== "win32" || !cfg || !isMsysPath(cfg)) return undefined;
1207
+ return `CLAUDE_CONFIG_DIR is a POSIX-style path (${cfg}); claude.exe will not read it. Set it with: eval "$(claudep env <name>)"`;
824
1208
  }
825
1209
 
826
1210
  async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
827
1211
  const names = args[0] ? [args[0]] : listProfileNames(L);
828
- const bin = Bun.which("claude");
829
- if (bin) {
1212
+ const launch = findClaude();
1213
+ if (launch && launch.kind !== "ps1") {
830
1214
  const { out } = await captureClaude(L, undefined, ["--version"]);
831
- ok(`claude: ${bin} (${out.trim() || "version unknown"})`);
832
- } else bad("claude binary not found on PATH");
1215
+ ok(`claude: ${launch.bin} (${out.trim() || "version unknown"})`);
1216
+ if (launch.kind === "cmd")
1217
+ console.log(
1218
+ `${c.dim("·")} claude is the npm cmd shim and runs through cmd.exe; the native installer's claude.exe avoids that hop`,
1219
+ );
1220
+ } else if (launch) bad(`only ${launch.bin} found; claudep needs claude.exe or claude.cmd`);
1221
+ else bad("claude binary not found on PATH");
833
1222
  ok(`base: ${L.base}${L.callerConfigDir && !L.managed ? c.yellow(" (from CLAUDE_CONFIG_DIR in your shell)") : ""}`);
1223
+ const msysWarning = msysConfigDirWarning(L);
1224
+ if (msysWarning) warn(msysWarning);
834
1225
  ok(`profiles root: ${L.profilesRoot}`);
835
1226
 
836
1227
  const shared = sharedItems(L.base);
@@ -853,36 +1244,32 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
853
1244
  problems++;
854
1245
  continue;
855
1246
  }
1247
+ let missing = 0;
856
1248
  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) {
1249
+ const state = linkState(L.base, dir, item);
1250
+ if (state === "ok") continue;
1251
+ problems++;
1252
+ if (state === "missing") {
1253
+ missing++;
865
1254
  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
- }
1255
+ } else if (state === "shadowed") warn(`${item.name}: real ${item.kind} shadows the shared one`);
1256
+ else if (state === "wrong-target")
1257
+ bad(`${item.name}: symlink points elsewhere (${readlinkSync(join(dir, item.name))})`);
1258
+ else bad(`${item.name}: broken symlink`);
877
1259
  }
1260
+ if (missing && L.platform === "win32")
1261
+ console.log(
1262
+ `${c.dim("·")} Windows creates symlinks only with Developer Mode on; claudep init ${name} says so when it is off`,
1263
+ );
878
1264
  ok(`${shared.length} shared item(s) checked`);
879
1265
  const strays = readdirSync(dir).filter((n) => !sharedNames.has(n) && !KNOWN_PRIVATE.has(n) && !n.endsWith(".md"));
880
1266
  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)`);
1267
+ if (L.platform === "darwin") {
1268
+ const svc = keychainService(dir);
1269
+ if (await keychainHas(svc)) ok(`keychain item "${svc}" present`);
1270
+ else warn(`no keychain item "${svc}". Not logged in yet (claudep ${name} auth login)`);
1271
+ } else if (credentialsFileHas(dir)) ok(".credentials.json present");
1272
+ else warn(`no .credentials.json in the profile. Not logged in yet (claudep ${name} auth login)`);
886
1273
  const s = await authStatus(L, dir);
887
1274
  if (s.loggedIn) ok(`logged in as ${s.email ?? "?"} (${s.orgName ?? "?"}, ${s.subscriptionType ?? "?"})`);
888
1275
  else warn("not logged in");
@@ -901,7 +1288,11 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
901
1288
  const dir = profileDir(L, name);
902
1289
  if (!existsSync(dir)) die(`profile "${name}" does not exist`);
903
1290
  const real = realpathSync(dir);
904
- if (!real.startsWith(`${realpathSync(L.profilesRoot)}/`) || real === realpathSync(L.base) || real === L.home)
1291
+ if (
1292
+ !isInside(realpathSync(L.profilesRoot), real, L.platform) ||
1293
+ samePath(real, realpathSync(L.base), L.platform) ||
1294
+ samePath(real, L.home, L.platform)
1295
+ )
905
1296
  die(`refusing to remove ${real}: not inside ${L.profilesRoot}`);
906
1297
  if (!f.bools.has("--yes")) {
907
1298
  const yes = confirm(`Remove profile "${name}" (${dir})? Shared items are only unlinked; ${L.base} is untouched.`);
@@ -911,15 +1302,16 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
911
1302
  }
912
1303
  }
913
1304
  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)");
1305
+ const proc = spawnClaude(L, dir, ["auth", "logout"], "inherit");
1306
+ if ((await proc.exited) === 0) ok("logged out (token revoked, credentials removed)");
921
1307
  else warn("logout failed or was not logged in. Continuing");
922
1308
  }
1309
+ // Unlink the shared items first so no recursive delete ever looks through
1310
+ // a symlink into the base, whatever the platform's rm does with them.
1311
+ for (const entry of readdirSync(dir)) {
1312
+ const p = join(dir, entry);
1313
+ if (lstatSync(p).isSymbolicLink()) removeLink(p);
1314
+ }
923
1315
  rmSync(dir, { recursive: true, force: true });
924
1316
  ok(`removed ${dir}`);
925
1317
  const shims = aliasDir();
@@ -937,7 +1329,7 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
937
1329
  }
938
1330
 
939
1331
  function help(L: Layout): void {
940
- const root = L.profilesRoot.startsWith(L.home) ? `~${L.profilesRoot.slice(L.home.length)}` : L.profilesRoot;
1332
+ const root = shortHome(L.profilesRoot, L.home, L.platform);
941
1333
  console.log(`${c.bold("claudep")} ${c.dim(version())}: run Claude Code under separate accounts on one machine
942
1334
 
943
1335
  ${c.bold("USAGE")}
@@ -946,7 +1338,7 @@ ${c.bold("USAGE")}
946
1338
  claudep list show every profile and who it is logged in as
947
1339
  claudep status <name> [--json] login state for one profile ("default" = ~/.claude)
948
1340
  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
1341
+ claudep env <name> | --unset print the CLAUDE_CONFIG_DIR export (or the unset) for eval / Invoke-Expression
950
1342
  claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
951
1343
  claudep doctor [name] verify symlinks, keychain entry, unclassified files
952
1344
  claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
@@ -956,7 +1348,7 @@ ${c.bold("DIRECTORY PINS")}
956
1348
  claudep local <name> [--force] write ./${PIN_FILE} so this tree uses <name>; --remove deletes it
957
1349
  claudep local show the pin that applies to the current directory
958
1350
  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
1351
+ claudep shell-init [zsh|bash|powershell] print the hook that applies pins on cd; load it from your rc file
960
1352
 
961
1353
  ${c.bold("INIT OPTIONS")}
962
1354
  --sso force the SSO login flow (Enterprise orgs)
@@ -973,8 +1365,10 @@ ${c.bold("EXAMPLES")}
973
1365
  claude # Claude Code as whatever ~/.claude is logged in as
974
1366
  claudep enterprise -p "summarize this repo"
975
1367
  eval "$(claudep env enterprise)" # pin the whole shell to a profile
1368
+ claudep env enterprise | Invoke-Expression # the same from PowerShell
976
1369
  claudep local enterprise # pin this repo; commit the ${PIN_FILE} file for the team
977
1370
  eval "$(claudep shell-init zsh)" # in .zshrc: shells follow ${PIN_FILE} pins on cd
1371
+ ${hookHint("powershell")} # the same line for $PROFILE
978
1372
 
979
1373
  ${c.bold("HOW IT WORKS")}
980
1374
  ~/.claude stays exactly as it is and remains the "default" profile. Each named profile is a
@@ -983,9 +1377,10 @@ ${c.bold("HOW IT WORKS")}
983
1377
  ${[...SHARED_FILES, "*.md", ...SHARED_DIRS].join(" ")}
984
1378
  Everything account-specific is real and per profile: .claude.json (login identity, MCP
985
1379
  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
1380
+ On macOS credentials never touch the profile dir: Claude Code stores them in the Keychain under
987
1381
  "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.
1382
+ login and refresh token and they cannot clobber each other. On Linux and Windows the login is
1383
+ <profile>/.credentials.json, a real file inside the profile that is never shared.
989
1384
 
990
1385
  ${c.bold("PIN RULES")}
991
1386
  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.0",
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",