Skip to main content

claude_agent/plugins/
manager.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use crate::common::IndexRegistry;
5use crate::mcp::McpServerConfig;
6use crate::skills::SkillIndex;
7use crate::subagents::SubagentIndex;
8
9use super::PluginError;
10use super::discovery::PluginDiscovery;
11use super::loader::{PluginHookEntry, PluginLoader, PluginResources};
12use super::manifest::PluginDescriptor;
13
14pub struct PluginManager {
15    plugins: Vec<PluginDescriptor>,
16    resources: PluginResources,
17}
18
19impl PluginManager {
20    pub async fn load_from_dirs(dirs: &[PathBuf]) -> Result<Self, PluginError> {
21        let plugins = PluginDiscovery::discover(dirs)?;
22
23        Self::validate_plugins(&plugins)?;
24
25        let mut resources = PluginResources::default();
26
27        for plugin in &plugins {
28            let plugin_resources = PluginLoader::load(plugin).await?;
29            Self::merge(&mut resources, plugin_resources);
30        }
31
32        Ok(Self { plugins, resources })
33    }
34
35    pub fn register_skills(&self, registry: &mut IndexRegistry<SkillIndex>) {
36        for skill in &self.resources.skills {
37            registry.register(skill.clone());
38        }
39    }
40
41    pub fn register_subagents(&self, registry: &mut IndexRegistry<SubagentIndex>) {
42        for subagent in &self.resources.subagents {
43            registry.register(subagent.clone());
44        }
45    }
46
47    pub fn hooks(&self) -> &[PluginHookEntry] {
48        &self.resources.hooks
49    }
50
51    pub fn mcp_servers(&self) -> &HashMap<String, McpServerConfig> {
52        &self.resources.mcp_servers
53    }
54
55    pub fn plugins(&self) -> &[PluginDescriptor] {
56        &self.plugins
57    }
58
59    pub fn plugin_count(&self) -> usize {
60        self.plugins.len()
61    }
62
63    pub fn has_plugin(&self, name: &str) -> bool {
64        self.plugins.iter().any(|p| p.name() == name)
65    }
66
67    fn validate_plugins(plugins: &[PluginDescriptor]) -> Result<(), PluginError> {
68        let mut seen: HashMap<String, &PathBuf> = HashMap::new();
69        for plugin in plugins {
70            let name = plugin.name();
71
72            if name.contains(super::namespace::NAMESPACE_SEP) {
73                return Err(PluginError::InvalidName {
74                    name: name.to_string(),
75                    reason: format!(
76                        "must not contain namespace separator '{}'",
77                        super::namespace::NAMESPACE_SEP
78                    ),
79                });
80            }
81
82            if let Some(first_path) = seen.get(name) {
83                return Err(PluginError::DuplicateName {
84                    name: name.to_string(),
85                    first: (*first_path).clone(),
86                    second: plugin.root_dir.clone(),
87                });
88            }
89            seen.insert(name.to_string(), &plugin.root_dir);
90        }
91        Ok(())
92    }
93
94    fn merge(target: &mut PluginResources, source: PluginResources) {
95        target.skills.extend(source.skills);
96        target.subagents.extend(source.subagents);
97        target.hooks.extend(source.hooks);
98        target.mcp_servers.extend(source.mcp_servers);
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use tempfile::tempdir;
106
107    fn create_full_plugin(parent: &std::path::Path, name: &str) {
108        let plugin_dir = parent.join(name);
109        let config_dir = plugin_dir.join(".claude-plugin");
110        std::fs::create_dir_all(&config_dir).unwrap();
111        std::fs::write(
112            config_dir.join("plugin.json"),
113            format!(
114                r#"{{"name":"{}","description":"Test","version":"1.0.0"}}"#,
115                name
116            ),
117        )
118        .unwrap();
119
120        let skills_dir = plugin_dir.join("skills");
121        std::fs::create_dir_all(&skills_dir).unwrap();
122        std::fs::write(
123            skills_dir.join("test.skill.md"),
124            format!(
125                "---\nname: test-skill\ndescription: A test skill for {}\n---\nContent",
126                name
127            ),
128        )
129        .unwrap();
130    }
131
132    #[tokio::test]
133    async fn test_load_from_dirs() {
134        let dir = tempdir().unwrap();
135        create_full_plugin(dir.path(), "plugin-a");
136        create_full_plugin(dir.path(), "plugin-b");
137
138        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
139            .await
140            .unwrap();
141
142        assert_eq!(manager.plugin_count(), 2);
143        assert!(manager.has_plugin("plugin-a"));
144        assert!(manager.has_plugin("plugin-b"));
145        assert!(!manager.has_plugin("plugin-c"));
146        let mut registry = IndexRegistry::new();
147        manager.register_skills(&mut registry);
148        assert_eq!(registry.len(), 2);
149    }
150
151    #[tokio::test]
152    async fn test_duplicate_detection() {
153        let dir1 = tempdir().unwrap();
154        let dir2 = tempdir().unwrap();
155
156        let plugin_dir1 = dir1.path().join("same");
157        let config1 = plugin_dir1.join(".claude-plugin");
158        std::fs::create_dir_all(&config1).unwrap();
159        std::fs::write(
160            config1.join("plugin.json"),
161            r#"{"name":"same","description":"A","version":"1.0.0"}"#,
162        )
163        .unwrap();
164
165        let plugin_dir2 = dir2.path().join("same");
166        let config2 = plugin_dir2.join(".claude-plugin");
167        std::fs::create_dir_all(&config2).unwrap();
168        std::fs::write(
169            config2.join("plugin.json"),
170            r#"{"name":"same","description":"B","version":"1.0.0"}"#,
171        )
172        .unwrap();
173
174        let result =
175            PluginManager::load_from_dirs(&[dir1.path().to_path_buf(), dir2.path().to_path_buf()])
176                .await;
177
178        assert!(
179            matches!(result, Err(PluginError::DuplicateName { ref name, .. }) if name == "same")
180        );
181    }
182
183    #[tokio::test]
184    async fn test_register_skills() {
185        let dir = tempdir().unwrap();
186        create_full_plugin(dir.path(), "my-plugin");
187
188        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
189            .await
190            .unwrap();
191
192        let mut registry = IndexRegistry::new();
193        manager.register_skills(&mut registry);
194
195        assert_eq!(registry.len(), 1);
196        assert!(registry.get("my-plugin:test-skill").is_some());
197    }
198
199    #[tokio::test]
200    async fn test_register_subagents() {
201        let dir = tempdir().unwrap();
202        let plugin_dir = dir.path().join("agent-plugin");
203        let config_dir = plugin_dir.join(".claude-plugin");
204        std::fs::create_dir_all(&config_dir).unwrap();
205        std::fs::write(
206            config_dir.join("plugin.json"),
207            r#"{"name":"agent-plugin","description":"Has agents","version":"1.0.0"}"#,
208        )
209        .unwrap();
210
211        let agents_dir = plugin_dir.join("agents");
212        std::fs::create_dir_all(&agents_dir).unwrap();
213        std::fs::write(
214            agents_dir.join("reviewer.md"),
215            "---\nname: reviewer\ndescription: Code reviewer\n---\nReview prompt",
216        )
217        .unwrap();
218
219        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
220            .await
221            .unwrap();
222
223        let mut registry = IndexRegistry::new();
224        manager.register_subagents(&mut registry);
225
226        assert_eq!(registry.len(), 1);
227        assert!(registry.get("agent-plugin:reviewer").is_some());
228    }
229
230    #[tokio::test]
231    async fn test_mcp_servers_aggregation() {
232        let dir = tempdir().unwrap();
233        let plugin_dir = dir.path().join("mcp-plugin");
234        let config_dir = plugin_dir.join(".claude-plugin");
235        std::fs::create_dir_all(&config_dir).unwrap();
236        std::fs::write(
237            config_dir.join("plugin.json"),
238            r#"{"name":"mcp-plugin","description":"Has MCP","version":"1.0.0"}"#,
239        )
240        .unwrap();
241        std::fs::write(
242            plugin_dir.join(".mcp.json"),
243            r#"{"mcpServers":{"ctx":{"type":"stdio","command":"npx","args":["@ctx/mcp"]}}}"#,
244        )
245        .unwrap();
246
247        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
248            .await
249            .unwrap();
250
251        let servers = manager.mcp_servers();
252        assert_eq!(servers.len(), 1);
253        assert!(servers.contains_key("mcp-plugin:ctx"));
254    }
255
256    #[tokio::test]
257    async fn test_empty_dirs() {
258        let manager = PluginManager::load_from_dirs(&[]).await.unwrap();
259        assert_eq!(manager.plugin_count(), 0);
260    }
261
262    #[tokio::test]
263    async fn test_plugins_accessor() {
264        let dir = tempdir().unwrap();
265        create_full_plugin(dir.path(), "accessible");
266
267        let manager = PluginManager::load_from_dirs(&[dir.path().to_path_buf()])
268            .await
269            .unwrap();
270
271        assert_eq!(manager.plugins().len(), 1);
272        assert_eq!(manager.plugins()[0].name(), "accessible");
273    }
274}