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
7use anyhow::{Context, Result, anyhow};
8use clap::Args;
9use std::path::Path;
10
11use crate::CliConfig;
12use crate::shared::CommandOutput;
13
14use super::types::{PluginValidateAllOutput, PluginValidateOutput};
15
16#[derive(Debug, Clone, Args)]
17pub struct ValidateArgs {
18    #[arg(help = "Plugin ID to validate (validates all if omitted)")]
19    pub id: Option<String>,
20}
21
22pub(super) fn execute(args: ValidateArgs, _config: &CliConfig) -> Result<CommandOutput> {
23    let profile = systemprompt_config::ProfileBootstrap::get().context("Failed to get profile")?;
24    let plugins_path = std::path::PathBuf::from(profile.paths.plugins());
25    let skills_path = std::path::PathBuf::from(profile.paths.skills());
26
27    let plugin_ids = match args.id {
28        Some(id) => {
29            let plugin_dir = plugins_path.join(&id);
30            if !plugin_dir.exists() {
31                return Err(anyhow!("Plugin '{}' not found", id));
32            }
33            vec![id]
34        },
35        None => collect_plugin_ids(&plugins_path)?,
36    };
37
38    let mut results = Vec::new();
39
40    for plugin_id in &plugin_ids {
41        let result = validate_plugin(plugin_id, &plugins_path, &skills_path);
42        results.push(result);
43    }
44
45    let output = PluginValidateAllOutput { results };
46
47    Ok(CommandOutput::table_of(
48        vec!["plugin_id", "valid", "errors", "warnings"],
49        &output.results,
50    )
51    .with_title("Plugin Validation Results"))
52}
53
54pub fn collect_plugin_ids(plugins_path: &Path) -> Result<Vec<String>> {
55    if !plugins_path.exists() {
56        return Ok(Vec::new());
57    }
58
59    let mut ids = Vec::new();
60    for entry in std::fs::read_dir(plugins_path)? {
61        let entry = entry?;
62        if entry.path().is_dir()
63            && entry.path().join("config.yaml").exists()
64            && let Some(name) = entry.file_name().to_str()
65        {
66            ids.push(name.to_owned());
67        }
68    }
69    ids.sort();
70    Ok(ids)
71}
72
73pub fn validate_plugin(
74    plugin_id: &str,
75    plugins_path: &Path,
76    skills_path: &Path,
77) -> PluginValidateOutput {
78    let mut errors = Vec::new();
79    let mut warnings = Vec::new();
80
81    let config_path = plugins_path.join(plugin_id).join("config.yaml");
82    let content = match std::fs::read_to_string(&config_path) {
83        Ok(c) => c,
84        Err(e) => {
85            errors.push(format!("Failed to read config.yaml: {}", e));
86            return PluginValidateOutput {
87                plugin_id: systemprompt_identifiers::PluginId::new(plugin_id),
88                valid: false,
89                errors,
90                warnings,
91            };
92        },
93    };
94
95    let plugin_file: systemprompt_models::PluginConfigFile = match serde_yaml::from_str(&content) {
96        Ok(p) => p,
97        Err(e) => {
98            errors.push(format!("Failed to parse config.yaml: {}", e));
99            return PluginValidateOutput {
100                plugin_id: systemprompt_identifiers::PluginId::new(plugin_id),
101                valid: false,
102                errors,
103                warnings,
104            };
105        },
106    };
107
108    let plugin = &plugin_file.plugin;
109
110    if let Err(e) = plugin.validate(plugin_id) {
111        errors.push(format!("{}", e));
112    }
113
114    if plugin.id != plugin_id {
115        warnings.push(format!(
116            "Plugin id '{}' does not match directory name '{}'",
117            plugin.id, plugin_id
118        ));
119    }
120
121    validate_skill_refs(plugin, skills_path, &mut errors, &mut warnings);
122    validate_scripts(plugin, plugins_path, plugin_id, &mut errors);
123
124    PluginValidateOutput {
125        plugin_id: systemprompt_identifiers::PluginId::new(plugin_id),
126        valid: errors.is_empty(),
127        errors,
128        warnings,
129    }
130}
131
132fn validate_skill_refs(
133    plugin: &systemprompt_models::PluginConfig,
134    skills_path: &Path,
135    errors: &mut Vec<String>,
136    warnings: &mut Vec<String>,
137) {
138    if plugin.skills.source == systemprompt_models::ComponentSource::Explicit {
139        for skill_id in &plugin.skills.include {
140            let skill_dir = skills_path.join(skill_id);
141            if !skill_dir.exists() {
142                errors.push(format!("Referenced skill '{}' not found", skill_id));
143            }
144        }
145    }
146
147    if !skills_path.exists()
148        && plugin.skills.source == systemprompt_models::ComponentSource::Instance
149    {
150        warnings.push("Skills directory does not exist".to_owned());
151    }
152}
153
154fn validate_scripts(
155    plugin: &systemprompt_models::PluginConfig,
156    plugins_path: &Path,
157    plugin_id: &str,
158    errors: &mut Vec<String>,
159) {
160    for script in &plugin.scripts {
161        let script_path = plugins_path.join(plugin_id).join(&script.source);
162        if !script_path.exists() {
163            errors.push(format!(
164                "Script '{}' not found at {}",
165                script.name,
166                script_path.display()
167            ));
168        }
169    }
170}