@efoo/ccprofile 0.1.3 → 0.3.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 +2 -2
- package/dist/commands/completion.js +4 -3
- package/dist/commands/doctor.js +283 -70
- package/dist/commands/usage.js +155 -0
- package/dist/index.js +8 -1
- package/dist/lib/chrome.js +162 -0
- package/dist/lib/claudeai.js +148 -0
- package/dist/lib/format.js +27 -3
- package/dist/lib/usage.js +156 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ Repeat with `ccprofile add personal` etc. Different terminals in different direc
|
|
|
73
73
|
| `ccprofile unlink [dir]` | Remove the managed block (deletes `.envrc` if nothing else remains) |
|
|
74
74
|
| `ccprofile token <name>` | Print the stored token to stdout (for scripting — handle with care) |
|
|
75
75
|
| `ccprofile remove <name>` | Delete the profile and its Keychain entry |
|
|
76
|
-
| `ccprofile doctor [dir]` | Diagnose provider overrides, stale/missing active token env, expiry, token liveness, broken links. `--offline` skips
|
|
76
|
+
| `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 |
|
|
77
77
|
| `ccprofile completion <shell>` | Print a completion script for fish, zsh, or bash |
|
|
78
78
|
|
|
79
79
|
## Shell completion
|
|
@@ -120,7 +120,7 @@ ccprofile is built on `claude setup-token`, whose long-lived tokens are **delibe
|
|
|
120
120
|
|
|
121
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.
|
|
122
122
|
*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). Check usage on claude.ai
|
|
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.
|
|
124
124
|
- **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
125
|
- **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
126
|
- **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.
|
|
@@ -7,6 +7,7 @@ export const SUBCOMMANDS = [
|
|
|
7
7
|
{ name: "token", description: "Print the stored token" },
|
|
8
8
|
{ name: "remove", description: "Delete a profile and its Keychain entry" },
|
|
9
9
|
{ name: "doctor", description: "Diagnose configuration problems" },
|
|
10
|
+
{ name: "usage", description: "Show claude.ai usage per account" },
|
|
10
11
|
{ name: "completion", description: "Print a shell completion script" },
|
|
11
12
|
{ name: "help", description: "Show help" },
|
|
12
13
|
];
|
|
@@ -41,7 +42,7 @@ complete -c ccprofile -n "__fish_seen_subcommand_from add" -l expires-at -r -d "
|
|
|
41
42
|
complete -c ccprofile -n "__fish_seen_subcommand_from add" -l token -r -d "Provide the token directly"
|
|
42
43
|
complete -c ccprofile -n "__fish_seen_subcommand_from add" -l force -d "Overwrite an existing profile"
|
|
43
44
|
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 list usage" -l json -d "JSON output"
|
|
45
46
|
complete -c ccprofile -n "__fish_seen_subcommand_from remove" -l force -d "Skip confirmation"
|
|
46
47
|
complete -c ccprofile -n __fish_use_subcommand -l help -d "Show help"
|
|
47
48
|
complete -c ccprofile -n __fish_use_subcommand -l version -d "Show version"
|
|
@@ -82,7 +83,7 @@ ${describeLines}
|
|
|
82
83
|
add)
|
|
83
84
|
compadd -- --email --expires-at --token --force --no-setup
|
|
84
85
|
;;
|
|
85
|
-
list)
|
|
86
|
+
list|usage)
|
|
86
87
|
compadd -- --json
|
|
87
88
|
;;
|
|
88
89
|
esac
|
|
@@ -119,7 +120,7 @@ _ccprofile() {
|
|
|
119
120
|
COMPREPLY=( \$(compgen -W "fish zsh bash" -- "\$cur") ) ;;
|
|
120
121
|
add)
|
|
121
122
|
COMPREPLY=( \$(compgen -W "--email --expires-at --token --force --no-setup" -- "\$cur") ) ;;
|
|
122
|
-
list)
|
|
123
|
+
list|usage)
|
|
123
124
|
COMPREPLY=( \$(compgen -W "--json" -- "\$cur") ) ;;
|
|
124
125
|
esac
|
|
125
126
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -7,22 +7,16 @@ import { daysRemaining, loadConfig } from "../lib/config.js";
|
|
|
7
7
|
import { parseLinkedProfile } from "../lib/envrc.js";
|
|
8
8
|
import { Keychain } from "../lib/keychain.js";
|
|
9
9
|
import { probeToken } from "../lib/probe.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
* Env vars that outrank ccprofile's managed ANTHROPIC_AUTH_TOKEN in Claude
|
|
13
|
-
* Code's documented authentication precedence. If any is set, ccprofile
|
|
14
|
-
* routing is bypassed before the token is considered.
|
|
15
|
-
*/
|
|
16
|
-
const OVERRIDING_ENV_VARS = [
|
|
17
|
-
"CLAUDE_CODE_USE_BEDROCK",
|
|
18
|
-
"CLAUDE_CODE_USE_VERTEX",
|
|
19
|
-
"CLAUDE_CODE_USE_FOUNDRY",
|
|
20
|
-
];
|
|
10
|
+
import { DEFAULT_PROBE_MODEL, FALLBACK_PROBE_MODEL, OVERRIDING_ENV_VARS, probeUsage, } from "../lib/usage.js";
|
|
11
|
+
import { bold, dim, fail, green, ok, red, stripAnsi, warn, yellow } from "../lib/format.js";
|
|
21
12
|
export async function doctorCommand(argv) {
|
|
22
13
|
const { values, positionals } = parseArgs({
|
|
23
14
|
args: argv,
|
|
24
15
|
allowPositionals: true,
|
|
25
|
-
options: {
|
|
16
|
+
options: {
|
|
17
|
+
offline: { type: "boolean", default: false },
|
|
18
|
+
model: { type: "string" },
|
|
19
|
+
},
|
|
26
20
|
});
|
|
27
21
|
const dir = resolve(positionals[0] ?? process.cwd());
|
|
28
22
|
let problems = 0;
|
|
@@ -31,23 +25,31 @@ export async function doctorCommand(argv) {
|
|
|
31
25
|
console.log(fail("Not macOS: ccprofile's Keychain backend is unavailable on this platform."));
|
|
32
26
|
return 1;
|
|
33
27
|
}
|
|
34
|
-
console.log(ok("Platform: macOS (Keychain backend available)"));
|
|
35
28
|
const claude = spawnSync("claude", ["--version"], { stdio: "pipe" });
|
|
36
|
-
|
|
37
|
-
console.log(warn("`claude` CLI not found. `ccprofile add` cannot launch setup-token for you."));
|
|
38
|
-
warnings += 1;
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
console.log(ok(`Claude Code: ${claude.stdout.toString().trim()}`));
|
|
42
|
-
}
|
|
29
|
+
const claudeAvailable = !claude.error && claude.status === 0;
|
|
43
30
|
const direnv = spawnSync("direnv", ["version"], { stdio: "pipe" });
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
31
|
+
const direnvAvailable = !direnv.error && direnv.status === 0;
|
|
32
|
+
if (claudeAvailable && direnvAvailable) {
|
|
33
|
+
const claudeVersion = claude.stdout.toString().trim().split(" ")[0] ?? "";
|
|
34
|
+
console.log(ok(`macOS Keychain · Claude Code ${claudeVersion} · direnv ${direnv.stdout.toString().trim()}`));
|
|
48
35
|
}
|
|
49
36
|
else {
|
|
50
|
-
console.log(ok(
|
|
37
|
+
console.log(ok("Platform: macOS (Keychain backend available)"));
|
|
38
|
+
if (claudeAvailable) {
|
|
39
|
+
console.log(ok(`Claude Code: ${claude.stdout.toString().trim()}`));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.log(warn("`claude` CLI not found. `ccprofile add` cannot launch setup-token for you."));
|
|
43
|
+
warnings += 1;
|
|
44
|
+
}
|
|
45
|
+
if (direnvAvailable) {
|
|
46
|
+
console.log(ok(`direnv: ${direnv.stdout.toString().trim()}`));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.log(fail("direnv not found. Linked directories will not activate tokens automatically."));
|
|
50
|
+
console.log(" Install: brew install direnv / fish hook: direnv hook fish | source");
|
|
51
|
+
problems += 1;
|
|
52
|
+
}
|
|
51
53
|
}
|
|
52
54
|
for (const envVar of OVERRIDING_ENV_VARS) {
|
|
53
55
|
if (process.env[envVar] !== undefined) {
|
|
@@ -72,72 +74,66 @@ export async function doctorCommand(argv) {
|
|
|
72
74
|
}
|
|
73
75
|
const config = loadConfig();
|
|
74
76
|
const keychain = new Keychain();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (live.status === "alive") {
|
|
101
|
-
if (live.email !== undefined && profile.email !== undefined && live.email !== profile.email) {
|
|
102
|
-
console.log(fail(`Profile "${name}": server says the token belongs to ${live.email}, but config records ${profile.email}.`));
|
|
103
|
-
problems += 1;
|
|
104
|
-
}
|
|
105
|
-
else {
|
|
106
|
-
console.log(ok(`Profile "${name}": token is live on the server${live.email ? ` (account: ${live.email})` : ""}.`));
|
|
77
|
+
const profiles = Object.entries(config.profiles);
|
|
78
|
+
if (profiles.length > 0) {
|
|
79
|
+
const ctx = {
|
|
80
|
+
keychain,
|
|
81
|
+
expiryWarningDays: config.settings.expiryWarningDays,
|
|
82
|
+
offline: values.offline,
|
|
83
|
+
claudeAvailable,
|
|
84
|
+
probeModel: values.model ?? DEFAULT_PROBE_MODEL,
|
|
85
|
+
};
|
|
86
|
+
// All profiles are checked concurrently (each profile's own steps stay
|
|
87
|
+
// sequential so the fable→haiku cascade works); rows print in config
|
|
88
|
+
// order as they resolve, making wall time the slowest profile, not the sum.
|
|
89
|
+
const running = profiles.map(([name, profile]) => checkProfile(name, profile, ctx));
|
|
90
|
+
const widths = columnWidths(profiles.map(([name]) => name));
|
|
91
|
+
console.log();
|
|
92
|
+
console.log(dim(renderCells(HEADER, widths)));
|
|
93
|
+
const spinner = startSpinner(values.offline || !claudeAvailable
|
|
94
|
+
? "checking profiles…"
|
|
95
|
+
: `probing ${profiles.length} profile(s) — one tiny real request each…`);
|
|
96
|
+
for (const pending of running) {
|
|
97
|
+
const row = await pending;
|
|
98
|
+
spinner.clear();
|
|
99
|
+
console.log(renderCells([bold(row.name), row.token, row.expires, row.fable, row.others, row.note], widths));
|
|
100
|
+
for (const detail of row.details) {
|
|
101
|
+
console.log(` ${dim("↳")} ${detail}`);
|
|
107
102
|
}
|
|
103
|
+
problems += row.problems;
|
|
104
|
+
warnings += row.warnings;
|
|
108
105
|
}
|
|
109
|
-
|
|
110
|
-
console.log(fail(`Profile "${name}": token rejected by the server (revoked or expired early). Re-issue with: ccprofile add ${name} --force`));
|
|
111
|
-
problems += 1;
|
|
112
|
-
}
|
|
113
|
-
else {
|
|
114
|
-
console.log(warn(`Profile "${name}": liveness check inconclusive (${live.detail}). Use --offline to skip.`));
|
|
115
|
-
warnings += 1;
|
|
116
|
-
}
|
|
106
|
+
spinner.stop();
|
|
117
107
|
}
|
|
118
108
|
const envrcPath = join(dir, ".envrc");
|
|
119
109
|
if (existsSync(envrcPath)) {
|
|
110
|
+
console.log();
|
|
120
111
|
const linked = parseLinkedProfile(readFileSync(envrcPath, "utf8"));
|
|
121
112
|
if (linked === null) {
|
|
122
113
|
console.log(ok(`${envrcPath} exists but has no ccprofile block (not managed here).`));
|
|
123
114
|
}
|
|
124
115
|
else if (config.profiles[linked]) {
|
|
125
|
-
|
|
126
|
-
|
|
116
|
+
if (dir !== resolve(process.cwd())) {
|
|
117
|
+
console.log(ok(`${bold(dir)} → profile "${linked}"`));
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
127
120
|
const linkedProfile = config.profiles[linked];
|
|
128
121
|
const exportedToken = process.env.ANTHROPIC_AUTH_TOKEN;
|
|
129
122
|
if (exportedToken === undefined) {
|
|
130
|
-
console.log(fail("
|
|
123
|
+
console.log(fail(`This directory → profile "${linked}", but ANTHROPIC_AUTH_TOKEN is not exported in this shell. Run: direnv reload`));
|
|
131
124
|
problems += 1;
|
|
132
125
|
}
|
|
133
126
|
else {
|
|
134
127
|
const linkedToken = await keychain.getToken(linkedProfile.keychain.service, linkedProfile.keychain.account);
|
|
135
128
|
if (linkedToken !== null && exportedToken !== linkedToken) {
|
|
136
|
-
console.log(fail(`
|
|
129
|
+
console.log(fail(`This directory → profile "${linked}", but this shell exports a different ANTHROPIC_AUTH_TOKEN. Run: direnv reload, then restart Claude Code.`));
|
|
137
130
|
problems += 1;
|
|
138
131
|
}
|
|
139
132
|
else if (linkedToken !== null) {
|
|
140
|
-
console.log(ok(`
|
|
133
|
+
console.log(ok(`This directory → profile "${linked}" (ANTHROPIC_AUTH_TOKEN exported)`));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
console.log(ok(`This directory → profile "${linked}"`));
|
|
141
137
|
}
|
|
142
138
|
}
|
|
143
139
|
}
|
|
@@ -155,3 +151,220 @@ export async function doctorCommand(argv) {
|
|
|
155
151
|
console.log(ok(`No problems found (${warnings} warning(s)).`));
|
|
156
152
|
return 0;
|
|
157
153
|
}
|
|
154
|
+
const HEADER = ["PROFILE", "TOKEN", "EXPIRES", "FABLE", "OTHERS", "NOTE"];
|
|
155
|
+
// The cell vocabulary is fixed, so column widths are known before any check
|
|
156
|
+
// resolves — rows can stream in as profiles finish, without a barrier.
|
|
157
|
+
const TOKEN_CELLS = {
|
|
158
|
+
stored: green("✓ stored"),
|
|
159
|
+
live: green("✓ live"),
|
|
160
|
+
missing: red("✗ missing"),
|
|
161
|
+
revoked: red("✗ revoked"),
|
|
162
|
+
mismatch: red("✗ mismatch"),
|
|
163
|
+
unknown: yellow("? unknown"),
|
|
164
|
+
};
|
|
165
|
+
const PROBE_CELLS = {
|
|
166
|
+
ok: green("✓ ok"),
|
|
167
|
+
limit: yellow("⚠ limit"),
|
|
168
|
+
rejected: red("✗"),
|
|
169
|
+
unknown: yellow("?"),
|
|
170
|
+
none: dim("-"),
|
|
171
|
+
};
|
|
172
|
+
function columnWidths(names) {
|
|
173
|
+
const vocab = (cells) => Object.values(cells).map((c) => stripAnsi(c).length);
|
|
174
|
+
return [
|
|
175
|
+
Math.max("PROFILE".length, ...names.map((n) => n.length)),
|
|
176
|
+
Math.max("TOKEN".length, ...vocab(TOKEN_CELLS)),
|
|
177
|
+
"EXPIRES".length,
|
|
178
|
+
Math.max("FABLE".length, ...vocab(PROBE_CELLS)),
|
|
179
|
+
Math.max("OTHERS".length, ...vocab(PROBE_CELLS)),
|
|
180
|
+
0, // NOTE is last and stays unpadded
|
|
181
|
+
];
|
|
182
|
+
}
|
|
183
|
+
function renderCells(cells, widths) {
|
|
184
|
+
return cells
|
|
185
|
+
.map((cell, i) => {
|
|
186
|
+
const width = widths[i] ?? 0;
|
|
187
|
+
return cell + " ".repeat(Math.max(0, width - stripAnsi(cell).length));
|
|
188
|
+
})
|
|
189
|
+
.join(" ")
|
|
190
|
+
.trimEnd();
|
|
191
|
+
}
|
|
192
|
+
function expiresCell(days, warningDays) {
|
|
193
|
+
const text = `${days}d`;
|
|
194
|
+
if (days < 0)
|
|
195
|
+
return red(text);
|
|
196
|
+
if (days <= warningDays)
|
|
197
|
+
return yellow(text);
|
|
198
|
+
return text;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Minimal dependency-free progress indicator for the parallel probe wait.
|
|
202
|
+
* Renders only on a TTY (piped/CI output stays clean). clear() erases the
|
|
203
|
+
* spinner line so a result row can print; the interval repaints it below.
|
|
204
|
+
*/
|
|
205
|
+
function startSpinner(text) {
|
|
206
|
+
if (!process.stdout.isTTY) {
|
|
207
|
+
return { clear: () => { }, stop: () => { } };
|
|
208
|
+
}
|
|
209
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
210
|
+
let i = 0;
|
|
211
|
+
const paint = () => {
|
|
212
|
+
process.stdout.write(`\r\u001B[2K${dim(`${frames[i % frames.length] ?? ""} ${text}`)}`);
|
|
213
|
+
i += 1;
|
|
214
|
+
};
|
|
215
|
+
paint();
|
|
216
|
+
const timer = setInterval(paint, 100);
|
|
217
|
+
const clear = () => {
|
|
218
|
+
process.stdout.write("\r\u001B[2K");
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
clear,
|
|
222
|
+
stop: () => {
|
|
223
|
+
clearInterval(timer);
|
|
224
|
+
clear();
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Runs every check for one profile and returns a table row instead of
|
|
230
|
+
* printing, so all profiles can run concurrently without interleaving
|
|
231
|
+
* output. Detail lines carry remediation/diagnostic text that does not fit
|
|
232
|
+
* a cell. Must never reject: doctor prints rows in config order while later
|
|
233
|
+
* profiles are still running, so a rejection before its turn would be an
|
|
234
|
+
* unhandled promise rejection.
|
|
235
|
+
*/
|
|
236
|
+
async function checkProfile(name, profile, ctx) {
|
|
237
|
+
const row = {
|
|
238
|
+
name,
|
|
239
|
+
token: PROBE_CELLS.none,
|
|
240
|
+
expires: PROBE_CELLS.none,
|
|
241
|
+
fable: PROBE_CELLS.none,
|
|
242
|
+
others: PROBE_CELLS.none,
|
|
243
|
+
note: "",
|
|
244
|
+
details: [],
|
|
245
|
+
problems: 0,
|
|
246
|
+
warnings: 0,
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
const present = await ctx.keychain.hasEntry(profile.keychain.service, profile.keychain.account);
|
|
250
|
+
if (!present) {
|
|
251
|
+
row.token = TOKEN_CELLS.missing;
|
|
252
|
+
row.problems += 1;
|
|
253
|
+
row.details.push(`Keychain entry missing. Re-run: ccprofile add ${name} --force`);
|
|
254
|
+
return row;
|
|
255
|
+
}
|
|
256
|
+
row.token = TOKEN_CELLS.stored;
|
|
257
|
+
const days = daysRemaining(profile.expiresAt);
|
|
258
|
+
row.expires = expiresCell(days, ctx.expiryWarningDays);
|
|
259
|
+
if (days < 0) {
|
|
260
|
+
row.problems += 1;
|
|
261
|
+
row.details.push(`Token recorded as expired ${-days}d ago. Re-issue with claude setup-token.`);
|
|
262
|
+
}
|
|
263
|
+
else if (days <= ctx.expiryWarningDays) {
|
|
264
|
+
row.warnings += 1;
|
|
265
|
+
}
|
|
266
|
+
if (ctx.offline)
|
|
267
|
+
return row;
|
|
268
|
+
const token = await ctx.keychain.getToken(profile.keychain.service, profile.keychain.account);
|
|
269
|
+
if (token === null)
|
|
270
|
+
return row;
|
|
271
|
+
const live = await probeToken(token);
|
|
272
|
+
if (live.status === "alive") {
|
|
273
|
+
if (live.email !== undefined && profile.email !== undefined && live.email !== profile.email) {
|
|
274
|
+
row.token = TOKEN_CELLS.mismatch;
|
|
275
|
+
row.problems += 1;
|
|
276
|
+
row.details.push(`Server says the token belongs to ${live.email}, but config records ${profile.email}.`);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
row.token = TOKEN_CELLS.live;
|
|
280
|
+
}
|
|
281
|
+
if (ctx.claudeAvailable) {
|
|
282
|
+
await appendUsageProbe(row, token, ctx.probeModel);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else if (live.status === "invalid") {
|
|
286
|
+
row.token = TOKEN_CELLS.revoked;
|
|
287
|
+
row.problems += 1;
|
|
288
|
+
row.details.push(`Token rejected by the server (revoked or expired early). Re-issue with: ccprofile add ${name} --force`);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
row.token = TOKEN_CELLS.unknown;
|
|
292
|
+
row.warnings += 1;
|
|
293
|
+
row.details.push(`Liveness check inconclusive (${live.detail}). Use --offline to skip.`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
row.problems += 1;
|
|
298
|
+
row.details.push(`Check failed (${error instanceof Error ? error.message : String(error)}).`);
|
|
299
|
+
}
|
|
300
|
+
return row;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Fills the FABLE/OTHERS cells via real inference. fable is probed first by
|
|
304
|
+
* default; on a fable limit, a haiku probe distinguishes "fable's separate
|
|
305
|
+
* budget is exhausted" from "the whole subscription window is exhausted".
|
|
306
|
+
* The two probes stay sequential within a profile (the cascade depends on
|
|
307
|
+
* the first result); concurrency happens across profiles. Limits count as
|
|
308
|
+
* warnings, not problems: doctor's exit code reflects configuration health,
|
|
309
|
+
* and a rate-limited profile is configured correctly.
|
|
310
|
+
*/
|
|
311
|
+
async function appendUsageProbe(row, token, model) {
|
|
312
|
+
const pinnedIsFable = /fable/i.test(model);
|
|
313
|
+
const setProbedCell = (cell) => {
|
|
314
|
+
if (pinnedIsFable)
|
|
315
|
+
row.fable = cell;
|
|
316
|
+
else
|
|
317
|
+
row.others = cell;
|
|
318
|
+
};
|
|
319
|
+
const usage = await probeUsage(token, model);
|
|
320
|
+
if (usage.status === "usable") {
|
|
321
|
+
if (pinnedIsFable) {
|
|
322
|
+
row.fable = PROBE_CELLS.ok;
|
|
323
|
+
// fable draws from the shared pool too, so fable OK implies the rest.
|
|
324
|
+
row.others = PROBE_CELLS.ok;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
row.others = PROBE_CELLS.ok;
|
|
328
|
+
row.note = `probed ${model}`;
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (usage.status === "limited") {
|
|
333
|
+
row.warnings += 1;
|
|
334
|
+
const resets = usage.resetsAt === undefined ? "" : ` (resets ${usage.resetsAt.toLocaleString()})`;
|
|
335
|
+
if (!pinnedIsFable) {
|
|
336
|
+
row.others = PROBE_CELLS.limit;
|
|
337
|
+
row.note = `${model} usage limit reached${resets}`;
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
row.fable = PROBE_CELLS.limit;
|
|
341
|
+
const fallback = await probeUsage(token, FALLBACK_PROBE_MODEL);
|
|
342
|
+
if (fallback.status === "usable") {
|
|
343
|
+
row.others = PROBE_CELLS.ok;
|
|
344
|
+
row.note = `${FALLBACK_PROBE_MODEL}/sonnet/opus available${resets}`;
|
|
345
|
+
}
|
|
346
|
+
else if (fallback.status === "limited") {
|
|
347
|
+
row.others = PROBE_CELLS.limit;
|
|
348
|
+
row.note =
|
|
349
|
+
fallback.resetsAt === undefined
|
|
350
|
+
? `subscription window exhausted${resets}`
|
|
351
|
+
: `subscription window exhausted (resets ${fallback.resetsAt.toLocaleString()})`;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
row.others = PROBE_CELLS.unknown;
|
|
355
|
+
row.note = `${FALLBACK_PROBE_MODEL} probe inconclusive`;
|
|
356
|
+
row.details.push(`${FALLBACK_PROBE_MODEL} probe: ${fallback.detail}`);
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (usage.status === "invalid") {
|
|
361
|
+
setProbedCell(PROBE_CELLS.rejected);
|
|
362
|
+
row.problems += 1;
|
|
363
|
+
row.details.push(`Inference probe rejected the token (${usage.detail}). Re-issue with: ccprofile add ${row.name} --force`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
setProbedCell(PROBE_CELLS.unknown);
|
|
367
|
+
row.warnings += 1;
|
|
368
|
+
row.note = "usage probe inconclusive";
|
|
369
|
+
row.details.push(`Usage probe (${model}): ${usage.detail}`);
|
|
370
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { assertDarwin } from "../lib/keychain.js";
|
|
5
|
+
import { chromeUserAgent, chromeUserDataDir, parseProfiles, readSessionCookies, safeStorageKey, } from "../lib/chrome.js";
|
|
6
|
+
import { fetchAccountUsage, NOT_SIGNED_IN, } from "../lib/claudeai.js";
|
|
7
|
+
import { bold, dim, fail, red, table, warn, yellow } from "../lib/format.js";
|
|
8
|
+
export async function usageCommand(argv) {
|
|
9
|
+
const { values } = parseArgs({
|
|
10
|
+
args: argv,
|
|
11
|
+
options: { json: { type: "boolean", default: false } },
|
|
12
|
+
});
|
|
13
|
+
assertDarwin();
|
|
14
|
+
const userDataDir = chromeUserDataDir();
|
|
15
|
+
const localStatePath = join(userDataDir, "Local State");
|
|
16
|
+
if (!existsSync(localStatePath)) {
|
|
17
|
+
console.error(fail("Google Chrome data not found. `ccprofile usage` reads claude.ai session cookies from Chrome."));
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
let key;
|
|
21
|
+
try {
|
|
22
|
+
key = await safeStorageKey();
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
console.error(fail(error instanceof Error ? error.message : String(error)));
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
const userAgent = chromeUserAgent(userDataDir);
|
|
29
|
+
const profiles = parseProfiles(readFileSync(localStatePath, "utf8")).filter((p) => existsSync(join(userDataDir, p.dir, "Cookies")));
|
|
30
|
+
const spinner = values.json
|
|
31
|
+
? { stop: () => { } }
|
|
32
|
+
: startSpinner(`querying claude.ai for ${profiles.length} Chrome profile(s)…`);
|
|
33
|
+
const results = await Promise.all(profiles.map((profile) => loadUsage(profile, userDataDir, key, userAgent)));
|
|
34
|
+
spinner.stop();
|
|
35
|
+
if (values.json) {
|
|
36
|
+
console.log(JSON.stringify(results.map(toJson), null, 2));
|
|
37
|
+
return hasRealFailure(results) ? 1 : 0;
|
|
38
|
+
}
|
|
39
|
+
return render(results);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* A profile that was never signed in is expected and not a failure; anything
|
|
43
|
+
* else (expired session, Cloudflare block, decrypt/read error) is, so the
|
|
44
|
+
* command exits non-zero for scripts even though the table still prints.
|
|
45
|
+
*/
|
|
46
|
+
function hasRealFailure(results) {
|
|
47
|
+
return results.some((r) => !r.usage.ok && r.usage.detail !== NOT_SIGNED_IN);
|
|
48
|
+
}
|
|
49
|
+
/** Decrypts one profile's cookies and fetches its usage; never throws. */
|
|
50
|
+
async function loadUsage(profile, userDataDir, key, userAgent) {
|
|
51
|
+
try {
|
|
52
|
+
const cookies = await readSessionCookies(join(userDataDir, profile.dir, "Cookies"), key);
|
|
53
|
+
return { profile, usage: await fetchAccountUsage(cookies, userAgent) };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
57
|
+
return { profile, usage: { ok: false, status: 0, detail } };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function render(results) {
|
|
61
|
+
const header = ["ACCOUNT", "CHROME", "5-HOUR", "WEEK · ALL", "FABLE · WEEK"].map(bold);
|
|
62
|
+
const rows = [header];
|
|
63
|
+
const problems = [];
|
|
64
|
+
for (const { profile, usage } of results) {
|
|
65
|
+
if (usage.ok) {
|
|
66
|
+
rows.push([
|
|
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
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
else if (usage.detail !== NOT_SIGNED_IN) {
|
|
75
|
+
// A missing session is expected for stray Chrome profiles; only real
|
|
76
|
+
// failures (expired session, Cloudflare block) are worth surfacing.
|
|
77
|
+
problems.push(warn(`${profile.name}: ${usage.detail}`));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (rows.length === 1 && problems.length === 0) {
|
|
81
|
+
console.log(dim("No claude.ai sessions found in Chrome. Sign in at https://claude.ai and retry."));
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
if (rows.length > 1)
|
|
85
|
+
console.log(table(rows));
|
|
86
|
+
for (const problem of problems)
|
|
87
|
+
console.log(problem);
|
|
88
|
+
return problems.length > 0 ? 1 : 0;
|
|
89
|
+
}
|
|
90
|
+
function windowCell(window) {
|
|
91
|
+
if (window === null)
|
|
92
|
+
return dim("-");
|
|
93
|
+
const reset = window.resetsAt === null ? "" : ` ${dim(formatReset(window.resetsAt))}`;
|
|
94
|
+
return `${percent(window)}${reset}`;
|
|
95
|
+
}
|
|
96
|
+
function percent(window) {
|
|
97
|
+
// Right-align to 3 digits so the reset time lines up down the column.
|
|
98
|
+
const text = `${String(window.percent).padStart(3, " ")}%`;
|
|
99
|
+
if (window.severity === "critical" || window.percent >= 90)
|
|
100
|
+
return red(text);
|
|
101
|
+
if (window.severity === "warning" || window.percent >= 80)
|
|
102
|
+
return yellow(text);
|
|
103
|
+
return text;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Local-time reset as `M/D HH:mm` (respects the machine's timezone). The date
|
|
107
|
+
* is right-padded to a fixed width so the clock times align down the column.
|
|
108
|
+
*/
|
|
109
|
+
function formatReset(date) {
|
|
110
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
111
|
+
const md = `${date.getMonth() + 1}/${date.getDate()}`.padStart(5, " ");
|
|
112
|
+
return `${md} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
113
|
+
}
|
|
114
|
+
function toJson(result) {
|
|
115
|
+
const { profile, usage } = result;
|
|
116
|
+
return {
|
|
117
|
+
chromeProfile: profile.name,
|
|
118
|
+
chromeDir: profile.dir,
|
|
119
|
+
email: usage.ok ? usage.email : null,
|
|
120
|
+
error: usage.ok ? null : usage.detail,
|
|
121
|
+
usage: usage.ok ? serializeReport(usage.report) : null,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function serializeReport(report) {
|
|
125
|
+
const win = (w) => w === null
|
|
126
|
+
? null
|
|
127
|
+
: { percent: w.percent, resetsAt: w.resetsAt?.toISOString() ?? null, severity: w.severity };
|
|
128
|
+
return {
|
|
129
|
+
session: win(report.session),
|
|
130
|
+
weeklyAll: win(report.weeklyAll),
|
|
131
|
+
fable: win(report.fable),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Minimal TTY-only spinner for the network wait; stays silent when output is
|
|
136
|
+
* piped so scripted/`--json` runs keep clean output.
|
|
137
|
+
*/
|
|
138
|
+
function startSpinner(text) {
|
|
139
|
+
if (!process.stdout.isTTY)
|
|
140
|
+
return { stop: () => { } };
|
|
141
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
142
|
+
let i = 0;
|
|
143
|
+
const paint = () => {
|
|
144
|
+
process.stdout.write(`\r\u001B[2K${dim(`${frames[i % frames.length] ?? ""} ${text}`)}`);
|
|
145
|
+
i += 1;
|
|
146
|
+
};
|
|
147
|
+
paint();
|
|
148
|
+
const timer = setInterval(paint, 100);
|
|
149
|
+
return {
|
|
150
|
+
stop: () => {
|
|
151
|
+
clearInterval(timer);
|
|
152
|
+
process.stdout.write("\r\u001B[2K");
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -23,7 +23,12 @@ ${bold("Commands")}
|
|
|
23
23
|
token <name> Print the stored token (for scripting; handle with care)
|
|
24
24
|
remove <name> [--force] Delete a profile and its Keychain entry
|
|
25
25
|
doctor [dir] Diagnose overriding env vars, expiry, token liveness,
|
|
26
|
-
--offline
|
|
26
|
+
--offline usage limits (real inference probe), and broken links
|
|
27
|
+
--model <alias> --offline skips probes; --model pins the probe model
|
|
28
|
+
(default: fable, then haiku to isolate fable limits)
|
|
29
|
+
usage [--json] Show claude.ai usage per account: 5-hour, weekly, and
|
|
30
|
+
Fable-weekly percent + reset. Reads Chrome session
|
|
31
|
+
cookies — no browser open or switch required
|
|
27
32
|
completion <shell> Print a completion script (fish, zsh, bash)
|
|
28
33
|
|
|
29
34
|
${bold("Typical flow")}
|
|
@@ -50,6 +55,8 @@ async function main() {
|
|
|
50
55
|
return (await import("./commands/token.js")).tokenCommand(rest);
|
|
51
56
|
case "doctor":
|
|
52
57
|
return (await import("./commands/doctor.js")).doctorCommand(rest);
|
|
58
|
+
case "usage":
|
|
59
|
+
return (await import("./commands/usage.js")).usageCommand(rest);
|
|
53
60
|
case "completion":
|
|
54
61
|
return (await import("./commands/completion.js")).completionCommand(rest);
|
|
55
62
|
// Hidden helper for shell completions: prints profile names, one per line.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createDecipheriv, pbkdf2Sync } from "node:crypto";
|
|
2
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { defaultRunner } from "./keychain.js";
|
|
6
|
+
/** claude.ai cookies needed to authenticate and clear the Cloudflare bot check. */
|
|
7
|
+
export const SESSION_COOKIE_NAMES = [
|
|
8
|
+
"sessionKey",
|
|
9
|
+
"lastActiveOrg",
|
|
10
|
+
"cf_clearance",
|
|
11
|
+
"__cf_bm",
|
|
12
|
+
];
|
|
13
|
+
const SAFE_STORAGE_SERVICE = "Chrome Safe Storage";
|
|
14
|
+
// Chrome's fixed KDF parameters for the "v10" Keychain-wrapped cookie scheme.
|
|
15
|
+
const KDF_SALT = "saltysalt";
|
|
16
|
+
const KDF_ITERATIONS = 1003;
|
|
17
|
+
const KEY_LENGTH = 16;
|
|
18
|
+
// AES-128-CBC with an all-spaces IV; the encrypted blob is prefixed with "v10".
|
|
19
|
+
const COOKIE_IV = Buffer.alloc(16, 0x20);
|
|
20
|
+
const COOKIE_PREFIX = "v10";
|
|
21
|
+
// Chrome 130+ prepends a 32-byte SHA-256 of the cookie's host to the plaintext.
|
|
22
|
+
const DOMAIN_HASH_LENGTH = 32;
|
|
23
|
+
export function chromeUserDataDir() {
|
|
24
|
+
return join(homedir(), "Library", "Application Support", "Google", "Chrome");
|
|
25
|
+
}
|
|
26
|
+
export function parseProfiles(localStateJson) {
|
|
27
|
+
const data = JSON.parse(localStateJson);
|
|
28
|
+
const cache = data.profile?.info_cache ?? {};
|
|
29
|
+
return Object.entries(cache)
|
|
30
|
+
.map(([dir, info]) => ({ dir, name: info.name?.trim() || dir }))
|
|
31
|
+
.sort((a, b) => a.dir.localeCompare(b.dir, undefined, { numeric: true }));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Builds a Chrome-matching User-Agent from the installed version. Cloudflare
|
|
35
|
+
* ties cf_clearance to the exact UA, so the string must track the same Chrome
|
|
36
|
+
* that solved the challenge — hence reading the local "Last Version" file
|
|
37
|
+
* rather than hard-coding a version.
|
|
38
|
+
*/
|
|
39
|
+
export function buildUserAgent(version) {
|
|
40
|
+
const major = version?.split(".")[0]?.trim();
|
|
41
|
+
const m = major !== undefined && /^\d+$/.test(major) ? major : "140";
|
|
42
|
+
return `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${m}.0.0.0 Safari/537.36`;
|
|
43
|
+
}
|
|
44
|
+
export function chromeUserAgent(userDataDir) {
|
|
45
|
+
let version = null;
|
|
46
|
+
try {
|
|
47
|
+
version = readFileSync(join(userDataDir, "Last Version"), "utf8");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
version = null;
|
|
51
|
+
}
|
|
52
|
+
return buildUserAgent(version);
|
|
53
|
+
}
|
|
54
|
+
export function deriveKey(password) {
|
|
55
|
+
return pbkdf2Sync(password, KDF_SALT, KDF_ITERATIONS, KEY_LENGTH, "sha1");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Fetches the Chrome cookie-encryption key from the login Keychain. The
|
|
59
|
+
* service name contains a space, so this cannot reuse the Keychain class
|
|
60
|
+
* (its assertSafe rejects spaces); a direct spawn with an argv array is safe.
|
|
61
|
+
*/
|
|
62
|
+
export async function safeStorageKey(run = defaultRunner) {
|
|
63
|
+
const result = await run("security", [
|
|
64
|
+
"find-generic-password",
|
|
65
|
+
"-w",
|
|
66
|
+
"-s",
|
|
67
|
+
SAFE_STORAGE_SERVICE,
|
|
68
|
+
]);
|
|
69
|
+
if (result.code !== 0) {
|
|
70
|
+
throw new Error('Could not read the "Chrome Safe Storage" key from the Keychain. Is Google Chrome installed and set up?');
|
|
71
|
+
}
|
|
72
|
+
return deriveKey(result.stdout.trim());
|
|
73
|
+
}
|
|
74
|
+
export function decryptCookieValue(encHex, key) {
|
|
75
|
+
if (encHex.length === 0)
|
|
76
|
+
return null;
|
|
77
|
+
const buf = Buffer.from(encHex, "hex");
|
|
78
|
+
if (buf.subarray(0, 3).toString("latin1") !== COOKIE_PREFIX) {
|
|
79
|
+
// v20 (app-bound) cookies use a different key path we do not support.
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const decipher = createDecipheriv("aes-128-cbc", key, COOKIE_IV);
|
|
84
|
+
const plain = Buffer.concat([decipher.update(buf.subarray(3)), decipher.final()]);
|
|
85
|
+
return decodePlaintext(plain);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Newer Chrome builds prefix the value with a 32-byte host hash; older ones
|
|
93
|
+
* store the bare value. The hash is binary, so a plaintext that is not clean
|
|
94
|
+
* printable text is treated as prefixed and the leading 32 bytes are dropped.
|
|
95
|
+
*/
|
|
96
|
+
function decodePlaintext(plain) {
|
|
97
|
+
if (isPrintable(plain))
|
|
98
|
+
return plain.toString("utf8");
|
|
99
|
+
return plain.subarray(DOMAIN_HASH_LENGTH).toString("utf8");
|
|
100
|
+
}
|
|
101
|
+
function isPrintable(buf) {
|
|
102
|
+
for (const b of buf) {
|
|
103
|
+
if (b < 0x20 || b > 0x7e)
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Copies the (Chrome-locked) cookie DB to a temp file and reads the claude.ai
|
|
110
|
+
* rows as hex via the macOS-bundled sqlite3, avoiding a native SQLite binding.
|
|
111
|
+
*/
|
|
112
|
+
export async function readRawClaudeCookies(cookiesDbPath, run = defaultRunner) {
|
|
113
|
+
const dir = mkdtempSync(join(tmpdir(), "ccprofile-cookies-"));
|
|
114
|
+
const copy = join(dir, "Cookies");
|
|
115
|
+
try {
|
|
116
|
+
copyFileSync(cookiesDbPath, copy);
|
|
117
|
+
// Chrome keeps the store in WAL mode; without the sidecars, recently
|
|
118
|
+
// refreshed cookies (notably the short-lived Cloudflare ones) stay
|
|
119
|
+
// invisible until Chrome checkpoints.
|
|
120
|
+
for (const suffix of ["-wal", "-shm"]) {
|
|
121
|
+
const sidecar = `${cookiesDbPath}${suffix}`;
|
|
122
|
+
if (existsSync(sidecar))
|
|
123
|
+
copyFileSync(sidecar, `${copy}${suffix}`);
|
|
124
|
+
}
|
|
125
|
+
// Only cookies a browser would send to https://claude.ai/: the apex
|
|
126
|
+
// host-only cookie and `.claude.ai` domain cookies. A subdomain host-only
|
|
127
|
+
// cookie (e.g. foo.claude.ai) is not sent there and could otherwise shadow
|
|
128
|
+
// the real sessionKey/cf_clearance.
|
|
129
|
+
const result = await run("/usr/bin/sqlite3", [
|
|
130
|
+
"-json",
|
|
131
|
+
copy,
|
|
132
|
+
"SELECT name, hex(encrypted_value) AS enc FROM cookies " +
|
|
133
|
+
"WHERE host_key = 'claude.ai' OR host_key = '.claude.ai'",
|
|
134
|
+
]);
|
|
135
|
+
// A non-zero exit means sqlite3 itself failed (locked DB, unsupported
|
|
136
|
+
// option). Surface it so loadUsage reports a per-profile error instead of
|
|
137
|
+
// silently rendering the profile as "not signed in".
|
|
138
|
+
if (result.code !== 0) {
|
|
139
|
+
throw new Error(`sqlite3 failed to read Chrome cookies (exit ${result.code})${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}`);
|
|
140
|
+
}
|
|
141
|
+
const out = result.stdout.trim();
|
|
142
|
+
if (out === "")
|
|
143
|
+
return [];
|
|
144
|
+
return JSON.parse(out);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export async function readSessionCookies(cookiesDbPath, key, run = defaultRunner) {
|
|
151
|
+
const raw = await readRawClaudeCookies(cookiesDbPath, run);
|
|
152
|
+
const wanted = new Set(SESSION_COOKIE_NAMES);
|
|
153
|
+
const cookies = {};
|
|
154
|
+
for (const { name, enc } of raw) {
|
|
155
|
+
if (!wanted.has(name))
|
|
156
|
+
continue;
|
|
157
|
+
const value = decryptCookieValue(enc, key);
|
|
158
|
+
if (value !== null)
|
|
159
|
+
cookies[name] = value;
|
|
160
|
+
}
|
|
161
|
+
return cookies;
|
|
162
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal client for the claude.ai web API. ccprofile's own setup-token
|
|
3
|
+
* cannot read usage (the api.anthropic.com/api/oauth/usage endpoint requires a
|
|
4
|
+
* scope setup-tokens lack — see src/lib/usage.ts), so `ccprofile usage` speaks
|
|
5
|
+
* to the same endpoints the web app uses, authenticated with the browser's
|
|
6
|
+
* session cookies.
|
|
7
|
+
*/
|
|
8
|
+
/** Per-request ceiling so one stalled account cannot hang the whole command. */
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
10
|
+
export const defaultJsonFetcher = async (url, headers) => {
|
|
11
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
12
|
+
const text = await res.text();
|
|
13
|
+
let body = null;
|
|
14
|
+
try {
|
|
15
|
+
body = JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
body = null;
|
|
19
|
+
}
|
|
20
|
+
return { status: res.status, body };
|
|
21
|
+
};
|
|
22
|
+
export function buildHeaders(cookieHeader, userAgent) {
|
|
23
|
+
return {
|
|
24
|
+
Cookie: cookieHeader,
|
|
25
|
+
"User-Agent": userAgent,
|
|
26
|
+
"anthropic-client-platform": "web_claude_ai",
|
|
27
|
+
Referer: "https://claude.ai/",
|
|
28
|
+
Accept: "*/*",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function isRecord(v) {
|
|
32
|
+
return typeof v === "object" && v !== null;
|
|
33
|
+
}
|
|
34
|
+
function toDate(v) {
|
|
35
|
+
if (typeof v !== "string")
|
|
36
|
+
return null;
|
|
37
|
+
const d = new Date(v);
|
|
38
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
39
|
+
}
|
|
40
|
+
function toSeverity(v) {
|
|
41
|
+
return v === "normal" || v === "warning" || v === "critical" ? v : "unknown";
|
|
42
|
+
}
|
|
43
|
+
function toPercent(v) {
|
|
44
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
45
|
+
}
|
|
46
|
+
/** A `limits[]` entry carries percent/severity/resets_at directly. */
|
|
47
|
+
function windowFromLimit(limit) {
|
|
48
|
+
return {
|
|
49
|
+
percent: toPercent(limit.percent),
|
|
50
|
+
resetsAt: toDate(limit.resets_at),
|
|
51
|
+
severity: toSeverity(limit.severity),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** The top-level five_hour/seven_day objects use `utilization` and lack severity. */
|
|
55
|
+
function windowFromTopLevel(obj) {
|
|
56
|
+
if (!isRecord(obj))
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
percent: toPercent(obj.utilization),
|
|
60
|
+
resetsAt: toDate(obj.resets_at),
|
|
61
|
+
severity: "unknown",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function isFableScoped(limit) {
|
|
65
|
+
if (limit.kind !== "weekly_scoped")
|
|
66
|
+
return false;
|
|
67
|
+
const scope = limit.scope;
|
|
68
|
+
if (!isRecord(scope))
|
|
69
|
+
return false;
|
|
70
|
+
const model = scope.model;
|
|
71
|
+
return isRecord(model) && model.display_name === "Fable";
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Normalizes an /organizations/{id}/usage response. The Fable window only ever
|
|
75
|
+
* appears inside `limits[]` (the top-level seven_day_* fields are null), so
|
|
76
|
+
* limits[] is the primary source; five_hour/seven_day are fallbacks for the
|
|
77
|
+
* session and weekly-all windows when limits[] is absent.
|
|
78
|
+
*/
|
|
79
|
+
export function parseUsage(json) {
|
|
80
|
+
const root = isRecord(json) ? json : {};
|
|
81
|
+
const limits = Array.isArray(root.limits) ? root.limits.filter(isRecord) : [];
|
|
82
|
+
const byKind = (kind) => limits.find((l) => l.kind === kind);
|
|
83
|
+
const session = byKind("session");
|
|
84
|
+
const weeklyAll = byKind("weekly_all");
|
|
85
|
+
const fable = limits.find(isFableScoped);
|
|
86
|
+
return {
|
|
87
|
+
session: session ? windowFromLimit(session) : windowFromTopLevel(root.five_hour),
|
|
88
|
+
weeklyAll: weeklyAll ? windowFromLimit(weeklyAll) : windowFromTopLevel(root.seven_day),
|
|
89
|
+
fable: fable ? windowFromLimit(fable) : null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function extractEmail(body) {
|
|
93
|
+
if (!isRecord(body))
|
|
94
|
+
return null;
|
|
95
|
+
const account = body.account;
|
|
96
|
+
if (!isRecord(account))
|
|
97
|
+
return null;
|
|
98
|
+
return typeof account.email_address === "string" ? account.email_address : null;
|
|
99
|
+
}
|
|
100
|
+
/** Detail set when a profile simply has no claude.ai session (not an error). */
|
|
101
|
+
export const NOT_SIGNED_IN = "not signed in to claude.ai";
|
|
102
|
+
function describeStatus(status) {
|
|
103
|
+
if (status === 401)
|
|
104
|
+
return "session expired — sign in again in Chrome";
|
|
105
|
+
if (status === 403)
|
|
106
|
+
return "blocked (session expired or Cloudflare challenge)";
|
|
107
|
+
if (status === 429)
|
|
108
|
+
return "rate limited by claude.ai";
|
|
109
|
+
return `claude.ai returned HTTP ${status}`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Resolves one account's email and usage from its cookies. Uses lastActiveOrg
|
|
113
|
+
* (the org the browser last viewed) for the usage URL so the numbers match
|
|
114
|
+
* what the web UI shows. Never throws — network/HTTP failures come back as
|
|
115
|
+
* `{ ok: false }` so callers can render one bad profile without aborting.
|
|
116
|
+
*/
|
|
117
|
+
export async function fetchAccountUsage(cookies, userAgent, fetcher = defaultJsonFetcher) {
|
|
118
|
+
if (cookies.sessionKey === undefined) {
|
|
119
|
+
return { ok: false, status: 0, detail: NOT_SIGNED_IN };
|
|
120
|
+
}
|
|
121
|
+
const org = cookies.lastActiveOrg;
|
|
122
|
+
if (org === undefined) {
|
|
123
|
+
return { ok: false, status: 0, detail: "no active organization cookie" };
|
|
124
|
+
}
|
|
125
|
+
const cookieHeader = Object.entries(cookies)
|
|
126
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
127
|
+
.join("; ");
|
|
128
|
+
const headers = buildHeaders(cookieHeader, userAgent);
|
|
129
|
+
try {
|
|
130
|
+
const boot = await fetcher("https://claude.ai/api/bootstrap", headers);
|
|
131
|
+
if (boot.status !== 200) {
|
|
132
|
+
return { ok: false, status: boot.status, detail: describeStatus(boot.status) };
|
|
133
|
+
}
|
|
134
|
+
const email = extractEmail(boot.body);
|
|
135
|
+
const usage = await fetcher(`https://claude.ai/api/organizations/${encodeURIComponent(org)}/usage`, headers);
|
|
136
|
+
if (usage.status !== 200) {
|
|
137
|
+
return { ok: false, status: usage.status, detail: describeStatus(usage.status) };
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, email, report: parseUsage(usage.body) };
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
status: 0,
|
|
145
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/lib/format.js
CHANGED
|
@@ -12,21 +12,45 @@ export const cyan = (s) => paint("36", s);
|
|
|
12
12
|
export const ok = (s) => `${green("✓")} ${s}`;
|
|
13
13
|
export const warn = (s) => `${yellow("⚠")} ${s}`;
|
|
14
14
|
export const fail = (s) => `${red("✗")} ${s}`;
|
|
15
|
-
function stripAnsi(s) {
|
|
15
|
+
export function stripAnsi(s) {
|
|
16
16
|
return s.replaceAll(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), "");
|
|
17
17
|
}
|
|
18
|
+
/** East Asian Wide/Fullwidth code points that occupy two terminal columns. */
|
|
19
|
+
function isWide(cp) {
|
|
20
|
+
return ((cp >= 0x1100 && cp <= 0x115f) ||
|
|
21
|
+
(cp >= 0x2e80 && cp <= 0x303e) ||
|
|
22
|
+
(cp >= 0x3041 && cp <= 0x33ff) ||
|
|
23
|
+
(cp >= 0x3400 && cp <= 0x4dbf) ||
|
|
24
|
+
(cp >= 0x4e00 && cp <= 0x9fff) ||
|
|
25
|
+
(cp >= 0xa000 && cp <= 0xa4cf) ||
|
|
26
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
27
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
28
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) ||
|
|
29
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
30
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
31
|
+
(cp >= 0x1f300 && cp <= 0x1faff) ||
|
|
32
|
+
(cp >= 0x20000 && cp <= 0x3fffd));
|
|
33
|
+
}
|
|
34
|
+
/** Rendered column count of a string, ANSI-stripped and wide-char aware. */
|
|
35
|
+
export function displayWidth(s) {
|
|
36
|
+
let width = 0;
|
|
37
|
+
for (const ch of stripAnsi(s)) {
|
|
38
|
+
width += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
39
|
+
}
|
|
40
|
+
return width;
|
|
41
|
+
}
|
|
18
42
|
export function table(rows) {
|
|
19
43
|
if (rows.length === 0)
|
|
20
44
|
return "";
|
|
21
45
|
const widths = [];
|
|
22
46
|
for (const row of rows) {
|
|
23
47
|
row.forEach((cell, i) => {
|
|
24
|
-
widths[i] = Math.max(widths[i] ?? 0,
|
|
48
|
+
widths[i] = Math.max(widths[i] ?? 0, displayWidth(cell));
|
|
25
49
|
});
|
|
26
50
|
}
|
|
27
51
|
return rows
|
|
28
52
|
.map((row) => row
|
|
29
|
-
.map((cell, i) => cell + " ".repeat((widths[i] ?? 0) -
|
|
53
|
+
.map((cell, i) => cell + " ".repeat((widths[i] ?? 0) - displayWidth(cell)))
|
|
30
54
|
.join(" ")
|
|
31
55
|
.trimEnd())
|
|
32
56
|
.join("\n");
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
/**
|
|
4
|
+
* fable has its own budget (~50% of the plan, 5h/1-week windows) separate
|
|
5
|
+
* from the pool shared by haiku/sonnet/opus, and fable usage also counts
|
|
6
|
+
* against the shared pool. Probing fable first therefore answers for
|
|
7
|
+
* everything: fable OK ⇒ all models OK; fable limited ⇒ probe the cheapest
|
|
8
|
+
* shared-pool model to tell "fable-only exhausted" from "window exhausted".
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_PROBE_MODEL = "fable";
|
|
11
|
+
export const FALLBACK_PROBE_MODEL = "haiku";
|
|
12
|
+
const PROBE_TIMEOUT_MS = 120_000;
|
|
13
|
+
/**
|
|
14
|
+
* Env vars that outrank ANTHROPIC_AUTH_TOKEN in Claude Code's documented
|
|
15
|
+
* authentication precedence. doctor flags them; the probe strips them so the
|
|
16
|
+
* spawned `claude -p` authenticates as the profile under test.
|
|
17
|
+
*/
|
|
18
|
+
export const OVERRIDING_ENV_VARS = [
|
|
19
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
20
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
21
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
22
|
+
];
|
|
23
|
+
export const defaultProbeRunner = (cmd, args, opts) => new Promise((resolve) => {
|
|
24
|
+
const child = spawn(cmd, args, {
|
|
25
|
+
env: opts.env,
|
|
26
|
+
cwd: opts.cwd,
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
});
|
|
29
|
+
let stdout = "";
|
|
30
|
+
let stderr = "";
|
|
31
|
+
let timedOut = false;
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
timedOut = true;
|
|
34
|
+
child.kill("SIGKILL");
|
|
35
|
+
}, opts.timeoutMs);
|
|
36
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
37
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
38
|
+
child.on("error", (error) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve({ code: null, stdout, stderr, timedOut, spawnError: error.message });
|
|
41
|
+
});
|
|
42
|
+
child.on("close", (code) => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
/**
|
|
48
|
+
* Everything here trims the request to near-zero cost and isolates it from
|
|
49
|
+
* the invoking user's Claude Code customizations: a one-word turn with a
|
|
50
|
+
* replacement system prompt, no tools, no MCP servers, no settings (hooks,
|
|
51
|
+
* plugins, apiKeyHelper), no session file, run from tmpdir so no project
|
|
52
|
+
* CLAUDE.md is discovered. --fallback-model must never be added: it would
|
|
53
|
+
* silently succeed on another model and mask the limit being probed for.
|
|
54
|
+
*/
|
|
55
|
+
function probeArgs(model) {
|
|
56
|
+
return [
|
|
57
|
+
"--print", "ping",
|
|
58
|
+
"--model", model,
|
|
59
|
+
"--effort", "low",
|
|
60
|
+
"--system-prompt", 'Reply with exactly "ok".',
|
|
61
|
+
"--tools", "",
|
|
62
|
+
"--setting-sources", "",
|
|
63
|
+
"--strict-mcp-config",
|
|
64
|
+
"--no-session-persistence",
|
|
65
|
+
"--output-format", "json",
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
function probeEnv(token) {
|
|
69
|
+
const env = { ...process.env, ANTHROPIC_AUTH_TOKEN: token };
|
|
70
|
+
delete env.ANTHROPIC_API_KEY;
|
|
71
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
72
|
+
delete env.ANTHROPIC_MODEL;
|
|
73
|
+
for (const name of OVERRIDING_ENV_VARS)
|
|
74
|
+
delete env[name];
|
|
75
|
+
return env;
|
|
76
|
+
}
|
|
77
|
+
// Claude Code's error strings are not a stable API; match loosely and fall
|
|
78
|
+
// back to "unknown" rather than misclassifying. Checked against the legacy
|
|
79
|
+
// print-mode message ("Claude AI usage limit reached|<epoch>"), the current
|
|
80
|
+
// prose ("You've reached your usage limit..."), and API rate_limit_error.
|
|
81
|
+
const LIMIT_RE = /usage limit|rate.?limit|limit (?:reached|exceeded|resets|will reset)|exceeded.*limit|out of (?:usage|credits)|hit your.*limit|\b429\b/i;
|
|
82
|
+
const AUTH_RE = /authentication_error|invalid (?:bearer|oauth|api key)|oauth token|\b401\b|\/login\b/i;
|
|
83
|
+
/** Legacy print-mode limit messages carry a reset time as "|<unix epoch>". */
|
|
84
|
+
function parseResetEpoch(text) {
|
|
85
|
+
const digits = /\|(\d{10,13})\b/.exec(text)?.[1];
|
|
86
|
+
if (digits === undefined)
|
|
87
|
+
return undefined;
|
|
88
|
+
const n = Number(digits);
|
|
89
|
+
return new Date(digits.length >= 13 ? n : n * 1000);
|
|
90
|
+
}
|
|
91
|
+
function summarize(text) {
|
|
92
|
+
const line = text
|
|
93
|
+
.replaceAll(/\u001B\[[0-9;]*m/g, "")
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map((l) => l.trim())
|
|
96
|
+
.find((l) => l.length > 0);
|
|
97
|
+
return (line ?? "").slice(0, 200);
|
|
98
|
+
}
|
|
99
|
+
function isRecord(v) {
|
|
100
|
+
return typeof v === "object" && v !== null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `--output-format json` emits a single result object in some Claude Code
|
|
104
|
+
* versions and an array of messages (init/assistant/result) in others
|
|
105
|
+
* (observed with 2.1.201). The result message carries `is_error`, the final
|
|
106
|
+
* text, and — on API rejections — a structured `api_error_status` (e.g. 429),
|
|
107
|
+
* which beats string matching.
|
|
108
|
+
*/
|
|
109
|
+
function extractResultMessage(stdout) {
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(stdout);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Startup failures print plain text before any JSON is emitted.
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
const items = Array.isArray(parsed) ? parsed : [parsed];
|
|
119
|
+
const message = items.find((i) => isRecord(i) && i.type === "result") ??
|
|
120
|
+
items.find((i) => isRecord(i) && "is_error" in i);
|
|
121
|
+
if (message === undefined)
|
|
122
|
+
return {};
|
|
123
|
+
return {
|
|
124
|
+
isError: typeof message.is_error === "boolean" ? message.is_error : undefined,
|
|
125
|
+
resultText: typeof message.result === "string" ? message.result : undefined,
|
|
126
|
+
apiErrorStatus: typeof message.api_error_status === "number" ? message.api_error_status : undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export function classifyProbeOutput(exec) {
|
|
130
|
+
if (exec.spawnError !== undefined) {
|
|
131
|
+
return { status: "unknown", detail: exec.spawnError };
|
|
132
|
+
}
|
|
133
|
+
if (exec.timedOut) {
|
|
134
|
+
return { status: "unknown", detail: `probe timed out after ${PROBE_TIMEOUT_MS / 1000}s` };
|
|
135
|
+
}
|
|
136
|
+
const { isError, resultText, apiErrorStatus } = extractResultMessage(exec.stdout);
|
|
137
|
+
if (exec.code === 0 && isError !== true)
|
|
138
|
+
return { status: "usable" };
|
|
139
|
+
const combined = [resultText ?? exec.stdout, exec.stderr].join("\n");
|
|
140
|
+
const detail = summarize(combined) || `claude exited with code ${exec.code}`;
|
|
141
|
+
if (apiErrorStatus === 429 || LIMIT_RE.test(combined)) {
|
|
142
|
+
return { status: "limited", detail, resetsAt: parseResetEpoch(combined) };
|
|
143
|
+
}
|
|
144
|
+
if (apiErrorStatus === 401 || AUTH_RE.test(combined)) {
|
|
145
|
+
return { status: "invalid", detail };
|
|
146
|
+
}
|
|
147
|
+
return { status: "unknown", detail };
|
|
148
|
+
}
|
|
149
|
+
export async function probeUsage(token, model, run = defaultProbeRunner) {
|
|
150
|
+
const exec = await run("claude", probeArgs(model), {
|
|
151
|
+
env: probeEnv(token),
|
|
152
|
+
cwd: tmpdir(),
|
|
153
|
+
timeoutMs: PROBE_TIMEOUT_MS,
|
|
154
|
+
});
|
|
155
|
+
return classifyProbeOutput(exec);
|
|
156
|
+
}
|
package/package.json
CHANGED