Scaffold a correct SKILL.md and lint your existing skills so Claude actually triggers them at the right moment.
A skill only fires when its frontmatter tells Claude when to reach for it. The mistakes that quietly stop that from happening (a name that does not match the folder, a description that says what the skill does but never when to use it, placeholder text left in from a template) do not raise an error anywhere. The skill just sits there and never runs. awl catches those before you ship.
$ awl lint skills/
skills/pdf-tools/SKILL.md
2 error name-dir-mismatch 'name' is 'pdf' but the skill directory is 'pdf-tools'; they must match
3 warn no-trigger-cue 'description' does not say when to use the skill (try 'Use when ...' or 'whenever ...'); Claude may not trigger it
awl: 2 problems (1 error, 1 warning) in 1 of 3 files
$ echo $?
1pip install awlOr run it straight from a clone:
git clone https://github.com/marcusikk/awl
cd awl
pip install -e .Requires Python 3.9 or newer.
awl new pdf-toolsThat writes pdf-tools/SKILL.md with the required frontmatter and a body laid out for you to fill in:
---
name: pdf-tools
description: <what this skill does and when Claude should use it, e.g. Use when the user shares a PDF and wants its text>
---
# pdf-tools
## When to use this skill
<Describe the situations that should trigger this skill so Claude reaches for
it at the right moment.>
...Fill in the placeholders, or hand the description to awl new directly and skip that step:
awl new pdf-tools --description "Use this skill when the user shares a PDF and wants its text extracted."awl lint # search the current directory for SKILL.md files
awl lint skills/ # search a folder
awl lint path/to/SKILL.md
awl lint --strict skills/ # treat warnings as errors too
awl lint --ignore no-trigger-cue skills/ # silence one rule
awl lint --format json skills/ # report as JSON instead of textawl lint exits 0 when everything is clean and 1 when it finds an error, so it drops straight into CI or a pre-commit hook. Warnings do not fail the run unless you pass --strict.
A path that gives it nothing to check exits 2 instead, whether the path is not there at all or is a directory with no SKILL.md under it:
$ awl lint skills/ plugins/
awl: no SKILL.md found under plugins/
awl: 3 skills checked, no problems foundA lint step that passes without checking anything is worse than one that fails: the skills move or the folder gets renamed, the CI invocation keeps the old path, and every skill in the tree ships unchecked from then on behind a green tick. 2 is a separate code from 1 so a build can tell a broken invocation from a broken skill, and it holds even when the paths that did resolve came back clean. A file awl found but could not read is the other way round, a problem in the tree rather than in the command, so it stays an error like any other. A directory it could not open exits 2, named with the error the system gave rather than reported as empty: awl was never allowed to look, so it cannot tell you what is under there. That holds for a locked folder deep inside a path that did resolve, since one of those is enough to leave part of the tree unchecked while everything around it comes back clean. A path you named below such a folder exits 2 for the same reason: awl was not allowed to look at it and never learned whether there was a skill there.
A search stays inside the path you name. awl does not descend into symlinked directories it finds along the way, so a link pointing out of a tree cannot add skills you did not ask about to the count, and a folder it cannot read on the far side of one cannot fail a run over somewhere you never named. A directory you name yourself is searched whether or not it is a link.
Each problem is reported with the line it is on, in file order, so you can jump straight to the key that needs fixing. A problem with the frontmatter as a whole (a missing field, a block that never opens or closes) is reported against the --- that starts it, and empty-body against the closing ---, since that is where the missing instructions belong.
--strict is all or nothing, and a couple of the rules are judgement calls: no-trigger-cue is a heuristic, and name-dir-mismatch holds a skill to the Agent Skills standard that Claude Code itself relaxes. One skill you disagree with should not cost you the other rules, so name the rule and keep the rest:
awl lint --strict --ignore no-trigger-cue skills/
awl lint --ignore no-trigger-cue,unknown-key skills/ # comma-separated, or repeat the flagAny rule from the table below can be silenced, errors included, and silencing beats --strict: an ignored warning is dropped rather than promoted, so it cannot fail the run. A name that is not a rule stops the run and lists the ones that are, since an --ignore that quietly matches nothing is the sort of thing awl exists to catch. Every run that dropped something says so:
$ awl lint --ignore no-trigger-cue skills/
awl: 3 skills checked, no problems found; 2 suppressed by --ignoreSo does every run that dropped nothing. A rule that is spelled right but trips nowhere silences just as little as a misspelled one: the skill gets fixed, the flag stays behind in the CI invocation, and from then on it narrows the run for a rule nobody is breaking. awl names it so you can retire it. That is not a failure, since one invocation may cover several trees and only some of them trip the rule, so the exit code is unchanged:
$ awl lint --ignore duplicate-key skills/
awl: --ignore duplicate-key matched nothing
awl: 3 skills checked, no problems found--format json writes the whole run to stdout as one JSON document, so a build step can act on the findings instead of scraping fixed-width columns out of the human report:
$ awl lint --format json skills/pdf-tools
{
"version": 1,
"files": [
{
"path": "skills/pdf-tools/SKILL.md",
"error": null,
"findings": [
{
"line": 2,
"level": "error",
"rule": "name-dir-mismatch",
"message": "'name' is 'pdf' but the skill directory is 'pdf-tools'; they must match"
},
{
"line": 3,
"level": "warning",
"rule": "no-trigger-cue",
"message": "'description' does not say when to use the skill (try 'Use when ...' or 'whenever ...'); Claude may not trigger it"
}
]
}
],
"unchecked": [],
"summary": {
"checked": 1,
"files_with_problems": 1,
"errors": 1,
"warnings": 1,
"suppressed": 0,
"unused_ignores": []
}
}The levels are error and warning, which are also the two GitHub Actions annotation levels, so a lint step can put every problem on the line it belongs to in the pull request diff:
awl lint --format json skills/ |
jq -r '.files[] as $file | $file.findings[] |
"::\(.level) file=\($file.path),line=\(.line)::\(.rule): \(.message)"'What the document guarantees:
- Every file awl opened is in
files, clean ones included, because "which skills did this run check" is the question a report that only lists problems cannot answer. A file awl found but could not read has its reason inerrorand counts towardsummary.errors, the same as in the text report; on every other fileerrorisnull. The file is named inpath, soerroris the reason on its own and never the path again. uncheckedsays what awl was pointed at and did not check, the entries behind exit2. Each one is{"path": ..., "reason": ...}:reasonis why nothing came of it, with the path kept out of it rather than folded into a sentence, andpathis the thing that went unchecked. Five things produce an entry, and the table under this list says what each one puts in the two keys. They are also still named on stderr:--formatchanges stdout and nothing else, so a run redirected to a file still says on the terminal why it failed.--strictand--ignoreapply first. A promoted warning arrives as anerror, a silenced rule is not in the document at all, and a rule that silenced nothing is named insummary.unused_ignoresrather than printed.- The exit codes are the same in both formats, and stdout is a valid document under all of them, including the run that checked nothing and has an empty
files. The one case worth piping into a parser is not the one where the parser gets an empty file. versionis the shape of the document. Moving or dropping a key raises it; adding one does not, so a reader that ignores keys it does not know keeps working.
Each one gets its own reason, so a build step can act on why a path checked nothing rather than only on the fact that one did. Three of them are exactly the string in the first column; the other two carry the system's own error after the colon, since that is the part worth reading:
reason |
What happened | path |
|---|---|---|
empty path argument |
An argument arrived empty, which is what awl lint "$SKILLS" does when the variable is unset. |
null. There is no path to report, and a null a consumer can test for is not the same as a key awl forgot to fill in. |
no such file or directory |
An argument names no file and no directory: nothing is there at all, or a symlink whose target is gone, or something that is there and is neither, such as a socket or a named pipe. They read the same way because none of them has a skill on it to lint. | The argument, as you typed it. |
no SKILL.md found |
An argument is a directory awl searched and found no SKILL.md under. |
The argument, as you typed it. |
cannot check: <the system's error> |
awl could not look at the argument at all, so it never learned whether that path is a skill, a directory or nothing. A path below a folder you cannot read is the usual way in, and the system's error after the colon says which one it was. |
The argument, as you typed it. |
cannot search: <the system's error> |
A directory awl was not allowed to open. It cannot say what is under there, so it does not report that tree as empty either. |
The directory it could not open. That is the argument itself when the locked directory is the one you named, and a path below the argument when the lock sits inside a tree that did resolve. |
One run that met a bit of everything puts something in every key: a skill whose name does not match its directory, a skill that was fine, a file saved in an encoding awl cannot read, a folder the skills have moved out of, and an argument that arrived empty because the variable behind it was unset. no-trigger-cue is silenced and has something to silence, duplicate-key is silenced and has nothing, and the run exits 2 because two of the paths it was given checked nothing.
$ awl lint --format json --ignore no-trigger-cue,duplicate-key skills/ plugins/ ""
{
"version": 1,
"files": [
{
"path": "skills/csv-tools/SKILL.md",
"error": null,
"findings": [
{
"line": 2,
"level": "error",
"rule": "name-dir-mismatch",
"message": "'name' is 'csv' but the skill directory is 'csv-tools'; they must match"
}
]
},
{
"path": "skills/legacy-notes/SKILL.md",
"error": "cannot read: 'utf-8' codec can't decode byte 0xe9 in position 105: invalid continuation byte",
"findings": []
},
{
"path": "skills/pdf-tools/SKILL.md",
"error": null,
"findings": []
}
],
"unchecked": [
{
"path": "plugins/",
"reason": "no SKILL.md found"
},
{
"path": null,
"reason": "empty path argument"
}
],
"summary": {
"checked": 3,
"files_with_problems": 1,
"errors": 2,
"warnings": 0,
"suppressed": 1,
"unused_ignores": [
"duplicate-key"
]
}
}The last row of that table is the one entry whose path the argument alone does not decide, so matching entries back to what you passed in has to allow for a path below one of your arguments. Here the lock is a folder inside the tree that was named, and the skill under it is the one nobody gets to lint. The rest of the run is reported as usual, and it exits 2 because part of the tree went unchecked:
$ awl lint --format json skills/vendor
{
"version": 1,
"files": [
{
"path": "skills/vendor/pdf-tools/SKILL.md",
"error": null,
"findings": []
}
],
"unchecked": [
{
"path": "skills/vendor/private",
"reason": "cannot search: Permission denied"
}
],
"summary": {
"checked": 1,
"files_with_problems": 0,
"errors": 0,
"warnings": 0,
"suppressed": 0,
"unused_ignores": []
}
}| Rule | Level | Why it matters |
|---|---|---|
no-frontmatter, unterminated-frontmatter, invalid-yaml, empty-frontmatter, invalid-frontmatter |
error | Without parseable frontmatter Claude cannot register the skill at all: no block, a block that never closes, YAML that does not parse, an empty block, or one that is not a mapping of keys to values. |
missing-name, missing-description |
error | Both are required fields. |
name-empty, name-not-string, description-empty, description-not-string |
error | The key is there but the value is not: blank, or a list or number where a string belongs. Claude is left as badly off as with the field missing. |
misspelled-key |
error | A required field is absent but a near-miss key (descriptoin, Name) is present, so Claude ignores the typo'd key and never sees the field. |
duplicate-key |
error | The same top-level key written twice. YAML keeps the last value, so the earlier one is thrown away without a word, and a stricter reader rejects the file outright. |
name-format |
error | Names must be lowercase letters, digits, and single hyphens. |
name-reserved-word |
error | A name containing anthropic or claude is refused, so the skill never registers. |
name-too-long |
error | The name limit is 64 characters. |
name-dir-mismatch |
error | The name must match the skill's directory name. |
description-too-long |
error | The description limit is 1024 characters. |
placeholder |
error | Leftover <...>/TODO text from a template means the skill was never filled in. |
no-trigger-cue |
warning | A description that never says when to use the skill tends to under-trigger. |
description-too-short |
warning | A few words cannot tell Claude what the skill is for. |
empty-body |
warning | A skill with no instructions has nothing to run. |
unknown-key |
warning | A frontmatter key the format does not define (a typo like allowed-tool, or a stray field) is ignored, so whatever you set through it silently does nothing. When the key looks like a typo, awl names the field it meant. |
Two things define what a skill may put in its frontmatter, so awl accepts both:
- The Agent Skills standard:
name,description,license,compatibility,metadata,allowed-tools. - Claude Code's extensions on top of it:
when_to_use,argument-hint,arguments,disable-model-invocation,user-invocable,disallowed-tools,model,effort,context,agent,background,hooks,paths,shell.
Anything else gets an unknown-key warning. Note that when_to_use is the only field spelled with an underscore; writing it when-to-use like every other field leaves it unrecognized, and awl names the real spelling.
Accepting a field is not the same as accepting every value for it, and name is where awl is stricter than Claude Code: it holds every skill to the Agent Skills standard, which requires name and requires it to match the skill's directory. Claude Code lets a personal or project skill leave name out (it falls back to the directory name) or set it to a display label such as "PDF Tools" that differs, so such a skill gets an error here: missing-name when the field is absent, name-format when the label is not lowercase letters, digits, and single hyphens, and name-dir-mismatch when it has that form but differs from the directory. Name the skill after its directory and it is valid on both, or keep the Claude Code spelling and silence the rule you are choosing to break with --ignore.
The name and description limits come from Anthropic's Agent Skills reference for SKILL.md frontmatter.
- It does not check the body of your skill for correctness, only the frontmatter and whether a body exists.
unknown-keyis a warning, not an error. It knows the standard fields and Claude Code's, but a third agent may define one neither lists, so a stray key is worth a heads-up rather than a failed build. Pass--strictif you want it to fail.- It does not tell you a field is ignored on another surface.
context: forkworks in Claude Code and means nothing to the API or claude.ai;awlaccepts it either way rather than guessing where you intend to run. no-trigger-cueis a heuristic. It looks for words like "when", "if", and "use this"; a well-written description that avoids all of them will get a warning you can ignore, or silence for the whole run with--ignore no-trigger-cue.--ignoreis per run, not per file: there is no way to silence a rule for one skill and keep it for the rest, and no way to silence one from inside aSKILL.md. The frontmatter belongs to Claude, soawldoes not put its own configuration there.- It does not talk to any API or upload your skills anywhere. Everything runs locally.
MIT. See LICENSE.