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
14pub struct SkillsManager {
24 inner: Arc<SkillsManagerInner>,
25}
26
27struct SkillsManagerInner {
28 skills: RwLock<HashMap<String, SkillInstructions>>,
30 config: SkillsConfig,
32 workspace_roots: Vec<PathBuf>,
34 home_dir: PathBuf,
36}
37
38impl SkillsManager {
39 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 pub fn reload(&self) {
59 let mut skills = HashMap::new();
60
61 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 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 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 for workspace_root in &self.inner.workspace_roots {
91 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 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 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 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 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 pub fn get_skill(&self, name: &str) -> Option<SkillInstructions> {
195 self.inner.skills.read().unwrap().get(name).cloned()
196 }
197
198 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 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 pub fn count(&self) -> usize {
222 self.inner.skills.read().unwrap().len()
223 }
224
225 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 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 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 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 assert_eq!(manager.count(), 0);
347 }
348}