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.
@@ -1,689 +1,7 @@
1
- #!/usr/bin/env python3
2
- """codex-cli-usage - Codex CLI usage monitor.
1
+ """codex-cli-usage: the Codex command of clanker-usage."""
3
2
 
4
- Fetches rate limit data from OpenAI's ChatGPT backend API
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
- parser = argparse.ArgumentParser(description="Codex CLI usage monitor")
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")
@@ -1,3 +1,3 @@
1
- from codex_cli_usage import main
1
+ from . import main
2
2
 
3
3
  raise SystemExit(main())
@@ -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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -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,,