1use std::collections::HashMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::helpers::dirs_home;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SkillSource {
21 User,
23 Plugin,
25 Project,
27}
28
29impl SkillSource {
30 pub fn label(&self) -> &'static str {
31 match self {
32 Self::User => "user",
33 Self::Plugin => "plugin",
34 Self::Project => "project",
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
40pub struct DiscoveredSkill {
41 pub name: String,
43 pub description: String,
45 pub path: PathBuf,
47 pub source: SkillSource,
49 pub plugin: Option<String>,
51 pub size_bytes: u64,
53}
54
55impl DiscoveredSkill {
56 pub fn semantic_key(&self) -> String {
59 format!("skill:{}", self.name.to_lowercase().replace(' ', "-"))
60 }
61
62 pub fn within_share_limit(&self) -> bool {
64 self.size_bytes <= 32 * 1024
68 }
69}
70
71pub fn discover(project_root: Option<&Path>) -> Vec<DiscoveredSkill> {
76 let mut out = Vec::new();
77 let home = dirs_home();
78
79 scan_skill_root(
80 &home.join(".claude/skills"),
81 SkillSource::User,
82 None,
83 &mut out,
84 );
85
86 let plugins_root = home.join(".claude/plugins");
87 if let Ok(entries) = fs::read_dir(&plugins_root) {
88 for entry in entries.flatten() {
89 let plugin_dir = entry.path();
90 if !plugin_dir.is_dir() {
91 continue;
92 }
93 let plugin_name = plugin_dir
94 .file_name()
95 .and_then(|n| n.to_str())
96 .unwrap_or("")
97 .to_string();
98 scan_skill_root(
99 &plugin_dir.join("skills"),
100 SkillSource::Plugin,
101 Some(plugin_name),
102 &mut out,
103 );
104 }
105 }
106
107 if let Some(root) = project_root {
108 scan_skill_root(
109 &root.join(".claude/skills"),
110 SkillSource::Project,
111 None,
112 &mut out,
113 );
114 }
115
116 out.sort_by_key(|a| a.name.to_lowercase());
117 out
118}
119
120fn scan_skill_root(
121 root: &Path,
122 source: SkillSource,
123 plugin: Option<String>,
124 out: &mut Vec<DiscoveredSkill>,
125) {
126 let Ok(entries) = fs::read_dir(root) else {
127 return;
128 };
129 for entry in entries.flatten() {
130 let path = entry.path();
131 if path.is_dir() {
132 let candidates = [path.join("SKILL.md"), path.join("skill.md")];
134 for candidate in &candidates {
135 if candidate.exists() {
136 if let Some(skill) = load_skill(candidate, source, plugin.clone(), Some(&path))
137 {
138 out.push(skill);
139 }
140 break;
141 }
142 }
143 } else if path.extension().and_then(|e| e.to_str()) == Some("md") {
144 if let Some(skill) = load_skill(&path, source, plugin.clone(), None) {
146 out.push(skill);
147 }
148 }
149 }
150}
151
152fn load_skill(
153 path: &Path,
154 source: SkillSource,
155 plugin: Option<String>,
156 dir_for_fallback_name: Option<&Path>,
157) -> Option<DiscoveredSkill> {
158 let body = fs::read_to_string(path).ok()?;
159 let fm = parse_frontmatter(&body);
160
161 let fallback_name = dir_for_fallback_name
162 .and_then(|d| d.file_name())
163 .or_else(|| path.file_stem())
164 .and_then(|n| n.to_str())
165 .unwrap_or("(unnamed)")
166 .to_string();
167
168 let name = fm.get("name").cloned().unwrap_or(fallback_name);
169 let description = fm.get("description").cloned().unwrap_or_default();
170
171 let size_bytes = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
172
173 Some(DiscoveredSkill {
174 name,
175 description,
176 path: path.to_path_buf(),
177 source,
178 plugin,
179 size_bytes,
180 })
181}
182
183fn parse_frontmatter(content: &str) -> HashMap<String, String> {
186 let mut map = HashMap::new();
187 let trimmed = content.trim_start();
188 if !trimmed.starts_with("---") {
189 return map;
190 }
191 let after_open = &trimmed[3..].trim_start_matches('\r');
192 let after_open = after_open.strip_prefix('\n').unwrap_or(after_open);
193 let Some(close_pos) = after_open.find("\n---") else {
194 return map;
195 };
196 let yaml = &after_open[..close_pos];
197 for line in yaml.lines() {
198 let line = line.trim();
199 if line.is_empty() || line.starts_with('#') {
200 continue;
201 }
202 if let Some((k, v)) = line.split_once(':') {
203 let key = k.trim().to_string();
204 let value = v.trim().trim_matches('"').trim_matches('\'').to_string();
205 if !key.is_empty() && !value.is_empty() {
206 map.insert(key, value);
207 }
208 }
209 }
210 map
211}
212
213pub fn is_shared(skill: &DiscoveredSkill, shared_keys: &std::collections::HashSet<String>) -> bool {
217 shared_keys.contains(&skill.semantic_key())
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use std::io::Write;
224 use tempfile::TempDir;
225
226 fn write_skill(dir: &Path, name: &str, frontmatter: &str, body: &str) -> PathBuf {
227 let skill_dir = dir.join(name);
228 fs::create_dir_all(&skill_dir).unwrap();
229 let path = skill_dir.join("SKILL.md");
230 let mut f = fs::File::create(&path).unwrap();
231 writeln!(f, "---\n{frontmatter}\n---").unwrap();
232 writeln!(f, "{body}").unwrap();
233 path
234 }
235
236 #[test]
237 fn parses_frontmatter_and_falls_back_to_dir_name() {
238 let tmp = TempDir::new().unwrap();
239 let root = tmp.path().join("skills");
240 fs::create_dir_all(&root).unwrap();
241 write_skill(
242 &root,
243 "demo-skill",
244 "name: Demo Skill\ndescription: A demo.\nversion: 1.0",
245 "body",
246 );
247 write_skill(&root, "no-frontmatter", "", "just body");
248
249 let mut out = Vec::new();
250 scan_skill_root(&root, SkillSource::User, None, &mut out);
251 out.sort_by(|a, b| a.name.cmp(&b.name));
252
253 assert_eq!(out.len(), 2);
254 assert_eq!(out[0].name, "Demo Skill");
255 assert_eq!(out[0].description, "A demo.");
256 assert_eq!(out[0].source, SkillSource::User);
257 assert_eq!(out[1].name, "no-frontmatter");
258 assert_eq!(out[1].description, "");
259 }
260
261 #[test]
262 fn semantic_key_matches_hive_shape() {
263 let tmp = TempDir::new().unwrap();
264 let root = tmp.path().join("skills");
265 fs::create_dir_all(&root).unwrap();
266 write_skill(&root, "x", "name: Session Monitoring\n", "");
267 let mut out = Vec::new();
268 scan_skill_root(&root, SkillSource::User, None, &mut out);
269 assert_eq!(out[0].semantic_key(), "skill:session-monitoring");
270 }
271
272 #[test]
273 fn within_share_limit_respects_32k() {
274 let mut skill = DiscoveredSkill {
275 name: "a".into(),
276 description: String::new(),
277 path: PathBuf::new(),
278 source: SkillSource::User,
279 plugin: None,
280 size_bytes: 0,
281 };
282 assert!(skill.within_share_limit());
283 skill.size_bytes = 32 * 1024;
284 assert!(skill.within_share_limit());
285 skill.size_bytes = 32 * 1024 + 1;
286 assert!(!skill.within_share_limit());
287 }
288}