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,264 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Resolve Package — resolve a package name to a GitHub repository URL.
6
+
7
+ Walks the canonical fallback chain documented in
8
+ `src/skf-quick-skill/references/registry-resolution.md`:
9
+
10
+ 1. npm registry (JavaScript/TypeScript)
11
+ 2. PyPI registry (Python)
12
+ 3. crates.io registry (Rust)
13
+
14
+ Per-call timeout (default 10s); a timeout is treated as a soft failure
15
+ and the resolver falls through to the next entry. Web-search fallback
16
+ is intentionally NOT in this helper — registries are deterministic;
17
+ web search is judgment, and stays in the LLM step.
18
+
19
+ CLI:
20
+
21
+ python3 skf-resolve-package.py <package_name> [--timeout 10]
22
+
23
+ Output JSON (stdout):
24
+
25
+ status: "ok" | "fallthrough"
26
+ package_name: "<input>"
27
+ resolved_url: "https://github.com/<owner>/<repo>" (when status == "ok")
28
+ repo_owner: "<owner>" (when status == "ok")
29
+ repo_name: "<repo>" (when status == "ok")
30
+ registry_used: "npm" | "pypi" | "crates" (when status == "ok")
31
+ registries_tried: ["npm", ...]
32
+ registry_outcomes: {"npm": "ok|404|timeout|error|no-github-link", ...}
33
+
34
+ Exit codes:
35
+
36
+ 0 status == "ok"
37
+ 1 status == "fallthrough" — every registry replied without a GitHub
38
+ URL; the LLM step should fall back to web search.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import re
46
+ import socket
47
+ import sys
48
+ import urllib.error
49
+ import urllib.parse
50
+ import urllib.request
51
+ from typing import Optional
52
+
53
+ REGISTRY_TIMEOUT_SECONDS = 10.0
54
+ USER_AGENT = "skf-resolve-package/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
55
+
56
+ _GITHUB_URL_RE = re.compile(
57
+ r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ def parse_github_url(url: str) -> Optional[tuple[str, str, str]]:
63
+ """Parse a GitHub URL/string into (canonical_url, owner, repo).
64
+
65
+ Handles the variants registries actually emit:
66
+ - https://github.com/owner/repo
67
+ - http://github.com/owner/repo
68
+ - github.com/owner/repo
69
+ - git+https://github.com/owner/repo.git
70
+ - git@github.com:owner/repo.git
71
+ - github:owner/repo (npm shortcut)
72
+ """
73
+ if not url or not isinstance(url, str):
74
+ return None
75
+
76
+ s = url.strip()
77
+
78
+ if s.startswith("github:"):
79
+ rest = s[len("github:") :]
80
+ if "/" in rest:
81
+ owner, _, repo = rest.partition("/")
82
+ repo = repo.removesuffix(".git").rstrip("/")
83
+ if owner and repo:
84
+ return f"https://github.com/{owner}/{repo}", owner, repo
85
+ return None
86
+
87
+ if s.startswith("git+"):
88
+ s = s[len("git+") :]
89
+
90
+ if s.startswith("git@github.com:"):
91
+ rest = s[len("git@github.com:") :]
92
+ rest = rest.removesuffix(".git").rstrip("/")
93
+ if "/" in rest:
94
+ owner, _, repo = rest.partition("/")
95
+ if owner and repo:
96
+ return f"https://github.com/{owner}/{repo}", owner, repo
97
+ return None
98
+
99
+ if s.startswith("github.com/"):
100
+ s = "https://" + s
101
+
102
+ m = _GITHUB_URL_RE.match(s)
103
+ if m:
104
+ owner, repo = m.group(1), m.group(2)
105
+ return f"https://github.com/{owner}/{repo}", owner, repo
106
+
107
+ return None
108
+
109
+
110
+ def _http_get_json(url: str, timeout: float) -> tuple[Optional[dict], str]:
111
+ """Fetch JSON. Returns (payload_or_None, outcome).
112
+
113
+ Outcome values:
114
+ "ok" — valid JSON object returned
115
+ "404" — HTTP 404 (package does not exist on this registry)
116
+ "timeout" — socket / urlopen timed out
117
+ "error" — any other transport / parse error
118
+ """
119
+ req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
120
+ try:
121
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
122
+ payload = json.loads(resp.read().decode("utf-8"))
123
+ if isinstance(payload, dict):
124
+ return payload, "ok"
125
+ return None, "error"
126
+ except urllib.error.HTTPError as e:
127
+ return None, "404" if e.code == 404 else "error"
128
+ except urllib.error.URLError as e:
129
+ if isinstance(getattr(e, "reason", None), (socket.timeout, TimeoutError)):
130
+ return None, "timeout"
131
+ return None, "error"
132
+ except (TimeoutError, socket.timeout):
133
+ return None, "timeout"
134
+ except (ValueError, json.JSONDecodeError):
135
+ return None, "error"
136
+
137
+
138
+ def try_npm(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
139
+ """Try the npm registry. Returns (parsed_or_None, outcome)."""
140
+ encoded = urllib.parse.quote(package_name, safe="@")
141
+ url = f"https://registry.npmjs.org/{encoded}"
142
+ payload, outcome = _http_get_json(url, timeout)
143
+ if payload is None:
144
+ return None, outcome
145
+ candidates: list[str] = []
146
+ repo = payload.get("repository")
147
+ if isinstance(repo, dict) and isinstance(repo.get("url"), str):
148
+ candidates.append(repo["url"])
149
+ elif isinstance(repo, str):
150
+ candidates.append(repo)
151
+ homepage = payload.get("homepage")
152
+ if isinstance(homepage, str):
153
+ candidates.append(homepage)
154
+ for c in candidates:
155
+ parsed = parse_github_url(c)
156
+ if parsed:
157
+ return parsed, "ok"
158
+ return None, "no-github-link"
159
+
160
+
161
+ def try_pypi(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
162
+ """Try the PyPI registry. Returns (parsed_or_None, outcome)."""
163
+ encoded = urllib.parse.quote(package_name, safe="")
164
+ url = f"https://pypi.org/pypi/{encoded}/json"
165
+ payload, outcome = _http_get_json(url, timeout)
166
+ if payload is None:
167
+ return None, outcome
168
+ info = payload.get("info") or {}
169
+ candidates: list[str] = []
170
+ project_urls = info.get("project_urls") or {}
171
+ if isinstance(project_urls, dict):
172
+ for key in ("Source", "Source Code", "Repository", "Homepage"):
173
+ v = project_urls.get(key)
174
+ if isinstance(v, str):
175
+ candidates.append(v)
176
+ home_page = info.get("home_page")
177
+ if isinstance(home_page, str):
178
+ candidates.append(home_page)
179
+ for c in candidates:
180
+ parsed = parse_github_url(c)
181
+ if parsed:
182
+ return parsed, "ok"
183
+ return None, "no-github-link"
184
+
185
+
186
+ def try_crates(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
187
+ """Try the crates.io registry. Returns (parsed_or_None, outcome)."""
188
+ encoded = urllib.parse.quote(package_name, safe="")
189
+ url = f"https://crates.io/api/v1/crates/{encoded}"
190
+ payload, outcome = _http_get_json(url, timeout)
191
+ if payload is None:
192
+ return None, outcome
193
+ crate = payload.get("crate") or {}
194
+ for key in ("repository", "homepage"):
195
+ v = crate.get(key)
196
+ if isinstance(v, str):
197
+ parsed = parse_github_url(v)
198
+ if parsed:
199
+ return parsed, "ok"
200
+ return None, "no-github-link"
201
+
202
+
203
+ _RESOLVER_NAMES: tuple[tuple[str, str], ...] = (
204
+ ("npm", "try_npm"),
205
+ ("pypi", "try_pypi"),
206
+ ("crates", "try_crates"),
207
+ )
208
+
209
+
210
+ def resolve_package(package_name: str, timeout: float = REGISTRY_TIMEOUT_SECONDS) -> dict:
211
+ registries_tried: list[str] = []
212
+ outcomes: dict[str, str] = {}
213
+
214
+ # Dynamic lookup so test code can monkeypatch try_npm / try_pypi / try_crates
215
+ # without re-binding entries in a module-level tuple of function refs.
216
+ for name, fn_name in _RESOLVER_NAMES:
217
+ registries_tried.append(name)
218
+ fn = globals()[fn_name]
219
+ result, outcome = fn(package_name, timeout)
220
+ outcomes[name] = outcome
221
+ if result is not None:
222
+ url, owner, repo = result
223
+ return {
224
+ "status": "ok",
225
+ "package_name": package_name,
226
+ "resolved_url": url,
227
+ "repo_owner": owner,
228
+ "repo_name": repo,
229
+ "registry_used": name,
230
+ "registries_tried": registries_tried,
231
+ "registry_outcomes": outcomes,
232
+ }
233
+
234
+ return {
235
+ "status": "fallthrough",
236
+ "package_name": package_name,
237
+ "registries_tried": registries_tried,
238
+ "registry_outcomes": outcomes,
239
+ }
240
+
241
+
242
+ def main(argv: list[str]) -> int:
243
+ parser = argparse.ArgumentParser(
244
+ description="Resolve a package name to a GitHub repository URL via npm/PyPI/crates.io.",
245
+ )
246
+ parser.add_argument(
247
+ "package_name",
248
+ help="Package name to resolve (e.g., lodash, @tanstack/query, requests, serde).",
249
+ )
250
+ parser.add_argument(
251
+ "--timeout",
252
+ type=float,
253
+ default=REGISTRY_TIMEOUT_SECONDS,
254
+ help=f"Per-registry timeout in seconds (default: {REGISTRY_TIMEOUT_SECONDS}).",
255
+ )
256
+ args = parser.parse_args(argv)
257
+
258
+ result = resolve_package(args.package_name, timeout=args.timeout)
259
+ print(json.dumps(result, indent=2))
260
+ return 0 if result["status"] == "ok" else 1
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,293 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Validate Brief Inputs — pre-pass validation for brief-skill headless invocation.
6
+
7
+ Validates and normalizes the headless argument set passed to skf-brief-skill
8
+ before the workflow's interactive sequence runs. Catches malformed inputs at
9
+ point of capture instead of letting them surface 5+ minutes later in step-02
10
+ or step-05.
11
+
12
+ CLI:
13
+ uv run skf-validate-brief-inputs.py --json '{...}'
14
+ echo '{...}' | uv run skf-validate-brief-inputs.py
15
+
16
+ Input (JSON object on stdin or via --json):
17
+ Required:
18
+ target_repo — string (URL or path); error if absent
19
+ skill_name — string (kebab-case); error if absent or malformed
20
+
21
+ Optional with enum constraints:
22
+ source_type — "source" | "docs-only" (default "source")
23
+ source_authority — "official" | "community" | "internal" (default "community")
24
+ scope_type — "full-library" | "specific-modules" | "public-api"
25
+ | "component-library" | "reference-app" | "docs-only"
26
+ scripts_intent — "detect" | "none" | free-text (default "detect")
27
+ assets_intent — "detect" | "none" | free-text (default "detect")
28
+
29
+ Optional with format constraints:
30
+ target_version — loose semver string (matches 1.2.3, 1.2.3-rc.1, 1.2.3+build.5)
31
+
32
+ Conditional:
33
+ doc_urls — required when source_type == "docs-only"
34
+
35
+ Free-text / pass-through:
36
+ scope_hint, language_hint, intent, include, exclude, force
37
+
38
+ Unrecognized keys are passed through unchanged but flagged as warnings.
39
+
40
+ Output (JSON on stdout):
41
+ {
42
+ "valid": bool,
43
+ "errors": [{"field": "...", "message": "..."}, ...],
44
+ "warnings": [{"field": "...", "message": "..."}, ...],
45
+ "normalized": { ...input dict with defaults applied... },
46
+ "halt_reason": "input-missing" | "input-invalid" | null
47
+ }
48
+
49
+ Exit codes:
50
+ 0 — valid (errors empty)
51
+ 1 — invalid (errors present); halt_reason is set
52
+ 2 — internal error (bad JSON input, IO failure)
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import argparse
58
+ import json
59
+ import re
60
+ import sys
61
+ from typing import Any
62
+
63
+ KNOWN_FIELDS = {
64
+ "target_repo",
65
+ "skill_name",
66
+ "source_type",
67
+ "source_authority",
68
+ "scope_type",
69
+ "target_version",
70
+ "doc_urls",
71
+ "scope_hint",
72
+ "language_hint",
73
+ "intent",
74
+ "scripts_intent",
75
+ "assets_intent",
76
+ "include",
77
+ "exclude",
78
+ "force",
79
+ }
80
+ # `preset` is intentionally NOT in KNOWN_FIELDS — it is consumed at the step-01 §8 GATE
81
+ # (the LLM merges the named preset YAML into the args dict and drops the `preset` key
82
+ # before calling the validator). If the key leaks through, the validator's existing
83
+ # unknown-field handling emits `"unrecognized field 'preset' — passed through unchanged"`
84
+ # in `warnings[]` so the missed drop is debuggable rather than silent.
85
+
86
+ VALID_SOURCE_TYPES = {"source", "docs-only"}
87
+ VALID_SOURCE_AUTHORITIES = {"official", "community", "internal"}
88
+ VALID_SCOPE_TYPES = {
89
+ "full-library",
90
+ "specific-modules",
91
+ "public-api",
92
+ "component-library",
93
+ "reference-app",
94
+ "docs-only",
95
+ }
96
+
97
+ KEBAB_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
98
+ # Require full X.Y.Z form (with optional v prefix and pre-release/build suffix).
99
+ # Loose forms like `1`, `1.2`, `v2` are rejected — the user should write the
100
+ # explicit triple. CalVer (e.g. 2024.04.01) is accepted because it satisfies
101
+ # the X.Y.Z shape.
102
+ SEMVER_RE = re.compile(
103
+ r"^v?\d+\.\d+\.\d+([.\-+][0-9A-Za-z][0-9A-Za-z.\-+]*)?$"
104
+ )
105
+ URL_RE = re.compile(r"^https?://", re.IGNORECASE)
106
+
107
+
108
+ def _err(field: str, message: str) -> dict[str, str]:
109
+ return {"field": field, "message": message}
110
+
111
+
112
+ def validate(inp: dict[str, Any]) -> dict[str, Any]:
113
+ errors: list[dict[str, str]] = []
114
+ warnings: list[dict[str, str]] = []
115
+
116
+ # Required: target_repo, skill_name
117
+ target_repo = inp.get("target_repo")
118
+ skill_name = inp.get("skill_name")
119
+ if not target_repo:
120
+ errors.append(_err("target_repo", "missing required argument target_repo"))
121
+ if not skill_name:
122
+ errors.append(_err("skill_name", "missing required argument skill_name"))
123
+
124
+ # skill_name format
125
+ if skill_name and isinstance(skill_name, str) and not KEBAB_RE.match(skill_name):
126
+ errors.append(
127
+ _err(
128
+ "skill_name",
129
+ f"skill_name must be kebab-case (lowercase letters/digits/hyphens, "
130
+ f"no leading or trailing hyphen). Got: {skill_name!r}",
131
+ )
132
+ )
133
+
134
+ # source_type enum
135
+ source_type_raw = inp.get("source_type", "source")
136
+ if source_type_raw not in VALID_SOURCE_TYPES:
137
+ errors.append(
138
+ _err(
139
+ "source_type",
140
+ f"source_type must be one of {sorted(VALID_SOURCE_TYPES)}. Got: {source_type_raw!r}",
141
+ )
142
+ )
143
+ # Treat as default for downstream conditional logic
144
+ source_type = "source"
145
+ else:
146
+ source_type = source_type_raw
147
+
148
+ # source_authority enum
149
+ source_authority_raw = inp.get("source_authority", "community")
150
+ if source_authority_raw not in VALID_SOURCE_AUTHORITIES:
151
+ errors.append(
152
+ _err(
153
+ "source_authority",
154
+ f"source_authority must be one of {sorted(VALID_SOURCE_AUTHORITIES)}. "
155
+ f"Got: {source_authority_raw!r}",
156
+ )
157
+ )
158
+
159
+ # scope_type enum (when present)
160
+ scope_type = inp.get("scope_type")
161
+ if scope_type is not None and scope_type not in VALID_SCOPE_TYPES:
162
+ errors.append(
163
+ _err(
164
+ "scope_type",
165
+ f"scope_type must be one of {sorted(VALID_SCOPE_TYPES)}. Got: {scope_type!r}",
166
+ )
167
+ )
168
+
169
+ # target_version semver-ish
170
+ target_version = inp.get("target_version")
171
+ if target_version is not None:
172
+ if not isinstance(target_version, str):
173
+ errors.append(
174
+ _err(
175
+ "target_version",
176
+ f"target_version must be a string. Got type {type(target_version).__name__}",
177
+ )
178
+ )
179
+ elif not SEMVER_RE.match(target_version):
180
+ errors.append(
181
+ _err(
182
+ "target_version",
183
+ f"target_version does not look like semver. Got: {target_version!r}. "
184
+ f"Expected forms: 1.2.3, v1.2.3, 1.2.3-rc.1, 1.2.3+build.5. "
185
+ f"Partial forms like '1' or '1.2' are not accepted — write the explicit triple.",
186
+ )
187
+ )
188
+
189
+ # docs-only requires doc_urls
190
+ doc_urls = inp.get("doc_urls")
191
+ if source_type == "docs-only" and not doc_urls:
192
+ errors.append(
193
+ _err("doc_urls", "doc_urls is required when source_type is docs-only")
194
+ )
195
+
196
+ # target_repo shape (warning only — script doesn't HEAD-check)
197
+ if isinstance(target_repo, str) and target_repo:
198
+ looks_like_url = URL_RE.match(target_repo) is not None
199
+ looks_like_path = (
200
+ target_repo.startswith("/")
201
+ or target_repo.startswith("./")
202
+ or target_repo.startswith("~")
203
+ )
204
+ if not (looks_like_url or looks_like_path):
205
+ warnings.append(
206
+ _err(
207
+ "target_repo",
208
+ f"target_repo does not look like a URL or absolute/relative path: {target_repo!r}",
209
+ )
210
+ )
211
+
212
+ # Unknown fields → warn
213
+ for key in inp:
214
+ if key not in KNOWN_FIELDS:
215
+ warnings.append(_err(key, f"unrecognized field {key!r} — passed through unchanged"))
216
+
217
+ # Build normalized payload
218
+ normalized: dict[str, Any] = dict(inp)
219
+ normalized.setdefault("source_type", "source")
220
+ # `source_authority` is intentionally NOT setdefault'd here for source-type targets:
221
+ # step-01 §3.3's headless detection branch runs `gh api user` and may resolve to
222
+ # `official` for repos owned by the authenticated user. Stamping a default at
223
+ # validator time would pre-empt the detection because step-01 §8 GATE treats the
224
+ # `normalized` object as the source of truth. Absence is the signal "run detection."
225
+ # The docs-only branch below still forces `community` since detection cannot apply.
226
+ normalized.setdefault("scripts_intent", "detect")
227
+ normalized.setdefault("assets_intent", "detect")
228
+
229
+ # Force community when docs-only
230
+ if normalized.get("source_type") == "docs-only":
231
+ if normalized.get("source_authority") not in (None, "community"):
232
+ warnings.append(
233
+ _err(
234
+ "source_authority",
235
+ f"source_authority forced to 'community' because source_type=docs-only "
236
+ f"(was {normalized['source_authority']!r})",
237
+ )
238
+ )
239
+ normalized["source_authority"] = "community"
240
+
241
+ # halt_reason classification
242
+ if errors:
243
+ missing_required = any(
244
+ e["message"].startswith("missing required") or "is required" in e["message"]
245
+ for e in errors
246
+ )
247
+ halt_reason = "input-missing" if missing_required else "input-invalid"
248
+ else:
249
+ halt_reason = None
250
+
251
+ return {
252
+ "valid": not errors,
253
+ "errors": errors,
254
+ "warnings": warnings,
255
+ "normalized": normalized,
256
+ "halt_reason": halt_reason,
257
+ }
258
+
259
+
260
+ def main() -> int:
261
+ p = argparse.ArgumentParser(prog="skf-validate-brief-inputs")
262
+ p.add_argument(
263
+ "--json",
264
+ help="JSON object as a string. If omitted, the script reads JSON from stdin.",
265
+ )
266
+ args = p.parse_args()
267
+
268
+ raw = args.json
269
+ if raw is None:
270
+ raw = sys.stdin.read()
271
+
272
+ if not raw or not raw.strip():
273
+ sys.stderr.write("skf-validate-brief-inputs: empty input\n")
274
+ return 2
275
+
276
+ try:
277
+ inp = json.loads(raw)
278
+ except json.JSONDecodeError as e:
279
+ sys.stderr.write(f"skf-validate-brief-inputs: invalid JSON input: {e}\n")
280
+ return 2
281
+
282
+ if not isinstance(inp, dict):
283
+ sys.stderr.write("skf-validate-brief-inputs: input must be a JSON object\n")
284
+ return 2
285
+
286
+ result = validate(inp)
287
+ json.dump(result, sys.stdout, indent=2, sort_keys=True)
288
+ sys.stdout.write("\n")
289
+ return 0 if result["valid"] else 1
290
+
291
+
292
+ if __name__ == "__main__":
293
+ sys.exit(main())
@@ -9,6 +9,7 @@ schema against agentskills.io specification. Outputs JSON validation results.
9
9
 
10
10
  CLI: python3 skf-validate-output.py <skill-package-dir>
11
11
  python3 skf-validate-output.py <skill-package-dir> --generated-by quick-skill
12
+ python3 skf-validate-output.py <skill-package-dir> --skip-frontmatter
12
13
  """
13
14
 
14
15
  from __future__ import annotations
@@ -73,7 +74,7 @@ def validate_body_structure(content):
73
74
  issues = []
74
75
  body = content.split("---", 2)[-1] if content.startswith("---") else content
75
76
 
76
- required_sections = ["Overview", "Key Exports", "Usage"]
77
+ required_sections = ["Overview", "Description", "Key Exports", "Usage"]
77
78
  for section in required_sections:
78
79
  pattern = rf"^##\s+.*{re.escape(section)}"
79
80
  if not re.search(pattern, body, re.MULTILINE | re.IGNORECASE):
@@ -155,8 +156,13 @@ def validate_metadata_json(data, generated_by=None):
155
156
  return issues
156
157
 
157
158
 
158
- def validate_skill_package(skill_dir, generated_by=None):
159
- """Validate a complete skill package directory."""
159
+ def validate_skill_package(skill_dir, generated_by=None, skip_frontmatter=False):
160
+ """Validate a complete skill package directory.
161
+
162
+ When `skip_frontmatter` is True, the SKILL.md frontmatter pass is omitted —
163
+ intended for callers that already validated frontmatter via skill-check or
164
+ skf-validate-frontmatter.py and only want body / snippet / metadata checks.
165
+ """
160
166
  skill_dir = Path(skill_dir)
161
167
  skill_name = skill_dir.name
162
168
 
@@ -183,9 +189,14 @@ def validate_skill_package(skill_dir, generated_by=None):
183
189
  skill_md_path = files["SKILL.md"]
184
190
  if skill_md_path.exists():
185
191
  content = skill_md_path.read_text(encoding="utf-8")
186
- fm_issues = validate_frontmatter(content, skill_name)
192
+ if skip_frontmatter:
193
+ fm_section = {"skipped": "frontmatter validation skipped (--skip-frontmatter)"}
194
+ fm_issues = []
195
+ else:
196
+ fm_issues = validate_frontmatter(content, skill_name)
197
+ fm_section = fm_issues
187
198
  body_issues = validate_body_structure(content)
188
- result["validation"]["skill_md"] = {"frontmatter": fm_issues, "body": body_issues}
199
+ result["validation"]["skill_md"] = {"frontmatter": fm_section, "body": body_issues}
189
200
  for issue in fm_issues + body_issues:
190
201
  result["summary"]["total_issues"] += 1
191
202
  result["summary"]["by_severity"][issue["severity"]] += 1
@@ -232,7 +243,11 @@ def validate_skill_package(skill_dir, generated_by=None):
232
243
 
233
244
  if __name__ == "__main__":
234
245
  if len(sys.argv) < 2:
235
- print("Usage: python3 skf-validate-output.py <skill-package-dir> [--generated-by <generator>]", file=sys.stderr)
246
+ print(
247
+ "Usage: python3 skf-validate-output.py <skill-package-dir> "
248
+ "[--generated-by <generator>] [--skip-frontmatter]",
249
+ file=sys.stderr,
250
+ )
236
251
  sys.exit(1)
237
252
 
238
253
  pkg_dir = sys.argv[1]
@@ -242,6 +257,8 @@ if __name__ == "__main__":
242
257
  if idx + 1 < len(sys.argv):
243
258
  gen_by = sys.argv[idx + 1]
244
259
 
245
- result = validate_skill_package(pkg_dir, generated_by=gen_by)
260
+ skip_fm = "--skip-frontmatter" in sys.argv
261
+
262
+ result = validate_skill_package(pkg_dir, generated_by=gen_by, skip_frontmatter=skip_fm)
246
263
  print(json.dumps(result, indent=2))
247
264
  sys.exit(0 if result["result"] == "PASS" else 1)