|
| 1 | +#!/usr/bin/env -S uv run --quiet |
| 2 | +# /// script |
| 3 | +# requires-python = ">=3.10" |
| 4 | +# dependencies = ["pyyaml>=6.0"] |
| 5 | +# /// |
| 6 | +"""Validate AMD skills against the standardized Agent Skills format. |
| 7 | +
|
| 8 | +Enforces the rules documented in AUTHORING.md: |
| 9 | +
|
| 10 | + - SKILL.md exists at the skill root |
| 11 | + - YAML frontmatter is parseable |
| 12 | + - `name` is lowercase-with-hyphens, <=64 chars, no `anthropic`/`claude` |
| 13 | + substrings, and matches the directory name |
| 14 | + - `description` is a non-empty string <=1024 chars |
| 15 | + - SKILL.md body is <=500 lines |
| 16 | +
|
| 17 | +Run from the repo root: |
| 18 | +
|
| 19 | + ./scripts/check.sh # used by CI; thin wrapper |
| 20 | + uv run scripts/validate_skills.py # ad-hoc |
| 21 | + uv run scripts/validate_skills.py --skills-dir skills |
| 22 | +
|
| 23 | +Exits non-zero if any skill fails validation. |
| 24 | +""" |
| 25 | + |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +import argparse |
| 29 | +import re |
| 30 | +import sys |
| 31 | +from dataclasses import dataclass, field |
| 32 | +from pathlib import Path |
| 33 | + |
| 34 | +import yaml |
| 35 | + |
| 36 | +REPO_ROOT = Path(__file__).resolve().parent.parent |
| 37 | +DEFAULT_SKILLS_DIR = REPO_ROOT / "skills" |
| 38 | + |
| 39 | +# Limits from AUTHORING.md and the standardized Agent Skills format. |
| 40 | +MAX_NAME_LEN = 64 |
| 41 | +MAX_DESCRIPTION_LEN = 1024 |
| 42 | +MAX_BODY_LINES = 500 |
| 43 | + |
| 44 | +NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") |
| 45 | +FRONTMATTER_RE = re.compile( |
| 46 | + r"\A---\r?\n(?P<frontmatter>.*?)\r?\n---\r?\n?(?P<body>.*)\Z", |
| 47 | + re.DOTALL, |
| 48 | +) |
| 49 | +RESERVED_NAME_SUBSTRINGS = ("anthropic", "claude") |
| 50 | + |
| 51 | + |
| 52 | +@dataclass |
| 53 | +class SkillReport: |
| 54 | + skill: str |
| 55 | + errors: list[str] = field(default_factory=list) |
| 56 | + |
| 57 | + |
| 58 | +def validate_skill(skill_dir: Path) -> SkillReport: |
| 59 | + """Run every validation rule against `skill_dir` and return a report.""" |
| 60 | + report = SkillReport(skill=skill_dir.name) |
| 61 | + skill_md = skill_dir / "SKILL.md" |
| 62 | + |
| 63 | + if not skill_md.exists(): |
| 64 | + report.errors.append("Missing SKILL.md.") |
| 65 | + return report |
| 66 | + |
| 67 | + text = skill_md.read_text(encoding="utf-8") |
| 68 | + match = FRONTMATTER_RE.match(text) |
| 69 | + if match is None: |
| 70 | + report.errors.append( |
| 71 | + "SKILL.md must start with a `---` YAML frontmatter block " |
| 72 | + "followed by `---` on its own line." |
| 73 | + ) |
| 74 | + return report |
| 75 | + |
| 76 | + try: |
| 77 | + frontmatter = yaml.safe_load(match.group("frontmatter")) |
| 78 | + except yaml.YAMLError as exc: |
| 79 | + report.errors.append(f"YAML frontmatter is invalid: {exc}") |
| 80 | + return report |
| 81 | + |
| 82 | + if not isinstance(frontmatter, dict): |
| 83 | + report.errors.append( |
| 84 | + "YAML frontmatter must be a mapping with at least `name` " |
| 85 | + "and `description`." |
| 86 | + ) |
| 87 | + return report |
| 88 | + |
| 89 | + _validate_name(frontmatter.get("name"), skill_dir.name, report) |
| 90 | + _validate_description(frontmatter.get("description"), report) |
| 91 | + _validate_body(match.group("body"), report) |
| 92 | + return report |
| 93 | + |
| 94 | + |
| 95 | +def _validate_name(name: object, dir_name: str, report: SkillReport) -> None: |
| 96 | + if not isinstance(name, str) or not name: |
| 97 | + report.errors.append("Frontmatter `name` is missing or not a non-empty string.") |
| 98 | + return |
| 99 | + |
| 100 | + if len(name) > MAX_NAME_LEN: |
| 101 | + report.errors.append( |
| 102 | + f"`name` length {len(name)} exceeds {MAX_NAME_LEN} characters." |
| 103 | + ) |
| 104 | + if not NAME_RE.match(name): |
| 105 | + report.errors.append( |
| 106 | + f"`name` `{name}` must be lowercase-with-hyphens " |
| 107 | + "(letters, digits, single hyphens between segments)." |
| 108 | + ) |
| 109 | + for sub in RESERVED_NAME_SUBSTRINGS: |
| 110 | + if sub in name.lower(): |
| 111 | + report.errors.append(f"`name` may not contain `{sub}`.") |
| 112 | + if name != dir_name: |
| 113 | + report.errors.append( |
| 114 | + f"`name` `{name}` must match the skill directory name `{dir_name}`." |
| 115 | + ) |
| 116 | + |
| 117 | + |
| 118 | +def _validate_description(description: object, report: SkillReport) -> None: |
| 119 | + if not isinstance(description, str) or not description: |
| 120 | + report.errors.append( |
| 121 | + "Frontmatter `description` is missing or not a non-empty string." |
| 122 | + ) |
| 123 | + return |
| 124 | + if len(description) > MAX_DESCRIPTION_LEN: |
| 125 | + report.errors.append( |
| 126 | + f"`description` length {len(description)} exceeds " |
| 127 | + f"{MAX_DESCRIPTION_LEN} characters." |
| 128 | + ) |
| 129 | + |
| 130 | + |
| 131 | +def _validate_body(body: str, report: SkillReport) -> None: |
| 132 | + # Skip surrounding blank lines so the blank line after `---` doesn't |
| 133 | + # inflate the count. |
| 134 | + lines = body.splitlines() |
| 135 | + while lines and not lines[0].strip(): |
| 136 | + lines.pop(0) |
| 137 | + while lines and not lines[-1].strip(): |
| 138 | + lines.pop() |
| 139 | + if len(lines) > MAX_BODY_LINES: |
| 140 | + report.errors.append( |
| 141 | + f"SKILL.md body is {len(lines)} lines; max is {MAX_BODY_LINES}. " |
| 142 | + "Move reference material into sibling files (reference.md, " |
| 143 | + "examples.md, ...) and link to them from SKILL.md." |
| 144 | + ) |
| 145 | + |
| 146 | + |
| 147 | +def discover_skills(root: Path) -> list[Path]: |
| 148 | + """List skill directories under `root`, ignoring dotfiles.""" |
| 149 | + if not root.exists(): |
| 150 | + return [] |
| 151 | + return sorted( |
| 152 | + p for p in root.iterdir() if p.is_dir() and not p.name.startswith(".") |
| 153 | + ) |
| 154 | + |
| 155 | + |
| 156 | +def run(skills_dir: Path) -> int: |
| 157 | + skills = discover_skills(skills_dir) |
| 158 | + if not skills: |
| 159 | + print(f"No skills found under {skills_dir}", file=sys.stderr) |
| 160 | + return 1 |
| 161 | + |
| 162 | + print(f"Validating {len(skills)} skill(s) in {skills_dir}\n") |
| 163 | + total_errors = 0 |
| 164 | + for skill_dir in skills: |
| 165 | + report = validate_skill(skill_dir) |
| 166 | + status = "OK " if not report.errors else "FAIL" |
| 167 | + print(f"[{status}] {report.skill}") |
| 168 | + for err in report.errors: |
| 169 | + print(f" {err}") |
| 170 | + total_errors += len(report.errors) |
| 171 | + |
| 172 | + print(f"\nSummary: {total_errors} error(s) across {len(skills)} skill(s)") |
| 173 | + return 0 if total_errors == 0 else 1 |
| 174 | + |
| 175 | + |
| 176 | +def main(argv: list[str] | None = None) -> int: |
| 177 | + parser = argparse.ArgumentParser( |
| 178 | + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter |
| 179 | + ) |
| 180 | + parser.add_argument( |
| 181 | + "--skills-dir", |
| 182 | + type=Path, |
| 183 | + default=DEFAULT_SKILLS_DIR, |
| 184 | + help=f"Directory containing skill folders (default: {DEFAULT_SKILLS_DIR}).", |
| 185 | + ) |
| 186 | + args = parser.parse_args(argv) |
| 187 | + return run(args.skills_dir.resolve()) |
| 188 | + |
| 189 | + |
| 190 | +if __name__ == "__main__": |
| 191 | + raise SystemExit(main()) |
0 commit comments