Skip to main content

systemprompt_models/services/
plugin.rs

1//! Plugin configuration and component-reference model.
2//!
3//! [`PluginConfig`] is the manifest shape loaded from a plugin's config file;
4//! its skill/agent/MCP/content references are [`PluginComponentRef`]s resolved
5//! against the instance ([`ComponentSource`]). [`PluginSummary`] is the
6//! list-view projection; [`PluginConfig::validate`] enforces id and reference
7//! rules.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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/// One plugin this plugin requires, in Claude Code's `plugin.json`
105/// `dependencies` vocabulary.
106///
107/// A bare `name` resolves inside the marketplace that carries the dependant;
108/// `marketplace` points at another marketplace, which the carrying
109/// [`MarketplaceConfig`](super::marketplace::MarketplaceConfig) must list in
110/// `allow_cross_marketplace_dependencies_on` and, when it is not one of this
111/// instance's own marketplaces, declare under `external_marketplaces`.
112/// `version` is a semver range Claude Code checks at install time.
113#[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/// Selects which hooks a plugin materialises into its `hooks/hooks.json`.
153///
154/// Claude Code executes plugin hooks session-globally — a `PreToolUse` hook
155/// with a `*` matcher fires for every tool call regardless of which plugin
156/// contributed the tool. One plugin therefore carries the governance hooks for
157/// the whole instance; every other plugin emits an empty hooks file.
158///
159/// `comms` is an opt-in on that same owner: when set, the bridge also installs
160/// the `UserPromptSubmit`/`Stop` hooks that drain gateway announcements into
161/// the session. Off by default — a tenant that publishes no announcements has
162/// no reason to run a command on every prompt.
163///
164/// `evaluation` is server-side only: it switches on the session-evaluation
165/// engine for the sessions this plugin's track hook reports, and installs no
166/// client hook of its own. Like `comms` it rides on the governance owner, so
167/// `is_empty` ignores it.
168#[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)]
176    pub evaluation: bool,
177    #[serde(default, skip_serializing_if = "Vec::is_empty")]
178    pub include: Vec<String>,
179}
180
181impl PluginHooksRef {
182    #[must_use]
183    pub const fn is_empty(&self) -> bool {
184        !self.governance && self.include.is_empty()
185    }
186}
187
188#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
189pub struct PluginComponentRef {
190    #[serde(default)]
191    pub source: ComponentSource,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub filter: Option<ComponentFilter>,
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub include: Vec<String>,
196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197    pub exclude: Vec<String>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct PluginScript {
202    pub name: String,
203    pub source: String,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct PluginAuthor {
208    pub name: String,
209    pub email: String,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
213pub struct PluginSummary {
214    pub id: PluginId,
215    pub name: String,
216    pub display_name: String,
217    pub enabled: bool,
218    pub skill_count: usize,
219    pub agent_count: usize,
220}
221
222impl From<&PluginConfig> for PluginSummary {
223    fn from(config: &PluginConfig) -> Self {
224        Self {
225            id: config.id.clone(),
226            name: config.name.clone(),
227            display_name: config.name.clone(),
228            enabled: config.enabled,
229            skill_count: config.skills.include.len(),
230            agent_count: config.agents.include.len(),
231        }
232    }
233}
234
235impl PluginConfig {
236    pub fn validate(&self, key: &str) -> Result<(), ConfigValidationError> {
237        let id_str = self.id.as_str();
238        if id_str.len() < 3 || id_str.len() > 50 {
239            return Err(ConfigValidationError::invalid_field(format!(
240                "Plugin '{key}': id must be between 3 and 50 characters"
241            )));
242        }
243
244        if !id_str
245            .chars()
246            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
247        {
248            return Err(ConfigValidationError::invalid_field(format!(
249                "Plugin '{key}': id must be lowercase alphanumeric with hyphens only (kebab-case)"
250            )));
251        }
252
253        if self.version.is_empty() {
254            return Err(ConfigValidationError::required(format!(
255                "Plugin '{key}': version must not be empty"
256            )));
257        }
258
259        Self::validate_component_ref(&self.skills, key, "skills")?;
260        Self::validate_component_ref(&self.agents, key, "agents")?;
261        Self::validate_component_ref(&self.artifacts, key, "artifacts")?;
262        Self::validate_component_ref(&self.rules, key, "rules")?;
263
264        for dependency in &self.dependencies {
265            dependency.validate(key)?;
266        }
267        let mut seen = std::collections::BTreeSet::new();
268        for dependency in &self.dependencies {
269            if !seen.insert((dependency.name.as_str(), dependency.marketplace.as_deref())) {
270                return Err(ConfigValidationError::invalid_field(format!(
271                    "Plugin '{key}': dependency '{}' is listed twice",
272                    dependency.name
273                )));
274            }
275        }
276
277        Ok(())
278    }
279
280    fn validate_component_ref(
281        component: &PluginComponentRef,
282        key: &str,
283        field: &str,
284    ) -> Result<(), ConfigValidationError> {
285        if component.source == ComponentSource::Instance && !component.include.is_empty() {
286            return Err(ConfigValidationError::invalid_field(format!(
287                "Plugin '{key}': {field}.source is 'instance' but {field}.include is set (ignored)"
288            )));
289        }
290
291        Ok(())
292    }
293}