Skip to main content

skilllite_core/skill/
discovery.rs

1//! Unified skill discovery: find skill directories (containing SKILL.md) in a workspace.
2//!
3//! Used by skill add, chat, agent-rpc, and swarm to consistently discover skills
4//! across `.skills`, `skills`, `.agents/skills`, `.claude/skills`.
5
6use std::collections::HashSet;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Default directories to search for skills, relative to workspace root.
11/// Includes "." to scan workspace root's direct children (e.g. for skill add from repo).
12pub const SKILL_SEARCH_DIRS: &[&str] =
13    &[".skills", "skills", ".agents/skills", ".claude/skills", "."];
14
15/// Discover all skill directories in a workspace.
16///
17/// Searches in `search_dirs` (or `SKILL_SEARCH_DIRS` if None) for directories
18/// containing SKILL.md. Deduplicates by canonical path.
19///
20/// Returns paths to skill directories (each has SKILL.md), sorted.
21pub 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 itself is a skill
30    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        // Search path itself might be a skill (skip for "." to avoid duplicate with workspace)
46        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        // Scan subdirectories
55        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
76/// Discover skill directories for `load_skills`, as `Vec<String>`.
77///
78/// Returns parent dirs (e.g. `.skills`, `skills`) so `load_skills` can:
79/// - scan subdirs for regular skills
80/// - load evolved skills from `_evolved/` (EVO-4)
81///   Using parent dirs ensures both regular and evolved skills are loaded.
82pub 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        // No skills in subdirs
133        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}