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) <noreply@anthropic.com>
285 lines
11 KiB
Bash
Executable File
285 lines
11 KiB
Bash
Executable File
#!/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] <PATH>
|
|
|
|
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
|