@bordoni/claudep 0.2.1 → 0.3.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 +15 -1
- package/README.md +33 -9
- package/claudep.ts +266 -31
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-09-10
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- fish support: `claudep shell-init fish` prints a hook for `config.fish` that follows `.claudep` pins on `cd` using builtins only, and `claudep env` prints `set -gx` and `set -e` lines when run from fish. Load both with `| source`. Needs fish 3.0 or later.
|
|
14
|
+
- `claudep env` looks at the shell it runs in (fish, pwsh, or an sh-like shell) before falling back to `$SHELL`, and `--shell sh|zsh|bash|fish|powershell` overrides both. `claudep env <name> | Invoke-Expression` works in pwsh on macOS and Linux as a result.
|
|
15
|
+
- `claudep current --name` prints only the profile name (`default` for `~/.claude`, `custom` for a config dir outside the profiles root), for prompts, scripts and the statusline.
|
|
16
|
+
- `claudep list --json` prints the same flat objects as `claudep status --json`, one per profile.
|
|
17
|
+
- `claudep doctor` fails when the shared `settings.json` sets `env.CLAUDE_CONFIG_DIR`, and on macOS when Claude Code is older than 2.1.144, the first build whose Keychain item is namespaced per config dir.
|
|
18
|
+
- `claudep doctor` and `claudep <name>` warn when `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` or `CLAUDE_CODE_OAUTH_TOKEN` is set, because Claude Code uses it instead of the profile's login. The variable is left alone.
|
|
19
|
+
- `claudep rm` says when the shell is on the profile being removed or the current directory pins it, and names the command that clears each.
|
|
20
|
+
- CI runs the shell hook in fish on macOS and Linux.
|
|
21
|
+
|
|
9
22
|
## [0.2.1] - 2026-09-08
|
|
10
23
|
|
|
11
24
|
### Changed
|
|
@@ -63,7 +76,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
|
63
76
|
- Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
|
|
64
77
|
- 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.
|
|
65
78
|
|
|
66
|
-
[Unreleased]: https://github.com/bordoni/claudep/compare/0.
|
|
79
|
+
[Unreleased]: https://github.com/bordoni/claudep/compare/0.3.0...HEAD
|
|
80
|
+
[0.3.0]: https://github.com/bordoni/claudep/compare/0.2.1...0.3.0
|
|
67
81
|
[0.2.1]: https://github.com/bordoni/claudep/compare/0.2.0...0.2.1
|
|
68
82
|
[0.2.0]: https://github.com/bordoni/claudep/compare/0.1.1...0.2.0
|
|
69
83
|
[0.1.1]: https://github.com/bordoni/claudep/compare/0.1.0...0.1.1
|
package/README.md
CHANGED
|
@@ -54,16 +54,16 @@ Two things differ from macOS:
|
|
|
54
54
|
```
|
|
55
55
|
claudep <name> [claude args…] run claude with profile <name>
|
|
56
56
|
claudep init <name> [options] create/update a profile and log in
|
|
57
|
-
claudep list
|
|
57
|
+
claudep list [--json] every profile and who it is logged in as
|
|
58
58
|
claudep status <name> [--json] login state for one profile ("default" = ~/.claude)
|
|
59
|
-
claudep env <name>
|
|
59
|
+
claudep env <name> [--shell <sh>] print the CLAUDE_CONFIG_DIR pin for eval, source or Invoke-Expression
|
|
60
60
|
claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
|
|
61
|
-
claudep current [--json]
|
|
62
|
-
claudep doctor [name] verify symlinks, keychain entry, unclassified files
|
|
61
|
+
claudep current [--json|--name] which profile this shell is on, and why
|
|
62
|
+
claudep doctor [name] verify symlinks, keychain entry, unclassified files, Claude Code version
|
|
63
63
|
claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
|
|
64
64
|
claudep local <name> | --remove pin the current directory tree to a profile (see below)
|
|
65
65
|
claudep resolve [dir] print the profile pinned for a directory
|
|
66
|
-
claudep shell-init [zsh|bash|powershell] print the hook that applies pins on cd
|
|
66
|
+
claudep shell-init [zsh|bash|fish|powershell] print the hook that applies pins on cd
|
|
67
67
|
claudep --version
|
|
68
68
|
```
|
|
69
69
|
|
|
@@ -84,20 +84,42 @@ Shells apply pins through a hook. Add one line to `~/.zshrc` (or `~/.bashrc` wit
|
|
|
84
84
|
eval "$(claudep shell-init zsh)"
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
In
|
|
87
|
+
In fish the line goes in `~/.config/fish/config.fish`, and in PowerShell in `$PROFILE`:
|
|
88
|
+
|
|
89
|
+
```fish
|
|
90
|
+
claudep shell-init fish | source
|
|
91
|
+
```
|
|
88
92
|
|
|
89
93
|
```powershell
|
|
90
94
|
claudep shell-init powershell | Out-String | Invoke-Expression
|
|
91
95
|
```
|
|
92
96
|
|
|
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:
|
|
97
|
+
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 (builtins in fish, cmdlets only in PowerShell), so it costs nothing at the prompt. The rules:
|
|
94
98
|
|
|
95
99
|
- The nearest `.claudep` file upward from the current directory wins. An empty one cancels a parent pin.
|
|
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)"`.
|
|
100
|
+
- 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 | source` in fish, `claudep env work | Invoke-Expression` in PowerShell) or a plain `export` stays put until you `eval "$(claudep env --unset)"`.
|
|
97
101
|
- A pin that names a profile you have not created prints one warning per directory change and sets nothing.
|
|
98
102
|
|
|
103
|
+
`claudep env` prints the syntax of the shell it runs in: it looks at its parent process (fish, pwsh, or an sh-like shell) and falls back to your login shell. Pass `--shell sh|fish|powershell` when neither answer fits, for example from a script or a Makefile.
|
|
104
|
+
|
|
99
105
|
`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.
|
|
100
106
|
|
|
107
|
+
## Show the profile in your prompt or statusline
|
|
108
|
+
|
|
109
|
+
In a shell prompt use the variable itself; no command runs:
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
# zsh or bash: the last path segment is the profile name when a profile is active
|
|
113
|
+
PROMPT='${CLAUDE_CONFIG_DIR:+[${CLAUDE_CONFIG_DIR##*/}] }'"$PROMPT"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```fish
|
|
117
|
+
# fish
|
|
118
|
+
function fish_prompt; set -q CLAUDE_CONFIG_DIR; and echo -n "[$(string replace -r '.*/' '' -- $CLAUDE_CONFIG_DIR)] "; ...; end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
In scripts, and in the shared `statusline-command.sh`, `claudep current --name` prints exactly one word: the profile name, `default` for `~/.claude`, or `custom` for a `CLAUDE_CONFIG_DIR` outside the profiles root. The statusline script inherits Claude Code's environment, so it sees the profile the session was started with.
|
|
122
|
+
|
|
101
123
|
## How it works
|
|
102
124
|
|
|
103
125
|
`~/.claude` is left exactly as it is and stays the **default** profile. Each named profile is a thin directory under `~/.claudep/<name>`. Inside it, shared configuration is a symlink back into `~/.claude`:
|
|
@@ -119,7 +141,9 @@ The shared list is an allowlist, so an account-specific file cannot leak across
|
|
|
119
141
|
- Two profiles running at the same time write the same `settings.json` and `plugins/`. That is the same situation as two terminals today.
|
|
120
142
|
- 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.
|
|
121
143
|
- 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.
|
|
122
|
-
- Never put `CLAUDE_CONFIG_DIR` in a `settings.json` `env` block. Claude Code detects that mismatch and disables features.
|
|
144
|
+
- Never put `CLAUDE_CONFIG_DIR` in a `settings.json` `env` block. Claude Code detects that mismatch and disables features; `claudep doctor` reports it.
|
|
145
|
+
- `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN` and `CLAUDE_CODE_OAUTH_TOKEN` override the login in any config dir, always in `-p` mode. `claudep <name>` and `claudep doctor` warn when one is set and leave it alone.
|
|
146
|
+
- `claudep doctor` fails on macOS when Claude Code is older than 2.1.144, the first build whose Keychain item is namespaced per config dir.
|
|
123
147
|
|
|
124
148
|
## Releases
|
|
125
149
|
|
package/claudep.ts
CHANGED
|
@@ -521,6 +521,49 @@ export async function readJson(path: string): Promise<JsonObject | undefined> {
|
|
|
521
521
|
}
|
|
522
522
|
}
|
|
523
523
|
|
|
524
|
+
/** Variables Claude Code uses instead of the login in its config dir when
|
|
525
|
+
* they are set (always in -p mode). They make a profile's login moot. */
|
|
526
|
+
export const AUTH_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"] as const;
|
|
527
|
+
|
|
528
|
+
/** The AUTH_ENV names that are set and non-empty. Windows environments are
|
|
529
|
+
* case-insensitive, so any spelling counts there. */
|
|
530
|
+
export function authEnvOverrides(env: Env = process.env, platform: NodeJS.Platform = process.platform): string[] {
|
|
531
|
+
return AUTH_ENV.filter((name) => {
|
|
532
|
+
if (platform !== "win32") return Boolean(env[name]);
|
|
533
|
+
return Object.keys(env).some((k) => k.toUpperCase() === name && Boolean(env[k]));
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** The oldest Claude Code whose macOS Keychain item is namespaced per config
|
|
538
|
+
* dir. Before it every config dir shared one login. */
|
|
539
|
+
export const MIN_CLAUDE_VERSION = "2.1.144";
|
|
540
|
+
|
|
541
|
+
export type Version = [number, number, number];
|
|
542
|
+
|
|
543
|
+
/** The first x.y.z in `claude --version` output. */
|
|
544
|
+
export function parseVersion(text: string): Version | undefined {
|
|
545
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
|
|
546
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function versionBelow(a: Version, b: Version): boolean {
|
|
550
|
+
for (let i = 0; i < 3; i++) {
|
|
551
|
+
if (a[i] !== b[i]) return (a[i] as number) < (b[i] as number);
|
|
552
|
+
}
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** `env.CLAUDE_CONFIG_DIR` from a settings file, when present and a string.
|
|
557
|
+
* Claude Code disables features when it finds one that differs from the
|
|
558
|
+
* active dir, and under claudep it always differs for some profile. */
|
|
559
|
+
export async function settingsEnvConfigDir(settingsPath: string): Promise<string | undefined> {
|
|
560
|
+
const settings = await readJson(settingsPath);
|
|
561
|
+
const env = settings?.env;
|
|
562
|
+
if (!env || typeof env !== "object" || Array.isArray(env)) return undefined;
|
|
563
|
+
const value = (env as JsonObject).CLAUDE_CONFIG_DIR;
|
|
564
|
+
return typeof value === "string" ? value : undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
524
567
|
export type SeedResult = "seeded" | "exists" | "no-base";
|
|
525
568
|
|
|
526
569
|
export async function seedGlobalJson(baseGlobalJson: string, dir: string, copyMcp: boolean): Promise<SeedResult> {
|
|
@@ -915,6 +958,8 @@ async function cmdRun(L: Layout, args: string[]): Promise<never> {
|
|
|
915
958
|
if (name === "default" || name === "base") return execClaude(L, undefined, rest);
|
|
916
959
|
const dir = profileDir(L, name);
|
|
917
960
|
if (!existsSync(dir)) die(`profile "${name}" does not exist. Run: claudep init ${name}`);
|
|
961
|
+
for (const v of authEnvOverrides(process.env, L.platform))
|
|
962
|
+
console.error(`claudep: ${v} is set; Claude Code will use it instead of the "${name}" login`);
|
|
918
963
|
return execClaude(L, dir, rest);
|
|
919
964
|
}
|
|
920
965
|
|
|
@@ -952,9 +997,20 @@ function printTable(rows: Row[], home: string, platform: NodeJS.Platform): void
|
|
|
952
997
|
});
|
|
953
998
|
}
|
|
954
999
|
|
|
955
|
-
|
|
1000
|
+
/** The flat object `status --json` prints for one row; `list --json` prints an array of them. */
|
|
1001
|
+
function rowJson(row: Row): JsonObject {
|
|
1002
|
+
return { name: row.name, dir: row.dir, ...row.status };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function cmdList(L: Layout, args: string[]): Promise<void> {
|
|
1006
|
+
const f = parseFlags(args, ["--json"], []);
|
|
956
1007
|
const names = ["default", ...listProfileNames(L)];
|
|
957
|
-
|
|
1008
|
+
const rows = await collectRows(L, names);
|
|
1009
|
+
if (f.bools.has("--json")) {
|
|
1010
|
+
console.log(JSON.stringify(rows.map(rowJson), null, 2));
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
printTable(rows, L.home, L.platform);
|
|
958
1014
|
if (names.length === 1) console.log(c.dim("\nNo profiles yet. Create one: claudep init <name>"));
|
|
959
1015
|
const cur = currentProfile(L);
|
|
960
1016
|
if (cur.kind !== "base") console.log(c.dim(`\nactive in this shell: ${describeCurrent(cur)}`));
|
|
@@ -967,43 +1023,65 @@ async function cmdStatus(L: Layout, args: string[]): Promise<void> {
|
|
|
967
1023
|
if (name !== "default" && !profileExists(L, name)) die(`profile "${name}" does not exist`);
|
|
968
1024
|
const [row] = await collectRows(L, [name]);
|
|
969
1025
|
if (!row) return;
|
|
970
|
-
if (f.bools.has("--json")) console.log(JSON.stringify(
|
|
1026
|
+
if (f.bools.has("--json")) console.log(JSON.stringify(rowJson(row), null, 2));
|
|
971
1027
|
else printTable([row], L.home, L.platform);
|
|
972
1028
|
}
|
|
973
1029
|
|
|
1030
|
+
function envUsage(syntax: EnvSyntax): string {
|
|
1031
|
+
if (syntax === "powershell")
|
|
1032
|
+
return "usage: claudep env <name> | Invoke-Expression or claudep env --unset | Invoke-Expression";
|
|
1033
|
+
if (syntax === "fish") return "usage: claudep env <name> | source or claudep env --unset | source";
|
|
1034
|
+
return 'usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)" (add --shell fish|powershell|sh when the shell was not detected)';
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/** The line that clears a pin, in the syntax of the shell the user is in. */
|
|
1038
|
+
export function envUnsetHint(syntax: EnvSyntax): string {
|
|
1039
|
+
if (syntax === "powershell") return "claudep env --unset | Invoke-Expression";
|
|
1040
|
+
if (syntax === "fish") return "claudep env --unset | source";
|
|
1041
|
+
return 'eval "$(claudep env --unset)"';
|
|
1042
|
+
}
|
|
1043
|
+
|
|
974
1044
|
function cmdEnv(L: Layout, args: string[]): void {
|
|
975
|
-
const
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
: 'usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)"',
|
|
982
|
-
);
|
|
983
|
-
if (name === "--unset") {
|
|
1045
|
+
const f = parseFlags(args, ["--unset"], ["--shell"]);
|
|
1046
|
+
const forced = f.strs.get("--shell");
|
|
1047
|
+
const syntax =
|
|
1048
|
+
forced === undefined ? shellSyntax(process.env, L.platform, parentProcessName(L.platform)) : envSyntaxOf(forced);
|
|
1049
|
+
if (!syntax) die(`unsupported shell "${forced}". Use sh, zsh, bash, fish or powershell`);
|
|
1050
|
+
if (f.bools.has("--unset")) {
|
|
984
1051
|
process.stdout.write(envScript(undefined, syntax));
|
|
985
1052
|
return;
|
|
986
1053
|
}
|
|
1054
|
+
const name = f.rest[0];
|
|
1055
|
+
if (!name) die(envUsage(syntax));
|
|
987
1056
|
const dir = profileDir(L, name);
|
|
988
1057
|
if (!existsSync(dir)) die(`profile "${name}" does not exist`);
|
|
989
1058
|
process.stdout.write(envScript(dir, syntax));
|
|
990
1059
|
}
|
|
991
1060
|
|
|
992
1061
|
function describeCurrent(cur: Current): string {
|
|
993
|
-
const label =
|
|
1062
|
+
const label = currentLabel(cur);
|
|
994
1063
|
const how = cur.setBy === "hook" ? "shell hook" : cur.setBy === "manual" ? "manual pin" : "nothing pinned";
|
|
995
1064
|
return `${label} (${how})`;
|
|
996
1065
|
}
|
|
997
1066
|
|
|
1067
|
+
/** The one-word answer: the profile name, `default` for the base, `custom` otherwise. */
|
|
1068
|
+
export function currentLabel(cur: Current): string {
|
|
1069
|
+
return cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
|
|
1070
|
+
}
|
|
1071
|
+
|
|
998
1072
|
function cmdCurrent(L: Layout, args: string[]): void {
|
|
999
|
-
const f = parseFlags(args, ["--json"], []);
|
|
1073
|
+
const f = parseFlags(args, ["--json", "--name"], []);
|
|
1000
1074
|
const cur = currentProfile(L);
|
|
1075
|
+
const label = currentLabel(cur);
|
|
1076
|
+
if (f.bools.has("--name")) {
|
|
1077
|
+
console.log(label);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1001
1080
|
const pin = resolvePin(process.cwd());
|
|
1002
1081
|
if (f.bools.has("--json")) {
|
|
1003
1082
|
console.log(JSON.stringify({ ...cur, pin: pin ?? null }, null, 2));
|
|
1004
1083
|
return;
|
|
1005
1084
|
}
|
|
1006
|
-
const label = cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
|
|
1007
1085
|
console.log(`${c.bold(label)} ${c.dim(shortHome(cur.dir, L.home, L.platform))}`);
|
|
1008
1086
|
if (cur.setBy === "hook")
|
|
1009
1087
|
console.log(`set by: shell hook${pin ? ` (${PIN_FILE} in ${shortHome(pin.dir, L.home, L.platform)})` : ""}`);
|
|
@@ -1063,28 +1141,83 @@ function cmdLocal(L: Layout, args: string[]): void {
|
|
|
1063
1141
|
/** The shell hook. Pure parameter expansion and builtins: it runs on every
|
|
1064
1142
|
* directory change (zsh chpwd) or prompt (bash PROMPT_COMMAND), so no
|
|
1065
1143
|
* subprocess is allowed here. Logic mirrors resolvePin(). */
|
|
1066
|
-
export type Shell = "zsh" | "bash" | "powershell";
|
|
1067
|
-
export const SHELLS: readonly Shell[] = ["zsh", "bash", "powershell"];
|
|
1144
|
+
export type Shell = "zsh" | "bash" | "fish" | "powershell";
|
|
1145
|
+
export const SHELLS: readonly Shell[] = ["zsh", "bash", "fish", "powershell"];
|
|
1068
1146
|
|
|
1069
1147
|
/** The shell to name in hints and to default `shell-init` to. */
|
|
1070
1148
|
export function defaultShell(env: Env = process.env, platform: NodeJS.Platform = process.platform): Shell {
|
|
1071
1149
|
if (platform === "win32") return env.MSYSTEM ? "bash" : "powershell";
|
|
1072
1150
|
const name = env.SHELL ? posix.basename(env.SHELL) : "";
|
|
1073
|
-
if (name === "zsh" || name === "bash") return name;
|
|
1151
|
+
if (name === "zsh" || name === "bash" || name === "fish") return name;
|
|
1074
1152
|
return platform === "darwin" ? "zsh" : "bash";
|
|
1075
1153
|
}
|
|
1076
1154
|
|
|
1077
1155
|
/** The one line that loads the hook in a shell's rc file. */
|
|
1078
1156
|
export function hookHint(shell: Shell): string {
|
|
1079
1157
|
if (shell === "powershell") return "claudep shell-init powershell | Out-String | Invoke-Expression";
|
|
1158
|
+
if (shell === "fish") return "claudep shell-init fish | source";
|
|
1080
1159
|
return `eval "$(claudep shell-init ${shell})"`;
|
|
1081
1160
|
}
|
|
1082
1161
|
|
|
1083
|
-
export type EnvSyntax = "sh" | "powershell";
|
|
1162
|
+
export type EnvSyntax = "sh" | "fish" | "powershell";
|
|
1084
1163
|
|
|
1085
|
-
/**
|
|
1086
|
-
|
|
1087
|
-
|
|
1164
|
+
/** The executable name of the parent process, which for `claudep env` is
|
|
1165
|
+
* the shell the user typed it in: `eval "$(...)"` forks the shell and a
|
|
1166
|
+
* fish pipeline runs claudep as a child of fish. Read from /proc on Linux
|
|
1167
|
+
* and from `ps` on macOS; undefined elsewhere or when it cannot be read.
|
|
1168
|
+
* This is `claudep env`, not the hook, so one `ps` is fine. */
|
|
1169
|
+
export function parentProcessName(
|
|
1170
|
+
platform: NodeJS.Platform = process.platform,
|
|
1171
|
+
ppid: number = process.ppid,
|
|
1172
|
+
): string | undefined {
|
|
1173
|
+
try {
|
|
1174
|
+
if (platform === "linux") return readFileSync(`/proc/${ppid}/comm`, "utf8").trim() || undefined;
|
|
1175
|
+
if (platform === "darwin") {
|
|
1176
|
+
const r = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(ppid)], { stdout: "pipe", stderr: "ignore" });
|
|
1177
|
+
const name = r.exitCode === 0 ? r.stdout.toString().trim() : "";
|
|
1178
|
+
return name || undefined;
|
|
1179
|
+
}
|
|
1180
|
+
} catch {
|
|
1181
|
+
/* fall through */
|
|
1182
|
+
}
|
|
1183
|
+
return undefined;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** `fish` from `fish`, `-fish` (a login shell) or `/usr/local/bin/fish`. */
|
|
1187
|
+
export function shellNameOf(comm: string): string {
|
|
1188
|
+
return posix.basename(comm.trim()).replace(/^-/, "");
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const SH_LIKE = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
|
|
1192
|
+
|
|
1193
|
+
/** What `claudep env` prints. PowerShell on Windows outside Git Bash. Then the
|
|
1194
|
+
* parent process decides when it is a shell claudep knows (fish, pwsh, or an
|
|
1195
|
+
* sh-like shell); otherwise the login shell in $SHELL: fish when that is
|
|
1196
|
+
* fish, sh for anything else. fish exports no marker a child could see,
|
|
1197
|
+
* which is why the parent is consulted at all. `--shell` overrides all of it. */
|
|
1198
|
+
export function shellSyntax(
|
|
1199
|
+
env: Env = process.env,
|
|
1200
|
+
platform: NodeJS.Platform = process.platform,
|
|
1201
|
+
parent: string | undefined = undefined,
|
|
1202
|
+
): EnvSyntax {
|
|
1203
|
+
if (platform === "win32" && !env.MSYSTEM) return "powershell";
|
|
1204
|
+
const p = parent !== undefined ? shellNameOf(parent) : "";
|
|
1205
|
+
if (p === "fish") return "fish";
|
|
1206
|
+
if (p === "pwsh" || p === "powershell") return "powershell";
|
|
1207
|
+
if (SH_LIKE.has(p)) return "sh";
|
|
1208
|
+
return env.SHELL && posix.basename(env.SHELL) === "fish" ? "fish" : "sh";
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** The syntax for a `--shell` value: the shell-init names plus `sh`. */
|
|
1212
|
+
export function envSyntaxOf(name: string): EnvSyntax | undefined {
|
|
1213
|
+
if (name === "sh" || name === "zsh" || name === "bash") return "sh";
|
|
1214
|
+
if (name === "fish" || name === "powershell") return name;
|
|
1215
|
+
return undefined;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** fish single-quoting: only \ and ' are special inside single quotes. */
|
|
1219
|
+
export function fishQuote(s: string): string {
|
|
1220
|
+
return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
1088
1221
|
}
|
|
1089
1222
|
|
|
1090
1223
|
/** The `claudep env` script: pin the shell to `dir`, or clear the pin. */
|
|
@@ -1094,12 +1227,81 @@ export function envScript(dir: string | undefined, syntax: EnvSyntax): string {
|
|
|
1094
1227
|
// Clearing CLAUDEP_AUTO turns this into a manual pin the shell hook will not touch.
|
|
1095
1228
|
return `$env:CLAUDE_CONFIG_DIR = '${dir.replace(/'/g, "''")}'\nRemove-Item Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue\n`;
|
|
1096
1229
|
}
|
|
1230
|
+
if (syntax === "fish") {
|
|
1231
|
+
// A bare `set -e` on a missing name returns 1 and `source` reports its
|
|
1232
|
+
// last status, which prompt themes paint red; the guard keeps it at 0.
|
|
1233
|
+
if (dir === undefined)
|
|
1234
|
+
return "not set -q CLAUDE_CONFIG_DIR; or set -e CLAUDE_CONFIG_DIR\nnot set -q CLAUDEP_AUTO; or set -e CLAUDEP_AUTO\n";
|
|
1235
|
+
return `set -gx CLAUDE_CONFIG_DIR ${fishQuote(dir)}\nnot set -q CLAUDEP_AUTO; or set -e CLAUDEP_AUTO\n`;
|
|
1236
|
+
}
|
|
1097
1237
|
if (dir === undefined) return "unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO\n";
|
|
1098
1238
|
return `export CLAUDE_CONFIG_DIR='${dir.replace(/'/g, `'\\''`)}'\nunset CLAUDEP_AUTO\n`;
|
|
1099
1239
|
}
|
|
1100
1240
|
|
|
1101
1241
|
export function shellInit(shell: Shell, profilesRoot: string, platform: NodeJS.Platform = process.platform): string {
|
|
1102
|
-
|
|
1242
|
+
if (shell === "powershell") return powershellHook(profilesRoot);
|
|
1243
|
+
if (shell === "fish") return fishHook(profilesRoot);
|
|
1244
|
+
return shHook(shell, profilesRoot, platform);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/** The fish hook. Builtins only (set, test, read, printf, string); every
|
|
1248
|
+
* `(...)` is a `string` builtin, which runs in-process. Needs fish 3.0:
|
|
1249
|
+
* `path dirname` would need 3.5 and `$(...)` 3.4, and Ubuntu 22.04 ships
|
|
1250
|
+
* 3.3. fish fires --on-variable on every `set` of PWD, `cd .` included, so
|
|
1251
|
+
* the last-pwd dedupe stays. Not a Windows target; joins with `/`. */
|
|
1252
|
+
function fishHook(profilesRoot: string): string {
|
|
1253
|
+
return `# claudep shell hook. Load it from ~/.config/fish/config.fish: ${hookHint("fish")}
|
|
1254
|
+
set -g _claudep_root ${fishQuote(profilesRoot)}
|
|
1255
|
+
function _claudep_auto --on-variable PWD
|
|
1256
|
+
test "$PWD" = "$_claudep_last_pwd"; and return 0
|
|
1257
|
+
set -g _claudep_last_pwd $PWD
|
|
1258
|
+
# Only manage a CLAUDE_CONFIG_DIR this hook set itself. A manual pin wins.
|
|
1259
|
+
if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$CLAUDEP_AUTO"
|
|
1260
|
+
return 0
|
|
1261
|
+
end
|
|
1262
|
+
set -l dir $PWD
|
|
1263
|
+
set -l name ''
|
|
1264
|
+
set -l found ''
|
|
1265
|
+
while true
|
|
1266
|
+
if test -f "$dir/${PIN_FILE}"
|
|
1267
|
+
set found $dir
|
|
1268
|
+
test -n "$found"; or set found /
|
|
1269
|
+
while read -l line
|
|
1270
|
+
set line (string trim -- $line)
|
|
1271
|
+
if test -z "$line"; or string match -q -- '#*' $line
|
|
1272
|
+
continue
|
|
1273
|
+
end
|
|
1274
|
+
set name $line
|
|
1275
|
+
break
|
|
1276
|
+
end < "$dir/${PIN_FILE}"
|
|
1277
|
+
break
|
|
1278
|
+
end
|
|
1279
|
+
if test -z "$dir"; or test "$dir" = /
|
|
1280
|
+
break
|
|
1281
|
+
end
|
|
1282
|
+
set dir (string replace -r -- '/[^/]*$' '' $dir)
|
|
1283
|
+
end
|
|
1284
|
+
if test -z "$name"
|
|
1285
|
+
# No pin here: hand the shell back to the base account.
|
|
1286
|
+
if test -n "$CLAUDEP_AUTO"
|
|
1287
|
+
set -e CLAUDE_CONFIG_DIR
|
|
1288
|
+
set -e CLAUDEP_AUTO
|
|
1289
|
+
end
|
|
1290
|
+
return 0
|
|
1291
|
+
end
|
|
1292
|
+
if not test -d "$_claudep_root/$name"
|
|
1293
|
+
if test -n "$CLAUDEP_AUTO"
|
|
1294
|
+
set -e CLAUDE_CONFIG_DIR
|
|
1295
|
+
set -e CLAUDEP_AUTO
|
|
1296
|
+
end
|
|
1297
|
+
printf 'claudep: %s/${PIN_FILE} names profile "%s", which does not exist. Run: claudep init %s\\n' "$found" "$name" "$name" >&2
|
|
1298
|
+
return 0
|
|
1299
|
+
end
|
|
1300
|
+
set -gx CLAUDE_CONFIG_DIR "$_claudep_root/$name"
|
|
1301
|
+
set -gx CLAUDEP_AUTO "$_claudep_root/$name"
|
|
1302
|
+
end
|
|
1303
|
+
_claudep_auto
|
|
1304
|
+
`;
|
|
1103
1305
|
}
|
|
1104
1306
|
|
|
1105
1307
|
/** The PowerShell hook, for Windows PowerShell 5.1 and PowerShell 7. Wraps
|
|
@@ -1217,7 +1419,7 @@ _claudep_auto
|
|
|
1217
1419
|
|
|
1218
1420
|
function cmdShellInit(L: Layout, args: string[]): void {
|
|
1219
1421
|
const shell = args[0] ?? defaultShell(process.env, L.platform);
|
|
1220
|
-
if (!SHELLS.includes(shell as Shell)) die(`unsupported shell "${shell}". Use
|
|
1422
|
+
if (!SHELLS.includes(shell as Shell)) die(`unsupported shell "${shell}". Use ${SHELLS.join(", ")}`);
|
|
1221
1423
|
process.stdout.write(shellInit(shell as Shell, L.profilesRoot, L.platform));
|
|
1222
1424
|
}
|
|
1223
1425
|
|
|
@@ -1230,6 +1432,7 @@ function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undef
|
|
|
1230
1432
|
|
|
1231
1433
|
async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
|
|
1232
1434
|
const names = args[0] ? [args[0]] : listProfileNames(L);
|
|
1435
|
+
let problems = 0;
|
|
1233
1436
|
const launch = findClaude();
|
|
1234
1437
|
if (launch && launch.kind !== "ps1") {
|
|
1235
1438
|
const { out } = await captureClaude(L, undefined, ["--version"]);
|
|
@@ -1238,11 +1441,29 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
|
|
|
1238
1441
|
console.log(
|
|
1239
1442
|
`${c.dim("·")} claude is the npm cmd shim and runs through cmd.exe; the native installer's claude.exe avoids that hop`,
|
|
1240
1443
|
);
|
|
1444
|
+
const v = parseVersion(out);
|
|
1445
|
+
const floor = parseVersion(MIN_CLAUDE_VERSION);
|
|
1446
|
+
if (L.platform === "darwin" && v && floor && versionBelow(v, floor)) {
|
|
1447
|
+
bad(
|
|
1448
|
+
`Claude Code ${v.join(".")} is older than ${MIN_CLAUDE_VERSION}; every config dir shares one Keychain item there, so profiles cannot hold separate logins. Update Claude Code`,
|
|
1449
|
+
);
|
|
1450
|
+
problems++;
|
|
1451
|
+
}
|
|
1241
1452
|
} else if (launch) bad(`only ${launch.bin} found; claudep needs claude.exe or claude.cmd`);
|
|
1242
1453
|
else bad("claude binary not found on PATH");
|
|
1243
1454
|
ok(`base: ${L.base}${L.callerConfigDir && !L.managed ? c.yellow(" (from CLAUDE_CONFIG_DIR in your shell)") : ""}`);
|
|
1244
1455
|
const msysWarning = msysConfigDirWarning(L);
|
|
1245
1456
|
if (msysWarning) warn(msysWarning);
|
|
1457
|
+
const settingsFile = join(L.base, "settings.json");
|
|
1458
|
+
const pinnedInSettings = await settingsEnvConfigDir(settingsFile);
|
|
1459
|
+
if (pinnedInSettings !== undefined) {
|
|
1460
|
+
bad(
|
|
1461
|
+
`${settingsFile} sets env.CLAUDE_CONFIG_DIR (${pinnedInSettings}). Claude Code disables features when that differs from the active dir, which it does in every profile. Remove it; pin shells with claudep env or a ${PIN_FILE} file instead`,
|
|
1462
|
+
);
|
|
1463
|
+
problems++;
|
|
1464
|
+
}
|
|
1465
|
+
for (const v of authEnvOverrides(process.env, L.platform))
|
|
1466
|
+
warn(`${v} is set in this shell; Claude Code uses it instead of the profile login, always in -p mode`);
|
|
1246
1467
|
ok(`profiles root: ${L.profilesRoot}`);
|
|
1247
1468
|
|
|
1248
1469
|
const shared = sharedItems(L.base);
|
|
@@ -1256,7 +1477,6 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
|
|
|
1256
1477
|
warn(`base items neither shared nor known-private (they stay per-profile): ${unclassified.join(", ")}`);
|
|
1257
1478
|
}
|
|
1258
1479
|
|
|
1259
|
-
let problems = 0;
|
|
1260
1480
|
for (const name of names) {
|
|
1261
1481
|
const dir = profileDir(L, name);
|
|
1262
1482
|
console.log(`\n${c.bold(name)} ${c.dim(dir)}`);
|
|
@@ -1315,6 +1535,18 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
|
|
|
1315
1535
|
samePath(real, L.home, L.platform)
|
|
1316
1536
|
)
|
|
1317
1537
|
die(`refusing to remove ${real}: not inside ${L.profilesRoot}`);
|
|
1538
|
+
const cur = currentProfile(L);
|
|
1539
|
+
if (cur.kind === "profile" && cur.name === name) {
|
|
1540
|
+
const unset = envUnsetHint(shellSyntax(process.env, L.platform, parentProcessName(L.platform)));
|
|
1541
|
+
warn(
|
|
1542
|
+
`this shell is on ${name} (${cur.setBy === "hook" ? "shell hook" : "manual pin"}). After removal run: ${unset}`,
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
const pin = resolvePin(process.cwd());
|
|
1546
|
+
if (pin && pin.name === name)
|
|
1547
|
+
warn(
|
|
1548
|
+
`${shortHome(pin.file, L.home, L.platform)} pins this directory tree to ${name}. Remove it with: claudep local --remove`,
|
|
1549
|
+
);
|
|
1318
1550
|
if (!f.bools.has("--yes")) {
|
|
1319
1551
|
const yes = confirm(`Remove profile "${name}" (${dir})? Shared items are only unlinked; ${L.base} is untouched.`);
|
|
1320
1552
|
if (!yes) {
|
|
@@ -1356,12 +1588,13 @@ function help(L: Layout): void {
|
|
|
1356
1588
|
${c.bold("USAGE")}
|
|
1357
1589
|
claudep <name> [claude args…] run claude with profile <name> (alias for "run")
|
|
1358
1590
|
claudep init <name> [options] create/update a profile and log in
|
|
1359
|
-
claudep list
|
|
1591
|
+
claudep list [--json] show every profile and who it is logged in as
|
|
1360
1592
|
claudep status <name> [--json] login state for one profile ("default" = ~/.claude)
|
|
1361
|
-
claudep current [--json]
|
|
1362
|
-
claudep env <name> | --unset print the CLAUDE_CONFIG_DIR
|
|
1593
|
+
claudep current [--json|--name] which profile this shell is on, and why; --name prints only the name
|
|
1594
|
+
claudep env <name> | --unset print the CLAUDE_CONFIG_DIR pin (or the unset) for eval, source or Invoke-Expression;
|
|
1595
|
+
--shell sh|fish|powershell picks the syntax when the shell was not detected
|
|
1363
1596
|
claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
|
|
1364
|
-
claudep doctor [name] verify symlinks, keychain entry, unclassified files
|
|
1597
|
+
claudep doctor [name] verify symlinks, keychain entry, unclassified files, Claude Code version
|
|
1365
1598
|
claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
|
|
1366
1599
|
claudep --version print the version
|
|
1367
1600
|
|
|
@@ -1369,7 +1602,7 @@ ${c.bold("DIRECTORY PINS")}
|
|
|
1369
1602
|
claudep local <name> [--force] write ./${PIN_FILE} so this tree uses <name>; --remove deletes it
|
|
1370
1603
|
claudep local show the pin that applies to the current directory
|
|
1371
1604
|
claudep resolve [dir] [--json] print the profile pinned for a directory (exit 1 when none)
|
|
1372
|
-
claudep shell-init [zsh|bash|powershell] print the hook that applies pins on cd; load it from your rc file
|
|
1605
|
+
claudep shell-init [zsh|bash|fish|powershell] print the hook that applies pins on cd; load it from your rc file
|
|
1373
1606
|
|
|
1374
1607
|
${c.bold("INIT OPTIONS")}
|
|
1375
1608
|
--sso force the SSO login flow (Enterprise orgs)
|
|
@@ -1386,9 +1619,11 @@ ${c.bold("EXAMPLES")}
|
|
|
1386
1619
|
claude # Claude Code as whatever ~/.claude is logged in as
|
|
1387
1620
|
claudep enterprise -p "summarize this repo"
|
|
1388
1621
|
eval "$(claudep env enterprise)" # pin the whole shell to a profile
|
|
1622
|
+
claudep env enterprise | source # the same from fish
|
|
1389
1623
|
claudep env enterprise | Invoke-Expression # the same from PowerShell
|
|
1390
1624
|
claudep local enterprise # pin this repo; commit the ${PIN_FILE} file for the team
|
|
1391
1625
|
eval "$(claudep shell-init zsh)" # in .zshrc: shells follow ${PIN_FILE} pins on cd
|
|
1626
|
+
${hookHint("fish")} # the same line for config.fish
|
|
1392
1627
|
${hookHint("powershell")} # the same line for $PROFILE
|
|
1393
1628
|
|
|
1394
1629
|
${c.bold("HOW IT WORKS")}
|
|
@@ -1437,7 +1672,7 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
1437
1672
|
return cmdRun(L, args);
|
|
1438
1673
|
case "list":
|
|
1439
1674
|
case "ls":
|
|
1440
|
-
return cmdList(L);
|
|
1675
|
+
return cmdList(L, args);
|
|
1441
1676
|
case "status":
|
|
1442
1677
|
return cmdStatus(L, args);
|
|
1443
1678
|
case "env":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bordoni/claudep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Unofficial, not affiliated with Anthropic. Run Claude Code under separate accounts on one machine (work + personal) with isolated logins and shared config.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|