Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

awl

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 $?
1

Install

pip install awl

Or run it straight from a clone:

git clone https://github.com/marcusikk/awl
cd awl
pip install -e .

Requires Python 3.9 or newer.

Scaffold a new skill

awl new pdf-tools

That 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."

Lint your skills

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 text

awl 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 found

A 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.

Silencing a rule

--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 flag

Any 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 --ignore

So 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

Machine-readable output

--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 in error and counts toward summary.errors, the same as in the text report; on every other file error is null. The file is named in path, so error is the reason on its own and never the path again.
  • unchecked says what awl was pointed at and did not check, the entries behind exit 2. Each one is {"path": ..., "reason": ...}: reason is why nothing came of it, with the path kept out of it rather than folded into a sentence, and path is 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: --format changes stdout and nothing else, so a run redirected to a file still says on the terminal why it failed.
  • --strict and --ignore apply first. A promoted warning arrives as an error, a silenced rule is not in the document at all, and a rule that silenced nothing is named in summary.unused_ignores rather 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.
  • version is 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": []
  }
}

What it checks

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.

Recognized frontmatter fields

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.

What it does not do

  • It does not check the body of your skill for correctness, only the frontmatter and whether a body exists.
  • unknown-key is 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 --strict if you want it to fail.
  • It does not tell you a field is ignored on another surface. context: fork works in Claude Code and means nothing to the API or claude.ai; awl accepts it either way rather than guessing where you intend to run.
  • no-trigger-cue is 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.
  • --ignore is 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 a SKILL.md. The frontmatter belongs to Claude, so awl does not put its own configuration there.
  • It does not talk to any API or upload your skills anywhere. Everything runs locally.

License

MIT. See LICENSE.

About

Scaffold a correct SKILL.md and lint your existing skills so Claude actually triggers them at the right moment.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages