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 name.contains(':') {
91 continue;
92 }
93 if out.iter().any(|s| s.name == name) {
94 continue; }
96 out.push(Skill {
97 refused: (planning.refused)(&name),
98 description: get("description").unwrap_or_default(),
99 path: format!("{dir}/{}/SKILL.md", e.file_name().to_string_lossy()),
100 invocable: get("user-invocable").is_none_or(|v| v != "false"),
101 name,
102 });
103 }
104 }
105 out.sort_by(|a, b| a.name.cmp(&b.name));
106 out
107}
108
109#[must_use]
114pub fn discover_trusted(root: &Path, planning: &Planning, state: &Path) -> Vec<Skill> {
115 use speccoder_core::extensions::ExtensionSource;
116 let mut out = discover_with(root, planning);
117 let dir = crate::hooks::dir(state, root);
118 for e in crate::extensions::load(root) {
119 let trusted = e.usable()
120 && crate::extensions::trust::state(&dir, &e)
121 == speccoder_core::hooks::TrustState::Trusted;
122 if e.source == ExtensionSource::AgentPlugins && trusted {
123 {
124 for (name, file) in &e.skills {
125 let text = std::fs::read_to_string(file).unwrap_or_default();
126 let (fields, _) = split(&text);
127 let rel = file
128 .strip_prefix(root)
129 .unwrap_or(file)
130 .to_string_lossy()
131 .replace('\\', "/");
132 out.push(Skill {
133 name: name.clone(),
134 description: fields
135 .iter()
136 .find(|(k, _)| k == "description")
137 .map(|(_, v)| v.clone())
138 .unwrap_or_default(),
139 path: rel,
140 invocable: true,
141 refused: None,
142 });
143 }
144 }
145 }
146 }
147 out.sort_by(|a, b| a.name.cmp(&b.name));
148 out
149}
150
151pub fn instruction(root: &Path, skill: &Skill, arguments: &str) -> Result<String, String> {
153 let path: PathBuf = root.join(&skill.path);
154 let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
155 let (_, body) = split(&text);
156 Ok(body.replace("$ARGUMENTS", arguments))
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn frontmatter_and_body_split_and_nested_keys_are_ignored() {
165 let text = "---\nname: \"speckit-plan\"\ndescription: \"Plan it.\"\nmetadata:\n author: \"x\"\nuser-invocable: true\n---\n\n## User Input\n\n$ARGUMENTS\n";
166 let (fields, body) = split(text);
167 assert_eq!(
168 fields,
169 [
170 ("name".to_owned(), "speckit-plan".to_owned()),
171 ("description".to_owned(), "Plan it.".to_owned()),
172 ("metadata".to_owned(), String::new()),
173 ("user-invocable".to_owned(), "true".to_owned()),
174 ]
175 );
176 assert!(body.starts_with("## User Input"));
177 assert!(refusal("speckit-implement").is_some() && refusal("speckit-plan").is_none());
178 }
179}