1use crate::discovery::{discover_instruction_files, discover_instruction_files_with_extras};
2use crate::error::SkillResult;
3use crate::model::{SkillDocument, SkillIndex};
4use crate::parser::parse_instruction_markdown;
5use sha2::{Digest, Sha256};
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10pub fn load_skill_index(root: impl AsRef<Path>) -> SkillResult<SkillIndex> {
15 let mut skills = Vec::new();
16 for path in discover_instruction_files(root)? {
17 let content = match fs::read_to_string(&path) {
18 Ok(c) => c,
19 Err(_) => continue,
20 };
21 let parsed = match parse_instruction_markdown(&path, &content) {
25 Ok(p) => p,
26 Err(_) => continue,
27 };
28
29 let mut hasher = Sha256::new();
30 hasher.update(content.as_bytes());
31 let hash = format!("{:x}", hasher.finalize());
32
33 let last_modified = fs::metadata(&path)
34 .ok()
35 .and_then(|meta| meta.modified().ok())
36 .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
37 .map(|d| d.as_secs() as i64);
38
39 let id = format!(
40 "{}-{}",
41 normalize_id(&parsed.name),
42 &hash.chars().take(12).collect::<String>()
43 );
44
45 skills.push(SkillDocument {
46 id,
47 name: parsed.name,
48 description: parsed.description,
49 version: parsed.version,
50 license: parsed.license,
51 compatibility: parsed.compatibility,
52 tags: parsed.tags,
53 allowed_tools: parsed.allowed_tools,
54 references: parsed.references,
55 trigger: parsed.trigger,
56 hint: parsed.hint,
57 metadata: parsed.metadata,
58 body: parsed.body,
59 path,
60 hash,
61 last_modified,
62 triggers: parsed.triggers,
63 });
64 }
65
66 skills.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
67 Ok(SkillIndex::new(skills))
68}
69
70pub fn load_skill_index_with_extras(
81 root: impl AsRef<Path>,
82 extra_dirs: &[PathBuf],
83) -> SkillResult<SkillIndex> {
84 let root = root.as_ref();
85 let mut skills = Vec::new();
86 for path in discover_instruction_files_with_extras(root, extra_dirs)? {
87 let content = match fs::read_to_string(&path) {
88 Ok(c) => c,
89 Err(_) => continue,
90 };
91 let parsed = match parse_instruction_markdown(&path, &content) {
92 Ok(p) => p,
93 Err(_) => continue,
94 };
95
96 let mut hasher = Sha256::new();
97 hasher.update(content.as_bytes());
98 let hash = format!("{:x}", hasher.finalize());
99
100 let last_modified = fs::metadata(&path)
101 .ok()
102 .and_then(|meta| meta.modified().ok())
103 .and_then(|ts| ts.duration_since(UNIX_EPOCH).ok())
104 .map(|d| d.as_secs() as i64);
105
106 let id = format!(
107 "{}-{}",
108 normalize_id(&parsed.name),
109 &hash.chars().take(12).collect::<String>()
110 );
111
112 skills.push(SkillDocument {
113 id,
114 name: parsed.name,
115 description: parsed.description,
116 version: parsed.version,
117 license: parsed.license,
118 compatibility: parsed.compatibility,
119 tags: parsed.tags,
120 allowed_tools: parsed.allowed_tools,
121 references: parsed.references,
122 trigger: parsed.trigger,
123 hint: parsed.hint,
124 metadata: parsed.metadata,
125 body: parsed.body,
126 path,
127 hash,
128 last_modified,
129 triggers: parsed.triggers,
130 });
131 }
132
133 let local_prefixes = [root.join(".skills"), root.join(".claude").join("skills")];
138 let is_project_local =
139 |path: &Path| local_prefixes.iter().any(|prefix| path.starts_with(prefix));
140
141 let mut by_name: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
142 let mut deduped: Vec<SkillDocument> = Vec::with_capacity(skills.len());
143
144 for skill in skills {
145 match by_name.get(&skill.name) {
146 Some(&idx) => {
147 if is_project_local(&skill.path) && !is_project_local(&deduped[idx].path) {
149 deduped[idx] = skill;
150 }
151 }
153 None => {
154 by_name.insert(skill.name.clone(), deduped.len());
155 deduped.push(skill);
156 }
157 }
158 }
159
160 deduped.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path)));
161 Ok(SkillIndex::new(deduped))
162}
163
164fn normalize_id(value: &str) -> String {
165 let mut out = String::new();
166 for c in value.chars() {
167 if c.is_ascii_alphanumeric() {
168 out.push(c.to_ascii_lowercase());
169 } else if c == ' ' || c == '-' || c == '_' {
170 out.push('-');
171 }
172 }
173 if out.is_empty() { "skill".to_string() } else { out }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use std::fs;
180
181 #[test]
182 fn loads_index_with_hash_and_summary_fields() {
183 let temp = tempfile::tempdir().unwrap();
184 let root = temp.path();
185 fs::create_dir_all(root.join(".skills")).unwrap();
186 fs::write(
187 root.join(".skills/search.md"),
188 "---\nname: search\ndescription: Search docs\n---\nUse rg first.",
189 )
190 .unwrap();
191
192 let index = load_skill_index(root).unwrap();
193 assert_eq!(index.len(), 1);
194 let skill = &index.skills()[0];
195 assert_eq!(skill.name, "search");
196 assert!(!skill.hash.is_empty());
197 assert!(skill.last_modified.is_some());
198 }
199
200 #[test]
201 fn loads_agents_md_as_skill_document() {
202 let temp = tempfile::tempdir().unwrap();
203 let root = temp.path();
204 fs::write(root.join("AGENTS.md"), "# Repo Instructions\nUse cargo test before commit.\n")
205 .unwrap();
206
207 let index = load_skill_index(root).unwrap();
208 assert_eq!(index.len(), 1);
209 let skill = &index.skills()[0];
210 assert_eq!(skill.name, "agents");
211 assert!(skill.tags.iter().any(|t| t == "agents-md"));
212 assert!(skill.body.contains("Use cargo test before commit."));
213 }
214
215 #[test]
216 fn skips_non_skill_md_files_in_subdirectories() {
217 let temp = tempfile::tempdir().unwrap();
220 let root = temp.path();
221 fs::create_dir_all(root.join(".skills/my-skill/references")).unwrap();
222 fs::create_dir_all(root.join(".skills/my-skill/assets")).unwrap();
223
224 fs::write(
226 root.join(".skills/my-skill/skill.md"),
227 "---\nname: my-skill\ndescription: A skill\n---\nBody",
228 )
229 .unwrap();
230
231 fs::write(
233 root.join(".skills/my-skill/references/docs.md"),
234 "# Reference Documentation\nThis is supporting docs.",
235 )
236 .unwrap();
237 fs::write(root.join(".skills/my-skill/assets/notes.md"), "Just plain text notes.").unwrap();
238
239 fs::write(
241 root.join(".skills/my-skill/README.md"),
242 "# My Skill README\nNo frontmatter here.",
243 )
244 .unwrap();
245
246 let index = load_skill_index(root).unwrap();
247 assert_eq!(index.len(), 1);
249 assert_eq!(index.skills()[0].name, "my-skill");
250 }
251
252 #[test]
253 fn load_with_extras_deduplicates_by_name_preferring_project_local() {
254 let temp = tempfile::tempdir().unwrap();
255 let root = temp.path();
256 let extra = tempfile::tempdir().unwrap();
257
258 fs::create_dir_all(root.join(".skills")).unwrap();
260 fs::write(
261 root.join(".skills/search.md"),
262 "---\nname: search\ndescription: Local search\n---\nLocal body.",
263 )
264 .unwrap();
265
266 fs::write(
268 extra.path().join("search.md"),
269 "---\nname: search\ndescription: Global search\n---\nGlobal body.",
270 )
271 .unwrap();
272
273 let index = load_skill_index_with_extras(root, &[extra.path().to_path_buf()]).unwrap();
274
275 let search_skills: Vec<_> = index.skills().iter().filter(|s| s.name == "search").collect();
277 assert_eq!(search_skills.len(), 1);
278 assert_eq!(search_skills[0].description, "Local search");
280 assert!(search_skills[0].path.starts_with(root));
281 }
282
283 #[test]
284 fn load_with_extras_keeps_distinct_names() {
285 let temp = tempfile::tempdir().unwrap();
286 let root = temp.path();
287 let extra = tempfile::tempdir().unwrap();
288
289 fs::create_dir_all(root.join(".skills")).unwrap();
290 fs::write(
291 root.join(".skills/alpha.md"),
292 "---\nname: alpha\ndescription: Alpha\n---\nAlpha body.",
293 )
294 .unwrap();
295
296 fs::write(
297 extra.path().join("beta.md"),
298 "---\nname: beta\ndescription: Beta\n---\nBeta body.",
299 )
300 .unwrap();
301
302 let index = load_skill_index_with_extras(root, &[extra.path().to_path_buf()]).unwrap();
303
304 assert_eq!(index.len(), 2);
305 assert!(index.find_by_name("alpha").is_some());
306 assert!(index.find_by_name("beta").is_some());
307 }
308
309 #[test]
310 fn loads_root_soul_md_as_skill_document() {
311 let temp = tempfile::tempdir().unwrap();
312 let root = temp.path();
313 fs::write(root.join("SOUL.MD"), "# Soul\nBias toward deterministic workflows.\n").unwrap();
314 fs::create_dir_all(root.join("pkg")).unwrap();
315 fs::write(root.join("pkg/SOUL.md"), "# Nested soul should not load\n").unwrap();
316
317 let index = load_skill_index(root).unwrap();
318 assert_eq!(index.len(), 1);
319 let skill = &index.skills()[0];
320 assert_eq!(skill.name, "soul");
321 assert!(skill.tags.iter().any(|t| t == "soul-md"));
322 assert!(skill.body.contains("deterministic workflows"));
323 }
324}