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>
140 lines
3.7 KiB
TypeScript
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);
|
|
}
|