agent_core_runtime/skills/
discovery.rs1use crate::skills::parser::parse_skill_md;
4use crate::skills::types::{Skill, SkillDiscoveryError};
5use std::path::PathBuf;
6
7const PROJECT_SKILLS_DIR: &str = ".skills";
9
10const USER_SKILLS_DIR: &str = ".agent-core/skills";
12
13#[derive(Debug, Default)]
15pub struct SkillDiscovery {
16 search_paths: Vec<PathBuf>,
17}
18
19impl SkillDiscovery {
20 pub fn new() -> Self {
26 let mut discovery = Self::default();
27
28 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 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 pub fn empty() -> Self {
45 Self::default()
46 }
47
48 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 pub fn search_paths(&self) -> &[PathBuf] {
57 &self.search_paths
58 }
59
60 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 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 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_skill(&temp_dir, "valid-skill", "A valid skill");
184
185 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 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 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 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 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 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 let valid: Vec<_> = results.into_iter().filter_map(Result::ok).collect();
291 assert_eq!(valid.len(), 2);
292
293 assert!(valid.iter().all(|s| s.metadata.name == "same-name"));
295 }
296
297}