@bordoni/claudep 0.2.0 → 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,27 @@ 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
+
22
+ ## [0.2.1] - 2026-09-08
23
+
24
+ ### Changed
25
+
26
+ - `rules/`, `output-styles/`, `themes/` and `workflows/` are shared with every profile, matching what Claude Code 2.1.263 reads from the user config directory. A custom theme named in the shared `settings.json` now resolves inside a profile. Existing profiles pick the links up on their next `claudep init <name>`.
27
+ - `teams/`, `uploads/`, `usage-data/`, `mcp-discovery-cache/`, `mcp-skill-archives/`, `daemon.json`, `launch.json`, `scheduled_tasks.json` and `seed-admin` are known-private, so `claudep doctor` no longer reports them as unclassified.
28
+ - The README notes that `claude --bg` sessions and `claude daemon install` do not work under a profile in Claude Code 2.1.263.
29
+
9
30
  ## [0.2.0] - 2026-09-07
10
31
 
11
32
  ### Added
@@ -55,7 +76,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
55
76
  - Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
56
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.
57
78
 
58
- [Unreleased]: https://github.com/bordoni/claudep/compare/0.2.0...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
81
+ [0.2.1]: https://github.com/bordoni/claudep/compare/0.2.0...0.2.1
59
82
  [0.2.0]: https://github.com/bordoni/claudep/compare/0.1.1...0.2.0
60
83
  [0.1.1]: https://github.com/bordoni/claudep/compare/0.1.0...0.1.1
61
84
  [0.1.0]: https://github.com/bordoni/claudep/releases/tag/0.1.0
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`:
@@ -106,8 +128,8 @@ From then on, `cd` into a pinned tree sets `CLAUDE_CONFIG_DIR` for that profile
106
128
  |---|---|
107
129
  | `CLAUDE.md` and other top-level `*.md` | `.claude.json` (login identity, user-scope MCP servers, folder trust) |
108
130
  | `settings.json`, `keybindings.json`, `statusline-command.sh` | org-pushed `remote-settings.json`, `policy-limits.json` |
109
- | `hooks/`, `skills/`, `commands/`, `agents/`, `plugins/`, `plans/` | `history.jsonl`, `todos/`, `sessions/`, caches, telemetry |
110
- | `projects/` (session transcripts and auto-memory) | credentials |
131
+ | `hooks/`, `skills/`, `commands/`, `agents/`, `rules/`, `output-styles/`, `themes/`, `workflows/` | `history.jsonl`, `todos/`, `sessions/`, `teams/`, caches, telemetry |
132
+ | `plugins/`, `plans/`, `projects/` (session transcripts and auto-memory) | credentials |
111
133
 
112
134
  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.
113
135
 
@@ -117,8 +139,11 @@ The shared list is an allowlist, so an account-specific file cannot leak across
117
139
 
118
140
  - The first time you open a repository under a new profile you will re-accept folder trust, and claude.ai connectors need their OAuth redone in that profile.
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.
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.
120
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.
121
- - 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.
122
147
 
123
148
  ## Releases
124
149
 
package/claudep.ts CHANGED
@@ -236,7 +236,19 @@ export function version(): string {
236
236
 
237
237
  /** Top-level base items symlinked into every profile (only if they exist). */
238
238
  export const SHARED_FILES = ["CLAUDE.md", "settings.json", "keybindings.json", "statusline-command.sh"] as const;
239
- export const SHARED_DIRS = ["hooks", "skills", "commands", "agents", "plugins", "plans", "projects"] as const;
239
+ export const SHARED_DIRS = [
240
+ "hooks",
241
+ "skills",
242
+ "commands",
243
+ "agents",
244
+ "plugins",
245
+ "plans",
246
+ "projects",
247
+ "rules",
248
+ "output-styles",
249
+ "themes",
250
+ "workflows",
251
+ ] as const;
240
252
 
241
253
  /** Per-profile state. First block is Claude Code's own runtime-state list;
242
254
  * the rest are observed extras. Never shared. */
@@ -280,6 +292,15 @@ export const KNOWN_PRIVATE = new Set<string>([
280
292
  ".last-update-result.json",
281
293
  "daemon-auth-cooldown",
282
294
  "daemon-auth-status.json",
295
+ "teams",
296
+ "uploads",
297
+ "usage-data",
298
+ "mcp-discovery-cache",
299
+ "mcp-skill-archives",
300
+ "daemon.json",
301
+ "launch.json",
302
+ "scheduled_tasks.json",
303
+ "seed-admin",
283
304
  ]);
284
305
 
285
306
  /** Keys copied from the base .claude.json into a fresh profile so first-run
@@ -500,6 +521,49 @@ export async function readJson(path: string): Promise<JsonObject | undefined> {
500
521
  }
501
522
  }
502
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
+
503
567
  export type SeedResult = "seeded" | "exists" | "no-base";
504
568
 
505
569
  export async function seedGlobalJson(baseGlobalJson: string, dir: string, copyMcp: boolean): Promise<SeedResult> {
@@ -894,6 +958,8 @@ async function cmdRun(L: Layout, args: string[]): Promise<never> {
894
958
  if (name === "default" || name === "base") return execClaude(L, undefined, rest);
895
959
  const dir = profileDir(L, name);
896
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`);
897
963
  return execClaude(L, dir, rest);
