#!/usr/bin/env node /** * Claude Code Skill Validator * * Validates skill structure according to Claude Code documentation: * - YAML frontmatter format and required fields * - File structure and /reference/ folder requirements * - Token budget estimates for metadata and body * - Path format validation (forward slashes) * - Description specificity checks * * Usage: node validate-skill.js */ const fs = require('fs'); const path = require('path'); const yaml = require('yaml'); // ANSI color codes for terminal output const colors = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', }; class SkillValidator { constructor(skillPath) { this.skillPath = path.resolve(skillPath); this.errors = []; this.warnings = []; this.info = []; this.skillMdPath = path.join(this.skillPath, 'SKILL.md'); } // Color helper methods error(msg) { this.errors.push(msg); console.error(`${colors.red}✗ ERROR: ${msg}${colors.reset}`); } warn(msg) { this.warnings.push(msg); console.warn(`${colors.yellow}⚠ WARNING: ${msg}${colors.reset}`); } success(msg) { console.log(`${colors.green}✓ ${msg}${colors.reset}`); } log(msg) { this.info.push(msg); console.log(`${colors.cyan}ℹ ${msg}${colors.reset}`); } // Token estimation (rough approximation: ~4 chars per token) estimateTokens(text) { return Math.ceil(text.length / 4); } // Validate skill directory exists validateDirectory() { if (!fs.existsSync(this.skillPath)) { this.error(`Skill directory not found: ${this.skillPath}`); return false; } if (!fs.statSync(this.skillPath).isDirectory()) { this.error(`Path is not a directory: ${this.skillPath}`); return false; } this.success('Skill directory exists'); return true; } // Validate SKILL.md exists validateSkillMdExists() { if (!fs.existsSync(this.skillMdPath)) { this.error('SKILL.md not found in skill directory'); return false; } this.success('SKILL.md exists'); return true; } // Parse and validate YAML frontmatter validateFrontmatter(content) { // Check for frontmatter delimiters if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) { this.error('SKILL.md must start with "---" on line 1'); return null; } const lines = content.split('\n'); let closingIndex = -1; for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { closingIndex = i; break; } } if (closingIndex === -1) { this.error('SKILL.md frontmatter missing closing "---"'); return null; } this.success('Valid YAML frontmatter delimiters found'); // Extract and parse YAML const yamlContent = lines.slice(1, closingIndex).join('\n'); let metadata; try { metadata = yaml.parse(yamlContent); } catch (e) { this.error(`Invalid YAML syntax: ${e.message}`); return null; } this.success('YAML syntax is valid'); return { metadata, bodyStartLine: closingIndex + 1 }; } // Validate required frontmatter fields validateRequiredFields(metadata) { const required = ['name', 'description']; let allPresent = true; required.forEach(field => { if (!metadata[field]) { this.error(`Required frontmatter field missing: "${field}"`); allPresent = false; } else { this.success(`Required field present: ${field}`); } }); return allPresent; } // Validate description specificity validateDescription(description) { if (!description) return false; const tokens = this.estimateTokens(description); this.log(`Description length: ${description.length} chars (~${tokens} tokens)`); // Check for vague terms const vagueTerms = ['helps', 'assists', 'provides', 'enables', 'allows']; const foundVague = vagueTerms.filter(term => description.toLowerCase().includes(term) ); if (foundVague.length > 2) { this.warn(`Description may be too vague. Contains: ${foundVague.join(', ')}`); this.warn('Consider adding specific triggers or use cases'); } // Check for "when to use" indicators const hasWhenIndicators = /when|use when|trigger|for \w+ing/i.test(description); if (!hasWhenIndicators) { this.warn('Description should include "when to use" indicators'); } else { this.success('Description includes usage triggers'); } // Check length if (tokens > 150) { this.warn(`Description is long (~${tokens} tokens). Consider keeping under 150 tokens`); } else if (tokens < 20) { this.warn(`Description is short (~${tokens} tokens). Add more specificity about when to use`); } else { this.success(`Description length is good (~${tokens} tokens)`); } return true; } // Validate allowed-tools field if present validateAllowedTools(metadata) { if (!metadata['allowed-tools']) { this.log('No allowed-tools restriction (skill has access to all tools)'); return true; } const allowedTools = metadata['allowed-tools']; if (typeof allowedTools === 'string') { const tools = allowedTools.split(',').map(t => t.trim()); this.success(`Tool restrictions defined: ${tools.join(', ')}`); } else { this.warn('allowed-tools should be a comma-separated string'); } return true; } // Validate markdown body validateBody(content, bodyStartLine) { const lines = content.split('\n'); const bodyContent = lines.slice(bodyStartLine).join('\n').trim(); if (!bodyContent) { this.error('SKILL.md body is empty'); return false; } const tokens = this.estimateTokens(bodyContent); this.log(`Body length: ${bodyContent.length} chars (~${tokens} tokens)`); if (tokens > 5000) { this.error(`Body exceeds 5k tokens (~${tokens} tokens). Move content to /reference/ files`); } else if (tokens > 2000) { this.warn(`Body is over 2k tokens (~${tokens} tokens). Consider moving details to /reference/`); } else { this.success(`Body token count is optimal (~${tokens} tokens, under 2k recommended)`); } return true; } // Validate file structure validateFileStructure() { const files = fs.readdirSync(this.skillPath); // Check for markdown files in root (only SKILL.md allowed) const rootMdFiles = files.filter(f => f.endsWith('.md') && f !== 'SKILL.md' ); if (rootMdFiles.length > 0) { this.error(`Markdown files found in root (should be in /reference/): ${rootMdFiles.join(', ')}`); this.error('Move these files to /reference/ folder'); } else { this.success('No stray markdown files in root directory'); } // Check for /reference/ folder const referencePath = path.join(this.skillPath, 'reference'); if (fs.existsSync(referencePath) && fs.statSync(referencePath).isDirectory()) { const referenceFiles = fs.readdirSync(referencePath); const mdFiles = referenceFiles.filter(f => f.endsWith('.md')); if (mdFiles.length > 0) { this.success(`/reference/ folder exists with ${mdFiles.length} file(s): ${mdFiles.join(', ')}`); } else { this.warn('/reference/ folder exists but contains no markdown files'); } } else { this.warn('/reference/ folder not found (optional, but recommended for detailed docs)'); } // Check for scripts folder const scriptsPath = path.join(this.skillPath, 'scripts'); if (fs.existsSync(scriptsPath)) { const scriptFiles = fs.readdirSync(scriptsPath); this.log(`/scripts/ folder exists with ${scriptFiles.length} file(s)`); } return rootMdFiles.length === 0; } // Validate paths in content (should use forward slashes) validatePaths(content) { const backslashPaths = content.match(/\]\([^)]*\\/g); if (backslashPaths && backslashPaths.length > 0) { this.warn('Found paths with backslashes. Use forward slashes for cross-platform compatibility'); backslashPaths.forEach(match => { this.warn(` Found: ${match}`); }); } else { this.success('All paths use forward slashes'); } // Check for reference links const referenceLinks = content.match(/\[.*?\]\(\.\/reference\/.*?\.md\)/g); if (referenceLinks && referenceLinks.length > 0) { this.success(`Found ${referenceLinks.length} links to /reference/ files`); // Validate that referenced files exist referenceLinks.forEach(link => { const match = link.match(/\(\.\/reference\/(.*?\.md)\)/); if (match) { const filename = match[1]; const filepath = path.join(this.skillPath, 'reference', filename); if (!fs.existsSync(filepath)) { this.error(`Referenced file does not exist: ./reference/${filename}`); } else { this.success(` ✓ ./reference/${filename} exists`); } } }); } return true; } // Validate metadata token budget (~100 tokens) validateMetadataTokens(metadata) { const metadataStr = yaml.stringify(metadata); const tokens = this.estimateTokens(metadataStr); this.log(`Metadata token estimate: ~${tokens} tokens`); if (tokens > 150) { this.warn(`Metadata is large (~${tokens} tokens). Aim for ~100 tokens`); } else if (tokens > 100) { this.log('Metadata is slightly over 100 tokens but acceptable'); } else { this.success(`Metadata token budget is optimal (~${tokens} tokens)`); } return true; } // Run all validations async validate() { console.log(`\n${colors.blue}╔════════════════════════════════════════════╗${colors.reset}`); console.log(`${colors.blue}║ Claude Code Skill Validator v1.0.0 ║${colors.reset}`); console.log(`${colors.blue}╚════════════════════════════════════════════╝${colors.reset}\n`); console.log(`Validating skill at: ${colors.cyan}${this.skillPath}${colors.reset}\n`); // Step 1: Directory validation console.log(`${colors.blue}[1/8]${colors.reset} Validating directory...`); if (!this.validateDirectory()) return this.generateReport(); // Step 2: SKILL.md existence console.log(`\n${colors.blue}[2/8]${colors.reset} Checking for SKILL.md...`); if (!this.validateSkillMdExists()) return this.generateReport(); // Read SKILL.md const content = fs.readFileSync(this.skillMdPath, 'utf-8'); // Step 3: Frontmatter parsing console.log(`\n${colors.blue}[3/8]${colors.reset} Validating YAML frontmatter...`); const parsed = this.validateFrontmatter(content); if (!parsed) return this.generateReport(); const { metadata, bodyStartLine } = parsed; // Step 4: Required fields console.log(`\n${colors.blue}[4/8]${colors.reset} Checking required fields...`); this.validateRequiredFields(metadata); // Step 5: Description quality console.log(`\n${colors.blue}[5/8]${colors.reset} Validating description...`); this.validateDescription(metadata.description); // Step 6: Optional fields console.log(`\n${colors.blue}[6/8]${colors.reset} Checking optional fields...`); this.validateAllowedTools(metadata); this.validateMetadataTokens(metadata); // Step 7: Body validation console.log(`\n${colors.blue}[7/8]${colors.reset} Validating body content...`); this.validateBody(content, bodyStartLine); // Step 8: File structure console.log(`\n${colors.blue}[8/8]${colors.reset} Validating file structure...`); this.validateFileStructure(); this.validatePaths(content); return this.generateReport(); } // Generate final report generateReport() { console.log(`\n${colors.blue}═══════════════════════════════════════════${colors.reset}`); console.log(`${colors.blue}VALIDATION REPORT${colors.reset}`); console.log(`${colors.blue}═══════════════════════════════════════════${colors.reset}\n`); if (this.errors.length === 0 && this.warnings.length === 0) { console.log(`${colors.green}✓ All validations passed! Skill structure is excellent.${colors.reset}\n`); return 0; } if (this.errors.length > 0) { console.log(`${colors.red}Errors: ${this.errors.length}${colors.reset}`); this.errors.forEach((err, i) => { console.log(` ${i + 1}. ${err}`); }); console.log(''); } if (this.warnings.length > 0) { console.log(`${colors.yellow}Warnings: ${this.warnings.length}${colors.reset}`); this.warnings.forEach((warn, i) => { console.log(` ${i + 1}. ${warn}`); }); console.log(''); } if (this.errors.length > 0) { console.log(`${colors.red}✗ Validation failed. Please fix errors before deploying.${colors.reset}\n`); return 1; } else { console.log(`${colors.yellow}⚠ Validation passed with warnings. Consider addressing warnings for best practices.${colors.reset}\n`); return 0; } } } // CLI entry point async function main() { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help') || args.includes('-h')) { console.log(` ${colors.cyan}Claude Code Skill Validator${colors.reset} ${colors.blue}Usage:${colors.reset} node validate-skill.js ${colors.blue}Examples:${colors.reset} node validate-skill.js ./my-skill node validate-skill.js ~/.claude/skills/my-skill node validate-skill.js . (validate current directory) ${colors.blue}What it validates:${colors.reset} ✓ YAML frontmatter format (opening/closing ---) ✓ Required fields: name, description ✓ Description specificity and triggers ✓ Token budgets (metadata ~100, body <2k recommended) ✓ File structure (/reference/ folder for docs) ✓ No stray .md files in root (except SKILL.md) ✓ Path format (forward slashes) ✓ Referenced files exist `); process.exit(0); } const skillPath = args[0]; const validator = new SkillValidator(skillPath); const exitCode = await validator.validate(); process.exit(exitCode); } // Run if called directly if (require.main === module) { main().catch(err => { console.error(`${colors.red}Fatal error: ${err.message}${colors.reset}`); process.exit(1); }); } module.exports = { SkillValidator };