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