898
964
  }
899
965
 
@@ -931,9 +997,20 @@ function printTable(rows: Row[], home: string, platform: NodeJS.Platform): void
931
997
  });
932
998
  }
933
999
 
934
- 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"], []);
935
1007
  const names = ["default", ...listProfileNames(L)];
936
- 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);
937
1014
  if (names.length === 1) console.log(c.dim("\nNo profiles yet. Create one: claudep init <name>"));
938
1015
  const cur = currentProfile(L);
939
1016
  if (cur.kind !== "base") console.log(c.dim(`\nactive in this shell: ${describeCurrent(cur)}`));
@@ -946,43 +1023,65 @@ async function cmdStatus(L: Layout, args: string[]): Promise<void> {
946
1023
  if (name !== "default" && !profileExists(L, name)) die(`profile "${name}" does not exist`);
947
1024
  const [row] = await collectRows(L, [name]);
948
1025
  if (!row) return;
949
- 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));
950
1027
  else printTable([row], L.home, L.platform);
951
1028
  }
952
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
+
953
1044
  function cmdEnv(L: Layout, args: string[]): void {
954
- const name = args[0];
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
- );
962
- 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")) {
963
1051
  process.stdout.write(envScript(undefined, syntax));
964
1052
  return;
965
1053
  }
1054
+ const name = f.rest[0];
1055
+ if (!name) die(envUsage(syntax));
966
1056
  const dir = profileDir(L, name);
967
1057
  if (!existsSync(dir)) die(`profile "${name}" does not exist`);
968
1058
  process.stdout.write(envScript(dir, syntax));
969
1059
  }
970
1060
 
971
1061
  function describeCurrent(cur: Current): string {
972
- const label = cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
1062
+ const label = currentLabel(cur);
973
1063
  const how = cur.setBy === "hook" ? "shell hook" : cur.setBy === "manual" ? "manual pin" : "nothing pinned";
974
1064
  return `${label} (${how})`;
975
1065
  }
