@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 +35 -1
- package/README.md +29 -7
- package/claudep.ts +562 -146
- package/package.json +2 -2
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.
|
|
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]
|
|
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
|
-
|
|
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/`, `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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 &&
|
|
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)
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 (
|
|
468
|
+
if (samePath(deps.readlink(dest), target, deps.platform)) return "ok";
|
|
330
469
|
if (force) {
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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(
|
|
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)
|
|
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
|
-
|
|
390
|
-
|
|
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 =
|
|
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
|
-
|
|
409
|
-
process.on(
|
|
410
|
-
process.on(
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
852
|
+
return splitPathVar(pathVar, platform).some((p) => {
|
|
582
853
|
try {
|
|
583
|
-
return realpathSync(canon(p))
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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] ??
|
|
822
|
-
if (shell
|
|
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
|
|
829
|
-
if (
|
|
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
|
-
|
|
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
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
else
|
|
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 (
|
|
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 =
|
|
915
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
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.
|
|
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
|
|
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",
|