Files
skill-md-validator/src/validator.ts
Ethan Beard 67af4e2125 Initial commit
CLI tool to validate skill.md files against the agent skill specification.
Supports local files and remote URLs, with human-readable and JSON output.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:27:07 -08:00

140 lines
3.7 KiB
TypeScript

import matter from 'gray-matter';
import { ValidationResult, ValidationIssue, ValidatorOptions } from './types.js';
import {
validateName,
validateVersion,
validateDescription,
validateHomepage,
validateRepository,
validateApiBase,
validateContent,
extractMetadata,
} from './rules.js';
import { fetchSkillContent, isUrl } from './fetcher.js';
import * as fs from 'fs';
import * as path from 'path';
export function validateSkill(
content: string,
filename: string,
_options: ValidatorOptions = {}
): ValidationResult {
const errors: ValidationIssue[] = [];
const warnings: ValidationIssue[] = [];
// Check for frontmatter markers
if (!content.startsWith('---')) {
errors.push({
code: 'missing-frontmatter',
message: 'File must start with YAML frontmatter (---)',
});
return { valid: false, file: filename, errors, warnings, parsed: null };
}
// Parse frontmatter
let parsed: matter.GrayMatterFile<string>;
try {
parsed = matter(content);
} catch (err) {
errors.push({
code: 'invalid-frontmatter',
message: `Failed to parse frontmatter: ${err instanceof Error ? err.message : 'Unknown error'}`,
});
return { valid: false, file: filename, errors, warnings, parsed: null };
}
const data = parsed.data as Record<string, unknown>;
// Validate required fields
const nameError = validateName(data);
if (nameError) errors.push(nameError);
const versionError = validateVersion(data);
if (versionError) errors.push(versionError);
const descResult = validateDescription(data);
if (descResult.error) errors.push(descResult.error);
if (descResult.warning) warnings.push(descResult.warning);
// Validate optional URL fields
const homepageResult = validateHomepage(data);
if (homepageResult.error) errors.push(homepageResult.error);
if (homepageResult.warning) warnings.push(homepageResult.warning);
const repoResult = validateRepository(data);
if (repoResult.error) errors.push(repoResult.error);
if (repoResult.warning) warnings.push(repoResult.warning);
const apiBaseError = validateApiBase(data);
if (apiBaseError) errors.push(apiBaseError);
// Validate content body
const contentResult = validateContent(parsed.content);
errors.push(...contentResult.errors);
warnings.push(...contentResult.warnings);
// Extract metadata for result
const metadata = extractMetadata(data);
return {
valid: errors.length === 0,
file: filename,
errors,
warnings,
parsed: metadata,
};
}
export async function validateSkillFile(
filepath: string,
options: ValidatorOptions = {}
): Promise<ValidationResult> {
const absolutePath = path.resolve(filepath);
if (!fs.existsSync(absolutePath)) {
return {
valid: false,
file: filepath,
errors: [{ code: 'file-not-found', message: `File not found: ${filepath}` }],
warnings: [],
parsed: null,
};
}
const content = fs.readFileSync(absolutePath, 'utf-8');
return validateSkill(content, filepath, options);
}
export async function validateSkillUrl(
url: string,
options: ValidatorOptions = {}
): Promise<ValidationResult> {
try {
const content = await fetchSkillContent(url);
return validateSkill(content, url, options);
} catch (err) {
return {
valid: false,
file: url,
errors: [
{
code: 'fetch-failed',
message: err instanceof Error ? err.message : 'Failed to fetch URL',
},
],
warnings: [],
parsed: null,
};
}
}
export async function validateSkillInput(
input: string,
options: ValidatorOptions = {}
): Promise<ValidationResult> {
if (isUrl(input)) {
return validateSkillUrl(input, options);
}
return validateSkillFile(input, options);
}