diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..27c09fb --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,26 @@ +name: validate + +on: + push: + branches: [main] + pull_request: + paths: + - "scripts/**" + - "**/SKILL.md" + - "skills/**" + - ".github/workflows/validate.yml" + workflow_dispatch: + +jobs: + validate: + name: Validate skills + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + + - name: Validate skills (size + standardized format) + run: ./scripts/check.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0cf731 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Local-only working area (briefings, drafts, generated decks). +internal/ + +# Python +__pycache__/ +*.pyc +.venv/ + +# uv +.uv-cache/ diff --git a/AUTHORING.md b/AUTHORING.md index c9537bf..6c6cf1e 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -148,3 +148,19 @@ Test the skill the way users will hit it: - [ ] Scripts handle expected errors and document their constants and dependencies - [ ] Prerequisites (ROCm version, GPU arch, container, env vars) are stated explicitly - [ ] Tested end-to-end on the target hardware against real prompts +- [ ] `./scripts/check.sh` passes (CI runs this on every PR) + +## Validating locally + +The structural rules from this guide — frontmatter shape, name format, description length, and `SKILL.md` body size — are enforced by `scripts/validate_skills.py` and run on every pull request. Run them locally before pushing: + +```bash +./scripts/check.sh # validates every skill (same command CI runs) +``` + +The validator checks every skill under `skills/` for: + +- a `SKILL.md` file with a valid YAML frontmatter block +- `name`: lowercase-with-hyphens, ≤ 64 characters, no `anthropic` / `claude` substrings, matches the directory name +- `description`: non-empty, ≤ 1024 characters +- `SKILL.md` body: ≤ 500 lines (push longer reference material into sibling files) diff --git a/README.md b/README.md index 7627e7d..10bbb8c 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,11 @@ Best for cross-cutting skills that do not have a natural product home. 2. Update the `SKILL.md` frontmatter so the `name` and `description` clearly explain *what* the skill does and *when* an agent should reach for it. 3. Add the supporting scripts, templates, and reference docs your instructions point to. Keep skills focused — one well-scoped task per skill is better than one mega-skill. 4. Register the skill in `.claude-plugin/marketplace.json` with a human-readable description. -5. Run the publishing script to validate and regenerate the metadata: +5. Validate the skill locally before pushing: ```bash - ./scripts/publish.sh + ./scripts/check.sh # validates every SKILL.md ``` -6. Open a pull request. CI will verify that names, descriptions, and paths are consistent across `SKILL.md` files and the marketplace manifest. +6. Open a pull request. The `validate` GitHub Actions workflow runs `./scripts/check.sh` and must pass before merge. See [AUTHORING.md](AUTHORING.md#validating-locally) for the full set of enforced rules. ### Path B — Skills authored in a product repository diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..9f51803 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Validate every SKILL.md in the catalog. +# +# Usage: +# ./scripts/check.sh Validate every skill. +# ./scripts/check.sh -h|--help Print this help. +# +# Requires `uv` (https://github.com/astral-sh/uv). +# +# Note: this repo does not publish anything yet. When we add catalog +# manifests (.cursor-plugin/plugin.json, .claude-plugin/marketplace.json, +# .mcp.json, etc.) we'll add a separate scripts/publish.sh for generation +# and check.sh will gain a `--check` mode that diffs the regenerated +# artifacts against the committed copy. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +usage() { + sed -n 's/^# \{0,1\}//p' "${BASH_SOURCE[0]}" | sed -n '/^Usage:/,/^Requires/p' +} + +case "${1:-}" in + "") + uv run scripts/validate_skills.py + ;; + -h|--help) + usage + ;; + *) + echo "Unknown option: $1" >&2 + echo "Run with --help for usage." >&2 + exit 2 + ;; +esac diff --git a/scripts/validate_skills.py b/scripts/validate_skills.py new file mode 100644 index 0000000..afec27b --- /dev/null +++ b/scripts/validate_skills.py @@ -0,0 +1,191 @@ +#!/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 AUTHORING.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 + +Run from the repo root: + + ./scripts/check.sh # used by CI; thin wrapper + uv run scripts/validate_skills.py # ad-hoc + uv run scripts/validate_skills.py --skills-dir skills + +Exits non-zero if any skill fails validation. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_SKILLS_DIR = REPO_ROOT / "skills" + +# Limits from AUTHORING.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.*?)\r?\n---\r?\n?(?P.*)\Z", + re.DOTALL, +) +RESERVED_NAME_SUBSTRINGS = ("anthropic", "claude") + + +@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) + 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 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 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: + report = validate_skill(skill_dir) + status = "OK " if not report.errors else "FAIL" + print(f"[{status}] {report.skill}") + for err in report.errors: + print(f" {err}") + total_errors += len(report.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}).", + ) + args = parser.parse_args(argv) + return run(args.skills_dir.resolve()) + + +if __name__ == "__main__": + raise SystemExit(main())