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