codex-cli-usage 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.
|
@@ -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
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-cli-usage
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Codex CLI usage monitor — fetches rate limits from OpenAI's ChatGPT backend API
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# codex-cli-usage
|
|
9
|
+
|
|
10
|
+
Codex CLI usage monitor. Fetches your rate limit data from OpenAI's ChatGPT backend API and displays it in the terminal.
|
|
11
|
+
|
|
12
|
+
## Example output
|
|
13
|
+
|
|
14
|
+
`codex-cli-usage` command:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Plan: plus
|
|
18
|
+
Session (5h) 39% resets 1h26m
|
|
19
|
+
Week (7d) 15% resets 143h26m
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Codex statusline (self-caching — refreshes from API when stale, no daemon needed):
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
5h:39% 7d:15% plus reset:1h26m
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv tool install codex-cli-usage
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then run:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Check usage once
|
|
38
|
+
codex-cli-usage
|
|
39
|
+
|
|
40
|
+
# Run the daemon (keeps usage-limits.json updated)
|
|
41
|
+
codex-cli-usage daemon
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
| Command | Description |
|
|
47
|
+
|---------|-------------|
|
|
48
|
+
| `codex-cli-usage` | Show current usage (colored terminal output) |
|
|
49
|
+
| `codex-cli-usage json` | Print raw JSON |
|
|
50
|
+
| `codex-cli-usage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
|
|
51
|
+
| `codex-cli-usage statusline` | Compact statusline (self-caching, no daemon needed) |
|
|
52
|
+
| `codex-cli-usage install` | Print setup instructions |
|
|
53
|
+
|
|
54
|
+
## How Codex CLI rate limiting works
|
|
55
|
+
|
|
56
|
+
Discovered by inspecting the Codex CLI and its authentication flow.
|
|
57
|
+
|
|
58
|
+
### Data sources
|
|
59
|
+
|
|
60
|
+
Codex CLI gets rate limit data from:
|
|
61
|
+
|
|
62
|
+
1. **`/backend-api/codex/usage` endpoint** — Returns utilization percentages and reset times for each rate limit window (primary 5h, secondary 7d, plus per-model and code review limits).
|
|
63
|
+
|
|
64
|
+
### Rate limit types
|
|
65
|
+
|
|
66
|
+
| Type | Description |
|
|
67
|
+
|------|-------------|
|
|
68
|
+
| `primary_window` | Rolling 5-hour session window |
|
|
69
|
+
| `secondary_window` | Rolling 7-day all-models window |
|
|
70
|
+
| `additional_rate_limits` | Per-model limits (e.g. specific model caps) |
|
|
71
|
+
| `code_review_rate_limit` | Code review usage limit |
|
|
72
|
+
|
|
73
|
+
### Authentication
|
|
74
|
+
|
|
75
|
+
The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
|
|
76
|
+
|
|
77
|
+
The access token expires roughly hourly. codex-cli-usage refreshes it automatically using the stored refresh token.
|
|
78
|
+
|
|
79
|
+
### Local files
|
|
80
|
+
|
|
81
|
+
| File | Written by | Contains |
|
|
82
|
+
|------|-----------|----------|
|
|
83
|
+
| `~/.codex/auth.json` | Codex CLI | OAuth tokens (access, refresh, id_token) |
|
|
84
|
+
| `~/.codex/usage-limits.json` | codex-cli-usage daemon | Cached API usage data (this tool) |
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# codex-cli-usage
|
|
2
|
+
|
|
3
|
+
Codex CLI usage monitor. Fetches your rate limit data from OpenAI's ChatGPT backend API and displays it in the terminal.
|
|
4
|
+
|
|
5
|
+
## Example output
|
|
6
|
+
|
|
7
|
+
`codex-cli-usage` command:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Plan: plus
|
|
11
|
+
Session (5h) 39% resets 1h26m
|
|
12
|
+
Week (7d) 15% resets 143h26m
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Codex statusline (self-caching — refreshes from API when stale, no daemon needed):
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
5h:39% 7d:15% plus reset:1h26m
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv tool install codex-cli-usage
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then run:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Check usage once
|
|
31
|
+
codex-cli-usage
|
|
32
|
+
|
|
33
|
+
# Run the daemon (keeps usage-limits.json updated)
|
|
34
|
+
codex-cli-usage daemon
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Description |
|
|
40
|
+
|---------|-------------|
|
|
41
|
+
| `codex-cli-usage` | Show current usage (colored terminal output) |
|
|
42
|
+
| `codex-cli-usage json` | Print raw JSON |
|
|
43
|
+
| `codex-cli-usage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
|
|
44
|
+
| `codex-cli-usage statusline` | Compact statusline (self-caching, no daemon needed) |
|
|
45
|
+
| `codex-cli-usage install` | Print setup instructions |
|
|
46
|
+
|
|
47
|
+
## How Codex CLI rate limiting works
|
|
48
|
+
|
|
49
|
+
Discovered by inspecting the Codex CLI and its authentication flow.
|
|
50
|
+
|
|
51
|
+
### Data sources
|
|
52
|
+
|
|
53
|
+
Codex CLI gets rate limit data from:
|
|
54
|
+
|
|
55
|
+
1. **`/backend-api/codex/usage` endpoint** — Returns utilization percentages and reset times for each rate limit window (primary 5h, secondary 7d, plus per-model and code review limits).
|
|
56
|
+
|
|
57
|
+
### Rate limit types
|
|
58
|
+
|
|
59
|
+
| Type | Description |
|
|
60
|
+
|------|-------------|
|
|
61
|
+
| `primary_window` | Rolling 5-hour session window |
|
|
62
|
+
| `secondary_window` | Rolling 7-day all-models window |
|
|
63
|
+
| `additional_rate_limits` | Per-model limits (e.g. specific model caps) |
|
|
64
|
+
| `code_review_rate_limit` | Code review usage limit |
|
|
65
|
+
|
|
66
|
+
### Authentication
|
|
67
|
+
|
|
68
|
+
The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
|
|
69
|
+
|
|
70
|
+
The access token expires roughly hourly. codex-cli-usage refreshes it automatically using the stored refresh token.
|
|
71
|
+
|
|
72
|
+
### Local files
|
|
73
|
+
|
|
74
|
+
| File | Written by | Contains |
|
|
75
|
+
|------|-----------|----------|
|
|
76
|
+
| `~/.codex/auth.json` | Codex CLI | OAuth tokens (access, refresh, id_token) |
|
|
77
|
+
| `~/.codex/usage-limits.json` | codex-cli-usage daemon | Cached API usage data (this tool) |
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "codex-cli-usage"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Codex CLI usage monitor — fetches rate limits from OpenAI's ChatGPT backend 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
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/codex_cli_usage"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
codex-cli-usage = "codex_cli_usage:main"
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codex-cli-usage - Codex CLI usage monitor.
|
|
3
|
+
|
|
4
|
+
Fetches rate limit data from OpenAI's ChatGPT backend API
|
|
5
|
+
using your Codex CLI OAuth token. Zero external dependencies.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
codex-cli-usage Show current usage (colored)
|
|
9
|
+
codex-cli-usage status Same as above
|
|
10
|
+
codex-cli-usage json Print raw JSON
|
|
11
|
+
codex-cli-usage daemon Run in foreground, refresh every 5 min, write to ~/.codex/usage-limits.json
|
|
12
|
+
codex-cli-usage statusline Codex statusline command (reads cache)
|
|
13
|
+
codex-cli-usage install Print setup instructions
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import signal
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
CODEX_DIR = Path.home() / ".codex"
|
|
27
|
+
AUTH_FILE = CODEX_DIR / "auth.json"
|
|
28
|
+
USAGE_FILE = CODEX_DIR / "usage-limits.json"
|
|
29
|
+
DAEMON_INTERVAL = 300 # 5 minutes
|
|
30
|
+
CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
31
|
+
USAGE_URL = "https://chatgpt.com/backend-api/codex/usage"
|
|
32
|
+
TOKEN_URL = "https://auth.openai.com/oauth/token"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_auth() -> dict | None:
|
|
36
|
+
"""Read OAuth credentials from Codex's auth file."""
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(AUTH_FILE.read_text())
|
|
39
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_plan(auth: dict | None = None) -> str:
|
|
44
|
+
"""Return plan type from auth tokens (decoded from id_token JWT)."""
|
|
45
|
+
if auth is None:
|
|
46
|
+
auth = get_auth()
|
|
47
|
+
if not auth:
|
|
48
|
+
return "unknown"
|
|
49
|
+
# Plan type comes from the usage API, fall back to checking JWT claims
|
|
50
|
+
tokens = auth.get("tokens", {})
|
|
51
|
+
# Try to decode the id_token payload (JWT middle segment)
|
|
52
|
+
id_token = tokens.get("id_token", "")
|
|
53
|
+
try:
|
|
54
|
+
import base64
|
|
55
|
+
payload = id_token.split(".")[1]
|
|
56
|
+
payload += "=" * (4 - len(payload) % 4)
|
|
57
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
58
|
+
return claims.get("https://api.openai.com/auth", {}).get("chatgpt_plan_type", "unknown")
|
|
59
|
+
except Exception:
|
|
60
|
+
return "unknown"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def refresh_access_token(auth: dict) -> str:
|
|
64
|
+
"""Refresh the access token using the refresh token."""
|
|
65
|
+
tokens = auth.get("tokens", {})
|
|
66
|
+
refresh_token = tokens.get("refresh_token")
|
|
67
|
+
if not refresh_token:
|
|
68
|
+
raise RuntimeError("No refresh token in auth.json")
|
|
69
|
+
|
|
70
|
+
data = urllib.parse.urlencode({
|
|
71
|
+
"grant_type": "refresh_token",
|
|
72
|
+
"refresh_token": refresh_token,
|
|
73
|
+
"client_id": CLIENT_ID,
|
|
74
|
+
}).encode()
|
|
75
|
+
|
|
76
|
+
req = urllib.request.Request(
|
|
77
|
+
TOKEN_URL,
|
|
78
|
+
data=data,
|
|
79
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
80
|
+
)
|
|
81
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
82
|
+
result = json.loads(resp.read())
|
|
83
|
+
|
|
84
|
+
return result["access_token"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fetch_usage() -> dict:
|
|
88
|
+
"""Fetch usage from ChatGPT's backend API.
|
|
89
|
+
|
|
90
|
+
Returns the raw API response with rate limit windows, plan info, etc.
|
|
91
|
+
"""
|
|
92
|
+
auth = get_auth()
|
|
93
|
+
if not auth:
|
|
94
|
+
raise RuntimeError("No auth at ~/.codex/auth.json — run `codex` first")
|
|
95
|
+
|
|
96
|
+
tokens = auth.get("tokens", {})
|
|
97
|
+
access_token = tokens.get("access_token")
|
|
98
|
+
if not access_token:
|
|
99
|
+
raise RuntimeError("No access token in auth.json")
|
|
100
|
+
|
|
101
|
+
account_id = tokens.get("account_id", "")
|
|
102
|
+
|
|
103
|
+
# Try with current token, refresh if needed
|
|
104
|
+
for attempt in range(2):
|
|
105
|
+
req = urllib.request.Request(
|
|
106
|
+
USAGE_URL,
|
|
107
|
+
headers={
|
|
108
|
+
"Authorization": f"Bearer {access_token}",
|
|
109
|
+
"chatgpt-account-id": account_id,
|
|
110
|
+
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
|
111
|
+
"Accept": "application/json",
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
116
|
+
return json.loads(resp.read())
|
|
117
|
+
except urllib.error.HTTPError as e:
|
|
118
|
+
if e.code == 401 and attempt == 0:
|
|
119
|
+
access_token = refresh_access_token(auth)
|
|
120
|
+
else:
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
raise RuntimeError("Failed to fetch usage after token refresh")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_usage_json(api_data: dict) -> dict:
|
|
127
|
+
"""Transform API response into our cached format."""
|
|
128
|
+
plan = api_data.get("plan_type", "unknown")
|
|
129
|
+
rl = api_data.get("rate_limit", {})
|
|
130
|
+
|
|
131
|
+
result = {
|
|
132
|
+
"plan": plan,
|
|
133
|
+
"source": "api",
|
|
134
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
primary = rl.get("primary_window")
|
|
138
|
+
if primary:
|
|
139
|
+
result["5h"] = {
|
|
140
|
+
"pct": primary["used_percent"],
|
|
141
|
+
"resets_at": datetime.fromtimestamp(primary["reset_at"], tz=timezone.utc).isoformat() if primary.get("reset_at") else None,
|
|
142
|
+
"window_secs": primary.get("limit_window_seconds"),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
secondary = rl.get("secondary_window")
|
|
146
|
+
if secondary:
|
|
147
|
+
result["7d"] = {
|
|
148
|
+
"pct": secondary["used_percent"],
|
|
149
|
+
"resets_at": datetime.fromtimestamp(secondary["reset_at"], tz=timezone.utc).isoformat() if secondary.get("reset_at") else None,
|
|
150
|
+
"window_secs": secondary.get("limit_window_seconds"),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Additional rate limits (e.g. per-model limits)
|
|
154
|
+
additional = api_data.get("additional_rate_limits", [])
|
|
155
|
+
if additional:
|
|
156
|
+
result["additional"] = []
|
|
157
|
+
for item in additional:
|
|
158
|
+
name = item.get("limit_name", "")
|
|
159
|
+
sub_rl = item.get("rate_limit", {})
|
|
160
|
+
entry = {"name": name}
|
|
161
|
+
p = sub_rl.get("primary_window")
|
|
162
|
+
if p:
|
|
163
|
+
entry["primary"] = {
|
|
164
|
+
"pct": p["used_percent"],
|
|
165
|
+
"resets_at": datetime.fromtimestamp(p["reset_at"], tz=timezone.utc).isoformat() if p.get("reset_at") else None,
|
|
166
|
+
}
|
|
167
|
+
s = sub_rl.get("secondary_window")
|
|
168
|
+
if s:
|
|
169
|
+
entry["secondary"] = {
|
|
170
|
+
"pct": s["used_percent"],
|
|
171
|
+
"resets_at": datetime.fromtimestamp(s["reset_at"], tz=timezone.utc).isoformat() if s.get("reset_at") else None,
|
|
172
|
+
}
|
|
173
|
+
result["additional"].append(entry)
|
|
174
|
+
|
|
175
|
+
# Code review limits
|
|
176
|
+
cr = api_data.get("code_review_rate_limit", {})
|
|
177
|
+
cr_primary = cr.get("primary_window")
|
|
178
|
+
if cr_primary and cr_primary.get("used_percent", 0) > 0:
|
|
179
|
+
result["code_review"] = {
|
|
180
|
+
"pct": cr_primary["used_percent"],
|
|
181
|
+
"resets_at": datetime.fromtimestamp(cr_primary["reset_at"], tz=timezone.utc).isoformat() if cr_primary.get("reset_at") else None,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# Credits
|
|
185
|
+
credits = api_data.get("credits", {})
|
|
186
|
+
if credits and credits.get("has_credits"):
|
|
187
|
+
result["credits"] = credits
|
|
188
|
+
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def write_usage_file(data: dict):
|
|
193
|
+
"""Write usage data to ~/.codex/usage-limits.json."""
|
|
194
|
+
USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# -- CLI commands --
|
|
198
|
+
|
|
199
|
+
def cmd_status(raw_json=False):
|
|
200
|
+
"""Fetch and display current usage."""
|
|
201
|
+
api_data = fetch_usage()
|
|
202
|
+
data = build_usage_json(api_data)
|
|
203
|
+
|
|
204
|
+
if raw_json:
|
|
205
|
+
print(json.dumps(data, indent=2))
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
R = "\033[0;31m"
|
|
209
|
+
Y = "\033[0;33m"
|
|
210
|
+
G = "\033[0;32m"
|
|
211
|
+
D = "\033[0;90m"
|
|
212
|
+
RST = "\033[0m"
|
|
213
|
+
|
|
214
|
+
def color_pct(pct):
|
|
215
|
+
p = int(pct)
|
|
216
|
+
c = R if p >= 70 else Y if p >= 50 else G
|
|
217
|
+
return f"{c}{p}%{RST}"
|
|
218
|
+
|
|
219
|
+
def fmt_reset(iso):
|
|
220
|
+
if not iso:
|
|
221
|
+
return ""
|
|
222
|
+
try:
|
|
223
|
+
reset = datetime.fromisoformat(iso)
|
|
224
|
+
now = datetime.now(timezone.utc)
|
|
225
|
+
secs = int((reset - now).total_seconds())
|
|
226
|
+
if secs <= 0:
|
|
227
|
+
return ""
|
|
228
|
+
m = secs // 60
|
|
229
|
+
if m >= 60:
|
|
230
|
+
return f" resets {m // 60}h{m % 60}m"
|
|
231
|
+
return f" resets {m}m"
|
|
232
|
+
except Exception:
|
|
233
|
+
return ""
|
|
234
|
+
|
|
235
|
+
print(f"Plan: {data.get('plan', '?')}")
|
|
236
|
+
for label, key in [
|
|
237
|
+
("Session (5h)", "5h"),
|
|
238
|
+
("Week (7d)", "7d"),
|
|
239
|
+
]:
|
|
240
|
+
bucket = data.get(key)
|
|
241
|
+
if bucket:
|
|
242
|
+
pct = bucket["pct"]
|
|
243
|
+
reset = fmt_reset(bucket.get("resets_at"))
|
|
244
|
+
print(f" {label:20s} {color_pct(pct)}{D}{reset}{RST}")
|
|
245
|
+
|
|
246
|
+
# Additional per-model limits
|
|
247
|
+
for item in data.get("additional", []):
|
|
248
|
+
name = item["name"]
|
|
249
|
+
for wlabel, wkey in [("5h", "primary"), ("7d", "secondary")]:
|
|
250
|
+
w = item.get(wkey)
|
|
251
|
+
if w and w.get("pct", 0) > 0:
|
|
252
|
+
label = f"{name} ({wlabel})"
|
|
253
|
+
print(f" {label:20s} {color_pct(w['pct'])}{D}{fmt_reset(w.get('resets_at'))}{RST}")
|
|
254
|
+
|
|
255
|
+
cr = data.get("code_review")
|
|
256
|
+
if cr:
|
|
257
|
+
print(f" {'Code Review':20s} {color_pct(cr['pct'])}{D}{fmt_reset(cr.get('resets_at'))}{RST}")
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
261
|
+
"""Run in foreground, refresh every `interval` seconds."""
|
|
262
|
+
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
263
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
264
|
+
|
|
265
|
+
print(f"codex-cli-usage daemon started (refreshing every {interval}s)")
|
|
266
|
+
print(f"Writing to {USAGE_FILE}")
|
|
267
|
+
|
|
268
|
+
while True:
|
|
269
|
+
try:
|
|
270
|
+
api_data = fetch_usage()
|
|
271
|
+
data = build_usage_json(api_data)
|
|
272
|
+
write_usage_file(data)
|
|
273
|
+
pcts = []
|
|
274
|
+
for key in ("5h", "7d"):
|
|
275
|
+
b = data.get(key)
|
|
276
|
+
if b:
|
|
277
|
+
pcts.append(f"{key}:{int(b['pct'])}%")
|
|
278
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
|
|
279
|
+
except Exception as e:
|
|
280
|
+
print(f"[{datetime.now().strftime('%H:%M:%S')}] Error: {e}", file=sys.stderr)
|
|
281
|
+
|
|
282
|
+
time.sleep(interval)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
|
|
286
|
+
"""Read cached usage, refreshing from API if stale or missing."""
|
|
287
|
+
try:
|
|
288
|
+
usage = json.loads(USAGE_FILE.read_text())
|
|
289
|
+
updated = datetime.fromisoformat(usage["updated_at"])
|
|
290
|
+
age = (datetime.now(timezone.utc) - updated).total_seconds()
|
|
291
|
+
if age < max_age:
|
|
292
|
+
return usage
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
try:
|
|
296
|
+
api_data = fetch_usage()
|
|
297
|
+
usage = build_usage_json(api_data)
|
|
298
|
+
write_usage_file(usage)
|
|
299
|
+
return usage
|
|
300
|
+
except Exception:
|
|
301
|
+
try:
|
|
302
|
+
return json.loads(USAGE_FILE.read_text())
|
|
303
|
+
except Exception:
|
|
304
|
+
return {}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def cmd_statusline():
|
|
308
|
+
"""Statusline command. Reads cached usage and prints compact summary."""
|
|
309
|
+
R = "\033[0;31m"
|
|
310
|
+
Y = "\033[0;33m"
|
|
311
|
+
G = "\033[0;32m"
|
|
312
|
+
D = "\033[0;90m"
|
|
313
|
+
RST = "\033[0m"
|
|
314
|
+
|
|
315
|
+
def color_pct(pct: int) -> str:
|
|
316
|
+
c = R if pct >= 70 else Y if pct >= 50 else G
|
|
317
|
+
return f"{c}{pct}%{RST}"
|
|
318
|
+
|
|
319
|
+
def fmt_reset(iso: str | None) -> str:
|
|
320
|
+
if not iso:
|
|
321
|
+
return ""
|
|
322
|
+
try:
|
|
323
|
+
reset = datetime.fromisoformat(iso)
|
|
324
|
+
secs = int((reset - datetime.now(timezone.utc)).total_seconds())
|
|
325
|
+
if secs <= 0:
|
|
326
|
+
return ""
|
|
327
|
+
m = secs // 60
|
|
328
|
+
if m >= 60:
|
|
329
|
+
return f"{m // 60}h{m % 60}m"
|
|
330
|
+
return f"{m}m"
|
|
331
|
+
except Exception:
|
|
332
|
+
return ""
|
|
333
|
+
|
|
334
|
+
usage = _get_cached_usage()
|
|
335
|
+
|
|
336
|
+
plan = usage.get("plan", "?")
|
|
337
|
+
five_h = usage.get("5h", {})
|
|
338
|
+
seven_d = usage.get("7d", {})
|
|
339
|
+
|
|
340
|
+
parts = []
|
|
341
|
+
|
|
342
|
+
if five_h:
|
|
343
|
+
parts.append(f"5h:{color_pct(int(five_h.get('pct', 0)))}")
|
|
344
|
+
if seven_d:
|
|
345
|
+
parts.append(f"7d:{color_pct(int(seven_d.get('pct', 0)))}")
|
|
346
|
+
|
|
347
|
+
parts.append(f"{D}{plan}{RST}")
|
|
348
|
+
|
|
349
|
+
reset = fmt_reset(five_h.get("resets_at"))
|
|
350
|
+
if reset:
|
|
351
|
+
parts.append(f"{D}reset:{reset}{RST}")
|
|
352
|
+
|
|
353
|
+
print(" ".join(parts))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def cmd_install():
|
|
357
|
+
"""Print setup instructions."""
|
|
358
|
+
print("""codex-cli-usage setup
|
|
359
|
+
================
|
|
360
|
+
|
|
361
|
+
1. Run the daemon (in a terminal, tmux, or systemd):
|
|
362
|
+
codex-cli-usage daemon
|
|
363
|
+
|
|
364
|
+
2. Quick check:
|
|
365
|
+
codex-cli-usage # show current usage
|
|
366
|
+
codex-cli-usage json # raw JSON output
|
|
367
|
+
|
|
368
|
+
3. The daemon writes to ~/.codex/usage-limits.json every 5 minutes.
|
|
369
|
+
""")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def main():
|
|
373
|
+
parser = argparse.ArgumentParser(description="Codex CLI usage monitor")
|
|
374
|
+
sub = parser.add_subparsers(dest="command")
|
|
375
|
+
sub.add_parser("status", help="Show current usage (default)")
|
|
376
|
+
sub.add_parser("json", help="Print raw JSON")
|
|
377
|
+
daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
|
|
378
|
+
daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
|
|
379
|
+
help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
|
|
380
|
+
sub.add_parser("statusline", help="Compact statusline (reads cache)")
|
|
381
|
+
sub.add_parser("install", help="Print setup instructions")
|
|
382
|
+
args = parser.parse_args()
|
|
383
|
+
|
|
384
|
+
cmd = args.command or "status"
|
|
385
|
+
if cmd == "status":
|
|
386
|
+
cmd_status()
|
|
387
|
+
elif cmd == "json":
|
|
388
|
+
cmd_status(raw_json=True)
|
|
389
|
+
elif cmd == "daemon":
|
|
390
|
+
cmd_daemon(interval=args.interval)
|
|
391
|
+
elif cmd == "statusline":
|
|
392
|
+
cmd_statusline()
|
|
393
|
+
elif cmd == "install":
|
|
394
|
+
cmd_install()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
main()
|