bmad-module-skill-forge 1.7.0 → 1.9.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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/bmad-synergy.md +16 -0
  4. package/docs/deepwiki.md +89 -0
  5. package/docs/verifying-a-skill.md +8 -2
  6. package/docs/workflows.md +17 -11
  7. package/package.json +2 -2
  8. package/src/shared/_known-workarounds.yaml +155 -0
  9. package/src/shared/references/pipeline-contracts.md +11 -2
  10. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
  11. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  12. package/src/shared/scripts/skf-detect-docs.py +412 -0
  13. package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
  14. package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
  15. package/src/shared/scripts/skf-preapply.py +192 -0
  16. package/src/shared/scripts/skf-shape-detect.py +563 -0
  17. package/src/shared/scripts/skf-validate-frontmatter.py +56 -5
  18. package/src/shared/scripts/skf-validate-pins.py +368 -0
  19. package/src/skf-analyze-source/SKILL.md +26 -20
  20. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  21. package/src/skf-analyze-source/references/identify-units.md +41 -4
  22. package/src/skf-analyze-source/references/init.md +36 -0
  23. package/src/skf-analyze-source/references/scan-project.md +5 -2
  24. package/src/skf-analyze-source/references/step-auto-scope.md +525 -0
  25. package/src/skf-analyze-source/references/step-shape-detect.md +79 -0
  26. package/src/skf-analyze-source/references/unit-detection-heuristics.md +16 -0
  27. package/src/skf-audit-skill/SKILL.md +1 -0
  28. package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
  29. package/src/skf-audit-skill/references/report.md +4 -0
  30. package/src/skf-audit-skill/references/severity-classify.md +1 -1
  31. package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
  32. package/src/skf-brief-skill/SKILL.md +7 -3
  33. package/src/skf-brief-skill/references/gather-intent.md +14 -0
  34. package/src/skf-brief-skill/references/step-auto-brief.md +171 -0
  35. package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
  36. package/src/skf-create-skill/SKILL.md +3 -0
  37. package/src/skf-create-skill/assets/skill-sections.md +2 -0
  38. package/src/skf-create-skill/references/compile.md +1 -1
  39. package/src/skf-create-skill/references/generate-artifacts.md +5 -10
  40. package/src/skf-create-skill/references/step-auto-shard.md +110 -0
  41. package/src/skf-create-skill/references/step-doc-rot.md +109 -0
  42. package/src/skf-create-skill/references/step-doc-sources.md +114 -0
  43. package/src/skf-create-stack-skill/references/generate-output.md +5 -5
  44. package/src/skf-forger/SKILL.md +8 -2
  45. package/src/skf-test-skill/SKILL.md +8 -7
  46. package/src/skf-test-skill/customize.toml +2 -1
  47. package/src/skf-test-skill/references/external-validators.md +1 -1
  48. package/src/skf-test-skill/references/init.md +16 -1
  49. package/src/skf-test-skill/references/report.md +5 -1
  50. package/src/skf-test-skill/references/score.md +95 -6
  51. package/src/skf-test-skill/references/source-access-protocol.md +14 -0
  52. package/src/skf-test-skill/references/step-hard-gate.md +73 -0
  53. package/src/skf-test-skill/templates/test-report-template.md +1 -0
