SkillSeal
Test your Agent Skills before your agents do.
SKILL.md files can be syntactically valid and still be bad: a vague
description that never routes correctly, an oversized file that eats context,
a curl | sh buried in a code block, a hardcoded /Users/you/... path that
only works on your machine. None of that shows up until an agent picks the
wrong skill, or picks the right one and runs something it shouldn't.
SkillSeal is a local-first, offline-first CLI that lints, scores, and
routing-tests SKILL.md files, so you catch that before an agent does. It's
deliberately scoped to what's useful today: static linting across four
categories, deterministic (LLM-optional) routing tests, and CI-friendly exit
codes and JSON output. No dashboard, no registry, no cloud — see
Roadmap for what's intentionally not here yet.
Installation
Requires Python 3.12+. Install from PyPI with uv, pipx, or pip:
uv tool install skillseal
# or: pipx install skillseal
# or: pip install skillseal
No install at all, one-off run:
uvx skillseal check ./skills
For local development, clone the repo instead:
git clone https://github.com/pespinel/skillseal
cd skillseal
uv sync
Quickstart
skillseal check examples
skillseal test examples
skillseal conflicts examples/conflicting-skills
(From a repo clone without installing, prefix these with uv run.)
All three commands accept a path to a single SKILL.md file, a single skill
directory, or a directory containing many skills (searched recursively).
Commands
skillseal check <path>
Runs every rule (SPECIFICATION, QUALITY, SECURITY, PORTABILITY) against each discovered skill and prints a per-skill report with a 0-100 score.
| Flag | Default | Meaning |
|---|---|---|
--format terminal|json |
terminal |
Output format. |
--fail-on warning|error |
error |
Minimum finding severity that fails the gate. |
--ignore PREFIX |
none | Suppress findings whose id starts with PREFIX. Repeatable. |
skillseal test <path>
Runs the routing test cases declared in each skill's skillseal.yaml
against a RoutingEvaluator, and reports accuracy against should_trigger
and should_not_trigger prompts. Skills without a skillseal.yaml are
skipped, not failed.
| Flag | Default | Meaning |
|---|---|---|
--threshold <float> |
0.9* |
Minimum accuracy per skill to pass the gate. |
--format terminal|json |
terminal |
Output format. |
--provider heuristic|llm |
heuristic |
Evaluator to use (see below). |
* Or whatever routing_threshold is set to in skillseal.toml — see
Configuration below.
skillseal conflicts <path>
Scans every skill under path together rather than one at a time, and
flags two things check/test can't see in isolation:
- Duplicate names — two skills declaring the same frontmatter
name(usually a copy-paste leftover). - Routing overlap — two skills whose vocabulary (name + description +
keywords:) is similar enough that an agent likely can't reliably tell them apart, using the same term-matchingHeuristicRoutingEvaluatoruses for routing tests, compared pairwise via Jaccard similarity.
| Flag | Default | Meaning |
|---|---|---|
--threshold <float> |
0.5* |
Minimum vocabulary similarity (Jaccard) to flag as overlap. |
--against <path> |
none | Check path against this broader corpus instead of all-pairs within path. |
--format terminal|json |
terminal |
Output format. |
* Or conflict_threshold in skillseal.toml — see
Configuration below.
Without --against, every skill under path is compared against every
other. With it, only pairs involving at least one skill from path are
considered — the PR-gate use case: "does the skill I just added or changed
conflict with anything in the existing repo?" without re-auditing the whole
existing corpus against itself on every run:
skillseal conflicts ./skills/my-new-skill --against ./skills
A skill can opt specific others out of routing-overlap comparison — useful
for deliberately similar variants — via conflict_ignore in its frontmatter
(matched by name or by a path substring; this only suppresses the routing-
overlap check, not duplicate-name detection, since a real name collision is
rarely something you actually want to allow):
---
name: my-skill
description: Use this when ...
conflict_ignore:
- legacy-skill
---
$ uv run skillseal conflicts examples/conflicting-skills
Duplicate names
✗ "alpha-reviewer" used by 2 skills:
- examples/conflicting-skills/alpha-reviewer/SKILL.md
- examples/conflicting-skills/alpha-reviewer-2/SKILL.md
Routing overlap
✗ "alpha-reviewer" and "beta-reviewer"
examples/conflicting-skills/alpha-reviewer/SKILL.md
examples/conflicting-skills/beta-reviewer/SKILL.md
Similarity: 53% (threshold: 50%)
Shared terms: bug, code, potential, quality, review, reviewer, skill, style
Exit codes (all commands)
| Code | Meaning |
|---|---|
0 |
Clean, or the gate passed. |
1 |
Gate failed (--fail-on / --threshold not met, or a conflict was found). |
2 |
Usage or config error — bad path, no SKILL.md found, malformed skillseal.yaml. |
A typo'd path can never silently report success: exit 2 is reserved for
"SkillSeal couldn't even run the check," distinct from "the check ran and
found problems" (exit 1).
Example output
$ uv run skillseal check examples/bad-skill
examples/bad-skill/SKILL.md
Specification WARN
Quality WARN
Security FAIL
Portability WARN
Issues
WARN name-directory-mismatch
Frontmatter 'name' does not match the skill's directory name.
name: 'helper', directory: 'bad-skill'
WARN description-too-vague
Description may not provide enough information for reliable routing.
matched vague phrase: "helps with tasks"
FAIL rm-rf
Potential risk: recursive force-delete command found in a code block.
1 occurrence(s), e.g. "rm -rf"
FAIL pipe-to-shell
Potential risk: downloads remote content and pipes it directly into a shell.
1 occurrence(s), e.g. "curl https://example.com/install.sh | sh"
WARN absolute-path
Skill assumes absolute filesystem paths, which won't exist on other machines.
/Users/someone/projects/output, /Users/someone/projects/output/tmp
... (more findings omitted for brevity — run it yourself to see the rest)
SkillSeal Score: 68/100
Specification 90
Quality 60
Security 40
Portability 90
$ uv run skillseal test examples/bad-skill
helper
Should trigger 5/5
Should NOT trigger 5/7
Accuracy 83.3%
Failures:
✗ "Help me write a poem"
Expected: NOT TRIGGER
Actual: TRIGGER
Likely reason:
Matched terms: help
skillseal.yaml format
Place a skillseal.yaml next to a SKILL.md to define its routing tests:
version: 1
routing:
should_trigger:
- "Review this payment implementation"
- "Check whether this Stripe integration is secure"
should_not_trigger:
- "Write a React button"
- "Explain Kubernetes"
- A missing
skillseal.yamlmeans that skill is skipped, not failed. - Empty
should_trigger/should_not_triggerlists are skipped too (no 0/0 false pass or divide-by-zero). - Malformed YAML is a usage error (exit
2), not a crash.
skillseal.toml format
Optional. Overrides a curated set of thresholds repo-wide, without forking a rule. Discovered by searching upward from the scanned path to the filesystem root (so one file at your repo root applies everywhere):
[thresholds]
min_description_length = 20 # default: 10
token_warn_threshold = 3000 # default: 5000
max_lines = 300 # default: 500
long_section_word_threshold = 1000 # default: 800
max_top_level_sections = 10 # default: 8
conflict_threshold = 0.6 # default: 0.5 — see `conflicts` above
routing_threshold = 0.85 # default: 0.9 — see `test` above
- Any threshold you omit keeps its default. An explicit
--thresholdontest/conflictsstill overrides whateverskillseal.tomlsets. - An unrecognized key, or malformed TOML, is a usage error (exit
2), not a silent no-op. - Deliberately not configurable: the numeric limits that come straight
from the agentskills.io spec (
name≤64 chars,description≤1024,compatibility≤500) — overriding those would meancheckno longer validates spec compliance, just a private opinion.
Using it in CI
As a reusable GitHub Action (action.yml):
- uses: pespinel/skillseal@v0.2.1
with:
path: ./skills
fail-on: error
Or driven directly, e.g. to also run routing tests:
- uses: astral-sh/setup-uv@v3
- name: Check Agent Skills
run: uvx skillseal check ./skills --fail-on error
- name: Test Agent Skill Routing
run: uvx skillseal test ./skills
This repo's own .github/workflows/ci.yml does
the same against examples/, plus lint/type-check/unit tests.
pre-commit
repos:
- repo: https://github.com/pespinel/skillseal
rev: v0.2.1
hooks:
- id: skillseal
Runs skillseal check . whenever a SKILL.md changes.
Releasing
Publishing to PyPI is automated via
.github/workflows/release.yml using
PyPI Trusted Publishing — no
API token stored anywhere.
- Bump
versioninpyproject.toml. - Commit, then tag:
git tag vX.Y.Z && git push origin vX.Y.Z. - The workflow verifies the tag matches
pyproject.toml, builds the sdist and wheel, signs a SLSA build provenance attestation, and publishes to PyPI via OIDC.
To verify a release artifact was actually built by this repo's workflow (not hand-uploaded) before installing it:
gh attestation verify dist/skillseal-*.whl --owner pespinel
The score
Deterministic, no LLM involved. Each of the four categories starts at 100 and loses points per finding:
| Severity | Deduction |
|---|---|
ERROR |
-25 |
WARNING |
-10 |
INFO |
-0 |
INFO findings (like "requires docker") are purely descriptive — declaring a
real dependency isn't a defect, so it doesn't cost points. Rules aggregate
repeated occurrences of the same issue into one finding with a count, so a
long file can't rack up an artificially low score just from file size.
The total is a weighted sum of the four category scores:
| Category | Weight | Why |
|---|---|---|
| Specification | 30% | Broken/missing metadata breaks loading and routing outright. |
| Quality | 30% | Vague or bloated instructions are the main cause of routing failures. |
| Security | 25% | Real risk, weighted close behind. |
| Portability | 15% | Declared environment dependencies are often expected, not defects. |
Architecture
src/skillseal/
├── models.py # pydantic models: Skill, Finding, SkillReport, routing models
├── parser.py # discover_skills(), parse_skill() — never raises on bad YAML
├── linter.py # ties parser + rules + scoring together
├── scoring.py # deterministic 0-100 scoring
├── config.py # skillseal.toml: threshold overrides
├── conflicts.py # cross-skill: duplicate names, routing-overlap (Jaccard)
├── rules/
│ ├── base.py # Rule protocol, FuncRule, registry, text helpers
│ ├── metadata.py # SPECIFICATION rules
│ ├── quality.py # QUALITY rules
│ ├── security.py # SECURITY rules
│ └── portability.py # PORTABILITY rules
├── routing/
│ ├── evaluator.py # HeuristicRoutingEvaluator, LLMRoutingEvaluator, LLMProvider
│ └── runner.py # loads skillseal.yaml, runs cases
├── reporters/
│ ├── terminal.py # Rich terminal output
│ └── json_reporter.py # stable JSON schema
└── cli.py # typer app: check, test, conflicts
A Rule is id, category, severity, description, and
check(skill) -> list[Finding]. Most rules are built with FuncRule, which
wraps a plain function so adding a check doesn't require a new class.
Routing evaluation is behind a RoutingEvaluator protocol with two
implementations:
HeuristicRoutingEvaluator(default): fully offline, no API key needed. Scores how much of a prompt's distinctive vocabulary (after stopword removal and light suffix stripping) is covered by the skill's own name, description, andkeywords:. It's deliberately simple — not real NLP — which is also why it's fast, free, and explainable ("Matched terms: ...").LLMRoutingEvaluator: delegates the trigger/no-trigger decision to anLLMProvider(complete(prompt) -> str).OpenAICompatibleProviderimplements this against any OpenAI-compatible/chat/completionsendpoint, configured viaSKILLSEAL_BASE_URL,SKILLSEAL_API_KEY, andSKILLSEAL_MODEL. Use--provider llmto opt in — it's never required.
Limitations
- Rules are regex/heuristic-based, not a real parser or NLP model — they will have false positives and false negatives. Findings are phrased as potential risk, never certainty.
- The heuristic routing evaluator uses simple tokenization and suffix stripping, not real stemming or embeddings; words like "secure" and "security" won't match each other.
- Token counts are a rough
len(text) // 4estimate, not a real tokenizer. - No sandboxing or dynamic execution — nothing in a skill is ever run.
- No compatibility testing against real agents (Claude Code, Codex, Gemini, etc.) — see the roadmap.
Roadmap
Documented, not implemented, on purpose — deliberately out of scope for now:
- Real execution against Claude Code, Codex, Gemini, and other agents
- A compatibility matrix across agents/environments
- Sandboxed dynamic analysis of skill-invoked commands
- Auto-fix for common findings
- Version-to-version comparison for a skill
- A GitHub App
- A web dashboard
- A skill registry / marketplace
- A hosted/cloud service
- Telemetry
- Skill certification
License
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file skillseal-0.5.0.tar.gz.
File metadata
- Download URL: skillseal-0.5.0.tar.gz
- Upload date:
- Size: 76.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1694085217ad9b9af6aea3f604f5c89b3d6b857b52ffd5563175d4eb78ffc429
|
|
| MD5 |
d9f08c2d2f9a38132553c4f828d8e1d6
|
|
| BLAKE2b-256 |
9c9af961d83a2bf9a42857b3e788166e34f8f8b4a202515f16e73dca7e6c7eb7
|
Provenance
The following attestation bundles were made for skillseal-0.5.0.tar.gz:
Publisher:
release.yml on pespinel/skillseal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
skillseal-0.5.0.tar.gz -
Subject digest:
1694085217ad9b9af6aea3f604f5c89b3d6b857b52ffd5563175d4eb78ffc429 - Sigstore transparency entry: 2583828856
- Sigstore integration time:
-
Permalink:
pespinel/skillseal@b80b5f7a7745b5de3ade27d2884a00ddbe533d15 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/pespinel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b80b5f7a7745b5de3ade27d2884a00ddbe533d15 -
Trigger Event:
push
-
Statement type:
File details
Details for the file skillseal-0.5.0-py3-none-any.whl.
File metadata
- Download URL: skillseal-0.5.0-py3-none-any.whl
- Upload date:
- Size: 35.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cde4110eec4a9a96cf8a30dcf14a4bba7dd1df5189c9d0a517ef778e37a56a3a
|
|
| MD5 |
ebe9337e819690f91b32f16d700756e6
|
|
| BLAKE2b-256 |
b367baed987c649a1af9b6e99a697cde030776e72b00414e119f7744719fe1ba
|
Provenance
The following attestation bundles were made for skillseal-0.5.0-py3-none-any.whl:
Publisher:
release.yml on pespinel/skillseal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
skillseal-0.5.0-py3-none-any.whl -
Subject digest:
cde4110eec4a9a96cf8a30dcf14a4bba7dd1df5189c9d0a517ef778e37a56a3a - Sigstore transparency entry: 2583829031
- Sigstore integration time:
-
Permalink:
pespinel/skillseal@b80b5f7a7745b5de3ade27d2884a00ddbe533d15 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/pespinel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b80b5f7a7745b5de3ade27d2884a00ddbe533d15 -
Trigger Event:
push
-
Statement type: