@efoo/ccprofile 0.2.0 → 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.
@@ -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,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
@@ -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
+ }
@@ -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, stripAnsi(cell).length);
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) - stripAnsi(cell).length))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@efoo/ccprofile",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Per-directory Claude Code account routing via ANTHROPIC_AUTH_TOKEN, direnv, and macOS Keychain",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.33.4",