Skip to main content

claude_agent/plugins/
loader.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use super::PluginError;
7use super::manifest::PluginDescriptor;
8use super::namespace;
9use crate::common::SourceType;
10use crate::config::HookConfig;
11use crate::hooks::{HookEvent, HookRule};
12use crate::mcp::McpServerConfig;
13use crate::skills::{SkillIndex, SkillIndexLoader};
14use crate::subagents::{SubagentIndex, SubagentIndexLoader};
15
16const PLUGIN_ROOT_VAR: &str = "${CLAUDE_PLUGIN_ROOT}";
17
18fn resolve_plugin_root(value: &str, root: &Path) -> String {
19    value.replace(PLUGIN_ROOT_VAR, &root.display().to_string())
20}
21
22fn resolve_hook_config(config: HookConfig, root: &Path) -> HookConfig {
23    match config {
24        HookConfig::Command(cmd) => HookConfig::Command(resolve_plugin_root(&cmd, root)),
25        HookConfig::Full {
26            command,
27            timeout_secs,
28            matcher,
29        } => HookConfig::Full {
30            command: resolve_plugin_root(&command, root),
31            timeout_secs,
32            matcher,
33        },
34    }
35}
36
37fn resolve_mcp_config(config: McpServerConfig, root: &Path) -> McpServerConfig {
38    match config {
39        McpServerConfig::Stdio {
40            command,
41            args,
42            env,
43            cwd,
44        } => McpServerConfig::Stdio {
45            command: resolve_plugin_root(&command, root),
46            args: args
47                .into_iter()
48                .map(|a| resolve_plugin_root(&a, root))
49                .collect(),
50            env: env
51                .into_iter()
52                .map(|(k, v)| (k, resolve_plugin_root(&v, root)))
53                .collect(),
54            cwd: cwd.map(|c| resolve_plugin_root(&c, root)),
55        },
56        other => other,
57    }
58}
59
60/// Outer wrapper for hooks.json — supports both official nested format
61/// and flat legacy format.
62#[derive(Debug, Deserialize)]
63#[serde(untagged)]
64enum PluginHooksFile {
65    /// Official format: `{"hooks": {"PreToolUse": [{"matcher": "...", "hooks": [...]}]}}`
66    Official {
67        hooks: HashMap<String, Vec<HookRule>>,
68    },
69    /// Legacy flat format: `{"PreToolUse": ["echo pre"]}`
70    Flat(HashMap<String, Vec<HookConfig>>),
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PluginHookEntry {
75    pub plugin: String,
76    pub event: HookEvent,
77    pub config: HookConfig,
78    pub plugin_root: PathBuf,
79}
80
81#[derive(Debug, Default)]
82pub struct PluginResources {
83    pub skills: Vec<SkillIndex>,
84    pub subagents: Vec<SubagentIndex>,
85    pub hooks: Vec<PluginHookEntry>,
86    pub mcp_servers: HashMap<String, McpServerConfig>,
87}
88
89pub struct PluginLoader;
90
91impl PluginLoader {
92    pub async fn load(plugin: &PluginDescriptor) -> Result<PluginResources, PluginError> {
93        let plugin_name = plugin.name();
94        let mut resources = PluginResources::default();
95
96        Self::load_skills(plugin, plugin_name, &mut resources).await?;
97        Self::load_commands(plugin, plugin_name, &mut resources).await?;
98        Self::load_subagents(plugin, plugin_name, &mut resources).await?;
99        Self::load_hooks(plugin, plugin_name, &mut resources).await?;
100        Self::load_mcp(plugin, plugin_name, &mut resources).await?;
101
102        Ok(resources)
103    }
104
105    async fn load_skills(
106        plugin: &PluginDescriptor,
107        plugin_name: &str,
108        resources: &mut PluginResources,
109    ) -> Result<(), PluginError> {
110        let skills_dir = plugin.skills_dir();
111        if !skills_dir.exists() {
112            return Ok(());
113        }
114
115        let loader = SkillIndexLoader::new();
116        let skills =
117            loader
118                .scan_directory(&skills_dir)
119                .await
120                .map_err(|e| PluginError::ResourceLoad {
121                    plugin: plugin_name.to_string(),
122                    message: format!("skills: {e}"),
123                })?;
124
125        let plugin_root = plugin.root_dir();
126        for mut skill in skills {
127            skill.name = namespace::namespaced(plugin_name, &skill.name);
128            skill.source_type = SourceType::Plugin;
129            Self::collect_resource_hooks(
130                &skill.hooks,
131                plugin_name,
132                plugin_root,
133                &mut resources.hooks,
134            );
135            resources.skills.push(skill);
136        }
137
138        Ok(())
139    }
140
141    /// Load legacy `commands/` directory (simple markdown files treated as skills).
142    async fn load_commands(
143        plugin: &PluginDescriptor,
144        plugin_name: &str,
145        resources: &mut PluginResources,
146    ) -> Result<(), PluginError> {
147        let commands_dir = plugin.commands_dir();
148        if !commands_dir.exists() {
149            return Ok(());
150        }
151
152        let loader = SkillIndexLoader::new();
153        let mut entries =
154            tokio::fs::read_dir(&commands_dir)
155                .await
156                .map_err(|e| PluginError::ResourceLoad {
157                    plugin: plugin_name.to_string(),
158                    message: format!("commands dir: {e}"),
159                })?;
160
161        while let Some(entry) =
162            entries
163                .next_entry()
164                .await
165                .map_err(|e| PluginError::ResourceLoad {
166                    plugin: plugin_name.to_string(),
167                    message: format!("commands entry: {e}"),
168                })?
169        {
170            let path = entry.path();
171            if !path.is_file() {
172                continue;
173            }
174            let ext = path.extension().and_then(|e| e.to_str());
175            if ext != Some("md") {
176                continue;
177            }
178
179            let skill = match loader.load_file(&path).await {
180                Ok(s) => s,
181                Err(_) => continue,
182            };
183
184            let namespaced = namespace::namespaced(plugin_name, &skill.name);
185            // Only add if not already loaded from skills/ (skills take precedence)
186            if !resources.skills.iter().any(|s| s.name == namespaced) {
187                let mut skill = skill;
188                skill.name = namespaced;
189                skill.source_type = SourceType::Plugin;
190                resources.skills.push(skill);
191            }
192        }
193
194        Ok(())
195    }
196
197    async fn load_subagents(
198        plugin: &PluginDescriptor,
199        plugin_name: &str,
200        resources: &mut PluginResources,
201    ) -> Result<(), PluginError> {
202        let agents_dir = plugin.agents_dir();
203        if !agents_dir.exists() {
204            return Ok(());
205        }
206
207        let loader = SubagentIndexLoader::new();
208        let subagents =
209            loader
210                .scan_directory(&agents_dir)
211                .await
212                .map_err(|e| PluginError::ResourceLoad {
213                    plugin: plugin_name.to_string(),
214                    message: format!("agents: {e}"),
215                })?;
216
217        let plugin_root = plugin.root_dir();
218        for mut subagent in subagents {
219            subagent.name = namespace::namespaced(plugin_name, &subagent.name);
220            subagent.source_type = SourceType::Plugin;
221            Self::collect_resource_hooks(
222                &subagent.hooks,
223                plugin_name,
224                plugin_root,
225                &mut resources.hooks,
226            );
227            resources.subagents.push(subagent);
228        }
229
230        Ok(())
231    }
232
233    fn collect_resource_hooks(
234        hooks: &Option<HashMap<String, Vec<HookRule>>>,
235        plugin_name: &str,
236        plugin_root: &Path,
237        out: &mut Vec<PluginHookEntry>,
238    ) {
239        let Some(hooks_map) = hooks else { return };
240        for (event_name, rules) in hooks_map {
241            let Some(event) = HookEvent::from_pascal_case(event_name) else {
242                continue;
243            };
244            for rule in rules {
245                let matcher = rule.matcher.as_deref();
246                for action in &rule.hooks {
247                    if let Some(config) = action
248                        .to_hook_config(matcher)
249                        .map(|c| resolve_hook_config(c, plugin_root))
250                    {
251                        out.push(PluginHookEntry {
252                            plugin: plugin_name.to_string(),
253                            event,
254                            config,
255                            plugin_root: plugin_root.to_path_buf(),
256                        });
257                    }
258                }
259            }
260        }
261    }
262
263    async fn load_hooks(
264        plugin: &PluginDescriptor,
265        plugin_name: &str,
266        resources: &mut PluginResources,
267    ) -> Result<(), PluginError> {
268        let hooks_path = plugin.hooks_config_path();
269        if !hooks_path.exists() {
270            return Ok(());
271        }
272
273        let content = tokio::fs::read_to_string(&hooks_path).await?;
274        let hooks_file: PluginHooksFile =
275            serde_json::from_str(&content).map_err(|e| PluginError::InvalidManifest {
276                path: hooks_path,
277                reason: format!("hooks config: {e}"),
278            })?;
279
280        let plugin_root = plugin.root_dir();
281
282        match hooks_file {
283            PluginHooksFile::Official { hooks } => {
284                Self::collect_resource_hooks(
285                    &Some(hooks),
286                    plugin_name,
287                    plugin_root,
288                    &mut resources.hooks,
289                );
290            }
291            PluginHooksFile::Flat(hooks_map) => {
292                for (event_name, configs) in hooks_map {
293                    let Some(event) = HookEvent::from_pascal_case(&event_name) else {
294                        continue;
295                    };
296                    for mut config in configs {
297                        config = resolve_hook_config(config, plugin_root);
298                        resources.hooks.push(PluginHookEntry {
299                            plugin: plugin_name.to_string(),
300                            event,
301                            config,
302                            plugin_root: plugin_root.to_path_buf(),
303                        });
304                    }
305                }
306            }
307        }
308
309        Ok(())
310    }
311
312    async fn load_mcp(
313        plugin: &PluginDescriptor,
314        plugin_name: &str,
315        resources: &mut PluginResources,
316    ) -> Result<(), PluginError> {
317        let mcp_path = plugin.mcp_config_path();
318        if !mcp_path.exists() {
319            return Ok(());
320        }
321
322        let content = tokio::fs::read_to_string(&mcp_path).await?;
323
324        #[derive(Deserialize)]
325        struct McpConfig {
326            #[serde(rename = "mcpServers", default)]
327            mcp_servers: HashMap<String, McpServerConfig>,
328        }
329
330        let config: McpConfig =
331            serde_json::from_str(&content).map_err(|e| PluginError::InvalidManifest {
332                path: mcp_path,
333                reason: format!("MCP config: {e}"),
334            })?;
335
336        let plugin_root = plugin.root_dir();
337        for (name, server_config) in config.mcp_servers {
338            let namespaced_name = namespace::namespaced(plugin_name, &name);
339            let resolved = resolve_mcp_config(server_config, plugin_root);
340            resources.mcp_servers.insert(namespaced_name, resolved);
341        }
342
343        Ok(())
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use std::path::PathBuf;
351    use tempfile::tempdir;
352
353    use crate::plugins::manifest::PluginManifest;
354
355    fn make_descriptor(root: PathBuf, name: &str) -> PluginDescriptor {
356        PluginDescriptor::new(
357            PluginManifest {
358                name: name.into(),
359                description: "test".into(),
360                version: "1.0.0".into(),
361                author: None,
362                homepage: None,
363                repository: None,
364                license: None,
365                keywords: Vec::new(),
366            },
367            root,
368        )
369    }
370
371    #[tokio::test]
372    async fn test_load_skills() {
373        let dir = tempdir().unwrap();
374        let skills_dir = dir.path().join("skills");
375        std::fs::create_dir_all(&skills_dir).unwrap();
376        std::fs::write(
377            skills_dir.join("commit.skill.md"),
378            "---\nname: commit\ndescription: Git commit\n---\nContent",
379        )
380        .unwrap();
381
382        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
383        let resources = PluginLoader::load(&descriptor).await.unwrap();
384
385        assert_eq!(resources.skills.len(), 1);
386        assert_eq!(resources.skills[0].name, "my-plugin:commit");
387        assert_eq!(resources.skills[0].source_type, SourceType::Plugin);
388    }
389
390    #[tokio::test]
391    async fn test_load_subagents() {
392        let dir = tempdir().unwrap();
393        let agents_dir = dir.path().join("agents");
394        std::fs::create_dir_all(&agents_dir).unwrap();
395        std::fs::write(
396            agents_dir.join("reviewer.md"),
397            "---\nname: reviewer\ndescription: Code reviewer\n---\nPrompt",
398        )
399        .unwrap();
400
401        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
402        let resources = PluginLoader::load(&descriptor).await.unwrap();
403
404        assert_eq!(resources.subagents.len(), 1);
405        assert_eq!(resources.subagents[0].name, "my-plugin:reviewer");
406        assert_eq!(resources.subagents[0].source_type, SourceType::Plugin);
407    }
408
409    #[tokio::test]
410    async fn test_load_hooks() {
411        let dir = tempdir().unwrap();
412        let hooks_dir = dir.path().join("hooks");
413        std::fs::create_dir_all(&hooks_dir).unwrap();
414        std::fs::write(
415            hooks_dir.join("hooks.json"),
416            r#"{"PreToolUse": ["echo pre"], "SessionStart": ["echo start"]}"#,
417        )
418        .unwrap();
419
420        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
421        let resources = PluginLoader::load(&descriptor).await.unwrap();
422
423        assert_eq!(resources.hooks.len(), 2);
424        assert!(
425            resources
426                .hooks
427                .iter()
428                .any(|h| h.event == HookEvent::PreToolUse)
429        );
430        assert!(
431            resources
432                .hooks
433                .iter()
434                .any(|h| h.event == HookEvent::SessionStart)
435        );
436    }
437
438    #[tokio::test]
439    async fn test_load_mcp() {
440        let dir = tempdir().unwrap();
441        std::fs::write(
442            dir.path().join(".mcp.json"),
443            r#"{"mcpServers":{"context7":{"type":"stdio","command":"npx","args":["@context7/mcp"]}}}"#,
444        )
445        .unwrap();
446
447        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
448        let resources = PluginLoader::load(&descriptor).await.unwrap();
449
450        assert_eq!(resources.mcp_servers.len(), 1);
451        assert!(resources.mcp_servers.contains_key("my-plugin:context7"));
452    }
453
454    #[tokio::test]
455    async fn test_load_empty_plugin() {
456        let dir = tempdir().unwrap();
457        let descriptor = make_descriptor(dir.path().to_path_buf(), "empty");
458        let resources = PluginLoader::load(&descriptor).await.unwrap();
459
460        assert!(resources.skills.is_empty());
461        assert!(resources.subagents.is_empty());
462        assert!(resources.hooks.is_empty());
463        assert!(resources.mcp_servers.is_empty());
464    }
465
466    #[tokio::test]
467    async fn test_namespace_applied_to_all_resources() {
468        let dir = tempdir().unwrap();
469
470        // Create skills
471        let skills_dir = dir.path().join("skills");
472        std::fs::create_dir_all(&skills_dir).unwrap();
473        std::fs::write(
474            skills_dir.join("build.skill.md"),
475            "---\nname: build\ndescription: Build project\n---\nBuild it",
476        )
477        .unwrap();
478
479        // Create MCP
480        std::fs::write(
481            dir.path().join(".mcp.json"),
482            r#"{"mcpServers":{"server1":{"type":"stdio","command":"cmd"}}}"#,
483        )
484        .unwrap();
485
486        let descriptor = make_descriptor(dir.path().to_path_buf(), "acme");
487        let resources = PluginLoader::load(&descriptor).await.unwrap();
488
489        assert_eq!(resources.skills[0].name, "acme:build");
490        assert!(resources.mcp_servers.contains_key("acme:server1"));
491    }
492
493    #[tokio::test]
494    async fn test_load_hooks_official_format() {
495        let dir = tempdir().unwrap();
496        let hooks_dir = dir.path().join("hooks");
497        std::fs::create_dir_all(&hooks_dir).unwrap();
498        std::fs::write(
499            hooks_dir.join("hooks.json"),
500            r#"{
501                "hooks": {
502                    "PostToolUse": [
503                        {
504                            "matcher": "Write|Edit",
505                            "hooks": [
506                                {"type": "command", "command": "scripts/format.sh"}
507                            ]
508                        }
509                    ],
510                    "PreToolUse": [
511                        {
512                            "hooks": [
513                                {"type": "command", "command": "scripts/check.sh", "timeout": 10}
514                            ]
515                        }
516                    ]
517                }
518            }"#,
519        )
520        .unwrap();
521
522        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
523        let resources = PluginLoader::load(&descriptor).await.unwrap();
524
525        assert_eq!(resources.hooks.len(), 2);
526
527        let post = resources
528            .hooks
529            .iter()
530            .find(|h| h.event == HookEvent::PostToolUse)
531            .unwrap();
532        match &post.config {
533            HookConfig::Full {
534                command, matcher, ..
535            } => {
536                assert_eq!(command, "scripts/format.sh");
537                assert_eq!(matcher.as_deref(), Some("Write|Edit"));
538            }
539            _ => panic!("Expected Full config"),
540        }
541
542        let pre = resources
543            .hooks
544            .iter()
545            .find(|h| h.event == HookEvent::PreToolUse)
546            .unwrap();
547        match &pre.config {
548            HookConfig::Full {
549                command,
550                timeout_secs,
551                ..
552            } => {
553                assert_eq!(command, "scripts/check.sh");
554                assert_eq!(*timeout_secs, Some(10));
555            }
556            _ => panic!("Expected Full config with timeout"),
557        }
558    }
559
560    #[tokio::test]
561    async fn test_load_commands() {
562        let dir = tempdir().unwrap();
563        let commands_dir = dir.path().join("commands");
564        std::fs::create_dir_all(&commands_dir).unwrap();
565        std::fs::write(
566            commands_dir.join("hello.md"),
567            "---\nname: hello\ndescription: Greet user\n---\nHello!",
568        )
569        .unwrap();
570
571        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
572        let resources = PluginLoader::load(&descriptor).await.unwrap();
573
574        assert_eq!(resources.skills.len(), 1);
575        assert_eq!(resources.skills[0].name, "my-plugin:hello");
576        assert_eq!(resources.skills[0].source_type, SourceType::Plugin);
577    }
578
579    #[tokio::test]
580    async fn test_skills_take_precedence_over_commands() {
581        let dir = tempdir().unwrap();
582
583        // Create skill with name "deploy"
584        let skills_dir = dir.path().join("skills");
585        std::fs::create_dir_all(&skills_dir).unwrap();
586        std::fs::write(
587            skills_dir.join("deploy.skill.md"),
588            "---\nname: deploy\ndescription: Deploy (skill)\n---\nSkill content",
589        )
590        .unwrap();
591
592        // Create command with same name "deploy"
593        let commands_dir = dir.path().join("commands");
594        std::fs::create_dir_all(&commands_dir).unwrap();
595        std::fs::write(
596            commands_dir.join("deploy.md"),
597            "---\nname: deploy\ndescription: Deploy (command)\n---\nCommand content",
598        )
599        .unwrap();
600
601        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
602        let resources = PluginLoader::load(&descriptor).await.unwrap();
603
604        // Only one skill should exist — skills/ takes precedence
605        assert_eq!(resources.skills.len(), 1);
606        assert_eq!(resources.skills[0].name, "my-plugin:deploy");
607        assert_eq!(resources.skills[0].description, "Deploy (skill)");
608    }
609
610    #[test]
611    fn test_resolve_plugin_root() {
612        let root = std::path::Path::new("/plugins/my-plugin");
613        assert_eq!(
614            super::resolve_plugin_root("${CLAUDE_PLUGIN_ROOT}/scripts/check.sh", root),
615            "/plugins/my-plugin/scripts/check.sh"
616        );
617        assert_eq!(super::resolve_plugin_root("echo hello", root), "echo hello");
618        assert_eq!(
619            super::resolve_plugin_root("${CLAUDE_PLUGIN_ROOT}/a ${CLAUDE_PLUGIN_ROOT}/b", root),
620            "/plugins/my-plugin/a /plugins/my-plugin/b"
621        );
622    }
623
624    #[tokio::test]
625    async fn test_hooks_plugin_root_substitution() {
626        let dir = tempdir().unwrap();
627        let hooks_dir = dir.path().join("hooks");
628        std::fs::create_dir_all(&hooks_dir).unwrap();
629        std::fs::write(
630            hooks_dir.join("hooks.json"),
631            r#"{"PreToolUse": ["${CLAUDE_PLUGIN_ROOT}/scripts/check.sh"]}"#,
632        )
633        .unwrap();
634
635        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
636        let resources = PluginLoader::load(&descriptor).await.unwrap();
637
638        assert_eq!(resources.hooks.len(), 1);
639        let expected_cmd = format!("{}/scripts/check.sh", dir.path().display());
640        match &resources.hooks[0].config {
641            HookConfig::Command(cmd) => assert_eq!(cmd, &expected_cmd),
642            _ => panic!("Expected Command config"),
643        }
644        assert_eq!(resources.hooks[0].plugin_root, dir.path());
645    }
646
647    #[tokio::test]
648    async fn test_mcp_plugin_root_substitution() {
649        let dir = tempdir().unwrap();
650        std::fs::write(
651            dir.path().join(".mcp.json"),
652            r#"{"mcpServers":{"srv":{"type":"stdio","command":"${CLAUDE_PLUGIN_ROOT}/bin/server","args":["--config","${CLAUDE_PLUGIN_ROOT}/config.json"],"env":{"DB_PATH":"${CLAUDE_PLUGIN_ROOT}/data"}}}}"#,
653        )
654        .unwrap();
655
656        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
657        let resources = PluginLoader::load(&descriptor).await.unwrap();
658
659        let config = resources.mcp_servers.get("my-plugin:srv").unwrap();
660        match config {
661            McpServerConfig::Stdio {
662                command, args, env, ..
663            } => {
664                let root = dir.path().display().to_string();
665                assert_eq!(command, &format!("{root}/bin/server"));
666                assert_eq!(args[1], format!("{root}/config.json"));
667                assert_eq!(env.get("DB_PATH").unwrap(), &format!("{root}/data"));
668            }
669            _ => panic!("Expected Stdio config"),
670        }
671    }
672
673    #[tokio::test]
674    async fn test_hooks_official_format_plugin_root_substitution() {
675        let dir = tempdir().unwrap();
676        let hooks_dir = dir.path().join("hooks");
677        std::fs::create_dir_all(&hooks_dir).unwrap();
678        std::fs::write(
679            hooks_dir.join("hooks.json"),
680            r#"{
681                "hooks": {
682                    "PostToolUse": [{
683                        "matcher": "Write",
684                        "hooks": [{"type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/fmt.sh"}]
685                    }]
686                }
687            }"#,
688        )
689        .unwrap();
690
691        let descriptor = make_descriptor(dir.path().to_path_buf(), "my-plugin");
692        let resources = PluginLoader::load(&descriptor).await.unwrap();
693
694        assert_eq!(resources.hooks.len(), 1);
695        match &resources.hooks[0].config {
696            HookConfig::Full { command, .. } => {
697                assert_eq!(command, &format!("{}/fmt.sh", dir.path().display()));
698            }
699            _ => panic!("Expected Full config"),
700        }
701    }
702}