use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub dir: PathBuf,
pub body: String,
}
impl Skill {
pub fn skill_md(&self) -> PathBuf {
self.dir.join("SKILL.md")
}
}
pub fn discover_skills(roots: &[PathBuf]) -> Vec<Skill> {
let mut out: Vec<Skill> = Vec::new();
for root in roots {
if !root.is_dir() {
continue;
}
let entries = match std::fs::read_dir(root) {
Ok(e) => e,
Err(e) => {
eprintln!("[warn] could not read skills dir {}: {e}", root.display());
continue;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let md = path.join("SKILL.md");
if !md.is_file() {
continue;
}
match load_skill(&path) {
Ok(skill) => {
if out.iter().any(|s| s.name == skill.name) {
eprintln!(
"[warn] duplicate skill name '{}' at {} (ignored)",
skill.name,
path.display()
);
continue;
}
out.push(skill);
}
Err(e) => {
eprintln!("[warn] skipping skill at {}: {e}", path.display());
}
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn load_skill(dir: &Path) -> Result<Skill> {
let md_path = dir.join("SKILL.md");
let raw = std::fs::read_to_string(&md_path)
.with_context(|| format!("reading {}", md_path.display()))?;
let (front, body) = split_frontmatter(&raw)
.with_context(|| format!("parsing frontmatter of {}", md_path.display()))?;
let name = front
.get("name")
.cloned()
.filter(|s| !s.is_empty())
.or_else(|| {
dir.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.ok_or_else(|| anyhow::anyhow!("skill has no 'name' in frontmatter"))?;
let description = front
.get("description")
.cloned()
.unwrap_or_else(|| "(no description)".to_string());
Ok(Skill {
name,
description,
dir: dir.to_path_buf(),
body: body.to_string(),
})
}
fn split_frontmatter(raw: &str) -> Result<(std::collections::BTreeMap<String, String>, &str)> {
let mut front = std::collections::BTreeMap::new();
let trimmed = raw.trim_start_matches('\u{feff}'); let Some(rest) = trimmed.strip_prefix("---") else {
return Ok((front, trimmed));
};
let rest = rest.strip_prefix('\n').or_else(|| rest.strip_prefix("\r\n"));
let Some(rest) = rest else {
return Ok((front, trimmed));
};
let mut end_idx: Option<usize> = None;
let mut cursor = 0usize;
for line in rest.split_inclusive('\n') {
let trimmed_line = line.trim_end_matches(['\n', '\r']);
if trimmed_line == "---" {
end_idx = Some(cursor + line.len());
break;
}
cursor += line.len();
}
let Some(end) = end_idx else {
return Ok((front, trimmed));
};
let front_block = &rest[..cursor];
let body = rest[end..].trim_start_matches(['\n', '\r']);
for line in front_block.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((k, v)) = line.split_once(':') else {
continue;
};
let key = k.trim().to_string();
let val = unquote(v.trim());
if !key.is_empty() {
front.insert(key, val);
}
}
Ok((front, body))
}
fn unquote(s: &str) -> String {
let bytes = s.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
return s[1..s.len() - 1].to_string();
}
s.to_string()
}
pub fn format_catalogue(skills: &[Skill]) -> Option<String> {
if skills.is_empty() {
return None;
}
let mut s = String::from(
"Use the `skill` tool with the skill's name to load its full instructions:
",
);
for sk in skills {
s.push_str(&format!("- {}: {}
", sk.name, sk.description));
}
Some(s)
}
pub fn list_skill_files(dir: &Path) -> Vec<String> {
let mut out = Vec::new();
walk(dir, dir, &mut out);
out.sort();
out
}
fn walk(root: &Path, cur: &Path, out: &mut Vec<String>) {
let entries = match std::fs::read_dir(cur) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let p = entry.path();
let file_type = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if file_type.is_dir() {
walk(root, &p, out);
} else if file_type.is_file() {
if let Ok(rel) = p.strip_prefix(root) {
out.push(rel.display().to_string());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn write_file(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, body).unwrap();
}
#[test]
fn parses_basic_frontmatter() {
let (front, body) =
split_frontmatter("---\nname: foo\ndescription: hi there\n---\nbody text\n").unwrap();
assert_eq!(front.get("name").unwrap(), "foo");
assert_eq!(front.get("description").unwrap(), "hi there");
assert_eq!(body, "body text\n");
}
#[test]
fn quoted_values_are_unwrapped() {
let (front, _) =
split_frontmatter("---\nname: 'foo bar'\ndescription: \"baz\"\n---\nx").unwrap();
assert_eq!(front.get("name").unwrap(), "foo bar");
assert_eq!(front.get("description").unwrap(), "baz");
}
#[test]
fn missing_frontmatter_returns_whole_body() {
let (front, body) = split_frontmatter("just markdown\nno front").unwrap();
assert!(front.is_empty());
assert_eq!(body, "just markdown\nno front");
}
#[test]
fn unclosed_frontmatter_falls_back_to_body() {
let (front, body) = split_frontmatter("---\nname: foo\nbody without close").unwrap();
assert!(front.is_empty());
assert!(body.starts_with("---"));
}
#[test]
fn discover_loads_skill_directory() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("skills");
write_file(
&root.join("greet/SKILL.md"),
"---\nname: greet\ndescription: say hi\n---\nSay hello to the user.\n",
);
write_file(&root.join("greet/extra.txt"), "support file");
let skills = discover_skills(&[root]);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "greet");
assert_eq!(skills[0].description, "say hi");
assert!(skills[0].body.contains("Say hello"));
let files = list_skill_files(&skills[0].dir);
assert!(files.iter().any(|f| f == "SKILL.md"));
assert!(files.iter().any(|f| f == "extra.txt"));
}
#[test]
fn duplicate_names_are_dropped() {
let tmp = tempdir().unwrap();
let a = tmp.path().join("a");
let b = tmp.path().join("b");
write_file(
&a.join("x/SKILL.md"),
"---\nname: x\ndescription: first\n---\nA\n",
);
write_file(
&b.join("x/SKILL.md"),
"---\nname: x\ndescription: second\n---\nB\n",
);
let skills = discover_skills(&[a, b]);
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].description, "first");
}
#[test]
fn missing_root_is_skipped() {
let skills = discover_skills(&[PathBuf::from("/definitely/not/here")]);
assert!(skills.is_empty());
}
#[test]
fn catalogue_renders_or_skips() {
assert!(format_catalogue(&[]).is_none());
let s = Skill {
name: "demo".into(),
description: "does stuff".into(),
dir: PathBuf::from("/tmp"),
body: String::new(),
};
let cat = format_catalogue(&[s]).unwrap();
assert!(cat.contains("Use the `skill` tool"));
assert!(cat.contains("demo: does stuff"));
assert!(!cat.contains("Available skills"));
}
}