Skip to main content

agent_core_runtime/skills/
discovery.rs

1//! Skill discovery by scanning directories for SKILL.md files.
2
3use crate::skills::parser::parse_skill_md;
4use crate::skills::types::{Skill, SkillDiscoveryError};
5use std::path::PathBuf;
6
7/// Default skill directory name within a project.
8const PROJECT_SKILLS_DIR: &str = ".skills";
9
10/// Default user skills directory name.
11const USER_SKILLS_DIR: &str = ".agent-core/skills";
12
13/// Discovers skills from configured directories.
14#[derive(Debug, Default)]
15pub struct SkillDiscovery {
16    search_paths: Vec<PathBuf>,
17}
18
19impl SkillDiscovery {
20    /// Create a new skill discovery instance with default search paths.
21    ///
22    /// Default paths:
23    /// 1. `$PWD/.skills/` - Project-local skills
24    /// 2. `~/.agent-core/skills/` - User skills
25    pub fn new() -> Self {
26        let mut discovery = Self::default();
27
28        // Add project-local skills directory
29        if let Ok(cwd) = std::env::current_dir() {
30            let project_skills = cwd.join(PROJECT_SKILLS_DIR);
31            discovery.add_path(project_skills);
32        }
33
34        // Add user skills directory
35        if let Some(home) = dirs::home_dir() {
36            let user_skills = home.join(USER_SKILLS_DIR);
37            discovery.add_path(user_skills);
38        }
39
40        discovery
41    }
42
43    /// Create an empty skill discovery instance with no default paths.
44    pub fn empty() -> Self {
45        Self::default()
46    }
47
48    /// Add a search path for skill discovery.
49    pub fn add_path(&mut self, path: PathBuf) {
50        if !self.search_paths.contains(&path) {
51            self.search_paths.push(path);
52        }
53    }
54
55    /// Get the configured search paths.
56    pub fn search_paths(&self) -> &[PathBuf] {
57        &self.search_paths
58    }
59
60    /// Discover all skills from configured directories.
61    ///
62    /// Returns a vector of results, one for each skill found or error encountered.
63    /// Each immediate subdirectory containing a SKILL.md file is treated as a skill.
64    pub fn discover(&self) -> Vec<Result<Skill, SkillDiscoveryError>> {
65        let mut results = Vec::new();
66
67        for search_path in &self.search_paths {
68            if !search_path.exists() {
69                continue;
70            }
71
72            if !search_path.is_dir() {
73                results.push(Err(SkillDiscoveryError::new(
74                    search_path.clone(),
75                    "Search path is not a directory",
76                )));
77                continue;
78            }
79
80            // Look for immediate subdirectories containing SKILL.md
81            let entries = match std::fs::read_dir(search_path) {
82                Ok(entries) => entries,
83                Err(e) => {
84                    results.push(Err(SkillDiscoveryError::new(
85                        search_path.clone(),
86                        format!("Failed to read directory: {}", e),
87                    )));
88                    continue;
89                }
90            };
91
92            for entry in entries.flatten() {
93                let skill_dir = entry.path();
94                if !skill_dir.is_dir() {
95                    continue;
96                }
97
98                let skill_md_path = skill_dir.join("SKILL.md");
99                if !skill_md_path.exists() {
100                    continue;
101                }
102
103                match parse_skill_md(&skill_md_path) {
104                    Ok(metadata) => {
105                        let skill = Skill {
106                            metadata,
107                            path: skill_dir.canonicalize().unwrap_or(skill_dir),
108                            skill_md_path: skill_md_path.canonicalize().unwrap_or(skill_md_path),
109                        };
110                        results.push(Ok(skill));
111                    }
112                    Err(e) => {
113                        results.push(Err(e));
114                    }
115                }
116            }
117        }
118
119        results
120    }
121
122    /// Discover skills and collect only the successful ones.
123    ///
124    /// Errors are silently ignored. Use `discover()` if you need error information.
125    pub fn discover_valid(&self) -> Vec<Skill> {
126        self.discover().into_iter().filter_map(Result::ok).collect()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::fs;
134    use tempfile::TempDir;
135
136    fn create_skill(dir: &TempDir, name: &str, description: &str) -> PathBuf {
137        let skill_dir = dir.path().join(name);
138        fs::create_dir_all(&skill_dir).unwrap();
139
140        let skill_md = skill_dir.join("SKILL.md");
141        let content = format!(
142            r#"---
143name: {}
144description: {}
145---
146
147# {}
148
149Instructions here.
150"#,
151            name, description, name
152        );
153        fs::write(&skill_md, content).unwrap();
154
155        skill_dir
156    }
157
158    #[test]
159    fn test_discover_skills() {
160        let temp_dir = TempDir::new().unwrap();
161
162        create_skill(&temp_dir, "skill-one", "First test skill");
163        create_skill(&temp_dir, "skill-two", "Second test skill");
164
165        let mut discovery = SkillDiscovery::empty();
166        discovery.add_path(temp_dir.path().to_path_buf());
167
168        let results = discovery.discover();
169        let skills: Vec<_> = results.into_iter().filter_map(Result::ok).collect();
170
171        assert_eq!(skills.len(), 2);
172
173        let names: Vec<_> = skills.iter().map(|s| s.metadata.name.as_str()).collect();
174        assert!(names.contains(&"skill-one"));
175        assert!(names.contains(&"skill-two"));
176    }
177
178    #[test]
179    fn test_discover_ignores_non_skill_dirs() {
180        let temp_dir = TempDir::new().unwrap();
181
182        // Create a valid skill
183        create_skill(&temp_dir, "valid-skill", "A valid skill");
184
185        // Create a directory without SKILL.md
186        let other_dir = temp_dir.path().join("other-dir");
187        fs::create_dir_all(&other_dir).unwrap();
188        fs::write(other_dir.join("README.md"), "Not a skill").unwrap();
189
190        // Create a file (not directory)
191        fs::write(temp_dir.path().join("random-file.txt"), "Not a skill").unwrap();
192
193        let mut discovery = SkillDiscovery::empty();
194        discovery.add_path(temp_dir.path().to_path_buf());
195
196        let skills = discovery.discover_valid();
197
198        assert_eq!(skills.len(), 1);
199        assert_eq!(skills[0].metadata.name, "valid-skill");
200    }
201
202    #[test]
203    fn test_discover_reports_invalid_skills() {
204        let temp_dir = TempDir::new().unwrap();
205
206        // Create an invalid skill (bad name)
207        let skill_dir = temp_dir.path().join("invalid");
208        fs::create_dir_all(&skill_dir).unwrap();
209        fs::write(
210            skill_dir.join("SKILL.md"),
211            r#"---
212name: InvalidName
213description: Bad name format.
214---
215"#,
216        )
217        .unwrap();
218
219        let mut discovery = SkillDiscovery::empty();
220        discovery.add_path(temp_dir.path().to_path_buf());
221
222        let results = discovery.discover();
223
224        assert_eq!(results.len(), 1);
225        assert!(results[0].is_err());
226    }
227
228    #[test]
229    fn test_discover_nonexistent_path() {
230        let mut discovery = SkillDiscovery::empty();
231        discovery.add_path(PathBuf::from("/nonexistent/path/that/does/not/exist"));
232
233        let results = discovery.discover();
234
235        // Should return empty, not error
236        assert!(results.is_empty());
237    }
238
239    #[test]
240    fn test_add_path_deduplication() {
241        let mut discovery = SkillDiscovery::empty();
242        let path = PathBuf::from("/some/path");
243
244        discovery.add_path(path.clone());
245        discovery.add_path(path.clone());
246        discovery.add_path(path);
247
248        assert_eq!(discovery.search_paths().len(), 1);
249    }
250
251    #[test]
252    fn test_discover_from_multiple_paths() {
253        // Test that skills can be loaded from multiple custom paths
254        // This validates the pattern used by load_skills_from()
255        let temp_dir1 = TempDir::new().unwrap();
256        let temp_dir2 = TempDir::new().unwrap();
257
258        create_skill(&temp_dir1, "skill-from-path1", "Skill from first path");
259        create_skill(&temp_dir2, "skill-from-path2", "Skill from second path");
260
261        let mut discovery = SkillDiscovery::empty();
262        discovery.add_path(temp_dir1.path().to_path_buf());
263        discovery.add_path(temp_dir2.path().to_path_buf());
264
265        let skills = discovery.discover_valid();
266        assert_eq!(skills.len(), 2);
267
268        let names: Vec<_> = skills.iter().map(|s| s.metadata.name.as_str()).collect();
269        assert!(names.contains(&"skill-from-path1"));
270        assert!(names.contains(&"skill-from-path2"));
271    }
272
273    #[test]
274    fn test_duplicate_skill_names_across_paths() {
275        // Test behavior when two paths have skills with the same name
276        // The second discovered skill should be returned (order depends on path order)
277        let temp_dir1 = TempDir::new().unwrap();
278        let temp_dir2 = TempDir::new().unwrap();
279
280        create_skill(&temp_dir1, "same-name", "First version from path1");
281        create_skill(&temp_dir2, "same-name", "Second version from path2");
282
283        let mut discovery = SkillDiscovery::empty();
284        discovery.add_path(temp_dir1.path().to_path_buf());
285        discovery.add_path(temp_dir2.path().to_path_buf());
286
287        let results = discovery.discover();
288
289        // Both skills are discovered (registry handles deduplication, not discovery)
290        let valid: Vec<_> = results.into_iter().filter_map(Result::ok).collect();
291        assert_eq!(valid.len(), 2);
292
293        // Both have the same name
294        assert!(valid.iter().all(|s| s.metadata.name == "same-name"));
295    }
296
297}