speccoder_runtime/
skills.rs1use std::path::{Path, PathBuf};
11
12use serde::Serialize;
13
14use crate::format::Planning;
15
16pub const DIRS: [&str; 2] = [".claude/skills", ".agents/skills"];
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Skill {
22 pub name: String,
24 pub description: String,
26 pub path: String,
28 pub invocable: bool,
30 pub refused: Option<String>,
32}
33
34#[must_use]
37pub fn refusal(name: &str) -> Option<String> {
38 (crate::format::reference().planning().refused)(name)
39}
40
41#[must_use]
43pub fn split(text: &str) -> (Vec<(String, String)>, String) {
44 let Some(rest) = text
45 .strip_prefix("---\n")
46 .or_else(|| text.strip_prefix("---\r\n"))
47 else {
48 return (Vec::new(), text.to_owned());
49 };
50 let Some(end) = rest.find("\n---") else {
51 return (Vec::new(), text.to_owned());
52 };
53 let fields = rest[..end]
54 .lines()
55 .filter(|l| !l.starts_with(' ') && !l.starts_with('\t'))
56 .filter_map(|l| l.split_once(':'))
57 .map(|(k, v)| {
58 let v = v.trim().trim_matches('"').trim_matches('\'').to_owned();
59 (k.trim().to_owned(), v)
60 })
61 .collect();
62 let body = rest[end + 4..].trim_start_matches(['\r', '\n']).to_owned();
63 (fields, body)
64}
65
66#[must_use]
69pub fn discover(root: &Path) -> Vec<Skill> {
70 discover_with(root, crate::format::reference().planning())
71}
72
73#[must_use]
75pub fn discover_with(root: &Path, planning: &Planning) -> Vec<Skill> {
76 let mut out: Vec<Skill> = Vec::new();
77 for dir in DIRS {
78 let Ok(entries) = std::fs::read_dir(root.join(dir)) else {
79 continue;
80 };
81 for e in entries.flatten() {
82 let file = e.path().join("SKILL.md");
83 let Ok(text) = std::fs::read_to_string(&file) else {
84 continue;
85 };
86 let (fields, _) = split(&text);
87 let get = |k: &str| fields.iter().find(|(f, _)| f == k).map(|(_, v)| v.clone());
88 let name = get("name").unwrap_or_else(|| e.file_name().to_string_lossy().into_owned());
89 if out.iter().any(|s| s.name == name) {
90 continue; }
92 out.push(Skill {
93 refused: (planning.refused)(&name),
94 description: get("description").unwrap_or_default(),
95 path: format!("{dir}/{}/SKILL.md", e.file_name().to_string_lossy()),
96 invocable: get("user-invocable").is_none_or(|v| v != "false"),
97 name,
98 });
99 }
100 }
101 out.sort_by(|a, b| a.name.cmp(&b.name));
102 out
103}
104
105pub fn instruction(root: &Path, skill: &Skill, arguments: &str) -> Result<String, String> {
107 let path: PathBuf = root.join(&skill.path);
108 let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
109 let (_, body) = split(&text);
110 Ok(body.replace("$ARGUMENTS", arguments))
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn frontmatter_and_body_split_and_nested_keys_are_ignored() {
119 let text = "---\nname: \"speckit-plan\"\ndescription: \"Plan it.\"\nmetadata:\n author: \"x\"\nuser-invocable: true\n---\n\n## User Input\n\n$ARGUMENTS\n";
120 let (fields, body) = split(text);
121 assert_eq!(
122 fields,
123 [
124 ("name".to_owned(), "speckit-plan".to_owned()),
125 ("description".to_owned(), "Plan it.".to_owned()),
126 ("metadata".to_owned(), String::new()),
127 ("user-invocable".to_owned(), "true".to_owned()),
128 ]
129 );
130 assert!(body.starts_with("## User Input"));
131 assert!(refusal("speckit-implement").is_some() && refusal("speckit-plan").is_none());
132 }
133}