#!/bin/bash # # validate-skill.sh - Strictly validate a skill's structure and content # # Usage: ./validate-skill.sh [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 [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