ccusage 0.2.0__tar.gz → 0.3.1__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,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ccusage
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -63,13 +63,15 @@ If Claude Code uses `CLAUDE_CONFIG_DIR`, ccusage reads credentials and writes
63
63
 
64
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
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
+
66
68
  ## Commands
67
69
 
68
70
  | Command | Description |
69
71
  |---------|-------------|
70
72
  | `ccusage` | Show current usage (colored terminal output) |
71
73
  | `ccusage json` | Print raw JSON |
72
- | `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) |
73
75
  | `ccusage statusline` | Claude Code statusline (self-caching, no daemon needed) |
74
76
  | `ccusage install` | Print setup instructions |
75
77
 
@@ -52,13 +52,15 @@ If Claude Code uses `CLAUDE_CONFIG_DIR`, ccusage reads credentials and writes
52
52
 
53
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
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
+
55
57
  ## Commands
56
58
 
57
59
  | Command | Description |
58
60
  |---------|-------------|
59
61
  | `ccusage` | Show current usage (colored terminal output) |
60
62
  | `ccusage json` | Print raw JSON |
61
- | `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) |
62
64
  | `ccusage statusline` | Claude Code statusline (self-caching, no daemon needed) |
63
65
  | `ccusage install` | Print setup instructions |
64
66
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ccusage"
3
- version = "0.2.0"
3
+ version = "0.3.1"
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"
@@ -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)
@@ -0,0 +1,9 @@
1
+ ccusage can renew an expired Claude login by itself again. Since Anthropic stopped accepting its refresh request, a config directory that Claude Code does not keep fresh, such as one used only by a second daemon, logged `Token refresh failed (403)` on every poll once its access token expired, about 8 hours after login. Directories that Claude Code uses were unaffected, because Claude Code renews their tokens.
2
+
3
+ ## Changes
4
+
5
+ - Fix OAuth token refresh, which Anthropic rejected ([`5f4bad5`](https://github.com/wakamex/ccusage/commit/5f4bad521f16154864aafc7b71b328de279e6176))
6
+
7
+ ## History
8
+
9
+ [v0.3.0...v0.3.1](https://github.com/wakamex/ccusage/compare/v0.3.0...v0.3.1)
@@ -84,7 +84,9 @@ USAGE_FILE = _resolve_claude_path("usage-limits.json")
84
84
  ACCOUNTS_DIR = USAGE_FILE.parent / "usage-limits"
85
85
  DAEMON_INTERVAL = 300 # 5 minutes
86
86
  UNAVAILABLE_RETRY_INTERVAL = 3600 # 1 hour
87
- TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
87
+ TOKEN_URL = "https://api.anthropic.com/v1/oauth/token"
88
+ # Anthropic's edge rejects the default urllib User-Agent with a 403.
89
+ USER_AGENT = "claude-code/2.1.71"
88
90
  CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" # Claude Code's public OAuth client
89
91
 
90
92
 
@@ -203,7 +205,7 @@ def refresh_credentials(creds: dict) -> dict:
203
205
  req = urllib.request.Request(
204
206
  TOKEN_URL,
205
207
  data=payload,
206
- headers={"Content-Type": "application/json"},
208
+ headers={"Content-Type": "application/json", "User-Agent": USER_AGENT},
207
209
  )
208
210
  try:
209
211
  with urllib.request.urlopen(req, timeout=10) as resp:
@@ -267,7 +269,7 @@ def fetch_usage() -> dict:
267
269
  headers={
268
270
  "Authorization": f"Bearer {token}",
269
271
  "Content-Type": "application/json",
270
- "User-Agent": "claude-code/2.1.71",
272
+ "User-Agent": USER_AGENT,
271
273
  "anthropic-beta": "oauth-2025-04-20",
272
274
  },
273
275
  )
@@ -627,8 +629,31 @@ def cmd_refresh():
627
629
  print(f" {pcts}")
628
630
 
629
631
 
630
- def cmd_daemon(interval: int = DAEMON_INTERVAL):
631
- """Run in foreground, refresh every `interval` seconds."""
632
+ def _covered_by_another_daemon(account: dict | None, own_stamp: str | None, interval: int) -> bool:
633
+ """True when a different daemon recently saved this account's snapshot.
634
+
635
+ Daemons for several config directories can share one snapshot directory
636
+ through a symlink. A snapshot whose timestamp this daemon did not write,
637
+ and that is younger than one and a half intervals, means another daemon is
638
+ polling the same account.
639
+ """
640
+ if not account:
641
+ return False
642
+ try:
643
+ stamp = json.loads((ACCOUNTS_DIR / f"{account['uuid']}.json").read_text())["updated_at"]
644
+ age = (datetime.now(timezone.utc) - datetime.fromisoformat(stamp)).total_seconds()
645
+ except (OSError, ValueError, KeyError, TypeError):
646
+ return False
647
+ return stamp != own_stamp and age < 1.5 * interval
648
+
649
+
650
+ def cmd_daemon(interval: int = DAEMON_INTERVAL, defer: bool = False):
651
+ """Run in foreground, refresh every `interval` seconds.
652
+
653
+ With `defer`, skip a refresh while another daemon keeps this account's
654
+ snapshot current, so an account logged in under two config directories is
655
+ polled once.
656
+ """
632
657
  signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
633
658
  if hasattr(signal, "SIGTERM"):
634
659
  signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
@@ -637,18 +662,29 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
637
662
  print(f"Writing to {USAGE_FILE}")
638
663
 
639
664
  backoff = 0
665
+ own_stamp = None
666
+ deferring = False
640
667
  while True:
641
668
  account = current_account()
669
+ if defer and _covered_by_another_daemon(account, own_stamp, interval):
670
+ if not deferring:
671
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] deferring to another daemon for this account")
672
+ deferring = True
673
+ time.sleep(interval)
674
+ continue
675
+ deferring = False
642
676
  try:
643
677
  api_data = fetch_usage()
644
678
  plan = get_plan()
645
679
  data = build_usage_json(api_data, plan)
646
680
  write_usage_file(data, account)
681
+ own_stamp = data["updated_at"]
647
682
  backoff = 0
648
683
  pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
649
684
  print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
650
685
  except UsageUnavailableError as e:
651
686
  data = _cache_unavailable(e, account)
687
+ own_stamp = data["updated_at"]
652
688
  backoff = UNAVAILABLE_RETRY_INTERVAL
653
689
  print(
654
690
  f"[{datetime.now().strftime('%H:%M:%S')}] "
@@ -793,6 +829,8 @@ def main():
793
829
  daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
794
830
  daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
795
831
  help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
832
+ daemon_parser.add_argument("--defer", action="store_true",
833
+ help="Skip a refresh while another daemon keeps this account's snapshot current")
796
834
  sub.add_parser("statusline", help="Claude Code statusline (reads stdin + cache)")
797
835
  sub.add_parser("install", help="Print setup instructions")
798
836
  args = parser.parse_args()
@@ -805,7 +843,7 @@ def main():
805
843
  elif cmd == "refresh":
806
844
  cmd_refresh()
807
845
  elif cmd == "daemon":
808
- cmd_daemon(interval=args.interval)
846
+ cmd_daemon(interval=args.interval, defer=args.defer)
809
847
  elif cmd == "statusline":
810
848
  cmd_statusline()
811
849
  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
 
@@ -116,6 +117,7 @@ class FetchUsageTests(unittest.TestCase):
116
117
  "client_id": ccusage.CLIENT_ID,
117
118
  },
118
119
  )
