321 lines
7.9 KiB
Bash
Executable File
321 lines
7.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# validate-command.sh - Validate Claude Code slash command structure
|
||
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
|
||
|
||
# Counters
|
||
ERRORS=0
|
||
WARNINGS=0
|
||
|
||
# Help text
|
||
show_help() {
|
||
cat << EOF
|
||
Usage: $(basename "$0") <command-file> [options]
|
||
|
||
Validate Claude Code slash command structure and frontmatter.
|
||
|
||
Arguments:
|
||
command-file Path to command .md file
|
||
|
||
Options:
|
||
-s, --strict Strict mode (warnings become errors)
|
||
-q, --quiet Only show errors and warnings
|
||
-h, --help Show this help
|
||
|
||
Examples:
|
||
# Validate single command
|
||
$(basename "$0") .claude/commands/deploy.md
|
||
|
||
# Validate all commands in directory
|
||
find .claude/commands -name "*.md" -exec $(basename "$0") {} \;
|
||
|
||
# Strict validation
|
||
$(basename "$0") my-command.md --strict
|
||
EOF
|
||
}
|
||
|
||
# Error reporting
|
||
error() {
|
||
((ERRORS++))
|
||
echo -e "${RED}✗ Error:${NC} $1"
|
||
}
|
||
|
||
warning() {
|
||
((WARNINGS++))
|
||
echo -e "${YELLOW}⚠ Warning:${NC} $1"
|
||
}
|
||
|
||
info() {
|
||
if [[ "$QUIET" == "false" ]]; then
|
||
echo -e "${BLUE}ℹ Info:${NC} $1"
|
||
fi
|
||
}
|
||
|
||
success() {
|
||
if [[ "$QUIET" == "false" ]]; then
|
||
echo -e "${GREEN}✓ $1${NC}"
|
||
fi
|
||
}
|
||
|
||
# Parse arguments
|
||
COMMAND_FILE=""
|
||
STRICT=false
|
||
QUIET=false
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
-h|--help)
|
||
show_help
|
||
exit 0
|
||
;;
|
||
-s|--strict)
|
||
STRICT=true
|
||
shift
|
||
;;
|
||
-q|--quiet)
|
||
QUIET=true
|
||
shift
|
||
;;
|
||
-*)
|
||
echo -e "${RED}Error: Unknown option $1${NC}"
|
||
show_help
|
||
exit 1
|
||
;;
|
||
*)
|
||
COMMAND_FILE="$1"
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Validate file argument
|
||
if [[ -z "$COMMAND_FILE" ]]; then
|
||
echo -e "${RED}Error: Command file required${NC}"
|
||
show_help
|
||
exit 1
|
||
fi
|
||
|
||
if [[ ! -f "$COMMAND_FILE" ]]; then
|
||
error "File not found: $COMMAND_FILE"
|
||
exit 1
|
||
fi
|
||
|
||
# Start validation
|
||
if [[ "$QUIET" == "false" ]]; then
|
||
echo -e "${BLUE}Validating: $COMMAND_FILE${NC}"
|
||
echo
|
||
fi
|
||
|
||
# 1. Check filename
|
||
FILENAME=$(basename "$COMMAND_FILE")
|
||
if [[ ! "$FILENAME" =~ \.md$ ]]; then
|
||
error "File must have .md extension"
|
||
fi
|
||
|
||
COMMAND_NAME="${FILENAME%.md}"
|
||
if [[ ! "$COMMAND_NAME" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
||
warning "Command name should be kebab-case: $COMMAND_NAME"
|
||
fi
|
||
|
||
# 2. Check file is not empty
|
||
if [[ ! -s "$COMMAND_FILE" ]]; then
|
||
error "File is empty"
|
||
exit 1
|
||
fi
|
||
|
||
# 3. Extract frontmatter if present
|
||
HAS_FRONTMATTER=false
|
||
FRONTMATTER=""
|
||
IN_FRONTMATTER=false
|
||
LINE_NUM=0
|
||
CONTENT_START=0
|
||
|
||
while IFS= read -r line; do
|
||
((LINE_NUM++))
|
||
|
||
if [[ $LINE_NUM -eq 1 && "$line" == "---" ]]; then
|
||
HAS_FRONTMATTER=true
|
||
IN_FRONTMATTER=true
|
||
continue
|
||
fi
|
||
|
||
if [[ "$IN_FRONTMATTER" == "true" ]]; then
|
||
if [[ "$line" == "---" ]]; then
|
||
IN_FRONTMATTER=false
|
||
CONTENT_START=$LINE_NUM
|
||
break
|
||
fi
|
||
FRONTMATTER+="$line"$'\n'
|
||
fi
|
||
done < "$COMMAND_FILE"
|
||
|
||
# 4. Validate frontmatter if present
|
||
if [[ "$HAS_FRONTMATTER" == "true" ]]; then
|
||
success "Frontmatter found"
|
||
|
||
# Check for unclosed frontmatter
|
||
if [[ "$IN_FRONTMATTER" == "true" ]]; then
|
||
error "Frontmatter not properly closed (missing closing ---)"
|
||
fi
|
||
|
||
# Validate YAML syntax (basic check)
|
||
if ! echo "$FRONTMATTER" | grep -qE '^[a-z-]+:'; then
|
||
warning "Frontmatter might have invalid YAML syntax"
|
||
fi
|
||
|
||
# Check for common fields
|
||
if echo "$FRONTMATTER" | grep -q '^description:'; then
|
||
DESCRIPTION=$(echo "$FRONTMATTER" | grep '^description:' | sed 's/^description: *//')
|
||
if [[ -n "$DESCRIPTION" ]]; then
|
||
success "Description: $DESCRIPTION"
|
||
|
||
# Check description length
|
||
DESC_LENGTH=${#DESCRIPTION}
|
||
if [[ $DESC_LENGTH -gt 100 ]]; then
|
||
warning "Description is long ($DESC_LENGTH chars). Consider keeping under 80 chars."
|
||
fi
|
||
else
|
||
warning "Description field is empty"
|
||
fi
|
||
else
|
||
warning "No description field (will use first line of content)"
|
||
fi
|
||
|
||
# Check argument-hint
|
||
if echo "$FRONTMATTER" | grep -q '^argument-hint:'; then
|
||
ARG_HINT=$(echo "$FRONTMATTER" | grep '^argument-hint:' | sed 's/^argument-hint: *//')
|
||
info "Argument hint: $ARG_HINT"
|
||
fi
|
||
|
||
# Check allowed-tools
|
||
if echo "$FRONTMATTER" | grep -q '^allowed-tools:'; then
|
||
TOOLS=$(echo "$FRONTMATTER" | grep '^allowed-tools:' | sed 's/^allowed-tools: *//')
|
||
info "Allowed tools: $TOOLS"
|
||
|
||
# Validate tool names - check for names starting with lowercase
|
||
# Claude tools are PascalCase (Read, Write, BashOutput)
|
||
for tool in $(echo "$TOOLS" | tr ',' ' '); do
|
||
tool=$(echo "$tool" | xargs) # trim whitespace
|
||
if [[ -n "$tool" && "$tool" =~ ^[a-z] ]]; then
|
||
warning "Tool name '$tool' starts with lowercase (Claude tools are PascalCase, e.g., 'Read')"
|
||
fi
|
||
done
|
||
fi
|
||
|
||
# Check model
|
||
if echo "$FRONTMATTER" | grep -q '^model:'; then
|
||
MODEL=$(echo "$FRONTMATTER" | grep '^model:' | sed 's/^model: *//')
|
||
info "Model: $MODEL"
|
||
fi
|
||
|
||
# Check for tabs (YAML doesn't allow tabs)
|
||
if echo "$FRONTMATTER" | grep -q $'\t'; then
|
||
error "Frontmatter contains tabs (YAML requires spaces)"
|
||
fi
|
||
|
||
else
|
||
info "No frontmatter (optional, but recommended)"
|
||
fi
|
||
|
||
# 5. Check content
|
||
CONTENT=$(tail -n +"$((CONTENT_START + 1))" "$COMMAND_FILE")
|
||
|
||
if [[ -z "$CONTENT" ]]; then
|
||
error "No content after frontmatter"
|
||
else
|
||
success "Content present"
|
||
|
||
# Check content length
|
||
CONTENT_LENGTH=${#CONTENT}
|
||
if [[ $CONTENT_LENGTH -lt 20 ]]; then
|
||
warning "Content is very short ($CONTENT_LENGTH chars)"
|
||
fi
|
||
|
||
# Check for argument usage
|
||
if echo "$CONTENT" | grep -qE '\$[0-9]|\$ARGUMENTS'; then
|
||
ARG_USAGE=$(echo "$CONTENT" | grep -oE '\$[0-9]|\$ARGUMENTS' | sort -u | tr '\n' ' ')
|
||
info "Uses arguments: $ARG_USAGE"
|
||
|
||
# Check if argument-hint is present
|
||
if [[ "$HAS_FRONTMATTER" == "true" ]]; then
|
||
if ! echo "$FRONTMATTER" | grep -q '^argument-hint:'; then
|
||
warning "Command uses arguments but has no argument-hint in frontmatter"
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Check for bash execution
|
||
if echo "$CONTENT" | grep -qE '!\`[^`]+\`'; then
|
||
BASH_COUNT=$(echo "$CONTENT" | grep -oE '!\`[^`]+\`' | wc -l | xargs)
|
||
info "Uses bash execution ($BASH_COUNT commands)"
|
||
|
||
# Check if Bash tool is allowed
|
||
if [[ "$HAS_FRONTMATTER" == "true" ]]; then
|
||
if echo "$FRONTMATTER" | grep -q '^allowed-tools:'; then
|
||
if ! echo "$FRONTMATTER" | grep 'allowed-tools:' | grep -q 'Bash'; then
|
||
warning "Command uses bash execution but Bash not in allowed-tools"
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# Check for file references
|
||
if echo "$CONTENT" | grep -qE '@[a-zA-Z0-9$/_.-]+'; then
|
||
FILE_REFS=$(echo "$CONTENT" | grep -oE '@[a-zA-Z0-9$/_.-]+' | wc -l | xargs)
|
||
info "Uses file references ($FILE_REFS references)"
|
||
|
||
# Check if Read tool is allowed
|
||
if [[ "$HAS_FRONTMATTER" == "true" ]]; then
|
||
if echo "$FRONTMATTER" | grep -q '^allowed-tools:'; then
|
||
if ! echo "$FRONTMATTER" | grep 'allowed-tools:' | grep -q 'Read'; then
|
||
warning "Command uses file references but Read not in allowed-tools"
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# 6. Check for common issues
|
||
|
||
# Unclosed code blocks (odd number of ``` markers indicates unclosed block)
|
||
FENCE_COUNT=$(echo "$CONTENT" | grep -c '^```' || true)
|
||
if [[ $FENCE_COUNT -gt 0 && $((FENCE_COUNT % 2)) -ne 0 ]]; then
|
||
warning "Possibly unclosed code block (odd number of \`\`\` markers)"
|
||
fi
|
||
|
||
# Very long lines
|
||
while IFS= read -r line; do
|
||
if [[ ${#line} -gt 200 ]]; then
|
||
warning "Line longer than 200 characters (consider breaking up)"
|
||
break
|
||
fi
|
||
done < "$COMMAND_FILE"
|
||
|
||
# Convert warnings to errors in strict mode
|
||
if [[ "$STRICT" == "true" && $WARNINGS -gt 0 ]]; then
|
||
ERRORS=$((ERRORS + WARNINGS))
|
||
WARNINGS=0
|
||
fi
|
||
|
||
# Summary
|
||
echo
|
||
if [[ $ERRORS -eq 0 && $WARNINGS -eq 0 ]]; then
|
||
echo -e "${GREEN}✓ Validation passed!${NC}"
|
||
exit 0
|
||
elif [[ $ERRORS -eq 0 ]]; then
|
||
echo -e "${YELLOW}⚠ Validation passed with $WARNINGS warning(s)${NC}"
|
||
exit 0
|
||
else
|
||
echo -e "${RED}✗ Validation failed with $ERRORS error(s)${NC}"
|
||
if [[ $WARNINGS -gt 0 ]]; then
|
||
echo -e "${YELLOW} and $WARNINGS warning(s)${NC}"
|
||
fi
|
||
exit 1
|
||
fi
|