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.
- {ccusage-0.2.0 → ccusage-0.3.0}/PKG-INFO +4 -2
- {ccusage-0.2.0 → ccusage-0.3.0}/README.md +3 -1
- {ccusage-0.2.0 → ccusage-0.3.0}/pyproject.toml +1 -1
- ccusage-0.3.0/release-notes/v0.3.0.md +11 -0
- {ccusage-0.2.0 → ccusage-0.3.0}/src/ccusage/__init__.py +39 -3
- {ccusage-0.2.0 → ccusage-0.3.0}/tests/test_ccusage.py +66 -0
- {ccusage-0.2.0 → ccusage-0.3.0}/uv.lock +1 -1
- {ccusage-0.2.0 → ccusage-0.3.0}/.github/workflows/publish.yml +0 -0
- {ccusage-0.2.0 → ccusage-0.3.0}/.github/workflows/release-eligibility.yml +0 -0
- {ccusage-0.2.0 → ccusage-0.3.0}/.gitignore +0 -0
- {ccusage-0.2.0 → ccusage-0.3.0}/.python-version +0 -0
- {ccusage-0.2.0 → ccusage-0.3.0}/LICENSE +0 -0
- {ccusage-0.2.0 → ccusage-0.3.0}/release-notes/v0.2.0.md +0 -0
- {ccusage-0.2.0 → ccusage-0.3.0}/src/ccusage/__main__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ccusage
|
|
3
|
-
Version: 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
|
|
|
@@ -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
|
|
631
|
-
"""
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|