ccusage 0.1.12__tar.gz → 0.3.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.3.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.3.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.
@@ -50,13 +61,17 @@ Configure the statusline in `~/.claude/settings.json`:
50
61
  If Claude Code uses `CLAUDE_CONFIG_DIR`, ccusage reads credentials and writes
51
62
  `usage-limits.json` in that directory instead.
52
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
+
66
+ To keep a second account current while you are logged into another, give it its own config directory with a permanent login, run a second daemon there with `CLAUDE_CONFIG_DIR` set, and symlink that directory's `usage-limits` folder to the main one so both daemons share their snapshots. Start that daemon with `--defer`: it then skips a refresh whenever another daemon saved the same account's snapshot within the last one and a half intervals, so an account logged in under two config directories is polled once.
67
+
53
68
  ## Commands
54
69
 
55
70
  | Command | Description |
56
71
  |---------|-------------|
57
72
  | `ccusage` | Show current usage (colored terminal output) |
58
73
  | `ccusage json` | Print raw JSON |
59
- | `ccusage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
74
+ | `ccusage daemon [-i SECS] [--defer]` | Run in foreground, refresh every 5 min (customizable) |
60
75
  | `ccusage statusline` | Claude Code statusline (self-caching, no daemon needed) |
61
76
  | `ccusage install` | Print setup instructions |
62
77
 
@@ -177,4 +192,5 @@ seven_day: 75% at 60%, 50% at 35%, 25% at 15%
177
192
  | `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
178
193
  | `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
179
194
  | `~/.claude/usage-limits.json` | ccusage daemon | Cached API usage data (this tool) |
195
+ | `~/.claude/usage-limits/<account uuid>.json` | ccusage daemon | Last usage seen for each Claude account (this tool) |
180
196
  | `~/.claude/statsig/` | Claude Code | Feature flags, experiment assignments |
@@ -1,12 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: ccusage
3
- Version: 0.1.12
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.
@@ -59,13 +50,17 @@ Configure the statusline in `~/.claude/settings.json`:
59
50
  If Claude Code uses `CLAUDE_CONFIG_DIR`, ccusage reads credentials and writes
60
51
  `usage-limits.json` in that directory instead.
61
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
+
55
+ To keep a second account current while you are logged into another, give it its own config directory with a permanent login, run a second daemon there with `CLAUDE_CONFIG_DIR` set, and symlink that directory's `usage-limits` folder to the main one so both daemons share their snapshots. Start that daemon with `--defer`: it then skips a refresh whenever another daemon saved the same account's snapshot within the last one and a half intervals, so an account logged in under two config directories is polled once.
56
+
62
57
  ## Commands
63
58
 
64
59
  | Command | Description |
65
60
  |---------|-------------|
66
61
  | `ccusage` | Show current usage (colored terminal output) |
67
62
  | `ccusage json` | Print raw JSON |
68
- | `ccusage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
63
+ | `ccusage daemon [-i SECS] [--defer]` | Run in foreground, refresh every 5 min (customizable) |
69
64
  | `ccusage statusline` | Claude Code statusline (self-caching, no daemon needed) |
70
65
  | `ccusage install` | Print setup instructions |
71
66
 
@@ -186,4 +181,5 @@ seven_day: 75% at 60%, 50% at 35%, 25% at 15%
186
181
  | `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
187
182
  | `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
188
183
  | `~/.claude/usage-limits.json` | ccusage daemon | Cached API usage data (this tool) |
184
+ | `~/.claude/usage-limits/<account uuid>.json` | ccusage daemon | Last usage seen for each Claude account (this tool) |
189
185
  | `~/.claude/statsig/` | Claude Code | Feature flags, experiment assignments |
