diff --git a/tools/agentskills-skill.schema.json b/tools/agentskills-skill.schema.json new file mode 100644 index 00000000..444cbc77 --- /dev/null +++ b/tools/agentskills-skill.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentskills.io/schema/skill-frontmatter.json", + "title": "agentskills.io SKILL.md frontmatter", + "description": "Strict JSON Schema for the YAML frontmatter of a SKILL.md file per the agentskills.io open standard (Anthropic, 2025-12-18). Only `name` and `description` are required; a small optional set is permitted; every other top-level key is a non-standard 'unexpected field'. Constraints that cannot be expressed in JSON Schema (name must equal the parent directory; frontmatter must contain no angle brackets) are enforced by tools/validate-agentskills.py.", + "type": "object", + "additionalProperties": false, + "required": ["name", "description"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "1-64 chars, lowercase alphanumeric and single hyphens only; no leading/trailing/consecutive hyphens; must match the parent directory name; must not be a reserved word." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "1-1024 chars. Must state BOTH what the skill does and when to use it." + }, + "license": { + "type": "string", + "minLength": 1, + "description": "SPDX license identifier or license text." + }, + "compatibility": { + "type": "string", + "maxLength": 500, + "description": "Free-text compatibility notes (<=500 chars)." + }, + "metadata": { + "type": "object", + "description": "Arbitrary key/value map for non-standard fields. This is where domain, subdomain, tags, version, author, and framework mappings (mitre_attack, nist_csf, atlas_techniques, d3fend_techniques, nist_ai_rmf, mitre_f3) belong under strict compliance.", + "additionalProperties": true + }, + "allowed-tools": { + "description": "EXPERIMENTAL. Tools the skill is permitted to use; string or array of strings.", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + } + } +} diff --git a/tools/validate-agentskills.py b/tools/validate-agentskills.py new file mode 100644 index 00000000..9fe0a74d --- /dev/null +++ b/tools/validate-agentskills.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Validate SKILL.md frontmatter against the strict agentskills.io standard. + +Reports, per skill, any deviation from tools/agentskills-skill.schema.json plus +the two constraints JSON Schema can't express (name == parent dir; no angle +brackets in frontmatter). READ-ONLY; never edits files. + +Usage: + python3 tools/validate-agentskills.py # summary + report + python3 tools/validate-agentskills.py --json # machine-readable JSON + python3 tools/validate-agentskills.py --strict # exit 1 if any non-compliant +""" +import os, re, sys, json, glob +from collections import Counter + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ALLOWED = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"} +NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + +def top_level_keys_and_scalars(fm): + """Minimal YAML: top-level keys (col 0) + scalar values for name/description.""" + keys = [] + scalars = {} + lines = fm.split("\n") + for i, line in enumerate(lines): + m = re.match(r"^([A-Za-z0-9_-]+):(.*)$", line) + if not m: + continue + key, rest = m.group(1), m.group(2) + keys.append(key) + val = rest.strip() + if val and val[0] in "|>": # block scalar -> gather following indented lines + buf = [] + for nxt in lines[i + 1:]: + if re.match(r"^\s+\S", nxt): + buf.append(nxt.strip()) + elif nxt.strip() == "": + buf.append("") + else: + break + val = " ".join(x for x in buf if x != "").strip() + elif not val: + # could be a folded plain scalar wrapped onto continuation lines + buf = [] + for nxt in lines[i + 1:]: + if re.match(r"^\s+-\s", nxt) or re.match(r"^[A-Za-z0-9_-]+:", nxt): + break + if re.match(r"^\s+\S", nxt): + buf.append(nxt.strip()) + else: + break + val = " ".join(buf).strip() + scalars[key] = val.strip().strip("\"'") + return keys, scalars + +def validate(path): + slug = os.path.basename(os.path.dirname(path)) + text = open(path, encoding="utf-8").read() + m = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) + problems = [] + if not m: + return slug, ["no YAML frontmatter block"], [] + fm = m.group(1) + keys, scalars = top_level_keys_and_scalars(fm) + + if "name" not in keys: + problems.append("missing required key: name") + if "description" not in keys: + problems.append("missing required key: description") + + name = scalars.get("name", "") + if name: + if not NAME_RE.match(name): + problems.append(f"name not lowercase-kebab-case: {name!r}") + if not (1 <= len(name) <= 64): + problems.append(f"name length {len(name)} out of 1..64") + if name != slug: + problems.append(f"name {name!r} != directory {slug!r}") + + desc = scalars.get("description", "") + if desc: + if not (1 <= len(desc) <= 1024): + problems.append(f"description length {len(desc)} out of 1..1024") + elif "description" in keys: + problems.append("description empty") + + if "<" in fm or ">" in fm: + problems.append("frontmatter contains angle brackets (injection risk / not allowed)") + + nonstd = [k for k in keys if k not in ALLOWED] + for k in nonstd: + problems.append(f"non-standard top-level key: {k}") + return slug, problems, nonstd + +def main(): + as_json = "--json" in sys.argv + strict = "--strict" in sys.argv + skills = sorted(glob.glob(os.path.join(REPO, "skills", "*", "SKILL.md"))) + results = [] + nonstd_hist = Counter() + compliant = 0 + for p in skills: + slug, problems, nonstd = validate(p) + nonstd_hist.update(nonstd) + if not problems: + compliant += 1 + results.append({"skill": slug, "compliant": not problems, "problems": problems}) + noncompliant = [r for r in results if not r["compliant"]] + summary = { + "total": len(skills), + "compliant": compliant, + "noncompliant": len(noncompliant), + "nonstandard_key_frequency": dict(nonstd_hist.most_common()), + } + if as_json: + print(json.dumps({"summary": summary, "results": results}, indent=1)) + else: + print(f"agentskills.io compliance: {compliant}/{len(skills)} compliant, " + f"{len(noncompliant)} non-compliant") + print("\nNon-standard top-level keys (count of skills carrying each):") + for k, n in nonstd_hist.most_common(): + print(f" {k:20s} {n}") + # distinct problem types (excluding the per-key nonstd noise) + other = Counter() + for r in noncompliant: + for pr in r["problems"]: + if not pr.startswith("non-standard top-level key:"): + other[re.sub(r':.*$', '', pr)] += 1 + if other: + print("\nOther (non-key) issues:") + for k, n in other.most_common(): + print(f" {k}: {n}") + if strict and noncompliant: + sys.exit(1) + +if __name__ == "__main__": + main()