bmad-module-skill-forge 1.2.0 → 1.4.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 (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/skill-model.md +26 -32
  4. package/docs/troubleshooting.md +12 -0
  5. package/docs/workflows.md +87 -15
  6. package/package.json +2 -2
  7. package/src/shared/references/output-contract-schema.md +10 -0
  8. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +58 -0
  9. package/src/shared/scripts/schemas/skill-brief.v1.json +77 -0
  10. package/src/shared/scripts/schemas/workspace-detection.v1.json +44 -0
  11. package/src/shared/scripts/skf-detect-language.py +277 -0
  12. package/src/shared/scripts/skf-detect-workspaces.py +427 -0
  13. package/src/shared/scripts/skf-emit-brief-result-envelope.py +257 -0
  14. package/src/shared/scripts/skf-extract-public-api.py +534 -0
  15. package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
  16. package/src/shared/scripts/skf-recommend-scope-type.py +369 -0
  17. package/src/shared/scripts/skf-render-quick-metadata.py +192 -0
  18. package/src/shared/scripts/skf-resolve-package.py +264 -0
  19. package/src/shared/scripts/skf-validate-brief-inputs.py +293 -0
  20. package/src/shared/scripts/skf-validate-output.py +24 -7
  21. package/src/shared/scripts/skf-write-skill-brief.py +509 -0
  22. package/src/skf-brief-skill/SKILL.md +41 -12
  23. package/src/skf-brief-skill/assets/description-voice-examples.md +19 -0
  24. package/src/skf-brief-skill/assets/scope-templates.md +5 -0
  25. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -40
  26. package/src/skf-brief-skill/references/draft-checkpoint.md +46 -0
  27. package/src/skf-brief-skill/references/headless-args.md +22 -0
  28. package/src/skf-brief-skill/references/headless-source-authority-detection.md +26 -0
  29. package/src/skf-brief-skill/references/portfolio-similarity-check.md +35 -0
  30. package/src/skf-brief-skill/references/qmd-collection-registration.md +52 -0
  31. package/src/skf-brief-skill/references/version-resolution.md +46 -0
  32. package/src/skf-brief-skill/steps-c/step-01-gather-intent.md +164 -14
  33. package/src/skf-brief-skill/steps-c/step-02-analyze-target.md +118 -50
  34. package/src/skf-brief-skill/steps-c/step-03-scope-definition.md +48 -5
  35. package/src/skf-brief-skill/steps-c/step-04-confirm-brief.md +33 -19
  36. package/src/skf-brief-skill/steps-c/step-05-write-brief.md +93 -97
  37. package/src/skf-brief-skill/steps-c/step-06-health-check.md +11 -2
  38. package/src/skf-quick-skill/SKILL.md +178 -10
  39. package/src/skf-quick-skill/assets/skill-template.md +5 -1
  40. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  41. package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
  42. package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
  43. package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
  44. package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
  45. package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
  46. package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
  47. package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
  48. package/src/skf-quick-skill/steps-c/step-05-validate.md +0 -193
@@ -0,0 +1,369 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Recommend Scope Type — deterministic 5-rule heuristic ladder.
6
+
7
+ Single source of truth for the scope-type recommendation logic that
8
+ skf-brief-skill step-03 §2c applies. Both the interactive recommendation
9
+ and the headless auto-selection paths invoke this script with the same
10
+ inputs to eliminate the drift seam between them.
11
+
12
+ The five-rule ladder (apply in order, first match wins):
13
+
14
+ 1. component-library — registry.ts / components.ts present (with 10+
15
+ entries or `Component[]` annotation when contents available;
16
+ presence-only when contents not available)
17
+ 2. reference-app — intent text mentions wiring / integration / starter
18
+ / lifecycle / build-config keywords, OR analysis flagged as demo/example
19
+ 3. specific-modules — intent names a specific subset ("just the X",
20
+ "only the Y") OR module_count >= 6
21
+ 4. public-api — export_count <= 8 AND intent mentions "the API",
22
+ "the SDK", "client library", "public API"
23
+ 5. full-library — fallback when no rule matches
24
+
25
+ Plus two short-circuits applied before the ladder:
26
+
27
+ - source_type == "docs-only" → docs-only (no source surface to scope)
28
+ - When mode == "interactive" the component-registry rule additionally
29
+ inspects file contents (10+ entries or Component[] annotation); when
30
+ mode == "headless" with no entry_files supplied, the script falls back
31
+ to file-presence-only matching for that rule.
32
+
33
+ CLI:
34
+ echo '{...}' | uv run skf-recommend-scope-type.py
35
+ uv run skf-recommend-scope-type.py --json '{...}'
36
+
37
+ Input (JSON object on stdin or via --json):
38
+ intent — string (combined intent + scope_hint), default ""
39
+ module_count — integer (top-level modules from step-02), default 0
40
+ export_count — integer (named exports from manifest), default 0
41
+ tree — list of repo-relative file paths, default []
42
+ entry_files — optional [{path, content}], default null
43
+ source_type — "source" | "docs-only" | null, default null
44
+ mode — "interactive" | "headless", default "headless"
45
+
46
+ Output (JSON on stdout):
47
+ scope_type — one of full-library | specific-modules | public-api
48
+ | component-library | reference-app | docs-only
49
+ matched_heuristic — "component-registry" | "reference-app-keywords"
50
+ | "specific-modules-naming" | "specific-modules-count"
51
+ | "narrow-public-api" | "default-full-library"
52
+ | "docs-only-shortcircuit"
53
+ signals — object naming the signals that fired (path, count, etc.)
54
+ rationale — one-sentence explanation referencing the signals
55
+
56
+ Exit codes:
57
+ 0 — recommendation produced
58
+ 2 — internal error (bad JSON input, IO failure)
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import argparse
64
+ import json
65
+ import re
66
+ import sys
67
+ from typing import Any
68
+
69
+ REFERENCE_APP_KEYWORDS = (
70
+ "wiring",
71
+ "integration example",
72
+ "integration sample",
73
+ "starter",
74
+ "lifecycle",
75
+ "build config",
76
+ "build-config",
77
+ "demo app",
78
+ "example app",
79
+ "reference app",
80
+ )
81
+
82
+ NARROW_PUBLIC_API_KEYWORDS = (
83
+ "the api",
84
+ "the sdk",
85
+ "public api",
86
+ "client library",
87
+ "client sdk",
88
+ )
89
+
90
+ # Phrases that indicate the user wants only a specific subset of modules
91
+ # rather than the whole library. The match is substring + lowercase, so
92
+ # "just the auth module" / "only the streaming part" / "specifically the
93
+ # parser" all match.
94
+ SPECIFIC_MODULE_NAMING_PATTERNS = (
95
+ re.compile(r"\bjust the\s+\w+", re.IGNORECASE),
96
+ re.compile(r"\bonly the\s+\w+", re.IGNORECASE),
97
+ re.compile(r"\bspecifically the\s+\w+", re.IGNORECASE),
98
+ re.compile(r"\bjust\s+(?:want|need|use)\s+the\s+\w+", re.IGNORECASE),
99
+ re.compile(r"\bonly\s+(?:want|need|use)\s+the\s+\w+", re.IGNORECASE),
100
+ re.compile(r"\bjust\s+\w+\s+module\b", re.IGNORECASE),
101
+ re.compile(r"\bonly\s+\w+\s+module\b", re.IGNORECASE),
102
+ )
103
+
104
+ VALID_MODES = {"interactive", "headless"}
105
+ VALID_SOURCE_TYPES = {None, "source", "docs-only"}
106
+
107
+
108
+ def _die(message: str, code: int = 2) -> None:
109
+ sys.stderr.write(f"skf-recommend-scope-type: {message}\n")
110
+ sys.exit(code)
111
+
112
+
113
+ def _find_registry_files(tree: list[str]) -> list[str]:
114
+ """Return repo-relative paths matching registry.ts / components.ts (any depth)."""
115
+ hits: list[str] = []
116
+ for path in tree:
117
+ if not isinstance(path, str):
118
+ continue
119
+ basename = path.rsplit("/", 1)[-1]
120
+ if basename in {"registry.ts", "components.ts", "registry.tsx", "components.tsx"}:
121
+ hits.append(path)
122
+ return hits
123
+
124
+
125
+ def _registry_entry_count(content: str) -> int:
126
+ """Approximate the number of entries in a registry array literal.
127
+
128
+ Counts top-level `{ ... }` objects within the first array-like body
129
+ we encounter. Approximate by design — used only as a >=10 threshold.
130
+ """
131
+ # Strip line comments to reduce noise; do not bother with block comments.
132
+ cleaned = re.sub(r"//.*", "", content)
133
+ # Find array literals — match `[` followed by whitespace then `{`.
134
+ array_match = re.search(r"\[\s*\{", cleaned)
135
+ if not array_match:
136
+ return 0
137
+ body_start = array_match.start()
138
+ # Walk forward, count balanced top-level `{` matches up to the closing `]`.
139
+ depth = 0
140
+ in_string: str | None = None
141
+ count = 0
142
+ for ch in cleaned[body_start:]:
143
+ if in_string:
144
+ if ch == in_string:
145
+ in_string = None
146
+ continue
147
+ if ch in ("'", '"', "`"):
148
+ in_string = ch
149
+ continue
150
+ if ch == "{":
151
+ if depth == 0:
152
+ count += 1
153
+ depth += 1
154
+ elif ch == "}":
155
+ depth = max(depth - 1, 0)
156
+ elif ch == "]" and depth == 0:
157
+ break
158
+ return count
159
+
160
+
161
+ def _has_component_array_annotation(content: str) -> bool:
162
+ """Detect a `Component[]` type annotation, allowing trivial whitespace and generics."""
163
+ return bool(re.search(r"\bComponent(?:\s*<[^>]*>)?\s*\[\s*\]", content))
164
+
165
+
166
+ def _component_registry_match(
167
+ tree: list[str],
168
+ entry_files: list[dict] | None,
169
+ mode: str,
170
+ ) -> dict | None:
171
+ """Apply rule 1 — component-library.
172
+
173
+ Returns a signals dict on match, or None.
174
+ """
175
+ registry_paths = _find_registry_files(tree)
176
+ if not registry_paths:
177
+ return None
178
+
179
+ # Index entry-file contents by path for O(1) lookup
180
+ contents: dict[str, str] = {}
181
+ if entry_files:
182
+ for ef in entry_files:
183
+ if not isinstance(ef, dict):
184
+ continue
185
+ path = ef.get("path")
186
+ content = ef.get("content")
187
+ if isinstance(path, str) and isinstance(content, str):
188
+ contents[path] = content
189
+
190
+ # When we have contents for any registry file, do the deep check
191
+ for path in registry_paths:
192
+ if path in contents:
193
+ entry_count = _registry_entry_count(contents[path])
194
+ has_annotation = _has_component_array_annotation(contents[path])
195
+ if entry_count >= 10 or has_annotation:
196
+ return {
197
+ "registry_path": path,
198
+ "entry_count": entry_count if entry_count >= 10 else None,
199
+ "component_array_annotation": has_annotation,
200
+ "contents_inspected": True,
201
+ }
202
+ # File present but neither threshold met — content disqualifies
203
+ continue
204
+
205
+ # No contents available for any registry file
206
+ if mode == "headless":
207
+ return {
208
+ "registry_path": registry_paths[0],
209
+ "entry_count": None,
210
+ "component_array_annotation": False,
211
+ "contents_inspected": False,
212
+ }
213
+ # Interactive without contents — fall through; the rule does not match
214
+ return None
215
+
216
+
217
+ def _reference_app_match(intent_lower: str) -> dict | None:
218
+ matches = [kw for kw in REFERENCE_APP_KEYWORDS if kw in intent_lower]
219
+ if matches:
220
+ return {"keywords": matches}
221
+ return None
222
+
223
+
224
+ def _specific_modules_match(intent: str, module_count: int) -> tuple[str, dict] | None:
225
+ naming_hits: list[str] = []
226
+ for pattern in SPECIFIC_MODULE_NAMING_PATTERNS:
227
+ m = pattern.search(intent)
228
+ if m:
229
+ naming_hits.append(m.group(0).strip())
230
+ if naming_hits:
231
+ return ("specific-modules-naming", {"phrases": naming_hits})
232
+ if module_count >= 6:
233
+ return ("specific-modules-count", {"module_count": module_count})
234
+ return None
235
+
236
+
237
+ def _narrow_public_api_match(intent_lower: str, export_count: int) -> dict | None:
238
+ keyword_hits = [kw for kw in NARROW_PUBLIC_API_KEYWORDS if kw in intent_lower]
239
+ if keyword_hits and 0 < export_count <= 8:
240
+ return {"keywords": keyword_hits, "export_count": export_count}
241
+ return None
242
+
243
+
244
+ def recommend(payload: dict) -> dict:
245
+ """Apply the five-rule ladder. Always returns a recommendation."""
246
+ intent = (payload.get("intent") or "").strip()
247
+ intent_lower = intent.lower()
248
+ module_count = int(payload.get("module_count") or 0)
249
+ export_count = int(payload.get("export_count") or 0)
250
+ tree = payload.get("tree") or []
251
+ entry_files = payload.get("entry_files")
252
+ source_type = payload.get("source_type")
253
+ mode = payload.get("mode") or "headless"
254
+
255
+ if source_type not in VALID_SOURCE_TYPES:
256
+ _die(f"source_type must be one of {sorted(t for t in VALID_SOURCE_TYPES if t)} or null; got {source_type!r}")
257
+ if mode not in VALID_MODES:
258
+ _die(f"mode must be one of {sorted(VALID_MODES)}; got {mode!r}")
259
+
260
+ # Short-circuit: docs-only has no source surface to scope
261
+ if source_type == "docs-only":
262
+ return {
263
+ "scope_type": "docs-only",
264
+ "matched_heuristic": "docs-only-shortcircuit",
265
+ "signals": {"source_type": "docs-only"},
266
+ "rationale": "source_type is docs-only — there is no source surface to scope, so the brief uses the docs-only template.",
267
+ }
268
+
269
+ # Rule 1 — component-library
270
+ cr = _component_registry_match(tree, entry_files, mode)
271
+ if cr:
272
+ rationale_bits = [f"a component registry was detected at {cr['registry_path']}"]
273
+ if cr.get("entry_count"):
274
+ rationale_bits.append(f"with {cr['entry_count']} entries")
275
+ if cr.get("component_array_annotation"):
276
+ rationale_bits.append("and a Component[] type annotation")
277
+ if not cr.get("contents_inspected"):
278
+ rationale_bits.append("(presence-only match — file contents not inspected in headless mode)")
279
+ return {
280
+ "scope_type": "component-library",
281
+ "matched_heuristic": "component-registry",
282
+ "signals": cr,
283
+ "rationale": "Component Library because " + " ".join(rationale_bits) + ".",
284
+ }
285
+
286
+ # Rule 2 — reference-app
287
+ ra = _reference_app_match(intent_lower)
288
+ if ra:
289
+ kw_str = ", ".join(f"'{k}'" for k in ra["keywords"])
290
+ return {
291
+ "scope_type": "reference-app",
292
+ "matched_heuristic": "reference-app-keywords",
293
+ "signals": ra,
294
+ "rationale": f"Reference App because the intent mentions {kw_str} — language consistent with a wiring-pattern skill rather than a library API.",
295
+ }
296
+
297
+ # Rule 3 — specific-modules
298
+ sm = _specific_modules_match(intent, module_count)
299
+ if sm:
300
+ heuristic, signals = sm
301
+ if heuristic == "specific-modules-naming":
302
+ phrases = ", ".join(f"'{p}'" for p in signals["phrases"])
303
+ rationale = f"Specific Modules because the intent names a subset ({phrases})."
304
+ else:
305
+ rationale = f"Specific Modules because the analysis surfaced {signals['module_count']} top-level modules — likely too many for a single cohesive scope."
306
+ return {
307
+ "scope_type": "specific-modules",
308
+ "matched_heuristic": heuristic,
309
+ "signals": signals,
310
+ "rationale": rationale,
311
+ }
312
+
313
+ # Rule 4 — narrow public API
314
+ pa = _narrow_public_api_match(intent_lower, export_count)
315
+ if pa:
316
+ kw_str = ", ".join(f"'{k}'" for k in pa["keywords"])
317
+ return {
318
+ "scope_type": "public-api",
319
+ "matched_heuristic": "narrow-public-api",
320
+ "signals": pa,
321
+ "rationale": f"Public API Only because the intent mentions {kw_str} and the manifest exposes {pa['export_count']} named exports — a clear narrow public surface.",
322
+ }
323
+
324
+ # Rule 5 — fallback
325
+ return {
326
+ "scope_type": "full-library",
327
+ "matched_heuristic": "default-full-library",
328
+ "signals": {
329
+ "module_count": module_count,
330
+ "export_count": export_count,
331
+ },
332
+ "rationale": "Full Library — no signal matched a narrower scope, so the default is to cover everything.",
333
+ }
334
+
335
+
336
+ def _parse_argv(argv: list[str]) -> dict:
337
+ parser = argparse.ArgumentParser(
338
+ description="Recommend a scope type by applying the documented 5-rule heuristic ladder.",
339
+ )
340
+ parser.add_argument(
341
+ "--json",
342
+ help="JSON payload (alternative to stdin)",
343
+ )
344
+ args = parser.parse_args(argv)
345
+ if args.json is not None:
346
+ raw = args.json
347
+ else:
348
+ raw = sys.stdin.read()
349
+ if not raw or not raw.strip():
350
+ _die("empty input (expected JSON payload on stdin or via --json)")
351
+ try:
352
+ payload = json.loads(raw)
353
+ except json.JSONDecodeError as e:
354
+ _die(f"invalid JSON input: {e}")
355
+ if not isinstance(payload, dict):
356
+ _die("payload must be a JSON object")
357
+ return payload
358
+
359
+
360
+ def main(argv: list[str]) -> int:
361
+ payload = _parse_argv(argv)
362
+ result = recommend(payload)
363
+ json.dump(result, sys.stdout, ensure_ascii=False)
364
+ sys.stdout.write("\n")
365
+ return 0
366
+
367
+
368
+ if __name__ == "__main__":
369
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,192 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Render Quick Metadata — render metadata.json per the canonical
6
+ skill-template.md schema with quick-skill-specific population rules.
7
+
8
+ Pure renderer — no I/O beyond stdin/stdout. Reads a JSON payload of
9
+ extracted state on stdin and emits the corresponding metadata.json
10
+ on stdout. Replaces the hand-assembly the LLM previously did in
11
+ skf-quick-skill step-04 §4.
12
+
13
+ Constants vs. input-derived split mirrors step-04's documentation:
14
+
15
+ Constants (always literal):
16
+ skill_type "single"
17
+ spec_version "1.3"
18
+ source_authority "community"
19
+ confidence_tier "Quick"
20
+ generated_by "quick-skill"
21
+ confidence_distribution.t1, t2, t3 0
22
+ tool_versions.ast_grep, tool_versions.qmd null
23
+ stats.exports_internal, stats.scripts_count, stats.assets_count 0
24
+ stats.public_api_coverage, stats.total_coverage 1.0
25
+
26
+ Input-derived (from stdin payload):
27
+ name, version, description, language, source_repo,
28
+ source_root, source_commit, source_package,
29
+ exports[], dependencies[], compatibility,
30
+ provenance.language_hint, provenance.scope_hint,
31
+ tool_versions.skf
32
+
33
+ Computed:
34
+ generation_date ISO 8601 UTC ("YYYY-MM-DDTHH:MM:SSZ")
35
+ confidence_distribution.t1_low count(exports)
36
+ stats.exports_documented / public_api / total count(exports)
37
+
38
+ Input JSON shape (stdin):
39
+
40
+ {
41
+ "name": "foo",
42
+ "version": "1.2.3", (default "1.0.0")
43
+ "description": "...", (default "")
44
+ "language": "python",
45
+ "source_repo": "https://github.com/x/y",
46
+ "source_root": "src/foo", (optional)
47
+ "source_commit": "abc123", (optional)
48
+ "source_package": "foo", (optional)
49
+ "exports": [{"name":"fn","type":"def"}] or list of strings
50
+ "dependencies": ["a", "b"], (default [])
51
+ "compatibility": ">=3.10", (optional, default "")
52
+ "language_hint": null, (echoed verbatim)
53
+ "scope_hint": null, (echoed verbatim)
54
+ "skf_version": "1.2.0" (default "unknown")
55
+ }
56
+
57
+ CLI:
58
+
59
+ python3 skf-render-quick-metadata.py < input.json
60
+
61
+ Exit codes:
62
+
63
+ 0 success
64
+ 1 payload-level error (missing required field)
65
+ 2 stdin / argparse / JSON-decode error
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import argparse
71
+ import datetime as dt
72
+ import json
73
+ import sys
74
+
75
+
76
+ REQUIRED_FIELDS = ("name", "language", "source_repo")
77
+
78
+
79
+ def _normalize_exports(raw) -> list[str]:
80
+ """Accept either a list of strings or a list of {name, type, ...} dicts.
81
+ Returns a flat list of export names in declaration order, deduplicated.
82
+ """
83
+ out: list[str] = []
84
+ seen: set[str] = set()
85
+ if not isinstance(raw, list):
86
+ return out
87
+ for item in raw:
88
+ name: str | None = None
89
+ if isinstance(item, str):
90
+ name = item
91
+ elif isinstance(item, dict):
92
+ v = item.get("name")
93
+ if isinstance(v, str):
94
+ name = v
95
+ if name and name not in seen:
96
+ seen.add(name)
97
+ out.append(name)
98
+ return out
99
+
100
+
101
+ def _iso_utc_now() -> str:
102
+ """Returns 'YYYY-MM-DDTHH:MM:SSZ' for the current UTC instant."""
103
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
104
+
105
+
106
+ def render_metadata(payload: dict, *, now_fn=_iso_utc_now) -> dict:
107
+ """Render the metadata.json envelope. Pass `now_fn` to inject a
108
+ deterministic timestamp in tests.
109
+ """
110
+ missing = [f for f in REQUIRED_FIELDS if not payload.get(f)]
111
+ if missing:
112
+ return {"_error": f"missing required field(s): {', '.join(missing)}"}
113
+
114
+ exports = _normalize_exports(payload.get("exports"))
115
+ export_count = len(exports)
116
+
117
+ metadata = {
118
+ "name": payload["name"],
119
+ "version": payload.get("version") or "1.0.0",
120
+ "description": payload.get("description") or "",
121
+ "skill_type": "single",
122
+ "source_authority": "community",
123
+ "source_repo": payload["source_repo"],
124
+ "source_root": payload.get("source_root") or "",
125
+ "source_commit": payload.get("source_commit") or "",
126
+ "source_package": payload.get("source_package") or payload["name"],
127
+ "language": payload["language"],
128
+ "generated_by": "quick-skill",
129
+ "generation_date": now_fn(),
130
+ "confidence_tier": "Quick",
131
+ "spec_version": "1.3",
132
+ "exports": exports,
133
+ "confidence_distribution": {
134
+ "t1": 0,
135
+ "t1_low": export_count,
136
+ "t2": 0,
137
+ "t3": 0,
138
+ },
139
+ "tool_versions": {
140
+ "ast_grep": None,
141
+ "qmd": None,
142
+ "skf": payload.get("skf_version") or "unknown",
143
+ },
144
+ "stats": {
145
+ "exports_documented": export_count,
146
+ "exports_public_api": export_count,
147
+ "exports_internal": 0,
148
+ "exports_total": export_count,
149
+ "public_api_coverage": 1.0,
150
+ "total_coverage": 1.0,
151
+ "scripts_count": 0,
152
+ "assets_count": 0,
153
+ },
154
+ "dependencies": payload.get("dependencies") or [],
155
+ "compatibility": payload.get("compatibility") or "",
156
+ "provenance": {
157
+ "language_hint": payload.get("language_hint"),
158
+ "scope_hint": payload.get("scope_hint"),
159
+ },
160
+ }
161
+ return metadata
162
+
163
+
164
+ def main(argv: list[str]) -> int:
165
+ parser = argparse.ArgumentParser(
166
+ description="Render skf-quick-skill metadata.json from extracted state on stdin.",
167
+ )
168
+ parser.parse_args(argv) # currently no flags; kept for forward compat
169
+
170
+ raw = sys.stdin.read()
171
+ if not raw.strip():
172
+ sys.stderr.write("error: no input on stdin (expected JSON payload)\n")
173
+ return 2
174
+ try:
175
+ payload = json.loads(raw)
176
+ except json.JSONDecodeError as e:
177
+ sys.stderr.write(f"error: stdin JSON parse error: {e}\n")
178
+ return 2
179
+ if not isinstance(payload, dict):
180
+ sys.stderr.write("error: stdin payload must be a JSON object\n")
181
+ return 2
182
+
183
+ result = render_metadata(payload)
184
+ if "_error" in result:
185
+ sys.stderr.write(f"error: {result['_error']}\n")
186
+ return 1
187
+ print(json.dumps(result, indent=2))
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main(sys.argv[1:]))