1use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::PluginError;
12
13#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct PluginManifest {
17 pub name: String,
19 pub version: String,
21 #[serde(default)]
23 pub description: String,
24 #[serde(default)]
26 pub author: String,
27 #[serde(default)]
29 pub license: String,
30 #[serde(default)]
32 pub homepage: Option<String>,
33 #[serde(default)]
35 pub components: ComponentSpec,
36 #[serde(default, rename = "mcpServers")]
39 pub mcp_servers_inline: BTreeMap<String, InlineMcpServer>,
40 #[serde(default)]
42 pub caliban: CalibanRequirements,
43 #[serde(flatten)]
45 pub extra: BTreeMap<String, serde_json::Value>,
46}
47
48#[derive(Debug, Clone, Default, Deserialize, Serialize)]
53#[serde(default)]
54pub struct ComponentSpec {
55 pub skills: Option<PathList>,
58 pub hooks: Option<PathList>,
60 pub agents: Option<PathList>,
62 pub output_styles: Option<PathList>,
64 pub mcp_servers: Option<PathList>,
66 pub commands: Option<PathList>,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize)]
72#[serde(untagged)]
73pub enum PathList {
74 Single(String),
76 Many(Vec<String>),
78}
79
80impl PathList {
81 #[must_use]
83 pub fn as_vec(&self) -> Vec<String> {
84 match self {
85 Self::Single(s) => vec![s.clone()],
86 Self::Many(v) => v.clone(),
87 }
88 }
89}
90
91#[derive(Debug, Clone, Deserialize, Serialize)]
94pub struct InlineMcpServer {
95 pub command: String,
97 #[serde(default)]
99 pub args: Vec<String>,
100 #[serde(default)]
102 pub env: BTreeMap<String, String>,
103 #[serde(default)]
105 pub cwd: Option<String>,
106 #[serde(default)]
108 pub transport: Option<String>,
109}
110
111#[derive(Debug, Clone, Default, Deserialize, Serialize)]
113#[serde(default)]
114pub struct CalibanRequirements {
115 pub min_version: Option<String>,
117 pub platforms: Option<Vec<String>>,
119}
120
121impl PluginManifest {
122 pub fn from_json(raw: &str, path: &Path) -> Result<Self, PluginError> {
129 let mf: Self = serde_json::from_str(raw).map_err(|source| PluginError::Parse {
130 path: path.to_path_buf(),
131 source,
132 })?;
133 mf.validate(path)?;
134 Ok(mf)
135 }
136
137 pub fn from_path(path: &Path) -> Result<Self, PluginError> {
144 let raw = std::fs::read_to_string(path).map_err(|source| PluginError::Io {
145 path: path.to_path_buf(),
146 source,
147 })?;
148 Self::from_json(&raw, path)
149 }
150
151 pub fn validate(&self, path: &Path) -> Result<(), PluginError> {
161 if !is_valid_name(&self.name) {
162 return Err(PluginError::Invalid {
163 path: path.to_path_buf(),
164 message: format!(
165 "invalid name '{}': must match [a-z0-9_-]{{1,32}} and be lowercase",
166 self.name
167 ),
168 });
169 }
170 semver::Version::parse(&self.version).map_err(|e| PluginError::Invalid {
172 path: path.to_path_buf(),
173 message: format!("invalid version '{}': {e}", self.version),
174 })?;
175 if let Some(min) = self.caliban.min_version.as_deref() {
176 semver::VersionReq::parse(&format!(">={min}")).map_err(|e| PluginError::Invalid {
178 path: path.to_path_buf(),
179 message: format!("invalid caliban.min_version '{min}': {e}"),
180 })?;
181 }
182 if let Some(ps) = self.caliban.platforms.as_ref() {
183 for p in ps {
184 if !matches!(p.as_str(), "macos" | "linux" | "windows") {
185 return Err(PluginError::Invalid {
186 path: path.to_path_buf(),
187 message: format!(
188 "invalid caliban.platforms entry '{p}': must be macos|linux|windows"
189 ),
190 });
191 }
192 }
193 }
194 Ok(())
195 }
196
197 pub fn check_name_matches_dir(&self, manifest_path: &Path) -> Result<(), PluginError> {
204 let dir_name = manifest_path
205 .parent()
206 .and_then(Path::file_name)
207 .and_then(|s| s.to_str())
208 .unwrap_or_default()
209 .to_string();
210 if dir_name == self.name {
211 Ok(())
212 } else {
213 Err(PluginError::NameMismatch {
214 manifest_name: self.name.clone(),
215 dir_name,
216 path: manifest_path.to_path_buf(),
217 })
218 }
219 }
220
221 #[must_use]
223 pub fn platform_matches(&self) -> bool {
224 let Some(allowed) = self.caliban.platforms.as_ref() else {
225 return true;
226 };
227 allowed.iter().any(|p| p == current_platform())
228 }
229
230 #[must_use]
234 pub fn resolved_components(&self, root: &Path) -> ResolvedComponents {
235 let resolve_list = |pl: &Option<PathList>| -> Vec<PathBuf> {
236 pl.as_ref()
237 .map(PathList::as_vec)
238 .unwrap_or_default()
239 .into_iter()
240 .map(|s| root.join(s))
241 .collect()
242 };
243 ResolvedComponents {
244 skills: resolve_list(&self.components.skills),
245 hooks: resolve_list(&self.components.hooks),
246 agents: resolve_list(&self.components.agents),
247 output_styles: resolve_list(&self.components.output_styles),
248 mcp_servers: resolve_list(&self.components.mcp_servers),
249 commands: resolve_list(&self.components.commands),
250 }
251 }
252}
253
254#[derive(Debug, Clone, Default)]
256pub struct ResolvedComponents {
257 pub skills: Vec<PathBuf>,
259 pub hooks: Vec<PathBuf>,
261 pub agents: Vec<PathBuf>,
263 pub output_styles: Vec<PathBuf>,
265 pub mcp_servers: Vec<PathBuf>,
267 pub commands: Vec<PathBuf>,
269}
270
271#[must_use]
273pub fn is_valid_name(name: &str) -> bool {
274 !name.is_empty()
275 && name.len() <= 32
276 && name
277 .chars()
278 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
279}
280
281#[must_use]
283pub fn current_platform() -> &'static str {
284 #[cfg(target_os = "macos")]
285 {
286 "macos"
287 }
288 #[cfg(target_os = "linux")]
289 {
290 "linux"
291 }
292 #[cfg(target_os = "windows")]
293 {
294 "windows"
295 }
296 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
297 {
298 "unknown"
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use std::path::Path;
306
307 #[test]
308 fn parses_minimal_manifest() {
309 let raw = r#"{ "name": "demo", "version": "0.1.0", "description": "demo plugin" }"#;
310 let mf = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap();
311 assert_eq!(mf.name, "demo");
312 assert_eq!(mf.version, "0.1.0");
313 assert_eq!(mf.description, "demo plugin");
314 assert!(mf.components.skills.is_none());
315 }
316
317 #[test]
318 fn parses_full_manifest() {
319 let raw = r#"{
320 "name": "superpowers",
321 "version": "1.4.2",
322 "description": "Curated skills",
323 "author": "alice <alice@example.com>",
324 "license": "MIT",
325 "homepage": "https://example.com",
326 "components": {
327 "skills": ["skills/foo", "skills/bar"],
328 "hooks": "hooks/hooks.json",
329 "agents": ["agents/reviewer.md"],
330 "output_styles": "output-styles/learning.md",
331 "mcp_servers": "mcp/.mcp.json",
332 "commands": ["commands/recap.md"]
333 },
334 "mcpServers": {
335 "fixtures": {
336 "command": "${CALIBAN_PLUGIN_ROOT}/bin/server",
337 "args": ["--verbose"]
338 }
339 },
340 "caliban": { "min_version": "0.5.0", "platforms": ["macos", "linux"] }
341 }"#;
342 let mf = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap();
343 assert_eq!(mf.author, "alice <alice@example.com>");
344 let skills = mf.components.skills.as_ref().unwrap().as_vec();
345 assert_eq!(skills, vec!["skills/foo".to_string(), "skills/bar".into()]);
346 let agents = mf.components.agents.as_ref().unwrap().as_vec();
347 assert_eq!(agents, vec!["agents/reviewer.md".to_string()]);
348 let hooks = mf.components.hooks.as_ref().unwrap().as_vec();
349 assert_eq!(hooks, vec!["hooks/hooks.json".to_string()]);
350 assert_eq!(mf.mcp_servers_inline.len(), 1);
351 assert_eq!(mf.caliban.platforms.as_ref().unwrap().len(), 2);
352 }
353
354 #[test]
355 fn invalid_json_is_parse_error() {
356 let raw = r"not json at all";
357 let err = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap_err();
358 assert!(matches!(err, PluginError::Parse { .. }));
359 }
360
361 #[test]
362 fn name_regex_enforced() {
363 assert!(is_valid_name("demo"));
364 assert!(is_valid_name("demo-1_x"));
365 assert!(!is_valid_name(""));
366 assert!(!is_valid_name("UPPER"));
367 assert!(!is_valid_name("with space"));
368 assert!(!is_valid_name(&"x".repeat(33)));
369 assert!(!is_valid_name("dot.name"));
370 }
371
372 #[test]
373 fn invalid_name_rejected_in_manifest() {
374 let raw = r#"{ "name": "BAD", "version": "0.1.0" }"#;
375 let err = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap_err();
376 assert!(matches!(err, PluginError::Invalid { .. }));
377 }
378
379 #[test]
380 fn invalid_semver_rejected() {
381 let raw = r#"{ "name": "demo", "version": "not.a.version" }"#;
382 let err = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap_err();
383 assert!(matches!(err, PluginError::Invalid { .. }));
384 }
385
386 #[test]
387 fn unknown_platform_rejected() {
388 let raw = r#"{
389 "name": "demo", "version": "0.1.0",
390 "caliban": { "platforms": ["beos"] }
391 }"#;
392 let err = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap_err();
393 assert!(matches!(err, PluginError::Invalid { .. }));
394 }
395
396 #[test]
397 fn check_name_matches_dir_ok() {
398 let raw = r#"{ "name": "demo", "version": "0.1.0" }"#;
399 let mf = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap();
400 let tmp = tempfile::TempDir::new().unwrap();
401 let plug_dir = tmp.path().join("demo");
402 std::fs::create_dir_all(&plug_dir).unwrap();
403 let manifest_path = plug_dir.join("plugin.json");
404 std::fs::write(&manifest_path, raw).unwrap();
405 mf.check_name_matches_dir(&manifest_path).unwrap();
406 }
407
408 #[test]
409 fn check_name_mismatch_errors() {
410 let raw = r#"{ "name": "demo", "version": "0.1.0" }"#;
411 let mf = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap();
412 let tmp = tempfile::TempDir::new().unwrap();
413 let plug_dir = tmp.path().join("wrong");
414 std::fs::create_dir_all(&plug_dir).unwrap();
415 let manifest_path = plug_dir.join("plugin.json");
416 std::fs::write(&manifest_path, raw).unwrap();
417 let err = mf.check_name_matches_dir(&manifest_path).unwrap_err();
418 assert!(matches!(err, PluginError::NameMismatch { .. }));
419 }
420
421 #[test]
422 fn unknown_fields_preserved() {
423 let raw = r#"{
424 "name": "demo", "version": "0.1.0",
425 "future_field": { "anything": [1, 2, 3] }
426 }"#;
427 let mf = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap();
428 assert!(mf.extra.contains_key("future_field"));
429 }
430
431 #[test]
432 fn resolves_components_to_absolute_paths() {
433 let raw = r#"{
434 "name": "demo", "version": "0.1.0",
435 "components": { "skills": ["skills/a", "skills/b"] }
436 }"#;
437 let mf = PluginManifest::from_json(raw, Path::new("plugin.json")).unwrap();
438 let root = Path::new("/plugins/demo");
439 let rc = mf.resolved_components(root);
440 assert_eq!(rc.skills.len(), 2);
441 assert_eq!(rc.skills[0], root.join("skills/a"));
442 assert_eq!(rc.skills[1], root.join("skills/b"));
443 }
444
445 #[test]
446 fn platform_matches_filters() {
447 let raw_other = r#"{
448 "name": "demo", "version": "0.1.0",
449 "caliban": { "platforms": ["windows"] }
450 }"#;
451 let mf = PluginManifest::from_json(raw_other, Path::new("plugin.json")).unwrap();
452 #[cfg(not(target_os = "windows"))]
453 assert!(!mf.platform_matches());
454 let raw_unset = r#"{ "name": "demo", "version": "0.1.0" }"#;
455 let mf2 = PluginManifest::from_json(raw_unset, Path::new("plugin.json")).unwrap();
456 assert!(mf2.platform_matches());
457 }
458}