Skip to main content

systemprompt_models/services/
plugin.rs

1use std::fmt;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use systemprompt_identifiers::PluginId;
6
7use super::hooks::HookEventsConfig;
8
9const fn default_true() -> bool {
10    true
11}
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
14#[serde(rename_all = "lowercase")]
15pub enum ComponentSource {
16    #[default]
17    Instance,
18    Explicit,
19}
20
21impl fmt::Display for ComponentSource {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        match self {
24            Self::Instance => write!(f, "instance"),
25            Self::Explicit => write!(f, "explicit"),
26        }
27    }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31#[serde(rename_all = "lowercase")]
32pub enum ComponentFilter {
33    Enabled,
34}
35
36impl fmt::Display for ComponentFilter {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::Enabled => write!(f, "enabled"),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PluginConfigFile {
46    pub plugin: PluginConfig,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
50pub struct PluginVariableDef {
51    pub name: String,
52    #[serde(default)]
53    pub description: String,
54    #[serde(default = "default_true")]
55    pub required: bool,
56    #[serde(default)]
57    pub secret: bool,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub example: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct PluginConfig {
64    pub id: PluginId,
65    pub name: String,
66    pub description: String,
67    pub version: String,
68    #[serde(default = "default_true")]
69    pub enabled: bool,
70    pub author: PluginAuthor,
71    pub keywords: Vec<String>,
72    pub license: String,
73    pub category: String,
74
75    pub skills: PluginComponentRef,
76    pub agents: PluginComponentRef,
77    #[serde(default)]
78    pub mcp_servers: Vec<String>,
79    #[serde(default)]
80    pub content_sources: Vec<String>,
81    #[serde(default)]
82    pub hooks: HookEventsConfig,
83    #[serde(default)]
84    pub scripts: Vec<PluginScript>,
85}
86
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
88pub struct PluginComponentRef {
89    #[serde(default)]
90    pub source: ComponentSource,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub filter: Option<ComponentFilter>,
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub include: Vec<String>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub exclude: Vec<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct PluginScript {
101    pub name: String,
102    pub source: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct PluginAuthor {
107    pub name: String,
108    pub email: String,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
112pub struct PluginSummary {
113    pub id: PluginId,
114    pub name: String,
115    pub display_name: String,
116    pub enabled: bool,
117    pub skill_count: usize,
118    pub agent_count: usize,
119}
120
121impl From<&PluginConfig> for PluginSummary {
122    fn from(config: &PluginConfig) -> Self {
123        Self {
124            id: config.id.clone(),
125            name: config.name.clone(),
126            display_name: config.name.clone(),
127            enabled: config.enabled,
128            skill_count: config.skills.include.len(),
129            agent_count: config.agents.include.len(),
130        }
131    }
132}
133
134impl PluginConfig {
135    pub fn validate(&self, key: &str) -> anyhow::Result<()> {
136        let id_str = self.id.as_str();
137        if id_str.len() < 3 || id_str.len() > 50 {
138            anyhow::bail!("Plugin '{}': id must be between 3 and 50 characters", key);
139        }
140
141        if !id_str
142            .chars()
143            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
144        {
145            anyhow::bail!(
146                "Plugin '{}': id must be lowercase alphanumeric with hyphens only (kebab-case)",
147                key
148            );
149        }
150
151        if self.version.is_empty() {
152            anyhow::bail!("Plugin '{}': version must not be empty", key);
153        }
154
155        Self::validate_component_ref(&self.skills, key, "skills")?;
156        Self::validate_component_ref(&self.agents, key, "agents")?;
157        self.hooks.validate()?;
158
159        Ok(())
160    }
161
162    fn validate_component_ref(
163        component: &PluginComponentRef,
164        key: &str,
165        field: &str,
166    ) -> anyhow::Result<()> {
167        if component.source == ComponentSource::Explicit && component.include.is_empty() {
168            anyhow::bail!(
169                "Plugin '{}': {}.source is 'explicit' but {}.include is empty",
170                key,
171                field,
172                field
173            );
174        }
175
176        Ok(())
177    }
178}