From a9d54dcf7f2840c261773ee8ff7c0bb57087ae8b Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Tue, 17 Mar 2026 11:14:20 +1300 Subject: [PATCH] Add validate-skill: SKILL.md linter for Claude Code skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks for common issues that silently break skills — non-ASCII frontmatter, $VAR in paths, uncovered binaries in allowed-tools, etc. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/validate-skill | 284 ++++++++++++++++++++++++++ specs/validate-skill.spec.md | 127 ++++++++++++ tests/test-validate-skill.sh | 381 +++++++++++++++++++++++++++++++++++ 3 files changed, 792 insertions(+) create mode 100755 scripts/validate-skill create mode 100644 specs/validate-skill.spec.md create mode 100755 tests/test-validate-skill.sh diff --git a/scripts/validate-skill b/scripts/validate-skill new file mode 100755 index 0000000..db8e747 --- /dev/null +++ b/scripts/validate-skill @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Colours +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BOLD='\033[1m' +RESET='\033[0m' + +usage() { + cat <<'USAGE' +Usage: validate-skill [OPTIONS] + +Validate Claude Code SKILL.md files against known restrictions. + +Arguments: + PATH A SKILL.md file or directory to scan recursively (default: .) + +Options: + -n, --dryrun List files that would be checked without validating + -h, --help Show this help message + +Checks for: + - Frontmatter: missing delimiters, name/description fields, invalid name format + - Bang-commands: ${VAR} syntax, $() substitution, ~/ and ../ paths + - allowed-tools: uncovered command binaries, overly broad patterns +USAGE +} + +DRYRUN=false +TARGET="." + +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--dryrun) DRYRUN=true; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + *) TARGET="$1"; shift ;; + esac +done + +# Discovery +files=() +if [[ -f "$TARGET" ]]; then + files+=("$(cd "$(dirname "$TARGET")" && pwd)/$(basename "$TARGET")") +elif [[ -d "$TARGET" ]]; then + TARGET="$(cd "$TARGET" && pwd)" + while IFS= read -r f; do + files+=("$f") + done < <(find "$TARGET" -name "SKILL.md" -type f 2>/dev/null | sort) +else + echo "Error: $TARGET is not a file or directory" >&2 + exit 2 +fi + +if [[ ${#files[@]} -eq 0 ]]; then + echo "No SKILL.md files found in $TARGET" + exit 0 +fi + +# Dryrun +if [[ "$DRYRUN" == true ]]; then + echo "[dryrun] Would validate ${#files[@]} files:" + for f in "${files[@]}"; do + if [[ -d "$TARGET" ]]; then + echo " ${f#"$TARGET"/}" + else + echo " $f" + fi + done + exit 0 +fi + +# Validation +total_errors=0 +total_warnings=0 +files_with_errors=0 + +for file in "${files[@]}"; do + file_errors=0 + file_warnings=0 + issues=() + + # Display path + if [[ -d "$TARGET" ]]; then + display="${f#"$TARGET"/}" + else + display="$file" + fi + + # --- Frontmatter checks --- + + first_line=$(head -1 "$file") + if [[ "$first_line" != "---" ]]; then + issues+=("$(printf " ${RED}ERROR${RESET} Line 1: File does not start with --- frontmatter delimiter")") + file_errors=$((file_errors + 1)) + # Can't do further frontmatter checks without delimiters + else + # Find closing --- + second_delim=$(awk 'NR>1 && /^---[[:space:]]*$/ {print NR; exit}' "$file") + if [[ -z "$second_delim" ]]; then + issues+=("$(printf " ${RED}ERROR${RESET} Frontmatter: Missing closing --- delimiter")") + file_errors=$((file_errors + 1)) + else + # Extract frontmatter + frontmatter=$(sed -n "2,$((second_delim - 1))p" "$file") + + # Check name field + name_value=$(echo "$frontmatter" | grep '^name:' | sed 's/^name:[[:space:]]*//' | head -1 || true) + if [[ -z "$name_value" ]]; then + issues+=("$(printf " ${RED}ERROR${RESET} Frontmatter: Missing 'name:' field")") + file_errors=$((file_errors + 1)) + else + if [[ ! "$name_value" =~ ^[a-z0-9-]+$ ]]; then + issues+=("$(printf " ${RED}ERROR${RESET} Frontmatter: name '${name_value}' contains invalid characters (must be lowercase+numbers+hyphens)")") + file_errors=$((file_errors + 1)) + fi + if [[ ${#name_value} -gt 64 ]]; then + issues+=("$(printf " ${RED}ERROR${RESET} Frontmatter: name exceeds 64 characters")") + file_errors=$((file_errors + 1)) + fi + fi + + # Check for non-ASCII characters in frontmatter (can silently break YAML parsing) + non_ascii_lines=$(echo "$frontmatter" | grep -nP '[^\x00-\x7F]' || true) + if [[ -n "$non_ascii_lines" ]]; then + while IFS= read -r nal; do + fm_ln="${nal%%:*}" + # Offset by 1 for the opening --- line + actual_ln=$((fm_ln + 1)) + issues+=("$(printf " ${RED}ERROR${RESET} Line ${actual_ln}: Frontmatter contains non-ASCII characters (em dashes, smart quotes, etc.) — can silently prevent skill loading")") + file_errors=$((file_errors + 1)) + done <<< "$non_ascii_lines" + fi + + # Check description field + desc_line=$(echo "$frontmatter" | grep '^description:' || true) + if [[ -z "$desc_line" ]]; then + issues+=("$(printf " ${RED}ERROR${RESET} Frontmatter: Missing 'description:' field")") + file_errors=$((file_errors + 1)) + else + desc_value=$(echo "$desc_line" | sed 's/^description:[[:space:]]*//') + # Multi-line > or | is non-empty; empty string after colon is not + if [[ -z "$desc_value" ]]; then + issues+=("$(printf " ${RED}ERROR${RESET} Frontmatter: 'description:' field is empty")") + file_errors=$((file_errors + 1)) + fi + fi + + # Extract allowed-tools + allowed_tools_line=$(echo "$frontmatter" | grep '^allowed-tools:' || true) + allowed_tools_value="" + bash_patterns=() + if [[ -n "$allowed_tools_line" ]]; then + allowed_tools_value=$(echo "$allowed_tools_line" | sed 's/^allowed-tools:[[:space:]]*//') + # Parse Bash(...) patterns + while IFS= read -r pattern; do + pattern=$(echo "$pattern" | xargs) # trim whitespace + if [[ "$pattern" == Bash\(*\) ]]; then + bash_patterns+=("$pattern") + fi + done < <(echo "$allowed_tools_value" | tr ',' '\n') + fi + + # Check for overly broad Bash(git *) pattern + for pattern in "${bash_patterns[@]+"${bash_patterns[@]}"}"; do + if [[ "$pattern" == "Bash(git *)" ]]; then + issues+=("$(printf " ${YELLOW}WARN${RESET} Frontmatter: 'Bash(git *)' is overly broad — use specific subcommands like 'Bash(git log *)', 'Bash(git diff *)'")") + file_warnings=$((file_warnings + 1)) + fi + done + fi + fi + + # --- Bang-command checks --- + + bang_commands=() + while IFS= read -r line; do + [[ -n "$line" ]] && bang_commands+=("$line") + done < <(grep -n '^!`' "$file" 2>/dev/null || true) + + # Warn if bang-commands exist but no allowed-tools + if [[ ${#bang_commands[@]} -gt 0 && -z "$allowed_tools_value" && "$first_line" == "---" && -n "${second_delim:-}" ]]; then + issues+=("$(printf " ${YELLOW}WARN${RESET} Bang-commands found but no 'allowed-tools' declared — commands won't be pre-authorised")") + file_warnings=$((file_warnings + 1)) + fi + + for entry in "${bang_commands[@]+"${bang_commands[@]}"}"; do + ln="${entry%%:*}" + line_content="${entry#*:}" + # Extract command between backticks + cmd=$(echo "$line_content" | sed 's/^!`//' | sed 's/`$//') + + # Check for ${VAR} syntax (e.g., ${HOME}, ${VAR:-default}) + if echo "$cmd" | grep -qE '\$\{[^}]+\}'; then + issues+=("$(printf " ${RED}ERROR${RESET} Line ${ln}: Bang-command uses \${VAR} syntax (rejected by permission checker)")") + file_errors=$((file_errors + 1)) + fi + + # Check for $VAR shell expansion in paths (e.g., $HOME/dev/...) + # Match $WORD followed by / (path context) but not $() or ${} which are caught above + if echo "$cmd" | grep -qE '\$[A-Za-z_][A-Za-z0-9_]*/'; then + issues+=("$(printf " ${RED}ERROR${RESET} Line ${ln}: Bang-command uses \$VAR in path (permission checker rejects shell expansion in paths — move to skill instructions instead)")") + file_errors=$((file_errors + 1)) + fi + + # Check for $() command substitution + if echo "$cmd" | grep -qE '\$\('; then + issues+=("$(printf " ${RED}ERROR${RESET} Line ${ln}: Bang-command uses \$() command substitution (rejected by permission checker)")") + file_errors=$((file_errors + 1)) + fi + + # Check for /home/ hardcoded paths (prefer ~/) + if echo "$cmd" | grep -qF '/home/'; then + issues+=("$(printf " ${RED}ERROR${RESET} Line ${ln}: Bang-command uses hardcoded /home/ path (use ~/ for portability)")") + file_errors=$((file_errors + 1)) + fi + + # Check for ~/ paths (more portable but may still be rejected by some permission modes) + if echo "$cmd" | grep -qF '~/'; then + issues+=("$(printf " ${YELLOW}WARN${RESET} Line ${ln}: Bang-command uses ~/ path (may be rejected by sandbox — consider moving to skill instructions)")") + file_warnings=$((file_warnings + 1)) + fi + + # Check for ../ relative paths + if echo "$cmd" | grep -qF '../'; then + issues+=("$(printf " ${RED}ERROR${RESET} Line ${ln}: Bang-command uses ../ relative path (fragile, breaks when CWD changes)")") + file_errors=$((file_errors + 1)) + fi + + # Check allowed-tools coverage + if [[ -n "$allowed_tools_value" && ${#bash_patterns[@]} -ge 0 ]]; then + binary=$(echo "$cmd" | awk '{print $1}') + matched=false + for pattern in "${bash_patterns[@]+"${bash_patterns[@]}"}"; do + # Extract binary from Bash(binary ...) pattern + pattern_binary=$(echo "$pattern" | sed 's/^Bash(\([^ )]*\).*/\1/') + if [[ "$binary" == "$pattern_binary" ]]; then + matched=true + break + fi + done + if [[ "$matched" == false ]]; then + issues+=("$(printf " ${RED}ERROR${RESET} Line ${ln}: Command binary '${binary}' not covered by any Bash() pattern in allowed-tools")") + file_errors=$((file_errors + 1)) + fi + fi + done + + # --- Print results for this file --- + + if [[ -d "${TARGET:-}" ]]; then + rel="${file#"$TARGET"/}" + else + rel="$file" + fi + echo -e "── ${BOLD}${rel}${RESET} ──" + + if [[ ${#issues[@]} -eq 0 ]]; then + echo -e " ${GREEN}No issues found.${RESET}" + else + for issue in "${issues[@]}"; do + echo -e "$issue" + done + echo -e " ${file_errors} errors, ${file_warnings} warnings" + fi + echo + + total_errors=$((total_errors + file_errors)) + total_warnings=$((total_warnings + file_warnings)) + if [[ $file_errors -gt 0 ]]; then + files_with_errors=$((files_with_errors + 1)) + fi +done + +# Summary +echo -e "Summary: ${#files[@]} files checked, ${total_errors} errors, ${total_warnings} warnings" + +if [[ $total_errors -gt 0 ]]; then + exit 1 +else + exit 0 +fi diff --git a/specs/validate-skill.spec.md b/specs/validate-skill.spec.md new file mode 100644 index 0000000..f01be8e --- /dev/null +++ b/specs/validate-skill.spec.md @@ -0,0 +1,127 @@ +# validate-skill + +## Purpose + +Validate Claude Code SKILL.md files against known restrictions that cause permission checker failures, sandbox errors, and other breakages. + +## Usage + +``` +validate-skill [OPTIONS] +``` + +### Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| `PATH` | `.` (current directory) | A SKILL.md file or a directory to scan recursively | + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--dryrun` | `-n` | List SKILL.md files that would be checked without validating | +| `--help` | `-h` | Show usage information | + +## Behaviour + +1. **Discovery phase:** If `PATH` is a file, validate that one file. If `PATH` is a directory, find all files named `SKILL.md` recursively. + +2. **Validation phase:** For each SKILL.md file, run these checks: + + **Frontmatter checks:** + a. File starts with `---` on line 1 + b. A closing `---` delimiter exists on a subsequent line + c. `name:` field exists, contains only lowercase letters, numbers, and hyphens, max 64 characters + d. `description:` field exists and is non-empty (multi-line `>` syntax counts as non-empty) + + e. Non-ASCII characters in frontmatter (em dashes, smart quotes, curly apostrophes, etc.) — these can silently break YAML parsing and prevent the skill from loading + + **Bang-command checks** (lines matching `!` followed by a backtick): + e. No `${VAR}` syntax — must use `$VAR` instead + f. No `$()` command substitution + g. No `~/` paths (sandbox-fragile) + h. No `../` relative paths (CWD-fragile) + i. Warning (not error) for hardcoded `/home/` paths + + **allowed-tools coverage checks:** + j. For each bang-command, the command binary (first word) must match a `Bash(binary *)` or `Bash(binary)` pattern in `allowed-tools` + k. Warning for overly broad patterns like `Bash(git *)` + l. Warning if bang-commands exist but no `allowed-tools` is declared + +3. **Report phase:** For each file: + - Print the file path + - Print each issue with severity (`ERROR`/`WARN`), line number, and description + - Print per-file summary: `N errors, M warnings` + - At the end, print total summary across all files + +4. **Exit code:** + - `0` — no errors (warnings are OK) + - `1` — at least one error found + - `2` — usage error (bad arguments) + +## Dryrun Behaviour + +When `--dryrun` is passed: +- Perform the discovery phase only +- Print each SKILL.md path, one per line +- Prefix output with `[dryrun] Would validate N files:` +- Exit code is always `0` + +## Edge Cases + +| Scenario | Handling | +|----------|----------| +| No SKILL.md files found | Print "No SKILL.md files found in " and exit 0 | +| File is not named SKILL.md (when given directly) | Validate it anyway (user explicitly chose it) | +| No frontmatter at all | Report missing `---` delimiter error, skip field checks | +| No bang-commands and no allowed-tools | Valid (like the linter skill) | +| Bang-commands exist but no allowed-tools declared | Warning: commands won't be pre-authorised | +| `$HOME` (without braces) in bang-command | No error — `$HOME` works fine, only `${HOME}` is rejected | +| Multi-line `description: >` | Treat as non-empty | +| Commands with `\|\| echo "fallback"` | Binary is the first word only | +| Bang-commands inside markdown code blocks | Still checked (they execute regardless of markdown context) | + +## Examples + +### Clean file +``` +── skills/log/SKILL.md ── + No issues found. + +Summary: 1 file checked, 0 errors, 0 warnings +``` + +### File with issues +``` +── skills/distill/SKILL.md ── + ERROR Line 18: Bang-command uses ${VAR} syntax (use $VAR instead) + ERROR Line 22: Bang-command uses $() command substitution + WARN Line 8: 'Bash(git *)' is overly broad — use specific subcommands + 2 errors, 1 warning + +Summary: 1 file checked, 2 errors, 1 warning +``` + +### Directory scan +``` +── skills/log/SKILL.md ── + No issues found. + +── skills/reflect/SKILL.md ── + No issues found. + +── skills/distill/SKILL.md ── + ERROR Line 18: Bang-command uses ${VAR} syntax (use $VAR instead) + 1 error, 0 warnings + +Summary: 3 files checked, 1 error, 0 warnings +``` + +### Dryrun +``` +[dryrun] Would validate 3 files: + skills/log/SKILL.md + skills/reflect/SKILL.md + skills/distill/SKILL.md +``` diff --git a/tests/test-validate-skill.sh b/tests/test-validate-skill.sh new file mode 100755 index 0000000..3a21a2c --- /dev/null +++ b/tests/test-validate-skill.sh @@ -0,0 +1,381 @@ +#!/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 ]]