claude_agent/plugins/
manager.rs1use 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}