120
+ self.assertEqual(req.get_header("User-agent"), ccusage.USER_AGENT)
119
121
  return _json_response(REFRESH_RESULT)
120
122
  self.assertEqual(req.headers["Authorization"], "Bearer new-token")
121
123
  return _json_response({"five_hour": {"utilization": 4.0}})
@@ -537,5 +539,70 @@ class AccountSnapshotTests(unittest.TestCase):
537
539
  self.assertEqual(snapshot["plan"], "max_20x")
538
540
 
539
541
 
542
+
543
+ class DeferTests(unittest.TestCase):
544
+ def setUp(self):
545
+ self._tmp = tempfile.TemporaryDirectory()
546
+ self.addCleanup(self._tmp.cleanup)
547
+ self.accounts_dir = Path(self._tmp.name) / "usage-limits"
548
+ self.accounts_dir.mkdir()
549
+ patcher = mock.patch.object(ccusage, "ACCOUNTS_DIR", self.accounts_dir)
550
+ patcher.start()
551
+ self.addCleanup(patcher.stop)
552
+ self.account = {"uuid": "uuid-a", "email": "a@example.com"}
553
+
554
+ def _snapshot(self, seconds_old: float) -> str:
555
+ stamp = (datetime.now(timezone.utc) - timedelta(seconds=seconds_old)).isoformat()
556
+ (self.accounts_dir / "uuid-a.json").write_text(json.dumps({"updated_at": stamp}))
557
+ return stamp
558
+
559
+ def test_fresh_snapshot_from_another_daemon_covers_the_account(self):
560
+ self._snapshot(100)
561
+ self.assertTrue(ccusage._covered_by_another_daemon(self.account, "own-earlier-stamp", 300))
562
+ self.assertTrue(ccusage._covered_by_another_daemon(self.account, None, 300))
563
+
564
+ def test_own_snapshot_does_not_cover_the_account(self):
565
+ stamp = self._snapshot(100)
566
+ self.assertFalse(ccusage._covered_by_another_daemon(self.account, stamp, 300))
567
+
568
+ def test_stale_snapshot_from_another_daemon_does_not_cover(self):
569
+ self._snapshot(451)
570
+ self.assertFalse(ccusage._covered_by_another_daemon(self.account, None, 300))
571
+
572
+ def test_missing_snapshot_or_account_does_not_cover(self):
573
+ self.assertFalse(ccusage._covered_by_another_daemon(self.account, None, 300))
574
+ self.assertFalse(ccusage._covered_by_another_daemon(None, None, 300))
575
+
576
+ def _run_daemon_once(self, defer: bool):
577
+ def stop(_seconds):
578
+ raise KeyboardInterrupt
579
+
580
+ api = {"limits": [{"name": "seven_day", "utilization": 12.0, "resets_at": None}]}
581
+ with (
582
+ mock.patch.object(ccusage, "current_account", return_value=self.account),
583
+ mock.patch.object(ccusage, "fetch_usage", return_value=api) as fetch,
584
+ mock.patch.object(ccusage, "get_plan", return_value="max_20x"),
585
+ mock.patch.object(ccusage, "write_usage_file"),
586
+ mock.patch.object(ccusage.signal, "signal"),
587
+ mock.patch.object(ccusage.time, "sleep", side_effect=stop),
588
+ mock.patch("sys.stdout", new_callable=io.StringIO),
589
+ self.assertRaises(KeyboardInterrupt),
590
+ ):
591
+ ccusage.cmd_daemon(interval=300, defer=defer)
592
+ return fetch
593
+
594
+ def test_deferring_daemon_skips_the_fetch_when_covered(self):
595
+ self._snapshot(100)
596
+ self._run_daemon_once(defer=True).assert_not_called()
597
+
598
+ def test_daemon_without_defer_always_fetches(self):
599
+ self._snapshot(100)
600
+ self._run_daemon_once(defer=False).assert_called_once()
601
+
602
+ def test_deferring_daemon_fetches_when_not_covered(self):
603
+ self._snapshot(451)
604
+ self._run_daemon_once(defer=True).assert_called_once()
605
+
606
+
540
607
  if __name__ == "__main__":
541
608
  unittest.main()
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "ccusage"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  source = { editable = "." }
File without changes
File without changes
File without changes
File without changes
File without changes