skilllite_core/skill/
discovery.rs1use std::collections::HashSet;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10pub const SKILL_SEARCH_DIRS: &[&str] =
13 &[".skills", "skills", ".agents/skills", ".claude/skills", "."];
14
15pub fn discover_skills_in_workspace(
22 workspace: &Path,
23 search_dirs: Option<&[&str]>,
24) -> Vec<PathBuf> {
25 let dirs = search_dirs.unwrap_or(SKILL_SEARCH_DIRS);
26 let mut candidates: Vec<PathBuf> = Vec::new();
27 let mut seen = HashSet::new();
28
29 if workspace.join("SKILL.md").exists() {
31 if let Ok(real) = workspace.canonicalize() {
32 if seen.insert(real) {
33 candidates.push(workspace.to_path_buf());
34 }
35 }
36 }
37
38 for search_dir in dirs {
39 let search_path = workspace.join(search_dir);
40 if !search_path.is_dir() {
41 continue;
42 }
43 let is_root = search_dir == &".";
44
45 if !is_root && search_path.join("SKILL.md").exists() {
47 if let Ok(real) = search_path.canonicalize() {
48 if seen.insert(real) {
49 candidates.push(search_path.clone());
50 }
51 }
52 }
53
54 let Ok(entries) = fs::read_dir(&search_path) else {
56 continue;
57 };
58 let mut children: Vec<_> = entries.flatten().collect();
59 children.sort_by_key(|e| e.file_name());
60 for entry in children {
61 let p = entry.path();
62 if p.is_dir() && p.join("SKILL.md").exists() {
63 if let Ok(real) = p.canonicalize() {
64 if seen.insert(real) {
65 candidates.push(p);
66 }
67 }
68 }
69 }
70 }
71
72 candidates.sort();
73 candidates
74}
75
76pub fn discover_skill_dirs_for_loading(
83 workspace: &Path,
84 search_dirs: Option<&[&str]>,
85) -> Vec<String> {
86 let dirs = search_dirs.unwrap_or(SKILL_SEARCH_DIRS);
87 let mut result = Vec::new();
88 for d in dirs {
89 let p = workspace.join(d);
90 if p.is_dir() {
91 result.push(p.to_string_lossy().to_string());
92 }
93 }
94 result
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100 use std::fs;
101
102 #[test]
103 fn test_discover_skills_in_workspace_empty() {
104 let tmp = tempfile::tempdir().unwrap();
105 let found = discover_skills_in_workspace(tmp.path(), Some(&[".skills", "skills"]));
106 assert!(found.is_empty());
107 }
108
109 #[test]
110 fn test_discover_skills_in_workspace_finds_skills() {
111 let tmp = tempfile::tempdir().unwrap();
112 let skills_dir = tmp.path().join(".skills");
113 fs::create_dir_all(&skills_dir).unwrap();
114 let skill_a = skills_dir.join("skill-a");
115 fs::create_dir_all(&skill_a).unwrap();
116 fs::write(skill_a.join("SKILL.md"), "name: skill-a\n").unwrap();
117 let skill_b = skills_dir.join("skill-b");
118 fs::create_dir_all(&skill_b).unwrap();
119 fs::write(skill_b.join("SKILL.md"), "name: skill-b\n").unwrap();
120
121 let found = discover_skills_in_workspace(tmp.path(), Some(&[".skills", "skills"]));
122 assert_eq!(found.len(), 2);
123 assert!(found.iter().any(|p| p.ends_with("skill-a")));
124 assert!(found.iter().any(|p| p.ends_with("skill-b")));
125 }
126
127 #[test]
128 fn test_discover_skill_dirs_for_loading_fallback() {
129 let tmp = tempfile::tempdir().unwrap();
130 let skills_dir = tmp.path().join(".skills");
131 fs::create_dir_all(&skills_dir).unwrap();
132 let found = discover_skill_dirs_for_loading(tmp.path(), Some(&[".skills", "skills"]));
134 assert_eq!(found.len(), 1);
135 assert!(found[0].ends_with(".skills"));
136 }
137}