@0dai-dev/cli 4.3.6 → 4.3.8

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 (77) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +133 -33
  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 +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +707 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +298 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/play.js +20 -4
  18. package/lib/commands/provider.js +30 -59
  19. package/lib/commands/quota.js +1 -1
  20. package/lib/commands/receipt.js +1 -1
  21. package/lib/commands/run.js +14 -6
  22. package/lib/commands/runner.js +31 -1
  23. package/lib/commands/status.js +176 -11
  24. package/lib/commands/swarm.js +130 -12
  25. package/lib/commands/trust.js +1 -1
  26. package/lib/commands/update.js +184 -38
  27. package/lib/commands/usage.js +1 -1
  28. package/lib/commands/validate.js +32 -3
  29. package/lib/commands/vault.js +43 -8
  30. package/lib/python/__init__.py +0 -0
  31. package/lib/python/agent_quotas.py +525 -0
  32. package/lib/python/anomaly_alert.py +397 -0
  33. package/lib/python/anti_pattern_detector.py +799 -0
  34. package/lib/python/auth.py +443 -0
  35. package/lib/python/capi_profile_guard.py +477 -0
  36. package/lib/python/compliance_report.py +581 -0
  37. package/lib/python/drift_detector.py +388 -0
  38. package/lib/python/experience_pipeline.py +1130 -0
  39. package/lib/python/graph.py +19 -0
  40. package/lib/python/graph_core.py +293 -0
  41. package/lib/python/graph_io.py +179 -0
  42. package/lib/python/graph_legacy.py +2052 -0
  43. package/lib/python/graph_legacy_helpers.py +221 -0
  44. package/lib/python/graph_outcomes_core.py +85 -0
  45. package/lib/python/graph_queries.py +171 -0
  46. package/lib/python/graph_slice.py +198 -0
  47. package/lib/python/graph_slicer.py +576 -0
  48. package/lib/python/graph_slicer_cli.py +60 -0
  49. package/lib/python/graph_validation.py +64 -0
  50. package/lib/python/heatmap.py +943 -0
  51. package/lib/python/json_utils.py +193 -0
  52. package/lib/python/mcp_exposure_check.py +247 -0
  53. package/lib/python/model_router.py +1434 -0
  54. package/lib/python/project_manager.py +621 -0
  55. package/lib/python/provider_profiles.py +1618 -0
  56. package/lib/python/provider_registry.py +1211 -0
  57. package/lib/python/provider_registry_cli.py +125 -0
  58. package/lib/python/receipt_png.py +727 -0
  59. package/lib/python/structural_memory.py +325 -0
  60. package/lib/python/swarm_cost.py +177 -0
  61. package/lib/python/usage_ledger.py +569 -0
  62. package/lib/scripts/mcp_tier_config.py +240 -0
  63. package/lib/shared.js +96 -12
  64. package/lib/tui/index.mjs +35174 -0
  65. package/lib/utils/activation_telemetry.js +1 -4
  66. package/lib/utils/constants.js +7 -1
  67. package/lib/utils/identity.js +184 -0
  68. package/lib/utils/mcp-auth.js +81 -15
  69. package/lib/utils/plan.js +1 -1
  70. package/lib/vault/index.js +19 -3
  71. package/lib/vault/storage.js +21 -2
  72. package/lib/wizard.js +5 -2
  73. package/package.json +9 -3
  74. package/scripts/build-python-bundle.js +106 -0
  75. package/scripts/build-tui.js +14 -1
  76. package/scripts/harvest_experience.py +523 -0
  77. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,727 @@
