@efoo/ccprofile 0.3.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 +20 -2
- package/dist/commands/completion.js +1 -0
- package/dist/commands/doctor.js +64 -40
- package/dist/commands/usage.js +58 -15
- package/dist/commands/which.js +114 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
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" },
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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.
|
package/dist/commands/usage.js
CHANGED
|
@@ -4,7 +4,32 @@ import { parseArgs } from "node:util";
|
|
|
4
4
|
import { assertDarwin } from "../lib/keychain.js";
|
|
5
5
|
import { chromeUserAgent, chromeUserDataDir, parseProfiles, readSessionCookies, safeStorageKey, } from "../lib/chrome.js";
|
|
6
6
|
import { fetchAccountUsage, NOT_SIGNED_IN, } from "../lib/claudeai.js";
|
|
7
|
-
import {
|
|
7
|
+
import { loadConfig } from "../lib/config.js";
|
|
8
|
+
import { bold, cyan, dim, fail, red, table, warn, yellow } from "../lib/format.js";
|
|
9
|
+
/**
|
|
10
|
+
* Maps a claude.ai account email to the ccprofile name registered for it, so a
|
|
11
|
+
* signed-in Chrome account that matches a stored profile is labelled with that
|
|
12
|
+
* profile's name. Matching is case-insensitive.
|
|
13
|
+
*/
|
|
14
|
+
function profileNamesByEmail() {
|
|
15
|
+
const byEmail = new Map();
|
|
16
|
+
try {
|
|
17
|
+
for (const [name, entry] of Object.entries(loadConfig().profiles)) {
|
|
18
|
+
if (entry.email)
|
|
19
|
+
byEmail.set(entry.email.toLowerCase(), name);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// A malformed/unsupported config.json shouldn't sink the usage table;
|
|
24
|
+
// profile labelling is auxiliary, so degrade to unlabelled accounts.
|
|
25
|
+
}
|
|
26
|
+
return byEmail;
|
|
27
|
+
}
|
|
28
|
+
function matchedProfileName(email, byEmail) {
|
|
29
|
+
if (email === null)
|
|
30
|
+
return null;
|
|
31
|
+
return byEmail.get(email.toLowerCase()) ?? null;
|
|
32
|
+
}
|
|
8
33
|
export async function usageCommand(argv) {
|
|
9
34
|
const { values } = parseArgs({
|
|
10
35
|
args: argv,
|
|
@@ -32,11 +57,12 @@ export async function usageCommand(argv) {
|
|
|
32
57
|
: startSpinner(`querying claude.ai for ${profiles.length} Chrome profile(s)…`);
|
|
33
58
|
const results = await Promise.all(profiles.map((profile) => loadUsage(profile, userDataDir, key, userAgent)));
|
|
34
59
|
spinner.stop();
|
|
60
|
+
const byEmail = profileNamesByEmail();
|
|
35
61
|
if (values.json) {
|
|
36
|
-
console.log(JSON.stringify(results.map(toJson), null, 2));
|
|
62
|
+
console.log(JSON.stringify(results.map((r) => toJson(r, byEmail)), null, 2));
|
|
37
63
|
return hasRealFailure(results) ? 1 : 0;
|
|
38
64
|
}
|
|
39
|
-
return render(results);
|
|
65
|
+
return render(results, byEmail);
|
|
40
66
|
}
|
|
41
67
|
/**
|
|
42
68
|
* A profile that was never signed in is expected and not a failure; anything
|
|
@@ -57,19 +83,12 @@ async function loadUsage(profile, userDataDir, key, userAgent) {
|
|
|
57
83
|
return { profile, usage: { ok: false, status: 0, detail } };
|
|
58
84
|
}
|
|
59
85
|
}
|
|
60
|
-
function render(results) {
|
|
61
|
-
const
|
|
62
|
-
const rows = [header];
|
|
86
|
+
function render(results, byEmail) {
|
|
87
|
+
const signedIn = [];
|
|
63
88
|
const problems = [];
|
|
64
89
|
for (const { profile, usage } of results) {
|
|
65
90
|
if (usage.ok) {
|
|
66
|
-
|
|
67
|
-
usage.email ?? dim("(unknown)"),
|
|
68
|
-
dim(profile.name),
|
|
69
|
-
windowCell(usage.report.session),
|
|
70
|
-
windowCell(usage.report.weeklyAll),
|
|
71
|
-
windowCell(usage.report.fable),
|
|
72
|
-
]);
|
|
91
|
+
signedIn.push({ profile, usage });
|
|
73
92
|
}
|
|
74
93
|
else if (usage.detail !== NOT_SIGNED_IN) {
|
|
75
94
|
// A missing session is expected for stray Chrome profiles; only real
|
|
@@ -77,6 +96,19 @@ function render(results) {
|
|
|
77
96
|
problems.push(warn(`${profile.name}: ${usage.detail}`));
|
|
78
97
|
}
|
|
79
98
|
}
|
|
99
|
+
const header = ["PROFILE", "ACCOUNT", "CHROME", "5-HOUR", "WEEK · ALL", "FABLE · WEEK"].map(bold);
|
|
100
|
+
const rows = [header];
|
|
101
|
+
for (const { profile, usage } of sortByWeeklyReset(signedIn)) {
|
|
102
|
+
const name = matchedProfileName(usage.email, byEmail);
|
|
103
|
+
rows.push([
|
|
104
|
+
name === null ? dim("-") : cyan(name),
|
|
105
|
+
usage.email ?? dim("(unknown)"),
|
|
106
|
+
dim(profile.name),
|
|
107
|
+
windowCell(usage.report.session),
|
|
108
|
+
windowCell(usage.report.weeklyAll),
|
|
109
|
+
windowCell(usage.report.fable),
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
80
112
|
if (rows.length === 1 && problems.length === 0) {
|
|
81
113
|
console.log(dim("No claude.ai sessions found in Chrome. Sign in at https://claude.ai and retry."));
|
|
82
114
|
return 0;
|
|
@@ -87,6 +119,15 @@ function render(results) {
|
|
|
87
119
|
console.log(problem);
|
|
88
120
|
return problems.length > 0 ? 1 : 0;
|
|
89
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Orders accounts by how soon their weekly (all-models) limit resets — the
|
|
124
|
+
* nearest reset first, the furthest last. Accounts with no weekly window sort
|
|
125
|
+
* to the end so a resolved figure never sits below a blank one.
|
|
126
|
+
*/
|
|
127
|
+
function sortByWeeklyReset(results) {
|
|
128
|
+
const resetKey = (r) => r.usage.report.weeklyAll?.resetsAt?.getTime() ?? Number.MAX_SAFE_INTEGER;
|
|
129
|
+
return [...results].sort((a, b) => resetKey(a) - resetKey(b));
|
|
130
|
+
}
|
|
90
131
|
function windowCell(window) {
|
|
91
132
|
if (window === null)
|
|
92
133
|
return dim("-");
|
|
@@ -111,12 +152,14 @@ function formatReset(date) {
|
|
|
111
152
|
const md = `${date.getMonth() + 1}/${date.getDate()}`.padStart(5, " ");
|
|
112
153
|
return `${md} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
113
154
|
}
|
|
114
|
-
function toJson(result) {
|
|
155
|
+
function toJson(result, byEmail) {
|
|
115
156
|
const { profile, usage } = result;
|
|
157
|
+
const email = usage.ok ? usage.email : null;
|
|
116
158
|
return {
|
|
159
|
+
profile: matchedProfileName(email, byEmail),
|
|
117
160
|
chromeProfile: profile.name,
|
|
118
161
|
chromeDir: profile.dir,
|
|
119
|
-
email
|
|
162
|
+
email,
|
|
120
163
|
error: usage.ok ? null : usage.detail,
|
|
121
164
|
usage: usage.ok ? serializeReport(usage.report) : null,
|
|
122
165
|
};
|
|
@@ -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