Skip to content

Commit e67d44b

Browse files
Merge pull request #2 from amd/dholanda/tests
Add validation scripts
2 parents ede9495 + 07f6dc0 commit e67d44b

6 files changed

Lines changed: 283 additions & 3 deletions

File tree

.github/workflows/validate.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: validate
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
paths:
8+
- "scripts/**"
9+
- "**/SKILL.md"
10+
- "skills/**"
11+
- ".github/workflows/validate.yml"
12+
workflow_dispatch:
13+
14+
jobs:
15+
validate:
16+
name: Validate skills
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Check out repository
20+
uses: actions/checkout@v4
21+
22+
- name: Set up uv
23+
uses: astral-sh/setup-uv@v7
24+
25+
- name: Validate skills (size + standardized format)
26+
run: ./scripts/check.sh

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Local-only working area (briefings, drafts, generated decks).
2+
internal/
3+
4+
# Python
5+
__pycache__/
6+
*.pyc
7+
.venv/
8+
9+
# uv
10+
.uv-cache/

AUTHORING.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,19 @@ Test the skill the way users will hit it:
148148
- [ ] Scripts handle expected errors and document their constants and dependencies
149149
- [ ] Prerequisites (ROCm version, GPU arch, container, env vars) are stated explicitly
150150
- [ ] Tested end-to-end on the target hardware against real prompts
151+
- [ ] `./scripts/check.sh` passes (CI runs this on every PR)
152+
153+
## Validating locally
154+
155+
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:
156+
157+
```bash
158+
./scripts/check.sh # validates every skill (same command CI runs)
159+
```
160+
161+
The validator checks every skill under `skills/` for:
162+
163+
- a `SKILL.md` file with a valid YAML frontmatter block
164+
- `name`: lowercase-with-hyphens, ≤ 64 characters, no `anthropic` / `claude` substrings, matches the directory name
165+
- `description`: non-empty, ≤ 1024 characters
166+
- `SKILL.md` body: ≤ 500 lines (push longer reference material into sibling files)

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,11 @@ Best for cross-cutting skills that do not have a natural product home.
185185
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.
186186
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.
187187
4. Register the skill in `.claude-plugin/marketplace.json` with a human-readable description.
188-
5. Run the publishing script to validate and regenerate the metadata:
188+
5. Validate the skill locally before pushing:
189189
```bash
190-
./scripts/publish.sh
190+
./scripts/check.sh # validates every SKILL.md
191191
```
192-
6. Open a pull request. CI will verify that names, descriptions, and paths are consistent across `SKILL.md` files and the marketplace manifest.
192+
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.
193193

194194
### Path B — Skills authored in a product repository
195195

scripts/check.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env bash
2+
# Validate every SKILL.md in the catalog.
3+
#
4+
# Usage:
5+
# ./scripts/check.sh Validate every skill.
6+
# ./scripts/check.sh -h|--help Print this help.
7+
#
8+
# Requires `uv` (https://github.com/astral-sh/uv).
9+
#
10+
# Note: this repo does not publish anything yet. When we add catalog
11+
# manifests (.cursor-plugin/plugin.json, .claude-plugin/marketplace.json,
12+
# .mcp.json, etc.) we'll add a separate scripts/publish.sh for generation
13+
# and check.sh will gain a `--check` mode that diffs the regenerated
14+
# artifacts against the committed copy.
15+
16+
set -euo pipefail
17+
18+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
19+
cd "$ROOT_DIR"
20+
21+
usage() {
22+
sed -n 's/^# \{0,1\}//p' "${BASH_SOURCE[0]}" | sed -n '/^Usage:/,/^Requires/p'
23+
}
24+
25+
case "${1:-}" in
26+
"")
27+
uv run scripts/validate_skills.py
28+
;;
29+
-h|--help)
30+
usage
31+
;;
32+
*)
33+
echo "Unknown option: $1" >&2
34+
echo "Run with --help for usage." >&2
35+
exit 2
36+
;;
37+
esac

scripts/validate_skills.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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

Comments
 (0)