Skip to content

Commit 684d7fe

Browse files
ci: lint skill frontmatter against Skills-API rules (DAT-563)
`claude plugin validate --strict` (run above) does NOT enforce the Agent Skills API frontmatter limits — it only truncates over-long descriptions for its listing budget. The claude.ai / Cowork "Add marketplace" sync, by contrast, uploads every bundled skill to the Skills API and HARD-REJECTS any skill whose description exceeds 1024 chars or contains XML tags, failing the entire sync with the generic "Marketplace sync failed. Check the repository URL and try again." (root cause of DAT-563). Add scripts/validate_skill_frontmatter.py and run it in validate.yml. For every skills/*/SKILL.md it enforces the documented rules the CLI misses: - description: required, non-empty, <=1024 chars AND <=1024 UTF-8 bytes, no XML tags - name: required, <=64 chars, ^[a-z0-9]+(-[a-z0-9]+)*$, no XML tags, no reserved words ("anthropic"/"claude") Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 21e32c9 commit 684d7fe

2 files changed

Lines changed: 128 additions & 0 deletions

File tree

.github/workflows/validate.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ jobs:
6565
python -m pip install --quiet jsonschema
6666
python scripts/validate_cursor_manifest.py
6767
68+
- name: Validate skill frontmatter (Skills-API rules)
69+
# `claude plugin validate` only truncates over-long descriptions; the
70+
# claude.ai / Cowork "Add marketplace" sync HARD-REJECTS them (and any XML
71+
# tags) and fails the whole sync. This lints what the CLI misses: per skill,
72+
# description non-empty + <=1024 chars/bytes + no XML tags, and name <=64,
73+
# kebab-case, no reserved words. See DAT-563.
74+
shell: bash
75+
run: |
76+
set -euo pipefail
77+
python -m pip install --quiet pyyaml
78+
python scripts/validate_skill_frontmatter.py
79+
6880
- name: Check manifest versions agree
6981
shell: bash
7082
# Assumes the current single-plugin marketplace (plugins[0], plugin.json
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#!/usr/bin/env python3
2+
"""Validate every skill's SKILL.md frontmatter against the Anthropic Agent Skills
3+
API rules that the claude.ai / Cowork marketplace sync enforces.
4+
5+
`claude plugin validate` (run by validate.yml) does NOT enforce these — it only
6+
truncates over-long descriptions for its listing budget — so a skill that the CLI
7+
and `/plugin marketplace add` accept can still hard-fail the web "Add marketplace"
8+
sync with the generic "Marketplace sync failed. Check the repository URL and try
9+
again." Because the web sync uploads every bundled skill to the Skills API and
10+
replaces all plugins atomically, ONE invalid skill fails the WHOLE sync. This
11+
linter closes that gap. See DAT-563 / anthropics/claude-code#63081.
12+
13+
Rules (https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices):
14+
name required; <= 64 chars; ^[a-z0-9]+(-[a-z0-9]+)*$; no XML tags;
15+
must not contain the reserved words "anthropic" / "claude".
16+
description required; non-empty; <= 1024 chars AND <= 1024 UTF-8 bytes;
17+
no XML tags (angle-bracket tags break the web upload).
18+
19+
Exit non-zero if any skill violates a rule, printing every problem (and a
20+
GitHub Actions ::error annotation per file).
21+
"""
22+
from __future__ import annotations
23+
24+
import glob
25+
import re
26+
import sys
27+
28+
import yaml
29+
30+
NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
31+
XML_TAG_RE = re.compile(r"</?[a-zA-Z][^>]*>")
32+
RESERVED_WORDS = ("anthropic", "claude")
33+
MAX_NAME_CHARS = 64
34+
MAX_DESC_CHARS = 1024
35+
MAX_DESC_BYTES = 1024
36+
37+
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*(?:\n|$)", re.S)
38+
39+
40+
def load_frontmatter(path: str) -> dict:
41+
with open(path, encoding="utf-8") as fh:
42+
text = fh.read()
43+
match = FRONTMATTER_RE.match(text)
44+
if not match:
45+
raise ValueError("missing or malformed YAML frontmatter (--- ... ---)")
46+
data = yaml.safe_load(match.group(1))
47+
if not isinstance(data, dict):
48+
raise ValueError("frontmatter is not a YAML mapping")
49+
return data
50+
51+
52+
def check_skill(path: str) -> list[str]:
53+
try:
54+
fm = load_frontmatter(path)
55+
except Exception as exc: # noqa: BLE001 — surface any parse error as a finding
56+
return [str(exc)]
57+
58+
errors: list[str] = []
59+
60+
name = fm.get("name")
61+
if not isinstance(name, str) or not name.strip():
62+
errors.append("name: missing or empty")
63+
else:
64+
if len(name) > MAX_NAME_CHARS:
65+
errors.append(f"name: {len(name)} chars > {MAX_NAME_CHARS}")
66+
if not NAME_RE.match(name):
67+
errors.append(f"name: {name!r} must match ^[a-z0-9]+(-[a-z0-9]+)*$")
68+
if XML_TAG_RE.search(name):
69+
errors.append("name: must not contain XML tags")
70+
lowered = name.lower()
71+
for word in RESERVED_WORDS:
72+
if word in lowered:
73+
errors.append(f"name: must not contain reserved word {word!r}")
74+
75+
description = fm.get("description")
76+
if not isinstance(description, str) or not description.strip():
77+
errors.append("description: missing or empty")
78+
else:
79+
n_chars = len(description)
80+
n_bytes = len(description.encode("utf-8"))
81+
if n_chars > MAX_DESC_CHARS:
82+
errors.append(f"description: {n_chars} chars > {MAX_DESC_CHARS}")
83+
if n_bytes > MAX_DESC_BYTES:
84+
errors.append(f"description: {n_bytes} bytes > {MAX_DESC_BYTES}")
85+
tag = XML_TAG_RE.search(description)
86+
if tag:
87+
errors.append(f"description: must not contain XML tags (found {tag.group(0)!r})")
88+
89+
return errors
90+
91+
92+
def main() -> int:
93+
paths = sorted(glob.glob("skills/*/SKILL.md"))
94+
if not paths:
95+
print("error: no skills/*/SKILL.md found (run from the repo root)", file=sys.stderr)
96+
return 1
97+
98+
failed = 0
99+
for path in paths:
100+
errors = check_skill(path)
101+
if errors:
102+
failed += 1
103+
print(f"::error file={path}::{'; '.join(errors)}")
104+
print(f"✘ {path}")
105+
for err in errors:
106+
print(f" - {err}")
107+
else:
108+
print(f"✔ {path}")
109+
110+
print()
111+
print(f"{len(paths)} skills checked, {failed} failed")
112+
return 1 if failed else 0
113+
114+
115+
if __name__ == "__main__":
116+
raise SystemExit(main())

0 commit comments

Comments
 (0)