@0dai-dev/cli 4.3.5 → 4.3.7

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.
Files changed (79) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +214 -40
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +55 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/detect.js +10 -4
  9. package/lib/commands/doctor.js +545 -26
  10. package/lib/commands/experience.js +40 -5
  11. package/lib/commands/export.js +73 -0
  12. package/lib/commands/feedback.js +157 -15
  13. package/lib/commands/gh.js +26 -0
  14. package/lib/commands/graph.js +9 -4
  15. package/lib/commands/heatmap.js +1 -1
  16. package/lib/commands/init.js +222 -30
  17. package/lib/commands/mcp.js +129 -21
  18. package/lib/commands/models.js +138 -41
  19. package/lib/commands/provider.js +30 -59
  20. package/lib/commands/quota.js +1 -1
  21. package/lib/commands/receipt.js +1 -1
  22. package/lib/commands/run.js +18 -7
  23. package/lib/commands/runner.js +31 -1
  24. package/lib/commands/status.js +44 -11
  25. package/lib/commands/swarm.js +130 -12
  26. package/lib/commands/trust.js +286 -0
  27. package/lib/commands/update.js +184 -38
  28. package/lib/commands/usage.js +1 -1
  29. package/lib/commands/validate.js +32 -3
  30. package/lib/commands/vault.js +46 -9
  31. package/lib/python/__init__.py +0 -0
  32. package/lib/python/agent_quotas.py +525 -0
  33. package/lib/python/anomaly_alert.py +397 -0
  34. package/lib/python/anti_pattern_detector.py +799 -0
  35. package/lib/python/auth.py +443 -0
  36. package/lib/python/capi_profile_guard.py +477 -0
  37. package/lib/python/compliance_report.py +581 -0
  38. package/lib/python/drift_detector.py +388 -0
  39. package/lib/python/experience_pipeline.py +1130 -0
  40. package/lib/python/graph.py +19 -0
  41. package/lib/python/graph_core.py +293 -0
  42. package/lib/python/graph_io.py +179 -0
  43. package/lib/python/graph_legacy.py +2052 -0
  44. package/lib/python/graph_legacy_helpers.py +221 -0
  45. package/lib/python/graph_outcomes_core.py +85 -0
  46. package/lib/python/graph_queries.py +171 -0
  47. package/lib/python/graph_slice.py +198 -0
  48. package/lib/python/graph_slicer.py +576 -0
  49. package/lib/python/graph_slicer_cli.py +60 -0
  50. package/lib/python/graph_validation.py +64 -0
  51. package/lib/python/heatmap.py +934 -0
  52. package/lib/python/json_utils.py +193 -0
  53. package/lib/python/mcp_exposure_check.py +247 -0
  54. package/lib/python/model_router.py +1434 -0
  55. package/lib/python/project_manager.py +621 -0
  56. package/lib/python/provider_profiles.py +1618 -0
  57. package/lib/python/provider_registry.py +1211 -0
  58. package/lib/python/provider_registry_cli.py +125 -0
  59. package/lib/python/receipt_png.py +727 -0
  60. package/lib/python/structural_memory.py +325 -0
  61. package/lib/python/swarm_cost.py +177 -0
  62. package/lib/python/usage_ledger.py +569 -0
  63. package/lib/scripts/mcp_tier_config.py +240 -0
  64. package/lib/shared.js +97 -14
  65. package/lib/tui/index.mjs +35174 -0
  66. package/lib/utils/activation_telemetry.js +230 -11
  67. package/lib/utils/constants.js +7 -1
  68. package/lib/utils/export-bundler.js +285 -0
  69. package/lib/utils/identity.js +198 -1
  70. package/lib/utils/mcp-auth.js +81 -15
  71. package/lib/utils/plan.js +1 -1
  72. package/lib/vault/index.js +19 -3
  73. package/lib/vault/storage.js +21 -2
  74. package/lib/wizard.js +5 -2
  75. package/package.json +9 -3
  76. package/scripts/build-python-bundle.js +106 -0
  77. package/scripts/build-tui.js +14 -1
  78. package/scripts/harvest_experience.py +523 -0
  79. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env python3
