claude-skills/scripts/check_frontmatter.py
Ben Fairless 5b85956d63
feat(ci): add frontmatter YAML validator as gate G10
Every existing gate reads frontmatter with a regex or a line scan
(generate-docs.py, sync-codex-skills.py, check_paths.py), so a block that is
not valid YAML passed CI while Claude Code loaded the skill with no metadata.
The 14 files fixed in the previous commit had drifted that way unnoticed.

check_frontmatter.py parses each block with yaml.safe_load and enforces what
Claude Code actually reads:

  errors   - unparseable YAML, non-mapping frontmatter, missing description,
             missing agent name, an agent name containing ':' (refused since
             CC 2.1.218), or a missing frontmatter block
  warnings - keys outside the current skill/agent frontmatter spec, and a
             combined description + when_to_use over the 1536-char cap that
             the skill listing truncates at

Warnings are non-blocking so this lands without requiring the wider metadata
cleanup; --strict flips them fatal. The run also tallies the off-spec keys no
runtime reads (license 172, metadata 125, domain 76, compatible_tools 37,
triggers 14), which gives that cleanup a worklist regenerated on every run.

Clean on the current tree: 593 files, 0 errors, 17 warnings.

Assisted-by: Claude Code:claude-opus-5
2026-08-03 08:55:36 +08:00

242 lines
9.2 KiB
Python

