From 5cb99fe67d8477af24e8c9f2ca4025f1f2fea2b7 Mon Sep 17 00:00:00 2001 From: Daniel Holanda Date: Mon, 4 May 2026 14:32:16 -0700 Subject: [PATCH 1/4] Add validation scripts --- .github/workflows/validate.yml | 26 +++++ .gitignore | 10 ++ AUTHORING.md | 16 +++ README.md | 7 +- scripts/check.sh | 39 +++++++ scripts/test_validate_skills.py | 171 ++++++++++++++++++++++++++++ scripts/validate_skills.py | 191 ++++++++++++++++++++++++++++++++ 7 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/validate.yml create mode 100644 .gitignore create mode 100644 scripts/check.sh create mode 100644 scripts/test_validate_skills.py create mode 100644 scripts/validate_skills.py 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..06e06d9 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 # runs unit tests + 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..efb5d48 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,12 @@ 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 # runs unit tests and 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. + Requires [`uv`](https://github.com/astral-sh/uv). The validator declares its own Python dependencies inline (PEP 723) so there is no `pip install` step. +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 100644 index 0000000..b4b095d --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Run unit tests and validate every SKILL.md in the catalog. +# +# Usage: +# ./scripts/check.sh Run tests + 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/test_validate_skills.py + uv run scripts/validate_skills.py + echo "All checks passed." + ;; + -h|--help) + usage + ;; + *) + echo "Unknown option: $1" >&2 + echo "Run with --help for usage." >&2 + exit 2 + ;; +esac diff --git a/scripts/test_validate_skills.py b/scripts/test_validate_skills.py new file mode 100644 index 0000000..381a625 --- /dev/null +++ b/scripts/test_validate_skills.py @@ -0,0 +1,171 @@ +#!/usr/bin/env -S uv run --quiet +# /// script +# requires-python = ">=3.10" +# dependencies = ["pyyaml>=6.0"] +# /// +"""Unit tests for `validate_skills`. + +Run via: + + uv run scripts/test_validate_skills.py + +Each test creates a throwaway skill in a tempdir, runs `validate_skill`, and +asserts on the resulting report. No external test framework needed. +""" + +from __future__ import annotations + +import json +import shutil +import tempfile +import unittest +from pathlib import Path + +from validate_skills import ( + MAX_BODY_LINES, + MAX_DESCRIPTION_LEN, + MAX_NAME_LEN, + validate_skill, +) + + +def _content( + *, + name: str = "good-skill", + description: str = "Does the thing. Use when the user wants the thing done.", + body: str = "# Heading\n\nMinimal body content.\n", +) -> str: + return ( + "---\n" + f"name: {name}\n" + f"description: {json.dumps(description)}\n" + "---\n\n" + f"{body}" + ) + + +def _make_skill(parent: Path, dir_name: str, content: str | None) -> Path: + skill_dir = parent / dir_name + skill_dir.mkdir(parents=True, exist_ok=True) + if content is not None: + (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") + return skill_dir + + +class ValidateSkillTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp(prefix="skills-test-")) + + def tearDown(self) -> None: + shutil.rmtree(self.tmp, ignore_errors=True) + + def assertHasError(self, errors: list[str], pattern: str) -> None: + self.assertTrue( + any(pattern in err for err in errors), + f"expected an error containing {pattern!r}; got: {errors}", + ) + + def test_accepts_well_formed_skill(self) -> None: + skill = _make_skill(self.tmp, "good-skill", _content()) + self.assertEqual(validate_skill(skill).errors, []) + + def test_flags_missing_skill_md(self) -> None: + skill = _make_skill(self.tmp, "no-skill-md", None) + report = validate_skill(skill) + self.assertEqual(len(report.errors), 1) + self.assertHasError(report.errors, "Missing SKILL.md") + + def test_flags_missing_frontmatter(self) -> None: + skill = _make_skill(self.tmp, "no-frontmatter", "# Body only\n") + report = validate_skill(skill) + self.assertHasError(report.errors, "YAML frontmatter block") + + def test_flags_invalid_yaml(self) -> None: + broken = "---\nname: : oops\n---\n\nbody\n" + skill = _make_skill(self.tmp, "broken-yaml", broken) + report = validate_skill(skill) + self.assertHasError(report.errors, "YAML frontmatter is invalid") + + def test_flags_missing_name(self) -> None: + skill = _make_skill( + self.tmp, + "no-name", + '---\ndescription: "Does X. Use when Y."\n---\n\nbody\n', + ) + report = validate_skill(skill) + self.assertHasError(report.errors, "`name` is missing") + + def test_flags_missing_description(self) -> None: + skill = _make_skill( + self.tmp, "no-description", "---\nname: no-description\n---\n\nbody\n" + ) + report = validate_skill(skill) + self.assertHasError(report.errors, "`description` is missing") + + def test_rejects_invalid_name_format(self) -> None: + skill = _make_skill(self.tmp, "Bad_Name", _content(name="Bad_Name")) + report = validate_skill(skill) + self.assertHasError(report.errors, "lowercase-with-hyphens") + + def test_rejects_too_long_name(self) -> None: + long_name = "a" * (MAX_NAME_LEN + 1) + skill = _make_skill(self.tmp, long_name, _content(name=long_name)) + report = validate_skill(skill) + self.assertHasError(report.errors, "exceeds") + + def test_rejects_reserved_name_substrings(self) -> None: + for reserved in ("claude-helper", "anthropic-tool"): + with self.subTest(name=reserved): + skill = _make_skill(self.tmp, reserved, _content(name=reserved)) + report = validate_skill(skill) + self.assertHasError(report.errors, "may not contain") + shutil.rmtree(skill) + + def test_rejects_name_directory_mismatch(self) -> None: + skill = _make_skill( + self.tmp, "actual-dir", _content(name="different-name") + ) + report = validate_skill(skill) + self.assertHasError(report.errors, "must match the skill directory") + + def test_rejects_too_long_description(self) -> None: + skill = _make_skill( + self.tmp, + "verbose", + _content(name="verbose", description="x" * (MAX_DESCRIPTION_LEN + 1)), + ) + report = validate_skill(skill) + self.assertHasError(report.errors, "`description` length") + + def test_rejects_too_long_body(self) -> None: + body = "\n".join(f"line {i}" for i in range(MAX_BODY_LINES + 1)) + "\n" + skill = _make_skill( + self.tmp, "long-body", _content(name="long-body", body=body) + ) + report = validate_skill(skill) + self.assertHasError(report.errors, "body is") + + def test_accepts_body_at_exact_limit(self) -> None: + body = "\n".join(f"line {i}" for i in range(MAX_BODY_LINES)) + "\n" + skill = _make_skill( + self.tmp, "at-limit", _content(name="at-limit", body=body) + ) + self.assertEqual(validate_skill(skill).errors, []) + + def test_ignores_blank_lines_around_body(self) -> None: + skill = _make_skill( + self.tmp, + "padded-body", + _content(name="padded-body", body="\n\n\n# Title\n\n\n\n"), + ) + self.assertEqual(validate_skill(skill).errors, []) + + def test_handles_crlf_line_endings(self) -> None: + skill = _make_skill( + self.tmp, "good-skill", _content().replace("\n", "\r\n") + ) + self.assertEqual(validate_skill(skill).errors, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate_skills.py b/scripts/validate_skills.py new file mode 100644 index 0000000..cf43c9f --- /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; runs tests + this + 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()) From b43ffed2c7d409352e455df9886e254428f0b0d7 Mon Sep 17 00:00:00 2001 From: Daniel Holanda Date: Mon, 4 May 2026 14:34:13 -0700 Subject: [PATCH 2/4] documentation --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index efb5d48..934d171 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,6 @@ Best for cross-cutting skills that do not have a natural product home. ```bash ./scripts/check.sh # runs unit tests and validates every SKILL.md ``` - Requires [`uv`](https://github.com/astral-sh/uv). The validator declares its own Python dependencies inline (PEP 723) so there is no `pip install` step. 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 From e3818edfe3ec2307fa65daa0c85af0fa3d1fcf8b Mon Sep 17 00:00:00 2001 From: Daniel Holanda Date: Mon, 4 May 2026 14:37:25 -0700 Subject: [PATCH 3/4] Simplify --- AUTHORING.md | 2 +- README.md | 2 +- scripts/check.sh | 6 +- scripts/test_validate_skills.py | 171 -------------------------------- scripts/validate_skills.py | 2 +- 5 files changed, 5 insertions(+), 178 deletions(-) delete mode 100644 scripts/test_validate_skills.py diff --git a/AUTHORING.md b/AUTHORING.md index 06e06d9..6c6cf1e 100644 --- a/AUTHORING.md +++ b/AUTHORING.md @@ -155,7 +155,7 @@ Test the skill the way users will hit it: 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 # runs unit tests + validates every skill (same command CI runs) +./scripts/check.sh # validates every skill (same command CI runs) ``` The validator checks every skill under `skills/` for: diff --git a/README.md b/README.md index 934d171..10bbb8c 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Best for cross-cutting skills that do not have a natural product home. 4. Register the skill in `.claude-plugin/marketplace.json` with a human-readable description. 5. Validate the skill locally before pushing: ```bash - ./scripts/check.sh # runs unit tests and validates every SKILL.md + ./scripts/check.sh # validates every SKILL.md ``` 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. diff --git a/scripts/check.sh b/scripts/check.sh index b4b095d..9f51803 100644 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Run unit tests and validate every SKILL.md in the catalog. +# Validate every SKILL.md in the catalog. # # Usage: -# ./scripts/check.sh Run tests + validate every skill. +# ./scripts/check.sh Validate every skill. # ./scripts/check.sh -h|--help Print this help. # # Requires `uv` (https://github.com/astral-sh/uv). @@ -24,9 +24,7 @@ usage() { case "${1:-}" in "") - uv run scripts/test_validate_skills.py uv run scripts/validate_skills.py - echo "All checks passed." ;; -h|--help) usage diff --git a/scripts/test_validate_skills.py b/scripts/test_validate_skills.py deleted file mode 100644 index 381a625..0000000 --- a/scripts/test_validate_skills.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env -S uv run --quiet -# /// script -# requires-python = ">=3.10" -# dependencies = ["pyyaml>=6.0"] -# /// -"""Unit tests for `validate_skills`. - -Run via: - - uv run scripts/test_validate_skills.py - -Each test creates a throwaway skill in a tempdir, runs `validate_skill`, and -asserts on the resulting report. No external test framework needed. -""" - -from __future__ import annotations - -import json -import shutil -import tempfile -import unittest -from pathlib import Path - -from validate_skills import ( - MAX_BODY_LINES, - MAX_DESCRIPTION_LEN, - MAX_NAME_LEN, - validate_skill, -) - - -def _content( - *, - name: str = "good-skill", - description: str = "Does the thing. Use when the user wants the thing done.", - body: str = "# Heading\n\nMinimal body content.\n", -) -> str: - return ( - "---\n" - f"name: {name}\n" - f"description: {json.dumps(description)}\n" - "---\n\n" - f"{body}" - ) - - -def _make_skill(parent: Path, dir_name: str, content: str | None) -> Path: - skill_dir = parent / dir_name - skill_dir.mkdir(parents=True, exist_ok=True) - if content is not None: - (skill_dir / "SKILL.md").write_text(content, encoding="utf-8") - return skill_dir - - -class ValidateSkillTests(unittest.TestCase): - def setUp(self) -> None: - self.tmp = Path(tempfile.mkdtemp(prefix="skills-test-")) - - def tearDown(self) -> None: - shutil.rmtree(self.tmp, ignore_errors=True) - - def assertHasError(self, errors: list[str], pattern: str) -> None: - self.assertTrue( - any(pattern in err for err in errors), - f"expected an error containing {pattern!r}; got: {errors}", - ) - - def test_accepts_well_formed_skill(self) -> None: - skill = _make_skill(self.tmp, "good-skill", _content()) - self.assertEqual(validate_skill(skill).errors, []) - - def test_flags_missing_skill_md(self) -> None: - skill = _make_skill(self.tmp, "no-skill-md", None) - report = validate_skill(skill) - self.assertEqual(len(report.errors), 1) - self.assertHasError(report.errors, "Missing SKILL.md") - - def test_flags_missing_frontmatter(self) -> None: - skill = _make_skill(self.tmp, "no-frontmatter", "# Body only\n") - report = validate_skill(skill) - self.assertHasError(report.errors, "YAML frontmatter block") - - def test_flags_invalid_yaml(self) -> None: - broken = "---\nname: : oops\n---\n\nbody\n" - skill = _make_skill(self.tmp, "broken-yaml", broken) - report = validate_skill(skill) - self.assertHasError(report.errors, "YAML frontmatter is invalid") - - def test_flags_missing_name(self) -> None: - skill = _make_skill( - self.tmp, - "no-name", - '---\ndescription: "Does X. Use when Y."\n---\n\nbody\n', - ) - report = validate_skill(skill) - self.assertHasError(report.errors, "`name` is missing") - - def test_flags_missing_description(self) -> None: - skill = _make_skill( - self.tmp, "no-description", "---\nname: no-description\n---\n\nbody\n" - ) - report = validate_skill(skill) - self.assertHasError(report.errors, "`description` is missing") - - def test_rejects_invalid_name_format(self) -> None: - skill = _make_skill(self.tmp, "Bad_Name", _content(name="Bad_Name")) - report = validate_skill(skill) - self.assertHasError(report.errors, "lowercase-with-hyphens") - - def test_rejects_too_long_name(self) -> None: - long_name = "a" * (MAX_NAME_LEN + 1) - skill = _make_skill(self.tmp, long_name, _content(name=long_name)) - report = validate_skill(skill) - self.assertHasError(report.errors, "exceeds") - - def test_rejects_reserved_name_substrings(self) -> None: - for reserved in ("claude-helper", "anthropic-tool"): - with self.subTest(name=reserved): - skill = _make_skill(self.tmp, reserved, _content(name=reserved)) - report = validate_skill(skill) - self.assertHasError(report.errors, "may not contain") - shutil.rmtree(skill) - - def test_rejects_name_directory_mismatch(self) -> None: - skill = _make_skill( - self.tmp, "actual-dir", _content(name="different-name") - ) - report = validate_skill(skill) - self.assertHasError(report.errors, "must match the skill directory") - - def test_rejects_too_long_description(self) -> None: - skill = _make_skill( - self.tmp, - "verbose", - _content(name="verbose", description="x" * (MAX_DESCRIPTION_LEN + 1)), - ) - report = validate_skill(skill) - self.assertHasError(report.errors, "`description` length") - - def test_rejects_too_long_body(self) -> None: - body = "\n".join(f"line {i}" for i in range(MAX_BODY_LINES + 1)) + "\n" - skill = _make_skill( - self.tmp, "long-body", _content(name="long-body", body=body) - ) - report = validate_skill(skill) - self.assertHasError(report.errors, "body is") - - def test_accepts_body_at_exact_limit(self) -> None: - body = "\n".join(f"line {i}" for i in range(MAX_BODY_LINES)) + "\n" - skill = _make_skill( - self.tmp, "at-limit", _content(name="at-limit", body=body) - ) - self.assertEqual(validate_skill(skill).errors, []) - - def test_ignores_blank_lines_around_body(self) -> None: - skill = _make_skill( - self.tmp, - "padded-body", - _content(name="padded-body", body="\n\n\n# Title\n\n\n\n"), - ) - self.assertEqual(validate_skill(skill).errors, []) - - def test_handles_crlf_line_endings(self) -> None: - skill = _make_skill( - self.tmp, "good-skill", _content().replace("\n", "\r\n") - ) - self.assertEqual(validate_skill(skill).errors, []) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/validate_skills.py b/scripts/validate_skills.py index cf43c9f..afec27b 100644 --- a/scripts/validate_skills.py +++ b/scripts/validate_skills.py @@ -16,7 +16,7 @@ Run from the repo root: - ./scripts/check.sh # used by CI; runs tests + this + ./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 From 07f6dc0b9b289d0b9c72ffd0f225b49c7ffb6312 Mon Sep 17 00:00:00 2001 From: Daniel Holanda Date: Mon, 4 May 2026 14:39:29 -0700 Subject: [PATCH 4/4] fix CI --- scripts/check.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/check.sh diff --git a/scripts/check.sh b/scripts/check.sh old mode 100644 new mode 100755