systemprompt-cli 0.63.0

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
//! `plugins validate` subcommand.
//!
//! Checks one or all plugin `config.yaml` files for parse errors, id/directory
//! mismatches, missing skill references, and missing script sources, returning
//! a per-plugin pass/fail report.
//!
//! Copyright (c) systemprompt.io — Business Source License 1.1.
//! See <https://systemprompt.io> for licensing details.

use anyhow::{Context, Result, anyhow};
use clap::Args;
use std::path::Path;

use crate::CliConfig;
use crate::shared::CommandOutput;

use super::types::{PluginValidateAllOutput, PluginValidateOutput};
use systemprompt_identifiers::PluginId;
use systemprompt_loader::ServicesRootBootstrap;

#[derive(Debug, Clone, Args)]
pub struct ValidateArgs {
    #[arg(
        help = "Plugin ID to validate (validates all if omitted)",
        value_parser = crate::shared::parse_plugin_id
    )]
    pub id: Option<PluginId>,
}

pub(super) fn execute(args: ValidateArgs, _config: &CliConfig) -> Result<(CommandOutput, bool)> {
    let profile = systemprompt_config::ProfileBootstrap::get().context("Failed to get profile")?;
    let plugins_path = ServicesRootBootstrap::active_path_or(&profile.paths.services, "plugins");
    let skills_path = ServicesRootBootstrap::active_path_or(&profile.paths.services, "skills");

    let plugin_ids = match args.id {
        Some(id) => {
            let plugin_dir = plugins_path.join(id.as_str());
            if !plugin_dir.exists() {
                return Err(anyhow!("Plugin '{}' not found", id));
            }
            vec![id]
        },
        None => collect_plugin_ids(&plugins_path)?,
    };

    let mut results = Vec::new();

    for plugin_id in &plugin_ids {
        let result = validate_plugin(plugin_id, &plugins_path, &skills_path);
        results.push(result);
    }

    let valid = results.iter().all(|result| result.valid);
    let output = PluginValidateAllOutput { results };

    Ok((
        CommandOutput::table_of(
            vec!["plugin_id", "valid", "errors", "warnings"],
            &output.results,
        )
        .with_title("Plugin Validation Results"),
        valid,
    ))
}

pub fn collect_plugin_ids(plugins_path: &Path) -> Result<Vec<PluginId>> {
    if !plugins_path.exists() {
        return Ok(Vec::new());
    }

    let mut ids = Vec::new();
    for entry in std::fs::read_dir(plugins_path)? {
        let entry = entry?;
        if entry.path().is_dir()
            && entry.path().join("config.yaml").exists()
            && let Some(name) = entry.file_name().to_str()
        {
            ids.push(PluginId::new(name));
        }
    }
    ids.sort();
    Ok(ids)
}

pub fn validate_plugin(
    plugin_id: &PluginId,
    plugins_path: &Path,
    skills_path: &Path,
) -> PluginValidateOutput {
    let mut errors = Vec::new();
    let mut warnings = Vec::new();

    let config_path = plugins_path.join(plugin_id.as_str()).join("config.yaml");
    let content = match std::fs::read_to_string(&config_path) {
        Ok(c) => c,
        Err(e) => {
            errors.push(format!("Failed to read config.yaml: {}", e));
            return PluginValidateOutput {
                plugin_id: plugin_id.clone(),
                valid: false,
                errors,
                warnings,
            };
        },
    };

    let plugin_file: systemprompt_manifest::PluginConfigFile = match serde_yaml::from_str(&content)
    {
        Ok(p) => p,
        Err(e) => {
            errors.push(format!("Failed to parse config.yaml: {}", e));
            return PluginValidateOutput {
                plugin_id: plugin_id.clone(),
                valid: false,
                errors,
                warnings,
            };
        },
    };

    let plugin = &plugin_file.plugin;

    if let Err(e) = plugin.validate(plugin_id.as_str()) {
        errors.push(format!("{}", e));
    }

    if plugin.id != *plugin_id {
        warnings.push(format!(
            "Plugin id '{}' does not match directory name '{}'",
            plugin.id, plugin_id
        ));
    }

    validate_skill_refs(plugin, skills_path, &mut errors, &mut warnings);
    validate_scripts(plugin, plugins_path, plugin_id, &mut errors);

    PluginValidateOutput {
        plugin_id: plugin_id.clone(),
        valid: errors.is_empty(),
        errors,
        warnings,
    }
}

fn validate_skill_refs(
    plugin: &systemprompt_manifest::PluginConfig,
    skills_path: &Path,
    errors: &mut Vec<String>,
    warnings: &mut Vec<String>,
) {
    if plugin.skills.source == systemprompt_models::plugin::ComponentSource::Explicit {
        let skills = declared_skills(skills_path);
        for skill_id in &plugin.skills.include {
            match skills.get(skill_id.as_str()) {
                None => errors.push(format!(
                    "Referenced skill '{}' does not exist (no skill declares that id)",
                    skill_id
                )),
                Some(skill) => {
                    if let Some(unknown) = &skill.unknown_host {
                        errors.push(format!("Referenced skill '{skill_id}' lists an {unknown}"));
                    }
                    if !skill.enabled {
                        warnings.push(format!(
                            "Referenced skill '{}' is disabled and will not be delivered",
                            skill_id
                        ));
                    }
                },
            }
        }
    }

    if !skills_path.exists()
        && plugin.skills.source == systemprompt_models::plugin::ComponentSource::Instance
    {
        warnings.push("Skills directory does not exist".to_owned());
    }
}

struct DeclaredSkill {
    enabled: bool,
    unknown_host: Option<systemprompt_models::bridge::host::UnknownHostKind>,
}

fn declared_skills(skills_path: &Path) -> std::collections::HashMap<String, DeclaredSkill> {
    let mut skills = std::collections::HashMap::new();
    let Ok(entries) = std::fs::read_dir(skills_path) else {
        return skills;
    };
    for entry in entries.flatten() {
        let dir = entry.path();
        if !dir.is_dir() {
            continue;
        }
        let Ok(content) = std::fs::read_to_string(dir.join("config.yaml")) else {
            continue;
        };
        let Ok(config) = serde_yaml::from_str::<systemprompt_manifest::DiskSkillConfig>(&content)
        else {
            continue;
        };
        let id = config.id.as_ref().map_or_else(
            || entry.file_name().to_string_lossy().into_owned(),
            |id| id.as_str().to_owned(),
        );
        skills.insert(
            id,
            DeclaredSkill {
                enabled: config.enabled,
                unknown_host: config.host_kinds().err(),
            },
        );
    }
    skills
}

fn validate_scripts(
    plugin: &systemprompt_manifest::PluginConfig,
    plugins_path: &Path,
    plugin_id: &PluginId,
    errors: &mut Vec<String>,
) {
    for script in &plugin.scripts {
        let script_path = plugins_path.join(plugin_id.as_str()).join(&script.source);
        if !script_path.exists() {
            errors.push(format!(
                "Script '{}' not found at {}",
                script.name,
                script_path.display()
            ));
        }
    }
}