125 lines
4.7 KiB
Python
125 lines
4.7 KiB
Python
import os
|
|
import re
|
|
import argparse
|
|
import sys
|
|
|
|
def parse_frontmatter(content):
|
|
"""
|
|
Simple frontmatter parser using regex to avoid external dependencies.
|
|
Returns a dict of key-values.
|
|
"""
|
|
fm_match = re.search(r'^---\s*\n(.*?)\n---', content, re.DOTALL)
|
|
if not fm_match:
|
|
return None
|
|
|
|
fm_text = fm_match.group(1)
|
|
metadata = {}
|
|
for line in fm_text.split('\n'):
|
|
if ':' in line:
|
|
key, val = line.split(':', 1)
|
|
metadata[key.strip()] = val.strip().strip('"').strip("'")
|
|
return metadata
|
|
|
|
def validate_skills(skills_dir, strict_mode=False):
|
|
print(f"🔍 Validating skills in: {skills_dir}")
|
|
print(f"⚙️ Mode: {'STRICT (CI)' if strict_mode else 'Standard (Dev)'}")
|
|
|
|
errors = []
|
|
warnings = []
|
|
skill_count = 0
|
|
|
|
# Pre-compiled regex
|
|
security_disclaimer_pattern = re.compile(r"AUTHORIZED USE ONLY", re.IGNORECASE)
|
|
trigger_section_pattern = re.compile(r"^##\s+When to Use", re.MULTILINE | re.IGNORECASE)
|
|
|
|
valid_risk_levels = ["none", "safe", "critical", "offensive"]
|
|
|
|
for root, dirs, files in os.walk(skills_dir):
|
|
# Skip .disabled or hidden directories
|
|
dirs[:] = [d for d in dirs if not d.startswith('.')]
|
|
|
|
if "SKILL.md" in files:
|
|
skill_count += 1
|
|
skill_path = os.path.join(root, "SKILL.md")
|
|
rel_path = os.path.relpath(skill_path, skills_dir)
|
|
|
|
try:
|
|
with open(skill_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
except Exception as e:
|
|
errors.append(f"❌ {rel_path}: Unreadable file - {str(e)}")
|
|
continue
|
|
|
|
# 1. Frontmatter Check
|
|
metadata = parse_frontmatter(content)
|
|
if not metadata:
|
|
errors.append(f"❌ {rel_path}: Missing or malformed YAML frontmatter")
|
|
continue # Cannot proceed without metadata
|
|
|
|
# 2. Metadata Schema Checks
|
|
if "name" not in metadata:
|
|
errors.append(f"❌ {rel_path}: Missing 'name' in frontmatter")
|
|
elif metadata["name"] != os.path.basename(root):
|
|
warnings.append(f"⚠️ {rel_path}: Name '{metadata['name']}' does not match folder name '{os.path.basename(root)}'")
|
|
|
|
if "description" not in metadata:
|
|
errors.append(f"❌ {rel_path}: Missing 'description' in frontmatter")
|
|
|
|
# Risk Validation (Quality Bar)
|
|
if "risk" not in metadata:
|
|
msg = f"⚠️ {rel_path}: Missing 'risk' label (defaulting to 'unknown')"
|
|
if strict_mode: errors.append(msg.replace("⚠️", "❌"))
|
|
else: warnings.append(msg)
|
|
elif metadata["risk"] not in valid_risk_levels:
|
|
errors.append(f"❌ {rel_path}: Invalid risk level '{metadata['risk']}'. Must be one of {valid_risk_levels}")
|
|
|
|
# Source Validation
|
|
if "source" not in metadata:
|
|
msg = f"⚠️ {rel_path}: Missing 'source' attribution"
|
|
if strict_mode: errors.append(msg.replace("⚠️", "❌"))
|
|
else: warnings.append(msg)
|
|
|
|
# 3. Content Checks (Triggers)
|
|
if not trigger_section_pattern.search(content):
|
|
msg = f"⚠️ {rel_path}: Missing '## When to Use' section"
|
|
if strict_mode: errors.append(msg.replace("⚠️", "❌"))
|
|
else: warnings.append(msg)
|
|
|
|
# 4. Security Guardrails
|
|
if metadata.get("risk") == "offensive":
|
|
if not security_disclaimer_pattern.search(content):
|
|
errors.append(f"🚨 {rel_path}: OFFENSIVE SKILL MISSING SECURITY DISCLAIMER! (Must contain 'AUTHORIZED USE ONLY')")
|
|
|
|
# Reporting
|
|
print(f"\n📊 Checked {skill_count} skills.")
|
|
|
|
if warnings:
|
|
print(f"\n⚠️ Found {len(warnings)} Warnings:")
|
|
for w in warnings:
|
|
print(w)
|
|
|
|
if errors:
|
|
print(f"\n❌ Found {len(errors)} Critical Errors:")
|
|
for e in errors:
|
|
print(e)
|
|
return False
|
|
|
|
if strict_mode and warnings:
|
|
print("\n❌ STRICT MODE: Failed due to warnings.")
|
|
return False
|
|
|
|
print("\n✨ All skills passed validation!")
|
|
return True
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(description="Validate Antigravity Skills")
|
|
parser.add_argument("--strict", action="store_true", help="Fail on warnings (for CI)")
|
|
args = parser.parse_args()
|
|
|
|
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
skills_path = os.path.join(base_dir, "skills")
|
|
|
|
success = validate_skills(skills_path, strict_mode=args.strict)
|
|
if not success:
|
|
sys.exit(1)
|