Skip to main content

systemprompt_models/bridge/
plugin_bundle.rs

1//! Plugin bundle contract: the `.claude-plugin/plugin.json` manifest shape and
2//! the well-formedness predicate every consumer shares.
3//!
4//! A *plugin bundle* is the installable artifact a host (Claude Code / Cowork)
5//! reads: a directory rooted on `.claude-plugin/plugin.json` plus the component
6//! files it ships (`skills/<n>/SKILL.md`, `agents/<n>.md`, `.mcp.json`, …).
7//! [`PluginManifest`] is that manifest; [`bundle_has_manifest`] is the single
8//! definition of "is this directory a well-formed bundle?" so the gateway
9//! serve path, the bridge sync, the CLI generator, and the marketplace export
10//! never drift on the contract.
11//!
12//! The manifest is also an *inbound* shape: the importer reads manifests
13//! authored for Claude Code, which permit component keys systemprompt derives
14//! from the tree instead (`skills`, `agents`, `commands`) and inline MCP server
15//! definitions. Those keys are accepted as opaque `serde_json::Value` — an
16//! external-format boundary whose shape Anthropic owns — and are never
17//! serialised back out, so the bundle contract this crate emits is unchanged.
18//!
19//! Copyright (c) systemprompt.io — Business Source License 1.1.
20//! See <https://systemprompt.io> for licensing details.
21
22use serde::{Deserialize, Serialize};
23
24use crate::plugin::PluginDependency;
25
26pub const PLUGIN_MANIFEST_RELPATH: &str = ".claude-plugin/plugin.json";
27
28pub const PLUGIN_MANIFEST_DIRS: &[&str] = &[".claude-plugin", "claude-plugin"];
29
30pub const PLUGIN_MANIFEST_FILE: &str = "plugin.json";
31
32pub const NODE_PACKAGE_FILE: &str = "package.json";
33
34// Why: Claude Code runs a frozen, script-less install only for these
35// lockfiles, checked in this order; yarn and pnpm lockfiles are skipped
36// because their installers cannot be told to ignore lifecycle scripts.
37pub const NODE_LOCKFILES: [&str; 4] = [
38    "bun.lock",
39    "bun.lockb",
40    "npm-shrinkwrap.json",
41    "package-lock.json",
42];
43
44#[must_use]
45pub fn node_lockfile(plugin_dir: &std::path::Path) -> Option<&'static str> {
46    NODE_LOCKFILES
47        .iter()
48        .copied()
49        .find(|name| plugin_dir.join(name).is_file())
50}
51
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct PluginManifest {
54    pub name: String,
55    #[serde(default)]
56    pub description: String,
57    #[serde(default)]
58    pub version: String,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub author: Option<ManifestAuthor>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub hooks: Option<String>,
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub keywords: Vec<String>,
65    #[serde(
66        default,
67        rename = "installationPreference",
68        skip_serializing_if = "Option::is_none"
69    )]
70    pub installation_preference: Option<String>,
71
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    // JSON: Claude plugin manifest importer keys; Claude Code owns the schema.
74    pub skills: Option<serde_json::Value>,
75
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    // JSON: Claude plugin manifest importer keys; Claude Code owns the schema.
78    pub agents: Option<serde_json::Value>,
79
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    // JSON: Claude plugin manifest importer keys; Claude Code owns the schema.
82    pub commands: Option<serde_json::Value>,
83
84    #[serde(
85        default,
86        rename = "mcpServers",
87        skip_serializing_if = "Option::is_none"
88    )]
89    // JSON: Claude plugin manifest importer keys; Claude Code owns the schema.
90    pub mcp_servers: Option<serde_json::Value>,
91
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub homepage: Option<String>,
94
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub repository: Option<String>,
97
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub license: Option<String>,
100
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub category: Option<String>,
103
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub dependencies: Vec<ManifestDependency>,
106}
107
108/// One `dependencies` entry in Claude Code's `plugin.json` vocabulary: a bare
109/// plugin name resolved in the same marketplace, or an object naming the
110/// marketplace and a semver range.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(untagged)]
113pub enum ManifestDependency {
114    Name(String),
115    Detailed {
116        name: String,
117        #[serde(default, skip_serializing_if = "Option::is_none")]
118        version: Option<String>,
119        #[serde(default, skip_serializing_if = "Option::is_none")]
120        marketplace: Option<String>,
121    },
122}
123
124impl ManifestDependency {
125    #[must_use]
126    pub fn name(&self) -> &str {
127        match self {
128            Self::Name(name) | Self::Detailed { name, .. } => name,
129        }
130    }
131
132    #[must_use]
133    pub fn marketplace(&self) -> Option<&str> {
134        match self {
135            Self::Name(_) => None,
136            Self::Detailed { marketplace, .. } => marketplace.as_deref(),
137        }
138    }
139
140    #[must_use]
141    pub fn version(&self) -> Option<&str> {
142        match self {
143            Self::Name(_) => None,
144            Self::Detailed { version, .. } => version.as_deref(),
145        }
146    }
147}
148
149impl From<&PluginDependency> for ManifestDependency {
150    fn from(dependency: &PluginDependency) -> Self {
151        if dependency.marketplace.is_none() && dependency.version.is_none() {
152            Self::Name(dependency.name.clone())
153        } else {
154            Self::Detailed {
155                name: dependency.name.clone(),
156                version: dependency.version.clone(),
157                marketplace: dependency.marketplace.clone(),
158            }
159        }
160    }
161}
162
163impl From<&ManifestDependency> for PluginDependency {
164    fn from(dependency: &ManifestDependency) -> Self {
165        Self {
166            name: dependency.name().to_owned(),
167            marketplace: dependency.marketplace().map(str::to_owned),
168            version: dependency.version().map(str::to_owned),
169        }
170    }
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ManifestAuthor {
175    pub name: String,
176    #[serde(default, skip_serializing_if = "String::is_empty")]
177    pub email: String,
178}
179
180pub fn bundle_has_manifest<S: AsRef<str>>(paths: impl IntoIterator<Item = S>) -> bool {
181    paths
182        .into_iter()
183        .any(|path| path.as_ref() == PLUGIN_MANIFEST_RELPATH)
184}