ccusage 0.1.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.1.0/.github/workflows/publish.yml +18 -0
- ccusage-0.1.0/.gitignore +10 -0
- ccusage-0.1.0/.python-version +1 -0
- ccusage-0.1.0/PKG-INFO +171 -0
- ccusage-0.1.0/README.md +164 -0
- ccusage-0.1.0/pyproject.toml +17 -0
- ccusage-0.1.0/src/ccusage/__init__.py +347 -0
- ccusage-0.1.0/uv.lock +8 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v4
|
|
17
|
+
- run: uv build
|
|
18
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
ccusage-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
ccusage-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ccusage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Claude Code usage monitor — fetches rate limits from Anthropic's API
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# ccusage
|
|
9
|
+
|
|
10
|
+
Claude Code usage monitor. Fetches your real rate limit data from Anthropic's API and displays it in the Claude Code statusline.
|
|
11
|
+
|
|
12
|
+
## Example output
|
|
13
|
+
|
|
14
|
+
`ccusage` command:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Plan: max_5x
|
|
18
|
+
Session (5h) 39% resets 1h26m
|
|
19
|
+
Week (all) 15% resets 143h26m
|
|
20
|
+
Week (Sonnet) 39% resets 65h26m
|
|
21
|
+
Extra usage $0.00 / $1000.00
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Claude Code statusline (self-caching — refreshes from API when stale, no daemon needed):
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
~/projects/myapp [Opus 4.6] 5h:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv tool install git+https://github.com/wakamex/ccusage
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Then run:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Check usage once
|
|
40
|
+
ccusage
|
|
41
|
+
|
|
42
|
+
# Run the daemon (keeps usage-limits.json updated)
|
|
43
|
+
ccusage daemon
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Configure the statusline in `~/.claude/settings.json`:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"statusLine": {
|
|
51
|
+
"type": "command",
|
|
52
|
+
"command": "ccusage statusline"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
| Command | Description |
|
|
60
|
+
|---------|-------------|
|
|
61
|
+
| `ccusage` | Show current usage (colored terminal output) |
|
|
62
|
+
| `ccusage json` | Print raw JSON |
|
|
63
|
+
| `ccusage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
|
|
64
|
+
| `ccusage statusline` | Claude Code statusline (self-caching, no daemon needed) |
|
|
65
|
+
| `ccusage install` | Print setup instructions |
|
|
66
|
+
|
|
67
|
+
## How Claude Code rate limiting works
|
|
68
|
+
|
|
69
|
+
Discovered by inspecting Claude Code's bundled `cli.js` (v2.1.32).
|
|
70
|
+
|
|
71
|
+
### Data sources
|
|
72
|
+
|
|
73
|
+
Claude Code gets rate limit data from two places:
|
|
74
|
+
|
|
75
|
+
1. **`/api/oauth/usage` endpoint** — Called by the `/status` slash command. Returns utilization percentages and reset times for each rate limit bucket. Requires the `anthropic-beta: oauth-2025-04-20` header.
|
|
76
|
+
|
|
77
|
+
2. **Response headers on every API call** — Every message response includes headers like:
|
|
78
|
+
- `anthropic-ratelimit-unified-{claim}-utilization` (0-100 float)
|
|
79
|
+
- `anthropic-ratelimit-unified-{claim}-reset` (unix timestamp)
|
|
80
|
+
- `anthropic-ratelimit-unified-status` (`allowed` / `allowed_warning` / `rejected`)
|
|
81
|
+
- `anthropic-ratelimit-unified-fallback` (`available` when fallback models are available)
|
|
82
|
+
- `anthropic-ratelimit-unified-overage-status` / `overage-reset`
|
|
83
|
+
- `anthropic-ratelimit-unified-representative-claim`
|
|
84
|
+
|
|
85
|
+
### Rate limit types
|
|
86
|
+
|
|
87
|
+
| Type | Key in API response | Description |
|
|
88
|
+
|------|-------------------|-------------|
|
|
89
|
+
| `five_hour` | `five_hour` | Rolling 5-hour session window |
|
|
90
|
+
| `seven_day` | `seven_day` | Rolling 7-day all-models window |
|
|
91
|
+
| `seven_day_sonnet` | `seven_day_sonnet` | Rolling 7-day Sonnet-specific window |
|
|
92
|
+
| `seven_day_opus` | `seven_day_opus` | Rolling 7-day Opus-specific window |
|
|
93
|
+
| `overage` | `extra_usage` | Extra/overage usage (if enabled) |
|
|
94
|
+
|
|
95
|
+
### API response format
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
GET https://api.anthropic.com/api/oauth/usage
|
|
99
|
+
Authorization: Bearer <oauth_access_token>
|
|
100
|
+
anthropic-beta: oauth-2025-04-20
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"five_hour": {
|
|
106
|
+
"utilization": 35.0,
|
|
107
|
+
"resets_at": "2026-02-06T22:00:00+00:00"
|
|
108
|
+
},
|
|
109
|
+
"seven_day": {
|
|
110
|
+
"utilization": 14.0,
|
|
111
|
+
"resets_at": "2026-02-12T20:00:00+00:00"
|
|
112
|
+
},
|
|
113
|
+
"seven_day_sonnet": {
|
|
114
|
+
"utilization": 39.0,
|
|
115
|
+
"resets_at": "2026-02-09T14:00:00+00:00"
|
|
116
|
+
},
|
|
117
|
+
"seven_day_opus": null,
|
|
118
|
+
"seven_day_oauth_apps": null,
|
|
119
|
+
"seven_day_cowork": null,
|
|
120
|
+
"iguana_necktie": null,
|
|
121
|
+
"extra_usage": {
|
|
122
|
+
"is_enabled": true,
|
|
123
|
+
"monthly_limit": 100000,
|
|
124
|
+
"used_credits": 0.0,
|
|
125
|
+
"utilization": null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Authentication
|
|
131
|
+
|
|
132
|
+
The OAuth token lives at `~/.claude/.credentials.json`:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"claudeAiOauth": {
|
|
137
|
+
"accessToken": "sk-ant-oat01-...",
|
|
138
|
+
"refreshToken": "sk-ant-ort01-...",
|
|
139
|
+
"expiresAt": 1770412938485,
|
|
140
|
+
"subscriptionType": "team",
|
|
141
|
+
"rateLimitTier": "default_claude_max_5x"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The access token expires roughly hourly. Claude Code refreshes it automatically — as long as you have an active Claude Code session, the token stays valid for ccusage to read.
|
|
147
|
+
|
|
148
|
+
### Warning thresholds (from cli.js)
|
|
149
|
+
|
|
150
|
+
Claude Code shows inline warnings based on these thresholds:
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
five_hour: 90% utilization when 72% of window has passed
|
|
154
|
+
seven_day: 75% at 60%, 50% at 35%, 25% at 15%
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Other endpoints found in cli.js
|
|
158
|
+
|
|
159
|
+
- `/api/oauth/profile` — User profile
|
|
160
|
+
- `/api/oauth/account/settings` — Account settings
|
|
161
|
+
- `/api/claude_code/policy_limits` — Org policy limits (needs `organizationUuid`)
|
|
162
|
+
- `/api/organization/claude_code_first_token_date` — Org onboarding date
|
|
163
|
+
|
|
164
|
+
### Local files Claude Code uses
|
|
165
|
+
|
|
166
|
+
| File | Written by | Contains |
|
|
167
|
+
|------|-----------|----------|
|
|
168
|
+
| `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
|
|
169
|
+
| `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
|
|
170
|
+
| `~/.claude/usage-limits.json` | ccusage daemon | Cached API usage data (this tool) |
|
|
171
|
+
| `~/.claude/statsig/` | Claude Code | Feature flags, experiment assignments |
|
ccusage-0.1.0/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# ccusage
|
|
2
|
+
|
|
3
|
+
Claude Code usage monitor. Fetches your real rate limit data from Anthropic's API and displays it in the Claude Code statusline.
|
|
4
|
+
|
|
5
|
+
## Example output
|
|
6
|
+
|
|
7
|
+
`ccusage` command:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Plan: max_5x
|
|
11
|
+
Session (5h) 39% resets 1h26m
|
|
12
|
+
Week (all) 15% resets 143h26m
|
|
13
|
+
Week (Sonnet) 39% resets 65h26m
|
|
14
|
+
Extra usage $0.00 / $1000.00
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Claude Code statusline (self-caching — refreshes from API when stale, no daemon needed):
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
~/projects/myapp [Opus 4.6] 5h:39% 7d:15% son:39% | $1.37 | max_5x | reset:1h26m
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv tool install git+https://github.com/wakamex/ccusage
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then run:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Check usage once
|
|
33
|
+
ccusage
|
|
34
|
+
|
|
35
|
+
# Run the daemon (keeps usage-limits.json updated)
|
|
36
|
+
ccusage daemon
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Configure the statusline in `~/.claude/settings.json`:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"statusLine": {
|
|
44
|
+
"type": "command",
|
|
45
|
+
"command": "ccusage statusline"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Commands
|
|
51
|
+
|
|
52
|
+
| Command | Description |
|
|
53
|
+
|---------|-------------|
|
|
54
|
+
| `ccusage` | Show current usage (colored terminal output) |
|
|
55
|
+
| `ccusage json` | Print raw JSON |
|
|
56
|
+
| `ccusage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
|
|
57
|
+
| `ccusage statusline` | Claude Code statusline (self-caching, no daemon needed) |
|
|
58
|
+
| `ccusage install` | Print setup instructions |
|
|
59
|
+
|
|
60
|
+
## How Claude Code rate limiting works
|
|
61
|
+
|
|
62
|
+
Discovered by inspecting Claude Code's bundled `cli.js` (v2.1.32).
|
|
63
|
+
|
|
64
|
+
### Data sources
|
|
65
|
+
|
|
66
|
+
Claude Code gets rate limit data from two places:
|
|
67
|
+
|
|
68
|
+
1. **`/api/oauth/usage` endpoint** — Called by the `/status` slash command. Returns utilization percentages and reset times for each rate limit bucket. Requires the `anthropic-beta: oauth-2025-04-20` header.
|
|
69
|
+
|
|
70
|
+
2. **Response headers on every API call** — Every message response includes headers like:
|
|
71
|
+
- `anthropic-ratelimit-unified-{claim}-utilization` (0-100 float)
|
|
72
|
+
- `anthropic-ratelimit-unified-{claim}-reset` (unix timestamp)
|
|
73
|
+
- `anthropic-ratelimit-unified-status` (`allowed` / `allowed_warning` / `rejected`)
|
|
74
|
+
- `anthropic-ratelimit-unified-fallback` (`available` when fallback models are available)
|
|
75
|
+
- `anthropic-ratelimit-unified-overage-status` / `overage-reset`
|
|
76
|
+
- `anthropic-ratelimit-unified-representative-claim`
|
|
77
|
+
|
|
78
|
+
### Rate limit types
|
|
79
|
+
|
|
80
|
+
| Type | Key in API response | Description |
|
|
81
|
+
|------|-------------------|-------------|
|
|
82
|
+
| `five_hour` | `five_hour` | Rolling 5-hour session window |
|
|
83
|
+
| `seven_day` | `seven_day` | Rolling 7-day all-models window |
|
|
84
|
+
| `seven_day_sonnet` | `seven_day_sonnet` | Rolling 7-day Sonnet-specific window |
|
|
85
|
+
| `seven_day_opus` | `seven_day_opus` | Rolling 7-day Opus-specific window |
|
|
86
|
+
| `overage` | `extra_usage` | Extra/overage usage (if enabled) |
|
|
87
|
+
|
|
88
|
+
### API response format
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
GET https://api.anthropic.com/api/oauth/usage
|
|
92
|
+
Authorization: Bearer <oauth_access_token>
|
|
93
|
+
anthropic-beta: oauth-2025-04-20
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"five_hour": {
|
|
99
|
+
"utilization": 35.0,
|
|
100
|
+
"resets_at": "2026-02-06T22:00:00+00:00"
|
|
101
|
+
},
|
|
102
|
+
"seven_day": {
|
|
103
|
+
"utilization": 14.0,
|
|
104
|
+
"resets_at": "2026-02-12T20:00:00+00:00"
|
|
105
|
+
},
|
|
106
|
+
"seven_day_sonnet": {
|
|
107
|
+
"utilization": 39.0,
|
|
108
|
+
"resets_at": "2026-02-09T14:00:00+00:00"
|
|
109
|
+
},
|
|
110
|
+
"seven_day_opus": null,
|
|
111
|
+
"seven_day_oauth_apps": null,
|
|
112
|
+
"seven_day_cowork": null,
|
|
113
|
+
"iguana_necktie": null,
|
|
114
|
+
"extra_usage": {
|
|
115
|
+
"is_enabled": true,
|
|
116
|
+
"monthly_limit": 100000,
|
|
117
|
+
"used_credits": 0.0,
|
|
118
|
+
"utilization": null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Authentication
|
|
124
|
+
|
|
125
|
+
The OAuth token lives at `~/.claude/.credentials.json`:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"claudeAiOauth": {
|
|
130
|
+
"accessToken": "sk-ant-oat01-...",
|
|
131
|
+
"refreshToken": "sk-ant-ort01-...",
|
|
132
|
+
"expiresAt": 1770412938485,
|
|
133
|
+
"subscriptionType": "team",
|
|
134
|
+
"rateLimitTier": "default_claude_max_5x"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The access token expires roughly hourly. Claude Code refreshes it automatically — as long as you have an active Claude Code session, the token stays valid for ccusage to read.
|
|
140
|
+
|
|
141
|
+
### Warning thresholds (from cli.js)
|
|
142
|
+
|
|
143
|
+
Claude Code shows inline warnings based on these thresholds:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
five_hour: 90% utilization when 72% of window has passed
|
|
147
|
+
seven_day: 75% at 60%, 50% at 35%, 25% at 15%
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Other endpoints found in cli.js
|
|
151
|
+
|
|
152
|
+
- `/api/oauth/profile` — User profile
|
|
153
|
+
- `/api/oauth/account/settings` — Account settings
|
|
154
|
+
- `/api/claude_code/policy_limits` — Org policy limits (needs `organizationUuid`)
|
|
155
|
+
- `/api/organization/claude_code_first_token_date` — Org onboarding date
|
|
156
|
+
|
|
157
|
+
### Local files Claude Code uses
|
|
158
|
+
|
|
159
|
+
| File | Written by | Contains |
|
|
160
|
+
|------|-----------|----------|
|
|
161
|
+
| `~/.claude/.credentials.json` | Claude Code | OAuth tokens, plan tier |
|
|
162
|
+
| `~/.claude/stats-cache.json` | Claude Code | Local usage stats (message counts, token counts per model) |
|
|
163
|
+
| `~/.claude/usage-limits.json` | ccusage daemon | Cached API usage data (this tool) |
|
|
164
|
+
| `~/.claude/statsig/` | Claude Code | Feature flags, experiment assignments |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ccusage"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Claude Code usage monitor — fetches rate limits from Anthropic's API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[tool.uv]
|
|
10
|
+
package = true
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
ccusage = "ccusage:main"
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ccusage - Claude Code usage monitor.
|
|
3
|
+
|
|
4
|
+
Fetches rate limit data from Anthropic's /api/oauth/usage endpoint
|
|
5
|
+
using your Claude Code OAuth token. Zero external dependencies.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
ccusage Show current usage (colored)
|
|
9
|
+
ccusage status Same as above
|
|
10
|
+
ccusage json Print raw JSON
|
|
11
|
+
ccusage daemon Run in foreground, refresh every 5 min, write to ~/.claude/usage-limits.json
|
|
12
|
+
ccusage statusline Claude Code statusline command (reads stdin + cache)
|
|
13
|
+
ccusage install Print setup instructions
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.request
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
CLAUDE_DIR = Path.home() / ".claude"
|
|
26
|
+
CREDENTIALS_FILE = CLAUDE_DIR / ".credentials.json"
|
|
27
|
+
USAGE_FILE = CLAUDE_DIR / "usage-limits.json"
|
|
28
|
+
DAEMON_INTERVAL = 300 # 5 minutes
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_credentials() -> dict | None:
|
|
32
|
+
"""Read OAuth credentials from Claude Code's credentials file."""
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(CREDENTIALS_FILE.read_text())
|
|
35
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_plan(creds: dict | None = None) -> str:
|
|
40
|
+
"""Return plan info from credentials (rateLimitTier or subscriptionType)."""
|
|
41
|
+
if creds is None:
|
|
42
|
+
creds = get_credentials()
|
|
43
|
+
if not creds:
|
|
44
|
+
return "unknown"
|
|
45
|
+
oauth = creds.get("claudeAiOauth", {})
|
|
46
|
+
tier = oauth.get("rateLimitTier") or oauth.get("subscriptionType") or "unknown"
|
|
47
|
+
return tier.removeprefix("default_claude_")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fetch_usage() -> dict:
|
|
51
|
+
"""Fetch usage from Anthropic's /api/oauth/usage endpoint.
|
|
52
|
+
|
|
53
|
+
Requires a valid (non-expired) OAuth token from ~/.claude/.credentials.json.
|
|
54
|
+
The key header is `anthropic-beta: oauth-2025-04-20` — without it, the
|
|
55
|
+
endpoint returns an auth error.
|
|
56
|
+
|
|
57
|
+
Returns the raw API response, e.g.:
|
|
58
|
+
{
|
|
59
|
+
"five_hour": {"utilization": 35.0, "resets_at": "..."},
|
|
60
|
+
"seven_day": {"utilization": 14.0, "resets_at": "..."},
|
|
61
|
+
"seven_day_sonnet": {"utilization": 39.0, "resets_at": "..."},
|
|
62
|
+
"seven_day_opus": null,
|
|
63
|
+
"extra_usage": {"is_enabled": true, "monthly_limit": 100000, ...}
|
|
64
|
+
}
|
|
65
|
+
"""
|
|
66
|
+
creds = get_credentials()
|
|
67
|
+
if not creds:
|
|
68
|
+
raise RuntimeError("No credentials at ~/.claude/.credentials.json — run `claude` first")
|
|
69
|
+
|
|
70
|
+
oauth = creds.get("claudeAiOauth", {})
|
|
71
|
+
token = oauth.get("accessToken")
|
|
72
|
+
if not token:
|
|
73
|
+
raise RuntimeError("No OAuth access token in credentials")
|
|
74
|
+
|
|
75
|
+
expires_at = oauth.get("expiresAt", 0)
|
|
76
|
+
if time.time() * 1000 > expires_at:
|
|
77
|
+
raise RuntimeError("OAuth token expired — open Claude Code to refresh it")
|
|
78
|
+
|
|
79
|
+
req = urllib.request.Request(
|
|
80
|
+
"https://api.anthropic.com/api/oauth/usage",
|
|
81
|
+
headers={
|
|
82
|
+
"Authorization": f"Bearer {token}",
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"User-Agent": "ccusage/1.0",
|
|
85
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
89
|
+
return json.loads(resp.read())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_usage_json(api_data: dict, plan: str) -> dict:
|
|
93
|
+
"""Transform API response into our cached format."""
|
|
94
|
+
result = {
|
|
95
|
+
"plan": plan,
|
|
96
|
+
"source": "api",
|
|
97
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
98
|
+
}
|
|
99
|
+
for key, api_key in [
|
|
100
|
+
("5h", "five_hour"),
|
|
101
|
+
("7d", "seven_day"),
|
|
102
|
+
("7d_sonnet", "seven_day_sonnet"),
|
|
103
|
+
("7d_opus", "seven_day_opus"),
|
|
104
|
+
]:
|
|
105
|
+
bucket = api_data.get(api_key)
|
|
106
|
+
if bucket:
|
|
107
|
+
result[key] = {
|
|
108
|
+
"pct": bucket["utilization"],
|
|
109
|
+
"resets_at": bucket.get("resets_at"),
|
|
110
|
+
}
|
|
111
|
+
extra = api_data.get("extra_usage")
|
|
112
|
+
if extra:
|
|
113
|
+
result["extra_usage"] = extra
|
|
114
|
+
return result
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def write_usage_file(data: dict):
|
|
118
|
+
"""Write usage data to ~/.claude/usage-limits.json."""
|
|
119
|
+
USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# -- CLI commands --
|
|
123
|
+
|
|
124
|
+
def cmd_status(raw_json=False):
|
|
125
|
+
"""Fetch and display current usage."""
|
|
126
|
+
api_data = fetch_usage()
|
|
127
|
+
plan = get_plan()
|
|
128
|
+
data = build_usage_json(api_data, plan)
|
|
129
|
+
|
|
130
|
+
if raw_json:
|
|
131
|
+
print(json.dumps(data, indent=2))
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
R = "\033[0;31m"
|
|
135
|
+
Y = "\033[0;33m"
|
|
136
|
+
G = "\033[0;32m"
|
|
137
|
+
D = "\033[0;90m"
|
|
138
|
+
RST = "\033[0m"
|
|
139
|
+
|
|
140
|
+
def color_pct(pct):
|
|
141
|
+
p = int(pct)
|
|
142
|
+
c = R if p >= 70 else Y if p >= 50 else G
|
|
143
|
+
return f"{c}{p}%{RST}"
|
|
144
|
+
|
|
145
|
+
def fmt_reset(iso):
|
|
146
|
+
if not iso:
|
|
147
|
+
return ""
|
|
148
|
+
try:
|
|
149
|
+
reset = datetime.fromisoformat(iso)
|
|
150
|
+
now = datetime.now(timezone.utc)
|
|
151
|
+
secs = int((reset - now).total_seconds())
|
|
152
|
+
if secs <= 0:
|
|
153
|
+
return ""
|
|
154
|
+
m = secs // 60
|
|
155
|
+
if m >= 60:
|
|
156
|
+
return f" resets {m // 60}h{m % 60}m"
|
|
157
|
+
return f" resets {m}m"
|
|
158
|
+
except Exception:
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
print(f"Plan: {plan}")
|
|
162
|
+
for label, key in [
|
|
163
|
+
("Session (5h)", "5h"),
|
|
164
|
+
("Week (all)", "7d"),
|
|
165
|
+
("Week (Sonnet)", "7d_sonnet"),
|
|
166
|
+
("Week (Opus)", "7d_opus"),
|
|
167
|
+
]:
|
|
168
|
+
bucket = data.get(key)
|
|
169
|
+
if bucket:
|
|
170
|
+
pct = bucket["pct"]
|
|
171
|
+
reset = fmt_reset(bucket.get("resets_at"))
|
|
172
|
+
print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
|
|
173
|
+
|
|
174
|
+
extra = data.get("extra_usage")
|
|
175
|
+
if extra and extra.get("is_enabled"):
|
|
176
|
+
used = extra.get("used_credits", 0) / 100
|
|
177
|
+
limit = extra.get("monthly_limit", 0) / 100
|
|
178
|
+
print(f" {'Extra usage':20s} ${used:.2f} / ${limit:.2f}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
182
|
+
"""Run in foreground, refresh every `interval` seconds."""
|
|
183
|
+
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
184
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
185
|
+
|
|
186
|
+
print(f"ccusage daemon started (refreshing every {interval}s)")
|
|
187
|
+
print(f"Writing to {USAGE_FILE}")
|
|
188
|
+
|
|
189
|
+
while True:
|
|
190
|
+
try:
|
|
191
|
+
api_data = fetch_usage()
|
|
192
|
+
plan = get_plan()
|
|
193
|
+
data = build_usage_json(api_data, plan)
|
|
194
|
+
write_usage_file(data)
|
|
195
|
+
pcts = []
|
|
196
|
+
for key in ("5h", "7d", "7d_sonnet"):
|
|
197
|
+
b = data.get(key)
|
|
198
|
+
if b:
|
|
199
|
+
pcts.append(f"{key}:{int(b['pct'])}%")
|
|
200
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
|
|
201
|
+
except Exception as e:
|
|
202
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] Error: {e}", file=sys.stderr)
|
|
203
|
+
|
|
204
|
+
time.sleep(interval)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
|
|
208
|
+
"""Read cached usage, refreshing from API if stale or missing."""
|
|
209
|
+
try:
|
|
210
|
+
usage = json.loads(USAGE_FILE.read_text())
|
|
211
|
+
updated = datetime.fromisoformat(usage["updated_at"])
|
|
212
|
+
age = (datetime.now(timezone.utc) - updated).total_seconds()
|
|
213
|
+
if age < max_age:
|
|
214
|
+
return usage
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
# Cache is stale or missing — try to refresh
|
|
218
|
+
try:
|
|
219
|
+
api_data = fetch_usage()
|
|
220
|
+
usage = build_usage_json(api_data, get_plan())
|
|
221
|
+
write_usage_file(usage)
|
|
222
|
+
return usage
|
|
223
|
+
except Exception:
|
|
224
|
+
# Return whatever we had, even if stale
|
|
225
|
+
try:
|
|
226
|
+
return json.loads(USAGE_FILE.read_text())
|
|
227
|
+
except Exception:
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_statusline():
|
|
232
|
+
"""Claude Code statusline command. Reads Claude's JSON from stdin + cached usage."""
|
|
233
|
+
R = "\033[0;31m"
|
|
234
|
+
Y = "\033[0;33m"
|
|
235
|
+
G = "\033[0;32m"
|
|
236
|
+
C = "\033[0;36m"
|
|
237
|
+
D = "\033[0;90m"
|
|
238
|
+
RST = "\033[0m"
|
|
239
|
+
|
|
240
|
+
def color_pct(pct: int) -> str:
|
|
241
|
+
c = R if pct >= 70 else Y if pct >= 50 else G
|
|
242
|
+
return f"{c}{pct}%{RST}"
|
|
243
|
+
|
|
244
|
+
def fmt_reset(iso: str | None) -> str:
|
|
245
|
+
if not iso:
|
|
246
|
+
return ""
|
|
247
|
+
try:
|
|
248
|
+
reset = datetime.fromisoformat(iso)
|
|
249
|
+
secs = int((reset - datetime.now(timezone.utc)).total_seconds())
|
|
250
|
+
if secs <= 0:
|
|
251
|
+
return ""
|
|
252
|
+
m = secs // 60
|
|
253
|
+
if m >= 60:
|
|
254
|
+
return f"{m // 60}h{m % 60}m"
|
|
255
|
+
return f"{m}m"
|
|
256
|
+
except Exception:
|
|
257
|
+
return ""
|
|
258
|
+
|
|
259
|
+
# Read Claude Code's JSON from stdin
|
|
260
|
+
try:
|
|
261
|
+
cc = json.loads(sys.stdin.read())
|
|
262
|
+
except Exception:
|
|
263
|
+
cc = {}
|
|
264
|
+
|
|
265
|
+
model = cc.get("model", {}).get("display_name", "?")
|
|
266
|
+
cost = cc.get("cost", {}).get("total_cost_usd", 0)
|
|
267
|
+
pwd = cc.get("workspace", {}).get("current_dir", "?")
|
|
268
|
+
home = str(Path.home())
|
|
269
|
+
if pwd.startswith(home):
|
|
270
|
+
pwd = "~" + pwd[len(home):]
|
|
271
|
+
|
|
272
|
+
cost_fmt = f"${cost:.2f}" if cost > 0 else "$0"
|
|
273
|
+
|
|
274
|
+
# Read cached usage, refresh if stale or missing
|
|
275
|
+
usage = _get_cached_usage()
|
|
276
|
+
|
|
277
|
+
plan = usage.get("plan", "?")
|
|
278
|
+
five_h = usage.get("5h", {})
|
|
279
|
+
seven_d = usage.get("7d", {})
|
|
280
|
+
sonnet = usage.get("7d_sonnet", {})
|
|
281
|
+
|
|
282
|
+
parts = [f"{D}{pwd}{RST}", f"[{C}{model}{RST}]"]
|
|
283
|
+
|
|
284
|
+
if five_h:
|
|
285
|
+
parts.append(f"5h:{color_pct(int(five_h.get('pct', 0)))}")
|
|
286
|
+
if seven_d:
|
|
287
|
+
parts.append(f"7d:{color_pct(int(seven_d.get('pct', 0)))}")
|
|
288
|
+
if sonnet:
|
|
289
|
+
parts.append(f"son:{color_pct(int(sonnet.get('pct', 0)))}")
|
|
290
|
+
|
|
291
|
+
parts.append(f"| {cost_fmt} | {D}{plan}{RST}")
|
|
292
|
+
|
|
293
|
+
reset = fmt_reset(five_h.get("resets_at"))
|
|
294
|
+
if reset:
|
|
295
|
+
parts.append(f"| {D}reset:{reset}{RST}")
|
|
296
|
+
|
|
297
|
+
print(" ".join(parts))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def cmd_install():
|
|
301
|
+
"""Print setup instructions."""
|
|
302
|
+
print("""ccusage setup
|
|
303
|
+
=============
|
|
304
|
+
|
|
305
|
+
1. Run the daemon (in a terminal, tmux, or systemd):
|
|
306
|
+
ccusage daemon
|
|
307
|
+
|
|
308
|
+
2. Configure Claude Code statusline in ~/.claude/settings.json:
|
|
309
|
+
{
|
|
310
|
+
"statusLine": {
|
|
311
|
+
"type": "command",
|
|
312
|
+
"command": "ccusage statusline"
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
3. The statusline reads ~/.claude/usage-limits.json (written by the daemon)
|
|
317
|
+
and shows: 5h session, 7d all-models, 7d Sonnet-specific limits.
|
|
318
|
+
""")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main():
|
|
322
|
+
parser = argparse.ArgumentParser(description="Claude Code usage monitor")
|
|
323
|
+
sub = parser.add_subparsers(dest="command")
|
|
324
|
+
sub.add_parser("status", help="Show current usage (default)")
|
|
325
|
+
sub.add_parser("json", help="Print raw JSON")
|
|
326
|
+
daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
|
|
327
|
+
daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
|
|
328
|
+
help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
|
|
329
|
+
sub.add_parser("statusline", help="Claude Code statusline (reads stdin + cache)")
|
|
330
|
+
sub.add_parser("install", help="Print setup instructions")
|
|
331
|
+
args = parser.parse_args()
|
|
332
|
+
|
|
333
|
+
cmd = args.command or "status"
|
|
334
|
+
if cmd == "status":
|
|
335
|
+
cmd_status()
|
|
336
|
+
elif cmd == "json":
|
|
337
|
+
cmd_status(raw_json=True)
|
|
338
|
+
elif cmd == "daemon":
|
|
339
|
+
cmd_daemon(interval=args.interval)
|
|
340
|
+
elif cmd == "statusline":
|
|
341
|
+
cmd_statusline()
|
|
342
|
+
elif cmd == "install":
|
|
343
|
+
cmd_install()
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
main()
|