ccusage 0.1.7__tar.gz → 0.1.9__tar.gz

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.
@@ -1,8 +1,10 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
- release:
5
- types: [published]
4
+ workflow_dispatch:
5
+ push:
6
+ tags:
7
+ - "v*"
6
8
 
7
9
  permissions:
8
10
  id-token: write
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccusage
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Claude Code usage monitor — fetches rate limits from Anthropic's API
5
5
  Project-URL: Homepage, https://github.com/wakamex/ccusage
6
6
  Project-URL: Source, https://github.com/wakamex/ccusage
@@ -17,7 +17,7 @@ Claude Code usage monitor. Fetches your real rate limit data from Anthropic's AP
17
17
 
18
18
  ```
19
19
  Plan: max_5x
20
- Session (5h) 39% resets 1h26m
20
+ Session 39% resets 1h26m
21
21
  Week (all) 15% resets 143h26m
22
22
  Week (Sonnet) 39% resets 65h26m
23
23
  Extra usage $0.00 / $1000.00
@@ -26,7 +26,7 @@ Plan: max_5x
26
26
  Claude Code statusline (self-caching — refreshes from API when stale, no daemon needed):
27
27
 
28
28
  ```
29
- ~/projects/myapp [Opus 4.6] 5h:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
29
+ ~/projects/myapp [Opus 4.6] sess:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
30
30
  ```
31
31
 
32
32
  ## Install
@@ -94,6 +94,18 @@ Claude Code gets rate limit data from two places:
94
94
  | `seven_day_opus` | `seven_day_opus` | Rolling 7-day Opus-specific window |
95
95
  | `overage` | `extra_usage` | Extra/overage usage (if enabled) |
96
96
 
97
+ The flat `seven_day_*` keys above are all `null` now — the live quota data
98
+ lives in a structured `limits` array, which ccusage reads. Each entry is
99
+ self-describing: `kind` (`session` / `weekly_all` / `weekly_scoped`),
100
+ `percent`, `resets_at`, and for scoped limits a `scope.model.display_name`.
101
+ Because the structured response does not include a session duration, ccusage
102
+ labels that limit `Session` and caches it under `session` rather than assuming
103
+ it is always five hours. Existing `5h` cache files remain readable.
104
+ Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
105
+ keyed by their model name, so any new one is auto-detected and displayed with
106
+ no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
107
+ statusline `fab:`).
108
+
97
109
  ### API response format
98
110
 
99
111
  ```
@@ -8,7 +8,7 @@ Claude Code usage monitor. Fetches your real rate limit data from Anthropic's AP
8
8
 
9
9
  ```
10
10
  Plan: max_5x
11
- Session (5h) 39% resets 1h26m
11
+ Session 39% resets 1h26m
12
12
  Week (all) 15% resets 143h26m
13
13
  Week (Sonnet) 39% resets 65h26m
14
14
  Extra usage $0.00 / $1000.00
@@ -17,7 +17,7 @@ Plan: max_5x
17
17
  Claude Code statusline (self-caching — refreshes from API when stale, no daemon needed):
18
18
 
19
19
  ```
