mirrorneuron-cli 1.1.2__tar.gz → 1.1.4__tar.gz

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 (45) hide show
  1. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/PKG-INFO +4 -1
  2. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/README.md +3 -0
  3. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/PKG-INFO +4 -1
  4. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/SOURCES.txt +6 -0
  5. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/libs/blueprint_cmds.py +212 -241
  6. mirrorneuron_cli-1.1.4/mn_cli/libs/blueprint_observability.py +158 -0
  7. mirrorneuron_cli-1.1.4/mn_cli/libs/blueprint_repository.py +159 -0
  8. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/libs/run_cmds.py +23 -259
  9. mirrorneuron_cli-1.1.4/mn_cli/libs/run_logs.py +202 -0
  10. mirrorneuron_cli-1.1.4/mn_cli/libs/run_manifest.py +253 -0
  11. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/server_cmds.py +13 -0
  12. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/update_cmds.py +17 -0
  13. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/tests/test_blueprint_cmds.py +132 -0
  14. mirrorneuron_cli-1.1.4/tests/test_blueprint_repository.py +46 -0
  15. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/tests/test_run_cmds.py +6 -2
  16. mirrorneuron_cli-1.1.4/tests/test_run_helpers.py +155 -0
  17. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/.github/workflows/ci.yml +0 -0
  18. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/.github/workflows/release.yml +0 -0
  19. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/.gitignore +0 -0
  20. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/LICENSE +0 -0
  21. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/RELEASE.md +0 -0
  22. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/dependency_links.txt +0 -0
  23. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/entry_points.txt +0 -0
  24. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/requires.txt +0 -0
  25. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mirrorneuron_cli.egg-info/top_level.txt +0 -0
  26. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/__init__.py +0 -0
  27. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/config.py +0 -0
  28. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/error_handler.py +0 -0
  29. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/libs/__init__.py +0 -0
  30. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/libs/job_cmds.py +0 -0
  31. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/libs/sys_cmds.py +0 -0
  32. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/libs/ui.py +0 -0
  33. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/logging_config.py +0 -0
  34. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/main.py +0 -0
  35. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/mn_cli/shared.py +0 -0
  36. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/pyproject.toml +0 -0
  37. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/scripts/check-release-artifacts.sh +0 -0
  38. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/scripts/make-release-zip.sh +0 -0
  39. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/scripts/validate-version-tag.sh +0 -0
  40. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/setup.cfg +0 -0
  41. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/tests/conftest.py +0 -0
  42. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/tests/test_job_cmds.py +0 -0
  43. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/tests/test_server_cmds.py +0 -0
  44. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/tests/test_sys_cmds.py +0 -0
  45. {mirrorneuron_cli-1.1.2 → mirrorneuron_cli-1.1.4}/tests/test_update_cmds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.1.2
3
+ Version: 1.1.4
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
@@ -122,6 +122,7 @@ mn blueprint list
122
122
  mn blueprint install
123
123
  mn blueprint update
124
124
  mn blueprint run <blueprint_id>
125
+ mn blueprint --blueprint-repo https://github.com/MirrorNeuronLab/customer-blueprints run <blueprint_id>
125
126
  mn blueprint run ./path/to/bundle_or_source_blueprint
126
127
  mn blueprint run <blueprint_id> --offline
127
128
  mn blueprint run <blueprint_id> --revision <git_sha_or_tag>
@@ -140,6 +141,8 @@ mn blueprint export <run_id> --format html
140
141
 
141
142
  Catalog runs use the cached blueprint library by default. Run `mn blueprint update` or pass `--update` when you want to refresh the local cache.
142
143
 
144
+ Use `mn blueprint --blueprint-repo <repo-url> ...` to read catalog commands from a different blueprint repository, including a private repository your Git credentials can access. Custom repositories are cached separately under `~/.mn/blueprint_repos/`, and the repository root must contain a valid `index.json` JSON list of blueprint entries.
145
+
143
146
  Blueprint run artifacts are stored under:
144
147
 
