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.
- ccusage-0.3.0/.github/workflows/publish.yml +29 -0
- ccusage-0.3.0/.github/workflows/release-eligibility.yml +12 -0
- ccusage-0.3.0/LICENSE +21 -0
- ccusage-0.1.12/README.md → ccusage-0.3.0/PKG-INFO +17 -1
- ccusage-0.1.12/PKG-INFO → ccusage-0.3.0/README.md +6 -10
- {ccusage-0.1.12 → ccusage-0.3.0}/pyproject.toml +3 -1
- ccusage-0.3.0/release-notes/v0.2.0.md +20 -0
- ccusage-0.3.0/release-notes/v0.3.0.md +11 -0
- {ccusage-0.1.12 → ccusage-0.3.0}/src/ccusage/__init__.py +96 -15
- {ccusage-0.1.12 → ccusage-0.3.0}/tests/test_ccusage.py +139 -0
- {ccusage-0.1.12 → ccusage-0.3.0}/uv.lock +1 -1
- ccusage-0.1.12/.github/workflows/publish.yml +0 -29
- {ccusage-0.1.12 → ccusage-0.3.0}/.gitignore +0 -0
- {ccusage-0.1.12 → ccusage-0.3.0}/.python-version +0 -0
- {ccusage-0.1.12 → ccusage-0.3.0}/src/ccusage/__main__.py +0 -0
|
@@ -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
|
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.
|
|
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
|
|
457
|
-
"""
|
|
458
|
-
|
|
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
|
|
588
|
-
"""
|
|
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()
|
|
@@ -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
|
|
File without changes
|