spanforge 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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/_cli.py ADDED
@@ -0,0 +1,2094 @@
1
+ """Command-line interface for spanforge utilities.
2
+
3
+ This module provides the ``spanforge`` entry-point command. It is excluded
4
+ from coverage measurement because it is a thin integration shim over the
5
+ public library API — all business logic lives in tested library modules.
6
+
7
+ Entry-point (configured in pyproject.toml)::
8
+
9
+ spanforge = "spanforge._cli:main"
10
+
11
+ Sub-commands
12
+ ------------
13
+ ``spanforge check``
14
+ End-to-end health check: validates configuration, emits a test event,
15
+ and confirms the export pipeline is working. Exits 0 on success.
16
+
17
+ ``spanforge check-compat <events.json>``
18
+ Load a JSON file containing a list of serialised events and run the
19
+ v1.0 compatibility checklist. Exits 0 on success, 1 on violations,
20
+ 2 on usage/parse errors.
21
+
22
+ ``spanforge list-deprecated``
23
+ Print all event types registered in the global deprecation registry.
24
+
25
+ ``spanforge migration-roadmap [--json]``
26
+ Print the planned v1 → v2 migration roadmap from
27
+ :func:`~spanforge.migrate.v2_migration_roadmap`. Pass
28
+ ``--json`` to emit JSON for machine consumption.
29
+
30
+ ``spanforge check-consumers``
31
+ Assert that all globally registered consumers are compatible with the
32
+ installed schema version. Exits 0 on success, 1 on incompatibilities.
33
+
34
+ ``spanforge validate <events.jsonl>``
35
+ Validate every event in a JSONL file against the published schema.
36
+ Exits 0 if all events are valid, 1 if any fail validation.
37
+
38
+ ``spanforge audit-chain <events.jsonl>``
39
+ Verify the HMAC signing chain of events in a JSONL file. Reads the
40
+ signing key from the ``SPANFORGE_SIGNING_KEY`` environment variable.
41
+ Exits 0 if the chain is intact, 1 if tampering or gaps are found.
42
+
43
+ ``spanforge inspect <event_id> <events.jsonl>``
44
+ Find a single event by ``event_id`` in a JSONL file and pretty-print
45
+ its JSON envelope to stdout. Exits 0 on success, 1 if not found.
46
+
47
+ ``spanforge stats <events.jsonl>``
48
+ Print a summary table of events in a JSONL file: event counts by type,
49
+ total prompt/completion/total tokens, total cost, and timestamp range.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import contextlib
56
+ import json
57
+ import os
58
+ import sys
59
+ import threading
60
+ from pathlib import Path
61
+ from typing import Any, NoReturn
62
+
63
+ from spanforge._cli_audit import add_audit_subcommands, dispatch_audit_command
64
+ from spanforge._cli_compliance import add_compliance_subcommands, dispatch_compliance_command
65
+ from spanforge._cli_cost import add_cost_subcommands, dispatch_cost_command
66
+ from spanforge._cli_ops import add_ops_subcommands, dispatch_ops_command
67
+ from spanforge._cli_phase11 import add_phase11_subcommands, dispatch_phase11_command
68
+
69
+ _NO_EVENTS_MSG = "No events found in file."
70
+
71
+
72
+ def _cmd_check(_args: argparse.Namespace) -> int:
73
+ """Implement the ``check`` sub-command — end-to-end health check."""
74
+ import traceback
75
+
76
+ print("spanforge health check")
77
+ print("=" * 40)
78
+ ok = True
79
+
80
+ # Step 1: Config
81
+ try:
82
+ from spanforge.config import get_config
83
+
84
+ cfg = get_config()
85
+ print(
86
+ f"[✓] Config loaded exporter={cfg.exporter!r} env={cfg.env!r} "
87
+ f"service={cfg.service_name!r}"
88
+ )
89
+ except Exception as exc:
90
+ print(f"[✗] Config failed: {exc}", file=sys.stderr)
91
+ return 1
92
+
93
+ # Step 2: Event creation
94
+ try:
95
+ from spanforge.event import Event
96
+ from spanforge.ulid import generate as gen_ulid
97
+
98
+ event = Event(
99
+ event_type="llm.trace.span.completed",
100
+ source=f"{cfg.service_name}@0.0.0",
101
+ payload={
102
+ "span_id": "0" * 16,
103
+ "trace_id": "0" * 32,
104
+ "span_name": "spanforge.health.check",
105
+ "operation": "chat",
106
+ "span_kind": "client",
107
+ "status": "ok",
108
+ "start_time_unix_nano": 0,
109
+ "end_time_unix_nano": 1_000_000,
110
+ "duration_ms": 1.0,
111
+ },
112
+ event_id=gen_ulid(),
113
+ )
114
+ print("[✓] Test event created")
115
+ except Exception as exc:
116
+ print(f"[✗] Event creation failed: {exc}", file=sys.stderr)
117
+ traceback.print_exc(file=sys.stderr)
118
+ return 1
119
+
120
+ # Step 3: Schema validation
121
+ try:
122
+ from spanforge.validate import validate_event
123
+
124
+ validate_event(event)
125
+ print("[✓] Schema validation passed")
126
+ except Exception as exc:
127
+ print(f"[✗] Schema validation failed: {exc}", file=sys.stderr)
128
+ ok = False
129
+
130
+ # Step 4: Export pipeline
131
+ try:
132
+ from spanforge._stream import _dispatch
133
+
134
+ _dispatch(event)
135
+ print("[✓] Export pipeline: event dispatched successfully")
136
+ except Exception as exc:
137
+ print(f"[✗] Export pipeline failed: {exc}", file=sys.stderr)
138
+ traceback.print_exc(file=sys.stderr)
139
+ ok = False
140
+
141
+ # Step 5: TraceStore recording (only if enabled)
142
+ if cfg.enable_trace_store:
143
+ try:
144
+ from spanforge._store import get_store
145
+
146
+ store = get_store()
147
+ events = store.get_trace("0" * 32)
148
+ if events is not None and len(events) >= 1:
149
+ print(f"[✓] TraceStore recorded {len(events)} event(s)")
150
+ else:
151
+ print("[✗] TraceStore: event not found after dispatch", file=sys.stderr)
152
+ ok = False
153
+ except Exception as exc:
154
+ print(f"[✗] TraceStore check failed: {exc}", file=sys.stderr)
155
+ ok = False
156
+ else:
157
+ print("[-] TraceStore: disabled (set SPANFORGE_ENABLE_TRACE_STORE=1 to enable)")
158
+
159
+ print("=" * 40)
160
+ if ok:
161
+ print("PASS — all checks passed.")
162
+ return 0
163
+ print("FAIL — one or more checks failed.", file=sys.stderr)
164
+ return 1
165
+
166
+
167
+ def _cmd_check_compat(args: argparse.Namespace) -> int:
168
+ """Implement the ``check-compat`` sub-command."""
169
+ from spanforge.compliance import test_compatibility
170
+ from spanforge.event import Event
171
+
172
+ path = Path(args.file)
173
+ if not path.exists():
174
+ print(f"error: file not found: {path}", file=sys.stderr)
175
+ return 2
176
+
177
+ try:
178
+ raw = json.loads(path.read_text(encoding="utf-8"))
179
+ except json.JSONDecodeError as exc:
180
+ print(f"error: invalid JSON in {path}: {exc}", file=sys.stderr)
181
+ return 2
182
+
183
+ if not isinstance(raw, list):
184
+ print("error: JSON file must contain a top-level array of events", file=sys.stderr)
185
+ return 2
186
+
187
+ from spanforge.exceptions import DeserializationError, SchemaValidationError
188
+
189
+ try:
190
+ events = [Event.from_dict(item) for item in raw]
191
+ except (DeserializationError, SchemaValidationError, KeyError, TypeError) as exc:
192
+ print(f"error: could not deserialise events: {exc}", file=sys.stderr)
193
+ return 2
194
+
195
+ result = test_compatibility(events)
196
+
197
+ if result.passed:
198
+ print(f"OK — {result.events_checked} event(s) passed all compatibility checks.")
199
+ return 0
200
+
201
+ print(
202
+ f"FAIL — {len(result.violations)} violation(s) found in {result.events_checked} event(s):\n"
203
+ )
204
+ for v in result.violations:
205
+ event_ref = f"[{v.event_id}] " if v.event_id else ""
206
+ print(f" {event_ref}{v.check_id} ({v.rule}): {v.detail}")
207
+
208
+ return 1
209
+
210
+
211
+ def _cmd_list_deprecated(_args: argparse.Namespace) -> int:
212
+ """Implement the ``list-deprecated`` sub-command."""
213
+ try:
214
+ from spanforge.deprecations import list_deprecated
215
+
216
+ notices = list_deprecated()
217
+ if not notices:
218
+ print("No deprecated event types registered.")
219
+ return 0
220
+
221
+ print(f"{'Event Type':<50} {'Since':<8} {'Sunset':<8} Replacement")
222
+ print("-" * 90)
223
+ for n in notices:
224
+ repl = n.replacement or "(no replacement)"
225
+ print(f"{n.event_type:<50} {n.since:<8} {n.sunset:<8} {repl}")
226
+ except Exception as exc:
227
+ print(f"error: {exc}", file=sys.stderr)
228
+ return 1
229
+ else:
230
+ return 0
231
+
232
+
233
+ def _cmd_migration_roadmap(args: argparse.Namespace) -> int:
234
+ """Implement the ``migration-roadmap`` sub-command."""
235
+ try:
236
+ import spanforge.migrate as _migrate_mod
237
+
238
+ v2_migration_roadmap = getattr(_migrate_mod, "v2_migration_roadmap", None)
239
+ if v2_migration_roadmap is None:
240
+ print("No migration records found.")
241
+ return 0
242
+ except ImportError as exc:
243
+ print(f"error: {exc}", file=sys.stderr)
244
+ return 1
245
+
246
+ roadmap = v2_migration_roadmap()
247
+ if not roadmap:
248
+ print("No migration records found.")
249
+ return 0
250
+
251
+ if getattr(args, "json", False):
252
+ output = [
253
+ {
254
+ "event_type": r.event_type,
255
+ "since": r.since,
256
+ "sunset": r.sunset,
257
+ "sunset_policy": r.sunset_policy.value,
258
+ "replacement": r.replacement,
259
+ "migration_notes": r.migration_notes,
260
+ "field_renames": r.field_renames,
261
+ }
262
+ for r in roadmap
263
+ ]
264
+ print(json.dumps(output, indent=2))
265
+ return 0
266
+
267
+ print(f"v1 → v2 Migration Roadmap ({len(roadmap)} changes)\n")
268
+ for r in roadmap:
269
+ arrow = f" → {r.replacement}" if r.replacement else " (removed)"
270
+ print(f" [{r.since}→{r.sunset}] {r.event_type}{arrow}")
271
+ if r.migration_notes:
272
+ import textwrap
273
+
274
+ wrapped = textwrap.fill(
275
+ r.migration_notes, width=72, initial_indent=" ", subsequent_indent=" "
276
+ )
277
+ print(wrapped)
278
+ if r.field_renames:
279
+ for old, new in r.field_renames.items():
280
+ print(f" field rename: {old!r} → {new!r}")
281
+ print()
282
+ return 0
283
+
284
+
285
+ def _cmd_check_consumers(_args: argparse.Namespace) -> int:
286
+ """Implement the ``check-consumers`` sub-command."""
287
+ from spanforge.consumer import get_registry
288
+
289
+ registry = get_registry()
290
+ all_records = registry.all()
291
+ if not all_records:
292
+ print("No consumers registered.")
293
+ return 0
294
+
295
+ incompatible = registry.check_compatible()
296
+ if not incompatible:
297
+ print(f"OK — all {len(all_records)} consumer(s) are compatible.")
298
+ return 0
299
+
300
+ print(f"INCOMPATIBLE — {len(incompatible)} consumer(s) require a newer schema:\n")
301
+ for tool_name, version in incompatible:
302
+ print(f" {tool_name!r} requires schema v{version}")
303
+ return 1
304
+
305
+
306
+ def _read_jsonl_events(path: Path) -> list[tuple[int, Any]]:
307
+ """Read a JSONL file and return a list of (lineno, Event | Exception) pairs."""
308
+ from spanforge.event import Event
309
+ from spanforge.exceptions import DeserializationError, SchemaValidationError
310
+
311
+ results: list[tuple[int, Any]] = []
312
+ for lineno, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
313
+ line = raw_line.strip()
314
+ if not line:
315
+ continue
316
+ try:
317
+ obj = json.loads(line)
318
+ event = Event.from_dict(obj)
319
+ results.append((lineno, event))
320
+ except (
321
+ json.JSONDecodeError,
322
+ DeserializationError,
323
+ SchemaValidationError,
324
+ KeyError,
325
+ TypeError,
326
+ ) as exc:
327
+ results.append((lineno, exc))
328
+ return results
329
+
330
+
331
+ def _cmd_validate(args: argparse.Namespace) -> int:
332
+ """Implement the ``validate`` sub-command."""
333
+ from spanforge.exceptions import SchemaValidationError
334
+ from spanforge.validate import validate_event
335
+
336
+ path = Path(args.file)
337
+ if not path.exists():
338
+ print(f"error: file not found: {path}", file=sys.stderr)
339
+ return 2
340
+
341
+ rows = _read_jsonl_events(path)
342
+ if not rows:
343
+ print(_NO_EVENTS_MSG)
344
+ return 0
345
+
346
+ errors: list[tuple[int, str]] = []
347
+ for lineno, item in rows:
348
+ if isinstance(item, Exception):
349
+ errors.append((lineno, f"parse error: {item}"))
350
+ continue
351
+ try:
352
+ validate_event(item)
353
+ except SchemaValidationError as exc:
354
+ errors.append((lineno, str(exc)))
355
+
356
+ total = len(rows)
357
+ if not errors:
358
+ print(f"OK — {total} event(s) passed schema validation.")
359
+ return 0
360
+
361
+ print(f"FAIL — {len(errors)} of {total} event(s) failed validation:\n")
362
+ for lineno, msg in errors:
363
+ print(f" line {lineno}: {msg}")
364
+ return 1
365
+
366
+
367
+
368
+
369
+ def _cmd_inspect(args: argparse.Namespace) -> int:
370
+ """Implement the ``inspect`` sub-command."""
371
+ path = Path(args.file)
372
+ if not path.exists():
373
+ print(f"error: file not found: {path}", file=sys.stderr)
374
+ return 2
375
+
376
+ rows = _read_jsonl_events(path)
377
+ target_id = args.event_id
378
+
379
+ for _lineno, item in rows:
380
+ if isinstance(item, Exception):
381
+ continue
382
+ if item.event_id == target_id:
383
+ print(json.dumps(item.to_dict(), indent=2))
384
+ return 0
385
+
386
+ print(f"error: event_id {target_id!r} not found in {path}", file=sys.stderr)
387
+ return 1
388
+
389
+
390
+ def _accumulate_stats(
391
+ rows: list[tuple[int, Any]],
392
+ ) -> tuple[dict[str, int], int, int, int, float, list[str], int]:
393
+ """Aggregate token/cost/type counters from parsed event rows."""
394
+ type_counts: dict[str, int] = {}
395
+ prompt_tokens = 0
396
+ completion_tokens = 0
397
+ total_tokens = 0
398
+ cost_usd = 0.0
399
+ timestamps: list[str] = []
400
+ parse_errors = 0
401
+ for _lineno, item in rows:
402
+ if isinstance(item, Exception):
403
+ parse_errors += 1
404
+ continue
405
+ event_type = str(item.event_type) if item.event_type else "(unknown)"
406
+ type_counts[event_type] = type_counts.get(event_type, 0) + 1
407
+ payload = item.payload or {}
408
+ prompt_tokens += int(payload.get("prompt_tokens") or 0)
409
+ completion_tokens += int(payload.get("completion_tokens") or 0)
410
+ total_tokens += int(payload.get("total_tokens") or 0)
411
+ cost_usd += float(payload.get("cost_usd") or 0.0)
412
+ if item.timestamp:
413
+ timestamps.append(item.timestamp)
414
+ return (
415
+ type_counts,
416
+ prompt_tokens,
417
+ completion_tokens,
418
+ total_tokens,
419
+ cost_usd,
420
+ timestamps,
421
+ parse_errors,
422
+ )
423
+
424
+
425
+ def _cmd_stats(args: argparse.Namespace) -> int:
426
+ """Implement the ``stats`` sub-command."""
427
+ path = Path(args.file)
428
+ if not path.exists():
429
+ print(f"error: file not found: {path}", file=sys.stderr)
430
+ return 2
431
+
432
+ rows = _read_jsonl_events(path)
433
+ if not rows:
434
+ print(_NO_EVENTS_MSG)
435
+ return 0
436
+
437
+ (
438
+ type_counts,
439
+ prompt_tokens,
440
+ completion_tokens,
441
+ total_tokens,
442
+ cost_usd,
443
+ timestamps,
444
+ parse_errors,
445
+ ) = _accumulate_stats(rows)
446
+
447
+ total_events = len(rows) - parse_errors
448
+ print(
449
+ f"Events: {total_events}"
450
+ + (f" ({parse_errors} parse error(s) skipped)" if parse_errors else "")
451
+ )
452
+ print()
453
+
454
+ if type_counts:
455
+ print(f"{'Event Type':<55} {'Count':>7}")
456
+ print("-" * 65)
457
+ for et, cnt in sorted(type_counts.items(), key=lambda x: -x[1]):
458
+ print(f" {et:<53} {cnt:>7}")
459
+ print()
460
+
461
+ print(f"Prompt tokens: {prompt_tokens:>12,}")
462
+ print(f"Completion tokens: {completion_tokens:>12,}")
463
+ print(f"Total tokens: {total_tokens:>12,}")
464
+ print(f"Cost (USD): {cost_usd:>12.6f}")
465
+ print()
466
+
467
+ if timestamps:
468
+ ts_sorted = sorted(timestamps)
469
+ print(f"Earliest: {ts_sorted[0]}")
470
+ print(f"Latest: {ts_sorted[-1]}")
471
+
472
+ return 0
473
+
474
+
475
+
476
+
477
+ def _cmd_dev(args: argparse.Namespace) -> int:
478
+ """Implement ``spanforge dev <action>``."""
479
+ from spanforge.core.dx import DevCLI
480
+
481
+ action = getattr(args, "dev_command", None)
482
+ if action is None:
483
+ print("error: specify a dev sub-command: start, stop, reset, logs, status", file=sys.stderr)
484
+ return 2
485
+
486
+ cli = DevCLI()
487
+ if action == "start":
488
+ service = getattr(args, "service", "spanforge-dev")
489
+ cli.start(service)
490
+ print(f"[✓] Dev environment started service={service!r}")
491
+ elif action == "stop":
492
+ cli.stop()
493
+ print("[✓] Dev environment stopped (no buffered spans)")
494
+ elif action == "reset":
495
+ cli.reset()
496
+ print("[✓] Dev environment reset")
497
+ elif action == "logs":
498
+ entries = cli.logs()
499
+ if not entries:
500
+ print("(no log entries for this session)")
501
+ else:
502
+ for line in entries:
503
+ print(line)
504
+ elif action == "status":
505
+ status = cli.status()
506
+ print(json.dumps(status, indent=2))
507
+ else:
508
+ print(f"error: unknown dev sub-command: {action!r}", file=sys.stderr)
509
+ return 2
510
+ return 0
511
+
512
+
513
+ def _cmd_module_create(args: argparse.Namespace) -> int:
514
+ """Implement ``spanforge module create``."""
515
+ from spanforge.core.dx import ModuleCLI
516
+
517
+ base_dir = Path(getattr(args, "output_dir", ".") or ".")
518
+ cli = ModuleCLI()
519
+ try:
520
+ scaffolded = cli.scaffold(
521
+ module_name=args.name,
522
+ trust_level=getattr(args, "trust_level", "UNTRUSTED"),
523
+ author=getattr(args, "author", "unknown"),
524
+ base_dir=base_dir,
525
+ )
526
+ except Exception as exc:
527
+ print(f"error: scaffolding failed: {exc}", file=sys.stderr)
528
+ return 1
529
+
530
+ # Write generated files to disk
531
+ root = scaffolded.root_dir
532
+ root.mkdir(parents=True, exist_ok=True)
533
+ for rel_path, content in scaffolded.files.items():
534
+ file_path = root / rel_path
535
+ file_path.parent.mkdir(parents=True, exist_ok=True)
536
+ file_path.write_text(content, encoding="utf-8")
537
+ print(f"[✓] {file_path}")
538
+
539
+ print(f"\nModule {args.name!r} created at {root}")
540
+ return 0
541
+
542
+
543
+ def _cmd_serve(args: argparse.Namespace) -> int:
544
+ """Implement ``spanforge serve`` — start a local trace viewer."""
545
+ import signal
546
+
547
+ from spanforge._server import TraceViewerServer
548
+
549
+ port: int = getattr(args, "port", 8888)
550
+ host: str = getattr(args, "host", "127.0.0.1")
551
+ jsonl_file: str | None = getattr(args, "file", None)
552
+
553
+ # Pre-load a JSONL file if provided.
554
+ if jsonl_file:
555
+ try:
556
+ import json
557
+
558
+ from spanforge._store import get_store
559
+ from spanforge.event import Event
560
+
561
+ store = get_store()
562
+ loaded = 0
563
+ with open(Path(jsonl_file), encoding="utf-8") as fh:
564
+ for line in fh:
565
+ line = line.strip()
566
+ if not line:
567
+ continue
568
+ raw = json.loads(line)
569
+ try:
570
+ evt = Event.from_dict(raw)
571
+ store.record(evt)
572
+ loaded += 1
573
+ except Exception as exc:
574
+ _ = exc
575
+ print(f"[spanforge] Loaded {loaded} events from {jsonl_file!r}")
576
+ except FileNotFoundError:
577
+ print(f"error: file not found: {jsonl_file!r}", file=sys.stderr)
578
+ return 1
579
+ except Exception as exc:
580
+ print(f"error: could not load file: {exc}", file=sys.stderr)
581
+ return 1
582
+
583
+ server = TraceViewerServer(port=port, host=host)
584
+ server.start()
585
+ print(f"[spanforge] Serving traces at http://{host}:{port}/traces")
586
+ print("[spanforge] Press Ctrl+C to stop.")
587
+
588
+ # Block until SIGINT / SIGTERM.
589
+ stop_event = threading.Event()
590
+
591
+ def _handle_signal(sig: int, frame: object) -> None:
592
+ stop_event.set()
593
+
594
+ signal.signal(signal.SIGINT, _handle_signal)
595
+ with contextlib.suppress(OSError, ValueError):
596
+ signal.signal(
597
+ signal.SIGTERM, _handle_signal
598
+ ) # SIGTERM not available on Windows in some contexts
599
+
600
+ stop_event.wait()
601
+ server.stop()
602
+ return 0
603
+
604
+
605
+ # ---------------------------------------------------------------------------
606
+ # New Phase B sub-commands: init, quickstart, report, ui
607
+ # ---------------------------------------------------------------------------
608
+
609
+ _SPANFORGE_TOML_TEMPLATE = """\
610
+ # spanforge.toml — project-level spanforge configuration
611
+ # Generated by: spanforge init
612
+ # Reference: https://www.getspanforge.com/docs/configuration
613
+
614
+ [spanforge]
615
+ service_name = "{service_name}"
616
+ env = "development" # development | staging | production
617
+ exporter = "console" # console | jsonl | otlp | webhook | datadog | grafana_loki
618
+
619
+ # Uncomment to write events to a local JSONL file:
620
+ # endpoint = "events.jsonl"
621
+
622
+ # Uncomment to enable HMAC audit-chain signing:
623
+ # signing_key = "" # base64-encoded 32-byte key; set via SPANFORGE_SIGNING_KEY env var
624
+
625
+ # PII redaction — enabled by default in production:
626
+ [spanforge.redaction]
627
+ enabled = true
628
+
629
+ # Sampling:
630
+ [spanforge.sampling]
631
+ rate = 1.0 # 1.0 = emit all events; 0.1 = 10 % sample
632
+ always_sample_errors = true
633
+ """
634
+
635
+ _EXAMPLE_PY_TEMPLATE = '''\
636
+ """Example: tracing an LLM call with spanforge.
637
+
638
+ Run: python examples/trace_llm.py
639
+ """
640
+
641
+ import spanforge
642
+
643
+ spanforge.configure(exporter="console", service_name="{service_name}")
644
+
645
+ with spanforge.span("call-llm") as span:
646
+ span.set_model(model="gpt-4o", system="openai")
647
+ # --- replace with your real LLM call ---
648
+ result = {{"role": "assistant", "content": "Hello, world!"}}
649
+ # ---------------------------------------
650
+ span.set_token_usage(input=10, output=8, total=18)
651
+ span.set_status("ok")
652
+
653
+ print("Event emitted. Check above output for the JSON envelope.")
654
+ '''
655
+
656
+
657
+ def _cmd_init(args: argparse.Namespace) -> int:
658
+ """Implement the ``init`` sub-command — scaffold spanforge.toml in current dir."""
659
+ out_dir = Path(args.output_dir).resolve()
660
+ out_dir.mkdir(parents=True, exist_ok=True)
661
+
662
+ toml_path = out_dir / "spanforge.toml"
663
+ if toml_path.exists() and not args.force:
664
+ print(f"[!] {toml_path} already exists. Use --force to overwrite.", file=sys.stderr)
665
+ return 1
666
+
667
+ service_name = args.service_name or Path.cwd().name or "my-service"
668
+ toml_path.write_text(
669
+ _SPANFORGE_TOML_TEMPLATE.format(service_name=service_name), encoding="utf-8"
670
+ )
671
+ print(f"[OK] Created {toml_path}")
672
+
673
+ examples_dir = out_dir / "examples"
674
+ examples_dir.mkdir(exist_ok=True)
675
+ ex_path = examples_dir / "trace_llm.py"
676
+ if not ex_path.exists():
677
+ ex_path.write_text(_EXAMPLE_PY_TEMPLATE.format(service_name=service_name), encoding="utf-8")
678
+ print(f"[OK] Created {ex_path}")
679
+
680
+ print("\nNext steps:")
681
+ print(f" 1. Edit {toml_path} to configure your exporter.")
682
+ print(" 2. Run: python examples/trace_llm.py")
683
+ print(" 3. Run: spanforge check")
684
+ return 0
685
+
686
+
687
+ def _cmd_quickstart(_args: argparse.Namespace) -> int:
688
+ """Implement the ``quickstart`` sub-command — interactive setup wizard."""
689
+ print("spanforge quickstart wizard")
690
+ print("=" * 40)
691
+ print("This wizard will configure spanforge for your project.\n")
692
+
693
+ try:
694
+ service_name = input("Service name [my-service]: ").strip() or "my-service"
695
+ env = (
696
+ input("Environment (development/staging/production) [development]: ").strip()
697
+ or "development"
698
+ )
699
+ exporter = input("Exporter (console/jsonl/otlp/datadog) [console]: ").strip() or "console"
700
+ endpoint = ""
701
+ if exporter == "jsonl":
702
+ endpoint = input("JSONL output path [events.jsonl]: ").strip() or "events.jsonl"
703
+ elif exporter in ("otlp", "datadog"):
704
+ endpoint = input("Endpoint URL: ").strip()
705
+ enable_signing = input("Enable HMAC signing? (y/N): ").strip().lower() in ("y", "yes")
706
+ except (KeyboardInterrupt, EOFError):
707
+ print("\nAborted.", file=sys.stderr)
708
+ return 1
709
+
710
+ lines = [
711
+ "# spanforge.toml — generated by spanforge quickstart",
712
+ "[spanforge]",
713
+ f'service_name = "{service_name}"',
714
+ f'env = "{env}"',
715
+ f'exporter = "{exporter}"',
716
+ ]
717
+ if endpoint:
718
+ lines.append(f'endpoint = "{endpoint}"')
719
+ if enable_signing:
720
+ lines.append('# signing_key = "" # export SPANFORGE_SIGNING_KEY=<key>')
721
+ Path("spanforge.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
722
+ print("[OK] Wrote spanforge.toml")
723
+
724
+ print("\nRunning health check ...")
725
+ import importlib
726
+
727
+ try:
728
+ sf = importlib.import_module("spanforge")
729
+ sf.configure(exporter=exporter, service_name=service_name, env=env)
730
+ with sf.span("quickstart-test") as span:
731
+ span.set_status("ok")
732
+ print("[OK] Test event emitted successfully!")
733
+ except Exception as exc:
734
+ print(f"[!] Health check failed: {exc}", file=sys.stderr)
735
+
736
+ print("\nSetup complete. Run 'spanforge check' any time to verify your pipeline.")
737
+ return 0
738
+
739
+
740
+ def _cmd_report(args: argparse.Namespace) -> int:
741
+ """Implement the ``report`` sub-command — generate a static HTML trace report."""
742
+ src = Path(args.file)
743
+ if not src.exists():
744
+ print(f"[x] File not found: {src}", file=sys.stderr)
745
+ return 1
746
+
747
+ out_path = Path(args.output)
748
+ events: list[dict[str, Any]] = []
749
+ with src.open(encoding="utf-8") as fh:
750
+ for lineno, line in enumerate(fh, 1):
751
+ line = line.strip()
752
+ if not line:
753
+ continue
754
+ try:
755
+ events.append(json.loads(line))
756
+ except json.JSONDecodeError as exc:
757
+ print(f"[!] Line {lineno}: {exc}", file=sys.stderr)
758
+
759
+ if not events:
760
+ print(_NO_EVENTS_MSG)
761
+ return 0
762
+
763
+ rows: list[str] = []
764
+ for ev in events:
765
+ ts = ev.get("timestamp", "")[:19]
766
+ ns = ev.get("namespace", "")
767
+ eid = ev.get("event_id", "")[:8]
768
+ svc = ev.get("service_name", "")
769
+ payload_str = json.dumps(ev.get("payload", {}), separators=(",", ":"))[:120]
770
+ rows.append(
771
+ f"<tr><td>{ts}</td><td><code>{ns}</code></td>"
772
+ f"<td><code>{eid}</code></td><td>{svc}</td>"
773
+ f"<td><pre style='margin:0;font-size:11px'>{payload_str}</pre></td></tr>"
774
+ )
775
+
776
+ html = (
777
+ "<!DOCTYPE html>\n<html lang='en'>\n<head>\n"
778
+ " <meta charset='utf-8'/>\n"
779
+ f" <title>spanforge report \u2014 {src.name}</title>\n"
780
+ " <style>\n"
781
+ " body{font-family:system-ui,sans-serif;padding:1rem 2rem}\n"
782
+ " h1{font-size:1.3rem;color:#333}\n"
783
+ " table{border-collapse:collapse;width:100%;font-size:13px}\n"
784
+ " th,td{border:1px solid #ddd;padding:6px 8px;text-align:left;vertical-align:top}\n"
785
+ " th{background:#f4f4f4}\n"
786
+ " tr:nth-child(even){background:#fafafa}\n"
787
+ " </style>\n</head>\n<body>\n"
788
+ " <h1>spanforge \u2014 Trace Report</h1>\n"
789
+ f" <p>Source: <code>{src}</code> &nbsp;|&nbsp; Events: <strong>{len(events)}</strong></p>\n"
790
+ " <table>\n <thead>\n"
791
+ " <tr><th>Timestamp</th><th>Namespace</th><th>Event ID</th>"
792
+ "<th>Service</th><th>Payload</th></tr>\n"
793
+ " </thead>\n <tbody>\n"
794
+ + "".join(f" {r}\n" for r in rows)
795
+ + " </tbody>\n </table>\n</body>\n</html>"
796
+ )
797
+
798
+ out_path.write_text(html, encoding="utf-8")
799
+ print(f"[OK] Report written to {out_path} ({len(events)} events)")
800
+ return 0
801
+
802
+
803
+ def _cmd_ui(args: argparse.Namespace) -> int:
804
+ """Implement the ``ui`` sub-command — serve the interactive SPA trace viewer."""
805
+ import signal
806
+ import webbrowser
807
+
808
+ from spanforge._server import TraceViewerServer
809
+
810
+ port = args.port
811
+
812
+ if args.file:
813
+ src = Path(args.file)
814
+ if not src.exists():
815
+ print(f"[x] File not found: {src}", file=sys.stderr)
816
+ return 1
817
+ from spanforge._store import get_store
818
+ from spanforge.event import Event
819
+
820
+ store = get_store()
821
+ loaded = 0
822
+ with src.open(encoding="utf-8") as fh:
823
+ for line in fh:
824
+ line = line.strip()
825
+ if not line:
826
+ continue
827
+ try:
828
+ store.record(Event.from_dict(json.loads(line)))
829
+ loaded += 1
830
+ except Exception as exc:
831
+ _ = exc
832
+ print(f"[spanforge] Loaded {loaded} events from {str(src)!r}")
833
+
834
+ server = TraceViewerServer(port=port, host="127.0.0.1")
835
+ server.start()
836
+ url = f"http://127.0.0.1:{port}/"
837
+ print(f"[spanforge] Trace viewer running at {url}")
838
+ print("[spanforge] Press Ctrl+C to stop.")
839
+
840
+ if not args.no_browser:
841
+ webbrowser.open(url)
842
+
843
+ stop_evt = threading.Event()
844
+
845
+ def _handle_sig(*_: object) -> None:
846
+ stop_evt.set()
847
+
848
+ signal.signal(signal.SIGINT, _handle_sig)
849
+ if hasattr(signal, "SIGTERM"):
850
+ signal.signal(signal.SIGTERM, _handle_sig)
851
+
852
+ stop_evt.wait()
853
+ server.stop()
854
+ print("\n[spanforge] Stopped.")
855
+ return 0
856
+
857
+
858
+
859
+
860
+ def _cmd_scan(args: argparse.Namespace) -> int:
861
+ """Implement ``spanforge scan`` — deep PII scan on a JSONL file."""
862
+ from spanforge.redact import scan_payload
863
+
864
+ path = Path(args.file)
865
+ if not path.exists():
866
+ print(f"error: file not found: {path}", file=sys.stderr)
867
+ return 2
868
+
869
+ rows = _read_jsonl_events(path)
870
+ if not rows:
871
+ print(_NO_EVENTS_MSG)
872
+ return 0
873
+
874
+ # GA-03-D: --types filter
875
+ type_filter: set[str] | None = None
876
+ raw_types = getattr(args, "types", None)
877
+ if raw_types:
878
+ type_filter = {t.strip().lower() for t in raw_types.split(",")}
879
+
880
+ all_hits: list[dict[str, str]] = []
881
+ total_scanned = 0
882
+
883
+ for idx, row in enumerate(rows):
884
+ if isinstance(row[1], Exception):
885
+ continue
886
+ event = row[1]
887
+ payload = getattr(event, "payload", None)
888
+ if not isinstance(payload, dict):
889
+ continue
890
+ result = scan_payload(payload)
891
+ total_scanned += result.scanned
892
+ for hit in result.hits:
893
+ if type_filter and hit.pii_type.lower() not in type_filter:
894
+ continue
895
+ all_hits.append(
896
+ {
897
+ "event_index": str(idx),
898
+ "event_id": getattr(event, "event_id", "unknown"),
899
+ "pii_type": hit.pii_type,
900
+ "path": hit.path,
901
+ "match_count": str(hit.match_count),
902
+ "sensitivity": hit.sensitivity,
903
+ }
904
+ )
905
+
906
+ fmt = getattr(args, "format", "text")
907
+ if fmt == "json":
908
+ import json as _json
909
+
910
+ print(
911
+ _json.dumps(
912
+ {
913
+ "file": str(path),
914
+ "events_scanned": len(rows),
915
+ "strings_scanned": total_scanned,
916
+ "pii_hits": len(all_hits),
917
+ "hits": all_hits,
918
+ },
919
+ indent=2,
920
+ )
921
+ )
922
+ else:
923
+ print(f"Scanned {len(rows)} events ({total_scanned} string values)")
924
+ if not all_hits:
925
+ print("[✓] No PII detected.")
926
+ else:
927
+ print(f"[!] Found {len(all_hits)} PII hit(s):\n")
928
+ for h in all_hits:
929
+ print(
930
+ f" event #{h['event_index']} ({h['event_id']}) "
931
+ f"{h['pii_type']:20s} path={h['path']} "
932
+ f"matches={h['match_count']} sensitivity={h['sensitivity']}"
933
+ )
934
+
935
+ # GA-03-D: --fail-on-match returns 1 on any hit
936
+ fail_on_match = getattr(args, "fail_on_match", False)
937
+ if fail_on_match and all_hits:
938
+ return 1
939
+ return 1 if all_hits else 0
940
+
941
+
942
+ def _cmd_migrate(args: argparse.Namespace) -> int:
943
+ """Implement ``spanforge migrate`` — schema v1→v2 migration."""
944
+ from spanforge.migrate import migrate_file
945
+
946
+ path = Path(args.file)
947
+ if not path.exists():
948
+ print(f"error: file not found: {path}", file=sys.stderr)
949
+ return 2
950
+
951
+ output = getattr(args, "output", None)
952
+ target_version = getattr(args, "target_version", "2.0")
953
+ dry_run = getattr(args, "dry_run", False)
954
+
955
+ # GA-05-C: --sign reads SPANFORGE_SIGNING_KEY for chain re-signing
956
+ org_secret: str | None = None
957
+ if getattr(args, "sign", False):
958
+ org_secret = os.environ.get("SPANFORGE_SIGNING_KEY", "")
959
+ if not org_secret:
960
+ print("error: --sign requires SPANFORGE_SIGNING_KEY", file=sys.stderr)
961
+ return 2
962
+
963
+ stats = migrate_file(
964
+ path,
965
+ output=output,
966
+ org_secret=org_secret,
967
+ target_version=target_version,
968
+ dry_run=dry_run,
969
+ )
970
+
971
+ print(f"Total events: {stats.total}")
972
+ print(f"Migrated (v1→v2): {stats.migrated}")
973
+ print(f"Skipped (v2): {stats.skipped}")
974
+ print(f"Errors: {stats.errors}")
975
+ if stats.warnings:
976
+ print(f"Warnings: {len(stats.warnings)}")
977
+ for w in stats.warnings:
978
+ print(f" - {w}")
979
+ if stats.transformed_fields:
980
+ print("Transformations:")
981
+ for k, v in stats.transformed_fields.items():
982
+ print(f" {k}: {v}")
983
+ if dry_run:
984
+ print("(dry run — no files written)")
985
+ else:
986
+ print(f"Output: {stats.output_path}")
987
+ return 1 if stats.errors > 0 else 0
988
+
989
+
990
+ def _cmd_migrate_langsmith(args: argparse.Namespace) -> int:
991
+ """Implement ``spanforge migrate-langsmith`` — LangSmith export import."""
992
+ import time as _time
993
+
994
+ from spanforge import EventType
995
+ from spanforge.ulid import generate as ulid_generate
996
+
997
+ path = Path(args.file)
998
+ if not path.exists():
999
+ print(f"error: file not found: {path}", file=sys.stderr)
1000
+ return 2
1001
+
1002
+ output = args.output or str(path).rsplit(".", 1)[0] + "_spanforge.jsonl"
1003
+ source = args.source
1004
+
1005
+ # Read LangSmith export (supports JSONL and JSON array)
1006
+ raw_runs: list[dict[str, Any]] = []
1007
+ content = path.read_text(encoding="utf-8")
1008
+ first_char = content.lstrip()[:1]
1009
+ if first_char == "[":
1010
+ # JSON array format
1011
+ try:
1012
+ raw_runs = json.loads(content)
1013
+ except json.JSONDecodeError as exc:
1014
+ print(f"error: invalid JSON: {exc}", file=sys.stderr)
1015
+ return 1
1016
+ else:
1017
+ # JSONL format
1018
+ for line in content.splitlines():
1019
+ line = line.strip()
1020
+ if line:
1021
+ try:
1022
+ raw_runs.append(json.loads(line))
1023
+ except json.JSONDecodeError:
1024
+ continue
1025
+
1026
+ if not raw_runs:
1027
+ print("error: no runs found in file", file=sys.stderr)
1028
+ return 1
1029
+
1030
+ events: list[dict[str, Any]] = []
1031
+ for run in raw_runs:
1032
+ # LangSmith run schema: id, name, run_type, inputs, outputs,
1033
+ # start_time, end_time, parent_run_id, trace_id, dotted_order,
1034
+ # total_tokens, prompt_tokens, completion_tokens, status, error
1035
+ run_type = run.get("run_type", "chain")
1036
+ run_name = run.get("name", "unknown")
1037
+ run_id = run.get("id", ulid_generate())
1038
+
1039
+ # Map LangSmith run_type → SpanForge EventType (F-27)
1040
+ if run_type == "llm":
1041
+ event_type = EventType.TRACE_SPAN_COMPLETED.value
1042
+ elif run_type in {"tool", "retriever"}:
1043
+ event_type = EventType.TOOL_CALL_COMPLETED.value
1044
+ elif run_type == "chain":
1045
+ event_type = EventType.CHAIN_COMPLETED.value
1046
+ else:
1047
+ event_type = EventType.TRACE_SPAN_COMPLETED.value
1048
+
1049
+ # Build payload
1050
+ payload: dict[str, Any] = {
1051
+ "span_name": run_name,
1052
+ "run_type": run_type,
1053
+ "status": run.get("status", "ok"),
1054
+ }
1055
+
1056
+ # Token usage
1057
+ total_tok = run.get("total_tokens") or 0
1058
+ prompt_tok = run.get("prompt_tokens") or 0
1059
+ completion_tok = run.get("completion_tokens") or 0
1060
+ if total_tok or prompt_tok or completion_tok:
1061
+ payload["token_usage"] = {
1062
+ "input_tokens": prompt_tok,
1063
+ "output_tokens": completion_tok,
1064
+ "total_tokens": total_tok or (prompt_tok + completion_tok),
1065
+ }
1066
+
1067
+ # Timing
1068
+ if run.get("start_time"):
1069
+ payload["start_time"] = run["start_time"]
1070
+ if run.get("end_time"):
1071
+ payload["end_time"] = run["end_time"]
1072
+
1073
+ # Inputs/outputs (sanitised — no raw content)
1074
+ if run.get("inputs"):
1075
+ payload["input_keys"] = (
1076
+ list(run["inputs"].keys()) if isinstance(run["inputs"], dict) else ["input"]
1077
+ )
1078
+ if run.get("outputs"):
1079
+ payload["output_keys"] = (
1080
+ list(run["outputs"].keys()) if isinstance(run["outputs"], dict) else ["output"]
1081
+ )
1082
+
1083
+ # Error info
1084
+ if run.get("error"):
1085
+ payload["error"] = str(run["error"])[:500]
1086
+
1087
+ # Build event
1088
+ trace_id = run.get("trace_id", run.get("session_id", ""))
1089
+ parent_id = run.get("parent_run_id", "")
1090
+
1091
+ event = {
1092
+ "event_id": ulid_generate(),
1093
+ "event_type": event_type,
1094
+ "source": source,
1095
+ "schema_version": "2.0",
1096
+ "timestamp": run.get("start_time") or _time.time(),
1097
+ "payload": payload,
1098
+ "tags": {
1099
+ "langsmith_run_id": str(run_id),
1100
+ "langsmith_trace_id": str(trace_id) if trace_id else "",
1101
+ "langsmith_parent_id": str(parent_id) if parent_id else "",
1102
+ },
1103
+ }
1104
+ events.append(event)
1105
+
1106
+ # Write output
1107
+ out_path = Path(output)
1108
+ with out_path.open("w", encoding="utf-8") as fh:
1109
+ for evt in events:
1110
+ fh.write(json.dumps(evt, default=str) + "\n")
1111
+
1112
+ print(f"[✓] Imported {len(events)} runs from LangSmith export")
1113
+ print(f" Source: {path}")
1114
+ print(f" Output: {out_path}")
1115
+
1116
+ # Summary by run_type
1117
+ type_counts: dict[str, int] = {}
1118
+ for run in raw_runs:
1119
+ rt = run.get("run_type", "unknown")
1120
+ type_counts[rt] = type_counts.get(rt, 0) + 1
1121
+ for rt, count in sorted(type_counts.items()):
1122
+ print(f" {rt}: {count}")
1123
+
1124
+ return 0
1125
+
1126
+
1127
+
1128
+
1129
+ # ---------------------------------------------------------------------------
1130
+ # T.R.U.S.T. Framework CLI handlers
1131
+ # ---------------------------------------------------------------------------
1132
+
1133
+
1134
+ def _cmd_consent(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
1135
+ """Handle ``spanforge consent`` subcommands."""
1136
+ from spanforge.consent import check_consent, grant_consent, revoke_consent
1137
+
1138
+ action = getattr(args, "consent_command", None)
1139
+ if action == "check":
1140
+ ok = check_consent(args.subject, args.scope)
1141
+ status = "GRANTED" if ok else "NOT GRANTED"
1142
+ print(f"consent({args.subject!r}, {args.scope!r}) = {status}")
1143
+ return 0 if ok else 1
1144
+ elif action == "grant":
1145
+ grant_consent(
1146
+ subject_id=args.subject,
1147
+ scope=args.scope,
1148
+ purpose=args.purpose,
1149
+ legal_basis=args.legal_basis,
1150
+ )
1151
+ print(f"[✓] Consent granted: subject={args.subject!r} scope={args.scope!r}")
1152
+ return 0
1153
+ elif action == "revoke":
1154
+ revoke_consent(subject_id=args.subject, scope=args.scope)
1155
+ print(f"[✓] Consent revoked: subject={args.subject!r} scope={args.scope!r}")
1156
+ return 0
1157
+ else:
1158
+ parser.print_help()
1159
+ return 2
1160
+
1161
+
1162
+ def _cmd_hitl(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
1163
+ """Handle ``spanforge hitl`` subcommands."""
1164
+ from spanforge.hitl import list_pending, review_item
1165
+
1166
+ action = getattr(args, "hitl_command", None)
1167
+ if action == "pending":
1168
+ items = list_pending()
1169
+ if not items:
1170
+ print("No pending HITL items.")
1171
+ else:
1172
+ for item in items:
1173
+ print(
1174
+ f" {item.decision_id} risk={item.risk_tier} "
1175
+ f"agent={item.agent_id} reason={item.reason}"
1176
+ )
1177
+ return 0
1178
+ elif action == "review":
1179
+ result = review_item(args.decision_id, args.reviewer, args.outcome)
1180
+ if result is None:
1181
+ print(f"[!] Decision {args.decision_id!r} not found in queue.")
1182
+ return 1
1183
+ print(f"[✓] Decision {args.decision_id!r} marked as {args.outcome} by {args.reviewer}")
1184
+ return 0
1185
+ else:
1186
+ parser.print_help()
1187
+ return 2
1188
+
1189
+
1190
+ def _cmd_model(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
1191
+ """Handle ``spanforge model`` subcommands."""
1192
+ from spanforge.model_registry import (
1193
+ deprecate_model,
1194
+ list_models,
1195
+ register_model,
1196
+ retire_model,
1197
+ )
1198
+
1199
+ action = getattr(args, "model_command", None)
1200
+ if action == "list":
1201
+ models = list_models()
1202
+ if not models:
1203
+ print("No models registered.")
1204
+ else:
1205
+ for m in models:
1206
+ print(
1207
+ f" {m.model_id} name={m.name!r} version={m.version} "
1208
+ f"status={m.status} risk={m.risk_tier} owner={m.owner}"
1209
+ )
1210
+ return 0
1211
+ elif action == "register":
1212
+ try:
1213
+ entry = register_model(
1214
+ model_id=args.model_id,
1215
+ name=args.name,
1216
+ version=args.version,
1217
+ risk_tier=args.risk_tier,
1218
+ owner=args.owner,
1219
+ purpose=args.purpose,
1220
+ )
1221
+ print(f"[✓] Model registered: {entry.model_id}")
1222
+ except ValueError as exc:
1223
+ print(f"[!] {exc}")
1224
+ return 1
1225
+ else:
1226
+ return 0
1227
+ elif action == "deprecate":
1228
+ try:
1229
+ entry = deprecate_model(args.model_id, reason=args.reason)
1230
+ print(f"[✓] Model deprecated: {entry.model_id}")
1231
+ except (KeyError, ValueError) as exc:
1232
+ print(f"[!] {exc}")
1233
+ return 1
1234
+ else:
1235
+ return 0
1236
+ elif action == "retire":
1237
+ try:
1238
+ entry = retire_model(args.model_id)
1239
+ print(f"[✓] Model retired: {entry.model_id}")
1240
+ except (KeyError, ValueError) as exc:
1241
+ print(f"[!] {exc}")
1242
+ return 1
1243
+ else:
1244
+ return 0
1245
+ else:
1246
+ parser.print_help()
1247
+ return 2
1248
+
1249
+
1250
+ def _cmd_explain(args: argparse.Namespace) -> int:
1251
+ """Handle ``spanforge explain`` subcommand."""
1252
+ from spanforge.explain import generate_explanation
1253
+
1254
+ record = generate_explanation(
1255
+ trace_id=args.trace_id,
1256
+ agent_id=args.agent_id,
1257
+ decision_id=args.decision_id,
1258
+ factors=[],
1259
+ summary=args.summary,
1260
+ )
1261
+ print(record.to_text())
1262
+ return 0
1263
+
1264
+
1265
+ def _cmd_eval(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
1266
+ """Handle ``spanforge eval`` subcommands."""
1267
+ import json
1268
+
1269
+ action = getattr(args, "eval_command", None)
1270
+
1271
+ if action == "save":
1272
+ # Save examples to JSONL dataset file
1273
+ examples: list[dict[str, Any]] = []
1274
+ if getattr(args, "input", None):
1275
+ in_path = Path(args.input)
1276
+ if not in_path.exists():
1277
+ print(f"[!] File not found: {in_path}")
1278
+ return 1
1279
+ with in_path.open(encoding="utf-8") as fh:
1280
+ for line in fh:
1281
+ line = line.strip()
1282
+ if line:
1283
+ try:
1284
+ obj = json.loads(line)
1285
+ # Extract example-shaped data from events
1286
+ payload = obj.get("payload", obj)
1287
+ example: dict[str, Any] = {}
1288
+ if "output" in payload:
1289
+ example["output"] = payload["output"]
1290
+ elif "response" in payload:
1291
+ example["output"] = payload["response"]
1292
+ if "context" in payload:
1293
+ example["context"] = payload["context"]
1294
+ if "reference" in payload:
1295
+ example["reference"] = payload["reference"]
1296
+ if "input" in payload:
1297
+ example["input"] = payload["input"]
1298
+ elif "query" in payload:
1299
+ example["input"] = payload["query"]
1300
+ if "span_id" in obj:
1301
+ example["span_id"] = obj["span_id"]
1302
+ if "trace_id" in obj:
1303
+ example["trace_id"] = obj["trace_id"]
1304
+ if example:
1305
+ examples.append(example)
1306
+ except json.JSONDecodeError:
1307
+ continue
1308
+ else:
1309
+ print("[!] --input is required for 'eval save'")
1310
+ return 1
1311
+
1312
+ out_path = Path(args.output)
1313
+ with out_path.open("w", encoding="utf-8") as fh:
1314
+ for ex in examples:
1315
+ fh.write(json.dumps(ex, default=str) + "\n")
1316
+ print(f"[✓] Saved {len(examples)} examples to {out_path}")
1317
+ return 0
1318
+
1319
+ elif action == "run":
1320
+ from spanforge.eval import (
1321
+ EvalRunner,
1322
+ EvalScorer,
1323
+ FaithfulnessScorer,
1324
+ PIILeakageScorer,
1325
+ RefusalDetectionScorer,
1326
+ )
1327
+
1328
+ dataset_path = Path(args.file)
1329
+ if not dataset_path.exists():
1330
+ print(f"[!] File not found: {dataset_path}")
1331
+ return 1
1332
+
1333
+ dataset: list[dict[str, Any]] = []
1334
+ with dataset_path.open(encoding="utf-8") as fh:
1335
+ for line in fh:
1336
+ line = line.strip()
1337
+ if line:
1338
+ try:
1339
+ dataset.append(json.loads(line))
1340
+ except json.JSONDecodeError:
1341
+ continue
1342
+
1343
+ if not dataset:
1344
+ print("[!] No examples found in dataset file")
1345
+ return 1
1346
+
1347
+ # Build scorers from --scorers flag (or default to all)
1348
+ scorer_map: dict[str, type[EvalScorer]] = {
1349
+ "faithfulness": FaithfulnessScorer,
1350
+ "refusal": RefusalDetectionScorer,
1351
+ "pii_leakage": PIILeakageScorer,
1352
+ }
1353
+ requested = args.scorers.split(",") if args.scorers else list(scorer_map.keys())
1354
+ scorers: list[EvalScorer] = []
1355
+ for name in requested:
1356
+ name = name.strip()
1357
+ if name not in scorer_map:
1358
+ print(f"[!] Unknown scorer: {name} (available: {', '.join(scorer_map)})")
1359
+ return 1
1360
+ scorers.append(scorer_map[name]())
1361
+
1362
+ runner = EvalRunner(scorers=scorers, emit=False)
1363
+ report = runner.run(dataset)
1364
+ summary = report.summary()
1365
+
1366
+ fmt = getattr(args, "format", "text")
1367
+ if fmt == "json":
1368
+ result = {
1369
+ "examples": len(dataset),
1370
+ "scores": len(report.scores),
1371
+ "summary": summary,
1372
+ }
1373
+ print(json.dumps(result, indent=2, default=str))
1374
+ else:
1375
+ print(f"Dataset: {dataset_path} ({len(dataset)} examples)")
1376
+ print(f"{'Metric':<30} {'Mean':>10}")
1377
+ print("-" * 43)
1378
+ for metric, mean in sorted(summary.items()):
1379
+ print(f"{metric:<30} {mean:>10.4f}")
1380
+ print("-" * 43)
1381
+ print(f"Total scores: {len(report.scores)}")
1382
+
1383
+ return 0
1384
+
1385
+ else:
1386
+ parser.print_help()
1387
+ return 2
1388
+
1389
+
1390
+ # ---------------------------------------------------------------------------
1391
+ # secrets sub-command — SEC-040 secrets scanning
1392
+ # ---------------------------------------------------------------------------
1393
+
1394
+
1395
+ def _cmd_secrets(args: argparse.Namespace, secrets_parser: argparse.ArgumentParser) -> int:
1396
+ """Implement the ``secrets`` sub-command group."""
1397
+ action = getattr(args, "secrets_command", None)
1398
+ if action == "scan":
1399
+ return _cmd_secrets_scan(args)
1400
+ secrets_parser.print_help()
1401
+ return 2
1402
+
1403
+
1404
+ def _cmd_secrets_scan(args: argparse.Namespace) -> int:
1405
+ """Implement the ``secrets scan`` sub-command (SEC-040).
1406
+
1407
+ Reads a file (plain text, JSONL, or source code), scans every line for
1408
+ hard-coded secrets, and reports results.
1409
+
1410
+ Exit codes::
1411
+
1412
+ 0 — no secrets detected above the confidence threshold.
1413
+ 1 — secrets detected (CI gate mode).
1414
+ 2 — usage or I/O error.
1415
+ """
1416
+ from spanforge.secrets import SecretsScanner
1417
+
1418
+ file_path = Path(args.file)
1419
+ if not file_path.exists():
1420
+ print(f"error: file not found: {file_path}", file=sys.stderr)
1421
+ return 2
1422
+
1423
+ try:
1424
+ text = file_path.read_text(encoding="utf-8", errors="replace")
1425
+ except OSError as exc:
1426
+ print(f"error: could not read {file_path}: {exc}", file=sys.stderr)
1427
+ return 2
1428
+
1429
+ confidence = float(getattr(args, "confidence", 0.75))
1430
+ redact = getattr(args, "redact", False)
1431
+ fmt = getattr(args, "format", "text")
1432
+
1433
+ try:
1434
+ scanner = SecretsScanner(confidence_threshold=confidence)
1435
+ result = scanner.scan(text, confidence_threshold=confidence)
1436
+ except Exception as exc:
1437
+ print(f"error: scan failed: {exc}", file=sys.stderr)
1438
+ return 2
1439
+
1440
+ if fmt == "sarif":
1441
+ output = result.to_sarif(tool_name="spanforge-secrets")
1442
+ print(json.dumps(output, indent=2))
1443
+ return 1 if result.detected else 0
1444
+
1445
+ if fmt == "json":
1446
+ data = result.to_dict()
1447
+ if not redact:
1448
+ data.pop("redacted_text", None)
1449
+ print(json.dumps(data, indent=2))
1450
+ return 1 if result.detected else 0
1451
+
1452
+ # --- text output ---
1453
+ if not result.detected:
1454
+ print(f"[✓] No secrets detected in {file_path}")
1455
+ return 0
1456
+
1457
+ print(f"[✗] {len(result.hits)} secret(s) detected in {file_path}:")
1458
+ for hit in result.hits:
1459
+ block_marker = " [BLOCKED]" if hit.auto_blocked else ""
1460
+ print(
1461
+ f" [{hit.secret_type}] offset {hit.start}-{hit.end} "
1462
+ f"confidence={hit.confidence:.2f}{block_marker}"
1463
+ )
1464
+ if hit.vault_hint:
1465
+ print(f" Hint: {hit.vault_hint}")
1466
+
1467
+ if redact:
1468
+ print("\n--- Redacted output ---")
1469
+ print(result.redacted_text)
1470
+
1471
+ if result.auto_blocked:
1472
+ print(
1473
+ "\nAUTO-BLOCKED: Zero-tolerance or high-confidence secrets detected. "
1474
+ "Remove secrets and rotate credentials before committing.",
1475
+ file=sys.stderr,
1476
+ )
1477
+
1478
+ return 1
1479
+
1480
+
1481
+
1482
+
1483
+
1484
+ def main(argv: list[str] | None = None) -> NoReturn:
1485
+ """Entry point for the ``spanforge`` CLI tool."""
1486
+ from spanforge import CONFORMANCE_PROFILE, __version__
1487
+
1488
+ parser = argparse.ArgumentParser(
1489
+ prog="spanforge",
1490
+ description="spanforge command-line utilities",
1491
+ )
1492
+ parser.add_argument(
1493
+ "-V",
1494
+ "--version",
1495
+ action="version",
1496
+ version=f"spanforge {__version__} [{CONFORMANCE_PROFILE}]",
1497
+ )
1498
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
1499
+
1500
+ # check sub-command (health check)
1501
+ sub.add_parser(
1502
+ "check",
1503
+ help="End-to-end health check: validates config, emits a test event, confirms export pipeline",
1504
+ )
1505
+
1506
+ # check-compat sub-command
1507
+ compat_parser = sub.add_parser(
1508
+ "check-compat",
1509
+ help="Check a JSON file of events against the v1.0 compatibility checklist",
1510
+ )
1511
+ compat_parser.add_argument(
1512
+ "file",
1513
+ metavar="EVENTS_JSON",
1514
+ help="Path to a JSON file containing a list of serialised events",
1515
+ )
1516
+
1517
+ # list-deprecated sub-command
1518
+ sub.add_parser(
1519
+ "list-deprecated",
1520
+ help="Print all deprecated event types from the global deprecation registry",
1521
+ )
1522
+
1523
+ # migration-roadmap sub-command
1524
+ roadmap_parser = sub.add_parser(
1525
+ "migration-roadmap",
1526
+ help="Print the planned v1 → v2 migration roadmap",
1527
+ )
1528
+ roadmap_parser.add_argument(
1529
+ "--json",
1530
+ action="store_true",
1531
+ default=False,
1532
+ help="Emit JSON output for machine consumption",
1533
+ )
1534
+
1535
+ # check-consumers sub-command
1536
+ sub.add_parser(
1537
+ "check-consumers",
1538
+ help="Assert all registered consumers are compatible with the installed schema",
1539
+ )
1540
+
1541
+ # validate sub-command
1542
+ validate_parser = sub.add_parser(
1543
+ "validate",
1544
+ help="Validate every event in a JSONL file against the published schema",
1545
+ )
1546
+ validate_parser.add_argument(
1547
+ "file",
1548
+ metavar="EVENTS_JSONL",
1549
+ help="Path to a JSONL file (one event JSON per line)",
1550
+ )
1551
+
1552
+ audit_group_parser = add_audit_subcommands(sub)
1553
+
1554
+ # scan sub-command — GA-03 deep PII scanning
1555
+ scan_parser = sub.add_parser(
1556
+ "scan",
1557
+ help="Scan a JSONL file for PII using regex detectors",
1558
+ )
1559
+ scan_parser.add_argument(
1560
+ "file",
1561
+ metavar="FILE",
1562
+ help="Path to the JSONL file to scan",
1563
+ )
1564
+ scan_parser.add_argument(
1565
+ "--format",
1566
+ choices=["text", "json"],
1567
+ default="text",
1568
+ help="Output format (default: text)",
1569
+ )
1570
+ scan_parser.add_argument(
1571
+ "--types",
1572
+ default=None,
1573
+ help="Comma-separated PII types to filter (e.g. 'ssn,credit_card')",
1574
+ )
1575
+ scan_parser.add_argument(
1576
+ "--fail-on-match",
1577
+ dest="fail_on_match",
1578
+ action="store_true",
1579
+ default=False,
1580
+ help="Exit with code 1 if any PII is detected (CI gate mode)",
1581
+ )
1582
+
1583
+ # secrets sub-command group — SEC-040 secrets scanning
1584
+ secrets_parser = sub.add_parser(
1585
+ "secrets",
1586
+ help="Secrets scanning utilities (scan files for hard-coded credentials)",
1587
+ )
1588
+ secrets_sub = secrets_parser.add_subparsers(dest="secrets_command", metavar="<action>")
1589
+
1590
+ secrets_scan_parser = secrets_sub.add_parser(
1591
+ "scan",
1592
+ help="Scan a file for hard-coded secrets (API keys, tokens, private keys, etc.)",
1593
+ )
1594
+ secrets_scan_parser.add_argument(
1595
+ "file",
1596
+ metavar="FILE",
1597
+ help="Path to the file to scan (plain text, source code, or JSONL)",
1598
+ )
1599
+ secrets_scan_parser.add_argument(
1600
+ "--format",
1601
+ choices=["text", "json", "sarif"],
1602
+ default="text",
1603
+ help="Output format: text (human-readable), json, or sarif 2.1.0 (default: text)",
1604
+ )
1605
+ secrets_scan_parser.add_argument(
1606
+ "--redact",
1607
+ action="store_true",
1608
+ default=False,
1609
+ help="Include redacted text in text/json output",
1610
+ )
1611
+ secrets_scan_parser.add_argument(
1612
+ "--confidence",
1613
+ type=float,
1614
+ default=0.75,
1615
+ metavar="FLOAT",
1616
+ help="Minimum confidence threshold [0.0-1.0] to report a hit (default: 0.75)",
1617
+ )
1618
+
1619
+ # migrate sub-command — GA-05 schema migration
1620
+ migrate_parser = sub.add_parser(
1621
+ "migrate",
1622
+ help="Migrate a JSONL file from schema v1 to v2",
1623
+ )
1624
+ migrate_parser.add_argument(
1625
+ "file",
1626
+ metavar="FILE",
1627
+ help="Path to the JSONL file to migrate",
1628
+ )
1629
+ migrate_parser.add_argument(
1630
+ "--output",
1631
+ default=None,
1632
+ metavar="FILE",
1633
+ help="Output file (default: <input>_v2.jsonl)",
1634
+ )
1635
+ migrate_parser.add_argument(
1636
+ "--target-version",
1637
+ dest="target_version",
1638
+ default="2.0",
1639
+ help="Target schema version (default: 2.0)",
1640
+ )
1641
+ migrate_parser.add_argument(
1642
+ "--sign",
1643
+ action="store_true",
1644
+ default=False,
1645
+ help="Re-sign the migrated chain (reads SPANFORGE_SIGNING_KEY)",
1646
+ )
1647
+ migrate_parser.add_argument(
1648
+ "--dry-run",
1649
+ dest="dry_run",
1650
+ action="store_true",
1651
+ default=False,
1652
+ help="Preview migration without writing output",
1653
+ )
1654
+
1655
+ # migrate-langsmith sub-command — import LangSmith exports
1656
+ ls_migrate_parser = sub.add_parser(
1657
+ "migrate-langsmith",
1658
+ help="Import a LangSmith export file and convert to SpanForge events",
1659
+ )
1660
+ ls_migrate_parser.add_argument(
1661
+ "file",
1662
+ metavar="FILE",
1663
+ help="Path to the LangSmith export file (JSONL or JSON)",
1664
+ )
1665
+ ls_migrate_parser.add_argument(
1666
+ "--output",
1667
+ default=None,
1668
+ metavar="FILE",
1669
+ help="Output JSONL file (default: <input>_spanforge.jsonl)",
1670
+ )
1671
+ ls_migrate_parser.add_argument(
1672
+ "--source",
1673
+ default="langsmith-import",
1674
+ help="Source identifier for generated events (default: langsmith-import)",
1675
+ )
1676
+
1677
+ # inspect sub-command
1678
+ inspect_parser = sub.add_parser(
1679
+ "inspect",
1680
+ help="Pretty-print a single event by event_id from a JSONL file",
1681
+ )
1682
+ inspect_parser.add_argument(
1683
+ "event_id",
1684
+ metavar="EVENT_ID",
1685
+ help="The event_id to look up",
1686
+ )
1687
+ inspect_parser.add_argument(
1688
+ "file",
1689
+ metavar="EVENTS_JSONL",
1690
+ help="Path to a JSONL file to search",
1691
+ )
1692
+
1693
+ # stats sub-command
1694
+ stats_parser = sub.add_parser(
1695
+ "stats",
1696
+ help="Print a summary of events in a JSONL file (counts, tokens, cost, timestamps)",
1697
+ )
1698
+ stats_parser.add_argument(
1699
+ "file",
1700
+ metavar="EVENTS_JSONL",
1701
+ help="Path to a JSONL file",
1702
+ )
1703
+
1704
+ compliance_parser = add_compliance_subcommands(sub)
1705
+
1706
+ cost_parser = add_cost_subcommands(sub)
1707
+
1708
+ # dev command group
1709
+ dev_parser = sub.add_parser(
1710
+ "dev",
1711
+ help="Local development environment lifecycle",
1712
+ )
1713
+ dev_sub = dev_parser.add_subparsers(dest="dev_command", metavar="<action>")
1714
+
1715
+ dev_start_p = dev_sub.add_parser("start", help="Start the local dev environment")
1716
+ dev_start_p.add_argument(
1717
+ "service",
1718
+ nargs="?",
1719
+ default="spanforge-dev",
1720
+ help="Service name (default: spanforge-dev)",
1721
+ )
1722
+ dev_sub.add_parser("stop", help="Flush buffer and stop the local dev environment")
1723
+ dev_sub.add_parser("reset", help="Reset all in-memory dev state")
1724
+ dev_sub.add_parser("logs", help="Print accumulated dev log entries")
1725
+ dev_sub.add_parser("status", help="Print the current dev environment status as JSON")
1726
+
1727
+ # module command group
1728
+ module_parser = sub.add_parser(
1729
+ "module",
1730
+ help="SpanForge plugin module scaffolding",
1731
+ )
1732
+ module_sub = module_parser.add_subparsers(dest="module_command", metavar="<action>")
1733
+
1734
+ create_parser = module_sub.add_parser(
1735
+ "create",
1736
+ help="Scaffold a new SpanForge plugin module directory",
1737
+ )
1738
+ create_parser.add_argument(
1739
+ "name", metavar="MODULE_NAME", help="Python-package-safe module name"
1740
+ )
1741
+ create_parser.add_argument(
1742
+ "--trust-level",
1743
+ dest="trust_level",
1744
+ default="UNTRUSTED",
1745
+ metavar="LEVEL",
1746
+ help="Trust level: UNTRUSTED, COMMUNITY, VERIFIED, OFFICIAL (default: UNTRUSTED)",
1747
+ )
1748
+ create_parser.add_argument("--author", default="unknown", help="Author identifier")
1749
+ create_parser.add_argument(
1750
+ "--output-dir",
1751
+ dest="output_dir",
1752
+ default=".",
1753
+ metavar="DIR",
1754
+ help="Parent directory for the scaffolded module (default: .)",
1755
+ )
1756
+
1757
+ # serve subcommand — local trace viewer
1758
+ serve_parser = sub.add_parser(
1759
+ "serve",
1760
+ help="Start a local HTTP trace viewer at /traces (default port 8888)",
1761
+ )
1762
+ serve_parser.add_argument(
1763
+ "--port",
1764
+ type=int,
1765
+ default=8888,
1766
+ help="HTTP port to bind (default: 8888)",
1767
+ )
1768
+ serve_parser.add_argument(
1769
+ "--host",
1770
+ default="127.0.0.1",
1771
+ help="Interface to bind (default: 127.0.0.1)",
1772
+ )
1773
+ serve_parser.add_argument(
1774
+ "--file",
1775
+ dest="file",
1776
+ default=None,
1777
+ metavar="FILE",
1778
+ help="Optional JSONL file to pre-load into the trace store before serving",
1779
+ )
1780
+
1781
+ # init sub-command
1782
+ init_parser = sub.add_parser(
1783
+ "init",
1784
+ help="Scaffold a spanforge.toml config file in the current directory",
1785
+ )
1786
+ init_parser.add_argument(
1787
+ "--service-name",
1788
+ dest="service_name",
1789
+ default=None,
1790
+ help="Service name to embed in spanforge.toml (default: current directory name)",
1791
+ )
1792
+ init_parser.add_argument(
1793
+ "--output-dir",
1794
+ dest="output_dir",
1795
+ default=".",
1796
+ metavar="DIR",
1797
+ help="Directory to write files into (default: .)",
1798
+ )
1799
+ init_parser.add_argument(
1800
+ "--force",
1801
+ action="store_true",
1802
+ default=False,
1803
+ help="Overwrite existing spanforge.toml without prompting",
1804
+ )
1805
+
1806
+ # quickstart sub-command
1807
+ sub.add_parser(
1808
+ "quickstart",
1809
+ help="Interactive setup wizard: configure exporter, service name, and signing",
1810
+ )
1811
+
1812
+ # report sub-command
1813
+ report_parser = sub.add_parser(
1814
+ "report",
1815
+ help="Generate a static HTML trace report from a JSONL events file",
1816
+ )
1817
+ report_parser.add_argument(
1818
+ "file",
1819
+ metavar="EVENTS_JSONL",
1820
+ help="Path to the JSONL events file",
1821
+ )
1822
+ report_parser.add_argument(
1823
+ "--output",
1824
+ default="spanforge-report.html",
1825
+ metavar="HTML_FILE",
1826
+ help="Output HTML file path (default: spanforge-report.html)",
1827
+ )
1828
+
1829
+ # ui sub-command
1830
+ ui_parser = sub.add_parser(
1831
+ "ui",
1832
+ help="Open a local HTML trace viewer in your browser",
1833
+ )
1834
+ ui_parser.add_argument(
1835
+ "--file",
1836
+ dest="file",
1837
+ default=None,
1838
+ metavar="EVENTS_JSONL",
1839
+ help="JSONL file to render as a trace report",
1840
+ )
1841
+ ui_parser.add_argument(
1842
+ "--port",
1843
+ type=int,
1844
+ default=8889,
1845
+ help="HTTP port to bind (default: 8889)",
1846
+ )
1847
+ ui_parser.add_argument(
1848
+ "--no-browser",
1849
+ dest="no_browser",
1850
+ action="store_true",
1851
+ default=False,
1852
+ help="Do not automatically open the browser",
1853
+ )
1854
+
1855
+ # ---------------------------------------------------------------------------
1856
+ # T.R.U.S.T. Framework CLI commands
1857
+ # ---------------------------------------------------------------------------
1858
+
1859
+ # consent command group
1860
+ consent_parser = sub.add_parser(
1861
+ "consent",
1862
+ help="Consent boundary management",
1863
+ )
1864
+ consent_sub = consent_parser.add_subparsers(dest="consent_command", metavar="<action>")
1865
+
1866
+ consent_check_parser = consent_sub.add_parser(
1867
+ "check",
1868
+ help="Check if consent is granted for a given subject and scope",
1869
+ )
1870
+ consent_check_parser.add_argument("--subject", required=True, help="Subject ID")
1871
+ consent_check_parser.add_argument("--scope", required=True, help="Consent scope")
1872
+
1873
+ consent_grant_parser = consent_sub.add_parser(
1874
+ "grant",
1875
+ help="Grant consent for a subject/scope",
1876
+ )
1877
+ consent_grant_parser.add_argument("--subject", required=True, help="Subject ID")
1878
+ consent_grant_parser.add_argument("--scope", required=True, help="Consent scope")
1879
+ consent_grant_parser.add_argument(
1880
+ "--purpose", default="cli-grant", help="Purpose (default: cli-grant)"
1881
+ )
1882
+ consent_grant_parser.add_argument(
1883
+ "--legal-basis", dest="legal_basis", default="consent", help="Legal basis"
1884
+ )
1885
+
1886
+ consent_revoke_parser = consent_sub.add_parser(
1887
+ "revoke",
1888
+ help="Revoke consent for a subject/scope",
1889
+ )
1890
+ consent_revoke_parser.add_argument("--subject", required=True, help="Subject ID")
1891
+ consent_revoke_parser.add_argument("--scope", required=True, help="Consent scope")
1892
+
1893
+ # hitl command group
1894
+ hitl_parser = sub.add_parser(
1895
+ "hitl",
1896
+ help="Human-in-the-loop review queue",
1897
+ )
1898
+ hitl_sub = hitl_parser.add_subparsers(dest="hitl_command", metavar="<action>")
1899
+
1900
+ hitl_sub.add_parser(
1901
+ "pending",
1902
+ help="List all pending (queued) HITL items",
1903
+ )
1904
+
1905
+ hitl_review_parser = hitl_sub.add_parser(
1906
+ "review",
1907
+ help="Record a review decision for a pending item",
1908
+ )
1909
+ hitl_review_parser.add_argument("--id", dest="decision_id", required=True, help="Decision ID")
1910
+ hitl_review_parser.add_argument("--reviewer", required=True, help="Reviewer name")
1911
+ hitl_review_parser.add_argument(
1912
+ "--outcome",
1913
+ required=True,
1914
+ choices=["approved", "rejected"],
1915
+ help="Review outcome",
1916
+ )
1917
+
1918
+ # model command group
1919
+ model_parser = sub.add_parser(
1920
+ "model",
1921
+ help="Model registry management",
1922
+ )
1923
+ model_sub = model_parser.add_subparsers(dest="model_command", metavar="<action>")
1924
+
1925
+ model_sub.add_parser("list", help="List all registered models")
1926
+
1927
+ model_reg_parser = model_sub.add_parser(
1928
+ "register",
1929
+ help="Register a new model",
1930
+ )
1931
+ model_reg_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
1932
+ model_reg_parser.add_argument("--name", required=True, help="Model name")
1933
+ model_reg_parser.add_argument("--version", required=True, help="Model version")
1934
+ model_reg_parser.add_argument(
1935
+ "--risk-tier",
1936
+ dest="risk_tier",
1937
+ required=True,
1938
+ choices=["low", "medium", "high", "critical"],
1939
+ help="Risk tier",
1940
+ )
1941
+ model_reg_parser.add_argument("--owner", required=True, help="Owner")
1942
+ model_reg_parser.add_argument("--purpose", required=True, help="Purpose")
1943
+
1944
+ model_dep_parser = model_sub.add_parser(
1945
+ "deprecate",
1946
+ help="Deprecate a model",
1947
+ )
1948
+ model_dep_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
1949
+ model_dep_parser.add_argument("--reason", default="", help="Deprecation reason")
1950
+
1951
+ model_ret_parser = model_sub.add_parser(
1952
+ "retire",
1953
+ help="Retire a model",
1954
+ )
1955
+ model_ret_parser.add_argument("--model-id", dest="model_id", required=True, help="Model ID")
1956
+
1957
+ # explain command
1958
+ explain_parser = sub.add_parser(
1959
+ "explain",
1960
+ help="Generate an explainability record",
1961
+ )
1962
+ explain_parser.add_argument("--trace-id", dest="trace_id", required=True, help="Trace ID")
1963
+ explain_parser.add_argument("--agent-id", dest="agent_id", required=True, help="Agent ID")
1964
+ explain_parser.add_argument(
1965
+ "--decision-id", dest="decision_id", required=True, help="Decision ID"
1966
+ )
1967
+ explain_parser.add_argument("--summary", required=True, help="Human-readable summary")
1968
+
1969
+ # eval command group
1970
+ eval_parser = sub.add_parser(
1971
+ "eval",
1972
+ help="Evaluation dataset management and scorer execution",
1973
+ )
1974
+ eval_sub = eval_parser.add_subparsers(dest="eval_command", metavar="<action>")
1975
+
1976
+ eval_save_parser = eval_sub.add_parser(
1977
+ "save",
1978
+ help="Extract evaluation examples from a JSONL events file",
1979
+ )
1980
+ eval_save_parser.add_argument(
1981
+ "--input",
1982
+ required=True,
1983
+ metavar="JSONL",
1984
+ help="Path to a JSONL events file to extract examples from",
1985
+ )
1986
+ eval_save_parser.add_argument(
1987
+ "--output",
1988
+ default="eval_dataset.jsonl",
1989
+ metavar="FILE",
1990
+ help="Output JSONL file for evaluation examples (default: eval_dataset.jsonl)",
1991
+ )
1992
+
1993
+ eval_run_parser = eval_sub.add_parser(
1994
+ "run",
1995
+ help="Run evaluation scorers over a JSONL dataset",
1996
+ )
1997
+ eval_run_parser.add_argument(
1998
+ "--file",
1999
+ required=True,
2000
+ metavar="JSONL",
2001
+ help="Path to a JSONL evaluation dataset file",
2002
+ )
2003
+ eval_run_parser.add_argument(
2004
+ "--scorers",
2005
+ default=None,
2006
+ help="Comma-separated scorer names (default: all). Available: faithfulness, refusal, pii_leakage",
2007
+ )
2008
+ eval_run_parser.add_argument(
2009
+ "--format",
2010
+ choices=["text", "json"],
2011
+ default="text",
2012
+ help="Output format (default: text)",
2013
+ )
2014
+
2015
+ config_parser, trust_parser, gate_parser, operator_parser = add_ops_subcommands(sub)
2016
+
2017
+ enterprise_parser, security_parser = add_phase11_subcommands(sub)
2018
+
2019
+
2020
+ args = parser.parse_args(argv)
2021
+
2022
+ if args.command == "check":
2023
+ sys.exit(_cmd_check(args))
2024
+ elif args.command == "check-compat":
2025
+ sys.exit(_cmd_check_compat(args))
2026
+ elif args.command == "list-deprecated":
2027
+ sys.exit(_cmd_list_deprecated(args))
2028
+ elif args.command == "migration-roadmap":
2029
+ sys.exit(_cmd_migration_roadmap(args))
2030
+ elif args.command == "check-consumers":
2031
+ sys.exit(_cmd_check_consumers(args))
2032
+ elif args.command == "validate":
2033
+ sys.exit(_cmd_validate(args))
2034
+ elif args.command in {"audit-chain", "audit"}:
2035
+ sys.exit(
2036
+ dispatch_audit_command(
2037
+ args,
2038
+ audit_group_parser,
2039
+ _read_jsonl_events,
2040
+ _NO_EVENTS_MSG,
2041
+ )
2042
+ )
2043
+ elif args.command == "inspect":
2044
+ sys.exit(_cmd_inspect(args))
2045
+ elif args.command == "scan":
2046
+ sys.exit(_cmd_scan(args))
2047
+ elif args.command == "secrets":
2048
+ sys.exit(_cmd_secrets(args, secrets_parser))
2049
+ elif args.command == "migrate":
2050
+ sys.exit(_cmd_migrate(args))
2051
+ elif args.command == "migrate-langsmith":
2052
+ sys.exit(_cmd_migrate_langsmith(args))
2053
+ elif args.command == "stats":
2054
+ sys.exit(_cmd_stats(args))
2055
+ elif args.command == "compliance":
2056
+ sys.exit(dispatch_compliance_command(args, compliance_parser))
2057
+ elif args.command == "cost":
2058
+ sys.exit(dispatch_cost_command(args, cost_parser))
2059
+ elif args.command in {"config", "trust", "gate", "operator", "doctor"}:
2060
+ sys.exit(dispatch_ops_command(args, config_parser, trust_parser, gate_parser, operator_parser))
2061
+ elif args.command == "dev":
2062
+ sys.exit(_cmd_dev(args))
2063
+ elif args.command == "module":
2064
+ action = getattr(args, "module_command", None)
2065
+ if action == "create":
2066
+ sys.exit(_cmd_module_create(args))
2067
+ else:
2068
+ module_parser.print_help()
2069
+ sys.exit(2)
2070
+ elif args.command == "serve":
2071
+ sys.exit(_cmd_serve(args))
2072
+ elif args.command == "init":
2073
+ sys.exit(_cmd_init(args))
2074
+ elif args.command == "quickstart":
2075
+ sys.exit(_cmd_quickstart(args))
2076
+ elif args.command == "report":
2077
+ sys.exit(_cmd_report(args))
2078
+ elif args.command == "ui":
2079
+ sys.exit(_cmd_ui(args))
2080
+ elif args.command == "consent":
2081
+ sys.exit(_cmd_consent(args, consent_parser))
2082
+ elif args.command == "hitl":
2083
+ sys.exit(_cmd_hitl(args, hitl_parser))
2084
+ elif args.command == "model":
2085
+ sys.exit(_cmd_model(args, model_parser))
2086
+ elif args.command == "explain":
2087
+ sys.exit(_cmd_explain(args))
2088
+ elif args.command == "eval":
2089
+ sys.exit(_cmd_eval(args, eval_parser))
2090
+ elif args.command in {"enterprise", "security"}:
2091
+ sys.exit(dispatch_phase11_command(args, enterprise_parser, security_parser))
2092
+ else:
2093
+ parser.print_help()
2094
+ sys.exit(2)