@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 +26 -1
- package/README.md +49 -9
- package/claudep.ts +543 -55
- package/package.json +1 -1
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.
|
|
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
|
|
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>
|
|
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]
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
/**
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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]
|
|
1362
|
-
claudep env <name> | --unset print the CLAUDE_CONFIG_DIR
|
|
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 [
|
|
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.
|
|
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",
|