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