Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:51:34 +08:00
commit acde81dcfe
59 changed files with 22282 additions and 0 deletions

View File

@@ -0,0 +1,451 @@
#!/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 <path-to-skill-directory>
*/
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 <path-to-skill-directory>
${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 };