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:
Ethan Beard
2026-02-02 15:27:07 -08:00
commit 67af4e2125
13 changed files with 920 additions and 0 deletions

23
src/fetcher.ts Normal file
View File

@@ -0,0 +1,23 @@
export async function fetchSkillContent(url: string): Promise<string> {
const response = await fetch(url, {
headers: {
'Accept': 'text/plain, text/markdown, */*',
'User-Agent': 'skill-md-validator/1.0.0',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
return response.text();
}
export function isUrl(input: string): boolean {
try {
const url = new URL(input);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}

81
src/index.ts Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { validateSkillInput } from './validator.js';
import { ValidationResult } from './types.js';
const program = new Command();
program
.name('skill-validate')
.description('Validate skill.md files against the agent skill specification')
.version('1.0.0')
.argument('<file>', 'Path to skill.md file or URL')
.option('-s, --strict', 'Treat warnings as errors')
.option('-j, --json', 'Output results as JSON')
.action(async (file: string, options: { strict?: boolean; json?: boolean }) => {
const result = await validateSkillInput(file, { strict: options.strict });
// In strict mode, warnings become errors
if (options.strict && result.warnings.length > 0) {
result.errors.push(...result.warnings);
result.warnings = [];
result.valid = result.errors.length === 0;
}
if (options.json) {
outputJson(result);
} else {
outputHuman(result);
}
// Exit codes: 0 = valid, 1 = invalid, 2 = file not found/fetch failed
if (!result.valid) {
const isFetchOrFileError = result.errors.some(
(e) => e.code === 'file-not-found' || e.code === 'fetch-failed'
);
process.exit(isFetchOrFileError ? 2 : 1);
}
});
function outputJson(result: ValidationResult): void {
console.log(JSON.stringify(result, null, 2));
}
function outputHuman(result: ValidationResult): void {
if (result.valid) {
console.log(`${result.file} is valid\n`);
} else {
console.log(`${result.file} is invalid\n`);
}
if (result.parsed) {
console.log(` name: ${result.parsed.name}`);
console.log(` version: ${result.parsed.version}`);
console.log(` description: ${truncate(result.parsed.description, 50)}`);
console.log();
}
if (result.errors.length > 0) {
console.log(' Errors:');
for (const error of result.errors) {
console.log(`${error.message}`);
}
console.log();
}
if (result.warnings.length > 0) {
console.log(' Warnings:');
for (const warning of result.warnings) {
console.log(`${warning.message}`);
}
console.log();
}
}
function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
program.parse();

191
src/rules.ts Normal file
View File

@@ -0,0 +1,191 @@
import * as semver from 'semver';
import { ValidationIssue, SkillMetadata } from './types.js';
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
const MIN_DESCRIPTION_LENGTH = 10;
const MAX_DESCRIPTION_LENGTH = 200;
const WARN_DESCRIPTION_LENGTH = 100;
export function validateName(data: Record<string, unknown>): ValidationIssue | null {
if (!data.name) {
return { code: 'missing-name', message: "Missing required field 'name'" };
}
if (typeof data.name !== 'string') {
return { code: 'invalid-name', message: "'name' must be a string" };
}
if (!NAME_PATTERN.test(data.name)) {
return {
code: 'invalid-name-format',
message: "'name' must be lowercase alphanumeric with hyphens (e.g., 'my-skill')",
};
}
return null;
}
export function validateVersion(data: Record<string, unknown>): ValidationIssue | null {
if (!data.version) {
return { code: 'missing-version', message: "Missing required field 'version'" };
}
if (typeof data.version !== 'string') {
return { code: 'invalid-version', message: "'version' must be a string" };
}
if (!semver.valid(data.version)) {
return {
code: 'invalid-version-format',
message: `'version' must be valid semver (e.g., '1.0.0'), got '${data.version}'`,
};
}
return null;
}
export function validateDescription(data: Record<string, unknown>): {
error: ValidationIssue | null;
warning: ValidationIssue | null;
} {
if (!data.description) {
return {
error: { code: 'missing-description', message: "Missing required field 'description'" },
warning: null,
};
}
if (typeof data.description !== 'string') {
return {
error: { code: 'invalid-description', message: "'description' must be a string" },
warning: null,
};
}
if (data.description.length < MIN_DESCRIPTION_LENGTH) {
return {
error: {
code: 'description-too-short',
message: `'description' must be at least ${MIN_DESCRIPTION_LENGTH} characters`,
},
warning: null,
};
}
if (data.description.length > MAX_DESCRIPTION_LENGTH) {
return {
error: {
code: 'description-too-long',
message: `'description' must be at most ${MAX_DESCRIPTION_LENGTH} characters`,
},
warning: null,
};
}
if (data.description.length > WARN_DESCRIPTION_LENGTH) {
return {
error: null,
warning: {
code: 'description-truncation-risk',
message: `'description' over ${WARN_DESCRIPTION_LENGTH} chars may be truncated in some displays`,
},
};
}
return { error: null, warning: null };
}
export function validateUrl(value: unknown, field: string): ValidationIssue | null {
if (value === undefined) {
return null;
}
if (typeof value !== 'string') {
return { code: `invalid-${field}`, message: `'${field}' must be a string` };
}
try {
new URL(value);
return null;
} catch {
return { code: `invalid-${field}-url`, message: `'${field}' must be a valid URL` };
}
}
export function validateHomepage(data: Record<string, unknown>): {
error: ValidationIssue | null;
warning: ValidationIssue | null;
} {
const error = validateUrl(data.homepage, 'homepage');
if (error) {
return { error, warning: null };
}
if (!data.homepage) {
return {
error: null,
warning: { code: 'missing-homepage', message: "Missing 'homepage' field" },
};
}
return { error: null, warning: null };
}
export function validateRepository(data: Record<string, unknown>): {
error: ValidationIssue | null;
warning: ValidationIssue | null;
} {
const error = validateUrl(data.repository, 'repository');
if (error) {
return { error, warning: null };
}
if (!data.repository) {
return {
error: null,
warning: { code: 'missing-repository', message: "Missing 'repository' field" },
};
}
return { error: null, warning: null };
}
export function validateApiBase(data: Record<string, unknown>): ValidationIssue | null {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (!metadata?.api_base) {
return null;
}
return validateUrl(metadata.api_base, 'metadata.api_base');
}
export function validateContent(content: string): {
errors: ValidationIssue[];
warnings: ValidationIssue[];
} {
const errors: ValidationIssue[] = [];
const warnings: ValidationIssue[] = [];
const trimmed = content.trim();
if (!trimmed) {
errors.push({ code: 'empty-content', message: 'Body content after frontmatter is empty' });
return { errors, warnings };
}
if (!/^#+\s+.+/m.test(content)) {
warnings.push({ code: 'no-heading', message: 'No markdown heading found in body' });
}
if (!/```[\s\S]*?```/.test(content)) {
warnings.push({ code: 'no-code-examples', message: 'No code examples found in body' });
}
if (!/^##\s+(Install|Installation)/im.test(content)) {
warnings.push({ code: 'no-install-section', message: 'No "## Install" section found' });
}
if (!/^##\s+Usage/im.test(content)) {
warnings.push({ code: 'no-usage-section', message: 'No "## Usage" section found' });
}
return { errors, warnings };
}
export function extractMetadata(data: Record<string, unknown>): SkillMetadata | null {
if (!data.name || !data.version || !data.description) {
return null;
}
return {
name: data.name as string,
version: data.version as string,
description: data.description as string,
homepage: data.homepage as string | undefined,
repository: data.repository as string | undefined,
author: data.author as string | undefined,
license: data.license as string | undefined,
metadata: data.metadata as Record<string, unknown> | undefined,
};
}

28
src/types.ts Normal file
View File

@@ -0,0 +1,28 @@
export interface ValidationIssue {
code: string;
message: string;
line?: number;
}
export interface SkillMetadata {
name: string;
version: string;
description: string;
homepage?: string;
repository?: string;
author?: string;
license?: string;
metadata?: Record<string, unknown>;
}
export interface ValidationResult {
valid: boolean;
file: string;
errors: ValidationIssue[];
warnings: ValidationIssue[];
parsed: SkillMetadata | null;
}
export interface ValidatorOptions {
strict?: boolean;
}

139
src/validator.ts Normal file
View 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);
}