ESLint, but for the SKILL.md files your AI agent reads. Catches the
vague triggers, missing fields, and dangerous shell commands that quietly
make Claude Code / Cursor / Windsurf / Aider skills unreliable — then
fixes most of them for you.
npx skilllint .claude/skills/No install, no config, no API key — point it at a directory and read the report. If you'd rather see it before running it:
$ skilllint .claude/skills/
skills/pdf-table-extractor/SKILL.md 100/100 (A)
✔ no issues found
skills/general-helper/SKILL.md 55/100 (F)
✖ File has no YAML frontmatter block (--- or +++ delimited). [required-fields]
⚠ "use-when" reads as an overly broad trigger ("always"). [broad-triggers]
⚠ Body is very short (12 words). [body-structure]
──────────────────────────────────────────────
Files linted: 2 Average score: 78/100
1 errors 2 warnings 0 info
Fail threshold: 70/100 — 1 file(s) below it
──────────────────────────────────────────────
Skill files are prompts, not just docs — a vague or overly broad use-when
degrades routing for every skill in the set, not just the sloppy one. As
teams accumulate dozens of skills across Claude Code, Cursor rules, and
other agent tools, SkillLint gives you the same kind of guardrail ESLint
gives JavaScript: a fast, CI-friendly check that catches the mistakes that
are easy to make and easy to overlook in review.
npm install --save-dev skilllint
# or run without installing:
npx skilllint .SkillLint has zero runtime dependencies — the whole tool, including its YAML frontmatter parser, is pure Node.js. Requires Node 18+.
skilllint [paths...] # lint files/directories (default: .)
skilllint --fix [paths...] # auto-fix issues in place
skilllint --fix --dry-run [paths] # preview fixes without writing files
skilllint --check [paths...] # exit 1 on errors / low scores — for CI
skilllint --json [paths...] # machine-readable report
skilllint --init # write a starter .skilllintrc.jsonCommon flags:
| Flag | Description |
|---|---|
--fix |
Apply automatic fixes and overwrite files |
--dry-run |
With --fix, show what would change without writing |
--check |
Exit code 1 if any file has errors or scores below the fail threshold |
--json |
Print a machine-readable JSON report |
--quiet, -q |
Only print the summary line |
--config <path> |
Use a specific config file |
--ignore-rule <id> |
Disable a rule for this run (repeatable) |
--fail-threshold <n> |
Override the configured fail threshold (0–100) |
--file-name <name> |
Override which filename to look for (default SKILL.md) |
--spec <name> |
Also enforce an external spec (currently agentskills.io) |
--no-delegates |
Disable shelling out to shellcheck/markdownlint even if installed |
--max-fallbacks <n> |
With --check, fail if more than n delegate checks fell back to heuristics |
You can point SkillLint at a single file or a whole directory tree — it
recursively finds every SKILL.md (or your configured filename). Linting
a path with zero matching files is treated as a configuration mistake,
not a clean pass: it exits with code 3 and a loud message rather than
silently reporting a perfect score.
| Rule | What it catches |
|---|---|
required-fields |
Missing frontmatter block, missing name / description / use-when |
use-when-quality |
Trigger text that's too short, vague ("use when needed"), or lacks any concrete signal (file types, verbs, quoted phrases) |
broad-triggers |
Triggers like "always", "any request", "everything" that will fire on nearly anything and crowd out more specific skills |
security |
Dangerous patterns in the body (eval(), curl | sh, rm -rf /, chmod 777, disabled TLS verification, etc.) and likely hardcoded secrets (API keys, tokens, private keys) — optionally deepened by shellcheck on embedded shell blocks, see Delegated deep linting |
body-structure |
Empty or extremely short/long bodies, empty sections, unclosed code fences, missing headings, unreadable wall-of-text paragraphs — optionally deepened by markdownlint, see below |
duplicate-skills (cross-file) |
Two skills with the same or near-identical name, or trigger text that overlaps so much the router can't tell them apart |
spec-conformance (opt-in, --spec agentskills.io) |
Non-kebab-case names, name not matching its directory, oversized descriptions, unrecognized frontmatter fields, bodies over 500 lines |
Every issue includes a severity (error / warning / info), a rule ID
you can reference in config, and — where useful — a concrete suggestion for
fixing it. Scores start at 100 and are reduced per issue found; see
src/score.js for the exact weighting.
The open agentskills.io standard is accepted by Claude Code, Cursor, Gemini CLI, Codex, and others, and adds a few structural requirements SkillLint doesn't enforce by default (name must be kebab-case and match its directory, description length bounds, no unrecognized frontmatter fields). Turn it on explicitly:
skilllint --spec agentskills.io ./skillsIt's opt-in rather than default because "name must equal directory name"
is a real constraint some setups (Cursor rules, single-file skill
collections) don't follow. --fix --spec agentskills.io will safely
rename a mismatched name field to match its directory when doing so is
unambiguous.
SkillLint's built-in security and body-structure rules are
zero-dependency regex heuristics — fast and dependency-free, but they can't
parse real shell syntax or catch every structural Markdown issue. When
shellcheck and/or markdownlint (from markdownlint-cli) are already
installed on your machine or CI runner, SkillLint will automatically shell
out to them for extra precision:
shellcheckparses```bash/```shcode fences in the body for real shell-injection and quoting issues the regex patterns insecurity.jscan't catch.markdownlintchecks body structure (heading levels, duplicate headings, code fences missing a language) — filtered to a curated, routing-relevant rule set, not its full prose-style ruleset.
Neither is a hard dependency: if a tool isn't found, SkillLint silently
falls back to its own heuristics, which already ran regardless. Disable
delegation entirely with --no-delegates for fully reproducible,
dependency-free runs. In CI, use --max-fallbacks <n> to fail the build if
more than n delegate checks silently degraded to heuristics — useful if
your pipeline is supposed to have these tools installed and you want to
know when it doesn't:
skilllint --check --max-fallbacks 0 ./skills # fail loud if shellcheck/markdownlint are missingThe summary line reports which delegates were used vs. unavailable:
Delegates — shellcheck: used; markdownlint: used
Auto-fix is now rule-driven: each rule that can safely repair its own
issue exposes a fix(), and the runner re-lints after every pass to a
fixpoint, so --fix never contradicts what the linter itself would flag.
skilllint --fix will:
- Add a missing frontmatter block (
---by default) if one doesn't exist - Fill in a missing
name(derived from the file/folder path) anddescription(derived from the first paragraph of the body) - Rewrite a
use-whenthat's missing, too short, or uses vague phrasing, generated from verbs, file extensions, and headings found in the body — flagged with aTODO:marker so you know to review and sharpen it - Remove empty sections (a heading with nothing under it) and collapse excess blank lines
Every fix is verified: SkillLint re-lints the result and refuses to write
a change that would leave the file's score lower than before. If a
proposed fix would regress the file, it's skipped and reported instead of
silently applied — you'll see (before → after) scores and a remaining
fixable-issue count for every file --fix touches.
Auto-fix never invents claims about what your skill does beyond what's
already in the body — generated text is a starting point, always marked
with a TODO, and meant to be reviewed before you ship it.
skilllint --fix --dry-run ./skills # see what would change first
skilllint --fix ./skills # then apply itRun skilllint --init to generate a starter .skilllintrc.json, or write
skilllint.config.js exporting the same shape:
{
"ignore": ["broad-triggers"],
"severity": { "body-structure": "warning" },
"failThreshold": 75,
"fileName": "SKILL.md",
"ignorePaths": ["node_modules", ".git", "dist", "build"]
}ignore— rule IDs to skip entirely.severity— override a rule's severity (error/warning/info) without disabling it.failThreshold— minimum score--checkrequires to pass.fileName— change what filename SkillLint looks for when given a directory (e.g..cursorrules.md,AGENT.md).ignorePaths— directory names to skip while walking.
Config is discovered by walking up from the target directory, the same way
.eslintrc resolution works, so a config at your repo root applies to
every skill underneath it.
# .github/workflows/skilllint.yml
- run: npx skilllint --check .See .github/workflows/skilllint.yml
for a complete example that also uploads a JSON report as a build artifact.
SkillLint ships small, dependency-free parsers purpose-built for the
frontmatter shapes actually used in agent-skill files: a YAML-subset parser
for --- blocks (scalars, quoted strings, inline [a, b, c] arrays, block
lists, nested maps, block scalars |/>), and a TOML-lite parser for
+++ blocks (flat key = value assignments, quoted strings, arrays).
+++ content is sniffed for which shape it actually has — some tools use
+++ with YAML content anyway — rather than assuming TOML from the fence
alone. Neither is a full spec implementation; that's a deliberate scope
match for what skill frontmatter actually contains, not a shortcut.
Parser honesty: if either parser hits a line it can't confidently
read — unexpected indentation, no recognizable key: value / key = value
shape — it does not silently drop that line and move on. It's reported as
a required-fields error naming the exact line and reason, because a
silent drop means the parsed object is missing data a human actually wrote
into the file, and a router relying on that data would never know. As a
consequence, --fix refuses to modify a file with unparseable
frontmatter lines rather than risk "cleaning up" a file by discarding
content it couldn't read — fix the flagged line by hand first, then
re-run --fix.
See examples/good for skills that score 100/100, and
examples/bad for skills demonstrating each rule failure
(missing frontmatter, vague triggers, overly broad triggers, and security
smells).
See CONTRIBUTING.md for the full guide, including a template for adding a new rule. Quick start:
git clone https://github.com/your-org/skilllint.git
cd skilllint
npm install
npm test # runs Node's built-in test runner
npm run lint:self # lints the bundled example skillsRules live in src/rules/, each as a small self-contained module with a
check(context) function returning an array of issues. Adding a rule is
as simple as dropping a new file in src/rules/ and registering it in
src/rules/index.js.
A few things SkillLint deliberately guards against, since they came up during hardening and are easy to regress:
--fixpreserves the original line-ending style (CRLF vs LF) and correctly escapes embedded newlines/tabs/quotes when re-serializing a frontmatter value — it never silently corrupts a field it didn't mean to touch.- Delegate subprocess calls (
shellcheck/markdownlint) have a timeout so a hung or misbehaving binary can't stall an entire lint run. - Word-count checks work across scripts that don't use spaces (CJK,
Japanese, Korean) via a script-aware estimator, not a raw
.split(/\s+/). - Duplicate frontmatter keys are reported, not silently overwritten.
- Symlinked skill directories are followed, with cycle detection so a symlink loop can't hang the file walk.
See test/robustness.test.js for the regression coverage on all of these.
MIT
