use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_yaml::Value as YamlValue;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillManifest {
#[serde(default)]
pub name: String,
#[serde(default)]
pub short: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub when_to_use: Option<String>,
#[serde(default)]
pub disable_model_invocation: bool,
#[serde(default)]
pub allowed_tools: Vec<String>,
#[serde(default)]
pub user_invocable: bool,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub hooks: BTreeMap<String, String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub effort: Option<String>,
#[serde(default)]
pub require_signature: bool,
#[serde(default)]
pub trusted_signers: Vec<String>,
#[serde(default)]
pub shell: Option<String>,
#[serde(default)]
pub argument_hint: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ParsedFrontmatter {
pub manifest: SkillManifest,
pub unknown_fields: Vec<String>,
}
const KNOWN_CANONICAL_KEYS: &[&str] = &[
"name",
"short",
"description",
"when_to_use",
"disable_model_invocation",
"allowed_tools",
"user_invocable",
"paths",
"context",
"agent",
"hooks",
"model",
"effort",
"require_signature",
"trusted_signers",
"shell",
"argument_hint",
];
pub fn split_frontmatter(source: &str) -> (&str, &str) {
let trimmed = source.strip_prefix('\u{feff}').unwrap_or(source);
let leading_lines = trimmed.lines();
let mut chars_consumed = 0usize;
let mut saw_opener = false;
let mut fm_start = 0usize;
for line in leading_lines {
let line_len_with_newline = line.len() + 1;
if !saw_opener {
if line.trim().is_empty() {
chars_consumed += line_len_with_newline;
continue;
}
if line.trim() == "---" {
saw_opener = true;
chars_consumed += line_len_with_newline;
fm_start = chars_consumed;
continue;
}
return ("", trimmed);
}
if line.trim() == "---" {
let fm_end = chars_consumed;
let body_start = (chars_consumed + line_len_with_newline).min(trimmed.len());
return (&trimmed[fm_start..fm_end], &trimmed[body_start..]);
}
chars_consumed += line_len_with_newline;
}
("", trimmed)
}
pub fn parse_frontmatter(yaml: &str) -> Result<ParsedFrontmatter, String> {
if yaml.trim().is_empty() {
return Ok(ParsedFrontmatter {
manifest: SkillManifest::default(),
unknown_fields: Vec::new(),
});
}
let raw: YamlValue =
serde_yaml::from_str(yaml).map_err(|e| format!("invalid SKILL.md YAML: {e}"))?;
let map = match raw {
YamlValue::Mapping(m) => m,
YamlValue::Null => {
return Ok(ParsedFrontmatter {
manifest: SkillManifest::default(),
unknown_fields: Vec::new(),
});
}
other => {
return Err(format!(
"SKILL.md frontmatter must be a YAML mapping, got {:?}",
discriminant(&other)
));
}
};
let mut normalized = serde_yaml::Mapping::new();
let mut unknown_fields = Vec::new();
for (k, v) in map {
let key_str = match k {
YamlValue::String(s) => s,
other => {
return Err(format!(
"SKILL.md frontmatter keys must be strings, got {:?}",
discriminant(&other)
));
}
};
let canonical = key_str.trim().replace('-', "_");
if !KNOWN_CANONICAL_KEYS.contains(&canonical.as_str()) {
unknown_fields.push(key_str);
continue;
}
normalized.insert(YamlValue::String(canonical), v);
}
if let Some(YamlValue::Sequence(seq)) = normalized.get("hooks").cloned() {
let mut flat = serde_yaml::Mapping::new();
for item in seq {
if let YamlValue::Mapping(entry) = item {
let event = entry
.get(YamlValue::String("event".into()))
.or_else(|| entry.get(YamlValue::String("name".into())))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let cmd = entry
.get(YamlValue::String("command".into()))
.or_else(|| entry.get(YamlValue::String("run".into())))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let (Some(event), Some(cmd)) = (event, cmd) {
flat.insert(YamlValue::String(event), YamlValue::String(cmd));
}
}
}
normalized.insert(YamlValue::String("hooks".into()), YamlValue::Mapping(flat));
}
let manifest: SkillManifest =
serde_yaml::from_value(YamlValue::Mapping(normalized)).map_err(|e| {
format!(
"SKILL.md frontmatter is well-formed YAML but doesn't match the expected field \
shapes: {e}"
)
})?;
if !yaml.trim().is_empty() && manifest.short.trim().is_empty() {
return Err("SKILL.md frontmatter requires a non-empty `short` field".to_string());
}
Ok(ParsedFrontmatter {
manifest,
unknown_fields,
})
}
fn discriminant(value: &YamlValue) -> &'static str {
match value {
YamlValue::Null => "null",
YamlValue::Bool(_) => "bool",
YamlValue::Number(_) => "number",
YamlValue::String(_) => "string",
YamlValue::Sequence(_) => "sequence",
YamlValue::Mapping(_) => "mapping",
YamlValue::Tagged(_) => "tagged",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_frontmatter_and_body() {
let src = "---\nname: hello\n---\n# Body\nline 2\n";
let (fm, body) = split_frontmatter(src);
assert_eq!(fm, "name: hello\n");
assert_eq!(body, "# Body\nline 2\n");
}
#[test]
fn no_frontmatter_returns_empty_and_full_body() {
let src = "# Just body\nno fm here\n";
let (fm, body) = split_frontmatter(src);
assert!(fm.is_empty());
assert_eq!(body, src);
}
#[test]
fn tolerates_utf8_bom() {
let src = "\u{feff}---\nname: hi\n---\nbody";
let (fm, body) = split_frontmatter(src);
assert_eq!(fm, "name: hi\n");
assert_eq!(body, "body");
}
#[test]
fn unterminated_frontmatter_becomes_body() {
let src = "---\nname: hi\nno closing delim";
let (fm, body) = split_frontmatter(src);
assert!(fm.is_empty());
assert_eq!(body, src);
}
#[test]
fn parses_canonical_fields() {
let yaml = "name: deploy\n\
short: \"Deploys the service when the user asks for a release\"\n\
description: \"Ship it\"\n\
when-to-use: \"when the user says deploy\"\n\
disable-model-invocation: true\n\
allowed-tools: [bash, git]\n\
user-invocable: true\n\
paths:\n - infra/**\n - Dockerfile\n\
model: claude-opus-4-7\n\
effort: high\n\
argument-hint: \"<target-env>\"\n";
let parsed = parse_frontmatter(yaml).expect("parse");
assert_eq!(parsed.manifest.name, "deploy");
assert_eq!(
parsed.manifest.short,
"Deploys the service when the user asks for a release"
);
assert_eq!(parsed.manifest.description, "Ship it");
assert_eq!(
parsed.manifest.when_to_use.as_deref(),
Some("when the user says deploy")
);
assert!(parsed.manifest.disable_model_invocation);
assert!(parsed.manifest.user_invocable);
assert_eq!(parsed.manifest.allowed_tools, vec!["bash", "git"]);
assert_eq!(parsed.manifest.paths, vec!["infra/**", "Dockerfile"]);
assert_eq!(parsed.manifest.model.as_deref(), Some("claude-opus-4-7"));
assert_eq!(parsed.manifest.effort.as_deref(), Some("high"));
assert_eq!(
parsed.manifest.argument_hint.as_deref(),
Some("<target-env>")
);
assert!(parsed.unknown_fields.is_empty());
}
#[test]
fn unknown_fields_surface_as_warnings_not_errors() {
let yaml = "name: hi\nshort: Quick card\nfuture_field: future_value\n";
let parsed = parse_frontmatter(yaml).expect("parse");
assert_eq!(parsed.manifest.name, "hi");
assert_eq!(parsed.unknown_fields, vec!["future_field"]);
}
#[test]
fn hooks_as_mapping_or_sequence() {
let mapping = "name: hi\nshort: Quick card\nhooks:\n on-activate: \"echo up\"\n on-deactivate: \"echo down\"\n";
let parsed = parse_frontmatter(mapping).expect("parse mapping");
assert_eq!(parsed.manifest.hooks.len(), 2);
assert_eq!(
parsed.manifest.hooks.get("on-activate").map(String::as_str),
Some("echo up"),
);
let sequence = "name: hi\nshort: Quick card\nhooks:\n - event: on-activate\n command: \"echo up\"\n - name: on-deactivate\n run: \"echo down\"\n";
let parsed = parse_frontmatter(sequence).expect("parse sequence");
assert_eq!(
parsed.manifest.hooks.get("on-activate").map(String::as_str),
Some("echo up"),
);
assert_eq!(
parsed
.manifest
.hooks
.get("on-deactivate")
.map(String::as_str),
Some("echo down"),
);
}
#[test]
fn rejects_non_mapping_top_level() {
let err = parse_frontmatter("- just\n- a list\n").unwrap_err();
assert!(err.contains("mapping"), "{err}");
}
#[test]
fn rejects_missing_short_field() {
let err = parse_frontmatter("name: hi\n").unwrap_err();
assert!(err.contains("`short`"), "{err}");
}
}