use std::collections::BTreeMap;
use std::fmt;
use yaml_rust2::{Yaml, YamlLoader};
use crate::markdown::Document;
use crate::path::PackagePath;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Skill {
pub directory: String,
pub path: PackagePath,
pub meta: SkillMeta,
pub body: Document,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillMeta {
pub name: String,
pub description: String,
pub license: Option<String>,
pub compatibility: Option<String>,
pub allowed_tools: Option<String>,
pub metadata: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SkillInvalid {
NotUtf8,
NoFrontmatter,
UnterminatedFrontmatter,
BadYaml {
detail: String,
},
NotAMapping,
UnknownField {
field: String,
},
MissingField {
field: &'static str,
},
WrongType {
field: String,
expected: &'static str,
},
BadName {
reason: String,
},
NameMismatch {
name: String,
directory: String,
},
BadDescription {
reason: String,
},
BadCompatibility {
reason: String,
},
}
impl fmt::Display for SkillInvalid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotUtf8 => f.write_str("SKILL.md is not UTF-8 text"),
Self::NoFrontmatter => f.write_str("SKILL.md must begin with YAML frontmatter (`---`)"),
Self::UnterminatedFrontmatter => {
f.write_str("SKILL.md frontmatter is not closed with `---`")
}
Self::BadYaml { detail } => write!(f, "invalid YAML in frontmatter: {detail}"),
Self::NotAMapping => f.write_str("frontmatter must be a YAML mapping"),
Self::UnknownField { field } => {
write!(f, "unexpected frontmatter field `{field}`")
}
Self::MissingField { field } => {
write!(f, "missing required frontmatter field `{field}`")
}
Self::WrongType { field, expected } => {
write!(f, "frontmatter field `{field}` must be {expected}")
}
Self::BadName { reason } => write!(f, "invalid skill name: {reason}"),
Self::NameMismatch { name, directory } => {
write!(f, "frontmatter name {name:?} must match directory name {directory:?}")
}
Self::BadDescription { reason } => write!(f, "invalid description: {reason}"),
Self::BadCompatibility { reason } => write!(f, "invalid compatibility: {reason}"),
}
}
}
impl std::error::Error for SkillInvalid {}
pub fn parse_skill_md(
directory: &str,
bytes: &[u8],
) -> Result<(SkillMeta, Document), SkillInvalid> {
let text = std::str::from_utf8(bytes).map_err(|_| SkillInvalid::NotUtf8)?;
let (frontmatter, body) = split_frontmatter(text)?;
let docs = YamlLoader::load_from_str(frontmatter)
.map_err(|e| SkillInvalid::BadYaml { detail: e.to_string() })?;
let mapping = match docs.first() {
Some(Yaml::Hash(mapping)) => mapping,
Some(_) => return Err(SkillInvalid::NotAMapping),
None => return Err(SkillInvalid::NotAMapping),
};
let mut name = None;
let mut description = None;
let mut license = None;
let mut compatibility = None;
let mut allowed_tools = None;
let mut metadata = BTreeMap::new();
for (key, value) in mapping {
let Yaml::String(key) = key else {
return Err(SkillInvalid::NotAMapping);
};
match key.as_str() {
"name" => name = Some(expect_str(key, value)?),
"description" => description = Some(expect_str(key, value)?),
"license" => license = Some(expect_str(key, value)?),
"compatibility" => compatibility = Some(expect_str(key, value)?),
"allowed-tools" => allowed_tools = Some(expect_str(key, value)?),
"metadata" => {
let Yaml::Hash(entries) = value else {
return Err(SkillInvalid::WrongType {
field: "metadata".to_owned(),
expected: "a mapping of strings to strings",
});
};
for (k, v) in entries {
let (Some(k), Some(v)) = (scalar_to_string(k), scalar_to_string(v)) else {
return Err(SkillInvalid::WrongType {
field: "metadata".to_owned(),
expected: "a mapping of strings to strings",
});
};
metadata.insert(k, v);
}
}
unknown => return Err(SkillInvalid::UnknownField { field: unknown.to_owned() }),
}
}
let name = name.ok_or(SkillInvalid::MissingField { field: "name" })?;
let description = description.ok_or(SkillInvalid::MissingField { field: "description" })?;
validate_name(&name)?;
if name != directory {
return Err(SkillInvalid::NameMismatch { name, directory: directory.to_owned() });
}
validate_description(&description)?;
if let Some(compat) = &compatibility {
let count = compat.chars().count();
if compat.trim().is_empty() {
return Err(SkillInvalid::BadCompatibility { reason: "empty".to_owned() });
}
if count > 500 {
return Err(SkillInvalid::BadCompatibility {
reason: format!("{count} characters; the limit is 500"),
});
}
}
Ok((
SkillMeta { name, description, license, compatibility, allowed_tools, metadata },
Document::parse(body),
))
}
fn split_frontmatter(text: &str) -> Result<(&str, &str), SkillInvalid> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut lines = text.split_inclusive('\n');
let first = lines.next().unwrap_or("");
if first.trim_end() != "---" {
return Err(SkillInvalid::NoFrontmatter);
}
let after_open = &text[first.len()..];
let mut offset = 0;
for line in after_open.split_inclusive('\n') {
if line.trim_end() == "---" {
let frontmatter = &after_open[..offset];
let body = &after_open[offset + line.len()..];
return Ok((frontmatter, body.trim_start_matches('\n')));
}
offset += line.len();
}
Err(SkillInvalid::UnterminatedFrontmatter)
}
fn expect_str(field: &str, value: &Yaml) -> Result<String, SkillInvalid> {
match value {
Yaml::String(s) => Ok(s.clone()),
_ => Err(SkillInvalid::WrongType { field: field.to_owned(), expected: "a string" }),
}
}
fn scalar_to_string(value: &Yaml) -> Option<String> {
match value {
Yaml::String(s) => Some(s.clone()),
Yaml::Integer(i) => Some(i.to_string()),
Yaml::Real(r) => Some(r.clone()),
Yaml::Boolean(b) => Some(b.to_string()),
_ => None,
}
}
fn validate_name(name: &str) -> Result<(), SkillInvalid> {
let bad = |reason: &str| SkillInvalid::BadName { reason: reason.to_owned() };
if name.trim().is_empty() {
return Err(bad("empty"));
}
let count = name.chars().count();
if count > 64 {
return Err(bad("longer than 64 characters"));
}
if name.chars().any(|c| c.is_uppercase()) {
return Err(bad("must be lowercase"));
}
if !name.chars().all(|c| c.is_alphanumeric() || c == '-') {
return Err(bad("only letters, digits, and hyphens are allowed"));
}
if name.starts_with('-') || name.ends_with('-') {
return Err(bad("must not start or end with a hyphen"));
}
if name.contains("--") {
return Err(bad("must not contain consecutive hyphens"));
}
Ok(())
}
fn validate_description(description: &str) -> Result<(), SkillInvalid> {
if description.trim().is_empty() {
return Err(SkillInvalid::BadDescription { reason: "empty".to_owned() });
}
let count = description.chars().count();
if count > 1024 {
return Err(SkillInvalid::BadDescription {
reason: format!("{count} characters; the limit is 1024"),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const VALID: &str =
"---\nname: deploy\ndescription: Deploys things. Use when deploying.\n---\n\nBody here.\n";
#[test]
fn valid_skill() {
let (meta, body) = parse_skill_md("deploy", VALID.as_bytes()).unwrap();
assert_eq!(meta.name, "deploy");
assert_eq!(body.source(), "Body here.\n");
}
#[test]
fn name_must_match_directory() {
let err = parse_skill_md("other", VALID.as_bytes()).unwrap_err();
assert!(matches!(err, SkillInvalid::NameMismatch { .. }));
}
#[test]
fn frontmatter_is_required() {
assert_eq!(parse_skill_md("x", b"# Just markdown"), Err(SkillInvalid::NoFrontmatter));
assert_eq!(
parse_skill_md("x", b"---\nname: x\n"),
Err(SkillInvalid::UnterminatedFrontmatter)
);
}
#[test]
fn unknown_fields_are_rejected() {
let text = "---\nname: x\ndescription: d\nversion: 1.0\n---\n";
assert_eq!(
parse_skill_md("x", text.as_bytes()),
Err(SkillInvalid::UnknownField { field: "version".to_owned() })
);
}
#[test]
fn metadata_scalars_coerce() {
let text = "---\nname: x\ndescription: d\nmetadata:\n version: 1.0\n pinned: true\n---\n";
let (meta, _) = parse_skill_md("x", text.as_bytes()).unwrap();
assert_eq!(meta.metadata.get("version").map(String::as_str), Some("1.0"));
assert_eq!(meta.metadata.get("pinned").map(String::as_str), Some("true"));
}
#[test]
fn description_length_limit() {
let text = format!("---\nname: x\ndescription: {}\n---\n", "d".repeat(1025));
assert!(matches!(
parse_skill_md("x", text.as_bytes()),
Err(SkillInvalid::BadDescription { .. })
));
}
#[test]
fn triple_dash_inside_body_is_fine() {
let text = "---\nname: x\ndescription: d\n---\n\nA body with --- a horizontal rule.\n";
let (_, body) = parse_skill_md("x", text.as_bytes()).unwrap();
assert!(body.source().contains("horizontal rule"));
}
}