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:
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -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
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.claude/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
113
README.md
Normal file
113
README.md
Normal file
@@ -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
|
||||
196
package-lock.json
generated
Normal file
196
package-lock.json
generated
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
45
package.json
Normal file
45
package.json
Normal file
@@ -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"
|
||||
}
|
||||
41
skill.md
Normal file
41
skill.md
Normal file
@@ -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.
|
||||
23
src/fetcher.ts
Normal file
23
src/fetcher.ts
Normal 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
81
src/index.ts
Normal 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
191
src/rules.ts
Normal 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
28
src/types.ts
Normal 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
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);
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user