@bordoni/claudep 0.2.1 → 0.4.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,29 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-09-14
10
+
11
+ ### Added
12
+
13
+ - `claudep completion zsh|bash|fish` prints tab completions for every subcommand, its flags, the values `--shell`, `shell-init` and `completion` take, and your profile names. Load it with `eval "$(claudep completion zsh)"` after `compinit`, `eval "$(claudep completion bash)"`, or `claudep completion fish | source`. Profile names are read from `~/.claudep` when you press Tab, so the script never runs claudep.
14
+
15
+ ### Changed
16
+
17
+ - **Breaking:** `completion` is now a reserved word. A profile named `completion` stops resolving; recreate it under another name. Reserved words, flag parsing and the completion scripts now come from one command table, so they cannot drift apart.
18
+
19
+ ## [0.3.0] - 2026-09-10
20
+
21
+ ### Added
22
+
23
+ - 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.
24
+ - `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.
25
+ - `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.
26
+ - `claudep list --json` prints the same flat objects as `claudep status --json`, one per profile.
27
+ - `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.
28
+ - `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.
29
+ - `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.
30
+ - CI runs the shell hook in fish on macOS and Linux.
31
+
9
32
  ## [0.2.1] - 2026-09-08
10
33
 
11
34
  ### Changed
@@ -63,7 +86,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
63
86
  - Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
64
87
  - 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
88
 
66
- [Unreleased]: https://github.com/bordoni/claudep/compare/0.2.1...HEAD
89
+ [Unreleased]: https://github.com/bordoni/claudep/compare/0.4.0...HEAD
90
+ [0.4.0]: https://github.com/bordoni/claudep/compare/0.3.0...0.4.0
91
+ [0.3.0]: https://github.com/bordoni/claudep/compare/0.2.1...0.3.0
67
92
  [0.2.1]: https://github.com/bordoni/claudep/compare/0.2.0...0.2.1
68
93
  [0.2.0]: https://github.com/bordoni/claudep/compare/0.1.1...0.2.0
69
94
  [0.1.1]: https://github.com/bordoni/claudep/compare/0.1.0...0.1.1
package/README.md CHANGED
@@ -54,16 +54,17 @@ 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
+ claudep completion [zsh|bash|fish] print tab completions for commands, flags and profile names
67
68
  claudep --version
68
69
  ```
69
70
 
