309 lines
9.1 KiB
Bash
Executable File
309 lines
9.1 KiB
Bash
Executable File
#!/bin/bash
|
||
#
|
||
# validate-skill.sh - Strictly validate a skill's structure and content
|
||
#
|
||
# Usage: ./validate-skill.sh <path/to/skill-directory> [options]
|
||
#
|
||
# Options:
|
||
# --strict Fail on warnings (default)
|
||
# --lenient Only fail on errors, report warnings
|
||
# --verbose Show detailed output
|
||
#
|
||
|
||
set -euo pipefail
|
||
|
||
# Colors for output
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
NC='\033[0m' # No Color
|
||
|
||
# Validation counters
|
||
ERRORS=0
|
||
WARNINGS=0
|
||
|
||
# Default mode
|
||
STRICT=true
|
||
VERBOSE=false
|
||
|
||
usage() {
|
||
echo "Usage: $0 <path/to/skill-directory> [options]"
|
||
echo ""
|
||
echo "Options:"
|
||
echo " --strict Fail on warnings (default)"
|
||
echo " --lenient Only fail on errors"
|
||
echo " --verbose Show detailed output"
|
||
echo ""
|
||
echo "Examples:"
|
||
echo " $0 ~/.config/opencode/skills/my-skill"
|
||
echo " $0 ./my-skill --verbose"
|
||
exit 1
|
||
}
|
||
|
||
# Error and warning functions
|
||
error() {
|
||
echo -e "${RED}✗ ERROR:${NC} $1"
|
||
((ERRORS++)) || true
|
||
}
|
||
|
||
warn() {
|
||
if [[ "$STRICT" == true ]]; then
|
||
echo -e "${YELLOW}✗ WARNING (treated as error in strict mode):${NC} $1"
|
||
((ERRORS++)) || true
|
||
else
|
||
echo -e "${YELLOW}⚠ WARNING:${NC} $1"
|
||
((WARNINGS++)) || true
|
||
fi
|
||
}
|
||
|
||
info() {
|
||
if [[ "$VERBOSE" == true ]]; then
|
||
echo -e "${BLUE}ℹ${NC} $1"
|
||
fi
|
||
}
|
||
|
||
success() {
|
||
echo -e "${GREEN}✓${NC} $1"
|
||
}
|
||
|
||
# Parse arguments
|
||
SKILL_DIR=""
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
--strict)
|
||
STRICT=true
|
||
shift
|
||
;;
|
||
--lenient)
|
||
STRICT=false
|
||
shift
|
||
;;
|
||
--verbose)
|
||
VERBOSE=true
|
||
shift
|
||
;;
|
||
-h|--help)
|
||
usage
|
||
;;
|
||
-*)
|
||
echo -e "${RED}Error: Unknown option $1${NC}"
|
||
usage
|
||
;;
|
||
*)
|
||
if [[ -z "$SKILL_DIR" ]]; then
|
||
SKILL_DIR="$1"
|
||
else
|
||
echo -e "${RED}Error: Multiple skill directories provided${NC}"
|
||
usage
|
||
fi
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Validate skill directory argument
|
||
if [[ -z "$SKILL_DIR" ]]; then
|
||
echo -e "${RED}Error: Skill directory is required${NC}"
|
||
usage
|
||
fi
|
||
|
||
# Resolve path
|
||
SKILL_DIR="$(cd "$SKILL_DIR" 2>/dev/null && pwd)" || {
|
||
echo -e "${RED}Error: Cannot access directory: $SKILL_DIR${NC}"
|
||
exit 1
|
||
}
|
||
|
||
# Get skill name from directory
|
||
SKILL_NAME="$(basename "$SKILL_DIR")"
|
||
|
||
echo "========================================"
|
||
echo "Validating skill: $SKILL_NAME"
|
||
echo "Directory: $SKILL_DIR"
|
||
echo "========================================"
|
||
echo ""
|
||
|
||
# Check 1: Directory exists
|
||
if [[ ! -d "$SKILL_DIR" ]]; then
|
||
error "Directory does not exist: $SKILL_DIR"
|
||
exit 1
|
||
fi
|
||
success "Directory exists"
|
||
|
||
# Check 2: SKILL.md exists
|
||
SKILL_MD="$SKILL_DIR/SKILL.md"
|
||
if [[ ! -f "$SKILL_MD" ]]; then
|
||
error "SKILL.md file not found in $SKILL_DIR"
|
||
error "Skill must contain a SKILL.md file"
|
||
exit 1
|
||
fi
|
||
success "SKILL.md file exists"
|
||
|
||
# Check 3: SKILL.md starts with YAML frontmatter
|
||
if ! head -1 "$SKILL_MD" | grep -q '^---\s*$'; then
|
||
error "SKILL.md must start with YAML frontmatter (---)"
|
||
else
|
||
success "SKILL.md starts with YAML frontmatter"
|
||
fi
|
||
|
||
# Extract frontmatter content
|
||
FRONTMATTER=$(sed -n '/^---$/,/^---$/p' "$SKILL_MD" | head -n -1 | tail -n +2)
|
||
info "Extracted frontmatter"
|
||
|
||
# Check 4: Name field exists and is valid
|
||
if ! echo "$FRONTMATTER" | grep -q '^name:'; then
|
||
error "Frontmatter missing required field: 'name'"
|
||
else
|
||
FM_NAME=$(echo "$FRONTMATTER" | grep '^name:' | sed 's/^name:\s*//' | tr -d '"' | tr -d "'")
|
||
|
||
if [[ -z "$FM_NAME" ]]; then
|
||
error "'name' field is empty"
|
||
elif [[ ! "$FM_NAME" =~ ^[a-z0-9-]+$ ]]; then
|
||
error "'name' must be lowercase alphanumeric with hyphens: '$FM_NAME'"
|
||
elif [[ "$FM_NAME" != "$SKILL_NAME" ]]; then
|
||
error "'name' ($FM_NAME) does not match directory name ($SKILL_NAME)"
|
||
else
|
||
success "'name' field is valid: $FM_NAME"
|
||
fi
|
||
fi
|
||
|
||
# Check 5: Description field exists and is valid
|
||
if ! echo "$FRONTMATTER" | grep -q '^description:'; then
|
||
error "Frontmatter missing required field: 'description'"
|
||
else
|
||
FM_DESC=$(echo "$FRONTMATTER" | sed -n '/^description:/,/^\S/{/^description:/p;/^\S/q}' | sed 's/^description:\s*//' | tr -d '"' | tr -d "'" | sed ':a;N;$!ba;s/\n/ /g')
|
||
DESC_LEN=${#FM_DESC}
|
||
|
||
if [[ -z "$FM_DESC" ]]; then
|
||
error "'description' field is empty"
|
||
elif [[ $DESC_LEN -lt 20 ]]; then
|
||
error "'description' must be at least 20 characters (found $DESC_LEN)"
|
||
elif [[ $DESC_LEN -gt 1024 ]]; then
|
||
error "'description' must be at most 1024 characters (found $DESC_LEN)"
|
||
else
|
||
success "'description' field length is valid ($DESC_LEN chars)"
|
||
fi
|
||
|
||
# Check for common description issues
|
||
if echo "$FM_DESC" | grep -qi '^use this skill'; then
|
||
warn "Description uses second person ('Use this skill'). Use third person: 'This skill should be used when...'"
|
||
fi
|
||
|
||
if echo "$FM_DESC" | grep -qi '^this skill provides\|^this skill helps'; then
|
||
warn "Description is vague. Include specific trigger phrases users would say"
|
||
fi
|
||
|
||
# Check for quoted phrases (allow either single or double quotes in original)
|
||
if ! echo "$FRONTMATTER" | grep -A 5 '^description:' | grep -q '"\|\"'; then
|
||
warn "Description should include specific trigger phrases in quotes, e.g.: \"create X\", \"configure Y\""
|
||
fi
|
||
fi
|
||
|
||
# Check 6: No unknown frontmatter fields (informational)
|
||
KNOWN_FIELDS="^name:\|^description:\|^license:\|^compatibility:\|^metadata:\|^allowed-tools:"
|
||
UNKNOWN_FIELDS=$(echo "$FRONTMATTER" | grep -v '^\s*$' | grep -v "$KNOWN_FIELDS" || true)
|
||
if [[ -n "$UNKNOWN_FIELDS" ]]; then
|
||
info "Unknown frontmatter fields (will be ignored):"
|
||
echo "$UNKNOWN_FIELDS" | sed 's/^/ /'
|
||
fi
|
||
|
||
# Check 7: YAML syntax is valid
|
||
if ! echo "$FRONTMATTER" | python3 -c "import yaml, sys; yaml.safe_load(sys.stdin)" 2>/dev/null; then
|
||
# Fallback: check with basic pattern matching
|
||
if echo "$FRONTMATTER" | grep -q '^\s*:\s*$'; then
|
||
error "Invalid YAML syntax: empty key found"
|
||
fi
|
||
|
||
# Count lines with colons to detect malformed entries
|
||
MALFORMED=$(echo "$FRONTMATTER" | grep -c '^\s*[^:]*:\s*$' || true)
|
||
if [[ $MALFORMED -gt 0 ]]; then
|
||
error "Potential YAML syntax issues detected"
|
||
fi
|
||
else
|
||
success "YAML frontmatter syntax appears valid"
|
||
fi
|
||
|
||
# Check 8: SKILL.md has content after frontmatter
|
||
# Remove everything from line 1 through the second occurrence of ---
|
||
BODY_CONTENT=$(sed '0,/^---$/d;0,/^---$/d' "$SKILL_MD")
|
||
if [[ -z "$(echo "$BODY_CONTENT" | tr -d '[:space:]')" ]]; then
|
||
error "SKILL.md has no content after frontmatter"
|
||
else
|
||
success "SKILL.md has body content"
|
||
fi
|
||
|
||
# Check 9: Check for common writing style issues in body
|
||
if echo "$BODY_CONTENT" | grep -qE '^You (should|need|can|must)'; then
|
||
warn "Body uses second person ('You should...'). Prefer imperative form: 'Do this...'"
|
||
fi
|
||
|
||
if echo "$BODY_CONTENT" | grep -q '^When to Use This Skill'; then
|
||
warn "'When to Use This Skill' section in body is redundant. Include triggers in description field instead"
|
||
fi
|
||
|
||
# Check 10: Verify referenced files exist
|
||
if [[ -d "$SKILL_DIR/references" ]]; then
|
||
REF_COUNT=$(find "$SKILL_DIR/references" -type f | wc -l)
|
||
success "References directory exists with $REF_COUNT file(s)"
|
||
|
||
# Check if references are mentioned in SKILL.md
|
||
for ref_file in "$SKILL_DIR/references"/*; do
|
||
if [[ -f "$ref_file" ]]; then
|
||
ref_name=$(basename "$ref_file")
|
||
if ! grep -q "$ref_name" "$SKILL_MD"; then
|
||
warn "Reference file '$ref_name' is not mentioned in SKILL.md"
|
||
fi
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if [[ -d "$SKILL_DIR/scripts" ]]; then
|
||
SCRIPT_COUNT=$(find "$SKILL_DIR/scripts" -type f | wc -l)
|
||
success "Scripts directory exists with $SCRIPT_COUNT file(s)"
|
||
|
||
# Check scripts are executable
|
||
for script in "$SKILL_DIR/scripts"/*; do
|
||
if [[ -f "$script" ]] && [[ ! -x "$script" ]]; then
|
||
warn "Script '$(basename "$script")' is not executable (run: chmod +x)"
|
||
fi
|
||
done
|
||
fi
|
||
|
||
if [[ -d "$SKILL_DIR/assets" ]]; then
|
||
ASSET_COUNT=$(find "$SKILL_DIR/assets" -type f | wc -l)
|
||
success "Assets directory exists with $ASSET_COUNT file(s)"
|
||
fi
|
||
|
||
# Check 11: File organization
|
||
if [[ -f "$SKILL_DIR/README.md" ]]; then
|
||
warn "README.md found. Skills should not include README.md files"
|
||
fi
|
||
|
||
if [[ -f "$SKILL_DIR/CHANGELOG.md" ]]; then
|
||
warn "CHANGELOG.md found. Skills should not include auxiliary documentation"
|
||
fi
|
||
|
||
echo ""
|
||
echo "========================================"
|
||
|
||
# Final report
|
||
if [[ $ERRORS -eq 0 ]]; then
|
||
echo -e "${GREEN}✓ VALIDATION PASSED${NC}"
|
||
if [[ $WARNINGS -gt 0 ]]; then
|
||
echo -e "${YELLOW} $WARNINGS warning(s) (not treated as errors)${NC}"
|
||
fi
|
||
echo ""
|
||
echo "Your skill is ready to use!"
|
||
exit 0
|
||
else
|
||
echo -e "${RED}✗ VALIDATION FAILED${NC}"
|
||
echo -e "${RED} $ERRORS error(s) found${NC}"
|
||
if [[ $WARNINGS -gt 0 ]]; then
|
||
echo -e "${YELLOW} $WARNINGS warning(s)${NC}"
|
||
fi
|
||
echo ""
|
||
echo "Fix the errors above and run validation again."
|
||
exit 1
|
||
fi
|