#!/usr/bin/env python3 # Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai # # validate_skills.py — deterministic linter enforcing the Sharp Skill Checklist # (see RESEARCH-SYNTHESIS.md) on every skill in this repository. Stdlib only. # # Usage: python3 tools/validate_skills.py [repo_root] # Exit codes: 0 = all skills pass, 1 = at least one failure, 2 = usage error. import re import sys from pathlib import Path # Spec limits from Anthropic's Agent Skills documentation. NAME_MAX = 64 DESC_MAX = 1024 BODY_MAX_LINES = 500 NAME_RE = re.compile(r"^[a-z0-9-]+$") # Reserved words are forbidden anywhere in the skill name per the spec. RESERVED = ("anthropic", "claude") HEADER_AUTHOR = "Author: Simon-Pierre Boucher" HEADER_CONTACT = "Contact: contact@spboucher.ai" # First/second-person openings that indicate a wrong point of view in a # description, which the docs warn causes discovery problems. BAD_POV = re.compile(r"\b(I can|I will|you can use this)\b", re.IGNORECASE) MD_LINK = re.compile(r"\]\(([^)#>][^)]*)\)") def parse_frontmatter(text): """Return (fields dict, body) or (None, text) if no frontmatter.""" if not text.startswith("---\n"): return None, text end = text.find("\n---", 4) if end == -1: return None, text fields = {} for line in text[4:end].splitlines(): if ":" in line and not line.startswith((" ", "\t", "#")): k, v = line.split(":", 1) fields[k.strip()] = v.strip().strip("\"'") return fields, text[end + 4:] def check_header(path, text): """Every project file must carry the author header near the top.""" head = "\n".join(text.splitlines()[:12]) return HEADER_AUTHOR in head and HEADER_CONTACT in head def lint_skill(skill_md, errors): text = skill_md.read_text(encoding="utf-8") rel = skill_md fm, body = parse_frontmatter(text) if fm is None: errors.append(f"{rel}: missing or unterminated YAML frontmatter") return name = fm.get("name", "") desc = fm.get("description", "") if not name: errors.append(f"{rel}: frontmatter missing 'name'") else: if len(name) > NAME_MAX: errors.append(f"{rel}: name exceeds {NAME_MAX} chars") if not NAME_RE.match(name): errors.append(f"{rel}: name must be lowercase letters/numbers/hyphens") if any(w in name for w in RESERVED): errors.append(f"{rel}: name contains a reserved word {RESERVED}") # CLAUDE.md mandates 'skill-N-' folders for the example skills; # the frontmatter name must match the folder minus that prefix. folder = re.sub(r"^skill-\d+-", "", skill_md.parent.name) if name != folder: errors.append(f"{rel}: name '{name}' != folder '{folder}'") if not desc: errors.append(f"{rel}: frontmatter missing 'description'") else: if len(desc) > DESC_MAX: errors.append(f"{rel}: description exceeds {DESC_MAX} chars ({len(desc)})") if "<" in desc and ">" in desc: errors.append(f"{rel}: description may contain XML tags") if "Use when" not in desc and "use when" not in desc: errors.append(f"{rel}: description lacks a 'Use when …' trigger clause") if "Do not use" not in desc and "do not use" not in desc: errors.append(f"{rel}: description lacks a 'Do not use for …' boundary") if BAD_POV.search(desc): errors.append(f"{rel}: description not in third person") if not check_header(skill_md.parent / "SKILL.md", body): errors.append(f"{rel}: author header missing after frontmatter") n_lines = len(body.splitlines()) if n_lines > BODY_MAX_LINES: errors.append(f"{rel}: body has {n_lines} lines (max {BODY_MAX_LINES})") for target in MD_LINK.findall(body): if target.startswith(("http://", "https://", "mailto:")): continue if "\\" in target: errors.append(f"{rel}: backslash path in link '{target}'") continue if not (skill_md.parent / target).exists(): errors.append(f"{rel}: broken reference link '{target}'") for sub in skill_md.parent.rglob("*"): if sub.is_file() and sub != skill_md and sub.suffix in (".md", ".py", ".sh"): if not check_header(sub, sub.read_text(encoding="utf-8", errors="replace")): errors.append(f"{sub}: author header missing") def main(): root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".") if not root.is_dir(): print(f"error: not a directory: {root}", file=sys.stderr) sys.exit(2) skill_files = sorted(root.rglob("SKILL.md")) if not skill_files: print(f"error: no SKILL.md files found under {root}", file=sys.stderr) sys.exit(2) errors = [] for skill_md in skill_files: lint_skill(skill_md, errors) print(f"checked {len(skill_files)} skill(s)") if errors: for e in errors: print(f"FAIL {e}") print(f"{len(errors)} failure(s)") sys.exit(1) print("all checks passed") sys.exit(0) if __name__ == "__main__": main()