microagents_core/
skills.rs1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5use std::{collections::HashMap, fs, io};
6use thiserror::Error;
7
8pub static GLOBAL_SKILLS_PATH: OnceLock<PathBuf> = OnceLock::new();
10
11pub fn global_skills_path() -> &'static PathBuf {
13 GLOBAL_SKILLS_PATH.get_or_init(|| {
14 dirs::home_dir()
15 .expect("could not determine home directory")
16 .join(".agents")
17 .join("skills")
18 })
19}
20
21pub const SKILLS_PATH: &str = ".agents/skills";
23
24fn null_as_empty_map<'de, D>(
25 deserializer: D,
26) -> std::result::Result<Option<HashMap<String, Value>>, D::Error>
27where
28 D: serde::Deserializer<'de>,
29{
30 let opt = Option::<HashMap<String, Value>>::deserialize(deserializer)?;
31 match opt {
32 Some(m) => Ok(Some(m)),
33 None => Ok(Some(HashMap::new())),
34 }
35}
36
37fn null_as_empty_string<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
38where
39 D: serde::Deserializer<'de>,
40{
41 let opt = Option::<String>::deserialize(deserializer)?;
42 match opt {
43 Some(s) => Ok(Some(s)),
44 None => Ok(Some(String::new())),
45 }
46}
47
48#[derive(Debug, Serialize, Deserialize)]
50struct SkillFrontmatter {
51 name: String,
52 description: String,
53 #[serde(default, deserialize_with = "null_as_empty_string")]
54 compatibility: Option<String>,
55 #[serde(
56 default,
57 rename = "allowed-tools",
58 deserialize_with = "null_as_empty_string"
59 )]
60 allowed_tools: Option<String>,
61 #[serde(default, deserialize_with = "null_as_empty_map")]
62 metadata: Option<HashMap<String, Value>>,
63 #[serde(default, deserialize_with = "null_as_empty_string")]
64 license: Option<String>,
65}
66
67#[derive(Debug, Error)]
69pub enum SkillLoadingError {
70 #[error("Error while reading the skill file")]
72 SkillReadError(#[from] io::Error),
73 #[error("Error while parsing the skill's frontmatter")]
75 SkillFrontMatterError(#[from] markdown_frontmatter::Error),
76}
77
78pub fn parse_skill(skill_file: &Path) -> Result<String, SkillLoadingError> {
83 let content = fs::read_to_string(skill_file)?;
84 let (frontmatter, _) = markdown_frontmatter::parse::<SkillFrontmatter>(&content)?;
85
86 Ok(frontmatter.description)
87}
88
89pub fn ensure_skill(skill_name: &str) -> Option<PathBuf> {
94 let g = global_skills_path().join(skill_name);
95 let p = PathBuf::from(SKILLS_PATH).join(skill_name);
96 if p.exists() {
97 return Some(p);
98 } else if g.exists() {
99 return Some(g);
100 }
101 None
102}
103
104pub fn find_skills() -> Result<HashMap<String, String>, SkillLoadingError> {
108 let g = global_skills_path();
109 let p = PathBuf::from(SKILLS_PATH);
110 let mut all_skills = HashMap::new();
111 if g.exists() {
112 let result = fs::read_dir(g)?;
113 for entry in result {
114 let entry = entry?;
115 if entry.path().is_dir() {
116 let des = parse_skill(&entry.path().join("SKILL.md"))?;
117 all_skills.insert(entry.file_name().to_string_lossy().into_owned(), des);
118 }
119 }
120 }
121
122 if p.exists() {
123 let result = fs::read_dir(p)?;
124 for entry in result {
125 let entry = entry?;
126 if entry.path().is_dir() {
127 let des = parse_skill(&entry.path().join("SKILL.md"))?;
128 all_skills.insert(entry.file_name().to_string_lossy().into_owned(), des);
129 }
130 }
131 }
132
133 Ok(all_skills)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use std::io::Write;
140
141 fn write_skill_md(dir: &std::path::Path, content: &str) {
142 let path = dir.join("SKILL.md");
143 let mut file = std::fs::File::create(&path).unwrap();
144 file.write_all(content.as_bytes()).unwrap();
145 }
146
147 #[test]
148 fn test_parse_skill_basic() {
149 let tmp = tempfile::tempdir().unwrap();
150 let skill_md = tmp.path().join("SKILL.md");
151 fs::write(
152 &skill_md,
153 "---\nname: rust\ndescription: Best practices for Rust\n---\n\n# Rust\n",
154 )
155 .unwrap();
156 let desc = parse_skill(&skill_md).unwrap();
157 assert_eq!(desc, "Best practices for Rust");
158 }
159
160 #[test]
161 fn test_parse_skill_with_all_frontmatter_fields() {
162 let tmp = tempfile::tempdir().unwrap();
163 let skill_md = tmp.path().join("SKILL.md");
164 fs::write(
165 &skill_md,
166 "---\n\
167 name: python\n\
168 description: Python skill\n\
169 compatibility: \">=3.10\"\n\
170 allowed-tools: read,write\n\
171 metadata:\n\
172 foo: bar\n\
173 license: MIT\n\
174 ---\n\n\
175 # Python\n",
176 )
177 .unwrap();
178 let desc = parse_skill(&skill_md).unwrap();
179 assert_eq!(desc, "Python skill");
180 }
181
182 #[test]
183 fn test_parse_skill_missing_frontmatter_fails() {
184 let tmp = tempfile::tempdir().unwrap();
185 let skill_md = tmp.path().join("SKILL.md");
186 fs::write(&skill_md, "# No frontmatter\n").unwrap();
187 let err = parse_skill(&skill_md).unwrap_err();
188 assert!(
189 matches!(err, SkillLoadingError::SkillFrontMatterError(_)),
190 "expected frontmatter error, got {:?}",
191 err
192 );
193 }
194
195 #[test]
196 fn test_parse_skill_missing_file_fails() {
197 let tmp = tempfile::tempdir().unwrap();
198 let missing = tmp.path().join("missing").join("SKILL.md");
199 let err = parse_skill(&missing).unwrap_err();
200 assert!(
201 matches!(err, SkillLoadingError::SkillReadError(_)),
202 "expected read error, got {:?}",
203 err
204 );
205 }
206
207 #[test]
208 #[serial_test::serial]
209 fn test_ensure_skill_prefers_local_path() {
210 let tmp = tempfile::tempdir().unwrap();
211 let local_skills = tmp.path().join(".agents").join("skills").join("test-skill");
212 fs::create_dir_all(&local_skills).unwrap();
213 fs::write(
214 local_skills.join("SKILL.md"),
215 "---\nname: test\ndescription: local\n---\n",
216 )
217 .unwrap();
218
219 let global_skills = tmp.path().join("global").join("skills").join("test-skill");
221 fs::create_dir_all(&global_skills).unwrap();
222 fs::write(
223 global_skills.join("SKILL.md"),
224 "---\nname: test\ndescription: global\n---\n",
225 )
226 .unwrap();
227
228 let original = std::env::current_dir().unwrap();
231 std::env::set_current_dir(tmp.path()).unwrap();
232 let result = ensure_skill("test-skill");
233 std::env::set_current_dir(original).unwrap();
234
235 let path = result.unwrap();
236 let path_str = path.to_string_lossy();
237 assert!(path_str.contains(".agents"));
238 assert!(path_str.contains("skills"));
239 assert!(path_str.contains("test-skill"));
240 }
241
242 #[test]
243 fn test_ensure_skill_not_found() {
244 let result = ensure_skill("definitely-nonexistent-skill-12345");
245 assert!(result.is_none());
246 }
247
248 #[test]
249 #[serial_test::serial]
250 fn test_find_skills_local_only() {
251 let tmp = tempfile::tempdir().unwrap();
252 let skills_dir = tmp.path().join(".agents").join("skills");
253 fs::create_dir_all(&skills_dir).unwrap();
254
255 let skill_a = skills_dir.join("skill-a");
256 fs::create_dir(&skill_a).unwrap();
257 write_skill_md(&skill_a, "---\nname: skill-a\ndescription: Skill A\n---\n");
258
259 let skill_b = skills_dir.join("skill-b");
260 fs::create_dir(&skill_b).unwrap();
261 write_skill_md(&skill_b, "---\nname: skill-b\ndescription: Skill B\n---\n");
262
263 let original = std::env::current_dir().unwrap();
264 std::env::set_current_dir(tmp.path()).unwrap();
265 let skills = find_skills().unwrap();
266 std::env::set_current_dir(original).unwrap();
267
268 let local_skills: Vec<_> = skills
270 .into_iter()
271 .filter(|(name, _)| name.starts_with("skill-"))
272 .collect();
273 assert_eq!(local_skills.len(), 2);
274 let mut names: Vec<_> = local_skills.into_iter().map(|(n, _)| n).collect();
275 names.sort();
276 assert_eq!(names, vec!["skill-a", "skill-b"]);
277 }
278
279 #[test]
280 #[serial_test::serial]
281 fn test_find_skills_empty_when_no_dirs() {
282 let tmp = tempfile::tempdir().unwrap();
283 let original = std::env::current_dir().unwrap();
284 std::env::set_current_dir(tmp.path()).unwrap();
285 let skills = find_skills().unwrap();
286 std::env::set_current_dir(original).unwrap();
287 let local_skills: Vec<_> = skills
289 .into_iter()
290 .filter(|(name, _)| name.starts_with("test-"))
291 .collect();
292 assert!(local_skills.is_empty());
293 }
294
295 #[test]
296 fn test_skill_frontmatter_null_handling() {
297 let yaml = r#"---
298name: null-test
299description: Handles nulls
300compatibility: null
301allowed-tools: null
302metadata: null
303license: null
304---
305"#;
306 let tmp = tempfile::tempdir().unwrap();
307 let path = tmp.path().join("SKILL.md");
308 fs::write(&path, yaml).unwrap();
309
310 let desc = parse_skill(&path).unwrap();
311 assert_eq!(desc, "Handles nulls");
312 }
313}