// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
///|
/// 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.
#derive(Eq, @debug.Debug)
pub(all) suberror Refused {
/// The text does not open with the `---` fence.
MissingFrontmatter
/// The opening fence has no closing `---`.
UnclosedFrontmatter
/// The frontmatter is not YAML the strict reader accepts. The text is the
/// reader's complaint, which begins with the line and column.
BadYaml(String)
/// The frontmatter is YAML, but not a mapping.
NotAMapping
/// The bytes are not UTF-8; the offset is where decoding stopped.
NotUtf8(Int32)
/// A skill directory offered neither `SKILL.md` nor `skill.md`.
NoDocument
}
///|
pub impl Show for Refused with fn output(self, logger) {
logger.write_string(self.message())
}
///|
pub extend Refused with Eq::{not_equal, equal}
///|
pub extend Refused with @debug.Debug::{to_repr}
///|
pub extend Refused with Show::{to_string, output}
///|
/// The refusal in one sentence, worded as the reference validator words it so a
/// report from either reads the same.
pub fn Refused::message(self : Refused) -> String {
match self {
MissingFrontmatter => "SKILL.md must start with YAML frontmatter (---)"
UnclosedFrontmatter => "SKILL.md frontmatter not properly closed with ---"
BadYaml(reason) => "Invalid YAML in frontmatter: \{reason}"
NotAMapping => "SKILL.md frontmatter must be a YAML mapping"
NotUtf8(at) => "SKILL.md is not UTF-8 at byte \{at}"
NoDocument => "Missing required file: SKILL.md"
}
}
///|
/// 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.
#derive(Eq, @debug.Debug)
pub(all) enum Fault {
/// Frontmatter keys the specification does not define, sorted.
UnexpectedFields(Array[String])
/// The frontmatter has no `name`.
MissingName
/// `name` is empty, blank, or not text.
NameEmpty
/// `name` is longer than the limit: the name, how long it is, and the limit.
NameTooLong(String, Int32, Int32)
/// `name` has an uppercase letter.
NameNotLowercase(String)
/// `name` starts or ends with a hyphen.
NameEdgeHyphen
/// `name` has two hyphens in a row.
NameDoubleHyphen
/// `name` has a character that is neither a letter, a digit, nor a hyphen.
NameBadCharacter(String)
/// The directory and the name differ: the directory name, then the name.
NameMismatch(String, String)
/// The frontmatter has no `description`.
MissingDescription
/// `description` is empty, blank, or not text.
DescriptionEmpty
/// `description` is longer than the limit: how long it is, then the limit.
DescriptionTooLong(Int32, Int32)
/// `compatibility` is present and is not text.
CompatibilityNotText
/// `compatibility` is longer than the limit: how long it is, then the limit.
CompatibilityTooLong(Int32, Int32)
/// The document could not be read as a skill at all.
Malformed(Refused)
/// There is nothing at the path.
MissingPath(String)
/// The path is a file, not a skill directory.
NotADirectory(String)
/// The directory has no `SKILL.md` or `skill.md`.
MissingDocument
}
///|
/// The fault in one sentence, worded as the reference validator words it so a
/// report from either reads the same.
pub fn Fault::message(self : Fault) -> String {
match self {
UnexpectedFields(fields) => {
let names = fields.join(", ")
"Unexpected fields in frontmatter: \{names}. Only ['allowed-tools', 'compatibility', 'description', 'license', 'metadata', 'name'] are allowed."
}
MissingName => "Missing required field in frontmatter: name"
NameEmpty => "Field 'name' must be a non-empty string"
NameTooLong(name, got, limit) =>
"Skill name '\{name}' exceeds \{limit} character limit (\{got} chars)"
NameNotLowercase(name) => "Skill name '\{name}' must be lowercase"
NameEdgeHyphen => "Skill name cannot start or end with a hyphen"
NameDoubleHyphen => "Skill name cannot contain consecutive hyphens"
NameBadCharacter(name) =>
"Skill name '\{name}' contains invalid characters. Only letters, digits, and hyphens are allowed."
NameMismatch(dir, name) =>
"Directory name '\{dir}' must match skill name '\{name}'"
MissingDescription => "Missing required field in frontmatter: description"
DescriptionEmpty => "Field 'description' must be a non-empty string"
DescriptionTooLong(got, limit) =>
"Description exceeds \{limit} character limit (\{got} chars)"
CompatibilityNotText => "Field 'compatibility' must be a string"
CompatibilityTooLong(got, limit) =>
"Compatibility exceeds \{limit} character limit (\{got} chars)"
Malformed(refused) => refused.message()
MissingPath(path) => "Path does not exist: \{path}"
NotADirectory(path) => "Not a directory: \{path}"
MissingDocument => "Missing required file: SKILL.md"
}
}
///|
pub impl Show for Fault with fn output(self, logger) {
logger.write_string(self.message())
}
///|
pub extend Fault with Eq::{not_equal, equal}
///|
pub extend Fault with @debug.Debug::{to_repr}
///|
pub extend Fault with Show::{to_string, output}
///|
/// 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.
pub fn Fault::kind(self : Fault) -> String {
match self {
UnexpectedFields(_) => "unexpected-fields"
MissingName => "missing-name"
NameEmpty => "name-empty"
NameTooLong(_, _, _) => "name-too-long"
NameNotLowercase(_) => "name-not-lowercase"
NameEdgeHyphen => "name-edge-hyphen"
NameDoubleHyphen => "name-double-hyphen"
NameBadCharacter(_) => "name-bad-character"
NameMismatch(_, _) => "name-mismatch"
MissingDescription => "missing-description"
DescriptionEmpty => "description-empty"
DescriptionTooLong(_, _) => "description-too-long"
CompatibilityNotText => "compatibility-not-text"
CompatibilityTooLong(_, _) => "compatibility-too-long"
Malformed(_) => "malformed"
MissingPath(_) => "missing-path"
NotADirectory(_) => "not-a-directory"
MissingDocument => "missing-document"
}
}