fix(skills): make all skills load-valid + add scripts/verify_skills.py
Add a comprehensive load verifier and fix the two issues it found:
- scripts/verify_skills.py: validates every loadable unit (flat root, suite sub-skill,
plugin skill) with real YAML parsing, name regex + global uniqueness, frontmatter
<=1024, description sanity, plugin.json JSON validity, and orphan detection. Read-only.
- 92-tui-design-template (root + code/SKILL.md): fix invalid YAML `triggers:` block
(`- "a", "b"` multi-scalar list items) -> one phrase per list item.
- 17-seo-schema-generator: remove ">" from description ("generate -> validate" ->
"generate then validate"); angle brackets are disallowed in descriptions.
Result: 75/75 loadable skills valid — 0 failures, 0 name collisions, 0 orphans,
0 plugin-manifest errors (65 flat + 3 plugins + 7 suite sub-skills).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
178
scripts/verify_skills.py
Normal file
178
scripts/verify_skills.py
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
verify_skills.py — verify every skill in custom-skills/ would load correctly.
|
||||
|
||||
Checks each loadable unit (flat root skill, suite sub-skill, or plugin skill) against
|
||||
the repo's canonical rules (skill-creator/quick_validate.py) PLUS:
|
||||
- frontmatter is valid YAML (parsed, not just regex-matched),
|
||||
- frontmatter <= 1024 chars (Agent Skills spec),
|
||||
- name matches ^[a-z0-9-]+$, no leading/trailing/double hyphen,
|
||||
- description present, no angle brackets,
|
||||
- skill `name` values are globally unique,
|
||||
- plugin.json files parse as JSON and carry a name,
|
||||
- every top-level custom-skills/ entry resolves to >=1 loadable skill (no orphans).
|
||||
|
||||
Exit 0 if everything loads; 1 otherwise. Read-only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKILLS = Path(__file__).resolve().parent.parent / "custom-skills"
|
||||
NAME_RE = re.compile(r"^[a-z0-9-]+$")
|
||||
FM_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
|
||||
|
||||
|
||||
def validate_skill_md(path):
|
||||
"""Return (name, errors[]). Mirrors quick_validate.py + YAML + size."""
|
||||
errors = []
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if not text.startswith("---"):
|
||||
return None, ["no YAML frontmatter"]
|
||||
m = FM_RE.match(text)
|
||||
if not m:
|
||||
return None, ["invalid frontmatter format"]
|
||||
fm = m.group(1)
|
||||
|
||||
if len(fm) > 1024:
|
||||
errors.append(f"frontmatter {len(fm)}>1024 chars")
|
||||
|
||||
try:
|
||||
data = yaml.safe_load(fm)
|
||||
except yaml.YAMLError as exc:
|
||||
return None, [f"YAML parse error: {str(exc).splitlines()[0]}"]
|
||||
if not isinstance(data, dict):
|
||||
return None, ["frontmatter is not a mapping"]
|
||||
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
errors.append("missing 'name'")
|
||||
else:
|
||||
name = str(name).strip()
|
||||
if not NAME_RE.match(name):
|
||||
errors.append(f"name '{name}' not [a-z0-9-]")
|
||||
if name.startswith("-") or name.endswith("-") or "--" in name:
|
||||
errors.append(f"name '{name}' bad hyphen placement")
|
||||
|
||||
desc = data.get("description")
|
||||
if not desc:
|
||||
errors.append("missing 'description'")
|
||||
elif "<" in str(desc) or ">" in str(desc):
|
||||
errors.append("description contains angle brackets (< or >)")
|
||||
|
||||
return (name if isinstance(name, str) else None), errors
|
||||
|
||||
|
||||
def sub_skill_skills(d):
|
||||
"""Sub-skill SKILL.md files (suite members), excluding the dir's own
|
||||
code/ and desktop/ packaging copies."""
|
||||
return [p for p in sorted(d.glob("*/SKILL.md"))
|
||||
if p.parent.name not in ("code", "desktop")]
|
||||
|
||||
|
||||
def classify_top_level(d):
|
||||
"""How does this top-level dir expose loadable skill(s)?"""
|
||||
if (d / "SKILL.md").exists():
|
||||
kind = "flat"
|
||||
elif (d / ".claude-plugin" / "plugin.json").exists():
|
||||
kind = "plugin"
|
||||
elif sub_skill_skills(d):
|
||||
kind = "suite"
|
||||
else:
|
||||
kind = "orphan"
|
||||
return kind
|
||||
|
||||
|
||||
def main():
|
||||
units = [] # (label, path) every loadable SKILL.md
|
||||
top_report = [] # (dir, kind, note)
|
||||
plugin_errors = []
|
||||
|
||||
for d in sorted(SKILLS.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith("_") or d.name.endswith("-shared"):
|
||||
continue
|
||||
kind = classify_top_level(d)
|
||||
note = ""
|
||||
if kind == "flat":
|
||||
units.append((d.name, d / "SKILL.md"))
|
||||
# a suite container may also have sub-skills
|
||||
for sub in sub_skill_skills(d):
|
||||
units.append((f"{d.name}/{sub.parent.name}", sub))
|
||||
elif kind == "plugin":
|
||||
pj = d / ".claude-plugin" / "plugin.json"
|
||||
try:
|
||||
meta = json.loads(pj.read_text(encoding="utf-8"))
|
||||
if not meta.get("name"):
|
||||
plugin_errors.append((d.name, "plugin.json missing name"))
|
||||
note = f"plugin '{meta.get('name','?')}'"
|
||||
except json.JSONDecodeError as exc:
|
||||
plugin_errors.append((d.name, f"plugin.json invalid JSON: {exc}"))
|
||||
sks = sorted(d.glob("skills/*/SKILL.md"))
|
||||
if not sks:
|
||||
note += " — NO skills/*/SKILL.md"
|
||||
for sk in sks:
|
||||
units.append((f"{d.name}/skills/{sk.parent.name}", sk))
|
||||
elif kind == "suite":
|
||||
for sub in sub_skill_skills(d):
|
||||
units.append((f"{d.name}/{sub.parent.name}", sub))
|
||||
top_report.append((d.name, kind, note))
|
||||
|
||||
# validate every loadable SKILL.md
|
||||
results = []
|
||||
names = defaultdict(list)
|
||||
for label, path in units:
|
||||
name, errs = validate_skill_md(path)
|
||||
results.append((label, name, errs))
|
||||
if name:
|
||||
names[name].append(label)
|
||||
|
||||
collisions = {n: ls for n, ls in names.items() if len(ls) > 1}
|
||||
failed = [(l, n, e) for l, n, e in results if e]
|
||||
orphans = [d for d, k, _ in top_report if k == "orphan"]
|
||||
|
||||
# ---- report ----
|
||||
print(f"Loadable skills checked: {len(results)} "
|
||||
f"(flat+suite+plugin across {len(top_report)} top-level dirs)\n")
|
||||
|
||||
if failed:
|
||||
print("FAILURES:")
|
||||
for label, name, errs in failed:
|
||||
print(f" ✗ {label}: {'; '.join(errs)}")
|
||||
print()
|
||||
if plugin_errors:
|
||||
print("PLUGIN MANIFEST ERRORS:")
|
||||
for d, e in plugin_errors:
|
||||
print(f" ✗ {d}: {e}")
|
||||
print()
|
||||
if collisions:
|
||||
print("NAME COLLISIONS (would shadow each other):")
|
||||
for n, ls in sorted(collisions.items()):
|
||||
print(f" ✗ '{n}': {', '.join(ls)}")
|
||||
print()
|
||||
if orphans:
|
||||
print("ORPHAN DIRS (no loadable skill):")
|
||||
for d in orphans:
|
||||
print(f" ✗ {d}")
|
||||
print()
|
||||
|
||||
by_kind = defaultdict(int)
|
||||
for _, k, _ in top_report:
|
||||
by_kind[k] += 1
|
||||
print("Top-level dirs by kind: " +
|
||||
", ".join(f"{k}={v}" for k, v in sorted(by_kind.items())))
|
||||
|
||||
ok = not (failed or plugin_errors or collisions or orphans)
|
||||
print(f"\n{'✅ ALL SKILLS LOAD CORRECTLY' if ok else '❌ ISSUES FOUND'} "
|
||||
f"— {len(results)-len(failed)}/{len(results)} skills valid, "
|
||||
f"{len(collisions)} name collisions, {len(orphans)} orphans, "
|
||||
f"{len(plugin_errors)} plugin errors")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user