145
148
  ```text
@@ -108,6 +108,7 @@ mn blueprint list
108
108
  mn blueprint install
109
109
  mn blueprint update
110
110
  mn blueprint run <blueprint_id>
111
+ mn blueprint --blueprint-repo https://github.com/MirrorNeuronLab/customer-blueprints run <blueprint_id>
111
112
  mn blueprint run ./path/to/bundle_or_source_blueprint
112
113
  mn blueprint run <blueprint_id> --offline
113
114
  mn blueprint run <blueprint_id> --revision <git_sha_or_tag>
@@ -126,6 +127,8 @@ mn blueprint export <run_id> --format html
126
127
 
127
128
  Catalog runs use the cached blueprint library by default. Run `mn blueprint update` or pass `--update` when you want to refresh the local cache.
128
129
 
130
+ Use `mn blueprint --blueprint-repo <repo-url> ...` to read catalog commands from a different blueprint repository, including a private repository your Git credentials can access. Custom repositories are cached separately under `~/.mn/blueprint_repos/`, and the repository root must contain a valid `index.json` JSON list of blueprint entries.
131
+
129
132
  Blueprint run artifacts are stored under:
130
133
 
131
134
  ```text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirrorneuron-cli
3
- Version: 1.1.2
3
+ Version: 1.1.4
4
4
  Summary: MirrorNeuron CLI
5
5
  License-Expression: MIT
6
6
  Classifier: Programming Language :: Python :: 3
@@ -122,6 +122,7 @@ mn blueprint list
122
122
  mn blueprint install
123
123
  mn blueprint update
124
124
  mn blueprint run <blueprint_id>
125
+ mn blueprint --blueprint-repo https://github.com/MirrorNeuronLab/customer-blueprints run <blueprint_id>
125
126
  mn blueprint run ./path/to/bundle_or_source_blueprint
126
127
  mn blueprint run <blueprint_id> --offline
127
128
  mn blueprint run <blueprint_id> --revision <git_sha_or_tag>
@@ -140,6 +141,8 @@ mn blueprint export <run_id> --format html
140
141
 
141
142
  Catalog runs use the cached blueprint library by default. Run `mn blueprint update` or pass `--update` when you want to refresh the local cache.
142
143
 
144
+ Use `mn blueprint --blueprint-repo <repo-url> ...` to read catalog commands from a different blueprint repository, including a private repository your Git credentials can access. Custom repositories are cached separately under `~/.mn/blueprint_repos/`, and the repository root must contain a valid `index.json` JSON list of blueprint entries.
145
+
143
146
  Blueprint run artifacts are stored under:
144
147
 
