ccusage 0.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ccusage
3
- Version: 0.2.0
3
+ Version: 0.3.0
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.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"
@@ -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)
@@ -627,8 +627,31 @@ def cmd_refresh():
627
627
  print(f" {pcts}")
628
628
 
629
629
 
630
- def cmd_daemon(interval: int = DAEMON_INTERVAL):
631
- """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
+ """
632
655
  signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
633
656
  if hasattr(signal, "SIGTERM"):
634
657
  signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
@@ -637,18 +660,29 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
637
660
  print(f"Writing to {USAGE_FILE}")
638
661
 
639
662
  backoff = 0
663
+ own_stamp = None
664
+ deferring = False
640
665
  while True:
641
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
642
674
  try:
643
675
  api_data = fetch_usage()
644
676
  plan = get_plan()
645
677
  data = build_usage_json(api_data, plan)
646
678
  write_usage_file(data, account)
679
+ own_stamp = data["updated_at"]
647
680
  backoff = 0
648
681
  pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
649
682
  print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
650
683
  except UsageUnavailableError as e:
651
684
  data = _cache_unavailable(e, account)
685
+ own_stamp = data["updated_at"]
652
686
  backoff = UNAVAILABLE_RETRY_INTERVAL
653
687
  print(
654
688
  f"[{datetime.now().strftime('%H:%M:%S')}] "
@@ -793,6 +827,8 @@ def main():
793
827
  daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
794
828
  daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
795
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")
796
832
  sub.add_parser("statusline", help="Claude Code statusline (reads stdin + cache)")
797
833
  sub.add_parser("install", help="Print setup instructions")
798
834
  args = parser.parse_args()
@@ -805,7 +841,7 @@ def main():
805
841
  elif cmd == "refresh":
806
842
  cmd_refresh()
807
843
  elif cmd == "daemon":
808
- cmd_daemon(interval=args.interval)
844
+ cmd_daemon(interval=args.interval, defer=args.defer)
809
845
  elif cmd == "statusline":
810
846
  cmd_statusline()
811
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
 
@@ -537,5 +538,70 @@ class AccountSnapshotTests(unittest.TestCase):
537
538
  self.assertEqual(snapshot["plan"], "max_20x")
538
539
 
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
+
540
606
  if __name__ == "__main__":
541
607
  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.0"
8
8
  source = { editable = "." }
File without changes
File without changes
File without changes
File without changes
File without changes