bmad-module-skill-forge 1.0.0 → 1.2.0
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +9 -7
- package/docs/_data/pinned.yaml +5 -2
- package/docs/{RELEASING.md → _internal/RELEASING.md} +87 -61
- package/docs/{STABILITY.md → _internal/STABILITY.md} +2 -2
- package/docs/bmad-synergy.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/troubleshooting.md +1 -1
- package/docs/why-skf.md +5 -4
- package/package.json +2 -7
- package/release-audits/v1.0.0-launch-audit.md +313 -2
- package/src/knowledge/ccc-bridge.md +1 -1
- package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +134 -0
- package/src/shared/scripts/skf-detect-tools.py +359 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +419 -0
- package/src/shared/scripts/skf-forge-tier-rw.py +413 -0
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +324 -0
- package/src/shared/scripts/skf-preflight.py +14 -4
- package/src/shared/scripts/skf-qmd-classify-collections.py +212 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +9 -3
- package/src/skf-audit-skill/steps-c/step-01-init.md +60 -0
- package/src/skf-audit-skill/steps-c/step-02-re-index.md +7 -1
- package/src/skf-audit-skill/steps-c/step-03-structural-diff.md +19 -1
- package/src/skf-audit-skill/steps-c/step-04-semantic-diff.md +10 -1
- package/src/skf-audit-skill/steps-c/step-05-severity-classify.md +8 -0
- package/src/skf-audit-skill/steps-c/step-06-report.md +8 -0
- package/src/skf-brief-skill/assets/skill-brief-schema.md +36 -18
- package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
- package/src/skf-drop-skill/steps-c/step-02-execute.md +1 -1
- package/src/skf-export-skill/assets/managed-section-format.md +1 -1
- package/src/skf-export-skill/steps-c/step-04-update-context.md +4 -4
- package/src/skf-rename-skill/steps-c/step-02-execute.md +1 -1
- package/src/skf-setup/SKILL.md +25 -10
- package/src/skf-setup/references/tier-rules.md +2 -2
- package/src/skf-setup/steps-c/step-01-detect-and-tier.md +58 -71
- package/src/skf-setup/steps-c/step-01b-ccc-index.md +49 -66
- package/src/skf-setup/steps-c/step-02-write-config.md +59 -86
- package/src/skf-setup/steps-c/step-03-auto-index.md +73 -91
- package/src/skf-setup/steps-c/step-04-report.md +135 -11
- package/src/skf-setup/steps-c/step-05-health-check.md +4 -2
- package/src/skf-test-skill/steps-c/step-01-init.md +4 -4
- package/src/skf-test-skill/steps-c/step-03-coverage-check.md +1 -1
- package/src/skf-update-skill/steps-c/step-02-detect-changes.md +123 -0
- package/tools/validate-docs-drift.js +42 -2
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""SKF Forge Tier RW — Read/write primitives for forger-sidecar YAML files.
|
|
6
|
+
|
|
7
|
+
Replaces the prose-driven YAML emission in `src/skf-setup/steps-c/
|
|
8
|
+
step-02-write-config.md` §1-§3 (and the prose-driven cleanup logic in
|
|
9
|
+
step-03 §5/§5b) with one Python invocation. The script is the source
|
|
10
|
+
of truth for the on-disk forge-tier.yaml schema (matching the
|
|
11
|
+
canonical template at step-02-write-config.md:24-58) and guarantees
|
|
12
|
+
that registry arrays are PRESERVED across rewrites — losing
|
|
13
|
+
`qmd_collections` or `ccc_index_registry` would break every
|
|
14
|
+
downstream skill (skf-create-skill, skf-audit-skill, skf-update-skill,
|
|
15
|
+
skf-brief-skill) that reads them.
|
|
16
|
+
|
|
17
|
+
Subcommands:
|
|
18
|
+
|
|
19
|
+
read Read forge-tier.yaml and emit the parsed structure as JSON
|
|
20
|
+
on stdout. Missing-file is not an error — emits null
|
|
21
|
+
payload with status=ok so first-run callers can branch.
|
|
22
|
+
|
|
23
|
+
write-tools Write a fresh forge-tier.yaml from a JSON context payload
|
|
24
|
+
on stdin. Preserves `qmd_collections`,
|
|
25
|
+
`ccc_index_registry`, and the user-customizable
|
|
26
|
+
`ccc_index.staleness_threshold_hours` from the existing
|
|
27
|
+
file (if any) by reading it first, then merging.
|
|
28
|
+
|
|
29
|
+
init-prefs Create preferences.yaml with first-run defaults IF it
|
|
30
|
+
does not exist. Idempotent — refuses to overwrite an
|
|
31
|
+
existing file (preserves user customization).
|
|
32
|
+
|
|
33
|
+
clean-stale Two cleanup operations gated by flags:
|
|
34
|
+
--qmd-live-names a,b,c — remove qmd_collections
|
|
35
|
+
entries whose `name` is not in the comma-separated
|
|
36
|
+
live-names list. Caller computes liveness via
|
|
37
|
+
`qmd collection list` (or skf-qmd-classify-
|
|
38
|
+
collections.py once that ships in PR B).
|
|
39
|
+
--prune-missing-ccc-paths — remove ccc_index_registry
|
|
40
|
+
entries whose `path` no longer exists on disk.
|
|
41
|
+
Both flags can be passed in the same invocation.
|
|
42
|
+
|
|
43
|
+
Output schema (the read subcommand and the response from every write):
|
|
44
|
+
|
|
45
|
+
{"status": "ok", "version": "v1", ...subcommand-specific fields...}
|
|
46
|
+
|
|
47
|
+
Errors emit `{"status": "error", "message": "..."}` to stderr and
|
|
48
|
+
exit non-zero (1 for user error, 2 for I/O failure).
|
|
49
|
+
|
|
50
|
+
Cross-platform: pure stdlib + PyYAML. Atomic writes via temp + rename
|
|
51
|
+
mirror skf-atomic-write.py's pattern. Concurrent access requires
|
|
52
|
+
external `flock` coordination — see step-07 of skf-create-skill for
|
|
53
|
+
the precedent.
|
|
54
|
+
|
|
55
|
+
CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
|
|
56
|
+
above is auto-resolved on first call and cached. `docs/getting-started.md`
|
|
57
|
+
documents uv as the runtime prerequisite for exactly this. Bare
|
|
58
|
+
`python3` will fail with `ModuleNotFoundError: No module named 'yaml'`
|
|
59
|
+
on a fresh interpreter where pyyaml has not been pip-installed
|
|
60
|
+
system-wide:
|
|
61
|
+
|
|
62
|
+
uv run skf-forge-tier-rw.py read --target /path/forge-tier.yaml
|
|
63
|
+
echo '{...}' | uv run skf-forge-tier-rw.py write-tools --target /path/forge-tier.yaml
|
|
64
|
+
uv run skf-forge-tier-rw.py init-prefs --target /path/preferences.yaml
|
|
65
|
+
uv run skf-forge-tier-rw.py clean-stale --target /path/forge-tier.yaml \\
|
|
66
|
+
--qmd-live-names foo-brief,bar-extraction --prune-missing-ccc-paths
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
from __future__ import annotations
|
|
70
|
+
|
|
71
|
+
import argparse
|
|
72
|
+
import json
|
|
73
|
+
import os
|
|
74
|
+
import sys
|
|
75
|
+
from datetime import datetime, timezone
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
|
|
78
|
+
import yaml
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
DEFAULT_STALENESS_HOURS = 24
|
|
82
|
+
PREFERENCES_TEMPLATE = """# Ferris Sidecar: User Preferences
|
|
83
|
+
# Created by setup workflow on first run
|
|
84
|
+
# Edit this file to customize Ferris behavior
|
|
85
|
+
|
|
86
|
+
# Override detected tier (set to Quick, Forge, Forge+, or Deep to force a tier)
|
|
87
|
+
tier_override: ~
|
|
88
|
+
|
|
89
|
+
# Passive context injection (set to false to skip snippet generation and CLAUDE.md updates during export)
|
|
90
|
+
passive_context: true
|
|
91
|
+
|
|
92
|
+
# Headless mode (set to true to skip confirmation gates in all workflows)
|
|
93
|
+
headless_mode: false
|
|
94
|
+
|
|
95
|
+
# Compact greeting (set to true to skip the full capabilities table on session start)
|
|
96
|
+
compact_greeting: false
|
|
97
|
+
|
|
98
|
+
# Reserved for future use — these fields are not yet consumed by any workflow step
|
|
99
|
+
# output_language: ~
|
|
100
|
+
# skill_format_version: ~
|
|
101
|
+
# citation_style: ~
|
|
102
|
+
# confidence_display: ~
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _die(code: int, message: str) -> None:
|
|
107
|
+
print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
|
|
108
|
+
sys.exit(code)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _ok(payload: dict) -> None:
|
|
112
|
+
payload.setdefault("status", "ok")
|
|
113
|
+
payload.setdefault("version", "v1")
|
|
114
|
+
print(json.dumps(payload, default=str))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_yaml(path: Path) -> dict | None:
|
|
118
|
+
"""Return parsed YAML as dict, or None if file missing. Raises on parse error."""
|
|
119
|
+
if not path.exists():
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
with path.open("r", encoding="utf-8") as f:
|
|
123
|
+
data = yaml.safe_load(f)
|
|
124
|
+
except yaml.YAMLError as e:
|
|
125
|
+
_die(2, f"failed to parse {path}: {e}")
|
|
126
|
+
if data is None:
|
|
127
|
+
return {}
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
_die(2, f"expected mapping at top of {path}, got {type(data).__name__}")
|
|
130
|
+
return data
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _atomic_write(target: Path, content: str) -> None:
|
|
134
|
+
"""Crash-safe write via temp + fsync + rename. Mirrors skf-atomic-write.py."""
|
|
135
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
tmp = target.with_name(target.name + ".skf-tmp")
|
|
137
|
+
try:
|
|
138
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
139
|
+
try:
|
|
140
|
+
os.write(fd, content.encode("utf-8"))
|
|
141
|
+
os.fsync(fd)
|
|
142
|
+
finally:
|
|
143
|
+
os.close(fd)
|
|
144
|
+
os.replace(tmp, target)
|
|
145
|
+
except OSError as e:
|
|
146
|
+
if tmp.exists():
|
|
147
|
+
try:
|
|
148
|
+
tmp.unlink()
|
|
149
|
+
except OSError:
|
|
150
|
+
pass
|
|
151
|
+
_die(2, f"atomic write failed for {target}: {e}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _yaml_block(value, indent: int = 0) -> str:
|
|
155
|
+
"""Dump a value as a YAML fragment, indented by `indent` spaces, no trailing newline."""
|
|
156
|
+
text = yaml.safe_dump(value, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
157
|
+
text = text.rstrip("\n")
|
|
158
|
+
if indent == 0:
|
|
159
|
+
return text
|
|
160
|
+
pad = " " * indent
|
|
161
|
+
return "\n".join(pad + line if line else line for line in text.split("\n"))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render_forge_tier_yaml(payload: dict) -> str:
|
|
165
|
+
"""Render the canonical forge-tier.yaml from a context payload.
|
|
166
|
+
|
|
167
|
+
Preserves the human-readable section comments from the template at
|
|
168
|
+
step-02-write-config.md:24-58. Sections are emitted in a fixed order
|
|
169
|
+
so re-runs against unchanged inputs produce byte-identical output.
|
|
170
|
+
"""
|
|
171
|
+
tools = payload["tools"]
|
|
172
|
+
tier = payload["tier"]
|
|
173
|
+
tier_detected_at = payload["tier_detected_at"]
|
|
174
|
+
ccc_index = payload["ccc_index"]
|
|
175
|
+
ccc_index_registry = payload.get("ccc_index_registry", [])
|
|
176
|
+
qmd_collections = payload.get("qmd_collections", [])
|
|
177
|
+
|
|
178
|
+
# Render `tools` block in the canonical key order (matches step-02 template).
|
|
179
|
+
tools_ordered = {
|
|
180
|
+
"ast_grep": tools["ast_grep"],
|
|
181
|
+
"gh_cli": tools["gh_cli"],
|
|
182
|
+
"qmd": tools["qmd"],
|
|
183
|
+
"ccc": tools["ccc"],
|
|
184
|
+
"ccc_daemon": tools.get("ccc_daemon"),
|
|
185
|
+
"security_scan": tools.get("security_scan", False),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# ccc_index keys in canonical order.
|
|
189
|
+
ccc_ordered = {
|
|
190
|
+
"indexed_path": ccc_index.get("indexed_path"),
|
|
191
|
+
"last_indexed": ccc_index.get("last_indexed"),
|
|
192
|
+
"status": ccc_index.get("status"),
|
|
193
|
+
"staleness_threshold_hours": ccc_index.get("staleness_threshold_hours", DEFAULT_STALENESS_HOURS),
|
|
194
|
+
"file_count": ccc_index.get("file_count"),
|
|
195
|
+
"exclude_patterns": ccc_index.get("exclude_patterns", []),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
parts = [
|
|
199
|
+
"# Ferris Sidecar: Forge Tier State",
|
|
200
|
+
"# Written by setup workflow",
|
|
201
|
+
"",
|
|
202
|
+
"# Tool availability (detected during [SF] Setup Forge)",
|
|
203
|
+
_yaml_block({"tools": tools_ordered}),
|
|
204
|
+
"",
|
|
205
|
+
"# Capability tier (derived from tool availability)",
|
|
206
|
+
"# Quick = no tools | Forge = + ast-grep | Forge+ = + ast-grep + ccc | Deep = + ast-grep + gh + QMD",
|
|
207
|
+
f"tier: {tier}",
|
|
208
|
+
f"tier_detected_at: {_yaml_scalar(tier_detected_at)}",
|
|
209
|
+
"",
|
|
210
|
+
"# CCC semantic index state (managed by setup step-01b and extraction workflows)",
|
|
211
|
+
_yaml_block({"ccc_index": ccc_ordered}),
|
|
212
|
+
"",
|
|
213
|
+
"# CCC index registry (tracks which source paths have been indexed for skill workflows)",
|
|
214
|
+
"# PRESERVE existing entries on re-runs",
|
|
215
|
+
_yaml_block({"ccc_index_registry": ccc_index_registry}),
|
|
216
|
+
"",
|
|
217
|
+
"# QMD collection registry (populated by create-skill, consumed by audit/update-skill)",
|
|
218
|
+
"# PRESERVE existing entries on re-runs",
|
|
219
|
+
_yaml_block({"qmd_collections": qmd_collections}),
|
|
220
|
+
"",
|
|
221
|
+
]
|
|
222
|
+
return "\n".join(parts)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _yaml_scalar(value) -> str:
|
|
226
|
+
"""Render a scalar value using YAML's own quoting rules so timestamps, booleans, etc. round-trip."""
|
|
227
|
+
return yaml.safe_dump(value, default_flow_style=False).rstrip("\n").rstrip("...").rstrip()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _merge_preserved_fields(payload: dict, existing: dict | None) -> dict:
|
|
231
|
+
"""Inject preserved fields from the existing file into the new payload.
|
|
232
|
+
|
|
233
|
+
Three preservation rules (per step-02 §1 "Note on re-runs"):
|
|
234
|
+
- `qmd_collections` array — preserved entirely from existing.
|
|
235
|
+
- `ccc_index_registry` array — preserved entirely from existing.
|
|
236
|
+
- `ccc_index.staleness_threshold_hours` scalar — preserved if user set
|
|
237
|
+
a non-default value; else uses payload value or DEFAULT.
|
|
238
|
+
Note: `ccc_index.exclude_patterns` is NOT preserved (rewritten fresh
|
|
239
|
+
by step-01b on every run, per the explicit step-02 contract).
|
|
240
|
+
"""
|
|
241
|
+
if existing is None:
|
|
242
|
+
return payload
|
|
243
|
+
|
|
244
|
+
payload.setdefault("qmd_collections", existing.get("qmd_collections", []))
|
|
245
|
+
payload.setdefault("ccc_index_registry", existing.get("ccc_index_registry", []))
|
|
246
|
+
|
|
247
|
+
payload.setdefault("ccc_index", {})
|
|
248
|
+
existing_ccc = existing.get("ccc_index", {}) or {}
|
|
249
|
+
if "staleness_threshold_hours" not in payload["ccc_index"]:
|
|
250
|
+
payload["ccc_index"]["staleness_threshold_hours"] = existing_ccc.get(
|
|
251
|
+
"staleness_threshold_hours", DEFAULT_STALENESS_HOURS
|
|
252
|
+
)
|
|
253
|
+
return payload
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ─── Subcommands ─────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_read(target: Path) -> None:
|
|
260
|
+
data = _read_yaml(target)
|
|
261
|
+
if data is None:
|
|
262
|
+
_ok({"exists": False, "data": None})
|
|
263
|
+
return
|
|
264
|
+
_ok({"exists": True, "data": data})
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def cmd_write_tools(target: Path) -> None:
|
|
268
|
+
raw = sys.stdin.read()
|
|
269
|
+
if not raw.strip():
|
|
270
|
+
_die(1, "write-tools: empty stdin (expected JSON payload)")
|
|
271
|
+
try:
|
|
272
|
+
payload = json.loads(raw)
|
|
273
|
+
except json.JSONDecodeError as e:
|
|
274
|
+
_die(1, f"write-tools: invalid JSON on stdin: {e}")
|
|
275
|
+
|
|
276
|
+
required = {"tools", "tier", "ccc_index"}
|
|
277
|
+
missing = required - set(payload.keys())
|
|
278
|
+
if missing:
|
|
279
|
+
_die(1, f"write-tools: payload missing required keys: {sorted(missing)}")
|
|
280
|
+
|
|
281
|
+
payload.setdefault("tier_detected_at", datetime.now(timezone.utc).isoformat())
|
|
282
|
+
|
|
283
|
+
existing = _read_yaml(target)
|
|
284
|
+
payload = _merge_preserved_fields(payload, existing)
|
|
285
|
+
|
|
286
|
+
rendered = render_forge_tier_yaml(payload)
|
|
287
|
+
_atomic_write(target, rendered)
|
|
288
|
+
_ok({
|
|
289
|
+
"wrote": str(target),
|
|
290
|
+
"preserved_arrays": {
|
|
291
|
+
"qmd_collections": len(payload.get("qmd_collections", [])),
|
|
292
|
+
"ccc_index_registry": len(payload.get("ccc_index_registry", [])),
|
|
293
|
+
},
|
|
294
|
+
"tier": payload["tier"],
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cmd_init_prefs(target: Path) -> None:
|
|
299
|
+
if target.exists():
|
|
300
|
+
_ok({"exists": True, "wrote": False, "path": str(target)})
|
|
301
|
+
return
|
|
302
|
+
_atomic_write(target, PREFERENCES_TEMPLATE)
|
|
303
|
+
_ok({"exists": True, "wrote": True, "path": str(target), "first_run": True})
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
|
|
307
|
+
prune_missing_ccc_paths: bool) -> None:
|
|
308
|
+
data = _read_yaml(target)
|
|
309
|
+
if data is None:
|
|
310
|
+
_die(1, f"clean-stale: target does not exist: {target}")
|
|
311
|
+
|
|
312
|
+
qmd_removed: list[str] = []
|
|
313
|
+
ccc_removed: list[str] = []
|
|
314
|
+
|
|
315
|
+
if qmd_live_names is not None:
|
|
316
|
+
live_set = set(qmd_live_names)
|
|
317
|
+
kept = []
|
|
318
|
+
for entry in data.get("qmd_collections", []) or []:
|
|
319
|
+
if not isinstance(entry, dict):
|
|
320
|
+
kept.append(entry)
|
|
321
|
+
continue
|
|
322
|
+
name = entry.get("name")
|
|
323
|
+
if name in live_set:
|
|
324
|
+
kept.append(entry)
|
|
325
|
+
else:
|
|
326
|
+
qmd_removed.append(str(name))
|
|
327
|
+
data["qmd_collections"] = kept
|
|
328
|
+
|
|
329
|
+
if prune_missing_ccc_paths:
|
|
330
|
+
kept = []
|
|
331
|
+
for entry in data.get("ccc_index_registry", []) or []:
|
|
332
|
+
if not isinstance(entry, dict):
|
|
333
|
+
kept.append(entry)
|
|
334
|
+
continue
|
|
335
|
+
entry_path = entry.get("path")
|
|
336
|
+
if entry_path and Path(entry_path).exists():
|
|
337
|
+
kept.append(entry)
|
|
338
|
+
else:
|
|
339
|
+
ccc_removed.append(str(entry_path))
|
|
340
|
+
data["ccc_index_registry"] = kept
|
|
341
|
+
|
|
342
|
+
if not qmd_removed and not ccc_removed:
|
|
343
|
+
_ok({"qmd_removed": [], "ccc_removed": [], "wrote": False})
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
# Round-trip through render to preserve the canonical format.
|
|
347
|
+
# Existing data already contains the full state; render needs the same shape.
|
|
348
|
+
payload = {
|
|
349
|
+
"tools": data.get("tools", {}),
|
|
350
|
+
"tier": data.get("tier", "Quick"),
|
|
351
|
+
"tier_detected_at": data.get("tier_detected_at",
|
|
352
|
+
datetime.now(timezone.utc).isoformat()),
|
|
353
|
+
"ccc_index": data.get("ccc_index", {}),
|
|
354
|
+
"ccc_index_registry": data.get("ccc_index_registry", []),
|
|
355
|
+
"qmd_collections": data.get("qmd_collections", []),
|
|
356
|
+
}
|
|
357
|
+
rendered = render_forge_tier_yaml(payload)
|
|
358
|
+
_atomic_write(target, rendered)
|
|
359
|
+
_ok({
|
|
360
|
+
"qmd_removed": qmd_removed,
|
|
361
|
+
"ccc_removed": ccc_removed,
|
|
362
|
+
"wrote": True,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def main() -> None:
|
|
370
|
+
parser = argparse.ArgumentParser(
|
|
371
|
+
description="Read/write primitives for forger-sidecar YAML files.",
|
|
372
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
373
|
+
)
|
|
374
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
375
|
+
|
|
376
|
+
p_read = sub.add_parser("read", help="Read a forge-tier.yaml and emit JSON")
|
|
377
|
+
p_read.add_argument("--target", type=Path, required=True)
|
|
378
|
+
|
|
379
|
+
p_write = sub.add_parser("write-tools",
|
|
380
|
+
help="Write a fresh forge-tier.yaml from a JSON payload on stdin")
|
|
381
|
+
p_write.add_argument("--target", type=Path, required=True)
|
|
382
|
+
|
|
383
|
+
p_init = sub.add_parser("init-prefs",
|
|
384
|
+
help="Create preferences.yaml with first-run defaults if missing")
|
|
385
|
+
p_init.add_argument("--target", type=Path, required=True)
|
|
386
|
+
|
|
387
|
+
p_clean = sub.add_parser("clean-stale",
|
|
388
|
+
help="Remove stale qmd_collections / ccc_index_registry entries")
|
|
389
|
+
p_clean.add_argument("--target", type=Path, required=True)
|
|
390
|
+
p_clean.add_argument("--qmd-live-names", default=None,
|
|
391
|
+
help="Comma-separated list of currently-live QMD collection names. "
|
|
392
|
+
"Entries in qmd_collections whose name is NOT in this list are removed. "
|
|
393
|
+
"Omit the flag entirely to skip QMD cleanup.")
|
|
394
|
+
p_clean.add_argument("--prune-missing-ccc-paths", action="store_true",
|
|
395
|
+
help="Remove ccc_index_registry entries whose path no longer exists.")
|
|
396
|
+
|
|
397
|
+
args = parser.parse_args()
|
|
398
|
+
|
|
399
|
+
if args.cmd == "read":
|
|
400
|
+
cmd_read(args.target)
|
|
401
|
+
elif args.cmd == "write-tools":
|
|
402
|
+
cmd_write_tools(args.target)
|
|
403
|
+
elif args.cmd == "init-prefs":
|
|
404
|
+
cmd_init_prefs(args.target)
|
|
405
|
+
elif args.cmd == "clean-stale":
|
|
406
|
+
live = None
|
|
407
|
+
if args.qmd_live_names is not None:
|
|
408
|
+
live = [n.strip() for n in args.qmd_live_names.split(",") if n.strip()]
|
|
409
|
+
cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
if __name__ == "__main__":
|
|
413
|
+
main()
|