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)]
63#[serde(untagged)]
64enum PluginHooksFile {
65 Official {
67 hooks: HashMap<String, Vec<HookRule>>,
68 },
69 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 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 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 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 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 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 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 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}