@@ -84,20 +85,57 @@ Shells apply pins through a hook. Add one line to `~/.zshrc` (or `~/.bashrc` wit
84
85
  eval "$(claudep shell-init zsh)"
85
86
  ```
86
87
 
87
- In PowerShell the line goes in `$PROFILE`:
88
+ In fish the line goes in `~/.config/fish/config.fish`, and in PowerShell in `$PROFILE`:
89
+
90
+ ```fish
91
+ claudep shell-init fish | source
92
+ ```
88
93
 
89
94
  ```powershell
90
95
  claudep shell-init powershell | Out-String | Invoke-Expression
91
96
  ```
92
97
 
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:
98
+ 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
99
 
95
100
  - 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)"`.
101
+ - 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
102
  - A pin that names a profile you have not created prints one warning per directory change and sets nothing.
98
103
 
104
+ `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.
105
+
99
106
  `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
107
 
108
+ ## Tab completion
109
+
110
+ One line in your rc file completes subcommands, their flags, the values `--shell` and `shell-init` take, and your profile names:
111
+
112
+ ```sh
113
+ eval "$(claudep completion zsh)" # ~/.zshrc, after compinit
114
+ eval "$(claudep completion bash)" # ~/.bashrc
115
+ ```
116
+
117
+ ```fish
118
+ claudep completion fish | source # ~/.config/fish/config.fish
119
+ ```
120
+
121
+ Profile names come from a directory listing of `~/.claudep` at the moment you press Tab, so the script never runs claudep and a new profile shows up without reloading anything. In zsh the same output also works as a file: `claudep completion zsh > ~/.zfunc/_claudep` with `~/.zfunc` on `fpath` before `compinit`, if you prefer autoloading over `eval`.
122
+
123
+ ## Show the profile in your prompt or statusline
124
+
125
+ In a shell prompt use the variable itself; no command runs:
126
+
127
+ ```sh
128
+ # zsh or bash: the last path segment is the profile name when a profile is active
129
+ PROMPT='${CLAUDE_CONFIG_DIR:+[${CLAUDE_CONFIG_DIR##*/}] }'"$PROMPT"
130
+ ```
131
+
132
+ ```fish
133
+ # fish
134
+ function fish_prompt; set -q CLAUDE_CONFIG_DIR; and echo -n "[$(string replace -r '.*/' '' -- $CLAUDE_CONFIG_DIR)] "; ...; end
135
+ ```
136
+
137
+ 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.
138
+
101
139
  ## How it works
102
140
 
103
141
  `~/.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 +157,9 @@ The shared list is an allowlist, so an account-specific file cannot leak across
119
157
  - Two profiles running at the same time write the same `settings.json` and `plugins/`. That is the same situation as two terminals today.
120
158
  - 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
159
  - 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.
160
+ - Never put `CLAUDE_CONFIG_DIR` in a `settings.json` `env` block. Claude Code detects that mismatch and disables features; `claudep doctor` reports it.
161
+ - `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.
162
+ - `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
163
 
124
164
  ## Releases
125
165
 
package/claudep.ts CHANGED
@@ -317,27 +317,87 @@ export const SEED_KEYS = [
317
317
  ] as const;
318
318
 
319
319
  export const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
320
+
321
+ /** Shells with a directory-pin hook, and the subset with tab completions. */
322
+ export type Shell = "zsh" | "bash" | "fish" | "powershell";
323
+ export const SHELLS: readonly Shell[] = ["zsh", "bash", "fish", "powershell"];
324
+ export const COMPLETION_SHELLS = ["zsh", "bash", "fish"] as const;
325
+ export type CompletionShell = (typeof COMPLETION_SHELLS)[number];
326
+ /** What `claudep env --shell` accepts. */
327
+ export const ENV_SHELLS = ["sh", "zsh", "bash", "fish", "powershell"] as const;
328
+
329
+ export type ValueFlag = { name: string; values?: readonly string[] };
330
+ export type Command = {
331
+ name: string;
332
+ /** One line, no colons: it becomes the zsh _describe and fish -d text. */
333
+ desc: string;
334
+ aliases?: readonly string[];
335
+ /** What the first positional completes to. */
336
+ arg?: "profile" | "dir" | readonly string[];
337
+ flags?: readonly string[];
338
+ valueFlags?: readonly ValueFlag[];
339
+ /** A real command that the top-level completion does not offer. */
340
+ hidden?: boolean;
341
+ };
342
+
343
+ /** One table for the dispatcher's reserved words, every parseFlags call and
344
+ * the completion scripts, so none of them can drift from the others. */
345
+ export const COMMANDS: readonly Command[] = [
346
+ {
347
+ name: "init",
348
+ desc: "create or update a profile and log in",
349
+ arg: "profile",
350
+ flags: ["--sso", "--console", "--copy-mcp", "--no-login", "--force"],
351
+ valueFlags: [{ name: "--email" }, { name: "--alias" }],
352
+ },
353
+ { name: "run", desc: "run claude with a profile", arg: "profile", hidden: true },
354
+ { name: "list", desc: "show every profile and who it is logged in as", aliases: ["ls"], flags: ["--json"] },
355
+ { name: "status", desc: "login state for one profile", arg: "profile", flags: ["--json"] },
356
+ { name: "current", desc: "which profile this shell is on, and why", flags: ["--json", "--name"] },
357
+ {
358
+ name: "env",
359
+ desc: "print the CLAUDE_CONFIG_DIR pin, or the unset, for your shell",
360
+ arg: "profile",
361
+ flags: ["--unset"],
362
+ valueFlags: [{ name: "--shell", values: ENV_SHELLS }],
363
+ },
364
+ { name: "alias", desc: "write a shim command that runs claudep with a profile", arg: "profile" },
365
+ { name: "doctor", desc: "verify symlinks, keychain entry, unclassified files, Claude Code version", arg: "profile" },
366
+ {
367
+ name: "rm",
368
+ desc: "log out and delete a profile",
369
+ aliases: ["remove"],
370
+ arg: "profile",
371
+ flags: ["--keep-login", "--yes"],
372
+ },
373
+ { name: "local", desc: "pin this directory tree to a profile", arg: "profile", flags: ["--remove", "--force"] },
374
+ { name: "resolve", desc: "print the profile pinned for a directory", arg: "dir", flags: ["--json"] },
375
+ { name: "shell-init", desc: "print the hook that applies pins on cd", arg: SHELLS },
376
+ { name: "completion", desc: "print tab completions for a shell", arg: COMPLETION_SHELLS },
377
+ { name: "help", desc: "show the help" },
378
+ { name: "version", desc: "print the version", aliases: ["--version"] },
379
+ ];
380
+
381
+ /** The words a completion offers for a command: its name and the aliases
382
+ * that are words (a `--version` alias is a flag, not a word). */
383
+ export function commandWords(c: Command): string[] {
384
+ return [c.name, ...(c.aliases ?? [])].filter((w) => !w.startsWith("-"));
385
+ }
386
+
387
+ /** Profile names that would collide with the dispatcher. Derived from the
388
+ * table plus the two names for the base. */
320
389
  export const RESERVED = new Set([
321
390
  "default",
322
391
  "base",
323
- "init",
324
- "run",
325
- "list",
326
- "ls",
327
- "status",
328
- "env",
329
- "doctor",
330
- "rm",
331
- "remove",
332
- "alias",
333
- "help",
334
- "current",
335
- "local",
336
- "resolve",
337
- "shell-init",
338
- "version",
392
+ ...COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]).filter((w) => NAME_RE.test(w)),
339
393
  ]);
340
394
 
395
+ /** The boolean and value flag names a command accepts, for parseFlags. */
396
+ export function flagSpec(name: string): [readonly string[], readonly string[]] {
397
+ const c = COMMANDS.find((x) => x.name === name);
398
+ return [c?.flags ?? [], (c?.valueFlags ?? []).map((v) => v.name)];
399
+ }
400
+
341
401
  // ---------------------------------------------------------------------------
342
402
  // Output helpers
343
403
  // ---------------------------------------------------------------------------
@@ -521,6 +581,49 @@ export async function readJson(path: string): Promise<JsonObject | undefined> {
521
581
  }
522
582
  }
523
583
 
584
+ /** Variables Claude Code uses instead of the login in its config dir when
585
+ * they are set (always in -p mode). They make a profile's login moot. */
586
+ export const AUTH_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"] as const;
587
+
588
+ /** The AUTH_ENV names that are set and non-empty. Windows environments are
589
+ * case-insensitive, so any spelling counts there. */
590
+ export function authEnvOverrides(env: Env = process.env, platform: NodeJS.Platform = process.platform): string[] {
591
+ return AUTH_ENV.filter((name) => {
592
+ if (platform !== "win32") return Boolean(env[name]);
593
+ return Object.keys(env).some((k) => k.toUpperCase() === name && Boolean(env[k]));
594
+ });
595
+ }
596
+
597
+ /** The oldest Claude Code whose macOS Keychain item is namespaced per config
598
+ * dir. Before it every config dir shared one login. */
599
+ export const MIN_CLAUDE_VERSION = "2.1.144";
600
+
601
+ export type Version = [number, number, number];
602
+
603
+ /** The first x.y.z in `claude --version` output. */
604
+ export function parseVersion(text: string): Version | undefined {
605
+ const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
606
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
607
+ }
608
+
609
+ export function versionBelow(a: Version, b: Version): boolean {
610
+ for (let i = 0; i < 3; i++) {
611
+ if (a[i] !== b[i]) return (a[i] as number) < (b[i] as number);
612
+ }
613
+ return false;
614
+ }
615
+
616
+ /** `env.CLAUDE_CONFIG_DIR` from a settings file, when present and a string.
617
+ * Claude Code disables features when it finds one that differs from the
618
+ * active dir, and under claudep it always differs for some profile. */
619
+ export async function settingsEnvConfigDir(settingsPath: string): Promise<string | undefined> {
620
+ const settings = await readJson(settingsPath);
621
+ const env = settings?.env;
622
+ if (!env || typeof env !== "object" || Array.isArray(env)) return undefined;
623
+ const value = (env as JsonObject).CLAUDE_CONFIG_DIR;
624
+ return typeof value === "string" ? value : undefined;
625
+ }
626
+
524
627
  export type SeedResult = "seeded" | "exists" | "no-base";
525
628
 
526
629
  export async function seedGlobalJson(baseGlobalJson: string, dir: string, copyMcp: boolean): Promise<SeedResult> {
@@ -774,7 +877,7 @@ export function parseFlags(args: string[], boolNames: readonly string[], strName
774
877
  // ---------------------------------------------------------------------------
775
878
 
776
879
  async function cmdInit(L: Layout, args: string[]): Promise<void> {
777
- const f = parseFlags(args, ["--copy-mcp", "--no-login", "--sso", "--console", "--force"], ["--email", "--alias"]);
880
+ const f = parseFlags(args, ...flagSpec("init"));
778
881
  const name = f.rest[0];
779
882
  if (!name)
780
883
  die(
@@ -915,6 +1018,8 @@ async function cmdRun(L: Layout, args: string[]): Promise<never> {
915
1018
  if (name === "default" || name === "base") return execClaude(L, undefined, rest);
916
1019
  const dir = profileDir(L, name);
917
1020
  if (!existsSync(dir)) die(`profile "${name}" does not exist. Run: claudep init ${name}`);
1021
+ for (const v of authEnvOverrides(process.env, L.platform))
1022
+ console.error(`claudep: ${v} is set; Claude Code will use it instead of the "${name}" login`);
918
1023
  return execClaude(L, dir, rest);
919
1024
  }
920
1025
 
@@ -952,58 +1057,91 @@ function printTable(rows: Row[], home: string, platform: NodeJS.Platform): void
952
1057
  });
953
1058
  }
954
1059
 
955
- async function cmdList(L: Layout): Promise<void> {
1060
+ /** The flat object `status --json` prints for one row; `list --json` prints an array of them. */
1061
+ function rowJson(row: Row): JsonObject {
1062
+ return { name: row.name, dir: row.dir, ...row.status };
1063
+ }
1064
+
1065
+ async function cmdList(L: Layout, args: string[]): Promise<void> {
1066
+ const f = parseFlags(args, ...flagSpec("list"));
956
1067
  const names = ["default", ...listProfileNames(L)];
957
- printTable(await collectRows(L, names), L.home, L.platform);
1068
+ const rows = await collectRows(L, names);
1069
+ if (f.bools.has("--json")) {
1070
+ console.log(JSON.stringify(rows.map(rowJson), null, 2));
1071
+ return;
1072
+ }
1073
+ printTable(rows, L.home, L.platform);
958
1074
  if (names.length === 1) console.log(c.dim("\nNo profiles yet. Create one: claudep init <name>"));
959
1075
  const cur = currentProfile(L);
960
1076
  if (cur.kind !== "base") console.log(c.dim(`\nactive in this shell: ${describeCurrent(cur)}`));
961
1077
  }
962
1078
 
963
1079
  async function cmdStatus(L: Layout, args: string[]): Promise<void> {
964
- const f = parseFlags(args, ["--json"], []);
1080
+ const f = parseFlags(args, ...flagSpec("status"));
965
1081
  const name = f.rest[0];
966
1082
  if (!name) die("usage: claudep status <name> [--json]");
967
1083
  if (name !== "default" && !profileExists(L, name)) die(`profile "${name}" does not exist`);
968
1084
  const [row] = await collectRows(L, [name]);
969
1085
  if (!row) return;
970
- if (f.bools.has("--json")) console.log(JSON.stringify({ name: row.name, dir: row.dir, ...row.status }, null, 2));
1086
+ if (f.bools.has("--json")) console.log(JSON.stringify(rowJson(row), null, 2));
971
1087
  else printTable([row], L.home, L.platform);
972
1088
  }
973
1089
 
1090
+ function envUsage(syntax: EnvSyntax): string {
1091
+ if (syntax === "powershell")
1092
+ return "usage: claudep env <name> | Invoke-Expression or claudep env --unset | Invoke-Expression";
1093
+ if (syntax === "fish") return "usage: claudep env <name> | source or claudep env --unset | source";
1094
+ return 'usage: eval "$(claudep env <name>)" or eval "$(claudep env --unset)" (add --shell fish|powershell|sh when the shell was not detected)';
1095
+ }
1096
+
1097
+ /** The line that clears a pin, in the syntax of the shell the user is in. */
1098
+ export function envUnsetHint(syntax: EnvSyntax): string {
1099
+ if (syntax === "powershell") return "claudep env --unset | Invoke-Expression";
1100
+ if (syntax === "fish") return "claudep env --unset | source";
1101
+ return 'eval "$(claudep env --unset)"';
1102
+ }
1103
+
974
1104
  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") {
1105
+ const f = parseFlags(args, ...flagSpec("env"));
1106
+ const forced = f.strs.get("--shell");
1107
+ const syntax =
1108
+ forced === undefined ? shellSyntax(process.env, L.platform, parentProcessName(L.platform)) : envSyntaxOf(forced);
1109
+ if (!syntax) die(`unsupported shell "${forced}". Use sh, zsh, bash, fish or powershell`);
1110
+ if (f.bools.has("--unset")) {
984
1111
  process.stdout.write(envScript(undefined, syntax));
985
1112
  return;
986
1113
  }
1114
+ const name = f.rest[0];
1115
+ if (!name) die(envUsage(syntax));
987
1116
  const dir = profileDir(L, name);
988
1117
  if (!existsSync(dir)) die(`profile "${name}" does not exist`);
989
1118
  process.stdout.write(envScript(dir, syntax));
990
1119
  }
991
1120
 
992
1121
  function describeCurrent(cur: Current): string {
993
- const label = cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
1122
+ const label = currentLabel(cur);
994
1123
  const how = cur.setBy === "hook" ? "shell hook" : cur.setBy === "manual" ? "manual pin" : "nothing pinned";
995
1124
  return `${label} (${how})`;
996
1125
  }
997
1126
 
1127
+ /** The one-word answer: the profile name, `default` for the base, `custom` otherwise. */
1128
+ export function currentLabel(cur: Current): string {
1129
+ return cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
1130
+ }
1131
+
998
1132
  function cmdCurrent(L: Layout, args: string[]): void {
999
- const f = parseFlags(args, ["--json"], []);
1133
+ const f = parseFlags(args, ...flagSpec("current"));
1000
1134
  const cur = currentProfile(L);
1135
+ const label = currentLabel(cur);
1136
+ if (f.bools.has("--name")) {
1137
+ console.log(label);
1138
+ return;
1139
+ }
1001
1140
  const pin = resolvePin(process.cwd());
1002
1141
  if (f.bools.has("--json")) {
1003
1142
  console.log(JSON.stringify({ ...cur, pin: pin ?? null }, null, 2));
1004
1143
  return;
1005
1144
  }
1006
- const label = cur.kind === "profile" ? (cur.name ?? "?") : cur.kind === "custom" ? "custom" : "default";
1007
1145
  console.log(`${c.bold(label)} ${c.dim(shortHome(cur.dir, L.home, L.platform))}`);
1008
1146
  if (cur.setBy === "hook")
1009
1147
  console.log(`set by: shell hook${pin ? ` (${PIN_FILE} in ${shortHome(pin.dir, L.home, L.platform)})` : ""}`);
@@ -1022,7 +1160,7 @@ function cmdCurrent(L: Layout, args: string[]): void {
1022
1160
  }
1023
1161
 
1024
1162
  function cmdResolve(L: Layout, args: string[]): void {
1025
- const f = parseFlags(args, ["--json"], []);
1163
+ const f = parseFlags(args, ...flagSpec("resolve"));
1026
1164
  const start = f.rest[0] ?? process.cwd();
1027
1165
  const pin = resolvePin(start);
1028
1166
  if (!pin || pin.name === "") process.exit(1);
@@ -1033,7 +1171,7 @@ function cmdResolve(L: Layout, args: string[]): void {
1033
1171
  }
1034
1172
 
1035
1173
  function cmdLocal(L: Layout, args: string[]): void {
1036
- const f = parseFlags(args, ["--remove", "--force"], []);
1174
+ const f = parseFlags(args, ...flagSpec("local"));
1037
1175
  const file = join(process.cwd(), PIN_FILE);
1038
1176
  if (f.bools.has("--remove")) {
1039
1177
  if (!existsSync(file)) die(`no ${PIN_FILE} in ${process.cwd()}`);
@@ -1062,29 +1200,83 @@ function cmdLocal(L: Layout, args: string[]): void {
1062
1200
 
1063
1201
  /** The shell hook. Pure parameter expansion and builtins: it runs on every
1064
1202
  * directory change (zsh chpwd) or prompt (bash PROMPT_COMMAND), so no
1065
- * subprocess is allowed here. Logic mirrors resolvePin(). */
1066
- export type Shell = "zsh" | "bash" | "powershell";
1067
- export const SHELLS: readonly Shell[] = ["zsh", "bash", "powershell"];
1203
+ * subprocess is allowed here. Logic mirrors resolvePin(). `Shell` and
1204
+ * `SHELLS` live next to the command table. */
1068
1205
 
1069
1206
  /** The shell to name in hints and to default `shell-init` to. */
1070
1207
  export function defaultShell(env: Env = process.env, platform: NodeJS.Platform = process.platform): Shell {
1071
1208
  if (platform === "win32") return env.MSYSTEM ? "bash" : "powershell";
1072
1209
  const name = env.SHELL ? posix.basename(env.SHELL) : "";
1073
- if (name === "zsh" || name === "bash") return name;
1210
+ if (name === "zsh" || name === "bash" || name === "fish") return name;
1074
1211
  return platform === "darwin" ? "zsh" : "bash";
1075
1212
  }
1076
1213
 
1077
1214
  /** The one line that loads the hook in a shell's rc file. */
1078
1215
  export function hookHint(shell: Shell): string {
1079
1216
  if (shell === "powershell") return "claudep shell-init powershell | Out-String | Invoke-Expression";
1217
+ if (shell === "fish") return "claudep shell-init fish | source";
1080
1218
  return `eval "$(claudep shell-init ${shell})"`;
1081
1219
  }
1082
1220
 
1083
- export type EnvSyntax = "sh" | "powershell";
1221
+ export type EnvSyntax = "sh" | "fish" | "powershell";
1084
1222
 
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";
1223
+ /** The executable name of the parent process, which for `claudep env` is
1224
+ * the shell the user typed it in: `eval "$(...)"` forks the shell and a
1225
+ * fish pipeline runs claudep as a child of fish. Read from /proc on Linux
1226
+ * and from `ps` on macOS; undefined elsewhere or when it cannot be read.
1227
+ * This is `claudep env`, not the hook, so one `ps` is fine. */
1228
+ export function parentProcessName(
1229
+ platform: NodeJS.Platform = process.platform,
1230
+ ppid: number = process.ppid,
1231
+ ): string | undefined {
1232
+ try {
1233
+ if (platform === "linux") return readFileSync(`/proc/${ppid}/comm`, "utf8").trim() || undefined;
1234
+ if (platform === "darwin") {
1235
+ const r = Bun.spawnSync(["ps", "-o", "comm=", "-p", String(ppid)], { stdout: "pipe", stderr: "ignore" });
1236
+ const name = r.exitCode === 0 ? r.stdout.toString().trim() : "";
1237
+ return name || undefined;
1238
+ }
1239
+ } catch {
1240
+ /* fall through */
1241
+ }
1242
+ return undefined;
1243
+ }
1244
+
1245
+ /** `fish` from `fish`, `-fish` (a login shell) or `/usr/local/bin/fish`. */
1246
+ export function shellNameOf(comm: string): string {
1247
+ return posix.basename(comm.trim()).replace(/^-/, "");
1248
+ }
1249
+
1250
+ const SH_LIKE = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash"]);
1251
+
1252
+ /** What `claudep env` prints. PowerShell on Windows outside Git Bash. Then the
1253
+ * parent process decides when it is a shell claudep knows (fish, pwsh, or an
1254
+ * sh-like shell); otherwise the login shell in $SHELL: fish when that is
1255
+ * fish, sh for anything else. fish exports no marker a child could see,
1256
+ * which is why the parent is consulted at all. `--shell` overrides all of it. */
1257
+ export function shellSyntax(
1258
+ env: Env = process.env,
1259
+ platform: NodeJS.Platform = process.platform,
1260
+ parent: string | undefined = undefined,
1261
+ ): EnvSyntax {
1262
+ if (platform === "win32" && !env.MSYSTEM) return "powershell";
1263
+ const p = parent !== undefined ? shellNameOf(parent) : "";
1264
+ if (p === "fish") return "fish";
1265
+ if (p === "pwsh" || p === "powershell") return "powershell";
1266
+ if (SH_LIKE.has(p)) return "sh";
1267
+ return env.SHELL && posix.basename(env.SHELL) === "fish" ? "fish" : "sh";
1268
+ }
1269
+
1270
+ /** The syntax for a `--shell` value: the shell-init names plus `sh`. */
1271
+ export function envSyntaxOf(name: string): EnvSyntax | undefined {
1272
+ if (name === "sh" || name === "zsh" || name === "bash") return "sh";
1273
+ if (name === "fish" || name === "powershell") return name;
1274
+ return undefined;
1275
+ }
1276
+
1277
+ /** fish single-quoting: only \ and ' are special inside single quotes. */
1278
+ export function fishQuote(s: string): string {
1279
+ return `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
1088
1280
  }