1
+ #!/usr/bin/env python3
2
+ """The Bill — shareable receipt PNG for a 0dai session.
3
+
4
+ Industrializes the "AI-dev receipt screenshot" format (vinext $1,100,
5
+ Huntley $297 → $50k, Fynn API-leak) by fusing cost + LOC + agent identity
6
+ + elapsed time + CI status from existing 0dai telemetry into a 1200×630
7
+ OG-sized PNG.
8
+
9
+ Pipeline
10
+ --------
11
+ 1. `collect_session_stats(target, session)` — pure data: reads the session
12
+ payload (active.json or saved/<id>.json) + experience events in window.
13
+ 2. `render_receipt(stats, out_path)` — Pillow 1200×630, monospace,
14
+ neural-substrate palette, big number + 4 chips.
15
+ 3. `copy_to_clipboard(path)` — pbcopy (mac) / xclip / wl-copy (linux),
16
+ image/png content type where the tool supports it.
17
+
18
+ Issue: #529
19
+ Design: ai/ideas/20260418-062835-0dai_feature_ideation_33_bold_plays_for_April_2026.md #1
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import pathlib
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ import time
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Palette — neural-substrate (matches CLI T = \x1b[38;2;45;212;168m)
34
+ # ---------------------------------------------------------------------------
35
+
36
+ PALETTE = {
37
+ "bg": (11, 18, 32), # #0B1220
38
+ "panel": (17, 25, 43), # #11192B
39
+ "accent": (45, 212, 168), # #2DD4A8 (0dai teal)
40
+ "text": (230, 237, 243), # #E6EDF3
41
+ "dim": (107, 114, 128), # #6B7280
42
+ "success": (134, 239, 172), # #86EFAC
43
+ "warn": (252, 211, 77), # #FCD34D
44
+ "error": (248, 113, 113), # #F87171
45
+ }
46
+
47
+ CANVAS = (1200, 630)
48
+ RECEIPTS_DIR_ENV = "ODAI_RECEIPTS_DIR"
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # File helpers
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _receipts_dir() -> pathlib.Path:
57
+ override = os.environ.get(RECEIPTS_DIR_ENV)
58
+ if override:
59
+ return pathlib.Path(override).expanduser()
60
+ return pathlib.Path.home() / ".0dai" / "receipts"
61
+
62
+
63
+ def _load_json(path: pathlib.Path) -> dict | None:
64
+ try:
65
+ return json.loads(path.read_text(encoding="utf-8"))
66
+ except (OSError, json.JSONDecodeError):
67
+ return None
68
+
69
+
70
+ def _parse_iso(value: str | None) -> float:
71
+ if not value:
72
+ return 0.0
73
+ try:
74
+ from datetime import datetime, timezone
75
+ text = str(value).strip().replace("Z", "+00:00")
76
+ return datetime.fromisoformat(text).astimezone(timezone.utc).timestamp()
77
+ except (ValueError, TypeError):
78
+ return 0.0
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Session lookup
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ def _active_session(target: pathlib.Path) -> dict | None:
87
+ return _load_json(target / "ai" / "sessions" / "active.json")
88
+
89
+
90
+ def _saved_session(target: pathlib.Path, session_id: str) -> dict | None:
91
+ saved_dir = target / "ai" / "sessions" / "saved"
92
+ if not saved_dir.is_dir():
93
+ return None
94
+ # Exact match first
95
+ direct = saved_dir / f"{session_id}.json"
96
+ if direct.is_file():
97
+ return _load_json(direct)
98
+ # Then search payloads by id field
99
+ for path in saved_dir.glob("*.json"):
100
+ payload = _load_json(path)
101
+ if payload and str(payload.get("session_id") or payload.get("id") or "") == session_id:
102
+ return payload
103
+ return None
104
+
105
+
106
+ def _last_saved_session(target: pathlib.Path) -> dict | None:
107
+ saved_dir = target / "ai" / "sessions" / "saved"
108
+ if not saved_dir.is_dir():
109
+ return None
110
+ candidates: list[tuple[float, dict]] = []
111
+ for path in saved_dir.glob("*.json"):
112
+ payload = _load_json(path)
113
+ if not payload:
114
+ continue
115
+ ts = _parse_iso(
116
+ payload.get("saved_at") or payload.get("updated") or payload.get("started")
117
+ )
118
+ candidates.append((ts, payload))
119
+ if not candidates:
120
+ return None
121
+ candidates.sort(key=lambda x: x[0], reverse=True)
122
+ return candidates[0][1]
123
+
124
+
125
+ def _resolve_session(target: pathlib.Path, selector: str) -> dict | None:
126
+ """selector: 'active' | 'last' | <session_id>."""
127
+ if selector == "active":
128
+ return _active_session(target)
129
+ if selector == "last":
130
+ last = _last_saved_session(target)
131
+ if last:
132
+ return last
133
+ return _active_session(target)
134
+ explicit = _saved_session(target, selector)
135
+ if explicit:
136
+ return explicit
137
+ active = _active_session(target)
138
+ if active and str(active.get("id") or active.get("session_id") or "") == selector:
139
+ return active
140
+ return None
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Experience events → cost / agents / tasks
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def _iter_events(target: pathlib.Path) -> list[dict]:
149
+ events_dir = target / "ai" / "experience" / "events"
150
+ if not events_dir.is_dir():
151
+ return []
152
+ events: list[dict] = []
153
+ for path in sorted(events_dir.glob("*.jsonl")):
154
+ try:
155
+ for line in path.read_text(encoding="utf-8").splitlines():
156
+ line = line.strip()
157
+ if not line:
158
+ continue
159
+ try:
160
+ events.append(json.loads(line))
161
+ except json.JSONDecodeError:
162
+ continue
163
+ except OSError:
164
+ continue
165
+ # Also allow single-event .json files (saved event payloads)
166
+ for path in sorted(events_dir.glob("*.json")):
167
+ payload = _load_json(path)
168
+ if payload:
169
+ events.append(payload)
170
+ return events
171
+
172
+
173
+ def _event_cost(event: dict) -> float:
174
+ task = event.get("task") or {}
175
+ value = task.get("cost_usd") or event.get("cost_usd") or event.get("cost")
176
+ try:
177
+ return float(value or 0.0)
178
+ except (TypeError, ValueError):
179
+ return 0.0
180
+
181
+
182
+ def _event_agent(event: dict) -> str:
183
+ return str(
184
+ event.get("tool") or event.get("agent") or (event.get("task") or {}).get("agent") or ""
185
+ ).strip()
186
+
187
+
188
+ def _event_elapsed(event: dict) -> float:
189
+ task = event.get("task") or {}
190
+ value = (
191
+ task.get("elapsed_seconds")
192
+ or event.get("elapsed_seconds")
193
+ or event.get("elapsed")
194
+ or 0.0
195
+ )
196
+ try:
197
+ return float(value or 0.0)
198
+ except (TypeError, ValueError):
199
+ return 0.0
200
+
201
+
202
+ def _event_result(event: dict) -> str:
203
+ task = event.get("task") or {}
204
+ result = task.get("result") or event.get("result") or event.get("event_type") or ""
205
+ if event.get("success") is True:
206
+ return "success"
207
+ if event.get("success") is False:
208
+ return "failure"
209
+ if event.get("ci_passed") is True:
210
+ return "success"
211
+ if event.get("ci_passed") is False:
212
+ return "failure"
213
+ return str(result or "").lower()
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Git diff → lines_added / lines_removed
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def _git_shortstat(target: pathlib.Path, base_ref: str | None) -> tuple[int, int]:
222
+ """Return (added, removed) via `git diff --shortstat`.
223
+
224
+ base_ref can be a commit SHA, branch, or None (working-tree diff).
225
+ Silent on any git error — returns (0, 0).
226
+ """
227
+ cmd = ["git", "-C", str(target), "diff", "--shortstat"]
228
+ if base_ref:
229
+ cmd.append(base_ref)
230
+ try:
231
+ out = subprocess.run(
232
+ cmd, check=False, capture_output=True, text=True, timeout=10,
233
+ ).stdout.strip()
234
+ except (OSError, subprocess.TimeoutExpired):
235
+ return (0, 0)
236
+ added = removed = 0
237
+ for chunk in out.split(","):
238
+ chunk = chunk.strip()
239
+ if "insertion" in chunk:
240
+ added = int(chunk.split(" ", 1)[0])
241
+ elif "deletion" in chunk:
242
+ removed = int(chunk.split(" ", 1)[0])
243
+ return (added, removed)
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Stats collection
248
+ # ---------------------------------------------------------------------------
249
+
250
+
251
+ def collect_session_stats(
252
+ target: pathlib.Path | str,
253
+ session: str = "active",
254
+ *,
255
+ now_epoch: float | None = None,
256
+ diff_reader=_git_shortstat,
257
+ ) -> dict:
258
+ """Build a receipt-ready stats dict for a session.
259
+
260
+ Values default to neutral (0 / empty / "unknown") when telemetry is
261
+ partial — the receipt still renders so operators can share *something*
262
+ even on low-data sessions.
263
+ """
264
+ target = pathlib.Path(target).resolve()
265
+ payload = _resolve_session(target, session) or {}
266
+
267
+ session_id = str(payload.get("session_id") or payload.get("id") or "session")
268
+ started = _parse_iso(payload.get("started") or payload.get("started_at"))
269
+ ended = _parse_iso(
270
+ payload.get("saved_at") or payload.get("updated") or payload.get("completed_at")
271
+ )
272
+ if ended <= 0:
273
+ ended = now_epoch if now_epoch is not None else time.time()
274
+ elapsed_s = max(0.0, ended - started) if started else 0.0
275
+
276
+ # Agents: started_by + current_agent + history + event-tool set
277
+ agents: list[str] = []
278
+ def _add(agent: str) -> None:
279
+ a = str(agent or "").strip()
280
+ if a and a not in agents:
281
+ agents.append(a)
282
+
283
+ _add(payload.get("started_by") or "")
284
+ _add(payload.get("current_agent") or "")
285
+ for entry in (payload.get("history") or []):
286
+ if isinstance(entry, dict):
287
+ _add(entry.get("agent") or "")
288
+
289
+ events = _iter_events(target)
290
+ in_window = []
291
+ for event in events:
292
+ ts = _parse_iso(event.get("timestamp") or event.get("ts") or "")
293
+ if started and ended and (ts == 0.0 or not (started <= ts <= ended)):
294
+ continue
295
+ in_window.append(event)
296
+
297
+ for event in in_window:
298
+ _add(_event_agent(event))
299
+
300
+ cost_usd = round(sum(_event_cost(e) for e in in_window), 4)
301
+ tasks_count = sum(
302
+ 1 for e in in_window
303
+ if str(e.get("event_type") or e.get("type") or "").startswith("task_")
304
+ or e.get("task_id")
305
+ )
306
+
307
+ results = [_event_result(e) for e in in_window if _event_result(e)]
308
+ failures = sum(1 for r in results if r in ("failure", "stuck", "timeout", "error"))
309
+ regressions = failures
310
+ if results and failures == 0:
311
+ status = "green"
312
+ elif results and failures > 0:
313
+ status = "red"
314
+ else:
315
+ status = "unknown"
316
+
317
+ base_ref = (payload.get("context") or {}).get("last_commit") or payload.get("base_commit")
318
+ lines_added, lines_removed = diff_reader(target, base_ref)
319
+
320
+ return {
321
+ "session_id": session_id,
322
+ "started": payload.get("started") or payload.get("started_at") or "",
323
+ "ended": payload.get("updated") or payload.get("saved_at") or "",
324
+ "elapsed_seconds": round(elapsed_s, 1),
325
+ "agents": agents or ["unknown"],
326
+ "cost_usd": cost_usd,
327
+ "lines_added": int(lines_added),
328
+ "lines_removed": int(lines_removed),
329
+ "lines_changed": int(lines_added + lines_removed),
330
+ "tasks_count": tasks_count,
331
+ "regressions": regressions,
332
+ "status": status,
333
+ }
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Formatting — chips
338
+ # ---------------------------------------------------------------------------
339
+
340
+
341
+ def _format_elapsed(seconds: float) -> str:
342
+ seconds = max(0, int(seconds))
343
+ if seconds < 60:
344
+ return f"{seconds}s"
345
+ if seconds < 3600:
346
+ return f"{seconds // 60}m"
347
+ hours, rem = divmod(seconds, 3600)
348
+ minutes = rem // 60
349
+ return f"{hours}h{minutes:02d}m" if minutes else f"{hours}h"
350
+
351
+
352
+ def _format_agents(agents: list[str]) -> str:
353
+ if not agents:
354
+ return "0"
355
+ if len(agents) == 1:
356
+ return agents[0]
357
+ return f"{len(agents)} agents"
358
+
359
+
360
+ def format_chips(stats: dict) -> list[tuple[str, str]]:
361
+ """Return the 4 display chips rendered under the headline cost.
362
+
363
+ Order matches the pitch: lines · agents · time · regressions.
364
+ """
365
+ lines = stats.get("lines_changed") or 0
366
+ agents = stats.get("agents") or []
367
+ elapsed = stats.get("elapsed_seconds") or 0
368
+ regressions = stats.get("regressions") or 0
369
+ return [
370
+ ("LINES", f"{int(lines):,}"),
371
+ ("AGENTS", _format_agents(agents)),
372
+ ("TIME", _format_elapsed(elapsed)),
373
+ ("REGRESSIONS", "zero" if regressions == 0 else str(int(regressions))),
374
+ ]
375
+
376
+
377
+ def format_cost(cost_usd: float) -> str:
378
+ try:
379
+ cost = float(cost_usd)
380
+ except (TypeError, ValueError):
381
+ cost = 0.0
382
+ if cost >= 100:
383
+ return f"${cost:,.0f}"
384
+ if cost >= 10:
385
+ return f"${cost:,.2f}"
386
+ return f"${cost:.2f}"
387
+
388
+
389
+ # ---------------------------------------------------------------------------
390
+ # Rendering
391
+ # ---------------------------------------------------------------------------
392
+
393
+
394
+ def _load_font(size: int, *, bold: bool = False):
395
+ from PIL import ImageFont # type: ignore
396
+
397
+ candidates = []
398
+ if bold:
399
+ candidates += [
400
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
401
+ "/System/Library/Fonts/Menlo.ttc",
402
+ "/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf",
403
+ ]
404
+ candidates += [
405
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
406
+ "/System/Library/Fonts/Menlo.ttc",
407
+ "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
408
+ "/Library/Fonts/Andale Mono.ttf",
409
+ ]
410
+ for path in candidates:
411
+ if pathlib.Path(path).is_file():
412
+ try:
413
+ return ImageFont.truetype(path, size)
414
+ except OSError:
415
+ continue
416
+ return ImageFont.load_default()
417
+
418
+
419
+ def render_receipt(
420
+ stats: dict,
421
+ out_path: pathlib.Path | str,
422
+ ) -> pathlib.Path:
423
+ """Render a 1200×630 PNG receipt for `stats` to `out_path`. Returns path."""
424
+ from PIL import Image, ImageDraw # type: ignore
425
+
426
+ out = pathlib.Path(out_path)
427
+ out.parent.mkdir(parents=True, exist_ok=True)
428
+
429
+ img = Image.new("RGB", CANVAS, PALETTE["bg"])
430
+ draw = ImageDraw.Draw(img)
431
+
432
+ # Thin accent bar on the left edge — signature 0dai teal
433
+ draw.rectangle([(0, 0), (8, CANVAS[1])], fill=PALETTE["accent"])
434
+
435
+ # Header
436
+ header_font = _load_font(28, bold=True)
437
+ draw.text(
438
+ (56, 48),
439
+ "0dai · session receipt",
440
+ font=header_font,
441
+ fill=PALETTE["accent"],
442
+ )
443
+
444
+ session_id = str(stats.get("session_id") or "session")[:28]
445
+ sid_font = _load_font(20)
446
+ draw.text((56, 90), session_id, font=sid_font, fill=PALETTE["dim"])
447
+
448
+ # Big headline: cost
449
+ cost_font = _load_font(168, bold=True)
450
+ cost_text = format_cost(stats.get("cost_usd") or 0)
451
+ bbox = draw.textbbox((0, 0), cost_text, font=cost_font)
452
+ cost_w = bbox[2] - bbox[0]
453
+ cost_x = (CANVAS[0] - cost_w) // 2
454
+ draw.text((cost_x, 160), cost_text, font=cost_font, fill=PALETTE["text"])
455
+
456
+ # Sub-headline
457
+ sub_font = _load_font(22)
458
+ sub_text = "session total · receipts that ship themselves"
459
+ sbbox = draw.textbbox((0, 0), sub_text, font=sub_font)
460
+ sub_w = sbbox[2] - sbbox[0]
461
+ draw.text(
462
+ ((CANVAS[0] - sub_w) // 2, 350),
463
+ sub_text,
464
+ font=sub_font,
465
+ fill=PALETTE["dim"],
466
+ )
467
+
468
+ # 4 chips along the bottom — equal widths
469
+ chips = format_chips(stats)
470
+ chip_label_font = _load_font(18)
471
+ chip_value_font = _load_font(30, bold=True)
472
+
473
+ chip_area_top = 420
474
+ chip_area_bottom = 560
475
+ chip_gap = 24
476
+ margin = 56
477
+ chip_w = (CANVAS[0] - margin * 2 - chip_gap * (len(chips) - 1)) // len(chips)
478
+
479
+ for idx, (label, value) in enumerate(chips):
480
+ x0 = margin + idx * (chip_w + chip_gap)
481
+ x1 = x0 + chip_w
482
+ draw.rectangle([(x0, chip_area_top), (x1, chip_area_bottom)], fill=PALETTE["panel"])
483
+ # Teal accent rail on top of each chip
484
+ draw.rectangle([(x0, chip_area_top), (x1, chip_area_top + 4)], fill=PALETTE["accent"])
485
+
486
+ draw.text(
487
+ (x0 + 18, chip_area_top + 22),
488
+ label,
489
+ font=chip_label_font,
490
+ fill=PALETTE["dim"],
491
+ )
492
+ value_color = PALETTE["text"]
493
+ if label == "REGRESSIONS":
494
+ value_color = PALETTE["success"] if value == "zero" else PALETTE["error"]
495
+ draw.text(
496
+ (x0 + 18, chip_area_top + 58),
497
+ value,
498
+ font=chip_value_font,
499
+ fill=value_color,
500
+ )
501
+
502
+ # Footer — 0dai.dev · status dot
503
+ footer_font = _load_font(18)
504
+ status = str(stats.get("status") or "unknown").lower()
505
+ status_color = {
506
+ "green": PALETTE["success"],
507
+ "red": PALETTE["error"],
508
+ "unknown": PALETTE["warn"],
509
+ }.get(status, PALETTE["warn"])
510
+ draw.ellipse([(56, 588), (72, 604)], fill=status_color)
511
+ draw.text((84, 586), f"ci: {status}", font=footer_font, fill=PALETTE["dim"])
512
+ draw.text(
513
+ (CANVAS[0] - 140, 586),
514
+ "0dai.dev",
515
+ font=footer_font,
516
+ fill=PALETTE["dim"],
517
+ )
518
+
519
+ img.save(out, format="PNG")
520
+ return out
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # Clipboard
525
+ # ---------------------------------------------------------------------------
526
+
527
+
528
+ def _which(name: str) -> str | None:
529
+ return shutil.which(name)
530
+
531
+
532
+ def copy_to_clipboard(
533
+ image_path: pathlib.Path | str,
534
+ *,
535
+ platform: str | None = None,
536
+ ) -> dict:
537
+ """Copy the PNG at `image_path` to the system clipboard.
538
+
539
+ Returns {copied: bool, tool: str|None, error: str|None}. Silent on
540
+ missing clipboard tools — receipts are saved to disk regardless.
541
+ """
542
+ path = pathlib.Path(image_path)
543
+ if not path.is_file():
544
+ return {"copied": False, "tool": None, "error": f"not a file: {path}"}
545
+
546
+ plat = (platform or sys.platform or "").lower()
547
+
548
+ # macOS: pbcopy does not accept image/png — use osascript to set the
549
+ # clipboard to a PNG file URL.
550
+ if plat.startswith("darwin"):
551
+ osascript = _which("osascript")
552
+ if not osascript:
553
+ return {"copied": False, "tool": None, "error": "osascript not found"}
554
+ script = (
555
+ 'set the clipboard to (read (POSIX file "'
556
+ + str(path.resolve())
557
+ + '") as «class PNGf»)'
558
+ )
559
+ try:
560
+ subprocess.run(
561
+ [osascript, "-e", script],
562
+ check=True, capture_output=True, timeout=10,
563
+ )
564
+ return {"copied": True, "tool": "osascript", "error": None}
565
+ except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc:
566
+ return {"copied": False, "tool": "osascript", "error": str(exc)}
567
+
568
+ # Wayland first, fall back to X11
569
+ for tool, args in (
570
+ ("wl-copy", ["--type", "image/png"]),
571
+ ("xclip", ["-selection", "clipboard", "-t", "image/png", "-i", str(path)]),
572
+ ):
573
+ binary = _which(tool)
574
+ if not binary:
575
+ continue
576
+ try:
577
+ if tool == "wl-copy":
578
+ with path.open("rb") as fh:
579
+ subprocess.run(
580
+ [binary, *args],
581
+ check=True, stdin=fh, capture_output=True, timeout=10,
582
+ )
583
+ else:
584
+ subprocess.run(
585
+ [binary, *args],
586
+ check=True, capture_output=True, timeout=10,
587
+ )
588
+ return {"copied": True, "tool": tool, "error": None}
589
+ except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc:
590
+ return {"copied": False, "tool": tool, "error": str(exc)}
591
+
592
+ return {
593
+ "copied": False,
594
+ "tool": None,
595
+ "error": "no clipboard tool found (install xclip, wl-copy, or use macOS)",
596
+ }
597
+
598
+
599
+ # ---------------------------------------------------------------------------
600
+ # End-to-end
601
+ # ---------------------------------------------------------------------------
602
+
603
+
604
+ def generate_receipt(
605
+ target: pathlib.Path | str,
606
+ session: str = "last",
607
+ *,
608
+ output: pathlib.Path | str | None = None,
609
+ copy: bool = True,
610
+ now_epoch: float | None = None,
611
+ ) -> dict:
612
+ """Collect stats, render PNG, optionally copy to clipboard.
613
+
614
+ Returns a result dict with stats, path, and clipboard status.
615
+ """
616
+ target_p = pathlib.Path(target).resolve()
617
+ stats = collect_session_stats(target_p, session, now_epoch=now_epoch)
618
+
619
+ if output is None:
620
+ base = _receipts_dir()
621
+ base.mkdir(parents=True, exist_ok=True)
622
+ slug = "".join(c if c.isalnum() or c in "-_" else "-" for c in stats["session_id"])[:40]
623
+ stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
624
+ out_path = base / f"{stamp}-{slug or 'session'}.png"
625
+ else:
626
+ out_path = pathlib.Path(output)
627
+
628
+ rendered = render_receipt(stats, out_path)
629
+ clipboard = copy_to_clipboard(rendered) if copy else {
630
+ "copied": False, "tool": None, "error": "clipboard disabled"
631
+ }
632
+
633
+ return {
634
+ "stats": stats,
635
+ "path": str(rendered),
636
+ "clipboard": clipboard,
637
+ }
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # CLI
642
+ # ---------------------------------------------------------------------------
643
+
644
+
645
+ def _build_parser() -> argparse.ArgumentParser:
646
+ parser = argparse.ArgumentParser(
647
+ prog="receipt_png",
648
+ description="The Bill — shareable receipt PNG for a 0dai session.",
649
+ )
650
+ parser.add_argument("--target", default=".", help="Repo root (default: cwd)")
651
+ group = parser.add_mutually_exclusive_group()
652
+ group.add_argument(
653
+ "--last", action="store_const", dest="session", const="last",
654
+ help="Use the most recent saved session (default fallback to active).",
655
+ )
656
+ group.add_argument(
657
+ "--active", action="store_const", dest="session", const="active",
658
+ help="Use the current active session.",
659
+ )
660
+ group.add_argument(
661
+ "--session", dest="session",
662
+ help="Use a specific session id (matches saved/<id>.json or active.json id).",
663
+ )
664
+ parser.set_defaults(session="last")
665
+ parser.add_argument("--output", help="Output PNG path (default: ~/.0dai/receipts/)")
666
+ parser.add_argument(
667
+ "--no-clipboard", action="store_true",
668
+ help="Skip the clipboard copy step.",
669
+ )
670
+ parser.add_argument("--json", action="store_true", help="Emit JSON result.")
671
+ parser.add_argument(
672
+ "--stats-only", action="store_true",
673
+ help="Print the collected stats and exit (no PNG render).",
674
+ )
675
+ return parser
676
+
677
+
678
+ def main(argv: list[str] | None = None) -> int:
679
+ parser = _build_parser()
680
+ args = parser.parse_args(argv)
681
+
682
+ target = pathlib.Path(args.target).resolve()
683
+ session = args.session or "last"
684
+
685
+ if args.stats_only:
686
+ stats = collect_session_stats(target, session)
687
+ if args.json:
688
+ print(json.dumps(stats, indent=2, ensure_ascii=False))
689
+ else:
690
+ print(_render_stats_text(stats))
691
+ return 0
692
+
693
+ result = generate_receipt(
694
+ target, session, output=args.output, copy=not args.no_clipboard,
695
+ )
696
+ if args.json:
697
+ print(json.dumps(result, indent=2, ensure_ascii=False))
698
+ else:
699
+ stats = result["stats"]
700
+ print(f"\n \U0001f9fe Receipt saved: {result['path']}")
701
+ print(f" {format_cost(stats['cost_usd'])} · "
702
+ f"{stats['lines_changed']:,} lines · "
703
+ f"{_format_agents(stats['agents'])} · "
704
+ f"{_format_elapsed(stats['elapsed_seconds'])} · "
705
+ f"{'zero regressions' if stats['regressions'] == 0 else str(stats['regressions']) + ' regressions'}")
706
+ clip = result["clipboard"]
707
+ if clip.get("copied"):
708
+ print(f" copied to clipboard via {clip['tool']}")
709
+ elif clip.get("error"):
710
+ print(f" clipboard: {clip['error']}")
711
+ print()
712
+ return 0
713
+
714
+
715
+ def _render_stats_text(stats: dict) -> str:
716
+ chips = format_chips(stats)
717
+ chip_line = " · ".join(f"{label.lower()}: {value}" for label, value in chips)
718
+ return (
719
+ f"\n session: {stats['session_id']}\n"
720
+ f" cost: {format_cost(stats['cost_usd'])}\n"
721
+ f" status: {stats['status']}\n"
722
+ f" {chip_line}\n"
723
+ )
724
+
725
+
726
+ if __name__ == "__main__":
727
+ sys.exit(main())