976
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
+
977
1072
  function cmdCurrent(L: Layout, args: string[]): void {
978
- const f = parseFlags(args, ["--json"], []);
1073
+ const f = parseFlags(args, ["--json", "--name"], []);
979
1074
  const cur = currentProfile(L);
1075
+ const label = currentLabel(cur);
1076
+ if (f.bools.has("--name")) {
1077
+ console.log(label);
1078
+ return;
1079
+ }
980
1080
  const pin = resolvePin(process.cwd());
981
1081
  if (f.bools.has("--json")) {
982
1082
  console.log(JSON.stringify({ ...cur, pin: pin ?? null }, null, 2));
983
1083
  return;
984
1084
  }
985
- const label = cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
986
1085
  console.log(`${c.bold(label)} ${c.dim(shortHome(cur.dir, L.home, L.platform))}`);
987
1086
  if (cur.setBy === "hook")
988
1087
  console.log(`set by: shell hook${pin ? ` (${PIN_FILE} in ${shortHome(pin.dir, L.home, L.platform)})` : ""}`);
@@ -1042,28 +1141,83 @@ function cmdLocal(L: Layout, args: string[]): void {
1042
1141
  /** The shell hook. Pure parameter expansion and builtins: it runs on every
1043
1142
  * directory change (zsh chpwd) or prompt (bash PROMPT_COMMAND), so no
1044
1143
  * subprocess is allowed here. Logic mirrors resolvePin(). */
1045
- export type Shell = "zsh" | "bash" | "powershell";
1046
- 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"];
1047
1146
 
1048
1147
  /** The shell to name in hints and to default `shell-init` to. */
1049
1148
  export function defaultShell(env: Env = process.env, platform: NodeJS.Platform = process.platform): Shell {
1050
1149
  if (platform === "win32") return env.MSYSTEM ? "bash" : "powershell";
1051
1150
  const name = env.SHELL ? posix.basename(env.SHELL) : "";
1052
- if (name === "zsh" || name === "bash") return name;
1151
+ if (name === "zsh" || name === "bash" || name === "fish") return name;
1053
1152
  return platform === "darwin" ? "zsh" : "bash";
1054
1153
  }
1055
1154
 
1056
1155
  /** The one line that loads the hook in a shell's rc file. */
1057
1156
  export function hookHint(shell: Shell): string {
1058
1157
  if (shell === "powershell") return "claudep shell-init powershell | Out-String | Invoke-Expression";
1158
+ if (shell === "fish") return "claudep shell-init fish | source";
1059
1159
  return `eval "$(claudep shell-init ${shell})"`;
1060
1160
  }
1061
1161
 
1062
- export type EnvSyntax = "sh" | "powershell";
1162
+ export type EnvSyntax = "sh" | "fish" | "powershell";
1063
1163
 
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";
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, "\\'")}'`;
1067
1221
  }
1068
1222
 
1069
1223
  /** The `claudep env` script: pin the shell to `dir`, or clear the pin. */
@@ -1073,12 +1227,81 @@ export function envScript(dir: string | undefined, syntax: EnvSyntax): string {
1073
1227
  // Clearing CLAUDEP_AUTO turns this into a manual pin the shell hook will not touch.
1074
1228
  return `$env:CLAUDE_CONFIG_DIR = '${dir.replace(/'/g, "''")}'\nRemove-Item Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue\n`;
1075
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
+ }
1076
1237
  if (dir === undefined) return "unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO\n";
1077
1238
  return `export CLAUDE_CONFIG_DIR='${dir.replace(/'/g, `'\\''`)}'\nunset CLAUDEP_AUTO\n`;
1078
1239
  }
1079
1240
 
1080
1241
  export function shellInit(shell: Shell, profilesRoot: string, platform: NodeJS.Platform = process.platform): string {
1081
- 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
+ `;
1082
1305
  }
1083
1306
 
