codex-cli-usage 0.1.9__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- codex_cli_usage/__init__.py +3 -685
- codex_cli_usage/__main__.py +1 -1
- codex_cli_usage-0.4.0.dist-info/METADATA +25 -0
- codex_cli_usage-0.4.0.dist-info/RECORD +7 -0
- {codex_cli_usage-0.1.9.dist-info → codex_cli_usage-0.4.0.dist-info}/WHEEL +1 -1
- codex_cli_usage-0.4.0.dist-info/licenses/LICENSE +21 -0
- codex_cli_usage-0.1.9.dist-info/METADATA +0 -113
- codex_cli_usage-0.1.9.dist-info/RECORD +0 -6
- {codex_cli_usage-0.1.9.dist-info → codex_cli_usage-0.4.0.dist-info}/entry_points.txt +0 -0
codex_cli_usage/__init__.py
CHANGED
|
@@ -1,689 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
"""codex-cli-usage - Codex CLI usage monitor.
|
|
1
|
+
"""codex-cli-usage: the Codex command of clanker-usage."""
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
through Codex's app-server, with a direct HTTP compatibility fallback.
|
|
6
|
-
Zero external Python dependencies.
|
|
7
|
-
|
|
8
|
-
Usage:
|
|
9
|
-
codex-cli-usage Show current usage (colored)
|
|
10
|
-
codex-cli-usage status Same as above
|
|
11
|
-
codex-cli-usage json Print raw JSON
|
|
12
|
-
codex-cli-usage daemon Run in foreground, refresh every 5 min, write to ~/.codex/usage-limits.json
|
|
13
|
-
codex-cli-usage statusline Codex statusline command (reads cache)
|
|
14
|
-
codex-cli-usage install Print setup instructions
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import argparse
|
|
18
|
-
import json
|
|
19
|
-
import queue
|
|
20
|
-
import shutil
|
|
21
|
-
import signal
|
|
22
|
-
import subprocess
|
|
23
|
-
import sys
|
|
24
|
-
import threading
|
|
25
|
-
import time
|
|
26
|
-
import urllib.request
|
|
27
|
-
from datetime import datetime, timezone
|
|
28
|
-
from pathlib import Path
|
|
29
|
-
|
|
30
|
-
_TTY = sys.stdout.isatty()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _find_codex_path(filename: str) -> Path:
|
|
34
|
-
"""Return the first existing path for a .codex file.
|
|
35
|
-
|
|
36
|
-
Checks the native path first (~/.codex/<filename>). On Windows, if the
|
|
37
|
-
native path doesn't exist, also checks WSL distros via //wsl$/.
|
|
38
|
-
"""
|
|
39
|
-
native = Path.home() / ".codex" / filename
|
|
40
|
-
if native.exists():
|
|
41
|
-
return native
|
|
42
|
-
|
|
43
|
-
if sys.platform == "win32":
|
|
44
|
-
try:
|
|
45
|
-
result = subprocess.run(
|
|
46
|
-
["wsl", "-l", "-q"],
|
|
47
|
-
capture_output=True, timeout=5,
|
|
48
|
-
)
|
|
49
|
-
decoded = result.stdout.decode("utf-16-le", errors="ignore")
|
|
50
|
-
distros = [d.strip() for d in decoded.splitlines() if d.strip()]
|
|
51
|
-
except Exception:
|
|
52
|
-
distros = []
|
|
53
|
-
|
|
54
|
-
for distro in distros:
|
|
55
|
-
wsl_home = Path(f"//wsl$/{distro}/home")
|
|
56
|
-
try:
|
|
57
|
-
users = [u for u in wsl_home.iterdir() if u.is_dir()]
|
|
58
|
-
except OSError:
|
|
59
|
-
continue
|
|
60
|
-
for user_dir in users:
|
|
61
|
-
candidate = user_dir / ".codex" / filename
|
|
62
|
-
if candidate.exists():
|
|
63
|
-
return candidate
|
|
64
|
-
|
|
65
|
-
# Fall back to native path even if it doesn't exist yet
|
|
66
|
-
return native
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
CODEX_DIR = Path.home() / ".codex"
|
|
70
|
-
AUTH_FILE = _find_codex_path("auth.json")
|
|
71
|
-
USAGE_FILE = _find_codex_path("usage-limits.json")
|
|
72
|
-
DAEMON_INTERVAL = 300 # 5 minutes
|
|
73
|
-
USAGE_URL = "https://chatgpt.com/backend-api/codex/usage"
|
|
74
|
-
AUTH_REFRESH_ERROR = "Authentication refresh failed. Run codex login again."
|
|
75
|
-
USAGE_403_ERROR = "Usage service temporarily rejected the request (HTTP 403). Try again."
|
|
76
|
-
USAGE_403_RETRY_DELAYS = (0, 0.25, 0.5, 0.75, 1, 1.5, 2)
|
|
77
|
-
APP_SERVER_TIMEOUT = 20
|
|
78
|
-
CLIENT_VERSION = "0.1.8"
|
|
79
|
-
_RPC_EOF = object()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class AuthenticationError(RuntimeError):
|
|
83
|
-
"""Authentication could not be recovered automatically."""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
class UsageServiceError(RuntimeError):
|
|
87
|
-
"""The usage service rejected an otherwise authenticated request."""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def get_auth() -> dict | None:
|
|
91
|
-
"""Read OAuth credentials from Codex's auth file."""
|
|
92
|
-
try:
|
|
93
|
-
return json.loads(AUTH_FILE.read_text())
|
|
94
|
-
except (FileNotFoundError, json.JSONDecodeError):
|
|
95
|
-
return None
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def get_plan(auth: dict | None = None) -> str:
|
|
99
|
-
"""Return plan type from auth tokens (decoded from id_token JWT)."""
|
|
100
|
-
if auth is None:
|
|
101
|
-
auth = get_auth()
|
|
102
|
-
if not auth:
|
|
103
|
-
return "unknown"
|
|
104
|
-
# Plan type comes from the usage API, fall back to checking JWT claims
|
|
105
|
-
tokens = auth.get("tokens", {})
|
|
106
|
-
# Try to decode the id_token payload (JWT middle segment)
|
|
107
|
-
id_token = tokens.get("id_token", "")
|
|
108
|
-
try:
|
|
109
|
-
import base64
|
|
110
|
-
payload = id_token.split(".")[1]
|
|
111
|
-
payload += "=" * (4 - len(payload) % 4)
|
|
112
|
-
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
113
|
-
return claims.get("https://api.openai.com/auth", {}).get("chatgpt_plan_type", "unknown")
|
|
114
|
-
except Exception:
|
|
115
|
-
return "unknown"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _request_usage(access_token: str, account_id: str) -> dict:
|
|
119
|
-
req = urllib.request.Request(
|
|
120
|
-
USAGE_URL,
|
|
121
|
-
headers={
|
|
122
|
-
"Authorization": f"Bearer {access_token}",
|
|
123
|
-
"chatgpt-account-id": account_id,
|
|
124
|
-
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
|
125
|
-
"Accept": "application/json",
|
|
126
|
-
},
|
|
127
|
-
)
|
|
128
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
129
|
-
return json.loads(resp.read())
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def _request_usage_with_403_retry(access_token: str, account_id: str) -> dict:
|
|
133
|
-
"""Retry transient 403 responses without refreshing credentials."""
|
|
134
|
-
last_error = None
|
|
135
|
-
transient_html_403 = False
|
|
136
|
-
for delay in (None, *USAGE_403_RETRY_DELAYS):
|
|
137
|
-
if delay is not None:
|
|
138
|
-
time.sleep(delay)
|
|
139
|
-
try:
|
|
140
|
-
return _request_usage(access_token, account_id)
|
|
141
|
-
except urllib.error.HTTPError as error:
|
|
142
|
-
if error.code != 403:
|
|
143
|
-
raise
|
|
144
|
-
last_error = error
|
|
145
|
-
content_type = error.headers.get_content_type() if error.headers else ""
|
|
146
|
-
transient_html_403 = content_type == "text/html"
|
|
147
|
-
|
|
148
|
-
if transient_html_403:
|
|
149
|
-
raise UsageServiceError(USAGE_403_ERROR) from None
|
|
150
|
-
raise last_error
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def _read_rpc_lines(stream, messages: queue.Queue) -> None:
|
|
154
|
-
try:
|
|
155
|
-
for line in stream:
|
|
156
|
-
messages.put(line)
|
|
157
|
-
finally:
|
|
158
|
-
messages.put(_RPC_EOF)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _wait_for_rpc_response(messages: queue.Queue, request_id: int, deadline: float) -> dict | None:
|
|
162
|
-
while True:
|
|
163
|
-
remaining = deadline - time.monotonic()
|
|
164
|
-
if remaining <= 0:
|
|
165
|
-
return None
|
|
166
|
-
try:
|
|
167
|
-
line = messages.get(timeout=remaining)
|
|
168
|
-
except queue.Empty:
|
|
169
|
-
return None
|
|
170
|
-
if line is _RPC_EOF:
|
|
171
|
-
return None
|
|
172
|
-
try:
|
|
173
|
-
message = json.loads(line)
|
|
174
|
-
except (json.JSONDecodeError, TypeError):
|
|
175
|
-
return None
|
|
176
|
-
if not isinstance(message, dict):
|
|
177
|
-
return None
|
|
178
|
-
if message.get("id") == request_id:
|
|
179
|
-
return message
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _stop_app_server(process: subprocess.Popen, deadline: float) -> bool:
|
|
183
|
-
if process.stdin and not process.stdin.closed:
|
|
184
|
-
process.stdin.close()
|
|
185
|
-
try:
|
|
186
|
-
return process.wait(timeout=max(0, deadline - time.monotonic())) == 0
|
|
187
|
-
except subprocess.TimeoutExpired:
|
|
188
|
-
process.terminate()
|
|
189
|
-
try:
|
|
190
|
-
process.wait(timeout=1)
|
|
191
|
-
except subprocess.TimeoutExpired:
|
|
192
|
-
process.kill()
|
|
193
|
-
process.wait()
|
|
194
|
-
return False
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def _rate_limit_snapshot(snapshot: dict) -> dict:
|
|
198
|
-
result = {}
|
|
199
|
-
for kind in ("primary", "secondary"):
|
|
200
|
-
window = snapshot.get(kind)
|
|
201
|
-
if not isinstance(window, dict):
|
|
202
|
-
continue
|
|
203
|
-
duration_mins = window.get("windowDurationMins")
|
|
204
|
-
result[f"{kind}_window"] = {
|
|
205
|
-
"used_percent": window.get("usedPercent"),
|
|
206
|
-
"reset_at": window.get("resetsAt"),
|
|
207
|
-
"limit_window_seconds": duration_mins * 60
|
|
208
|
-
if isinstance(duration_mins, int | float) and not isinstance(duration_mins, bool)
|
|
209
|
-
else None,
|
|
210
|
-
}
|
|
211
|
-
return result
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _adapt_app_server_rate_limits(result: dict) -> dict | None:
|
|
215
|
-
aggregate = result.get("rateLimits")
|
|
216
|
-
if not isinstance(aggregate, dict):
|
|
217
|
-
return None
|
|
218
|
-
|
|
219
|
-
api_data = {
|
|
220
|
-
"plan_type": result.get("planType") or get_plan(),
|
|
221
|
-
"rate_limit": _rate_limit_snapshot(aggregate),
|
|
222
|
-
}
|
|
223
|
-
by_limit_id = result.get("rateLimitsByLimitId")
|
|
224
|
-
if not isinstance(by_limit_id, dict):
|
|
225
|
-
return api_data
|
|
226
|
-
|
|
227
|
-
aggregate_id = aggregate.get("limitId") or "codex"
|
|
228
|
-
additional = []
|
|
229
|
-
for limit_id, snapshot in by_limit_id.items():
|
|
230
|
-
if (
|
|
231
|
-
limit_id in {aggregate_id, "codex"}
|
|
232
|
-
or not isinstance(snapshot, dict)
|
|
233
|
-
or snapshot == aggregate
|
|
234
|
-
):
|
|
235
|
-
continue
|
|
236
|
-
additional.append({
|
|
237
|
-
"limit_name": snapshot.get("limitName") or limit_id,
|
|
238
|
-
"rate_limit": _rate_limit_snapshot(snapshot),
|
|
239
|
-
})
|
|
240
|
-
if additional:
|
|
241
|
-
api_data["additional_rate_limits"] = additional
|
|
242
|
-
return api_data
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def _fetch_usage_via_app_server() -> dict | None:
|
|
246
|
-
"""Fetch usage through Codex's authenticated JSON-RPC app-server."""
|
|
247
|
-
codex = shutil.which("codex")
|
|
248
|
-
if not codex:
|
|
249
|
-
return None
|
|
250
|
-
|
|
251
|
-
deadline = time.monotonic() + APP_SERVER_TIMEOUT
|
|
252
|
-
try:
|
|
253
|
-
process = subprocess.Popen(
|
|
254
|
-
[codex, "app-server", "--stdio"],
|
|
255
|
-
stdin=subprocess.PIPE,
|
|
256
|
-
stdout=subprocess.PIPE,
|
|
257
|
-
stderr=subprocess.DEVNULL,
|
|
258
|
-
text=True,
|
|
259
|
-
encoding="utf-8",
|
|
260
|
-
bufsize=1,
|
|
261
|
-
)
|
|
262
|
-
except OSError:
|
|
263
|
-
return None
|
|
264
|
-
|
|
265
|
-
stopped = False
|
|
266
|
-
try:
|
|
267
|
-
if process.stdin is None or process.stdout is None:
|
|
268
|
-
return None
|
|
269
|
-
messages = queue.Queue()
|
|
270
|
-
threading.Thread(
|
|
271
|
-
target=_read_rpc_lines,
|
|
272
|
-
args=(process.stdout, messages),
|
|
273
|
-
daemon=True,
|
|
274
|
-
).start()
|
|
275
|
-
|
|
276
|
-
initialize = {
|
|
277
|
-
"method": "initialize",
|
|
278
|
-
"id": 1,
|
|
279
|
-
"params": {
|
|
280
|
-
"clientInfo": {
|
|
281
|
-
"name": "codex_cli_usage",
|
|
282
|
-
"title": "Codex CLI Usage",
|
|
283
|
-
"version": CLIENT_VERSION,
|
|
284
|
-
},
|
|
285
|
-
},
|
|
286
|
-
}
|
|
287
|
-
process.stdin.write(json.dumps(initialize) + "\n")
|
|
288
|
-
process.stdin.flush()
|
|
289
|
-
initialized = _wait_for_rpc_response(messages, 1, deadline)
|
|
290
|
-
if not initialized or initialized.get("error") or "result" not in initialized:
|
|
291
|
-
return None
|
|
292
|
-
|
|
293
|
-
process.stdin.write(json.dumps({"method": "initialized"}) + "\n")
|
|
294
|
-
process.stdin.write(json.dumps({
|
|
295
|
-
"method": "account/rateLimits/read",
|
|
296
|
-
"id": 2,
|
|
297
|
-
}) + "\n")
|
|
298
|
-
process.stdin.flush()
|
|
299
|
-
response = _wait_for_rpc_response(messages, 2, deadline)
|
|
300
|
-
if not response or response.get("error") or not isinstance(response.get("result"), dict):
|
|
301
|
-
return None
|
|
302
|
-
usage = _adapt_app_server_rate_limits(response["result"])
|
|
303
|
-
if usage is None:
|
|
304
|
-
return None
|
|
305
|
-
stopped = _stop_app_server(process, deadline)
|
|
306
|
-
return usage if stopped else None
|
|
307
|
-
except (BrokenPipeError, OSError, ValueError):
|
|
308
|
-
return None
|
|
309
|
-
finally:
|
|
310
|
-
if not stopped and process.poll() is None:
|
|
311
|
-
process.terminate()
|
|
312
|
-
try:
|
|
313
|
-
process.wait(timeout=1)
|
|
314
|
-
except subprocess.TimeoutExpired:
|
|
315
|
-
process.kill()
|
|
316
|
-
process.wait()
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
def _fetch_usage_direct() -> dict:
|
|
320
|
-
"""Compatibility fallback using auth.json and urllib directly.
|
|
321
|
-
|
|
322
|
-
Returns the raw API response with rate limit windows, plan info, etc.
|
|
323
|
-
"""
|
|
324
|
-
auth = get_auth()
|
|
325
|
-
if not auth:
|
|
326
|
-
raise RuntimeError("No auth at ~/.codex/auth.json — run `codex` first")
|
|
327
|
-
|
|
328
|
-
tokens = auth.get("tokens", {})
|
|
329
|
-
access_token = tokens.get("access_token")
|
|
330
|
-
if not access_token:
|
|
331
|
-
raise RuntimeError("No access token in auth.json")
|
|
332
|
-
|
|
333
|
-
account_id = tokens.get("account_id", "")
|
|
334
|
-
|
|
335
|
-
try:
|
|
336
|
-
return _request_usage_with_403_retry(access_token, account_id)
|
|
337
|
-
except urllib.error.HTTPError as error:
|
|
338
|
-
if error.code not in (401, 403):
|
|
339
|
-
raise
|
|
340
|
-
|
|
341
|
-
# Codex may have rotated credentials while this process was requesting.
|
|
342
|
-
latest = get_auth()
|
|
343
|
-
latest_tokens = latest.get("tokens", {}) if latest else {}
|
|
344
|
-
latest_access_token = latest_tokens.get("access_token")
|
|
345
|
-
if latest_access_token and latest_access_token != access_token:
|
|
346
|
-
auth = latest
|
|
347
|
-
access_token = latest_access_token
|
|
348
|
-
account_id = latest_tokens.get("account_id", account_id)
|
|
349
|
-
try:
|
|
350
|
-
return _request_usage_with_403_retry(access_token, account_id)
|
|
351
|
-
except urllib.error.HTTPError as error:
|
|
352
|
-
if error.code not in (401, 403):
|
|
353
|
-
raise
|
|
354
|
-
|
|
355
|
-
raise AuthenticationError(AUTH_REFRESH_ERROR) from None
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
def fetch_usage() -> dict:
|
|
359
|
-
"""Fetch usage through Codex, falling back to direct HTTP for compatibility."""
|
|
360
|
-
usage = _fetch_usage_via_app_server()
|
|
361
|
-
return usage if usage is not None else _fetch_usage_direct()
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
_WINDOW_CLASSES = (
|
|
365
|
-
("5h", "5-hour", 5 * 3600),
|
|
366
|
-
("daily", "Daily", 24 * 3600),
|
|
367
|
-
("weekly", "Weekly", 7 * 24 * 3600),
|
|
368
|
-
("monthly", "Monthly", 30 * 24 * 3600),
|
|
369
|
-
("annual", "Annual", 365 * 24 * 3600),
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def classify_window(seconds: int | float | None, kind: str) -> tuple[str, str]:
|
|
374
|
-
"""Derive a stable key and display label from a window duration."""
|
|
375
|
-
if not isinstance(seconds, int | float) or isinstance(seconds, bool) or seconds <= 0:
|
|
376
|
-
return kind, kind.title()
|
|
377
|
-
for key, label, expected in _WINDOW_CLASSES:
|
|
378
|
-
if abs(seconds - expected) / expected <= 0.1:
|
|
379
|
-
return key, label
|
|
380
|
-
return kind, kind.title()
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
def _build_window(kind: str, window: dict) -> dict:
|
|
384
|
-
seconds = window.get("limit_window_seconds")
|
|
385
|
-
key, label = classify_window(seconds, kind)
|
|
386
|
-
reset_at = window.get("reset_at")
|
|
387
|
-
return {
|
|
388
|
-
"kind": kind,
|
|
389
|
-
"pct": window.get("used_percent"),
|
|
390
|
-
"resets_at": datetime.fromtimestamp(reset_at, tz=timezone.utc).isoformat() if reset_at else None,
|
|
391
|
-
"window_secs": seconds,
|
|
392
|
-
"key": key,
|
|
393
|
-
"label": label,
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def build_usage_json(api_data: dict) -> dict:
|
|
398
|
-
"""Transform API response into our cached format."""
|
|
399
|
-
plan = api_data.get("plan_type", "unknown")
|
|
400
|
-
rl = api_data.get("rate_limit") or {}
|
|
401
|
-
|
|
402
|
-
result = {
|
|
403
|
-
"schema_version": 2,
|
|
404
|
-
"plan": plan,
|
|
405
|
-
"source": "api",
|
|
406
|
-
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
for kind in ("primary", "secondary"):
|
|
410
|
-
raw_window = rl.get(f"{kind}_window")
|
|
411
|
-
if not isinstance(raw_window, dict):
|
|
412
|
-
continue
|
|
413
|
-
window = _build_window(kind, raw_window)
|
|
414
|
-
result[kind] = {
|
|
415
|
-
"pct": window["pct"],
|
|
416
|
-
"resets_at": window["resets_at"],
|
|
417
|
-
"window_secs": window["window_secs"],
|
|
418
|
-
}
|
|
419
|
-
legacy_key = {"5h": "5h", "weekly": "7d"}.get(window["key"])
|
|
420
|
-
if legacy_key and legacy_key not in result:
|
|
421
|
-
result[legacy_key] = dict(result[kind])
|
|
422
|
-
|
|
423
|
-
# Preserve additional limits in JSON for compatibility, but normal output
|
|
424
|
-
# intentionally shows aggregate Codex limits only.
|
|
425
|
-
additional = api_data.get("additional_rate_limits") or []
|
|
426
|
-
if additional:
|
|
427
|
-
result["additional"] = []
|
|
428
|
-
for item in additional:
|
|
429
|
-
entry = {"name": item.get("limit_name", "")}
|
|
430
|
-
sub_rl = item.get("rate_limit") or {}
|
|
431
|
-
for kind in ("primary", "secondary"):
|
|
432
|
-
raw_window = sub_rl.get(f"{kind}_window")
|
|
433
|
-
if isinstance(raw_window, dict):
|
|
434
|
-
window = _build_window(kind, raw_window)
|
|
435
|
-
entry[kind] = {
|
|
436
|
-
"pct": window["pct"],
|
|
437
|
-
"resets_at": window["resets_at"],
|
|
438
|
-
"window_secs": window["window_secs"],
|
|
439
|
-
}
|
|
440
|
-
result["additional"].append(entry)
|
|
441
|
-
|
|
442
|
-
# Code review limits
|
|
443
|
-
cr = api_data.get("code_review_rate_limit") or {}
|
|
444
|
-
cr_primary = cr.get("primary_window")
|
|
445
|
-
if cr_primary and cr_primary.get("used_percent", 0) > 0:
|
|
446
|
-
result["code_review"] = {
|
|
447
|
-
"pct": cr_primary["used_percent"],
|
|
448
|
-
"resets_at": datetime.fromtimestamp(cr_primary["reset_at"], tz=timezone.utc).isoformat() if cr_primary.get("reset_at") else None,
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
# Credits
|
|
452
|
-
credits = api_data.get("credits") or {}
|
|
453
|
-
if credits and credits.get("has_credits"):
|
|
454
|
-
result["credits"] = credits
|
|
455
|
-
|
|
456
|
-
return result
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
def usage_windows(usage: dict) -> list[dict]:
|
|
460
|
-
"""Enumerate schema-v2 windows, falling back to duration-keyed caches."""
|
|
461
|
-
result = []
|
|
462
|
-
if usage.get("schema_version", 1) >= 2 or any(key in usage for key in ("primary", "secondary")):
|
|
463
|
-
for kind in ("primary", "secondary"):
|
|
464
|
-
bucket = usage.get(kind)
|
|
465
|
-
if not isinstance(bucket, dict):
|
|
466
|
-
continue
|
|
467
|
-
key, label = classify_window(bucket.get("window_secs"), kind)
|
|
468
|
-
result.append({**bucket, "kind": kind, "key": key, "label": label})
|
|
469
|
-
return result
|
|
470
|
-
|
|
471
|
-
for legacy_key, kind, fallback_seconds in (
|
|
472
|
-
("5h", "primary", 5 * 3600),
|
|
473
|
-
("7d", "secondary", 7 * 24 * 3600),
|
|
474
|
-
):
|
|
475
|
-
bucket = usage.get(legacy_key)
|
|
476
|
-
if not isinstance(bucket, dict):
|
|
477
|
-
continue
|
|
478
|
-
seconds = bucket.get("window_secs")
|
|
479
|
-
if not isinstance(seconds, int | float) or isinstance(seconds, bool) or seconds <= 0:
|
|
480
|
-
seconds = fallback_seconds
|
|
481
|
-
key, label = classify_window(seconds, kind)
|
|
482
|
-
result.append({
|
|
483
|
-
"kind": kind,
|
|
484
|
-
"pct": bucket.get("pct"),
|
|
485
|
-
"resets_at": bucket.get("resets_at"),
|
|
486
|
-
"window_secs": seconds,
|
|
487
|
-
"key": key,
|
|
488
|
-
"label": label,
|
|
489
|
-
})
|
|
490
|
-
return result
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
def write_usage_file(data: dict):
|
|
494
|
-
"""Write usage data to ~/.codex/usage-limits.json."""
|
|
495
|
-
USAGE_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
# -- CLI commands --
|
|
499
|
-
|
|
500
|
-
def cmd_status(raw_json=False):
|
|
501
|
-
"""Fetch and display current usage."""
|
|
502
|
-
api_data = fetch_usage()
|
|
503
|
-
data = build_usage_json(api_data)
|
|
504
|
-
|
|
505
|
-
if raw_json:
|
|
506
|
-
print(json.dumps(data, indent=2))
|
|
507
|
-
return
|
|
508
|
-
|
|
509
|
-
R = "\033[0;31m" if _TTY else ""
|
|
510
|
-
Y = "\033[0;33m" if _TTY else ""
|
|
511
|
-
G = "\033[0;32m" if _TTY else ""
|
|
512
|
-
D = "\033[0;90m" if _TTY else ""
|
|
513
|
-
RST = "\033[0m" if _TTY else ""
|
|
514
|
-
|
|
515
|
-
def color_pct(pct):
|
|
516
|
-
p = int(pct)
|
|
517
|
-
c = R if p >= 70 else Y if p >= 50 else G
|
|
518
|
-
return f"{c}{p}%{RST}"
|
|
519
|
-
|
|
520
|
-
def fmt_reset(iso):
|
|
521
|
-
if not iso:
|
|
522
|
-
return ""
|
|
523
|
-
try:
|
|
524
|
-
reset = datetime.fromisoformat(iso)
|
|
525
|
-
now = datetime.now(timezone.utc)
|
|
526
|
-
secs = int((reset - now).total_seconds())
|
|
527
|
-
if secs <= 0:
|
|
528
|
-
return ""
|
|
529
|
-
m = secs // 60
|
|
530
|
-
if m >= 60:
|
|
531
|
-
return f" resets {m // 60}h{m % 60}m"
|
|
532
|
-
return f" resets {m}m"
|
|
533
|
-
except Exception:
|
|
534
|
-
return ""
|
|
535
|
-
|
|
536
|
-
print(f"Plan: {data.get('plan', '?')}")
|
|
537
|
-
for window in usage_windows(data):
|
|
538
|
-
if window.get("pct") is None:
|
|
539
|
-
continue
|
|
540
|
-
reset = fmt_reset(window.get("resets_at"))
|
|
541
|
-
print(f" {window['label']:20s} {color_pct(window['pct'])}{D}{reset}{RST}")
|
|
542
|
-
|
|
543
|
-
cr = data.get("code_review")
|
|
544
|
-
if cr:
|
|
545
|
-
print(f" {'Code Review':20s} {color_pct(cr['pct'])}{D}{fmt_reset(cr.get('resets_at'))}{RST}")
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
def cmd_daemon(interval: int = DAEMON_INTERVAL):
|
|
549
|
-
"""Run in foreground, refresh every `interval` seconds."""
|
|
550
|
-
signal.signal(signal.SIGINT, lambda *_: sys.exit(0))
|
|
551
|
-
if hasattr(signal, "SIGTERM"):
|
|
552
|
-
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
553
|
-
|
|
554
|
-
print(f"codex-cli-usage daemon started (refreshing every {interval}s)")
|
|
555
|
-
print(f"Writing to {USAGE_FILE}")
|
|
556
|
-
|
|
557
|
-
while True:
|
|
558
|
-
try:
|
|
559
|
-
api_data = fetch_usage()
|
|
560
|
-
data = build_usage_json(api_data)
|
|
561
|
-
write_usage_file(data)
|
|
562
|
-
pcts = []
|
|
563
|
-
for window in usage_windows(data):
|
|
564
|
-
if window.get("pct") is not None:
|
|
565
|
-
pcts.append(f"{window['key']}:{int(window['pct'])}%")
|
|
566
|
-
print(f"[{datetime.now().strftime('%H:%M:%S')}] {' '.join(pcts)}")
|
|
567
|
-
except Exception as e:
|
|
568
|
-
print(f"[{datetime.now().strftime('%H:%M:%S')}] Error: {e}", file=sys.stderr)
|
|
569
|
-
|
|
570
|
-
time.sleep(interval)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
def _get_cached_usage(max_age: int = DAEMON_INTERVAL) -> dict:
|
|
574
|
-
"""Read cached usage, refreshing from API if stale or missing."""
|
|
575
|
-
try:
|
|
576
|
-
usage = json.loads(USAGE_FILE.read_text())
|
|
577
|
-
updated = datetime.fromisoformat(usage["updated_at"])
|
|
578
|
-
age = (datetime.now(timezone.utc) - updated).total_seconds()
|
|
579
|
-
if age < max_age:
|
|
580
|
-
return usage
|
|
581
|
-
except Exception:
|
|
582
|
-
pass
|
|
583
|
-
try:
|
|
584
|
-
api_data = fetch_usage()
|
|
585
|
-
usage = build_usage_json(api_data)
|
|
586
|
-
write_usage_file(usage)
|
|
587
|
-
return usage
|
|
588
|
-
except Exception:
|
|
589
|
-
try:
|
|
590
|
-
return json.loads(USAGE_FILE.read_text())
|
|
591
|
-
except Exception:
|
|
592
|
-
return {}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
def cmd_statusline():
|
|
596
|
-
"""Statusline command. Reads cached usage and prints compact summary."""
|
|
597
|
-
R = "\033[0;31m" if _TTY else ""
|
|
598
|
-
Y = "\033[0;33m" if _TTY else ""
|
|
599
|
-
G = "\033[0;32m" if _TTY else ""
|
|
600
|
-
D = "\033[0;90m" if _TTY else ""
|
|
601
|
-
RST = "\033[0m" if _TTY else ""
|
|
602
|
-
|
|
603
|
-
def color_pct(pct: int) -> str:
|
|
604
|
-
c = R if pct >= 70 else Y if pct >= 50 else G
|
|
605
|
-
return f"{c}{pct}%{RST}"
|
|
606
|
-
|
|
607
|
-
def fmt_reset(iso: str | None) -> str:
|
|
608
|
-
if not iso:
|
|
609
|
-
return ""
|
|
610
|
-
try:
|
|
611
|
-
reset = datetime.fromisoformat(iso)
|
|
612
|
-
secs = int((reset - datetime.now(timezone.utc)).total_seconds())
|
|
613
|
-
if secs <= 0:
|
|
614
|
-
return ""
|
|
615
|
-
m = secs // 60
|
|
616
|
-
if m >= 60:
|
|
617
|
-
return f"{m // 60}h{m % 60}m"
|
|
618
|
-
return f"{m}m"
|
|
619
|
-
except Exception:
|
|
620
|
-
return ""
|
|
621
|
-
|
|
622
|
-
usage = _get_cached_usage()
|
|
623
|
-
|
|
624
|
-
plan = usage.get("plan", "?")
|
|
625
|
-
windows = usage_windows(usage)
|
|
626
|
-
|
|
627
|
-
parts = []
|
|
628
|
-
|
|
629
|
-
for window in windows:
|
|
630
|
-
if window.get("pct") is not None:
|
|
631
|
-
parts.append(f"{window['key']}:{color_pct(int(window['pct']))}")
|
|
632
|
-
|
|
633
|
-
parts.append(f"{D}{plan}{RST}")
|
|
634
|
-
|
|
635
|
-
reset = fmt_reset(windows[0].get("resets_at")) if windows else ""
|
|
636
|
-
if reset:
|
|
637
|
-
parts.append(f"{D}reset:{reset}{RST}")
|
|
638
|
-
|
|
639
|
-
print(" ".join(parts))
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
def cmd_install():
|
|
643
|
-
"""Print setup instructions."""
|
|
644
|
-
print("""codex-cli-usage setup
|
|
645
|
-
================
|
|
646
|
-
|
|
647
|
-
1. Run the daemon (in a terminal, tmux, or systemd):
|
|
648
|
-
codex-cli-usage daemon
|
|
649
|
-
|
|
650
|
-
2. Quick check:
|
|
651
|
-
codex-cli-usage # show current usage
|
|
652
|
-
codex-cli-usage json # raw JSON output
|
|
653
|
-
|
|
654
|
-
3. The daemon writes to ~/.codex/usage-limits.json every 5 minutes.
|
|
655
|
-
""")
|
|
3
|
+
from clanker_usage import run
|
|
656
4
|
|
|
657
5
|
|
|
658
6
|
def main():
|
|
659
|
-
|
|
660
|
-
sub = parser.add_subparsers(dest="command")
|
|
661
|
-
sub.add_parser("status", help="Show current usage (default)")
|
|
662
|
-
sub.add_parser("json", help="Print raw JSON")
|
|
663
|
-
daemon_parser = sub.add_parser("daemon", help="Run refresh daemon")
|
|
664
|
-
daemon_parser.add_argument("-i", "--interval", type=int, default=DAEMON_INTERVAL,
|
|
665
|
-
help=f"Refresh interval in seconds (default: {DAEMON_INTERVAL})")
|
|
666
|
-
sub.add_parser("statusline", help="Compact statusline (reads cache)")
|
|
667
|
-
sub.add_parser("install", help="Print setup instructions")
|
|
668
|
-
args = parser.parse_args()
|
|
669
|
-
|
|
670
|
-
cmd = args.command or "status"
|
|
671
|
-
try:
|
|
672
|
-
if cmd == "status":
|
|
673
|
-
cmd_status()
|
|
674
|
-
elif cmd == "json":
|
|
675
|
-
cmd_status(raw_json=True)
|
|
676
|
-
elif cmd == "daemon":
|
|
677
|
-
cmd_daemon(interval=args.interval)
|
|
678
|
-
elif cmd == "statusline":
|
|
679
|
-
cmd_statusline()
|
|
680
|
-
elif cmd == "install":
|
|
681
|
-
cmd_install()
|
|
682
|
-
except (AuthenticationError, UsageServiceError) as error:
|
|
683
|
-
print(f"Error: {error}", file=sys.stderr)
|
|
684
|
-
return 1
|
|
685
|
-
return 0
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
if __name__ == "__main__":
|
|
689
|
-
raise SystemExit(main())
|
|
7
|
+
return run("codex")
|
codex_cli_usage/__main__.py
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: codex-cli-usage
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Codex rate limit monitor
|
|
5
|
+
Project-URL: Homepage, https://github.com/wakamex/clanker-usage
|
|
6
|
+
Project-URL: Source, https://github.com/wakamex/clanker-usage
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: chatgpt,cli,codex,openai,rate-limits,statusline,usage
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Requires-Dist: clanker-usage==0.4.0
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# codex-cli-usage
|
|
15
|
+
|
|
16
|
+
Monitor your Codex quota from the terminal: current usage, a one-line statusline, and a daemon that keeps a cache fresh.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uv tool install codex-cli-usage
|
|
20
|
+
codex-cli-usage # current quotas
|
|
21
|
+
codex-cli-usage statusline # one line, from a cache less than five minutes old
|
|
22
|
+
codex-cli-usage json # the usage-v1 document
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`codex-cli-usage` is the Codex command of [clanker-usage](https://github.com/wakamex/clanker-usage), so it installs clanker-usage at the same version. The documentation lives there.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
codex_cli_usage/__init__.py,sha256=5IcxP1PJkMbOrT029je6Osmw2q--Mrbf9z-T8wMh62A,128
|
|
2
|
+
codex_cli_usage/__main__.py,sha256=BHLi_-4Bx1gsZn-z6x7wECPn1YTxdc74y0deRea9FzY,45
|
|
3
|
+
codex_cli_usage-0.4.0.dist-info/METADATA,sha256=abQdcQ_7FMv1BBC8ZJdxSv8ihwjJAKYQqlUTXfTQpeI,976
|
|
4
|
+
codex_cli_usage-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
codex_cli_usage-0.4.0.dist-info/entry_points.txt,sha256=P4fiVVq76DXzfc_gXLICI7J4-xwdBqeZoQjlUxq2d4g,57
|
|
6
|
+
codex_cli_usage-0.4.0.dist-info/licenses/LICENSE,sha256=w4-6JigLlGuJ1K9j4_lJX593Fwte2kffMCyBbOUQUME,1062
|
|
7
|
+
codex_cli_usage-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mihai
|
|
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,113 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: codex-cli-usage
|
|
3
|
-
Version: 0.1.9
|
|
4
|
-
Summary: Codex CLI usage monitor — fetches rate limits through the Codex app-server
|
|
5
|
-
Project-URL: Homepage, https://github.com/wakamex/codex-cli-usage
|
|
6
|
-
Project-URL: Source, https://github.com/wakamex/codex-cli-usage
|
|
7
|
-
Requires-Python: >=3.12
|
|
8
|
-
Description-Content-Type: text/markdown
|
|
9
|
-
|
|
10
|
-
# codex-cli-usage
|
|
11
|
-
|
|
12
|
-
Codex CLI usage monitor. Fetches your rate limit data through Codex and displays it in the terminal.
|
|
13
|
-
|
|
14
|
-
## Example output
|
|
15
|
-
|
|
16
|
-
`codex-cli-usage` command:
|
|
17
|
-
|
|
18
|
-
```
|
|
19
|
-
Plan: plus
|
|
20
|
-
Weekly 39% resets 143h26m
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Codex statusline (self-caching — refreshes from API when stale, no daemon needed):
|
|
24
|
-
|
|
25
|
-
```
|
|
26
|
-
weekly:39% plus reset:143h26m
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
## Install
|
|
30
|
-
|
|
31
|
-
```bash
|
|
32
|
-
uv tool install codex-cli-usage
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Then run:
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
# Check usage once
|
|
39
|
-
codex-cli-usage
|
|
40
|
-
|
|
41
|
-
# Run the daemon (keeps usage-limits.json updated)
|
|
42
|
-
codex-cli-usage daemon
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
## Commands
|
|
46
|
-
|
|
47
|
-
| Command | Description |
|
|
48
|
-
|---------|-------------|
|
|
49
|
-
| `codex-cli-usage` | Show current usage (colored terminal output) |
|
|
50
|
-
| `codex-cli-usage json` | Print raw JSON |
|
|
51
|
-
| `codex-cli-usage daemon [-i SECS]` | Run in foreground, refresh every 5 min (customizable) |
|
|
52
|
-
| `codex-cli-usage statusline` | Compact statusline (self-caching, no daemon needed) |
|
|
53
|
-
| `codex-cli-usage install` | Print setup instructions |
|
|
54
|
-
|
|
55
|
-
## How Codex CLI rate limiting works
|
|
56
|
-
|
|
57
|
-
Discovered by inspecting the Codex CLI and its authentication flow.
|
|
58
|
-
|
|
59
|
-
### Data sources
|
|
60
|
-
|
|
61
|
-
codex-cli-usage gets rate limit data from:
|
|
62
|
-
|
|
63
|
-
1. **`codex app-server --stdio`** — Preferred. Codex owns authentication and returns rate limits through `account/rateLimits/read`.
|
|
64
|
-
2. **`/backend-api/codex/usage` endpoint** — Compatibility fallback when Codex is missing or its app-server does not support the rate-limit method.
|
|
65
|
-
|
|
66
|
-
### Rate limit types
|
|
67
|
-
|
|
68
|
-
| Type | Description |
|
|
69
|
-
|------|-------------|
|
|
70
|
-
| `primary_window` | Primary window; duration comes from `limit_window_seconds` |
|
|
71
|
-
| `secondary_window` | Secondary window; duration comes from `limit_window_seconds` |
|
|
72
|
-
| `additional_rate_limits` | Per-model limits (e.g. specific model caps) |
|
|
73
|
-
| `code_review_rate_limit` | Code review usage limit |
|
|
74
|
-
|
|
75
|
-
Window names are derived from `limit_window_seconds`, not from whether the
|
|
76
|
-
backend calls a window primary or secondary. Approximately 5-hour, daily,
|
|
77
|
-
weekly, monthly, and annual windows receive duration labels. Other or missing
|
|
78
|
-
durations are shown as `Primary` or `Secondary`. Additional per-model limits
|
|
79
|
-
remain available in JSON for compatibility but are not shown in normal output.
|
|
80
|
-
|
|
81
|
-
### Cache format
|
|
82
|
-
|
|
83
|
-
Schema version 2 stores aggregate windows by their backend role while retaining
|
|
84
|
-
their actual duration:
|
|
85
|
-
|
|
86
|
-
```json
|
|
87
|
-
{
|
|
88
|
-
"schema_version": 2,
|
|
89
|
-
"primary": {
|
|
90
|
-
"pct": 39,
|
|
91
|
-
"resets_at": "2026-07-18T20:00:00+00:00",
|
|
92
|
-
"window_secs": 604800
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
Legacy `5h` and `7d` aliases are written only when the corresponding aggregate
|
|
98
|
-
window actually classifies as 5-hour or weekly. Readers should prefer
|
|
99
|
-
`primary` and `secondary`.
|
|
100
|
-
|
|
101
|
-
### Authentication
|
|
102
|
-
|
|
103
|
-
The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
|
|
104
|
-
|
|
105
|
-
Codex owns automatic token refresh and credential persistence. The compatibility
|
|
106
|
-
fallback reads `auth.json` but never modifies credentials.
|
|
107
|
-
|
|
108
|
-
### Local files
|
|
109
|
-
|
|
110
|
-
| File | Written by | Contains |
|
|
111
|
-
|------|-----------|----------|
|
|
112
|
-
| `~/.codex/auth.json` | Codex CLI | OAuth tokens (access, refresh, id_token) |
|
|
113
|
-
| `~/.codex/usage-limits.json` | codex-cli-usage daemon | Cached API usage data (this tool) |
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
codex_cli_usage/__init__.py,sha256=FPHWaTVqb2-rVXovkA_iE-iqABvZIpApKDFk3sNxGog,22939
|
|
2
|
-
codex_cli_usage/__main__.py,sha256=V_ll4j4TO5TIww8Uh9mQZwAGMMvb66-9JSXuTkYbr3A,59
|
|
3
|
-
codex_cli_usage-0.1.9.dist-info/METADATA,sha256=eO2PlghUxjqpG2IuP1khkCCQKH6EiKPDDrSoj7p8eZI,3455
|
|
4
|
-
codex_cli_usage-0.1.9.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
-
codex_cli_usage-0.1.9.dist-info/entry_points.txt,sha256=P4fiVVq76DXzfc_gXLICI7J4-xwdBqeZoQjlUxq2d4g,57
|
|
6
|
-
codex_cli_usage-0.1.9.dist-info/RECORD,,
|
|
File without changes
|