mofa_plugins/skill/
disclosure.rs1use crate::skill::{Requirement, RequirementCheck, metadata::SkillMetadata, parser::SkillParser};
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone)]
11pub struct DisclosureController {
12 search_dirs: Vec<PathBuf>,
14 metadata_cache: HashMap<String, SkillMetadata>,
16 skill_sources: HashMap<String, PathBuf>,
18}
19
20impl DisclosureController {
21 pub fn new(skills_dir: impl Into<PathBuf>) -> Self {
23 Self {
24 search_dirs: vec![skills_dir.into()],
25 metadata_cache: HashMap::new(),
26 skill_sources: HashMap::new(),
27 }
28 }
29
30 pub fn with_search_dirs(search_dirs: Vec<PathBuf>) -> Self {
36 Self {
37 search_dirs,
38 metadata_cache: HashMap::new(),
39 skill_sources: HashMap::new(),
40 }
41 }
42
43 pub fn find_builtin_skills() -> Option<PathBuf> {
50 if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
52 let skills_path = PathBuf::from(manifest_dir).join("skills");
53 if skills_path.exists() {
54 return Some(skills_path);
55 }
56 }
57
58 if let Ok(exe) = std::env::current_exe()
60 && let Some(parent) = exe.parent()
61 {
62 let skills_path = parent.join("skills");
63 if skills_path.exists() {
64 return Some(skills_path);
65 }
66 }
67
68 if let Ok(exe) = std::env::current_exe()
70 && let Some(grandparent) = exe.parent().and_then(|p| p.parent())
71 {
72 let skills_path = grandparent.join("lib").join("mofa").join("skills");
73 if skills_path.exists() {
74 return Some(skills_path);
75 }
76 }
77
78 let standard_path = PathBuf::from("/usr/local/lib/mofa/skills");
80 if standard_path.exists() {
81 return Some(standard_path);
82 }
83
84 None
85 }
86
87 pub fn scan_metadata(&mut self) -> anyhow::Result<usize> {
91 let mut count = 0;
92
93 for skills_dir in &self.search_dirs {
94 if !skills_dir.exists() {
95 continue;
96 }
97
98 for entry in walkdir::WalkDir::new(skills_dir)
99 .min_depth(1)
100 .max_depth(1)
101 .into_iter()
102 .filter_map(|e| e.ok())
103 {
104 if entry.file_type().is_dir() {
105 let skill_name = entry.file_name().to_string_lossy().to_string();
106
107 if self.metadata_cache.contains_key(&skill_name) {
109 continue;
110 }
111
112 let skill_md = entry.path().join("SKILL.md");
113 if skill_md.exists()
114 && let Ok((metadata, _)) = SkillParser::parse_from_file(&skill_md)
115 {
116 self.metadata_cache.insert(metadata.name.clone(), metadata);
117 self.skill_sources.insert(skill_name, skills_dir.clone());
118 count += 1;
119 }
120 }
121 }
122 }
123
124 tracing::info!(
125 "Scanned {} skills from {} directories",
126 count,
127 self.search_dirs.len()
128 );
129 Ok(count)
130 }
131
132 pub fn get_all_metadata(&self) -> Vec<SkillMetadata> {
134 self.metadata_cache.values().cloned().collect()
135 }
136
137 pub fn build_system_prompt(&self) -> String {
139 let metadata: Vec<String> = self
140 .metadata_cache
141 .values()
142 .map(|m| format!("- {}: {}", m.name, m.description))
143 .collect();
144
145 format!(
146 "You have access to the following skills:\n{}\n\nWhen a task requires a specific skill, \
147 load the full SKILL.md file to get detailed instructions.",
148 metadata.join("\n")
149 )
150 }
151
152 pub fn get_skill_path(&self, name: &str) -> Option<PathBuf> {
154 self.skill_sources.get(name).map(|dir| dir.join(name))
155 }
156
157 pub fn has_skill(&self, name: &str) -> bool {
159 self.metadata_cache.contains_key(name)
160 }
161
162 pub fn get_always_skills(&self) -> Vec<String> {
164 self.metadata_cache
165 .values()
166 .filter(|m| m.always)
167 .map(|m| m.name.clone())
168 .collect()
169 }
170
171 pub fn check_requirements(&self, name: &str) -> RequirementCheck {
173 let metadata = match self.metadata_cache.get(name) {
174 Some(m) => m,
175 None => return RequirementCheck::default(),
176 };
177
178 let requires = metadata.requires.as_ref();
179 let mut missing = Vec::new();
180
181 if let Some(reqs) = requires {
182 for tool in &reqs.cli_tools {
184 if !Self::command_exists(tool) {
185 missing.push(Requirement::CliTool(tool.clone()));
186 }
187 }
188
189 for env_var in &reqs.env_vars {
191 if std::env::var(env_var).is_err() {
192 missing.push(Requirement::EnvVar(env_var.clone()));
193 }
194 }
195 }
196
197 RequirementCheck {
198 satisfied: missing.is_empty(),
199 missing,
200 }
201 }
202
203 pub fn get_install_instructions(&self, name: &str) -> Option<String> {
205 self.metadata_cache
206 .get(name)
207 .and_then(|m| m.install.as_ref())
208 .cloned()
209 }
210
211 pub fn get_missing_requirements_description(&self, name: &str) -> String {
213 let check = self.check_requirements(name);
214 if check.satisfied {
215 String::new()
216 } else {
217 check
218 .missing
219 .iter()
220 .map(|r| match r {
221 Requirement::CliTool(t) => format!("CLI: {}", t),
222 Requirement::EnvVar(v) => format!("ENV: {}", v),
223 })
224 .collect::<Vec<_>>()
225 .join(", ")
226 }
227 }
228
229 fn command_exists(cmd: &str) -> bool {
231 which::which(cmd).is_ok()
232 }
233
234 pub fn search(&self, query: &str) -> Vec<String> {
236 let query_lower = query.to_lowercase();
237
238 self.metadata_cache
239 .values()
240 .filter(|m| {
241 m.name.to_lowercase().contains(&query_lower)
242 || m.description.to_lowercase().contains(&query_lower)
243 || m.tags
244 .iter()
245 .any(|t| t.to_lowercase().contains(&query_lower))
246 })
247 .map(|m| m.name.clone())
248 .collect()
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use std::fs;
256 use std::path::Path;
257 use tempfile::TempDir;
258
259 fn create_test_skill(dir: &Path, name: &str, description: &str) -> std::io::Result<()> {
260 let skill_dir = dir.join(name);
261 fs::create_dir_all(&skill_dir)?;
262
263 let content = format!(
264 r#"---
265name: {}
266description: {}
267category: test
268tags: [test]
269version: "1.0.0"
270---
271
272# {} Skill
273
274This is a test skill."#,
275 name, description, name
276 );
277
278 fs::write(skill_dir.join("SKILL.md"), content)?;
279 Ok(())
280 }
281
282 #[test]
283 fn test_scan_metadata() {
284 let temp_dir = TempDir::new().unwrap();
285 let skills_dir = temp_dir.path();
286
287 create_test_skill(skills_dir, "skill1", "First skill").unwrap();
288 create_test_skill(skills_dir, "skill2", "Second skill").unwrap();
289
290 let mut controller = DisclosureController::new(skills_dir);
291 let count = controller.scan_metadata().unwrap();
292
293 assert_eq!(count, 2);
294 assert!(controller.has_skill("skill1"));
295 assert!(controller.has_skill("skill2"));
296 assert!(!controller.has_skill("skill3"));
297 }
298
299 #[test]
300 fn test_build_system_prompt() {
301 let temp_dir = TempDir::new().unwrap();
302 let skills_dir = temp_dir.path();
303
304 create_test_skill(skills_dir, "skill1", "First skill").unwrap();
305 create_test_skill(skills_dir, "skill2", "Second skill").unwrap();
306
307 let mut controller = DisclosureController::new(skills_dir);
308 controller.scan_metadata().unwrap();
309
310 let prompt = controller.build_system_prompt();
311 assert!(prompt.contains("skill1"));
312 assert!(prompt.contains("First skill"));
313 assert!(prompt.contains("skill2"));
314 assert!(prompt.contains("Second skill"));
315 }
316
317 #[test]
318 fn test_search() {
319 let temp_dir = TempDir::new().unwrap();
320 let skills_dir = temp_dir.path();
321
322 create_test_skill(skills_dir, "pdf_processing", "Process PDF files").unwrap();
323 create_test_skill(skills_dir, "web_scraping", "Scrape web pages").unwrap();
324
325 let mut controller = DisclosureController::new(skills_dir);
326 controller.scan_metadata().unwrap();
327
328 let results = controller.search("pdf");
329 assert_eq!(results, vec!["pdf_processing".to_string()]);
330
331 let results = controller.search("web");
332 assert_eq!(results, vec!["web_scraping".to_string()]);
333 }
334}