1use crate::error::Result;
7use crate::parser::SkillFile;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use walkdir::WalkDir;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
17pub enum SkillScope {
18 Project = 0,
20 Personal = 1,
22 Enterprise = 2,
24 Plugin = 3,
26}
27
28impl std::fmt::Display for SkillScope {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 match self {
31 SkillScope::Project => write!(f, "project"),
32 SkillScope::Personal => write!(f, "personal"),
33 SkillScope::Enterprise => write!(f, "enterprise"),
34 SkillScope::Plugin => write!(f, "plugin"),
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
41pub struct SkillLoaderConfig {
42 pub project_dir: Option<PathBuf>,
44 pub personal_dir: Option<PathBuf>,
46 pub enterprise_dir: Option<PathBuf>,
48 pub plugin_dirs: Vec<PathBuf>,
50 pub max_depth: usize,
52}
53
54impl Default for SkillLoaderConfig {
55 fn default() -> Self {
56 Self {
57 project_dir: Some(PathBuf::from(".claude/skills")),
58 personal_dir: dirs::home_dir().map(|h| h.join(".claude/skills")),
59 enterprise_dir: None,
60 plugin_dirs: Vec::new(),
61 max_depth: 3,
62 }
63 }
64}
65
66impl SkillLoaderConfig {
67 pub fn project_only(path: impl Into<PathBuf>) -> Self {
69 Self {
70 project_dir: Some(path.into()),
71 personal_dir: None,
72 enterprise_dir: None,
73 plugin_dirs: Vec::new(),
74 max_depth: 3,
75 }
76 }
77
78 pub fn single(path: impl Into<PathBuf>) -> Self {
80 Self::project_only(path)
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct LoadedSkill {
87 pub file: SkillFile,
89 pub scope: SkillScope,
91 pub namespace: Option<String>,
93}
94
95impl LoadedSkill {
96 pub fn qualified_name(&self) -> String {
98 match &self.namespace {
99 Some(ns) => format!("{}:{}", ns, self.file.effective_name()),
100 None => self.file.effective_name(),
101 }
102 }
103
104 pub fn effective_description(&self) -> String {
106 self.file.effective_description()
107 }
108
109 pub fn is_model_invocable(&self) -> bool {
111 !self.file.frontmatter.disable_model_invocation
112 }
113
114 pub fn is_user_invocable(&self) -> bool {
116 self.file.frontmatter.user_invocable
117 }
118}
119
120pub struct SkillLoader {
122 config: SkillLoaderConfig,
123}
124
125impl SkillLoader {
126 pub fn new(config: SkillLoaderConfig) -> Self {
128 Self { config }
129 }
130
131 pub fn with_defaults() -> Self {
133 Self::new(SkillLoaderConfig::default())
134 }
135
136 pub fn load_all(&self) -> Result<Vec<LoadedSkill>> {
138 let mut skills = Vec::new();
139
140 if let Some(ref dir) = self.config.project_dir {
142 skills.extend(self.load_from_directory(dir, SkillScope::Project, None)?);
143 }
144
145 if let Some(ref dir) = self.config.personal_dir {
146 skills.extend(self.load_from_directory(dir, SkillScope::Personal, None)?);
147 }
148
149 if let Some(ref dir) = self.config.enterprise_dir {
150 skills.extend(self.load_from_directory(dir, SkillScope::Enterprise, None)?);
151 }
152
153 for plugin_dir in &self.config.plugin_dirs {
155 if let Some(plugin_name) = plugin_dir.file_name().and_then(|n| n.to_str()) {
156 let skills_dir = plugin_dir.join("skills");
157 if skills_dir.exists() {
158 skills.extend(self.load_from_directory(
159 &skills_dir,
160 SkillScope::Plugin,
161 Some(plugin_name.to_string()),
162 )?);
163 }
164 }
165 }
166
167 Ok(skills)
168 }
169
170 fn load_from_directory(
172 &self,
173 dir: &Path,
174 scope: SkillScope,
175 namespace: Option<String>,
176 ) -> Result<Vec<LoadedSkill>> {
177 let mut skills = Vec::new();
178
179 if !dir.exists() {
180 return Ok(skills);
181 }
182
183 for entry in WalkDir::new(dir)
184 .max_depth(self.config.max_depth)
185 .into_iter()
186 .filter_map(|e| e.ok())
187 {
188 let path = entry.path();
189 if path.is_file() && path.file_name() == Some(std::ffi::OsStr::new("SKILL.md")) {
190 match SkillFile::parse(path) {
191 Ok(file) => {
192 skills.push(LoadedSkill {
193 file,
194 scope,
195 namespace: namespace.clone(),
196 });
197 }
198 Err(e) => {
199 eprintln!("Warning: Failed to load skill at {:?}: {}", path, e);
201 }
202 }
203 }
204 }
205
206 Ok(skills)
207 }
208
209 pub fn resolve_priority(skills: Vec<LoadedSkill>) -> HashMap<String, LoadedSkill> {
211 let mut resolved: HashMap<String, LoadedSkill> = HashMap::new();
212
213 let mut sorted = skills;
215 sorted.sort_by_key(|s| s.scope);
216
217 for skill in sorted {
218 let name = skill.qualified_name();
219 if let Some(existing) = resolved.get(&name) {
221 if skill.scope >= existing.scope {
222 resolved.insert(name, skill);
223 }
224 } else {
225 resolved.insert(name, skill);
226 }
227 }
228
229 resolved
230 }
231
232 pub fn find_skill<'a>(skills: &'a [LoadedSkill], name: &str) -> Option<&'a LoadedSkill> {
234 skills
235 .iter()
236 .find(|s| s.file.effective_name() == name || s.qualified_name() == name)
237 }
238
239 pub fn model_invocable(skills: &[LoadedSkill]) -> Vec<&LoadedSkill> {
241 skills.iter().filter(|s| s.is_model_invocable()).collect()
242 }
243
244 pub fn user_invocable(skills: &[LoadedSkill]) -> Vec<&LoadedSkill> {
246 skills.iter().filter(|s| s.is_user_invocable()).collect()
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn test_skill_scope_ordering() {
256 assert!(SkillScope::Enterprise > SkillScope::Personal);
257 assert!(SkillScope::Personal > SkillScope::Project);
258 assert!(SkillScope::Plugin > SkillScope::Enterprise);
259 }
260
261 #[test]
262 fn test_qualified_name_no_namespace() {
263 let skill = LoadedSkill {
264 file: create_mock_skill_file("test-skill"),
265 scope: SkillScope::Project,
266 namespace: None,
267 };
268 assert_eq!(skill.qualified_name(), "test-skill");
269 }
270
271 #[test]
272 fn test_qualified_name_with_namespace() {
273 let skill = LoadedSkill {
274 file: create_mock_skill_file("helper"),
275 scope: SkillScope::Plugin,
276 namespace: Some("myplugin".to_string()),
277 };
278 assert_eq!(skill.qualified_name(), "myplugin:helper");
279 }
280
281 #[test]
282 fn test_resolve_priority() {
283 let project_skill = LoadedSkill {
284 file: create_mock_skill_file("shared"),
285 scope: SkillScope::Project,
286 namespace: None,
287 };
288 let personal_skill = LoadedSkill {
289 file: create_mock_skill_file("shared"),
290 scope: SkillScope::Personal,
291 namespace: None,
292 };
293
294 let skills = vec![project_skill, personal_skill];
295 let resolved = SkillLoader::resolve_priority(skills);
296
297 assert_eq!(resolved.get("shared").unwrap().scope, SkillScope::Personal);
299 }
300
301 fn create_mock_skill_file(name: &str) -> SkillFile {
302 use crate::frontmatter::SkillFrontmatter;
303
304 SkillFile {
305 frontmatter: SkillFrontmatter {
306 name: Some(name.to_string()),
307 ..Default::default()
308 },
309 content: String::new(),
310 path: PathBuf::new(),
311 directory: PathBuf::new(),
312 supporting_files: Vec::new(),
313 }
314 }
315}