@efoo/ccprofile 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 efoo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # ccprofile
2
+
3
+ Per-directory Claude Code account routing via `CLAUDE_CODE_OAUTH_TOKEN`, direnv, and the macOS Keychain.
4
+
5
+ `ccprofile` lets you run **multiple Claude Code accounts in parallel** — one per terminal, one per project — with zero manual switching. It never touches Claude Code's own Keychain entry, so there is no global "active account" to corrupt.
6
+
7
+ ## Why
8
+
9
+ Claude Code stores its OAuth credentials in a single macOS Keychain entry, shared across every `CLAUDE_CONFIG_DIR` profile ([#20553](https://github.com/anthropics/claude-code/issues/20553)). Switcher-style tools work around this by swapping that entry in place — which breaks down the moment two sessions with different accounts run at the same time (in-session token refresh writes the old account back).
10
+
11
+ `ccprofile` takes the declarative route instead:
12
+
13
+ - Each account's **long-lived OAuth token** (`claude setup-token`, valid ~1 year) is stored in the Keychain under ccprofile's own namespace — one entry per profile, no sharing, no swapping.
14
+ - `ccprofile link` writes a **self-contained `.envrc`** that exports `CLAUDE_CODE_OAUTH_TOKEN` straight from the Keychain. direnv activates it when you enter the directory. No node/npx in the hot path.
15
+ - `CLAUDE_CODE_OAUTH_TOKEN` outranks the stored login in Claude Code's [documented auth precedence](https://code.claude.com/docs/en/authentication#authentication-precedence), so linked directories route to their account and everywhere else falls back to your normal `/login`.
16
+
17
+ Auth state lives in each process's environment — parallel sessions cannot interfere with each other by construction.
18
+
19
+ ## Requirements
20
+
21
+ - macOS (tokens are stored in the Keychain)
22
+ - [Claude Code](https://code.claude.com) with a Pro / Max / Team / Enterprise subscription (`claude setup-token` requires one)
23
+ - [direnv](https://direnv.net) — `brew install direnv` + the shell hook (fish: `direnv hook fish | source`)
24
+ - Node.js >= 20 (only for running ccprofile itself)
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ npm install -g @efoo/ccprofile # provides the `ccprofile` command
30
+ # or run ad hoc:
31
+ npx @efoo/ccprofile --help
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```sh
37
+ # 1. Register an account (launches `claude setup-token`, stores the token in the Keychain)
38
+ ccprofile add work --email you@company.example
39
+
40
+ # 2. Route a project to it — run inside the project directory…
41
+ cd ~/src/my-project
42
+ ccprofile link work
43
+
44
+ # …or point at a directory from anywhere:
45
+ ccprofile link work ~/src/my-project
46
+
47
+ # 3. Done — any claude launched in that directory (and below) runs as "work"
48
+ claude # /status shows "Auth token: CLAUDE_CODE_OAUTH_TOKEN"
49
+ ```
50
+
51
+ Repeat with `ccprofile add personal` etc. Different terminals in different directories run different accounts concurrently.
52
+
53
+ ## Commands
54
+
55
+ | Command | Description |
56
+ | --- | --- |
57
+ | `ccprofile add <name>` | Register a profile. Flags: `--email`, `--expires-at <iso>`, `--token <token>`, `--force` |
58
+ | `ccprofile list [--json]` | Profiles with token presence and expiry countdown |
59
+ | `ccprofile link <name> [dir]` | Write the managed `.envrc` block and `direnv allow` |
60
+ | `ccprofile unlink [dir]` | Remove the managed block (deletes `.envrc` if nothing else remains) |
61
+ | `ccprofile token <name>` | Print the stored token to stdout (for scripting — handle with care) |
62
+ | `ccprofile remove <name>` | Delete the profile and its Keychain entry |
63
+ | `ccprofile doctor [dir]` | Diagnose overriding env vars (`ANTHROPIC_API_KEY` etc.), `apiKeyHelper`, expiry, token liveness, broken links. `--offline` skips the server probe |
64
+ | `ccprofile completion <shell>` | Print a completion script for fish, zsh, or bash |
65
+
66
+ ## Shell completion
67
+
68
+ Subcommands, flags, and registered profile names are all tab-completable:
69
+
70
+ ```sh
71
+ # fish
72
+ ccprofile completion fish > ~/.config/fish/completions/ccprofile.fish
73
+
74
+ # zsh (place _ccprofile somewhere in $fpath, then restart zsh)
75
+ ccprofile completion zsh > "${fpath[1]}/_ccprofile"
76
+
77
+ # bash
78
+ echo 'eval "$(ccprofile completion bash)"' >> ~/.bashrc
79
+ ```
80
+
81
+ `ccprofile link <TAB>` completes profile names by calling the hidden `ccprofile _profiles` helper, which only reads `~/.ccprofile/config.json` (never the Keychain).
82
+
83
+ ## How it works
84
+
85
+ ```
86
+ ~/.ccprofile/config.json profile metadata: email, expiry, keychain ref (no secrets)
87
+ macOS Keychain service "ccprofile", one entry per profile (the secrets)
88
+ <project>/.envrc managed block, generated by `ccprofile link`:
89
+
90
+ # >>> ccprofile managed >>>
91
+ # profile: work
92
+ export CLAUDE_CODE_OAUTH_TOKEN="$(security find-generic-password -w -s 'ccprofile' -a 'work' 2>/dev/null)"
93
+ # <<< ccprofile managed <<<
94
+ ```
95
+
96
+ Notes:
97
+
98
+ - Tokens are written to the Keychain via `security -i` (stdin), so secrets never appear in `ps` output.
99
+ - The `.envrc` block is **self-contained**: direnv re-evaluates it on every directory entry, and it must stay fast and dependency-free. ccprofile is only needed for CRUD operations.
100
+ - Add `.envrc` to your project's `.gitignore` — it is machine-local.
101
+
102
+ ## Limitations — the price of parallel accounts
103
+
104
+ ccprofile is built on `claude setup-token`, whose long-lived tokens are **deliberately scoped to inference only** ("for security reasons", per Claude Code's own `/doctor`). Several conveniences of a normal `/login` session are therefore unavailable in linked directories:
105
+
106
+ - **No account identity introspection.** The token cannot answer "whose token is this?" — the OAuth profile endpoint rejects it (`user:profile` scope missing, see [#11985](https://github.com/anthropics/claude-code/issues/11985)). The `--email` you record is a self-declared label, not verified.
107
+ *Verify identity once, at registration time:* make sure the browser is logged into the intended claude.ai account before `claude setup-token`, then send a couple of prompts from a linked directory and confirm on claude.ai (web) that the intended account's usage moved.
108
+ - **`/status` → Usage tab shows no plan utilization** in token-authenticated sessions (same scope restriction). Check usage on claude.ai instead.
109
+ - **Remote Control is unavailable** in token-authenticated sessions; it requires a full-scope login token.
110
+ - **Tokens last up to 1 year but can die earlier** (password change, logout-all). The recorded expiry is a hint, not a guarantee — `ccprofile doctor` probes the server and tells live tokens apart from revoked ones.
111
+ - **`claude --bare` does not read `CLAUDE_CODE_OAUTH_TOKEN`.**
112
+ - **Subscription accounting:** from June 15, 2026, `claude -p` / Agent SDK usage on subscription plans draws from a separate monthly Agent SDK credit.
113
+ - **direnv only sees shell-launched processes.** Apps started outside a hooked shell (GUI launchers) bypass the routing.
114
+ - **Higher-precedence auth wins silently.** `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `apiKeyHelper`, and Bedrock/Vertex/Foundry env vars all outrank the token — `ccprofile doctor` flags them.
115
+ - **macOS only** for now (the token store is the macOS Keychain).
116
+
117
+ ## Development
118
+
119
+ ```sh
120
+ pnpm install
121
+ pnpm build # tsc → dist/
122
+ pnpm test # vitest
123
+ node dist/index.js --help
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,73 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { parseArgs } from "node:util";
3
+ import { KEYCHAIN_SERVICE, computeExpiresAt, loadConfig, saveConfig, validateProfileName, } from "../lib/config.js";
4
+ import { Keychain, assertDarwin } from "../lib/keychain.js";
5
+ import { askHidden, confirm } from "../lib/prompt.js";
6
+ import { bold, cyan, dim, ok, warn } from "../lib/format.js";
7
+ const TOKEN_PREFIX = "sk-ant-";
8
+ export async function addCommand(argv) {
9
+ const { values, positionals } = parseArgs({
10
+ args: argv,
11
+ allowPositionals: true,
12
+ options: {
13
+ email: { type: "string" },
14
+ "expires-at": { type: "string" },
15
+ token: { type: "string" },
16
+ force: { type: "boolean", default: false },
17
+ "no-setup": { type: "boolean", default: false },
18
+ },
19
+ });
20
+ const name = positionals[0];
21
+ if (!name) {
22
+ console.error("Usage: ccprofile add <name> [--email <email>] [--expires-at <ISO date>]");
23
+ return 1;
24
+ }
25
+ validateProfileName(name);
26
+ assertDarwin();
27
+ const config = loadConfig();
28
+ if (config.profiles[name] && !values.force) {
29
+ console.error(`Profile "${name}" already exists. Use --force to overwrite its token and metadata.`);
30
+ return 1;
31
+ }
32
+ let token = values.token;
33
+ if (!token) {
34
+ console.log(`A long-lived OAuth token is issued by ${bold("claude setup-token")} (requires a Claude subscription).`);
35
+ if (!values["no-setup"] && process.stdin.isTTY) {
36
+ if (await confirm("Run `claude setup-token` now?")) {
37
+ const result = spawnSync("claude", ["setup-token"], { stdio: "inherit" });
38
+ if (result.error) {
39
+ console.error("Could not launch `claude`. Install Claude Code first, or run `claude setup-token` manually.");
40
+ return 1;
41
+ }
42
+ }
43
+ }
44
+ token = await askHidden("Paste the token (input is hidden): ");
45
+ }
46
+ if (token === "") {
47
+ console.error("No token provided. Aborting.");
48
+ return 1;
49
+ }
50
+ if (!token.startsWith(TOKEN_PREFIX)) {
51
+ console.log(warn(`The token does not start with "${TOKEN_PREFIX}", which is unexpected.`));
52
+ if (process.stdin.isTTY && !(await confirm("Store it anyway?", false))) {
53
+ return 1;
54
+ }
55
+ }
56
+ const keychain = new Keychain();
57
+ await keychain.setToken(KEYCHAIN_SERVICE, name, token);
58
+ const now = new Date();
59
+ const expiresAt = values["expires-at"]
60
+ ? new Date(values["expires-at"]).toISOString()
61
+ : computeExpiresAt(now);
62
+ config.profiles[name] = {
63
+ ...(values.email ? { email: values.email } : {}),
64
+ createdAt: now.toISOString(),
65
+ expiresAt,
66
+ keychain: { service: KEYCHAIN_SERVICE, account: name },
67
+ };
68
+ saveConfig(config);
69
+ console.log(ok(`Profile ${bold(name)} saved (Keychain: ${KEYCHAIN_SERVICE}/${name}).`));
70
+ console.log(dim(`Token recorded as expiring at ${expiresAt} (setup-token issues 1-year tokens).`));
71
+ console.log(`\nNext: route a project directory to this account:\n ${cyan(`ccprofile link ${name} <project-dir>`)}`);
72
+ return 0;
73
+ }
@@ -0,0 +1,149 @@
1
+ import { parseArgs } from "node:util";
2
+ export const SUBCOMMANDS = [
3
+ { name: "add", description: "Register a profile (runs claude setup-token)" },
4
+ { name: "list", description: "Show profiles, token presence, and expiry" },
5
+ { name: "link", description: "Route a directory to a profile" },
6
+ { name: "unlink", description: "Remove the managed .envrc block" },
7
+ { name: "token", description: "Print the stored token" },
8
+ { name: "remove", description: "Delete a profile and its Keychain entry" },
9
+ { name: "doctor", description: "Diagnose configuration problems" },
10
+ { name: "completion", description: "Print a shell completion script" },
11
+ { name: "help", description: "Show help" },
12
+ ];
13
+ const names = SUBCOMMANDS.map((s) => s.name).join(" ");
14
+ export function renderFishCompletion() {
15
+ const subcommandLines = SUBCOMMANDS.map((s) => `complete -c ccprofile -n __fish_use_subcommand -a ${s.name} -d "${s.description}"`).join("\n");
16
+ return `# ccprofile fish completion
17
+ # Install: ccprofile completion fish > ~/.config/fish/completions/ccprofile.fish
18
+
19
+ function __ccprofile_pos_eq
20
+ test (count (commandline -opc)) -eq $argv[1]
21
+ end
22
+
23
+ complete -c ccprofile -f
24
+
25
+ ${subcommandLines}
26
+
27
+ # Profile name arguments
28
+ complete -c ccprofile -n "__fish_seen_subcommand_from link" -n "__ccprofile_pos_eq 2" -a "(command ccprofile _profiles 2>/dev/null)"
29
+ complete -c ccprofile -n "__fish_seen_subcommand_from remove token" -n "__ccprofile_pos_eq 2" -a "(command ccprofile _profiles 2>/dev/null)"
30
+
31
+ # Directory arguments
32
+ complete -c ccprofile -n "__fish_seen_subcommand_from link" -n "__ccprofile_pos_eq 3" -F
33
+ complete -c ccprofile -n "__fish_seen_subcommand_from unlink doctor" -n "__ccprofile_pos_eq 2" -F
34
+
35
+ # completion <shell>
36
+ complete -c ccprofile -n "__fish_seen_subcommand_from completion" -n "__ccprofile_pos_eq 2" -a "fish zsh bash"
37
+
38
+ # Flags
39
+ complete -c ccprofile -n "__fish_seen_subcommand_from add" -l email -r -d "Account email (metadata)"
40
+ complete -c ccprofile -n "__fish_seen_subcommand_from add" -l expires-at -r -d "Override recorded expiry (ISO date)"
41
+ complete -c ccprofile -n "__fish_seen_subcommand_from add" -l token -r -d "Provide the token directly"
42
+ complete -c ccprofile -n "__fish_seen_subcommand_from add" -l force -d "Overwrite an existing profile"
43
+ complete -c ccprofile -n "__fish_seen_subcommand_from add" -l no-setup -d "Skip launching claude setup-token"
44
+ complete -c ccprofile -n "__fish_seen_subcommand_from list" -l json -d "JSON output"
45
+ complete -c ccprofile -n "__fish_seen_subcommand_from remove" -l force -d "Skip confirmation"
46
+ complete -c ccprofile -n __fish_use_subcommand -l help -d "Show help"
47
+ complete -c ccprofile -n __fish_use_subcommand -l version -d "Show version"
48
+ `;
49
+ }
50
+ export function renderZshCompletion() {
51
+ const describeLines = SUBCOMMANDS.map((s) => ` '${s.name}:${s.description}'`).join("\n");
52
+ return `#compdef ccprofile
53
+ # ccprofile zsh completion
54
+ # Install: ccprofile completion zsh > "\${fpath[1]}/_ccprofile" (then restart zsh)
55
+
56
+ _ccprofile() {
57
+ local -a subcmds
58
+ subcmds=(
59
+ ${describeLines}
60
+ )
61
+ if (( CURRENT == 2 )); then
62
+ _describe 'command' subcmds
63
+ return
64
+ fi
65
+ case "\${words[2]}" in
66
+ link)
67
+ if (( CURRENT == 3 )); then
68
+ compadd -- \$(ccprofile _profiles 2>/dev/null)
69
+ else
70
+ _files -/
71
+ fi
72
+ ;;
73
+ remove|token)
74
+ (( CURRENT == 3 )) && compadd -- \$(ccprofile _profiles 2>/dev/null)
75
+ ;;
76
+ unlink|doctor)
77
+ _files -/
78
+ ;;
79
+ completion)
80
+ compadd fish zsh bash
81
+ ;;
82
+ add)
83
+ compadd -- --email --expires-at --token --force --no-setup
84
+ ;;
85
+ list)
86
+ compadd -- --json
87
+ ;;
88
+ esac
89
+ }
90
+
91
+ _ccprofile "\$@"
92
+ `;
93
+ }
94
+ export function renderBashCompletion() {
95
+ return `# ccprofile bash completion
96
+ # Install: ccprofile completion bash > /usr/local/etc/bash_completion.d/ccprofile
97
+ # (or: eval "\$(ccprofile completion bash)" in ~/.bashrc)
98
+
99
+ _ccprofile() {
100
+ local cur sub
101
+ cur="\${COMP_WORDS[COMP_CWORD]}"
102
+ sub="\${COMP_WORDS[1]}"
103
+ if [ "\$COMP_CWORD" -eq 1 ]; then
104
+ COMPREPLY=( \$(compgen -W "${names}" -- "\$cur") )
105
+ return
106
+ fi
107
+ case "\$sub" in
108
+ link)
109
+ if [ "\$COMP_CWORD" -eq 2 ]; then
110
+ COMPREPLY=( \$(compgen -W "\$(ccprofile _profiles 2>/dev/null)" -- "\$cur") )
111
+ else
112
+ COMPREPLY=( \$(compgen -d -- "\$cur") )
113
+ fi ;;
114
+ remove|token)
115
+ [ "\$COMP_CWORD" -eq 2 ] && COMPREPLY=( \$(compgen -W "\$(ccprofile _profiles 2>/dev/null)" -- "\$cur") ) ;;
116
+ unlink|doctor)
117
+ COMPREPLY=( \$(compgen -d -- "\$cur") ) ;;
118
+ completion)
119
+ COMPREPLY=( \$(compgen -W "fish zsh bash" -- "\$cur") ) ;;
120
+ add)
121
+ COMPREPLY=( \$(compgen -W "--email --expires-at --token --force --no-setup" -- "\$cur") ) ;;
122
+ list)
123
+ COMPREPLY=( \$(compgen -W "--json" -- "\$cur") ) ;;
124
+ esac
125
+ }
126
+
127
+ complete -F _ccprofile ccprofile
128
+ `;
129
+ }
130
+ export async function completionCommand(argv) {
131
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
132
+ const shell = positionals[0];
133
+ switch (shell) {
134
+ case "fish":
135
+ process.stdout.write(renderFishCompletion());
136
+ return 0;
137
+ case "zsh":
138
+ process.stdout.write(renderZshCompletion());
139
+ return 0;
140
+ case "bash":
141
+ process.stdout.write(renderBashCompletion());
142
+ return 0;
143
+ default:
144
+ console.error("Usage: ccprofile completion <fish|zsh|bash>");
145
+ console.error("\nInstall (fish):");
146
+ console.error(" ccprofile completion fish > ~/.config/fish/completions/ccprofile.fish");
147
+ return 1;
148
+ }
149
+ }
@@ -0,0 +1,141 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { daysRemaining, loadConfig } from "../lib/config.js";
7
+ import { parseLinkedProfile } from "../lib/envrc.js";
8
+ import { Keychain } from "../lib/keychain.js";
9
+ import { probeToken } from "../lib/probe.js";
10
+ import { bold, fail, ok, warn } from "../lib/format.js";
11
+ /**
12
+ * Env vars that outrank CLAUDE_CODE_OAUTH_TOKEN in Claude Code's documented
13
+ * authentication precedence. If any is set, ccprofile routing is silently
14
+ * bypassed — that is the failure mode this command exists to catch.
15
+ */
16
+ const OVERRIDING_ENV_VARS = [
17
+ "CLAUDE_CODE_USE_BEDROCK",
18
+ "CLAUDE_CODE_USE_VERTEX",
19
+ "CLAUDE_CODE_USE_FOUNDRY",
20
+ "ANTHROPIC_AUTH_TOKEN",
21
+ "ANTHROPIC_API_KEY",
22
+ ];
23
+ export async function doctorCommand(argv) {
24
+ const { values, positionals } = parseArgs({
25
+ args: argv,
26
+ allowPositionals: true,
27
+ options: { offline: { type: "boolean", default: false } },
28
+ });
29
+ const dir = resolve(positionals[0] ?? process.cwd());
30
+ let problems = 0;
31
+ let warnings = 0;
32
+ if (process.platform !== "darwin") {
33
+ console.log(fail("Not macOS: ccprofile's Keychain backend is unavailable on this platform."));
34
+ return 1;
35
+ }
36
+ console.log(ok("Platform: macOS (Keychain backend available)"));
37
+ const claude = spawnSync("claude", ["--version"], { stdio: "pipe" });
38
+ if (claude.error || claude.status !== 0) {
39
+ console.log(warn("`claude` CLI not found. `ccprofile add` cannot launch setup-token for you."));
40
+ warnings += 1;
41
+ }
42
+ else {
43
+ console.log(ok(`Claude Code: ${claude.stdout.toString().trim()}`));
44
+ }
45
+ const direnv = spawnSync("direnv", ["version"], { stdio: "pipe" });
46
+ if (direnv.error || direnv.status !== 0) {
47
+ console.log(fail("direnv not found. Linked directories will not activate tokens automatically."));
48
+ console.log(" Install: brew install direnv / fish hook: direnv hook fish | source");
49
+ problems += 1;
50
+ }
51
+ else {
52
+ console.log(ok(`direnv: ${direnv.stdout.toString().trim()}`));
53
+ }
54
+ for (const envVar of OVERRIDING_ENV_VARS) {
55
+ if (process.env[envVar] !== undefined) {
56
+ console.log(fail(`${envVar} is set: it overrides CLAUDE_CODE_OAUTH_TOKEN and bypasses ccprofile routing.`));
57
+ problems += 1;
58
+ }
59
+ }
60
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
61
+ const settingsPath = join(claudeConfigDir, "settings.json");
62
+ if (existsSync(settingsPath)) {
63
+ try {
64
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
65
+ if (settings.apiKeyHelper !== undefined) {
66
+ console.log(fail(`apiKeyHelper is configured in ${settingsPath}: it overrides CLAUDE_CODE_OAUTH_TOKEN.`));
67
+ problems += 1;
68
+ }
69
+ }
70
+ catch {
71
+ console.log(warn(`Could not parse ${settingsPath}.`));
72
+ warnings += 1;
73
+ }
74
+ }
75
+ const config = loadConfig();
76
+ const keychain = new Keychain();
77
+ for (const [name, profile] of Object.entries(config.profiles)) {
78
+ const present = await keychain.hasEntry(profile.keychain.service, profile.keychain.account);
79
+ if (!present) {
80
+ console.log(fail(`Profile "${name}": Keychain entry missing. Re-run: ccprofile add ${name} --force`));
81
+ problems += 1;
82
+ continue;
83
+ }
84
+ const days = daysRemaining(profile.expiresAt);
85
+ if (days < 0) {
86
+ console.log(fail(`Profile "${name}": token recorded as expired ${-days}d ago. Re-issue with claude setup-token.`));
87
+ problems += 1;
88
+ }
89
+ else if (days <= config.settings.expiryWarningDays) {
90
+ console.log(warn(`Profile "${name}": token expires in ${days}d.`));
91
+ warnings += 1;
92
+ }
93
+ else {
94
+ console.log(ok(`Profile "${name}": token stored, expires in ${days}d.`));
95
+ }
96
+ if (values.offline)
97
+ continue;
98
+ const token = await keychain.getToken(profile.keychain.service, profile.keychain.account);
99
+ if (token === null)
100
+ continue;
101
+ const live = await probeToken(token);
102
+ if (live.status === "alive") {
103
+ if (live.email !== undefined && profile.email !== undefined && live.email !== profile.email) {
104
+ console.log(fail(`Profile "${name}": server says the token belongs to ${live.email}, but config records ${profile.email}.`));
105
+ problems += 1;
106
+ }
107
+ else {
108
+ console.log(ok(`Profile "${name}": token is live on the server${live.email ? ` (account: ${live.email})` : ""}.`));
109
+ }
110
+ }
111
+ else if (live.status === "invalid") {
112
+ console.log(fail(`Profile "${name}": token rejected by the server (revoked or expired early). Re-issue with: ccprofile add ${name} --force`));
113
+ problems += 1;
114
+ }
115
+ else {
116
+ console.log(warn(`Profile "${name}": liveness check inconclusive (${live.detail}). Use --offline to skip.`));
117
+ warnings += 1;
118
+ }
119
+ }
120
+ const envrcPath = join(dir, ".envrc");
121
+ if (existsSync(envrcPath)) {
122
+ const linked = parseLinkedProfile(readFileSync(envrcPath, "utf8"));
123
+ if (linked === null) {
124
+ console.log(ok(`${envrcPath} exists but has no ccprofile block (not managed here).`));
125
+ }
126
+ else if (config.profiles[linked]) {
127
+ console.log(ok(`${bold(dir)} is linked to profile "${linked}".`));
128
+ }
129
+ else {
130
+ console.log(fail(`${envrcPath} references unknown profile "${linked}". Run: ccprofile link <profile> ${dir}`));
131
+ problems += 1;
132
+ }
133
+ }
134
+ console.log();
135
+ if (problems > 0) {
136
+ console.log(fail(`${problems} problem(s), ${warnings} warning(s).`));
137
+ return 1;
138
+ }
139
+ console.log(ok(`No problems found (${warnings} warning(s)).`));
140
+ return 0;
141
+ }
@@ -0,0 +1,45 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { resolve, join } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { loadConfig } from "../lib/config.js";
6
+ import { renderBlock, upsertBlock } from "../lib/envrc.js";
7
+ import { bold, cyan, dim, ok, warn } from "../lib/format.js";
8
+ export async function linkCommand(argv) {
9
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
10
+ const name = positionals[0];
11
+ if (!name) {
12
+ console.error("Usage: ccprofile link <profile> [dir]");
13
+ return 1;
14
+ }
15
+ const dir = resolve(positionals[1] ?? process.cwd());
16
+ const config = loadConfig();
17
+ const profile = config.profiles[name];
18
+ if (!profile) {
19
+ console.error(`Profile "${name}" does not exist. Create it with: ccprofile add ${name}`);
20
+ return 1;
21
+ }
22
+ if (!existsSync(dir)) {
23
+ console.error(`Directory does not exist: ${dir}`);
24
+ return 1;
25
+ }
26
+ const envrcPath = join(dir, ".envrc");
27
+ const current = existsSync(envrcPath) ? readFileSync(envrcPath, "utf8") : "";
28
+ const block = renderBlock(name, profile.keychain.service, profile.keychain.account);
29
+ writeFileSync(envrcPath, upsertBlock(current, block));
30
+ console.log(ok(`${bold(dir)} now routes Claude Code to profile ${bold(name)}${profile.email ? dim(` (${profile.email})`) : ""}.`));
31
+ const allow = spawnSync("direnv", ["allow", dir], { stdio: "pipe" });
32
+ if (allow.error) {
33
+ console.log(warn("direnv is not installed; the .envrc was written but will not load automatically."));
34
+ console.log(` Install it with ${cyan("brew install direnv")} and add the shell hook (fish: ${cyan("direnv hook fish | source")}).`);
35
+ }
36
+ else if (allow.status !== 0) {
37
+ console.log(warn(`\`direnv allow\` failed: ${allow.stderr.toString().trim()}`));
38
+ return 1;
39
+ }
40
+ else {
41
+ console.log(ok("direnv allow completed. Entering the directory activates the token."));
42
+ }
43
+ console.log(dim(`Tip: add .envrc to the project's .gitignore (paths are machine-local).`));
44
+ return 0;
45
+ }
@@ -0,0 +1,48 @@
1
+ import { parseArgs } from "node:util";
2
+ import { daysRemaining, loadConfig } from "../lib/config.js";
3
+ import { Keychain, assertDarwin } from "../lib/keychain.js";
4
+ import { bold, describeExpiry, dim, red, table } from "../lib/format.js";
5
+ export async function listCommand(argv) {
6
+ const { values } = parseArgs({
7
+ args: argv,
8
+ options: { json: { type: "boolean", default: false } },
9
+ });
10
+ const config = loadConfig();
11
+ const names = Object.keys(config.profiles).sort();
12
+ if (values.json) {
13
+ const out = names.map((name) => {
14
+ const p = config.profiles[name];
15
+ return {
16
+ name,
17
+ email: p.email ?? null,
18
+ createdAt: p.createdAt,
19
+ expiresAt: p.expiresAt,
20
+ daysRemaining: daysRemaining(p.expiresAt),
21
+ keychain: p.keychain,
22
+ };
23
+ });
24
+ console.log(JSON.stringify(out, null, 2));
25
+ return 0;
26
+ }
27
+ if (names.length === 0) {
28
+ console.log(`No profiles yet. Create one with ${bold("ccprofile add <name>")}.`);
29
+ return 0;
30
+ }
31
+ assertDarwin();
32
+ const keychain = new Keychain();
33
+ const rows = [
34
+ [bold("NAME"), bold("EMAIL"), bold("TOKEN"), bold("EXPIRY")],
35
+ ];
36
+ for (const name of names) {
37
+ const p = config.profiles[name];
38
+ const present = await keychain.hasEntry(p.keychain.service, p.keychain.account);
39
+ rows.push([
40
+ name,
41
+ p.email ?? dim("-"),
42
+ present ? "stored" : red("missing"),
43
+ describeExpiry(daysRemaining(p.expiresAt), config.settings.expiryWarningDays),
44
+ ]);
45
+ }
46
+ console.log(table(rows));
47
+ return 0;
48
+ }
@@ -0,0 +1,39 @@
1
+ import { parseArgs } from "node:util";
2
+ import { loadConfig, saveConfig } from "../lib/config.js";
3
+ import { Keychain, assertDarwin } from "../lib/keychain.js";
4
+ import { bold, ok, warn } from "../lib/format.js";
5
+ import { confirm } from "../lib/prompt.js";
6
+ export async function removeCommand(argv) {
7
+ const { values, positionals } = parseArgs({
8
+ args: argv,
9
+ allowPositionals: true,
10
+ options: { force: { type: "boolean", default: false } },
11
+ });
12
+ const name = positionals[0];
13
+ if (!name) {
14
+ console.error("Usage: ccprofile remove <name> [--force]");
15
+ return 1;
16
+ }
17
+ const config = loadConfig();
18
+ const profile = config.profiles[name];
19
+ if (!profile) {
20
+ console.error(`Profile "${name}" does not exist.`);
21
+ return 1;
22
+ }
23
+ if (!values.force && process.stdin.isTTY) {
24
+ const yes = await confirm(`Delete profile ${bold(name)} and its Keychain token?`, false);
25
+ if (!yes)
26
+ return 1;
27
+ }
28
+ assertDarwin();
29
+ const keychain = new Keychain();
30
+ const deleted = await keychain.deleteToken(profile.keychain.service, profile.keychain.account);
31
+ if (!deleted) {
32
+ console.log(warn("Keychain entry was not found (already removed?). Continuing."));
33
+ }
34
+ delete config.profiles[name];
35
+ saveConfig(config);
36
+ console.log(ok(`Profile ${bold(name)} removed.`));
37
+ console.log("Note: any .envrc still referencing this profile will now export an empty token; run `ccprofile unlink <dir>` there.");
38
+ return 0;
39
+ }
@@ -0,0 +1,26 @@
1
+ import { parseArgs } from "node:util";
2
+ import { loadConfig } from "../lib/config.js";
3
+ import { Keychain, assertDarwin } from "../lib/keychain.js";
4
+ export async function tokenCommand(argv) {
5
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
6
+ const name = positionals[0];
7
+ if (!name) {
8
+ console.error("Usage: ccprofile token <profile>");
9
+ return 1;
10
+ }
11
+ const config = loadConfig();
12
+ const profile = config.profiles[name];
13
+ if (!profile) {
14
+ console.error(`Profile "${name}" does not exist.`);
15
+ return 1;
16
+ }
17
+ assertDarwin();
18
+ const keychain = new Keychain();
19
+ const token = await keychain.getToken(profile.keychain.service, profile.keychain.account);
20
+ if (token === null) {
21
+ console.error(`No Keychain entry for profile "${name}". Re-run: ccprofile add ${name} --force`);
22
+ return 1;
23
+ }
24
+ process.stdout.write(`${token}\n`);
25
+ return 0;
26
+ }
@@ -0,0 +1,29 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { removeBlock } from "../lib/envrc.js";
5
+ import { bold, ok } from "../lib/format.js";
6
+ export async function unlinkCommand(argv) {
7
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
8
+ const dir = resolve(positionals[0] ?? process.cwd());
9
+ const envrcPath = join(dir, ".envrc");
10
+ if (!existsSync(envrcPath)) {
11
+ console.error(`No .envrc found in ${dir}.`);
12
+ return 1;
13
+ }
14
+ const current = readFileSync(envrcPath, "utf8");
15
+ const { content, removed } = removeBlock(current);
16
+ if (!removed) {
17
+ console.error(`No ccprofile-managed block found in ${envrcPath}.`);
18
+ return 1;
19
+ }
20
+ if (content.trim() === "") {
21
+ unlinkSync(envrcPath);
22
+ console.log(ok(`Removed ${bold(envrcPath)} (it contained only the ccprofile block).`));
23
+ }
24
+ else {
25
+ writeFileSync(envrcPath, content);
26
+ console.log(ok(`Removed the ccprofile block from ${bold(envrcPath)}.`));
27
+ }
28
+ return 0;
29
+ }
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import { bold, cyan } from "./lib/format.js";
4
+ const require = createRequire(import.meta.url);
5
+ const HELP = `${bold("ccprofile")} — per-directory Claude Code account routing
6
+
7
+ Tokens live in the macOS Keychain; profile metadata lives in ~/.ccprofile/config.json.
8
+ Linked directories get a self-contained .envrc that direnv loads on entry, so
9
+ parallel sessions with different accounts never share mutable auth state.
10
+
11
+ ${bold("Usage")}
12
+ ccprofile <command> [options]
13
+
14
+ ${bold("Commands")}
15
+ add <name> Register a profile (runs ${cyan("claude setup-token")}, stores the token)
16
+ --email <email> Attach the account email as metadata
17
+ --expires-at <iso> Override the recorded expiry (default: +365 days)
18
+ --token <token> Provide the token directly (skips prompts)
19
+ --force Overwrite an existing profile
20
+ list [--json] Show profiles, token presence, and expiry
21
+ link <name> [dir] Route a directory to a profile (writes .envrc, direnv allow)
22
+ unlink [dir] Remove the managed block from a directory's .envrc
23
+ token <name> Print the stored token (for scripting; handle with care)
24
+ remove <name> [--force] Delete a profile and its Keychain entry
25
+ doctor [dir] Diagnose overriding env vars, expiry, token liveness,
26
+ --offline and broken links; --offline skips the server probe
27
+ completion <shell> Print a completion script (fish, zsh, bash)
28
+
29
+ ${bold("Typical flow")}
30
+ ccprofile add work --email you@company.example
31
+ ccprofile link work ~/src/my-project
32
+ cd ~/src/my-project && claude # runs as "work" via CLAUDE_CODE_OAUTH_TOKEN
33
+ `;
34
+ async function main() {
35
+ const [command, ...rest] = process.argv.slice(2);
36
+ switch (command) {
37
+ case "add":
38
+ return (await import("./commands/add.js")).addCommand(rest);
39
+ case "list":
40
+ case "ls":
41
+ return (await import("./commands/list.js")).listCommand(rest);
42
+ case "remove":
43
+ case "rm":
44
+ return (await import("./commands/remove.js")).removeCommand(rest);
45
+ case "link":
46
+ return (await import("./commands/link.js")).linkCommand(rest);
47
+ case "unlink":
48
+ return (await import("./commands/unlink.js")).unlinkCommand(rest);
49
+ case "token":
50
+ return (await import("./commands/token.js")).tokenCommand(rest);
51
+ case "doctor":
52
+ return (await import("./commands/doctor.js")).doctorCommand(rest);
53
+ case "completion":
54
+ return (await import("./commands/completion.js")).completionCommand(rest);
55
+ // Hidden helper for shell completions: prints profile names, one per line.
56
+ case "_profiles": {
57
+ const { loadConfig } = await import("./lib/config.js");
58
+ for (const name of Object.keys(loadConfig().profiles).sort()) {
59
+ console.log(name);
60
+ }
61
+ return 0;
62
+ }
63
+ case "--version":
64
+ case "-v": {
65
+ const pkg = require("../package.json");
66
+ console.log(pkg.version);
67
+ return 0;
68
+ }
69
+ case undefined:
70
+ case "help":
71
+ case "--help":
72
+ case "-h":
73
+ console.log(HELP);
74
+ return command === undefined ? 1 : 0;
75
+ default:
76
+ console.error(`Unknown command: ${command}\n`);
77
+ console.log(HELP);
78
+ return 1;
79
+ }
80
+ }
81
+ main()
82
+ .then((code) => {
83
+ process.exitCode = code;
84
+ })
85
+ .catch((error) => {
86
+ console.error(error instanceof Error ? error.message : String(error));
87
+ process.exitCode = 1;
88
+ });
@@ -0,0 +1,59 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export const KEYCHAIN_SERVICE = "ccprofile";
5
+ export const DEFAULT_EXPIRY_DAYS = 365;
6
+ export const DEFAULT_EXPIRY_WARNING_DAYS = 30;
7
+ export function defaultConfig() {
8
+ return {
9
+ version: 1,
10
+ settings: { expiryWarningDays: DEFAULT_EXPIRY_WARNING_DAYS },
11
+ profiles: {},
12
+ };
13
+ }
14
+ export function configDir() {
15
+ return process.env.CCPROFILE_DIR ?? join(homedir(), ".ccprofile");
16
+ }
17
+ export function configPath() {
18
+ return join(configDir(), "config.json");
19
+ }
20
+ export function loadConfig() {
21
+ let raw;
22
+ try {
23
+ raw = readFileSync(configPath(), "utf8");
24
+ }
25
+ catch {
26
+ return defaultConfig();
27
+ }
28
+ const parsed = JSON.parse(raw);
29
+ if (parsed.version !== 1) {
30
+ throw new Error(`Unsupported config version ${String(parsed.version)} in ${configPath()}`);
31
+ }
32
+ return {
33
+ ...defaultConfig(),
34
+ ...parsed,
35
+ settings: { ...defaultConfig().settings, ...parsed.settings },
36
+ profiles: parsed.profiles ?? {},
37
+ };
38
+ }
39
+ export function saveConfig(config) {
40
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
41
+ writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`, {
42
+ mode: 0o600,
43
+ });
44
+ }
45
+ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
46
+ export function validateProfileName(name) {
47
+ if (!PROFILE_NAME_RE.test(name)) {
48
+ throw new Error(`Invalid profile name "${name}". Use lowercase letters, digits, ".", "_" or "-" (must start with a letter or digit).`);
49
+ }
50
+ }
51
+ export function computeExpiresAt(from, days = DEFAULT_EXPIRY_DAYS) {
52
+ const d = new Date(from.getTime());
53
+ d.setUTCDate(d.getUTCDate() + days);
54
+ return d.toISOString();
55
+ }
56
+ export function daysRemaining(expiresAt, now = new Date()) {
57
+ const ms = new Date(expiresAt).getTime() - now.getTime();
58
+ return Math.floor(ms / 86_400_000);
59
+ }
@@ -0,0 +1,42 @@
1
+ const BEGIN = "# >>> ccprofile managed >>>";
2
+ const END = "# <<< ccprofile managed <<<";
3
+ const BLOCK_RE = new RegExp(`${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}\\n?`);
4
+ function escapeRe(s) {
5
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ }
7
+ /**
8
+ * The generated block is self-contained on purpose: direnv evaluates .envrc
9
+ * on every directory entry, so it must not depend on node/npx being invoked.
10
+ */
11
+ export function renderBlock(profile, service, account) {
12
+ return [
13
+ BEGIN,
14
+ `# profile: ${profile}`,
15
+ `export CLAUDE_CODE_OAUTH_TOKEN="$(security find-generic-password -w -s '${service}' -a '${account}' 2>/dev/null)"`,
16
+ END,
17
+ "",
18
+ ].join("\n");
19
+ }
20
+ export function upsertBlock(content, block) {
21
+ if (BLOCK_RE.test(content)) {
22
+ return content.replace(BLOCK_RE, block);
23
+ }
24
+ if (content.trim() === "")
25
+ return block;
26
+ const sep = content.endsWith("\n") ? "\n" : "\n\n";
27
+ return `${content}${sep}${block}`;
28
+ }
29
+ export function removeBlock(content) {
30
+ if (!BLOCK_RE.test(content)) {
31
+ return { content, removed: false };
32
+ }
33
+ const next = content.replace(BLOCK_RE, "").replace(/\n{3,}/g, "\n\n");
34
+ return { content: next, removed: true };
35
+ }
36
+ export function parseLinkedProfile(content) {
37
+ const match = BLOCK_RE.exec(content);
38
+ if (!match)
39
+ return null;
40
+ const inner = /^# profile: (.+)$/m.exec(match[0]);
41
+ return inner?.[1]?.trim() ?? null;
42
+ }
@@ -0,0 +1,40 @@
1
+ const ESC = "\u001B";
2
+ const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
3
+ function paint(code, s) {
4
+ return useColor ? `${ESC}[${code}m${s}${ESC}[0m` : s;
5
+ }
6
+ export const bold = (s) => paint("1", s);
7
+ export const dim = (s) => paint("2", s);
8
+ export const red = (s) => paint("31", s);
9
+ export const green = (s) => paint("32", s);
10
+ export const yellow = (s) => paint("33", s);
11
+ export const cyan = (s) => paint("36", s);
12
+ export const ok = (s) => `${green("✓")} ${s}`;
13
+ export const warn = (s) => `${yellow("⚠")} ${s}`;
14
+ export const fail = (s) => `${red("✗")} ${s}`;
15
+ function stripAnsi(s) {
16
+ return s.replaceAll(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), "");
17
+ }
18
+ export function table(rows) {
19
+ if (rows.length === 0)
20
+ return "";
21
+ const widths = [];
22
+ for (const row of rows) {
23
+ row.forEach((cell, i) => {
24
+ widths[i] = Math.max(widths[i] ?? 0, stripAnsi(cell).length);
25
+ });
26
+ }
27
+ return rows
28
+ .map((row) => row
29
+ .map((cell, i) => cell + " ".repeat((widths[i] ?? 0) - stripAnsi(cell).length))
30
+ .join(" ")
31
+ .trimEnd())
32
+ .join("\n");
33
+ }
34
+ export function describeExpiry(days, warningDays) {
35
+ if (days < 0)
36
+ return red(`expired ${-days}d ago`);
37
+ if (days <= warningDays)
38
+ return yellow(`expires in ${days}d`);
39
+ return green(`expires in ${days}d`);
40
+ }
@@ -0,0 +1,91 @@
1
+ import { spawn } from "node:child_process";
2
+ export const defaultRunner = (cmd, args, opts) => new Promise((resolve, reject) => {
3
+ const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
4
+ let stdout = "";
5
+ let stderr = "";
6
+ child.stdout.on("data", (d) => (stdout += d.toString()));
7
+ child.stderr.on("data", (d) => (stderr += d.toString()));
8
+ child.on("error", reject);
9
+ child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
10
+ if (opts?.stdinData !== undefined) {
11
+ child.stdin.write(opts.stdinData);
12
+ }
13
+ child.stdin.end();
14
+ });
15
+ // The `security -i` batch parser and the generated .envrc both consume these
16
+ // values unquoted, so the charset must stay shell- and parser-safe.
17
+ const SAFE_VALUE_RE = /^[A-Za-z0-9._-]+$/;
18
+ function assertSafe(label, value) {
19
+ if (!SAFE_VALUE_RE.test(value)) {
20
+ throw new Error(`${label} contains unsupported characters (allowed: letters, digits, ".", "_", "-").`);
21
+ }
22
+ }
23
+ export function assertDarwin() {
24
+ if (process.platform !== "darwin") {
25
+ throw new Error("ccprofile currently supports macOS only (tokens are stored in the macOS Keychain).");
26
+ }
27
+ }
28
+ export class Keychain {
29
+ run;
30
+ constructor(run = defaultRunner) {
31
+ this.run = run;
32
+ }
33
+ /**
34
+ * Stores the token via `security -i` (commands over stdin) so the secret
35
+ * never appears in the process argument list visible to `ps`.
36
+ * Write is verified with a read-back because `security -i` exit codes do
37
+ * not reliably reflect per-command failures.
38
+ */
39
+ async setToken(service, account, token) {
40
+ assertSafe("service", service);
41
+ assertSafe("account", account);
42
+ assertSafe("token", token);
43
+ await this.run("security", ["-i"], {
44
+ stdinData: `add-generic-password -U -s ${service} -a ${account} -w ${token}\n`,
45
+ });
46
+ const readBack = await this.getToken(service, account);
47
+ if (readBack !== token) {
48
+ throw new Error(`Failed to store the token in the Keychain (service=${service}, account=${account}).`);
49
+ }
50
+ }
51
+ async getToken(service, account) {
52
+ assertSafe("service", service);
53
+ assertSafe("account", account);
54
+ const result = await this.run("security", [
55
+ "find-generic-password",
56
+ "-w",
57
+ "-s",
58
+ service,
59
+ "-a",
60
+ account,
61
+ ]);
62
+ if (result.code !== 0)
63
+ return null;
64
+ return result.stdout.replace(/\n$/, "");
65
+ }
66
+ /** Presence check without reading the secret value. */
67
+ async hasEntry(service, account) {
68
+ assertSafe("service", service);
69
+ assertSafe("account", account);
70
+ const result = await this.run("security", [
71
+ "find-generic-password",
72
+ "-s",
73
+ service,
74
+ "-a",
75
+ account,
76
+ ]);
77
+ return result.code === 0;
78
+ }
79
+ async deleteToken(service, account) {
80
+ assertSafe("service", service);
81
+ assertSafe("account", account);
82
+ const result = await this.run("security", [
83
+ "delete-generic-password",
84
+ "-s",
85
+ service,
86
+ "-a",
87
+ account,
88
+ ]);
89
+ return result.code === 0;
90
+ }
91
+ }
@@ -0,0 +1,53 @@
1
+ const PROFILE_ENDPOINT = "https://api.anthropic.com/api/oauth/profile";
2
+ /**
3
+ * Distinguishes live tokens from revoked ones using the OAuth profile
4
+ * endpoint. setup-token tokens are inference-only by design, so a scope
5
+ * rejection (permission_error) proves the token authenticated successfully
6
+ * — that is the expected "alive" signal, not a failure. See
7
+ * anthropics/claude-code#11985.
8
+ */
9
+ export async function probeToken(token, fetchFn = fetch) {
10
+ let res;
11
+ try {
12
+ res = await fetchFn(PROFILE_ENDPOINT, {
13
+ headers: {
14
+ Authorization: `Bearer ${token}`,
15
+ "anthropic-beta": "oauth-2025-04-20",
16
+ },
17
+ });
18
+ }
19
+ catch (error) {
20
+ return {
21
+ status: "unknown",
22
+ detail: error instanceof Error ? error.message : String(error),
23
+ };
24
+ }
25
+ if (res.ok) {
26
+ // Tokens with the user:profile scope (not setup-token) get account info.
27
+ try {
28
+ const body = (await res.json());
29
+ const email = body.account?.email ?? body.account?.email_address;
30
+ return email ? { status: "alive", email } : { status: "alive" };
31
+ }
32
+ catch {
33
+ return { status: "alive" };
34
+ }
35
+ }
36
+ let errType = "";
37
+ let message = "";
38
+ try {
39
+ const body = (await res.json());
40
+ errType = body.error?.type ?? "";
41
+ message = body.error?.message ?? "";
42
+ }
43
+ catch {
44
+ // non-JSON error body
45
+ }
46
+ if (errType === "permission_error" && message.includes("scope")) {
47
+ return { status: "alive" };
48
+ }
49
+ if (res.status === 401 || errType === "authentication_error") {
50
+ return { status: "invalid", detail: message || `HTTP ${res.status}` };
51
+ }
52
+ return { status: "unknown", detail: message || `HTTP ${res.status}` };
53
+ }
@@ -0,0 +1,71 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ export async function ask(question) {
4
+ const rl = createInterface({ input: stdin, output: stdout });
5
+ try {
6
+ return (await rl.question(question)).trim();
7
+ }
8
+ finally {
9
+ rl.close();
10
+ }
11
+ }
12
+ export async function confirm(question, defaultYes = true) {
13
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
14
+ const answer = (await ask(`${question} ${hint} `)).toLowerCase();
15
+ if (answer === "")
16
+ return defaultYes;
17
+ return answer === "y" || answer === "yes";
18
+ }
19
+ /** Reads a line without echoing it (for token paste). */
20
+ export function askHidden(question) {
21
+ return new Promise((resolve, reject) => {
22
+ if (!stdin.isTTY) {
23
+ // Piped input: fall back to reading a single line.
24
+ let buf = "";
25
+ stdin.setEncoding("utf8");
26
+ stdin.on("data", (chunk) => {
27
+ buf += chunk;
28
+ const nl = buf.indexOf("\n");
29
+ if (nl !== -1) {
30
+ stdin.pause();
31
+ resolve(buf.slice(0, nl).trim());
32
+ }
33
+ });
34
+ stdin.on("end", () => resolve(buf.trim()));
35
+ stdin.on("error", reject);
36
+ return;
37
+ }
38
+ stdout.write(question);
39
+ stdin.setRawMode(true);
40
+ stdin.resume();
41
+ stdin.setEncoding("utf8");
42
+ let value = "";
43
+ const onData = (chunk) => {
44
+ for (const ch of chunk) {
45
+ if (ch === "") {
46
+ // Ctrl-C
47
+ cleanup();
48
+ stdout.write("\n");
49
+ process.exit(130);
50
+ }
51
+ if (ch === "\r" || ch === "\n") {
52
+ cleanup();
53
+ stdout.write("\n");
54
+ resolve(value.trim());
55
+ return;
56
+ }
57
+ if (ch === "" || ch === "\b") {
58
+ value = value.slice(0, -1);
59
+ continue;
60
+ }
61
+ value += ch;
62
+ }
63
+ };
64
+ const cleanup = () => {
65
+ stdin.setRawMode(false);
66
+ stdin.pause();
67
+ stdin.off("data", onData);
68
+ };
69
+ stdin.on("data", onData);
70
+ });
71
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@efoo/ccprofile",
3
+ "version": "0.1.0",
4
+ "description": "Per-directory Claude Code account routing via CLAUDE_CODE_OAUTH_TOKEN, direnv, and macOS Keychain",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/efoo-team/ccprofile.git"
10
+ },
11
+ "homepage": "https://github.com/efoo-team/ccprofile#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/efoo-team/ccprofile/issues"
14
+ },
15
+ "keywords": [
16
+ "claude",
17
+ "claude-code",
18
+ "account",
19
+ "profile",
20
+ "direnv",
21
+ "keychain",
22
+ "cli"
23
+ ],
24
+ "bin": {
25
+ "ccprofile": "./dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "vitest run",
40
+ "test:watch": "vitest",
41
+ "prepublishOnly": "pnpm run build"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.10.0",
45
+ "typescript": "^5.7.0",
46
+ "vitest": "^3.0.0"
47
+ }
48
+ }