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