Files
jezweb__claude-skills/bin/skill-lint
marcusjezweb 16adb0575a feat: add skill-lint structural linter (#71)
Ship bin/skill-lint — bash script that validates SKILL.md files against conventions (frontmatter, line count, file references, directory structure). Includes GitHub Actions workflow for PR-level CI. First use of bin/ executables feature (v2.1.91). Relates to #52, #70.
2026-04-03 22:25:17 +11:00

225 lines
6.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# skill-lint — structural linter for Claude Code skills
# Validates SKILL.md files against conventions.
# Usage: skill-lint [path...]
# path One or more skill directories or SKILL.md files.
# If no paths given, finds all SKILL.md files under current directory.
#
# Exit codes:
# 0 All checks passed
# 1 One or more checks failed
set -euo pipefail
MAX_LINES=500
ERRORS=0
WARNINGS=0
CHECKED=0
red() { printf '\033[0;31m%s\033[0m\n' "$1"; }
yellow() { printf '\033[0;33m%s\033[0m\n' "$1"; }
green() { printf '\033[0;32m%s\033[0m\n' "$1"; }
dim() { printf '\033[0;90m%s\033[0m\n' "$1"; }
error() {
red " ERROR: $1"
ERRORS=$((ERRORS + 1))
}
warn() {
yellow " WARN: $1"
WARNINGS=$((WARNINGS + 1))
}
pass() {
dim " OK: $1"
}
lint_skill() {
local skill_md="$1"
local skill_dir
skill_dir=$(dirname "$skill_md")
local skill_name
skill_name=$(basename "$skill_dir")
CHECKED=$((CHECKED + 1))
echo ""
echo "Linting: $skill_md"
# 1. SKILL.md exists and is not empty
if [ ! -s "$skill_md" ]; then
error "SKILL.md is empty"
return
fi
pass "SKILL.md exists and is not empty"
# 2. Has frontmatter delimiters
local first_line
first_line=$(head -1 "$skill_md")
if [ "$first_line" != "---" ]; then
error "Missing frontmatter — first line must be '---'"
return
fi
local frontmatter_end
frontmatter_end=$(tail -n +2 "$skill_md" | grep -n '^---$' | head -1 | cut -d: -f1)
if [ -z "$frontmatter_end" ]; then
error "Frontmatter never closed — missing closing '---'"
return
fi
pass "Valid frontmatter delimiters"
# Extract frontmatter (between the two --- lines)
local frontmatter
frontmatter=$(sed -n "2,$((frontmatter_end))p" "$skill_md")
# 3. Has name field
if ! echo "$frontmatter" | grep -qE '^name:'; then
error "Missing 'name' field in frontmatter"
else
local name_value
name_value=$(echo "$frontmatter" | grep -E '^name:' | sed 's/^name:[[:space:]]*//' | tr -d '"' | tr -d "'")
if [ -z "$name_value" ]; then
error "'name' field is empty"
else
# Check name matches directory name
if [ "$name_value" != "$skill_name" ]; then
warn "name '$name_value' doesn't match directory '$skill_name'"
else
pass "name matches directory"
fi
fi
fi
# 4. Has description field
if ! echo "$frontmatter" | grep -qE '^description:'; then
error "Missing 'description' field in frontmatter"
else
local desc_value
desc_value=$(echo "$frontmatter" | grep -E '^description:' | sed 's/^description:[[:space:]]*//' | tr -d '"' | tr -d "'")
if [ -z "$desc_value" ] || [ "$desc_value" = ">" ]; then
# Could be multi-line YAML — check next line
local desc_next
desc_next=$(echo "$frontmatter" | grep -A1 '^description:' | tail -1 | tr -d '[:space:]')
if [ -z "$desc_next" ]; then
error "'description' field is empty"
else
pass "description present (multi-line)"
fi
else
pass "description present"
fi
fi
# 5. Line count
local line_count
line_count=$(wc -l < "$skill_md" | tr -d '[:space:]')
if [ "$line_count" -gt "$MAX_LINES" ]; then
error "SKILL.md is $line_count lines (max $MAX_LINES)"
else
pass "$line_count lines (limit: $MAX_LINES)"
fi
# 6. Has content after frontmatter
local total_lines
total_lines=$(wc -l < "$skill_md" | tr -d '[:space:]')
local content_start=$((frontmatter_end + 2))
if [ "$content_start" -ge "$total_lines" ]; then
warn "No content after frontmatter — skill body is empty"
else
pass "Has content body"
fi
# 7. Check for references to non-existent files
local refs
refs=$(grep -oE '\./references/[^ )\"]+|\./assets/[^ )\"]+' "$skill_md" 2>/dev/null || true)
if [ -n "$refs" ]; then
while IFS= read -r ref; do
local ref_path="$skill_dir/$ref"
if [ ! -e "$ref_path" ]; then
error "References '$ref' but file doesn't exist"
else
pass "Reference '$ref' exists"
fi
done <<< "$refs"
fi
# 8. Check for stray files that shouldn't be in a skill directory
local stray_files
stray_files=$(find "$skill_dir" -maxdepth 1 -type f ! -name "SKILL.md" ! -name "*.md" ! -name ".DS_Store" 2>/dev/null || true)
if [ -n "$stray_files" ]; then
while IFS= read -r stray; do
[ -z "$stray" ] && continue
warn "Unexpected file: $(basename "$stray")"
done <<< "$stray_files"
fi
# 9. Check subdirectories follow conventions
local subdirs
subdirs=$(find "$skill_dir" -mindepth 1 -maxdepth 1 -type d ! -name "references" ! -name "assets" ! -name "templates" ! -name "examples" ! -name "bin" ! -name ".git" 2>/dev/null || true)
if [ -n "$subdirs" ]; then
while IFS= read -r subdir; do
[ -z "$subdir" ] && continue
warn "Non-standard directory: $(basename "$subdir")/ (expected: references/, assets/, templates/, examples/, bin/)"
done <<< "$subdirs"
fi
}
# --- Main ---
echo "skill-lint — Claude Code skill validator"
echo "========================================="
# Collect SKILL.md files
SKILL_FILES=()
if [ $# -eq 0 ]; then
# No args — find all SKILL.md files
while IFS= read -r f; do
SKILL_FILES+=("$f")
done < <(find . -name "SKILL.md" -type f | sort)
else
# Args provided — resolve each to a SKILL.md
for arg in "$@"; do
if [ -f "$arg" ] && [ "$(basename "$arg")" = "SKILL.md" ]; then
SKILL_FILES+=("$arg")
elif [ -d "$arg" ] && [ -f "$arg/SKILL.md" ]; then
SKILL_FILES+=("$arg/SKILL.md")
elif [ -d "$arg" ]; then
while IFS= read -r f; do
SKILL_FILES+=("$f")
done < <(find "$arg" -name "SKILL.md" -type f | sort)
else
red "Not a skill directory or SKILL.md: $arg"
ERRORS=$((ERRORS + 1))
fi
done
fi
if [ ${#SKILL_FILES[@]} -eq 0 ]; then
yellow "No SKILL.md files found."
exit 0
fi
for skill_md in "${SKILL_FILES[@]}"; do
lint_skill "$skill_md"
done
# Summary
echo ""
echo "========================================="
echo "Checked: $CHECKED skills"
if [ $ERRORS -gt 0 ]; then
red "Errors: $ERRORS"
fi
if [ $WARNINGS -gt 0 ]; then
yellow "Warnings: $WARNINGS"
fi
if [ $ERRORS -eq 0 ]; then
green "Result: PASS"
exit 0
else
red "Result: FAIL"
exit 1
fi