mirrorneuron-cli 1.0.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.
@@ -0,0 +1,598 @@
1
+ import os
2
+ import json
3
+ import shutil
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Optional
9
+
10
+ import typer
11
+ from rich.table import Table
12
+ from mn_cli.shared import console, logger
13
+ from mn_cli.libs.run_cmds import run_bundle as _run_bundle
14
+
15
+ blueprint_app = typer.Typer(help="Manage and run MirrorNeuron blueprints")
16
+ DEFAULT_BLUEPRINT_REPO = "https://github.com/MirrorNeuronLab/mn-blueprints"
17
+
18
+
19
+ def _load_observability_api() -> tuple[Callable[..., list[dict[str, Any]]], Callable[..., dict[str, Any]], Callable[..., list[dict[str, Any]]]]:
20
+ try:
21
+ from mn_blueprint_support.observability import list_runs, load_run, read_run_events
22
+ except ModuleNotFoundError:
23
+ repo_root = Path(__file__).resolve().parents[3]
24
+ support_src = repo_root / "mn-skills" / "blueprint_support_skill" / "src"
25
+ if support_src.exists() and str(support_src) not in sys.path:
26
+ sys.path.insert(0, str(support_src))
27
+ try:
28
+ from mn_blueprint_support.observability import list_runs, load_run, read_run_events
29
+ except ModuleNotFoundError:
30
+ console.print(
31
+ "[red]Blueprint observability support is unavailable. "
32
+ "Install the blueprint support package or run from the monorepo checkout.[/red]"
33
+ )
34
+ raise typer.Exit(1)
35
+ return list_runs, load_run, read_run_events
36
+
37
+
38
+ def _load_web_ui_api() -> Callable[..., Any]:
39
+ _load_observability_api()
40
+ try:
41
+ from mn_blueprint_support.web_ui import write_static_run_report
42
+ except ModuleNotFoundError:
43
+ console.print("[red]Blueprint web UI support is unavailable.[/red]")
44
+ raise typer.Exit(1)
45
+ return write_static_run_report
46
+
47
+
48
+ def _make_blueprint_run_id(blueprint_id: str) -> str:
49
+ try:
50
+ _load_observability_api()
51
+ from mn_blueprint_support import make_run_id
52
+
53
+ return make_run_id(blueprint_id)
54
+ except Exception:
55
+ import uuid
56
+
57
+ return f"{blueprint_id}-{time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())}-{uuid.uuid4().hex[:10]}"
58
+
59
+
60
+ def _ensure_blueprint_source(
61
+ *,
62
+ source: Optional[str],
63
+ update: bool,
64
+ offline: bool,
65
+ revision: Optional[str],
66
+ ) -> str:
67
+ if source:
68
+ source_path = Path(source).expanduser()
69
+ if source_path.exists():
70
+ storage_dir = source_path
71
+ else:
72
+ storage_dir = Path(os.path.expanduser("~/.mn/blueprints"))
73
+ if offline:
74
+ console.print(f"[red]Offline mode cannot clone missing source {source!r}.[/red]")
75
+ raise typer.Exit(1)
76
+ if not storage_dir.exists():
77
+ _clone_blueprint_repo(source, storage_dir)
78
+ elif update:
79
+ _git_pull(storage_dir)
80
+ else:
81
+ storage_dir = Path(os.path.expanduser("~/.mn/blueprints"))
82
+ if not storage_dir.exists():
83
+ if offline:
84
+ console.print(f"[red]Blueprint storage not found at {storage_dir}; offline mode cannot clone it.[/red]")
85
+ raise typer.Exit(1)
86
+ console.print(f"Initializing blueprint storage at {storage_dir}...")
87
+ _clone_blueprint_repo(DEFAULT_BLUEPRINT_REPO, storage_dir)
88
+ elif update:
89
+ _git_pull(storage_dir)
90
+ else:
91
+ console.print(f"Using cached blueprint storage at {storage_dir}. Run 'mn blueprint update' or pass --update to refresh.")
92
+
93
+ if revision:
94
+ if offline:
95
+ _git_checkout(storage_dir, revision)
96
+ else:
97
+ _git_fetch(storage_dir)
98
+ _git_checkout(storage_dir, revision)
99
+ return str(storage_dir)
100
+
101
+
102
+ def _clone_blueprint_repo(source: str, storage_dir: Path) -> None:
103
+ storage_dir.parent.mkdir(parents=True, exist_ok=True)
104
+ res = subprocess.run(["git", "clone", source, str(storage_dir)], capture_output=True, text=True)
105
+ if res.returncode != 0:
106
+ logger.error("Failed to clone blueprint repository: %s", res.stderr)
107
+ console.print(f"[red]Failed to clone blueprint repository: {res.stderr}[/red]")
108
+ raise typer.Exit(1)
109
+
110
+
111
+ def _git_pull(storage_dir: Path) -> None:
112
+ console.print(f"Updating blueprint storage at {storage_dir}...")
113
+ res = subprocess.run(["git", "-C", str(storage_dir), "pull", "--ff-only"], capture_output=True, text=True)
114
+ if res.returncode != 0:
115
+ logger.warning("Failed to update blueprint repository: %s", res.stderr)
116
+ console.print(f"[yellow]Warning: Failed to update blueprint repository: {res.stderr}[/yellow]")
117
+
118
+
119
+ def _git_fetch(storage_dir: Path) -> None:
120
+ subprocess.run(["git", "-C", str(storage_dir), "fetch", "--all", "--tags"], capture_output=True, text=True)
121
+
122
+
123
+ def _git_checkout(storage_dir: Path, revision: str) -> None:
124
+ res = subprocess.run(["git", "-C", str(storage_dir), "checkout", revision], capture_output=True, text=True)
125
+ if res.returncode != 0:
126
+ console.print(f"[red]Failed to checkout blueprint revision {revision}: {res.stderr}[/red]")
127
+ raise typer.Exit(1)
128
+
129
+
130
+ def _git_revision(storage_dir: Path) -> Optional[str]:
131
+ res = subprocess.run(["git", "-C", str(storage_dir), "rev-parse", "HEAD"], capture_output=True, text=True)
132
+ if res.returncode != 0:
133
+ return None
134
+ stdout = getattr(res, "stdout", "") or ""
135
+ return str(stdout).strip() or None
136
+
137
+
138
+ def _is_python_source_blueprint(manifest: dict[str, Any]) -> bool:
139
+ metadata = manifest.get("metadata") or {}
140
+ return metadata.get("python_source_mode") is True or bool(metadata.get("python_workflow"))
141
+
142
+
143
+ def _load_blueprint_manifest(blueprint_dir: Path, target_name: str) -> dict[str, Any]:
144
+ manifest_path = blueprint_dir / "manifest.json"
145
+ if not manifest_path.exists():
146
+ console.print(f"[red]Error: Blueprint '{target_name}' is missing manifest.json. Validation failed.[/red]")
147
+ raise typer.Exit(1)
148
+ try:
149
+ return json.loads(manifest_path.read_text(encoding="utf-8"))
150
+ except Exception as exc:
151
+ logger.exception("Error parsing blueprint manifest")
152
+ console.print(f"[red]Error parsing manifest.json for blueprint '{target_name}': {exc}[/red]")
153
+ raise typer.Exit(1)
154
+
155
+
156
+ def _prepare_blueprint_bundle_for_run(
157
+ blueprint_dir: Path,
158
+ manifest: dict[str, Any],
159
+ run_id: str,
160
+ ) -> Path:
161
+ if not _is_python_source_blueprint(manifest):
162
+ return blueprint_dir
163
+
164
+ generated_root = Path(os.path.expanduser("~/.mn/generated_blueprint_bundles"))
165
+ output_dir = generated_root / run_id
166
+ if output_dir.exists():
167
+ shutil.rmtree(output_dir)
168
+ output_dir.parent.mkdir(parents=True, exist_ok=True)
169
+
170
+ console.print(f"Generating Python workflow bundle at {output_dir}...")
171
+ try:
172
+ output_dir = _generate_python_source_bundle(blueprint_dir, output_dir)
173
+ except Exception as exc:
174
+ logger.exception("Failed to generate Python workflow bundle")
175
+ console.print(f"[red]Failed to generate Python workflow bundle: {exc}[/red]")
176
+ raise typer.Exit(1)
177
+ return output_dir
178
+
179
+
180
+ def _generate_python_source_bundle(blueprint_dir: Path, output_dir: Path) -> Path:
181
+ _load_observability_api()
182
+ from mn_blueprint_support.python_workflow_bundle import (
183
+ generate_python_workflow_bundle_from_blueprint_dir,
184
+ )
185
+
186
+ return generate_python_workflow_bundle_from_blueprint_dir(
187
+ blueprint_dir,
188
+ output_dir,
189
+ )
190
+
191
+
192
+ def _run_resolved_blueprint(
193
+ *,
194
+ blueprint_dir: Path,
195
+ manifest: dict[str, Any],
196
+ display_name: str,
197
+ blueprint_id: str,
198
+ run_id: Optional[str],
199
+ revision: Optional[str],
200
+ source_label: str,
201
+ follow_seconds: Optional[float],
202
+ ) -> None:
203
+ shared_run_id = run_id or _make_blueprint_run_id(blueprint_id)
204
+ console.print(f"[green]Blueprint '{display_name}' validated. Running...[/green]")
205
+ console.print(f"Blueprint run_id: [bold green]{shared_run_id}[/bold green]")
206
+ if revision:
207
+ console.print(f"Blueprint revision: {revision}")
208
+ bundle_path = _prepare_blueprint_bundle_for_run(blueprint_dir, manifest, shared_run_id)
209
+ _run_bundle(
210
+ str(bundle_path),
211
+ follow_seconds=follow_seconds,
212
+ env_overrides={
213
+ "MN_RUN_ID": shared_run_id,
214
+ "MN_BLUEPRINT_REVISION": revision or "",
215
+ },
216
+ submission_metadata={
217
+ "blueprint_id": blueprint_id,
218
+ "blueprint_run_id": shared_run_id,
219
+ "blueprint_revision": revision,
220
+ "blueprint_source": source_label,
221
+ },
222
+ )
223
+
224
+
225
+ def _run_local_blueprint_target(
226
+ target: str,
227
+ *,
228
+ run_id: Optional[str],
229
+ follow_seconds: Optional[float],
230
+ ) -> bool:
231
+ blueprint_dir = Path(target).expanduser()
232
+ if not blueprint_dir.is_dir():
233
+ return False
234
+
235
+ manifest = _load_blueprint_manifest(blueprint_dir, target)
236
+ metadata = manifest.get("metadata") or {}
237
+ blueprint_id = str(metadata.get("blueprint_id") or manifest.get("graph_id") or blueprint_dir.name)
238
+ resolved_revision = _git_revision(blueprint_dir)
239
+ _run_resolved_blueprint(
240
+ blueprint_dir=blueprint_dir,
241
+ manifest=manifest,
242
+ display_name=target,
243
+ blueprint_id=blueprint_id,
244
+ run_id=run_id,
245
+ revision=resolved_revision,
246
+ source_label=str(blueprint_dir),
247
+ follow_seconds=follow_seconds,
248
+ )
249
+ return True
250
+
251
+
252
+ def _display(value: Any, *, max_length: int = 140) -> str:
253
+ if value is None:
254
+ return ""
255
+ if isinstance(value, (dict, list)):
256
+ text = json.dumps(value, sort_keys=True)
257
+ else:
258
+ text = str(value)
259
+ return text if len(text) <= max_length else text[: max_length - 1] + "…"
260
+
261
+
262
+ def _run_summary(run: dict[str, Any]) -> dict[str, Any]:
263
+ return {
264
+ "Run ID": run.get("run_id"),
265
+ "Blueprint": run.get("blueprint_id"),
266
+ "Status": run.get("status"),
267
+ "Started": run.get("started_at"),
268
+ "Ended": run.get("ended_at"),
269
+ "Run Directory": run.get("run_dir"),
270
+ }
271
+
272
+
273
+ def _run_summary_with_job(record: dict[str, Any]) -> dict[str, Any]:
274
+ summary = _run_summary(record.get("run") or record)
275
+ job_id = _job_id(record)
276
+ if job_id:
277
+ summary["Job ID"] = job_id
278
+ return summary
279
+
280
+
281
+ def _final_artifact(record: dict[str, Any]) -> dict[str, Any]:
282
+ final_artifact = record.get("final_artifact") or {}
283
+ if final_artifact:
284
+ return final_artifact
285
+ result = record.get("result") or {}
286
+ nested = result.get("final_artifact") if isinstance(result, dict) else None
287
+ return nested if isinstance(nested, dict) else {}
288
+
289
+
290
+ def _artifact_headline(artifact: dict[str, Any]) -> str:
291
+ for key in ("recommended_action", "recommendation", "decision", "risk_level", "priority", "summary"):
292
+ if key in artifact:
293
+ return _display(artifact[key])
294
+ return _display(artifact)
295
+
296
+
297
+ def _web_ui_url(record: dict[str, Any]) -> str:
298
+ web_ui = record.get("web_ui") or {}
299
+ return str(web_ui.get("url") or "")
300
+
301
+
302
+ def _job_id(record: dict[str, Any]) -> str:
303
+ job = record.get("job") or {}
304
+ return str(job.get("job_id") or "")
305
+
306
+
307
+ def _print_run_table(runs: list[dict[str, Any]]) -> None:
308
+ if not runs:
309
+ console.print("[yellow]No blueprint runs found.[/yellow]")
310
+ return
311
+ console.print(f"{'Run ID':<28} {'Job ID':<18} {'Status':<12} {'Ended':<25} {'Blueprint':<42} Web UI", markup=False)
312
+ console.print(f"{'-' * 28} {'-' * 18} {'-' * 12} {'-' * 25} {'-' * 42} {'-' * 6}", markup=False)
313
+ for run in runs:
314
+ console.print(
315
+ f"{_display(run.get('run_id')):<28} "
316
+ f"{_display(_job_id(run), max_length=17):<18} "
317
+ f"{_display(run.get('status')):<12} "
318
+ f"{_display(run.get('ended_at'), max_length=24):<25} "
319
+ f"{_display(run.get('blueprint_id'), max_length=42):<42} "
320
+ f"{_display(_web_ui_url(run), max_length=70)}",
321
+ markup=False,
322
+ )
323
+
324
+
325
+ def _load_run_or_exit(run_id: str, runs_root: Optional[str]) -> dict[str, Any]:
326
+ _, load_run, _ = _load_observability_api()
327
+ try:
328
+ return load_run(run_id, runs_root=runs_root)
329
+ except FileNotFoundError as exc:
330
+ console.print(f"[red]{exc}[/red]")
331
+ raise typer.Exit(1)
332
+
333
+
334
+ def _print_events(events: list[dict[str, Any]]) -> None:
335
+ for event in events:
336
+ timestamp = event.get("timestamp") or event.get("time") or event.get("ts") or ""
337
+ event_type = event.get("type") or event.get("event") or event.get("name") or "event"
338
+ details = {
339
+ key: value
340
+ for key, value in event.items()
341
+ if key not in {"timestamp", "time", "ts", "type", "event", "name"}
342
+ }
343
+ detail_text = json.dumps(details, sort_keys=True) if details else ""
344
+ console.print(f"{_display(timestamp, max_length=36)} {_display(event_type, max_length=48)} {detail_text}", markup=False)
345
+
346
+
347
+ def _markdown_table(rows: list[tuple[str, Any]]) -> list[str]:
348
+ output = ["| Field | Value |", "|---|---|"]
349
+ for key, value in rows:
350
+ escaped_value = _display(value).replace("|", "\\|")
351
+ output.append(f"| {key} | {escaped_value} |")
352
+ return output
353
+
354
+
355
+ def _render_markdown_export(record: dict[str, Any]) -> str:
356
+ run = record.get("run") or {}
357
+ artifact = _final_artifact(record)
358
+ lines = [f"# Blueprint Run {run.get('run_id', 'unknown')}", ""]
359
+ lines.extend(["## Summary", ""])
360
+ lines.extend(_markdown_table(list(_run_summary_with_job(record).items())))
361
+ lines.extend(["", "## Final Artifact", "", "```json", json.dumps(artifact, indent=2, sort_keys=True), "```"])
362
+ web_ui = record.get("web_ui") or {}
363
+ if web_ui:
364
+ lines.extend(["", "## Web UI", ""])
365
+ lines.extend(_markdown_table([("URL", web_ui.get("url")), ("Adapter", web_ui.get("adapter")), ("Status", web_ui.get("status"))]))
366
+ lines.extend(["", "## Result", "", "```json", json.dumps(record.get("result") or {}, indent=2, sort_keys=True), "```"])
367
+ lines.extend(["", "## Inputs", "", "```json", json.dumps(record.get("inputs") or {}, indent=2, sort_keys=True), "```"])
368
+ lines.extend(["", "## Config", "", "```json", json.dumps(record.get("config") or {}, indent=2, sort_keys=True), "```"])
369
+ lines.extend(["", "## Event Tail", "", "```json"])
370
+ for event in (record.get("events") or [])[-20:]:
371
+ lines.append(json.dumps(event, sort_keys=True))
372
+ lines.extend(["```", ""])
373
+ return "\n".join(lines)
374
+
375
+ @blueprint_app.command("list")
376
+ def blueprint_list():
377
+ """List all available blueprints from the local storage shared with mn staff"""
378
+ index_path = os.path.expanduser("~/.mn/blueprints/index.json")
379
+ if not os.path.exists(index_path):
380
+ console.print("[yellow]Blueprint storage not initialized. Run 'mn blueprint run <name>' to initialize.[/yellow]")
381
+ return
382
+ try:
383
+ with open(index_path, "r") as f:
384
+ blueprints = json.load(f)
385
+ table = Table("ID", "Name", "Job Name", "Description")
386
+ for bp in blueprints:
387
+ table.add_row(
388
+ bp.get("id", "N/A"),
389
+ bp.get("name", "N/A"),
390
+ bp.get("job_name", "N/A"),
391
+ bp.get("description", "")
392
+ )
393
+ console.print(table)
394
+ except Exception as e:
395
+ logger.exception("Error reading blueprint index")
396
+ console.print(f"[red]Error reading blueprints index: {e}[/red]")
397
+
398
+ @blueprint_app.command("run")
399
+ def blueprint_run(
400
+ blueprint_path_name: str,
401
+ run_id: Optional[str] = typer.Option(None, "--run-id", help="Use a specific shared blueprint run ID."),
402
+ source: Optional[str] = typer.Option(None, "--source", help="Use a local blueprint repo/path or clone URL instead of ~/.mn/blueprints."),
403
+ update: bool = typer.Option(False, "--update", help="Update the cached blueprint repository before running."),
404
+ offline: bool = typer.Option(False, "--offline", help="Use only local blueprint files; never clone, fetch, or pull."),
405
+ revision: Optional[str] = typer.Option(None, "--revision", help="Checkout a specific git revision before running."),
406
+ follow_seconds: Optional[float] = typer.Option(None, "--follow-seconds", help="Seconds to follow runtime events before detaching."),
407
+ ):
408
+ """Run a blueprint by name or local folder."""
409
+ if _run_local_blueprint_target(
410
+ blueprint_path_name,
411
+ run_id=run_id,
412
+ follow_seconds=follow_seconds,
413
+ ):
414
+ return
415
+
416
+ storage_dir = _ensure_blueprint_source(source=source, update=update, offline=offline, revision=revision)
417
+
418
+ index_path = os.path.join(storage_dir, "index.json")
419
+ if not os.path.exists(index_path):
420
+ console.print("[red]Error: index.json not found in blueprint storage.[/red]")
421
+ raise typer.Exit(1)
422
+
423
+ try:
424
+ with open(index_path, "r") as f:
425
+ blueprints = json.load(f)
426
+ except Exception as e:
427
+ logger.exception("Error parsing blueprint index")
428
+ console.print(f"[red]Error parsing index.json: {e}[/red]")
429
+ raise typer.Exit(1)
430
+
431
+ target_bp = None
432
+ for bp in blueprints:
433
+ if bp.get("id") == blueprint_path_name or bp.get("path") == blueprint_path_name:
434
+ target_bp = bp
435
+ break
436
+
437
+ if not target_bp:
438
+ console.print(f"[red]Error: Blueprint '{blueprint_path_name}' not found in index.[/red]")
439
+ raise typer.Exit(1)
440
+
441
+ bp_path = os.path.join(storage_dir, target_bp.get("path"))
442
+
443
+ manifest = _load_blueprint_manifest(Path(bp_path), blueprint_path_name)
444
+ blueprint_id = str((manifest.get("metadata") or {}).get("blueprint_id") or target_bp.get("id") or blueprint_path_name)
445
+ resolved_revision = _git_revision(Path(storage_dir)) or revision
446
+ _run_resolved_blueprint(
447
+ blueprint_dir=Path(bp_path),
448
+ manifest=manifest,
449
+ display_name=blueprint_path_name,
450
+ blueprint_id=blueprint_id,
451
+ run_id=run_id,
452
+ revision=resolved_revision,
453
+ source_label=str(storage_dir),
454
+ follow_seconds=follow_seconds,
455
+ )
456
+
457
+
458
+ @blueprint_app.command("install")
459
+ def blueprint_install(
460
+ source: str = typer.Option(DEFAULT_BLUEPRINT_REPO, "--source", help="Blueprint repository URL or local path."),
461
+ force: bool = typer.Option(False, "--force", help="Replace the existing cached repository."),
462
+ ):
463
+ """Install the blueprint library into ~/.mn/blueprints."""
464
+ storage_dir = Path(os.path.expanduser("~/.mn/blueprints"))
465
+ if storage_dir.exists() and not force:
466
+ console.print(f"[yellow]Blueprint storage already exists at {storage_dir}. Use --force to replace it.[/yellow]")
467
+ return
468
+ if storage_dir.exists() and force:
469
+ import shutil
470
+
471
+ shutil.rmtree(storage_dir)
472
+ _clone_blueprint_repo(source, storage_dir)
473
+ console.print(f"[green]Installed blueprints at {storage_dir}.[/green]")
474
+
475
+
476
+ @blueprint_app.command("update")
477
+ def blueprint_update(
478
+ source: Optional[str] = typer.Option(None, "--source", help="Cached blueprint repo/path to update."),
479
+ ):
480
+ """Update the cached blueprint library explicitly."""
481
+ storage_dir = Path(source).expanduser() if source else Path(os.path.expanduser("~/.mn/blueprints"))
482
+ if not storage_dir.exists():
483
+ console.print(f"[red]Blueprint storage not found at {storage_dir}. Run 'mn blueprint install' first.[/red]")
484
+ raise typer.Exit(1)
485
+ _git_pull(storage_dir)
486
+
487
+
488
+ @blueprint_app.command("monitor")
489
+ def blueprint_monitor(
490
+ follow: bool = typer.Option(False, "--follow", "-f", help="Refresh the run table until interrupted."),
491
+ blueprint_id: Optional[str] = typer.Option(None, "--blueprint-id", help="Only show runs for one blueprint ID."),
492
+ max_runs: int = typer.Option(20, "--max-runs", help="Maximum number of runs to display."),
493
+ runs_root: Optional[str] = typer.Option(None, "--runs-root", help="Override the default ~/.mn/runs directory."),
494
+ interval: float = typer.Option(2.0, "--interval", help="Refresh interval in seconds when --follow is enabled."),
495
+ ):
496
+ """Show recent blueprint runs from the shared run store."""
497
+ list_runs, _, _ = _load_observability_api()
498
+ try:
499
+ while True:
500
+ runs = list_runs(runs_root=runs_root, blueprint_id=blueprint_id, limit=max_runs)
501
+ console.print(f"[bold]Blueprint runs[/bold] {time.strftime('%Y-%m-%d %H:%M:%S')}")
502
+ _print_run_table(runs)
503
+ if not follow:
504
+ return
505
+ time.sleep(interval)
506
+ except KeyboardInterrupt:
507
+ console.print("\n[yellow]Stopped blueprint monitor.[/yellow]")
508
+
509
+
510
+ @blueprint_app.command("tail")
511
+ def blueprint_tail(
512
+ run_id: str,
513
+ lines: int = typer.Option(20, "--lines", "-n", help="Number of events to show."),
514
+ follow: bool = typer.Option(False, "--follow", "-f", help="Continue printing new events until interrupted."),
515
+ runs_root: Optional[str] = typer.Option(None, "--runs-root", help="Override the default ~/.mn/runs directory."),
516
+ interval: float = typer.Option(1.0, "--interval", help="Polling interval in seconds when --follow is enabled."),
517
+ ):
518
+ """Print the event stream for one blueprint run."""
519
+ _load_run_or_exit(run_id, runs_root)
520
+ _, _, read_run_events = _load_observability_api()
521
+ seen = 0
522
+ try:
523
+ while True:
524
+ events = read_run_events(run_id, runs_root=runs_root)
525
+ if not events:
526
+ console.print(f"[yellow]No events found for run {run_id}.[/yellow]")
527
+ elif seen == 0:
528
+ selected = events[-lines:]
529
+ _print_events(selected)
530
+ seen = len(events)
531
+ else:
532
+ new_events = events[seen:]
533
+ _print_events(new_events)
534
+ seen = len(events)
535
+ if not follow:
536
+ return
537
+ time.sleep(interval)
538
+ except FileNotFoundError as exc:
539
+ console.print(f"[red]{exc}[/red]")
540
+ raise typer.Exit(1)
541
+ except KeyboardInterrupt:
542
+ console.print(f"\n[yellow]Stopped tailing {run_id}.[/yellow]")
543
+
544
+
545
+ @blueprint_app.command("compare")
546
+ def blueprint_compare(
547
+ run_a: str,
548
+ run_b: str,
549
+ runs_root: Optional[str] = typer.Option(None, "--runs-root", help="Override the default ~/.mn/runs directory."),
550
+ ):
551
+ """Compare two blueprint runs from the shared run store."""
552
+ record_a = _load_run_or_exit(run_a, runs_root)
553
+ record_b = _load_run_or_exit(run_b, runs_root)
554
+ summary_a = _run_summary(record_a.get("run") or {})
555
+ summary_b = _run_summary(record_b.get("run") or {})
556
+ artifact_a = _final_artifact(record_a)
557
+ artifact_b = _final_artifact(record_b)
558
+
559
+ table = Table("Field", run_a, run_b)
560
+ for field in ("Blueprint", "Status", "Started", "Ended"):
561
+ table.add_row(field, _display(summary_a.get(field)), _display(summary_b.get(field)))
562
+ table.add_row("Event count", str(len(record_a.get("events") or [])), str(len(record_b.get("events") or [])))
563
+ table.add_row("Final artifact", _artifact_headline(artifact_a), _artifact_headline(artifact_b))
564
+
565
+ scalar_keys = sorted(set(artifact_a.keys()) | set(artifact_b.keys()))
566
+ for key in scalar_keys:
567
+ value_a = artifact_a.get(key)
568
+ value_b = artifact_b.get(key)
569
+ if isinstance(value_a, (dict, list)) or isinstance(value_b, (dict, list)):
570
+ continue
571
+ table.add_row(f"artifact.{key}", _display(value_a), _display(value_b))
572
+ console.print(table)
573
+
574
+
575
+ @blueprint_app.command("export")
576
+ def blueprint_export(
577
+ run_id: str,
578
+ output_format: str = typer.Option("json", "--format", "-f", help="Export format: json, markdown, or html."),
579
+ runs_root: Optional[str] = typer.Option(None, "--runs-root", help="Override the default ~/.mn/runs directory."),
580
+ ):
581
+ """Export one blueprint run as JSON, Markdown, or static HTML."""
582
+ record = _load_run_or_exit(run_id, runs_root)
583
+ normalized_format = output_format.lower().strip()
584
+ if normalized_format == "json":
585
+ console.print(json.dumps(record, indent=2, sort_keys=True), markup=False)
586
+ elif normalized_format in {"markdown", "md"}:
587
+ console.print(_render_markdown_export(record), markup=False)
588
+ elif normalized_format in {"html", "static_html", "web"}:
589
+ run_dir = (record.get("run") or {}).get("run_dir")
590
+ if not run_dir:
591
+ console.print("[red]Cannot write HTML export because this run has no run_dir.[/red]")
592
+ raise typer.Exit(1)
593
+ write_static_run_report = _load_web_ui_api()
594
+ handle = write_static_run_report(record, run_dir)
595
+ console.print(handle.url, markup=False)
596
+ else:
597
+ console.print("[red]Unsupported export format. Use 'json', 'markdown', or 'html'.[/red]")
598
+ raise typer.Exit(1)