use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use super::{Error, Result};
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct SkillFrontmatter {
pub name: String,
pub description: String,
#[serde(default)]
pub metadata: BTreeMap<String, serde_json::Value>,
#[serde(skip)]
pub raw: serde_json::Map<String, serde_json::Value>,
}
fn extract_frontmatter_block(content: &str) -> Option<&str> {
let rest = content.strip_prefix("---")?;
let rest = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))?;
let mut offset = 0;
for line in rest.split_inclusive('\n') {
let text = line.strip_suffix('\n').unwrap_or(line);
let text = text.strip_suffix('\r').unwrap_or(text);
if text == "---" {
return Some(&rest[..offset]);
}
offset += line.len();
}
None
}
pub fn skill_body(content: &str) -> &str {
let Some(block) = extract_frontmatter_block(content) else {
return content;
};
let consumed = block.as_ptr() as usize - content.as_ptr() as usize + block.len();
let after_block = &content[consumed..];
let after_fence = after_block
.split_inclusive('\n')
.next()
.map_or("", |fence| &after_block[fence.len()..]);
after_fence.trim_start_matches(['\n', '\r'])
}
pub fn parse_frontmatter(content: &str) -> Result<SkillFrontmatter> {
let block = extract_frontmatter_block(content).ok_or(Error::MissingFrontmatter)?;
let mut parsed = serde_saphyr::from_str::<SkillFrontmatter>(block)
.map_err(|e| Error::Frontmatter(e.to_string()))?;
let raw = serde_saphyr::from_str::<serde_json::Value>(block)
.map_err(|e| Error::Frontmatter(e.to_string()))?;
parsed.raw = raw
.as_object()
.cloned()
.ok_or_else(|| Error::Frontmatter("frontmatter must be a YAML mapping".to_string()))?;
Ok(parsed)
}
fn skill_search_roots(root: &Path) -> Vec<PathBuf> {
vec![
root.to_path_buf(),
root.join("skills"),
root.join(".claude").join("skills"),
]
}
pub(crate) fn is_safe_installed_path(root: &Path, candidate: &Path) -> bool {
let Ok(relative) = candidate.strip_prefix(root) else {
return false;
};
let (Ok(canonical_root), Ok(canonical_candidate)) =
(root.canonicalize(), candidate.canonicalize())
else {
return false;
};
if !canonical_candidate.starts_with(&canonical_root) {
return false;
}
let mut cursor = root.to_path_buf();
for component in relative.components() {
let std::path::Component::Normal(segment) = component else {
return false;
};
cursor.push(segment);
let Ok(metadata) = std::fs::symlink_metadata(&cursor) else {
return false;
};
if metadata.file_type().is_symlink() {
return false;
}
}
true
}
#[derive(Debug)]
pub struct DiscoveredSkill {
pub dir: PathBuf,
pub skill_md: PathBuf,
pub frontmatter: Result<SkillFrontmatter>,
}
impl DiscoveredSkill {
pub fn name(&self) -> String {
match &self.frontmatter {
Ok(fm) => fm.name.clone(),
Err(_) => self
.dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string(),
}
}
pub fn description(&self) -> &str {
match &self.frontmatter {
Ok(fm) => fm.description.as_str(),
Err(_) => "",
}
}
pub fn problem(&self) -> Option<String> {
let fm = match &self.frontmatter {
Ok(fm) => fm,
Err(e) => return Some(e.to_string()),
};
let dir_name = self.dir.file_name().and_then(|n| n.to_str());
if dir_name != Some(fm.name.as_str()) {
return Some(format!(
"directory is named {:?} but frontmatter declares name {:?}; \
they must match",
dir_name.unwrap_or_default(),
fm.name
));
}
if !super::is_valid_skill_name(&fm.name) {
return Some(super::Error::InvalidSkillName(fm.name.clone()).to_string());
}
if !super::is_valid_skill_description(&fm.description) {
return Some("description must be 1-1024 characters".to_string());
}
None
}
}
pub fn discover_all_skills(root: &Path) -> Vec<DiscoveredSkill> {
let mut found = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let mut push = |path: PathBuf, found: &mut Vec<DiscoveredSkill>| {
if !is_safe_installed_path(root, &path) {
return;
}
if !seen.insert(path.clone()) {
return;
}
let Some(dir) = path.parent().map(Path::to_path_buf) else {
return;
};
let frontmatter = match std::fs::read_to_string(&path) {
Ok(content) => parse_frontmatter(&content),
Err(e) => Err(Error::Io(format!("reading {}: {e}", path.display()))),
};
found.push(DiscoveredSkill {
dir,
skill_md: path,
frontmatter,
});
};
for base in skill_search_roots(root) {
if !is_safe_installed_path(root, &base) {
continue;
}
push(base.join("SKILL.md"), &mut found);
let Ok(entries) = std::fs::read_dir(&base) else {
continue;
};
for entry in entries.flatten() {
push(entry.path().join("SKILL.md"), &mut found);
}
}
found.sort_by(|a, b| a.skill_md.cmp(&b.skill_md));
found
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_name_and_description() {
let content = "---\nname: my-skill\ndescription: Does a thing.\n---\n\n# Body\n";
let fm = parse_frontmatter(content).expect("should parse");
assert_eq!(fm.name, "my-skill");
assert_eq!(fm.description, "Does a thing.");
assert!(fm.metadata.is_empty());
}
#[test]
fn parses_metadata_map() {
let content = "---\nname: s\ndescription: d\nmetadata:\n version: \"1.2.0\"\n internal: true\n---\nbody";
let fm = parse_frontmatter(content).expect("should parse");
assert_eq!(
fm.metadata.get("version"),
Some(&serde_json::Value::String("1.2.0".to_string()))
);
assert_eq!(
fm.metadata.get("internal"),
Some(&serde_json::Value::Bool(true))
);
}
#[test]
fn four_dash_line_is_not_a_closing_fence() {
let content = "---\nname: s\ndescription: d\n----\nmore\n";
let err = parse_frontmatter(content).expect_err("no exact --- fence");
assert!(matches!(err, Error::MissingFrontmatter));
}
#[test]
fn inner_dashes_do_not_truncate_block() {
let content = "---\nname: s\ndescription: d\nnote: \"----\"\n---\nbody\n";
let fm = parse_frontmatter(content).expect("parses to the real fence");
assert_eq!(fm.name, "s");
assert_eq!(fm.description, "d");
}
#[test]
fn missing_frontmatter_is_an_error() {
let err = parse_frontmatter("# Just a heading\n").expect_err("no fence");
assert!(matches!(err, Error::MissingFrontmatter));
}
#[test]
fn unterminated_frontmatter_is_missing() {
let err = parse_frontmatter("---\nname: s\n").expect_err("no closing fence");
assert!(matches!(err, Error::MissingFrontmatter));
}
#[test]
fn malformed_yaml_is_a_parse_error() {
let err = parse_frontmatter("---\nname: s\n---\n").expect_err("incomplete");
assert!(matches!(err, Error::Frontmatter(_)));
}
#[test]
fn discovers_skill_in_root() {
let dir = tempdir("discover-root");
std::fs::write(
dir.join("SKILL.md"),
"---\nname: root-skill\ndescription: d\n---\n",
)
.unwrap();
let found = discover_all_skills(&dir).into_iter().next().expect("found");
assert_eq!(found.name(), "root-skill");
assert!(found.skill_md.ends_with("SKILL.md"));
assert_eq!(found.dir, dir);
cleanup(&dir);
}
#[test]
fn discovers_skill_in_skills_subdir() {
let dir = tempdir("discover-subdir");
let nested = dir.join("skills").join("alpha");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(
nested.join("SKILL.md"),
"---\nname: alpha\ndescription: d\n---\n",
)
.unwrap();
let found = discover_all_skills(&dir).into_iter().next().expect("found");
assert_eq!(found.name(), "alpha");
cleanup(&dir);
}
#[test]
fn discover_all_finds_multiple() {
let dir = tempdir("discover-all");
let skills = dir.join("skills");
for name in ["one", "two"] {
let nested = skills.join(name);
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(
nested.join("SKILL.md"),
format!("---\nname: {name}\ndescription: d\n---\n"),
)
.unwrap();
}
let all = discover_all_skills(&dir);
let mut names: Vec<_> = all.iter().map(DiscoveredSkill::name).collect();
names.sort();
assert_eq!(names, vec!["one".to_string(), "two".to_string()]);
assert!(all.iter().all(|s| s.problem().is_none()));
cleanup(&dir);
}
#[test]
fn a_skill_with_broken_frontmatter_is_discovered_and_carries_a_problem() {
let dir = tempdir("discover-broken");
let nested = dir.join(".claude").join("skills").join("broken");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("SKILL.md"), "---\nname: broken\n---\n").unwrap();
let found = discover_all_skills(&dir).into_iter().next().expect("found");
assert_eq!(found.name(), "broken", "falls back to the directory name");
assert_eq!(found.description(), "");
assert!(
found.problem().is_some_and(|p| p.contains("frontmatter")),
"{:?}",
found.problem()
);
cleanup(&dir);
}
#[test]
fn a_directory_name_mismatch_is_a_problem_not_a_disappearance() {
let dir = tempdir("discover-mismatch");
let nested = dir.join(".claude").join("skills").join("on-disk");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(
nested.join("SKILL.md"),
"---\nname: in-frontmatter\ndescription: d\n---\n",
)
.unwrap();
let found = discover_all_skills(&dir).into_iter().next().expect("found");
assert_eq!(found.name(), "in-frontmatter");
let problem = found.problem().expect("mismatch is a problem");
assert!(
problem.contains("on-disk") && problem.contains("in-frontmatter"),
"{problem}"
);
cleanup(&dir);
}
#[test]
fn body_starts_after_the_frontmatter_fence() {
assert_eq!(
skill_body("---\nname: s\ndescription: d\n---\n\n# Alpha\n\nRun it.\n"),
"# Alpha\n\nRun it.\n"
);
assert_eq!(
skill_body("---\r\nname: s\r\ndescription: d\r\n---\r\n# Alpha\r\n"),
"# Alpha\r\n"
);
assert_eq!(skill_body("# Just a heading\n"), "# Just a heading\n");
assert_eq!(skill_body("---\nname: s\n"), "---\nname: s\n");
}
#[test]
fn discover_finds_nothing_when_empty() {
let dir = tempdir("discover-empty");
assert!(discover_all_skills(&dir).is_empty());
cleanup(&dir);
}
fn tempdir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("brskills-fm-{label}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn cleanup(dir: &Path) {
let _ = std::fs::remove_dir_all(dir);
}
}