#!/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 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 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 def ok(msg): print(f"PASS {msg}") def fail(msg): global failed print(f"FAIL {msg}") failed = True # --- 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 — inline or block scalar (> or |) desc = "" desc_m = re.search(r'^description:\s*([>|])\n((?:[ \t]+.+\n?)+)', fm, re.MULTILINE) if desc_m: raw = desc_m.group(2) desc = re.sub(r'\s+', ' ', raw).strip() else: desc_inline = re.search(r'^description:\s*(.+)', fm, re.MULTILINE) if desc_inline: desc = desc_inline.group(1).strip() 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 if desc: dlen = len(desc) if dlen <= 1024: ok(f"description length {dlen} chars (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'(?