bmad-module-skill-forge 1.1.0 → 1.3.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 +6 -4
- package/docs/_data/pinned.yaml +1 -1
- package/docs/bmad-synergy.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/skill-model.md +26 -32
- package/docs/troubleshooting.md +13 -1
- package/docs/why-skf.md +5 -4
- package/docs/workflows.md +53 -0
- package/package.json +2 -2
- package/src/knowledge/ccc-bridge.md +1 -1
- package/src/shared/references/output-contract-schema.md +10 -0
- 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-extract-public-api.py +505 -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-render-quick-metadata.py +192 -0
- package/src/shared/scripts/skf-resolve-package.py +264 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +9 -3
- package/src/shared/scripts/skf-validate-output.py +24 -7
- package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
- package/src/skf-quick-skill/SKILL.md +178 -10
- package/src/skf-quick-skill/assets/skill-template.md +5 -1
- package/src/skf-quick-skill/references/registry-resolution.md +2 -0
- package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
- package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
- package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
- package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
- package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
- package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
- package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
- 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-quick-skill/steps-c/step-05-validate.md +0 -193
|
@@ -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:]))
|
|
@@ -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:]))
|
|
@@ -11,9 +11,15 @@ agentskills/agentskills/skills-ref/src/skills_ref/validator.py.
|
|
|
11
11
|
Designed for pre-check use in test-skill, create-skill, update-skill, and
|
|
12
12
|
export-skill workflows. Returns structured JSON for deterministic integration.
|
|
13
13
|
|
|
14
|
-
CLI
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
|
|
15
|
+
above is auto-resolved on first call and cached. `docs/getting-started.md`
|
|
16
|
+
documents uv as the runtime prerequisite for exactly this. Bare
|
|
17
|
+
`python3` will fail with `ModuleNotFoundError: No module named 'yaml'`
|
|
18
|
+
on a fresh interpreter where pyyaml has not been pip-installed
|
|
19
|
+
system-wide:
|
|
20
|
+
|
|
21
|
+
uv run skf-validate-frontmatter.py <skill-md-path>
|
|
22
|
+
uv run skf-validate-frontmatter.py <skill-md-path> --skill-dir-name <name>
|
|
17
23
|
|
|
18
24
|
Input:
|
|
19
25
|
Path to a SKILL.md file.
|
|
@@ -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
|
-
|
|
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":
|
|
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(
|
|
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
|
-
|
|
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)
|
|
@@ -40,7 +40,7 @@ To prevent this, any tool invocation that may touch SKILL.md must run inside the
|
|
|
40
40
|
2. **Execute.** Run the tool as specified in its section.
|
|
41
41
|
3. **Verify.** After the tool completes, re-read the on-disk SKILL.md and compare its frontmatter `description` against `guarded_description` as **token streams**: split each string on whitespace (`str.split()` — any run of spaces/tabs/newlines collapses) and compare the resulting lists element-by-element. This catches content divergence (missing words, replaced phrases, truncation, angle-bracket re-introduction) while ignoring cosmetic whitespace changes that a tool may apply (trailing newline, re-wrapped quoted strings). Do NOT use a normalized-string equality — a tool that rewrites `"foo bar"` to `"foo bar"` (collapsed inner run) would trip a naive normalization even though no semantic content changed, and a tool that swapped one word would slip past a looser fuzzy match. Token-stream comparison is the sweet spot.
|
|
42
42
|
4. **Restore on divergence.** If the post-tool description differs from `guarded_description` in any way other than whitespace normalization, write `guarded_description` back to the on-disk SKILL.md frontmatter and update the in-context copy to match. Record `description_guard_restored: true` with the tool name in context for the evidence report.
|
|
43
|
-
5. **Re-validate restored description.** After a restore, run `
|
|
43
|
+
5. **Re-validate restored description.** After a restore, run `uv run {project-root}/src/shared/scripts/skf-validate-frontmatter.py <staging-skill-dir>/SKILL.md` against the on-disk file to confirm the restored description still satisfies the frontmatter contract (length limits, forbidden tokens, required fields). The script declares pyyaml in its PEP 723 inline metadata; `uv run` resolves it automatically (per `docs/getting-started.md`'s uv prereq), while bare `python3` would `ModuleNotFoundError` on a fresh interpreter. Capture `schema_revalidation_result` in context. If the validator exits non-zero OR reports failure for the `description` field: flip the Schema result back to `FAIL` in the evidence report (overriding any prior PASS/WARN from §2), record `description_guard_revalidation: FAIL` with the validator's diagnostic message, and continue — do not halt (step-09 health-check and result contract still need to run so the failure is surfaced through the normal artifact path).
|
|
44
44
|
|
|
45
45
|
**What counts as divergence:**
|
|
46
46
|
|