@@ -0,0 +1,412 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Detect Docs — discover documentation URLs for a GitHub repository.
6
+
7
+ Standalone doc-detection script that discovers documentation URLs through
8
+ multiple methods. BS, CS, AS, and campaign workflows all locate a repo's
9
+ docs through this shared entry point, eliminating duplicate detection logic.
10
+
11
+ Detection chain (all four methods attempted, results aggregated):
12
+
13
+ 1. homepageUrl — GitHub repo metadata homepage field
14
+ 2. readme_link — links parsed from the repo README
15
+ 3. pages_api — GitHub Pages site URL
16
+ 4. docs_folder — markdown files in the repo's docs/ directory
17
+
18
+ CLI:
19
+ uv run python src/shared/scripts/skf-detect-docs.py \\
20
+ --repo-url <url> [--local-path <path>] [--skip-pages-api]
21
+
22
+ Input:
23
+ --repo-url GitHub repository URL (required)
24
+ --local-path local clone path for docs/ folder scan (optional)
25
+ --skip-pages-api skip GitHub Pages API detection (optional flag)
26
+
27
+ Output (JSON array on stdout):
28
+ [
29
+ {
30
+ "url": "https://docs.example.com",
31
+ "detected_via": "homepageUrl",
32
+ "content_hash": "sha256:a1b2c3...",
33
+ "content_type": "api-docs"
34
+ }
35
+ ]
36
+
37
+ Exit codes:
38
+ 0 found >=1 documentation source
39
+ 1 no documentation sources found (empty array)
40
+ 2 error (invalid args, gh not found, etc.)
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import base64
47
+ import hashlib
48
+ import json
49
+ import re
50
+ import subprocess
51
+ import sys
52
+ import urllib.error
53
+ import urllib.request
54
+ from pathlib import Path
55
+ from typing import Any, Dict, List, Optional
56
+
57
+ _GITHUB_URL_RE = re.compile(
58
+ r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
59
+ re.IGNORECASE,
60
+ )
61
+
62
+ USER_AGENT = "skf-detect-docs/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
63
+
64
+ _FETCH_TIMEOUT = 15
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # URL exclusion patterns (AC #5)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ _EXCLUSION_PATTERNS = re.compile(
71
+ r"(?:^|/)(?:"
72
+ r"CHANGELOG|CHANGES|HISTORY"
73
+ r"|MIGRATION|MIGRATING|UPGRADE"
74
+ r"|RELEASE|release-notes|releases"
75
+ r"|CONTRIBUTING|CODE_OF_CONDUCT"
76
+ r"|LICENSE|SECURITY"
77
+ r")(?:\.|/|$)",
78
+ re.IGNORECASE,
79
+ )
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # README link scanning patterns
83
+ # ---------------------------------------------------------------------------
84
+
85
+ _MD_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
86
+ _HTML_LINK_RE = re.compile(r'<a\s[^>]*href=["\']([^"\']+)["\']', re.IGNORECASE)
87
+ _BARE_URL_RE = re.compile(r"^(https?://\S+)$", re.MULTILINE)
88
+
89
+ _DOC_DOMAIN_RE = re.compile(
90
+ r"(?:docs\.|\.readthedocs\.|wiki\.|documentation\.)",
91
+ re.IGNORECASE,
92
+ )
93
+
94
+ _DOC_PATH_RE = re.compile(
95
+ r"(?:/docs/|/documentation/|/api/|/reference/|/guide/|/wiki/)",
96
+ re.IGNORECASE,
97
+ )
98
+
99
+ _DOC_TEXT_RE = re.compile(
100
+ r"(?:documentation|docs|api\s+reference|guide|wiki)",
101
+ re.IGNORECASE,
102
+ )
103
+
104
+ _REJECT_URL_RE = re.compile(
105
+ r"(?:"
106
+ r"github\.com/[^/]+/[^/]+/(?:issues|pull|actions)"
107
+ r"|img\.shields\.io"
108
+ r"|badge"
109
+ r"|\.(?:svg|png|gif|jpg|jpeg)(?:\?|$)"
110
+ r"|travis-ci\."
111
+ r"|circleci\."
112
+ r"|npmjs\.com"
113
+ r"|pypi\.org"
114
+ r"|crates\.io"
115
+ r"|twitter\.com|x\.com"
116
+ r"|discord\.(?:gg|com)"
117
+ r"|slack\.com"
118
+ r")",
119
+ re.IGNORECASE,
120
+ )
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Content type classification
125
+ # ---------------------------------------------------------------------------
126
+
127
+ _API_DOCS_RE = re.compile(r"(?:/api/|/reference/|/sdk/|api-docs|api\.)", re.IGNORECASE)
128
+ _GUIDE_RE = re.compile(
129
+ r"(?:/guide/|/tutorial/|/getting-started|/quickstart|/howto)",
130
+ re.IGNORECASE,
131
+ )
132
+
133
+
134
+ def _classify_content_type(url: str) -> str:
135
+ if _API_DOCS_RE.search(url):
136
+ return "api-docs"
137
+ if _GUIDE_RE.search(url):
138
+ return "guide"
139
+ return "reference"
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # URL helpers
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def _is_excluded(url: str) -> bool:
147
+ return bool(_EXCLUSION_PATTERNS.search(url))
148
+
149
+
150
+ def _is_github_self_url(url: str, owner: str, repo: str) -> bool:
151
+ return bool(re.match(
152
+ rf"https?://(?:www\.)?github\.com/{re.escape(owner)}/{re.escape(repo)}/?$",
153
+ url,
154
+ re.IGNORECASE,
155
+ ))
156
+
157
+
158
+ def _is_doc_url(url: str, link_text: str = "") -> bool:
159
+ if _REJECT_URL_RE.search(url):
160
+ return False
161
+ if _DOC_DOMAIN_RE.search(url):
162
+ return True
163
+ if _DOC_PATH_RE.search(url):
164
+ return True
165
+ if link_text and _DOC_TEXT_RE.search(link_text):
166
+ return True
167
+ return False
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # gh CLI helper
172
+ # ---------------------------------------------------------------------------
173
+
174
+ def _run_gh(args: List[str]) -> Optional[str]:
175
+ try:
176
+ result = subprocess.run(
177
+ ["gh"] + args,
178
+ capture_output=True,
179
+ text=True,
180
+ )
181
+ if result.returncode == 0:
182
+ return result.stdout.strip()
183
+ return None
184
+ except FileNotFoundError:
185
+ return None
186
+
187
+
188
+ def _check_gh_available() -> bool:
189
+ try:
190
+ subprocess.run(
191
+ ["gh", "--version"],
192
+ capture_output=True,
193
+ text=True,
194
+ )
195
+ return True
196
+ except FileNotFoundError:
197
+ return False
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Content hashing
202
+ # ---------------------------------------------------------------------------
203
+
204
+ def _fetch_and_hash(url: str) -> Optional[str]:
205
+ if url.startswith("file://"):
206
+ try:
207
+ local_path = url[7:]
208
+ with open(local_path, "rb") as fh:
209
+ content = fh.read()
210
+ return "sha256:" + hashlib.sha256(content).hexdigest()
211
+ except (OSError, IOError):
212
+ return None
213
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
214
+ try:
215
+ with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
216
+ content = resp.read()
217
+ return "sha256:" + hashlib.sha256(content).hexdigest()
218
+ except Exception:
219
+ return None
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Detection method 1 — homepageUrl
224
+ # ---------------------------------------------------------------------------
225
+
226
+ def _detect_homepage_url(owner: str, repo: str) -> List[Dict[str, Any]]:
227
+ raw = _run_gh(["api", f"repos/{owner}/{repo}", "--jq", ".homepage"])
228
+ if not raw or raw == "null":
229
+ return []
230
+ url = raw.strip()
231
+ if not url:
232
+ return []
233
+ if _is_github_self_url(url, owner, repo):
234
+ return []
235
+ return [{"url": url, "detected_via": "homepageUrl", "content_type": _classify_content_type(url)}]
236
+
237
+
238
+ # ---------------------------------------------------------------------------
239
+ # Detection method 2 — README link scanning
240
+ # ---------------------------------------------------------------------------
241
+
242
+ def _detect_readme_links(owner: str, repo: str) -> List[Dict[str, Any]]:
243
+ raw = _run_gh(["api", f"repos/{owner}/{repo}/readme"])
244
+ if not raw:
245
+ return []
246
+ try:
247
+ data = json.loads(raw)
248
+ except json.JSONDecodeError:
249
+ return []
250
+ encoded = data.get("content", "")
251
+ if not encoded:
252
+ return []
253
+ try:
254
+ readme_text = base64.b64decode(encoded).decode("utf-8", errors="replace")
255
+ except Exception:
256
+ return []
257
+
258
+ urls_with_text: List[tuple] = []
259
+ for text, url in _MD_LINK_RE.findall(readme_text):
260
+ urls_with_text.append((url.strip(), text.strip()))
261
+ for url in _HTML_LINK_RE.findall(readme_text):
262
+ urls_with_text.append((url.strip(), ""))
263
+ for url in _BARE_URL_RE.findall(readme_text):
264
+ urls_with_text.append((url.strip(), ""))
265
+
266
+ results: List[Dict[str, Any]] = []
267
+ seen: set = set()
268
+ for url, text in urls_with_text:
269
+ if not url.startswith("http"):
270
+ continue
271
+ if url in seen:
272
+ continue
273
+ if _is_doc_url(url, text):
274
+ seen.add(url)
275
+ results.append({
276
+ "url": url,
277
+ "detected_via": "readme_link",
278
+ "content_type": _classify_content_type(url),
279
+ })
280
+ return results
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Detection method 3 — Pages API
285
+ # ---------------------------------------------------------------------------
286
+
287
+ def _detect_pages_api(owner: str, repo: str) -> List[Dict[str, Any]]:
288
+ raw = _run_gh(["api", f"repos/{owner}/{repo}/pages", "--jq", ".html_url"])
289
+ if not raw or raw == "null":
290
+ return []
291
+ url = raw.strip()
292
+ if not url:
293
+ return []
294
+ return [{"url": url, "detected_via": "pages_api", "content_type": _classify_content_type(url)}]
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Detection method 4 — docs/ folder
299
+ # ---------------------------------------------------------------------------
300
+
301
+ def _detect_docs_folder(owner: str, repo: str, local_path: Optional[str] = None) -> List[Dict[str, Any]]:
302
+ results: List[Dict[str, Any]] = []
303
+ if local_path:
304
+ docs_dir = Path(local_path) / "docs"
305
+ if docs_dir.is_dir():
306
+ for md_file in sorted(docs_dir.rglob("*.md")):
307
+ file_url = "file://" + md_file.as_posix()
308
+ results.append({
309
+ "url": file_url,
310
+ "detected_via": "docs_folder",
311
+ "content_type": _classify_content_type(md_file.as_posix()),
312
+ })
313
+ else:
314
+ raw = _run_gh(["api", f"repos/{owner}/{repo}/contents/docs"])
315
+ if not raw:
316
+ return []
317
+ try:
318
+ entries = json.loads(raw)
319
+ except json.JSONDecodeError:
320
+ return []
321
+ if not isinstance(entries, list):
322
+ return []
323
+ for entry in entries:
324
+ name = entry.get("name", "")
325
+ if not name.lower().endswith(".md"):
326
+ continue
327
+ download_url = entry.get("download_url", "")
328
+ if download_url:
329
+ results.append({
330
+ "url": download_url,
331
+ "detected_via": "docs_folder",
332
+ "content_type": _classify_content_type(download_url),
333
+ })
334
+ return results
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Main detection orchestrator
339
+ # ---------------------------------------------------------------------------
340
+
341
+ def detect(
342
+ repo_url: str,
343
+ local_path: Optional[str] = None,
344
+ skip_pages_api: bool = False,
345
+ ) -> List[Dict[str, Any]]:
346
+ m = _GITHUB_URL_RE.match(repo_url.strip())
347
+ if not m:
348
+ return []
349
+
350
+ owner, repo = m.group(1), m.group(2)
351
+
352
+ all_results: List[Dict[str, Any]] = []
353
+ all_results.extend(_detect_homepage_url(owner, repo))
354
+ all_results.extend(_detect_readme_links(owner, repo))
355
+ if not skip_pages_api:
356
+ all_results.extend(_detect_pages_api(owner, repo))
357
+ all_results.extend(_detect_docs_folder(owner, repo, local_path))
358
+
359
+ filtered = [r for r in all_results if not _is_excluded(r["url"])]
360
+
361
+ seen: set = set()
362
+ deduped: List[Dict[str, Any]] = []
363
+ for r in filtered:
364
+ if r["url"] not in seen:
365
+ seen.add(r["url"])
366
+ deduped.append(r)
367
+
368
+ for entry in deduped:
369
+ entry["content_hash"] = _fetch_and_hash(entry["url"])
370
+
371
+ return deduped
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # CLI entry point
376
+ # ---------------------------------------------------------------------------
377
+
378
+ def main() -> int:
379
+ parser = argparse.ArgumentParser(
380
+ description="Detect documentation URLs for a GitHub repository.",
381
+ )
382
+ parser.add_argument("--repo-url", required=True, help="GitHub repository URL")
383
+ parser.add_argument("--local-path", default=None, help="Local clone path for docs/ folder scan")
384
+ parser.add_argument("--skip-pages-api", action="store_true", help="Skip GitHub Pages API detection")
385
+ args = parser.parse_args()
386
+
387
+ if not _check_gh_available():
388
+ json.dump({"error": "gh CLI not found", "code": "GH_NOT_FOUND"}, sys.stderr)
389
+ sys.stderr.write("\n")
390
+ return 2
391
+
392
+ m = _GITHUB_URL_RE.match(args.repo_url.strip())
393
+ if not m:
394
+ json.dump({"error": f"Not a GitHub URL: {args.repo_url}", "code": "INVALID_URL"}, sys.stderr)
395
+ sys.stderr.write("\n")
396
+ return 2
397
+
398
+ try:
399
+ results = detect(args.repo_url, args.local_path, args.skip_pages_api)
400
+ except Exception as exc:
401
+ json.dump({"error": str(exc), "code": "DETECTION_ERROR"}, sys.stderr)
402
+ sys.stderr.write("\n")
403
+ return 2
404
+
405
+ json.dump(results, sys.stdout, separators=(",", ":"))
406
+ sys.stdout.write("\n")
407
+
408
+ return 0 if results else 1
409
+
410
+
411
+ if __name__ == "__main__":
412
+ raise SystemExit(main())
@@ -44,7 +44,8 @@ Context payload shape (consumed by `emit`):
44
44
  "halt_reason": null | "input-missing" | "input-invalid" |
