claude-cup 0.4.1 → 0.7.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/src/usage-api.js CHANGED
@@ -1,250 +1,250 @@
1
- // Polls Anthropic's OAuth usage endpoint (the same data /usage shows in Claude Code).
2
- // Strictly read-only on credentials. Cached aggressively: the endpoint rate-limits hard.
3
- import { EventEmitter } from 'node:events';
4
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
- import { join, dirname } from 'node:path';
6
-
7
- const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
8
- const POLL_MS = 5 * 60 * 1000;
9
- const BACKOFF_429_MS = 10 * 60 * 1000;
10
-
11
- // utilization is a percentage (verified against the live endpoint and the
12
- // Claude UI side by side: raw value 1 === "1%"). Never reinterpret it.
13
- function normPct(v) {
14
- if (typeof v !== 'number' || !Number.isFinite(v)) return null;
15
- return Math.max(0, Math.min(100, Math.round(v * 10) / 10));
16
- }
17
-
18
- function normBucket(b) {
19
- if (!b || typeof b !== 'object') return null;
20
- const pct = normPct(b.utilization);
21
- if (pct === null) return null;
22
- return { pct, resetsAt: b.resets_at || null };
23
- }
24
-
25
- export class UsagePoller extends EventEmitter {
26
- constructor({ configDir, cachePath, intervalMs = POLL_MS, fetchImpl = fetch, now = () => Date.now() } = {}) {
27
- super();
28
- this.configDir = configDir;
29
- this.cachePath = cachePath || null;
30
- this.intervalMs = intervalMs;
31
- this.fetch = fetchImpl;
32
- this.now = now;
33
- this.state = { status: 'unavailable', reason: 'not polled yet', fetchedAt: 0 };
34
- this.samples = []; // {t, pct} history of the 5h window, for pace estimation
35
- this.backoffUntil = 0;
36
- this.timer = null;
37
- this._loadDiskCache();
38
- }
39
-
40
- /** Keep only samples after the most recent window reset, sorted, max 90 min old. */
41
- _normalizeSamples(arr) {
42
- const cutoff = this.now() - 90 * 60000;
43
- const clean = arr
44
- .filter((s) => s && typeof s.t === 'number' && typeof s.pct === 'number' && s.t >= cutoff)
45
- .sort((a, b) => a.t - b.t);
46
- let cut = 0;
47
- for (let i = 1; i < clean.length; i++) {
48
- if (clean[i].pct < clean[i - 1].pct - 0.5) cut = i; // window reset happened here
49
- }
50
- return clean.slice(cut);
51
- }
52
-
53
- _loadDiskCache() {
54
- if (!this.cachePath) return;
55
- try {
56
- const c = JSON.parse(readFileSync(this.cachePath, 'utf8'));
57
- if (c && c.fiveHour !== undefined) {
58
- const { samples, ...cachedState } = c;
59
- this.state = { ...cachedState, status: 'stale', reason: 'cached from previous run' };
60
- // restore pace history persisted by any previous claude-cup process
61
- if (Array.isArray(samples)) {
62
- this.samples = this._normalizeSamples(samples);
63
- } else if (c.fiveHour && typeof c.fetchedAt === 'number' && this.now() - c.fetchedAt < 45 * 60000) {
64
- this.samples = [{ t: c.fetchedAt, pct: c.fiveHour.pct }];
65
- }
66
- if (this.state.fiveHour) this.state.timeLeft = this._timeLeft();
67
- }
68
- } catch {
69
- /* no cache yet */
70
- }
71
- }
72
-
73
- /**
74
- * Instance cooperation: if another claude-cup process (TUI, web, statusline)
75
- * fetched fresh data recently, adopt its result and pace samples from the
76
- * shared cache file instead of hitting the rate-limited endpoint again.
77
- */
78
- _adoptSharedCache() {
79
- if (!this.cachePath) return false;
80
- try {
81
- const c = JSON.parse(readFileSync(this.cachePath, 'utf8'));
82
- if (!c || typeof c.fetchedAt !== 'number' || !c.fiveHour) return false;
83
- if (this.now() - c.fetchedAt >= Math.min(this.intervalMs, POLL_MS)) return false;
84
- const newer = c.fetchedAt > (this.state.fetchedAt || 0);
85
- const refreshable = this.state.status !== 'ok' && c.fetchedAt >= (this.state.fetchedAt || 0);
86
- if (!newer && !refreshable) return false;
87
- const { samples, ...freshState } = c;
88
- const merged = [...this.samples];
89
- const seen = new Set(merged.map((s) => s.t));
90
- for (const s of Array.isArray(samples) ? samples : []) {
91
- if (s && !seen.has(s.t)) merged.push(s);
92
- }
93
- this.samples = this._normalizeSamples(merged);
94
- this.state = { ...freshState, status: 'ok', reason: null };
95
- this.state.timeLeft = this._timeLeft();
96
- this.emit('usage', this.state);
97
- return true;
98
- } catch {
99
- return false;
100
- }
101
- }
102
-
103
- _recordSample() {
104
- const pct = this.state.fiveHour?.pct;
105
- if (pct == null) return;
106
- const t = this.now();
107
- const last = this.samples[this.samples.length - 1];
108
- // a noticeable drop means the 5h window reset - old pace is meaningless
109
- if (last && pct < last.pct - 0.5) this.samples = [];
110
- this.samples.push({ t, pct });
111
- const cutoff = t - 90 * 60000;
112
- this.samples = this.samples.filter((s) => s.t >= cutoff);
113
- }
114
-
115
- /**
116
- * Projects time until the 5h limit is hit, from the measured pace of the
117
- * official used-% (covers ALL the user's Claude surfaces - desktop, CLI, web).
118
- * @returns {null | {outlasts: true} | {minutes: number, outlasts: false}}
119
- * null = not enough data yet ("measuring")
120
- * outlasts = at this pace the window resets before the limit is reached
121
- */
122
- _timeLeft() {
123
- const f = this.state.fiveHour;
124
- if (!f || f.pct == null) return null;
125
- const t = this.now();
126
- const win = this.samples.filter((s) => s.t >= t - 60 * 60000);
127
- if (win.length < 2) return null;
128
- const first = win[0];
129
- const last = win[win.length - 1];
130
- const spanMin = (last.t - first.t) / 60000;
131
- if (spanMin < 4) return null;
132
- const slope = (last.pct - first.pct) / spanMin; // % per minute
133
- const resetsInMin = f.resetsAt ? Math.max(0, (Date.parse(f.resetsAt) - t) / 60000) : null;
134
- if (slope <= 0.02) return { outlasts: true }; // idle or near-idle burn
135
- const minutes = (100 - last.pct) / slope;
136
- if (resetsInMin !== null && minutes >= resetsInMin) return { outlasts: true };
137
- return { minutes: Math.round(minutes), outlasts: false };
138
- }
139
-
140
- _saveDiskCache() {
141
- if (!this.cachePath) return;
142
- try {
143
- mkdirSync(dirname(this.cachePath), { recursive: true });
144
- writeFileSync(this.cachePath, JSON.stringify({ ...this.state, samples: this.samples }));
145
- } catch {
146
- /* non-fatal */
147
- }
148
- }
149
-
150
- readToken() {
151
- const file = join(this.configDir, '.credentials.json');
152
- try {
153
- const creds = JSON.parse(readFileSync(file, 'utf8'));
154
- const oauth = creds.claudeAiOauth;
155
- if (!oauth || !oauth.accessToken) return { error: 'no token in credentials file' };
156
- return {
157
- token: oauth.accessToken,
158
- expired: typeof oauth.expiresAt === 'number' && oauth.expiresAt < Date.now(),
159
- tier: oauth.rateLimitTier || oauth.subscriptionType || null,
160
- };
161
- } catch {
162
- return { error: 'credentials file not found (is Claude Code logged in?)' };
163
- }
164
- }
165
-
166
- start() {
167
- this.pollNow();
168
- this.timer = setInterval(() => this.pollNow(), this.intervalMs);
169
- this.timer.unref?.();
170
- }
171
-
172
- stop() {
173
- if (this.timer) clearInterval(this.timer);
174
- }
175
-
176
- _degrade(reason) {
177
- if (this.state.fiveHour) {
178
- this.state = { ...this.state, status: 'stale', reason };
179
- // pace projection still works from the samples we already have
180
- this.state.timeLeft = this._timeLeft();
181
- } else {
182
- this.state = { status: 'unavailable', reason, fetchedAt: 0 };
183
- }
184
- this.emit('usage', this.state);
185
- }
186
-
187
- async pollNow() {
188
- if (Date.now() < this.backoffUntil) return this.state;
189
- // another claude-cup process may have fetched moments ago - reuse its data
190
- if (this._adoptSharedCache()) return this.state;
191
- const cred = this.readToken();
192
- if (cred.error) {
193
- this._degrade(cred.error);
194
- return this.state;
195
- }
196
-
197
- try {
198
- const ctrl = new AbortController();
199
- const t = setTimeout(() => ctrl.abort(), 10_000);
200
- const res = await this.fetch(USAGE_URL, {
201
- headers: {
202
- Authorization: `Bearer ${cred.token}`,
203
- 'anthropic-beta': 'oauth-2025-04-20',
204
- },
205
- signal: ctrl.signal,
206
- });
207
- clearTimeout(t);
208
-
209
- if (res.status === 429) {
210
- this.backoffUntil = Date.now() + BACKOFF_429_MS;
211
- this._degrade('rate limited by usage endpoint, backing off');
212
- return this.state;
213
- }
214
- if (res.status === 401 || res.status === 403) {
215
- this._degrade(cred.expired ? 'token expired (open Claude Code to refresh it)' : 'token rejected');
216
- return this.state;
217
- }
218
- if (!res.ok) {
219
- this._degrade(`usage endpoint returned ${res.status}`);
220
- return this.state;
221
- }
222
-
223
- const json = await res.json();
224
- this.state = {
225
- status: 'ok',
226
- reason: null,
227
- fetchedAt: this.now(),
228
- tier: cred.tier,
229
- fiveHour: normBucket(json.five_hour),
230
- sevenDay: normBucket(json.seven_day),
231
- sevenDaySonnet: normBucket(json.seven_day_sonnet),
232
- sevenDayOpus: normBucket(json.seven_day_opus),
233
- extraUsage: json.extra_usage && json.extra_usage.is_enabled
234
- ? {
235
- monthlyLimit: json.extra_usage.monthly_limit ?? null,
236
- usedCredits: json.extra_usage.used_credits ?? null,
237
- }
238
- : null,
239
- };
240
- this._recordSample();
241
- this.state.timeLeft = this._timeLeft();
242
- this._saveDiskCache();
243
- this.emit('usage', this.state);
244
- return this.state;
245
- } catch (err) {
246
- this._degrade(err.name === 'AbortError' ? 'usage request timed out' : 'network error reaching usage endpoint');
247
- return this.state;
248
- }
249
- }
250
- }
1
+ // Polls Anthropic's OAuth usage endpoint (the same data /usage shows in Claude Code).
2
+ // Strictly read-only on credentials. Cached aggressively: the endpoint rate-limits hard.
3
+ import { EventEmitter } from 'node:events';
4
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { join, dirname } from 'node:path';
6
+
7
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
8
+ const POLL_MS = 5 * 60 * 1000;
9
+ const BACKOFF_429_MS = 10 * 60 * 1000;
10
+
11
+ // utilization is a percentage (verified against the live endpoint and the
12
+ // Claude UI side by side: raw value 1 === "1%"). Never reinterpret it.
13
+ function normPct(v) {
14
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null;
15
+ return Math.max(0, Math.min(100, Math.round(v * 10) / 10));
16
+ }
17
+
18
+ function normBucket(b) {
19
+ if (!b || typeof b !== 'object') return null;
20
+ const pct = normPct(b.utilization);
21
+ if (pct === null) return null;
22
+ return { pct, resetsAt: b.resets_at || null };
23
+ }
24
+
25
+ export class UsagePoller extends EventEmitter {
26
+ constructor({ configDir, cachePath, intervalMs = POLL_MS, fetchImpl = fetch, now = () => Date.now() } = {}) {
27
+ super();
28
+ this.configDir = configDir;
29
+ this.cachePath = cachePath || null;
30
+ this.intervalMs = intervalMs;
31
+ this.fetch = fetchImpl;
32
+ this.now = now;
33
+ this.state = { status: 'unavailable', reason: 'not polled yet', fetchedAt: 0 };
34
+ this.samples = []; // {t, pct} history of the 5h window, for pace estimation
35
+ this.backoffUntil = 0;
36
+ this.timer = null;
37
+ this._loadDiskCache();
38
+ }
39
+
40
+ /** Keep only samples after the most recent window reset, sorted, max 90 min old. */
41
+ _normalizeSamples(arr) {
42
+ const cutoff = this.now() - 90 * 60000;
43
+ const clean = arr
44
+ .filter((s) => s && typeof s.t === 'number' && typeof s.pct === 'number' && s.t >= cutoff)
45
+ .sort((a, b) => a.t - b.t);
46
+ let cut = 0;
47
+ for (let i = 1; i < clean.length; i++) {
48
+ if (clean[i].pct < clean[i - 1].pct - 0.5) cut = i; // window reset happened here
49
+ }
50
+ return clean.slice(cut);
51
+ }
52
+
53
+ _loadDiskCache() {
54
+ if (!this.cachePath) return;
55
+ try {
56
+ const c = JSON.parse(readFileSync(this.cachePath, 'utf8'));
57
+ if (c && c.fiveHour !== undefined) {
58
+ const { samples, ...cachedState } = c;
59
+ this.state = { ...cachedState, status: 'stale', reason: 'cached from previous run' };
60
+ // restore pace history persisted by any previous claude-cup process
61
+ if (Array.isArray(samples)) {
62
+ this.samples = this._normalizeSamples(samples);
63
+ } else if (c.fiveHour && typeof c.fetchedAt === 'number' && this.now() - c.fetchedAt < 45 * 60000) {
64
+ this.samples = [{ t: c.fetchedAt, pct: c.fiveHour.pct }];
65
+ }
66
+ if (this.state.fiveHour) this.state.timeLeft = this._timeLeft();
67
+ }
68
+ } catch {
69
+ /* no cache yet */
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Instance cooperation: if another claude-cup process (TUI, web, statusline)
75
+ * fetched fresh data recently, adopt its result and pace samples from the
76
+ * shared cache file instead of hitting the rate-limited endpoint again.
77
+ */
78
+ _adoptSharedCache() {
79
+ if (!this.cachePath) return false;
80
+ try {
81
+ const c = JSON.parse(readFileSync(this.cachePath, 'utf8'));
82
+ if (!c || typeof c.fetchedAt !== 'number' || !c.fiveHour) return false;
83
+ if (this.now() - c.fetchedAt >= Math.min(this.intervalMs, POLL_MS)) return false;
84
+ const newer = c.fetchedAt > (this.state.fetchedAt || 0);
85
+ const refreshable = this.state.status !== 'ok' && c.fetchedAt >= (this.state.fetchedAt || 0);
86
+ if (!newer && !refreshable) return false;
87
+ const { samples, ...freshState } = c;
88
+ const merged = [...this.samples];
89
+ const seen = new Set(merged.map((s) => s.t));
90
+ for (const s of Array.isArray(samples) ? samples : []) {
91
+ if (s && !seen.has(s.t)) merged.push(s);
92
+ }
93
+ this.samples = this._normalizeSamples(merged);
94
+ this.state = { ...freshState, status: 'ok', reason: null };
95
+ this.state.timeLeft = this._timeLeft();
96
+ this.emit('usage', this.state);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ _recordSample() {
104
+ const pct = this.state.fiveHour?.pct;
105
+ if (pct == null) return;
106
+ const t = this.now();
107
+ const last = this.samples[this.samples.length - 1];
108
+ // a noticeable drop means the 5h window reset - old pace is meaningless
109
+ if (last && pct < last.pct - 0.5) this.samples = [];
110
+ this.samples.push({ t, pct });
111
+ const cutoff = t - 90 * 60000;
112
+ this.samples = this.samples.filter((s) => s.t >= cutoff);
113
+ }
114
+
115
+ /**
116
+ * Projects time until the 5h limit is hit, from the measured pace of the
117
+ * official used-% (covers ALL the user's Claude surfaces - desktop, CLI, web).
118
+ * @returns {null | {outlasts: true} | {minutes: number, outlasts: false}}
119
+ * null = not enough data yet ("measuring")
120
+ * outlasts = at this pace the window resets before the limit is reached
121
+ */
122
+ _timeLeft() {
123
+ const f = this.state.fiveHour;
124
+ if (!f || f.pct == null) return null;
125
+ const t = this.now();
126
+ const win = this.samples.filter((s) => s.t >= t - 60 * 60000);
127
+ if (win.length < 2) return null;
128
+ const first = win[0];
129
+ const last = win[win.length - 1];
130
+ const spanMin = (last.t - first.t) / 60000;
131
+ if (spanMin < 4) return null;
132
+ const slope = (last.pct - first.pct) / spanMin; // % per minute
133
+ const resetsInMin = f.resetsAt ? Math.max(0, (Date.parse(f.resetsAt) - t) / 60000) : null;
134
+ if (slope <= 0.02) return { outlasts: true }; // idle or near-idle burn
135
+ const minutes = (100 - last.pct) / slope;
136
+ if (resetsInMin !== null && minutes >= resetsInMin) return { outlasts: true };
137
+ return { minutes: Math.round(minutes), outlasts: false };
138
+ }
139
+
140
+ _saveDiskCache() {
141
+ if (!this.cachePath) return;
142
+ try {
143
+ mkdirSync(dirname(this.cachePath), { recursive: true });
144
+ writeFileSync(this.cachePath, JSON.stringify({ ...this.state, samples: this.samples }));
145
+ } catch {
146
+ /* non-fatal */
147
+ }
148
+ }
149
+
150
+ readToken() {
151
+ const file = join(this.configDir, '.credentials.json');
152
+ try {
153
+ const creds = JSON.parse(readFileSync(file, 'utf8'));
154
+ const oauth = creds.claudeAiOauth;
155
+ if (!oauth || !oauth.accessToken) return { error: 'no token in credentials file' };
156
+ return {
157
+ token: oauth.accessToken,
158
+ expired: typeof oauth.expiresAt === 'number' && oauth.expiresAt < Date.now(),
159
+ tier: oauth.rateLimitTier || oauth.subscriptionType || null,
160
+ };
161
+ } catch {
162
+ return { error: 'credentials file not found (is Claude Code logged in?)' };
163
+ }
164
+ }
165
+
166
+ start() {
167
+ this.pollNow();
168
+ this.timer = setInterval(() => this.pollNow(), this.intervalMs);
169
+ this.timer.unref?.();
170
+ }
171
+
172
+ stop() {
173
+ if (this.timer) clearInterval(this.timer);
174
+ }
175
+
176
+ _degrade(reason) {
177
+ if (this.state.fiveHour) {
178
+ this.state = { ...this.state, status: 'stale', reason };
179
+ // pace projection still works from the samples we already have
180
+ this.state.timeLeft = this._timeLeft();
181
+ } else {
182
+ this.state = { status: 'unavailable', reason, fetchedAt: 0 };
183
+ }
184
+ this.emit('usage', this.state);
185
+ }
186
+
187
+ async pollNow() {
188
+ if (Date.now() < this.backoffUntil) return this.state;
189
+ // another claude-cup process may have fetched moments ago - reuse its data
190
+ if (this._adoptSharedCache()) return this.state;
191
+ const cred = this.readToken();
192
+ if (cred.error) {
193
+ this._degrade(cred.error);
194
+ return this.state;
195
+ }
196
+
197
+ try {
198
+ const ctrl = new AbortController();
199
+ const t = setTimeout(() => ctrl.abort(), 10_000);
200
+ const res = await this.fetch(USAGE_URL, {
201
+ headers: {
202
+ Authorization: `Bearer ${cred.token}`,
203
+ 'anthropic-beta': 'oauth-2025-04-20',
204
+ },
205
+ signal: ctrl.signal,
206
+ });
207
+ clearTimeout(t);
208
+
209
+ if (res.status === 429) {
210
+ this.backoffUntil = Date.now() + BACKOFF_429_MS;
211
+ this._degrade('rate limited by usage endpoint, backing off');
212
+ return this.state;
213
+ }
214
+ if (res.status === 401 || res.status === 403) {
215
+ this._degrade(cred.expired ? 'token expired (open Claude Code to refresh it)' : 'token rejected');
216
+ return this.state;
217
+ }
218
+ if (!res.ok) {
219
+ this._degrade(`usage endpoint returned ${res.status}`);
220
+ return this.state;
221
+ }
222
+
223
+ const json = await res.json();
224
+ this.state = {
225
+ status: 'ok',
226
+ reason: null,
227
+ fetchedAt: this.now(),
228
+ tier: cred.tier,
229
+ fiveHour: normBucket(json.five_hour),
230
+ sevenDay: normBucket(json.seven_day),
231
+ sevenDaySonnet: normBucket(json.seven_day_sonnet),
232
+ sevenDayOpus: normBucket(json.seven_day_opus),
233
+ extraUsage: json.extra_usage && json.extra_usage.is_enabled
234
+ ? {
235
+ monthlyLimit: json.extra_usage.monthly_limit ?? null,
236
+ usedCredits: json.extra_usage.used_credits ?? null,
237
+ }
238
+ : null,
239
+ };
240
+ this._recordSample();
241
+ this.state.timeLeft = this._timeLeft();
242
+ this._saveDiskCache();
243
+ this.emit('usage', this.state);
244
+ return this.state;
245
+ } catch (err) {
246
+ this._degrade(err.name === 'AbortError' ? 'usage request timed out' : 'network error reaching usage endpoint');
247
+ return this.state;
248
+ }
249
+ }
250
+ }