@efoo/ccprofile 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/completion.js +4 -3
- package/dist/commands/usage.js +198 -0
- package/dist/index.js +5 -0
- package/dist/lib/chrome.js +162 -0
- package/dist/lib/claudeai.js +148 -0
- package/dist/lib/format.js +26 -2
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -0,0 +1,198 @@
|
|
|
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 { 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
|
+
}
|
|
33
|
+
export async function usageCommand(argv) {
|
|
34
|
+
const { values } = parseArgs({
|
|
35
|
+
args: argv,
|
|
36
|
+
options: { json: { type: "boolean", default: false } },
|
|
37
|
+
});
|
|
38
|
+
assertDarwin();
|
|
39
|
+
const userDataDir = chromeUserDataDir();
|
|
40
|
+
const localStatePath = join(userDataDir, "Local State");
|
|
41
|
+
if (!existsSync(localStatePath)) {
|
|
42
|
+
console.error(fail("Google Chrome data not found. `ccprofile usage` reads claude.ai session cookies from Chrome."));
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
let key;
|
|
46
|
+
try {
|
|
47
|
+
key = await safeStorageKey();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
console.error(fail(error instanceof Error ? error.message : String(error)));
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
const userAgent = chromeUserAgent(userDataDir);
|
|
54
|
+
const profiles = parseProfiles(readFileSync(localStatePath, "utf8")).filter((p) => existsSync(join(userDataDir, p.dir, "Cookies")));
|
|
55
|
+
const spinner = values.json
|
|
56
|
+
? { stop: () => { } }
|
|
57
|
+
: startSpinner(`querying claude.ai for ${profiles.length} Chrome profile(s)…`);
|
|
58
|
+
const results = await Promise.all(profiles.map((profile) => loadUsage(profile, userDataDir, key, userAgent)));
|
|
59
|
+
spinner.stop();
|
|
60
|
+
const byEmail = profileNamesByEmail();
|
|
61
|
+
if (values.json) {
|
|
62
|
+
console.log(JSON.stringify(results.map((r) => toJson(r, byEmail)), null, 2));
|
|
63
|
+
return hasRealFailure(results) ? 1 : 0;
|
|
64
|
+
}
|
|
65
|
+
return render(results, byEmail);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A profile that was never signed in is expected and not a failure; anything
|
|
69
|
+
* else (expired session, Cloudflare block, decrypt/read error) is, so the
|
|
70
|
+
* command exits non-zero for scripts even though the table still prints.
|
|
71
|
+
*/
|
|
72
|
+
function hasRealFailure(results) {
|
|
73
|
+
return results.some((r) => !r.usage.ok && r.usage.detail !== NOT_SIGNED_IN);
|
|
74
|
+
}
|
|
75
|
+
/** Decrypts one profile's cookies and fetches its usage; never throws. */
|
|
76
|
+
async function loadUsage(profile, userDataDir, key, userAgent) {
|
|
77
|
+
try {
|
|
78
|
+
const cookies = await readSessionCookies(join(userDataDir, profile.dir, "Cookies"), key);
|
|
79
|
+
return { profile, usage: await fetchAccountUsage(cookies, userAgent) };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return { profile, usage: { ok: false, status: 0, detail } };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function render(results, byEmail) {
|
|
87
|
+
const signedIn = [];
|
|
88
|
+
const problems = [];
|
|
89
|
+
for (const { profile, usage } of results) {
|
|
90
|
+
if (usage.ok) {
|
|
91
|
+
signedIn.push({ profile, usage });
|
|
92
|
+
}
|
|
93
|
+
else if (usage.detail !== NOT_SIGNED_IN) {
|
|
94
|
+
// A missing session is expected for stray Chrome profiles; only real
|
|
95
|
+
// failures (expired session, Cloudflare block) are worth surfacing.
|
|
96
|
+
problems.push(warn(`${profile.name}: ${usage.detail}`));
|
|
97
|
+
}
|
|
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
|
+
}
|
|
112
|
+
if (rows.length === 1 && problems.length === 0) {
|
|
113
|
+
console.log(dim("No claude.ai sessions found in Chrome. Sign in at https://claude.ai and retry."));
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
if (rows.length > 1)
|
|
117
|
+
console.log(table(rows));
|
|
118
|
+
for (const problem of problems)
|
|
119
|
+
console.log(problem);
|
|
120
|
+
return problems.length > 0 ? 1 : 0;
|
|
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
|
+
}
|
|
131
|
+
function windowCell(window) {
|
|
132
|
+
if (window === null)
|
|
133
|
+
return dim("-");
|
|
134
|
+
const reset = window.resetsAt === null ? "" : ` ${dim(formatReset(window.resetsAt))}`;
|
|
135
|
+
return `${percent(window)}${reset}`;
|
|
136
|
+
}
|
|
137
|
+
function percent(window) {
|
|
138
|
+
// Right-align to 3 digits so the reset time lines up down the column.
|
|
139
|
+
const text = `${String(window.percent).padStart(3, " ")}%`;
|
|
140
|
+
if (window.severity === "critical" || window.percent >= 90)
|
|
141
|
+
return red(text);
|
|
142
|
+
if (window.severity === "warning" || window.percent >= 80)
|
|
143
|
+
return yellow(text);
|
|
144
|
+
return text;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Local-time reset as `M/D HH:mm` (respects the machine's timezone). The date
|
|
148
|
+
* is right-padded to a fixed width so the clock times align down the column.
|
|
149
|
+
*/
|
|
150
|
+
function formatReset(date) {
|
|
151
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
152
|
+
const md = `${date.getMonth() + 1}/${date.getDate()}`.padStart(5, " ");
|
|
153
|
+
return `${md} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
154
|
+
}
|
|
155
|
+
function toJson(result, byEmail) {
|
|
156
|
+
const { profile, usage } = result;
|
|
157
|
+
const email = usage.ok ? usage.email : null;
|
|
158
|
+
return {
|
|
159
|
+
profile: matchedProfileName(email, byEmail),
|
|
160
|
+
chromeProfile: profile.name,
|
|
161
|
+
chromeDir: profile.dir,
|
|
162
|
+
email,
|
|
163
|
+
error: usage.ok ? null : usage.detail,
|
|
164
|
+
usage: usage.ok ? serializeReport(usage.report) : null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function serializeReport(report) {
|
|
168
|
+
const win = (w) => w === null
|
|
169
|
+
? null
|
|
170
|
+
: { percent: w.percent, resetsAt: w.resetsAt?.toISOString() ?? null, severity: w.severity };
|
|
171
|
+
return {
|
|
172
|
+
session: win(report.session),
|
|
173
|
+
weeklyAll: win(report.weeklyAll),
|
|
174
|
+
fable: win(report.fable),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Minimal TTY-only spinner for the network wait; stays silent when output is
|
|
179
|
+
* piped so scripted/`--json` runs keep clean output.
|
|
180
|
+
*/
|
|
181
|
+
function startSpinner(text) {
|
|
182
|
+
if (!process.stdout.isTTY)
|
|
183
|
+
return { stop: () => { } };
|
|
184
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
185
|
+
let i = 0;
|
|
186
|
+
const paint = () => {
|
|
187
|
+
process.stdout.write(`\r\u001B[2K${dim(`${frames[i % frames.length] ?? ""} ${text}`)}`);
|
|
188
|
+
i += 1;
|
|
189
|
+
};
|
|
190
|
+
paint();
|
|
191
|
+
const timer = setInterval(paint, 100);
|
|
192
|
+
return {
|
|
193
|
+
stop: () => {
|
|
194
|
+
clearInterval(timer);
|
|
195
|
+
process.stdout.write("\r\u001B[2K");
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,9 @@ ${bold("Commands")}
|
|
|
26
26
|
--offline usage limits (real inference probe), and broken links
|
|
27
27
|
--model <alias> --offline skips probes; --model pins the probe model
|
|
28
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
|
|
29
32
|
completion <shell> Print a completion script (fish, zsh, bash)
|
|
30
33
|
|
|
31
34
|
${bold("Typical flow")}
|
|
@@ -52,6 +55,8 @@ async function main() {
|
|
|
52
55
|
return (await import("./commands/token.js")).tokenCommand(rest);
|
|
53
56
|
case "doctor":
|
|
54
57
|
return (await import("./commands/doctor.js")).doctorCommand(rest);
|
|
58
|
+
case "usage":
|
|
59
|
+
return (await import("./commands/usage.js")).usageCommand(rest);
|
|
55
60
|
case "completion":
|
|
56
61
|
return (await import("./commands/completion.js")).completionCommand(rest);
|
|
57
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
|
@@ -15,18 +15,42 @@ export const fail = (s) => `${red("✗")} ${s}`;
|
|
|
15
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");
|
package/package.json
CHANGED