/** * Action: validate * * Validates a skill directory against the agentskills.io specification. * * Checks performed: * 1. SKILL.md exists * 2. YAML frontmatter delimiters (--- ... ---) are present * 3. 'name' field is present in frontmatter * 4. 'name' matches the directory name (basename) * 5. 'name' length ≤ 64 characters * 6. 'name' uses only a-z, 0-9, hyphens * 7. 'name' does not start with a hyphen * 8. 'name' does not end with a hyphen * 9. 'name' has no consecutive hyphens * 10. 'description' field is present and non-empty * 11. 'description' length ≤ 1024 characters * 12. SKILL.md body (lines after frontmatter) ≤ 500 lines (recommendation) * 13. If scripts/ exists, Taskfile.yml must be present inside it * * Output: YAML with success flag, counts, and per-check detail list. */ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join, basename } from "node:path"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface CheckResult { id: number; pass: boolean; level: "error" | "warning" | "info"; message: string; } interface DirectoryInfo { found: boolean; files?: number; "Taskfile.yml"?: boolean; } interface ValidateOutput { action: "validate"; "skill-dir": string; success: boolean; summary: { passed: number; failed: number; warnings: number; }; checks: CheckResult[]; optional: { "scripts/": DirectoryInfo; "references/": DirectoryInfo; "assets/": DirectoryInfo; }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function countFilesSync(dir: string): number { try { const entries = readdirSync(dir, { withFileTypes: true }); let count = 0; for (const e of entries) { if (e.isDirectory()) { count += countFilesSync(join(dir, e.name)); } else { count++; } } return count; } catch { return 0; } } // --------------------------------------------------------------------------- // Frontmatter parser — handles scalar and > block values // --------------------------------------------------------------------------- function parseFrontmatter(raw: string): Record { const result: Record = {}; const lines = raw.split("\n"); let i = 0; while (i < lines.length) { const line = lines[i]; const keyMatch = line.match(/^([a-zA-Z_-]+)\s*:\s*(.*)/); if (keyMatch) { const key = keyMatch[1]; const val = keyMatch[2].trim(); if (val === ">" || val === "|") { // Multi-line block scalar — collect indented continuation lines const parts: string[] = []; i++; while ( i < lines.length && (lines[i].startsWith(" ") || lines[i].startsWith("\t") || lines[i].trim() === "") ) { parts.push(lines[i].trim()); i++; } result[key] = parts.join(" ").trim(); continue; } else { result[key] = val; } } i++; } return result; } // --------------------------------------------------------------------------- // Optional directories inspector // --------------------------------------------------------------------------- function inspectOptional(skillDir: string): ValidateOutput["optional"] { function dirInfo(subdir: string): DirectoryInfo { const p = join(skillDir, subdir); if (!existsSync(p)) return { found: false }; return { found: true, files: countFilesSync(p) }; } const scriptsDir = join(skillDir, "scripts"); const scriptsInfo = dirInfo("scripts"); if (scriptsInfo.found) { scriptsInfo["Taskfile.yml"] = existsSync(join(scriptsDir, "Taskfile.yml")); } return { "scripts/": scriptsInfo, "references/": dirInfo("references"), "assets/": dirInfo("assets"), }; } // --------------------------------------------------------------------------- // Output builder // --------------------------------------------------------------------------- function buildOutput( skillDir: string, checks: CheckResult[], optional: ValidateOutput["optional"] ): Record { const passed = checks.filter((c) => c.pass).length; const failed = checks.filter((c) => !c.pass && c.level === "error").length; const warnings = checks.filter((c) => !c.pass && c.level === "warning").length; return { action: "validate", "skill-dir": skillDir, success: failed === 0, summary: { passed, failed, warnings }, checks, optional, }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function run(options: Record): Record { const skillDir = options["skill-dir"]; if (!skillDir) { return { action: "validate", success: false, error: "Missing required option: --skill-dir", }; } const checks: CheckResult[] = []; let checkId = 1; const ok = (message: string, level: CheckResult["level"] = "error"): CheckResult => ({ id: checkId++, pass: true, level, message }); const err = (message: string, level: CheckResult["level"] = "error"): CheckResult => ({ id: checkId++, pass: false, level, message }); // ── Check 1: SKILL.md exists ───────────────────────────────────────────── const skillMdPath = join(skillDir, "SKILL.md"); if (!existsSync(skillMdPath)) { checks.push(err("SKILL.md does not exist in skill directory")); return buildOutput(skillDir, checks, inspectOptional(skillDir)); } checks.push(ok("SKILL.md exists")); const raw = readFileSync(skillMdPath, "utf-8"); // ── Check 2: Frontmatter delimiters ────────────────────────────────────── const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); if (!fmMatch) { checks.push(err("YAML frontmatter delimiters (--- ... ---) not found")); return buildOutput(skillDir, checks, inspectOptional(skillDir)); } checks.push(ok("YAML frontmatter delimiters found")); const fm = parseFrontmatter(fmMatch[1]); const body = fmMatch[2]; // ── Checks 3–9: name field ─────────────────────────────────────────────── const name = fm["name"]; if (!name) { checks.push(err("'name' field is missing from frontmatter")); } else { checks.push(ok(`'name' field is present: ${name}`)); const dirName = basename(skillDir); checks.push( name === dirName ? ok("'name' matches directory name") : err(`'name' (${name}) does not match directory name (${dirName})`) ); checks.push( name.length <= 64 ? ok(`'name' length is ${name.length} (max 64)`) : err(`'name' length is ${name.length} (max 64)`) ); checks.push( /[^a-z0-9-]/.test(name) ? err("'name' contains characters outside a-z, 0-9, hyphen") : ok("'name' uses only allowed characters (a-z, 0-9, -)") ); checks.push( name.startsWith("-") ? err("'name' must not start with a hyphen") : ok("'name' does not start with a hyphen") ); checks.push( name.endsWith("-") ? err("'name' must not end with a hyphen") : ok("'name' does not end with a hyphen") ); checks.push( /--/.test(name) ? err("'name' contains consecutive hyphens") : ok("'name' has no consecutive hyphens") ); } // ── Checks 10–11: description field ────────────────────────────────────── const description = fm["description"]; if (!description || description.trim() === "") { checks.push(err("'description' field is missing or empty")); } else { const descLen = description.trim().length; checks.push(ok(`'description' is present (${descLen} chars)`)); checks.push( descLen <= 1024 ? ok(`'description' length is ${descLen} (max 1024)`) : err(`'description' length is ${descLen} (max 1024)`) ); } // ── Check 12: Body line count ───────────────────────────────────────────── const bodyLines = body.split("\n").length; checks.push( bodyLines <= 500 ? ok(`SKILL.md body is ${bodyLines} lines (max 500 recommended)`) : err(`SKILL.md body is ${bodyLines} lines (max 500 recommended)`, "warning") ); // ── Check 13: scripts/ must have Taskfile.yml ───────────────────────────── const scriptsDir = join(skillDir, "scripts"); if (existsSync(scriptsDir)) { checks.push( existsSync(join(scriptsDir, "Taskfile.yml")) ? ok("scripts/Taskfile.yml exists") : err("scripts/ exists but Taskfile.yml is missing — it is required") ); } return buildOutput(skillDir, checks, inspectOptional(skillDir)); }