Skip to main content

edgecrab_plugins/skill/
loader.rs

1use std::path::{Path, PathBuf};
2
3use edgecrab_types::Platform;
4
5use crate::error::PluginError;
6use crate::skill::manifest::{SkillManifest, parse_skill_manifest};
7use crate::skill::platform::skill_matches_platform;
8use crate::skill::readiness::resolve_skill_readiness;
9use crate::types::{SkillReadinessStatus, SkillSource};
10
11const EXCLUDED_DIRS: &[&str] = &[
12    ".git",
13    ".github",
14    ".quarantine",
15    ".hub",
16    "node_modules",
17    "target",
18    "__pycache__",
19];
20
21#[derive(Debug, Clone)]
22pub struct LoadedSkill {
23    pub path: PathBuf,
24    pub source: SkillSource,
25    pub manifest: SkillManifest,
26    pub readiness: SkillReadinessStatus,
27    pub platform_visible: bool,
28}
29
30pub fn scan_skills_dir(
31    root: &Path,
32    source: SkillSource,
33    disabled: &[String],
34    _platform: Platform,
35) -> Result<Vec<LoadedSkill>, PluginError> {
36    if !root.exists() {
37        return Ok(Vec::new());
38    }
39
40    let mut out = Vec::new();
41    for skill_file in candidate_skill_files(root, 0)? {
42        let manifest = parse_skill_manifest(&skill_file)?;
43        if disabled.iter().any(|candidate| candidate == &manifest.name) {
44            continue;
45        }
46        let required_env = manifest
47            .required_environment_variables
48            .iter()
49            .map(|entry| entry.name.clone())
50            .chain(
51                manifest
52                    .collect_secrets
53                    .iter()
54                    .map(|entry| entry.env_var.clone()),
55            )
56            .collect::<Vec<_>>();
57        out.push(LoadedSkill {
58            path: skill_file,
59            source,
60            platform_visible: skill_matches_platform(&manifest.platforms),
61            readiness: resolve_skill_readiness(&required_env),
62            manifest,
63        });
64    }
65
66    out.sort_by(|left, right| left.manifest.name.cmp(&right.manifest.name));
67    Ok(out)
68}
69
70fn candidate_skill_files(root: &Path, depth: usize) -> Result<Vec<PathBuf>, PluginError> {
71    if depth > 2 {
72        return Ok(Vec::new());
73    }
74
75    let mut out = Vec::new();
76    let root_skill = root.join("SKILL.md");
77    if root_skill.is_file() {
78        out.push(root_skill);
79    }
80    if depth >= 2 {
81        return Ok(out);
82    }
83
84    for entry in std::fs::read_dir(root)? {
85        let entry = entry?;
86        let path = entry.path();
87        if !path.is_dir() {
88            continue;
89        }
90        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
91            continue;
92        };
93        if EXCLUDED_DIRS
94            .iter()
95            .any(|excluded| excluded.eq_ignore_ascii_case(name))
96        {
97            continue;
98        }
99        let file = path.join("SKILL.md");
100        if file.is_file() {
101            out.push(file);
102        }
103        out.extend(candidate_skill_files(&path, depth + 1)?);
104    }
105    Ok(out)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn scan_detects_root_and_depth_two_skills_but_not_depth_three() {
114        let temp = tempfile::tempdir().expect("tempdir");
115        std::fs::write(
116            temp.path().join("SKILL.md"),
117            skill_doc("root-skill", "Root"),
118        )
119        .expect("write root");
120        std::fs::create_dir_all(temp.path().join("one/two/three")).expect("dirs");
121        std::fs::write(
122            temp.path().join("one/SKILL.md"),
123            skill_doc("one-skill", "One"),
124        )
125        .expect("write one");
126        std::fs::write(
127            temp.path().join("one/two/SKILL.md"),
128            skill_doc("two-skill", "Two"),
129        )
130        .expect("write two");
131        std::fs::write(
132            temp.path().join("one/two/three/SKILL.md"),
133            skill_doc("three-skill", "Three"),
134        )
135        .expect("write three");
136
137        let loaded = scan_skills_dir(temp.path(), SkillSource::User, &[], Platform::Cli)
138            .expect("skills load");
139
140        let names = loaded
141            .into_iter()
142            .map(|skill| skill.manifest.name)
143            .collect::<Vec<_>>();
144        assert!(names.contains(&"root-skill".to_string()));
145        assert!(names.contains(&"one-skill".to_string()));
146        assert!(names.contains(&"two-skill".to_string()));
147        assert!(!names.contains(&"three-skill".to_string()));
148    }
149
150    fn skill_doc(name: &str, title: &str) -> String {
151        format!("---\nname: {name}\ndescription: {title}\n---\n\n# {title}\n\nBody.\n")
152    }
153}