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::{McpServerTransportType, ScopedMcpServer, ScopedMcpServers};
14
15use super::file_set::PluginFileSet;
16use super::manifest::{McpServersField, PluginManifest};
17
18const PLUGIN_CAPABILITY_PREFIX: &str = "plugin:";
21const MAX_PLUGIN_NAME_BYTES: usize = 50 - PLUGIN_CAPABILITY_PREFIX.len(); #[derive(Debug, Clone)]
25pub struct CompiledPlugin {
26 pub manifest: PluginManifest,
28 pub definition: DeclarativeCapabilityDefinition,
30 pub warnings: Vec<String>,
32}
33
34pub fn compile_plugin(file_set: &PluginFileSet) -> Result<CompiledPlugin, String> {
40 let (manifest, mut warnings) = file_set.manifest()?;
41
42 let name = sanitize_plugin_name(&manifest.name)?;
44 if name.len() > MAX_PLUGIN_NAME_BYTES {
45 return Err(format!(
46 "plugin name '{}' is {} bytes but must fit in {} bytes (plugin: prefix occupies {} bytes)",
47 name,
48 name.len(),
49 MAX_PLUGIN_NAME_BYTES,
50 PLUGIN_CAPABILITY_PREFIX.len()
51 ));
52 }
53
54 let description = manifest
56 .description
57 .clone()
58 .filter(|d| !d.trim().is_empty())
59 .ok_or_else(|| "plugin manifest is missing a 'description' field".to_string())?;
60
61 let display_name = manifest
63 .display_name
64 .clone()
65 .filter(|d| !d.trim().is_empty());
66
67 let system_prompt = compile_agents(file_set, &manifest, &mut warnings);
69
70 let skills = compile_skills(file_set, &manifest, &mut warnings);
72
73 let command_skills = compile_commands(file_set, &manifest, &mut warnings);
75
76 let mut all_skills = skills;
77 all_skills.extend(command_skills);
78
79 let mcp_servers = compile_mcp_servers(file_set, &manifest, &mut warnings)?;
81
82 for ignored_field in &["hooks", "lspServers", "monitors", "themes", "outputStyles"] {
84 if manifest.extra.contains_key(*ignored_field) {
85 warnings.push(format!(
86 "plugin manifest: '{ignored_field}' is not supported in v1 and will be ignored"
87 ));
88 }
89 }
90
91 let definition = DeclarativeCapabilityDefinition {
92 name: name.clone(),
93 display_name,
94 description,
95 status: CapabilityStatus::Available,
96 icon: Some("puzzle".to_string()),
97 category: Some("Plugin".to_string()),
98 system_prompt,
99 mcp_servers,
100 skills: all_skills,
101 files: Vec::<DeclarativeCapabilityFile>::new(),
102 dependencies: Vec::new(),
103 features: Vec::new(),
104 risk_level: crate::capabilities::RiskLevel::Low,
105 };
106
107 validate_declarative_capability_definition(&definition)
109 .map_err(|e| format!("compiled plugin failed declarative validation: {e}"))?;
110
111 Ok(CompiledPlugin {
112 manifest,
113 definition,
114 warnings,
115 })
116}
117
118fn sanitize_plugin_name(name: &str) -> Result<String, String> {
121 let trimmed = name.trim();
122 if trimmed.is_empty() {
123 return Err("plugin name is empty".to_string());
124 }
125 let mut chars = trimmed.chars();
127 let first = chars.next().unwrap();
128 if !first.is_ascii_lowercase() {
129 return Err(format!(
130 "plugin name '{}' must start with a lowercase letter",
131 trimmed
132 ));
133 }
134 if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
135 return Err(format!(
136 "plugin name '{trimmed}' may only contain lowercase letters, digits, '-', and '_'"
137 ));
138 }
139 if trimmed.ends_with('-') || trimmed.ends_with('_') {
140 return Err(format!(
141 "plugin name '{trimmed}' must not end with '-' or '_'"
142 ));
143 }
144 Ok(trimmed.to_string())
145}
146
147fn compile_agents(
156 file_set: &PluginFileSet,
157 manifest: &PluginManifest,
158 _warnings: &mut Vec<String>,
159) -> Option<String> {
160 let agent_dirs = match &manifest.agents {
161 Some(paths) => resolve_component_paths(paths),
162 None => vec!["agents".to_string()],
163 };
164
165 let mut sections: Vec<String> = Vec::new();
166
167 for agent_dir in &agent_dirs {
168 let dir = strip_dot_slash(agent_dir);
169 let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
171 entries.sort_by_key(|(name, _)| *name);
172
173 for (filename, full_path) in entries {
174 if !filename.ends_with(".md") {
175 continue;
176 }
177 let Some(content) = file_set.text_file(full_path) else {
178 continue;
179 };
180
181 let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
183
184 let agent_name =
186 fm_name.unwrap_or_else(|| filename.trim_end_matches(".md").to_string());
187
188 let mut section = format!("<agent name=\"{}\"", escape_attr(&agent_name));
189 if let Some(desc) = fm_desc {
190 section.push_str(&format!(" description=\"{}\"", escape_attr(&desc)));
191 }
192 section.push_str(">\n");
193 section.push_str(body.trim());
194 section.push_str("\n</agent>");
195 sections.push(section);
196 }
197 }
198
199 if sections.is_empty() {
200 None
201 } else {
202 Some(sections.join("\n\n"))
203 }
204}
205
206fn compile_skills(
211 file_set: &PluginFileSet,
212 manifest: &PluginManifest,
213 warnings: &mut Vec<String>,
214) -> Vec<DeclarativeCapabilitySkill> {
215 let skill_dirs = match &manifest.skills {
216 Some(paths) => resolve_component_paths(paths),
217 None => vec!["skills".to_string()],
218 };
219
220 let mut skills = Vec::new();
221
222 for skill_dir in &skill_dirs {
223 let dir = strip_dot_slash(skill_dir);
224 let prefix = format!("{dir}/");
227 let mut seen_subdirs = std::collections::BTreeSet::new();
228 for key in file_set.files.keys() {
229 if let Some(rest) = key.strip_prefix(&prefix)
230 && let Some(slash_pos) = rest.find('/')
231 {
232 seen_subdirs.insert(rest[..slash_pos].to_string());
233 }
234 }
235
236 for subdir_name in &seen_subdirs {
237 let skill_path = format!("{dir}/{subdir_name}");
238 let skill_md_path = format!("{skill_path}/SKILL.md");
239
240 let Some(skill_md_content) = file_set.text_file(&skill_md_path) else {
241 continue;
242 };
243
244 match crate::skill::parse_skill_md(&skill_md_content) {
246 Ok(parsed) => {
247 let mut skill_files = Vec::new();
249 let all_skill_files = file_set.list_dir_recursive(&skill_path);
250 for file_path in all_skill_files {
251 if file_path == skill_md_path {
252 continue;
253 }
254 let rel_within_skill = file_path
255 .strip_prefix(&format!("{skill_path}/"))
256 .unwrap_or(file_path);
257
258 if let Some(bytes) = file_set.files.get(file_path) {
259 match String::from_utf8(bytes.clone()) {
260 Ok(text) => {
261 skill_files.push(DeclarativeCapabilitySkillFile {
262 path: rel_within_skill.to_string(),
263 content: text,
264 });
265 }
266 Err(_) => {
267 warnings.push(format!(
268 "skill '{}': binary file '{}' skipped (text only)",
269 parsed.name, rel_within_skill
270 ));
271 }
272 }
273 }
274 }
275
276 skills.push(DeclarativeCapabilitySkill {
277 name: parsed.name,
278 description: parsed.description,
279 instructions: parsed.instructions,
280 files: skill_files,
281 user_invocable: parsed.user_invocable,
282 disable_model_invocation: parsed.disable_model_invocation,
283 });
284 }
285 Err(errors) => {
286 warnings.push(format!(
287 "skill '{}': SKILL.md parse errors — {}: skill skipped",
288 subdir_name,
289 errors.join("; ")
290 ));
291 }
292 }
293 }
294 }
295
296 skills
297}
298
299fn compile_commands(
304 file_set: &PluginFileSet,
305 manifest: &PluginManifest,
306 _warnings: &mut Vec<String>,
307) -> Vec<DeclarativeCapabilitySkill> {
308 let command_dirs = match &manifest.commands {
309 Some(paths) => resolve_component_paths(paths),
310 None => vec!["commands".to_string()],
311 };
312
313 let mut skills = Vec::new();
314
315 for command_dir in &command_dirs {
316 let dir = strip_dot_slash(command_dir);
317 let mut entries: Vec<(&str, &str)> = file_set.list_dir(dir);
318 entries.sort_by_key(|(name, _)| *name);
319
320 for (filename, full_path) in entries {
321 if !filename.ends_with(".md") {
322 continue;
323 }
324
325 let Some(content) = file_set.text_file(full_path) else {
326 continue;
327 };
328
329 let (fm_name, fm_desc, body) = parse_simple_frontmatter(&content);
330 let stem = filename.trim_end_matches(".md");
331 let name = fm_name.unwrap_or_else(|| stem.to_string());
332 let description = fm_desc.unwrap_or_else(|| format!("/{name} command"));
333
334 skills.push(DeclarativeCapabilitySkill {
335 name,
336 description,
337 instructions: body.trim().to_string(),
338 files: Vec::new(),
339 user_invocable: true,
340 disable_model_invocation: false,
341 });
342 }
343 }
344
345 skills
346}
347
348fn compile_mcp_servers(
356 file_set: &PluginFileSet,
357 manifest: &PluginManifest,
358 warnings: &mut Vec<String>,
359) -> Result<Option<ScopedMcpServers>, String> {
360 let mcp_source = match &manifest.mcp_servers {
362 Some(McpServersField::Path(path)) => {
363 let p = strip_dot_slash(path);
365 match file_set.text_file(p) {
366 Some(content) => McpConfigSource::File(content),
367 None => return Ok(None),
368 }
369 }
370 Some(McpServersField::Paths(paths)) => {
371 let mut merged: BTreeMap<String, serde_json::Value> = BTreeMap::new();
373 for path in paths {
374 let p = strip_dot_slash(path);
375 if let Some(content) = file_set.text_file(p) {
376 let parsed = parse_mcp_json_file(&content, p)?;
377 merged.extend(parsed);
378 }
379 }
380 McpConfigSource::Map(merged)
381 }
382 Some(McpServersField::Inline(map)) => {
383 McpConfigSource::Map(map.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
384 }
385 None => {
386 match file_set.text_file(".mcp.json") {
388 Some(content) => McpConfigSource::File(content),
389 None => return Ok(None),
390 }
391 }
392 };
393
394 let raw_map = match mcp_source {
395 McpConfigSource::File(content) => parse_mcp_json_file(&content, ".mcp.json")?,
396 McpConfigSource::Map(m) => m,
397 };
398
399 if raw_map.is_empty() {
400 return Ok(None);
401 }
402
403 let mut servers = ScopedMcpServers::new();
404
405 for (server_name, server_config) in raw_map {
406 let transport_str = server_config
408 .get("type")
409 .and_then(|v| v.as_str())
410 .unwrap_or("http");
411
412 let has_command = server_config.get("command").is_some();
414 let is_stdio = transport_str == "stdio" || has_command;
415
416 if is_stdio {
417 warnings.push(format!(
418 "MCP server '{server_name}': stdio transport is not supported in v1 and will be skipped"
419 ));
420 continue;
421 }
422
423 let url = server_config
424 .get("url")
425 .and_then(|v| v.as_str())
426 .unwrap_or("")
427 .to_string();
428
429 servers.insert(
430 server_name,
431 ScopedMcpServer {
432 transport_type: McpServerTransportType::Http,
433 url,
434 ..ScopedMcpServer::default()
435 },
436 );
437 }
438
439 if servers.is_empty() {
440 Ok(None)
441 } else {
442 Ok(Some(servers))
443 }
444}
445
446enum McpConfigSource {
447 File(String),
448 Map(BTreeMap<String, serde_json::Value>),
449}
450
451fn parse_mcp_json_file(
453 content: &str,
454 path: &str,
455) -> Result<BTreeMap<String, serde_json::Value>, String> {
456 let value: serde_json::Value =
457 serde_json::from_str(content).map_err(|e| format!("failed to parse {path}: {e}"))?;
458
459 if let Some(servers) = value.get("mcpServers").and_then(|v| v.as_object()) {
461 return Ok(servers
462 .iter()
463 .map(|(k, v)| (k.clone(), v.clone()))
464 .collect());
465 }
466
467 if let Some(obj) = value.as_object() {
469 return Ok(obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
470 }
471
472 Ok(BTreeMap::new())
473}
474
475fn resolve_component_paths(field: &super::manifest::StringOrArray) -> Vec<String> {
481 field.to_vec()
482}
483
484fn strip_dot_slash(path: &str) -> &str {
486 let p = path.strip_prefix("./").unwrap_or(path);
487 p.trim_end_matches('/')
488}
489
490fn escape_attr(s: &str) -> String {
492 s.replace('&', "&")
493 .replace('"', """)
494 .replace('<', "<")
495 .replace('>', ">")
496}
497
498fn parse_simple_frontmatter(content: &str) -> (Option<String>, Option<String>, &str) {
504 let trimmed = content.trim_start();
505 if !trimmed.starts_with("---") {
506 return (None, None, content);
507 }
508 let after_first = &trimmed[3..];
509 let Some(closing) = after_first.find("\n---") else {
510 return (None, None, content);
511 };
512
513 let fm_text = &after_first[..closing];
514 let body_start = closing + 4;
515 let body = if body_start < after_first.len() {
516 after_first[body_start..].trim_start_matches('\n')
517 } else {
518 ""
519 };
520
521 let mut name = None;
522 let mut description = None;
523 for line in fm_text.lines() {
524 if let Some(rest) = line.strip_prefix("name:") {
525 name = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
526 } else if let Some(rest) = line.strip_prefix("description:") {
527 description = Some(rest.trim().trim_matches('"').trim_matches('\'').to_string());
528 }
529 }
530
531 (name, description, body)
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[test]
541 fn compile_microsoft_docs_fixture() {
542 let fixture = std::path::Path::new(concat!(
543 env!("CARGO_MANIFEST_DIR"),
544 "/../../testdata/plugins/microsoft-docs"
545 ));
546 let file_set = PluginFileSet::from_dir(fixture).expect("load fixture");
547 let compiled = compile_plugin(&file_set).expect("compile fixture");
548
549 assert_eq!(compiled.definition.name, "microsoft-docs");
551 assert_eq!(
552 compiled.definition.display_name.as_deref(),
553 Some("Microsoft Docs")
554 );
555
556 assert!(!compiled.definition.description.is_empty());
558
559 let mcp = compiled
561 .definition
562 .mcp_servers
563 .as_ref()
564 .expect("mcp_servers");
565 let server = mcp.get("microsoft-learn").expect("microsoft-learn server");
566 assert_eq!(server.url, "https://learn.microsoft.com/api/mcp");
567 assert!(matches!(
568 server.transport_type,
569 McpServerTransportType::Http
570 ));
571
572 let skill = compiled
574 .definition
575 .skills
576 .iter()
577 .find(|s| s.name == "microsoft-docs")
578 .expect("microsoft-docs skill");
579 assert!(!skill.instructions.is_empty());
580
581 let command = compiled
583 .definition
584 .skills
585 .iter()
586 .find(|s| s.name == "ms-docs")
587 .expect("ms-docs command skill");
588 assert!(command.user_invocable);
589
590 let prompt = compiled
592 .definition
593 .system_prompt
594 .as_ref()
595 .expect("system_prompt");
596 assert!(
597 prompt.contains("docs-researcher"),
598 "expected docs-researcher in system_prompt, got: {prompt}"
599 );
600
601 assert!(
603 compiled.warnings.iter().any(|w| w.contains("interface")),
604 "expected interface warning, got: {:?}",
605 compiled.warnings
606 );
607 }
608
609 #[test]
612 fn traversal_rejection() {
613 let err = sanitize_plugin_name("../evil").unwrap_err();
618 assert!(err.contains("must start with a lowercase letter"), "{err}");
619 let err2 = sanitize_plugin_name("a/b").unwrap_err();
621 assert!(err2.contains("only contain"), "{err2}");
622 }
623
624 #[test]
625 fn stdio_mcp_produces_warning() {
626 let mut warnings = Vec::new();
627 let file_set_files = {
628 let mut f = std::collections::BTreeMap::new();
629 f.insert(
630 ".claude-plugin/plugin.json".to_string(),
631 serde_json::json!({
632 "name": "test-plugin",
633 "description": "A test plugin."
634 })
635 .to_string()
636 .into_bytes(),
637 );
638 f.insert(
639 ".mcp.json".to_string(),
640 serde_json::json!({
641 "mcpServers": {
642 "my-server": {
643 "type": "stdio",
644 "command": "npx",
645 "args": ["-y", "@some/mcp-server"]
646 }
647 }
648 })
649 .to_string()
650 .into_bytes(),
651 );
652 f
653 };
654 let file_set = PluginFileSet {
655 files: file_set_files,
656 dir_name: "test-plugin".to_string(),
657 };
658 let manifest = PluginManifest {
659 name: "test-plugin".to_string(),
660 display_name: None,
661 version: None,
662 description: Some("test".to_string()),
663 author: None,
664 homepage: None,
665 repository: None,
666 license: None,
667 keywords: Vec::new(),
668 skills: None,
669 commands: None,
670 agents: None,
671 mcp_servers: None,
672 extra: Default::default(),
673 };
674 let result = compile_mcp_servers(&file_set, &manifest, &mut warnings);
675 assert!(result.is_ok());
676 assert!(
677 warnings.iter().any(|w| w.contains("stdio")),
678 "expected stdio warning, got: {warnings:?}"
679 );
680 assert!(result.unwrap().is_none());
682 }
683
684 #[test]
685 fn missing_description_is_error() {
686 let file_set_files = {
687 let mut f = std::collections::BTreeMap::new();
688 f.insert(
689 ".claude-plugin/plugin.json".to_string(),
690 serde_json::json!({
691 "name": "nodesc-plugin"
692 })
693 .to_string()
694 .into_bytes(),
695 );
696 f
697 };
698 let file_set = PluginFileSet {
699 files: file_set_files,
700 dir_name: "nodesc-plugin".to_string(),
701 };
702 let err = compile_plugin(&file_set).unwrap_err();
703 assert!(err.contains("description"), "error was: {err}");
704 }
705
706 #[test]
707 fn oversized_name_is_error() {
708 let long_name = "a".repeat(MAX_PLUGIN_NAME_BYTES + 1);
710 let file_set_files = {
711 let mut f = std::collections::BTreeMap::new();
712 f.insert(
713 ".claude-plugin/plugin.json".to_string(),
714 serde_json::json!({
715 "name": long_name,
716 "description": "test"
717 })
718 .to_string()
719 .into_bytes(),
720 );
721 f
722 };
723 let file_set = PluginFileSet {
724 files: file_set_files,
725 dir_name: "aaa".to_string(),
726 };
727 let err = compile_plugin(&file_set).unwrap_err();
728 assert!(err.contains("bytes"), "error was: {err}");
729 }
730}