reqledger 0.1.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.
reqledger/cli.py ADDED
@@ -0,0 +1,694 @@
1
+ """ReqLedger command-line interface (Typer).
2
+
3
+ Commands: init, new, list, show, validate, index, link, review, export.
4
+ Global options: ``--version``, ``--config PATH``, ``--json``.
5
+
6
+ Exit codes follow the brief: 0 success, 1 validation errors, 2 usage/config
7
+ errors.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime as _dt
13
+ import json as _json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import typer
18
+
19
+ from reqledger import ids as id_utils
20
+ from reqledger import manifest as manifest_mod
21
+ from reqledger import review as review_mod
22
+ from reqledger import store as store_mod
23
+ from reqledger.config import load_config
24
+ from reqledger.errors import (
25
+ DuplicateIdError,
26
+ NotFoundError,
27
+ ParseError,
28
+ ReqLedgerError,
29
+ )
30
+ from reqledger.model import ReqLedgerConfig, Requirement
31
+ from reqledger.parser import render_record_text, split_front_matter_text
32
+
33
+ app = typer.Typer(
34
+ name="reqledger",
35
+ help=("ReqLedger: durable record owner for requirements and acceptance criteria."),
36
+ no_args_is_help=True,
37
+ add_completion=False,
38
+ )
39
+
40
+ # Exit codes per brief.
41
+ EXIT_OK = 0
42
+ EXIT_VALIDATION = 1
43
+ EXIT_USAGE = 2
44
+
45
+ DEFAULT_README_BODY = """# Requirements
46
+
47
+ ReqLedger stores durable requirement records here as Markdown files with TOML
48
+ front matter. Records are the source of truth; the manifest is derived state.
49
+ """
50
+
51
+ _DEFAULT_CONFIG_BODY = """\
52
+ schema_version = 1
53
+
54
+ [paths]
55
+ root = "requirements"
56
+ records_dir = "requirements/records"
57
+ manifest = "requirements/manifest.json"
58
+ reports_dir = "requirements/reports"
59
+ reports_state_dir = "requirements/reports/reqledger"
60
+
61
+ [ids]
62
+ requirement_prefix = "REQ"
63
+ criterion_prefix = "AC"
64
+ width = 4
65
+
66
+ [review]
67
+ draft_stale_days = 90
68
+ """
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Global option state (populated by the main callback).
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ class _State:
77
+ config: ReqLedgerConfig | None = None
78
+ config_arg: str | None = None
79
+
80
+
81
+ _state = _State()
82
+
83
+
84
+ def _utc_today() -> str:
85
+ return _dt.datetime.now(_dt.timezone.utc).date().isoformat()
86
+
87
+
88
+ def _emit_json(payload: object) -> None:
89
+ typer.echo(_json.dumps(payload, indent=2, sort_keys=False, ensure_ascii=False))
90
+
91
+
92
+ def _resolve_config(start: Path | None = None) -> ReqLedgerConfig:
93
+ # Always resolve fresh: each CLI invocation is a single process, and
94
+ # caching would leak state across CliRunner calls in tests.
95
+ return load_config(config=_state.config_arg, start=start or Path.cwd())
96
+
97
+
98
+ def _config_error(code: int = EXIT_USAGE) -> typer.Exit:
99
+ raise typer.Exit(code=code)
100
+
101
+
102
+ def _load_workspace_records(config: ReqLedgerConfig) -> list[Requirement]:
103
+ paths = store_mod.discover_records(config.records_dir)
104
+ records = store_mod.load_records(paths)
105
+ return records
106
+
107
+
108
+ @app.callback(invoke_without_command=True)
109
+ def main_callback(
110
+ version: bool = typer.Option(
111
+ False,
112
+ "--version",
113
+ help="Show the ReqLedger version and exit.",
114
+ is_eager=True,
115
+ ),
116
+ config: str | None = typer.Option(
117
+ None,
118
+ "--config",
119
+ help="Path to a reqledger.toml config file.",
120
+ metavar="PATH",
121
+ ),
122
+ ) -> None:
123
+ """ReqLedger: durable record owner for requirements and acceptance criteria."""
124
+ from reqledger import __version__
125
+
126
+ _state.config_arg = config
127
+ if version:
128
+ typer.echo(__version__)
129
+ raise typer.Exit(code=EXIT_OK)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # init
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ @app.command("init")
138
+ def init_cmd(
139
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
140
+ json_output: bool = typer.Option(
141
+ False, "--json", help="Emit machine-readable JSON."
142
+ ),
143
+ ) -> None:
144
+ """Create the requirements workspace layout and default config."""
145
+ config = _resolve_config()
146
+ created: list[str] = []
147
+ existing: list[str] = []
148
+ skipped: list[str] = []
149
+
150
+ files: list[tuple[Path, str]] = []
151
+ config_dir = config.workspace_root
152
+ config_file = config_dir / "reqledger.toml"
153
+ files.append((config_file, _DEFAULT_CONFIG_BODY))
154
+ readme = config.root / "README.md"
155
+ files.append((readme, DEFAULT_README_BODY))
156
+ dirs = [
157
+ config.records_dir,
158
+ config.reports_dir,
159
+ config.reports_state_dir,
160
+ ]
161
+
162
+ for path, content in files:
163
+ if path.exists() and not force:
164
+ existing.append(path.as_posix())
165
+ continue
166
+ path.parent.mkdir(parents=True, exist_ok=True)
167
+ path.write_text(content, encoding="utf-8")
168
+ created.append(path.as_posix())
169
+
170
+ for directory in dirs:
171
+ if directory.exists() and not force:
172
+ existing.append(directory.as_posix() + "/")
173
+ continue
174
+ directory.mkdir(parents=True, exist_ok=True)
175
+ created.append(directory.as_posix() + "/")
176
+
177
+ # records/ may equal reports/ root sibling; ensure records exists too.
178
+ if not config.records_dir.exists():
179
+ config.records_dir.mkdir(parents=True, exist_ok=True)
180
+ created.append(config.records_dir.as_posix() + "/")
181
+
182
+ if json_output:
183
+ _emit_json(
184
+ {
185
+ "created": sorted(created),
186
+ "existing": sorted(existing),
187
+ "skipped": sorted(skipped),
188
+ "config": str(config_file),
189
+ }
190
+ )
191
+ raise typer.Exit(code=EXIT_OK)
192
+
193
+ typer.echo(f"config: {config_file}")
194
+ for path in sorted(created):
195
+ typer.echo(f"created: {path}")
196
+ for path in sorted(existing):
197
+ typer.echo(f"existing: {path}")
198
+ raise typer.Exit(code=EXIT_OK)
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # new
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ @app.command("new")
207
+ def new_cmd(
208
+ title: str = typer.Argument(..., help="Requirement title."),
209
+ kind: str = typer.Option("functional", "--kind", help="Requirement kind."),
210
+ priority: str = typer.Option("must", "--priority", help="Requirement priority."),
211
+ tag: list[str] = typer.Option([], "--tag", help="Tag (repeatable).", metavar="TAG"),
212
+ criterion: list[str] = typer.Option(
213
+ [],
214
+ "--criterion",
215
+ help="Acceptance criterion statement (repeatable).",
216
+ metavar="STATEMENT",
217
+ ),
218
+ status: str = typer.Option("draft", "--status", help="Initial requirement status."),
219
+ source: str = typer.Option("manual", "--source", help="Requirement source."),
220
+ json_output: bool = typer.Option(
221
+ False, "--json", help="Emit machine-readable JSON."
222
+ ),
223
+ ) -> None:
224
+ """Create a new requirement record."""
225
+ config = _resolve_config()
226
+ try:
227
+ records = _load_workspace_records(config)
228
+ except ReqLedgerError as exc:
229
+ typer.echo(f"error: {exc}", err=True)
230
+ _config_error(EXIT_USAGE)
231
+
232
+ existing_ids = store_mod.existing_requirement_ids(records)
233
+ new_id = id_utils.next_requirement_id(existing_ids, config)
234
+
235
+ # Refuse file/id collisions defensively.
236
+ target_path = config.records_dir / id_utils.requirement_filename(new_id, config)
237
+ if target_path.exists():
238
+ typer.echo(f"error: record file already exists: {target_path}", err=True)
239
+ _config_error(EXIT_USAGE)
240
+
241
+ criteria: list[dict[str, object]] = []
242
+ for index, statement in enumerate(criterion, start=1):
243
+ cid = f"{config.criterion_prefix}-{index:0{config.width}d}"
244
+ criteria.append(
245
+ {
246
+ "id": cid,
247
+ "statement": statement,
248
+ "verification": "behavior",
249
+ "status": "accepted" if status == "accepted" else "draft",
250
+ "tags": list(tag),
251
+ }
252
+ )
253
+
254
+ today = _utc_today()
255
+ metadata: dict[str, object] = {
256
+ "schema_version": config.schema_version,
257
+ "id": new_id,
258
+ "title": title,
259
+ "kind": kind,
260
+ "status": status,
261
+ "priority": priority,
262
+ "owner": "",
263
+ "tags": list(tag),
264
+ "parent_ids": [],
265
+ "supersedes": [],
266
+ "superseded_by": [],
267
+ "task_refs": [],
268
+ "arch_refs": [],
269
+ "spec_refs": [],
270
+ "evidence_refs": [],
271
+ "source": source,
272
+ "source_refs": [],
273
+ "created": today,
274
+ "updated": today,
275
+ "criteria": criteria,
276
+ }
277
+ body = f"# {new_id}: {title}\n\n## Intent\n\nTODO: describe intent.\n"
278
+ content = render_record_text(metadata, body)
279
+
280
+ target_path.parent.mkdir(parents=True, exist_ok=True)
281
+ target_path.write_text(content, encoding="utf-8")
282
+
283
+ if json_output:
284
+ _emit_json({"id": new_id, "path": target_path.as_posix()})
285
+ raise typer.Exit(code=EXIT_OK)
286
+
287
+ typer.echo(f"created: {new_id} -> {target_path}")
288
+ raise typer.Exit(code=EXIT_OK)
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # list
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ @app.command("list")
297
+ def list_cmd(
298
+ status: str | None = typer.Option(None, "--status", help="Filter by status."),
299
+ tag: str | None = typer.Option(None, "--tag", help="Filter by tag."),
300
+ json_output: bool = typer.Option(
301
+ False, "--json", help="Emit machine-readable JSON."
302
+ ),
303
+ ) -> None:
304
+ """List requirement records."""
305
+ config = _resolve_config()
306
+ try:
307
+ records = _load_workspace_records(config)
308
+ except ReqLedgerError as exc:
309
+ typer.echo(f"error: {exc}", err=True)
310
+ _config_error(EXIT_USAGE)
311
+
312
+ filtered = [
313
+ r
314
+ for r in records
315
+ if (status is None or r.status == status) and (tag is None or tag in r.tags)
316
+ ]
317
+
318
+ if json_output:
319
+ _emit_json([r.to_manifest_entry() for r in filtered])
320
+ raise typer.Exit(code=EXIT_OK)
321
+
322
+ for record in filtered:
323
+ parts = (record.id, record.status, record.priority, record.kind, record.title)
324
+ typer.echo(" ".join(parts))
325
+ raise typer.Exit(code=EXIT_OK)
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # show
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ @app.command("show")
334
+ def show_cmd(
335
+ requirement_id: str = typer.Argument(..., help="Requirement ID (e.g. REQ-0001)."),
336
+ json_output: bool = typer.Option(
337
+ False, "--json", help="Emit machine-readable JSON."
338
+ ),
339
+ ) -> None:
340
+ """Show a single requirement record."""
341
+ config = _resolve_config()
342
+ try:
343
+ records = _load_workspace_records(config)
344
+ record = store_mod.resolve_single(records, requirement_id)
345
+ except (NotFoundError, DuplicateIdError) as exc:
346
+ typer.echo(f"error: {exc}", err=True)
347
+ _config_error(EXIT_VALIDATION)
348
+ except ReqLedgerError as exc:
349
+ typer.echo(f"error: {exc}", err=True)
350
+ _config_error(EXIT_USAGE)
351
+
352
+ if json_output:
353
+ _emit_json(record.to_manifest_entry())
354
+ raise typer.Exit(code=EXIT_OK)
355
+
356
+ typer.echo(f"id: {record.id}")
357
+ typer.echo(f"title: {record.title}")
358
+ typer.echo(f"kind: {record.kind}")
359
+ typer.echo(f"status: {record.status}")
360
+ typer.echo(f"priority: {record.priority}")
361
+ typer.echo(f"tags: {', '.join(record.tags)}")
362
+ typer.echo(f"source: {record.source}")
363
+ typer.echo(f"path: {record.path.as_posix()}")
364
+ typer.echo("criteria:")
365
+ for crit in record.criteria:
366
+ typer.echo(
367
+ f" - {crit.id} [{crit.status}/{crit.verification}]: {crit.statement}"
368
+ )
369
+ raise typer.Exit(code=EXIT_OK)
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # validate
374
+ # ---------------------------------------------------------------------------
375
+
376
+
377
+ @app.command("validate")
378
+ def validate_cmd(
379
+ json_output: bool = typer.Option(
380
+ False, "--json", help="Emit machine-readable JSON."
381
+ ),
382
+ ) -> None:
383
+ """Validate all requirement records (fail-closed)."""
384
+ config = _resolve_config()
385
+ try:
386
+ paths = store_mod.discover_records(config.records_dir)
387
+ records = store_mod.load_records(paths)
388
+ except ReqLedgerError as exc:
389
+ typer.echo(f"error: {exc}", err=True)
390
+ _config_error(EXIT_USAGE)
391
+
392
+ raw_dicts: dict[str, dict[str, object]] = {}
393
+ parse_failures: list[dict[str, object]] = []
394
+ for path in paths:
395
+ try:
396
+ metadata, _body = split_front_matter_text(path.read_text(encoding="utf-8"))
397
+ except ParseError as exc:
398
+ parse_failures.append(
399
+ {
400
+ "severity": "error",
401
+ "code": review_mod.RQL014,
402
+ "message": str(exc),
403
+ "requirement_id": "",
404
+ "criterion_id": "",
405
+ "path": path.as_posix(),
406
+ }
407
+ )
408
+ continue
409
+ rid = str(metadata.get("id", ""))
410
+ raw_dicts[rid] = metadata
411
+
412
+ findings = review_mod.validate_records(records, raw_dicts=raw_dicts, config=config)
413
+ errors = [f for f in findings if f.severity == "error"]
414
+ error_payloads = parse_failures + [f.to_dict() for f in errors]
415
+
416
+ if json_output:
417
+ _emit_json(
418
+ {
419
+ "ok": not error_payloads,
420
+ "errors": error_payloads,
421
+ "warnings": [f.to_dict() for f in findings if f.severity == "warning"],
422
+ }
423
+ )
424
+ raise typer.Exit(code=EXIT_OK if not error_payloads else EXIT_VALIDATION)
425
+
426
+ if parse_failures:
427
+ for item in parse_failures:
428
+ typer.echo(
429
+ f"error: {item['code']} {item['path']}: {item['message']}", err=True
430
+ )
431
+ for finding in findings:
432
+ stream = sys.stderr if finding.severity == "error" else sys.stdout
433
+ text = f"{finding.severity}: {finding.code} {finding.requirement_id}"
434
+ typer.echo(f"{text}: {finding.message}", file=stream)
435
+ if error_payloads:
436
+ typer.echo(f"validation failed with {len(error_payloads)} error(s)", err=True)
437
+ raise typer.Exit(code=EXIT_VALIDATION)
438
+ typer.echo(f"validation ok: {len(records)} record(s)")
439
+ raise typer.Exit(code=EXIT_OK)
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # index
444
+ # ---------------------------------------------------------------------------
445
+
446
+
447
+ @app.command("index")
448
+ def index_cmd(
449
+ json_output: bool = typer.Option(
450
+ False, "--json", help="Emit machine-readable JSON."
451
+ ),
452
+ ) -> None:
453
+ """Validate, then write the deterministic manifest.json."""
454
+ config = _resolve_config()
455
+ try:
456
+ paths = store_mod.discover_records(config.records_dir)
457
+ records = store_mod.load_records(paths)
458
+ except ReqLedgerError as exc:
459
+ typer.echo(f"error: {exc}", err=True)
460
+ _config_error(EXIT_USAGE)
461
+
462
+ raw_dicts: dict[str, dict[str, object]] = {}
463
+ parse_failures = False
464
+ for path in paths:
465
+ try:
466
+ metadata, _body = split_front_matter_text(path.read_text(encoding="utf-8"))
467
+ except ParseError as exc:
468
+ typer.echo(f"error: {exc}", err=True)
469
+ parse_failures = True
470
+ continue
471
+ raw_dicts[str(metadata.get("id", ""))] = metadata
472
+
473
+ findings = review_mod.validate_records(records, raw_dicts=raw_dicts, config=config)
474
+ errors = [f for f in findings if f.severity == "error"]
475
+ if parse_failures or errors:
476
+ if json_output:
477
+ _emit_json(
478
+ {
479
+ "ok": False,
480
+ "errors": [f.to_dict() for f in errors],
481
+ "manifest_path": None,
482
+ }
483
+ )
484
+ else:
485
+ for finding in errors:
486
+ msg = (
487
+ f"error: {finding.code} {finding.requirement_id}: {finding.message}"
488
+ )
489
+ typer.echo(msg, err=True)
490
+ typer.echo("validation failed; manifest not written", err=True)
491
+ raise typer.Exit(code=EXIT_VALIDATION)
492
+
493
+ manifest_mod.write_manifest(
494
+ records,
495
+ config.manifest,
496
+ schema_version=config.schema_version,
497
+ base_path=config.workspace_root,
498
+ )
499
+
500
+ if json_output:
501
+ _emit_json(
502
+ {
503
+ "ok": True,
504
+ "manifest_path": config.manifest.as_posix(),
505
+ "requirements": len(records),
506
+ }
507
+ )
508
+ raise typer.Exit(code=EXIT_OK)
509
+
510
+ typer.echo(f"manifest written: {config.manifest} ({len(records)} requirements)")
511
+ raise typer.Exit(code=EXIT_OK)
512
+
513
+
514
+ # ---------------------------------------------------------------------------
515
+ # link
516
+ # ---------------------------------------------------------------------------
517
+
518
+
519
+ @app.command("link")
520
+ def link_cmd(
521
+ requirement_id: str = typer.Argument(..., help="Requirement ID to link."),
522
+ task: str | None = typer.Option(None, "--task", help="Task reference."),
523
+ arch: str | None = typer.Option(None, "--arch", help="Architecture reference."),
524
+ spec: str | None = typer.Option(
525
+ None, "--spec", help="Spec reference (path or id)."
526
+ ),
527
+ evidence: str | None = typer.Option(None, "--evidence", help="Evidence reference."),
528
+ json_output: bool = typer.Option(
529
+ False, "--json", help="Emit machine-readable JSON."
530
+ ),
531
+ ) -> None:
532
+ """Link a requirement to external references."""
533
+ config = _resolve_config()
534
+ try:
535
+ records = _load_workspace_records(config)
536
+ record = store_mod.resolve_single(records, requirement_id)
537
+ except (NotFoundError, DuplicateIdError) as exc:
538
+ typer.echo(f"error: {exc}", err=True)
539
+ _config_error(EXIT_VALIDATION)
540
+ except ReqLedgerError as exc:
541
+ typer.echo(f"error: {exc}", err=True)
542
+ _config_error(EXIT_USAGE)
543
+
544
+ additions: list[tuple[str, str, list[str]]] = []
545
+ if task:
546
+ additions.append(("task", task, list(record.task_refs)))
547
+ if arch:
548
+ additions.append(("arch", arch, list(record.arch_refs)))
549
+ if spec:
550
+ additions.append(("spec", spec, list(record.spec_refs)))
551
+ if evidence:
552
+ additions.append(("evidence", evidence, list(record.evidence_refs)))
553
+
554
+ if not additions:
555
+ typer.echo(
556
+ "error: provide at least one of --task/--arch/--spec/--evidence", err=True
557
+ )
558
+ _config_error(EXIT_USAGE)
559
+
560
+ warnings: list[str] = []
561
+ field_map = {
562
+ "task": "task_refs",
563
+ "arch": "arch_refs",
564
+ "spec": "spec_refs",
565
+ "evidence": "evidence_refs",
566
+ }
567
+
568
+ raw = record.path.read_text(encoding="utf-8")
569
+ metadata, body = split_front_matter_text(raw)
570
+ for kind, value, _existing in additions:
571
+ # Warn (not fail) on missing local path refs.
572
+ candidate = Path(value)
573
+ if "/" in value and not candidate.exists() and not _looks_like_id(value):
574
+ warnings.append(f"warning: local path ref does not exist: {value}")
575
+ field_name = field_map[kind]
576
+ current = list(metadata.get(field_name, [])) # type: ignore[arg-type]
577
+ if value not in current:
578
+ current.append(value)
579
+ metadata[field_name] = current
580
+ metadata["updated"] = _utc_today()
581
+ new_text = render_record_text(metadata, body)
582
+ record.path.write_text(new_text, encoding="utf-8")
583
+
584
+ if json_output:
585
+ _emit_json(
586
+ {
587
+ "id": record.id,
588
+ "path": record.path.as_posix(),
589
+ "warnings": warnings,
590
+ "task_refs": metadata.get("task_refs", []),
591
+ "arch_refs": metadata.get("arch_refs", []),
592
+ "spec_refs": metadata.get("spec_refs", []),
593
+ "evidence_refs": metadata.get("evidence_refs", []),
594
+ }
595
+ )
596
+ raise typer.Exit(code=EXIT_OK)
597
+
598
+ for message in warnings:
599
+ typer.echo(message)
600
+ typer.echo(f"linked: {record.id} -> {record.path}")
601
+ raise typer.Exit(code=EXIT_OK)
602
+
603
+
604
+ def _looks_like_id(value: str) -> bool:
605
+ return "-" in value and value.split("-")[0].isalpha()
606
+
607
+
608
+ # ---------------------------------------------------------------------------
609
+ # review
610
+ # ---------------------------------------------------------------------------
611
+
612
+
613
+ @app.command("review")
614
+ def review_cmd(
615
+ json_output: bool = typer.Option(
616
+ False, "--json", help="Emit machine-readable JSON."
617
+ ),
618
+ ) -> None:
619
+ """Write review.md and review.json reports (fail-closed)."""
620
+ config = _resolve_config()
621
+ try:
622
+ paths = store_mod.discover_records(config.records_dir)
623
+ records = store_mod.load_records(paths)
624
+ except ReqLedgerError as exc:
625
+ typer.echo(f"error: {exc}", err=True)
626
+ _config_error(EXIT_USAGE)
627
+
628
+ review_mod.write_review_reports(
629
+ records,
630
+ config=config,
631
+ markdown_path=config.reports_state_dir / "review.md",
632
+ json_path=config.reports_state_dir / "review.json",
633
+ )
634
+
635
+ if json_output:
636
+ report = review_mod.build_review_report(records, config=config)
637
+ _emit_json(report)
638
+ raise typer.Exit(code=EXIT_OK)
639
+
640
+ typer.echo(
641
+ f"review written: {config.reports_state_dir / 'review.md'} "
642
+ f"({config.reports_state_dir / 'review.json'})"
643
+ )
644
+ raise typer.Exit(code=EXIT_OK)
645
+
646
+
647
+ # ---------------------------------------------------------------------------
648
+ # export
649
+ # ---------------------------------------------------------------------------
650
+
651
+
652
+ @app.command("export")
653
+ def export_cmd(
654
+ fmt: str = typer.Option("json", "--format", help="Export format (MVP: json only)."),
655
+ output: str | None = typer.Option(
656
+ None, "--output", help="Output file (default: stdout).", metavar="PATH"
657
+ ),
658
+ json_output: bool = typer.Option(
659
+ False, "--json", help="Emit machine-readable JSON."
660
+ ),
661
+ ) -> None:
662
+ """Export machine-readable JSON for downstream tools."""
663
+ if fmt != "json":
664
+ typer.echo(
665
+ f"error: unsupported format {fmt!r} (MVP supports only 'json')", err=True
666
+ )
667
+ _config_error(EXIT_USAGE)
668
+
669
+ config = _resolve_config()
670
+ try:
671
+ paths = store_mod.discover_records(config.records_dir)
672
+ records = store_mod.load_records(paths)
673
+ except ReqLedgerError as exc:
674
+ typer.echo(f"error: {exc}", err=True)
675
+ _config_error(EXIT_USAGE)
676
+
677
+ text = manifest_mod.render_export_json(
678
+ records, schema_version=config.schema_version, base_path=config.workspace_root
679
+ )
680
+ if output:
681
+ out_path = Path(output)
682
+ out_path.parent.mkdir(parents=True, exist_ok=True)
683
+ out_path.write_text(text, encoding="utf-8")
684
+ if json_output:
685
+ _emit_json({"exported": True, "path": out_path.as_posix()})
686
+ else:
687
+ typer.echo(f"export written: {out_path}")
688
+ raise typer.Exit(code=EXIT_OK)
689
+
690
+ typer.echo(text)
691
+ raise typer.Exit(code=EXIT_OK)
692
+
693
+
694
+ __all__ = ["app"]