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