use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{Read, Take};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
const METADATA_LIMIT: u64 = 8192;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillMetadata {
pub name: String,
pub description: String,
pub path: PathBuf,
}
pub fn skill_roots(home: &Path, project_root: &Path) -> Vec<PathBuf> {
vec![
home.join(".agents/skills"),
project_root.join(".agents/skills"),
]
}
pub fn discover_skills(roots: &[PathBuf]) -> Result<Vec<SkillMetadata>> {
let mut skills = BTreeMap::new();
for root in roots {
index_root(root, &mut skills)?;
}
Ok(skills.into_values().collect())
}
fn index_root(root: &Path, skills: &mut BTreeMap<String, SkillMetadata>) -> Result<()> {
if !root.is_dir() {
return Ok(());
}
let mut entries = fs::read_dir(root)
.with_context(|| format!("read skills directory {}", root.display()))?
.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if !entry.file_type()?.is_dir() {
continue;
}
let path = entry.path().join("SKILL.md");
if !path.is_file() {
continue;
}
match read_metadata_head(&path).and_then(|source| parse_skill_metadata(&source, &path)) {
Ok(metadata) => {
skills.insert(metadata.name.clone(), metadata);
}
Err(error) => eprintln!("warning: skipping skill {}: {error}", path.display()),
}
}
Ok(())
}
fn read_metadata_head(path: &Path) -> Result<String> {
let mut bytes = Vec::new();
let mut reader: Take<File> = File::open(path)
.with_context(|| format!("open {}", path.display()))?
.take(METADATA_LIMIT);
reader
.read_to_end(&mut bytes)
.with_context(|| format!("read {}", path.display()))?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub fn parse_skill_metadata(source: &str, path: &Path) -> Result<SkillMetadata> {
let directory = path
.parent()
.and_then(Path::file_name)
.and_then(|name| name.to_str())
.context("skill path has no UTF-8 directory name")?;
let frontmatter =
frontmatter(source).context("no YAML frontmatter delimited by --- was found")?;
let fields = top_level_fields(frontmatter);
let description = fields
.get("description")
.map(|value| value.trim().to_owned())
.unwrap_or_default();
if description.is_empty() {
anyhow::bail!("description is required and must not be empty");
}
let name = match fields.get("name").map(|name| name.trim()) {
Some(name) if !name.is_empty() => {
if name != directory {
eprintln!(
"warning: skill {} declares name {name:?} but its directory is {directory:?}",
path.display()
);
}
if name.chars().count() > 64 {
eprintln!("warning: skill name {name:?} exceeds 64 characters");
}
name.to_owned()
}
_ => directory.to_owned(),
};
Ok(SkillMetadata {
name,
description,
path: path.to_path_buf(),
})
}
fn frontmatter(source: &str) -> Option<&str> {
let rest = source
.strip_prefix("---\n")
.or_else(|| source.strip_prefix("---\r\n"))?;
let mut offset = 0;
for line in rest.lines() {
if line.trim_end() == "---" {
return Some(&rest[..offset]);
}
offset += line.len() + 1;
}
None
}
fn top_level_fields(frontmatter: &str) -> BTreeMap<String, String> {
let mut fields = BTreeMap::new();
let mut lines = frontmatter.lines().peekable();
while let Some(line) = lines.next() {
if line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty() {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim().to_owned();
let value = value.trim();
let value = if matches!(value, "|" | "|-" | "|+" | ">" | ">-" | ">+") {
let folded = value.starts_with('>');
let mut parts = Vec::new();
while let Some(next) = lines.peek() {
let indented = next.starts_with(' ') || next.starts_with('\t');
if !indented && !next.trim().is_empty() {
break;
}
parts.push(lines.next().unwrap_or_default().trim().to_owned());
}
while parts.last().is_some_and(|part| part.is_empty()) {
parts.pop();
}
if folded {
parts.join(" ")
} else {
parts.join("\n")
}
} else {
unquote(value)
};
fields.insert(key, value);
}
fields
}
fn unquote(value: &str) -> String {
for quote in ['"', '\''] {
if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
return value[1..value.len() - 1].to_owned();
}
}
value.to_owned()
}