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
24pub const PLUGIN_MANIFEST_RELPATH: &str = ".claude-plugin/plugin.json";
25
26pub const PLUGIN_MANIFEST_DIRS: &[&str] = &[".claude-plugin", "claude-plugin"];
27
28pub const PLUGIN_MANIFEST_FILE: &str = "plugin.json";
29
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct PluginManifest {
32    pub name: String,
33    #[serde(default)]
34    pub description: String,
35    #[serde(default)]
36    pub version: String,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub author: Option<ManifestAuthor>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub hooks: Option<String>,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub keywords: Vec<String>,
43    #[serde(
44        default,
45        rename = "installationPreference",
46        skip_serializing_if = "Option::is_none"
47    )]
48    pub installation_preference: Option<String>,
49
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub skills: Option<serde_json::Value>,
52
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub agents: Option<serde_json::Value>,
55
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub commands: Option<serde_json::Value>,
58
59    #[serde(
60        default,
61        rename = "mcpServers",
62        alias = "mcp_servers",
63        skip_serializing_if = "Option::is_none"
64    )]
65    pub mcp_servers: Option<serde_json::Value>,
66
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub homepage: Option<String>,
69
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub repository: Option<String>,
72
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub license: Option<String>,
75
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub category: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ManifestAuthor {
82    pub name: String,
83    #[serde(default, skip_serializing_if = "String::is_empty")]
84    pub email: String,
85}
86
87pub fn bundle_has_manifest<S: AsRef<str>>(paths: impl IntoIterator<Item = S>) -> bool {
88    paths
89        .into_iter()
90        .any(|path| path.as_ref() == PLUGIN_MANIFEST_RELPATH)
91}