@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 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.2.1...HEAD
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 every profile and who it is logged in as
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> print "export CLAUDE_CONFIG_DIR=…" for eval
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] which profile this shell is on, and why
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 PowerShell the line goes in `$PROFILE`:
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
- async function cmdList(L: Layout): Promise<void> {
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
- printTable(await collectRows(L, names), L.home, L.platform);
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({ name: row.name, dir: row.dir, ...row.status }, null, 2));
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 name = args[0];
976
- const syntax = shellSyntax(process.env, L.platform);
977
- if (!name)
978
- die(
979
- syntax === "powershell"
980
- ? "usage: claudep env <name> | Invoke-Expression or claudep env --unset | Invoke-Expression"
981
- : 'usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)"',
982
- );
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 = cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
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
- /** What `claudep env` prints. PowerShell syntax only on Windows outside Git Bash. */
1086
- export function shellSyntax(env: Env = process.env, platform: NodeJS.Platform = process.platform): EnvSyntax {
1087
- return platform === "win32" && !env.MSYSTEM ? "powershell" : "sh";
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
- return shell === "powershell" ? powershellHook(profilesRoot) : shHook(shell, profilesRoot, platform);
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 zsh, bash or powershell`);
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 show every profile and who it is logged in as
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] which profile this shell is on, and why
1362
- claudep env <name> | --unset print the CLAUDE_CONFIG_DIR export (or the unset) for eval / Invoke-Expression
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.2.1",
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",