Files
2025-11-29 18:46:49 +08:00

280 lines
8.7 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Analyze skill structure and validate against Anthropic standards.
Usage:
python analyze_skill_structure.py --skill skills/category/skill-name
"""
import sys
import io
import re
import argparse
from pathlib import Path
from typing import Dict, List, Tuple
# Configure stdout for UTF-8 encoding (prevents Windows encoding errors)
if sys.platform == 'win32':
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
def parse_frontmatter(content: str) -> Tuple[Dict[str, str], str]:
"""Extract and parse YAML frontmatter from SKILL.md"""
if not content.startswith('---'):
return {}, content
match = re.match(r'^---\n(.*?)\n---\n(.*)$', content, re.DOTALL)
if not match:
return {}, content
frontmatter_text = match.group(1)
body = match.group(2)
# Parse frontmatter
frontmatter = {}
for line in frontmatter_text.split('\n'):
if ':' in line:
key, value = line.split(':', 1)
frontmatter[key.strip()] = value.strip()
return frontmatter, body
def check_name_convention(name: str) -> List[str]:
"""Check if name follows hyphen-case convention"""
issues = []
if not re.match(r'^[a-z0-9-]+$', name):
issues.append(f"Name '{name}' should use only lowercase letters, digits, and hyphens")
if name.startswith('-') or name.endswith('-'):
issues.append(f"Name '{name}' cannot start or end with hyphen")
if '--' in name:
issues.append(f"Name '{name}' cannot contain consecutive hyphens")
return issues
def check_description(description: str) -> List[str]:
"""Check description quality"""
issues = []
if len(description) > 1024:
issues.append(f"Description is {len(description)} characters (max 1024)")
if '<' in description or '>' in description:
issues.append("Description cannot contain angle brackets")
# Check for third-person voice
first_person_patterns = [
r'\bI\s+',
r'\bwe\s+',
r'\bour\s+',
r'\bmy\s+',
]
for pattern in first_person_patterns:
if re.search(pattern, description, re.IGNORECASE):
issues.append("Description should use third-person voice, not first-person")
break
# Check if it includes trigger terms
if 'trigger' not in description.lower() and 'include' not in description.lower():
issues.append("Description should explicitly mention trigger terms")
return issues
def check_second_person_usage(body: str) -> List[Tuple[int, str]]:
"""Find second-person usage in skill body"""
violations = []
patterns = [
r'\byou\s+should\b',
r'\byou\s+can\b',
r'\byou\s+need\s+to\b',
r'\byou\s+have\s+to\b',
r'\byou\s+must\b',
r'\byou\s+will\b',
r'\byour\b',
]
lines = body.split('\n')
for i, line in enumerate(lines, start=1):
# Skip code blocks
if line.strip().startswith('```') or line.strip().startswith(' '):
continue
for pattern in patterns:
if re.search(pattern, line, re.IGNORECASE):
violations.append((i, line.strip()))
break
return violations
def check_emoji_usage(content: str) -> List[Tuple[int, str]]:
"""Find emoji usage in skill content"""
violations = []
# Common emojis that should be replaced with ASCII
# Using Unicode ranges to avoid encoding issues
emoji_pattern = re.compile(
r'[\U0001F300-\U0001F9FF' # Miscellaneous Symbols and Pictographs, Supplemental
r'\u2600-\u26FF' # Miscellaneous Symbols
r'\u2700-\u27BF' # Dingbats
r'\U0001F600-\U0001F64F' # Emoticons
r'\U0001F680-\U0001F6FF' # Transport and Map
r'\U0001F1E0-\U0001F1FF]' # Regional Indicator Symbols
)
lines = content.split('\n')
for i, line in enumerate(lines, start=1):
if emoji_pattern.search(line):
violations.append((i, line.strip()[:100])) # Limit to 100 chars
return violations
def analyze_skill(skill_path: Path) -> Dict:
"""Analyze skill structure and return findings"""
results = {
'valid': True,
'critical_issues': [],
'warnings': [],
'suggestions': [],
}
# Check if SKILL.md exists
skill_md = skill_path / 'SKILL.md'
if not skill_md.exists():
results['valid'] = False
results['critical_issues'].append('SKILL.md not found')
return results
# Read SKILL.md
content = skill_md.read_text(encoding='utf-8')
# Parse frontmatter
frontmatter, body = parse_frontmatter(content)
if not frontmatter:
results['valid'] = False
results['critical_issues'].append('No YAML frontmatter found')
return results
# Check required fields
if 'name' not in frontmatter:
results['valid'] = False
results['critical_issues'].append('Missing "name" field in frontmatter')
else:
name = frontmatter['name']
name_issues = check_name_convention(name)
if name_issues:
results['valid'] = False
results['critical_issues'].extend(name_issues)
if 'description' not in frontmatter:
results['valid'] = False
results['critical_issues'].append('Missing "description" field in frontmatter')
else:
description = frontmatter['description']
desc_issues = check_description(description)
if desc_issues:
for issue in desc_issues:
if 'angle brackets' in issue or 'characters' in issue:
results['valid'] = False
results['critical_issues'].append(issue)
else:
results['warnings'].append(issue)
# Check for second-person usage
second_person = check_second_person_usage(body)
if second_person:
results['warnings'].append(f"Found {len(second_person)} instances of second-person usage")
results['second_person_violations'] = second_person[:5] # First 5
# Check for emoji usage (critical issue)
emoji_violations = check_emoji_usage(content)
if emoji_violations:
results['valid'] = False
results['critical_issues'].append(f"Found {len(emoji_violations)} emoji characters (must use ASCII alternatives)")
results['emoji_violations'] = emoji_violations[:5] # First 5
# Check resource directories
resources = {
'scripts': skill_path / 'scripts',
'references': skill_path / 'references',
'assets': skill_path / 'assets',
}
mentioned_resources = {}
for resource_type, resource_path in resources.items():
pattern = f'{resource_type}/'
if pattern in body:
mentioned_resources[resource_type] = resource_path.exists()
for resource_type, exists in mentioned_resources.items():
if not exists:
results['suggestions'].append(
f"Skill mentions {resource_type}/ but directory doesn't exist"
)
return results
def main():
parser = argparse.ArgumentParser(description='Analyze skill structure')
parser.add_argument('--skill', required=True, help='Path to skill directory')
args = parser.parse_args()
skill_path = Path(args.skill)
if not skill_path.exists():
print(f"Error: Skill directory not found: {skill_path}")
sys.exit(1)
print(f"Analyzing skill: {skill_path}")
print("=" * 60)
results = analyze_skill(skill_path)
# Print results
if results['valid']:
print("[PASS] Skill structure is valid!")
else:
print("[FAIL] Skill structure has critical issues")
print()
if results['critical_issues']:
print("CRITICAL ISSUES:")
for issue in results['critical_issues']:
print(f" [ERROR] {issue}")
print()
if results['warnings']:
print("WARNINGS:")
for warning in results['warnings']:
print(f" [WARN] {warning}")
print()
if 'second_person_violations' in results:
print("SECOND-PERSON USAGE (first 5):")
for line_num, line in results['second_person_violations']:
print(f" Line {line_num}: {line}")
print()
if 'emoji_violations' in results:
print("EMOJI USAGE DETECTED (first 5):")
print(" Emojis must be replaced with ASCII alternatives:")
print(" [OK]/[PASS], [ERROR]/[FAIL], [WARN], [TIP], [INFO], [X]")
for line_num, line in results['emoji_violations']:
print(f" Line {line_num}: {line}")
print()
if results['suggestions']:
print("SUGGESTIONS:")
for suggestion in results['suggestions']:
print(f" [TIP] {suggestion}")
print()
sys.exit(0 if results['valid'] else 1)
if __name__ == "__main__":
main()