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