ccusage 0.1.11__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.
- ccusage-0.2.0/.github/workflows/publish.yml +29 -0
- ccusage-0.2.0/.github/workflows/release-eligibility.yml +12 -0
- ccusage-0.2.0/LICENSE +21 -0
- ccusage-0.1.11/README.md → ccusage-0.2.0/PKG-INFO +19 -1
- ccusage-0.1.11/PKG-INFO → ccusage-0.2.0/README.md +8 -10
- {ccusage-0.1.11 → ccusage-0.2.0}/pyproject.toml +3 -1
- ccusage-0.2.0/release-notes/v0.2.0.md +20 -0
- {ccusage-0.1.11 → ccusage-0.2.0}/src/ccusage/__init__.py +104 -24
- {ccusage-0.1.11 → ccusage-0.2.0}/tests/test_ccusage.py +146 -0
- {ccusage-0.1.11 → ccusage-0.2.0}/uv.lock +1 -1
- ccusage-0.1.11/.github/workflows/publish.yml +0 -29
- {ccusage-0.1.11 → ccusage-0.2.0}/.gitignore +0 -0
- {ccusage-0.1.11 → ccusage-0.2.0}/.python-version +0 -0
- {ccusage-0.1.11 → ccusage-0.2.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.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.
|
|
@@ -47,6 +58,11 @@ Configure the statusline in `~/.claude/settings.json`:
|
|
|
47
58
|
}
|
|
48
59
|
```
|
|
49
60
|
|
|
61
|
+
If Claude Code uses `CLAUDE_CONFIG_DIR`, ccusage reads credentials and writes
|
|
62
|
+
`usage-limits.json` in that directory instead.
|
|
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
|
+
|
|
50
66
|
## Commands
|
|
51
67
|
|
|
52
68
|
| Command | Description |
|
|
@@ -134,7 +150,8 @@ anthropic-beta: oauth-2025-04-20
|
|
|
134
150
|
|
|
135
151
|
### Authentication
|
|
136
152
|
|
|
137
|
-
The OAuth token lives at `~/.claude/.credentials.json
|
|
153
|
+
The OAuth token normally lives at `~/.claude/.credentials.json`, or under
|
|
154
|
+
`CLAUDE_CONFIG_DIR` when that environment variable is set:
|
|
138
155
|
|
|
139
156
|
```json
|
|
140
157
|
{
|
|
@@ -173,4 +190,5 @@ seven_day: 75% at 60%, 50% at 35%, 25% at 15%
|
|
|
173
190
|
| `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
|
|
174
191
|
| `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
|
|
175
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) |
|
|
176
194
|
| `~/.claude/statsig/` | Claude Code | Feature flags, experiment assignments |
|
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: ccusage
|
|
3
|
-
Version: 0.1.11
|
|
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.
|
|
@@ -56,6 +47,11 @@ Configure the statusline in `~/.claude/settings.json`:
|
|
|
56
47
|
}
|
|
57
48
|
```
|
|
58
49
|
|
|
50
|
+
If Claude Code uses `CLAUDE_CONFIG_DIR`, ccusage reads credentials and writes
|
|
51
|
+
`usage-limits.json` in that directory instead.
|
|
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
|
+
|
|
59
55
|
## Commands
|
|
60
56
|
|
|
61
57
|
| Command | Description |
|
|
@@ -143,7 +139,8 @@ anthropic-beta: oauth-2025-04-20
|
|
|
143
139
|
|
|
144
140
|
### Authentication
|
|
145
141
|
|
|
146
|
-
The OAuth token lives at `~/.claude/.credentials.json
|
|
142
|
+
The OAuth token normally lives at `~/.claude/.credentials.json`, or under
|
|
143
|
+
`CLAUDE_CONFIG_DIR` when that environment variable is set:
|
|
147
144
|
|
|
148
145
|
```json
|
|
149
146
|
{
|
|
@@ -182,4 +179,5 @@ seven_day: 75% at 60%, 50% at 35%, 25% at 15%
|
|
|
182
179
|
| `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
|
|
183
180
|
| `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
|
|
184
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) |
|
|
185
183
|
| `~/.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.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,12 +9,14 @@ 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
|
"""
|
|
15
16
|
|
|
16
17
|
import argparse
|
|
17
18
|
import json
|
|
19
|
+
import math
|
|
18
20
|
import os
|
|
19
21
|
import signal
|
|
20
22
|
import subprocess
|
|
@@ -29,18 +31,23 @@ from pathlib import Path
|
|
|
29
31
|
_TTY = sys.stdout.isatty()
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
def _claude_config_dir() -> Path:
|
|
35
|
+
override = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
36
|
+
return Path(override) if override is not None else Path.home() / ".claude"
|
|
37
|
+
|
|
38
|
+
|
|
32
39
|
def _resolve_claude_path(relative: str) -> Path:
|
|
33
|
-
"""Return the first existing path for a file inside
|
|
40
|
+
"""Return the first existing path for a file inside Claude's config directory.
|
|
34
41
|
|
|
35
42
|
On Windows, if the native path doesn't exist, also checks WSL distros
|
|
36
43
|
so the tool works from a Windows terminal against a WSL-based Claude Code
|
|
37
44
|
installation.
|
|
38
45
|
|
|
39
46
|
Args:
|
|
40
|
-
relative: path relative to
|
|
47
|
+
relative: path relative to Claude's config directory, e.g. ".credentials.json"
|
|
41
48
|
"""
|
|
42
|
-
native =
|
|
43
|
-
if native.exists() or sys.platform != "win32":
|
|
49
|
+
native = _claude_config_dir() / relative
|
|
50
|
+
if "CLAUDE_CONFIG_DIR" in os.environ or native.exists() or sys.platform != "win32":
|
|
44
51
|
return native
|
|
45
52
|
|
|
46
53
|
# Windows: try WSL paths
|
|
@@ -68,9 +75,13 @@ def _resolve_claude_path(relative: str) -> Path:
|
|
|
68
75
|
return native
|
|
69
76
|
|
|
70
77
|
|
|
71
|
-
CLAUDE_DIR =
|
|
78
|
+
CLAUDE_DIR = _claude_config_dir()
|
|
72
79
|
CREDENTIALS_FILE = _resolve_claude_path(".credentials.json")
|
|
73
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"
|
|
74
85
|
DAEMON_INTERVAL = 300 # 5 minutes
|
|
75
86
|
UNAVAILABLE_RETRY_INTERVAL = 3600 # 1 hour
|
|
76
87
|
TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
@@ -237,7 +248,7 @@ def fetch_usage() -> dict:
|
|
|
237
248
|
"""
|
|
238
249
|
creds = get_credentials()
|
|
239
250
|
if not creds:
|
|
240
|
-
raise RuntimeError("No credentials at
|
|
251
|
+
raise RuntimeError(f"No credentials at {CREDENTIALS_FILE} — run `claude` first")
|
|
241
252
|
|
|
242
253
|
oauth = creds.get("claudeAiOauth", {})
|
|
243
254
|
token = oauth.get("accessToken")
|
|
@@ -338,23 +349,50 @@ def _buckets_from_limits(limits) -> list:
|
|
|
338
349
|
out = []
|
|
339
350
|
for entry in limits:
|
|
340
351
|
if not isinstance(entry, dict):
|
|
341
|
-
|
|
352
|
+
raise ValueError("Invalid usage response: each limit must be an object")
|
|
342
353
|
pct = entry.get("percent")
|
|
343
354
|
if pct is None:
|
|
344
355
|
continue
|
|
356
|
+
if (
|
|
357
|
+
isinstance(pct, bool)
|
|
358
|
+
or not isinstance(pct, int | float)
|
|
359
|
+
or not math.isfinite(pct)
|
|
360
|
+
or not 0 <= pct <= 100
|
|
361
|
+
):
|
|
362
|
+
raise ValueError("Invalid usage response: limit percent must be between 0 and 100")
|
|
345
363
|
kind = entry.get("kind")
|
|
364
|
+
if not isinstance(kind, str) or not kind:
|
|
365
|
+
raise ValueError("Invalid usage response: limit kind must be a non-empty string")
|
|
346
366
|
if kind == "session":
|
|
347
367
|
short_key, order = "session", 0
|
|
348
368
|
elif kind == "weekly_all":
|
|
349
369
|
short_key, order = "7d", 1
|
|
350
370
|
elif kind == "weekly_scoped":
|
|
351
|
-
|
|
352
|
-
|
|
371
|
+
scope = entry.get("scope")
|
|
372
|
+
model = scope.get("model") if isinstance(scope, dict) else None
|
|
373
|
+
if not isinstance(model, dict):
|
|
374
|
+
raise ValueError("Invalid usage response: scoped limit must identify a model")
|
|
375
|
+
name = model.get("display_name") or model.get("id")
|
|
376
|
+
if not isinstance(name, str) or not name.strip():
|
|
377
|
+
raise ValueError("Invalid usage response: scoped limit must identify a model")
|
|
378
|
+
name = name.strip()
|
|
353
379
|
short_key = "7d_" + name.lower().replace(" ", "_")
|
|
354
380
|
order = {"7d_opus": 2, "7d_sonnet": 3}.get(short_key, 4)
|
|
355
381
|
else:
|
|
356
|
-
short_key, order = kind
|
|
357
|
-
|
|
382
|
+
short_key, order = kind, 5
|
|
383
|
+
resets_at = entry.get("resets_at")
|
|
384
|
+
if resets_at is not None:
|
|
385
|
+
if not isinstance(resets_at, str):
|
|
386
|
+
raise ValueError("Invalid usage response: reset time must be an ISO timestamp")
|
|
387
|
+
try:
|
|
388
|
+
reset = datetime.fromisoformat(resets_at.replace("Z", "+00:00"))
|
|
389
|
+
except ValueError as exc:
|
|
390
|
+
raise ValueError(
|
|
391
|
+
"Invalid usage response: reset time must be an ISO timestamp"
|
|
392
|
+
) from exc
|
|
393
|
+
if reset.tzinfo is None:
|
|
394
|
+
raise ValueError("Invalid usage response: reset time must include a timezone")
|
|
395
|
+
out.append((order, short_key, {"pct": pct, "resets_at": resets_at}))
|
|
358
396
|
return out
|
|
359
397
|
|
|
360
398
|
|
|
@@ -372,7 +410,9 @@ def build_usage_json(api_data: dict, plan: str) -> dict:
|
|
|
372
410
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
373
411
|
}
|
|
374
412
|
limits = api_data.get("limits")
|
|
375
|
-
|
|
413
|
+
if not isinstance(limits, list):
|
|
414
|
+
raise ValueError("Invalid usage response: limits must be an array")
|
|
415
|
+
buckets = _buckets_from_limits(limits)
|
|
376
416
|
for _, short_key, bucket in sorted(buckets, key=lambda b: b[0]):
|
|
377
417
|
result[short_key] = bucket
|
|
378
418
|
extra = api_data.get("extra_usage")
|
|
@@ -412,15 +452,51 @@ def _unavailable_hint(data: dict) -> str | None:
|
|
|
412
452
|
return unavailable.get("hint") or unavailable.get("message") or "usage unavailable"
|
|
413
453
|
|
|
414
454
|
|
|
415
|
-
def _cache_unavailable(error: UsageUnavailableError) -> dict:
|
|
455
|
+
def _cache_unavailable(error: UsageUnavailableError, account: dict | None = None) -> dict:
|
|
416
456
|
data = build_unavailable_usage(error, get_plan(), _read_cache())
|
|
417
|
-
write_usage_file(data)
|
|
457
|
+
write_usage_file(data, account)
|
|
418
458
|
return data
|
|
419
459
|
|
|
420
460
|
|
|
421
|
-
def
|
|
422
|
-
"""
|
|
423
|
-
|
|
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)
|
|
424
500
|
|
|
425
501
|
|
|
426
502
|
def _read_cache() -> dict | None:
|
|
@@ -458,11 +534,12 @@ def cmd_status(raw_json=False):
|
|
|
458
534
|
token) falls back to the last cached usage rather than crashing.
|
|
459
535
|
"""
|
|
460
536
|
stale = False
|
|
537
|
+
account = current_account()
|
|
461
538
|
try:
|
|
462
539
|
api_data = fetch_usage()
|
|
463
540
|
data = build_usage_json(api_data, get_plan())
|
|
464
541
|
except UsageUnavailableError as e:
|
|
465
|
-
data = _cache_unavailable(e)
|
|
542
|
+
data = _cache_unavailable(e, account)
|
|
466
543
|
except Exception as e:
|
|
467
544
|
cached = _read_cache()
|
|
468
545
|
if cached is None:
|
|
@@ -530,10 +607,11 @@ def cmd_refresh():
|
|
|
530
607
|
A one-shot equivalent of a single daemon tick — use it to force
|
|
531
608
|
~/.claude/usage-limits.json up to date without running the daemon.
|
|
532
609
|
"""
|
|
610
|
+
account = current_account()
|
|
533
611
|
try:
|
|
534
612
|
api_data = fetch_usage()
|
|
535
613
|
except UsageUnavailableError as e:
|
|
536
|
-
data = _cache_unavailable(e)
|
|
614
|
+
data = _cache_unavailable(e, account)
|
|
537
615
|
print(f"Updated {USAGE_FILE}")
|
|
538
616
|
print(f" Usage unavailable: {_unavailable_hint(data)}")
|
|
539
617
|
return
|
|
@@ -542,7 +620,7 @@ def cmd_refresh():
|
|
|
542
620
|
print(f"Could not refresh usage: {reason}", file=sys.stderr)
|
|
543
621
|
sys.exit(1)
|
|
544
622
|
data = build_usage_json(api_data, get_plan())
|
|
545
|
-
write_usage_file(data)
|
|
623
|
+
write_usage_file(data, account)
|
|
546
624
|
pcts = " ".join(f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data))
|
|
547
625
|
print(f"Updated {USAGE_FILE}")
|
|
548
626
|
if pcts:
|
|
@@ -560,16 +638,17 @@ def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
|
560
638
|
|
|
561
639
|
backoff = 0
|
|
562
640
|
while True:
|
|
641
|
+
account = current_account()
|
|
563
642
|
try:
|
|
564
643
|
api_data = fetch_usage()
|
|
565
644
|
plan = get_plan()
|
|
566
645
|
data = build_usage_json(api_data, plan)
|
|
567
|
-
write_usage_file(data)
|
|
646
|
+
write_usage_file(data, account)
|
|
568
647
|
backoff = 0
|
|
569
648
|
pcts = [f"{key}:{int(b['pct'])}%" for key, b in _quota_buckets(data)]
|
|
570
649
|
print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
|
|
571
650
|
except UsageUnavailableError as e:
|
|
572
|
-
data = _cache_unavailable(e)
|
|
651
|
+
data = _cache_unavailable(e, account)
|
|
573
652
|
backoff = UNAVAILABLE_RETRY_INTERVAL
|
|
574
653
|
print(
|
|
575
654
|
f"[{datetime.now().strftime('%H:%M:%S')}] "
|
|
@@ -599,13 +678,14 @@ def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
|
|
|
599
678
|
except Exception:
|
|
600
679
|
pass
|
|
601
680
|
# Cache is stale or missing — try to refresh
|
|
681
|
+
account = current_account()
|
|
602
682
|
try:
|
|
603
683
|
api_data = fetch_usage()
|
|
604
684
|
usage = build_usage_json(api_data, get_plan())
|
|
605
|
-
write_usage_file(usage)
|
|
685
|
+
write_usage_file(usage, account)
|
|
606
686
|
return usage
|
|
607
687
|
except UsageUnavailableError as e:
|
|
608
|
-
return _cache_unavailable(e)
|
|
688
|
+
return _cache_unavailable(e, account)
|
|
609
689
|
except Exception:
|
|
610
690
|
# Return whatever we had, even if stale
|
|
611
691
|
try:
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import io
|
|
4
4
|
import json
|
|
5
|
+
import os
|
|
5
6
|
import tempfile
|
|
6
7
|
import time
|
|
7
8
|
import unittest
|
|
@@ -46,6 +47,33 @@ REFRESH_RESULT = {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
class ClaudePathTests(unittest.TestCase):
|
|
51
|
+
def test_config_dir_override_matches_claude_code(self):
|
|
52
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
53
|
+
config_dir = Path(tmp) / "claude-config"
|
|
54
|
+
with mock.patch.dict(
|
|
55
|
+
os.environ, {"CLAUDE_CONFIG_DIR": str(config_dir)}, clear=False
|
|
56
|
+
):
|
|
57
|
+
path = ccusage._resolve_claude_path(".credentials.json")
|
|
58
|
+
|
|
59
|
+
self.assertEqual(path, config_dir / ".credentials.json")
|
|
60
|
+
|
|
61
|
+
def test_config_dir_override_does_not_fall_back_to_wsl(self):
|
|
62
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
63
|
+
config_dir = Path(tmp) / "missing-config"
|
|
64
|
+
with (
|
|
65
|
+
mock.patch.dict(
|
|
66
|
+
os.environ, {"CLAUDE_CONFIG_DIR": str(config_dir)}, clear=False
|
|
67
|
+
),
|
|
68
|
+
mock.patch.object(ccusage.sys, "platform", "win32"),
|
|
69
|
+
mock.patch.object(ccusage.subprocess, "run") as run_mock,
|
|
70
|
+
):
|
|
71
|
+
path = ccusage._resolve_claude_path(".credentials.json")
|
|
72
|
+
|
|
73
|
+
self.assertEqual(path, config_dir / ".credentials.json")
|
|
74
|
+
run_mock.assert_not_called()
|
|
75
|
+
|
|
76
|
+
|
|
49
77
|
class FetchUsageTests(unittest.TestCase):
|
|
50
78
|
def setUp(self):
|
|
51
79
|
self._tmp = tempfile.TemporaryDirectory()
|
|
@@ -279,6 +307,7 @@ class BuildUsageJsonTests(unittest.TestCase):
|
|
|
279
307
|
}
|
|
280
308
|
with (
|
|
281
309
|
mock.patch.object(ccusage, "_get_cached_usage", return_value=usage),
|
|
310
|
+
mock.patch.object(ccusage, "_TTY", False),
|
|
282
311
|
mock.patch("sys.stdin", io.StringIO(json.dumps(status_input))),
|
|
283
312
|
mock.patch("sys.stdout", new_callable=io.StringIO) as stdout,
|
|
284
313
|
):
|
|
@@ -296,6 +325,50 @@ class BuildUsageJsonTests(unittest.TestCase):
|
|
|
296
325
|
result = ccusage.build_usage_json(api_data, "max_20x")
|
|
297
326
|
self.assertEqual([k for k, _ in ccusage._quota_buckets(result)], [])
|
|
298
327
|
|
|
328
|
+
def test_rejects_missing_or_ill_typed_limits(self):
|
|
329
|
+
for api_data in ({}, {"limits": None}, {"limits": {}}, {"limits": "bad"}):
|
|
330
|
+
with self.subTest(api_data=api_data):
|
|
331
|
+
with self.assertRaisesRegex(ValueError, "limits must be an array"):
|
|
332
|
+
ccusage.build_usage_json(api_data, "max_20x")
|
|
333
|
+
|
|
334
|
+
def test_rejects_malformed_limit_entries(self):
|
|
335
|
+
malformed = [
|
|
336
|
+
"bad",
|
|
337
|
+
{"kind": None, "percent": 10},
|
|
338
|
+
{"kind": "weekly_scoped", "percent": 10, "scope": []},
|
|
339
|
+
{"kind": "weekly_scoped", "percent": 10, "scope": {"model": {}}},
|
|
340
|
+
]
|
|
341
|
+
for entry in malformed:
|
|
342
|
+
with self.subTest(entry=entry):
|
|
343
|
+
with self.assertRaisesRegex(ValueError, "Invalid usage response"):
|
|
344
|
+
ccusage.build_usage_json({"limits": [entry]}, "max_20x")
|
|
345
|
+
|
|
346
|
+
def test_rejects_non_finite_or_out_of_range_percentages(self):
|
|
347
|
+
for pct in (float("nan"), float("inf"), float("-inf"), -0.1, 100.1, True, "10"):
|
|
348
|
+
with self.subTest(pct=pct):
|
|
349
|
+
with self.assertRaisesRegex(ValueError, "percent must be between 0 and 100"):
|
|
350
|
+
ccusage.build_usage_json(
|
|
351
|
+
{"limits": [{"kind": "session", "percent": pct}]},
|
|
352
|
+
"max_20x",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
def test_rejects_invalid_reset_times(self):
|
|
356
|
+
for resets_at in (42, "not-a-time", "2026-07-04T05:10:00"):
|
|
357
|
+
with self.subTest(resets_at=resets_at):
|
|
358
|
+
with self.assertRaisesRegex(ValueError, "reset time"):
|
|
359
|
+
ccusage.build_usage_json(
|
|
360
|
+
{
|
|
361
|
+
"limits": [
|
|
362
|
+
{
|
|
363
|
+
"kind": "session",
|
|
364
|
+
"percent": 10,
|
|
365
|
+
"resets_at": resets_at,
|
|
366
|
+
}
|
|
367
|
+
]
|
|
368
|
+
},
|
|
369
|
+
"max_20x",
|
|
370
|
+
)
|
|
371
|
+
|
|
299
372
|
def test_unavailable_tombstone_preserves_success_time_not_quota_values(self):
|
|
300
373
|
previous = {
|
|
301
374
|
"plan": "max_20x",
|
|
@@ -391,5 +464,78 @@ class UnavailableCacheTests(unittest.TestCase):
|
|
|
391
464
|
self.assertNotIn("sess:", output)
|
|
392
465
|
|
|
393
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
|
+
|
|
394
540
|
if __name__ == "__main__":
|
|
395
541
|
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 version --short)"
|
|
24
|
-
- name: Test
|
|
25
|
-
run: uv run --locked python -m unittest discover -s tests
|
|
26
|
-
- name: Build
|
|
27
|
-
run: uv build --no-sources
|
|
28
|
-
- name: Publish
|
|
29
|
-
run: uv publish --trusted-publishing always
|
|
File without changes
|
|
File without changes
|
|
File without changes
|