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::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/// 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    /// Display name parsed from frontmatter `name` field
30    pub display_name: Option<String>,
31    /// Version parsed from frontmatter `version` field
32    pub version: Option<String>,
33    /// Allowed tools for this skill
34    pub allowed_tools: Option<Vec<String>>,
35    /// Argument hint
36    pub argument_hint: Option<String>,
37    /// When to use this skill
38    pub when_to_use: Option<String>,
39    /// Whether the skill is user-invocable
40    pub user_invocable: Option<bool>,
41}
42
43/// A skill loaded from a plugin
44#[derive(Debug, Clone)]
45pub struct PluginSkill {
46    pub metadata: PluginSkillMetadata,
47    /// The skill content (markdown)
48    pub content: String,
49    /// The base directory for this skill
50    pub base_dir: String,
51    /// Source plugin name
52    pub source: String,
53    /// Path to the SKILL.md file
54    pub file_path: String,
55}
56
57/// Loaded skills grouped by plugin
58#[derive(Debug, Clone, Default)]
59pub struct PluginSkills {
60    pub skills: HashMap<String, PluginSkill>,
61}
62
63impl PluginSkills {
64    /// Create a new empty PluginSkills
65    pub fn new() -> Self {
66        Self {
67            skills: HashMap::new(),
68        }
69    }
70
71    /// Add a skill to the collection
72    pub fn insert(&mut self, skill: PluginSkill) {
73        self.skills.insert(skill.metadata.full_name.clone(), skill);
74    }
75
76    /// Get a skill by full name
77    pub fn get(&self, name: &str) -> Option<&PluginSkill> {
78        self.skills.get(name)
79    }
80
81    /// Get all skill names
82    pub fn names(&self) -> Vec<String> {
83        self.skills.keys().cloned().collect()
84    }
85
86    /// Get skills count
87    pub fn len(&self) -> usize {
88        self.skills.len()
89    }
90
91    /// Check if empty
92    pub fn is_empty(&self) -> bool {
93        self.skills.is_empty()
94    }
95
96    /// Convert to LoadedSkill for integration with the skill system
97    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
133/// Parse frontmatter from SKILL.md content
134fn 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
162/// Load a single skill from a SKILL.md file in a plugin
163fn 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
214/// Load skills from a plugin skills directory
215///
216/// Supports two formats:
217/// 1. Direct SKILL.md in the skills directory (skills_path/SKILL.md)
218/// 2. Subdirectories with SKILL.md (skills_path/skill-name/SKILL.md)
219fn 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    // Check for direct SKILL.md in the skills directory
229    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            // Skill name is the directory name
236            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    // Otherwise, scan subdirectories for SKILL.md files
256    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            // Accept both directories and symlinks
265            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
301/// Load skills from a plugin
302///
303/// Loads skills from:
304/// 1. Default skills directory (plugin_path/skills)
305/// 2. Additional paths specified in manifest.skills
306pub fn load_plugin_skills(plugin: &LoadedPlugin) -> PluginSkills {
307    let mut skills = PluginSkills::new();
308    let mut loaded_paths = std::collections::HashSet::new();
309
310    // Load from default skills directory
311    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    // Load from additional paths specified in manifest
333    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
359/// Load skills from multiple plugins
360pub 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
373/// Register plugin skills with the global skill registry
374pub 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        // Create skills directory structure
433        let skills_dir = temp_dir.path().join("skills");
434        fs::create_dir_all(&skills_dir).unwrap();
435
436        // Create a skill subdirectory
437        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        // Load skills
449        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}