1089
1281
 
1090
1282
  /** The `claudep env` script: pin the shell to `dir`, or clear the pin. */
@@ -1094,12 +1286,81 @@ export function envScript(dir: string | undefined, syntax: EnvSyntax): string {
1094
1286
  // Clearing CLAUDEP_AUTO turns this into a manual pin the shell hook will not touch.
1095
1287
  return `$env:CLAUDE_CONFIG_DIR = '${dir.replace(/'/g, "''")}'\nRemove-Item Env:CLAUDEP_AUTO -ErrorAction SilentlyContinue\n`;
1096
1288
  }
1289
+ if (syntax === "fish") {
1290
+ // A bare `set -e` on a missing name returns 1 and `source` reports its
1291
+ // last status, which prompt themes paint red; the guard keeps it at 0.
1292
+ if (dir === undefined)
1293
+ return "not set -q CLAUDE_CONFIG_DIR; or set -e CLAUDE_CONFIG_DIR\nnot set -q CLAUDEP_AUTO; or set -e CLAUDEP_AUTO\n";
1294
+ return `set -gx CLAUDE_CONFIG_DIR ${fishQuote(dir)}\nnot set -q CLAUDEP_AUTO; or set -e CLAUDEP_AUTO\n`;
1295
+ }
1097
1296
  if (dir === undefined) return "unset CLAUDE_CONFIG_DIR CLAUDEP_AUTO\n";
