#!/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())