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