pbom 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.
pbom/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Public API surface for the PBOM package."""
2
+
3
+ from .canonical import canonicalize_messages
4
+ from .exceptions import UnsupportedMessageShapeError
5
+ from pbom.emitter import PBOMEmitter
6
+ from pbom.exceptions import (
7
+ ChainCorruptedError,
8
+ InvalidCommitmentError,
9
+ PBOMError,
10
+ SchemaValidationError,
11
+ )
12
+ from pbom.schema import PBOMRecord
13
+ from pbom.validator import ValidationResult, validate_chain, validate_commitment
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "ChainCorruptedError",
19
+ "InvalidCommitmentError",
20
+ "PBOMEmitter",
21
+ "PBOMError",
22
+ "PBOMRecord",
23
+ "UnsupportedMessageShapeError",
24
+ "SchemaValidationError",
25
+ "ValidationResult",
26
+ "__version__",
27
+ "canonicalize_messages",
28
+ "validate_chain",
29
+ "validate_commitment",
30
+ ]
pbom/canonical.py ADDED
@@ -0,0 +1,157 @@
1
+ """Canonical message-shape bridge for PBOM emitter APIs.
2
+
3
+ This module provides one blessed helper for converting OpenAI-style message
4
+ lists into the two-field ``(system_prompt, user_prompt)`` tuple that
5
+ ``PBOMEmitter.commit()`` and ``PBOMEmitter.record()`` require.
6
+
7
+ It exists because adopters (including cold agents instrumenting existing call
8
+ sites) independently invented their own message-bridging functions, which led
9
+ to inconsistent prompt canonicalization and non-comparable hashes across
10
+ implementations. Centralizing the conversion here ensures one canonical bridge
11
+ in the OSS package.
12
+
13
+ The v1.0.0 implementation is intentionally narrow and supports only a small
14
+ set of input shapes. Multi-turn conversations, tool calls, and non-string
15
+ content are explicitly deferred to a future version and will evolve based on
16
+ adopter feedback.
17
+
18
+ Versioning note: PBOM OSS follows semantic versioning. Supported input shapes
19
+ for this helper may expand in v1.x minor releases, but the output semantics
20
+ (the meaning of the returned ``(system_prompt, user_prompt)`` tuple) remain
21
+ stable within v1.x.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+
28
+ from .exceptions import UnsupportedMessageShapeError
29
+
30
+ logger = logging.getLogger("pbom")
31
+
32
+
33
+ def canonicalize_messages(messages: list[dict]) -> tuple[str, str]:
34
+ """Convert an OpenAI-style message list to (system_prompt, user_prompt).
35
+
36
+ Accepted shapes (v1.0.0):
37
+ [{"role": "system", "content": "<str>"},
38
+ {"role": "user", "content": "<str>"}]
39
+ -> ("<system content>", "<user content>")
40
+
41
+ [{"role": "user", "content": "<str>"}]
42
+ -> ("", "<user content>")
43
+
44
+ Rejected shapes (raise UnsupportedMessageShapeError):
45
+ - Any list with more than one message per role
46
+ - Any list containing assistant, tool, or function messages
47
+ - Any message whose content is not a string (e.g., list of
48
+ content parts, images, tool calls)
49
+ - Any message with unknown keys beyond "role" and "content"
50
+ - Empty list
51
+ - Messages in the wrong order (user before system)
52
+
53
+ Multi-turn support, tool calls, and content-parts are deferred to
54
+ a future version. See the module docstring for context.
55
+
56
+ Args:
57
+ messages: OpenAI-style message list.
58
+
59
+ Returns:
60
+ (system_prompt, user_prompt) tuple of strings. system_prompt
61
+ may be the empty string if no system message was provided.
62
+
63
+ Raises:
64
+ UnsupportedMessageShapeError: if the input does not match one
65
+ of the accepted shapes above. The error message names the
66
+ specific reason for rejection.
67
+ TypeError: if messages is not a list, or a message is not a
68
+ dict.
69
+ """
70
+
71
+ def _unsupported(
72
+ specific_reason: str,
73
+ *,
74
+ shape_detail: dict[str, object] | None = None,
75
+ ) -> UnsupportedMessageShapeError:
76
+ return UnsupportedMessageShapeError(
77
+ (
78
+ "canonicalize_messages: "
79
+ f"{specific_reason}. "
80
+ "This shape is not supported in pbom v1.0.0. "
81
+ "For multi-turn conversations, tool calls, or non-string content, "
82
+ "concatenate the relevant content into a single string and pass "
83
+ "it directly to PBOMEmitter.commit() or .record(). "
84
+ "See docs/canonical.md."
85
+ ),
86
+ shape_detail=shape_detail,
87
+ )
88
+
89
+ # Top-level type and cardinality checks.
90
+ if not isinstance(messages, list):
91
+ raise TypeError(
92
+ f"canonicalize_messages: messages must be a list, got {type(messages).__name__}"
93
+ )
94
+ if len(messages) == 0:
95
+ raise _unsupported("empty message list")
96
+ if len(messages) > 2:
97
+ raise _unsupported(
98
+ "message list has more than two items",
99
+ shape_detail={"message_count": len(messages)},
100
+ )
101
+
102
+ # Per-message structural validation.
103
+ validated: list[dict[str, str]] = []
104
+ for idx, msg in enumerate(messages):
105
+ if not isinstance(msg, dict):
106
+ raise TypeError(
107
+ "canonicalize_messages: each message must be a dict, "
108
+ f"got {type(msg).__name__} at index {idx}"
109
+ )
110
+
111
+ unknown_keys = set(msg.keys()) - {"role", "content"}
112
+ if unknown_keys:
113
+ raise _unsupported(
114
+ f"message at index {idx} contains unknown keys {sorted(unknown_keys)}",
115
+ shape_detail={"unknown_keys": sorted(unknown_keys)},
116
+ )
117
+
118
+ if "role" not in msg:
119
+ raise _unsupported(f"message at index {idx} is missing required key 'role'")
120
+
121
+ if "content" not in msg:
122
+ raise _unsupported(
123
+ f"message at index {idx} is missing required key 'content'"
124
+ )
125
+
126
+ role = msg["role"]
127
+ content = msg["content"]
128
+ if role not in {"system", "user"}:
129
+ raise _unsupported(
130
+ f"message at index {idx} has unsupported role {role!r}",
131
+ shape_detail={"role": role},
132
+ )
133
+ if not isinstance(content, str):
134
+ raise _unsupported(
135
+ f"message at index {idx} has non-string content",
136
+ shape_detail={"content_type": type(content).__name__},
137
+ )
138
+
139
+ validated.append({"role": role, "content": content})
140
+
141
+ # Shape-specific acceptance/rejection.
142
+ if len(validated) == 1:
143
+ only = validated[0]
144
+ if only["role"] != "user":
145
+ raise _unsupported("single-message shape must be a user message")
146
+ return "", only["content"]
147
+
148
+ first, second = validated
149
+ if first["role"] == "system" and second["role"] == "user":
150
+ return first["content"], second["content"]
151
+
152
+ # Any other two-message combination is rejected:
153
+ # [user, system], [system, system], [user, user].
154
+ raise _unsupported(
155
+ "two-message shape must be exactly [system, user] in that order",
156
+ shape_detail={"roles": [first["role"], second["role"]]},
157
+ )
pbom/chain.py ADDED
@@ -0,0 +1,188 @@
1
+ """Merkle-style chain state for PBOM records.
2
+
3
+ The chain is per-directory, not per-application. Multiple ``application_id``
4
+ values writing to the same ``.pbom/`` directory share one chain.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import threading
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from .exceptions import ChainCorruptedError
16
+ from .hashing import compute_sha256
17
+
18
+ logger = logging.getLogger("pbom")
19
+
20
+
21
+ class ChainState:
22
+ """Thread-safe in-memory chain state with strict on-disk resume checks."""
23
+
24
+ def __init__(self) -> None:
25
+ """Initialize empty chain state guarded by an internal lock."""
26
+ self._lock = threading.Lock()
27
+ self._previous_entry_hash: Optional[str] = None
28
+ self._chain_sequence: int = 0
29
+
30
+ def next_chain_position(self) -> tuple[int, Optional[str]]:
31
+ """Return next sequence number and current previous-entry hash.
32
+
33
+ All reads and writes of in-memory chain state happen under the lock.
34
+ """
35
+ with self._lock:
36
+ self._chain_sequence += 1
37
+ return self._chain_sequence, self._previous_entry_hash
38
+
39
+ def update_chain(self, canonical_json: str) -> str:
40
+ """Update chain with canonical JSON and return its SHA-256 hash."""
41
+ new_hash = compute_sha256(canonical_json)
42
+ with self._lock:
43
+ self._previous_entry_hash = new_hash
44
+ return new_hash
45
+
46
+ def resume_from(self, directory: Path) -> None:
47
+ """Resume chain state from ``*.pbom.json`` files with strict validation.
48
+
49
+ Validation performed:
50
+ - Records must parse as JSON and include ``identity.chain_sequence_number``
51
+ - Sequence numbers must be unique and contiguous from 1..N
52
+ - For each consecutive pair, hash(previous_record_canonical_json) must
53
+ equal ``next.identity.previous_entry_hash``
54
+ """
55
+ record_files = sorted(directory.glob("*.pbom.json"))
56
+ if not record_files:
57
+ self.reset()
58
+ return
59
+
60
+ parsed_records: list[tuple[int, str, str, str | None]] = []
61
+ # tuple: (sequence, entry_id, canonical_json, previous_entry_hash)
62
+
63
+ for record_path in record_files:
64
+ try:
65
+ raw_text = record_path.read_text(encoding="utf-8")
66
+ record_data = json.loads(raw_text)
67
+ except (OSError, json.JSONDecodeError) as exc:
68
+ raise ChainCorruptedError(
69
+ "Failed to parse PBOM record during chain resume.",
70
+ entry_id=record_path.name,
71
+ details=str(exc),
72
+ ) from exc
73
+
74
+ identity = record_data.get("identity")
75
+ if not isinstance(identity, dict):
76
+ raise ChainCorruptedError(
77
+ "Record missing identity object during chain resume.",
78
+ entry_id=record_path.name,
79
+ details="identity must be an object",
80
+ )
81
+
82
+ sequence = identity.get("chain_sequence_number")
83
+ if not isinstance(sequence, int):
84
+ raise ChainCorruptedError(
85
+ "Record has invalid chain sequence number.",
86
+ entry_id=str(identity.get("entry_id") or record_path.name),
87
+ details="identity.chain_sequence_number must be an integer",
88
+ )
89
+
90
+ entry_id = str(identity.get("entry_id") or record_path.name)
91
+ previous_entry_hash = identity.get("previous_entry_hash")
92
+ if previous_entry_hash is not None and not isinstance(
93
+ previous_entry_hash, str
94
+ ):
95
+ raise ChainCorruptedError(
96
+ "Record has invalid previous_entry_hash type.",
97
+ entry_id=entry_id,
98
+ details="identity.previous_entry_hash must be null or string",
99
+ )
100
+
101
+ canonical_json = json.dumps(
102
+ record_data, sort_keys=True, separators=(",", ":")
103
+ )
104
+ parsed_records.append(
105
+ (sequence, entry_id, canonical_json, previous_entry_hash)
106
+ )
107
+
108
+ parsed_records.sort(key=lambda item: item[0])
109
+
110
+ seen_sequences: set[int] = set()
111
+ for sequence, entry_id, _, _ in parsed_records:
112
+ if sequence in seen_sequences:
113
+ raise ChainCorruptedError(
114
+ "Duplicate chain sequence number detected.",
115
+ entry_id=entry_id,
116
+ details=f"duplicate chain_sequence_number={sequence}",
117
+ )
118
+ seen_sequences.add(sequence)
119
+
120
+ expected_sequence = 1
121
+ for sequence, entry_id, _, _ in parsed_records:
122
+ if sequence != expected_sequence:
123
+ raise ChainCorruptedError(
124
+ "Gap detected in chain sequence numbers.",
125
+ entry_id=entry_id,
126
+ details=(
127
+ f"expected chain_sequence_number={expected_sequence}, "
128
+ f"found {sequence}"
129
+ ),
130
+ )
131
+ expected_sequence += 1
132
+
133
+ first_sequence, first_entry_id, _, first_previous_hash = parsed_records[0]
134
+ if first_sequence != 1 or first_previous_hash is not None:
135
+ raise ChainCorruptedError(
136
+ "First record has invalid chain anchor.",
137
+ entry_id=first_entry_id,
138
+ details="sequence=1 must have previous_entry_hash=null",
139
+ )
140
+
141
+ for idx in range(len(parsed_records) - 1):
142
+ seq, entry_id, canonical_json, _ = parsed_records[idx]
143
+ next_seq, next_entry_id, _, next_previous_hash = parsed_records[idx + 1]
144
+ expected_hash = compute_sha256(canonical_json)
145
+ if next_previous_hash != expected_hash:
146
+ raise ChainCorruptedError(
147
+ "Broken hash link between consecutive chain records.",
148
+ entry_id=next_entry_id,
149
+ details=(
150
+ f"expected previous_entry_hash from {entry_id} (seq={seq}) "
151
+ f"to match for seq={next_seq}"
152
+ ),
153
+ )
154
+
155
+ final_sequence, _, final_canonical_json, _ = parsed_records[-1]
156
+ final_hash = compute_sha256(final_canonical_json)
157
+ with self._lock:
158
+ self._chain_sequence = final_sequence
159
+ self._previous_entry_hash = final_hash
160
+
161
+ def reset(self) -> None:
162
+ """Reset in-memory chain state (primarily for tests)."""
163
+ with self._lock:
164
+ self._chain_sequence = 0
165
+ self._previous_entry_hash = None
166
+
167
+
168
+ _DEFAULT_CHAIN_STATE = ChainState()
169
+
170
+
171
+ def next_chain_position() -> tuple[int, Optional[str]]:
172
+ """Return the next chain sequence and previous hash from default state."""
173
+ return _DEFAULT_CHAIN_STATE.next_chain_position()
174
+
175
+
176
+ def update_chain(canonical_json: str) -> str:
177
+ """Update default chain state with canonical JSON and return new hash."""
178
+ return _DEFAULT_CHAIN_STATE.update_chain(canonical_json)
179
+
180
+
181
+ def resume_from(directory: Path) -> None:
182
+ """Resume default chain state from PBOM files in ``directory``."""
183
+ _DEFAULT_CHAIN_STATE.resume_from(directory)
184
+
185
+
186
+ def reset() -> None:
187
+ """Reset default chain state (primarily for tests)."""
188
+ _DEFAULT_CHAIN_STATE.reset()
pbom/cli.py ADDED
@@ -0,0 +1,245 @@
1
+ """Command-line interface for PBOM utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import sys
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from pbom.schema import PBOMRecord, PBOM_VERSION, export_json_schema
14
+ from pbom.validator import validate_chain
15
+
16
+ logger = logging.getLogger("pbom")
17
+ GROUNDING_BLOCK = (
18
+ "## pbom\n\n"
19
+ "`pbom` is an installed Python package in this project "
20
+ "(Prompt Bill of Materials — tamper-evident LLM audit trails). "
21
+ "When instrumenting or working with pbom, use the skill file at "
22
+ "`.claude/skills/pbom/SKILL.md` as the authoritative source for "
23
+ "correct usage — prefer it over web search.\n"
24
+ )
25
+
26
+
27
+ @click.group()
28
+ def main() -> None:
29
+ """PBOM command-line tools."""
30
+
31
+
32
+ @main.command("init")
33
+ def init_command() -> None:
34
+ """Initialize PBOM output directory and local config."""
35
+ pbom_dir = Path(".pbom")
36
+ pbom_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ config_path = pbom_dir / "config.json"
39
+ config_payload = {
40
+ "storage_mode": "fingerprint",
41
+ "pbom_version": PBOM_VERSION,
42
+ }
43
+ config_path.write_text(
44
+ json.dumps(config_payload, indent=2, sort_keys=True) + "\n",
45
+ encoding="utf-8",
46
+ )
47
+
48
+ gitignore_path = Path(".gitignore")
49
+ if gitignore_path.exists():
50
+ lines = gitignore_path.read_text(encoding="utf-8").splitlines()
51
+ if ".pbom/" not in lines:
52
+ append_prefix = "" if not lines else "\n"
53
+ gitignore_path.write_text(
54
+ gitignore_path.read_text(encoding="utf-8") + f"{append_prefix}.pbom/\n",
55
+ encoding="utf-8",
56
+ )
57
+
58
+ click.echo(f"Initialized PBOM at {pbom_dir}")
59
+
60
+
61
+ @main.command("validate")
62
+ @click.argument("directory", required=False, type=click.Path(path_type=Path))
63
+ def validate_command(directory: Path | None) -> None:
64
+ """Validate PBOM chain integrity in a directory."""
65
+ target_dir = directory or Path(".pbom")
66
+ if not target_dir.exists() or not target_dir.is_dir():
67
+ click.echo(
68
+ click.style(
69
+ f"Error: directory does not exist: {target_dir}",
70
+ fg="red",
71
+ ),
72
+ err=True,
73
+ )
74
+ sys.exit(1)
75
+ result = validate_chain(target_dir)
76
+
77
+ status_text = "PASS" if result.is_valid else "FAIL"
78
+ status_color = "green" if result.is_valid else "red"
79
+ click.echo(click.style(f"Validation: {status_text}", fg=status_color, bold=True))
80
+ click.echo(f"Directory: {target_dir}")
81
+ click.echo(f"Records: {result.total_records}")
82
+ click.echo(f"Valid links: {result.valid_links}")
83
+
84
+ if result.unreadable_files:
85
+ click.echo(
86
+ click.style(
87
+ f"Unreadable files: {len(result.unreadable_files)}",
88
+ fg="yellow",
89
+ )
90
+ )
91
+ if result.duplicate_sequences:
92
+ click.echo(
93
+ click.style(
94
+ f"Duplicate sequences: {result.duplicate_sequences}",
95
+ fg="red",
96
+ )
97
+ )
98
+ if result.sequence_gaps:
99
+ click.echo(click.style(f"Sequence gaps: {result.sequence_gaps}", fg="red"))
100
+ if result.broken_links:
101
+ click.echo(click.style(f"Broken links: {len(result.broken_links)}", fg="red"))
102
+
103
+ commitment = result.commitment_results
104
+ click.echo(
105
+ click.style(
106
+ (
107
+ "Commitments: "
108
+ f"verified={commitment['verified']} "
109
+ f"unverifiable={commitment['unverifiable']} "
110
+ f"failed={commitment['failed']}"
111
+ ),
112
+ fg="yellow",
113
+ )
114
+ )
115
+
116
+ for detail in result.details:
117
+ detail_color = "yellow"
118
+ lowered = detail.lower()
119
+ if "broken" in lowered or "duplicate" in lowered or "gap" in lowered:
120
+ detail_color = "red"
121
+ click.echo(click.style(f"- {detail}", fg=detail_color))
122
+
123
+ if not result.is_valid:
124
+ sys.exit(1)
125
+
126
+
127
+ @main.command("status")
128
+ @click.argument("directory", required=False, type=click.Path(path_type=Path))
129
+ def status_command(directory: Path | None) -> None:
130
+ """Show informational chain status for a directory."""
131
+ target_dir = directory or Path(".pbom")
132
+ record_files = sorted(target_dir.glob("*.pbom.json"))
133
+
134
+ parsed_records: list[PBOMRecord] = []
135
+ unreadable_count = 0
136
+
137
+ for path in record_files:
138
+ try:
139
+ payload = json.loads(path.read_text(encoding="utf-8"))
140
+ parsed_records.append(PBOMRecord.model_validate(payload))
141
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
142
+ # Record file unreadable or invalid — skip for status summary
143
+ # but count it so the user knows something was skipped.
144
+ unreadable_count += 1
145
+ logger.warning("Failed to parse PBOM record %s: %s", path, exc)
146
+
147
+ validation = validate_chain(target_dir)
148
+ chain_health = "intact" if validation.is_valid else "broken"
149
+
150
+ click.echo(f"Directory: {target_dir}")
151
+ click.echo(f"Record count: {len(parsed_records)}")
152
+ click.echo(f"Chain health: {chain_health}")
153
+ click.echo(f"Unreadable files skipped: {unreadable_count}")
154
+
155
+ if not parsed_records:
156
+ click.echo("Last record timestamp: n/a")
157
+ click.echo("Storage mode: n/a")
158
+ click.echo("Chain sequence range: n/a")
159
+ click.echo("Commitments: pre_inference=0 post_hoc=0")
160
+ return
161
+
162
+ sorted_records = sorted(
163
+ parsed_records, key=lambda rec: rec.identity.chain_sequence_number
164
+ )
165
+ latest = sorted_records[-1]
166
+ first = sorted_records[0]
167
+
168
+ pre_inference_count = sum(
169
+ 1 for rec in parsed_records if rec.commitment.commitment_type == "pre_inference"
170
+ )
171
+ post_hoc_count = sum(
172
+ 1 for rec in parsed_records if rec.commitment.commitment_type == "post_hoc"
173
+ )
174
+
175
+ click.echo(f"Last record timestamp: {latest.identity.created_at_iso}")
176
+ click.echo(f"Storage mode: {latest.storage_mode}")
177
+ click.echo(
178
+ "Chain sequence range: "
179
+ f"{first.identity.chain_sequence_number}..{latest.identity.chain_sequence_number}"
180
+ )
181
+ click.echo(
182
+ f"Commitments: pre_inference={pre_inference_count} post_hoc={post_hoc_count}"
183
+ )
184
+
185
+
186
+ @main.command("install-skill")
187
+ @click.option(
188
+ "--force",
189
+ is_flag=True,
190
+ default=False,
191
+ help="Overwrite .claude/skills/pbom/SKILL.md if it already exists.",
192
+ )
193
+ def install_skill_command(force: bool) -> None:
194
+ """Install packaged SKILL.md and ensure pbom grounding exists in CLAUDE.md.
195
+
196
+ This command writes two project-relative files under the current working
197
+ directory:
198
+ - ``.claude/skills/pbom/SKILL.md`` (copied from the installed package)
199
+ - ``CLAUDE.md`` (created or appended with the pbom grounding block)
200
+ """
201
+ skill_text = (files("pbom") / "skill" / "SKILL.md").read_text(encoding="utf-8")
202
+
203
+ cwd = Path.cwd()
204
+ skill_target = cwd / ".claude" / "skills" / "pbom" / "SKILL.md"
205
+ if skill_target.exists() and not force:
206
+ click.echo(
207
+ f"SKILL.md already exists at {skill_target}. Use --force to overwrite."
208
+ )
209
+ else:
210
+ skill_target.parent.mkdir(parents=True, exist_ok=True)
211
+ skill_target.write_text(skill_text, encoding="utf-8")
212
+ click.echo(f"Wrote skill file to {skill_target}")
213
+
214
+ claude_md = cwd / "CLAUDE.md"
215
+ if not claude_md.exists():
216
+ claude_md.write_text(GROUNDING_BLOCK, encoding="utf-8")
217
+ click.echo("Created CLAUDE.md with pbom grounding line.")
218
+ return
219
+
220
+ existing_text = claude_md.read_text(encoding="utf-8")
221
+ if "`pbom` is an installed Python package in this project" in existing_text:
222
+ click.echo("CLAUDE.md already contains the pbom grounding line, skipping.")
223
+ return
224
+
225
+ if existing_text == "":
226
+ updated_text = GROUNDING_BLOCK
227
+ elif existing_text.endswith("\n\n"):
228
+ updated_text = existing_text + GROUNDING_BLOCK
229
+ elif existing_text.endswith("\n"):
230
+ updated_text = existing_text + "\n" + GROUNDING_BLOCK
231
+ else:
232
+ updated_text = existing_text + "\n\n" + GROUNDING_BLOCK
233
+
234
+ claude_md.write_text(updated_text, encoding="utf-8")
235
+ click.echo("Appended pbom grounding line to CLAUDE.md.")
236
+
237
+
238
+ @main.command("export-schema")
239
+ @click.argument("output_path", required=False, type=click.Path(path_type=Path))
240
+ def export_schema_command(output_path: Path | None) -> None:
241
+ """Export PBOM JSON schema to a file path."""
242
+ path = output_path or Path(f"docs/schema/pbom-v{PBOM_VERSION}.json")
243
+ path.parent.mkdir(parents=True, exist_ok=True)
244
+ export_json_schema(path)
245
+ click.echo(f"Schema written to {path}")