20
- ~/projects/myapp [Opus 4.6] 5h:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
20
+ ~/projects/myapp [Opus 4.6] sess:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
21
21
  ```
22
22
 
23
23
  ## Install
@@ -85,6 +85,18 @@ Claude Code gets rate limit data from two places:
85
85
  | `seven_day_opus` | `seven_day_opus` | Rolling 7-day Opus-specific window |
86
86
  | `overage` | `extra_usage` | Extra/overage usage (if enabled) |
87
87
 
88
+ The flat `seven_day_*` keys above are all `null` now — the live quota data
89
+ lives in a structured `limits` array, which ccusage reads. Each entry is
90
+ self-describing: `kind` (`session` / `weekly_all` / `weekly_scoped`),
91
+ `percent`, `resets_at`, and for scoped limits a `scope.model.display_name`.
92
+ Because the structured response does not include a session duration, ccusage
93
+ labels that limit `Session` and caches it under `session` rather than assuming
94
+ it is always five hours. Existing `5h` cache files remain readable.
95
+ Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
96
+ keyed by their model name, so any new one is auto-detected and displayed with
97
+ no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
98
+ statusline `fab:`).
99
+
88
100
  ### API response format
89
101
 
90
102
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.7"
3
+ version = "0.1.9"
4
4
  description = "Claude Code usage monitor — fetches rate limits from Anthropic's API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -19,6 +19,7 @@ import os
19
19
  import signal
20
20
  import subprocess
21
21
  import sys
22
+ import tempfile
22
23
  import time
23
24
  import urllib.error
24
25
  import urllib.request
@@ -94,6 +95,48 @@ def get_plan(creds: dict | None = None) -> str:
94
95
  return tier.removeprefix("default_claude_")
95
96
 
96
97
 
98
+ def _credential_identity(creds: dict | None) -> tuple[object, object]:
99
+ oauth = (creds or {}).get("claudeAiOauth", {})
100
+ return oauth.get("accessToken"), oauth.get("refreshToken")
101
+
102
+
103
+ def _persist_credentials(updated: dict, expected_identity: tuple[object, object]) -> dict:
104
+ latest = get_credentials()
105
+ if latest and _credential_identity(latest) != expected_identity:
106
+ return latest
107
+ if latest:
108
+ latest_oauth = latest.get("claudeAiOauth", {})
109
+ updated_oauth = updated.get("claudeAiOauth", {})
110
+ updated = {
111
+ **latest,
112
+ **updated,
113
+ "claudeAiOauth": {**latest_oauth, **updated_oauth},
114
+ }
115
+
116
+ tmp: Path | None = None
117
+ try:
118
+ CREDENTIALS_FILE.parent.mkdir(parents=True, exist_ok=True)
119
+ fd, tmp_name = tempfile.mkstemp(
120
+ prefix=f".{CREDENTIALS_FILE.name}.", dir=CREDENTIALS_FILE.parent
121
+ )
122
+ tmp = Path(tmp_name)
123
+ os.fchmod(fd, 0o600)
124
+ with os.fdopen(fd, "w") as file:
125
+ file.write(json.dumps(updated))
126
+ os.replace(tmp, CREDENTIALS_FILE)
127
+ except OSError as exc:
128
+ if tmp:
129
+ try:
130
+ tmp.unlink()
131
+ except OSError:
132
+ pass
133
+ print(
134
+ f"Warning: refreshed token but could not write {CREDENTIALS_FILE}: {exc}",
135
+ file=sys.stderr,
136
+ )
137
+ return updated
138
+
139
+
97
140
  def refresh_credentials(creds: dict) -> dict:
98
141
  """Refresh the OAuth access token and persist updated credentials.
99
142
 
@@ -101,6 +144,11 @@ def refresh_credentials(creds: dict) -> dict:
101
144
  back to .credentials.json or Claude Code's stored one goes stale and the
102
145
  user gets logged out.
103
146
  """
147
+ expected_identity = _credential_identity(creds)
148
+ latest = get_credentials()
149
+ if latest and _credential_identity(latest) != expected_identity:
150
+ return latest
151
+
104
152
  oauth = creds.get("claudeAiOauth", {})
105
153
  refresh_token = oauth.get("refreshToken")
106
154
  if not refresh_token:
@@ -120,6 +168,10 @@ def refresh_credentials(creds: dict) -> dict:
120
168
  with urllib.request.urlopen(req, timeout=10) as resp:
121
169
  result = json.loads(resp.read())
122
170
  except urllib.error.HTTPError as e:
171
+ latest = get_credentials()
172
+ if e.code in (400, 401) and latest:
173
+ if _credential_identity(latest) != expected_identity:
174
+ return latest
123
175
  raise RuntimeError(f"Token refresh failed ({e.code}) — open Claude Code to refresh it") from e
124
176
 
125
177
  oauth = dict(oauth)
@@ -130,16 +182,7 @@ def refresh_credentials(creds: dict) -> dict:
130
182
  updated = dict(creds)
131
183
  updated["claudeAiOauth"] = oauth
132
184
 
133
- try:
134
- tmp = CREDENTIALS_FILE.parent / (CREDENTIALS_FILE.name + ".tmp")
135
- fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
136
- with os.fdopen(fd, "w") as f:
137
- f.write(json.dumps(updated))
138
- os.replace(tmp, CREDENTIALS_FILE)
139
- except OSError as e:
140
- print(f"Warning: refreshed token but could not write {CREDENTIALS_FILE}: {e}", file=sys.stderr)
141
-
142
- return updated
185
+ return _persist_credentials(updated, expected_identity)
143
186
 
144
187
 
145
188
  def fetch_usage() -> dict:
@@ -168,13 +211,13 @@ def fetch_usage() -> dict:
168
211
  if not token:
169
212
  raise RuntimeError("No OAuth access token in credentials")
170
213
 
171
- # Refresh proactively if expired (or about to), then retry once on 401/403
172
- # in case the token was revoked early.
214
+ # Refresh proactively if expired (or about to).
173
215
  if time.time() * 1000 > oauth.get("expiresAt", 0) - 60_000:
174
216
  creds = refresh_credentials(creds)
175
217
  token = creds["claudeAiOauth"]["accessToken"]
176
218
 
177
- for attempt in range(2):
219
+ refreshed_after_rejection = False
220
+ for attempt in range(3):
178
221
  req = urllib.request.Request(
179
222
  "https://api.anthropic.com/api/oauth/usage",
180
223
  headers={
@@ -188,34 +231,111 @@ def fetch_usage() -> dict:
188
231
  with urllib.request.urlopen(req, timeout=10) as resp:
189
232
  return json.loads(resp.read())
190
233
  except urllib.error.HTTPError as e:
191
- if e.code in (401, 403) and attempt == 0:
192
- creds = refresh_credentials(creds)
193
- token = creds["claudeAiOauth"]["accessToken"]
194
- else:
234
+ if e.code not in (401, 403):
195
235
  raise
196
236
 
237
+ latest = get_credentials()
238
+ latest_token = (latest or {}).get("claudeAiOauth", {}).get("accessToken")
239
+ if latest_token and latest_token != token:
240
+ creds, token = latest, latest_token
241
+ continue
242
+ if not refreshed_after_rejection:
243
+ creds = refresh_credentials(latest or creds)
244
+ token = creds["claudeAiOauth"]["accessToken"]
245
+ refreshed_after_rejection = True
246
+ continue
247
+ raise
248
+
197
249
  raise RuntimeError("Failed to fetch usage after token refresh")
198
250
 
199
251
 
252
+ # Top-level keys in the cached usage dict that are NOT rate-limit buckets.
253
+ _META_KEYS = {"plan", "source", "updated_at", "extra_usage"}
254
+
255
+
256
+ def _bucket_display(short_key: str) -> tuple[str, str]:
257
+ """Return (full label, statusline abbrev) for a short bucket key.
258
+
259
+ Derived from the key so unknown/newly-added buckets get a reasonable
260
+ label automatically, e.g. "7d_fable" -> ("Week (Fable)", "fab").
261
+ """
262
+ known = {
263
+ "session": ("Session", "sess"),
264
+ "5h": ("Session (5h)", "5h"),
265
+ "7d": ("Week (all)", "7d"),
266
+ "7d_opus": ("Week (Opus)", "opus"),
267
+ "7d_sonnet": ("Week (Sonnet)", "son"),
268
+ }
269
+ if short_key in known:
270
+ return known[short_key]
271
+ if short_key.startswith("7d_"):
272
+ model = short_key[3:]
273
+ return f"Week ({model.replace('_', ' ').title()})", model[:3]
274
+ return short_key.replace("_", " ").title(), short_key[:4]
275
+
276
+
277
+ def _quota_buckets(data: dict):
278
+ """Yield (short_key, bucket) for each rate-limit bucket in a usage dict.
279
+
280
+ Relies on insertion order (build_usage_json inserts them sorted), so
281
+ callers get a stable, sensible display sequence.
282
+ """
283
+ for key, val in data.items():
284
+ if key in _META_KEYS:
285
+ continue
286
+ if isinstance(val, dict) and "pct" in val:
287
+ yield key, val
288
+
289
+
290
+ def _buckets_from_limits(limits) -> list:
291
+ """Parse the API's structured `limits` array into (order, short_key, bucket).
292
+
293
+ This is the current API shape. Each entry is self-describing:
294
+ {"kind": "session"|"weekly_all"|"weekly_scoped", "percent": 58,
295
+ "resets_at": "...", "scope": {"model": {"display_name": "Fable"}}}
296
+ Model-scoped weekly limits (Opus, Sonnet, Fable, ...) are keyed by their
297
+ model name, so a newly added one appears automatically.
298
+ """
299
+ out = []
300
+ for entry in limits:
301
+ if not isinstance(entry, dict):
302
+ continue
303
+ pct = entry.get("percent")
304
+ if pct is None:
305
+ continue
306
+ kind = entry.get("kind")
307
+ if kind == "session":
308
+ short_key, order = "session", 0
309
+ elif kind == "weekly_all":
310
+ short_key, order = "7d", 1
311
+ elif kind == "weekly_scoped":
312
+ model = (entry.get("scope") or {}).get("model") or {}
313
+ name = (model.get("display_name") or model.get("id") or "scoped").strip()
314
+ short_key = "7d_" + name.lower().replace(" ", "_")
315
+ order = {"7d_opus": 2, "7d_sonnet": 3}.get(short_key, 4)
316
+ else:
317
+ short_key, order = kind or "unknown", 5
318
+ out.append((order, short_key, {"pct": pct, "resets_at": entry.get("resets_at")}))
319
+ return out
320
+
321
+
200
322
  def build_usage_json(api_data: dict, plan: str) -> dict:
201
- """Transform API response into our cached format."""
323
+ """Transform API response into our cached format.
324
+
325
+ Reads the API's structured `limits` array — every quota it reports (session,
326
+ weekly-all, and per-model weekly windows like Opus/Sonnet/Fable) is included
327
+ and mapped to a short key, so a newly added one appears automatically
328
+ instead of being dropped.
329
+ """
202
330
  result = {
203
331
  "plan": plan,
204
332
  "source": "api",
205
333
  "updated_at": datetime.now(timezone.utc).isoformat(),
206
334
  }
207
- for key, api_key in [
208
- ("5h", "five_hour"),
209
- ("7d", "seven_day"),
210
- ("7d_sonnet", "seven_day_sonnet"),
211
- ("7d_opus", "seven_day_opus"),
212
- ]:
213
- bucket = api_data.get(api_key)
214
- if bucket:
215
- result[key] = {
216
- "pct": bucket["utilization"],
217
- "resets_at": bucket.get("resets_at"),
218
- }
335
+ limits = api_data.get("limits")
336
+ buckets = _buckets_from_limits(limits) if isinstance(limits, list) else []
337
+ for _, short_key, bucket in sorted(buckets, key=lambda b: b[0]):
338
+ result[short_key] = bucket
219
339
  extra = api_data.get("extra_usage")
220
340
  if extra:
221
341
  result["extra_usage"] = extra
@@ -227,13 +347,51 @@ def write_usage_file(data: dict):
227
347
  USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
228
348
 
229
349
 
350
+ def _read_cache() -> dict | None:
351
+ """Read the cached usage file, or None if missing/unreadable."""
352
+ try:
353
+ return json.loads(USAGE_FILE.read_text())
354
+ except Exception:
355
+ return None
356
+
357
+
358
+ def _is_429(err: Exception) -> bool:
359
+ """True if the exception is (or wraps) an HTTP 429 rate-limit error."""
360
+ if isinstance(err, urllib.error.HTTPError) and err.code == 429:
361
+ return True
362
+ return "429" in str(err)
363
+
364
+
365
+ def _cache_age_str(data: dict) -> str:
366
+ """Return e.g. ' 3m ago' from a usage dict's updated_at, or '' if unknown."""
367
+ try:
368
+ updated = datetime.fromisoformat(data["updated_at"])
369
+ secs = int((datetime.now(timezone.utc) - updated).total_seconds())
370
+ m = secs // 60
371
+ return f" {m // 60}h{m % 60}m ago" if m >= 60 else f" {m}m ago"
372
+ except Exception:
373
+ return ""
374
+
375
+
230
376
  # -- CLI commands --
231
377
 
232
378
  def cmd_status(raw_json=False):
233
- """Fetch and display current usage."""
234
- api_data = fetch_usage()
235
- plan = get_plan()
236
- data = build_usage_json(api_data, plan)
379
+ """Fetch and display current usage.
380
+
381
+ Fetches fresh from the API, but on failure (rate limit, offline, expired
382
+ token) falls back to the last cached usage rather than crashing.
383
+ """
384
+ stale = False
385
+ try:
386
+ api_data = fetch_usage()
387
+ data = build_usage_json(api_data, get_plan())
388
+ except Exception as e:
389
+ cached = _read_cache()
390
+ if cached is None:
391
+ reason = "rate limited (HTTP 429) — try again shortly" if _is_429(e) else str(e)
392
+ print(f"Could not fetch usage: {reason}", file=sys.stderr)
393
+ sys.exit(1)
394
+ data, stale = cached, True
237
395
 
238
396
  if raw_json:
239
397
  print(json.dumps(data, indent=2))
@@ -266,18 +424,12 @@ def cmd_status(raw_json=False):
266
424
  except Exception:
267
425
  return ""
268
426
 
269
- print(f"Plan: {plan}")
270
- for label, key in [
271
- ("Session (5h)", "5h"),
272
- ("Week (all)", "7d"),
273
- ("Week (Sonnet)", "7d_sonnet"),
274
- ("Week (Opus)", "7d_opus"),
275
- ]:
276
- bucket = data.get(key)
277
- if bucket:
278
- pct = bucket["pct"]
279
- reset = fmt_reset(bucket.get("resets_at"))
280
- print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
427
+ print(f"Plan: {data.get('plan', '?')}")
428
+ for key, bucket in _quota_buckets(data):
429
+ label = _bucket_display(key)[0]
430
+ pct = bucket["pct"]
431
+ reset = fmt_reset(bucket.get("resets_at"))
432
+ print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
281
433
 
282
434
  extra = data.get("extra_usage")
283
435
  if extra and extra.get("is_enabled"):
@@ -285,6 +437,30 @@ def cmd_status(raw_json=False):
285
437
  limit = extra.get("monthly_limit", 0) / 100
286
438
  print(f" {'Extra usage':20s} ${used:.2f} / ${limit:.2f}")
287
439
 
440
+ if stale:
441
+ age = _cache_age_str(data)
442
+ print(f"{D} (cached{age} — live fetch failed){RST}", file=sys.stderr)
443
+
444
+
445
+ def cmd_refresh():
446
+ """Fetch fresh usage from the API and write the cache file once, then exit.
447
+
448
+ A one-shot equivalent of a single daemon tick — use it to force
449
+ ~/.claude/usage-limits.json up to date without running the daemon.
450
+ """
451
+ try:
452
+ api_data = fetch_usage()
453
+ except Exception as e:
454
+ reason = "rate limited (HTTP 429) — try again shortly" if _is_429(e) else str(e)
455
+ print(f"Could not refresh usage: {reason}", file=sys.stderr)
456
+ sys.exit(1)
457
+ data = build_usage_json(api_data, get_plan())
458
+ write_usage_file(data)
459
+ pcts = " ".join(f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data))
460
+ print(f"Updated {USAGE_FILE}")
461
+ if pcts:
462
+ print(f" {pcts}")
463
+
288
464
 
289
465
  def cmd_daemon(interval: int = DAEMON_INTERVAL):
290
466
  """Run in foreground, refresh every `interval` seconds."""
@@ -303,11 +479,7 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
303
479
  data = build_usage_json(api_data, plan)
304
480
  write_usage_file(data)
305
481
  backoff = 0
306
- pcts = []
307
- for key in ("5h", "7d", "7d_sonnet"):
308
- b = data.get(key)
309
- if b:
310
- pcts.append(f"{key}:{int(b['pct'])}%")
482
+ pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
311
483
  print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
312
484
  except urllib.error.HTTPError as e:
313
485
  if e.code == 429:
@@ -392,22 +564,19 @@ def cmd_statusline():
392
564
  usage = _get_cached_usage()
393
565
 
394
566
  plan = usage.get("plan", "?")
395
- five_h = usage.get("5h", {})
396
- seven_d = usage.get("7d", {})
397
- sonnet = usage.get("7d_sonnet", {})
398
-
399
567
  parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
400
568
 
401
- if five_h:
402
- parts.append(f"5h:{color_pct(int(five_h.get('pct', 0)))}")
403
- if seven_d:
404
- parts.append(f"7d:{color_pct(int(seven_d.get('pct', 0)))}")
405
- if sonnet:
406
- parts.append(f"son:{color_pct(int(sonnet.get('pct', 0)))}")
569
+ # Auto-include every quota bucket present (a newly added one just appears).
570
+ session_bucket = {}
571
+ for key, bucket in _quota_buckets(usage):
572
+ abbrev = _bucket_display(key)[1]
573
+ parts.append(f"{abbrev}:{color_pct(int(bucket.get('pct', 0)))}")
574
+ if key in {"session", "5h"}:
575
+ session_bucket = bucket
407
576
 
408
577
  parts.append(f"| {cost_fmt} | {D}{plan}{RST}")
409
578
 
410
- reset = fmt_reset(five_h.get("resets_at"))
579
+ reset = fmt_reset(session_bucket.get("resets_at"))
411
580
  if reset:
412
581
  parts.append(f"| {D}reset:{reset}{RST}")
413
582
 
@@ -431,7 +600,7 @@ def cmd_install():
431
600
  }
432
601
 
433
602
  3. The statusline reads ~/.claude/usage-limits.json (written by the daemon)
434
- and shows: 5h session, 7d all-models, 7d Sonnet-specific limits.
603
+ and shows: session, weekly all-models, and weekly scoped limits.
435
604
  """)
436
605
 
437
606
 
@@ -440,6 +609,7 @@ def main():
440
609
  sub = parser.add_subparsers(dest="command")
441
610
  sub.add_parser("status", help="Show current usage (default)")
442
611
  sub.add_parser("json", help="Print raw JSON")
612
+ sub.add_parser("refresh", help="Fetch fresh usage and update the cache file once")
443
613
  daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
444
614
  daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
445
615
  help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
@@ -452,6 +622,8 @@ def main():
452
622
  cmd_status()
453
623
  elif cmd == "json":
454
624
  cmd_status(raw_json=True)
625
+ elif cmd == "refresh":
626
+ cmd_refresh()
455
627
  elif cmd == "daemon":
456
628
  cmd_daemon(interval=args.interval)
457
629
  elif cmd == "statusline":
@@ -107,6 +107,7 @@ class FetchUsageTests(unittest.TestCase):
107
107
  self.assertEqual(oauth["subscriptionType"], "max")
108
108
  self.assertEqual(on_disk["otherTopLevel"], "keep-me")
109
109
  self.assertEqual(self.credfile.stat().st_mode & 0o777, 0o600)
110
+ self.assertEqual(list(self.credfile.parent.glob("..credentials.json.*")), [])
110
111
 
111
112
  def test_rejected_token_retries_once_after_refresh(self):
112
113
  self._write_creds(int(time.time() * 1000) + 3_600_000)
@@ -139,6 +140,52 @@ class FetchUsageTests(unittest.TestCase):
139
140
  with self.assertRaises(urllib.error.HTTPError):
140
141
  ccusage.fetch_usage()
141
142
 
143
+ def test_rejected_token_reloads_concurrently_updated_credentials(self):
144
+ self._write_creds(int(time.time() * 1000) + 3_600_000)
145
+ replacement = _creds(int(time.time() * 1000) + 3_600_000)
146
+ replacement["claudeAiOauth"]["accessToken"] = "replacement-token"
147
+ replacement["claudeAiOauth"]["refreshToken"] = "replacement-refresh"
148
+ calls = []
149
+
150
+ def fake_urlopen(req, timeout=None):
151
+ calls.append(req.full_url)
152
+ if len(calls) == 1:
153
+ self.credfile.write_text(json.dumps(replacement))
154
+ raise urllib.error.HTTPError(
155
+ req.full_url, 401, "Unauthorized", {}, io.BytesIO(b"")
156
+ )
157
+ self.assertEqual(
158
+ req.headers["Authorization"], "Bearer replacement-token"
159
+ )
160
+ return _json_response({"ok": True})
161
+
162
+ with mock.patch.object(ccusage.urllib.request, "urlopen", fake_urlopen):
163
+ self.assertEqual(ccusage.fetch_usage(), {"ok": True})
164
+
165
+ self.assertEqual(calls, [USAGE_URL, USAGE_URL])
166
+
167
+ def test_refresh_does_not_overwrite_concurrently_rotated_credentials(self):
168
+ self._write_creds(0)
169
+ original = json.loads(self.credfile.read_text())
170
+ replacement = _creds(int(time.time() * 1000) + 3_600_000)
171
+ replacement["claudeAiOauth"]["accessToken"] = "replacement-token"
172
+ replacement["claudeAiOauth"]["refreshToken"] = "replacement-refresh"
173
+
174
+ class RotatingResponse(_FakeResponse):
175
+ def read(inner_self, *args):
176
+ self.credfile.write_text(json.dumps(replacement))
177
+ return super().read(*args)
178
+
179
+ with mock.patch.object(
180
+ ccusage.urllib.request,
181
+ "urlopen",
182
+ return_value=RotatingResponse(json.dumps(REFRESH_RESULT).encode()),
183
+ ):
184
+ result = ccusage.refresh_credentials(original)
185
+
186
+ self.assertEqual(result, replacement)
187
+ self.assertEqual(json.loads(self.credfile.read_text()), replacement)
188
+
142
189
  def test_expired_token_without_refresh_token_raises(self):
143
190
  creds = _creds(0)
144
191
  del creds["claudeAiOauth"]["refreshToken"]
@@ -159,20 +206,65 @@ class FetchUsageTests(unittest.TestCase):
159
206
 
160
207
 
161
208
  class BuildUsageJsonTests(unittest.TestCase):
162
- def test_maps_buckets_and_extra_usage(self):
209
+ def test_reads_structured_limits_array(self):
210
+ # Current API shape: session/weekly totals and the model-scoped Fable
211
+ # quota all live in `limits`; the flat seven_day_* keys are ignored.
163
212
  api_data = {
164
- "five_hour": {"utilization": 35.0, "resets_at": "2026-06-12T00:00:00+00:00"},
165
- "seven_day": {"utilization": 14.0, "resets_at": None},
166
- "seven_day_sonnet": None,
167
213
  "seven_day_opus": None,
214
+ "seven_day_sonnet": None,
215
+ "iguana_necktie": None,
216
+ "limits": [
217
+ {"kind": "session", "percent": 3, "resets_at": "2026-07-04T05:10:00+00:00"},
218
+ {"kind": "weekly_all", "percent": 33, "resets_at": "2026-07-07T13:00:00+00:00"},
219
+ {"kind": "weekly_scoped", "percent": 58,
220
+ "resets_at": "2026-07-07T13:00:00+00:00",
221
+ "scope": {"model": {"id": None, "display_name": "Fable"}}},
222
+ ],
168
223
  "extra_usage": {"is_enabled": True, "monthly_limit": 100000},
169
224
  }
170
225
  result = ccusage.build_usage_json(api_data, "max_20x")
171
226
  self.assertEqual(result["plan"], "max_20x")
172
- self.assertEqual(result["5h"], {"pct": 35.0, "resets_at": "2026-06-12T00:00:00+00:00"})
173
- self.assertEqual(result["7d"], {"pct": 14.0, "resets_at": None})
174
- self.assertNotIn("7d_sonnet", result)
227
+ self.assertEqual(result["session"], {"pct": 3, "resets_at": "2026-07-04T05:10:00+00:00"})
228
+ self.assertNotIn("5h", result)
229
+ self.assertEqual(result["7d"], {"pct": 33, "resets_at": "2026-07-07T13:00:00+00:00"})
230
+ self.assertEqual(result["7d_fable"], {"pct": 58, "resets_at": "2026-07-07T13:00:00+00:00"})
175
231
  self.assertEqual(result["extra_usage"], {"is_enabled": True, "monthly_limit": 100000})
232
+ self.assertEqual(ccusage._bucket_display("7d_fable"), ("Week (Fable)", "fab"))
233
+ # Buckets appear in a stable, sensible order.
234
+ keys = [k for k, _ in ccusage._quota_buckets(result)]
235
+ self.assertEqual(keys, ["session", "7d", "7d_fable"])
236
+
237
+ def test_statusline_uses_semantic_session_bucket_for_reset(self):
238
+ usage = {
239
+ "plan": "max_20x",
240
+ "session": {
241
+ "pct": 3,
242
+ "resets_at": "2099-01-01T00:00:00+00:00",
243
+ },
244
+ "7d": {"pct": 13, "resets_at": None},
245
+ }
246
+ status_input = {
247
+ "model": {"display_name": "Test"},
248
+ "workspace": {"current_dir": "/code/test"},
249
+ }
250
+ with (
251
+ mock.patch.object(ccusage, "_get_cached_usage", return_value=usage),
252
+ mock.patch("sys.stdin", io.StringIO(json.dumps(status_input))),
253
+ mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
254
+ ):
255
+ ccusage.cmd_statusline()
256
+
257
+ output = stdout.getvalue()
258
+ self.assertIn("sess:3%", output)
259
+ self.assertIn("reset:", output)
260
+
261
+ def test_scoped_limit_without_percent_is_skipped(self):
262
+ api_data = {"limits": [
263
+ {"kind": "weekly_scoped", "percent": None,
264
+ "scope": {"model": {"display_name": "Opus"}}},
265
+ ]}
266
+ result = ccusage.build_usage_json(api_data, "max_20x")
267
+ self.assertEqual([k for k, _ in ccusage._quota_buckets(result)], [])
176
268
 
177
269
 
178
270
  if __name__ == "__main__":
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "ccusage"
7
- version = "0.1.4"
7
+ version = "0.1.9"
8
8
  source = { editable = "." }
File without changes
File without changes
File without changes