commit 67af4e2125cfcbfbec552628388c048b7d2cecdf Author: Ethan Beard Date: Mon Feb 2 15:27:07 2026 -0800 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 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..86e884b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fef5791 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +.claude/ +coverage/ +.nyc_output/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c13f991 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..59d250f --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# skill-md-validator + +Validate `skill.md` files against the agent skill specification. + +## What is skill.md? + +Agents share capabilities via `skill.md` files — markdown documents with YAML frontmatter describing what a skill does, how to use it, and metadata for discovery. This tool validates those files to catch errors before publishing. + +## Install + +```bash +npm install -g skill-md-validator +``` + +## Usage + +```bash +# Validate local file +skill-validate ./skill.md + +# Validate remote URL +skill-validate https://api.example.com/skill.md + +# Strict mode (warnings become errors) +skill-validate --strict ./skill.md + +# Output JSON (for programmatic use) +skill-validate --json ./skill.md +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | Valid (may have warnings) | +| `1` | Invalid (has errors) | +| `2` | File not found / fetch failed | + +## Validation Rules + +### Required Fields + +| Field | Format | Description | +|-------|--------|-------------| +| `name` | `^[a-z][a-z0-9-]*$` | Lowercase alphanumeric with hyphens | +| `version` | semver | e.g., `1.0.0` | +| `description` | 10-200 chars | One-line description | + +### Optional Fields + +| Field | Format | Description | +|-------|--------|-------------| +| `homepage` | URL | Project homepage | +| `repository` | URL | Source repository | +| `author` | string | Author name or handle | +| `license` | string | SPDX license identifier | +| `metadata` | object | Arbitrary key-value pairs | + +### Content Checks + +- Body after frontmatter must not be empty +- Should have at least one markdown heading +- Warns if missing code examples +- Warns if missing `## Install` or `## Usage` sections + +## Example Output + +``` +✓ skill.md is valid + + name: my-skill + version: 1.2.0 + description: Does something useful for agents... + + Warnings: + ⚠ Missing 'repository' field + ⚠ No "## Install" section found +``` + +## JSON Output + +```json +{ + "valid": true, + "file": "./skill.md", + "errors": [], + "warnings": [ + {"code": "missing-repository", "message": "Missing 'repository' field"} + ], + "parsed": { + "name": "my-skill", + "version": "1.2.0", + "description": "Does something useful for agents..." + } +} +``` + +## Development + +```bash +# Install dependencies +npm install + +# Build +npm run build + +# Run locally +npm start ./skill.md +``` + +## License + +MIT diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4bdf43f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,196 @@ +{ + "name": "skill-md-validator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skill-md-validator", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "gray-matter": "^4.0.3", + "semver": "^7.6.3" + }, + "bin": { + "skill-validate": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/semver": "^7.5.8", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4787934 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "skill-md-validator", + "version": "1.0.0", + "description": "Validate skill.md files against the agent skill specification", + "main": "dist/index.js", + "bin": { + "skill-validate": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsc && node dist/index.js", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "skill", + "markdown", + "validator", + "agent", + "ai" + ], + "author": "", + "license": "MIT", + "dependencies": { + "commander": "^12.1.0", + "gray-matter": "^4.0.3", + "semver": "^7.6.3" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/semver": "^7.5.8", + "typescript": "^5.5.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/severith/skill-md-validator.git" + }, + "homepage": "https://github.com/severith/skill-md-validator" +} diff --git a/skill.md b/skill.md new file mode 100644 index 0000000..928fc67 --- /dev/null +++ b/skill.md @@ -0,0 +1,41 @@ +--- +name: skill-md-validator +version: 1.0.0 +description: Validate skill.md files against the agent skill specification +homepage: https://github.com/severith/skill-md-validator +repository: https://github.com/severith/skill-md-validator.git +license: MIT +metadata: + emoji: ✅ + category: tooling +--- + +# skill-md-validator + +Validate your skill.md files before publishing. + +## Install + +```bash +npm install -g skill-md-validator +``` + +## Usage + +```bash +# Local file +skill-validate ./skill.md + +# Remote URL +skill-validate https://example.com/skill.md + +# Strict mode (warnings become errors) +skill-validate --strict ./skill.md + +# JSON output +skill-validate --json ./skill.md +``` + +## Why + +skill.md files are how agents discover and understand capabilities. A malformed file means agents can't find you or misunderstand what you offer. Validate before you ship. diff --git a/src/fetcher.ts b/src/fetcher.ts new file mode 100644 index 0000000..25b3555 --- /dev/null +++ b/src/fetcher.ts @@ -0,0 +1,23 @@ +export async function fetchSkillContent(url: string): Promise { + 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; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d6eade9 --- /dev/null +++ b/src/index.ts @@ -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('', '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(); diff --git a/src/rules.ts b/src/rules.ts new file mode 100644 index 0000000..7355168 --- /dev/null +++ b/src/rules.ts @@ -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): 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): 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): { + 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): { + 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): { + 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): ValidationIssue | null { + const metadata = data.metadata as Record | 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): 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 | undefined, + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c663daf --- /dev/null +++ b/src/types.ts @@ -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; +} + +export interface ValidationResult { + valid: boolean; + file: string; + errors: ValidationIssue[]; + warnings: ValidationIssue[]; + parsed: SkillMetadata | null; +} + +export interface ValidatorOptions { + strict?: boolean; +} diff --git a/src/validator.ts b/src/validator.ts new file mode 100644 index 0000000..c07ecb6 --- /dev/null +++ b/src/validator.ts @@ -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; + 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; + + // 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 { + 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 { + 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 { + if (isUrl(input)) { + return validateSkillUrl(input, options); + } + return validateSkillFile(input, options); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1951778 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}