Skip to main content

microagents_core/
skills.rs

1use 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
8/// Lazily-initialised path to the global skills directory (`~/.agents/skills`).
9pub static GLOBAL_SKILLS_PATH: OnceLock<PathBuf> = OnceLock::new();
10
11/// Return the global skills directory, creating the cached path on first call.
12pub 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
21/// Relative path to the project-local skills directory.
22pub 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/// Frontmatter extracted from a skill's `SKILL.md` file.
49#[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/// Errors that can occur while loading a skill from disk.
68#[derive(Debug, Error)]
69pub enum SkillLoadingError {
70    /// The skill file could not be read.
71    #[error("Error while reading the skill file")]
72    SkillReadError(#[from] io::Error),
73    /// The YAML/TOML frontmatter in the skill file is invalid.
74    #[error("Error while parsing the skill's frontmatter")]
75    SkillFrontMatterError(#[from] markdown_frontmatter::Error),
76}
77
78/// Parse a skill's `SKILL.md` and return its description.
79///
80/// The file is expected to contain YAML frontmatter with at least a
81/// `description` field.
82pub 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
89/// Locate a skill by name, preferring the local project directory.
90///
91/// Searches `.agents/skills/{name}` first, then `~/.agents/skills/{name}`.
92/// Returns `None` if the skill cannot be found in either location.
93pub 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
104/// Discover all available skills in both local and global directories.
105///
106/// Duplicates are removed; local skills shadow global ones.
107pub 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        // Override global path to something else so local wins
220        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        // We can't easily override OnceLock in tests, but we can at least verify
229        // that a local path is returned when it exists by temporarily changing CWD
230        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        // Only count skills that came from our temp directory
269        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        // Only count skills that came from our temp directory
288        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}