ccusage 0.1.11__tar.gz → 0.2.0__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.
@@ -0,0 +1,29 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ validate:
12
+ uses: wakamex/release-actions/.github/workflows/python-validation.yml@stable
13
+
14
+ publish:
15
+ needs: validate
16
+ runs-on: ubuntu-24.04
17
+ environment: pypi
18
+ permissions:
19
+ contents: read
20
+ id-token: write
21
+ steps:
22
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
23
+ - uses: wakamex/release-actions/pypi-publish@stable
24
+
25
+ github-release:
26
+ needs: publish
27
+ permissions:
28
+ contents: write
29
+ uses: wakamex/release-actions/.github/workflows/github-release.yml@stable
@@ -0,0 +1,12 @@
1
+ name: Release eligibility
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ release-eligible:
12
+ uses: wakamex/release-actions/.github/workflows/python-validation.yml@stable
ccusage-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wakamex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,3 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: ccusage
3
+ Version: 0.2.0
4
+ Summary: Claude Code usage monitor — fetches rate limits from Anthropic's API
5
+ Project-URL: Homepage, https://github.com/wakamex/ccusage
6
+ Project-URL: Source, https://github.com/wakamex/ccusage
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.12
10
+ Description-Content-Type: text/markdown
11
+
1
12
  # ccusage
2
13
 
3
14
  Claude Code usage monitor. Fetches your real rate limit data from Anthropic's API and displays it in the Claude Code statusline.
@@ -47,6 +58,11 @@ Configure the statusline in `~/.claude/settings.json`:
47
58
  }
48
59
  ```
49
60
 
61
+ If Claude Code uses `CLAUDE_CONFIG_DIR`, ccusage reads credentials and writes
62
+ `usage-limits.json` in that directory instead.
63
+
64
+ Each refresh also saves a copy under `usage-limits/<account uuid>.json`, tagged with the logged-in account's UUID and email from Claude Code's `.claude.json`. If you switch between Claude accounts with `/login`, the account you logged out of keeps its last usage there. An idle account cannot use quota, so that snapshot stays accurate until its windows reset.
65
+
50
66
  ## Commands
51
67
 
52
68
  | Command | Description |
@@ -134,7 +150,8 @@ anthropic-beta: oauth-2025-04-20
134
150
 
135
151
  ### Authentication
136
152
 
137
- The OAuth token lives at `~/.claude/.credentials.json`:
153
+ The OAuth token normally lives at `~/.claude/.credentials.json`, or under
154
+ `CLAUDE_CONFIG_DIR` when that environment variable is set:
138
155
 
139
156
  ```json
140
157
  {
@@ -173,4 +190,5 @@ seven_day: 75% at 60%, 50% at 35%, 25% at 15%
173
190
  | `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
174
191
  | `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
175
192
  | `~/.claude/usage-limits.json` | ccusage daemon | Cached API usage data (this tool) |
193
+ | `~/.claude/usage-limits/<account uuid>.json` | ccusage daemon | Last usage seen for each Claude account (this tool) |
176
194
  | `~/.claude/statsig/` | Claude Code | Feature flags, experiment assignments |
@@ -1,12 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: ccusage
3
- Version: 0.1.11
4
- Summary: Claude Code usage monitor — fetches rate limits from Anthropic's API
5
- Project-URL: Homepage, https://github.com/wakamex/ccusage
6
- Project-URL: Source, https://github.com/wakamex/ccusage
7
- Requires-Python: >=3.12
8
- Description-Content-Type: text/markdown
9
-
10
1
  # ccusage
11
2
 
12
3
  Claude Code usage monitor. Fetches your real rate limit data from Anthropic's API and displays it in the Claude Code statusline.
@@ -56,6 +47,11 @@ Configure the statusline in `~/.claude/settings.json`:
56
47
  }
57
48
  ```
58
49
 
50
+ If Claude Code uses `CLAUDE_CONFIG_DIR`, ccusage reads credentials and writes
51
+ `usage-limits.json` in that directory instead.
52
+
53
+ Each refresh also saves a copy under `usage-limits/<account uuid>.json`, tagged with the logged-in account's UUID and email from Claude Code's `.claude.json`. If you switch between Claude accounts with `/login`, the account you logged out of keeps its last usage there. An idle account cannot use quota, so that snapshot stays accurate until its windows reset.
54
+
59
55
  ## Commands
60
56
 
61
57
  | Command | Description |
@@ -143,7 +139,8 @@ anthropic-beta: oauth-2025-04-20
143
139
 
144
140
  ### Authentication
145
141
 
146
- The OAuth token lives at `~/.claude/.credentials.json`:
142
+ The OAuth token normally lives at `~/.claude/.credentials.json`, or under
143
+ `CLAUDE_CONFIG_DIR` when that environment variable is set:
147
144
 
148
145
  ```json
