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#[derive(Debug, Deserialize)]
62#[serde(untagged)]
63enum PluginHooksFile {
64 Nested {
66 hooks: HashMap<String, Vec<HookRule>>,
67 },
68 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 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 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 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 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 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 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 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}