1098
1297
  return `export CLAUDE_CONFIG_DIR='${dir.replace(/'/g, `'\\''`)}'\nunset CLAUDEP_AUTO\n`;
1099
1298
  }
1100
1299
 
1101
1300
  export function shellInit(shell: Shell, profilesRoot: string, platform: NodeJS.Platform = process.platform): string {
1102
- return shell === "powershell" ? powershellHook(profilesRoot) : shHook(shell, profilesRoot, platform);
1301
+ if (shell === "powershell") return powershellHook(profilesRoot);
1302
+ if (shell === "fish") return fishHook(profilesRoot);
1303
+ return shHook(shell, profilesRoot, platform);
1304
+ }
1305
+
1306
+ /** The fish hook. Builtins only (set, test, read, printf, string); every
1307
+ * `(...)` is a `string` builtin, which runs in-process. Needs fish 3.0:
1308
+ * `path dirname` would need 3.5 and `$(...)` 3.4, and Ubuntu 22.04 ships
1309
+ * 3.3. fish fires --on-variable on every `set` of PWD, `cd .` included, so
1310
+ * the last-pwd dedupe stays. Not a Windows target; joins with `/`. */
1311
+ function fishHook(profilesRoot: string): string {
1312
+ return `# claudep shell hook. Load it from ~/.config/fish/config.fish: ${hookHint("fish")}
1313
+ set -g _claudep_root ${fishQuote(profilesRoot)}
1314
+ function _claudep_auto --on-variable PWD
1315
+ test "$PWD" = "$_claudep_last_pwd"; and return 0
1316
+ set -g _claudep_last_pwd $PWD
1317
+ # Only manage a CLAUDE_CONFIG_DIR this hook set itself. A manual pin wins.
1318
+ if test -n "$CLAUDE_CONFIG_DIR"; and test "$CLAUDE_CONFIG_DIR" != "$CLAUDEP_AUTO"
1319
+ return 0
1320
+ end
1321
+ set -l dir $PWD
1322
+ set -l name ''
1323
+ set -l found ''
1324
+ while true
1325
+ if test -f "$dir/${PIN_FILE}"
1326
+ set found $dir
1327
+ test -n "$found"; or set found /
1328
+ while read -l line
1329
+ set line (string trim -- $line)
1330
+ if test -z "$line"; or string match -q -- '#*' $line
1331
+ continue
1332
+ end
1333
+ set name $line
1334
+ break
1335
+ end < "$dir/${PIN_FILE}"
1336
+ break
1337
+ end
1338
+ if test -z "$dir"; or test "$dir" = /
1339
+ break
1340
+ end
1341
+ set dir (string replace -r -- '/[^/]*$' '' $dir)
1342
+ end
1343
+ if test -z "$name"
1344
+ # No pin here: hand the shell back to the base account.
1345
+ if test -n "$CLAUDEP_AUTO"
1346
+ set -e CLAUDE_CONFIG_DIR
1347
+ set -e CLAUDEP_AUTO
1348
+ end
1349
+ return 0
1350
+ end
1351
+ if not test -d "$_claudep_root/$name"
1352
+ if test -n "$CLAUDEP_AUTO"
1353
+ set -e CLAUDE_CONFIG_DIR
1354
+ set -e CLAUDEP_AUTO
1355
+ end
1356
+ printf 'claudep: %s/${PIN_FILE} names profile "%s", which does not exist. Run: claudep init %s\\n' "$found" "$name" "$name" >&2
1357
+ return 0
1358
+ end
1359
+ set -gx CLAUDE_CONFIG_DIR "$_claudep_root/$name"
1360
+ set -gx CLAUDEP_AUTO "$_claudep_root/$name"
1361
+ end
1362
+ _claudep_auto
1363
+ `;
1103
1364
  }
1104
1365
 
1105
1366
  /** The PowerShell hook, for Windows PowerShell 5.1 and PowerShell 7. Wraps
@@ -1217,10 +1478,190 @@ _claudep_auto
1217
1478
 
1218
1479
  function cmdShellInit(L: Layout, args: string[]): void {
1219
1480
  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`);
