← Files AMDARCHIVED FILE
.github/scripts/validate_skills.py
15.8 KB · Sep 30, 2026 · 23:13 UTC
#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Validate AMD skills against the standardized Agent Skills format.
Enforces the rules documented in docs/skill-requirements.md:
- SKILL.md exists at the skill root
- YAML frontmatter is parseable
- `name` is lowercase-with-hyphens, <=64 chars, no `anthropic`/`claude`
substrings, and matches the directory name
- `description` is a non-empty string <=1024 chars
- SKILL.md body is <=500 lines
- skill-card.md exists at the skill root and has non-empty
`## Description`, `## Owner`, and `## License` sections
Also validates the single-bundle plugin model: `.claude-plugin/marketplace.json`
lists exactly one plugin whose `source` is the repo root (`./`) with
`strict: false`, and whose `skills` array names the published skill folders as
`./skills/<name>` paths. Each listed path must resolve to a real skill under
`skills/`. Skills that are not listed are allowed -- they are simply unpublished
(the "canonical catalog, curated publish" model), so a skill can live under
`skills/` without shipping. No files are duplicated: the bundle ships the
skill folders in place, so there is no generated `plugins/` tree to keep in sync.
Run from the repo root:
./.github/scripts/check.sh # used locally; thin wrapper
uv run .github/scripts/validate_skills.py # validate every skill + manifest
uv run .github/scripts/validate_skills.py --skills-dir skills
uv run .github/scripts/validate_skills.py --list # print skill names as JSON
uv run .github/scripts/validate_skills.py --skill rocm-doctor # one skill only
uv run .github/scripts/validate_skills.py --marketplace-only # manifest only
The `--list` / `--skill` options let CI validate each skill in its own job
(see .github/workflows/validate.yml) so a single bad skill doesn't mask the
status of the others.
Exits non-zero if any validated skill (or the marketplace check) fails.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_SKILLS_DIR = REPO_ROOT / "skills"
CLAUDE_MARKETPLACE = REPO_ROOT / ".claude-plugin" / "marketplace.json"
# Every published skill is referenced from the bundle's `skills` array as a
# path of this form, relative to the plugin `source` (the repo root).
SKILLS_PATH_PREFIX = "./skills/"
# Limits from docs/skill-requirements.md and the standardized Agent Skills format.
MAX_NAME_LEN = 64
MAX_DESCRIPTION_LEN = 1024
MAX_BODY_LINES = 500
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
FRONTMATTER_RE = re.compile(
r"\A---\r?\n(?P<frontmatter>.*?)\r?\n---\r?\n?(?P<body>.*)\Z",
re.DOTALL,
)
RESERVED_NAME_SUBSTRINGS = ("anthropic", "claude")
# Per-skill governance card (see docs/skill-requirements.md). Each section must be a
# top-level `##` heading followed by some non-empty body text.
CARD_FILENAME = "skill-card.md"
REQUIRED_CARD_SECTIONS = ("Description", "Owner", "License")
@dataclass
class SkillReport:
skill: str
errors: list[str] = field(default_factory=list)
def validate_skill(skill_dir: Path) -> SkillReport:
"""Run every validation rule against `skill_dir` and return a report."""
report = SkillReport(skill=skill_dir.name)
skill_md = skill_dir / "SKILL.md"
if not skill_md.exists():
report.errors.append("Missing SKILL.md.")
return report
text = skill_md.read_text(encoding="utf-8")
match = FRONTMATTER_RE.match(text)
if match is None:
report.errors.append(
"SKILL.md must start with a `---` YAML frontmatter block "
"followed by `---` on its own line."
)
return report
try:
frontmatter = yaml.safe_load(match.group("frontmatter"))
except yaml.YAMLError as exc:
report.errors.append(f"YAML frontmatter is invalid: {exc}")
return report
if not isinstance(frontmatter, dict):
report.errors.append(
"YAML frontmatter must be a mapping with at least `name` "
"and `description`."
)
return report
_validate_name(frontmatter.get("name"), skill_dir.name, report)
_validate_description(frontmatter.get("description"), report)
_validate_body(match.group("body"), report)
_validate_card(skill_dir, report)
return report
def _validate_name(name: object, dir_name: str, report: SkillReport) -> None:
if not isinstance(name, str) or not name:
report.errors.append("Frontmatter `name` is missing or not a non-empty string.")
return
if len(name) > MAX_NAME_LEN:
report.errors.append(
f"`name` length {len(name)} exceeds {MAX_NAME_LEN} characters."
)
if not NAME_RE.match(name):
report.errors.append(
f"`name` `{name}` must be lowercase-with-hyphens "
"(letters, digits, single hyphens between segments)."
)
for sub in RESERVED_NAME_SUBSTRINGS:
if sub in name.lower():
report.errors.append(f"`name` may not contain `{sub}`.")
if name != dir_name:
report.errors.append(
f"`name` `{name}` must match the skill directory name `{dir_name}`."
)
def _validate_description(description: object, report: SkillReport) -> None:
if not isinstance(description, str) or not description:
report.errors.append(
"Frontmatter `description` is missing or not a non-empty string."
)
return
if len(description) > MAX_DESCRIPTION_LEN:
report.errors.append(
f"`description` length {len(description)} exceeds "
f"{MAX_DESCRIPTION_LEN} characters."
)
def _validate_body(body: str, report: SkillReport) -> None:
# Skip surrounding blank lines so the blank line after `---` doesn't
# inflate the count.
lines = body.splitlines()
while lines and not lines[0].strip():
lines.pop(0)
while lines and not lines[-1].strip():
lines.pop()
if len(lines) > MAX_BODY_LINES:
report.errors.append(
f"SKILL.md body is {len(lines)} lines; max is {MAX_BODY_LINES}. "
"Move reference material into sibling files (reference.md, "
"examples.md, ...) and link to them from SKILL.md."
)
def _validate_card(skill_dir: Path, report: SkillReport) -> None:
"""Require a skill-card.md with non-empty Description, Owner, License."""
card = skill_dir / CARD_FILENAME
if not card.exists():
report.errors.append(
f"Missing {CARD_FILENAME} (governance card). See docs/skill-requirements.md; "
"it needs `## Description`, `## Owner`, and `## License` sections."
)
return
sections = _parse_card_sections(card.read_text(encoding="utf-8"))
for name in REQUIRED_CARD_SECTIONS:
body = sections.get(name.lower())
if body is None:
report.errors.append(f"{CARD_FILENAME} is missing a `## {name}` section.")
elif not body.strip():
report.errors.append(f"{CARD_FILENAME} `## {name}` section is empty.")
def _parse_card_sections(text: str) -> dict[str, str]:
"""Map each `##` heading (lowercased) to the text until the next heading."""
sections: dict[str, str] = {}
current: str | None = None
buffer: list[str] = []
def flush() -> None:
if current is not None:
sections[current] = "\n".join(buffer).strip()
for line in text.splitlines():
heading = re.match(r"^##\s+(?P<title>.+?)\s*$", line)
if heading:
flush()
current = heading.group("title").lower()
buffer = []
elif current is not None:
buffer.append(line)
flush()
return sections
def discover_skills(root: Path) -> list[Path]:
"""List skill directories under `root`, ignoring dotfiles."""
if not root.exists():
return []
return sorted(
p for p in root.iterdir() if p.is_dir() and not p.name.startswith(".")
)
def validate_claude_marketplace(skill_dirs: list[Path]) -> list[str]:
"""Return error strings if the bundle plugin doesn't match skills/ on disk.
AMD ships a single curated plugin whose `source` is the repo root (`./`)
with `strict: false` (so no `plugin.json` is needed). Its `skills` array
lists the published skills as `./skills/<name>` paths; each must resolve to
a real skill under `skills/`. Skills that are not listed are allowed -- they
are simply unpublished. The plugin's human-readable `description` is
intentionally allowed to differ from the SKILL.md descriptions (per
docs/skill-requirements.md), so this only enforces that names and paths line up.
"""
if not CLAUDE_MARKETPLACE.exists():
return [
f"Missing {CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}; expected the "
"AMD bundle plugin entry."
]
try:
data = json.loads(CLAUDE_MARKETPLACE.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return [f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}: invalid JSON: {exc}"]
plugins = data.get("plugins") if isinstance(data, dict) else None
if not isinstance(plugins, list):
return [
f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}: top-level `plugins` "
"array is missing."
]
if len(plugins) != 1:
return [
f"{CLAUDE_MARKETPLACE.relative_to(REPO_ROOT)}: expected exactly one "
f"plugin (the AMD bundle), found {len(plugins)}."
]
entry = plugins[0]
if not isinstance(entry, dict):
return ["plugins[0] must be an object."]
errors: list[str] = []
name = entry.get("name")
source = entry.get("source")
description = entry.get("description")
if not isinstance(name, str) or not name:
errors.append("plugins[0] is missing a non-empty `name`.")
return errors
if source != "./":
errors.append(
f"plugins[0] (`{name}`): `source` must be `./` (the repo root is the "
f"bundle), got `{source}`."
)
# With a repo-root source the plugin ships no `plugin.json`, so the entry
# must declare `strict: false` or Claude Code will look for one and fail.
if entry.get("strict") is not False:
errors.append(
f"plugins[0] (`{name}`): must set `strict` to `false` (no plugin.json "
"ships with a repo-root source)."
)
if not isinstance(description, str) or not description.strip():
errors.append(f"plugins[0] (`{name}`) is missing a non-empty `description`.")
errors.extend(_validate_bundle_skills(entry, {p.name for p in skill_dirs}))
return errors
def _validate_bundle_skills(entry: dict, skill_names: set[str]) -> list[str]:
"""Check the bundle's `skills` paths resolve to real skills under skills/."""
skills = entry.get("skills")
if not isinstance(skills, list) or not skills:
return [
"plugins[0] `skills` must be a non-empty list of "
f"`{SKILLS_PATH_PREFIX}<name>` paths naming the published skills."
]
errors: list[str] = []
seen: set[str] = set()
for path in skills:
if not isinstance(path, str) or not path.startswith(SKILLS_PATH_PREFIX):
errors.append(
f"`skills` entry {path!r} must be a `{SKILLS_PATH_PREFIX}<name>` path."
)
continue
skill = path[len(SKILLS_PATH_PREFIX) :].strip("/")
if not skill or "/" in skill:
errors.append(f"`skills` entry {path!r} must point at a single skill folder.")
continue
if skill in seen:
errors.append(f"`skills` lists `{path}` more than once.")
continue
seen.add(skill)
if skill not in skill_names:
errors.append(
f"`skills` names `{path}`, which has no directory under skills/."
)
# Skills present under skills/ but absent from `skills` are intentionally
# unpublished, so there is no error for that difference here.
return errors
def _print_report(report: SkillReport) -> int:
"""Print a single skill report and return its error count."""
status = "OK " if not report.errors else "FAIL"
print(f"[{status}] {report.skill}")
for err in report.errors:
print(f" {err}")
return len(report.errors)
def list_skills(skills_dir: Path) -> int:
"""Print discovered skill names as a compact JSON array (for CI matrices)."""
skills = discover_skills(skills_dir)
if not skills:
print(f"No skills found under {skills_dir}", file=sys.stderr)
return 1
print(json.dumps([p.name for p in skills], separators=(",", ":")))
return 0
def run_single(skills_dir: Path, name: str) -> int:
"""Validate a single skill directory by name (no marketplace cross-check)."""
skill_dir = skills_dir / name
if not skill_dir.is_dir():
print(f"No such skill directory: {skill_dir}", file=sys.stderr)
return 1
errors = _print_report(validate_skill(skill_dir))
print(f"\nSummary: {errors} error(s) in skill `{name}`")
return 0 if errors == 0 else 1
def run_marketplace(skills_dir: Path) -> int:
"""Validate only that marketplace.json is in sync with skills on disk."""
skills = discover_skills(skills_dir)
if not skills:
print(f"No skills found under {skills_dir}", file=sys.stderr)
return 1
marketplace_errors = validate_claude_marketplace(skills)
status = "OK " if not marketplace_errors else "FAIL"
print(f"[{status}] .claude-plugin/marketplace.json")
for err in marketplace_errors:
print(f" {err}")
print(f"\nSummary: {len(marketplace_errors)} error(s) in marketplace manifest")
return 0 if not marketplace_errors else 1
def run(skills_dir: Path) -> int:
skills = discover_skills(skills_dir)
if not skills:
print(f"No skills found under {skills_dir}", file=sys.stderr)
return 1
print(f"Validating {len(skills)} skill(s) in {skills_dir}\n")
total_errors = 0
for skill_dir in skills:
total_errors += _print_report(validate_skill(skill_dir))
marketplace_errors = validate_claude_marketplace(skills)
marketplace_status = "OK " if not marketplace_errors else "FAIL"
print(f"\n[{marketplace_status}] .claude-plugin/marketplace.json")
for err in marketplace_errors:
print(f" {err}")
total_errors += len(marketplace_errors)
print(f"\nSummary: {total_errors} error(s) across {len(skills)} skill(s)")
return 0 if total_errors == 0 else 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--skills-dir",
type=Path,
default=DEFAULT_SKILLS_DIR,
help=f"Directory containing skill folders (default: {DEFAULT_SKILLS_DIR}).",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--list",
action="store_true",
help="Print discovered skill names as a JSON array and exit.",
)
group.add_argument(
"--skill",
metavar="NAME",
help="Validate only the named skill directory (skips the marketplace "
"cross-check, which is repo-wide).",
)
group.add_argument(
"--marketplace-only",
action="store_true",
help="Only validate that marketplace.json is in sync with skills/.",
)
args = parser.parse_args(argv)
skills_dir = args.skills_dir.resolve()
if args.list:
return list_skills(skills_dir)
if args.skill:
return run_single(skills_dir, args.skill)
if args.marketplace_only:
return run_marketplace(skills_dir)
return run(skills_dir)
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 612a81828d27b0851b0524c5470f320b407d6bafdbb9838f18541fa1fbd57e4b