@bordoni/claudep 0.1.0 → 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 +35 -2
- package/README.md +31 -6
- package/claudep.ts +542 -145
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,36 @@ 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
|
+
|
|
33
|
+
## [0.1.1] - 2026-09-03
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- The 0.1.0 tarball on npm was published by hand from a commit that predates the notice that claudep is independent and not affiliated with Anthropic. This release is the same code with the notice in the README, in `claudep help` and in the package description.
|
|
38
|
+
|
|
9
39
|
## [0.1.0] - 2026-09-03
|
|
10
40
|
|
|
11
41
|
### Added
|
|
@@ -21,8 +51,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
|
21
51
|
- Directory pins: a `.claudep` file names the profile for a directory tree. `claudep local`, `claudep resolve` and `claudep shell-init zsh|bash` manage and apply pins; the hook only touches a `CLAUDE_CONFIG_DIR` it set itself.
|
|
22
52
|
- `claudep --version`.
|
|
23
53
|
- Published as `@bordoni/claudep` on npm and GitHub Packages; the installed command is `claudep`.
|
|
54
|
+
- A clear notice in the README, in `claudep help` and in the package description that this is an independent tool with no affiliation to Anthropic.
|
|
24
55
|
- Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
|
|
25
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.
|
|
26
57
|
|
|
27
|
-
[Unreleased]: https://github.com/bordoni/claudep/compare/
|
|
28
|
-
[0.
|
|
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
|
|
60
|
+
[0.1.1]: https://github.com/bordoni/claudep/compare/0.1.0...0.1.1
|
|
61
|
+
[0.1.0]: https://github.com/bordoni/claudep/releases/tag/0.1.0
|
package/README.md
CHANGED
|
@@ -12,6 +12,10 @@ eclaude # Claude Code as the enterprise account
|
|
|
12
12
|
claude # Claude Code as whatever ~/.claude is logged in as
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
## Not affiliated with Anthropic
|
|
16
|
+
|
|
17
|
+
claudep is an independent, unofficial tool written by me, Gustavo Bordoni. I have no affiliation with Anthropic. Anthropic has not endorsed, sponsored, reviewed or supported this project in any way. "Claude" and "Claude Code" are Anthropic's names and trademarks and appear here only to describe what the tool works with. Use it at your own risk and under the terms of your own Claude account.
|
|
18
|
+
|
|
15
19
|
## Install
|
|
16
20
|
|
|
17
21
|
Requires [bun](https://bun.sh) and an existing Claude Code install (`claude` on your PATH).
|
|
@@ -30,6 +34,21 @@ git clone git@github.com:bordoni/claudep.git ~/workspace/claudep
|
|
|
30
34
|
ln -s ~/workspace/claudep/claudep.ts ~/.local/bin/claudep
|
|
31
35
|
```
|
|
32
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
|
+
|
|
33
52
|
## Usage
|
|
34
53
|
|
|
35
54
|
```
|
|
@@ -44,7 +63,7 @@ claudep doctor [name] verify symlinks, keychain entry, unclassifi
|
|
|
44
63
|
claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
|
|
45
64
|
claudep local <name> | --remove pin the current directory tree to a profile (see below)
|
|
46
65
|
claudep resolve [dir] print the profile pinned for a directory
|
|
47
|
-
claudep shell-init [zsh|bash]
|
|
66
|
+
claudep shell-init [zsh|bash|powershell] print the hook that applies pins on cd
|
|
48
67
|
claudep --version
|
|
49
68
|
```
|
|
50
69
|
|
|
@@ -59,16 +78,22 @@ cd ~/work/acme
|
|
|
59
78
|
claudep local enterprise # writes ./.claudep containing "enterprise"
|
|
60
79
|
```
|
|
61
80
|
|
|
62
|
-
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):
|
|
63
82
|
|
|
64
83
|
```sh
|
|
65
84
|
eval "$(claudep shell-init zsh)"
|
|
66
85
|
```
|
|
67
86
|
|
|
68
|
-
|
|
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:
|
|
69
94
|
|
|
70
95
|
- The nearest `.claudep` file upward from the current directory wins. An empty one cancels a parent pin.
|
|
71
|
-
- The hook only changes a `CLAUDE_CONFIG_DIR` it set itself. It tracks that in `CLAUDEP_AUTO`, so a manual pin from `eval "$(claudep env work)"` or a plain `export` stays put until you `eval "$(claudep env --unset)"`.
|
|
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)"`.
|
|
72
97
|
- A pin that names a profile you have not created prints one warning per directory change and sets nothing.
|
|
73
98
|
|
|
74
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.
|
|
@@ -84,7 +109,7 @@ From then on, `cd` into a pinned tree sets `CLAUDE_CONFIG_DIR` for that profile
|
|
|
84
109
|
| `hooks/`, `skills/`, `commands/`, `agents/`, `plugins/`, `plans/` | `history.jsonl`, `todos/`, `sessions/`, caches, telemetry |
|
|
85
110
|
| `projects/` (session transcripts and auto-memory) | credentials |
|
|
86
111
|
|
|
87
|
-
|
|
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.
|
|
88
113
|
|
|
89
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.
|
|
90
115
|
|
|
@@ -97,7 +122,7 @@ The shared list is an allowlist, so an account-specific file cannot leak across
|
|
|
97
122
|
|
|
98
123
|
## Releases
|
|
99
124
|
|
|
100
|
-
Versions, dates and changes are in [`CHANGELOG.md`](./CHANGELOG.md). Releases are tagged `
|
|
125
|
+
Versions, dates and changes are in [`CHANGELOG.md`](./CHANGELOG.md). Releases are tagged with the bare version, `0.1.0`, and published to npm with provenance by GitHub Actions.
|
|
101
126
|
|
|
102
127
|
## Development
|
|
103
128
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 (
|
|
447
|
+
if (samePath(deps.readlink(dest), target, deps.platform)) return "ok";
|
|
330
448
|
if (force) {
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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(
|
|
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)
|
|
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
|
-
|
|
390
|
-
|
|
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 =
|
|
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
|
-
|
|
409
|
-
process.on(
|
|
410
|
-
process.on(
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
831
|
+
return splitPathVar(pathVar, platform).some((p) => {
|
|
582
832
|
try {
|
|
583
|
-
return realpathSync(canon(p))
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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] ??
|
|
822
|
-
if (shell
|
|
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
|
|
829
|
-
if (
|
|
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
|
-
|
|
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
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
else
|
|
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 (
|
|
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 =
|
|
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)");
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
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
|
|
@@ -996,6 +1391,8 @@ ${c.bold("PIN RULES")}
|
|
|
996
1391
|
${c.bold("ENVIRONMENT")}
|
|
997
1392
|
CLAUDE_PROFILES_DIR where profiles live (default ~/.claudep; keep it out of iCloud/Dropbox)
|
|
998
1393
|
CLAUDEP_AUTO set by the hook next to CLAUDE_CONFIG_DIR; marks the pin as hook-managed
|
|
1394
|
+
|
|
1395
|
+
${c.dim("claudep is an independent, unofficial tool. It is not affiliated with, endorsed by or supported by Anthropic.")}
|
|
999
1396
|
`);
|
|
1000
1397
|
}
|
|
1001
1398
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bordoni/claudep",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Run Claude Code under separate accounts on one machine (work + personal) with isolated logins and shared config.",
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"author": "Gustavo Bordoni",
|
|
@@ -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",
|