1481
+ if (!SHELLS.includes(shell as Shell)) die(`unsupported shell "${shell}". Use ${SHELLS.join(", ")}`);
1221
1482
  process.stdout.write(shellInit(shell as Shell, L.profilesRoot, L.platform));
1222
1483
  }
1223
1484
 
1485
+ // ---------------------------------------------------------------------------
1486
+ // Tab completions, generated from COMMANDS. Profile names come from a glob
1487
+ // over the profiles root at completion time; no script ever runs claudep.
1488
+ // ---------------------------------------------------------------------------
1489
+
1490
+ /** The one rc-file line that loads the completions. */
1491
+ export function completionHint(shell: CompletionShell): string {
1492
+ return shell === "fish" ? "claudep completion fish | source" : `eval "$(claudep completion ${shell})"`;
1493
+ }
1494
+
1495
+ const visibleCommands = () => COMMANDS.filter((c) => !c.hidden);
1496
+ const isList = (arg: Command["arg"]): arg is readonly string[] => arg !== undefined && typeof arg !== "string";
1497
+
1498
+ export function completionScript(shell: CompletionShell, profilesRoot: string): string {
1499
+ if (shell === "zsh") return zshCompletion(profilesRoot);
1500
+ if (shell === "bash") return bashCompletion(profilesRoot);
1501
+ return fishCompletion(profilesRoot);
1502
+ }
1503
+
1504
+ /** Dual-mode: `eval "$(claudep completion zsh)"` after compinit registers it
1505
+ * with compdef; saved as `_claudep` on fpath, the #compdef line and the
1506
+ * funcstack check make it autoload. Names come from a glob with (N/:t):
1507
+ * nothing on an empty root, directories only, basename. */
1508
+ function zshCompletion(profilesRoot: string): string {
1509
+ const q = `'${profilesRoot.replace(/'/g, `'\\''`)}'`;
1510
+ const noColon = (s: string) => s.replace(/:/g, "\\:");
1511
+ const cmds = visibleCommands().flatMap((c) => commandWords(c).map((w) => ` '${w}:${noColon(c.desc)}'`));
1512
+ const cases = COMMANDS.filter((c) => c.arg || c.flags || c.valueFlags).map((c) => {
1513
+ const specs: string[] = [];
1514
+ if (c.arg === "profile") specs.push("'1:profile:_claudep_profiles'");
1515
+ else if (c.arg === "dir") specs.push("'1:directory:_files -/'");
1516
+ else if (isList(c.arg)) specs.push(`'1:shell:(${c.arg.join(" ")})'`);
1517
+ if (c.name === "run") specs.push("'*:claude arguments:'");
1518
+ for (const f of c.flags ?? []) specs.push(`'${f}'`);
1519
+ for (const v of c.valueFlags ?? [])
1520
+ specs.push(`'${v.name}=-:${v.name.slice(2)}:${v.values ? `(${v.values.join(" ")})` : ""}'`);
1521
+ return ` ${commandWords(c).join("|")}) _arguments ${specs.join(" ")} ;;`;
1522
+ });
1523
+ return `#compdef claudep
1524
+ # claudep completions. Load them after compinit in ~/.zshrc: ${completionHint("zsh")}
1525
+ _claudep_profiles() {
1526
+ local -a names
1527
+ names=(default ${q}/*(N/:t))
1528
+ _describe -t profiles 'profile' names
1529
+ }
1530
+ _claudep() {
1531
+ local -a cmds
1532
+ cmds=(
1533
+ ${cmds.join("\n")}
1534
+ )
1535
+ _arguments -C '1:command:->cmd' '*::arg:->args'
1536
+ case $state in
1537
+ cmd)
1538
+ _describe -t commands 'claudep command' cmds
1539
+ _claudep_profiles
1540
+ ;;
1541
+ args)
1542
+ case $words[1] in
1543
+ ${cases.join("\n")}
1544
+ esac
1545
+ ;;
1546
+ esac
1547
+ }
1548
+ if [ "\${funcstack[1]}" = "_claudep" ]; then _claudep "$@"; else compdef _claudep claudep; fi
1549
+ `;
1550
+ }
1551
+
1552
+ /** bash 3.2 safe: no mapfile, no compopt. The root's backslashes become
1553
+ * slashes so a Git Bash user with a C:\\ root still globs. */
1554
+ function bashCompletion(profilesRoot: string): string {
1555
+ const q = `'${profilesRoot.replace(/'/g, `'\\''`)}'`;
1556
+ const top = [...visibleCommands().flatMap(commandWords), "default"].join(" ");
1557
+ const flagCases = COMMANDS.filter((c) => c.flags || c.valueFlags).map(
1558
+ (c) =>
1559
+ ` ${commandWords(c).join("|")}) words="${[...(c.flags ?? []), ...(c.valueFlags ?? []).map((v) => v.name)].join(" ")}" ;;`,
1560
+ );
1561
+ const valued = COMMANDS.flatMap((c) => (c.valueFlags ?? []).filter((v) => v.values));
1562
+ const valueBranches = valued.map(
1563
+ (v, i) => ` ${i === 0 ? "if" : "elif"} [ "$prev" = "${v.name}" ]; then words="${(v.values ?? []).join(" ")}"`,
1564
+ );
1565
+ const argCases = COMMANDS.filter((c) => c.arg).map((c) => {
1566
+ const names = commandWords(c).join("|");
1567
+ if (c.arg === "profile") return ` ${names}) words="default $(_claudep_profiles)" ;;`;
1568
+ if (c.arg === "dir") return ` ${names}) COMPREPLY=($(compgen -d -- "$cur")); return 0 ;;`;
1569
+ return ` ${names}) words="${isList(c.arg) ? c.arg.join(" ") : ""}" ;;`;
1570
+ });
1571
+ return `# claudep completions. Load them from ~/.bashrc: ${completionHint("bash")}
1572
+ _claudep_profiles() {
1573
+ local root=${q} d
1574
+ root=\${root//\\\\//}
1575
+ for d in "$root"/*/; do
1576
+ d=\${d%/}
1577
+ [ -d "$d" ] && printf '%s\\n' "\${d##*/}"
1578
+ done
1579
+ }
1580
+ _claudep() {
1581
+ local cur prev cmd words=""
1582
+ cur=\${COMP_WORDS[COMP_CWORD]}
1583
+ prev=\${COMP_WORDS[COMP_CWORD-1]}
1584
+ cmd=\${COMP_WORDS[1]}
1585
+ COMPREPLY=()
1586
+ if [ "$COMP_CWORD" -eq 1 ]; then
1587
+ COMPREPLY=($(compgen -W "${top} $(_claudep_profiles)" -- "$cur"))
1588
+ return 0
1589
+ fi
1590
+ case $cur in
1591
+ -*)
1592
+ case $cmd in
1593
+ ${flagCases.join("\n")}
1594
+ esac
1595
+ ;;
1596
+ *)
1597
+ ${valueBranches.join("\n")}
1598
+ ${valueBranches.length ? "elif" : "if"} [ "$COMP_CWORD" -eq 2 ]; then
1599
+ case $cmd in
1600
+ ${argCases.join("\n")}
1601
+ esac
1602
+ fi
1603
+ ;;
1604
+ esac
1605
+ COMPREPLY=($(compgen -W "$words" -- "$cur"))
1606
+ }
1607
+ complete -F _claudep claudep
1608
+ `;
1609
+ }
1610
+
1611
+ /** The two conditions are written out over `commandline -opc` instead of
1612
+ * fish's __fish_use_subcommand and __fish_seen_subcommand_from, whose
1613
+ * names moved between versions. `for` over an unmatched glob runs zero
1614
+ * times, so an empty root offers only `default`. */
1615
+ function fishCompletion(profilesRoot: string): string {
1616
+ const lines: string[] = [];
1617
+ for (const c of visibleCommands())
1618
+ for (const w of commandWords(c))
1619
+ lines.push(`complete -c claudep -n '__claudep_at 1' -a ${w} -d ${fishQuote(c.desc)}`);
1620
+ lines.push("complete -c claudep -n '__claudep_at 1' -a '(__claudep_profiles)' -d profile");
1621
+ const profileCmds = COMMANDS.filter((c) => c.arg === "profile")
1622
+ .flatMap(commandWords)
1623
+ .join(" ");
1624
+ lines.push(
1625
+ `complete -c claudep -n '__claudep_cmd ${profileCmds}; and __claudep_at 2' -a '(__claudep_profiles)' -d profile`,
1626
+ );
1627
+ for (const c of COMMANDS) {
1628
+ const ws = commandWords(c).join(" ");
1629
+ if (isList(c.arg))
1630
+ lines.push(`complete -c claudep -n '__claudep_cmd ${ws}; and __claudep_at 2' -a '${c.arg.join(" ")}'`);
1631
+ if (c.arg === "dir")
1632
+ lines.push(`complete -c claudep -n '__claudep_cmd ${ws}; and __claudep_at 2' -a '(__fish_complete_directories)'`);
1633
+ for (const f of c.flags ?? []) lines.push(`complete -c claudep -n '__claudep_cmd ${ws}' -l ${f.slice(2)}`);
1634
+ for (const v of c.valueFlags ?? [])
1635
+ lines.push(
1636
+ `complete -c claudep -n '__claudep_cmd ${ws}' -l ${v.name.slice(2)} -x${v.values ? ` -a '${v.values.join(" ")}'` : ""}`,
1637
+ );
1638
+ }
1639
+ return `# claudep completions. Load them from ~/.config/fish/config.fish: ${completionHint("fish")}
1640
+ function __claudep_profiles
1641
+ echo default
1642
+ for d in ${fishQuote(profilesRoot)}/*/
1643
+ string replace -r -- '^.*/([^/]+)/$' '$1' $d
1644
+ end
1645
+ end
1646
+ function __claudep_at -a n
1647
+ test (count (commandline -opc)) -eq $n
1648
+ end
1649
+ function __claudep_cmd
1650
+ set -l t (commandline -opc)
1651
+ test (count $t) -ge 2; and contains -- $t[2] $argv
1652
+ end
1653
+ complete -c claudep -f
1654
+ ${lines.join("\n")}
1655
+ `;
1656
+ }
1657
+
1658
+ function cmdCompletion(L: Layout, args: string[]): void {
1659
+ const shell = args[0] ?? defaultShell(process.env, L.platform);
1660
+ if (!COMPLETION_SHELLS.includes(shell as CompletionShell))
1661
+ die(`no completions for "${shell}". Use ${COMPLETION_SHELLS.join(", ")}`);
1662
+ process.stdout.write(completionScript(shell as CompletionShell, L.profilesRoot));
1663
+ }
1664
+
1224
1665
  /** A POSIX-style CLAUDE_CONFIG_DIR on Windows is one claude.exe cannot read. */
1225
1666
  function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undefined {
1226
1667
  const cfg = env.CLAUDE_CONFIG_DIR;
@@ -1230,6 +1671,7 @@ function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undef
1230
1671
 
1231
1672
  async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1232
1673
  const names = args[0] ? [args[0]] : listProfileNames(L);
1674
+ let problems = 0;
1233
1675
  const launch = findClaude();
1234
1676
  if (launch && launch.kind !== "ps1") {
1235
1677
  const { out } = await captureClaude(L, undefined, ["--version"]);
@@ -1238,11 +1680,29 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1238
1680
  console.log(
1239
1681
  `${c.dim("·")} claude is the npm cmd shim and runs through cmd.exe; the native installer's claude.exe avoids that hop`,
