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