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