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}