Skip to main content

everruns_core/plugins/
manifest.rs

1// Plugin manifest types for the cross-host plugin.json dialect.
2//
3// This module provides tolerant deserialization of plugin.json files that are
4// compatible with Claude Code, Codex, and Cursor plugin conventions.
5// Unrecognized top-level fields are collected as warnings, not errors.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Author metadata in a plugin manifest.
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12#[serde(rename_all = "camelCase")]
13pub struct PluginAuthor {
14    /// Author's display name (required).
15    #[serde(default)]
16    pub name: String,
17    /// Optional contact email.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub email: Option<String>,
20    /// Optional homepage URL.
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub url: Option<String>,
23}
24
25/// `mcpServers` in a plugin manifest can be:
26///   - A string path to a `.mcp.json` file (e.g. `"./.mcp.json"`)
27///   - An array of string paths
28///   - An inline map of server name → server config objects (same shape as `.mcp.json`)
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(untagged)]
31pub enum McpServersField {
32    /// A single path string to a `.mcp.json` file.
33    Path(String),
34    /// Multiple path strings to `.mcp.json` files.
35    Paths(Vec<String>),
36    /// Inline server configuration map.
37    Inline(HashMap<String, serde_json::Value>),
38}
39
40/// A string or array-of-strings field for component path overrides.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(untagged)]
43pub enum StringOrArray {
44    /// A single string value.
45    Single(String),
46    /// Multiple string values.
47    Multiple(Vec<String>),
48}
49
50impl StringOrArray {
51    /// Flatten to a vec of strings.
52    pub fn to_vec(&self) -> Vec<String> {
53        match self {
54            StringOrArray::Single(s) => vec![s.clone()],
55            StringOrArray::Multiple(v) => v.clone(),
56        }
57    }
58}
59
60/// Parsed plugin manifest (`plugin.json`).
61///
62/// This is a tolerant representation: unrecognized top-level fields are
63/// collected in `extra` for warning purposes instead of failing parsing.
64/// This mirrors Claude Code's own loading policy for unrecognized fields.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct PluginManifest {
68    /// Unique plugin name (kebab-case). Required.
69    pub name: String,
70
71    /// Human-readable display name.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub display_name: Option<String>,
74
75    /// Semver string or freeform version tag.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub version: Option<String>,
78
79    /// Short description of the plugin.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub description: Option<String>,
82
83    /// Author metadata.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub author: Option<PluginAuthor>,
86
87    /// Plugin homepage URL.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub homepage: Option<String>,
90
91    /// Source repository URL.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub repository: Option<String>,
94
95    /// SPDX license identifier or freeform string.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub license: Option<String>,
98
99    /// Search keywords.
100    #[serde(default, skip_serializing_if = "Vec::is_empty")]
101    pub keywords: Vec<String>,
102
103    // --- Component path overrides ---
104    /// Override for the skills directory or file list.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub skills: Option<StringOrArray>,
107
108    /// Override for the commands directory or file list.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub commands: Option<StringOrArray>,
111
112    /// Override for the agents directory or file list.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub agents: Option<StringOrArray>,
115
116    /// MCP server configuration: path, paths, or inline map.
117    #[serde(
118        rename = "mcpServers",
119        default,
120        skip_serializing_if = "Option::is_none"
121    )]
122    pub mcp_servers: Option<McpServersField>,
123
124    /// Unrecognized top-level fields, preserved for warning generation.
125    /// The presence of any keys here becomes an install warning.
126    #[serde(flatten)]
127    pub extra: HashMap<String, serde_json::Value>,
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn parse_basic_manifest() {
136        let json = r#"{
137            "name": "microsoft-docs",
138            "displayName": "Microsoft Docs",
139            "version": "0.1.0",
140            "description": "Search official Microsoft docs.",
141            "license": "MIT"
142        }"#;
143        let manifest: PluginManifest = serde_json::from_str(json).unwrap();
144        assert_eq!(manifest.name, "microsoft-docs");
145        assert_eq!(manifest.display_name.as_deref(), Some("Microsoft Docs"));
146        assert_eq!(manifest.version.as_deref(), Some("0.1.0"));
147        assert_eq!(manifest.license.as_deref(), Some("MIT"));
148        assert!(manifest.extra.is_empty());
149    }
150
151    #[test]
152    fn unrecognized_fields_land_in_extra() {
153        let json = r#"{
154            "name": "test-plugin",
155            "description": "A test plugin.",
156            "interface": {"displayName": "Test", "category": "Dev"}
157        }"#;
158        let manifest: PluginManifest = serde_json::from_str(json).unwrap();
159        assert!(manifest.extra.contains_key("interface"));
160    }
161
162    #[test]
163    fn mcp_servers_path_string() {
164        let json = r#"{"name": "x", "description": "y", "mcpServers": "./.mcp.json"}"#;
165        let manifest: PluginManifest = serde_json::from_str(json).unwrap();
166        assert!(matches!(
167            manifest.mcp_servers,
168            Some(McpServersField::Path(_))
169        ));
170    }
171
172    #[test]
173    fn skills_string_or_array() {
174        let json = r#"{"name": "x", "description": "y", "skills": "./skills/"}"#;
175        let manifest: PluginManifest = serde_json::from_str(json).unwrap();
176        assert!(matches!(manifest.skills, Some(StringOrArray::Single(_))));
177    }
178}