Skip to main content

claude_agent/plugins/
manifest.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use super::PluginError;
6
7pub(super) const PLUGIN_CONFIG_DIR: &str = ".claude-plugin";
8const PLUGIN_MANIFEST_FILE: &str = "plugin.json";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PluginAuthor {
12    pub name: String,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub email: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub url: Option<String>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PluginManifest {
21    pub name: String,
22    pub description: String,
23    pub version: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub author: Option<PluginAuthor>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub homepage: Option<String>,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub repository: Option<String>,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub license: Option<String>,
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub keywords: Vec<String>,
34}
35
36impl PluginManifest {
37    pub fn load(root_dir: &Path) -> Result<Self, PluginError> {
38        let manifest_path = root_dir.join(PLUGIN_CONFIG_DIR).join(PLUGIN_MANIFEST_FILE);
39        if !manifest_path.exists() {
40            return Err(PluginError::ManifestNotFound {
41                path: manifest_path,
42            });
43        }
44        let content = std::fs::read_to_string(&manifest_path)?;
45        serde_json::from_str(&content).map_err(|e| PluginError::InvalidManifest {
46            path: manifest_path,
47            reason: e.to_string(),
48        })
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct PluginDescriptor {
54    pub(crate) manifest: PluginManifest,
55    pub(crate) root_dir: PathBuf,
56}
57
58impl PluginDescriptor {
59    pub(crate) fn new(manifest: PluginManifest, root_dir: PathBuf) -> Self {
60        Self { manifest, root_dir }
61    }
62
63    pub fn name(&self) -> &str {
64        &self.manifest.name
65    }
66
67    pub fn version(&self) -> &str {
68        &self.manifest.version
69    }
70
71    pub fn description(&self) -> &str {
72        &self.manifest.description
73    }
74
75    pub fn root_dir(&self) -> &Path {
76        &self.root_dir
77    }
78
79    pub fn skills_dir(&self) -> PathBuf {
80        self.root_dir.join("skills")
81    }
82
83    pub fn commands_dir(&self) -> PathBuf {
84        self.root_dir.join("commands")
85    }
86
87    pub fn agents_dir(&self) -> PathBuf {
88        self.root_dir.join("agents")
89    }
90
91    pub fn hooks_config_path(&self) -> PathBuf {
92        self.root_dir.join("hooks").join("hooks.json")
93    }
94
95    pub fn mcp_config_path(&self) -> PathBuf {
96        self.root_dir.join(".mcp.json")
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use tempfile::tempdir;
104
105    #[test]
106    fn test_manifest_load() {
107        let dir = tempdir().unwrap();
108        let config_dir = dir.path().join(PLUGIN_CONFIG_DIR);
109        std::fs::create_dir_all(&config_dir).unwrap();
110        std::fs::write(
111            config_dir.join(PLUGIN_MANIFEST_FILE),
112            r#"{"name":"test-plugin","description":"A test plugin","version":"1.0.0"}"#,
113        )
114        .unwrap();
115
116        let manifest = PluginManifest::load(dir.path()).unwrap();
117        assert_eq!(manifest.name, "test-plugin");
118        assert_eq!(manifest.description, "A test plugin");
119        assert_eq!(manifest.version, "1.0.0");
120        assert!(manifest.author.is_none());
121    }
122
123    #[test]
124    fn test_manifest_load_with_author() {
125        let dir = tempdir().unwrap();
126        let config_dir = dir.path().join(PLUGIN_CONFIG_DIR);
127        std::fs::create_dir_all(&config_dir).unwrap();
128        std::fs::write(
129            config_dir.join(PLUGIN_MANIFEST_FILE),
130            r#"{
131                "name": "authored",
132                "description": "Has author",
133                "version": "0.1.0",
134                "author": {"name": "Alice", "email": "alice@example.com"}
135            }"#,
136        )
137        .unwrap();
138
139        let manifest = PluginManifest::load(dir.path()).unwrap();
140        let author = manifest.author.unwrap();
141        assert_eq!(author.name, "Alice");
142        assert_eq!(author.email.as_deref(), Some("alice@example.com"));
143    }
144
145    #[test]
146    fn test_manifest_not_found() {
147        let dir = tempdir().unwrap();
148        let err = PluginManifest::load(dir.path()).unwrap_err();
149        assert!(matches!(err, PluginError::ManifestNotFound { .. }));
150    }
151
152    #[test]
153    fn test_manifest_invalid_json() {
154        let dir = tempdir().unwrap();
155        let config_dir = dir.path().join(PLUGIN_CONFIG_DIR);
156        std::fs::create_dir_all(&config_dir).unwrap();
157        std::fs::write(config_dir.join(PLUGIN_MANIFEST_FILE), "not json").unwrap();
158
159        let err = PluginManifest::load(dir.path()).unwrap_err();
160        assert!(matches!(err, PluginError::InvalidManifest { .. }));
161    }
162
163    #[test]
164    fn test_manifest_missing_required_fields() {
165        let dir = tempdir().unwrap();
166        let config_dir = dir.path().join(PLUGIN_CONFIG_DIR);
167        std::fs::create_dir_all(&config_dir).unwrap();
168        std::fs::write(
169            config_dir.join(PLUGIN_MANIFEST_FILE),
170            r#"{"name":"incomplete"}"#,
171        )
172        .unwrap();
173
174        let err = PluginManifest::load(dir.path()).unwrap_err();
175        assert!(matches!(err, PluginError::InvalidManifest { .. }));
176    }
177
178    #[test]
179    fn test_descriptor_paths() {
180        let descriptor = PluginDescriptor::new(
181            PluginManifest {
182                name: "my-plugin".into(),
183                description: "desc".into(),
184                version: "1.0.0".into(),
185                author: None,
186                homepage: None,
187                repository: None,
188                license: None,
189                keywords: Vec::new(),
190            },
191            PathBuf::from("/plugins/my-plugin"),
192        );
193
194        assert_eq!(descriptor.name(), "my-plugin");
195        assert_eq!(
196            descriptor.skills_dir(),
197            PathBuf::from("/plugins/my-plugin/skills")
198        );
199        assert_eq!(
200            descriptor.commands_dir(),
201            PathBuf::from("/plugins/my-plugin/commands")
202        );
203        assert_eq!(
204            descriptor.agents_dir(),
205            PathBuf::from("/plugins/my-plugin/agents")
206        );
207        assert_eq!(
208            descriptor.hooks_config_path(),
209            PathBuf::from("/plugins/my-plugin/hooks/hooks.json")
210        );
211        assert_eq!(
212            descriptor.mcp_config_path(),
213            PathBuf::from("/plugins/my-plugin/.mcp.json")
214        );
215    }
216
217    #[test]
218    fn test_manifest_serde_roundtrip() {
219        let manifest = PluginManifest {
220            name: "roundtrip".into(),
221            description: "Test roundtrip".into(),
222            version: "2.0.0".into(),
223            author: Some(PluginAuthor {
224                name: "Bob".into(),
225                email: None,
226                url: Some("https://example.com".into()),
227            }),
228            homepage: None,
229            repository: Some("https://github.com/bob/roundtrip".into()),
230            license: Some("MIT".into()),
231            keywords: vec!["test".into(), "roundtrip".into()],
232        };
233
234        let json = serde_json::to_string(&manifest).unwrap();
235        let parsed: PluginManifest = serde_json::from_str(&json).unwrap();
236        assert_eq!(parsed.name, "roundtrip");
237        assert_eq!(parsed.version, "2.0.0");
238        assert!(parsed.author.unwrap().url.is_some());
239    }
240}