ccusage 0.1.8__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.8
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
@@ -98,6 +98,9 @@ The flat `seven_day_*` keys above are all `null` now — the live quota data
98
98
  lives in a structured `limits` array, which ccusage reads. Each entry is
99
99
  self-describing: `kind` (`session` / `weekly_all` / `weekly_scoped`),
100
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.
101
104
  Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
102
105
  keyed by their model name, so any new one is auto-detected and displayed with
103
106
  no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
@@ -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
@@ -89,6 +89,9 @@ The flat `seven_day_*` keys above are all `null` now — the live quota data
89
89
  lives in a structured `limits` array, which ccusage reads. Each entry is
90
90
  self-describing: `kind` (`session` / `weekly_all` / `weekly_scoped`),
91
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.
92
95
  Model-scoped weekly windows (Opus, Sonnet, and a newly added **Fable**) are
93
96
  keyed by their model name, so any new one is auto-detected and displayed with
94
97
  no code change (e.g. `scope.model.display_name: "Fable"` → "Week (Fable)",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.8"
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,12 +231,21 @@ 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
 
@@ -208,6 +260,7 @@ def _bucket_display(short_key: str) -> tuple[str, str]:
208
260
  label automatically, e.g. "7d_fable" -> ("Week (Fable)", "fab").
209
261
  """
210
262
  known = {
263
+ "session": ("Session", "sess"),
211
264
  "5h": ("Session (5h)", "5h"),
212
265
  "7d": ("Week (all)", "7d"),
213
266
  "7d_opus": ("Week (Opus)", "opus"),
@@ -252,7 +305,7 @@ def _buckets_from_limits(limits) -> list:
252
305
  continue
253
306
  kind = entry.get("kind")
254
307
  if kind == "session":
255
- short_key, order = "5h", 0
308
+ short_key, order = "session", 0
256
309
  elif kind == "weekly_all":
257
310
  short_key, order = "7d", 1
258
311
  elif kind == "weekly_scoped":
@@ -511,18 +564,19 @@ def cmd_statusline():
511
564
  usage = _get_cached_usage()
512
565
 
513
566
  plan = usage.get("plan", "?")
514
- five_h = usage.get("5h", {})
515
-
516
567
  parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
517
568
 
518
569
  # Auto-include every quota bucket present (a newly added one just appears).
570
+ session_bucket = {}
519
571
  for key, bucket in _quota_buckets(usage):
520
572
  abbrev = _bucket_display(key)[1]
521
573
  parts.append(f"{abbrev}:{color_pct(int(bucket.get('pct', 0)))}")
574
+ if key in {"session", "5h"}:
575
+ session_bucket = bucket
522
576
 
523
577
  parts.append(f"| {cost_fmt} | {D}{plan}{RST}")
524
578
 
525
- reset = fmt_reset(five_h.get("resets_at"))
579
+ reset = fmt_reset(session_bucket.get("resets_at"))
526
580
  if reset:
527
581
  parts.append(f"| {D}reset:{reset}{RST}")
528
582
 
@@ -546,7 +600,7 @@ def cmd_install():
546
600
  }
547
601
 
548
602
  3. The statusline reads ~/.claude/usage-limits.json (written by the daemon)
549
- and shows: 5h session, 7d all-models, 7d Sonnet-specific limits.
603
+ and shows: session, weekly all-models, and weekly scoped limits.
550
604
  """)
551
605
 
552
606
 
@@ -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"]
@@ -177,14 +224,39 @@ class BuildUsageJsonTests(unittest.TestCase):
177
224
  }
178
225
  result = ccusage.build_usage_json(api_data, "max_20x")
179
226
  self.assertEqual(result["plan"], "max_20x")
180
- self.assertEqual(result["5h"], {"pct": 3, "resets_at": "2026-07-04T05:10:00+00:00"})
227
+ self.assertEqual(result["session"], {"pct": 3, "resets_at": "2026-07-04T05:10:00+00:00"})
228
+ self.assertNotIn("5h", result)
181
229
  self.assertEqual(result["7d"], {"pct": 33, "resets_at": "2026-07-07T13:00:00+00:00"})
182
230
  self.assertEqual(result["7d_fable"], {"pct": 58, "resets_at": "2026-07-07T13:00:00+00:00"})
183
231
  self.assertEqual(result["extra_usage"], {"is_enabled": True, "monthly_limit": 100000})
184
232
  self.assertEqual(ccusage._bucket_display("7d_fable"), ("Week (Fable)", "fab"))
185
233
  # Buckets appear in a stable, sensible order.
186
234
  keys = [k for k, _ in ccusage._quota_buckets(result)]
187
- self.assertEqual(keys, ["5h", "7d", "7d_fable"])
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)
188
260
 
189
261
  def test_scoped_limit_without_percent_is_skipped(self):
190
262
  api_data = {"limits": [
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "ccusage"
7
- version = "0.1.8"
7
+ version = "0.1.9"
8
8
  source = { editable = "." }
File without changes
File without changes
File without changes