use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::format::Planning;
pub const DIRS: [&str; 2] = [".claude/skills", ".agents/skills"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub path: String,
pub invocable: bool,
pub refused: Option<String>,
}
#[must_use]
pub fn refusal(name: &str) -> Option<String> {
(crate::format::reference().planning().refused)(name)
}
#[must_use]
pub fn split(text: &str) -> (Vec<(String, String)>, String) {
let Some(rest) = text
.strip_prefix("---\n")
.or_else(|| text.strip_prefix("---\r\n"))
else {
return (Vec::new(), text.to_owned());
};
let Some(end) = rest.find("\n---") else {
return (Vec::new(), text.to_owned());
};
let fields = rest[..end]
.lines()
.filter(|l| !l.starts_with(' ') && !l.starts_with('\t'))
.filter_map(|l| l.split_once(':'))
.map(|(k, v)| {
let v = v.trim().trim_matches('"').trim_matches('\'').to_owned();
(k.trim().to_owned(), v)
})
.collect();
let body = rest[end + 4..].trim_start_matches(['\r', '\n']).to_owned();
(fields, body)
}
#[must_use]
pub fn discover(root: &Path) -> Vec<Skill> {
discover_with(root, crate::format::reference().planning())
}
#[must_use]
pub fn discover_with(root: &Path, planning: &Planning) -> Vec<Skill> {
let mut out: Vec<Skill> = Vec::new();
for dir in DIRS {
let Ok(entries) = std::fs::read_dir(root.join(dir)) else {
continue;
};
for e in entries.flatten() {
let file = e.path().join("SKILL.md");
let Ok(text) = std::fs::read_to_string(&file) else {
continue;
};
let (fields, _) = split(&text);
let get = |k: &str| fields.iter().find(|(f, _)| f == k).map(|(_, v)| v.clone());
let name = get("name").unwrap_or_else(|| e.file_name().to_string_lossy().into_owned());
if out.iter().any(|s| s.name == name) {
continue; }
out.push(Skill {
refused: (planning.refused)(&name),
description: get("description").unwrap_or_default(),
path: format!("{dir}/{}/SKILL.md", e.file_name().to_string_lossy()),
invocable: get("user-invocable").is_none_or(|v| v != "false"),
name,
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn instruction(root: &Path, skill: &Skill, arguments: &str) -> Result<String, String> {
let path: PathBuf = root.join(&skill.path);
let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let (_, body) = split(&text);
Ok(body.replace("$ARGUMENTS", arguments))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frontmatter_and_body_split_and_nested_keys_are_ignored() {
let text = "---\nname: \"speckit-plan\"\ndescription: \"Plan it.\"\nmetadata:\n author: \"x\"\nuser-invocable: true\n---\n\n## User Input\n\n$ARGUMENTS\n";
let (fields, body) = split(text);
assert_eq!(
fields,
[
("name".to_owned(), "speckit-plan".to_owned()),
("description".to_owned(), "Plan it.".to_owned()),
("metadata".to_owned(), String::new()),
("user-invocable".to_owned(), "true".to_owned()),
]
);
assert!(body.starts_with("## User Input"));
assert!(refusal("speckit-implement").is_some() && refusal("speckit-plan").is_none());
}
}