edgecrab_plugins/skill/
loader.rs1use 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}