systemprompt_cli/commands/core/plugins/
validate.rs1use anyhow::{Context, Result, anyhow};
11use clap::Args;
12use std::path::Path;
13
14use crate::CliConfig;
15use crate::shared::CommandOutput;
16
17use super::types::{PluginValidateAllOutput, PluginValidateOutput};
18use systemprompt_identifiers::PluginId;
19use systemprompt_loader::ServicesRootBootstrap;
20
21#[derive(Debug, Clone, Args)]
22pub struct ValidateArgs {
23 #[arg(
24 help = "Plugin ID to validate (validates all if omitted)",
25 value_parser = crate::shared::parse_plugin_id
26 )]
27 pub id: Option<PluginId>,
28}
29
30pub(super) fn execute(args: ValidateArgs, _config: &CliConfig) -> Result<(CommandOutput, bool)> {
31 let profile = systemprompt_config::ProfileBootstrap::get().context("Failed to get profile")?;
32 let plugins_path = ServicesRootBootstrap::active_path_or(&profile.paths.services, "plugins");
33 let skills_path = ServicesRootBootstrap::active_path_or(&profile.paths.services, "skills");
34
35 let plugin_ids = match args.id {
36 Some(id) => {
37 let plugin_dir = plugins_path.join(id.as_str());
38 if !plugin_dir.exists() {
39 return Err(anyhow!("Plugin '{}' not found", id));
40 }
41 vec![id]
42 },
43 None => collect_plugin_ids(&plugins_path)?,
44 };
45
46 let mut results = Vec::new();
47
48 for plugin_id in &plugin_ids {
49 let result = validate_plugin(plugin_id, &plugins_path, &skills_path);
50 results.push(result);
51 }
52
53 let valid = results.iter().all(|result| result.valid);
54 let output = PluginValidateAllOutput { results };
55
56 Ok((
57 CommandOutput::table_of(
58 vec!["plugin_id", "valid", "errors", "warnings"],
59 &output.results,
60 )
61 .with_title("Plugin Validation Results"),
62 valid,
63 ))
64}
65
66pub fn collect_plugin_ids(plugins_path: &Path) -> Result<Vec<PluginId>> {
67 if !plugins_path.exists() {
68 return Ok(Vec::new());
69 }
70
71 let mut ids = Vec::new();
72 for entry in std::fs::read_dir(plugins_path)? {
73 let entry = entry?;
74 if entry.path().is_dir()
75 && entry.path().join("config.yaml").exists()
76 && let Some(name) = entry.file_name().to_str()
77 {
78 ids.push(PluginId::new(name));
79 }
80 }
81 ids.sort();
82 Ok(ids)
83}
84
85pub fn validate_plugin(
86 plugin_id: &PluginId,
87 plugins_path: &Path,
88 skills_path: &Path,
89) -> PluginValidateOutput {
90 let mut errors = Vec::new();
91 let mut warnings = Vec::new();
92
93 let config_path = plugins_path.join(plugin_id.as_str()).join("config.yaml");
94 let content = match std::fs::read_to_string(&config_path) {
95 Ok(c) => c,
96 Err(e) => {
97 errors.push(format!("Failed to read config.yaml: {}", e));
98 return PluginValidateOutput {
99 plugin_id: plugin_id.clone(),
100 valid: false,
101 errors,
102 warnings,
103 };
104 },
105 };
106
107 let plugin_file: systemprompt_manifest::PluginConfigFile = match serde_yaml::from_str(&content)
108 {
109 Ok(p) => p,
110 Err(e) => {
111 errors.push(format!("Failed to parse config.yaml: {}", e));
112 return PluginValidateOutput {
113 plugin_id: plugin_id.clone(),
114 valid: false,
115 errors,
116 warnings,
117 };
118 },
119 };
120
121 let plugin = &plugin_file.plugin;
122
123 if let Err(e) = plugin.validate(plugin_id.as_str()) {
124 errors.push(format!("{}", e));
125 }
126
127 if plugin.id != *plugin_id {
128 warnings.push(format!(
129 "Plugin id '{}' does not match directory name '{}'",
130 plugin.id, plugin_id
131 ));
132 }
133
134 validate_skill_refs(plugin, skills_path, &mut errors, &mut warnings);
135 validate_scripts(plugin, plugins_path, plugin_id, &mut errors);
136
137 PluginValidateOutput {
138 plugin_id: plugin_id.clone(),
139 valid: errors.is_empty(),
140 errors,
141 warnings,
142 }
143}
144
145fn validate_skill_refs(
146 plugin: &systemprompt_manifest::PluginConfig,
147 skills_path: &Path,
148 errors: &mut Vec<String>,
149 warnings: &mut Vec<String>,
150) {
151 if plugin.skills.source == systemprompt_models::plugin::ComponentSource::Explicit {
152 let skills = declared_skills(skills_path);
153 for skill_id in &plugin.skills.include {
154 match skills.get(skill_id.as_str()) {
155 None => errors.push(format!(
156 "Referenced skill '{}' does not exist (no skill declares that id)",
157 skill_id
158 )),
159 Some(skill) => {
160 if let Some(unknown) = &skill.unknown_host {
161 errors.push(format!("Referenced skill '{skill_id}' lists an {unknown}"));
162 }
163 if !skill.enabled {
164 warnings.push(format!(
165 "Referenced skill '{}' is disabled and will not be delivered",
166 skill_id
167 ));
168 }
169 },
170 }
171 }
172 }
173
174 if !skills_path.exists()
175 && plugin.skills.source == systemprompt_models::plugin::ComponentSource::Instance
176 {
177 warnings.push("Skills directory does not exist".to_owned());
178 }
179}
180
181struct DeclaredSkill {
182 enabled: bool,
183 unknown_host: Option<systemprompt_models::bridge::host::UnknownHostKind>,
184}
185
186fn declared_skills(skills_path: &Path) -> std::collections::HashMap<String, DeclaredSkill> {
187 let mut skills = std::collections::HashMap::new();
188 let Ok(entries) = std::fs::read_dir(skills_path) else {
189 return skills;
190 };
191 for entry in entries.flatten() {
192 let dir = entry.path();
193 if !dir.is_dir() {
194 continue;
195 }
196 let Ok(content) = std::fs::read_to_string(dir.join("config.yaml")) else {
197 continue;
198 };
199 let Ok(config) = serde_yaml::from_str::<systemprompt_manifest::DiskSkillConfig>(&content)
200 else {
201 continue;
202 };
203 let id = config.id.as_ref().map_or_else(
204 || entry.file_name().to_string_lossy().into_owned(),
205 |id| id.as_str().to_owned(),
206 );
207 skills.insert(
208 id,
209 DeclaredSkill {
210 enabled: config.enabled,
211 unknown_host: config.host_kinds().err(),
212 },
213 );
214 }
215 skills
216}
217
218fn validate_scripts(
219 plugin: &systemprompt_manifest::PluginConfig,
220 plugins_path: &Path,
221 plugin_id: &PluginId,
222 errors: &mut Vec<String>,
223) {
224 for script in &plugin.scripts {
225 let script_path = plugins_path.join(plugin_id.as_str()).join(&script.source);
226 if !script_path.exists() {
227 errors.push(format!(
228 "Script '{}' not found at {}",
229 script.name,
230 script_path.display()
231 ));
232 }
233 }
234}