Skip to main content

agents_skills/core/
discover.rs

1//! SKILL.md discovery and frontmatter parsing.
2//!
3//! Priority container dirs (repo root + `skills/` + `.curated/.experimental/.system` +
4//! each agent's project skills dir) recurse at most 3 levels, with shallow shadowing deep.
5//! A root `SKILL.md` short-circuits the whole source to a single skill; when the
6//! container walk finds nothing, full-tree recursion (max 5 levels) falls back.
7//! Not supported: installed-project-skill filtering and plugin manifests.
8
9use std::collections::HashSet;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use serde::Deserialize;
14
15use crate::error::{Result, SkillsError};
16
17/// Default max depth when searching within known container dirs.
18pub const DEFAULT_SKILL_CONTAINER_DEPTH: usize = 3;
19
20/// Dirs skipped during recursive search.
21const SKIP_DIRS: [&str; 5] = ["node_modules", ".git", "dist", "build", "__pycache__"];
22
23/// Each agent's project-level skills dir (one of the container dirs).
24///
25/// Kept by hand, intentionally independent of the agent table in `agents.rs`: this
26/// list is the set of container dirs searched when discovering skills inside a
27/// *source* repo, and it includes dirs that map to no registered agent (e.g.
28/// `.github/skills`, `.codex/skills`). When an agent gains its own project dir,
29/// decide whether discovery should cover it and update both lists.
30pub 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/// A discovered skill.
61#[derive(Debug, Clone)]
62pub struct Skill {
63    /// Skill name (from frontmatter).
64    pub name: String,
65    /// Skill description (from frontmatter).
66    pub description: String,
67    /// Directory containing SKILL.md.
68    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
81/// Split the `---`-delimited frontmatter, returning the YAML data.
82/// Returns None when there is no frontmatter.
83fn 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
91/// Parse a single SKILL.md; return None on any error (read failure / invalid YAML / missing fields).
92/// Internal skills are hidden by default unless explicitly requested or `INSTALL_INTERNAL_SKILLS=1`.
93pub fn parse_skill_md(skill_md: &Path) -> Option<Skill> {
94    parse_skill_md_inner(skill_md, false)
95}
96
97/// Like [`parse_skill_md`], but allows including internal skills when `include_internal` is true.
98pub 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    // internal skill: only visible when explicitly requested or INSTALL_INTERNAL_SKILLS=1.
106    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
130/// Validate that a subpath does not escape the base dir (path traversal guard).
131pub fn is_subpath_safe(base: &Path, subpath: &str) -> bool {
132    use std::path::Component;
133
134    // Resolve the base to an absolute path (handles symlinks like /var -> /private/var).
135    let base_abs = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
136    // Lexically walk `subpath` from the base, resolving `.` / `..` without touching
137    // the filesystem. This is correct even when the target path does not exist yet:
138    // `canonicalize` would fail there and we'd fall back to a raw path whose `..`
139    // segments defeat component-wise prefix comparison.
140    let mut target = base_abs.clone();
141    for comp in Path::new(subpath).components() {
142        match comp {
143            Component::CurDir => {}
144            Component::ParentDir => {
145                // Popping past the base root means the subpath escapes upward.
146                if !target.pop() {
147                    return false;
148                }
149            }
150            Component::RootDir => return false, // absolute subpaths are not allowed
151            Component::Prefix(_) | Component::Normal(_) => target.push(comp.as_os_str()),
152        }
153    }
154    target == base_abs || target.starts_with(&base_abs)
155}
156
157/// Try to parse `dir/SKILL.md` and add it to results (shallow shadowing by name). Returns whether the dir has a SKILL.md.
158fn 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
176/// Directed walk of container dirs: each level checks subdirs for SKILL.md; on a hit, don't descend further.
177fn 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        // Use fs::metadata (follows symlinks) instead of entry.metadata /
190        // entry.file_type so symlinked skill dirs — e.g. goose's
191        // `pdf -> ~/.skills-manager/skills/pdf` — are traversed, not skipped.
192        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
211/// Full-tree recursion fallback, max 5 levels, collecting SKILL.md dirs at each level.
212fn 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
249/// Discover skills within `base` (or a `subpath`-scoped range).
250///
251/// `include_internal`: include internal skills when explicitly specifying a skill (`--skill` or `@skill`).
252pub 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    // A root SKILL.md hit short-circuits: the whole source is one skill.
269    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    // Priority container dirs: repo root depth=1, other containers depth=3.
279    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    // Nothing in the priority containers: full-tree recursion fallback.
301    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
307/// Filter by input names (case-insensitive, exact match on name or directory name).
308pub 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        // Visible when explicitly requested.
389        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        // 4th-level dir under skills/, beyond the default container depth (3 levels).
408        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}