@bordoni/claudep 0.3.0 → 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,16 @@ 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
+
9
19
  ## [0.3.0] - 2026-09-10
10
20
 
11
21
  ### Added
@@ -76,7 +86,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
76
86
  - Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
77
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.
78
88
 
79
- [Unreleased]: https://github.com/bordoni/claudep/compare/0.3.0...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
80
91
  [0.3.0]: https://github.com/bordoni/claudep/compare/0.2.1...0.3.0
81
92
  [0.2.1]: https://github.com/bordoni/claudep/compare/0.2.0...0.2.1
82
93
  [0.2.0]: https://github.com/bordoni/claudep/compare/0.1.1...0.2.0
package/README.md CHANGED
@@ -64,6 +64,7 @@ claudep rm <name> [--keep-login] log out and delete a profile (base is never
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
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
 
@@ -104,6 +105,21 @@ From then on, `cd` into a pinned tree sets `CLAUDE_CONFIG_DIR` for that profile
104
105
 
105
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.
106
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
+
107
123
  ## Show the profile in your prompt or statusline
108
124
 
109
125
  In a shell prompt use the variable itself; no command runs:
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
  // ---------------------------------------------------------------------------
@@ -817,7 +877,7 @@ export function parseFlags(args: string[], boolNames: readonly string[], strName
817
877
  // ---------------------------------------------------------------------------
818
878
 
819
879
  async function cmdInit(L: Layout, args: string[]): Promise<void> {
820
- const f = parseFlags(args, ["--copy-mcp", "--no-login", "--sso", "--console", "--force"], ["--email", "--alias"]);
880
+ const f = parseFlags(args, ...flagSpec("init"));
821
881
  const name = f.rest[0];
822
882
  if (!name)
823
883
  die(
@@ -1003,7 +1063,7 @@ function rowJson(row: Row): JsonObject {
1003
1063
  }
1004
1064
 
1005
1065
  async function cmdList(L: Layout, args: string[]): Promise<void> {
1006
- const f = parseFlags(args, ["--json"], []);
1066
+ const f = parseFlags(args, ...flagSpec("list"));
1007
1067
  const names = ["default", ...listProfileNames(L)];
1008
1068
  const rows = await collectRows(L, names);
1009
1069
  if (f.bools.has("--json")) {
@@ -1017,7 +1077,7 @@ async function cmdList(L: Layout, args: string[]): Promise<void> {
1017
1077
  }
1018
1078
 
1019
1079
  async function cmdStatus(L: Layout, args: string[]): Promise<void> {
1020
- const f = parseFlags(args, ["--json"], []);
1080
+ const f = parseFlags(args, ...flagSpec("status"));
1021
1081
  const name = f.rest[0];
1022
1082
  if (!name) die("usage: claudep status <name> [--json]");
1023
1083
  if (name !== "default" && !profileExists(L, name)) die(`profile "${name}" does not exist`);
@@ -1042,7 +1102,7 @@ export function envUnsetHint(syntax: EnvSyntax): string {
1042
1102
  }
1043
1103
 
1044
1104
  function cmdEnv(L: Layout, args: string[]): void {
1045
- const f = parseFlags(args, ["--unset"], ["--shell"]);
1105
+ const f = parseFlags(args, ...flagSpec("env"));
1046
1106
  const forced = f.strs.get("--shell");
1047
1107
  const syntax =
1048
1108
  forced === undefined ? shellSyntax(process.env, L.platform, parentProcessName(L.platform)) : envSyntaxOf(forced);
@@ -1070,7 +1130,7 @@ export function currentLabel(cur: Current): string {
1070
1130
  }
1071
1131
 
1072
1132
  function cmdCurrent(L: Layout, args: string[]): void {
1073
- const f = parseFlags(args, ["--json", "--name"], []);
1133
+ const f = parseFlags(args, ...flagSpec("current"));
1074
1134
  const cur = currentProfile(L);
1075
1135
  const label = currentLabel(cur);
1076
1136
  if (f.bools.has("--name")) {
@@ -1100,7 +1160,7 @@ function cmdCurrent(L: Layout, args: string[]): void {
1100
1160
  }
1101
1161
 
1102
1162
  function cmdResolve(L: Layout, args: string[]): void {
1103
- const f = parseFlags(args, ["--json"], []);
1163
+ const f = parseFlags(args, ...flagSpec("resolve"));
1104
1164
  const start = f.rest[0] ?? process.cwd();
1105
1165
  const pin = resolvePin(start);
1106
1166
  if (!pin || pin.name === "") process.exit(1);
@@ -1111,7 +1171,7 @@ function cmdResolve(L: Layout, args: string[]): void {
1111
1171
  }
1112
1172
 
1113
1173
  function cmdLocal(L: Layout, args: string[]): void {
1114
- const f = parseFlags(args, ["--remove", "--force"], []);
1174
+ const f = parseFlags(args, ...flagSpec("local"));
1115
1175
  const file = join(process.cwd(), PIN_FILE);
1116
1176
  if (f.bools.has("--remove")) {
1117
1177
  if (!existsSync(file)) die(`no ${PIN_FILE} in ${process.cwd()}`);
@@ -1140,9 +1200,8 @@ function cmdLocal(L: Layout, args: string[]): void {
1140
1200
 
1141
1201
  /** The shell hook. Pure parameter expansion and builtins: it runs on every
1142
1202
  * directory change (zsh chpwd) or prompt (bash PROMPT_COMMAND), so no
1143
- * subprocess is allowed here. Logic mirrors resolvePin(). */
1144
- export type Shell = "zsh" | "bash" | "fish" | "powershell";
1145
- export const SHELLS: readonly Shell[] = ["zsh", "bash", "fish", "powershell"];
1203
+ * subprocess is allowed here. Logic mirrors resolvePin(). `Shell` and
1204
+ * `SHELLS` live next to the command table. */
1146
1205
 
1147
1206
  /** The shell to name in hints and to default `shell-init` to. */
1148
1207
  export function defaultShell(env: Env = process.env, platform: NodeJS.Platform = process.platform): Shell {
@@ -1423,6 +1482,186 @@ function cmdShellInit(L: Layout, args: string[]): void {
1423
1482
  process.stdout.write(shellInit(shell as Shell, L.profilesRoot, L.platform));
1424
1483
  }
1425
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
+
1426
1665
  /** A POSIX-style CLAUDE_CONFIG_DIR on Windows is one claude.exe cannot read. */
1427
1666
  function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undefined {
1428
1667
  const cfg = env.CLAUDE_CONFIG_DIR;
@@ -1523,7 +1762,7 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1523
1762
  }
1524
1763
 
1525
1764
  async function cmdRm(L: Layout, args: string[]): Promise<void> {
1526
- const f = parseFlags(args, ["--keep-login", "--yes"], []);
1765
+ const f = parseFlags(args, ...flagSpec("rm"));
1527
1766
  const name = f.rest[0];
1528
1767
  if (!name) die("usage: claudep rm <name> [--keep-login] [--yes]");
1529
1768
  const dir = profileDir(L, name);
@@ -1582,8 +1821,14 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
1582
1821
  }
1583
1822
 
1584
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 {
1585
1830
  const root = shortHome(L.profilesRoot, L.home, L.platform);
1586
- 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
1587
1832
 
1588
1833
  ${c.bold("USAGE")}
1589
1834
  claudep <name> [claude args…] run claude with profile <name> (alias for "run")
@@ -1596,13 +1841,18 @@ ${c.bold("USAGE")}
1596
1841
  claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
1597
1842
  claudep doctor [name] verify symlinks, keychain entry, unclassified files, Claude Code version
1598
1843
  claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
1844
+ claudep help show this text
1599
1845
  claudep --version print the version
1600
1846
 
1601
1847
  ${c.bold("DIRECTORY PINS")}
1602
1848
  claudep local <name> [--force] write ./${PIN_FILE} so this tree uses <name>; --remove deletes it
1603
1849
  claudep local show the pin that applies to the current directory
1604
1850
  claudep resolve [dir] [--json] print the profile pinned for a directory (exit 1 when none)
1605
- claudep shell-init [zsh|bash|fish|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)
1606
1856
 
1607
1857
  ${c.bold("INIT OPTIONS")}
1608
1858
  --sso force the SSO login flow (Enterprise orgs)
@@ -1625,6 +1875,7 @@ ${c.bold("EXAMPLES")}
1625
1875
  eval "$(claudep shell-init zsh)" # in .zshrc: shells follow ${PIN_FILE} pins on cd
1626
1876
  ${hookHint("fish")} # the same line for config.fish
1627
1877
  ${hookHint("powershell")} # the same line for $PROFILE
1878
+ ${completionHint("zsh")} # in .zshrc after compinit: tab completion
1628
1879
 
1629
1880
  ${c.bold("HOW IT WORKS")}
1630
1881
  ~/.claude stays exactly as it is and remains the "default" profile. Each named profile is a
@@ -1649,7 +1900,7 @@ ${c.bold("ENVIRONMENT")}
1649
1900
  CLAUDEP_AUTO set by the hook next to CLAUDE_CONFIG_DIR; marks the pin as hook-managed
1650
1901
 
1651
1902
  ${c.dim("claudep is an independent, unofficial tool. It is not affiliated with, endorsed by or supported by Anthropic.")}
1652
- `);
1903
+ `;
1653
1904
  }
1654
1905
 
1655
1906
  // ---------------------------------------------------------------------------
@@ -1685,6 +1936,8 @@ export async function main(argv: string[]): Promise<void> {
1685
1936
  return cmdResolve(L, args);
1686
1937
  case "shell-init":
1687
1938
  return cmdShellInit(L, args);
1939
+ case "completion":
1940
+ return cmdCompletion(L, args);
1688
1941
  case "version":
1689
1942
  case "--version":
1690
1943
  case "-v":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bordoni/claudep",
3
- "version": "0.3.0",
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",