whoburnedmore 0.9.4 → 0.9.6

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.
Files changed (3) hide show
  1. package/README.md +40 -23
  2. package/dist/index.js +412 -131
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # whoburnedmore
2
2
 
3
- **who burned more tokens — you or them?** One command reads your local AI coding-agent usage logs, shows your burn, and gives you a **shareable dashboard with no sign-in required**.
3
+ **who burned more tokens — you or them?** One command reads your local AI coding-agent usage logs, adds up your burn, and puts you on the public leaderboard with a **shareable dashboard**.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/whoburnedmore.svg?color=f97316)](https://www.npmjs.com/package/whoburnedmore)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/whoburnedmore.svg?color=f97316)](https://www.npmjs.com/package/whoburnedmore)
@@ -11,7 +11,7 @@
11
11
  npx whoburnedmore
12
12
  ```
13
13
 
14
- That's the whole thing. No install, no account, no config.
14
+ That's the whole thing. No install, no config. Your **first run signs you in** — the CLI opens a page, you approve a short code, and this machine is bound to your account.
15
15
 
16
16
  ---
17
17
 
@@ -28,13 +28,15 @@ whoburnedmore · https://whoburnedmore.com
28
28
  total 2.31B tokens $2,656.91
29
29
  today 92.8M tokens
30
30
 
31
- You burned 2.31B tokens. Your shareable dashboard:
32
- https://whoburnedmore.com/d/5twjwi-jtga4r
31
+ You burned 2.31B tokens. Your dashboard:
32
+ https://whoburnedmore.com/u/<your-handle>
33
33
 
34
- Re-run anytime to update it · `npx whoburnedmore login` to claim a public handle and compete.
34
+ Re-run anytime to update it. Background sync keeps it fresh every 15 min.
35
35
  ```
36
36
 
37
- Your dashboard is **anonymous and unlisted by default** — only people you send the link to can see it. Run the command again anytime to refresh it.
37
+ Once you're signed in, your usage lands on the leaderboard under your handle. Run the command again anytime to refresh — or let the background sync do it for you.
38
+
39
+ **Want to stay off the board?** Run `npx whoburnedmore private` to hide your dashboard, or `npx whoburnedmore --local` to never upload anything at all.
38
40
 
39
41
  ## Privacy first
40
42
 
@@ -46,7 +48,7 @@ See exactly what would be sent, and send nothing:
46
48
  npx whoburnedmore --dry-run
47
49
  ```
48
50
 
49
- Or stay completely offline — `--local` builds the same dashboard as a self-contained HTML file on your machine and opens it in your browser, sending nothing anywhere:
51
+ Or stay completely offline — `--local` builds the same dashboard as a self-contained HTML file on your machine and opens it in your browser, sending nothing anywhere and never signing you in:
50
52
 
51
53
  ```bash
52
54
  npx whoburnedmore --local
@@ -56,14 +58,21 @@ npx whoburnedmore --local
56
58
 
57
59
  | Command | What it does |
58
60
  | --- | --- |
59
- | `npx whoburnedmore` | Show your burn + get a shareable dashboard (no sign-in) |
60
- | `npx whoburnedmore --local` | Build the dashboard locally and open it (fully offline) |
61
+ | `npx whoburnedmore` | Sign in, submit your burn, land on the leaderboard, open your dashboard |
62
+ | `npx whoburnedmore --board=CODE` | Compare with friends sign in and join their board |
63
+ | `npx whoburnedmore --org=SLUG` | Submit to your organization's board (companies / hackathons) |
64
+ | `npx whoburnedmore --local` | Build the dashboard locally and open it (fully offline, no sign-in) |
61
65
  | `npx whoburnedmore --dry-run` | Print exactly what would be sent, send nothing |
62
66
  | `npx whoburnedmore --no-submit` | Print local stats only, send nothing |
63
- | `npx whoburnedmore login` | Sign in to claim a public handle + join the leaderboard |
64
- | `npx whoburnedmore logout` | Forget the local token (your data is untouched) |
65
- | `npx whoburnedmore install-sync` | Keep your dashboard live with a background sync (every 15 min) |
66
- | `npx whoburnedmore uninstall-sync` | Remove the background sync |
67
+ | `npx whoburnedmore private` | Hide your dashboard from the leaderboard |
68
+ | `npx whoburnedmore public` | Put it back on the leaderboard |
69
+ | `npx whoburnedmore remove` | Delete your dashboard and all its data |
70
+ | `npx whoburnedmore verify` | Delisted? Re-verify your usage to get back on |
71
+ | `npx whoburnedmore link --token=TOKEN` | Bind a server / VM to your account (token from your profile) |
72
+ | `npx whoburnedmore daemon` | Keep syncing in the foreground (containers with no scheduler) |
73
+ | `npx whoburnedmore status` | Check background-sync health (last sync, staleness) |
74
+ | `npx whoburnedmore install-sync` | Turn the 15-minute background sync back on |
75
+ | `npx whoburnedmore uninstall-sync` | Turn the background sync off |
67
76
 
68
77
  ## Supported tools
69
78
 
@@ -73,29 +82,37 @@ Usage is read via [`ccusage`](https://www.npmjs.com/package/ccusage) from any of
73
82
 
74
83
  Whatever you have logs for is picked up automatically — you don't pass any flags.
75
84
 
76
- ## Claim a public handle (optional)
85
+ ## Compete with friends and teams
77
86
 
78
- Want on the public leaderboard? Sign in with **GitHub or Google**:
87
+ - **Friends:** join someone's board with `npx whoburnedmore --board=CODE` (the code comes from their board's invite).
88
+ - **Companies / hackathons:** submit to an organization board with `npx whoburnedmore --org=SLUG` (add `--pass=CODE` if the org requires a join code).
79
89
 
80
- ```bash
81
- npx whoburnedmore login
82
- ```
83
-
84
- It uses a device flow — a code appears in your terminal, you approve it in the browser, and you're done. Your submissions then link to your handle at `whoburnedmore.com/u/<handle>`. You can delete your account and every row of data anytime from your profile.
90
+ Your submissions link to your handle at `whoburnedmore.com/u/<handle>`. You can delete your account and every row of data anytime from your profile.
85
91
 
86
92
  ## Keep it live
87
93
 
88
- Want your dashboard to stay fresh without re-running by hand?
94
+ Background sync is on by default after your first run, so your dashboard stays fresh without re-running by hand. To manage it:
89
95
 
90
96
  ```bash
91
- npx whoburnedmore install-sync # background sync every 15 min (launchd / cron / scheduled task)
92
- npx whoburnedmore uninstall-sync # remove it
97
+ npx whoburnedmore status # last sync + staleness
98
+ npx whoburnedmore uninstall-sync # turn the background sync off
99
+ npx whoburnedmore install-sync # turn it back on (launchd / cron / scheduled task)
93
100
  ```
94
101
 
95
102
  The installed background job runs the latest published `whoburnedmore` package on
96
103
  each sync tick, so future CLI fixes are picked up automatically after the next
97
104
  15-minute refresh.
98
105
 
106
+ ## Servers & VMs
107
+
108
+ Generate a one-time `link` command from your profile on whoburnedmore.com and run it inside the machine to bind it to your account:
109
+
110
+ ```bash
111
+ npx whoburnedmore link --token=TOKEN
112
+ ```
113
+
114
+ On a persistent VM, background sync uses cron or a systemd user timer automatically. In a container or any host without a scheduler, run `npx whoburnedmore daemon` under your process manager instead. Set `WHOBURNEDMORE_CONFIG_DIR` to a persistent path so the machine identity survives restarts.
115
+
99
116
  ## Links
100
117
 
101
118
  - 🏆 Leaderboard — **[whoburnedmore.com](https://whoburnedmore.com)**
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ var __export = (target, all) => {
7
7
 
8
8
  // src/index.ts
9
9
  import { spawn } from "node:child_process";
10
+ import { createHash } from "node:crypto";
10
11
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
11
12
  import { createRequire as createRequire4 } from "node:module";
12
13
  import { platform as platform3 } from "node:os";
@@ -95,12 +96,14 @@ async function readJson(res) {
95
96
  };
96
97
  }
97
98
  }
98
- async function post(path, body) {
99
+ async function post(path, body, token) {
100
+ const headers = { "Content-Type": "application/json" };
101
+ if (token) headers.Authorization = `Bearer ${token}`;
99
102
  let res;
100
103
  try {
101
104
  res = await fetch(`${apiBase()}${path}`, {
102
105
  method: "POST",
103
- headers: { "Content-Type": "application/json" },
106
+ headers,
104
107
  body: JSON.stringify(body),
105
108
  // Bound the request so a slow/black-holing/hostile server can't hang the CLI
106
109
  // — or the unattended 15-minute background sync — indefinitely.
@@ -113,8 +116,21 @@ async function post(path, body) {
113
116
  }
114
117
  return { status: res.status, body: await readJson(res) };
115
118
  }
119
+ async function verifyUsage(token, payload) {
120
+ const { status, body } = await post("/v1/verify", payload, token);
121
+ if (status === 401) throw new UnauthorizedError();
122
+ if (status !== 200) {
123
+ const err = body;
124
+ const details = err.details?.length ? `
125
+ - ${err.details.join("\n - ")}` : "";
126
+ throw new Error(`${err.error ?? "verification failed"}${details}`);
127
+ }
128
+ return body;
129
+ }
130
+ var ANON_RETIRED_MESSAGE = "Anonymous mode has been retired. Run `npx whoburnedmore` and sign in, or `npx whoburnedmore link --token=\u2026` for a server/CI, to put your usage on the leaderboard.";
116
131
  async function anonSubmit(anonKey, payload) {
117
132
  const { status, body } = await post("/v1/anon/submit", { ...payload, anonKey });
133
+ if (status === 410) throw new Error(ANON_RETIRED_MESSAGE);
118
134
  if (status !== 200) {
119
135
  const err = body;
120
136
  const details = err.details?.length ? `
@@ -123,22 +139,54 @@ async function anonSubmit(anonKey, payload) {
123
139
  }
124
140
  return body;
125
141
  }
126
- function claimUrl(dashboardUrl, anonKey) {
127
- return `${dashboardUrl}#k=${encodeURIComponent(anonKey)}`;
142
+ var UnauthorizedError = class extends Error {
143
+ constructor(message = "your sign-in has expired") {
144
+ super(message);
145
+ this.name = "UnauthorizedError";
146
+ }
147
+ };
148
+ async function deviceStart() {
149
+ const { status, body } = await post(
150
+ "/v1/auth/device",
151
+ {}
152
+ );
153
+ if (status !== 200) {
154
+ throw new Error(
155
+ body.error ?? `sign-in failed (HTTP ${status})`
156
+ );
157
+ }
158
+ return body;
128
159
  }
129
- function boardClaimUrl(boardUrl, slug, anonKey) {
130
- return `${boardUrl}#k=${encodeURIComponent(anonKey)}&u=${encodeURIComponent(slug)}`;
160
+ async function devicePoll(deviceCode) {
161
+ const { status, body } = await post(
162
+ "/v1/auth/device/token",
163
+ { deviceCode }
164
+ );
165
+ if (status !== 200) {
166
+ throw new Error(`sign-in failed (HTTP ${status})`);
167
+ }
168
+ return body;
131
169
  }
132
- function resolveOpenTarget(result, anonKey) {
133
- const baseUrl = result.orgBoardUrl ?? result.boardUrl ?? result.dashboardUrl;
134
- const target = result.orgBoardUrl ? boardClaimUrl(result.orgBoardUrl, result.slug, anonKey) : result.boardUrl ? boardClaimUrl(result.boardUrl, result.slug, anonKey) : claimUrl(result.dashboardUrl, anonKey);
135
- return { baseUrl, target };
170
+ async function submit(token, payload) {
171
+ const { status, body } = await post("/v1/submit", payload, token);
172
+ if (status === 401) throw new UnauthorizedError();
173
+ if (status !== 200) {
174
+ const err = body;
175
+ const details = err.details?.length ? `
176
+ - ${err.details.join("\n - ")}` : "";
177
+ throw new Error(`${err.error ?? `submit failed (HTTP ${status})`}${details}`);
178
+ }
179
+ return body;
180
+ }
181
+ function claimUrl(dashboardUrl, anonKey) {
182
+ return `${dashboardUrl}#k=${encodeURIComponent(anonKey)}`;
136
183
  }
137
184
  async function anonVisibility(anonKey, listed) {
138
185
  const { status, body } = await post(
139
186
  "/v1/anon/visibility",
140
187
  { anonKey, listed }
141
188
  );
189
+ if (status === 410) throw new Error(ANON_RETIRED_MESSAGE);
142
190
  if (status !== 200) {
143
191
  throw new Error(body.error ?? `failed (HTTP ${status})`);
144
192
  }
@@ -150,6 +198,7 @@ async function anonRemove(anonKey) {
150
198
  body: JSON.stringify({ anonKey }),
151
199
  signal: AbortSignal.timeout(3e4)
152
200
  });
201
+ if (res.status === 410) throw new Error(ANON_RETIRED_MESSAGE);
153
202
  if (res.status !== 200) {
154
203
  const b = await res.json().catch(() => ({}));
155
204
  throw new Error(b.error ?? `failed (HTTP ${res.status})`);
@@ -204,6 +253,8 @@ function loadConfig(dir = defaultConfigDir()) {
204
253
  const parsed = JSON.parse(readFileSync(file, "utf8"));
205
254
  const config = {};
206
255
  if (typeof parsed.anonKey === "string") config.anonKey = parsed.anonKey;
256
+ if (typeof parsed.cliToken === "string") config.cliToken = parsed.cliToken;
257
+ if (typeof parsed.handle === "string") config.handle = parsed.handle;
207
258
  if (typeof parsed.lastSyncAt === "number" && Number.isFinite(parsed.lastSyncAt))
208
259
  config.lastSyncAt = parsed.lastSyncAt;
209
260
  if (typeof parsed.launchNotificationDeliveredAt === "number" && Number.isFinite(parsed.launchNotificationDeliveredAt)) {
@@ -244,9 +295,15 @@ function recordSync(dir = defaultConfigDir(), when = Date.now()) {
244
295
  const config = loadConfig(dir) ?? {};
245
296
  saveConfig(dir, { ...config, lastSyncAt: when });
246
297
  }
247
- function recordLaunchNotificationDelivered(dir = defaultConfigDir(), when = Date.now()) {
298
+ function saveAuth(dir = defaultConfigDir(), auth = { cliToken: "" }) {
248
299
  const config = loadConfig(dir) ?? {};
249
- saveConfig(dir, { ...config, launchNotificationDeliveredAt: when });
300
+ saveConfig(dir, { ...config, cliToken: auth.cliToken, handle: auth.handle });
301
+ }
302
+ function clearAuth(dir = defaultConfigDir()) {
303
+ const config = loadConfig(dir);
304
+ if (!config?.cliToken && !config?.handle) return;
305
+ const { cliToken: _t, handle: _h, ...rest } = config;
306
+ saveConfig(dir, rest);
250
307
  }
251
308
 
252
309
  // src/autosync.ts
@@ -665,41 +722,6 @@ function rotateLogIfLarge(path = syncLogPath(), capBytes = 256 * 1024) {
665
722
  return false;
666
723
  }
667
724
  }
668
- function notifyLaunchLive(opts) {
669
- const os = opts?.platform ?? platform();
670
- const run2 = opts?.spawn ?? ((cmd, args) => spawnSync(cmd, args, { stdio: "ignore" }));
671
- const title = "whoburnedmore is live";
672
- const message = "Your dashboard is ready. Go to whoburnedmore.com";
673
- if (os === "darwin") {
674
- return run2("osascript", [
675
- "-e",
676
- `display notification "${message}" with title "${title}"`
677
- ]).status === 0;
678
- }
679
- if (os === "linux") {
680
- return run2("notify-send", [title, message]).status === 0;
681
- }
682
- if (os === "win32") {
683
- const psQuote = (value) => `'${value.replaceAll("'", "''")}'`;
684
- const script = [
685
- "Add-Type -AssemblyName System.Windows.Forms",
686
- "$n = New-Object System.Windows.Forms.NotifyIcon",
687
- "$n.Icon = [System.Drawing.SystemIcons]::Application",
688
- `$n.BalloonTipTitle = ${psQuote(title)}`,
689
- `$n.BalloonTipText = ${psQuote(message)}`,
690
- "$n.Visible = $true",
691
- "$n.ShowBalloonTip(10000)",
692
- "Start-Sleep -Seconds 2",
693
- "$n.Dispose()"
694
- ].join("; ");
695
- return run2("powershell", [
696
- "-NoProfile",
697
- "-Command",
698
- script
699
- ]).status === 0;
700
- }
701
- return false;
702
- }
703
725
  function autoSyncLoaded() {
704
726
  if (platform() === "darwin") {
705
727
  const res = spawnSync("launchctl", ["list"], { encoding: "utf8" });
@@ -1332,6 +1354,8 @@ function parseClaudeLine(raw) {
1332
1354
  if (message.role !== void 0 && message.role !== "assistant") return null;
1333
1355
  const date = localDate(String(obj.timestamp ?? ""));
1334
1356
  if (!date) return null;
1357
+ const tsParsed = Date.parse(String(obj.timestamp ?? ""));
1358
+ const ts = Number.isFinite(tsParsed) ? tsParsed : 0;
1335
1359
  const messageId = typeof message.id === "string" ? message.id : "";
1336
1360
  const requestId = typeof obj.requestId === "string" ? obj.requestId : "";
1337
1361
  const hasRealId = messageId !== "" || requestId !== "";
@@ -1340,6 +1364,7 @@ function parseClaudeLine(raw) {
1340
1364
  key,
1341
1365
  hasRealId,
1342
1366
  date,
1367
+ ts,
1343
1368
  model: typeof message.model === "string" ? message.model : "unknown",
1344
1369
  inputTokens: num3(usage.input_tokens),
1345
1370
  outputTokens: num3(usage.output_tokens),
@@ -1458,6 +1483,28 @@ async function collectClaudeNative(env = process.env, opts = {}) {
1458
1483
  }
1459
1484
  return { entries: finalizeClaudeEntries(acc), found: true, filesScanned: scanned };
1460
1485
  }
1486
+ async function collectClaudeRequests(env = process.env, opts = {}) {
1487
+ const dirs = resolveClaudeProjectDirs(env);
1488
+ const files = [];
1489
+ for (const dir of dirs) files.push(...await listJsonl(dir));
1490
+ if (files.length === 0) return { requests: [], found: false };
1491
+ const now = opts.now ?? Date.now;
1492
+ const deadline = now() + (opts.budgetMs ?? NATIVE_READ_BUDGET_MS);
1493
+ const acc = /* @__PURE__ */ new Map();
1494
+ for (const f of files) {
1495
+ if (now() > deadline) {
1496
+ return { requests: [...acc.values()], found: true, timedOut: true };
1497
+ }
1498
+ let content;
1499
+ try {
1500
+ content = await readFile(f, "utf8");
1501
+ } catch {
1502
+ continue;
1503
+ }
1504
+ accumulateClaudeLines(acc, splitLines(content));
1505
+ }
1506
+ return { requests: [...acc.values()], found: true };
1507
+ }
1461
1508
 
1462
1509
  // src/native/codex.ts
1463
1510
  import { readdir as readdir2, readFile as readFile2 } from "node:fs/promises";
@@ -1491,9 +1538,8 @@ function readTokenFields(payload) {
1491
1538
  }
1492
1539
  function parseCodexRollout(lines) {
1493
1540
  let model = "unknown";
1494
- let lastDate = null;
1495
- let last = null;
1496
- let turnCount = 0;
1541
+ const perDay = /* @__PURE__ */ new Map();
1542
+ let lastSeenDate = null;
1497
1543
  for (const raw of lines) {
1498
1544
  const trimmed = raw.trim();
1499
1545
  if (!trimmed) continue;
@@ -1512,49 +1558,68 @@ function parseCodexRollout(lines) {
1512
1558
  if (payload.type === "token_count") {
1513
1559
  const fields = readTokenFields(payload);
1514
1560
  if (!fields) continue;
1515
- last = fields;
1516
- turnCount += 1;
1517
- const d = localDate2(String(obj.timestamp ?? ""));
1518
- if (d) lastDate = d;
1561
+ const parsed = localDate2(String(obj.timestamp ?? ""));
1562
+ const day = parsed ?? lastSeenDate;
1563
+ if (!day) continue;
1564
+ lastSeenDate = day;
1565
+ const e = perDay.get(day);
1566
+ if (e) {
1567
+ e.cum = fields;
1568
+ e.turns += 1;
1569
+ } else {
1570
+ perDay.set(day, { cum: fields, turns: 1 });
1571
+ }
1519
1572
  }
1520
1573
  }
1521
- if (!last || !lastDate) return null;
1522
- const cacheReadTokens = last.cached;
1523
- const inputTokens = Math.max(0, last.input - last.cached);
1524
- const outputTokens = last.output + last.reasoning;
1525
- if (inputTokens + outputTokens + cacheReadTokens === 0) return null;
1526
- return {
1527
- date: lastDate,
1528
- model,
1529
- inputTokens,
1530
- outputTokens,
1531
- cacheCreationTokens: 0,
1532
- cacheReadTokens,
1533
- turnCount
1534
- };
1574
+ if (perDay.size === 0) return [];
1575
+ const dates = [...perDay.keys()].sort();
1576
+ const out = [];
1577
+ let prev = { input: 0, cached: 0, output: 0, reasoning: 0 };
1578
+ for (const date of dates) {
1579
+ const { cum, turns } = perDay.get(date);
1580
+ const dInput = Math.max(0, cum.input - prev.input);
1581
+ const dCached = Math.max(0, cum.cached - prev.cached);
1582
+ const dOutput = Math.max(0, cum.output - prev.output);
1583
+ const dReasoning = Math.max(0, cum.reasoning - prev.reasoning);
1584
+ prev = cum;
1585
+ const cacheReadTokens = dCached;
1586
+ const inputTokens = Math.max(0, dInput - dCached);
1587
+ const outputTokens = dOutput + dReasoning;
1588
+ if (inputTokens + outputTokens + cacheReadTokens === 0) continue;
1589
+ out.push({
1590
+ date,
1591
+ model,
1592
+ inputTokens,
1593
+ outputTokens,
1594
+ cacheCreationTokens: 0,
1595
+ cacheReadTokens,
1596
+ turnCount: turns
1597
+ });
1598
+ }
1599
+ return out;
1535
1600
  }
1536
1601
  function accumulateCodexSession(acc, lines) {
1537
- const s = parseCodexRollout(lines);
1538
- if (!s) return;
1539
- const k = `${s.date}|${s.model}`;
1540
- let b = acc.get(k);
1541
- if (!b) {
1542
- b = {
1543
- date: s.date,
1544
- model: s.model,
1545
- inputTokens: 0,
1546
- outputTokens: 0,
1547
- cacheCreationTokens: 0,
1548
- cacheReadTokens: 0,
1549
- requestCount: 0
1550
- };
1551
- acc.set(k, b);
1602
+ for (const s of parseCodexRollout(lines)) {
1603
+ const k = `${s.date}|${s.model}`;
1604
+ let b = acc.get(k);
1605
+ if (!b) {
1606
+ b = {
1607
+ date: s.date,
1608
+ model: s.model,
1609
+ inputTokens: 0,
1610
+ outputTokens: 0,
1611
+ cacheCreationTokens: 0,
1612
+ cacheReadTokens: 0,
1613
+ requestCount: 0
1614
+ };
1615
+ acc.set(k, b);
1616
+ }
1617
+ b.inputTokens += s.inputTokens;
1618
+ b.outputTokens += s.outputTokens;
1619
+ b.cacheCreationTokens += s.cacheCreationTokens;
1620
+ b.cacheReadTokens += s.cacheReadTokens;
1621
+ b.requestCount += s.turnCount;
1552
1622
  }
1553
- b.inputTokens += s.inputTokens;
1554
- b.outputTokens += s.outputTokens;
1555
- b.cacheCreationTokens += s.cacheCreationTokens;
1556
- b.cacheReadTokens += s.cacheReadTokens;
1557
- b.requestCount += s.turnCount;
1558
1623
  }
1559
1624
  function finalizeCodexEntries(acc) {
1560
1625
  const entries = [];
@@ -6241,6 +6306,32 @@ var AnonSubmitPayload = SubmitPayload.extend({
6241
6306
  /** Client-generated secret (hex). The server stores only its hash. */
6242
6307
  anonKey: external_exports.string().min(16).max(128)
6243
6308
  });
6309
+ var VerifyRequestRecord = external_exports.object({
6310
+ /** Local calendar date (YYYY-MM-DD) this request is bucketed under. */
6311
+ date: DateString,
6312
+ /** Epoch milliseconds of the request's final (max-token) transcript line. */
6313
+ ts: external_exports.number().int().nonnegative(),
6314
+ tool: external_exports.string().min(1).max(64),
6315
+ model: external_exports.string().min(1).max(128),
6316
+ inputTokens: tokenCount,
6317
+ outputTokens: tokenCount,
6318
+ cacheCreationTokens: tokenCount,
6319
+ cacheReadTokens: tokenCount,
6320
+ /** sha256(msgId|reqId), truncated — preserves uniqueness without revealing the id. */
6321
+ reqHash: external_exports.string().min(1).max(64)
6322
+ });
6323
+ var VerifyPayload = external_exports.object({
6324
+ cliVersion: external_exports.string().min(1).max(32),
6325
+ /** The per-request skeleton (bounded/sampled — see `truncated`). */
6326
+ requests: external_exports.array(VerifyRequestRecord).min(1).max(1e5),
6327
+ /**
6328
+ * True when the local corpus exceeded the client upload cap and was sampled to
6329
+ * the most recent N requests. The server treats a truncated upload conservatively
6330
+ * (it never AUTO-passes a truncated one — it can still auto-FAIL on a physical
6331
+ * impossibility, or route to a human).
6332
+ */
6333
+ truncated: external_exports.boolean().optional()
6334
+ });
6244
6335
  function entryTotalTokens(e) {
6245
6336
  return e.inputTokens + e.outputTokens + e.cacheCreationTokens + e.cacheReadTokens;
6246
6337
  }
@@ -6359,20 +6450,24 @@ function formatUSD(n) {
6359
6450
  function sanitizeServerText(s) {
6360
6451
  return s.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
6361
6452
  }
6362
- function submitNextStepLines(result) {
6453
+ function signedInNextStepLines(result) {
6454
+ if (result.orgBoardUrl) {
6455
+ return [
6456
+ ` \u{1F3E2} You're on your team board: ${sanitizeServerText(result.orgBoardUrl)}`,
6457
+ " \u2192 Open it to see who burned more."
6458
+ ];
6459
+ }
6363
6460
  if (result.boardUrl) {
6364
- const code = result.boardCode ?? result.boardUrl.split("/").filter(Boolean).pop() ?? "";
6461
+ const code = result.boardUrl.split("/").filter(Boolean).pop() ?? "";
6365
6462
  return [
6366
6463
  ` \u{1F91D} You're on the board: ${sanitizeServerText(result.boardUrl)}`,
6367
6464
  " \u2192 Open it to see who burned more.",
6368
- ` \u2192 Get a friend on it \u2014 have them run: npx whoburnedmore --board=${sanitizeServerText(code)}`,
6369
- " \u2192 Sign in on the page and add your X to claim your spot and own your rank."
6465
+ ` \u2192 Get a friend on it \u2014 have them run: npx whoburnedmore --board=${sanitizeServerText(code)}`
6370
6466
  ];
6371
6467
  }
6372
6468
  return [
6373
- ` Your dashboard: ${sanitizeServerText(result.dashboardUrl)}`,
6374
- " \u2192 Sign in and add your X on the page to get on the leaderboard and claim your rank.",
6375
- " Private until you do. Manage anytime: `npx whoburnedmore private` \xB7 `public` \xB7 `remove`."
6469
+ ` Your dashboard: ${sanitizeServerText(result.profileUrl)}`,
6470
+ " \u2192 Open it to see your rank and share your profile."
6376
6471
  ];
6377
6472
  }
6378
6473
 
@@ -6752,56 +6847,139 @@ async function run(flags) {
6752
6847
  console.log(pc2.dim(" --no-submit: skipped the dashboard."));
6753
6848
  return;
6754
6849
  }
6755
- const anonKey = ensureAnonKey();
6756
- const result = await anonSubmit(anonKey, payload);
6850
+ const cfg = loadConfig();
6851
+ const canSignIn = !flags.quiet && Boolean(process.stdout.isTTY);
6852
+ if (cfg?.cliToken) {
6853
+ await submitSignedIn(cfg.cliToken, payload, flags, canSignIn);
6854
+ } else if (canSignIn) {
6855
+ const auth = await ensureSignedIn();
6856
+ if (!auth) return;
6857
+ await submitSignedIn(auth.token, payload, flags, canSignIn);
6858
+ } else if (!flags.quiet) {
6859
+ console.log(pc2.yellow(" Sign in to put your usage on the leaderboard."));
6860
+ console.log(
6861
+ pc2.dim(
6862
+ " Run `npx whoburnedmore` in an interactive terminal to sign in, or `npx whoburnedmore link --token=\u2026` (from your signed-in profile) for servers/CI."
6863
+ )
6864
+ );
6865
+ } else {
6866
+ return;
6867
+ }
6868
+ }
6869
+ function sleep(ms) {
6870
+ return new Promise((resolve) => setTimeout(resolve, ms));
6871
+ }
6872
+ async function ensureSignedIn() {
6873
+ const cfg = loadConfig();
6874
+ if (cfg?.cliToken) return { token: cfg.cliToken, handle: cfg.handle ?? "" };
6875
+ let dev;
6757
6876
  try {
6758
- recordSync();
6759
- } catch {
6877
+ dev = await deviceStart();
6878
+ } catch (err) {
6879
+ console.log(pc2.yellow(` Couldn't start sign-in: ${err.message}`));
6880
+ return null;
6760
6881
  }
6882
+ console.log();
6883
+ console.log(pc2.bold(" Sign in to whoburnedmore to put your usage on the board."));
6884
+ console.log(` 1. Opening ${pc2.cyan(sanitizeServerText(dev.verifyUrl))} \u2014 or go there yourself.`);
6885
+ console.log(` 2. Approve this code: ${pc2.bold(sanitizeServerText(dev.userCode))}`);
6886
+ if (isTrustedWebUrl(dev.verifyUrl)) openBrowser(dev.verifyUrl);
6887
+ console.log(pc2.dim(" Waiting for you to approve in the browser\u2026"));
6888
+ const deadline = Date.now() + dev.expiresInSeconds * 1e3;
6889
+ const intervalMs = Math.max(1, dev.pollIntervalSeconds) * 1e3;
6890
+ while (Date.now() < deadline) {
6891
+ await sleep(intervalMs);
6892
+ let res;
6893
+ try {
6894
+ res = await devicePoll(dev.deviceCode);
6895
+ } catch {
6896
+ continue;
6897
+ }
6898
+ if (res.status === "ok") {
6899
+ saveAuth(void 0, { cliToken: res.token, handle: res.handle });
6900
+ console.log(pc2.green(` \u2713 Signed in as @${sanitizeServerText(res.handle)}.`));
6901
+ return { token: res.token, handle: res.handle };
6902
+ }
6903
+ if (res.status === "expired") break;
6904
+ }
6905
+ console.log(
6906
+ pc2.yellow(" Sign-in timed out. Run `npx whoburnedmore` again to retry.")
6907
+ );
6908
+ return null;
6909
+ }
6910
+ async function submitSignedIn(token, payload, flags, interactive) {
6911
+ let result;
6761
6912
  try {
6762
- const config = loadConfig();
6763
- if (result.launch?.live && !config?.launchNotificationDeliveredAt) {
6764
- if (notifyLaunchLive()) {
6765
- recordLaunchNotificationDelivered();
6766
- }
6913
+ result = await submit(token, payload);
6914
+ } catch (err) {
6915
+ if (err instanceof UnauthorizedError) {
6916
+ clearAuth();
6917
+ if (!interactive) return;
6918
+ const auth = await ensureSignedIn();
6919
+ if (!auth) return;
6920
+ result = await submit(auth.token, payload);
6921
+ } else {
6922
+ throw err;
6767
6923
  }
6924
+ }
6925
+ try {
6926
+ recordSync();
6768
6927
  } catch {
6769
6928
  }
6770
- const { baseUrl, target } = resolveOpenTarget(result, anonKey);
6771
- const trusted = isTrustedWebUrl(baseUrl);
6929
+ const baseUrl = result.orgBoardUrl ?? result.boardUrl ?? result.profileUrl;
6772
6930
  if (!flags.quiet) {
6773
6931
  console.log(
6774
6932
  pc2.green(" \u2713 Synced securely.") + pc2.dim(" Only your daily totals left this machine \u2014 never your prompts, code, or file names.")
6775
6933
  );
6776
- if (trusted) {
6934
+ if (result.suppressed) {
6935
+ console.log();
6936
+ console.log(
6937
+ pc2.yellow(
6938
+ " \u26A0 You're currently OFF the leaderboard \u2014 we couldn't verify these numbers."
6939
+ )
6940
+ );
6941
+ console.log(
6942
+ pc2.dim(
6943
+ " Verify your usage to get back on \u2014 this sends a detailed per-request breakdown (timestamps + token counts, never your prompts or code)."
6944
+ )
6945
+ );
6946
+ if (process.stdin.isTTY && await confirm(" Verify now?")) {
6947
+ await runVerify();
6948
+ afterSubmitChores(flags);
6949
+ return;
6950
+ }
6951
+ console.log(
6952
+ pc2.dim(
6953
+ " Run `npx whoburnedmore verify` anytime, or appeal at whoburnedmore.com/appeal."
6954
+ )
6955
+ );
6956
+ }
6957
+ if (isTrustedWebUrl(baseUrl)) {
6777
6958
  console.log(pc2.dim(" Opening your dashboard in your browser\u2026"));
6778
- openBrowser(target);
6959
+ openBrowser(baseUrl);
6779
6960
  } else {
6780
6961
  console.log(
6781
6962
  pc2.dim(" The server returned an unexpected dashboard address, so it was NOT auto-opened. Open it yourself only if you trust it:")
6782
6963
  );
6783
6964
  console.log(` ${sanitizeServerText(baseUrl)}`);
6784
6965
  }
6785
- }
6786
- const lines = submitNextStepLines(result);
6787
- for (const line of lines) {
6788
- if (line.includes("\u2192")) console.log(pc2.bold(line));
6789
- else if (line.startsWith(" Private until you do")) {
6790
- if (!flags.quiet) console.log(pc2.dim(line));
6791
- } else console.log(line);
6792
- }
6793
- if (!flags.quiet) {
6794
- try {
6795
- reconcileAutoSync();
6796
- } catch {
6966
+ for (const line of signedInNextStepLines(result)) {
6967
+ if (line.includes("\u2192")) console.log(pc2.bold(line));
6968
+ else console.log(line);
6797
6969
  }
6798
6970
  }
6799
- if (!flags.quiet) {
6800
- console.log();
6801
- console.log(
6802
- autoSyncInstalled() ? pc2.dim(" Background sync is on \u2014 your page updates automatically every 15 min (`npx whoburnedmore uninstall-sync` to stop).") : pc2.dim(" Re-run anytime to update your page.")
6803
- );
6971
+ afterSubmitChores(flags);
6972
+ }
6973
+ function afterSubmitChores(flags) {
6974
+ if (flags.quiet) return;
6975
+ try {
6976
+ reconcileAutoSync();
6977
+ } catch {
6804
6978
  }
6979
+ console.log();
6980
+ console.log(
6981
+ autoSyncInstalled() ? pc2.dim(" Background sync is on \u2014 your page updates automatically every 15 min (`npx whoburnedmore uninstall-sync` to stop).") : pc2.dim(" Re-run anytime to update your page.")
6982
+ );
6805
6983
  }
6806
6984
  async function linkServerInstall(token) {
6807
6985
  if (!token) {
@@ -6809,6 +6987,9 @@ async function linkServerInstall(token) {
6809
6987
  }
6810
6988
  const anonKey = ensureAnonKey();
6811
6989
  const linked = await redeemServerInstall(token, anonKey);
6990
+ if (linked.cliToken) {
6991
+ saveAuth(void 0, { cliToken: linked.cliToken, handle: linked.handle });
6992
+ }
6812
6993
  const handle = sanitizeServerText(linked.handle);
6813
6994
  console.log(
6814
6995
  linked.alreadyLinked ? ` This machine is already linked to @${handle}.` : ` Linked this machine to @${handle}.`
@@ -6883,6 +7064,102 @@ async function runDaemon() {
6883
7064
  console.log();
6884
7065
  console.log(pc2.dim(` Daemon stopped after ${cycles} sync cycle${cycles === 1 ? "" : "s"}.`));
6885
7066
  }
7067
+ async function runVerify() {
7068
+ printBanner();
7069
+ console.log();
7070
+ console.log(pc2.bold(" Verify your usage to get back on the leaderboard."));
7071
+ console.log(
7072
+ pc2.dim(
7073
+ " This sends a DETAILED per-request breakdown \u2014 timestamps and token counts, still never your prompts, code, or file names \u2014 so we can confirm your usage is real. It's deleted after review."
7074
+ )
7075
+ );
7076
+ const cfg = loadConfig();
7077
+ let token = cfg?.cliToken;
7078
+ if (!token) {
7079
+ if (!process.stdout.isTTY) {
7080
+ console.log(
7081
+ pc2.yellow(
7082
+ " Sign in first \u2014 run `npx whoburnedmore verify` in an interactive terminal."
7083
+ )
7084
+ );
7085
+ return;
7086
+ }
7087
+ const auth = await ensureSignedIn();
7088
+ if (!auth) return;
7089
+ token = auth.token;
7090
+ }
7091
+ if (process.stdin.isTTY) {
7092
+ const ok = await confirm("\n Send this breakdown to verify your usage?");
7093
+ if (!ok) {
7094
+ console.log(pc2.dim(" Cancelled \u2014 nothing was sent."));
7095
+ return;
7096
+ }
7097
+ }
7098
+ console.log(pc2.dim(" Reading your local Claude Code logs\u2026"));
7099
+ const { requests, found } = await collectClaudeRequests();
7100
+ if (!found || requests.length === 0) {
7101
+ console.log(
7102
+ pc2.yellow(" No local Claude Code logs found on this machine to verify.")
7103
+ );
7104
+ console.log(
7105
+ pc2.dim(
7106
+ " Run this on the machine where you actually use Claude Code, or file a written appeal at whoburnedmore.com/appeal."
7107
+ )
7108
+ );
7109
+ return;
7110
+ }
7111
+ const CAP = 5e4;
7112
+ const sorted = requests.slice().sort((a, b) => b.ts - a.ts);
7113
+ const truncated = sorted.length > CAP;
7114
+ const capped = truncated ? sorted.slice(0, CAP) : sorted;
7115
+ const records = capped.map((r) => ({
7116
+ date: r.date,
7117
+ ts: r.ts,
7118
+ tool: "claude",
7119
+ model: r.model,
7120
+ inputTokens: r.inputTokens,
7121
+ outputTokens: r.outputTokens,
7122
+ cacheCreationTokens: r.cacheCreationTokens,
7123
+ cacheReadTokens: r.cacheReadTokens,
7124
+ reqHash: createHash("sha256").update(r.key).digest("hex").slice(0, 32)
7125
+ }));
7126
+ console.log(
7127
+ pc2.dim(
7128
+ ` Sending ${records.length} request records for analysis${truncated ? " (most recent, sampled)" : ""}\u2026`
7129
+ )
7130
+ );
7131
+ let result;
7132
+ try {
7133
+ result = await verifyUsage(token, {
7134
+ cliVersion: VERSION,
7135
+ requests: records,
7136
+ ...truncated ? { truncated: true } : {}
7137
+ });
7138
+ } catch (err) {
7139
+ if (err instanceof UnauthorizedError) {
7140
+ clearAuth();
7141
+ console.log(
7142
+ pc2.yellow(
7143
+ " Your sign-in expired \u2014 run `npx whoburnedmore verify` again to retry."
7144
+ )
7145
+ );
7146
+ return;
7147
+ }
7148
+ throw err;
7149
+ }
7150
+ console.log();
7151
+ const line = ` ${sanitizeServerText(result.message)}`;
7152
+ if (result.relisted) {
7153
+ console.log(pc2.green(` \u2713${line.slice(1)}`));
7154
+ } else {
7155
+ console.log(result.verdict === "fail" ? pc2.yellow(line) : line);
7156
+ console.log(
7157
+ pc2.dim(
7158
+ " We'll relist you automatically if it checks out \u2014 re-run `npx whoburnedmore` anytime to check."
7159
+ )
7160
+ );
7161
+ }
7162
+ }
6886
7163
  async function main() {
6887
7164
  const major = Number(process.versions.node.split(".")[0]);
6888
7165
  if (major < 20) {
@@ -6918,6 +7195,9 @@ async function main() {
6918
7195
  case "daemon":
6919
7196
  await runDaemon();
6920
7197
  break;
7198
+ case "verify":
7199
+ await runVerify();
7200
+ break;
6921
7201
  case "status":
6922
7202
  case "doctor": {
6923
7203
  for (const line of agentStatusReport()) console.log(line);
@@ -6969,8 +7249,8 @@ function printHelp() {
6969
7249
  ${pc2.bold("whoburnedmore")} \u2014 who burned more tokens, you or them?
6970
7250
 
6971
7251
  ${pc2.bold("usage")}
6972
- npx whoburnedmore burn + land on the public leaderboard, open your dashboard
6973
- npx whoburnedmore --board=CODE compare with friends \u2014 join their board (no sign-in)
7252
+ npx whoburnedmore sign in, burn + land on the public leaderboard, open your dashboard
7253
+ npx whoburnedmore --board=CODE compare with friends \u2014 sign in and join their board
6974
7254
  npx whoburnedmore --org=SLUG submit to your organization's board (companies/hackathons)
6975
7255
  npx whoburnedmore --local build the dashboard on your machine and open it (offline)
6976
7256
  npx whoburnedmore --dry-run print exactly what would be sent, send nothing
@@ -6980,14 +7260,15 @@ function printHelp() {
6980
7260
  npx whoburnedmore private hide your dashboard from the leaderboard
6981
7261
  npx whoburnedmore public put it back on the leaderboard
6982
7262
  npx whoburnedmore remove delete your dashboard and its data
7263
+ npx whoburnedmore verify delisted? re-verify your usage to get back on (sends a detailed breakdown)
6983
7264
  npx whoburnedmore status check background-sync health (last sync, staleness)
6984
7265
  npx whoburnedmore uninstall-sync turn off the background sync
6985
7266
  npx whoburnedmore install-sync turn it back on after uninstalling
6986
7267
 
6987
- Background sync is on by default: after your first run, your page refreshes
6988
- automatically every 15 min (\`uninstall-sync\` to stop). Your dashboard is public on
6989
- the leaderboard as an anonymous burner \u2014 sign in on whoburnedmore.com to claim
6990
- it (handle + X) and own your rank, or run \`private\`/\`remove\` to pull it. Only
7268
+ Your first run signs you in (we open a page, you approve a short code) and binds
7269
+ this machine to your account \u2014 your usage lands on the leaderboard under your
7270
+ handle. Background sync is on by default: your page then refreshes automatically
7271
+ every 15 min (\`uninstall-sync\` to stop); run \`private\`/\`remove\` to pull it. Only
6991
7272
  daily aggregate numbers (date, tool, model, token counts, est. cost) ever leave
6992
7273
  your machine \u2014 never prompts, code, or file names. With --local, nothing leaves
6993
7274
  your machine at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whoburnedmore",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "description": "Find out who burned more — submit your AI coding-agent token usage to the public leaderboard at whoburnedmore.com",
5
5
  "type": "module",
6
6
  "bin": {