claude-rpc 0.15.6 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,6 +109,8 @@ A card that updates as you work. The large image swaps between five states (work
109
109
 
110
110
  A *View on GitHub →* button appears automatically when your cwd is a git repo with a github origin. The daemon checks `.git/config` directly — no shell-out, no surprise GH API call.
111
111
 
112
+ A rotation frame can show your **subscription usage** — `Usage · 34% weekly` — the exact numbers Claude Code's own `/usage` screen shows. The daemon asks Anthropic's usage endpoint with the OAuth token Claude Code already stores locally; the token goes only to its issuer, the percentages go only where you template them, and `usage.enabled: false` turns the whole thing off ([`SECURITY.md` §3d](SECURITY.md)). `claude-rpc usage` prints the same data as heat-graded bars in your terminal.
113
+
112
114
  ### on your machine
113
115
 
114
116
  Three local surfaces, all reading the same `~/.claude-rpc/aggregate.json`:
@@ -269,6 +271,7 @@ The full default config is in [`src/default-config.js`](src/default-config.js)
269
271
  | `start` / `stop` / `restart` | Lifecycle for the detached daemon |
270
272
  | `status` | Interactive TUI — heatmap, hour histogram, leaderboards (`--dump` for plain output) |
271
273
  | `today` / `week` | Focused views (today's stats, weekday breakdown) |
274
+ | `usage` | Subscription limits — session + weekly %, the same numbers `/usage` shows |
272
275
  | `serve` | Open the local web dashboard (port 47474) |
273
276
  | `preview` | Render every rotation frame against real state |
274
277
  | `scan` / `rescan`| Incremental / forced re-parse of `~/.claude/projects` |
package/SECURITY.md CHANGED
@@ -78,7 +78,9 @@ events: `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
78
78
 
79
79
  ## 3. Outbound network
80
80
 
81
- There are three distinct network behaviors. Two are optional; one is cosmetic.
81
+ There are five distinct network behaviors: community totals (3a), gist
82
+ publishing (3b), squads/web login (3c), subscription-usage polling (3d), and
83
+ the cosmetic GIF assets (3e). Each is independently optional.
82
84
 
83
85
  ### 3a. Community totals (telemetry) — ON by default for fresh installs
84
86
 
@@ -142,7 +144,36 @@ Worker-side storage adds: `gh:<login>` → profile link, `squad:*` membership
142
144
  records, and weekly baseline snapshots (auto-expiring). Leaving your last
143
145
  squad deletes its record.
144
146
 
145
- ### 3d. Presence GIF assets Discord-side only
147
+ ### 3d. Subscription usageyour own token, to its issuer, ON by default
148
+
149
+ **Source:** `src/usage.js`; consumed by the daemon poll, `claude-rpc usage`,
150
+ and the `{usageWeeklyPct}`-family template variables.
151
+
152
+ The daemon reads the OAuth access token Claude Code already stores on your
153
+ machine (`~/.claude/.credentials.json`; the login keychain on macOS) and calls
154
+ `GET https://api.anthropic.com/api/oauth/usage` — the same internal endpoint
155
+ Claude Code's own `/usage` screen uses — every 10 minutes **while a session is
156
+ live**. The response (session %, weekly %, reset times) is cached in
157
+ `$TMPDIR/claude-rpc/usage.json`.
158
+
159
+ The trust boundary is deliberately narrow:
160
+
161
+ - The token is sent **only to `api.anthropic.com` — the party that issued
162
+ it**. It is never logged, never written anywhere new, and never sent to the
163
+ claude-rpc worker or any other host. Only the daemon and the one-shot
164
+ `claude-rpc usage` command touch credentials; every other surface reads the
165
+ percentage cache.
166
+ - **Read-only:** the refresh token is never used or modified. If the access
167
+ token expires, polling goes quiet until Claude Code itself refreshes it.
168
+ - The percentages stay local unless **you** template them into your Discord
169
+ card (a default rotation frame does, and disappears whenever data is
170
+ missing or stale).
171
+ - Installs without OAuth credentials (API key, enterprise gateways) are
172
+ silently skipped — there is nothing to fetch.
173
+ - **Off switch:** `usage.enabled: false` in `config.json` stops the polling,
174
+ the command's live fetch, and the card frame in one go.
175
+
176
+ ### 3e. Presence GIF assets — Discord-side only
146
177
 
147
178
  `default-config.js` references `https://cdn.qualit.ly/clawd-*.gif`. These URLs
148
179
  are handed to Discord as image keys; **Discord's** client fetches them to render
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.15.6",
3
+ "version": "0.16.0",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -23,6 +23,7 @@ import { badgeSvg } from './badge.js';
23
23
  import { fmtCost } from './pricing.js';
24
24
  import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
25
25
  import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
26
+ import { readUsageCache, fetchUsage, writeUsageCache, fmtResetTime, fmtResetDay } from './usage.js';
26
27
  import { loadConfig, hasUserConfig } from './config.js';
27
28
  import * as lb from './leaderboard.js';
28
29
  import { VERSION } from './version.js';
@@ -316,6 +317,7 @@ function showStatus() {
316
317
  const config = loadConfig();
317
318
  const live = findLiveSessions({ thresholdMs: 90_000 });
318
319
  state.liveSessions = live;
320
+ state.usage = readUsageCache();
319
321
  const vars = buildVars(state, config, aggregate);
320
322
  const pid = daemonPid();
321
323
 
@@ -357,6 +359,11 @@ function showStatus() {
357
359
  box('today', todayBoxLines(vars, aggregate));
358
360
  console.log('');
359
361
 
362
+ if (state.usage) {
363
+ box('claude usage', usageBoxLines(state.usage));
364
+ console.log('');
365
+ }
366
+
360
367
  box('streak', [
361
368
  pair('current', `${c.bold}${c.magenta}${vars.streak}${c.reset} ${c.dim}days${c.reset}`, ''),
362
369
  pair('longest', `${vars.longestStreak} ${c.dim}days${c.reset}`, c.cyan),
@@ -581,6 +588,54 @@ function showWeek() {
581
588
  }
582
589
  }
583
590
 
591
+ // Rows for the subscription-usage box (shared by `status` and `usage`).
592
+ // Bars ride the heat ramp — the % IS the intensity. Absent buckets (per-model
593
+ // outside Max plans) drop out.
594
+ function usageBoxLines(u) {
595
+ const row = (label, pct, resets) => pct == null ? null : pair(
596
+ label,
597
+ `${bar(pct, 100, 18)} ${c.bold}${String(pct).padStart(3)}%${c.reset}${resets ? ` ${c.dim}resets ${resets}${c.reset}` : ''}`,
598
+ '',
599
+ );
600
+ return [
601
+ row('session (5h)', u.sessionPct, fmtResetTime(u.sessionResetsAt)),
602
+ row('week', u.weeklyPct, fmtResetDay(u.weeklyResetsAt)),
603
+ row(' sonnet', u.weeklySonnetPct, null),
604
+ row(' opus', u.weeklyOpusPct, null),
605
+ ].filter(Boolean);
606
+ }
607
+
608
+ // `claude-rpc usage` — subscription limits, the same numbers Claude Code's
609
+ // /usage screen shows. Prefers the daemon's cache; falls back to a one-shot
610
+ // live fetch so it works with the daemon stopped.
611
+ async function showUsage() {
612
+ let u = readUsageCache();
613
+ let src = 'cached by the daemon';
614
+ if (!u) {
615
+ const r = await fetchUsage();
616
+ if (!r.ok) {
617
+ const hints = {
618
+ 'no-credentials': 'claude-rpc reads Claude Code\'s own OAuth credentials (~/.claude) — API-key installs have no subscription limits to show',
619
+ 'token-expired': 'Claude Code\'s token has expired — open Claude Code once; it refreshes itself',
620
+ unauthorized: 'the token was rejected — open Claude Code once; it refreshes itself',
621
+ };
622
+ return fail(`could not fetch usage: ${r.reason}`,
623
+ { hint: hints[r.reason] || 'check your network and try again', code: EX_SYS_ERROR });
624
+ }
625
+ u = r.usage;
626
+ src = 'live';
627
+ writeUsageCache(u); // so preview/status/frames see it without the daemon
628
+ }
629
+ const plan = u.plan ? ` · Claude ${u.plan.charAt(0).toUpperCase()}${u.plan.slice(1)}` : '';
630
+ console.log('');
631
+ console.log(` ${c.bold}${c.magenta}◆ usage${c.reset} ${c.dim}— subscription limits${plan} (what /usage shows)${c.reset}`);
632
+ console.log('');
633
+ box('usage', usageBoxLines(u));
634
+ console.log('');
635
+ console.log(` ${c.dim}${src} · the daemon polls every ${loadConfig().usage?.pollIntervalMin || 10} min while you code · off: usage.enabled false${c.reset}`);
636
+ console.log('');
637
+ }
638
+
584
639
  function statusColor(status) {
585
640
  switch (status) {
586
641
  case 'working': return c.green;
@@ -598,6 +653,7 @@ function showPreview() {
598
653
  const live = findLiveSessions({ thresholdMs: 90_000 });
599
654
  state.liveSessions = live;
600
655
  state = applyIdle(state, config);
656
+ state.usage = readUsageCache();
601
657
  const vars = buildVars(state, config, aggregate);
602
658
  const p = config.presence || {};
603
659
  const frames = (Array.isArray(p.rotation) ? p.rotation : [{ details: p.details, state: p.state }]);
@@ -637,6 +693,7 @@ function dumpVars() {
637
693
  const config = loadConfig();
638
694
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
639
695
  state = applyIdle(state, config);
696
+ state.usage = readUsageCache();
640
697
  const live = buildVars(state, config, readAggregate() || {});
641
698
  process.stdout.write(JSON.stringify({ vars: Object.keys(live).sort(), live }));
642
699
  }
@@ -863,6 +920,7 @@ function liveVars() {
863
920
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
864
921
  const config = loadConfig();
865
922
  const resolved = applyIdle(state, config);
923
+ resolved.usage = readUsageCache();
866
924
  return { vars: buildVars(resolved, config, readAggregate()), config };
867
925
  }
868
926
 
@@ -1713,6 +1771,7 @@ function overview() {
1713
1771
  const state = readState();
1714
1772
  const aggregate = readAggregate();
1715
1773
  state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
1774
+ state.usage = readUsageCache();
1716
1775
  const vars = buildVars(state, cfg, aggregate);
1717
1776
  const dot = pid
1718
1777
  ? `${c.green}●${c.reset} running ${c.dim}pid ${pid}${c.reset}`
@@ -1768,6 +1827,7 @@ function help() {
1768
1827
  ['status', 'Print current session + all-time stats'],
1769
1828
  ['today', 'Focus view: today\'s stats + 24h activity histogram'],
1770
1829
  ['week', 'Focus view: this week, daily breakdown'],
1830
+ ['usage', 'Subscription limits — session + weekly % (what /usage shows)'],
1771
1831
  ['serve', 'Open a live web dashboard in your browser'],
1772
1832
  ['preview', 'Show how each rotation frame renders right now'],
1773
1833
  ['scan', 'Incrementally scan ~/.claude/projects for all-time totals'],
@@ -1905,6 +1965,7 @@ const packagedDefault = IS_PACKAGED && !cmd;
1905
1965
  case 'dump': showStatus(); break;
1906
1966
  case 'today': showToday(); break;
1907
1967
  case 'week': showWeek(); break;
1968
+ case 'usage': await showUsage(); break;
1908
1969
  case 'serve': await import('./server/index.js'); break;
1909
1970
  case 'preview': showPreview(); break;
1910
1971
  case 'vars': dumpVars(); break;
package/src/daemon.js CHANGED
@@ -13,6 +13,7 @@ import { migrateConfig } from './install.js';
13
13
  import { desktopNotify, postWebhook, shouldWebhook, shouldNotify } from './notify.js';
14
14
  import { humanProject } from './format.js';
15
15
  import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
16
+ import { readUsageCache, pollUsage } from './usage.js';
16
17
 
17
18
  if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
18
19
 
@@ -184,6 +185,9 @@ function buildActivity(opts = {}) {
184
185
  // pushPresence). Fall back to resolving here for any standalone caller.
185
186
  const state = opts.resolved || resolvePresence(opts);
186
187
 
188
+ // Subscription usage rides in like liveSessions: injected onto state so
189
+ // buildVars stays pure. Stale/missing cache → null → usage frames vanish.
190
+ state.usage = readUsageCache();
187
191
  const vars = buildVars(state, config, opts.aggregate || aggregate);
188
192
  const p = config.presence || {};
189
193
 
@@ -643,6 +647,40 @@ async function runCommunityFlush() {
643
647
  }
644
648
  const communityFlushMs = Math.max(60_000, (config.community?.flushIntervalMin || 30) * 60 * 1000);
645
649
  setInterval(runCommunityFlush, communityFlushMs);
650
+
651
+ // Subscription-usage poll (feeds {usageWeeklyPct} & friends + `claude-rpc
652
+ // usage`). Only while something is live — no point burning requests against
653
+ // an idle account — and backs off to hourly retries after a bad run, since
654
+ // the endpoint is internal and deserves politeness. pollUsage never throws.
655
+ let usageFailStreak = 0;
656
+ let usageSkipUntil = 0;
657
+ async function runUsagePoll() {
658
+ if (config.usage?.enabled === false) return;
659
+ if (Date.now() < usageSkipUntil) return;
660
+ try {
661
+ if (!findLiveSessions({ thresholdMs: 30 * 60_000 }).length) return;
662
+ } catch { /* detection hiccup — poll anyway */ }
663
+ try {
664
+ const r = await pollUsage(config);
665
+ if (r.ok) {
666
+ usageFailStreak = 0;
667
+ log(`usage: session ${r.usage.sessionPct}% · week ${r.usage.weeklyPct}%`);
668
+ } else if (r.reason !== 'disabled') {
669
+ usageFailStreak += 1;
670
+ log(`usage: ${r.reason}${r.error ? ' (' + r.error + ')' : ''}`);
671
+ if (usageFailStreak >= 3) {
672
+ usageSkipUntil = Date.now() + 60 * 60_000;
673
+ usageFailStreak = 0;
674
+ }
675
+ }
676
+ } catch (e) {
677
+ log('usage poll threw:', e.message);
678
+ }
679
+ }
680
+ const usagePollMs = Math.max(5 * 60_000, (config.usage?.pollIntervalMin || 10) * 60_000);
681
+ setInterval(runUsagePoll, usagePollMs);
682
+ // First poll shortly after startup so the frame doesn't wait a full interval.
683
+ setTimeout(runUsagePoll, 15_000);
646
684
  // Initial flush after a short delay — gives the scan above a chance to
647
685
  // build aggregate.json before we ask community.js to read it.
648
686
  setTimeout(runCommunityFlush, 60_000);
@@ -104,6 +104,18 @@ export const DEFAULT_CONFIG = {
104
104
  endpoint: "https://claude-rpc-totals.claude-rpc.workers.dev",
105
105
  flushIntervalMin: 30,
106
106
  },
107
+ // Subscription usage — the numbers Claude Code's own /usage screen shows
108
+ // (5h session %, weekly %). The daemon reads Claude Code's OAuth token
109
+ // LOCALLY and asks api.anthropic.com — the token's issuer — for the
110
+ // utilization; the token and the percentages are never sent anywhere else
111
+ // and the leaderboard never sees them (SECURITY.md §3d). Feeds the
112
+ // {usageWeeklyPct}-family template vars and `claude-rpc usage`. Installs
113
+ // without OAuth credentials (API key / enterprise) simply get no data.
114
+ // Kill switch: `usage.enabled: false`.
115
+ usage: {
116
+ enabled: true,
117
+ pollIntervalMin: 10,
118
+ },
107
119
  // Public leaderboard / profile (opt-in, off by default). When enabled with a
108
120
  // handle, the daemon flush also publishes your display identity + validated
109
121
  // usage deltas to the board. Link a GitHub user to earn the verified ✓.
@@ -144,6 +156,9 @@ export const DEFAULT_CONFIG = {
144
156
  // Pops in for ~5min when the session crosses an hour milestone, then
145
157
  // the `requires` gate drops it and we're back to the single frame.
146
158
  { details: "{sessionMilestoneLabel} · {project}", state: "{tokensLabel} · {messagesLabel}", requires: ["sessionMilestoneHit"] },
159
+ // Subscription usage — only renders while the daemon's usage poll
160
+ // is fresh (requires drops it otherwise; see the `usage` block).
161
+ { details: "Usage · {usageWeeklyPct}% weekly", state: "{usageStateLabel}", requires: ["usageWeeklyPct", "usageStateLabel"] },
147
162
  ],
148
163
  },
149
164
  thinking: {
@@ -182,6 +197,7 @@ export const DEFAULT_CONFIG = {
182
197
  { details: "{allFreshTokensFmt} fresh tokens", state: "{allCachePctLabel}", requires: ["allCachePctLabel"] },
183
198
  { details: "Code churn · {linesAddedFmt} added", state: "{linesNetFmt} net · {topLanguage}", requires: ["topLanguage"] },
184
199
  { details: "Cost · {todayCostFmt} today", state: "{allCostFmt} all-time", requires: ["allCost"] },
200
+ { details: "Usage · {usageWeeklyPct}% weekly", state: "{usageStateLabel}", requires: ["usageWeeklyPct", "usageStateLabel"] },
185
201
  { details: "Daily goal", state: "{goalLabel}", requires: ["goalLabel"] },
186
202
  { details: "Monthly budget", state: "{budgetLabel}", requires: ["budgetLabel"] },
187
203
  ],
package/src/doctor.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from './paths.js';
17
17
  import { findLiveSessions } from './scanner.js';
18
18
  import { resolveVisibility, listPrivateCwds } from './privacy.js';
19
+ import { readClaudeCredentials, readUsageCache } from './usage.js';
19
20
  import { c, check as uiCheck } from './ui.js';
20
21
 
21
22
  const counters = { pass: 0, fail: 0, warn: 0 };
@@ -349,6 +350,30 @@ function checkLiveSessions() {
349
350
  }
350
351
  }
351
352
 
353
+ // Subscription-usage polling (src/usage.js). Three healthy non-pass states
354
+ // exist — disabled, no OAuth credentials, daemon hasn't polled yet — so only
355
+ // the last is a warn; the others are informational.
356
+ function checkUsage(cfg) {
357
+ if (cfg?.usage?.enabled === false) {
358
+ check('usage polling', 'info', 'disabled in config (usage.enabled: false)');
359
+ return;
360
+ }
361
+ const creds = readClaudeCredentials();
362
+ if (!creds) {
363
+ check('usage polling', 'info',
364
+ 'no Claude Code OAuth credentials — subscription limits unavailable (API-key install?)');
365
+ return;
366
+ }
367
+ const u = readUsageCache();
368
+ if (u) {
369
+ check('usage polling', 'pass',
370
+ `week ${u.weeklyPct}% · session ${u.sessionPct}% · fetched ${Math.max(0, Math.round((Date.now() - u.fetchedAt) / 60_000))} min ago`);
371
+ } else {
372
+ check('usage polling', 'warn', 'no fresh usage data yet',
373
+ 'the daemon polls every 10 min while a session is live — or run `claude-rpc usage` for a live fetch');
374
+ }
375
+ }
376
+
352
377
  function checkDataDir() {
353
378
  if (!existsSync(USER_CONFIG_DIR)) {
354
379
  check('user config dir', 'warn', `${USER_CONFIG_DIR} missing`,
@@ -389,6 +414,7 @@ export function runDoctor() {
389
414
  section('Data');
390
415
  checkAggregate();
391
416
  checkLiveSessions();
417
+ checkUsage(cfg);
392
418
 
393
419
  section('Privacy');
394
420
  checkPrivacy(cfg);
package/src/format.js CHANGED
@@ -3,6 +3,7 @@ import { dayKey, weekKey, DATE_SUFFIX_RE, cleanProjectName } from './scanner.js'
3
3
  import { fmtCost } from './pricing.js';
4
4
  import { languageOf } from './languages.js';
5
5
  import { detectGitBranch, detectGitRepo } from './git.js';
6
+ import { fmtResetTime, fmtResetDay } from './usage.js';
6
7
 
7
8
  const WEEKDAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
8
9
 
@@ -438,6 +439,22 @@ export function buildVars(state, config, aggregate) {
438
439
  const filesRead = (state.filesRead || []).length;
439
440
  const filesOpened = (state.filesOpened || []).length;
440
441
 
442
+ // ── Subscription usage (state.usage, injected by the caller from
443
+ // readUsageCache like liveSessions is). All-empty when the daemon hasn't
444
+ // polled / polling is disabled / the cache went stale, so `requires`-gated
445
+ // usage frames simply vanish rather than rendering blanks.
446
+ const usage = state.usage || null;
447
+ const usageSessionPct = usage?.sessionPct ?? '';
448
+ const usageWeeklyPct = usage?.weeklyPct ?? '';
449
+ let usageStateLabel = '';
450
+ if (usage) {
451
+ const bits = [];
452
+ if (usage.sessionPct != null) bits.push(`session ${usage.sessionPct}%`);
453
+ const day = fmtResetDay(usage.weeklyResetsAt);
454
+ if (day) bits.push(`resets ${day}`);
455
+ usageStateLabel = bits.join(' · ');
456
+ }
457
+
441
458
  return {
442
459
  // session — raw
443
460
  status: state.status || 'idle',
@@ -471,6 +488,16 @@ export function buildVars(state, config, aggregate) {
471
488
  currentFile: state.currentFile || '',
472
489
  currentFilePretty,
473
490
 
491
+ // ── Subscription usage (v0.16) — what Claude Code's /usage shows ──
492
+ usageSessionPct,
493
+ usageWeeklyPct,
494
+ usageWeeklyOpusPct: usage?.weeklyOpusPct ?? '',
495
+ usageWeeklySonnetPct: usage?.weeklySonnetPct ?? '',
496
+ usageSessionResets: usage ? fmtResetTime(usage.sessionResetsAt) : '',
497
+ usageWeeklyResets: usage ? fmtResetDay(usage.weeklyResetsAt) : '',
498
+ usageStateLabel,
499
+ usagePlan: usage?.plan ? usage.plan.charAt(0).toUpperCase() + usage.plan.slice(1) : '',
500
+
474
501
  // ── File / directory / language (v0.3.6) ────────────────────
475
502
  fileName,
476
503
  fileExt,
package/src/paths.js CHANGED
@@ -73,6 +73,9 @@ export const LOG_PATH = join(STATE_DIR, 'daemon.log');
73
73
  // Presence snooze marker (`claude-rpc pause`). Lives in the tmp state dir on
74
74
  // purpose — a reboot clearing a forgotten pause is the right failure mode.
75
75
  export const PAUSE_PATH = join(STATE_DIR, 'pause.json');
76
+ // Subscription-usage cache (see src/usage.js). Volatile by design — stale
77
+ // percentages are worse than none, so a reboot clearing it is correct.
78
+ export const USAGE_CACHE_PATH = join(STATE_DIR, 'usage.json');
76
79
  export const DATA_DIR = join(homedir(), '.claude-rpc');
77
80
  export const AGGREGATE_PATH = join(DATA_DIR, 'aggregate.json');
78
81
  export const SCAN_CACHE_PATH = join(DATA_DIR, 'scan-cache.json');
package/src/usage.js ADDED
@@ -0,0 +1,151 @@
1
+ // Subscription usage — the exact numbers Claude Code's /usage screen shows
2
+ // (5-hour session window %, weekly %, per-model weekly buckets).
3
+ //
4
+ // Source: GET https://api.anthropic.com/api/oauth/usage, authenticated with
5
+ // the SAME OAuth access token Claude Code itself uses — read from
6
+ // ~/.claude/.credentials.json (Linux/Windows) or the login keychain (macOS).
7
+ // The token is sent ONLY to api.anthropic.com — its issuer — never logged,
8
+ // never stored anywhere else, never forwarded (SECURITY.md §3d). Strictly
9
+ // read-only: the refresh token is never touched (refresh rotation could
10
+ // corrupt Claude Code's own session), so an expired access token just means
11
+ // "no data until Claude Code next runs".
12
+ //
13
+ // Same contract as community.js: never throws — every failure resolves to
14
+ // { ok: false, reason }. The daemon polls while sessions are live and writes
15
+ // a small cache file; every other surface (CLI, TUI, dashboard, VS Code
16
+ // extension) reads the cache, never the credentials.
17
+
18
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
19
+ import { spawnSync } from 'node:child_process';
20
+ import { join, dirname } from 'node:path';
21
+ import { CLAUDE_HOME, USAGE_CACHE_PATH } from './paths.js';
22
+
23
+ const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
24
+ // The oauth API requires this beta header — the same one Claude Code sends.
25
+ const OAUTH_BETA = 'oauth-2025-04-20';
26
+ // A dead daemon must not pin hours-old percentages on the card: cache entries
27
+ // older than this read as "no data" and the usage frames/vars vanish.
28
+ export const USAGE_STALE_MS = 30 * 60 * 1000;
29
+
30
+ // Claude Code's credentials file: { claudeAiOauth: { accessToken, expiresAt,
31
+ // subscriptionType, ... } }. macOS may keep the same JSON in the keychain
32
+ // instead (item "Claude Code-credentials"); reading it can prompt once —
33
+ // degrade silently if denied.
34
+ export function readClaudeCredentials({ home = CLAUDE_HOME } = {}) {
35
+ try {
36
+ const p = join(home, '.credentials.json');
37
+ if (existsSync(p)) {
38
+ const o = JSON.parse(readFileSync(p, 'utf8'));
39
+ if (o?.claudeAiOauth?.accessToken) return o.claudeAiOauth;
40
+ }
41
+ } catch { /* unreadable → try the keychain below */ }
42
+ if (process.platform === 'darwin') {
43
+ try {
44
+ const r = spawnSync('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
45
+ { encoding: 'utf8', timeout: 4000 });
46
+ if (r.status === 0 && r.stdout) {
47
+ const o = JSON.parse(r.stdout.trim());
48
+ if (o?.claudeAiOauth?.accessToken) return o.claudeAiOauth;
49
+ }
50
+ } catch { /* keychain locked or denied → no credentials */ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ // Normalize the API response to the few fields we surface. Percentages round
56
+ // to integers; absent buckets (e.g. seven_day_opus outside Max plans) → null.
57
+ // Unknown fields are deliberately ignored — the endpoint is internal and
58
+ // carries experimental buckets that come and go between releases.
59
+ export function normalizeUsage(json) {
60
+ const bucket = (b) => (b && Number.isFinite(Number(b.utilization)))
61
+ ? { pct: Math.round(Number(b.utilization)), resetsAt: b.resets_at || null }
62
+ : null;
63
+ const session = bucket(json?.five_hour);
64
+ const week = bucket(json?.seven_day);
65
+ if (!session && !week) return null;
66
+ return {
67
+ sessionPct: session?.pct ?? null,
68
+ sessionResetsAt: session?.resetsAt ?? null,
69
+ weeklyPct: week?.pct ?? null,
70
+ weeklyResetsAt: week?.resetsAt ?? null,
71
+ weeklyOpusPct: bucket(json?.seven_day_opus)?.pct ?? null,
72
+ weeklySonnetPct: bucket(json?.seven_day_sonnet)?.pct ?? null,
73
+ };
74
+ }
75
+
76
+ export async function fetchUsage({ fetchImpl = globalThis.fetch, creds = readClaudeCredentials(), now = Date.now() } = {}) {
77
+ if (!creds?.accessToken) return { ok: false, reason: 'no-credentials' };
78
+ if (creds.expiresAt && now > creds.expiresAt) return { ok: false, reason: 'token-expired' };
79
+ let res;
80
+ try {
81
+ res = await fetchImpl(USAGE_ENDPOINT, {
82
+ headers: {
83
+ Authorization: `Bearer ${creds.accessToken}`,
84
+ 'anthropic-beta': OAUTH_BETA,
85
+ 'Content-Type': 'application/json',
86
+ },
87
+ signal: AbortSignal.timeout(10_000),
88
+ });
89
+ } catch (e) {
90
+ return { ok: false, reason: 'network', error: e.message };
91
+ }
92
+ if (res.status === 401 || res.status === 403) return { ok: false, reason: 'unauthorized' };
93
+ if (!res.ok) return { ok: false, reason: `http-${res.status}` };
94
+ let json;
95
+ try { json = await res.json(); } catch { return { ok: false, reason: 'bad-json' }; }
96
+ const usage = normalizeUsage(json);
97
+ if (!usage) return { ok: false, reason: 'no-buckets' };
98
+ usage.plan = typeof creds.subscriptionType === 'string' ? creds.subscriptionType : null;
99
+ usage.fetchedAt = now;
100
+ return { ok: true, usage };
101
+ }
102
+
103
+ export function writeUsageCache(usage, path = USAGE_CACHE_PATH) {
104
+ try {
105
+ mkdirSync(dirname(path), { recursive: true });
106
+ writeFileSync(path, JSON.stringify(usage, null, 2));
107
+ } catch { /* cache is best-effort — next poll retries */ }
108
+ }
109
+
110
+ // Fresh cache or null — staleness collapses to "no data" so consumers need
111
+ // no freshness logic of their own.
112
+ export function readUsageCache({ path = USAGE_CACHE_PATH, maxAgeMs = USAGE_STALE_MS, now = Date.now() } = {}) {
113
+ try {
114
+ const u = JSON.parse(readFileSync(path, 'utf8'));
115
+ if (!u?.fetchedAt || now - u.fetchedAt > maxAgeMs) return null;
116
+ return u;
117
+ } catch { return null; }
118
+ }
119
+
120
+ // Daemon-facing one-shot: config gate → fetch → cache. Never throws.
121
+ export async function pollUsage(config, { fetchImpl } = {}) {
122
+ if (config?.usage?.enabled === false) return { ok: false, reason: 'disabled' };
123
+ const r = await fetchUsage(fetchImpl ? { fetchImpl } : {});
124
+ if (r.ok) writeUsageCache(r.usage);
125
+ return r;
126
+ }
127
+
128
+ // ── Display formatting (shared by template vars and the CLI view) ────────
129
+
130
+ // "6pm" / "6:30pm" local — how the 5-hour window reset reads on a card.
131
+ export function fmtResetTime(iso) {
132
+ const d = new Date(iso || NaN);
133
+ if (isNaN(d)) return '';
134
+ let h = d.getHours();
135
+ const m = d.getMinutes();
136
+ const ap = h >= 12 ? 'pm' : 'am';
137
+ h = h % 12 || 12;
138
+ return m ? `${h}:${String(m).padStart(2, '0')}${ap}` : `${h}${ap}`;
139
+ }
140
+
141
+ // "today" / "tomorrow" / "Tue" — how the weekly reset reads.
142
+ export function fmtResetDay(iso, now = new Date()) {
143
+ const d = new Date(iso || NaN);
144
+ if (isNaN(d)) return '';
145
+ const today = new Date(now); today.setHours(0, 0, 0, 0);
146
+ const that = new Date(d); that.setHours(0, 0, 0, 0);
147
+ const diff = Math.round((that - today) / 86_400_000);
148
+ if (diff <= 0) return 'today';
149
+ if (diff === 1) return 'tomorrow';
150
+ return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()];
151
+ }
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.15.6';
14
+ const BAKED = '0.16.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {