claude-rpc 0.15.5 → 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 +3 -0
- package/SECURITY.md +33 -2
- package/package.json +1 -1
- package/src/cli.js +85 -5
- package/src/daemon.js +38 -0
- package/src/default-config.js +16 -0
- package/src/doctor.js +26 -0
- package/src/format.js +27 -0
- package/src/paths.js +3 -0
- package/src/usage.js +151 -0
- package/src/version.js +1 -1
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
|
|
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.
|
|
147
|
+
### 3d. Subscription usage — your 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
package/src/cli.js
CHANGED
|
@@ -23,13 +23,14 @@ 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';
|
|
29
30
|
import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
|
|
30
31
|
import { randomUUID } from 'node:crypto';
|
|
31
32
|
import { createInterface } from 'node:readline';
|
|
32
|
-
import { basename } from 'node:path';
|
|
33
|
+
import { basename, join } from 'node:path';
|
|
33
34
|
|
|
34
35
|
const cmd = process.argv[2];
|
|
35
36
|
|
|
@@ -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'],
|
|
@@ -1849,14 +1909,33 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1849
1909
|
try {
|
|
1850
1910
|
if (IS_NPX) {
|
|
1851
1911
|
// Our own tree is npm's throwaway _npx cache; launch from the global
|
|
1852
|
-
// install setup just promoted to
|
|
1912
|
+
// install setup just promoted to. The global copy must be resolved
|
|
1913
|
+
// EXPLICITLY (npm root -g): inside an npx run, PATH has the _npx
|
|
1914
|
+
// cache's .bin first, so a bare `claude-rpc` resolves right back
|
|
1915
|
+
// into the cache we're escaping. And it must be spawned as a direct
|
|
1916
|
+
// `node <script>` child — a shell+shim chain (cmd → .cmd → node)
|
|
1917
|
+
// only hides the FIRST hop's window; the detached cmd loses its
|
|
1918
|
+
// console, the grandchild node allocates a fresh one, and Windows 11
|
|
1919
|
+
// pops it as a visible Windows Terminal window whose closure kills
|
|
1920
|
+
// the daemon.
|
|
1853
1921
|
if (!daemonPid()) {
|
|
1854
|
-
|
|
1922
|
+
let script = null;
|
|
1923
|
+
try {
|
|
1924
|
+
const r = spawnSync('npm', ['root', '-g'], {
|
|
1925
|
+
encoding: 'utf8', timeout: 8000, windowsHide: true,
|
|
1926
|
+
shell: process.platform === 'win32', // npm is npm.cmd on Windows
|
|
1927
|
+
});
|
|
1928
|
+
const root = (r.stdout || '').trim();
|
|
1929
|
+
const candidate = root && join(root, 'claude-rpc', 'src', 'daemon.js');
|
|
1930
|
+
if (candidate && existsSync(candidate)) script = candidate;
|
|
1931
|
+
} catch { /* npm unavailable → fall back below */ }
|
|
1932
|
+
// Fallback: run from this (npx) tree. Still invisible — only the
|
|
1933
|
+
// npx-cache-eviction caveat remains, healed by the next setup.
|
|
1934
|
+
const child = spawn(process.execPath, [script || DAEMON_SCRIPT], {
|
|
1855
1935
|
detached: true, stdio: 'ignore', windowsHide: true,
|
|
1856
|
-
shell: process.platform === 'win32',
|
|
1857
1936
|
});
|
|
1858
1937
|
child.unref();
|
|
1859
|
-
console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}log ${shortPath(LOG_PATH)}${c.reset}`);
|
|
1938
|
+
console.log(` ${c.green}✓${c.reset} ${'daemon launched'.padEnd(16)}${c.dim}pid ${c.reset}${c.cyan}${child.pid}${c.reset}${c.dim} · log ${shortPath(LOG_PATH)}${c.reset}`);
|
|
1860
1939
|
} else {
|
|
1861
1940
|
console.log(` ${c.cyan}·${c.reset} ${'daemon running'.padEnd(16)}${c.dim}pid ${daemonPid()}${c.reset}`);
|
|
1862
1941
|
}
|
|
@@ -1886,6 +1965,7 @@ const packagedDefault = IS_PACKAGED && !cmd;
|
|
|
1886
1965
|
case 'dump': showStatus(); break;
|
|
1887
1966
|
case 'today': showToday(); break;
|
|
1888
1967
|
case 'week': showWeek(); break;
|
|
1968
|
+
case 'usage': await showUsage(); break;
|
|
1889
1969
|
case 'serve': await import('./server/index.js'); break;
|
|
1890
1970
|
case 'preview': showPreview(); break;
|
|
1891
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);
|
package/src/default-config.js
CHANGED
|
@@ -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
|
+
}
|