SPB Git

spb/ultra-sharp-agent-skills Public

Ultra-Sharp Agent Skills — a research-first skill-authoring system + 72 production-ready skills for AI agents.

Python 100%
5.1 KB · 145 lines python
Raw Blame History
1#!/usr/bin/env python32# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai4#5# validate_skills.py — deterministic linter enforcing the Sharp Skill Checklist6# (see RESEARCH-SYNTHESIS.md) on every skill in this repository. Stdlib only.7#8# Usage: python3 tools/validate_skills.py [repo_root]9# Exit codes: 0 = all skills pass, 1 = at least one failure, 2 = usage error.1011import re12import sys13from pathlib import Path1415# Spec limits from Anthropic's Agent Skills documentation.16NAME_MAX = 6417DESC_MAX = 102418BODY_MAX_LINES = 5001920NAME_RE = re.compile(r"^[a-z0-9-]+$")21# Reserved words are forbidden anywhere in the skill name per the spec.22RESERVED = ("anthropic", "claude")2324HEADER_AUTHOR = "Author: Simon-Pierre Boucher"25HEADER_CONTACT = "Contact: contact@spboucher.ai"2627# First/second-person openings that indicate a wrong point of view in a28# description, which the docs warn causes discovery problems.29BAD_POV = re.compile(r"\b(I can|I will|you can use this)\b", re.IGNORECASE)3031MD_LINK = re.compile(r"\]\(([^)#>][^)]*)\)")323334def parse_frontmatter(text):35    """Return (fields dict, body) or (None, text) if no frontmatter."""36    if not text.startswith("---\n"):37        return None, text38    end = text.find("\n---", 4)39    if end == -1:40        return None, text41    fields = {}42    for line in text[4:end].splitlines():43        if ":" in line and not line.startswith((" ", "\t", "#")):44            k, v = line.split(":", 1)45            fields[k.strip()] = v.strip().strip("\"'")46    return fields, text[end + 4:]474849def check_header(path, text):50    """Every project file must carry the author header near the top."""51    head = "\n".join(text.splitlines()[:12])52    return HEADER_AUTHOR in head and HEADER_CONTACT in head535455def lint_skill(skill_md, errors):56    text = skill_md.read_text(encoding="utf-8")57    rel = skill_md58    fm, body = parse_frontmatter(text)5960    if fm is None:61        errors.append(f"{rel}: missing or unterminated YAML frontmatter")62        return6364    name = fm.get("name", "")65    desc = fm.get("description", "")6667    if not name:68        errors.append(f"{rel}: frontmatter missing 'name'")69    else:70        if len(name) > NAME_MAX:71            errors.append(f"{rel}: name exceeds {NAME_MAX} chars")72        if not NAME_RE.match(name):73            errors.append(f"{rel}: name must be lowercase letters/numbers/hyphens")74        if any(w in name for w in RESERVED):75            errors.append(f"{rel}: name contains a reserved word {RESERVED}")76        # CLAUDE.md mandates 'skill-N-<name>' folders for the example skills;77        # the frontmatter name must match the folder minus that prefix.78        folder = re.sub(r"^skill-\d+-", "", skill_md.parent.name)79        if name != folder:80            errors.append(f"{rel}: name '{name}' != folder '{folder}'")8182    if not desc:83        errors.append(f"{rel}: frontmatter missing 'description'")84    else:85        if len(desc) > DESC_MAX:86            errors.append(f"{rel}: description exceeds {DESC_MAX} chars ({len(desc)})")87        if "<" in desc and ">" in desc:88            errors.append(f"{rel}: description may contain XML tags")89        if "Use when" not in desc and "use when" not in desc:90            errors.append(f"{rel}: description lacks a 'Use when …' trigger clause")91        if "Do not use" not in desc and "do not use" not in desc:92            errors.append(f"{rel}: description lacks a 'Do not use for …' boundary")93        if BAD_POV.search(desc):94            errors.append(f"{rel}: description not in third person")9596    if not check_header(skill_md.parent / "SKILL.md", body):97        errors.append(f"{rel}: author header missing after frontmatter")9899    n_lines = len(body.splitlines())100    if n_lines > BODY_MAX_LINES:101        errors.append(f"{rel}: body has {n_lines} lines (max {BODY_MAX_LINES})")102103    for target in MD_LINK.findall(body):104        if target.startswith(("http://", "https://", "mailto:")):105            continue106        if "\\" in target:107            errors.append(f"{rel}: backslash path in link '{target}'")108            continue109        if not (skill_md.parent / target).exists():110            errors.append(f"{rel}: broken reference link '{target}'")111112    for sub in skill_md.parent.rglob("*"):113        if sub.is_file() and sub != skill_md and sub.suffix in (".md", ".py", ".sh"):114            if not check_header(sub, sub.read_text(encoding="utf-8", errors="replace")):115                errors.append(f"{sub}: author header missing")116117118def main():119    root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")120    if not root.is_dir():121        print(f"error: not a directory: {root}", file=sys.stderr)122        sys.exit(2)123124    skill_files = sorted(root.rglob("SKILL.md"))125    if not skill_files:126        print(f"error: no SKILL.md files found under {root}", file=sys.stderr)127        sys.exit(2)128129    errors = []130    for skill_md in skill_files:131        lint_skill(skill_md, errors)132133    print(f"checked {len(skill_files)} skill(s)")134    if errors:135        for e in errors:136            print(f"FAIL {e}")137        print(f"{len(errors)} failure(s)")138        sys.exit(1)139    print("all checks passed")140    sys.exit(0)141142143if __name__ == "__main__":144    main()145