45
45
  "forge-tier-missing" | "target-inaccessible" |
46
46
  "gh-auth-failed" | "write-failed" |
47
- "overwrite-cancelled" | "user-cancelled"
47
+ "overwrite-cancelled" | "user-cancelled",
48
+ "mode": null | "auto"
48
49
  }
49
50
 
50
51
  The caller does NOT supply exit_code — the script derives it from
@@ -105,6 +106,8 @@ HALT_TO_EXIT = {
105
106
  "user-cancelled": 6,
106
107
  }
107
108
 
109
+ VALID_MODES = {None, "auto"}
110
+
108
111
  # Envelope key order — fixed so byte-stable diffs are possible.
109
112
  KEY_ORDER = [
110
113
  "status",
@@ -115,6 +118,7 @@ KEY_ORDER = [
115
118
  "scope_type",
116
119
  "exit_code",
117
120
  "halt_reason",
121
+ "mode",
118
122
  ]
119
123
 
120
124
 
@@ -152,6 +156,10 @@ def assemble(ctx: dict[str, Any]) -> dict[str, Any]:
152
156
  if not skill_name or not isinstance(skill_name, str):
153
157
  _die(f"skill_name is required and must be a non-empty string; got {skill_name!r}")
154
158
 
159
+ mode = ctx.get("mode")
160
+ if mode not in VALID_MODES:
161
+ _die(f"mode must be one of {sorted(m for m in VALID_MODES if m is not None)} or null; got {mode!r}")
162
+
155
163
  envelope = {
156
164
  "status": status,
157
165
  "brief_path": ctx.get("brief_path"),
@@ -161,6 +169,7 @@ def assemble(ctx: dict[str, Any]) -> dict[str, Any]:
161
169
  "scope_type": scope_type,
162
170
  "exit_code": HALT_TO_EXIT[halt_reason],
163
171
  "halt_reason": halt_reason,
172
+ "mode": mode,
164
173
  }
165
174
  # Re-emit in canonical key order
166
175
  return {k: envelope[k] for k in KEY_ORDER}
@@ -181,6 +190,8 @@ def validate(envelope: dict[str, Any]) -> None:
181
190
  _die(f"halt_reason invalid: {envelope.get('halt_reason')!r}")
182
191
  if envelope.get("scope_type") not in VALID_SCOPE_TYPES:
183
192
  _die(f"scope_type invalid: {envelope.get('scope_type')!r}")
193
+ if envelope.get("mode") not in VALID_MODES:
194
+ _die(f"mode invalid: {envelope.get('mode')!r}")
184
195
  if envelope.get("exit_code") not in {0, 2, 3, 4, 5, 6}:
185
196
  _die(f"exit_code invalid: {envelope.get('exit_code')!r}")
186
197
  expected_exit = HALT_TO_EXIT[envelope.get("halt_reason")]
@@ -42,6 +42,17 @@ Subcommands:
42
42
  collections without re-rendering the whole file in
43
43
  prose.
44
44
 
45
+ register-ccc-index
46
+ Append-or-replace a single entry in the
47
+ `ccc_index_registry` array. Reads the entry as JSON on
48
+ stdin (must include `source_repo` and `skill_name`;
49
+ composite key `source_repo`+`skill_name` is the upsert
50
+ key — existing entry with the same composite key is
51
+ replaced, otherwise appended). All other forge-tier
52
+ state is preserved verbatim. Used by skf-create-skill
53
+ §6b to register CCC-indexed source paths without
54
+ re-rendering the whole file in prose.
55
+
45
56
  clean-stale Two cleanup operations gated by flags:
46
57
  --qmd-live-names a,b,c — remove qmd_collections
47
58
  entries whose `name` is not in the comma-separated
@@ -74,6 +85,7 @@ system-wide:
74
85
  uv run skf-forge-tier-rw.py read --target /path/forge-tier.yaml
75
86
  echo '{...}' | uv run skf-forge-tier-rw.py write-tools --target /path/forge-tier.yaml
76
87
  uv run skf-forge-tier-rw.py init-prefs --target /path/preferences.yaml
88
+ echo '{...}' | uv run skf-forge-tier-rw.py register-ccc-index --target /path/forge-tier.yaml
77
89
  uv run skf-forge-tier-rw.py clean-stale --target /path/forge-tier.yaml \\
78
90
  --qmd-live-names foo-brief,bar-extraction --prune-missing-ccc-paths
79
91
  """
@@ -373,6 +385,61 @@ def cmd_register_qmd_collection(target: Path) -> None:
373
385
  })
374
386
 
375
387
 
388
+ def cmd_register_ccc_index(target: Path) -> None:
389
+ raw = sys.stdin.read()
390
+ if not raw.strip():
391
+ _die(1, "register-ccc-index: empty stdin (expected JSON entry)")
392
+ try:
393
+ entry = json.loads(raw)
394
+ except json.JSONDecodeError as e:
395
+ _die(1, f"register-ccc-index: invalid JSON on stdin: {e}")
396
+
397
+ if not isinstance(entry, dict):
398
+ _die(1, "register-ccc-index: entry must be a JSON object")
399
+ source_repo = entry.get("source_repo")
400
+ skill_name = entry.get("skill_name")
401
+ if not source_repo or not isinstance(source_repo, str):
402
+ _die(1, "register-ccc-index: entry must include a non-empty 'source_repo' string")
403
+ if not skill_name or not isinstance(skill_name, str):
404
+ _die(1, "register-ccc-index: entry must include a non-empty 'skill_name' string")
405
+
406
+ data = _read_yaml(target)
407
+ if data is None:
408
+ _die(1, f"register-ccc-index: target does not exist: {target}. "
409
+ f"Run setup workflow first to create forge-tier.yaml.")
410
+
411
+ registry = list(data.get("ccc_index_registry") or [])
412
+ replaced = False
413
+ for i, existing in enumerate(registry):
414
+ if (isinstance(existing, dict)
415
+ and existing.get("source_repo") == source_repo
416
+ and existing.get("skill_name") == skill_name):
417
+ registry[i] = entry
418
+ replaced = True
419
+ break
420
+ if not replaced:
421
+ registry.append(entry)
422
+
423
+ payload = {
424
+ "tools": data.get("tools", {}),
425
+ "tier": data.get("tier", "Quick"),
426
+ "tier_detected_at": data.get("tier_detected_at",
427
+ datetime.now(timezone.utc).isoformat()),
428
+ "ccc_index": data.get("ccc_index", {}),
429
+ "ccc_index_registry": registry,
430
+ "qmd_collections": data.get("qmd_collections", []),
431
+ }
432
+ rendered = render_forge_tier_yaml(payload)
433
+ _atomic_write(target, rendered)
434
+ _ok({
435
+ "source_repo": source_repo,
436
+ "skill_name": skill_name,
437
+ "action": "replaced" if replaced else "appended",
438
+ "ccc_index_registry_count": len(registry),
439
+ "wrote": str(target),
440
+ })
441
+
442
+
376
443
  def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
377
444
  prune_missing_ccc_paths: bool) -> None:
378
445
  data = _read_yaml(target)
@@ -468,6 +535,10 @@ def main() -> None:
468
535
  help="Append-or-replace a single qmd_collections entry by name")
469
536
  p_register.add_argument("--target", type=Path, required=True)
470
537
 
538
+ p_ccc = sub.add_parser("register-ccc-index",
539
+ help="Append-or-replace a single ccc_index_registry entry by source_repo+skill_name")
540
+ p_ccc.add_argument("--target", type=Path, required=True)
541
+
471
542
  args = parser.parse_args()
472
543
 
473
544
  if args.cmd == "read":
@@ -483,6 +554,8 @@ def main() -> None:
483
554
  cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths)
484
555
  elif args.cmd == "register-qmd-collection":
485
556
  cmd_register_qmd_collection(args.target)
557
+ elif args.cmd == "register-ccc-index":
558
+ cmd_register_ccc_index(args.target)
486
559
 
487
560
 
488
561
  if __name__ == "__main__":