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};
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}