201 lines
6.9 KiB
Python
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() |