@bordoni/claudep 0.3.0 → 0.4.1

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,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.1] - 2026-10-01
10
+
11
+ ### Added
12
+
13
+ - `claudep doctor` warns when your Claude Code asks for approval on every auto-memory write in a profile. Since 2.1.280 a write through the shared `projects/` symlink is judged at `~/.claude/projects`, which auto mode cannot approve ([anthropics/claude-code#98044](https://github.com/anthropics/claude-code/issues/98044)). The README explains it.
14
+
15
+ ### Changed
16
+
17
+ - Caught up with Claude Code 2.1.280, whose list of per-instance files grew. `state/`, `policy-limits.json.stamp.json`, the `.signature.json` files next to `policy-limits.json` and `remote-settings.json`, `daemon.lock`, `shares/`, `storage-v2/` and about twenty-five caches and locks are known-private, so `doctor` stops calling them unclassified.
18
+
19
+ ### Fixed
20
+
21
+ - `loop.md`, which Claude Code keeps per instance, is no longer shared by the rule that links every top-level `*.md`. `claudep doctor` names any `loop.md` symlink an older claudep left in a profile and tells you to remove it.
22
+ - `claudep doctor` on Linux and Windows no longer calls a profile "not logged in yet" when it has no `.credentials.json` but Claude Code reports it logged in. Claude Code can keep the login elsewhere, such as Windows Credential Manager behind a feature flag.
23
+
24
+ ## [0.4.0] - 2026-09-14
25
+
26
+ ### Added
27
+
28
+ - `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.
29
+
30
+ ### Changed
31
+
32
+ - **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.
33
+
9
34
  ## [0.3.0] - 2026-09-10
10
35
 
11
36
  ### Added
@@ -76,7 +101,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
76
101
  - Keychain isolation per config dir verified against Claude Code 2.1.259: the service name is `Claude Code-credentials-<sha256(dir)[0:8]>`.
77
102
  - 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
103
 
79
- [Unreleased]: https://github.com/bordoni/claudep/compare/0.3.0...HEAD
104
+ [Unreleased]: https://github.com/bordoni/claudep/compare/0.4.1...HEAD
105
+ [0.4.1]: https://github.com/bordoni/claudep/compare/0.4.0...0.4.1
106
+ [0.4.0]: https://github.com/bordoni/claudep/compare/0.3.0...0.4.0
80
107
  [0.3.0]: https://github.com/bordoni/claudep/compare/0.2.1...0.3.0
81
108
  [0.2.1]: https://github.com/bordoni/claudep/compare/0.2.0...0.2.1
82
109
  [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:
@@ -139,7 +155,11 @@ The shared list is an allowlist, so an account-specific file cannot leak across
139
155
 
140
156
  - 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.
141
157
  - 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.
158
+ - Background sessions and the daemon are tied to `~/.claude`. As of Claude Code 2.1.280, `claude daemon install` refuses to run with `CLAUDE_CONFIG_DIR` set, and the launcher skips the daemon for `claude --bg` under a profile. Anthropic's docs describe a separate background supervisor per config dir; I have not verified that here.
159
+ - Since Claude Code 2.1.280, every auto-memory write in a profile asks for approval, and auto mode cannot give it. The memory directory sits under the shared `projects/` symlink, and Claude Code now checks a write at the path it lands on, `~/.claude/projects/...`, which it treats as sensitive. This is [anthropics/claude-code#98044](https://github.com/anthropics/claude-code/issues/98044), open upstream. `claudep doctor` warns while your Claude Code is affected. The default profile is not.
160
+ - Skills and plugins synced from claude.ai land in the shared `skills/synced/` and `plugins/synced/`, in one folder per account, so profiles do not mix them. The `syncClaudeAiSkills` and `syncClaudeAiPlugins` switches live in the shared `settings.json` and apply to every profile.
161
+ - Console sign-ins made without an API key live in `~/.config/anthropic`, outside every config dir, so all profiles see the same ones. `ANTHROPIC_PROFILE` picks which one Claude Code uses.
162
+ - In VS Code, `CLAUDE_CONFIG_DIR` in `claudeCode.environmentVariables` must be an absolute path. The session list ignores it when `claudeProcessWrapper` is set.
143
163
  - 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.
144
164
  - Never put `CLAUDE_CONFIG_DIR` in a `settings.json` `env` block. Claude Code detects that mismatch and disables features; `claudep doctor` reports it.
145
165
  - `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.
package/claudep.ts CHANGED
@@ -250,8 +250,9 @@ export const SHARED_DIRS = [
250
250
  "workflows",
251
251
  ] as const;
252
252
 
253
- /** Per-profile state. First block is Claude Code's own runtime-state list;
254
- * the rest are observed extras. Never shared. */
253
+ /** Per-profile state. First block is Claude Code's own runtime-state list as of
254
+ * 2.1.263, then observed extras, then what 2.1.280 added to that list. Never
255
+ * shared, and a name here is never shared by the top-level *.md rule either. */
255
256
  export const KNOWN_PRIVATE = new Set<string>([
256
257
  ".claude.json",
257
258
  ".claude.json.backup",
@@ -301,6 +302,42 @@ export const KNOWN_PRIVATE = new Set<string>([
301
302
  "launch.json",
302
303
  "scheduled_tasks.json",
303
304
  "seed-admin",
305
+ // Added to Claude Code's runtime-state list by 2.1.280 (checked 2026-09-30).
306
+ "state",
307
+ "policy-limits.json.stamp.json",
308
+ "policy-limits.json.signature.json",
309
+ "policy-limits.json.signature-iat.json",
310
+ "remote-settings.json.signature.json",
311
+ "remote-settings.json.signature-iat.json",
312
+ "remote-settings-consent.json",
313
+ "remote-settings-helper-consent",
314
+ "hfi-auth.json",
315
+ "shares",
316
+ "storage-v2",
317
+ "daemon.lock",
318
+ "gh-pr-status-cache.json",
319
+ "active-time.json",
320
+ "server-sessions.json",
321
+ "image-cache",
322
+ "file-transfers",
323
+ "computer-use.lock",
324
+ "server.lock",
325
+ "downloads",
326
+ "scratch",
327
+ "traces",
328
+ "startup-perf",
329
+ "feedback-bundles",
330
+ "ccr",
331
+ "bridge-spawn",
332
+ "local-settings",
333
+ "project-settings",
334
+ "remote",
335
+ "systemd",
336
+ "api-dumps",
337
+ "dump-prompts",
338
+ "antproto.json",
339
+ ".cc-writes",
340
+ "loop.md",
304
341
  ]);
305
342
 
306
343
  /** Keys copied from the base .claude.json into a fresh profile so first-run
@@ -317,27 +354,87 @@ export const SEED_KEYS = [
317
354
  ] as const;
318
355
 
319
356
  export const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
357
+
358
+ /** Shells with a directory-pin hook, and the subset with tab completions. */
359
+ export type Shell = "zsh" | "bash" | "fish" | "powershell";
360
+ export const SHELLS: readonly Shell[] = ["zsh", "bash", "fish", "powershell"];
361
+ export const COMPLETION_SHELLS = ["zsh", "bash", "fish"] as const;
362
+ export type CompletionShell = (typeof COMPLETION_SHELLS)[number];
363
+ /** What `claudep env --shell` accepts. */
364
+ export const ENV_SHELLS = ["sh", "zsh", "bash", "fish", "powershell"] as const;
365
+
366
+ export type ValueFlag = { name: string; values?: readonly string[] };
367
+ export type Command = {
368
+ name: string;
369
+ /** One line, no colons: it becomes the zsh _describe and fish -d text. */
370
+ desc: string;
371
+ aliases?: readonly string[];
372
+ /** What the first positional completes to. */
373
+ arg?: "profile" | "dir" | readonly string[];
374
+ flags?: readonly string[];
375
+ valueFlags?: readonly ValueFlag[];
376
+ /** A real command that the top-level completion does not offer. */
377
+ hidden?: boolean;
378
+ };
379
+
380
+ /** One table for the dispatcher's reserved words, every parseFlags call and
381
+ * the completion scripts, so none of them can drift from the others. */
382
+ export const COMMANDS: readonly Command[] = [
383
+ {
384
+ name: "init",
385
+ desc: "create or update a profile and log in",
386
+ arg: "profile",
387
+ flags: ["--sso", "--console", "--copy-mcp", "--no-login", "--force"],
388
+ valueFlags: [{ name: "--email" }, { name: "--alias" }],
389
+ },
390
+ { name: "run", desc: "run claude with a profile", arg: "profile", hidden: true },
391
+ { name: "list", desc: "show every profile and who it is logged in as", aliases: ["ls"], flags: ["--json"] },
392
+ { name: "status", desc: "login state for one profile", arg: "profile", flags: ["--json"] },
393
+ { name: "current", desc: "which profile this shell is on, and why", flags: ["--json", "--name"] },
394
+ {
395
+ name: "env",
396
+ desc: "print the CLAUDE_CONFIG_DIR pin, or the unset, for your shell",
397
+ arg: "profile",
398
+ flags: ["--unset"],
399
+ valueFlags: [{ name: "--shell", values: ENV_SHELLS }],
400
+ },
401
+ { name: "alias", desc: "write a shim command that runs claudep with a profile", arg: "profile" },
402
+ { name: "doctor", desc: "verify symlinks, keychain entry, unclassified files, Claude Code version", arg: "profile" },
403
+ {
404
+ name: "rm",
405
+ desc: "log out and delete a profile",
406
+ aliases: ["remove"],
407
+ arg: "profile",
408
+ flags: ["--keep-login", "--yes"],
409
+ },
410
+ { name: "local", desc: "pin this directory tree to a profile", arg: "profile", flags: ["--remove", "--force"] },
411
+ { name: "resolve", desc: "print the profile pinned for a directory", arg: "dir", flags: ["--json"] },
412
+ { name: "shell-init", desc: "print the hook that applies pins on cd", arg: SHELLS },
413
+ { name: "completion", desc: "print tab completions for a shell", arg: COMPLETION_SHELLS },
414
+ { name: "help", desc: "show the help" },
415
+ { name: "version", desc: "print the version", aliases: ["--version"] },
416
+ ];
417
+
418
+ /** The words a completion offers for a command: its name and the aliases
419
+ * that are words (a `--version` alias is a flag, not a word). */
420
+ export function commandWords(c: Command): string[] {
421
+ return [c.name, ...(c.aliases ?? [])].filter((w) => !w.startsWith("-"));
422
+ }
423
+
424
+ /** Profile names that would collide with the dispatcher. Derived from the
425
+ * table plus the two names for the base. */
320
426
  export const RESERVED = new Set([
321
427
  "default",
322
428
  "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",
429
+ ...COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]).filter((w) => NAME_RE.test(w)),
339
430
  ]);
340
431
 
432
+ /** The boolean and value flag names a command accepts, for parseFlags. */
433
+ export function flagSpec(name: string): [readonly string[], readonly string[]] {
434
+ const c = COMMANDS.find((x) => x.name === name);
435
+ return [c?.flags ?? [], (c?.valueFlags ?? []).map((v) => v.name)];
436
+ }
437
+
341
438
  // ---------------------------------------------------------------------------
342
439
  // Output helpers
343
440
  // ---------------------------------------------------------------------------
@@ -402,7 +499,8 @@ export function sharedItems(base: string): SharedItem[] {
402
499
  for (const f of SHARED_FILES) push(f, "file");
403
500
  if (existsSync(base)) {
404
501
  for (const entry of readdirSync(base, { withFileTypes: true })) {
405
- if (entry.name.endsWith(".md") && !entry.isDirectory()) push(entry.name, "file");
502
+ if (entry.name.endsWith(".md") && !entry.isDirectory() && !KNOWN_PRIVATE.has(entry.name))
503
+ push(entry.name, "file");
406
504
  }
407
505
  }
408
506
  for (const d of SHARED_DIRS) push(d, "dir");
@@ -497,6 +595,18 @@ export function linkState(
497
595
  return existsSync(dest) ? "ok" : "broken";
498
596
  }
499
597
 
598
+ /** Known-private names in a profile that are still symlinks into the base.
599
+ * An older claudep linked every top-level *.md, which once included loop.md;
600
+ * link() never deletes, so doctor names them for the user to remove. */
601
+ export function staleSharedLinks(base: string, dir: string, platform: NodeJS.Platform = process.platform): string[] {
602
+ if (!existsSync(dir)) return [];
603
+ return readdirSync(dir, { withFileTypes: true })
604
+ .filter((e) => e.isSymbolicLink() && KNOWN_PRIVATE.has(e.name))
605
+ .filter((e) => samePath(readlinkSync(join(dir, e.name)), join(base, e.name), platform))
606
+ .map((e) => e.name)
607
+ .sort();
608
+ }
609
+
500
610
  /** What to tell the user when the OS refused to create a symlink. */
501
611
  export function symlinkDeniedHint(platform: NodeJS.Platform, name: string): string {
502
612
  if (platform === "win32")
@@ -538,8 +648,23 @@ export function authEnvOverrides(env: Env = process.env, platform: NodeJS.Platfo
538
648
  * dir. Before it every config dir shared one login. */
539
649
  export const MIN_CLAUDE_VERSION = "2.1.144";
540
650
 
651
+ /** From this Claude Code on, a write through a symlink is also judged at the
652
+ * path it lands on. Auto memory in a profile whose projects/ is shared lands
653
+ * in ~/.claude/projects and asks for approval on every write, which auto mode
654
+ * cannot give (anthropics/claude-code#98044). No fixed version yet. */
655
+ export const MEMORY_SYMLINK_PROMPT_FROM = "2.1.280";
656
+ export const MEMORY_SYMLINK_ISSUE = "https://github.com/anthropics/claude-code/issues/98044";
657
+
541
658
  export type Version = [number, number, number];
542
659
 
660
+ /** True when this Claude Code prompts on every auto-memory write through the
661
+ * shared projects/ symlink. An unknown version is not reported. */
662
+ export function memoryWritesPrompt(v: Version | undefined, shared: readonly SharedItem[]): boolean {
663
+ const from = parseVersion(MEMORY_SYMLINK_PROMPT_FROM);
664
+ if (!v || !from || versionBelow(v, from)) return false;
665
+ return shared.some((s) => s.name === "projects");
666
+ }
667
+
543
668
  /** The first x.y.z in `claude --version` output. */
544
669
  export function parseVersion(text: string): Version | undefined {
545
670
  const m = /(\d+)\.(\d+)\.(\d+)/.exec(text);
@@ -817,7 +942,7 @@ export function parseFlags(args: string[], boolNames: readonly string[], strName
817
942
  // ---------------------------------------------------------------------------
818
943
 
819
944
  async function cmdInit(L: Layout, args: string[]): Promise<void> {
820
- const f = parseFlags(args, ["--copy-mcp", "--no-login", "--sso", "--console", "--force"], ["--email", "--alias"]);
945
+ const f = parseFlags(args, ...flagSpec("init"));
821
946
  const name = f.rest[0];
822
947
  if (!name)
823
948
  die(
@@ -1003,7 +1128,7 @@ function rowJson(row: Row): JsonObject {
1003
1128
  }
1004
1129
 
1005
1130
  async function cmdList(L: Layout, args: string[]): Promise<void> {
1006
- const f = parseFlags(args, ["--json"], []);
1131
+ const f = parseFlags(args, ...flagSpec("list"));
1007
1132
  const names = ["default", ...listProfileNames(L)];
1008
1133
  const rows = await collectRows(L, names);
1009
1134
  if (f.bools.has("--json")) {
@@ -1017,7 +1142,7 @@ async function cmdList(L: Layout, args: string[]): Promise<void> {
1017
1142
  }
1018
1143
 
1019
1144
  async function cmdStatus(L: Layout, args: string[]): Promise<void> {
1020
- const f = parseFlags(args, ["--json"], []);
1145
+ const f = parseFlags(args, ...flagSpec("status"));
1021
1146
  const name = f.rest[0];
1022
1147
  if (!name) die("usage: claudep status <name> [--json]");
1023
1148
  if (name !== "default" && !profileExists(L, name)) die(`profile "${name}" does not exist`);
@@ -1042,7 +1167,7 @@ export function envUnsetHint(syntax: EnvSyntax): string {
1042
1167
  }
1043
1168
 
1044
1169
  function cmdEnv(L: Layout, args: string[]): void {
1045
- const f = parseFlags(args, ["--unset"], ["--shell"]);
1170
+ const f = parseFlags(args, ...flagSpec("env"));
1046
1171
  const forced = f.strs.get("--shell");
1047
1172
  const syntax =
1048
1173
  forced === undefined ? shellSyntax(process.env, L.platform, parentProcessName(L.platform)) : envSyntaxOf(forced);
@@ -1070,7 +1195,7 @@ export function currentLabel(cur: Current): string {
1070
1195
  }
1071
1196
 
1072
1197
  function cmdCurrent(L: Layout, args: string[]): void {
1073
- const f = parseFlags(args, ["--json", "--name"], []);
1198
+ const f = parseFlags(args, ...flagSpec("current"));
1074
1199
  const cur = currentProfile(L);
1075
1200
  const label = currentLabel(cur);
1076
1201
  if (f.bools.has("--name")) {
@@ -1100,7 +1225,7 @@ function cmdCurrent(L: Layout, args: string[]): void {
1100
1225
  }
1101
1226
 
1102
1227
  function cmdResolve(L: Layout, args: string[]): void {
1103
- const f = parseFlags(args, ["--json"], []);
1228
+ const f = parseFlags(args, ...flagSpec("resolve"));
1104
1229
  const start = f.rest[0] ?? process.cwd();
1105
1230
  const pin = resolvePin(start);
1106
1231
  if (!pin || pin.name === "") process.exit(1);
@@ -1111,7 +1236,7 @@ function cmdResolve(L: Layout, args: string[]): void {
1111
1236
  }
1112
1237
 
1113
1238
  function cmdLocal(L: Layout, args: string[]): void {
1114
- const f = parseFlags(args, ["--remove", "--force"], []);
1239
+ const f = parseFlags(args, ...flagSpec("local"));
1115
1240
  const file = join(process.cwd(), PIN_FILE);
1116
1241
  if (f.bools.has("--remove")) {
1117
1242
  if (!existsSync(file)) die(`no ${PIN_FILE} in ${process.cwd()}`);
@@ -1140,9 +1265,8 @@ function cmdLocal(L: Layout, args: string[]): void {
1140
1265
 
1141
1266
  /** The shell hook. Pure parameter expansion and builtins: it runs on every
1142
1267
  * 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"];
1268
+ * subprocess is allowed here. Logic mirrors resolvePin(). `Shell` and
1269
+ * `SHELLS` live next to the command table. */
1146
1270
 
1147
1271
  /** The shell to name in hints and to default `shell-init` to. */
1148
1272
  export function defaultShell(env: Env = process.env, platform: NodeJS.Platform = process.platform): Shell {
@@ -1423,6 +1547,186 @@ function cmdShellInit(L: Layout, args: string[]): void {
1423
1547
  process.stdout.write(shellInit(shell as Shell, L.profilesRoot, L.platform));
1424
1548
  }
1425
1549
 
1550
+ // ---------------------------------------------------------------------------
1551
+ // Tab completions, generated from COMMANDS. Profile names come from a glob
1552
+ // over the profiles root at completion time; no script ever runs claudep.
1553
+ // ---------------------------------------------------------------------------
1554
+
1555
+ /** The one rc-file line that loads the completions. */
1556
+ export function completionHint(shell: CompletionShell): string {
1557
+ return shell === "fish" ? "claudep completion fish | source" : `eval "$(claudep completion ${shell})"`;
1558
+ }
1559
+
1560
+ const visibleCommands = () => COMMANDS.filter((c) => !c.hidden);
1561
+ const isList = (arg: Command["arg"]): arg is readonly string[] => arg !== undefined && typeof arg !== "string";
1562
+
1563
+ export function completionScript(shell: CompletionShell, profilesRoot: string): string {
1564
+ if (shell === "zsh") return zshCompletion(profilesRoot);
1565
+ if (shell === "bash") return bashCompletion(profilesRoot);
1566
+ return fishCompletion(profilesRoot);
1567
+ }
1568
+
1569
+ /** Dual-mode: `eval "$(claudep completion zsh)"` after compinit registers it
1570
+ * with compdef; saved as `_claudep` on fpath, the #compdef line and the
1571
+ * funcstack check make it autoload. Names come from a glob with (N/:t):
1572
+ * nothing on an empty root, directories only, basename. */
1573
+ function zshCompletion(profilesRoot: string): string {
1574
+ const q = `'${profilesRoot.replace(/'/g, `'\\''`)}'`;
1575
+ const noColon = (s: string) => s.replace(/:/g, "\\:");
1576
+ const cmds = visibleCommands().flatMap((c) => commandWords(c).map((w) => ` '${w}:${noColon(c.desc)}'`));
1577
+ const cases = COMMANDS.filter((c) => c.arg || c.flags || c.valueFlags).map((c) => {
1578
+ const specs: string[] = [];
1579
+ if (c.arg === "profile") specs.push("'1:profile:_claudep_profiles'");
1580
+ else if (c.arg === "dir") specs.push("'1:directory:_files -/'");
1581
+ else if (isList(c.arg)) specs.push(`'1:shell:(${c.arg.join(" ")})'`);
1582
+ if (c.name === "run") specs.push("'*:claude arguments:'");
1583
+ for (const f of c.flags ?? []) specs.push(`'${f}'`);
1584
+ for (const v of c.valueFlags ?? [])
1585
+ specs.push(`'${v.name}=-:${v.name.slice(2)}:${v.values ? `(${v.values.join(" ")})` : ""}'`);
1586
+ return ` ${commandWords(c).join("|")}) _arguments ${specs.join(" ")} ;;`;
1587
+ });
1588
+ return `#compdef claudep
1589
+ # claudep completions. Load them after compinit in ~/.zshrc: ${completionHint("zsh")}
1590
+ _claudep_profiles() {
1591
+ local -a names
1592
+ names=(default ${q}/*(N/:t))
1593
+ _describe -t profiles 'profile' names
1594
+ }
1595
+ _claudep() {
1596
+ local -a cmds
1597
+ cmds=(
1598
+ ${cmds.join("\n")}
1599
+ )
1600
+ _arguments -C '1:command:->cmd' '*::arg:->args'
1601
+ case $state in
1602
+ cmd)
1603
+ _describe -t commands 'claudep command' cmds
1604
+ _claudep_profiles
1605
+ ;;
1606
+ args)
1607
+ case $words[1] in
1608
+ ${cases.join("\n")}
1609
+ esac
1610
+ ;;
1611
+ esac
1612
+ }
1613
+ if [ "\${funcstack[1]}" = "_claudep" ]; then _claudep "$@"; else compdef _claudep claudep; fi
1614
+ `;
1615
+ }
1616
+
1617
+ /** bash 3.2 safe: no mapfile, no compopt. The root's backslashes become
1618
+ * slashes so a Git Bash user with a C:\\ root still globs. */
1619
+ function bashCompletion(profilesRoot: string): string {
1620
+ const q = `'${profilesRoot.replace(/'/g, `'\\''`)}'`;
1621
+ const top = [...visibleCommands().flatMap(commandWords), "default"].join(" ");
1622
+ const flagCases = COMMANDS.filter((c) => c.flags || c.valueFlags).map(
1623
+ (c) =>
1624
+ ` ${commandWords(c).join("|")}) words="${[...(c.flags ?? []), ...(c.valueFlags ?? []).map((v) => v.name)].join(" ")}" ;;`,
1625
+ );
1626
+ const valued = COMMANDS.flatMap((c) => (c.valueFlags ?? []).filter((v) => v.values));
1627
+ const valueBranches = valued.map(
1628
+ (v, i) => ` ${i === 0 ? "if" : "elif"} [ "$prev" = "${v.name}" ]; then words="${(v.values ?? []).join(" ")}"`,
1629
+ );
1630
+ const argCases = COMMANDS.filter((c) => c.arg).map((c) => {
1631
+ const names = commandWords(c).join("|");
1632
+ if (c.arg === "profile") return ` ${names}) words="default $(_claudep_profiles)" ;;`;
1633
+ if (c.arg === "dir") return ` ${names}) COMPREPLY=($(compgen -d -- "$cur")); return 0 ;;`;
1634
+ return ` ${names}) words="${isList(c.arg) ? c.arg.join(" ") : ""}" ;;`;
1635
+ });
1636
+ return `# claudep completions. Load them from ~/.bashrc: ${completionHint("bash")}
1637
+ _claudep_profiles() {
1638
+ local root=${q} d
1639
+ root=\${root//\\\\//}
1640
+ for d in "$root"/*/; do
1641
+ d=\${d%/}
1642
+ [ -d "$d" ] && printf '%s\\n' "\${d##*/}"
1643
+ done
1644
+ }
1645
+ _claudep() {
1646
+ local cur prev cmd words=""
1647
+ cur=\${COMP_WORDS[COMP_CWORD]}
1648
+ prev=\${COMP_WORDS[COMP_CWORD-1]}
1649
+ cmd=\${COMP_WORDS[1]}
1650
+ COMPREPLY=()
1651
+ if [ "$COMP_CWORD" -eq 1 ]; then
1652
+ COMPREPLY=($(compgen -W "${top} $(_claudep_profiles)" -- "$cur"))
1653
+ return 0
1654
+ fi
1655
+ case $cur in
1656
+ -*)
1657
+ case $cmd in
1658
+ ${flagCases.join("\n")}
1659
+ esac
1660
+ ;;
1661
+ *)
1662
+ ${valueBranches.join("\n")}
1663
+ ${valueBranches.length ? "elif" : "if"} [ "$COMP_CWORD" -eq 2 ]; then
1664
+ case $cmd in
1665
+ ${argCases.join("\n")}
1666
+ esac
1667
+ fi
1668
+ ;;
1669
+ esac
1670
+ COMPREPLY=($(compgen -W "$words" -- "$cur"))
1671
+ }
1672
+ complete -F _claudep claudep
1673
+ `;
1674
+ }
1675
+
1676
+ /** The two conditions are written out over `commandline -opc` instead of
1677
+ * fish's __fish_use_subcommand and __fish_seen_subcommand_from, whose
1678
+ * names moved between versions. `for` over an unmatched glob runs zero
1679
+ * times, so an empty root offers only `default`. */
1680
+ function fishCompletion(profilesRoot: string): string {
1681
+ const lines: string[] = [];
1682
+ for (const c of visibleCommands())
1683
+ for (const w of commandWords(c))
1684
+ lines.push(`complete -c claudep -n '__claudep_at 1' -a ${w} -d ${fishQuote(c.desc)}`);
1685
+ lines.push("complete -c claudep -n '__claudep_at 1' -a '(__claudep_profiles)' -d profile");
1686
+ const profileCmds = COMMANDS.filter((c) => c.arg === "profile")
1687
+ .flatMap(commandWords)
1688
+ .join(" ");
1689
+ lines.push(
1690
+ `complete -c claudep -n '__claudep_cmd ${profileCmds}; and __claudep_at 2' -a '(__claudep_profiles)' -d profile`,
1691
+ );
1692
+ for (const c of COMMANDS) {
1693
+ const ws = commandWords(c).join(" ");
1694
+ if (isList(c.arg))
1695
+ lines.push(`complete -c claudep -n '__claudep_cmd ${ws}; and __claudep_at 2' -a '${c.arg.join(" ")}'`);
1696
+ if (c.arg === "dir")
1697
+ lines.push(`complete -c claudep -n '__claudep_cmd ${ws}; and __claudep_at 2' -a '(__fish_complete_directories)'`);
1698
+ for (const f of c.flags ?? []) lines.push(`complete -c claudep -n '__claudep_cmd ${ws}' -l ${f.slice(2)}`);
1699
+ for (const v of c.valueFlags ?? [])
1700
+ lines.push(
1701
+ `complete -c claudep -n '__claudep_cmd ${ws}' -l ${v.name.slice(2)} -x${v.values ? ` -a '${v.values.join(" ")}'` : ""}`,
1702
+ );
1703
+ }
1704
+ return `# claudep completions. Load them from ~/.config/fish/config.fish: ${completionHint("fish")}
1705
+ function __claudep_profiles
1706
+ echo default
1707
+ for d in ${fishQuote(profilesRoot)}/*/
1708
+ string replace -r -- '^.*/([^/]+)/$' '$1' $d
1709
+ end
1710
+ end
1711
+ function __claudep_at -a n
1712
+ test (count (commandline -opc)) -eq $n
1713
+ end
1714
+ function __claudep_cmd
1715
+ set -l t (commandline -opc)
1716
+ test (count $t) -ge 2; and contains -- $t[2] $argv
1717
+ end
1718
+ complete -c claudep -f
1719
+ ${lines.join("\n")}
1720
+ `;
1721
+ }
1722
+
1723
+ function cmdCompletion(L: Layout, args: string[]): void {
1724
+ const shell = args[0] ?? defaultShell(process.env, L.platform);
1725
+ if (!COMPLETION_SHELLS.includes(shell as CompletionShell))
1726
+ die(`no completions for "${shell}". Use ${COMPLETION_SHELLS.join(", ")}`);
1727
+ process.stdout.write(completionScript(shell as CompletionShell, L.profilesRoot));
1728
+ }
1729
+
1426
1730
  /** A POSIX-style CLAUDE_CONFIG_DIR on Windows is one claude.exe cannot read. */
1427
1731
  function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undefined {
1428
1732
  const cfg = env.CLAUDE_CONFIG_DIR;
@@ -1433,6 +1737,7 @@ function msysConfigDirWarning(L: Layout, env: Env = process.env): string | undef
1433
1737
  async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1434
1738
  const names = args[0] ? [args[0]] : listProfileNames(L);
1435
1739
  let problems = 0;
1740
+ let v: Version | undefined;
1436
1741
  const launch = findClaude();
1437
1742
  if (launch && launch.kind !== "ps1") {
1438
1743
  const { out } = await captureClaude(L, undefined, ["--version"]);
@@ -1441,7 +1746,7 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1441
1746
  console.log(
1442
1747
  `${c.dim("·")} claude is the npm cmd shim and runs through cmd.exe; the native installer's claude.exe avoids that hop`,
1443
1748
  );
1444
- const v = parseVersion(out);
1749
+ v = parseVersion(out);
1445
1750
  const floor = parseVersion(MIN_CLAUDE_VERSION);
1446
1751
  if (L.platform === "darwin" && v && floor && versionBelow(v, floor)) {
1447
1752
  bad(
@@ -1476,6 +1781,10 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1476
1781
  if (unclassified.length) {
1477
1782
  warn(`base items neither shared nor known-private (they stay per-profile): ${unclassified.join(", ")}`);
1478
1783
  }
1784
+ if (memoryWritesPrompt(v, shared))
1785
+ warn(
1786
+ `Claude Code ${v?.join(".")} asks for approval on every auto-memory write in a profile, because projects/ is shared through a symlink; auto mode cannot approve it. Known upstream bug: ${MEMORY_SYMLINK_ISSUE}`,
1787
+ );
1479
1788
 
1480
1789
  for (const name of names) {
1481
1790
  const dir = profileDir(L, name);
@@ -1505,13 +1814,23 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1505
1814
  ok(`${shared.length} shared item(s) checked`);
1506
1815
  const strays = readdirSync(dir).filter((n) => !sharedNames.has(n) && !KNOWN_PRIVATE.has(n) && !n.endsWith(".md"));
1507
1816
  if (strays.length) warn(`unexpected private items: ${strays.join(", ")}`);
1817
+ for (const n of staleSharedLinks(L.base, dir, L.platform)) {
1818
+ bad(
1819
+ `${n}: linked to the base by an older claudep, but it is per-profile state now. Remove the symlink: ${join(dir, n)}`,
1820
+ );
1821
+ problems++;
1822
+ }
1823
+ const s = await authStatus(L, dir);
1508
1824
  if (L.platform === "darwin") {
1509
1825
  const svc = keychainService(dir);
1510
1826
  if (await keychainHas(svc)) ok(`keychain item "${svc}" present`);
1511
1827
  else warn(`no keychain item "${svc}". Not logged in yet (claudep ${name} auth login)`);
1512
1828
  } else if (credentialsFileHas(dir)) ok(".credentials.json present");
1829
+ else if (s.loggedIn)
1830
+ console.log(
1831
+ `${c.dim("·")} no .credentials.json in the profile; Claude Code keeps this login elsewhere (on Windows, possibly Credential Manager)`,
1832
+ );
1513
1833
  else warn(`no .credentials.json in the profile. Not logged in yet (claudep ${name} auth login)`);
1514
- const s = await authStatus(L, dir);
1515
1834
  if (s.loggedIn) ok(`logged in as ${s.email ?? "?"} (${s.orgName ?? "?"}, ${s.subscriptionType ?? "?"})`);
1516
1835
  else warn("not logged in");
1517
1836
  }
@@ -1523,7 +1842,7 @@ async function cmdDoctor(L: Layout, args: string[]): Promise<void> {
1523
1842
  }
1524
1843
 
1525
1844
  async function cmdRm(L: Layout, args: string[]): Promise<void> {
1526
- const f = parseFlags(args, ["--keep-login", "--yes"], []);
1845
+ const f = parseFlags(args, ...flagSpec("rm"));
1527
1846
  const name = f.rest[0];
1528
1847
  if (!name) die("usage: claudep rm <name> [--keep-login] [--yes]");
1529
1848
  const dir = profileDir(L, name);
@@ -1582,8 +1901,14 @@ async function cmdRm(L: Layout, args: string[]): Promise<void> {
1582
1901
  }
1583
1902
 
1584
1903
  function help(L: Layout): void {
1904
+ console.log(helpText(L));
1905
+ }
1906
+
1907
+ /** Hand-written on purpose: the layout says more than the table can. A test
1908
+ * checks that every command in COMMANDS appears in it. */
1909
+ export function helpText(L: Layout): string {
1585
1910
  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
1911
+ return `${c.bold("claudep")} ${c.dim(version())}: run Claude Code under separate accounts on one machine
1587
1912
 
1588
1913
  ${c.bold("USAGE")}
1589
1914
  claudep <name> [claude args…] run claude with profile <name> (alias for "run")
@@ -1596,13 +1921,18 @@ ${c.bold("USAGE")}
1596
1921
  claudep alias <name> <command> write a shim so "<command>" == "claudep <name>"
1597
1922
  claudep doctor [name] verify symlinks, keychain entry, unclassified files, Claude Code version
1598
1923
  claudep rm <name> [--keep-login] log out and delete a profile (base is never touched)
1924
+ claudep help show this text
1599
1925
  claudep --version print the version
1600
1926
 
1601
1927
  ${c.bold("DIRECTORY PINS")}
1602
1928
  claudep local <name> [--force] write ./${PIN_FILE} so this tree uses <name>; --remove deletes it
1603
1929
  claudep local show the pin that applies to the current directory
1604
1930
  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
1931
+ claudep shell-init [${SHELLS.join("|")}] print the hook that applies pins on cd; load it from your rc file
1932
+
1933
+ ${c.bold("TAB COMPLETION")}
1934
+ claudep completion [${COMPLETION_SHELLS.join("|")}] print completions for commands, flags and profile names;
1935
+ load them from your rc file (after compinit in zsh)
1606
1936
 
1607
1937
  ${c.bold("INIT OPTIONS")}
1608
1938
  --sso force the SSO login flow (Enterprise orgs)
@@ -1625,6 +1955,7 @@ ${c.bold("EXAMPLES")}
1625
1955
  eval "$(claudep shell-init zsh)" # in .zshrc: shells follow ${PIN_FILE} pins on cd
1626
1956
  ${hookHint("fish")} # the same line for config.fish
1627
1957
  ${hookHint("powershell")} # the same line for $PROFILE
1958
+ ${completionHint("zsh")} # in .zshrc after compinit: tab completion
1628
1959
 
1629
1960
  ${c.bold("HOW IT WORKS")}
1630
1961
  ~/.claude stays exactly as it is and remains the "default" profile. Each named profile is a
@@ -1649,7 +1980,7 @@ ${c.bold("ENVIRONMENT")}
1649
1980
  CLAUDEP_AUTO set by the hook next to CLAUDE_CONFIG_DIR; marks the pin as hook-managed
1650
1981
 
1651
1982
  ${c.dim("claudep is an independent, unofficial tool. It is not affiliated with, endorsed by or supported by Anthropic.")}
1652
- `);
1983
+ `;
1653
1984
  }
1654
1985
 
1655
1986
  // ---------------------------------------------------------------------------
@@ -1685,6 +2016,8 @@ export async function main(argv: string[]): Promise<void> {
1685
2016
  return cmdResolve(L, args);
1686
2017
  case "shell-init":
1687
2018
  return cmdShellInit(L, args);
2019
+ case "completion":
2020
+ return cmdCompletion(L, args);
1688
2021
  case "version":
1689
2022
  case "--version":
1690
2023
  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.1",
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",