1use std::collections::HashMap;
18use std::path::Path;
19
20use mermaid_domain::{AgentTypeConfig, McpServerConfig};
21
22#[derive(Debug, Default)]
25pub struct PluginAssets {
26 pub mcp_servers: HashMap<String, McpServerConfig>,
29 pub commands: Vec<mermaid_domain::PluginCommand>,
31 pub agent_types: HashMap<String, AgentTypeConfig>,
33 pub warnings: Vec<String>,
34}
35
36#[derive(serde::Deserialize)]
38struct McpBundle {
39 #[serde(default)]
40 servers: HashMap<String, McpServerConfig>,
41}
42
43#[derive(serde::Deserialize)]
44struct AgentBundle {
45 #[serde(default)]
46 types: HashMap<String, AgentTypeConfig>,
47}
48
49#[must_use]
54pub fn load() -> PluginAssets {
55 let mut assets = PluginAssets::default();
56 let Ok(store) = mermaid_runtime::RuntimeStore::open_default() else {
57 return assets;
58 };
59 let Ok(mut plugins) = store.plugins().list() else {
60 return assets;
61 };
62 plugins.sort_by(|a, b| a.name.cmp(&b.name));
63 for plugin in plugins {
64 if !plugin.enabled {
65 continue;
66 }
67 let Ok(manifest) =
68 serde_json::from_str::<mermaid_runtime::PluginManifest>(&plugin.manifest_json)
69 else {
70 assets.warnings.push(format!(
71 "plugin '{}': unreadable manifest; skipped",
72 plugin.name
73 ));
74 continue;
75 };
76 let Ok(root) = std::fs::canonicalize(&plugin.source) else {
77 continue;
78 };
79 merge_assets(&mut assets, assets_from_manifest(&root, &manifest));
80 }
81 assets
82}
83
84fn merge_assets(acc: &mut PluginAssets, next: PluginAssets) {
87 for (name, server) in next.mcp_servers {
88 if acc.mcp_servers.contains_key(&name) {
89 acc.warnings.push(format!(
90 "plugin MCP server '{name}' is defined by more than one plugin; keeping the first"
91 ));
92 continue;
93 }
94 acc.mcp_servers.insert(name, server);
95 }
96 for command in next.commands {
97 if acc.commands.iter().any(|c| c.name == command.name) {
98 acc.warnings.push(format!(
99 "plugin command '/{}' is defined by more than one plugin; keeping the first",
100 command.name
101 ));
102 continue;
103 }
104 acc.commands.push(command);
105 }
106 for (name, agent) in next.agent_types {
107 if acc.agent_types.contains_key(&name) {
108 acc.warnings.push(format!(
109 "plugin agent type '{name}' is defined by more than one plugin; keeping the first"
110 ));
111 continue;
112 }
113 acc.agent_types.insert(name, agent);
114 }
115 acc.warnings.extend(next.warnings);
116}
117
118pub(crate) fn assets_from_manifest(
121 root: &Path,
122 manifest: &mermaid_runtime::PluginManifest,
123) -> PluginAssets {
124 let mut assets = PluginAssets::default();
125 let plugin = &manifest.name;
126 for entry in &manifest.mcp {
127 let Some(raw) = read_contained(root, entry, plugin, &mut assets.warnings) else {
128 continue;
129 };
130 match toml::from_str::<McpBundle>(&raw) {
131 Ok(bundle) => {
132 for (name, mut server) in bundle.servers {
133 if let Some(rel) = server
139 .command
140 .strip_prefix("./")
141 .map(str::to_string)
142 .filter(|r| !r.is_empty())
143 {
144 match std::fs::canonicalize(root.join(&rel)) {
145 Ok(resolved) if resolved.starts_with(root) => {
146 server.command = resolved.display().to_string();
147 },
148 _ => {
149 assets.warnings.push(format!(
150 "plugin '{plugin}': MCP server '{name}' command '{}' escapes \
151 the plugin directory or is missing; skipped",
152 server.command
153 ));
154 continue;
155 },
156 }
157 }
158 assets.mcp_servers.insert(name, server);
159 }
160 },
161 Err(err) => assets.warnings.push(format!(
162 "plugin '{plugin}': MCP bundle {entry} did not parse: {err}"
163 )),
164 }
165 }
166 for entry in &manifest.prompts {
167 let Some(raw) = read_contained(root, entry, plugin, &mut assets.warnings) else {
168 continue;
169 };
170 let (name, description, body) = super::skills::parse_frontmatter_with_body(&raw);
171 let stem = Path::new(entry)
172 .file_stem()
173 .map(|s| s.to_string_lossy().to_string())
174 .unwrap_or_default();
175 let name = name.unwrap_or(stem);
176 if !name
177 .chars()
178 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
179 || name.is_empty()
180 {
181 assets.warnings.push(format!(
182 "plugin '{plugin}': prompt name '{name}' is not [a-z0-9-]+; skipped"
183 ));
184 continue;
185 }
186 if body.trim().is_empty() {
187 assets.warnings.push(format!(
188 "plugin '{plugin}': prompt '/{name}' has an empty body; skipped"
189 ));
190 continue;
191 }
192 if mermaid_domain::slash_commands::COMMAND_REGISTRY
194 .iter()
195 .any(|c| c.name == name || c.aliases.contains(&name.as_str()))
196 {
197 assets.warnings.push(format!(
198 "plugin '{plugin}': prompt '/{name}' shadows a built-in command; skipped"
199 ));
200 continue;
201 }
202 assets.commands.push(mermaid_domain::PluginCommand {
203 name,
204 description: description.unwrap_or_default(),
205 body: body.trim().to_string(),
206 plugin: plugin.clone(),
207 });
208 }
209 for entry in &manifest.agents {
210 let Some(raw) = read_contained(root, entry, plugin, &mut assets.warnings) else {
211 continue;
212 };
213 match toml::from_str::<AgentBundle>(&raw) {
214 Ok(bundle) => assets.agent_types.extend(bundle.types),
215 Err(err) => assets.warnings.push(format!(
216 "plugin '{plugin}': agent bundle {entry} did not parse: {err}"
217 )),
218 }
219 }
220 assets
221}
222
223fn read_contained(
227 root: &Path,
228 entry: &str,
229 plugin: &str,
230 warnings: &mut Vec<String>,
231) -> Option<String> {
232 let resolved = std::fs::canonicalize(root.join(entry)).ok()?;
233 if !resolved.starts_with(root) {
234 warnings.push(format!(
235 "plugin '{plugin}': asset {entry} escapes the plugin directory; skipped"
236 ));
237 return None;
238 }
239 std::fs::read_to_string(&resolved).ok()
240}
241
242pub fn apply(config: &mut mermaid_domain::Config, assets: &PluginAssets) -> Vec<String> {
246 let mut warnings = assets.warnings.clone();
247 for (name, server) in &assets.mcp_servers {
248 if config.mcp_servers.contains_key(name) {
249 warnings.push(format!(
250 "plugin MCP server '{name}' is shadowed by [mcp_servers.{name}] in config; \
251 using the config entry"
252 ));
253 continue;
254 }
255 config.mcp_servers.insert(name.clone(), server.clone());
256 }
257 for (name, agent) in &assets.agent_types {
258 if config.agents.types.contains_key(name) {
259 warnings.push(format!(
260 "plugin agent type '{name}' is shadowed by [agents.types.{name}] in config; \
261 using the config entry"
262 ));
263 continue;
264 }
265 config.agents.types.insert(name.clone(), agent.clone());
266 }
267 warnings
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn fixture_root(tag: &str) -> std::path::PathBuf {
275 let dir = std::env::temp_dir().join(format!(
276 "mermaid-plugin-assets-{}-{}",
277 tag,
278 std::process::id()
279 ));
280 let _ = std::fs::remove_dir_all(&dir);
281 std::fs::create_dir_all(&dir).unwrap();
282 std::fs::canonicalize(&dir).unwrap()
283 }
284
285 fn manifest(
286 root: &std::path::Path,
287 mcp: &[&str],
288 prompts: &[&str],
289 agents: &[&str],
290 ) -> mermaid_runtime::PluginManifest {
291 let _ = root;
292 mermaid_runtime::PluginManifest {
293 name: "demo".to_string(),
294 version: None,
295 description: None,
296 skills: vec![],
297 agents: agents.iter().map(|s| s.to_string()).collect(),
298 hooks: vec![],
299 mcp: mcp.iter().map(|s| s.to_string()).collect(),
300 capabilities: vec![],
301 prompts: prompts.iter().map(|s| s.to_string()).collect(),
302 bin: vec![],
303 }
304 }
305
306 #[test]
307 fn fixture_plugin_parses_all_three_asset_kinds() {
308 let root = fixture_root("full");
309 std::fs::write(
310 root.join("servers.toml"),
311 "[servers.context7]\ncommand = \"npx\"\nargs = [\"-y\", \"context7\"]\n",
312 )
313 .unwrap();
314 std::fs::write(
315 root.join("deploy.md"),
316 "---\nname: deploy\ndescription: Deploy the app\n---\nDeploy to $ARGUMENTS now.\n",
317 )
318 .unwrap();
319 std::fs::write(
320 root.join("types.toml"),
321 "[types.scout]\nsafety = \"read_only\"\npreamble = \"\"\"\nBe brief.\n\"\"\"\n",
322 )
323 .unwrap();
324 let m = manifest(&root, &["servers.toml"], &["deploy.md"], &["types.toml"]);
325 let assets = assets_from_manifest(&root, &m);
326 assert!(assets.warnings.is_empty(), "{:?}", assets.warnings);
327 assert_eq!(assets.mcp_servers["context7"].command, "npx");
328 assert_eq!(assets.commands.len(), 1);
329 assert_eq!(assets.commands[0].name, "deploy");
330 assert_eq!(assets.commands[0].body, "Deploy to $ARGUMENTS now.");
331 assert_eq!(
332 assets.agent_types["scout"].safety.as_deref(),
333 Some("read_only")
334 );
335 let _ = std::fs::remove_dir_all(&root);
336 }
337
338 #[test]
339 fn prompt_name_falls_back_to_stem_and_rejects_bad_names() {
340 let root = fixture_root("names");
341 std::fs::write(root.join("ship-it.md"), "Ship the thing.\n").unwrap();
342 std::fs::write(root.join("Bad Name.md"), "body\n").unwrap();
343 std::fs::write(root.join("empty.md"), "---\nname: empty\n---\n\n").unwrap();
344 let m = manifest(&root, &[], &["ship-it.md", "Bad Name.md", "empty.md"], &[]);
345 let assets = assets_from_manifest(&root, &m);
346 assert_eq!(assets.commands.len(), 1, "{:?}", assets.warnings);
347 assert_eq!(assets.commands[0].name, "ship-it");
348 assert!(assets.warnings.iter().any(|w| w.contains("not [a-z0-9-]+")));
349 assert!(assets.warnings.iter().any(|w| w.contains("empty body")));
350 let _ = std::fs::remove_dir_all(&root);
351 }
352
353 #[test]
354 fn prompt_shadowing_a_builtin_is_skipped() {
355 let root = fixture_root("shadow");
356 std::fs::write(root.join("help.md"), "hijack the help\n").unwrap();
357 std::fs::write(root.join("q.md"), "hijack the quit alias\n").unwrap();
358 let m = manifest(&root, &[], &["help.md", "q.md"], &[]);
359 let assets = assets_from_manifest(&root, &m);
360 assert!(assets.commands.is_empty());
361 assert_eq!(
362 assets
363 .warnings
364 .iter()
365 .filter(|w| w.contains("shadows a built-in"))
366 .count(),
367 2,
368 "{:?}",
369 assets.warnings
370 );
371 let _ = std::fs::remove_dir_all(&root);
372 }
373
374 #[cfg(unix)]
375 #[test]
376 fn symlink_escape_is_skipped() {
377 let root = fixture_root("escape");
378 let outside = fixture_root("escape-outside");
379 std::fs::write(
380 outside.join("evil.toml"),
381 "[servers.evil]\ncommand = \"sh\"\n",
382 )
383 .unwrap();
384 std::os::unix::fs::symlink(outside.join("evil.toml"), root.join("link.toml")).unwrap();
385 let m = manifest(&root, &["link.toml"], &[], &[]);
386 let assets = assets_from_manifest(&root, &m);
387 assert!(assets.mcp_servers.is_empty());
388 assert!(
389 assets.warnings.iter().any(|w| w.contains("escapes")),
390 "{:?}",
391 assets.warnings
392 );
393 let _ = std::fs::remove_dir_all(&root);
394 let _ = std::fs::remove_dir_all(&outside);
395 }
396
397 #[cfg(unix)]
398 #[test]
399 fn dot_slash_command_resolves_in_root_with_containment() {
400 let root = fixture_root("cmd");
401 std::fs::write(root.join("server.sh"), "#!/bin/sh\n").unwrap();
402 std::fs::write(
403 root.join("servers.toml"),
404 "[servers.local]\ncommand = \"./server.sh\"\n[servers.gone]\ncommand = \"./missing.sh\"\n",
405 )
406 .unwrap();
407 let m = manifest(&root, &["servers.toml"], &[], &[]);
408 let assets = assets_from_manifest(&root, &m);
409 assert!(
410 assets.mcp_servers["local"].command.ends_with("server.sh"),
411 "{}",
412 assets.mcp_servers["local"].command
413 );
414 assert!(std::path::Path::new(&assets.mcp_servers["local"].command).is_absolute());
415 assert!(!assets.mcp_servers.contains_key("gone"));
416 assert!(assets.warnings.iter().any(|w| w.contains("missing.sh")));
417 let _ = std::fs::remove_dir_all(&root);
418 }
419
420 #[cfg(unix)]
421 #[test]
422 fn url_only_bundle_entry_loads_without_command_rewrite() {
423 let root = fixture_root("url-only");
426 std::fs::write(
427 root.join("servers.toml"),
428 "[servers.remote]\nurl = \"https://example.com/mcp\"\n",
429 )
430 .unwrap();
431 let m = manifest(&root, &["servers.toml"], &[], &[]);
432 let assets = assets_from_manifest(&root, &m);
433 assert_eq!(
434 assets.mcp_servers["remote"].url.as_deref(),
435 Some("https://example.com/mcp")
436 );
437 assert!(assets.mcp_servers["remote"].command.is_empty());
438 assert!(assets.warnings.is_empty(), "{:?}", assets.warnings);
439 let _ = std::fs::remove_dir_all(&root);
440 }
441
442 #[test]
443 fn apply_lets_config_win_and_merges_the_rest() {
444 let mut config = mermaid_domain::Config::default();
445 config.mcp_servers.insert(
446 "shared".to_string(),
447 McpServerConfig {
448 command: "config-wins".to_string(),
449 ..Default::default()
450 },
451 );
452 let mut assets = PluginAssets::default();
453 assets.mcp_servers.insert(
454 "shared".to_string(),
455 McpServerConfig {
456 command: "plugin-loses".to_string(),
457 ..Default::default()
458 },
459 );
460 assets.mcp_servers.insert(
461 "fresh".to_string(),
462 McpServerConfig {
463 command: "plugin-wins".to_string(),
464 ..Default::default()
465 },
466 );
467 assets.agent_types.insert(
468 "scout".to_string(),
469 AgentTypeConfig {
470 tools: None,
471 safety: Some("read_only".to_string()),
472 preamble: None,
473 model: None,
474 isolation: None,
475 },
476 );
477 let warnings = apply(&mut config, &assets);
478 assert_eq!(config.mcp_servers["shared"].command, "config-wins");
479 assert_eq!(config.mcp_servers["fresh"].command, "plugin-wins");
480 assert!(config.agents.types.contains_key("scout"));
481 assert!(
482 warnings.iter().any(|w| w.contains("shadowed")),
483 "{warnings:?}"
484 );
485 }
486
487 #[test]
488 fn merge_assets_is_first_wins_deterministic() {
489 let mut acc = PluginAssets::default();
490 let mut first = PluginAssets::default();
491 first.mcp_servers.insert(
492 "s".to_string(),
493 McpServerConfig {
494 command: "first".to_string(),
495 ..Default::default()
496 },
497 );
498 let mut second = PluginAssets::default();
499 second.mcp_servers.insert(
500 "s".to_string(),
501 McpServerConfig {
502 command: "second".to_string(),
503 ..Default::default()
504 },
505 );
506 merge_assets(&mut acc, first);
507 merge_assets(&mut acc, second);
508 assert_eq!(acc.mcp_servers["s"].command, "first");
509 assert!(
510 acc.warnings
511 .iter()
512 .any(|w| w.contains("more than one plugin"))
513 );
514 }
515}