use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, thiserror::Error)]
pub enum SkillError {
#[error("skill directory missing SKILL.md: {0}")]
MissingSkillFile(String),
#[error("invalid SKILL.md frontmatter: {0}")]
Frontmatter(String),
#[error("SKILL.md missing required field: {0}")]
MissingField(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Deserialize)]
pub struct SkillFrontmatter {
pub name: Option<String>,
pub description: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub allowed_tools: Option<Vec<String>>,
#[serde(flatten)]
#[allow(dead_code)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct Skill {
frontmatter: SkillFrontmatter,
body: String,
dir: PathBuf,
}
impl Skill {
pub fn load(dir: impl Into<PathBuf>) -> Result<Self, SkillError> {
let dir = dir.into();
let skill_path = dir.join("SKILL.md");
if !skill_path.is_file() {
return Err(SkillError::MissingSkillFile(skill_path.display().to_string()));
}
let raw = fs::read_to_string(&skill_path)?;
let (frontmatter, body) = parse_frontmatter(&raw, &skill_path)?;
Ok(Self {
frontmatter,
body,
dir,
})
}
pub fn scan(root: impl AsRef<Path>) -> Result<Vec<Self>, SkillError> {
let mut skills = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() && path.join("SKILL.md").is_file() {
skills.push(Self::load(&path)?);
}
}
Ok(skills)
}
pub fn name(&self) -> &str {
self.frontmatter.name.as_deref().unwrap_or_default()
}
pub fn description(&self) -> &str {
self.frontmatter.description.as_deref().unwrap_or_default()
}
pub fn disclosure_view(&self) -> String {
format!("{}: {}", self.name(), self.description())
}
pub fn full_text(&self) -> String {
format!(
"# Skill: {}\n\n## Description\n{}\n\n## Instructions\n\n{}",
self.name(),
self.description(),
self.body
)
}
pub fn directory(&self) -> &Path {
&self.dir
}
}
fn parse_frontmatter(raw: &str, path: &Path) -> Result<(SkillFrontmatter, String), SkillError> {
let stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw); if !stripped.trim_start().starts_with("---") {
return Err(SkillError::MissingField(
"frontmatter block (---...) missing".into(),
));
}
let after_open = stripped.find('\n').ok_or_else(|| {
SkillError::Frontmatter(format!("{}: no newline after opening ---", path.display()))
})?;
let rest = &stripped[after_open..];
let close = rest.find("\n---").ok_or_else(|| {
SkillError::Frontmatter(format!("{}: no closing --- for frontmatter", path.display()))
})?;
let yaml = &rest[..close];
let body = &rest[close + 4..];
let frontmatter: SkillFrontmatter = serde_yaml::from_str(yaml)
.map_err(|e| SkillError::Frontmatter(format!("{}: {}", path.display(), e)))?;
match &frontmatter.name {
Some(n) if !n.trim().is_empty() => {}
_ => return Err(SkillError::MissingField("name".into())),
}
match &frontmatter.description {
Some(d) if !d.trim().is_empty() => {}
_ => return Err(SkillError::MissingField("description".into())),
}
Ok((frontmatter, body.trim_matches('\n').to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_skill(dir: &Path, name: &str, description: &str, body: &str) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let frontmatter = format!("---\nname: {name}\ndescription: {description}\n---\n\n");
let p = dir.join("SKILL.md");
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(format!("{frontmatter}{body}").as_bytes()).unwrap();
p
}
#[test]
fn parses_frontmatter_and_body() {
let dir = tempfile::tempdir().unwrap();
write_skill(dir.path(), "web_search", "Searches the web", "Do a search then summarize.");
let skill = Skill::load(dir.path()).unwrap();
assert_eq!(skill.name(), "web_search");
assert_eq!(skill.description(), "Searches the web");
assert_eq!(skill.full_text().contains("Do a search then summarize."), true);
}
#[test]
fn disclosure_view_excludes_body() {
let dir = tempfile::tempdir().unwrap();
write_skill(dir.path(), "secret", "Just a name", "<!-- SECRET BODY -->");
let skill = Skill::load(dir.path()).unwrap();
assert_eq!(skill.disclosure_view(), "secret: Just a name");
assert_eq!(skill.disclosure_view().contains("SECRET BODY"), false);
assert_eq!(skill.full_text().contains("SECRET BODY"), true);
}
#[test]
fn missing_skill_file_errors() {
let dir = tempfile::tempdir().unwrap();
let err = Skill::load(dir.path()).unwrap_err();
assert!(matches!(err, SkillError::MissingSkillFile(_)));
}
#[test]
fn missing_required_field_errors() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("SKILL.md");
std::fs::write(&p, "---\ndescription: no name here\n---\n\nbody").unwrap();
let err = Skill::load(dir.path()).unwrap_err();
assert!(matches!(err, SkillError::MissingField(_)));
}
#[test]
fn scan_finds_only_skill_dirs() {
let root = tempfile::tempdir().unwrap();
write_skill(&root.path().join("a"), "a", "skill A", "body a");
write_skill(&root.path().join("b"), "b", "skill B", "body b");
std::fs::create_dir(root.path().join("plain")).unwrap();
let skills = Skill::scan(root.path()).unwrap();
assert_eq!(skills.len(), 2);
}
}