1084
1307
  /** The PowerShell hook, for Windows PowerShell 5.1 and PowerShell 7. Wraps
@@ -1196,7 +1419,7 @@ _claudep_auto
1196
1419
 
1197
1420
  function cmdShellInit(L: Layout, args: string[]): void {
1198
1421
  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`);
1422
+ if (!SHELLS.includes(shell as Shell)) die(`unsupported shell "${shell}". Use ${SHELLS.join(", ")}`);
1200
1423
  process.stdout.write(shellInit(shell as Shell, L.profilesRoot, L.platform));
1201
1424
  }
1202
1425
 
@@ -1209,6 +1432,7 @@ function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undef
1209
1432
 
1210
1433
  async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1211
1434
  const names = args[0] ? [args[0]] : listProfileNames(L);
1435
+ let problems = 0;
1212
1436
  const launch = findClaude();
1213
1437
  if (launch && launch.kind !== "ps1") {
1214
1438
  const { out } = await captureClaude(L, undefined, ["--version"]);
@@ -1217,11 +1441,29 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1217
1441
  console.log(
1218
1442
  `${c.dim("·")} claude is the npm cmd shim and runs through cmd.exe; the native installer's claude.exe avoids that hop`,
1219
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
+ }
1220
1452
  } else if (launch) bad(`only ${launch.bin} found; claudep needs claude.exe or claude.cmd`);
1221
1453
  else bad("claude binary not found on PATH");
1222
1454
  ok(`base: ${L.base}${L.callerConfigDir && !L.managed ? c.yellow(" (from CLAUDE_CONFIG_DIR in your shell)") : ""}`);
1223
1455
  const msysWarning = msysConfigDirWarning(L);
1224
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`);
1225
1467
  ok(`profiles root: ${L.profilesRoot}`);
1226
1468
 
1227
1469
  const shared = sharedItems(L.base);
@@ -1235,7 +1477,6 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1235
1477
  warn(`base items neither shared nor known-private (they stay per-profile): ${unclassified.join(", ")}`);
1236
1478
  }
1237
1479
 
1238
- let problems = 0;
1239
1480
  for (const name of names) {
1240
1481
  const dir = profileDir(L, name);
1241
1482
  console.log(`\n${c.bold(name)} ${c.dim(dir)}`);
@@ -1294,6 +1535,18 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
1294
1535
  samePath(real, L.home, L.platform)
1295
1536
  )
1296
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
+ );
1297
1550
  if (!f.bools.has("--yes")) {
1298
1551
  const yes = confirm(`Remove profile "${name}" (${dir})? Shared items are only unlinked; ${L.base} is untouched.`);
1299
1552
  if (!yes) {
@@ -1335,12 +1588,13 @@ function help(L: Layout): void {
1335
1588
  ${c.bold("USAGE")}
1336
1589
  claudep <name> [claude args…] run claude with profile <name> (alias for "run")
1337
1590
  claudep init <name> [options] create/update a profile and log in
1338
- 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
1339
1592
  claudep status <name> [--json] login state for one profile ("default" = ~/.claude)
1340
- claudep current [--json] which profile this shell is on, and why
1341
- 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
1342
1596
  claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
1343
- claudep doctor [name] verify symlinks, keychain entry, unclassified files
1597
+ claudep doctor [name] verify symlinks, keychain entry, unclassified files, Claude Code version
1344
1598
  claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
1345
1599
  claudep --version print the version
1346
1600
 
@@ -1348,7 +1602,7 @@ ${c.bold("DIRECTORY PINS")}
1348
1602
  claudep local <name> [--force] write ./${PIN_FILE} so this tree uses <name>; --remove deletes it
1349
1603
  claudep local show the pin that applies to the current directory
1350
1604
  claudep resolve [dir] [--json] print the profile pinned for a directory (exit 1 when none)
1351
- 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
1352
1606
 
1353
1607
  ${c.bold("INIT OPTIONS")}
1354
1608
  --sso force the SSO login flow (Enterprise orgs)
@@ -1365,9 +1619,11 @@ ${c.bold("EXAMPLES")}
1365
1619
  claude # Claude Code as whatever ~/.claude is logged in as
1366
1620
  claudep enterprise -p "summarize this repo"
1367
1621
  eval "$(claudep env enterprise)" # pin the whole shell to a profile
1622
+ claudep env enterprise | source # the same from fish
1368
1623
  claudep env enterprise | Invoke-Expression # the same from PowerShell
1369
1624
  claudep local enterprise # pin this repo; commit the ${PIN_FILE} file for the team
1370
1625
  eval "$(claudep shell-init zsh)" # in .zshrc: shells follow ${PIN_FILE} pins on cd
1626
+ ${hookHint("fish")} # the same line for config.fish
1371
1627
  ${hookHint("powershell")} # the same line for $PROFILE
1372
1628
 
1373
1629
  ${c.bold("HOW IT WORKS")}
@@ -1416,7 +1672,7 @@ export async function main(argv: string[]): Promise<void> {
1416
1672
  return cmdRun(L, args);
1417
1673
  case "list":
1418
1674
  case "ls":
1419
- return cmdList(L);
1675
+ return cmdList(L, args);
1420
1676
  case "status":
1421
1677
  return cmdStatus(L, args);
1422
1678
  case "env":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bordoni/claudep",
3
- "version": "0.2.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",