Skip to main content

caliban_plugins/
manifest.rs

1//! `plugin.json` manifest schema + parser.
2//!
3//! See `docs/superpowers/specs/2026-05-24-plugin-system-design.md` for the
4//! authoritative spec.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::PluginError;
12
13/// Top-level `plugin.json` shape. Unknown keys are preserved in `extra`
14/// to leave room for forward-compatible additions.
15#[derive(Debug, Clone, Deserialize, Serialize)]
16pub struct PluginManifest {
17    /// Plugin name. Must match the parent directory. `^[a-z0-9_-]{1,32}$`.
18    pub name: String,
19    /// Semver string. Validated by [`PluginManifest::validate`].
20    pub version: String,
21    /// One-line description; surfaced in `/plugins` and trust prompts.
22    #[serde(default)]
23    pub description: String,
24    /// Free-form author tag.
25    #[serde(default)]
26    pub author: String,
27    /// SPDX license id.
28    #[serde(default)]
29    pub license: String,
30    /// Optional homepage URL.
31    #[serde(default)]
32    pub homepage: Option<String>,
33    /// Component-paths map (skills, hooks, agents, `output_styles`, `mcp_servers`, commands).
34    #[serde(default)]
35    pub components: ComponentSpec,
36    /// Inline MCP server configurations. Mutually exclusive with
37    /// `components.mcp_servers`; inline wins when both are present.
38    #[serde(default, rename = "mcpServers")]
39    pub mcp_servers_inline: BTreeMap<String, InlineMcpServer>,
40    /// Optional caliban-specific gating.
41    #[serde(default)]
42    pub caliban: CalibanRequirements,
43    /// Unknown manifest keys (forward-compat).
44    #[serde(flatten)]
45    pub extra: BTreeMap<String, serde_json::Value>,
46}
47
48/// Component paths relative to the plugin root. Each value is *either* a
49/// string (one path) or an array (multiple). Missing values default to
50/// "discover everything in the conventional subdirectory" — see the spec
51/// for which fields auto-discover.
52#[derive(Debug, Clone, Default, Deserialize, Serialize)]
53#[serde(default)]
54pub struct ComponentSpec {
55    /// Skill subdirectories. Each entry should be a directory containing
56    /// `SKILL.md`. When unset, the loader scans `skills/*/SKILL.md`.
57    pub skills: Option<PathList>,
58    /// Hook config file (defaults to `hooks/hooks.json`).
59    pub hooks: Option<PathList>,
60    /// Sub-agent `.md` files (defaults to `agents/*.md`).
61    pub agents: Option<PathList>,
62    /// Output style `.md` files (defaults to `output-styles/*.md`).
63    pub output_styles: Option<PathList>,
64    /// MCP server config file (defaults to `mcp/.mcp.json`).
65    pub mcp_servers: Option<PathList>,
66    /// Optional slash-command markdown files (deferred to ADR 0040).
67    pub commands: Option<PathList>,
68}
69
70/// A "string-or-list-of-strings" wrapper used by every `components.*` field.
71#[derive(Debug, Clone, Deserialize, Serialize)]
72#[serde(untagged)]
73pub enum PathList {
74    /// Single path.
75    Single(String),
76    /// Multiple paths.
77    Many(Vec<String>),
78}
79
80impl PathList {
81    /// Return paths as a Vec.
82    #[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/// Inline MCP server config block under the top-level `mcpServers` key.
92/// Mirrors the JSON shape Claude Code uses; not a full toml-mcp parse.
93#[derive(Debug, Clone, Deserialize, Serialize)]
94pub struct InlineMcpServer {
95    /// Executable path (after `${CALIBAN_PLUGIN_ROOT}` expansion).
96    pub command: String,
97    /// CLI args.
98    #[serde(default)]
99    pub args: Vec<String>,
100    /// Env-var overrides.
101    #[serde(default)]
102    pub env: BTreeMap<String, String>,
103    /// Working directory.
104    #[serde(default)]
105    pub cwd: Option<String>,
106    /// Optional transport hint (`stdio` is default).
107    #[serde(default)]
108    pub transport: Option<String>,
109}
110
111/// Caliban-specific requirements / filters.
112#[derive(Debug, Clone, Default, Deserialize, Serialize)]
113#[serde(default)]
114pub struct CalibanRequirements {
115    /// Skip the plugin when the running caliban is older than this semver.
116    pub min_version: Option<String>,
117    /// Limit plugin to these platforms (`macos`, `linux`, `windows`).
118    pub platforms: Option<Vec<String>>,
119}
120
121impl PluginManifest {
122    /// Parse a manifest from raw JSON bytes.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`PluginError::Parse`] on JSON syntax errors and
127    /// [`PluginError::Invalid`] on validation failures.
128    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    /// Read and parse a manifest from disk.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`PluginError::Io`] on read failures, plus the errors
142    /// surfaced by [`PluginManifest::from_json`].
143    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    /// Validate the manifest in isolation (no on-disk component checks).
152    ///
153    /// Performs name-regex, semver, and `caliban.min_version`/`platforms`
154    /// shape checks. Does *not* check that `name` matches the parent dir
155    /// — that's [`PluginManifest::check_name_matches_dir`].
156    ///
157    /// # Errors
158    ///
159    /// Returns [`PluginError::Invalid`] when any check fails.
160    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        // Version itself must parse as semver.
171        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            // Accept partial versions like "0.5" by widening to semver-req.
177            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    /// Confirm the on-disk parent directory's name matches `self.name`.
198    ///
199    /// # Errors
200    ///
201    /// Returns [`PluginError::NameMismatch`] when the parent dir name and
202    /// manifest name disagree.
203    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    /// Return true if the manifest applies to the running platform.
222    #[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    /// Resolve `components.*` entries to absolute paths under `root`.
231    /// Missing files are *not* an error here — the downstream loader
232    /// decides whether to warn or skip.
233    #[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/// Resolved component paths (all absolute under the plugin root).
255#[derive(Debug, Clone, Default)]
256pub struct ResolvedComponents {
257    /// Skill directories (each contains `SKILL.md`).
258    pub skills: Vec<PathBuf>,
259    /// Hook config files.
260    pub hooks: Vec<PathBuf>,
261    /// Sub-agent `.md` files.
262    pub agents: Vec<PathBuf>,
263    /// Output-style `.md` files.
264    pub output_styles: Vec<PathBuf>,
265    /// MCP server config files.
266    pub mcp_servers: Vec<PathBuf>,
267    /// Slash command files.
268    pub commands: Vec<PathBuf>,
269}
270
271/// Plugin name regex check (`^[a-z0-9_-]{1,32}$`).
272#[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/// Return the static platform string used by `caliban.platforms`.
282#[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}