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 mcp_servers: PluginComponentRef,
90    #[serde(default)]
91    pub content_sources: PluginComponentRef,
92    #[serde(default)]
93    pub artifacts: PluginComponentRef,
94    #[serde(default)]
95    pub hooks: PluginHooksRef,
96    #[serde(default)]
97    pub scripts: Vec<PluginScript>,
98}
99
100/// Selects which hooks a plugin materialises into its `hooks/hooks.json`.
101///
102/// Claude Code executes plugin hooks session-globally — a `PreToolUse` hook
103/// with a `*` matcher fires for every tool call regardless of which plugin
104/// contributed the tool. One plugin therefore carries the governance hooks for
105/// the whole instance; every other plugin emits an empty hooks file.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct PluginHooksRef {
109    /// Emit the built-in govern + track HTTP hooks into this plugin.
110    #[serde(default)]
111    pub governance: bool,
112    /// Ids of custom hooks from `services/hooks/<id>/` to materialise here.
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub include: Vec<String>,
115}
116
117impl PluginHooksRef {
118    #[must_use]
119    pub const fn is_empty(&self) -> bool {
120        !self.governance && self.include.is_empty()
121    }
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
125pub struct PluginComponentRef {
126    #[serde(default)]
127    pub source: ComponentSource,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub filter: Option<ComponentFilter>,
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub include: Vec<String>,
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub exclude: Vec<String>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PluginScript {
138    pub name: String,
139    pub source: String,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct PluginAuthor {
144    pub name: String,
145    pub email: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
149pub struct PluginSummary {
150    pub id: PluginId,
151    pub name: String,
152    pub display_name: String,
153    pub enabled: bool,
154    pub skill_count: usize,
155    pub agent_count: usize,
156}
157
158impl From<&PluginConfig> for PluginSummary {
159    fn from(config: &PluginConfig) -> Self {
160        Self {
161            id: config.id.clone(),
162            name: config.name.clone(),
163            display_name: config.name.clone(),
164            enabled: config.enabled,
165            skill_count: config.skills.include.len(),
166            agent_count: config.agents.include.len(),
167        }
168    }
169}
170
171impl PluginConfig {
172    pub fn validate(&self, key: &str) -> Result<(), ConfigValidationError> {
173        let id_str = self.id.as_str();
174        if id_str.len() < 3 || id_str.len() > 50 {
175            return Err(ConfigValidationError::invalid_field(format!(
176                "Plugin '{key}': id must be between 3 and 50 characters"
177            )));
178        }
179
180        if !id_str
181            .chars()
182            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
183        {
184            return Err(ConfigValidationError::invalid_field(format!(
185                "Plugin '{key}': id must be lowercase alphanumeric with hyphens only (kebab-case)"
186            )));
187        }
188
189        if self.version.is_empty() {
190            return Err(ConfigValidationError::required(format!(
191                "Plugin '{key}': version must not be empty"
192            )));
193        }
194
195        Self::validate_component_ref(&self.skills, key, "skills")?;
196        Self::validate_component_ref(&self.agents, key, "agents")?;
197        Self::validate_component_ref(&self.artifacts, key, "artifacts")?;
198
199        Ok(())
200    }
201
202    fn validate_component_ref(
203        component: &PluginComponentRef,
204        key: &str,
205        field: &str,
206    ) -> Result<(), ConfigValidationError> {
207        // `explicit` (the default) means "exactly what `include` lists" — an empty
208        // list is valid and scopes the plugin to zero of this component. Under
209        // `instance` the whole catalogue is taken, so a stray `include` would be
210        // silently ignored: reject it as a likely mistake.
211        if component.source == ComponentSource::Instance && !component.include.is_empty() {
212            return Err(ConfigValidationError::invalid_field(format!(
213                "Plugin '{key}': {field}.source is 'instance' but {field}.include is set (ignored)"
214            )));
215        }
216
217        Ok(())
218    }
219}