A skill begins with a fence, a YAML mapping, and a second fence.
parse returns the three things a client uses: the name, the description and the Markdown below the fence. root is the directory the document was found in — passing it is what lets the checker compare the two later.
moonbit
test {
let text =
#|---
#|name: pdf-tools
#|description: Extract text from PDFs.
#|---
#|# PDF tools
let skill = @moonskill.parse(text, root="pdf-tools")
assert_eq(skill.summary(), "pdf-tools: Extract text from PDFs.")
assert_eq(skill.location(), "pdf-tools/SKILL.md")
}
pdf-tools: Extract text from PDFs.
summary is the tier-1 line: the name and the description, joined so a list of them stays a list. location is where the document sits, which is what a client hands back to a model that asked for the body.
refusednot a skill document six ways
What parsing will not read
Parsing is lenient about fields and strict about shape. A missing name still parses — validate reports it — but a document that is not a skill document at all is refused, and the refusal is a value you can match on.
The six refusals
Refusal
When
MissingFrontmatter
The text does not open with ---.
UnclosedFrontmatter
The opening fence has no closing one.
BadYaml
The frontmatter is not YAML the strict reader accepts; the message begins with the line and column.
NotAMapping
The frontmatter is YAML, but a list or a scalar rather than a mapping.
NotUtf8
The bytes are not UTF-8; the offset is where decoding stopped.
NoDocument
A directory offered as a skill holds neither SKILL.md nor skill.md.
03judge itof 08
Every fault, in one pass, because a person fixing a document wants the list.
A document that does not parse comes back as one fault of kind malformed, which is what lets a batch be checked without a single bad file ending the run.
moonbit
test {
let text =
#|---
#|name: PDF_Tools
#|description: Extract text from PDFs.
#|---
#|Body
let faults = @moonskill.faults(text, root="pdf-tools")
assert_eq(faults.length(), 3)
assert_eq(faults[0].kind(), "name-not-lowercase")
assert_eq(faults[2].kind(), "name-mismatch")
}
name-not-lowercase: Skill name 'PDF_Tools' must be lowercase
name-bad-character: Skill name 'PDF_Tools' contains invalid characters. Only letters, digits, and hyphens are allowed.
name-mismatch: Directory name 'pdf-tools' must match skill name 'PDF_Tools'
kind is the tag a report is filed under and does not change with the wording; message is the sentence for a person. The order is the order a reader meets the document: fields that do not belong, the name, the description, then compatibility.
04the three tiersof 08
The body is already in the skill you parsed. The files it points at are not.
resolve joins the root and refuses anything that would climb out of the skill, so a document can point at its own files and nowhere else.
moonbit
test {
let text =
#|---
#|name: pdf-tools
#|description: Extract text from PDFs.
#|---
#|Run scripts/extract.py on the file.
let skill = @moonskill.parse(text, root="pdf-tools")
assert_eq(skill.resolve("scripts/extract.py"), Some("pdf-tools/scripts/extract.py"))
assert_eq(skill.resolve("../other-skill/SKILL.md"), None)
assert_eq(skill.resolve("/etc/passwd"), None)
}
Reading the file itself is the source's job; with a directory reader that is one call.
moonbit, native
async test {
let tree = @fs.Dir::at("docs/skills")
let skill = @moonskill.load(tree, root="moonskill")
if skill.resolve("SKILL.md") is Some(path) {
let bytes = tree.read(path)
assert_eq(bytes.length() > 0, true)
}
}
05discover a treeof 08
A source, not a filesystem.
discover asks a Source which skill roots it offers and reads each one. The trait is two methods, so skills kept in a tarball, a database or an HTTP cache need no change above it.
moonbit, native
async test {
let tree = @fs.Dir::at("docs/skills")
let skills = @moonskill.discover(tree)
assert_eq(skills.length() > 0, true)
assert_eq(skills[0].name, "moonskill")
}
A directory offered as a skill but holding no document is refused rather than skipped: offering a model a skill it cannot open is how an agent comes to invent a tool it does not have.
Writing a source of your own is two methods: list returns the roots, read returns the bytes of a path. Paths speak of / between segments whatever the platform uses, so one report reads the same everywhere.
06the shortlistof 08
The block a model chooses from.
prompt writes tier 1 as XML: one entry per skill, with the name, the description, and where the document is. A skill with an empty description still gets an entry — a model that cannot see the name cannot ask for the skill.
moonbit
test {
let text =
#|---
#|name: pdf-tools
#|description: Extract text from PDFs.
#|---
#|Body
let skill = @moonskill.parse(text, root="pdf-tools")
let block = @moonskill.prompt([skill])
assert_eq(block.contains("<name>\npdf-tools\n</name>"), true)
assert_eq(block.contains("<location>\npdf-tools/SKILL.md\n</location>"), true)
}
<available_skills>
<skill>
<name>
pdf-tools
</name>
<description>
Extract text from PDFs.
</description>
<location>
pdf-tools/SKILL.md
</location>
</skill>
</available_skills>
07a tighter budgetof 08
A client with a smaller context can hold a skill to less.
The three lengths the specification fixes are a record, so the limits can be lowered without changing the document.
moonbit
test {
let text =
#|---
#|name: my-skill
#|description: A description long enough to break.
#|---
#|Body
let tight : @moonskill.Limits = { name: 64, description: 8, compatibility: 500 }
let faults = @moonskill.faults(text, root="my-skill", limits=tight)
assert_eq(faults.length(), 1)
assert_eq(faults[0].kind(), "description-too-long")
}
The defaults are @moonskill.limits: 64 for the name, 1024 for the description, 500 for compatibility.
08from a shellof 08
One exit code per directory.
src/cmd/skillcheck takes any number of directories, prints one line for each, and exits non-zero when anything failed.
shell
moon run src/cmd/skillcheck --target native -- docs/skills/moonskill
ok docs/skills/moonskill
shell
moon run src/cmd/skillcheck --target native -- broken/
FAIL broken/
- name-not-lowercase: Skill name 'Broken' must be lowercase
--json prints one object per directory instead, which is what a program wants to read: path, valid, and the faults with their tags.