#!/usr/bin/env bash set -euo pipefail usage() { cat < Validate a skill directory against the agentskills.io specification. Arguments: skill-dir Path to the skill directory containing SKILL.md. Exit codes: 0 All checks passed (may include SUGGESTIONs) 1 One or more checks failed EOF } if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then usage exit 0 fi if [[ $# -lt 1 ]]; then echo "Error: skill-dir is required." >&2 echo "" >&2 usage >&2 exit 1 fi python3 -u - "$1" <<'PYTHON' import sys import os import re import glob skill_dir = os.path.abspath(sys.argv[1]) skill_md = os.path.join(skill_dir, "SKILL.md") if not os.path.isfile(skill_md): print(f"Error: '{skill_md}' not found.", file=sys.stderr) sys.exit(1) with open(skill_md) as f: content = f.read() failed = False suggestions = [] def ok(msg): print(f"PASS {msg}") def fail(msg): global failed print(f"FAIL {msg}") failed = True def suggest(msg): # SUGGESTIONs are printed after every check and NEVER touch the exit code. # skill-audit's Step 4 report counts them into its `PASS (N suggestions)` # result line, which is what makes the ADR-0020 SUGGESTION tier visible # rather than another silently-ignored warning (ADR-0013). suggestions.append(msg) # --- Parse frontmatter --- fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) if not fm_match: fail("No valid YAML frontmatter block found (expected ---...---)") sys.exit(1) fm = fm_match.group(1) body_start = fm_match.end() # Extract name name_m = re.search(r'^name:\s*(\S+)', fm, re.MULTILINE) name = name_m.group(1).strip('"\'') if name_m else "" # Extract description — the VALUE, with YAML folding resolved. Most of this # corpus writes descriptions as `>`-folded block scalars, so the raw lines # carry indentation and newlines that are not part of the value: every length # measurement below is wrong unless the scalar is folded first. PyYAML is used # when importable (it is a real parser); the fallback recognises exactly the # shapes this corpus uses — an inline scalar, optionally quoted and optionally # continued on following indented lines, and a `>`/`|` block scalar with # optional indentation and chomping indicators. def normalize(value): return re.sub(r'\s+', ' ', value).strip() def fold_description_fallback(fm_text): lines = fm_text.splitlines() for i, line in enumerate(lines): m = re.match(r'^description:[ \t]*(.*)$', line) if not m: continue head = m.group(1).strip() block = bool(re.match(r'^[>|][0-9]*[-+]?$|^[>|][-+]?[0-9]*$', head)) parts = [] if block else [head] for nxt in lines[i + 1:]: if not nxt.strip(): parts.append('') continue if not re.match(r'^[ \t]', nxt): break parts.append(nxt.strip()) value = ' '.join(parts) if not block: value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in '"\'': value = value[1:-1] return value return '' def extract_description(fm_text): try: import yaml data = yaml.safe_load(fm_text) if isinstance(data, dict): value = data.get('description') if isinstance(value, str): return normalize(value) if value is not None: return normalize(str(value)) return '' except Exception: pass return normalize(fold_description_fallback(fm_text)) desc = extract_description(fm) dir_name = os.path.basename(skill_dir) # --- Checks --- # name present if name: ok(f"name present: '{name}'") else: fail("name field is missing or empty") # name matches directory if name and dir_name: if name == dir_name: ok(f"name '{name}' matches directory '{dir_name}'") else: fail(f"name '{name}' does not match directory '{dir_name}'") # name length if name: if len(name) <= 64: ok(f"name length {len(name)} chars (limit: 64)") else: fail(f"name '{name}' is {len(name)} chars — exceeds 64-character limit") # name format if name: if re.match(r'^[a-z0-9]+(-[a-z0-9]+)*$', name): ok(f"name format valid (kebab-case)") else: fail(f"name '{name}' is invalid — use lowercase letters, numbers, and hyphens only; no leading, trailing, or consecutive hyphens") # description present if desc: ok(f"description present") else: fail("description field is missing or empty") # description length — agentskills.io spec backstop. UNCHANGED by ADR-0020: # 1024 is the specification's hard limit, and the ADR-0020 budget gate below # sits underneath it rather than replacing it. if desc: dlen = len(desc) if dlen <= 1024: ok(f"description length {dlen} chars (agentskills.io spec limit: 1024)") else: fail(f"description length {dlen} chars — exceeds 1024-character limit") # Unfilled placeholder detection — matches FILL IN: followed by actual content, # but not backtick-quoted references like `FILL IN:` used in instructions. PLACEHOLDER_RE = re.compile(r'(? DESC_MAX_CHARS: fail(f"description is {dlen} chars — exceeds the {DESC_MAX_CHARS}-character " f"ADR-0020 ceiling. It is preloaded into every session whether or not the " f"skill is invoked. Keep a trigger clause, at most one capability clause, " f"and a boundary clause; move capability enumeration, output-format detail, " f"composition notes and implementation detail to the body or README.md") elif dlen > DESC_SUGGEST_CHARS: suggest(f"description is {dlen} chars — over the {DESC_SUGGEST_CHARS}-character " f"ADR-0020 target (hard fail at {DESC_MAX_CHARS}). The SUGGESTION tier is " f"what moves the corpus average; the FAIL tier only stops outliers") else: ok(f"description length {dlen} chars (ADR-0020 target: {DESC_SUGGEST_CHARS})") # --- ADR-0020: body budget ------------------------------------------------- # Counts the BODY ONLY — everything after the closing --- of the frontmatter. # This is a different measurement from MAX_WORDS above, which counts the whole # file including frontmatter as a spec-conformance backstop. Both are reported. body_word_count = len(body.split()) if body_word_count > BODY_MAX_WORDS: fail(f"SKILL.md body is {body_word_count} words — exceeds the {BODY_MAX_WORDS}-word " f"ADR-0020 ceiling (body only; separate from the {MAX_WORDS}-word whole-file " f"limit above). Move lookup tables, spec restatements, output schemas, templates " f"and rationale prose to references/ behind an explicit " f"\"If X, read `references/file.md`\" trigger. At two or more mutually exclusive " f"flows, dispatch is mandatory: the body carries the dispatch table and the gates " f"common to every branch, each flow gets its own self-contained references/ file") elif body_word_count > BODY_SUGGEST_WORDS: suggest(f"SKILL.md body is {body_word_count} words — over the {BODY_SUGGEST_WORDS}-word " f"ADR-0020 target (hard fail at {BODY_MAX_WORDS})") else: ok(f"SKILL.md body word count {body_word_count} (ADR-0020 target: {BODY_SUGGEST_WORDS})") # --- ADR-0020: resolvable boundary targets --------------------------------- # A boundary clause names another skill — or an agent, which is an equally # valid routing target (git-workflow routes to the git-orchestrate agent). Every # named target is resolved against the AUTHORING SOURCE, plugins/*/.apm/skills/ # and plugins/*/.apm/agents/, so the check works offline and before an # `apm install` has deployed anything into .claude/skills/. # # False positives are the design constraint here, not recall. Two rules do the # work: # * A BARE hyphenated word is read as a routing target only inside a boundary # sentence (one carrying "do not"/"instead"/"rather than"/"not for"). # Without that, pc-run's "run pre-commit hooks" reads as a route to a # non-existent `pre-commit` skill. # * A BARE arrow target counts only in ADR-0020's compressed boundary form, # `Not -> `. Without that, diagnose's process chain # "fix -> regression-test" reads as a route to `regression-test`. # Backticked and /slash-command targets are unambiguous and always count. Tool # names (Read, Write, Edit) are excluded by the lowercase-only name pattern; # MCP tool names (issue_write, pull_request_write) by its rejection of # underscores; file names by its rejection of dots and slashes. NAME_ANY = r"[a-z0-9]+(?:-[a-z0-9]+)*" NAME_HYPH = r"[a-z0-9]+(?:-[a-z0-9]+)+" ROUTE_VERB = (r"(?:use|uses|using|run|runs|invoke|invokes|invoking|try|see" r"|that'?s|compose|composes|call|calls)") MARKED_TARGET = r"(?:`/?(%s)`|(?|→)\s*%s" % MARKED_TARGET) ARROW_BOUNDARY = re.compile(r"\bnot\b[^.;]*?(?:->|→)\s*(%s)\b" % NAME_HYPH, re.I) BACKTICK = re.compile(r"`(%s)`" % NAME_HYPH) BOUNDARY_MARKER = re.compile(r"\b(?:do\s+not|instead|rather\s+than|not\s+for)\b", re.I) SENTENCE_SPLIT = re.compile('(?<=[.!?])\\s+(?=[A-Z"“(])') def _first_group(groups): for g in groups: if g: return g return None def _scan_routes(text, route_re, cont_re, out): for m in route_re.finditer(text): target = _first_group(m.groups()) if not target: continue out.append(target) # Conjoined targets: "use git-history or git-branches instead", # "use gitea-issues / gitea-prs". pos = m.end() while True: cm = cont_re.match(text, pos) if not cm: break nxt = _first_group(cm.groups()) if nxt: out.append(nxt) pos = cm.end() def boundary_targets(description): out = [] for sentence in SENTENCE_SPLIT.split(description): boundary = bool(BOUNDARY_MARKER.search(sentence)) _scan_routes(sentence, ROUTE_ANY if boundary else ROUTE_MARKED, CONT_ANY if boundary else CONT_MARKED, out) for m in ARROW_MARKED.finditer(sentence): target = _first_group(m.groups()) if target: out.append(target) for m in ARROW_BOUNDARY.finditer(sentence): out.append(m.group(1)) out.extend(BACKTICK.findall(sentence)) return sorted(set(out)) def known_targets(start_dir): names = set() # Sibling skills/agents. This is the branch that works in a cache-installed # plugin and in a deployed .claude/skills/ tree, neither of which has a # plugins/ directory above it. parent = os.path.dirname(os.path.abspath(start_dir)) if os.path.basename(parent) == 'skills' and os.path.isdir(parent): for entry in os.listdir(parent): if os.path.isdir(os.path.join(parent, entry)): names.add(entry) agents_dir = os.path.join(os.path.dirname(parent), 'agents') if os.path.isdir(agents_dir): for entry in os.listdir(agents_dir): if entry.endswith('.agent.md'): names.add(entry[:-len('.agent.md')]) elif entry.endswith('.md'): names.add(entry[:-len('.md')]) # Walk up for a monorepo root (plugins/*/.apm/) or a plugin root (.apm/). # Capped at ten levels so a pathological path can't become a filesystem # crawl; that covers every real layout by a wide margin. current = os.path.abspath(start_dir) for _ in range(10): # Never glob the filesystem root: a stray /.apm/skills/ (a scaffolding # test's leftover, say) would otherwise become part of every skill's # resolution universe on that machine. if os.path.dirname(current) == current: break for pattern in ('plugins/*/.apm/skills/*/', '.apm/skills/*/'): for path in glob.glob(os.path.join(current, pattern)): names.add(os.path.basename(path.rstrip('/'))) for pattern in ('plugins/*/.apm/agents/*.agent.md', '.apm/agents/*.agent.md'): for path in glob.glob(os.path.join(current, pattern)): names.add(os.path.basename(path)[:-len('.agent.md')]) current = os.path.dirname(current) return names if desc: routing_targets = boundary_targets(desc) known = known_targets(skill_dir) if routing_targets else set() # An empty universe means no authoring source was found anywhere above this # skill — reporting every target as dangling there would be noise, not a # finding, so the check declines to run rather than guessing. if routing_targets and known: unresolved = [t for t in routing_targets if t not in known] for target in unresolved: fail(f"description routes to '{target}', which resolves to no skill under " f"plugins/*/.apm/skills/ and no agent under plugins/*/.apm/agents/ — " f"a boundary clause naming a non-existent target sends the router nowhere") if not unresolved: ok(f"all {len(routing_targets)} boundary target(s) resolve: " f"{', '.join(routing_targets)}") # Body unfilled placeholders fill_matches = PLACEHOLDER_RE.findall(body) if fill_matches: fail(f"SKILL.md body contains {len(fill_matches)} unfilled 'FILL IN:' placeholder(s)") else: ok("SKILL.md body has no unfilled placeholders") # Interactive prompt heuristic. # # A line-initial `read` only blocks an agent when its stdin is the terminal. # These forms never touch a TTY and are ordinary data plumbing, so flagging # them is a false positive — one that has already cost two authors a # contorted rewrite of working source: # # read -r MODE ROOT <<< "$WALK_OUTPUT" here-string # read -r X <: " X` is interactive and # must still fail. unquoted = re.sub(r'"[^"]*"|\'[^\']*\'', '', line) return '<' in unquoted or prev_line.rstrip().endswith('|') def interactive_reads(source): hits = [] prev_line = '' for line in source.splitlines(): stripped = line.strip() if re.match(r'read(\s|$)', stripped): if not stdin_redirected(line, prev_line): hits.append(stripped) elif re.match(r'input\(', stripped): hits.append(stripped) # Blank lines and comments cannot carry the pipe that feeds a # following `read`, so they never displace the previous line. if stripped and not stripped.startswith('#'): prev_line = line return hits # Scripts checks scripts_dir = os.path.join(skill_dir, "scripts") if os.path.isdir(scripts_dir): scripts = [f for f in os.listdir(scripts_dir) if os.path.isfile(os.path.join(scripts_dir, f)) and not f.endswith('.md')] for fname in scripts: fpath = os.path.join(scripts_dir, fname) with open(fpath) as f: sc = f.read() interactive = interactive_reads(sc) if interactive: fail(f"scripts/{fname}: may use interactive input " f"(read/input from a terminal detected): {interactive[0]}") else: ok(f"scripts/{fname}: no interactive prompts detected") # Executable bit if os.access(fpath, os.X_OK): ok(f"scripts/{fname}: is executable") else: fail(f"scripts/{fname}: not executable — run: chmod +x {fpath}") # Summary print() for s in suggestions: print(f"SUGGESTION {s}") if suggestions: print() if not failed: if suggestions: # Feeds skill-audit's Step 4 `PASS (N suggestions)` result line. A # SUGGESTION never changes the exit code — only a FAIL does. print(f"All checks passed ({len(suggestions)} suggestion(s)).") else: print("All checks passed.") sys.exit(0) else: print("One or more checks failed.") sys.exit(1) PYTHON