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::services::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        alias = "mcp_servers",
88        skip_serializing_if = "Option::is_none"
89    )]
90    // JSON: Claude plugin manifest importer keys; Claude Code owns the schema.
91    pub mcp_servers: Option<serde_json::Value>,
92
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub homepage: Option<String>,
95
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub repository: Option<String>,
98
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub license: Option<String>,
101
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub category: Option<String>,
104
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub dependencies: Vec<ManifestDependency>,
107}
108
109/// One `dependencies` entry in Claude Code's `plugin.json` vocabulary: a bare
110/// plugin name resolved in the same marketplace, or an object naming the
111/// marketplace and a semver range.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(untagged)]
114pub enum ManifestDependency {
115    Name(String),
116    Detailed {
117        name: String,
118        #[serde(default, skip_serializing_if = "Option::is_none")]
119        version: Option<String>,
120        #[serde(default, skip_serializing_if = "Option::is_none")]
121        marketplace: Option<String>,
122    },
123}
124
125impl ManifestDependency {
126    #[must_use]
127    pub fn name(&self) -> &str {
128        match self {
129            Self::Name(name) | Self::Detailed { name, .. } => name,
130        }
131    }
132
133    #[must_use]
134    pub fn marketplace(&self) -> Option<&str> {
135        match self {
136            Self::Name(_) => None,
137            Self::Detailed { marketplace, .. } => marketplace.as_deref(),
138        }
139    }
140
141    #[must_use]
142    pub fn version(&self) -> Option<&str> {
143        match self {
144            Self::Name(_) => None,
145            Self::Detailed { version, .. } => version.as_deref(),
146        }
147    }
148}
149
150impl From<&PluginDependency> for ManifestDependency {
151    fn from(dependency: &PluginDependency) -> Self {
152        if dependency.marketplace.is_none() && dependency.version.is_none() {
153            Self::Name(dependency.name.clone())
154        } else {
155            Self::Detailed {
156                name: dependency.name.clone(),
157                version: dependency.version.clone(),
158                marketplace: dependency.marketplace.clone(),
159            }
160        }
161    }
162}
163
164impl From<&ManifestDependency> for PluginDependency {
165    fn from(dependency: &ManifestDependency) -> Self {
166        Self {
167            name: dependency.name().to_owned(),
168            marketplace: dependency.marketplace().map(str::to_owned),
169            version: dependency.version().map(str::to_owned),
170        }
171    }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ManifestAuthor {
176    pub name: String,
177    #[serde(default, skip_serializing_if = "String::is_empty")]
178    pub email: String,
179}
180
181pub fn bundle_has_manifest<S: AsRef<str>>(paths: impl IntoIterator<Item = S>) -> bool {
182    paths
183        .into_iter()
184        .any(|path| path.as_ref() == PLUGIN_MANIFEST_RELPATH)
185}