systemprompt_models/bridge/
plugin_bundle.rs1use 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
34pub 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 pub skills: Option<serde_json::Value>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub agents: Option<serde_json::Value>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 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 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#[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}