#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT="$SCRIPT_DIR/../scripts/validate-skill" RED='\033[0;31m' GREEN='\033[0;32m' RESET='\033[0m' PASS=0 FAIL=0 assert_exit() { local desc="$1" expected="$2" shift 2 local actual set +e "$@" >/dev/null 2>&1 actual=$? set -e if [[ "$actual" -eq "$expected" ]]; then echo -e "${GREEN}PASS${RESET}: $desc" PASS=$((PASS + 1)) else echo -e "${RED}FAIL${RESET}: $desc (expected exit $expected, got $actual)" FAIL=$((FAIL + 1)) fi } assert_contains() { local desc="$1" pattern="$2" shift 2 local output set +e output=$("$@" 2>&1) set -e if echo "$output" | grep -qF "$pattern"; then echo -e "${GREEN}PASS${RESET}: $desc" PASS=$((PASS + 1)) else echo -e "${RED}FAIL${RESET}: $desc (output missing: '$pattern')" echo " Got: $output" FAIL=$((FAIL + 1)) fi } assert_not_contains() { local desc="$1" pattern="$2" shift 2 local output set +e output=$("$@" 2>&1) set -e if echo "$output" | grep -qF "$pattern"; then echo -e "${RED}FAIL${RESET}: $desc (output should NOT contain: '$pattern')" echo " Got: $output" FAIL=$((FAIL + 1)) else echo -e "${GREEN}PASS${RESET}: $desc" PASS=$((PASS + 1)) fi } # Setup temp directory with test fixtures TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT # --- Fixture: valid skill --- mkdir -p "$TMPDIR/valid" cat > "$TMPDIR/valid/SKILL.md" <<'EOF' --- name: test-skill description: A test skill for validation allowed-tools: Read, Bash(cat *), Bash(ls *), Bash(date *) --- # Test Skill ## Context !`cat README.md 2>/dev/null || echo "no readme"` !`ls -1 scripts/ 2>/dev/null || echo "no scripts"` !`date +%Y-%m-%d` EOF # --- Fixture: missing frontmatter --- mkdir -p "$TMPDIR/no-frontmatter" cat > "$TMPDIR/no-frontmatter/SKILL.md" <<'EOF' # No frontmatter here Just some content. EOF # --- Fixture: missing closing delimiter --- mkdir -p "$TMPDIR/no-close" cat > "$TMPDIR/no-close/SKILL.md" <<'EOF' --- name: broken description: Missing closing delimiter EOF # --- Fixture: bad name --- mkdir -p "$TMPDIR/bad-name" cat > "$TMPDIR/bad-name/SKILL.md" <<'EOF' --- name: My_Skill! description: A skill with a bad name --- # Bad Name Skill EOF # --- Fixture: missing name --- mkdir -p "$TMPDIR/no-name" cat > "$TMPDIR/no-name/SKILL.md" <<'EOF' --- description: A skill without a name --- # No Name Skill EOF # --- Fixture: missing description --- mkdir -p "$TMPDIR/no-desc" cat > "$TMPDIR/no-desc/SKILL.md" <<'EOF' --- name: no-desc --- # No Description EOF # --- Fixture: ${VAR} in bang-command --- mkdir -p "$TMPDIR/curly-var" cat > "$TMPDIR/curly-var/SKILL.md" <<'EOF' --- name: curly-var description: Uses curly brace variable allowed-tools: Read, Bash(cat *) --- # Curly Var !`cat ${HOME}/some/path` EOF # --- Fixture: $() in bang-command --- mkdir -p "$TMPDIR/subshell" cat > "$TMPDIR/subshell/SKILL.md" <<'EOF' --- name: subshell description: Uses command substitution allowed-tools: Read, Bash(git *) --- # Subshell !`git log --since="$(date -d '7 days ago')"` EOF # --- Fixture: ~/ in bang-command --- mkdir -p "$TMPDIR/tilde" cat > "$TMPDIR/tilde/SKILL.md" <<'EOF' --- name: tilde description: Uses tilde path allowed-tools: Read, Bash(cat *) --- # Tilde !`cat ~/dev/claude/settings.yaml` EOF # --- Fixture: ../ in bang-command --- mkdir -p "$TMPDIR/dotdot" cat > "$TMPDIR/dotdot/SKILL.md" <<'EOF' --- name: dotdot description: Uses relative path allowed-tools: Read, Bash(cat *) --- # Dotdot !`cat ../claude-foundations/settings.yaml` EOF # --- Fixture: uncovered binary --- mkdir -p "$TMPDIR/uncovered" cat > "$TMPDIR/uncovered/SKILL.md" <<'EOF' --- name: uncovered description: Uses a binary not in allowed-tools allowed-tools: Read, Bash(cat *) --- # Uncovered !`cat README.md` !`head -5 README.md` EOF # --- Fixture: /home/ hardcoded path (warning only) --- mkdir -p "$TMPDIR/home-path" cat > "$TMPDIR/home-path/SKILL.md" <<'EOF' --- name: home-path description: Uses hardcoded home path allowed-tools: Read, Bash(cat *) --- # Home Path !`cat /home/paul/dev/claude/settings.yaml` EOF # --- Fixture: broad Bash(git *) (warning only) --- mkdir -p "$TMPDIR/broad-git" cat > "$TMPDIR/broad-git/SKILL.md" <<'EOF' --- name: broad-git description: Uses overly broad git pattern allowed-tools: Read, Bash(git *) --- # Broad Git !`git log --oneline -5` EOF # --- Fixture: no bang-commands, no allowed-tools (valid) --- mkdir -p "$TMPDIR/no-bangs" cat > "$TMPDIR/no-bangs/SKILL.md" <<'EOF' --- name: no-bangs description: A simple instruction-only skill --- # No Bang Commands Just instructions, no dynamic context. EOF # --- Fixture: $HOME in path (should be error) --- mkdir -p "$TMPDIR/dollar-home" cat > "$TMPDIR/dollar-home/SKILL.md" <<'EOF' --- name: dollar-home description: Uses dollar home in path allowed-tools: Read, Bash(cat *) --- # Dollar Home !`cat $HOME/dev/claude/settings.yaml 2>/dev/null || echo "not found"` EOF # --- Fixture: $VAR not in path context (should be fine) --- mkdir -p "$TMPDIR/dollar-nopath" cat > "$TMPDIR/dollar-nopath/SKILL.md" <<'EOF' --- name: dollar-nopath description: Uses dollar var not as a path allowed-tools: Read, Bash(date *), Bash(echo *) --- # Dollar No Path !`date +%Y-%m-%d` !`echo $USER said hello` EOF # --- Fixture: multi-line description with > --- mkdir -p "$TMPDIR/multiline-desc" cat > "$TMPDIR/multiline-desc/SKILL.md" <<'EOF' --- name: multiline-desc description: > A skill with a multi-line description that spans multiple lines --- # Multi-line Description EOF # --- Fixture: non-ASCII in frontmatter (em dash) --- mkdir -p "$TMPDIR/non-ascii" printf -- '---\nname: non-ascii\ndescription: >\n A skill that takes no action \xe2\x80\x94 information only\n---\n\n# Non-ASCII\n' > "$TMPDIR/non-ascii/SKILL.md" # --- Fixture: bang-commands but no allowed-tools --- mkdir -p "$TMPDIR/bangs-no-tools" cat > "$TMPDIR/bangs-no-tools/SKILL.md" <<'EOF' --- name: bangs-no-tools description: Has bang-commands but no allowed-tools --- # Bangs No Tools !`cat README.md` EOF # ============================ # Tests # ============================ echo "=== validate-skill tests ===" echo echo "-- basic functionality --" assert_exit "--help exits 0" 0 "$SCRIPT" --help assert_contains "--help shows usage" "Usage:" "$SCRIPT" --help assert_exit "valid skill exits 0" 0 "$SCRIPT" "$TMPDIR/valid/SKILL.md" assert_contains "valid skill shows no issues" "No issues found" "$SCRIPT" "$TMPDIR/valid/SKILL.md" echo echo "-- dryrun --" assert_exit "--dryrun always exits 0" 0 "$SCRIPT" --dryrun "$TMPDIR" assert_contains "--dryrun lists files" "[dryrun]" "$SCRIPT" --dryrun "$TMPDIR" echo echo "-- frontmatter errors --" assert_exit "missing frontmatter exits 1" 1 "$SCRIPT" "$TMPDIR/no-frontmatter/SKILL.md" assert_contains "reports missing ---" "does not start with ---" "$SCRIPT" "$TMPDIR/no-frontmatter/SKILL.md" assert_exit "missing closing --- exits 1" 1 "$SCRIPT" "$TMPDIR/no-close/SKILL.md" assert_contains "reports missing closing ---" "Missing closing ---" "$SCRIPT" "$TMPDIR/no-close/SKILL.md" assert_exit "bad name exits 1" 1 "$SCRIPT" "$TMPDIR/bad-name/SKILL.md" assert_contains "reports invalid name" "invalid characters" "$SCRIPT" "$TMPDIR/bad-name/SKILL.md" assert_exit "missing name exits 1" 1 "$SCRIPT" "$TMPDIR/no-name/SKILL.md" assert_contains "reports missing name" "Missing 'name:'" "$SCRIPT" "$TMPDIR/no-name/SKILL.md" assert_exit "missing description exits 1" 1 "$SCRIPT" "$TMPDIR/no-desc/SKILL.md" assert_contains "reports missing description" "Missing 'description:'" "$SCRIPT" "$TMPDIR/no-desc/SKILL.md" echo echo "-- bang-command errors --" assert_exit '${VAR} detected exits 1' 1 "$SCRIPT" "$TMPDIR/curly-var/SKILL.md" assert_contains '${VAR} message shown' '${VAR} syntax' "$SCRIPT" "$TMPDIR/curly-var/SKILL.md" assert_exit '$() detected exits 1' 1 "$SCRIPT" "$TMPDIR/subshell/SKILL.md" assert_contains '$() message shown' '$() command substitution' "$SCRIPT" "$TMPDIR/subshell/SKILL.md" assert_exit '../ detected exits 1' 1 "$SCRIPT" "$TMPDIR/dotdot/SKILL.md" assert_contains '../ message shown' '../ relative path' "$SCRIPT" "$TMPDIR/dotdot/SKILL.md" echo echo "-- allowed-tools coverage --" assert_exit "uncovered binary exits 1" 1 "$SCRIPT" "$TMPDIR/uncovered/SKILL.md" assert_contains "reports uncovered binary" "not covered" "$SCRIPT" "$TMPDIR/uncovered/SKILL.md" assert_contains "identifies the binary" "'head'" "$SCRIPT" "$TMPDIR/uncovered/SKILL.md" echo echo "-- path errors --" assert_exit '/home/ path exits 1' 1 "$SCRIPT" "$TMPDIR/home-path/SKILL.md" assert_contains '/home/ suggests ~/' "use ~/" "$SCRIPT" "$TMPDIR/home-path/SKILL.md" assert_exit '$HOME in path exits 1' 1 "$SCRIPT" "$TMPDIR/dollar-home/SKILL.md" assert_contains '$HOME in path message' '$VAR in path' "$SCRIPT" "$TMPDIR/dollar-home/SKILL.md" echo echo "-- warnings (should not cause exit 1) --" assert_exit "~/ path is warning only" 0 "$SCRIPT" "$TMPDIR/tilde/SKILL.md" assert_contains "~/ warning shown" "WARN" "$SCRIPT" "$TMPDIR/tilde/SKILL.md" assert_exit "broad git pattern is warning only" 0 "$SCRIPT" "$TMPDIR/broad-git/SKILL.md" assert_contains "broad git warning shown" "overly broad" "$SCRIPT" "$TMPDIR/broad-git/SKILL.md" assert_exit "bangs without allowed-tools is warning only" 0 "$SCRIPT" "$TMPDIR/bangs-no-tools/SKILL.md" assert_contains "bangs without allowed-tools warning shown" "WARN" "$SCRIPT" "$TMPDIR/bangs-no-tools/SKILL.md" echo echo "-- non-ASCII in frontmatter --" assert_exit "non-ASCII in frontmatter exits 1" 1 "$SCRIPT" "$TMPDIR/non-ascii/SKILL.md" assert_contains "reports non-ASCII" "non-ASCII" "$SCRIPT" "$TMPDIR/non-ascii/SKILL.md" echo echo "-- edge cases --" assert_exit "no bang-commands is valid" 0 "$SCRIPT" "$TMPDIR/no-bangs/SKILL.md" assert_exit '$VAR not in path context is valid' 0 "$SCRIPT" "$TMPDIR/dollar-nopath/SKILL.md" assert_not_contains '$VAR not in path has no errors' "ERROR" "$SCRIPT" "$TMPDIR/dollar-nopath/SKILL.md" assert_exit "multi-line description is valid" 0 "$SCRIPT" "$TMPDIR/multiline-desc/SKILL.md" # No files found EMPTY="$(mktemp -d)" assert_contains "no files message" "No SKILL.md files found" "$SCRIPT" "$EMPTY" assert_exit "no files exits 0" 0 "$SCRIPT" "$EMPTY" rmdir "$EMPTY" echo echo "-- directory scan --" assert_exit "directory scan finds errors" 1 "$SCRIPT" "$TMPDIR" assert_contains "summary shows file count" "files checked" "$SCRIPT" "$TMPDIR" echo echo "---" echo -e "Results: ${GREEN}$PASS passed${RESET}, ${RED}$FAIL failed${RESET}" [[ $FAIL -eq 0 ]]