@efoo/ccprofile 0.4.0 → 0.5.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/README.md CHANGED
@@ -63,6 +63,22 @@ claude # /status shows "Auth token: ANTHROPIC_AUTH_TOKEN"
63
63
 
64
64
  Repeat with `ccprofile add personal` etc. Different terminals in different directories run different accounts concurrently.
65
65
 
66
+ ### Checking status
67
+
68
+ ```sh
69
+ ccprofile which # which account does *this* shell/directory resolve to? (instant, no network)
70
+ ccprofile usage # per-account plan utilization + reset times, straight from claude.ai
71
+ ```
72
+
73
+ `ccprofile which` (alias `whoami`) answers "who am I running as here?" from local
74
+ signals only, in Claude Code's own precedence order: a provider override
75
+ (`CLAUDE_CODE_USE_{BEDROCK,VERTEX,FOUNDRY}`) wins first if set, otherwise the
76
+ exported `ANTHROPIC_AUTH_TOKEN` matched against your registered profiles, then the
77
+ directory's `.envrc` link — so it returns immediately. `ccprofile usage` decrypts your Chrome claude.ai session cookies
78
+ to fetch the real 5-hour / weekly / Fable-weekly utilization for every signed-in
79
+ account, ordered by whichever weekly limit resets soonest — without opening or
80
+ switching the browser (you just need to be signed in to claude.ai in Chrome).
81
+
66
82
  ## Commands
67
83
 
68
84
  | Command | Description |
@@ -71,9 +87,11 @@ Repeat with `ccprofile add personal` etc. Different terminals in different direc
71
87
  | `ccprofile list [--json]` | Profiles with token presence and expiry countdown |
72
88
  | `ccprofile link <name> [dir]` | Write the managed `.envrc` block and `direnv allow` |
73
89
  | `ccprofile unlink [dir]` | Remove the managed block (deletes `.envrc` if nothing else remains) |
90
+ | `ccprofile which` | Show which account this shell/directory resolves to right now — instant, no probes (alias: `whoami`) |
74
91
  | `ccprofile token <name>` | Print the stored token to stdout (for scripting — handle with care) |
75
92
  | `ccprofile remove <name>` | Delete the profile and its Keychain entry |
76
93
  | `ccprofile doctor [dir]` | Diagnose provider overrides, stale/missing active token env, expiry, token liveness, usage limits (a minimal real inference per profile — fable first, haiku fallback), broken links. `--model <alias>` pins the probe model; `--offline` skips all server probes |
94
+ | `ccprofile usage [--json]` | Per-account plan utilization (5-hour, weekly, and Fable-weekly) with reset times, read from claude.ai via your Chrome session — no browser open or switch needed |
77
95
  | `ccprofile completion <shell>` | Print a completion script for fish, zsh, or bash |
78
96
 
79
97
  ## Shell completion
@@ -118,9 +136,9 @@ Notes:
118
136
 
119
137
  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:
120
138
 
121
- - **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.
139
+ - **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. (`ccprofile which` still names the *active* account, but locally — by matching the exported token against your registered profiles, never asking the server.)
122
140
  *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.
123
- - **`/status` → Usage tab shows no plan utilization** in token-authenticated sessions (same scope restriction — the usage endpoint also requires `user:profile`). Check usage on claude.ai, or run `ccprofile doctor`: it detects exhausted usage limits the only way these tokens allow, by sending one minimal real inference per profile (fable first; on a fable limit it retries with haiku to tell "fable's separate budget exhausted" from "subscription window exhausted"). The probe consumes a negligible amount of quota and starts the 5-hour window of an idle profile; use `--offline` if you don't want that.
141
+ - **`/status` → Usage tab shows no plan utilization** in token-authenticated sessions (same scope restriction — the usage endpoint also requires `user:profile`). Check usage on claude.ai, or run `ccprofile doctor`: it detects exhausted usage limits the only way these tokens allow, by sending one minimal real inference per profile (fable first; on a fable limit it retries with haiku to tell "fable's separate budget exhausted" from "subscription window exhausted"). The probe consumes a negligible amount of quota and starts the 5-hour window of an idle profile; use `--offline` if you don't want that. To read *real* remaining quota without spending any, run `ccprofile usage`, which pulls per-account utilization straight from claude.ai using your signed-in Chrome session (no token probe, no window started).
124
142
  - **Remote Control is unavailable** in linked directories. Claude Code treats `ANTHROPIC_AUTH_TOKEN` sessions as API-key authentication, while Remote Control requires claude.ai subscription authentication.
125
143
  - **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.
126
144
  - **Routing only applies to shell-launched processes.** direnv activates the token when a hooked shell enters the directory; apps launched outside a hooked shell (GUI launchers) bypass it.
