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>
This commit is contained in:
139
src/validator.ts
Normal file
139
src/validator.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user