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:
@@ -6,7 +6,7 @@ description: |
|
|||||||
(2) from collected sources for a not-yet-published site — reconcile conflicting
|
(2) from collected sources for a not-yet-published site — reconcile conflicting
|
||||||
facts into a provenance-tracked claims register. Both modes emit the same claims
|
facts into a provenance-tracked claims register. Both modes emit the same claims
|
||||||
register, build pruned drafts from type templates (no placeholders shipped), and
|
register, build pruned drafts from type templates (no placeholders shipped), and
|
||||||
hand off to 16-seo-schema-validator (generate -> validate, gate = zero P0).
|
hand off to 16-seo-schema-validator (generate then validate, gate = zero P0).
|
||||||
Triggers: generate schema, create JSON-LD, schema markup, structured data generator,
|
Triggers: generate schema, create JSON-LD, schema markup, structured data generator,
|
||||||
source-to-schema, pre-launch schema, claims register, 스키마 생성, 스키마 저작,
|
source-to-schema, pre-launch schema, claims register, 스키마 생성, 스키마 저작,
|
||||||
구조화 데이터 생성, 미발행 사이트 스키마, 기존 사이트 스키마 추출.
|
구조화 데이터 생성, 미발행 사이트 스키마, 기존 사이트 스키마 추출.
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ name: 92-tui-design-template
|
|||||||
description: Build Norton Commander / Gopher style TUI wizard interfaces for CLI tools using Python Rich. Covers architecture, components, keyboard input, bilingual i18n, and battle-tested gotchas.
|
description: Build Norton Commander / Gopher style TUI wizard interfaces for CLI tools using Python Rich. Covers architecture, components, keyboard input, bilingual i18n, and battle-tested gotchas.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
triggers:
|
triggers:
|
||||||
- "build TUI", "TUI wizard", "terminal UI", "CLI wizard"
|
- build TUI
|
||||||
- "Norton Commander style", "Gopher style", "retro TUI"
|
- TUI wizard
|
||||||
- "Rich TUI", "interactive CLI", "keyboard navigation"
|
- terminal UI
|
||||||
- "dual panel interface", "terminal wizard"
|
- CLI wizard
|
||||||
|
- Norton Commander style
|
||||||
|
- Gopher style
|
||||||
|
- retro TUI
|
||||||
|
- Rich TUI
|
||||||
|
- interactive CLI
|
||||||
|
- keyboard navigation
|
||||||
|
- dual panel interface
|
||||||
|
- terminal wizard
|
||||||
tools:
|
tools:
|
||||||
- Read
|
- Read
|
||||||
- Write
|
- Write
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ name: tui-design-template
|
|||||||
description: Build Norton Commander / Gopher style TUI wizard interfaces for CLI tools using Python Rich. Covers architecture, components, keyboard input, bilingual i18n, and battle-tested gotchas.
|
description: Build Norton Commander / Gopher style TUI wizard interfaces for CLI tools using Python Rich. Covers architecture, components, keyboard input, bilingual i18n, and battle-tested gotchas.
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
triggers:
|
triggers:
|
||||||
- "build TUI", "TUI wizard", "terminal UI", "CLI wizard"
|
- build TUI
|
||||||
- "Norton Commander style", "Gopher style", "retro TUI"
|
- TUI wizard
|
||||||
- "Rich TUI", "interactive CLI", "keyboard navigation"
|
- terminal UI
|
||||||
- "dual panel interface", "terminal wizard"
|
- CLI wizard
|
||||||
|
- Norton Commander style
|
||||||
|
- Gopher style
|
||||||
|
- retro TUI
|
||||||
|
- Rich TUI
|
||||||
|
- interactive CLI
|
||||||
|
- keyboard navigation
|
||||||
|
- dual panel interface
|
||||||
|
- terminal wizard
|
||||||
tools:
|
tools:
|
||||||
- Read
|
- Read
|
||||||
- Write
|
- Write
|
||||||
|
|||||||
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