#!/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