1use std::collections::BTreeMap;
7
8use crate::capabilities::{
9 CapabilityStatus, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
10 DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile,
11 validate_declarative_capability_definition,
12};
13use crate::mcp_server::{
14 McpServerAuthMode, McpServerTransportType, ScopedMcpServer, ScopedMcpServers,
15};
16
17use super::file_set::PluginFileSet;
18use super::manifest::{McpServersField, PluginManifest};
19
20const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
23const MAX_PLUGIN_NAME_BYTES: usize = 50 - PLUGIN_CAPABILITY_PREFIX.len(); #[derive(Debug, Clone)]
27pub struct CompiledPlugin {
28 pub manifest: PluginManifest,
30 pub definition: DeclarativeCapabilityDefinition,
32 pub warnings: Vec<String>,
34}
35
36pub fn compile_plugin(file_set: &PluginFileSet) -> Result<CompiledPlugin, String> {
42 let (manifest, mut warnings) = file_set.manifest()?;
43
44 let name = sanitize_plugin_name(&manifest.name)?;
46 if name.len() > MAX_PLUGIN_NAME_BYTES {
47 return Err(format!(
48 "plugin name '{}' is {} bytes but must fit in {} bytes (plugin: prefix occupies {} bytes)",
49 name,
50 name.len(),
51 MAX_PLUGIN_NAME_BYTES,
52 PLUGIN_CAPABILITY_PREFIX.len()
53 ));
54 }
55
56 let description = manifest
58 .description
59 .clone()
60 .filter(|d| !d.trim().is_empty())
61 .ok_or_else(|| "plugin manifest is missing a 'description' field".to_string())?;
62
63 let display_name = manifest
65 .display_name
66 .clone()
67 .filter(|d| !d.trim().is_empty());
68
69 let system_prompt = compile_agents(file_set, &manifest, &mut warnings);
71
72 let skills = compile_skills(file_set, &manifest, &mut warnings);
74
75 let command_skills = compile_commands(file_set, &manifest, &mut warnings);
77
78 let mut all_skills = skills;
79 all_skills.extend(command_skills);
80
81 let mcp_servers = compile_mcp_servers(file_set, &manifest, &mut warnings)?;
83
84 for ignored_field in &["hooks", "lspServers", "monitors", "themes", "outputStyles"] {
86 if manifest.extra.contains_key(*ignored_field) {
87 warnings.push(format!(
88 "plugin manifest: '{ignored_field}' is not supported in v1 and will be ignored"
89 ));
90 }
91 }
92
93 let definition = DeclarativeCapabilityDefinition {
94 name: name.clone(),
95 display_name,
96 description,
97 status: CapabilityStatus::Available,
98 icon: Some("puzzle".to_string()),
99 category: Some("Plugin".to_string()),
100 system_prompt,
101 mcp_servers,
102 skills: all_skills,
103 files: Vec::<DeclarativeCapabilityFile>::new(),
104 dependencies: Vec::new(),
105 features: Vec::new(),
106 risk_level: crate::capabilities::RiskLevel::Low,
107 };
108
109 validate_declarative_capability_definition(&definition)
111 .map_err(|e| format!("compiled plugin failed declarative validation: {e}"))?;
112
113 Ok(CompiledPlugin {
114 manifest,
115 definition,
116 warnings,
117 })
118}
119
120fn sanitize_plugin_name(name: &str) -> Result<String, String> {
123 let trimmed = name.trim();
124 if trimmed.is_empty() {
125 return Err("plugin name is empty".to_string());
126 }
127 let mut chars = trimmed.chars();
129 let first = chars.next().unwrap();
130 if !first.is_ascii_lowercase() {
131 return Err(format!(
132 "plugin name '{}' must start with a lowercase letter",
133 trimmed
134 ));
135 }
136 if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
137 return Err(format!(
138 "plugin name '{trimmed}' may only contain lowercase letters, digits, '-', and '_'"
139 ));
140 }
141 if trimmed.ends_with('-') || trimmed.ends_with('_') {
142 return Err(format!(
143 "plugin name '{trimmed}' must not end with '-' or '_'"
144 ));
145 }
146 Ok(trimmed.to_string())
147}
148
149fn compile_agents(
158 file_set: &PluginFileSet,
159 manifest: &PluginManifest,
160 _warnings: &mut Vec<String>,
161) -> Option<String> {
162 let agent_dirs = match &manifest.agents {
163 Some(paths) => resolve_component_paths(paths),
164 None => vec!["agents".to_string()],
165 };
166
167 let mut sections: Vec<String> = Vec::new();
168
169 for agent_dir in &agent_dirs {
170 let dir = strip_dot_slash(agent_dir);
171 let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
173 entries.sort_by_key(|(name, _)| *name);
174
175 for (filename, full_path) in entries {
176 if !filename.ends_with(".md") {
177 continue;
178 }
179 let Some(content) = file_set.text_file(full_path) else {
180 continue;
181 };
182
183 let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
185
186 let agent_name =
188 fm_name.unwrap_or_else(|| filename.trim_end_matches(".md").to_string());
189
190 let mut section = format!("<agent name=\"{}\"", escape_attr(&agent_name));
191 if let Some(desc) = fm_desc {
192 section.push_str(&format!(" description=\"{}\"", escape_attr(&desc)));
193 }
194 section.push_str(">\n");
195 section.push_str(body.trim());
196 section.push_str("\n</agent>");
197 sections.push(section);
198 }
199 }
200
201 if sections.is_empty() {
202 None
203 } else {
204 Some(sections.join("\n\n"))
205 }
206}
207
208fn compile_skills(
213 file_set: &PluginFileSet,
214 manifest: &PluginManifest,
215 warnings: &mut Vec<String>,
216) -> Vec<DeclarativeCapabilitySkill> {
217 let skill_dirs = match &manifest.skills {
218 Some(paths) => resolve_component_paths(paths),
219 None => vec!["skills".to_string()],
220 };
221
222 let mut skills = Vec::new();
223
224 for skill_dir in &skill_dirs {
225 let dir = strip_dot_slash(skill_dir);
226 let prefix = format!("{dir}/");
229 let mut seen_subdirs = std::collections::BTreeSet::new();
230 for key in file_set.files.keys() {
231 if let Some(rest) = key.strip_prefix(&prefix)
232 && let Some(slash_pos) = rest.find('/')
233 {
234 seen_subdirs.insert(rest[..slash_pos].to_string());
235 }
236 }
237
238 for subdir_name in &seen_subdirs {
239 let skill_path = format!("{dir}/{subdir_name}");
240 let skill_md_path = format!("{skill_path}/SKILL.md");
241
242 let Some(skill_md_content) = file_set.text_file(&skill_md_path) else {
243 continue;
244 };
245
246 match crate::skill::parse_skill_md(&skill_md_content) {
248 Ok(parsed) => {
249 let mut skill_files = Vec::new();
251 let all_skill_files = file_set.list_dir_recursive(&skill_path);
252 for file_path in all_skill_files {
253 if file_path == skill_md_path {
254 continue;
255 }
256 let rel_within_skill = file_path
257 .strip_prefix(&format!("{skill_path}/"))
258 .unwrap_or(file_path);
259
260 if let Some(bytes) = file_set.files.get(file_path) {
261 match String::from_utf8(bytes.clone()) {
262 Ok(text) => {
263 skill_files.push(DeclarativeCapabilitySkillFile {
264 path: rel_within_skill.to_string(),
265 content: text,
266 });
267 }
268 Err(_) => {
269 warnings.push(format!(
270 "skill '{}': binary file '{}' skipped (text only)",
271 parsed.name, rel_within_skill
272 ));
273 }
274 }
275 }
276 }
277
278 skills.push(DeclarativeCapabilitySkill {
279 name: parsed.name,
280 description: parsed.description,
281 instructions: parsed.instructions,
282 files: skill_files,
283 user_invocable: parsed.user_invocable,
284 disable_model_invocation: parsed.disable_model_invocation,
285 });
286 }
287 Err(errors) => {
288 warnings.push(format!(
289 "skill '{}': SKILL.md parse errors — {}: skill skipped",
290 subdir_name,
291 errors.join("; ")
292 ));
293 }
294 }
295 }
296 }
297
298 skills
299}
300
301fn compile_commands(
306 file_set: &PluginFileSet,
307 manifest: &PluginManifest,
308 _warnings: &mut Vec<String>,
309) -> Vec<DeclarativeCapabilitySkill> {
310 let command_dirs = match &manifest.commands {
311 Some(paths) => resolve_component_paths(paths),
312 None => vec!["commands".to_string()],
313 };
314
315 let mut skills = Vec::new();
316
317 for command_dir in &command_dirs {
318 let dir = strip_dot_slash(command_dir);
319 let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
320 entries.sort_by_key(|(name, _)| *name);
321
322 for (filename, full_path) in entries {
323 if !filename.ends_with(".md") {
324 continue;
325 }
326
327 let Some(content) = file_set.text_file(full_path) else {
328 continue;
329 };
330
331 let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
332 let stem = filename.trim_end_matches(".md");
333 let name = fm_name.unwrap_or_else(|| stem.to_string());
334 let description = fm_desc.unwrap_or_else(|| format!("/{name} command"));
335
336 skills.push(DeclarativeCapabilitySkill {
337 name,
338 description,
339 instructions: body.trim().to_string(),
340 files: Vec::new(),
341 user_invocable: true,
342 disable_model_invocation: false,
343 });
344 }
345 }
346
347 skills
348}
349
350fn compile_mcp_servers(
358 file_set: &PluginFileSet,
359 manifest: &PluginManifest,
360 warnings: &mut Vec<String>,
361) -> Result<Option<ScopedMcpServers>, String> {
362 let mcp_source = match &manifest.mcp_servers {
364 Some(McpServersField::Path(path)) => {
365 let p = strip_dot_slash(path);
367 match file_set.text_file(p) {
368 Some(content) => McpConfigSource::File(content),
369 None => return Ok(None),
370 }
371 }
372 Some(McpServersField::Paths(paths)) => {
373 let mut merged: BTreeMap<String, serde_json::Value> = BTreeMap::new();
375 for path in paths {
376 let p = strip_dot_slash(path);
377 if let Some(content) = file_set.text_file(p) {
378 let parsed = parse_mcp_json_file(&content, p)?;
379 merged.extend(parsed);
380 }
381 }
382 McpConfigSource::Map(merged)
383 }
384 Some(McpServersField::Inline(map)) => {
385 McpConfigSource::Map(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
386 }
387 None => {
388 match file_set.text_file(".mcp.json") {
390 Some(content) => McpConfigSource::File(content),
391 None => return Ok(None),
392 }
393 }
394 };
395
396 let raw_map = match mcp_source {
397 McpConfigSource::File(content) => parse_mcp_json_file(&content, ".mcp.json")?,
398 McpConfigSource::Map(m) => m,
399 };
400
401 if raw_map.is_empty() {
402 return Ok(None);
403 }
404
405 let mut servers = ScopedMcpServers::new();
406
407 for (server_name, server_config) in raw_map {
408 let transport_str = server_config
410 .get("type")
411 .and_then(|v| v.as_str())
412 .unwrap_or("http");
413
414 let has_command = server_config.get("command").is_some();
416 let is_stdio = transport_str == "stdio" || has_command;
417
418 if is_stdio {
419 warnings.push(format!(
420 "MCP server '{server_name}': stdio transport is not supported in v1 and will be skipped"
421 ));
422 continue;
423 }
424
425 let url = server_config
426 .get("url")
427 .and_then(|v| v.as_str())
428 .unwrap_or("")
429 .to_string();
430
431 let mut headers = std::collections::HashMap::new();
433 if let Some(header_map) = server_config.get("headers").and_then(|v| v.as_object()) {
434 for (header_name, header_value) in header_map {
435 match header_value.as_str() {
436 Some(value) => {
437 headers.insert(header_name.clone(), value.to_string());
438 }
439 None => warnings.push(format!(
440 "MCP server '{server_name}': header '{header_name}' is not a string and will be ignored"
441 )),
442 }
443 }
444 }
445
446 let auth_value = server_config
451 .get("auth")
452 .or_else(|| server_config.get("auth_mode"))
453 .and_then(|v| v.as_str());
454 let auth_mode = match auth_value.map(str::to_ascii_lowercase).as_deref() {
455 Some("oauth") => McpServerAuthMode::OAuth,
456 Some("none") | None => McpServerAuthMode::None,
457 Some(other) => {
458 warnings.push(format!(
459 "MCP server '{server_name}': auth mode '{other}' is not supported for plugin servers and will be ignored"
460 ));
461 McpServerAuthMode::None
462 }
463 };
464
465 if server_config.get("oauth_provider_id").is_some() {
470 warnings.push(format!(
471 "MCP server '{server_name}': 'oauth_provider_id' cannot be set by a plugin and will be ignored"
472 ));
473 }
474
475 servers.insert(
476 server_name,
477 ScopedMcpServer {
478 transport_type: McpServerTransportType::Http,
479 url,
480 headers,
481 auth_mode,
482 ..ScopedMcpServer::default()
483 },
484 );
485 }
486
487 if servers.is_empty() {
488 Ok(None)
489 } else {
490 Ok(Some(servers))
491 }
492}
493
494enum McpConfigSource {
495 File(String),
496 Map(BTreeMap<String, serde_json::Value>),
497}
498
499fn parse_mcp_json_file(
501 content: &str,
502 path: &str,
503) -> Result<BTreeMap<String, serde_json::Value>, String> {
504 let value: serde_json::Value =
505 serde_json::from_str(content).map_err(|e| format!("failed to parse {path}: {e}"))?;
506
507 if let Some(servers) = value.get("mcpServers").and_then(|v| v.as_object()) {
509 return Ok(servers
510 .iter()
511 .map(|(k, v)| (k.clone(), v.clone()))
512 .collect());
513 }
514
515 if let Some(obj) = value.as_object() {
517 return Ok(obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
518 }
519
520 Ok(BTreeMap::new())
521}
522
523fn resolve_component_paths(field: &super::manifest::StringOrArray) -> Vec<String> {
529 field.to_vec()
530}
531
532fn strip_dot_slash(path: &str) -> &str {
534 let p = path.strip_prefix("./").unwrap_or(path);
535 p.trim_end_matches('/')
536}
537
538fn escape_attr(s: &str) -> String {
540 s.replace('&', "&")
541 .replace('"', """)
542 .replace('<', "<")
543 .replace('>', ">")
544}
545
546fn parse_simple_frontmatter(content: &str) -> (Option<String>, Option<String>, &str) {
552 let trimmed = content.trim_start();
553 if !trimmed.starts_with("---") {
554 return (None, None, content);
555 }
556 let after_first = &trimmed[3..];
557 let Some(closing) = after_first.find("\n---") else {
558 return (None, None, content);
559 };
560
561 let fm_text = &after_first[..closing];
562 let body_start = closing + 4;
563 let body = if body_start < after_first.len() {
564 after_first[body_start..].trim_start_matches('\n')
565 } else {
566 ""
567 };
568
569 let mut name = None;
570 let mut description = None;
571 for line in fm_text.lines() {
572 if let Some(rest) = line.strip_prefix("name:") {
573 name = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
574 } else if let Some(rest) = line.strip_prefix("description:") {
575 description = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
576 }
577 }
578
579 (name, description, body)
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
589 fn compile_microsoft_docs_fixture() {
590 let fixture = std::path::Path::new(concat!(
591 env!("CARGO_MANIFEST_DIR"),
592 "/../../testdata/plugins/microsoft-docs"
593 ));
594 let file_set = PluginFileSet::from_dir(fixture).expect("load fixture");
595 let compiled = compile_plugin(&file_set).expect("compile fixture");
596
597 assert_eq!(compiled.definition.name, "microsoft-docs");
599 assert_eq!(
600 compiled.definition.display_name.as_deref(),
601 Some("Microsoft Docs")
602 );
603
604 assert!(!compiled.definition.description.is_empty());
606
607 let mcp = compiled
609 .definition
610 .mcp_servers
611 .as_ref()
612 .expect("mcp_servers");
613 let server = mcp.get("microsoft-learn").expect("microsoft-learn server");
614 assert_eq!(server.url, "https://learn.microsoft.com/api/mcp");
615 assert!(matches!(
616 server.transport_type,
617 McpServerTransportType::Http
618 ));
619
620 let skill = compiled
622 .definition
623 .skills
624 .iter()
625 .find(|s| s.name == "microsoft-docs")
626 .expect("microsoft-docs skill");
627 assert!(!skill.instructions.is_empty());
628
629 let command = compiled
631 .definition
632 .skills
633 .iter()
634 .find(|s| s.name == "ms-docs")
635 .expect("ms-docs command skill");
636 assert!(command.user_invocable);
637
638 let prompt = compiled
640 .definition
641 .system_prompt
642 .as_ref()
643 .expect("system_prompt");
644 assert!(
645 prompt.contains("docs-researcher"),
646 "expected docs-researcher in system_prompt, got: {prompt}"
647 );
648
649 assert!(
651 compiled.warnings.iter().any(|w| w.contains("interface")),
652 "expected interface warning, got: {:?}",
653 compiled.warnings
654 );
655 }
656
657 #[test]
660 fn traversal_rejection() {
661 let err = sanitize_plugin_name("../evil").unwrap_err();
666 assert!(err.contains("must start with a lowercase letter"), "{err}");
667 let err2 = sanitize_plugin_name("a/b").unwrap_err();
669 assert!(err2.contains("only contain"), "{err2}");
670 }
671
672 #[test]
673 fn stdio_mcp_produces_warning() {
674 let mut warnings = Vec::new();
675 let file_set_files = {
676 let mut f = std::collections::BTreeMap::new();
677 f.insert(
678 ".claude-plugin/plugin.json".to_string(),
679 serde_json::json!({
680 "name": "test-plugin",
681 "description": "A test plugin."
682 })
683 .to_string()
684 .into_bytes(),
685 );
686 f.insert(
687 ".mcp.json".to_string(),
688 serde_json::json!({
689 "mcpServers": {
690 "my-server": {
691 "type": "stdio",
692 "command": "npx",
693 "args": ["-y", "@some/mcp-server"]
694 }
695 }
696 })
697 .to_string()
698 .into_bytes(),
699 );
700 f
701 };
702 let file_set = PluginFileSet {
703 files: file_set_files,
704 dir_name: "test-plugin".to_string(),
705 };
706 let manifest = PluginManifest {
707 name: "test-plugin".to_string(),
708 display_name: None,
709 version: None,
710 description: Some("test".to_string()),
711 author: None,
712 homepage: None,
713 repository: None,
714 license: None,
715 keywords: Vec::new(),
716 skills: None,
717 commands: None,
718 agents: None,
719 mcp_servers: None,
720 extra: Default::default(),
721 };
722 let result = compile_mcp_servers(&file_set, &manifest, &mut warnings);
723 assert!(result.is_ok());
724 assert!(
725 warnings.iter().any(|w| w.contains("stdio")),
726 "expected stdio warning, got: {warnings:?}"
727 );
728 assert!(result.unwrap().is_none());
730 }
731
732 #[test]
733 fn oauth_mcp_server_preserves_auth_and_headers() {
734 let mut warnings = Vec::new();
735 let file_set = PluginFileSet {
736 files: {
737 let mut f = std::collections::BTreeMap::new();
738 f.insert(
739 ".mcp.json".to_string(),
740 serde_json::json!({
741 "mcpServers": {
742 "resend": {
743 "type": "http",
744 "url": "https://mcp.resend.com/mcp",
745 "auth": "oauth",
746 "headers": { "X-Custom": "1", "X-Bad": 5 },
747 "oauth_provider_id": "github"
748 }
749 }
750 })
751 .to_string()
752 .into_bytes(),
753 );
754 f
755 },
756 dir_name: "resend".to_string(),
757 };
758 let manifest = PluginManifest {
759 name: "resend".to_string(),
760 display_name: None,
761 version: None,
762 description: Some("test".to_string()),
763 author: None,
764 homepage: None,
765 repository: None,
766 license: None,
767 keywords: Vec::new(),
768 skills: None,
769 commands: None,
770 agents: None,
771 mcp_servers: None,
772 extra: Default::default(),
773 };
774 let servers = compile_mcp_servers(&file_set, &manifest, &mut warnings)
775 .expect("compile")
776 .expect("servers");
777 let server = servers.get("resend").expect("resend server");
778 assert_eq!(server.auth_mode, McpServerAuthMode::OAuth);
779 assert!(server.oauth_provider_id.is_none());
782 assert_eq!(
783 server.headers.get("X-Custom").map(String::as_str),
784 Some("1")
785 );
786 assert!(!server.headers.contains_key("X-Bad"));
787 assert!(
788 warnings
789 .iter()
790 .any(|w| w.contains("oauth_provider_id") && w.contains("ignored")),
791 "expected oauth_provider_id warning, got: {warnings:?}"
792 );
793 assert!(
794 warnings.iter().any(|w| w.contains("X-Bad")),
795 "expected non-string header warning, got: {warnings:?}"
796 );
797 }
798
799 #[test]
800 fn unsupported_auth_mode_produces_warning() {
801 let mut warnings = Vec::new();
802 let file_set = PluginFileSet {
803 files: {
804 let mut f = std::collections::BTreeMap::new();
805 f.insert(
806 ".mcp.json".to_string(),
807 serde_json::json!({
808 "mcpServers": {
809 "svc": { "url": "https://example.com/mcp", "auth": "api_key" }
810 }
811 })
812 .to_string()
813 .into_bytes(),
814 );
815 f
816 },
817 dir_name: "svc".to_string(),
818 };
819 let manifest = PluginManifest {
820 name: "svc".to_string(),
821 display_name: None,
822 version: None,
823 description: Some("test".to_string()),
824 author: None,
825 homepage: None,
826 repository: None,
827 license: None,
828 keywords: Vec::new(),
829 skills: None,
830 commands: None,
831 agents: None,
832 mcp_servers: None,
833 extra: Default::default(),
834 };
835 let servers = compile_mcp_servers(&file_set, &manifest, &mut warnings)
836 .expect("compile")
837 .expect("servers");
838 assert_eq!(
839 servers.get("svc").expect("svc").auth_mode,
840 McpServerAuthMode::None
841 );
842 assert!(
843 warnings.iter().any(|w| w.contains("api_key")),
844 "expected auth warning, got: {warnings:?}"
845 );
846 }
847
848 #[test]
849 fn missing_description_is_error() {
850 let file_set_files = {
851 let mut f = std::collections::BTreeMap::new();
852 f.insert(
853 ".claude-plugin/plugin.json".to_string(),
854 serde_json::json!({
855 "name": "nodesc-plugin"
856 })
857 .to_string()
858 .into_bytes(),
859 );
860 f
861 };
862 let file_set = PluginFileSet {
863 files: file_set_files,
864 dir_name: "nodesc-plugin".to_string(),
865 };
866 let err = compile_plugin(&file_set).unwrap_err();
867 assert!(err.contains("description"), "error was: {err}");
868 }
869
870 #[test]
871 fn oversized_name_is_error() {
872 let long_name = "a".repeat(MAX_PLUGIN_NAME_BYTES + 1);
874 let file_set_files = {
875 let mut f = std::collections::BTreeMap::new();
876 f.insert(
877 ".claude-plugin/plugin.json".to_string(),
878 serde_json::json!({
879 "name": long_name,
880 "description": "test"
881 })
882 .to_string()
883 .into_bytes(),
884 );
885 f
886 };
887 let file_set = PluginFileSet {
888 files: file_set_files,
889 dir_name: "aaa".to_string(),
890 };
891 let err = compile_plugin(&file_set).unwrap_err();
892 assert!(err.contains("bytes"), "error was: {err}");
893 }
894}