2
+ """Agent subscription quota poller.
3
+
4
+ Polls each known coding-agent CLI (codex, qoder, claude, opencode, gemini, capi)
5
+ for its current subscription usage and caches the result in
6
+ ``ai/manifest/agent-quotas.json``. The cache TTL is 10 minutes; callers should
7
+ treat the file as read-mostly runtime state (it is gitignored).
8
+
9
+ Public surface
10
+ --------------
11
+ - ``get_quotas(target=None, force_refresh=False) -> dict``
12
+ Returns ``{agent: {plan, weekly_pct_remaining, hourly_pct_remaining,
13
+ resets_at, source, error}}`` for every known agent. Deprecated
14
+ ``*_pct_used`` aliases are still emitted for old dashboards/cache readers,
15
+ but subscription banners expose remaining quota.
16
+ - ``poll_agent(agent) -> dict``
17
+ Polls a single agent and returns its quota record (no cache I/O).
18
+ - ``CACHE_TTL_SECONDS`` - default 600s.
19
+
20
+ The poller is defensive: a missing CLI, a non-zero exit, an unparseable
21
+ banner, or a tmux failure all yield a record with ``error`` set, never
22
+ an exception. The dispatcher (``swarm_lib.dispatch_candidates_for_task``)
23
+ treats any agent without a clean quota record as "unknown" and keeps it
24
+ in the candidate list.
25
+
26
+ Codex usage banner is captured by attaching tmux to ``codex --status``
27
+ output; the regex looks for the canonical
28
+ ``Context X% left * Context Y% used * 5h Z% * w W%`` shape.
29
+ Qoder is queried via ``qodercli quota --json`` if the CLI is on PATH.
30
+ Claude / OpenCode / Gemini / CAPI fall back to "unknown" gracefully when
31
+ their respective CLIs do not expose a quota subcommand on this host.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ import logging
38
+ import os
39
+ import pathlib
40
+ import re
41
+ import shutil
42
+ import subprocess
43
+ import time
44
+ from typing import Any, Callable
45
+
46
+ log = logging.getLogger("0dai.agent_quotas")
47
+
48
+ KNOWN_AGENTS: tuple[str, ...] = (
49
+ "codex",
50
+ "qoder",
51
+ "claude",
52
+ "opencode",
53
+ "gemini",
54
+ "capi",
55
+ )
56
+
57
+ CACHE_TTL_SECONDS = 600 # 10 minutes per the spec
58
+ DEFAULT_TARGET = pathlib.Path.cwd()
59
+ CACHE_RELATIVE = pathlib.Path("ai") / "manifest" / "agent-quotas.json"
60
+
61
+ # Codex usage banner pattern, e.g.
62
+ # Context 41% left · Context 59% used · 5h 12% · w 38%
63
+ # The trailing weekly group is optional because narrow tmux panes truncate
64
+ # the banner to e.g. "5h 96% ·…" — we still want hourly + context%
65
+ # even when weekly is cut off.
66
+ CODEX_USAGE_RE = re.compile(
67
+ r"Context\s+(?P<ctx_left>\d+)%\s+left\s*[·•|-]\s*"
68
+ r"Context\s+(?P<ctx_used>\d+)%\s+used\s*[·•|-]\s*"
69
+ r"5h\s+(?P<hourly>\d+)%"
70
+ r"(?:\s*[·•|-]\s*w\s+(?P<weekly>\d+)%)?",
71
+ re.IGNORECASE,
72
+ )
73
+
74
+ # Generic "X% used / Y% remaining" fallback
75
+ GENERIC_PCT_RE = re.compile(r"(?P<n>\d{1,3})\s*%")
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # Cache I/O
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ def _resolve_target(target: str | os.PathLike[str] | None) -> pathlib.Path:
84
+ if target is None:
85
+ return DEFAULT_TARGET
86
+ return pathlib.Path(target).resolve()
87
+
88
+
89
+ def _cache_path(target: pathlib.Path) -> pathlib.Path:
90
+ return target / CACHE_RELATIVE
91
+
92
+
93
+ def _load_cache(path: pathlib.Path) -> dict[str, Any] | None:
94
+ try:
95
+ return json.loads(path.read_text(encoding="utf-8"))
96
+ except (OSError, ValueError):
97
+ return None
98
+
99
+
100
+ def _save_cache(path: pathlib.Path, payload: dict[str, Any]) -> None:
101
+ try:
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
104
+ except OSError as exc:
105
+ log.warning("agent_quotas cache write failed at %s: %s", path, exc)
106
+
107
+
108
+ def _is_fresh(cache: dict[str, Any] | None, now: float, ttl: int) -> bool:
109
+ if not cache:
110
+ return False
111
+ fetched = cache.get("fetched_at")
112
+ if not isinstance(fetched, (int, float)):
113
+ return False
114
+ return (now - float(fetched)) < ttl
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Per-agent pollers
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ def _empty_record(
123
+ agent: str, *, error: str | None = None, source: str = "unknown"
124
+ ) -> dict[str, Any]:
125
+ return {
126
+ "agent": agent,
127
+ "plan": "unknown",
128
+ "weekly_pct_remaining": None,
129
+ "hourly_pct_remaining": None,
130
+ # Deprecated compatibility aliases. The stored values are remaining,
131
+ # matching historical cache semantics despite the old field names.
132
+ "weekly_pct_used": None,
133
+ "hourly_pct_used": None,
134
+ "resets_at": None,
135
+ "source": source,
136
+ "error": error,
137
+ }
138
+
139
+
140
+ def _coerce_pct(value: Any) -> int | None:
141
+ if value is None or isinstance(value, bool):
142
+ return None
143
+ try:
144
+ pct = int(value)
145
+ except (TypeError, ValueError):
146
+ return None
147
+ return max(0, min(100, pct))
148
+
149
+
150
+ def _first_present(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
151
+ for key in keys:
152
+ if key in data and data[key] is not None:
153
+ return data[key]
154
+ return None
155
+
156
+
157
+ def _with_remaining_fields(
158
+ *,
159
+ weekly: Any = None,
160
+ hourly: Any = None,
161
+ ) -> dict[str, int | None]:
162
+ weekly_pct = _coerce_pct(weekly)
163
+ hourly_pct = _coerce_pct(hourly)
164
+ return {
165
+ "weekly_pct_remaining": weekly_pct,
166
+ "hourly_pct_remaining": hourly_pct,
167
+ "weekly_pct_used": weekly_pct,
168
+ "hourly_pct_used": hourly_pct,
169
+ }
170
+
171
+
172
+ def quota_pct_remaining(record: dict[str, Any] | None, window: str) -> float | None:
173
+ """Return quota remaining for ``weekly``/``hourly`` records.
174
+
175
+ New records use ``*_pct_remaining``. Old cache records only have
176
+ ``*_pct_used``; those values were already remaining but mislabeled, so they
177
+ remain a fail-open compatibility fallback.
178
+ """
179
+ if not isinstance(record, dict):
180
+ return None
181
+ canonical = record.get(f"{window}_pct_remaining")
182
+ if isinstance(canonical, (int, float)) and not isinstance(canonical, bool):
183
+ return float(canonical)
184
+ legacy = record.get(f"{window}_pct_used")
185
+ if isinstance(legacy, (int, float)) and not isinstance(legacy, bool):
186
+ return float(legacy)
187
+ return None
188
+
189
+
190
+ def _which(binary: str) -> str | None:
191
+ return shutil.which(binary)
192
+
193
+
194
+ def _run(cmd: list[str], *, timeout: int = 8) -> tuple[int, str, str]:
195
+ """Run a subprocess and capture stdout/stderr without raising."""
196
+ try:
197
+ proc = subprocess.run(
198
+ cmd,
199
+ stdout=subprocess.PIPE,
200
+ stderr=subprocess.PIPE,
201
+ timeout=timeout,
202
+ check=False,
203
+ )
204
+ out = proc.stdout.decode("utf-8", errors="replace") if proc.stdout else ""
205
+ err = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
206
+ return proc.returncode, out, err
207
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
208
+ return 1, "", f"{type(exc).__name__}: {exc}"
209
+
210
+
211
+ def _parse_codex_banner(text: str) -> dict[str, Any] | None:
212
+ m = CODEX_USAGE_RE.search(text)
213
+ if not m:
214
+ return None
215
+ weekly_raw = m.group("weekly")
216
+ return _with_remaining_fields(
217
+ weekly=weekly_raw if weekly_raw is not None else None,
218
+ hourly=m.group("hourly"),
219
+ )
220
+
221
+
222
+ def poll_codex(
223
+ *, runner: Callable[[list[str]], tuple[int, str, str]] = _run
224
+ ) -> dict[str, Any]:
225
+ """Poll codex via tmux capture if available, otherwise via ``codex --status``."""
226
+ rec = _empty_record("codex")
227
+ bin_path = _which("codex")
228
+ if not bin_path:
229
+ rec["error"] = "codex CLI not found on PATH"
230
+ return rec
231
+ rec["plan"] = "subscription"
232
+
233
+ # Try direct status first - cheap and avoids tmux churn
234
+ code, out, err = runner([bin_path, "--status"])
235
+ text = (out or "") + "\n" + (err or "")
236
+ parsed = _parse_codex_banner(text)
237
+ if parsed:
238
+ rec.update(parsed)
239
+ rec["source"] = "codex --status"
240
+ return rec
241
+
242
+ # Fall back to tmux session capture. Spec: "tmux capture + regex".
243
+ # Use -x 220 so the codex TUI status bar fits on one line — narrower
244
+ # panes truncate "5h 96% · w 38%" to "5h 96% ·…" via Unicode ellipsis,
245
+ # which breaks the banner regex even with the weekly group made optional.
246
+ if _which("tmux"):
247
+ session = f"0dai-quota-codex-{int(time.time())}"
248
+ try:
249
+ runner(
250
+ [
251
+ "tmux", "new-session", "-d", "-s", session,
252
+ "-x", "220", "-y", "50",
253
+ bin_path,
254
+ ]
255
+ )
256
+ time.sleep(5.0) # codex TUI banner takes ~3-5s to render after spawn
257
+ cap_code, cap_out, _cap_err = runner(
258
+ ["tmux", "capture-pane", "-pt", session]
259
+ )
260
+ parsed = _parse_codex_banner(cap_out or "")
261
+ if parsed:
262
+ rec.update(parsed)
263
+ rec["source"] = "tmux capture"
264
+ return rec
265
+ finally:
266
+ runner(["tmux", "kill-session", "-t", session])
267
+
268
+ rec["error"] = "codex usage banner not found"
269
+ return rec
270
+
271
+
272
+ def poll_qoder(
273
+ *, runner: Callable[[list[str]], tuple[int, str, str]] = _run
274
+ ) -> dict[str, Any]:
275
+ rec = _empty_record("qoder")
276
+ bin_path = _which("qodercli") or _which("qoder")
277
+ if not bin_path:
278
+ rec["error"] = "qoder CLI not found on PATH"
279
+ return rec
280
+ rec["plan"] = "subscription"
281
+
282
+ # Prefer JSON if the CLI supports it; fall back to plain text.
283
+ for argset in (["quota", "--json"], ["quota"], ["--quota"], ["status", "--json"]):
284
+ code, out, err = runner([bin_path, *argset])
285
+ if code != 0 and not out:
286
+ continue
287
+ try:
288
+ data = json.loads(out)
289
+ except (ValueError, TypeError):
290
+ data = None
291
+ if isinstance(data, dict):
292
+ weekly = _first_present(
293
+ data,
294
+ (
295
+ "weekly_pct_remaining",
296
+ "weekly_remaining_pct",
297
+ "weekly_remaining",
298
+ "weekly",
299
+ ),
300
+ )
301
+ hourly = _first_present(
302
+ data,
303
+ (
304
+ "hourly_pct_remaining",
305
+ "hourly_remaining_pct",
306
+ "hourly_remaining",
307
+ "hourly",
308
+ ),
309
+ )
310
+ weekly_used = _first_present(data, ("weekly_pct_used", "weekly_used_pct"))
311
+ hourly_used = _first_present(data, ("hourly_pct_used", "hourly_used_pct"))
312
+ if weekly is None and weekly_used is not None:
313
+ used = _coerce_pct(weekly_used)
314
+ weekly = None if used is None else 100 - used
315
+ if hourly is None and hourly_used is not None:
316
+ used = _coerce_pct(hourly_used)
317
+ hourly = None if used is None else 100 - used
318
+ plan = data.get("plan") or data.get("tier")
319
+ resets = data.get("resets_at") or data.get("reset_at")
320
+ if weekly is not None or hourly is not None or plan:
321
+ rec.update(_with_remaining_fields(weekly=weekly, hourly=hourly))
322
+ if plan:
323
+ rec["plan"] = str(plan)
324
+ if resets:
325
+ rec["resets_at"] = str(resets)
326
+ rec["source"] = f"{bin_path} {' '.join(argset)}"
327
+ return rec
328
+ # Plain-text fallback: scrape any "weekly: X%" / "hourly: Y%" lines
329
+ weekly_m = re.search(r"weekly[^0-9%]*(\d{1,3})\s*%", out, re.IGNORECASE)
330
+ hourly_m = re.search(r"(?:hourly|5h)[^0-9%]*(\d{1,3})\s*%", out, re.IGNORECASE)
331
+ if weekly_m or hourly_m:
332
+ rec.update(
333
+ _with_remaining_fields(
334
+ weekly=weekly_m.group(1) if weekly_m else None,
335
+ hourly=hourly_m.group(1) if hourly_m else None,
336
+ )
337
+ )
338
+ rec["source"] = f"{bin_path} {' '.join(argset)}"
339
+ return rec
340
+
341
+ rec["error"] = "qoder quota output not parseable"
342
+ return rec
343
+
344
+
345
+ def _poll_unknown(agent: str) -> dict[str, Any]:
346
+ """Return a record indicating the agent has no programmatic quota source."""
347
+ bin_path = _which(agent)
348
+ rec = _empty_record(agent)
349
+ if not bin_path:
350
+ rec["error"] = f"{agent} CLI not found on PATH"
351
+ return rec
352
+ rec["plan"] = "subscription"
353
+ rec["source"] = "no-quota-api"
354
+ rec["error"] = f"{agent} CLI does not expose a quota endpoint"
355
+ return rec
356
+
357
+
358
+ def poll_claude() -> dict[str, Any]:
359
+ return _poll_unknown("claude")
360
+
361
+
362
+ def poll_opencode() -> dict[str, Any]:
363
+ return _poll_unknown("opencode")
364
+
365
+
366
+ def poll_gemini() -> dict[str, Any]:
367
+ return _poll_unknown("gemini")
368
+
369
+
370
+ def poll_capi() -> dict[str, Any]:
371
+ rec = _poll_unknown("capi")
372
+ # capi rides on the user's account-local token; treat as "unknown"
373
+ # but mark plan as 'managed' so dispatcher knows it's available.
374
+ if rec.get("error", "").endswith("not found on PATH"):
375
+ return rec
376
+ rec["plan"] = "managed"
377
+ return rec
378
+
379
+
380
+ _POLLERS: dict[str, Callable[[], dict[str, Any]]] = {
381
+ "codex": poll_codex,
382
+ "qoder": poll_qoder,
383
+ "claude": poll_claude,
384
+ "opencode": poll_opencode,
385
+ "gemini": poll_gemini,
386
+ "capi": poll_capi,
387
+ }
388
+
389
+
390
+ def poll_agent(agent: str) -> dict[str, Any]:
391
+ poller = _POLLERS.get(agent)
392
+ if not poller:
393
+ return _empty_record(agent, error=f"unknown agent: {agent}")
394
+ try:
395
+ return poller()
396
+ except Exception as exc: # noqa: BLE001 — intentional: defensive, never let a third-party poller raise out
397
+ log.warning("poll_agent(%s) failed: %s", agent, exc)
398
+ return _empty_record(agent, error=f"poller exception: {exc}")
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # Public API
403
+ # ---------------------------------------------------------------------------
404
+
405
+
406
+ def refresh_quotas(target: str | os.PathLike[str] | None = None) -> dict[str, Any]:
407
+ """Force a poll of every known agent and persist to cache."""
408
+ target_path = _resolve_target(target)
409
+ now = time.time()
410
+ agents: dict[str, Any] = {}
411
+ for agent in KNOWN_AGENTS:
412
+ agents[agent] = poll_agent(agent)
413
+ payload = {
414
+ "fetched_at": now,
415
+ "fetched_at_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
416
+ "ttl_seconds": CACHE_TTL_SECONDS,
417
+ "agents": agents,
418
+ }
419
+ _save_cache(_cache_path(target_path), payload)
420
+ return payload
421
+
422
+
423
+ def get_quotas(
424
+ target: str | os.PathLike[str] | None = None,
425
+ *,
426
+ force_refresh: bool = False,
427
+ ttl_seconds: int = CACHE_TTL_SECONDS,
428
+ ) -> dict[str, Any]:
429
+ """Return cached quota data, refreshing if stale or forced.
430
+
431
+ Returns the ``agents`` mapping directly: ``{agent: record}``.
432
+ The full envelope (with ``fetched_at`` etc.) is available via
433
+ :func:`get_quotas_envelope`.
434
+ """
435
+ return get_quotas_envelope(
436
+ target, force_refresh=force_refresh, ttl_seconds=ttl_seconds
437
+ )["agents"]
438
+
439
+
440
+ def get_quotas_envelope(
441
+ target: str | os.PathLike[str] | None = None,
442
+ *,
443
+ force_refresh: bool = False,
444
+ ttl_seconds: int = CACHE_TTL_SECONDS,
445
+ ) -> dict[str, Any]:
446
+ target_path = _resolve_target(target)
447
+ cache_file = _cache_path(target_path)
448
+ now = time.time()
449
+ cache = _load_cache(cache_file)
450
+ if not force_refresh and _is_fresh(cache, now, ttl_seconds):
451
+ return cache # type: ignore[return-value]
452
+ return refresh_quotas(target_path)
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Helpers used by dispatcher
457
+ # ---------------------------------------------------------------------------
458
+
459
+ WEEKLY_EXHAUSTION_THRESHOLD = 5 # subscription banners expose remaining quota
460
+
461
+
462
+ def is_agent_exhausted(
463
+ record: dict[str, Any] | None, threshold: int = WEEKLY_EXHAUSTION_THRESHOLD
464
+ ) -> bool:
465
+ """True iff the record reports weekly remaining quota at or below ``threshold``.
466
+
467
+ Records with no weekly data (None / unknown / error) are NOT considered
468
+ exhausted - the dispatcher should not penalize agents whose CLI lacks a
469
+ quota endpoint.
470
+ """
471
+ weekly = quota_pct_remaining(record, "weekly")
472
+ if weekly is None:
473
+ return False
474
+ return float(weekly) <= float(threshold)
475
+
476
+
477
+ # ---------------------------------------------------------------------------
478
+ # CLI entry point - useful for `python3 -m scripts.agent_quotas`
479
+ # ---------------------------------------------------------------------------
480
+
481
+
482
+ def _format_table(envelope: dict[str, Any]) -> str:
483
+ rows = [("AGENT", "PLAN", "WEEK%", "HOUR%", "RESETS", "SOURCE")]
484
+ for agent in KNOWN_AGENTS:
485
+ rec = envelope.get("agents", {}).get(agent) or _empty_record(agent)
486
+ weekly = quota_pct_remaining(rec, "weekly")
487
+ hourly = quota_pct_remaining(rec, "hourly")
488
+ rows.append(
489
+ (
490
+ agent,
491
+ str(rec.get("plan") or "unknown"),
492
+ "—" if weekly is None else f"{weekly}%",
493
+ "—" if hourly is None else f"{hourly}%",
494
+ str(rec.get("resets_at") or "—"),
495
+ str(rec.get("source") or rec.get("error") or "—"),
496
+ )
497
+ )
498
+ widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
499
+ lines = []
500
+ for row in rows:
501
+ lines.append(" ".join(row[i].ljust(widths[i]) for i in range(len(row))))
502
+ return "\n".join(lines)
503
+
504
+
505
+ def main(argv: list[str] | None = None) -> int:
506
+ import argparse
507
+
508
+ parser = argparse.ArgumentParser(description="0dai agent quota poller")
509
+ parser.add_argument("--target", default=None, help="Project root (default: cwd)")
510
+ parser.add_argument(
511
+ "--refresh", action="store_true", help="Force refresh, ignore cache TTL"
512
+ )
513
+ parser.add_argument("--json", action="store_true", help="Emit JSON envelope")
514
+ args = parser.parse_args(argv)
515
+
516
+ envelope = get_quotas_envelope(args.target, force_refresh=args.refresh)
517
+ if args.json:
518
+ print(json.dumps(envelope, indent=2, sort_keys=True))
519
+ else:
520
+ print(_format_table(envelope))
521
+ return 0
522
+
523
+
524
+ if __name__ == "__main__":
525
+ raise SystemExit(main())