@@ -4,6 +4,7 @@ export const SUBCOMMANDS = [
4
4
  { name: "list", description: "Show profiles, token presence, and expiry" },
5
5
  { name: "link", description: "Route a directory to a profile" },
6
6
  { name: "unlink", description: "Remove the managed .envrc block" },
7
+ { name: "which", description: "Show which account this shell resolves to" },
7
8
  { name: "token", description: "Print the stored token" },
8
9
  { name: "remove", description: "Delete a profile and its Keychain entry" },
9
10
  { name: "doctor", description: "Diagnose configuration problems" },
@@ -51,6 +51,13 @@ export async function doctorCommand(argv) {
51
51
  problems += 1;
52
52
  }
53
53
  }
54
+ const config = loadConfig();
55
+ const keychain = new Keychain();
56
+ // Answer "which account is active here?" up front — it is cheap (one .envrc
57
+ // read plus at most one Keychain lookup) and is the thing users most often
58
+ // open doctor to check, so it must not sit behind the slow per-profile
59
+ // inference probes further down.
60
+ problems += await reportCurrentLink(dir, config, keychain);
54
61
  for (const envVar of OVERRIDING_ENV_VARS) {
55
62
  if (process.env[envVar] !== undefined) {
56
63
  console.log(fail(`${envVar} is set: it overrides ANTHROPIC_AUTH_TOKEN and bypasses ccprofile routing.`));
@@ -72,8 +79,6 @@ export async function doctorCommand(argv) {
72
79
  warnings += 1;
73
80
  }
74
81
  }
75
- const config = loadConfig();
76
- const keychain = new Keychain();
77
82
  const profiles = Object.entries(config.profiles);
78
83
  if (profiles.length > 0) {
79
84
  const ctx = {
@@ -105,44 +110,6 @@ export async function doctorCommand(argv) {
105
110
  }
106
111
  spinner.stop();
107
112
  }
108
- const envrcPath = join(dir, ".envrc");
109
- if (existsSync(envrcPath)) {
110
- console.log();
111
- const linked = parseLinkedProfile(readFileSync(envrcPath, "utf8"));
112
- if (linked === null) {
113
- console.log(ok(`${envrcPath} exists but has no ccprofile block (not managed here).`));
114
- }
115
- else if (config.profiles[linked]) {
116
- if (dir !== resolve(process.cwd())) {
117
- console.log(ok(`${bold(dir)} → profile "${linked}"`));
118
- }
119
- else {
120
- const linkedProfile = config.profiles[linked];
121
- const exportedToken = process.env.ANTHROPIC_AUTH_TOKEN;
122
- if (exportedToken === undefined) {
123
- console.log(fail(`This directory → profile "${linked}", but ANTHROPIC_AUTH_TOKEN is not exported in this shell. Run: direnv reload`));
124
- problems += 1;
125
- }
126
- else {
127
- const linkedToken = await keychain.getToken(linkedProfile.keychain.service, linkedProfile.keychain.account);
128
- if (linkedToken !== null && exportedToken !== linkedToken) {
129
- console.log(fail(`This directory → profile "${linked}", but this shell exports a different ANTHROPIC_AUTH_TOKEN. Run: direnv reload, then restart Claude Code.`));
130
- problems += 1;
131
- }
132
- else if (linkedToken !== null) {
133
- console.log(ok(`This directory → profile "${linked}" (ANTHROPIC_AUTH_TOKEN exported)`));
134
- }
135
- else {
136
- console.log(ok(`This directory → profile "${linked}"`));
137
- }
138
- }
139
- }
140
- }
141
- else {
142
- console.log(fail(`${envrcPath} references unknown profile "${linked}". Run: ccprofile link <profile> ${dir}`));
143
- problems += 1;
144
- }
145
- }
146
113
  console.log();
147
114
  if (problems > 0) {
148
115
  console.log(fail(`${problems} problem(s), ${warnings} warning(s).`));
@@ -151,6 +118,63 @@ export async function doctorCommand(argv) {
151
118
  console.log(ok(`No problems found (${warnings} warning(s)).`));
152
119
  return 0;
153
120
  }
121
+ /**
122
+ * Prints the "which account does this directory resolve to?" summary and
123
+ * returns how many problems it found. Kept intentionally cheap — one .envrc
124
+ * read plus at most one Keychain lookup — so it can run before the inference
125
+ * probes and give an instant answer.
126
+ */
127
+ async function reportCurrentLink(dir, config, keychain) {
128
+ const envrcPath = join(dir, ".envrc");
129
+ let content;
130
+ try {
131
+ if (!existsSync(envrcPath))
132
+ return 0;
133
+ content = readFileSync(envrcPath, "utf8");
134
+ }
135
+ catch {
136
+ // The .envrc vanished between the check and the read, or is unreadable —
137
+ // nothing to report, and doctor must not crash over a transient FS error.
138
+ return 0;
139
+ }
140
+ console.log();
141
+ const linked = parseLinkedProfile(content);
142
+ if (linked === null) {
143
+ console.log(ok(`${envrcPath} exists but has no ccprofile block (not managed here).`));
144
+ return 0;
145
+ }
146
+ const linkedProfile = config.profiles[linked];
147
+ if (linkedProfile === undefined) {
148
+ console.log(fail(`${envrcPath} references unknown profile "${linked}". Run: ccprofile link <profile> ${dir}`));
149
+ return 1;
150
+ }
151
+ if (dir !== resolve(process.cwd())) {
152
+ console.log(ok(`${bold(dir)} → profile "${linked}"`));
153
+ return 0;
154
+ }
155
+ const exportedToken = process.env.ANTHROPIC_AUTH_TOKEN;
156
+ if (exportedToken === undefined) {
157
+ console.log(fail(`This directory → profile "${linked}", but ANTHROPIC_AUTH_TOKEN is not exported in this shell. Run: direnv reload`));
158
+ return 1;
159
+ }
160
+ let linkedToken = null;
161
+ try {
162
+ linkedToken = await keychain.getToken(linkedProfile.keychain.service, linkedProfile.keychain.account);
163
+ }
164
+ catch {
165
+ // A Keychain hiccup (e.g. `security` failing) must not abort doctor; fall
166
+ // back to reporting the link without the token-match confirmation.
167
+ linkedToken = null;
168
+ }
169
+ if (linkedToken !== null && exportedToken !== linkedToken) {
170
+ console.log(fail(`This directory → profile "${linked}", but this shell exports a different ANTHROPIC_AUTH_TOKEN. Run: direnv reload, then restart Claude Code.`));
171
+ return 1;
172
+ }
173
+ console.log(linkedToken !== null
174
+ ? ok(`This directory → profile "${linked}" (ANTHROPIC_AUTH_TOKEN exported)`)
175
+ : ok(`This directory → profile "${linked}"`));
176
+ return 0;
177
+ }
154
178
  const HEADER = ["PROFILE", "TOKEN", "EXPIRES", "FABLE", "OTHERS", "NOTE"];
155
179
  // The cell vocabulary is fixed, so column widths are known before any check
156
180
  // resolves — rows can stream in as profiles finish, without a barrier.
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+ import { loadConfig } from "../lib/config.js";
5
+ import { parseLinkedProfile } from "../lib/envrc.js";
6
+ import { assertDarwin, Keychain } from "../lib/keychain.js";
7
+ import { OVERRIDING_ENV_VARS } from "../lib/usage.js";
8
+ import { bold, cyan, dim, fail, ok, warn } from "../lib/format.js";
9
+ /**
10
+ * `ccprofile which` answers one question fast: which account will Claude Code
11
+ * use in this shell, right now? Unlike `doctor` it runs no inference probes and
12
+ * no liveness checks — just the local signals (env vars, the exported token,
13
+ * and the current directory's link) — so it returns effectively instantly.
14
+ */
15
+ export async function whichCommand(argv) {
16
+ parseArgs({ args: argv, allowPositionals: true, options: {} });
17
+ assertDarwin();
18
+ const config = loadConfig();
19
+ const overrideVar = OVERRIDING_ENV_VARS.find((v) => process.env[v] !== undefined);
20
+ const exportedToken = process.env.ANTHROPIC_AUTH_TOKEN;
21
+ const linkName = readCwdLink();
22
+ const linkIsKnown = linkName !== null && config.profiles[linkName] !== undefined;
23
+ // The Keychain read is the only I/O with a cost, and only when a token is
24
+ // actually exported — resolving it back to a profile name.
25
+ let matchedProfile = null;
26
+ if (overrideVar === undefined && exportedToken !== undefined && exportedToken !== "") {
27
+ matchedProfile = await findProfileByToken(config, exportedToken);
28
+ }
29
+ const result = classifyActiveAccount({
30
+ overrideVar,
31
+ exportedToken,
32
+ linkName,
33
+ linkIsKnown,
34
+ matchedProfile,
35
+ });
36
+ return printResult(result, config);
37
+ }
38
+ /**
39
+ * Pure resolution of "who am I running as" from the local signals, mirroring
40
+ * Claude Code's own precedence: an override env var wins; otherwise the
41
+ * exported ANTHROPIC_AUTH_TOKEN decides; with no token, a directory link is
42
+ * merely configured (not active) and bare shells use /login.
43
+ */
44
+ export function classifyActiveAccount(input) {
45
+ if (input.overrideVar !== undefined) {
46
+ return { kind: "override", envVar: input.overrideVar };
47
+ }
48
+ if (input.exportedToken === undefined || input.exportedToken === "") {
49
+ if (input.linkName !== null && input.linkIsKnown) {
50
+ return { kind: "link-inactive", profile: input.linkName };
51
+ }
52
+ return { kind: "none" };
53
+ }
54
+ if (input.matchedProfile === null) {
55
+ return { kind: "foreign" };
56
+ }
57
+ return {
58
+ kind: "active",
59
+ profile: input.matchedProfile,
60
+ viaLink: input.linkName === input.matchedProfile,
61
+ linkMismatch: input.linkName !== null && input.linkName !== input.matchedProfile ? input.linkName : null,
62
+ };
63
+ }
64
+ function printResult(result, config) {
65
+ switch (result.kind) {
66
+ case "override":
67
+ console.log(fail(`${result.envVar} is set — Claude Code ignores ccprofile routing and uses it instead.`));
68
+ return 1;
69
+ case "active": {
70
+ const email = config.profiles[result.profile]?.email;
71
+ console.log(`${cyan(bold(result.profile))}${email ? ` ${dim(`(${email})`)}` : ""}`);
72
+ const sources = result.viaLink
73
+ ? [".envrc link", "ANTHROPIC_AUTH_TOKEN exported"]
74
+ : ["ANTHROPIC_AUTH_TOKEN exported"];
75
+ console.log(dim(` source: ${sources.join(" · ")}`));
76
+ if (result.linkMismatch !== null) {
77
+ console.log(warn(` This directory links "${result.linkMismatch}", but the exported token is "${result.profile}". Run: direnv reload`));
78
+ }
79
+ return 0;
80
+ }
81
+ case "foreign":
82
+ console.log(warn("An ANTHROPIC_AUTH_TOKEN is exported, but it matches no ccprofile profile."));
83
+ console.log(dim(" It was likely set manually or by another tool."));
84
+ return 0;
85
+ case "link-inactive":
86
+ console.log(warn(`This directory links profile ${bold(result.profile)}, but no token is exported here.`));
87
+ console.log(dim(" Run: direnv reload (or re-enter the directory) to activate it."));
88
+ return 1;
89
+ case "none":
90
+ console.log(ok(dim("No ccprofile account active — Claude Code uses your /login (subscription) session.")));
91
+ return 0;
92
+ }
93
+ }
94
+ function readCwdLink() {
95
+ const envrcPath = join(resolve(process.cwd()), ".envrc");
96
+ if (!existsSync(envrcPath))
97
+ return null;
98
+ try {
99
+ return parseLinkedProfile(readFileSync(envrcPath, "utf8"));
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ /** Finds the profile whose stored Keychain token equals the exported one. */
106
+ async function findProfileByToken(config, token) {
107
+ const keychain = new Keychain();
108
+ for (const [name, profile] of Object.entries(config.profiles)) {
109
+ const stored = await keychain.getToken(profile.keychain.service, profile.keychain.account);
110
+ if (stored !== null && stored === token)
111
+ return name;
112
+ }
113
+ return null;
114
+ }
package/dist/index.js CHANGED
@@ -20,6 +20,8 @@ ${bold("Commands")}
20
20
  list [--json] Show profiles, token presence, and expiry
21
21
  link <name> [dir] Route a directory to a profile (writes .envrc, direnv allow)
22
22
  unlink [dir] Remove the managed block from a directory's .envrc
23
+ which Show which account this shell/directory resolves to,
24
+ instantly (no probes). Alias: whoami
23
25
  token <name> Print the stored token (for scripting; handle with care)
24
26
  remove <name> [--force] Delete a profile and its Keychain entry
25
27
  doctor [dir] Diagnose overriding env vars, expiry, token liveness,
@@ -51,6 +53,9 @@ async function main() {
51
53
  return (await import("./commands/link.js")).linkCommand(rest);
52
54
  case "unlink":
53
55
  return (await import("./commands/unlink.js")).unlinkCommand(rest);
56
+ case "which":
57
+ case "whoami":
58
+ return (await import("./commands/which.js")).whichCommand(rest);
54
59
  case "token":
55
60
  return (await import("./commands/token.js")).tokenCommand(rest);
56
61
  case "doctor":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efoo/ccprofile",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Per-directory Claude Code account routing via ANTHROPIC_AUTH_TOKEN, direnv, and macOS Keychain",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.4",