Files
troupe-standup/wizard-work/spark/skill_md_validator.py
T

201 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""
SKILL.md Validator for Hermes Agent
Validates SKILL.md files against the hermes-agent-skill-authoring standards.
"""
import re
import sys
from pathlib import Path
try:
from ruamel.yaml import YAML
YAML_AVAILABLE = True
except ImportError:
YAML_AVAILABLE = False
def validate_skill_md(file_path):
"""Validate a SKILL.md file against the hermes authoring standards."""
errors = []
warnings = []
path = Path(file_path)
if not path.exists():
return {'valid': False, 'errors': [f"File not found: {path}"], 'warnings': []}
content = path.read_text()
# Check 1: Starts with --- as first bytes
if not content.startswith("---\n") and not (len(content) > 4 and content[:4] == "---\r"):
errors.append("File must start with '---' as the first bytes (no leading blank line)")
# Check 2: Find closing ---
body = ""
if content.startswith("---"):
# Find the closing delimiter
m = re.search(r'\n---\s*\n', content[3:])
if not m:
errors.append("Missing closing '---\\n' after frontmatter")
return {'valid': False, 'errors': errors, 'warnings': warnings}
frontmatter_end = 3 + m.end() - 1 # Position after closing ---
frontmatter_text = content[3:frontmatter_end - 1]
body = content[frontmatter_end:] # Rest is body
else:
return {'valid': False, 'errors': errors + ["File must start with '---'"], 'warnings': warnings}
# Check 3: Parse YAML frontmatter
fm = {}
if YAML_AVAILABLE:
yaml = YAML(typ='safe')
try:
fm = yaml.load(frontmatter_text) or {}
if not isinstance(fm, dict):
errors.append(f"Frontmatter must be a YAML mapping, got {type(fm).__name__}")
return {'valid': False, 'errors': errors, 'warnings': warnings}
except Exception as e:
errors.append(f"YAML parse error: {e}")
return {'valid': False, 'errors': errors, 'warnings': warnings}
else:
warnings.append("ruamel.yaml not installed, skipping YAML validation")
fm = parse_frontmatter_simple(frontmatter_text)
# Validate required fields
# Check name
if 'name' not in fm:
errors.append("Missing required field: 'name'")
elif not isinstance(fm['name'], str):
errors.append("'name' must be a string")
elif len(fm['name']) > 64:
warnings.append(f"'name' exceeds 64 chars: {len(fm['name'])}")
elif not re.match(r'^[a-z0-9](?:-[a-z0-9]+)*$', fm['name']):
warnings.append(f"'name' should be lowercase-hyphenated: {fm['name']}")
# Check description
if 'description' not in fm:
errors.append("Missing required field: 'description'")
elif not isinstance(fm['description'], str):
errors.append("'description' must be a string")
elif len(fm['description']) > 60:
errors.append(f"'description' is {len(fm['description'])} chars (max 60)")
elif not fm['description'].endswith('.'):
errors.append("description must end with a period")
elif has_marketing_words(fm['description']):
warnings.append("description contains marketing words")
# Check version
if 'version' not in fm:
errors.append("Missing required field: 'version'")
elif not isinstance(fm['version'], str):
errors.append("'version' must be a string")
elif not re.match(r'^\d+\.\d+\.\d+$', fm['version']):
warnings.append(f"version should be semver: {fm['version']}")
# Check author
if 'author' not in fm:
errors.append("Missing required field: 'author'")
elif not isinstance(fm['author'], str):
errors.append("'author' must be a string")
# Check license
if 'license' not in fm:
errors.append("Missing required field: 'license'")
# Check platforms
if 'platforms' not in fm:
errors.append("Missing required field: 'platforms'")
elif not isinstance(fm['platforms'], list):
errors.append("'platforms' must be a list")
elif not all(isinstance(p, str) for p in fm['platforms']):
errors.append("'platforms' must contain only strings")
# Check metadata.hermes
if 'metadata' not in fm:
errors.append("Missing required field: 'metadata'")
else:
if 'hermes' not in fm['metadata']:
warnings.append("metadata.hermes section missing")
else:
if 'tags' not in fm['metadata']['hermes']:
warnings.append("metadata.hermes.tags missing")
if 'related_skills' not in fm['metadata']['hermes']:
warnings.append("metadata.hermes.related_skills missing")
# Validate description chars for trigger window
if 'description' in fm and len(fm['description']) > 57:
warnings.append("description may be truncated in system prompt (57 chars + '...')")
# Check 4: Non-empty body
if body and not body.strip():
errors.append("Body is empty after frontmatter")
# Check 5: Body has reasonable content
if body and len(body.strip()) < 50:
warnings.append("Body content seems unusually short")
return {
'valid': len(errors) == 0,
'errors': errors,
'warnings': warnings
}
def has_marketing_words(text):
"""Check if text contains marketing words that should be avoided."""
marketing = ['powerful', 'comprehensive', 'seamless', 'advanced', 'robust',
'intuitive', 'elegant', 'cutting-edge', 'state-of-the-art']
text_lower = text.lower()
return any(word in text_lower for word in marketing)
def parse_frontmatter_simple(text):
"""Simple YAML-like parser for basic validation when ruamel.yaml unavailable."""
result = {}
for line in text.split('\n'):
if ':' in line:
key, _, value = line.partition(':')
key = key.strip().strip('\'"')
value = value.strip().strip('\'"')
if value:
# Try to parse as list
if value.startswith('[') and value.endswith(']'):
items = value[1:-1].split(',')
result[key] = [i.strip().strip('\'"') for i in items if i.strip()]
else:
result[key] = value
return result
def main():
if len(sys.argv) < 2:
print("Usage: skill_md_validator.py <path-to-SKILL.md>")
sys.exit(1)
result = validate_skill_md(sys.argv[1])
print(f"Validating: {sys.argv[1]}")
print("-" * 40)
if result['warnings']:
print("Warnings:")
for w in result['warnings']:
print(f" [WARNING] {w}")
if result['errors']:
print("Errors:")
for e in result['errors']:
print(f" [ERROR] {e}")
print("-" * 40)
if result['valid']:
print("VALID")
sys.exit(0)
else:
print("INVALID")
sys.exit(1)
if __name__ == "__main__":
main()