Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Local-only working area (briefings, drafts, generated decks).
internal/

# Python
__pycache__/
*.pyc
.venv/

# uv
.uv-cache/
16 changes: 16 additions & 0 deletions AUTHORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions scripts/check.sh
Original file line number Diff line number Diff line change
@@ -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
191 changes: 191 additions & 0 deletions scripts/validate_skills.py
Original file line number Diff line number Diff line change
@@ -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<frontmatter>.*?)\r?\n---\r?\n?(?P<body>.*)\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())
Loading