Skip to main content
Glama
quick_validate.py8.1 kB
#!/usr/bin/env python3 """ Quick validation of SKILL.md frontmatter. Forked from anthropics/skills/skill-creator (Apache 2.0) See LICENSE.txt for attribution. Usage: python quick_validate.py <skill-path> Example: python quick_validate.py ./my-lead-qualification """ import argparse import re import sys from pathlib import Path from typing import Dict, List, Tuple, Any # Constraints from Claude Skills specification MAX_NAME_LENGTH = 64 MAX_DESCRIPTION_LENGTH = 1024 NAME_PATTERN = re.compile(r'^[a-z][a-z0-9]*(-[a-z0-9]+)*$') ALLOWED_FRONTMATTER_KEYS = {'name', 'description', 'license', 'allowed-tools', 'metadata'} def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]: """ Parse YAML frontmatter from SKILL.md content. Expected format: --- name: skill-name description: Skill description --- Args: content: Full SKILL.md file content Returns: Tuple of (frontmatter_dict, body_content) Raises: ValueError: If frontmatter is malformed """ if not content.startswith('---'): raise ValueError("SKILL.md must start with '---' frontmatter delimiter") # Find closing delimiter end_match = re.search(r'\n---\s*\n', content[3:]) if not end_match: raise ValueError("SKILL.md frontmatter missing closing '---' delimiter") frontmatter_text = content[3:end_match.start() + 3] body = content[end_match.end() + 3:] # Parse YAML manually (avoid external dependency) frontmatter = {} current_key = None current_value_lines = [] for line in frontmatter_text.split('\n'): # Skip empty lines if not line.strip(): continue # Check for key: value pattern key_match = re.match(r'^([a-z][a-z0-9-]*)\s*:\s*(.*)$', line) if key_match: # Save previous key if exists if current_key: frontmatter[current_key] = '\n'.join(current_value_lines).strip() current_key = key_match.group(1) current_value_lines = [key_match.group(2)] elif current_key and line.startswith(' '): # Continuation of multi-line value current_value_lines.append(line.strip()) # Save last key if current_key: frontmatter[current_key] = '\n'.join(current_value_lines).strip() return frontmatter, body def validate_frontmatter(frontmatter: Dict[str, Any]) -> List[str]: """ Validate frontmatter against Claude Skills requirements. Checks: - Required fields: name, description - name: hyphen-case, max 64 chars - description: max 1024 chars, no angle brackets - No unexpected keys Args: frontmatter: Parsed frontmatter dictionary Returns: List of error messages (empty if valid) """ errors = [] # Check for unexpected keys unexpected_keys = set(frontmatter.keys()) - ALLOWED_FRONTMATTER_KEYS if unexpected_keys: errors.append(f"Unexpected frontmatter keys: {', '.join(sorted(unexpected_keys))}") # Validate name if 'name' not in frontmatter: errors.append("Missing required field: name") else: name = frontmatter['name'] if not isinstance(name, str): errors.append("Field 'name' must be a string") else: if len(name) > MAX_NAME_LENGTH: errors.append(f"Name exceeds {MAX_NAME_LENGTH} characters: {len(name)}") if not NAME_PATTERN.match(name): if name[0].isupper(): errors.append("Name must start with lowercase letter") elif '--' in name: errors.append("Name cannot contain consecutive hyphens") elif name.startswith('-') or name.endswith('-'): errors.append("Name cannot start or end with hyphen") else: errors.append("Name must be hyphen-case (lowercase, digits, hyphens)") # Validate description if 'description' not in frontmatter: errors.append("Missing required field: description") else: description = frontmatter['description'] if not isinstance(description, str): errors.append("Field 'description' must be a string") else: if len(description) > MAX_DESCRIPTION_LENGTH: errors.append(f"Description exceeds {MAX_DESCRIPTION_LENGTH} characters: {len(description)}") if '<' in description or '>' in description: errors.append("Description cannot contain angle brackets (< or >)") return errors def validate_skill_structure(skill_path: Path) -> List[str]: """ Validate skill directory structure. Checks: - SKILL.md exists - resources/ directory exists (optional but recommended) - No broken internal links Args: skill_path: Path to skill directory Returns: List of error messages (empty if valid) """ errors = [] skill_md = skill_path / 'SKILL.md' if not skill_md.exists(): errors.append(f"Missing required file: SKILL.md") return errors # Can't continue without SKILL.md # Check for resources directory resources_dir = skill_path / 'resources' if not resources_dir.exists(): errors.append("Warning: resources/ directory not found (recommended)") return errors def validate_skill(skill_path: str) -> Tuple[bool, List[str]]: """ Perform complete skill validation. Args: skill_path: Path to skill directory Returns: Tuple of (is_valid, list of error/warning messages) """ path = Path(skill_path).resolve() all_messages = [] # Check path exists if not path.exists(): return False, [f"Path does not exist: {path}"] if not path.is_dir(): return False, [f"Path is not a directory: {path}"] # Validate structure structure_errors = validate_skill_structure(path) all_messages.extend(structure_errors) # If SKILL.md exists, validate content skill_md = path / 'SKILL.md' if skill_md.exists(): try: content = skill_md.read_text() frontmatter, _ = parse_frontmatter(content) frontmatter_errors = validate_frontmatter(frontmatter) all_messages.extend(frontmatter_errors) except ValueError as e: all_messages.append(f"Frontmatter error: {e}") # Separate errors from warnings errors = [m for m in all_messages if not m.startswith('Warning')] warnings = [m for m in all_messages if m.startswith('Warning')] is_valid = len(errors) == 0 # Combine with warnings at the end return is_valid, errors + warnings def main(): """CLI entry point for skill validation.""" parser = argparse.ArgumentParser( description='Validate Attio workflow skill structure and frontmatter', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=''' Examples: python quick_validate.py ./my-lead-qualification python quick_validate.py ./generated-skills/acme-deal-management Validation checks: - SKILL.md exists and has valid frontmatter - name: hyphen-case, max 64 characters - description: max 1024 characters, no angle brackets - Recommended: resources/ directory exists ''' ) parser.add_argument( 'skill_path', help='Path to skill directory' ) parser.add_argument( '--quiet', '-q', action='store_true', help='Only output errors, no success message' ) args = parser.parse_args() is_valid, messages = validate_skill(args.skill_path) if messages: for msg in messages: symbol = "⚠" if msg.startswith("Warning") else "✗" print(f" {symbol} {msg}") if is_valid: if not args.quiet: print(f"Validation passed: {args.skill_path}") return 0 else: print(f"Validation failed: {args.skill_path}") return 1 if __name__ == '__main__': sys.exit(main())

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kesslerio/attio-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server