moonbitstack guide eight steps gv–02 github

reading a skill

Eight programs, each of which compiles and prints the result under it.

01installof 08

One package, or two if you want the directory reader as well.

shell
moon add moonbitstack/moonskill

moonbitstack/moonskill reads text and never touches a file. moonbitstack/moonskill/fs is the reader that does — it is there on the native target only.

moon.pkg
import {
  "moonbitstack/moonskill",
  "moonbitstack/moonskill/fs",
}

02read a documentof 08

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
RefusalWhen
MissingFrontmatterThe text does not open with ---.
UnclosedFrontmatterThe opening fence has no closing one.
BadYamlThe frontmatter is not YAML the strict reader accepts; the message begins with the line and column.
NotAMappingThe frontmatter is YAML, but a list or a scalar rather than a mapping.
NotUtf8The bytes are not UTF-8; the offset is where decoding stopped.
NoDocumentA 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.

top