Skip to main content

systemprompt_models/services/
plugin.rs

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