Skip to main content

tycode_core/skills/
discovery.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, RwLock};
4
5use anyhow::Result;
6use tracing::{debug, warn};
7
8use super::parser::parse_skill_file;
9use super::types::{SkillInstructions, SkillMetadata, SkillSource};
10use crate::settings::config::SkillsConfig;
11
12const SKILL_FILE_NAME: &str = "SKILL.md";
13
14/// Manages skill discovery and loading.
15///
16/// SkillsManager discovers skills from multiple directories (in priority order):
17/// 1. `~/.claude/skills/` (user-level Claude Code compatibility, lowest priority)
18/// 2. `~/.tycode/skills/` (user-level)
19/// 3. `.claude/skills/` in each workspace (project-level Claude Code compatibility)
20/// 4. `.tycode/skills/` in each workspace (project-level, highest priority)
21///
22/// Later sources override earlier ones if the same skill name is found.
23pub struct SkillsManager {
24    inner: Arc<SkillsManagerInner>,
25}
26
27struct SkillsManagerInner {
28    /// All discovered skills indexed by name
29    skills: RwLock<HashMap<String, SkillInstructions>>,
30    /// Configuration
31    config: SkillsConfig,
32    /// Workspace roots for project-level skill discovery
33    workspace_roots: Vec<PathBuf>,
34    /// Home directory
35    home_dir: PathBuf,
36}
37
38impl SkillsManager {
39    /// Discovers skills from all configured directories.
40    pub fn discover(workspace_roots: &[PathBuf], home_dir: &Path, config: &SkillsConfig) -> Self {
41        let inner = Arc::new(SkillsManagerInner {
42            skills: RwLock::new(HashMap::new()),
43            config: config.clone(),
44            workspace_roots: workspace_roots.to_vec(),
45            home_dir: home_dir.to_path_buf(),
46        });
47
48        let manager = Self { inner };
49
50        if config.enabled {
51            manager.reload();
52        }
53
54        manager
55    }
56
57    /// Reloads skills from all directories.
58    pub fn reload(&self) {
59        let mut skills = HashMap::new();
60
61        // 1. Load from ~/.claude/skills/ (Claude Code compatibility, lowest priority)
62        if self.inner.config.enable_claude_code_compat {
63            let claude_skills_dir = self.inner.home_dir.join(".claude").join("skills");
64            if claude_skills_dir.is_dir() {
65                debug!("Discovering skills from {:?}", claude_skills_dir);
66                self.discover_from_directory(
67                    &claude_skills_dir,
68                    SkillSource::ClaudeCode,
69                    &mut skills,
70                );
71            }
72        }
73
74        // 2. Load from ~/.tycode/skills/ (user-level)
75        let user_skills_dir = self.inner.home_dir.join(".tycode").join("skills");
76        if user_skills_dir.is_dir() {
77            debug!("Discovering skills from {:?}", user_skills_dir);
78            self.discover_from_directory(&user_skills_dir, SkillSource::User, &mut skills);
79        }
80
81        // 3. Load from additional directories configured in settings
82        for dir in &self.inner.config.additional_dirs {
83            if dir.is_dir() {
84                debug!("Discovering skills from additional dir {:?}", dir);
85                self.discover_from_directory(dir, SkillSource::User, &mut skills);
86            }
87        }
88
89        // 4. Load from .tycode/skills/ and .claude/skills/ in each workspace root (project-level, highest priority)
90        for workspace_root in &self.inner.workspace_roots {
91            // Check .tycode/skills/
92            let tycode_skills_dir = workspace_root.join(".tycode").join("skills");
93            if tycode_skills_dir.is_dir() {
94                debug!("Discovering skills from {:?}", tycode_skills_dir);
95                self.discover_from_directory(
96                    &tycode_skills_dir,
97                    SkillSource::Project(workspace_root.clone()),
98                    &mut skills,
99                );
100            }
101
102            // Check .claude/skills/ (Claude Code project-level compatibility)
103            if self.inner.config.enable_claude_code_compat {
104                let claude_skills_dir = workspace_root.join(".claude").join("skills");
105                if claude_skills_dir.is_dir() {
106                    debug!("Discovering skills from {:?}", claude_skills_dir);
107                    self.discover_from_directory(
108                        &claude_skills_dir,
109                        SkillSource::Project(workspace_root.clone()),
110                        &mut skills,
111                    );
112                }
113            }
114        }
115
116        let count = skills.len();
117        *self.inner.skills.write().unwrap() = skills;
118        debug!("Discovered {} skills", count);
119    }
120
121    /// Discovers skills from a single directory.
122    fn discover_from_directory(
123        &self,
124        dir: &Path,
125        source: SkillSource,
126        skills: &mut HashMap<String, SkillInstructions>,
127    ) {
128        let entries = match std::fs::read_dir(dir) {
129            Ok(entries) => entries,
130            Err(e) => {
131                warn!("Failed to read skills directory {:?}: {}", dir, e);
132                return;
133            }
134        };
135
136        for entry in entries.flatten() {
137            let path = entry.path();
138            if !path.is_dir() {
139                continue;
140            }
141
142            let skill_file = path.join(SKILL_FILE_NAME);
143            if !skill_file.is_file() {
144                continue;
145            }
146
147            let enabled = !self.inner.config.disabled_skills.contains(
148                &path
149                    .file_name()
150                    .unwrap_or_default()
151                    .to_string_lossy()
152                    .to_string(),
153            );
154
155            match parse_skill_file(&skill_file, source.clone(), enabled) {
156                Ok(skill) => {
157                    debug!(
158                        "Discovered skill '{}' from {:?} (enabled: {})",
159                        skill.metadata.name, skill_file, enabled
160                    );
161                    skills.insert(skill.metadata.name.clone(), skill);
162                }
163                Err(e) => {
164                    warn!("Failed to parse skill at {:?}: {}", skill_file, e);
165                }
166            }
167        }
168    }
169
170    /// Returns metadata for all discovered skills.
171    pub fn get_all_metadata(&self) -> Vec<SkillMetadata> {
172        self.inner
173            .skills
174            .read()
175            .unwrap()
176            .values()
177            .map(|s| s.metadata.clone())
178            .collect()
179    }
180
181    /// Returns metadata for enabled skills only.
182    pub fn get_enabled_metadata(&self) -> Vec<SkillMetadata> {
183        self.inner
184            .skills
185            .read()
186            .unwrap()
187            .values()
188            .filter(|s| s.metadata.enabled)
189            .map(|s| s.metadata.clone())
190            .collect()
191    }
192
193    /// Gets a skill by name.
194    pub fn get_skill(&self, name: &str) -> Option<SkillInstructions> {
195        self.inner.skills.read().unwrap().get(name).cloned()
196    }
197
198    /// Loads full instructions for a skill.
199    pub fn load_instructions(&self, name: &str) -> Result<SkillInstructions> {
200        self.inner
201            .skills
202            .read()
203            .unwrap()
204            .get(name)
205            .cloned()
206            .ok_or_else(|| anyhow::anyhow!("Skill '{}' not found", name))
207    }
208
209    /// Checks if a skill exists and is enabled.
210    pub fn is_enabled(&self, name: &str) -> bool {
211        self.inner
212            .skills
213            .read()
214            .unwrap()
215            .get(name)
216            .map(|s| s.metadata.enabled)
217            .unwrap_or(false)
218    }
219
220    /// Returns the number of discovered skills.
221    pub fn count(&self) -> usize {
222        self.inner.skills.read().unwrap().len()
223    }
224
225    /// Returns the number of enabled skills.
226    pub fn enabled_count(&self) -> usize {
227        self.inner
228            .skills
229            .read()
230            .unwrap()
231            .values()
232            .filter(|s| s.metadata.enabled)
233            .count()
234    }
235}
236
237impl Clone for SkillsManager {
238    fn clone(&self) -> Self {
239        Self {
240            inner: self.inner.clone(),
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::fs;
249    use tempfile::TempDir;
250
251    fn create_test_skill(dir: &Path, name: &str, description: &str) {
252        let skill_dir = dir.join(name);
253        fs::create_dir_all(&skill_dir).unwrap();
254
255        let content = format!(
256            r#"---
257name: {}
258description: {}
259---
260
261# {} Skill
262
263Instructions for the skill.
264"#,
265            name, description, name
266        );
267
268        fs::write(skill_dir.join("SKILL.md"), content).unwrap();
269    }
270
271    #[test]
272    fn test_discover_skills() {
273        let temp = TempDir::new().unwrap();
274        let skills_dir = temp.path().join(".tycode").join("skills");
275        fs::create_dir_all(&skills_dir).unwrap();
276
277        create_test_skill(&skills_dir, "test-skill", "A test skill");
278        create_test_skill(&skills_dir, "another-skill", "Another skill");
279
280        let config = SkillsConfig::default();
281        let manager = SkillsManager::discover(&[], temp.path(), &config);
282
283        assert_eq!(manager.count(), 2);
284        assert!(manager.get_skill("test-skill").is_some());
285        assert!(manager.get_skill("another-skill").is_some());
286    }
287
288    #[test]
289    fn test_project_overrides_user() {
290        let temp = TempDir::new().unwrap();
291
292        // Create user-level skill
293        let user_skills = temp.path().join(".tycode").join("skills");
294        fs::create_dir_all(&user_skills).unwrap();
295        create_test_skill(&user_skills, "my-skill", "User version");
296
297        // Create project-level skill with same name
298        let project_skills = temp.path().join("project").join(".tycode").join("skills");
299        fs::create_dir_all(&project_skills).unwrap();
300        create_test_skill(&project_skills, "my-skill", "Project version");
301
302        let config = SkillsConfig::default();
303        let workspace_roots = vec![temp.path().join("project")];
304        let manager = SkillsManager::discover(&workspace_roots, temp.path(), &config);
305
306        // Should have only 1 skill (project overrides user)
307        assert_eq!(manager.count(), 1);
308
309        let skill = manager.get_skill("my-skill").unwrap();
310        assert_eq!(skill.metadata.description, "Project version");
311    }
312
313    #[test]
314    fn test_disabled_skills() {
315        let temp = TempDir::new().unwrap();
316        let skills_dir = temp.path().join(".tycode").join("skills");
317        fs::create_dir_all(&skills_dir).unwrap();
318
319        create_test_skill(&skills_dir, "enabled-skill", "Enabled");
320        create_test_skill(&skills_dir, "disabled-skill", "Disabled");
321
322        let mut config = SkillsConfig::default();
323        config.disabled_skills.insert("disabled-skill".to_string());
324
325        let manager = SkillsManager::discover(&[], temp.path(), &config);
326
327        assert_eq!(manager.count(), 2);
328        assert_eq!(manager.enabled_count(), 1);
329        assert!(manager.is_enabled("enabled-skill"));
330        assert!(!manager.is_enabled("disabled-skill"));
331    }
332
333    #[test]
334    fn test_skills_disabled_in_config() {
335        let temp = TempDir::new().unwrap();
336        let skills_dir = temp.path().join(".tycode").join("skills");
337        fs::create_dir_all(&skills_dir).unwrap();
338        create_test_skill(&skills_dir, "test-skill", "Test");
339
340        let mut config = SkillsConfig::default();
341        config.enabled = false;
342
343        let manager = SkillsManager::discover(&[], temp.path(), &config);
344
345        // Skills discovery is disabled, so no skills should be found
346        assert_eq!(manager.count(), 0);
347    }
348}