269 lines
8.3 KiB
Python
Executable File
269 lines
8.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Validate a Claude Code SKILL.md file.
|
|
|
|
Usage:
|
|
python validate-skill.py path/to/SKILL.md
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Tuple, List
|
|
|
|
# Validation rules
|
|
NAME_PATTERN = re.compile(r'^[a-z0-9-]+$')
|
|
MAX_NAME_LENGTH = 64
|
|
MAX_DESCRIPTION_LENGTH = 1024
|
|
|
|
def validate_yaml_frontmatter(content: str) -> Tuple[bool, List[str], dict]:
|
|
"""
|
|
Validate YAML frontmatter in SKILL.md.
|
|
|
|
Returns:
|
|
Tuple of (is_valid, errors, frontmatter_data)
|
|
"""
|
|
errors = []
|
|
frontmatter_data = {}
|
|
|
|
lines = content.split('\n')
|
|
|
|
# Check first line is ---
|
|
if not lines or lines[0].strip() != '---':
|
|
errors.append("YAML frontmatter must start with '---' on line 1")
|
|
return False, errors, frontmatter_data
|
|
|
|
# Find closing ---
|
|
closing_idx = None
|
|
for i, line in enumerate(lines[1:], start=1):
|
|
if line.strip() == '---':
|
|
closing_idx = i
|
|
break
|
|
|
|
if closing_idx is None:
|
|
errors.append("YAML frontmatter must end with '---'")
|
|
return False, errors, frontmatter_data
|
|
|
|
# Extract frontmatter
|
|
frontmatter_lines = lines[1:closing_idx]
|
|
|
|
# Parse YAML (simple parsing for validation)
|
|
for line in frontmatter_lines:
|
|
if ':' in line:
|
|
key, value = line.split(':', 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
|
|
# Remove quotes if present
|
|
if value.startswith('"') and value.endswith('"'):
|
|
value = value[1:-1]
|
|
elif value.startswith("'") and value.endswith("'"):
|
|
value = value[1:-1]
|
|
|
|
frontmatter_data[key] = value
|
|
|
|
# Check required fields
|
|
if 'name' not in frontmatter_data:
|
|
errors.append("Missing required field: 'name'")
|
|
|
|
if 'description' not in frontmatter_data:
|
|
errors.append("Missing required field: 'description'")
|
|
|
|
return len(errors) == 0, errors, frontmatter_data
|
|
|
|
|
|
def validate_name(name: str) -> Tuple[bool, List[str]]:
|
|
"""Validate skill name."""
|
|
errors = []
|
|
|
|
if not name:
|
|
errors.append("Name cannot be empty")
|
|
return False, errors
|
|
|
|
if len(name) > MAX_NAME_LENGTH:
|
|
errors.append(f"Name exceeds maximum length of {MAX_NAME_LENGTH} characters (got {len(name)})")
|
|
|
|
if not NAME_PATTERN.match(name):
|
|
errors.append(f"Name must contain only lowercase letters, numbers, and hyphens (got: '{name}')")
|
|
|
|
# Provide specific feedback
|
|
if any(c.isupper() for c in name):
|
|
errors.append(" - Contains uppercase letters (use lowercase only)")
|
|
if '_' in name:
|
|
errors.append(" - Contains underscores (use hyphens instead)")
|
|
if ' ' in name:
|
|
errors.append(" - Contains spaces (use hyphens instead)")
|
|
if any(c in name for c in '!@#$%^&*()+=[]{}|\\;:\'",.<>?/'):
|
|
errors.append(" - Contains special characters (only hyphens allowed)")
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
def validate_description(description: str) -> Tuple[bool, List[str]]:
|
|
"""Validate skill description."""
|
|
errors = []
|
|
warnings = []
|
|
|
|
if not description:
|
|
errors.append("Description cannot be empty")
|
|
return False, errors
|
|
|
|
if len(description) > MAX_DESCRIPTION_LENGTH:
|
|
errors.append(f"Description exceeds maximum length of {MAX_DESCRIPTION_LENGTH} characters (got {len(description)})")
|
|
|
|
# Check for best practices (warnings, not errors)
|
|
description_lower = description.lower()
|
|
|
|
if 'use when' not in description_lower:
|
|
warnings.append("Description should include 'Use when' to indicate when to activate this skill")
|
|
|
|
if len(description) < 50:
|
|
warnings.append("Description is quite short - consider adding more detail about what it does and when to use it")
|
|
|
|
# Check for vague terms
|
|
vague_terms = ['helps', 'assists', 'general', 'various', 'stuff', 'things']
|
|
found_vague = [term for term in vague_terms if term in description_lower]
|
|
if found_vague:
|
|
warnings.append(f"Description contains vague terms: {', '.join(found_vague)}. Be more specific.")
|
|
|
|
if warnings:
|
|
print("\n⚠️ Warnings (not errors, but consider addressing):")
|
|
for warning in warnings:
|
|
print(f" - {warning}")
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
def validate_file_structure(skill_path: Path) -> Tuple[bool, List[str]]:
|
|
"""Validate the skill file structure."""
|
|
errors = []
|
|
warnings = []
|
|
|
|
skill_md = skill_path
|
|
skill_dir = skill_path.parent
|
|
|
|
if not skill_md.exists():
|
|
errors.append(f"SKILL.md not found at {skill_md}")
|
|
return False, errors
|
|
|
|
# Check for supporting files referenced in SKILL.md
|
|
content = skill_md.read_text()
|
|
|
|
# Find markdown links and code references
|
|
link_pattern = re.compile(r'\[([^\]]+)\]\(([^\)]+)\)')
|
|
matches = link_pattern.findall(content)
|
|
|
|
for link_text, link_path in matches:
|
|
# Skip external links
|
|
if link_path.startswith('http://') or link_path.startswith('https://'):
|
|
continue
|
|
|
|
# Check if referenced file exists
|
|
referenced_file = skill_dir / link_path
|
|
if not referenced_file.exists():
|
|
warnings.append(f"Referenced file not found: {link_path}")
|
|
|
|
if warnings:
|
|
print("\n⚠️ File Structure Warnings:")
|
|
for warning in warnings:
|
|
print(f" - {warning}")
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
def validate_skill_file(skill_path: Path) -> bool:
|
|
"""
|
|
Validate a complete SKILL.md file.
|
|
|
|
Returns:
|
|
True if valid, False otherwise
|
|
"""
|
|
print(f"Validating: {skill_path}\n")
|
|
|
|
if not skill_path.exists():
|
|
print(f"❌ Error: File not found: {skill_path}")
|
|
return False
|
|
|
|
content = skill_path.read_text()
|
|
all_valid = True
|
|
|
|
# Validate YAML frontmatter
|
|
print("Checking YAML frontmatter...")
|
|
valid_yaml, yaml_errors, frontmatter = validate_yaml_frontmatter(content)
|
|
|
|
if not valid_yaml:
|
|
print("❌ YAML frontmatter errors:")
|
|
for error in yaml_errors:
|
|
print(f" - {error}")
|
|
all_valid = False
|
|
else:
|
|
print("✅ YAML frontmatter is valid")
|
|
|
|
# Validate name
|
|
if 'name' in frontmatter:
|
|
print("\nChecking name...")
|
|
valid_name, name_errors = validate_name(frontmatter['name'])
|
|
if not valid_name:
|
|
print("❌ Name validation errors:")
|
|
for error in name_errors:
|
|
print(f" - {error}")
|
|
all_valid = False
|
|
else:
|
|
print(f"✅ Name is valid: '{frontmatter['name']}'")
|
|
|
|
# Validate description
|
|
if 'description' in frontmatter:
|
|
print("\nChecking description...")
|
|
valid_desc, desc_errors = validate_description(frontmatter['description'])
|
|
if not valid_desc:
|
|
print("❌ Description validation errors:")
|
|
for error in desc_errors:
|
|
print(f" - {error}")
|
|
all_valid = False
|
|
else:
|
|
print(f"✅ Description is valid ({len(frontmatter['description'])} characters)")
|
|
|
|
# Validate allowed-tools if present
|
|
if 'allowed-tools' in frontmatter:
|
|
print("\nChecking allowed-tools...")
|
|
print(f"✅ Tool restrictions found: {frontmatter['allowed-tools']}")
|
|
|
|
# Validate file structure
|
|
print("\nChecking file structure...")
|
|
valid_structure, structure_errors = validate_file_structure(skill_path)
|
|
if not valid_structure:
|
|
print("❌ File structure errors:")
|
|
for error in structure_errors:
|
|
print(f" - {error}")
|
|
all_valid = False
|
|
else:
|
|
print("✅ File structure is valid")
|
|
|
|
# Summary
|
|
print("\n" + "="*60)
|
|
if all_valid:
|
|
print("✅ Skill validation PASSED")
|
|
print("\nYour skill is ready to use!")
|
|
print("Remember to restart Claude Code to load the new skill.")
|
|
else:
|
|
print("❌ Skill validation FAILED")
|
|
print("\nPlease fix the errors above and try again.")
|
|
print("="*60)
|
|
|
|
return all_valid
|
|
|
|
|
|
def main():
|
|
if len(sys.argv) != 2:
|
|
print("Usage: python validate-skill.py path/to/SKILL.md")
|
|
sys.exit(1)
|
|
|
|
skill_path = Path(sys.argv[1])
|
|
valid = validate_skill_file(skill_path)
|
|
|
|
sys.exit(0 if valid else 1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|