Skip to main content

ai_agent/plugin/
skills.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/commands/skills/skills.tsx
2//! Plugin skills - loads skills from plugins
3//!
4//! Ported from ~/claudecode/openclaudecode/src/utils/plugins/loadPluginCommands.ts
5//!
6//! This module provides functionality to load skills from plugin directories.
7//! Skills can be defined either as:
8//! - A direct SKILL.md file in the plugin's skills directory
9//! - Subdirectories containing SKILL.md files (skill-name/SKILL.md format)
10
11use 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/// Skill metadata parsed from plugin SKILL.md frontmatter
19#[derive(Debug, Clone)]
20pub struct PluginSkillMetadata {
21    /// Full skill name including plugin prefix (e.g., "my-plugin:skill-name")
22    pub full_name: String,
23    /// Plugin name (e.g., "my-plugin")
24    pub plugin_name: String,
25    /// Skill name without plugin prefix (e.g., "skill-name")
26    pub skill_name: String,
27    /// Human-readable description
28    pub description: Option<String>,
29    /// Allowed tools for this skill
30    pub allowed_tools: Option<Vec<String>>,
31    /// Argument hint
32    pub argument_hint: Option<String>,
33    /// When to use this skill
34    pub when_to_use: Option<String>,
35    /// Whether the skill is user-invocable
36    pub user_invocable: Option<bool>,
37}
38
39/// A skill loaded from a plugin
40#[derive(Debug, Clone)]
41pub struct PluginSkill {
42    pub metadata: PluginSkillMetadata,
43    /// The skill content (markdown)
44    pub content: String,
45    /// The base directory for this skill
46    pub base_dir: String,
47    /// Source plugin name
48    pub source: String,
49    /// Path to the SKILL.md file
50    pub file_path: String,
51}
52
53/// Loaded skills grouped by plugin
54#[derive(Debug, Clone, Default)]
55pub struct PluginSkills {
56    pub skills: HashMap<String, PluginSkill>,
57}
58
59impl PluginSkills {
60    /// Create a new empty PluginSkills
61    pub fn new() -> Self {
62        Self {
63            skills: HashMap::new(),
64        }
65    }
66
67    /// Add a skill to the collection
68    pub fn insert(&mut self, skill: PluginSkill) {
69        self.skills.insert(skill.metadata.full_name.clone(), skill);
70    }
71
72    /// Get a skill by full name
73    pub fn get(&self, name: &str) -> Option<&PluginSkill> {
74        self.skills.get(name)
75    }
76
77    /// Get all skill names
78    pub fn names(&self) -> Vec<String> {
79        self.skills.keys().cloned().collect()
80    }
81
82    /// Get skills count
83    pub fn len(&self) -> usize {
84        self.skills.len()
85    }
86
87    /// Check if empty
88    pub fn is_empty(&self) -> bool {
89        self.skills.is_empty()
90    }
91
92    /// Convert to LoadedSkill for integration with the skill system
93    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
126/// Parse frontmatter from SKILL.md content
127fn 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
155/// Load a single skill from a SKILL.md file in a plugin
156fn 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
205/// Load skills from a plugin skills directory
206///
207/// Supports two formats:
208/// 1. Direct SKILL.md in the skills directory (skills_path/SKILL.md)
209/// 2. Subdirectories with SKILL.md (skills_path/skill-name/SKILL.md)
210fn 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    // Check for direct SKILL.md in the skills directory
220    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            // Skill name is the directory name
227            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    // Otherwise, scan subdirectories for SKILL.md files
247    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            // Accept both directories and symlinks
256            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
292/// Load skills from a plugin
293///
294/// Loads skills from:
295/// 1. Default skills directory (plugin_path/skills)
296/// 2. Additional paths specified in manifest.skills
297pub fn load_plugin_skills(plugin: &LoadedPlugin) -> PluginSkills {
298    let mut skills = PluginSkills::new();
299    let mut loaded_paths = std::collections::HashSet::new();
300
301    // Load from default skills directory
302    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    // Load from additional paths specified in manifest
324    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
350/// Load skills from multiple plugins
351pub 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
364/// Register plugin skills with the global skill registry
365pub 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        // Create skills directory structure
424        let skills_dir = temp_dir.path().join("skills");
425        fs::create_dir_all(&skills_dir).unwrap();
426
427        // Create a skill subdirectory
428        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        // Load skills
440        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}