1use std::collections::HashSet;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use serde::Deserialize;
14
15use crate::error::{Result, SkillsError};
16
17pub const DEFAULT_SKILL_CONTAINER_DEPTH: usize = 3;
19
20const SKIP_DIRS: [&str; 5] = ["node_modules", ".git", "dist", "build", "__pycache__"];
22
23pub const AGENT_PROJECT_SKILL_DIRS: [&str; 27] = [
31 ".agents/skills",
32 ".claude/skills",
33 ".cline/skills",
34 ".codebuddy/skills",
35 ".codex/skills",
36 ".commandcode/skills",
37 ".continue/skills",
38 ".github/skills",
39 ".goose/skills",
40 ".grok/skills",
41 ".iflow/skills",
42 ".junie/skills",
43 ".kilocode/skills",
44 ".kimchi/skills",
45 ".kiro/skills",
46 ".minimax/skills",
47 ".mux/skills",
48 ".neovate/skills",
49 ".opencode/skills",
50 ".openhands/skills",
51 ".pi/skills",
52 ".qoder/skills",
53 ".roo/skills",
54 ".trae/skills",
55 ".windsurf/skills",
56 ".zcode/skills",
57 ".zencoder/skills",
58];
59
60#[derive(Debug, Clone)]
62pub struct Skill {
63 pub name: String,
65 pub description: String,
67 pub dir: PathBuf,
69}
70
71#[derive(Debug, Deserialize)]
72struct Frontmatter {
73 #[serde(default)]
74 name: Option<String>,
75 #[serde(default)]
76 description: Option<String>,
77 #[serde(default)]
78 metadata: Option<serde_yaml::Value>,
79}
80
81fn split_frontmatter(raw: &str) -> Option<&str> {
84 let rest = raw
85 .strip_prefix("---\r\n")
86 .or_else(|| raw.strip_prefix("---\n"))?;
87 let end = rest.find("\n---")?;
88 Some(&rest[..end])
89}
90
91pub fn parse_skill_md(skill_md: &Path) -> Option<Skill> {
94 parse_skill_md_inner(skill_md, false)
95}
96
97pub fn parse_skill_md_inner(skill_md: &Path, include_internal: bool) -> Option<Skill> {
99 let content = fs::read_to_string(skill_md).ok()?;
100 let data = split_frontmatter(&content)?;
101 let fm: Frontmatter = serde_yaml::from_str(data).ok()?;
102 let name = fm.name?;
103 let description = fm.description?;
104
105 let is_internal = matches!(
107 fm.metadata,
108 Some(serde_yaml::Value::Mapping(m))
109 if m.get(serde_yaml::Value::String("internal".to_string()))
110 == Some(&serde_yaml::Value::Bool(true))
111 );
112 if is_internal && !include_internal && !install_internal_skills() {
113 return None;
114 }
115
116 Some(Skill {
117 name,
118 description,
119 dir: skill_md.parent()?.to_path_buf(),
120 })
121}
122
123fn install_internal_skills() -> bool {
124 match std::env::var("INSTALL_INTERNAL_SKILLS") {
125 Ok(v) => v == "1" || v == "true",
126 Err(_) => false,
127 }
128}
129
130pub fn is_subpath_safe(base: &Path, subpath: &str) -> bool {
132 use std::path::Component;
133
134 let base_abs = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
136 let mut target = base_abs.clone();
141 for comp in Path::new(subpath).components() {
142 match comp {
143 Component::CurDir => {}
144 Component::ParentDir => {
145 if !target.pop() {
147 return false;
148 }
149 }
150 Component::RootDir => return false, Component::Prefix(_) | Component::Normal(_) => target.push(comp.as_os_str()),
152 }
153 }
154 target == base_abs || target.starts_with(&base_abs)
155}
156
157fn try_add_skill_at(
159 dir: &Path,
160 include_internal: bool,
161 seen: &mut HashSet<String>,
162 skills: &mut Vec<Skill>,
163) -> bool {
164 if !dir.join("SKILL.md").is_file() {
165 return false;
166 }
167 if let Some(skill) = parse_skill_md_inner(&dir.join("SKILL.md"), include_internal)
168 && !seen.contains(&skill.name)
169 {
170 seen.insert(skill.name.clone());
171 skills.push(skill);
172 }
173 true
174}
175
176fn walk_skill_dirs(
178 dir: &Path,
179 max_depth: usize,
180 depth: usize,
181 include_internal: bool,
182 seen: &mut HashSet<String>,
183 skills: &mut Vec<Skill>,
184) {
185 let Ok(entries) = fs::read_dir(dir) else {
186 return;
187 };
188 for entry in entries.flatten() {
189 let Ok(md) = fs::metadata(entry.path()) else {
193 continue;
194 };
195 if !md.is_dir() {
196 continue;
197 }
198 let child = entry.path();
199 let name = entry.file_name().to_string_lossy().into_owned();
200 if SKIP_DIRS.contains(&name.as_str()) {
201 continue;
202 }
203 let found = try_add_skill_at(&child, include_internal, seen, skills);
204 if found || depth >= max_depth {
205 continue;
206 }
207 walk_skill_dirs(&child, max_depth, depth + 1, include_internal, seen, skills);
208 }
209}
210
211fn find_all_skill_dirs(
213 dir: &Path,
214 depth: usize,
215 max_depth: usize,
216 include_internal: bool,
217 seen: &mut HashSet<String>,
218 skills: &mut Vec<Skill>,
219) {
220 if depth > max_depth {
221 return;
222 }
223 try_add_skill_at(dir, include_internal, seen, skills);
224 let Ok(entries) = fs::read_dir(dir) else {
225 return;
226 };
227 for entry in entries.flatten() {
228 let Ok(md) = fs::metadata(entry.path()) else {
229 continue;
230 };
231 if !md.is_dir() {
232 continue;
233 }
234 let name = entry.file_name().to_string_lossy().into_owned();
235 if SKIP_DIRS.contains(&name.as_str()) {
236 continue;
237 }
238 find_all_skill_dirs(
239 &entry.path(),
240 depth + 1,
241 max_depth,
242 include_internal,
243 seen,
244 skills,
245 );
246 }
247}
248
249pub fn discover_skills(
253 base: &Path,
254 subpath: Option<&str>,
255 include_internal: bool,
256) -> Result<Vec<Skill>> {
257 if let Some(sp) = subpath
258 && !is_subpath_safe(base, sp)
259 {
260 return Err(SkillsError::msg(format!(
261 "Invalid subpath: \"{sp}\" resolves outside the repository directory. Subpath must not contain \"..\" segments that escape the base path."
262 )));
263 }
264 let search_path = base.join(subpath.unwrap_or(""));
265 let mut skills: Vec<Skill> = Vec::new();
266 let mut seen: HashSet<String> = HashSet::new();
267
268 if search_path.join("SKILL.md").is_file()
270 && let Some(skill) = parse_skill_md_inner(&search_path.join("SKILL.md"), include_internal)
271 && !seen.contains(&skill.name)
272 {
273 seen.insert(skill.name.clone());
274 skills.push(skill);
275 return Ok(skills);
276 }
277
278 let mut priority: Vec<PathBuf> = vec![search_path.clone()];
280 for rel in [
281 "skills",
282 "skills/.curated",
283 "skills/.experimental",
284 "skills/.system",
285 ] {
286 priority.push(search_path.join(rel));
287 }
288 for rel in AGENT_PROJECT_SKILL_DIRS {
289 priority.push(search_path.join(rel));
290 }
291 for (i, dir) in priority.iter().enumerate() {
292 let max_depth = if i == 0 {
293 1
294 } else {
295 DEFAULT_SKILL_CONTAINER_DEPTH
296 };
297 walk_skill_dirs(dir, max_depth, 1, include_internal, &mut seen, &mut skills);
298 }
299
300 if skills.is_empty() {
302 find_all_skill_dirs(&search_path, 0, 5, include_internal, &mut seen, &mut skills);
303 }
304 Ok(skills)
305}
306
307pub fn filter_skills(skills: &[Skill], input_names: &[String]) -> Vec<Skill> {
309 let normalized: Vec<String> = input_names.iter().map(|n| n.to_lowercase()).collect();
310 skills
311 .iter()
312 .filter(|s| {
313 let name = s.name.to_lowercase();
314 let dir_name = s
315 .dir
316 .file_name()
317 .map(|f| f.to_string_lossy().to_lowercase())
318 .unwrap_or_default();
319 normalized.iter().any(|i| *i == name || *i == dir_name)
320 })
321 .cloned()
322 .collect()
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::core::test_utils::write_skill_md;
329
330 #[test]
331 fn parse_valid_skill() {
332 let tmp = tempfile::TempDir::new().unwrap();
333 let md = write_skill_md(tmp.path(), "pdf", "pdf");
334 let skill = parse_skill_md(&md).unwrap();
335 assert_eq!(skill.name, "pdf");
336 assert_eq!(skill.description, "does pdf");
337 assert_eq!(skill.dir, tmp.path().join("pdf"));
338 }
339
340 #[test]
341 fn parse_missing_required_field_is_none() {
342 let tmp = tempfile::TempDir::new().unwrap();
343 let md = tmp.path().join("SKILL.md");
344 fs::write(&md, "---\nname: only-name\n---\nbody").unwrap();
345 assert!(parse_skill_md(&md).is_none());
346 }
347
348 #[test]
349 fn parse_invalid_yaml_is_none() {
350 let tmp = tempfile::TempDir::new().unwrap();
351 let md = tmp.path().join("SKILL.md");
352 fs::write(&md, "---\nname: [unclosed\n---\nbody").unwrap();
353 assert!(parse_skill_md(&md).is_none());
354 }
355
356 #[test]
357 fn parse_without_frontmatter_is_none() {
358 let tmp = tempfile::TempDir::new().unwrap();
359 let md = tmp.path().join("SKILL.md");
360 fs::write(&md, "# just a heading\n").unwrap();
361 assert!(parse_skill_md(&md).is_none());
362 }
363
364 #[test]
365 fn parse_quoted_and_block_description() {
366 let tmp = tempfile::TempDir::new().unwrap();
367 let md = tmp.path().join("SKILL.md");
368 fs::write(
369 &md,
370 "---\nname: \"pdf\"\ndescription: |\n Multi line\n description here\n---\nbody",
371 )
372 .unwrap();
373 let skill = parse_skill_md(&md).unwrap();
374 assert_eq!(skill.name, "pdf");
375 assert!(skill.description.contains("Multi line"));
376 }
377
378 #[test]
379 fn parse_internal_skill_hidden_by_default() {
380 let tmp = tempfile::TempDir::new().unwrap();
381 let md = tmp.path().join("SKILL.md");
382 fs::write(
383 &md,
384 "---\nname: secret\ndescription: internal\nmetadata:\n internal: true\n---\nbody",
385 )
386 .unwrap();
387 assert!(parse_skill_md(&md).is_none());
388 assert!(parse_skill_md_inner(&md, true).is_some());
390 }
391
392 #[test]
393 fn root_skill_short_circuits() {
394 let tmp = tempfile::TempDir::new().unwrap();
395 write_skill_md(tmp.path(), ".", "root");
396 write_skill_md(tmp.path(), "skills/other", "other");
397 let skills = discover_skills(tmp.path(), None, false).unwrap();
398 assert_eq!(skills.len(), 1);
399 assert_eq!(skills[0].name, "root");
400 }
401
402 #[test]
403 fn container_walk_respects_depth_boundary() {
404 let tmp = tempfile::TempDir::new().unwrap();
405 write_skill_md(tmp.path(), "skills/pdf", "pdf");
406 write_skill_md(tmp.path(), "skills/category/pdf", "pdf-nested");
407 write_skill_md(tmp.path(), "skills/category/sub/x/pdf", "pdf-deep");
409 let skills = discover_skills(tmp.path(), None, false).unwrap();
410 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
411 assert!(names.contains(&"pdf"));
412 assert!(names.contains(&"pdf-nested"));
413 assert!(!names.contains(&"pdf-deep"));
414 }
415
416 #[test]
417 fn shallow_skill_shadows_deep_skill() {
418 let tmp = tempfile::TempDir::new().unwrap();
419 write_skill_md(tmp.path(), "skills/pdf", "pdf");
420 write_skill_md(tmp.path(), "skills/pdf/pdf", "pdf");
421 let skills = discover_skills(tmp.path(), None, false).unwrap();
422 assert_eq!(skills.iter().filter(|s| s.name == "pdf").count(), 1);
423 assert_eq!(skills[0].dir, tmp.path().join("skills/pdf"));
424 }
425
426 #[test]
427 fn skip_dirs_are_ignored() {
428 let tmp = tempfile::TempDir::new().unwrap();
429 write_skill_md(tmp.path(), "skills/pdf", "pdf");
430 write_skill_md(tmp.path(), "node_modules/x", "x");
431 write_skill_md(tmp.path(), "skills/.git/x", "git-x");
432 let skills = discover_skills(tmp.path(), None, false).unwrap();
433 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
434 assert!(names.contains(&"pdf"));
435 assert!(!names.contains(&"x"));
436 assert!(!names.contains(&"git-x"));
437 }
438
439 #[test]
440 fn unsafe_subpath_is_rejected() {
441 let tmp = tempfile::TempDir::new().unwrap();
442 write_skill_md(tmp.path(), "pdf", "pdf");
443 assert!(discover_skills(tmp.path(), Some("../evil"), false).is_err());
444 }
445
446 #[test]
447 fn filter_matches_name_or_dir_case_insensitive() {
448 let tmp = tempfile::TempDir::new().unwrap();
449 write_skill_md(tmp.path(), "skills/pdf", "PDF Master");
450 write_skill_md(tmp.path(), "skills/doc", "docx");
451 let skills = discover_skills(tmp.path(), None, false).unwrap();
452 let hit = filter_skills(&skills, &["pdf master".to_string()]);
453 assert_eq!(hit.len(), 1);
454 assert_eq!(hit[0].name, "PDF Master");
455 let hit2 = filter_skills(&skills, &["pdf".to_string()]);
456 assert_eq!(hit2.len(), 1);
457 }
458}