1use crate::plugin::types::{LoadedPlugin, PluginManifest};
12use crate::skills::loader::LoadedSkill;
13use crate::AgentError;
14use std::collections::HashMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone)]
20pub struct PluginSkillMetadata {
21 pub full_name: String,
23 pub plugin_name: String,
25 pub skill_name: String,
27 pub description: Option<String>,
29 pub allowed_tools: Option<Vec<String>>,
31 pub argument_hint: Option<String>,
33 pub when_to_use: Option<String>,
35 pub user_invocable: Option<bool>,
37}
38
39#[derive(Debug, Clone)]
41pub struct PluginSkill {
42 pub metadata: PluginSkillMetadata,
43 pub content: String,
45 pub base_dir: String,
47 pub source: String,
49 pub file_path: String,
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct PluginSkills {
56 pub skills: HashMap<String, PluginSkill>,
57}
58
59impl PluginSkills {
60 pub fn new() -> Self {
62 Self {
63 skills: HashMap::new(),
64 }
65 }
66
67 pub fn insert(&mut self, skill: PluginSkill) {
69 self.skills.insert(skill.metadata.full_name.clone(), skill);
70 }
71
72 pub fn get(&self, name: &str) -> Option<&PluginSkill> {
74 self.skills.get(name)
75 }
76
77 pub fn names(&self) -> Vec<String> {
79 self.skills.keys().cloned().collect()
80 }
81
82 pub fn len(&self) -> usize {
84 self.skills.len()
85 }
86
87 pub fn is_empty(&self) -> bool {
89 self.skills.is_empty()
90 }
91
92 pub fn to_loaded_skills(&self) -> Vec<LoadedSkill> {
94 self.skills
95 .values()
96 .map(|plugin_skill| {
97 let metadata = crate::skills::loader::SkillMetadata {
98 name: plugin_skill.metadata.full_name.clone(),
99 description: plugin_skill
100 .metadata
101 .description
102 .clone()
103 .unwrap_or_default(),
104 allowed_tools: plugin_skill.metadata.allowed_tools.clone(),
105 argument_hint: plugin_skill.metadata.argument_hint.clone(),
106 arg_names: None,
107 when_to_use: plugin_skill.metadata.when_to_use.clone(),
108 user_invocable: plugin_skill.metadata.user_invocable,
109 paths: None,
110 hooks: None,
111 effort: None,
112 model: None,
113 context: None,
114 agent: None,
115 };
116 LoadedSkill {
117 metadata,
118 content: plugin_skill.content.clone(),
119 base_dir: plugin_skill.base_dir.clone(),
120 }
121 })
122 .collect()
123 }
124}
125
126fn parse_frontmatter(content: &str) -> (HashMap<String, String>, String) {
128 let mut fields = HashMap::new();
129 let trimmed = content.trim();
130
131 if !trimmed.starts_with("---") {
132 return (fields, content.to_string());
133 }
134
135 if let Some(end_pos) = trimmed[3..].find("---") {
136 let frontmatter = &trimmed[3..end_pos + 3];
137 for line in frontmatter.lines() {
138 let line = line.trim();
139 if line.is_empty() || line.starts_with('#') {
140 continue;
141 }
142 if let Some(colon_pos) = line.find(':') {
143 let key = line[..colon_pos].trim().to_string();
144 let value = line[colon_pos + 1..].trim().to_string();
145 fields.insert(key, value);
146 }
147 }
148 let body = trimmed[end_pos + 6..].trim_start().to_string();
149 return (fields, body);
150 }
151
152 (fields, content.to_string())
153}
154
155fn load_skill_from_file(
157 skill_file_path: &Path,
158 plugin_name: &str,
159 skill_name: &str,
160 source: &str,
161) -> Result<PluginSkill, AgentError> {
162 let content = fs::read_to_string(skill_file_path).map_err(|e| AgentError::Io(e))?;
163
164 let (fields, body) = parse_frontmatter(&content);
165
166 let full_name = format!("{}:{}", plugin_name, skill_name);
167
168 let description = fields.get("description").cloned();
169 let allowed_tools = fields
170 .get("allowed-tools")
171 .map(|s| s.split(',').map(|x| x.trim().to_string()).collect());
172 let argument_hint = fields.get("argument-hint").cloned();
173 let when_to_use = fields.get("when_to_use").cloned();
174 let user_invocable = fields.get("user-invocable").and_then(|v| match v.as_str() {
175 "true" | "1" => Some(true),
176 "false" | "0" => Some(false),
177 _ => None,
178 });
179
180 let metadata = PluginSkillMetadata {
181 full_name: full_name.clone(),
182 plugin_name: plugin_name.to_string(),
183 skill_name: skill_name.to_string(),
184 description,
185 allowed_tools,
186 argument_hint,
187 when_to_use,
188 user_invocable,
189 };
190
191 let base_dir = skill_file_path
192 .parent()
193 .map(|p| p.to_string_lossy().to_string())
194 .unwrap_or_default();
195
196 Ok(PluginSkill {
197 metadata,
198 content: body,
199 base_dir,
200 source: source.to_string(),
201 file_path: skill_file_path.to_string_lossy().to_string(),
202 })
203}
204
205fn load_skills_from_plugin_dir(
211 skills_path: &Path,
212 plugin_name: &str,
213 source: &str,
214 _manifest: &PluginManifest,
215 loaded_paths: &mut std::collections::HashSet<String>,
216) -> Vec<PluginSkill> {
217 let mut skills = Vec::new();
218
219 let direct_skill_path = skills_path.join("SKILL.md");
221 if direct_skill_path.exists() {
222 let path_str = direct_skill_path.to_string_lossy().to_string();
223 if !loaded_paths.contains(&path_str) {
224 loaded_paths.insert(path_str);
225
226 let skill_name = skills_path
228 .file_name()
229 .and_then(|n| n.to_str())
230 .unwrap_or("unknown");
231
232 match load_skill_from_file(&direct_skill_path, plugin_name, skill_name, source) {
233 Ok(skill) => skills.push(skill),
234 Err(e) => {
235 log::warn!(
236 "Failed to load skill from {}: {}",
237 direct_skill_path.display(),
238 e
239 );
240 }
241 }
242 return skills;
243 }
244 }
245
246 if !skills_path.is_dir() {
248 return skills;
249 }
250
251 if let Ok(entries) = fs::read_dir(skills_path) {
252 for entry in entries.flatten() {
253 let entry_path = entry.path();
254
255 if !entry_path.is_dir() && !entry_path.is_symlink() {
257 continue;
258 }
259
260 let skill_file_path = entry_path.join("SKILL.md");
261 if !skill_file_path.exists() {
262 continue;
263 }
264
265 let path_str = skill_file_path.to_string_lossy().to_string();
266 if loaded_paths.contains(&path_str) {
267 continue;
268 }
269 loaded_paths.insert(path_str);
270
271 let skill_name = entry_path
272 .file_name()
273 .and_then(|n| n.to_str())
274 .unwrap_or("unknown");
275
276 match load_skill_from_file(&skill_file_path, plugin_name, skill_name, source) {
277 Ok(skill) => skills.push(skill),
278 Err(e) => {
279 log::warn!(
280 "Failed to load skill from {}: {}",
281 skill_file_path.display(),
282 e
283 );
284 }
285 }
286 }
287 }
288
289 skills
290}
291
292pub fn load_plugin_skills(plugin: &LoadedPlugin) -> PluginSkills {
298 let mut skills = PluginSkills::new();
299 let mut loaded_paths = std::collections::HashSet::new();
300
301 if let Some(ref skills_path) = plugin.skills_path {
303 let path = PathBuf::from(skills_path);
304 if path.exists() {
305 log::debug!(
306 "Loading skills from plugin {} default path: {}",
307 plugin.name,
308 skills_path
309 );
310 let loaded = load_skills_from_plugin_dir(
311 &path,
312 &plugin.name,
313 &plugin.source,
314 &plugin.manifest,
315 &mut loaded_paths,
316 );
317 for skill in loaded {
318 skills.insert(skill);
319 }
320 }
321 }
322
323 if let Some(ref skills_paths) = plugin.skills_paths {
325 for skill_path in skills_paths {
326 let path = PathBuf::from(skill_path);
327 if path.exists() {
328 log::debug!(
329 "Loading skills from plugin {} custom path: {}",
330 plugin.name,
331 skill_path
332 );
333 let loaded = load_skills_from_plugin_dir(
334 &path,
335 &plugin.name,
336 &plugin.source,
337 &plugin.manifest,
338 &mut loaded_paths,
339 );
340 for skill in loaded {
341 skills.insert(skill);
342 }
343 }
344 }
345 }
346
347 skills
348}
349
350pub fn load_skills_from_plugins(plugins: &[LoadedPlugin]) -> PluginSkills {
352 let mut all_skills = PluginSkills::new();
353
354 for plugin in plugins {
355 let plugin_skills = load_plugin_skills(plugin);
356 for skill in plugin_skills.skills.into_values() {
357 all_skills.insert(skill);
358 }
359 }
360
361 all_skills
362}
363
364pub fn register_plugin_skills(plugins: &[LoadedPlugin]) {
366 let plugin_skills = load_skills_from_plugins(plugins);
367 if !plugin_skills.is_empty() {
368 let loaded_skills = plugin_skills.to_loaded_skills();
369 crate::tools::skill::register_skills(loaded_skills.clone());
370 log::info!(
371 "Registered {} plugin skills: {:?}",
372 loaded_skills.len(),
373 loaded_skills
374 .iter()
375 .map(|s| s.metadata.name.clone())
376 .collect::<Vec<_>>()
377 );
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use std::fs;
385 use tempfile::TempDir;
386
387 fn create_test_skill(dir: &Path, name: &str, content: &str) {
388 let skill_dir = dir.join(name);
389 fs::create_dir_all(&skill_dir).unwrap();
390 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
391 }
392
393 #[test]
394 fn test_parse_frontmatter() {
395 let content = r#"---
396description: A test skill
397allowed-tools: tool1,tool2
398---
399
400This is the skill content.
401"#;
402 let (fields, body) = parse_frontmatter(content);
403 assert_eq!(fields.get("description"), Some(&"A test skill".to_string()));
404 assert_eq!(
405 fields.get("allowed-tools"),
406 Some(&"tool1,tool2".to_string())
407 );
408 assert_eq!(body, "This is the skill content.");
409 }
410
411 #[test]
412 fn test_parse_frontmatter_no_frontmatter() {
413 let content = "Just plain content without frontmatter";
414 let (fields, body) = parse_frontmatter(content);
415 assert!(fields.is_empty());
416 assert_eq!(body, content);
417 }
418
419 #[test]
420 fn test_load_skills_from_plugin_dir() {
421 let temp_dir = TempDir::new().unwrap();
422
423 let skills_dir = temp_dir.path().join("skills");
425 fs::create_dir_all(&skills_dir).unwrap();
426
427 create_test_skill(
429 &skills_dir,
430 "test-skill",
431 r#"---
432description: A test skill
433---
434
435Test skill content here.
436"#,
437 );
438
439 let mut loaded_paths = std::collections::HashSet::new();
441 let skills = load_skills_from_plugin_dir(
442 &skills_dir,
443 "test-plugin",
444 "test-source",
445 &PluginManifest {
446 name: "test-plugin".to_string(),
447 version: None,
448 description: None,
449 author: None,
450 homepage: None,
451 repository: None,
452 license: None,
453 keywords: None,
454 dependencies: None,
455 commands: None,
456 agents: None,
457 skills: None,
458 hooks: None,
459 output_styles: None,
460 channels: None,
461 mcp_servers: None,
462 lsp_servers: None,
463 settings: None,
464 user_config: None,
465 },
466 &mut loaded_paths,
467 );
468
469 assert_eq!(skills.len(), 1);
470 assert_eq!(skills[0].metadata.full_name, "test-plugin:test-skill");
471 assert_eq!(skills[0].metadata.plugin_name, "test-plugin");
472 assert_eq!(skills[0].metadata.skill_name, "test-skill");
473 assert_eq!(skills[0].content, "Test skill content here.");
474 }
475
476 #[test]
477 fn test_plugin_skill_to_loaded_skill() {
478 let plugin_skill = PluginSkill {
479 metadata: PluginSkillMetadata {
480 full_name: "my-plugin:my-skill".to_string(),
481 plugin_name: "my-plugin".to_string(),
482 skill_name: "my-skill".to_string(),
483 description: Some("A plugin skill".to_string()),
484 allowed_tools: Some(vec!["tool1".to_string()]),
485 argument_hint: None,
486 when_to_use: None,
487 user_invocable: Some(true),
488 },
489 content: "Skill content".to_string(),
490 base_dir: "/path/to/skill".to_string(),
491 source: "my-plugin".to_string(),
492 file_path: "/path/to/skill/SKILL.md".to_string(),
493 };
494
495 let loaded_skills = PluginSkills {
496 skills: [("my-plugin:my-skill".to_string(), plugin_skill)]
497 .into_iter()
498 .collect(),
499 };
500
501 let converted = loaded_skills.to_loaded_skills();
502 assert_eq!(converted.len(), 1);
503 assert_eq!(converted[0].metadata.name, "my-plugin:my-skill");
504 assert_eq!(converted[0].content, "Skill content");
505 }
506
507 #[test]
508 fn test_plugin_skills_collection() {
509 let mut skills = PluginSkills::new();
510 assert!(skills.is_empty());
511
512 let skill = PluginSkill {
513 metadata: PluginSkillMetadata {
514 full_name: "test:skill".to_string(),
515 plugin_name: "test".to_string(),
516 skill_name: "skill".to_string(),
517 description: None,
518 allowed_tools: None,
519 argument_hint: None,
520 when_to_use: None,
521 user_invocable: None,
522 },
523 content: "content".to_string(),
524 base_dir: "/base".to_string(),
525 source: "test".to_string(),
526 file_path: "/base/SKILL.md".to_string(),
527 };
528
529 skills.insert(skill);
530 assert_eq!(skills.len(), 1);
531 assert_eq!(skills.get("test:skill").unwrap().content, "content");
532 assert_eq!(skills.names(), vec!["test:skill"]);
533 }
534}