#!/usr/bin/env python3
"""check_frontmatter.py — YAML frontmatter validator for skills, agents, and commands.
Every other gate in this repo extracts frontmatter with a regex or a line scan
(see generate-docs.py and sync-codex-skills.py), so a block that is not valid
YAML sails through CI unnoticed. Claude Code parses it properly: when the YAML
is malformed it loads the body with EMPTY metadata, which silently removes the
skill's `description` from the skill listing and makes it invisible to
model invocation. Agents, where `name` and `description` are required, may fail
to load entirely.
This gate parses each block with yaml.safe_load and enforces the fields Claude
Code actually reads.
Errors (exit 1):
- frontmatter block present but not parseable, or not a YAML mapping
- missing `description` (skills/commands) or `name`/`description` (agents)
- an agent `name` containing ':' (rejected by Claude Code >= 2.1.218)
- no frontmatter block at all in a file that should have one
Warnings (exit 0 unless --strict):
- keys outside the current Claude Code frontmatter spec
- combined description + when_to_use over the 1536-char skill-listing cap
Exit codes: 0 = clean, 1 = at least one error (or a warning under --strict).
Intended as CI gate G10.
Usage:
python3 scripts/check_frontmatter.py --all # scan canonical dirs repo-wide
python3 scripts/check_frontmatter.py FILE [FILE ...] # scan specific files
python3 scripts/check_frontmatter.py --all --json # machine-readable output
python3 scripts/check_frontmatter.py --all --strict # warnings become errors
"""
import argparse
import fnmatch
import json
import os
import re
import sys
try:
import yaml
except ImportError: # pragma: no cover - CI installs yamllint, which vendors PyYAML
sys.stderr.write(
"check_frontmatter.py needs PyYAML (pip install pyyaml). "
"Parsing frontmatter with a regex is what this gate exists to prevent.\n"
)
sys.exit(2)
EXCLUDED_DIRS = {
".git", ".codex", ".gemini", ".hermes", ".vibe", "docs", "audit",
"node_modules", ".github", ".claude-plugin",
}
FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n", re.DOTALL)
# Files that legitimately carry no frontmatter: documentation that happens to
# live inside a canonical dir, and the skill-tester's deliberate bare fixture.
DOC_BASENAMES = {"README.md", "CLAUDE.md", "TEMPLATE.md"}
NO_FRONTMATTER_OK = ("*/assets/sample-skill/SKILL.md",)
# Claude Code skill frontmatter. Commands are skills, so they share this set.
# https://code.claude.com/docs/en/skills#frontmatter-reference
SKILL_SPEC_KEYS = {
"name", "description", "when_to_use", "argument-hint", "arguments",
"disable-model-invocation", "user-invocable", "allowed-tools",
"disallowed-tools", "model", "effort", "context", "agent", "background",
"hooks", "paths", "shell",
}
# Claude Code subagent frontmatter.
# https://code.claude.com/docs/en/sub-agents#write-subagent-files
AGENT_SPEC_KEYS = {
"name", "description", "tools", "disallowedTools", "model",
"permissionMode", "maxTurns", "skills", "mcpServers", "hooks", "memory",
"background", "effort", "isolation", "color", "initialPrompt",
}
# Pre-existing repo conventions that no runtime reads. Reported separately from
# genuine typos so the migration in the v5 plan has a worklist rather than noise.
KNOWN_REPO_EXTRAS = {
"license", "metadata", "version", "author", "tags", "compatible_tools",
"triggers", "command", "domain", "title", "emoji", "vibe", "tier",
"category", "dependencies", "not_for", "agents", "source", "attribution",
}
# Combined description + when_to_use budget per entry in the skill listing.
DESC_CAP = 1536
def kind_for(rel_path):
"""skill | agent | command, based on where the file lives."""
if os.path.basename(rel_path) == "SKILL.md":
return "skill"
parts = rel_path.split(os.sep)
if "agents" in parts:
return "agent"
return "command"
def no_frontmatter_ok(rel_path):
if os.path.basename(rel_path) in DOC_BASENAMES:
return True
posix = rel_path.replace(os.sep, "/")
return any(fnmatch.fnmatch(posix, pat) for pat in NO_FRONTMATTER_OK)
def check_file(path, repo_root):
"""Return (errors, warnings, offspec_keys) for one file."""
rel = os.path.relpath(path, repo_root)
kind = kind_for(rel)
errors, warnings, offspec = [], [], []
text = open(path, encoding="utf-8", errors="replace").read()
match = FRONTMATTER_RE.match(text)
if not match:
if not no_frontmatter_ok(rel):
errors.append("no YAML frontmatter block")
return errors, warnings, offspec
try:
data = yaml.safe_load(match.group(1))
except yaml.YAMLError as exc:
detail = str(exc).replace("\n", " ")
errors.append(f"frontmatter is not valid YAML: {detail}")
return errors, warnings, offspec
if not isinstance(data, dict):
errors.append(f"frontmatter is not a YAML mapping (parsed as {type(data).__name__})")
return errors, warnings, offspec
if not str(data.get("description") or "").strip():
errors.append("missing `description` — Claude Code has nothing to match the skill against")
if kind == "agent":
name = str(data.get("name") or "").strip()
if not name:
errors.append("missing `name` — required for subagents")
elif ":" in name:
errors.append(f"`name: {name}` contains ':', which Claude Code >= 2.1.218 refuses to load")
spec = AGENT_SPEC_KEYS if kind == "agent" else SKILL_SPEC_KEYS
for key in data:
if key in spec:
continue
if key in KNOWN_REPO_EXTRAS:
offspec.append(key)
else:
warnings.append(f"unrecognized key `{key}` (not in the {kind} frontmatter spec)")
listing = len(str(data.get("description") or "")) + len(str(data.get("when_to_use") or ""))
if kind != "agent" and listing > DESC_CAP:
warnings.append(
f"description + when_to_use is {listing} chars; the skill listing truncates at {DESC_CAP}"
)
return errors, warnings, offspec
def collect_canonical(repo_root):
"""All SKILL.md + *.md under any agents/ or commands/ dir, excluding sync/doc trees."""
targets = []
for dirpath, dirnames, filenames in os.walk(repo_root):
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIRS]
parts = os.path.relpath(dirpath, repo_root).split(os.sep)
in_canonical_dir = "agents" in parts or "commands" in parts
for fn in filenames:
if fn == "SKILL.md" or (in_canonical_dir and fn.endswith(".md")):
targets.append(os.path.join(dirpath, fn))
return sorted(targets)
def main():
ap = argparse.ArgumentParser(
description="Validate YAML frontmatter on SKILL.md / agents / commands files."
)
ap.add_argument("files", nargs="*", help="Specific markdown files to scan")
ap.add_argument("--all", action="store_true",
help="Scan all SKILL.md + agents/*.md + commands/*.md in the repo")
ap.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
ap.add_argument("--strict", action="store_true", help="Treat warnings as errors")
ap.add_argument("--root", default=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
help="Repo root (default: parent of this script)")
args = ap.parse_args()
repo_root = os.path.abspath(args.root)
if args.all:
targets = collect_canonical(repo_root)
elif args.files:
targets = [os.path.abspath(f) for f in args.files]
else:
ap.print_help()
return 0
errors, warnings = {}, {}
offspec_counts = {}
for path in targets:
errs, warns, offspec = check_file(path, repo_root)
rel = os.path.relpath(path, repo_root)
if errs:
errors[rel] = errs
if warns:
warnings[rel] = warns
for key in offspec:
offspec_counts[key] = offspec_counts.get(key, 0) + 1
n_err = sum(len(v) for v in errors.values())
n_warn = sum(len(v) for v in warnings.values())
if args.json:
print(json.dumps({
"files_scanned": len(targets),
"errors": errors,
"warnings": warnings,
"total_errors": n_err,
"total_warnings": n_warn,
"off_spec_key_counts": dict(sorted(offspec_counts.items(),
key=lambda kv: -kv[1])),
}, indent=2))
else:
for rel in sorted(errors):
print(f"{rel}:")
for msg in errors[rel]:
print(f" ERROR: {msg}")
for rel in sorted(warnings):
print(f"{rel}:")
for msg in warnings[rel]:
print(f" WARN: {msg}")
if offspec_counts:
print("\nOff-spec keys still in use (no runtime reads these):")
for key, count in sorted(offspec_counts.items(), key=lambda kv: -kv[1]):
print(f" {count:4d} {key}")
print(f"\nScanned {len(targets)} files; {n_err} errors, {n_warn} warnings.")
if n_err:
return 1
return 1 if (args.strict and n_warn) else 0
if __name__ == "__main__":
sys.exit(main())