@@ -1,9 +1,11 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.1.12"
3
+ version = "0.3.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)
@@ -0,0 +1,11 @@
1
+ `ccusage daemon --defer` lets you keep several Claude accounts current without polling any of them twice.
2
+
3
+ To track an account you are not logged into, give it its own config directory with a permanent login, run a second daemon there with `CLAUDE_CONFIG_DIR` set, and symlink that directory's `usage-limits` folder to the main one. If you then log your main directory into the same account, both daemons would poll it. A daemon started with `--defer` skips a refresh whenever another daemon saved the same account's snapshot within the last one and a half refresh intervals, and starts refreshing again once that other daemon moves to a different account. Daemons without the flag behave as before.
4
+
5
+ ## Changes
6
+
7
+ - Add daemon --defer to poll an account once across config directories ([`97d96a2`](https://github.com/wakamex/ccusage/commit/97d96a2b70e3424cece8f32bc8cac440df93c68c))
8
+
9
+ ## History
10
+
11
+ [v0.2.0...v0.3.0](https://github.com/wakamex/ccusage/compare/v0.2.0...v0.3.0)
@@ -9,6 +9,7 @@ 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
  """
@@ -77,6 +78,10 @@ def _resolve_claude_path(relative: str) -> Path:
77
78
  CLAUDE_DIR = _claude_config_dir()
78
79
  CREDENTIALS_FILE = _resolve_claude_path(".credentials.json")
79
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"
80
85
  DAEMON_INTERVAL = 300 # 5 minutes
81
86
  UNAVAILABLE_RETRY_INTERVAL = 3600 # 1 hour
82
87
  TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
@@ -447,15 +452,51 @@ def _unavailable_hint(data: dict) -> str | None:
447
452
  return unavailable.get("hint") or unavailable.get("message") or "usage unavailable"
448
453
 
449
454
 
450
- def _cache_unavailable(error: UsageUnavailableError) -> dict:
455
+ def _cache_unavailable(error: UsageUnavailableError, account: dict | None = None) -> dict:
451
456
  data = build_unavailable_usage(error, get_plan(), _read_cache())
452
- write_usage_file(data)
457
+ write_usage_file(data, account)
453
458
  return data
454
459
 
455
460
 
456
- def write_usage_file(data: dict):
457
- """Write usage data to ~/.claude/usage-limits.json."""
458
- 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)
459
500
 
460
501
 
461
502
  def _read_cache() -> dict | None:
@@ -493,11 +534,12 @@ def cmd_status(raw_json=False):
493
534
  token) falls back to the last cached usage rather than crashing.
494
535
  """
495
536
  stale = False
537
+ account = current_account()
496
538
  try:
497
539
  api_data = fetch_usage()
498
540
  data = build_usage_json(api_data, get_plan())
499
541
  except UsageUnavailableError as e:
500
- data = _cache_unavailable(e)
542
+ data = _cache_unavailable(e, account)
501
543
  except Exception as e:
502
544
  cached = _read_cache()
503
545
  if cached is None:
@@ -565,10 +607,11 @@ def cmd_refresh():
565
607
  A one-shot equivalent of a single daemon tick — use it to force
566
608
  ~/.claude/usage-limits.json up to date without running the daemon.
567
609
  """
610
+ account = current_account()
568
611
  try:
569
612
  api_data = fetch_usage()
570
613
  except UsageUnavailableError as e:
571
- data = _cache_unavailable(e)
614
+ data = _cache_unavailable(e, account)
572
615
  print(f"Updated {USAGE_FILE}")
573
616
  print(f" Usage unavailable: {_unavailable_hint(data)}")
574
617
  return
@@ -577,15 +620,38 @@ def cmd_refresh():
577
620
  print(f"Could not refresh usage: {reason}", file=sys.stderr)
578
621
  sys.exit(1)
579
622
  data = build_usage_json(api_data, get_plan())
580
- write_usage_file(data)
623
+ write_usage_file(data, account)
581
624
  pcts = " ".join(f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data))
582
625
  print(f"Updated {USAGE_FILE}")
583
626
  if pcts:
584
627
  print(f" {pcts}")
585
628
 
586
629
 
587
- def cmd_daemon(interval: int = DAEMON_INTERVAL):
588
- """Run in foreground, refresh every `interval` seconds."""
630
+ def _covered_by_another_daemon(account: dict | None, own_stamp: str | None, interval: int) -> bool:
631
+ """True when a different daemon recently saved this account's snapshot.
632
+
633
+ Daemons for several config directories can share one snapshot directory
634
+ through a symlink. A snapshot whose timestamp this daemon did not write,
635
+ and that is younger than one and a half intervals, means another daemon is
636
+ polling the same account.
637
+ """
638
+ if not account:
639
+ return False
640
+ try:
641
+ stamp = json.loads((ACCOUNTS_DIR / f"{account['uuid']}.json").read_text())["updated_at"]
642
+ age = (datetime.now(timezone.utc) - datetime.fromisoformat(stamp)).total_seconds()
643
+ except (OSError, ValueError, KeyError, TypeError):
644
+ return False
645
+ return stamp != own_stamp and age < 1.5 * interval
646
+
647
+
648
+ def cmd_daemon(interval: int = DAEMON_INTERVAL, defer: bool = False):
649
+ """Run in foreground, refresh every `interval` seconds.
650
+
651
+ With `defer`, skip a refresh while another daemon keeps this account's
652
+ snapshot current, so an account logged in under two config directories is
653
+ polled once.
654
+ """
589
655
  signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
590
656
  if hasattr(signal, "SIGTERM"):
591
657
  signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
@@ -594,17 +660,29 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
594
660
  print(f"Writing to {USAGE_FILE}")
595
661
 
596
662
  backoff = 0
663
+ own_stamp = None
664
+ deferring = False
597
665
  while True:
666
+ account = current_account()
667
+ if defer and _covered_by_another_daemon(account, own_stamp, interval):
668
+ if not deferring:
669
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] deferring to another daemon for this account")
670
+ deferring = True
671
+ time.sleep(interval)
672
+ continue
673
+ deferring = False
598
674
  try:
599
675
  api_data = fetch_usage()
600
676
  plan = get_plan()
601
677
  data = build_usage_json(api_data, plan)
602
- write_usage_file(data)
678
+ write_usage_file(data, account)
679
+ own_stamp = data["updated_at"]
603
680
  backoff = 0
604
681
  pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
605
682
  print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
606
683
  except UsageUnavailableError as e:
607
- data = _cache_unavailable(e)
684
+ data = _cache_unavailable(e, account)
685
+ own_stamp = data["updated_at"]
608
686
  backoff = UNAVAILABLE_RETRY_INTERVAL
609
687
  print(
610
688
  f"[{datetime.now().strftime('%H:%M:%S')}] "
@@ -634,13 +712,14 @@ def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
634
712
  except Exception:
635
713
  pass
636
714
  # Cache is stale or missing — try to refresh
715
+ account = current_account()
637
716
  try:
638
717
  api_data = fetch_usage()
639
718
  usage = build_usage_json(api_data, get_plan())
640
- write_usage_file(usage)
719
+ write_usage_file(usage, account)
641
720
  return usage
642
721
  except UsageUnavailableError as e:
643
- return _cache_unavailable(e)
722
+ return _cache_unavailable(e, account)
644
723
  except Exception:
645
724
  # Return whatever we had, even if stale
646
725
  try:
@@ -748,6 +827,8 @@ def main():
748
827
  daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
749
828
  daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
750
829
  help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
830
+ daemon_parser.add_argument("--defer", action="store_true",
831
+ help="Skip a refresh while another daemon keeps this account's snapshot current")
751
832
  sub.add_parser("statusline", help="Claude Code statusline (reads stdin + cache)")
752
833
  sub.add_parser("install", help="Print setup instructions")
753
834
  args = parser.parse_args()
@@ -760,7 +841,7 @@ def main():
760
841
  elif cmd == "refresh":
761
842
  cmd_refresh()
762
843
  elif cmd == "daemon":
763
- cmd_daemon(interval=args.interval)
844
+ cmd_daemon(interval=args.interval, defer=args.defer)
764
845
  elif cmd == "statusline":
765
846
  cmd_statusline()
766
847
  elif cmd == "install":
@@ -7,6 +7,7 @@ import tempfile
7
7
  import time
8
8
  import unittest
9
9
  import urllib.error
10
+ from datetime import datetime, timedelta, timezone
10
11
  from pathlib import Path
11
12
  from unittest import mock
12
13
 
@@ -464,5 +465,143 @@ class UnavailableCacheTests(unittest.TestCase):
464
465
  self.assertNotIn("sess:", output)
465
466
 
466
467
 
468
+ class AccountSnapshotTests(unittest.TestCase):
469
+ def setUp(self):
470
+ self._tmp = tempfile.TemporaryDirectory()
471
+ self.addCleanup(self._tmp.cleanup)
472
+ self.root = Path(self._tmp.name)
473
+ self.usage_file = self.root / "usage-limits.json"
474
+ self.accounts_dir = self.root / "usage-limits"
475
+ for name, value in (("USAGE_FILE", self.usage_file), ("ACCOUNTS_DIR", self.accounts_dir)):
476
+ patcher = mock.patch.object(ccusage, name, value)
477
+ patcher.start()
478
+ self.addCleanup(patcher.stop)
479
+ self.config_dir = self.root / "config"
480
+ self.config_dir.mkdir()
481
+ env = mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": str(self.config_dir)}, clear=False)
482
+ env.start()
483
+ self.addCleanup(env.stop)
484
+
485
+ def _login(self, uuid: str, email: str):
486
+ (self.config_dir / ".claude.json").write_text(json.dumps({
487
+ "oauthAccount": {"accountUuid": uuid, "emailAddress": email, "organizationName": "x"},
488
+ }))
489
+
490
+ def test_current_account_reads_uuid_and_email_from_config_dir(self):
491
+ self._login("uuid-a", "a@example.com")
492
+ self.assertEqual(ccusage.current_account(), {"uuid": "uuid-a", "email": "a@example.com"})
493
+
494
+ def test_current_account_is_none_without_login(self):
495
+ self.assertIsNone(ccusage.current_account())
496
+ (self.config_dir / ".claude.json").write_text(json.dumps({"oauthAccount": None}))
497
+ self.assertIsNone(ccusage.current_account())
498
+
499
+ def test_write_keeps_a_snapshot_per_account(self):
500
+ self._login("uuid-a", "a@example.com")
501
+ account_a = ccusage.current_account()
502
+ ccusage.write_usage_file({"7d": {"pct": 91}}, account_a)
503
+ self._login("uuid-b", "b@example.com")
504
+ account_b = ccusage.current_account()
505
+ ccusage.write_usage_file({"7d": {"pct": 5}}, account_b)
506
+
507
+ snapshot_a = json.loads((self.accounts_dir / "uuid-a.json").read_text())
508
+ snapshot_b = json.loads((self.accounts_dir / "uuid-b.json").read_text())
509
+ main = json.loads(self.usage_file.read_text())
510
+ self.assertEqual(snapshot_a["7d"]["pct"], 91)
511
+ self.assertEqual(snapshot_a["account"]["email"], "a@example.com")
512
+ self.assertEqual(snapshot_b["7d"]["pct"], 5)
513
+ self.assertEqual(main, snapshot_b)
514
+
515
+ def test_snapshot_skipped_when_login_changes_during_fetch(self):
516
+ self._login("uuid-a", "a@example.com")
517
+ account_before_fetch = ccusage.current_account()
518
+ self._login("uuid-b", "b@example.com")
519
+ ccusage.write_usage_file({"7d": {"pct": 40}}, account_before_fetch)
520
+
521
+ self.assertFalse(self.accounts_dir.exists())
522
+ main = json.loads(self.usage_file.read_text())
523
+ self.assertNotIn("account", main)
524
+ self.assertEqual(main["7d"]["pct"], 40)
525
+
526
+ def test_refresh_writes_snapshot_for_logged_in_account(self):
527
+ self._login("uuid-a", "a@example.com")
528
+ api = {"limits": [{"name": "seven_day", "utilization": 12.0, "resets_at": None}]}
529
+ with (
530
+ mock.patch.object(ccusage, "fetch_usage", return_value=api),
531
+ mock.patch.object(ccusage, "get_plan", return_value="max_20x"),
532
+ mock.patch("sys.stdout", new_callable=io.StringIO),
533
+ ):
534
+ ccusage.cmd_refresh()
535
+
536
+ snapshot = json.loads((self.accounts_dir / "uuid-a.json").read_text())
537
+ self.assertEqual(snapshot["account"]["uuid"], "uuid-a")
538
+ self.assertEqual(snapshot["plan"], "max_20x")
539
+
540
+
541
+
542
+ class DeferTests(unittest.TestCase):
543
+ def setUp(self):
544
+ self._tmp = tempfile.TemporaryDirectory()
545
+ self.addCleanup(self._tmp.cleanup)
546
+ self.accounts_dir = Path(self._tmp.name) / "usage-limits"
547
+ self.accounts_dir.mkdir()
548
+ patcher = mock.patch.object(ccusage, "ACCOUNTS_DIR", self.accounts_dir)
549
+ patcher.start()
550
+ self.addCleanup(patcher.stop)
551
+ self.account = {"uuid": "uuid-a", "email": "a@example.com"}
552
+
553
+ def _snapshot(self, seconds_old: float) -> str:
554
+ stamp = (datetime.now(timezone.utc) - timedelta(seconds=seconds_old)).isoformat()
555
+ (self.accounts_dir / "uuid-a.json").write_text(json.dumps({"updated_at": stamp}))
556
+ return stamp
557
+
558
+ def test_fresh_snapshot_from_another_daemon_covers_the_account(self):
559
+ self._snapshot(100)
560
+ self.assertTrue(ccusage._covered_by_another_daemon(self.account, "own-earlier-stamp", 300))
561
+ self.assertTrue(ccusage._covered_by_another_daemon(self.account, None, 300))
562
+
563
+ def test_own_snapshot_does_not_cover_the_account(self):
564
+ stamp = self._snapshot(100)
565
+ self.assertFalse(ccusage._covered_by_another_daemon(self.account, stamp, 300))
566
+
567
+ def test_stale_snapshot_from_another_daemon_does_not_cover(self):
568
+ self._snapshot(451)
569
+ self.assertFalse(ccusage._covered_by_another_daemon(self.account, None, 300))
570
+
571
+ def test_missing_snapshot_or_account_does_not_cover(self):
572
+ self.assertFalse(ccusage._covered_by_another_daemon(self.account, None, 300))
573
+ self.assertFalse(ccusage._covered_by_another_daemon(None, None, 300))
574
+
575
+ def _run_daemon_once(self, defer: bool):
576
+ def stop(_seconds):
577
+ raise KeyboardInterrupt
578
+
579
+ api = {"limits": [{"name": "seven_day", "utilization": 12.0, "resets_at": None}]}
580
+ with (
581
+ mock.patch.object(ccusage, "current_account", return_value=self.account),
582
+ mock.patch.object(ccusage, "fetch_usage", return_value=api) as fetch,
583
+ mock.patch.object(ccusage, "get_plan", return_value="max_20x"),
584
+ mock.patch.object(ccusage, "write_usage_file"),
585
+ mock.patch.object(ccusage.signal, "signal"),
586
+ mock.patch.object(ccusage.time, "sleep", side_effect=stop),
587
+ mock.patch("sys.stdout", new_callable=io.StringIO),
588
+ self.assertRaises(KeyboardInterrupt),
589
+ ):
590
+ ccusage.cmd_daemon(interval=300, defer=defer)
591
+ return fetch
592
+
593
+ def test_deferring_daemon_skips_the_fetch_when_covered(self):
594
+ self._snapshot(100)
595
+ self._run_daemon_once(defer=True).assert_not_called()
596
+
597
+ def test_daemon_without_defer_always_fetches(self):
598
+ self._snapshot(100)
599
+ self._run_daemon_once(defer=False).assert_called_once()
600
+
601
+ def test_deferring_daemon_fetches_when_not_covered(self):
602
+ self._snapshot(451)
603
+ self._run_daemon_once(defer=True).assert_called_once()
604
+
605
+
467
606
  if __name__ == "__main__":
468
607
  unittest.main()
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "ccusage"
7
- version = "0.1.12"
7
+ version = "0.3.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 --no-config version --short)"
24
- - name: Test
25
- run: uv --no-config run --locked python -m unittest discover -s tests
26
- - name: Build
27
- run: uv --no-config build --no-sources
28
- - name: Publish
29
- run: uv publish --trusted-publishing always
File without changes
File without changes