1240
1682
  );
1683
+ const v = parseVersion(out);
1684
+ const floor = parseVersion(MIN_CLAUDE_VERSION);
1685
+ if (L.platform === "darwin" && v && floor && versionBelow(v, floor)) {
1686
+ bad(
1687
+ `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`,
1688
+ );
1689
+ problems++;
1690
+ }
1241
1691
  } else if (launch) bad(`only ${launch.bin} found; claudep needs claude.exe or claude.cmd`);
1242
1692
  else bad("claude binary not found on PATH");
1243
1693
  ok(`base: ${L.base}${L.callerConfigDir && !L.managed ? c.yellow(" (from CLAUDE_CONFIG_DIR in your shell)") : ""}`);
1244
1694
  const msysWarning = msysConfigDirWarning(L);
1245
1695
  if (msysWarning) warn(msysWarning);
1696
+ const settingsFile = join(L.base, "settings.json");
1697
+ const pinnedInSettings = await settingsEnvConfigDir(settingsFile);
1698
+ if (pinnedInSettings !== undefined) {
1699
+ bad(
1700
+ `${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`,
1701
+ );
1702
+ problems++;
1703
+ }
1704
+ for (const v of authEnvOverrides(process.env, L.platform))
1705
+ warn(`${v} is set in this shell; Claude Code uses it instead of the profile login, always in -p mode`);
1246
1706
  ok(`profiles root: ${L.profilesRoot}`);