145
148
  ```text
@@ -21,8 +21,12 @@ mn_cli/shared.py
21
21
  mn_cli/update_cmds.py
22
22
  mn_cli/libs/__init__.py
23
23
  mn_cli/libs/blueprint_cmds.py
24
+ mn_cli/libs/blueprint_observability.py
25
+ mn_cli/libs/blueprint_repository.py
24
26
  mn_cli/libs/job_cmds.py
25
27
  mn_cli/libs/run_cmds.py
28
+ mn_cli/libs/run_logs.py
29
+ mn_cli/libs/run_manifest.py
26
30
  mn_cli/libs/sys_cmds.py
27
31
  mn_cli/libs/ui.py
28
32
  scripts/check-release-artifacts.sh
@@ -30,8 +34,10 @@ scripts/make-release-zip.sh
30
34
  scripts/validate-version-tag.sh
31
35
  tests/conftest.py
32
36
  tests/test_blueprint_cmds.py
37
+ tests/test_blueprint_repository.py
33
38
  tests/test_job_cmds.py
34
39
  tests/test_run_cmds.py
40
+ tests/test_run_helpers.py
35
41
  tests/test_server_cmds.py
36
42
  tests/test_sys_cmds.py
37
43
  tests/test_update_cmds.py
@@ -5,135 +5,59 @@ import subprocess
5
5
  import sys
6
6
  import time
7
7
  from pathlib import Path
8
- from typing import Any, Callable, Optional
8
+ from typing import Any, Optional
9
9
 
10
10
  import typer
11
11
  from rich.table import Table
12
+ from mn_cli.libs.blueprint_observability import (
13
+ artifact_headline as _artifact_headline,
14
+ display as _display,
15
+ final_artifact as _final_artifact,
16
+ job_id_from_record as _job_id,
17
+ load_observability_api as _load_observability_api,
18
+ load_run_or_exit as _load_run_or_exit,
19
+ load_web_ui_api as _load_web_ui_api,
20
+ make_blueprint_run_id as _make_blueprint_run_id,
21
+ print_events as _print_events,
22
+ render_markdown_export as _render_markdown_export,
23
+ run_summary as _run_summary,
24
+ web_ui_url as _web_ui_url,
25
+ )
26
+ from mn_cli.libs.blueprint_repository import (
27
+ BLUEPRINT_REPO_CONTEXT_KEY,
28
+ DEFAULT_BLUEPRINT_REPO,
29
+ BlueprintIndexError,
30
+ blueprint_cache_dir_for_repo as _blueprint_cache_dir_for_repo,
31
+ blueprint_storage_dir_for_source as _blueprint_storage_dir_for_source,
32
+ clone_blueprint_repo as _clone_blueprint_repo,
33
+ context_blueprint_repo as _context_blueprint_repo,
34
+ default_blueprint_storage_dir as _default_blueprint_storage_dir,
35
+ ensure_blueprint_source as _ensure_blueprint_source,
36
+ git_checkout as _git_checkout,
37
+ git_fetch as _git_fetch,
38
+ git_pull as _git_pull,
39
+ git_revision as _git_revision,
40
+ load_blueprint_index as _load_blueprint_index,
41
+ )
12
42
  from mn_cli.shared import console, logger
13
43
  from mn_cli.libs.run_cmds import run_bundle as _run_bundle
44
+ from mn_cli.libs.run_manifest import load_blueprint_config as _load_blueprint_config
14
45
 
15
46
  blueprint_app = typer.Typer(help="Manage and run MirrorNeuron blueprints")
16
- DEFAULT_BLUEPRINT_REPO = "https://github.com/MirrorNeuronLab/mn-blueprints"
47
+ _PATCH_COMPAT = (subprocess, _git_checkout, _git_fetch)
17
48
 
18
49
 
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
-
50
+ @blueprint_app.callback()
51
+ def blueprint_callback(
52
+ ctx: typer.Context,
53
+ blueprint_repo: Optional[str] = typer.Option(
54
+ None,
55
+ "--blueprint-repo",
56
+ help="Use this blueprint repository URL/path instead of the default catalog.",
57
+ ),
58
+ ) -> None:
59
+ ctx.obj = dict(ctx.obj or {})
60
+ ctx.obj[BLUEPRINT_REPO_CONTEXT_KEY] = blueprint_repo
137
61
 
138
62
  def _is_python_source_blueprint(manifest: dict[str, Any]) -> bool:
139
63
  metadata = manifest.get("metadata") or {}
@@ -206,6 +130,7 @@ def _run_resolved_blueprint(
206
130
  if revision:
207
131
  console.print(f"Blueprint revision: {revision}")
208
132
  bundle_path = _prepare_blueprint_bundle_for_run(blueprint_dir, manifest, shared_run_id)
133
+ config_overrides = _collect_init_config_review_overrides(bundle_path, manifest)
209
134
  _run_bundle(
210
135
  str(bundle_path),
211
136
  follow_seconds=follow_seconds,
@@ -219,6 +144,7 @@ def _run_resolved_blueprint(
219
144
  "blueprint_revision": revision,
220
145
  "blueprint_source": source_label,
221
146
  },
147
+ config_overrides=config_overrides,
222
148
  )
223
149
 
224
150
 
@@ -249,59 +175,114 @@ def _run_local_blueprint_target(
249
175
  return True
250
176
 
251
177
 
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)
178
+ def _collect_init_config_review_overrides(
179
+ bundle_path: Path,
180
+ manifest: dict[str, Any],
181
+ ) -> dict[str, Any] | None:
182
+ review = _manifest_init_config_review(manifest)
183
+ if not isinstance(review, dict):
184
+ return None
185
+ fields = review.get("fields")
186
+ if not isinstance(fields, list) or not fields:
187
+ return None
188
+ if _env_flag("MN_BLUEPRINT_SKIP_INIT_CONFIG_REVIEW"):
189
+ return None
190
+ if not sys.stdin.isatty():
191
+ if review.get("required") is True:
192
+ console.print("[yellow]Blueprint config review requested; keeping current config in this non-interactive run.[/yellow]")
193
+ return None
295
194
 
195
+ config = _load_blueprint_config(bundle_path) or {}
196
+ overrides: dict[str, Any] = {}
197
+ console.print("[bold]Review blueprint config before launch[/bold]")
198
+ instruction = review.get("instruction")
199
+ if isinstance(instruction, str) and instruction.strip():
200
+ console.print(instruction.strip())
296
201
 
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 "")
202
+ for raw_field in fields:
203
+ if not isinstance(raw_field, dict):
204
+ continue
205
+ path = raw_field.get("path")
206
+ if not isinstance(path, str) or not path.strip():
207
+ continue
208
+ path = path.strip()
209
+ label = str(raw_field.get("label") or path)
210
+ description = raw_field.get("description")
211
+ current = _config_path_get(config, path)
212
+ fallback = raw_field.get("default")
213
+ default_value = current if current is not None else fallback
214
+ if isinstance(description, str) and description.strip():
215
+ console.print(f"{label}: {description.strip()}")
216
+ if default_value is None:
217
+ response = typer.prompt(label, default="", show_default=False)
218
+ if response == "":
219
+ continue
220
+ else:
221
+ response = typer.prompt(label, default=str(default_value), show_default=True)
222
+ parsed = _parse_review_value(response, default_value)
223
+ if parsed != current:
224
+ _config_path_set(overrides, path, parsed)
225
+
226
+ return overrides or None
227
+
228
+
229
+ def _manifest_init_config_review(manifest: dict[str, Any]) -> Any:
230
+ if "init_config_review" in manifest:
231
+ return manifest.get("init_config_review")
232
+ metadata = manifest.get("metadata")
233
+ if isinstance(metadata, dict):
234
+ return metadata.get("init_config_review")
235
+ return None
236
+
237
+
238
+ def _config_path_get(config: dict[str, Any], dotted_path: str) -> Any:
239
+ cursor: Any = config
240
+ for part in dotted_path.split("."):
241
+ if not isinstance(cursor, dict) or part not in cursor:
242
+ return None
243
+ cursor = cursor[part]
244
+ return cursor
245
+
246
+
247
+ def _config_path_set(config: dict[str, Any], dotted_path: str, value: Any) -> None:
248
+ cursor = config
249
+ parts = [part for part in dotted_path.split(".") if part]
250
+ for part in parts[:-1]:
251
+ next_value = cursor.get(part)
252
+ if not isinstance(next_value, dict):
253
+ next_value = {}
254
+ cursor[part] = next_value
255
+ cursor = next_value
256
+ if parts:
257
+ cursor[parts[-1]] = value
258
+
259
+
260
+ def _parse_review_value(value: str, default_value: Any) -> Any:
261
+ if isinstance(default_value, bool):
262
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
263
+ if isinstance(default_value, int) and not isinstance(default_value, bool):
264
+ try:
265
+ return int(value)
266
+ except ValueError:
267
+ return value
268
+ if isinstance(default_value, float):
269
+ try:
270
+ return float(value)
271
+ except ValueError:
272
+ return value
273
+ if isinstance(default_value, (dict, list)):
274
+ try:
275
+ return json.loads(value)
276
+ except json.JSONDecodeError:
277
+ return value
278
+ return value
300
279
 
301
280
 
302
- def _job_id(record: dict[str, Any]) -> str:
303
- job = record.get("job") or {}
304
- return str(job.get("job_id") or "")
281
+ def _env_flag(name: str) -> bool:
282
+ value = os.environ.get(name)
283
+ if value is None:
284
+ return False
285
+ return value.strip().lower() in {"1", "true", "yes", "on"}
305
286
 
306
287
 
307
288
  def _print_run_table(runs: list[dict[str, Any]]) -> None:
@@ -322,66 +303,28 @@ def _print_run_table(runs: list[dict[str, Any]]) -> None:
322
303
  )
323
304
 
324
305
 
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
306
  @blueprint_app.command("list")
376
- def blueprint_list():
307
+ def blueprint_list(ctx: typer.Context):
377
308
  """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
309
+ blueprint_repo = _context_blueprint_repo(ctx)
310
+ if blueprint_repo:
311
+ storage_dir = Path(
312
+ _ensure_blueprint_source(
313
+ source=None,
314
+ blueprint_repo=blueprint_repo,
315
+ update=False,
316
+ offline=False,
317
+ revision=None,
318
+ )
319
+ )
320
+ index_path = storage_dir / "index.json"
321
+ else:
322
+ index_path = Path(os.path.expanduser("~/.mn/blueprints/index.json"))
323
+ if not index_path.exists():
324
+ console.print("[yellow]Blueprint storage not initialized. Run 'mn blueprint run <name>' to initialize.[/yellow]")
325
+ return
382
326
  try:
383
- with open(index_path, "r") as f:
384
- blueprints = json.load(f)
327
+ blueprints = _load_blueprint_index(index_path)
385
328
  table = Table("ID", "Name", "Job Name", "Description")
386
329
  for bp in blueprints:
387
330
  table.add_row(
@@ -391,12 +334,15 @@ def blueprint_list():
391
334
  bp.get("description", "")
392
335
  )
393
336
  console.print(table)
394
- except Exception as e:
337
+ except BlueprintIndexError as e:
395
338
  logger.exception("Error reading blueprint index")
396
339
  console.print(f"[red]Error reading blueprints index: {e}[/red]")
340
+ if blueprint_repo:
341
+ raise typer.Exit(1)
397
342
 
398
343
  @blueprint_app.command("run")
399
344
  def blueprint_run(
345
+ ctx: typer.Context,
400
346
  blueprint_path_name: str,
401
347
  run_id: Optional[str] = typer.Option(None, "--run-id", help="Use a specific shared blueprint run ID."),
402
348
  source: Optional[str] = typer.Option(None, "--source", help="Use a local blueprint repo/path or clone URL instead of ~/.mn/blueprints."),
@@ -413,19 +359,20 @@ def blueprint_run(
413
359
  ):
414
360
  return
415
361
 
416
- storage_dir = _ensure_blueprint_source(source=source, update=update, offline=offline, revision=revision)
362
+ storage_dir = _ensure_blueprint_source(
363
+ source=source,
364
+ blueprint_repo=_context_blueprint_repo(ctx),
365
+ update=update,
366
+ offline=offline,
367
+ revision=revision,
368
+ )
417
369
 
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
-
370
+ index_path = Path(storage_dir) / "index.json"
423
371
  try:
424
- with open(index_path, "r") as f:
425
- blueprints = json.load(f)
426
- except Exception as e:
372
+ blueprints = _load_blueprint_index(index_path, require_paths=True)
373
+ except BlueprintIndexError as e:
427
374
  logger.exception("Error parsing blueprint index")
428
- console.print(f"[red]Error parsing index.json: {e}[/red]")
375
+ console.print(f"[red]Error: {e}[/red]")
429
376
  raise typer.Exit(1)
430
377
 
431
378
  target_bp = None
@@ -457,11 +404,18 @@ def blueprint_run(
457
404
 
458
405
  @blueprint_app.command("install")
459
406
  def blueprint_install(
460
- source: str = typer.Option(DEFAULT_BLUEPRINT_REPO, "--source", help="Blueprint repository URL or local path."),
407
+ ctx: typer.Context,
408
+ source: Optional[str] = typer.Option(None, "--source", help="Blueprint repository URL or local path."),
461
409
  force: bool = typer.Option(False, "--force", help="Replace the existing cached repository."),
462
410
  ):
463
411
  """Install the blueprint library into ~/.mn/blueprints."""
464
- storage_dir = Path(os.path.expanduser("~/.mn/blueprints"))
412
+ blueprint_repo = _context_blueprint_repo(ctx)
413
+ repo_source = source or blueprint_repo or DEFAULT_BLUEPRINT_REPO
414
+ storage_dir = (
415
+ _blueprint_cache_dir_for_repo(repo_source)
416
+ if source is None and blueprint_repo
417
+ else _default_blueprint_storage_dir()
418
+ )
465
419
  if storage_dir.exists() and not force:
466
420
  console.print(f"[yellow]Blueprint storage already exists at {storage_dir}. Use --force to replace it.[/yellow]")
467
421
  return
@@ -469,20 +423,37 @@ def blueprint_install(
469
423
  import shutil
470
424
 
471
425
  shutil.rmtree(storage_dir)
472
- _clone_blueprint_repo(source, storage_dir)
426
+ _clone_blueprint_repo(repo_source, storage_dir)
427
+ try:
428
+ _load_blueprint_index(storage_dir / "index.json")
429
+ except BlueprintIndexError as e:
430
+ console.print(f"[red]Error: {e}[/red]")
431
+ raise typer.Exit(1)
473
432
  console.print(f"[green]Installed blueprints at {storage_dir}.[/green]")
474
433
 
475
434
 
476
435
  @blueprint_app.command("update")
477
436
  def blueprint_update(
437
+ ctx: typer.Context,
478
438
  source: Optional[str] = typer.Option(None, "--source", help="Cached blueprint repo/path to update."),
479
439
  ):
480
440
  """Update the cached blueprint library explicitly."""
481
- storage_dir = Path(source).expanduser() if source else Path(os.path.expanduser("~/.mn/blueprints"))
441
+ blueprint_repo = _context_blueprint_repo(ctx)
442
+ if source:
443
+ storage_dir = Path(source).expanduser()
444
+ elif blueprint_repo:
445
+ storage_dir = _blueprint_storage_dir_for_source(blueprint_repo)
446
+ else:
447
+ storage_dir = _default_blueprint_storage_dir()
482
448
  if not storage_dir.exists():
483
449
  console.print(f"[red]Blueprint storage not found at {storage_dir}. Run 'mn blueprint install' first.[/red]")
484
450
  raise typer.Exit(1)
485
451
  _git_pull(storage_dir)
452
+ try:
453
+ _load_blueprint_index(storage_dir / "index.json")
454
+ except BlueprintIndexError as e:
455
+ console.print(f"[red]Error: {e}[/red]")
456
+ raise typer.Exit(1)
486
457
 
487
458
 
488
459
  @blueprint_app.command("monitor")