149
146
  {
@@ -182,4 +179,5 @@ seven_day: 75% at 60%, 50% at 35%, 25% at 15%
182
179
  | `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
183
180
  | `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
184
181
  | `~/.claude/usage-limits.json` | ccusage daemon | Cached API usage data (this tool) |
182
+ | `~/.claude/usage-limits/<account uuid>.json` | ccusage daemon | Last usage seen for each Claude account (this tool) |
185
183
  | `~/.claude/statsig/` | Claude Code | Feature flags, experiment assignments |
@@ -1,9 +1,11 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.11"
3
+ version = "0.2.0"
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"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
7
9
  dependencies = []
8
10
 
9
11
  [tool.uv]
@@ -0,0 +1,20 @@
1
+ ccusage now remembers the usage of every Claude account you log into, so switching accounts with `/login` no longer loses the other account's numbers. The package is also licensed under MIT.
2
+
3
+ ## Highlights
4
+
5
+ - Each refresh also saves a copy of the usage to `~/.claude/usage-limits/<account uuid>.json`, tagged with the logged-in account's UUID and email. The account you switched away from keeps its last usage there, and since an idle account can't use quota, that copy stays accurate until its windows reset. `usage-limits.json` also gains an `account` field and is otherwise unchanged. ([`e2ed247`](https://github.com/wakamex/ccusage/commit/e2ed247b0b677c7e36220cb5b83f916022e0d14b))
6
+ - If you switch accounts in the middle of a refresh, ccusage skips the per-account copy for that refresh, so usage is never saved under the wrong account. Both cache files are now written atomically, so readers never see a half-written file. ([`e2ed247`](https://github.com/wakamex/ccusage/commit/e2ed247b0b677c7e36220cb5b83f916022e0d14b))
7
+ - The wheel and sdist now include a LICENSE file and declare `License-Expression: MIT`. ([`adbcbdb`](https://github.com/wakamex/ccusage/commit/adbcbdb40450f0e231a46026ad00848b0d188ac1))
8
+
9
+ ## Changes
10
+
11
+ - Create the GitHub Release from committed release notes ([`146bf16`](https://github.com/wakamex/ccusage/commit/146bf165297bdd16c7300bd0dfdac3240d568fbf))
12
+ - Use the shared release actions ([`e623b1d`](https://github.com/wakamex/ccusage/commit/e623b1d0f5189e71b561f1fbb5fda1c92b788069))
13
+ - Remove the Markdown fence from the release workflows ([`614db37`](https://github.com/wakamex/ccusage/commit/614db372050760ba84f34469acd2ade6e03b0206))
14
+ - Pin runners and actions, and follow release-actions stable ([`1ef57c9`](https://github.com/wakamex/ccusage/commit/1ef57c9009a6604b2bd0804ba654f84064a5b090))
15
+ - Keep the last usage of each Claude account ([`e2ed247`](https://github.com/wakamex/ccusage/commit/e2ed247b0b677c7e36220cb5b83f916022e0d14b))
16
+ - License ccusage under MIT ([`adbcbdb`](https://github.com/wakamex/ccusage/commit/adbcbdb40450f0e231a46026ad00848b0d188ac1))
17
+
18
+ ## History
19
+
20
+ [v0.1.12...v0.2.0](https://github.com/wakamex/ccusage/compare/v0.1.12...v0.2.0)
@@ -9,12 +9,14 @@ Usage:
9
9
  ccusage status Same as above
10
10
  ccusage json Print raw JSON
11
11
  ccusage daemon Run in foreground, refresh every 5 min, write to ~/.claude/usage-limits.json
12
+ and ~/.claude/usage-limits/<account uuid>.json
12
13
  ccusage statusline Claude Code statusline command (reads stdin + cache)
13
14
  ccusage install Print setup instructions
14
15
  """
15
16
 
16
17
  import argparse
17
18
  import json
19
+ import math
18
20
  import os
19
21
  import signal
20
22
  import subprocess
@@ -29,18 +31,23 @@ from pathlib import Path
29
31
  _TTY = sys.stdout.isatty()
30
32
 
31
33
 
34
+ def _claude_config_dir() -> Path:
35
+ override = os.environ.get("CLAUDE_CONFIG_DIR")
36
+ return Path(override) if override is not None else Path.home() / ".claude"
37
+
38
+
32
39
  def _resolve_claude_path(relative: str) -> Path:
33
- """Return the first existing path for a file inside ~/.claude/.
40
+ """Return the first existing path for a file inside Claude's config directory.
34
41
 
35
42
  On Windows, if the native path doesn't exist, also checks WSL distros
36
43
  so the tool works from a Windows terminal against a WSL-based Claude Code
37
44
  installation.
38
45
 
39
46
  Args:
40
- relative: path relative to the .claude directory, e.g. ".credentials.json"
47
+ relative: path relative to Claude's config directory, e.g. ".credentials.json"
41
48
  """
42
- native = Path.home() / ".claude" / relative
43
- if native.exists() or sys.platform != "win32":
49
+ native = _claude_config_dir() / relative
50
+ if "CLAUDE_CONFIG_DIR" in os.environ or native.exists() or sys.platform != "win32":
44
51
  return native
45
52
 
46
53
  # Windows: try WSL paths
@@ -68,9 +75,13 @@ def _resolve_claude_path(relative: str) -> Path:
68
75
  return native
69
76
 
70
77
 
71
- CLAUDE_DIR = Path.home() / ".claude"
78
+ CLAUDE_DIR = _claude_config_dir()
72
79
  CREDENTIALS_FILE = _resolve_claude_path(".credentials.json")
73
80
  USAGE_FILE = _resolve_claude_path("usage-limits.json")
81
+ # One snapshot per Claude account, keyed by account UUID, so an account that is
82
+ # logged out keeps its last usage. Idle accounts cannot use quota, so the
83
+ # snapshot stays accurate until its windows reset.
84
+ ACCOUNTS_DIR = USAGE_FILE.parent / "usage-limits"
74
85
  DAEMON_INTERVAL = 300 # 5 minutes
75
86
  UNAVAILABLE_RETRY_INTERVAL = 3600 # 1 hour
76
87
  TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
@@ -237,7 +248,7 @@ def fetch_usage() -> dict:
237
248
  """
238
249
  creds = get_credentials()
239
250
  if not creds:
240
- raise RuntimeError("No credentials at ~/.claude/.credentials.json — run `claude` first")
251
+ raise RuntimeError(f"No credentials at {CREDENTIALS_FILE} — run `claude` first")
241
252
 
242
253
  oauth = creds.get("claudeAiOauth", {})
243
254
  token = oauth.get("accessToken")
@@ -338,23 +349,50 @@ def _buckets_from_limits(limits) -> list:
338
349
  out = []
339
350
  for entry in limits:
340
351
  if not isinstance(entry, dict):
341
- continue
352
+ raise ValueError("Invalid usage response: each limit must be an object")
342
353
  pct = entry.get("percent")
343
354
  if pct is None:
344
355
  continue
356
+ if (
357
+ isinstance(pct, bool)
358
+ or not isinstance(pct, int | float)
359
+ or not math.isfinite(pct)
360
+ or not 0 <= pct <= 100
361
+ ):
362
+ raise ValueError("Invalid usage response: limit percent must be between 0 and 100")
345
363
  kind = entry.get("kind")
364
+ if not isinstance(kind, str) or not kind:
365
+ raise ValueError("Invalid usage response: limit kind must be a non-empty string")
346
366
  if kind == "session":
347
367
  short_key, order = "session", 0
348
368
  elif kind == "weekly_all":
349
369
  short_key, order = "7d", 1
350
370
  elif kind == "weekly_scoped":
351
- model = (entry.get("scope") or {}).get("model") or {}
352
- name = (model.get("display_name") or model.get("id") or "scoped").strip()
371
+ scope = entry.get("scope")
372
+ model = scope.get("model") if isinstance(scope, dict) else None
373
+ if not isinstance(model, dict):
374
+ raise ValueError("Invalid usage response: scoped limit must identify a model")
375
+ name = model.get("display_name") or model.get("id")
376
+ if not isinstance(name, str) or not name.strip():
377
+ raise ValueError("Invalid usage response: scoped limit must identify a model")
378
+ name = name.strip()
353
379
  short_key = "7d_" + name.lower().replace(" ", "_")
354
380
  order = {"7d_opus": 2, "7d_sonnet": 3}.get(short_key, 4)
355
381
  else:
356
- short_key, order = kind or "unknown", 5
357
- out.append((order, short_key, {"pct": pct, "resets_at": entry.get("resets_at")}))
382
+ short_key, order = kind, 5
383
+ resets_at = entry.get("resets_at")
384
+ if resets_at is not None:
385
+ if not isinstance(resets_at, str):
386
+ raise ValueError("Invalid usage response: reset time must be an ISO timestamp")
387
+ try:
388
+ reset = datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
389
+ except ValueError as exc:
390
+ raise ValueError(
391
+ "Invalid usage response: reset time must be an ISO timestamp"
392
+ ) from exc
393
+ if reset.tzinfo is None:
394
+ raise ValueError("Invalid usage response: reset time must include a timezone")
395
+ out.append((order, short_key, {"pct": pct, "resets_at": resets_at}))
358
396
  return out
359
397
 
360
398
 
@@ -372,7 +410,9 @@ def build_usage_json(api_data: dict, plan: str) -> dict:
372
410
  "updated_at": datetime.now(timezone.utc).isoformat(),
373
411
  }
374
412
  limits = api_data.get("limits")
375
- buckets = _buckets_from_limits(limits) if isinstance(limits, list) else []
413
+ if not isinstance(limits, list):
414
+ raise ValueError("Invalid usage response: limits must be an array")
415
+ buckets = _buckets_from_limits(limits)
376
416
  for _, short_key, bucket in sorted(buckets, key=lambda b: b[0]):
377
417
  result[short_key] = bucket
378
418
  extra = api_data.get("extra_usage")
@@ -412,15 +452,51 @@ def _unavailable_hint(data: dict) -> str | None:
412
452
  return unavailable.get("hint") or unavailable.get("message") or "usage unavailable"
413
453
 
414
454
 
415
- def _cache_unavailable(error: UsageUnavailableError) -> dict:
455
+ def _cache_unavailable(error: UsageUnavailableError, account: dict | None = None) -> dict:
416
456
  data = build_unavailable_usage(error, get_plan(), _read_cache())
417
- write_usage_file(data)
457
+ write_usage_file(data, account)
418
458
  return data
419
459
 
420
460
 
421
- def write_usage_file(data: dict):
422
- """Write usage data to ~/.claude/usage-limits.json."""
423
- USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
461
+ def current_account() -> dict | None:
462
+ """Return the logged-in account's UUID and email from Claude Code's config."""
463
+ if "CLAUDE_CONFIG_DIR" in os.environ:
464
+ path = _claude_config_dir() / ".claude.json"
465
+ else:
466
+ path = Path.home() / ".claude.json"
467
+ try:
468
+ account = json.loads(path.read_text()).get("oauthAccount") or {}
469
+ except (OSError, ValueError, AttributeError):
470
+ return None
471
+ if not account.get("accountUuid"):
472
+ return None
473
+ return {"uuid": account["accountUuid"], "email": account.get("emailAddress")}
474
+
475
+
476
+ def _write_json(path: Path, data: dict):
477
+ """Write JSON through a temporary file so readers never see a partial file."""
478
+ path.parent.mkdir(parents=True, exist_ok=True)
479
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
480
+ try:
481
+ with os.fdopen(fd, "w") as file:
482
+ file.write(json.dumps(data, indent=2) + "\n")
483
+ os.replace(tmp_name, path)
484
+ except BaseException:
485
+ Path(tmp_name).unlink(missing_ok=True)
486
+ raise
487
+
488
+
489
+ def write_usage_file(data: dict, account: dict | None = None):
490
+ """Write usage data to ~/.claude/usage-limits.json and the account's snapshot.
491
+
492
+ `account` is the logged-in account read before the usage was fetched. The
493
+ snapshot is skipped when a /login switched accounts during the fetch, since
494
+ the data could then belong to either account.
495
+ """
496
+ if account and current_account() == account:
497
+ data["account"] = account
498
+ _write_json(ACCOUNTS_DIR / f"{account['uuid']}.json", data)
499
+ _write_json(USAGE_FILE, data)
424
500
 
425
501
 
426
502
  def _read_cache() -> dict | None:
@@ -458,11 +534,12 @@ def cmd_status(raw_json=False):
458
534
  token) falls back to the last cached usage rather than crashing.
459
535
  """
460
536
  stale = False
537
+ account = current_account()
461
538
  try:
462
539
  api_data = fetch_usage()
463
540
  data = build_usage_json(api_data, get_plan())
464
541
  except UsageUnavailableError as e:
465
- data = _cache_unavailable(e)
542
+ data = _cache_unavailable(e, account)
466
543
  except Exception as e:
467
544
  cached = _read_cache()
468
545
  if cached is None:
@@ -530,10 +607,11 @@ def cmd_refresh():
530
607
  A one-shot equivalent of a single daemon tick — use it to force
531
608
  ~/.claude/usage-limits.json up to date without running the daemon.
532
609
  """
610
+ account = current_account()
533
611
  try:
534
612
  api_data = fetch_usage()
535
613
  except UsageUnavailableError as e:
536
- data = _cache_unavailable(e)
614
+ data = _cache_unavailable(e, account)
537
615
  print(f"Updated {USAGE_FILE}")
538
616
  print(f" Usage unavailable: {_unavailable_hint(data)}")
539
617
  return
@@ -542,7 +620,7 @@ def cmd_refresh():
542
620
  print(f"Could not refresh usage: {reason}", file=sys.stderr)
543
621
  sys.exit(1)
544
622
  data = build_usage_json(api_data, get_plan())
545
- write_usage_file(data)
623
+ write_usage_file(data, account)
546
624
  pcts = " ".join(f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data))
547
625
  print(f"Updated {USAGE_FILE}")
548
626
  if pcts:
@@ -560,16 +638,17 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
560
638
 
561
639
  backoff = 0
562
640
  while True:
641
+ account = current_account()
563
642
  try:
564
643
  api_data = fetch_usage()
565
644
  plan = get_plan()
566
645
  data = build_usage_json(api_data, plan)
567
- write_usage_file(data)
646
+ write_usage_file(data, account)
568
647
  backoff = 0
569
648
  pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
570
649
  print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
571
650
  except UsageUnavailableError as e:
572
- data = _cache_unavailable(e)
651
+ data = _cache_unavailable(e, account)
573
652
  backoff = UNAVAILABLE_RETRY_INTERVAL
574
653
  print(
575
654
  f"[{datetime.now().strftime('%H:%M:%S')}] "
@@ -599,13 +678,14 @@ def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
599
678
  except Exception:
600
679
  pass
601
680
  # Cache is stale or missing — try to refresh
681
+ account = current_account()
602
682
  try:
603
683
  api_data = fetch_usage()
604
684
  usage = build_usage_json(api_data, get_plan())
605
- write_usage_file(usage)
685
+ write_usage_file(usage, account)
606
686
  return usage
607
687
  except UsageUnavailableError as e:
608
- return _cache_unavailable(e)
688
+ return _cache_unavailable(e, account)
609
689
  except Exception:
610
690
  # Return whatever we had, even if stale
611
691
  try:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import io
4
4
  import json
5
+ import os
5
6
  import tempfile
6
7
  import time
7
8
  import unittest
@@ -46,6 +47,33 @@ REFRESH_RESULT = {
46
47
  }
47
48
 
48
49
 
50
+ class ClaudePathTests(unittest.TestCase):
51
+ def test_config_dir_override_matches_claude_code(self):
52
+ with tempfile.TemporaryDirectory() as tmp:
53
+ config_dir = Path(tmp) / "claude-config"
54
+ with mock.patch.dict(
55
+ os.environ, {"CLAUDE_CONFIG_DIR": str(config_dir)}, clear=False
56
+ ):
57
+ path = ccusage._resolve_claude_path(".credentials.json")
58
+
59
+ self.assertEqual(path, config_dir / ".credentials.json")
60
+
61
+ def test_config_dir_override_does_not_fall_back_to_wsl(self):
62
+ with tempfile.TemporaryDirectory() as tmp:
63
+ config_dir = Path(tmp) / "missing-config"
64
+ with (
65
+ mock.patch.dict(
66
+ os.environ, {"CLAUDE_CONFIG_DIR": str(config_dir)}, clear=False
67
+ ),
68
+ mock.patch.object(ccusage.sys, "platform", "win32"),
69
+ mock.patch.object(ccusage.subprocess, "run") as run_mock,
70
+ ):
71
+ path = ccusage._resolve_claude_path(".credentials.json")
72
+
73
+ self.assertEqual(path, config_dir / ".credentials.json")
74
+ run_mock.assert_not_called()
75
+
76
+
49
77
  class FetchUsageTests(unittest.TestCase):
50
78
  def setUp(self):
51
79
  self._tmp = tempfile.TemporaryDirectory()
@@ -279,6 +307,7 @@ class BuildUsageJsonTests(unittest.TestCase):
279
307
  }
280
308
  with (
281
309
  mock.patch.object(ccusage, "_get_cached_usage", return_value=usage),
310
+ mock.patch.object(ccusage, "_TTY", False),
282
311
  mock.patch("sys.stdin", io.StringIO(json.dumps(status_input))),
283
312
  mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
284
313
  ):
@@ -296,6 +325,50 @@ class BuildUsageJsonTests(unittest.TestCase):
296
325
  result = ccusage.build_usage_json(api_data, "max_20x")
297
326
  self.assertEqual([k for k, _ in ccusage._quota_buckets(result)], [])
298
327
 
328
+ def test_rejects_missing_or_ill_typed_limits(self):
329
+ for api_data in ({}, {"limits": None}, {"limits": {}}, {"limits": "bad"}):
330
+ with self.subTest(api_data=api_data):
331
+ with self.assertRaisesRegex(ValueError, "limits must be an array"):
332
+ ccusage.build_usage_json(api_data, "max_20x")
333
+
334
+ def test_rejects_malformed_limit_entries(self):
335
+ malformed = [
336
+ "bad",
337
+ {"kind": None, "percent": 10},
338
+ {"kind": "weekly_scoped", "percent": 10, "scope": []},
339
+ {"kind": "weekly_scoped", "percent": 10, "scope": {"model": {}}},
340
+ ]
341
+ for entry in malformed:
342
+ with self.subTest(entry=entry):
343
+ with self.assertRaisesRegex(ValueError, "Invalid usage response"):
344
+ ccusage.build_usage_json({"limits": [entry]}, "max_20x")
345
+
346
+ def test_rejects_non_finite_or_out_of_range_percentages(self):
347
+ for pct in (float("nan"), float("inf"), float("-inf"), -0.1, 100.1, True, "10"):
348
+ with self.subTest(pct=pct):
349
+ with self.assertRaisesRegex(ValueError, "percent must be between 0 and 100"):
350
+ ccusage.build_usage_json(
351
+ {"limits": [{"kind": "session", "percent": pct}]},
352
+ "max_20x",
353
+ )
354
+
355
+ def test_rejects_invalid_reset_times(self):
356
+ for resets_at in (42, "not-a-time", "2026-07-04T05:10:00"):
357
+ with self.subTest(resets_at=resets_at):
358
+ with self.assertRaisesRegex(ValueError, "reset time"):
359
+ ccusage.build_usage_json(
360
+ {
361
+ "limits": [
362
+ {
363
+ "kind": "session",
364
+ "percent": 10,
365
+ "resets_at": resets_at,
366
+ }
367
+ ]
368
+ },
369
+ "max_20x",
370
+ )
371
+
299
372
  def test_unavailable_tombstone_preserves_success_time_not_quota_values(self):
300
373
  previous = {
301
374
  "plan": "max_20x",
@@ -391,5 +464,78 @@ class UnavailableCacheTests(unittest.TestCase):
391
464
  self.assertNotIn("sess:", output)
392
465
 
393
466
 
467
+ class AccountSnapshotTests(unittest.TestCase):
468
+ def setUp(self):
469
+ self._tmp = tempfile.TemporaryDirectory()
470
+ self.addCleanup(self._tmp.cleanup)
471
+ self.root = Path(self._tmp.name)
472
+ self.usage_file = self.root / "usage-limits.json"
473
+ self.accounts_dir = self.root / "usage-limits"
474
+ for name, value in (("USAGE_FILE", self.usage_file), ("ACCOUNTS_DIR", self.accounts_dir)):
475
+ patcher = mock.patch.object(ccusage, name, value)
476
+ patcher.start()
477
+ self.addCleanup(patcher.stop)
478
+ self.config_dir = self.root / "config"
479
+ self.config_dir.mkdir()
480
+ env = mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(self.config_dir)}, clear=False)
481
+ env.start()
482
+ self.addCleanup(env.stop)
483
+
484
+ def _login(self, uuid: str, email: str):
485
+ (self.config_dir / ".claude.json").write_text(json.dumps({
486
+ "oauthAccount": {"accountUuid": uuid, "emailAddress": email, "organizationName": "x"},
487
+ }))
488
+
489
+ def test_current_account_reads_uuid_and_email_from_config_dir(self):
490
+ self._login("uuid-a", "a@example.com")
491
+ self.assertEqual(ccusage.current_account(), {"uuid": "uuid-a", "email": "a@example.com"})
492
+
493
+ def test_current_account_is_none_without_login(self):
494
+ self.assertIsNone(ccusage.current_account())
495
+ (self.config_dir / ".claude.json").write_text(json.dumps({"oauthAccount": None}))
496
+ self.assertIsNone(ccusage.current_account())
497
+
498
+ def test_write_keeps_a_snapshot_per_account(self):
499
+ self._login("uuid-a", "a@example.com")
500
+ account_a = ccusage.current_account()
501
+ ccusage.write_usage_file({"7d": {"pct": 91}}, account_a)
502
+ self._login("uuid-b", "b@example.com")
503
+ account_b = ccusage.current_account()
504
+ ccusage.write_usage_file({"7d": {"pct": 5}}, account_b)
505
+
506
+ snapshot_a = json.loads((self.accounts_dir / "uuid-a.json").read_text())
507
+ snapshot_b = json.loads((self.accounts_dir / "uuid-b.json").read_text())
508
+ main = json.loads(self.usage_file.read_text())
509
+ self.assertEqual(snapshot_a["7d"]["pct"], 91)
510
+ self.assertEqual(snapshot_a["account"]["email"], "a@example.com")
511
+ self.assertEqual(snapshot_b["7d"]["pct"], 5)
512
+ self.assertEqual(main, snapshot_b)
513
+
514
+ def test_snapshot_skipped_when_login_changes_during_fetch(self):
515
+ self._login("uuid-a", "a@example.com")
516
+ account_before_fetch = ccusage.current_account()
517
+ self._login("uuid-b", "b@example.com")
518
+ ccusage.write_usage_file({"7d": {"pct": 40}}, account_before_fetch)
519
+
520
+ self.assertFalse(self.accounts_dir.exists())
521
+ main = json.loads(self.usage_file.read_text())
522
+ self.assertNotIn("account", main)
523
+ self.assertEqual(main["7d"]["pct"], 40)
524
+
525
+ def test_refresh_writes_snapshot_for_logged_in_account(self):
526
+ self._login("uuid-a", "a@example.com")
527
+ api = {"limits": [{"name": "seven_day", "utilization": 12.0, "resets_at": None}]}
528
+ with (
529
+ mock.patch.object(ccusage, "fetch_usage", return_value=api),
530
+ mock.patch.object(ccusage, "get_plan", return_value="max_20x"),
531
+ mock.patch("sys.stdout", new_callable=io.StringIO),
532
+ ):
533
+ ccusage.cmd_refresh()
534
+
535
+ snapshot = json.loads((self.accounts_dir / "uuid-a.json").read_text())
536
+ self.assertEqual(snapshot["account"]["uuid"], "uuid-a")
537
+ self.assertEqual(snapshot["plan"], "max_20x")
538
+
539
+
394
540
  if __name__ == "__main__":
395
541
  unittest.main()
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "ccusage"
7
- version = "0.1.11"
7
+ version = "0.2.0"
8
8
  source = { editable = "." }
@@ -1,29 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- push:
5
- tags:
6
- - "v*"
7
-
8
- jobs:
9
- publish:
10
- runs-on: ubuntu-latest
11
- environment: pypi
12
- permissions:
13
- contents: read
14
- id-token: write
15
- steps:
16
- - name: Checkout
17
- uses: actions/checkout@v7.0.1
18
- - name: Install uv
19
- uses: astral-sh/setup-uv@v9.0.0
20
- with:
21
- enable-cache: false
22
- - name: Verify version tag
23
- run: test "$GITHUB_REF_NAME" = "v$(uv version --short)"
24
- - name: Test
25
- run: uv run --locked python -m unittest discover -s tests
26
- - name: Build
27
- run: uv build --no-sources
28
- - name: Publish
29
- run: uv publish --trusted-publishing always
File without changes
File without changes