playbook/outfitter-agents/plugins/outfitter/scripts/validate-skill-frontmatter.ts

494 lines
13 KiB
TypeScript

#!/usr/bin/env bun
/**
* validate-skill-frontmatter.ts
*
* Validates SKILL.md frontmatter against the Agent Skills specification.
* Designed for use as a PreToolUse hook for Write/Edit operations on SKILL.md files.
*
* Exit codes:
* 0 - Valid/skip (proceed with tool use)
* 2 - Block (critical errors)
*
* Input: JSON on stdin from Claude Code PreToolUse hook
* {
* "tool_name": "Write" | "Edit",
* "tool_input": { "file_path": string, "content"?: string, "old_string"?: string, "new_string"?: string }
* }
*/
import { readFileSync } from "fs";
import { basename, dirname } from "path";
import { parse as parseYaml } from "yaml";
// Import context detection (if available)
const RESERVED_WORDS = ["anthropic", "claude"];
const NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
const MAX_LINES = 500;
const MAX_DESCRIPTION_LENGTH = 1024;
const MIN_DESCRIPTION_LENGTH = 10;
/**
* Result of skill frontmatter validation.
*/
interface ValidationResult {
/** Whether the frontmatter passes all required checks */
valid: boolean;
/** Blocking errors that must be fixed */
errors: string[];
/** Non-blocking warnings for improvement */
warnings: string[];
/** Parsed frontmatter if YAML was valid */
frontmatter: Record<string, unknown> | null;
}
/**
* Expected structure of SKILL.md frontmatter.
*/
interface SkillFrontmatter {
name?: string;
description?: string;
version?: string;
license?: string;
compatibility?: string;
metadata?: Record<string, unknown>;
"allowed-tools"?: string;
"user-invocable"?: boolean;
"disable-model-invocation"?: boolean;
context?: string;
agent?: string;
model?: string;
hooks?: Record<string, string>;
"argument-hint"?: string;
[key: string]: unknown;
}
// Base spec fields (cross-platform)
const BASE_FIELDS = new Set([
"name",
"description",
"version",
"license",
"compatibility",
"metadata",
]);
// Claude-specific extension fields
const CLAUDE_FIELDS = new Set([
"allowed-tools",
"user-invocable",
"disable-model-invocation",
"context",
"agent",
"model",
"hooks",
"argument-hint",
]);
/**
* Extracts YAML frontmatter from markdown content.
* @param content - Full markdown file content
* @returns Object with extracted YAML string and total line count
*/
function extractFrontmatter(content: string): {
yaml: string | null;
lineCount: number;
} {
const lines = content.split("\n");
const lineCount = lines.length;
if (!lines[0]?.trim().startsWith("---")) {
return { yaml: null, lineCount };
}
let endIndex = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === "---") {
endIndex = i;
break;
}
}
if (endIndex === -1) {
return { yaml: null, lineCount };
}
const yaml = lines.slice(1, endIndex).join("\n");
return { yaml, lineCount };
}
/**
* Checks if a file path indicates Claude Code context.
* @param path - File path to check
* @returns True if path matches Claude skill locations
*/
function detectClaudeContext(path: string): boolean {
const patterns = [
/\.claude-plugin\//,
/\.claude\/skills\//,
/\/\.claude\/skills\//,
];
return patterns.some((p) => p.test(path));
}
/**
* Validates SKILL.md content against the Agent Skills specification.
* @param content - Full SKILL.md file content
* @param filePath - Path to the file (used for context detection)
* @returns Validation result with errors, warnings, and parsed frontmatter
*/
function validate(
content: string,
filePath: string
): ValidationResult {
const result: ValidationResult = {
valid: true,
errors: [],
warnings: [],
frontmatter: null,
};
const { yaml, lineCount } = extractFrontmatter(content);
// Check for YAML syntax
if (yaml === null) {
result.valid = false;
result.errors.push(
"Missing or invalid frontmatter. SKILL.md must start with --- and have closing ---"
);
return result;
}
// Check for tabs (YAML requires spaces)
if (yaml.includes("\t")) {
result.valid = false;
result.errors.push(
"YAML contains tabs. Use spaces for indentation (YAML specification requirement)"
);
}
// Parse YAML
let frontmatter: SkillFrontmatter;
try {
frontmatter = parseYaml(yaml) as SkillFrontmatter;
result.frontmatter = frontmatter;
} catch (e) {
result.valid = false;
result.errors.push(
`YAML parse error: ${e instanceof Error ? e.message : String(e)}`
);
return result;
}
if (!frontmatter || typeof frontmatter !== "object") {
result.valid = false;
result.errors.push("Frontmatter must be a YAML object");
return result;
}
// Required fields
if (!frontmatter.name) {
result.valid = false;
result.errors.push("Missing required field: name");
}
if (!frontmatter.description) {
result.valid = false;
result.errors.push("Missing required field: description");
}
// Name validation
if (frontmatter.name) {
const name = frontmatter.name;
// Pattern check
if (!NAME_PATTERN.test(name)) {
result.valid = false;
result.errors.push(
`Invalid name format: '${name}'. Must be lowercase, numbers, hyphens only. Pattern: ${NAME_PATTERN}`
);
}
// Length check
if (name.length < 2 || name.length > 64) {
result.valid = false;
result.errors.push(
`Name length must be 2-64 characters. Got: ${name.length}`
);
}
// Reserved words
for (const reserved of RESERVED_WORDS) {
if (name.toLowerCase().includes(reserved)) {
result.valid = false;
result.errors.push(
`Name cannot contain reserved word: '${reserved}'. Found in: '${name}'`
);
}
}
// Directory match (if file path provided)
if (filePath && filePath !== "--stdin") {
const parentDir = basename(dirname(filePath));
if (parentDir !== name && parentDir !== "skills") {
result.warnings.push(
`Name '${name}' does not match parent directory '${parentDir}'. Consider renaming for consistency.`
);
}
}
}
// Description validation
if (frontmatter.description) {
const desc = frontmatter.description;
if (desc.length < MIN_DESCRIPTION_LENGTH) {
result.valid = false;
result.errors.push(
`Description too short: ${desc.length} chars. Minimum: ${MIN_DESCRIPTION_LENGTH}`
);
}
if (desc.length > MAX_DESCRIPTION_LENGTH) {
result.valid = false;
result.errors.push(
`Description too long: ${desc.length} chars. Maximum: ${MAX_DESCRIPTION_LENGTH}`
);
}
// Quality warnings
const hasWhat = /\b(extracts?|process(es)?|creates?|generates?|validates?|manages?|handles?|analyzes?|reviews?|debugs?|implements?)\b/i.test(desc);
const hasWhen = /\b(use when|when working|when the user|when you need)\b/i.test(desc);
if (!hasWhat) {
result.warnings.push(
"Description should include WHAT the skill does (verbs like 'extracts', 'processes', 'creates')"
);
}
if (!hasWhen) {
result.warnings.push(
"Description should include WHEN to use it (e.g., 'Use when working with...')"
);
}
}
// Check for custom fields at top level (should be under metadata)
const allKnownFields = new Set([...BASE_FIELDS, ...CLAUDE_FIELDS]);
for (const key of Object.keys(frontmatter)) {
if (!allKnownFields.has(key)) {
result.warnings.push(
`Custom field '${key}' should be nested under 'metadata'. Top-level custom fields may cause parsing issues.`
);
}
}
// Line count warning
if (lineCount > MAX_LINES) {
result.warnings.push(
`SKILL.md has ${lineCount} lines (recommended max: ${MAX_LINES}). Consider moving details to references/.`
);
}
// Claude context recommendations
const isClaudeContext = detectClaudeContext(filePath);
if (isClaudeContext) {
if (!frontmatter["allowed-tools"]) {
result.warnings.push(
"Claude context detected. Consider adding 'allowed-tools' for tool permissions."
);
}
}
return result;
}
/**
* Formats validation result for human-readable console output.
* @param result - Validation result to format
* @param path - Original file path for display
*/
function formatOutput(result: ValidationResult, path: string): void {
const status = result.valid
? result.warnings.length > 0
? "WARNINGS"
: "PASS"
: "FAIL";
console.log(`# Skill Validation: ${basename(dirname(path))}`);
console.log(`**Status**: ${status}`);
console.log(
`**Issues**: ${result.errors.length} errors, ${result.warnings.length} warnings`
);
if (result.errors.length > 0) {
console.log("\n## Errors (must fix)");
for (const error of result.errors) {
console.log(`- ${error}`);
}
}
if (result.warnings.length > 0) {
console.log("\n## Warnings (should fix)");
for (const warning of result.warnings) {
console.log(`- ${warning}`);
}
}
if (result.valid && result.warnings.length === 0) {
console.log("\n✓ All checks passed");
}
}
/**
* Hook input from Claude Code PreToolUse
*/
interface HookInput {
tool_name: string;
tool_input: {
file_path: string;
content?: string; // Write tool
old_string?: string; // Edit tool
new_string?: string; // Edit tool
};
}
/**
* Quick check: does this string look like it might affect frontmatter?
* Used to bail out fast on Edit operations that don't touch frontmatter.
*/
function mightAffectFrontmatter(str: string): boolean {
// Contains frontmatter delimiter
if (str.includes("---")) return true;
// Contains YAML-like key: value pattern
if (/^[a-z][-a-z]*:/m.test(str)) return true;
return false;
}
/**
* Read JSON from stdin with timeout protection
*/
async function readStdin(timeoutMs = 5000): Promise<string> {
const chunks: Buffer[] = [];
const timeout = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("stdin timeout")), timeoutMs);
});
const read = (async () => {
for await (const chunk of Bun.stdin.stream()) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf-8");
})();
return Promise.race([read, timeout]);
}
async function main() {
const args = process.argv.slice(2);
// CLI mode for manual testing
if (args.length > 0 && !args[0].startsWith("{")) {
if (args[0] === "--help" || args[0] === "-h") {
console.log(`Usage: validate-skill-frontmatter.ts [file]
PreToolUse hook for SKILL.md frontmatter validation.
Hook mode (default): Reads JSON from stdin
CLI mode: Pass file path as argument for manual testing
Exit codes:
0 - Valid/skip (proceed)
2 - Block (errors)
`);
process.exit(0);
}
// Manual file validation
const filePath = args[0];
let content: string;
try {
content = readFileSync(filePath, "utf-8");
} catch (e) {
console.error(`Error reading file: ${filePath}`);
process.exit(2);
}
const result = validate(content, filePath);
formatOutput(result, filePath);
process.exit(result.valid ? 0 : 2);
}
// Hook mode: read JSON from stdin
let input: HookInput;
try {
const raw = await readStdin();
input = JSON.parse(raw);
} catch (e) {
// Can't parse input → don't block, exit cleanly
process.exit(0);
}
const { tool_name, tool_input } = input;
const filePath = tool_input?.file_path ?? "";
// Fast bailout: Edit that doesn't touch frontmatter
if (tool_name === "Edit") {
const oldStr = tool_input.old_string ?? "";
const newStr = tool_input.new_string ?? "";
if (!mightAffectFrontmatter(oldStr) && !mightAffectFrontmatter(newStr)) {
// Edit doesn't touch frontmatter area → skip validation
process.exit(0);
}
// For Edit, we need current file + apply changes to validate
// Read current file, apply edit, validate result
let currentContent: string;
try {
currentContent = readFileSync(filePath, "utf-8");
} catch {
// File doesn't exist yet or can't read → skip
process.exit(0);
}
// Apply the edit
if (!currentContent.includes(oldStr)) {
// old_string not found → Claude will error anyway, don't block
process.exit(0);
}
const newContent = currentContent.replace(oldStr, newStr);
const result = validate(newContent, filePath);
if (!result.valid) {
formatOutput(result, filePath);
process.exit(2);
}
process.exit(0);
}
// Write tool: validate the new content directly
if (tool_name === "Write") {
const content = tool_input.content ?? "";
if (!content.trim()) {
// Empty content → don't block
process.exit(0);
}
const result = validate(content, filePath);
if (!result.valid) {
formatOutput(result, filePath);
process.exit(2);
}
process.exit(0);
}
// Unknown tool → don't block
process.exit(0);
}
main();