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        for skill_id in &plugin.skills.include {
143            let skill_dir = skills_path.join(skill_id);
144            if !skill_dir.exists() {
145                errors.push(format!("Referenced skill '{}' not found", skill_id));
146            }
147        }
148    }
149
150    if !skills_path.exists()
151        && plugin.skills.source == systemprompt_models::ComponentSource::Instance
152    {
153        warnings.push("Skills directory does not exist".to_owned());
154    }
155}
156
157fn validate_scripts(
158    plugin: &systemprompt_models::PluginConfig,
159    plugins_path: &Path,
160    plugin_id: &str,
161    errors: &mut Vec<String>,
162) {
163    for script in &plugin.scripts {
164        let script_path = plugins_path.join(plugin_id).join(&script.source);
165        if !script_path.exists() {
166            errors.push(format!(
167                "Script '{}' not found at {}",
168                script.name,
169                script_path.display()
170            ));
171        }
172    }
173}