1247
1707
 
1248
1708
  const shared = sharedItems(L.base);
@@ -1256,7 +1716,6 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1256
1716
  warn(`base items neither shared nor known-private (they stay per-profile): ${unclassified.join(", ")}`);
1257
1717
  }
1258
1718
 
1259
- let problems = 0;
1260
1719
  for (const name of names) {
1261
1720
  const dir = profileDir(L, name);
1262
1721
  console.log(`\n${c.bold(name)} ${c.dim(dir)}`);
@@ -1303,7 +1762,7 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1303
1762
  }
1304
1763
 
1305
1764
  async function cmdRm(L: Layout, args: string[]): Promise<void> {
1306
- const f = parseFlags(args, ["--keep-login", "--yes"], []);
1765
+ const f = parseFlags(args, ...flagSpec("rm"));
1307
1766
  const name = f.rest[0];
1308
1767
  if (!name) die("usage: claudep rm <name> [--keep-login] [--yes]");
1309
1768
  const dir = profileDir(L, name);
@@ -1315,6 +1774,18 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
1315
1774
  samePath(real, L.home, L.platform)
1316
1775
  )
1317
1776
  die(`refusing to remove ${real}: not inside ${L.profilesRoot}`);
1777
+ const cur = currentProfile(L);
1778
+ if (cur.kind === "profile" && cur.name === name) {
1779
+ const unset = envUnsetHint(shellSyntax(process.env, L.platform, parentProcessName(L.platform)));
1780
+ warn(
1781
+ `this shell is on ${name} (${cur.setBy === "hook" ? "shell hook" : "manual pin"}). After removal run: ${unset}`,
1782
+ );
1783
+ }
1784
+ const pin = resolvePin(process.cwd());
1785
+ if (pin && pin.name === name)
1786
+ warn(
1787
+ `${shortHome(pin.file, L.home, L.platform)} pins this directory tree to ${name}. Remove it with: claudep local --remove`,
1788
+ );
1318
1789
  if (!f.bools.has("--yes")) {
1319
1790
  const yes = confirm(`Remove profile "${name}" (${dir})? Shared items are only unlinked; ${L.base} is untouched.`);
1320
1791
  if (!yes) {
@@ -1350,26 +1821,38 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
1350
1821
  }
1351
1822
 
1352
1823
  function help(L: Layout): void {
1824
+ console.log(helpText(L));
1825
+ }
1826
+
1827
+ /** Hand-written on purpose: the layout says more than the table can. A test
1828
+ * checks that every command in COMMANDS appears in it. */
1829
+ export function helpText(L: Layout): string {
1353
1830
  const root = shortHome(L.profilesRoot, L.home, L.platform);
1354
- console.log(`${c.bold("claudep")} ${c.dim(version())}: run Claude Code under separate accounts on one machine
1831
+ return `${c.bold("claudep")} ${c.dim(version())}: run Claude Code under separate accounts on one machine
1355
1832
 
1356
1833
  ${c.bold("USAGE")}
1357
1834
  claudep <name> [claude args…] run claude with profile <name> (alias for "run")
1358
1835
  claudep init <name> [options] create/update a profile and log in
1359
- claudep list show every profile and who it is logged in as
1836
+ claudep list [--json] show every profile and who it is logged in as
1360
1837
  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
1838
+ claudep current [--json|--name] which profile this shell is on, and why; --name prints only the name
1839
+ claudep env <name> | --unset print the CLAUDE_CONFIG_DIR pin (or the unset) for eval, source or Invoke-Expression;
1840
+ --shell sh|fish|powershell picks the syntax when the shell was not detected
1363
1841
  claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
1364
- claudep doctor [name] verify symlinks, keychain entry, unclassified files
1842
+ claudep doctor [name] verify symlinks, keychain entry, unclassified files, Claude Code version
1365
1843
  claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
1844
+ claudep help show this text
1366
1845
  claudep --version print the version
1367
1846
 
1368
1847
  ${c.bold("DIRECTORY PINS")}
1369
1848
  claudep local <name> [--force] write ./${PIN_FILE} so this tree uses <name>; --remove deletes it
1370
1849
  claudep local show the pin that applies to the current directory
1371
1850
  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
1851
+ claudep shell-init [${SHELLS.join("|")}] print the hook that applies pins on cd; load it from your rc file
1852
+
1853
+ ${c.bold("TAB COMPLETION")}
1854
+ claudep completion [${COMPLETION_SHELLS.join("|")}] print completions for commands, flags and profile names;
1855
+ load them from your rc file (after compinit in zsh)
1373
1856
 
1374
1857
  ${c.bold("INIT OPTIONS")}
1375
1858
  --sso force the SSO login flow (Enterprise orgs)
@@ -1386,10 +1869,13 @@ ${c.bold("EXAMPLES")}
1386
1869
  claude # Claude Code as whatever ~/.claude is logged in as
1387
1870
  claudep enterprise -p "summarize this repo"
1388
1871
  eval "$(claudep env enterprise)" # pin the whole shell to a profile
1872
+ claudep env enterprise | source # the same from fish
1389
1873
  claudep env enterprise | Invoke-Expression # the same from PowerShell
1390
1874
  claudep local enterprise # pin this repo; commit the ${PIN_FILE} file for the team
1391
1875
  eval "$(claudep shell-init zsh)" # in .zshrc: shells follow ${PIN_FILE} pins on cd
1876
+ ${hookHint("fish")} # the same line for config.fish
1392
1877
  ${hookHint("powershell")} # the same line for $PROFILE
1878
+ ${completionHint("zsh")} # in .zshrc after compinit: tab completion
1393
1879
 
1394
1880
  ${c.bold("HOW IT WORKS")}
1395
1881
  ~/.claude stays exactly as it is and remains the "default" profile. Each named profile is a
@@ -1414,7 +1900,7 @@ ${c.bold("ENVIRONMENT")}
1414
1900
  CLAUDEP_AUTO set by the hook next to CLAUDE_CONFIG_DIR; marks the pin as hook-managed
1415
1901
 
1416
1902
  ${c.dim("claudep is an independent, unofficial tool. It is not affiliated with, endorsed by or supported by Anthropic.")}
1417
- `);
1903
+ `;
1418
1904
  }
1419
1905
 
1420
1906
  // ---------------------------------------------------------------------------
@@ -1437,7 +1923,7 @@ export async function main(argv: string[]): Promise<void> {
1437
1923
  return cmdRun(L, args);
1438
1924
  case "list":
1439
1925
  case "ls":
1440
- return cmdList(L);
1926
+ return cmdList(L, args);
1441
1927
  case "status":
1442
1928
  return cmdStatus(L, args);
1443
1929
  case "env":
@@ -1450,6 +1936,8 @@ export async function main(argv: string[]): Promise<void> {
1450
1936
  return cmdResolve(L, args);
1451
1937
  case "shell-init":
1452
1938
  return cmdShellInit(L, args);
1939
+ case "completion":
1940
+ return cmdCompletion(L, args);
1453
1941
  case "version":
1454
1942
  case "--version":
1455
1943
  case "-v":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bordoni/claudep",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",