Skip to main content

systemprompt_cli/commands/core/plugins/
validate.rs

1//! `plugins validate` subcommand.
2//!
3//! Checks one or all plugin `config.yaml` files for parse errors, id/directory
4//! mismatches, missing skill references, and missing script sources, returning
5//! a per-plugin pass/fail report.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use 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}