ccusage 0.1.12__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.
@@ -50,6 +61,8 @@ 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
+
53
66
  ## Commands
54
67
 
55
68
  | Command | Description |
@@ -177,4 +190,5 @@ seven_day: 75% at 60%, 50% at 35%, 25% at 15%
177
190
  | `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
178
191
  | `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
179
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) |
180
194
  | `~/.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,6 +50,8 @@ 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
+
62
55
  ## Commands
63
56
 
64
57
  | Command | Description |
@@ -186,4 +179,5 @@ seven_day: 75% at 60%, 50% at 35%, 25% at 15%
186
179
  | `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
187
180
  | `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
188
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) |
189
183
  | `~/.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.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,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,7 +620,7 @@ 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:
@@ -595,16 +638,17 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
595
638
 
596
639
  backoff = 0
597
640
  while True:
641
+ account = current_account()
598
642
  try:
599
643
  api_data = fetch_usage()
600
644
  plan = get_plan()
601
645
  data = build_usage_json(api_data, plan)
602
- write_usage_file(data)
646
+ write_usage_file(data, account)
603
647
  backoff = 0
604
648
  pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
605
649
  print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
606
650
  except UsageUnavailableError as e:
607
- data = _cache_unavailable(e)
651
+ data = _cache_unavailable(e, account)
608
652
  backoff = UNAVAILABLE_RETRY_INTERVAL
609
653
  print(
610
654
  f"[{datetime.now().strftime('%H:%M:%S')}] "
@@ -634,13 +678,14 @@ def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
634
678
  except Exception:
635
679
  pass
636
680
  # Cache is stale or missing — try to refresh
681
+ account = current_account()
637
682
  try:
638
683
  api_data = fetch_usage()
639
684
  usage = build_usage_json(api_data, get_plan())
640
- write_usage_file(usage)
685
+ write_usage_file(usage, account)
641
686
  return usage
642
687
  except UsageUnavailableError as e:
643
- return _cache_unavailable(e)
688
+ return _cache_unavailable(e, account)
644
689
  except Exception:
645
690
  # Return whatever we had, even if stale
646
691
  try:
@@ -464,5 +464,78 @@ class UnavailableCacheTests(unittest.TestCase):
464
464
  self.assertNotIn("sess:", output)
465
465
 
466
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
+
467
540
  if __name__ == "__main__":
468
541
  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.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 --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