moonskill

    Agent Skills for MoonBit: discover skill directories, read SKILL.md, validate it against the specification.

    agent-skills
    skills
    skill-md
    agentskills
    agent-skills-specification
    skills-ref
    frontmatter
    yaml
    markdown
    progressive-disclosure
    validation
    discovery
    agent
    moonbit
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    9 hours ago
    Downloads
    12

    #moonskill

    Agent Skills for MoonBit. A skill is a directory with a SKILL.md: a name, a description, and instructions a model reads only when it needs them. This library reads that format, judges a document against the specification, and hands a model the one line per skill it needs to choose.

    mooncakes license

    #Why

    An agent that can be handed tools at runtime needs a way to find out what is available without paying for all of it up front. Agent Skills answers that with three tiers: the name and description of every skill, the body of the one skill the model chose, and the files the body points at when the task reaches them. moonskill is that format in MoonBit, with no opinion about which agent is using it.

    • Parse a document with StrictYAML frontmatter, so name: 42 is the name forty-two and a frontmatter that means something other than what it looks like is refused rather than guessed.
    • Validate against every rule the specification states: the length limits, the character rules, the directory match, the fields that may appear.
    • Discover a tree of skills through a Source you implement, with a directory reader included.
    • Prompt a model with the metadata tier as an <available_skills> block.

    #Install

    moon add moonbitstack/moonskill

    import { "moonbitstack/moonskill", "moonbitstack/moonskill/fs", }

    #Read a document

    --- name: pdf-tools description: Extract text from PDFs. Use when a task hands you a PDF. --- # PDF tools Run `scripts/extract.py` on the file.
    test {
    let text = "---\nname: pdf-tools\ndescription: Extract text from PDFs.\n---\n# PDF tools\n"
    let skill = @moonskill.parse(text, root="pdf-tools")
    inspect(skill.name, content="pdf-tools")
    inspect(skill.body, content="# PDF tools")
    }

    parse is lenient about the fields: a document with no name still parses, and validate is what says so. Only a document that is not a skill document — no fence, YAML that does not read, a frontmatter that is not a mapping — is refused.

    #Judge a document

    test {
    let text = "---\nname: PDF_Tools\ndescription: Extract text from PDFs.\n---\nBody\n"
    let faults = @moonskill.faults(text, root="pdf-tools")
    inspect(faults.length(), content="3")
    for fault in faults {
    println(fault.kind() + ": " + fault.message())
    }
    }

    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'

    Every fault is reported at once, because a person fixing a document wants the list rather than the first line. Fault::kind returns a tag that does not change with the wording, for a report read by a program.

    #Discover a tree

    @fs reads the disk, so it is there on the native target only.

    async test {
    let tree = @fs.Dir::at("docs/skills")
    let skills = @moonskill.discover(tree)
    for skill in skills {
    println(skill.summary())
    }
    }

    discover reads each skill's document once. A directory that is offered as a skill but has no document raises rather than being skipped: a model that is told about a skill it cannot open will use it anyway.

    Source is the seam. Anything that can list skill roots and read a file can be one — a tarball, a database, an HTTP cache:

    test {
    let skills : Array[@moonskill.Skill] = []
    let block = @moonskill.prompt(skills)
    inspect(block, content="<available_skills>\n</available_skills>")
    }

    #The three tiers

    TierWhat it isHow to read it
    1name and description of every skillSkill::summary, prompt
    2the body of the chosen skillSkill::body
    3a file the body refers toSkill::resolve then Source::read

    async test {
    let tree = @fs.Dir::at("docs/skills")
    let skills = @moonskill.discover(tree)
    guard skills.length() > 0 else { return }
    let skill = skills[0]
    if skill.resolve("SKILL.md") is Some(path) {
    let bytes = tree.read(path)
    println("\{bytes.length()} bytes")
    }
    }

    resolve joins the skill root and refuses a path that climbs out of it, so a document can point at its own files and nowhere else. Every file inside the skill is read the same way; the document itself is no exception.

    #Check a directory from a command line

    moon run src/cmd/skillcheck --target native -- docs/skills/moonskill ok docs/skills/moonskill

    moon run src/cmd/skillcheck --target native -- docs/skills/broken FAIL docs/skills/broken - name-not-lowercase: Skill name 'Broken' must be lowercase

    --json prints one object per directory for a program to read. The command exits non-zero when anything failed, which is what a CI step wants.

    #Interface

    ItemSignature
    parseparse(text : StringView, root? : String) -> Skill raise Refused
    validatevalidate(skill : Skill, limits~ : Limits = limits) -> Array[Fault]
    faultsfaults(text : StringView, root? : String, limits~ : Limits = limits) -> Array[Fault]
    discoverasync discover(source : &Source) -> Array[Skill] raise
    loadasync load(source : &Source, root~ : String) -> Skill raise
    promptprompt(skills : ArrayView[Skill]) -> String
    Sourceasync list(Self) -> Array[String] raise, async read(Self, path : String) -> Bytes raise
    Skillname, description, body, meta : Map[String, Json], root
    Skill::summarySkill::summary(Skill) -> String
    Skill::locationSkill::location(Skill) -> String
    Skill::resolveSkill::resolve(Skill, StringView) -> String?
    Skill::licenseSkill::license(Skill) -> String?
    Skill::compatibilitySkill::compatibility(Skill) -> String?
    Skill::allowed_toolsSkill::allowed_tools(Skill) -> String?
    Skill::metadataSkill::metadata(Skill) -> Map[String, String]?
    Faultmessage, kind
    Limitsname, description, compatibility, with limits as the preset
    @fs.DirDir::at(StringView) -> Dir, Dir::path(Dir) -> String
    @fs.loadasync load(StringView) -> Skill raise
    @fs.checkasync check(StringView) -> Array[Fault] raise

    #Limits

    The three lengths the specification fixes are a record, so a client with a smaller context can hold a skill to a tighter budget:

    test {
    let text = "---\nname: my-skill\ndescription: A description long enough to break.\n---\nBody\n"
    let tight : @moonskill.Limits = { name: 64, description: 8, compatibility: 500 }
    inspect(@moonskill.faults(text, root="my-skill", limits=tight).length(), content="1")
    }

    #Conformance

    scripts/conformance/skills/ holds 39 skill directories: one per rule the specification states, in both directions, plus the shapes that make a document refuse to parse. The reference validator — skills-ref from agentskills/agentskills, pinned by commit — judges the same batch, and scripts/conformance/check_reference_parity.py fails when the two disagree about a directory, or count a different number of problems in it. The two agree on all 40 directories judged — the 39 cases and this library's own skill — 13 accepted and 27 refused.

    The comparison runs in CI, together with scripts/conformance/gen_corpus.py--check, so a case cannot be worded in one place and slide in the other.

    #Licence

    Apache-2.0. See LICENSE.

    Source

    pub(open) trait Source {
    async fn list(Self) -> Array[String]
    async fn read(Self, path : String) -> Bytes
    }

    Where skill documents come from.

    The specification says a skill is a directory, and this is that directory seen as two questions: which skills are here, and what is in this file. A client with skills in a tarball, a database, or an HTTP cache implements the two methods and gets [discover], the three-tier loading, and everything else unchanged — nothing above this trait knows what a filesystem is.

    list returns skill roots: the directories that hold a document, in the order the client wants them offered to a model. read returns the bytes of a path. Both speak of paths with / between segments whatever the platform uses, so the same report reads the same everywhere.

    Refused

    pub(all) suberror Refused {
    MissingFrontmatter
    UnclosedFrontmatter
    BadYaml(String)
    NotAMapping
    NotUtf8(Int)
    NoDocument
    } derive(Eq,
    Debug
    )

    Why a skill document was refused before it could become a [Skill].

    A refusal is about the shape of the document — no frontmatter, YAML that does not read, bytes that are not UTF-8. Whether the fields inside are allowed is [validate]'s question, and it answers with a list rather than a refusal so one run reports every fault.
    impl Show for Refused

    Refused::equal

    fn Refused::equal(Refused, Refused) -> Bool

    Refused::message

    fn Refused::message(self : Refused) -> String

    The refusal in one sentence, worded as the reference validator words it so a report from either reads the same.

    Refused::not_equal

    fn Refused::not_equal(x : Refused, y : Refused) -> Bool

    Refused::output

    fn Refused::output(self : Refused, logger : &Logger) -> Unit

    Refused::to_repr

    Refused::to_string

    fn Refused::to_string(self : Refused) -> String

    Fault

    pub(all) enum Fault {
    UnexpectedFields(Array[String])
    MissingName
    NameEmpty
    NameTooLong(String, Int, Int)
    NameNotLowercase(String)
    NameEdgeHyphen
    NameDoubleHyphen
    NameBadCharacter(String)
    NameMismatch(String, String)
    MissingDescription
    DescriptionEmpty
    DescriptionTooLong(Int, Int)
    CompatibilityNotText
    CompatibilityTooLong(Int, Int)
    Malformed(Refused)
    MissingPath(String)
    NotADirectory(String)
    MissingDocument
    } derive(Eq,
    Debug
    )

    A rule of the Agent Skills specification that a skill does not meet.

    [validate] reports every fault it finds, in the order a person reads the frontmatter: keys that do not belong, the name, the description, then compatibility. The last four only arise when a whole directory is checked: they are about the tree, not about a document that already parsed.
    impl Show for Fault

    Fault::equal

    fn Fault::equal(Fault, Fault) -> Bool

    Fault::kind

    fn Fault::kind(self : Fault) -> String

    A stable tag for the fault, for reports that are read by a program rather than by a person. The wording of [Fault::message] may be rephrased; these do not change.

    Fault::message

    fn Fault::message(self : Fault) -> String

    The fault in one sentence, worded as the reference validator words it so a report from either reads the same.

    Fault::not_equal

    fn Fault::not_equal(x : Fault, y : Fault) -> Bool

    Fault::output

    fn Fault::output(self : Fault, logger : &Logger) -> Unit

    Fault::to_repr

    Fault::to_string

    fn Fault::to_string(self : Fault) -> String

    Limits

    pub(all) struct Limits {
    name : Int
    description : Int
    compatibility : Int
    }

    The length limits the specification puts on a frontmatter field.

    They are a record rather than three constants because a client may enforce a tighter budget than the specification's — a model with a smaller context wants shorter descriptions — and because a test that reads the boundary should read it from one place.

    Skill

    pub(all) struct Skill {
    name : String
    description : String
    body : String
    meta : Map[String,
    Json
    ]
    root : String
    }

    A skill document: the frontmatter the specification defines, the Markdown an agent reads when the skill is activated, and the directory it came from.

    The specification loads a skill in three tiers, and the fields follow them: [Skill::summary] is the tier a client reads for every skill it knows about, body is the tier it reads once the skill is chosen, and the files the body points at are read one at a time through [Skill::resolve] when the task reaches them.

    Skill::allowed_tools

    fn Skill::allowed_tools(self : Skill) -> String?

    The experimental allowed-tools field, or None.

    Skill::compatibility

    fn Skill::compatibility(self : Skill) -> String?

    The compatibility field, or None when it is absent or not text.

    Skill::license

    fn Skill::license(self : Skill) -> String?

    The license field, or None when it is absent or not text.

    Skill::location

    fn Skill::location(self : Skill) -> String

    The path of the document, which is what a client hands back to the model so it can open the skill if it wants the body.

    Skill::metadata

    fn Skill::metadata(self : Skill) -> Map[String, String]?

    The metadata mapping as text, or None when it is absent. The specification defines it as string keys to string values; a value that is not text is skipped rather than turned into JSON spelling.

    Skill::resolve

    fn Skill::resolve(self : Skill, path : StringView) -> String?

    The path of a file the body refers to, resolved against the skill root, or None when the reference is not a file inside this skill.

    This is how the third tier is read: the body says references/REFERENCE.md, the model asks for that file, and the client fetches it from the same [Source] the skill came from. A reference that climbs out of the directory — ../other-skill/SKILL.md, an absolute path, a URL — is None rather than a path: a skill may point at its own files and nowhere else.

    Skill::summary

    fn Skill::summary(self : Skill) -> String

    The metadata tier: what a client puts in front of a model before the model has chosen anything, which the specification budgets at about a hundred tokens per skill. One line, so a list of them stays a list.

    base_name

    fn base_name(path : StringView) -> String

    The last segment of a path, which is the directory name the specification compares a skill's name against.

    discover

    async fn discover(source : &Source) -> Array[Skill]

    Every skill a source offers, read and parsed.

    A source that lists a directory with no document is a broken source, not an empty one, so discover refuses rather than skipping it: silently offering a model a skill it cannot open is how an agent comes to invent a tool it does not have.

    faults

    fn faults(text : StringView, root? : String, limits? : Limits) -> Array[Fault]

    Every fault in a document, from the fence to the last rule.

    This is the call a validator makes: a document that does not parse is one fault of kind malformed, and the rest of the list is whatever [validate] finds in the fields. It is the whole check of a SKILL.md in one function, which is what a command line and a test both want.

    join_path

    fn join_path(a : StringView, b : StringView) -> String

    a and b with exactly one / between them, whichever slashes either already carries.

    Paths are joined here rather than with the platform's separator: a skill root is a key in a [Source], and a key that reads the same on every system is the one a report can be compared against.

    limits

    let limits : Limits

    The limits of the Agent Skills specification.

    load

    async fn load(source : &Source, root~ : String) -> Skill

    One skill, read from the source.

    The document is SKILL.md, or skill.md for a directory written on a system that folds case, which the specification allows readers to accept.

    parse

    fn parse(text : StringView, root? : String) -> Skill raise Refused

    Read a skill document.

    The document opens with a --- fence, then YAML, then a closing ---, then the Markdown body. The YAML is read as StrictYAML, the dialect the specification was written against: every scalar is text, so name: 42 is the name forty-two rather than a number, and a frontmatter that means something other than what it looks like is refused instead of guessed.

    root is the directory the document came from. [validate] compares the directory name with the skill name, and [Skill::location] needs it to name the document; leave it out when the text has no directory yet.

    Reading is lenient about the fields themselves: a document with no name still parses, and [validate] says what is wrong with it. Only a document that is not a skill document at all is refused.

    prompt

    fn prompt(skills : ArrayView[Skill]) -> String

    The block a client puts in a system prompt so a model knows which skills exist, without putting their bodies in the context.

    This is the first tier of the specification's progressive disclosure: one entry per skill, the name and the description, and where the document is so the model can ask for the rest. A skill with an empty description still gets an entry — a model that cannot see the name cannot choose the skill, and the description is what [validate] reports.

    validate

    fn validate(skill : Skill, limits? : Limits) -> Array[Fault]

    Every rule the specification states about the frontmatter, judged against the skill's own text.

    The list is empty when the skill is valid. It is not a refusal: a validator reports all the faults at once, because a person fixing a document wants the whole list rather than the first line.

    Two things are deliberately not faults. The body length and the token budget are recommendations — the specification asks for a SKILL.md under five hundred lines and says nothing breaks when it is longer — and an unknown key under metadata is the caller's business, since the specification reserves that mapping for clients.