systemprompt_models/services/
plugin.rs1use std::fmt;
13
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use systemprompt_identifiers::PluginId;
17
18use crate::errors::ConfigValidationError;
19
20const fn default_true() -> bool {
21 true
22}
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
25#[serde(rename_all = "lowercase")]
26pub enum ComponentSource {
27 Instance,
28 #[default]
29 Explicit,
30}
31
32impl fmt::Display for ComponentSource {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 match self {
35 Self::Instance => write!(f, "instance"),
36 Self::Explicit => write!(f, "explicit"),
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
42#[serde(rename_all = "lowercase")]
43pub enum ComponentFilter {
44 Enabled,
45}
46
47impl fmt::Display for ComponentFilter {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self {
50 Self::Enabled => write!(f, "enabled"),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PluginConfigFile {
57 pub plugin: PluginConfig,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
61pub struct PluginVariableDef {
62 pub name: String,
63 #[serde(default)]
64 pub description: String,
65 #[serde(default = "default_true")]
66 pub required: bool,
67 #[serde(default)]
68 pub secret: bool,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub example: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct PluginConfig {
75 pub id: PluginId,
76 pub name: String,
77 pub description: String,
78 pub version: String,
79 #[serde(default = "default_true")]
80 pub enabled: bool,
81 pub author: PluginAuthor,
82 pub keywords: Vec<String>,
83 pub license: String,
84 pub category: String,
85
86 pub skills: PluginComponentRef,
87 pub agents: PluginComponentRef,
88 #[serde(default)]
89 pub rules: PluginComponentRef,
90 #[serde(default)]
91 pub mcp_servers: PluginComponentRef,
92 #[serde(default)]
93 pub content_sources: PluginComponentRef,
94 #[serde(default)]
95 pub artifacts: PluginComponentRef,
96 #[serde(default)]
97 pub hooks: PluginHooksRef,
98 #[serde(default)]
99 pub scripts: Vec<PluginScript>,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
101 pub dependencies: Vec<PluginDependency>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[serde(deny_unknown_fields)]
115pub struct PluginDependency {
116 pub name: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub marketplace: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub version: Option<String>,
121}
122
123impl PluginDependency {
124 fn validate(&self, key: &str) -> Result<(), ConfigValidationError> {
125 if self.name.trim().is_empty() {
126 return Err(ConfigValidationError::required(format!(
127 "Plugin '{key}': dependencies entries must name a plugin"
128 )));
129 }
130 if self
131 .marketplace
132 .as_deref()
133 .is_some_and(|m| m.trim().is_empty())
134 {
135 return Err(ConfigValidationError::invalid_field(format!(
136 "Plugin '{key}': dependency '{}' sets an empty marketplace",
137 self.name
138 )));
139 }
140 if let Some(range) = &self.version
141 && semver::VersionReq::parse(range).is_err()
142 {
143 return Err(ConfigValidationError::invalid_field(format!(
144 "Plugin '{key}': dependency '{}' version '{range}' is not a semver range",
145 self.name
146 )));
147 }
148 Ok(())
149 }
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct PluginHooksRef {
171 #[serde(default)]
172 pub governance: bool,
173 #[serde(default)]
174 pub comms: bool,
175 #[serde(default, alias = "evaluation")]
182 pub judge: bool,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub include: Vec<String>,
185}
186
187impl PluginHooksRef {
188 #[must_use]
189 pub const fn is_empty(&self) -> bool {
190 !self.governance && self.include.is_empty()
191 }
192}
193
194#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
195pub struct PluginComponentRef {
196 #[serde(default)]
197 pub source: ComponentSource,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub filter: Option<ComponentFilter>,
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
201 pub include: Vec<String>,
202 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub exclude: Vec<String>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct PluginScript {
208 pub name: String,
209 pub source: String,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct PluginAuthor {
214 pub name: String,
215 pub email: String,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
219pub struct PluginSummary {
220 pub id: PluginId,
221 pub name: String,
222 pub display_name: String,
223 pub enabled: bool,
224 pub skill_count: usize,
225 pub agent_count: usize,
226}
227
228impl From<&PluginConfig> for PluginSummary {
229 fn from(config: &PluginConfig) -> Self {
230 Self {
231 id: config.id.clone(),
232 name: config.name.clone(),
233 display_name: config.name.clone(),
234 enabled: config.enabled,
235 skill_count: config.skills.include.len(),
236 agent_count: config.agents.include.len(),
237 }
238 }
239}
240
241impl PluginConfig {
242 pub fn validate(&self, key: &str) -> Result<(), ConfigValidationError> {
243 let id_str = self.id.as_str();
244 if id_str.len() < 3 || id_str.len() > 50 {
245 return Err(ConfigValidationError::invalid_field(format!(
246 "Plugin '{key}': id must be between 3 and 50 characters"
247 )));
248 }
249
250 if !id_str
251 .chars()
252 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
253 {
254 return Err(ConfigValidationError::invalid_field(format!(
255 "Plugin '{key}': id must be lowercase alphanumeric with hyphens only (kebab-case)"
256 )));
257 }
258
259 if self.version.is_empty() {
260 return Err(ConfigValidationError::required(format!(
261 "Plugin '{key}': version must not be empty"
262 )));
263 }
264
265 Self::validate_component_ref(&self.skills, key, "skills")?;
266 Self::validate_component_ref(&self.agents, key, "agents")?;
267 Self::validate_component_ref(&self.artifacts, key, "artifacts")?;
268 Self::validate_component_ref(&self.rules, key, "rules")?;
269
270 for dependency in &self.dependencies {
271 dependency.validate(key)?;
272 }
273 let mut seen = std::collections::BTreeSet::new();
274 for dependency in &self.dependencies {
275 if !seen.insert((dependency.name.as_str(), dependency.marketplace.as_deref())) {
276 return Err(ConfigValidationError::invalid_field(format!(
277 "Plugin '{key}': dependency '{}' is listed twice",
278 dependency.name
279 )));
280 }
281 }
282
283 Ok(())
284 }
285
286 fn validate_component_ref(
287 component: &PluginComponentRef,
288 key: &str,
289 field: &str,
290 ) -> Result<(), ConfigValidationError> {
291 if component.source == ComponentSource::Instance && !component.include.is_empty() {
292 return Err(ConfigValidationError::invalid_field(format!(
293 "Plugin '{key}': {field}.source is 'instance' but {field}.include is set (ignored)"
294 )));
295 }
296
297 Ok(())
298 }
299}