Three divergences between what the audit skills claim and what the hooks enforce, each of which fails silently rather than loudly: - `skill-size-check.sh` blocked at 2900 words while `validate.sh` checked only the 500-line ceiling, so `/skill-audit` could report a skill ready to ship that the commit hook then rejected. `validate.sh` now checks the same pair on the same inclusive terms; the constants are duplicated with a comment naming the other file, because a plugin skill's scripts cannot read outside the plugin directory once installed to the cache. - Both audit skills' Step 1 passed `--config assets/vale/.vale.ini`, which is redundant (the wrapper self-locates its sibling config) and fragile: an agent that resolves the script path against the skill directory but not the config path gets E100, exit 2, which the surrounding fallback clause misreads as "vale unavailable" and downgrades to full LLM judgment with no signal. - The external-consumer test registered only the two Vale hooks, never the third shipped hook, so a lost executable bit would have broken every consumer while the local suite stayed green. Verified by mutation: `chmod 644` on the copied script now turns three passes into two failures. Also corrects the size hook's calibration comment, which claimed ~5.7-6.5 characters per word against a corpus whose measured median is 6.79 — the stated upper bound sat below the median, so the "calibrated with margin" claim was inverted for prose-dense files. MAX_WORDS is unchanged pending a decision; the comment is now explicit that the gate holds under 5,000 tokens for typical prose density, not for any file. Refs: #85
196 lines
5.7 KiB
Bash
Executable File
196 lines
5.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: validate.sh <skill-dir>
|
|
|
|
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'(?<!`)FILL IN:[^`\n]')
|
|
|
|
# description contains unfilled placeholder
|
|
if desc and PLACEHOLDER_RE.search(desc):
|
|
fail("description still contains 'FILL IN:' placeholder — replace before shipping")
|
|
else:
|
|
if desc:
|
|
ok("description has no unfilled placeholders")
|
|
|
|
# SKILL.md size ceilings (agentskills.io skill-authoring.md: 500 lines,
|
|
# ~5,000 tokens). Both constants are DUPLICATED from the repo-root pre-commit
|
|
# hook scripts/skill-size-check.sh — a plugin skill's scripts cannot read files
|
|
# outside the plugin directory once the plugin is cache-installed, so there is
|
|
# no single source to share. Keep the two in sync by hand: if they drift, this
|
|
# audit will report a skill ready to ship that the commit hook then rejects.
|
|
MAX_LINES = 500
|
|
MAX_WORDS = 2900 # word-count proxy for the ~5,000-token ceiling
|
|
|
|
line_count = len(content.splitlines())
|
|
if line_count <= MAX_LINES:
|
|
ok(f"SKILL.md line count {line_count} (limit: {MAX_LINES})")
|
|
else:
|
|
fail(f"SKILL.md line count {line_count} — exceeds {MAX_LINES}-line limit")
|
|
|
|
# str.split() with no argument splits on runs of whitespace, matching the
|
|
# `wc -w` the hook uses, and counts the whole file including frontmatter.
|
|
word_count = len(content.split())
|
|
if word_count <= MAX_WORDS:
|
|
ok(f"SKILL.md word count {word_count} (limit: {MAX_WORDS}, proxy for ~5,000 tokens)")
|
|
else:
|
|
fail(f"SKILL.md word count {word_count} — exceeds {MAX_WORDS}-word limit (proxy for ~5,000 tokens)")
|
|
|
|
# Body unfilled placeholders
|
|
body = content[body_start:]
|
|
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")
|
|
|
|
# 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 prompt heuristic
|
|
if re.search(r'^\s*(read\s|input\()', sc, re.MULTILINE):
|
|
fail(f"scripts/{fname}: may use interactive input (read/input detected)")
|
|
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()
|
|
if not failed:
|
|
print("All checks passed.")
|
|
sys.exit(0)
|
|
else:
|
|
print("One or more checks failed.")
|
|
sys.exit(1)
|
|
PYTHON
|