Skip to main content

systemprompt_cli/commands/core/plugins/generate/
mod.rs

1//! Generation of Claude Code plugin output from `config.yaml` definitions.
2//!
3//! `execute` reads one or all plugin configs under the profile's plugins
4//! path and materialises skill, agent, MCP, script, and marketplace artifacts
5//! into the plugins storage directory. The per-component generators live in the
6//! `agents`, `mcp`, `skills`, and `marketplace` submodules.
7
8pub mod agents;
9pub mod marketplace;
10pub mod mcp;
11pub mod skills;
12
13use anyhow::{Context, Result, anyhow};
14use clap::Args;
15use std::path::{Path, PathBuf};
16
17use crate::CliConfig;
18use crate::shared::CommandOutput;
19use systemprompt_models::PluginConfigFile;
20
21use super::types::{PluginGenerateAllOutput, PluginGenerateOutput};
22
23const DEFAULT_AGENT_TOOLS: &str = "Read, Grep, Glob, Bash, Write, Edit, WebFetch, WebSearch";
24
25#[derive(Debug, Clone, Args)]
26pub struct GenerateArgs {
27    #[arg(long, help = "Plugin ID to generate (generates all if omitted)")]
28    pub id: Option<String>,
29
30    #[arg(long, help = "Output directory (defaults to plugin directory)")]
31    pub output_dir: Option<String>,
32}
33
34#[derive(Debug, Clone, Copy)]
35pub struct PluginGenerateContext<'a> {
36    pub plugins_path: &'a Path,
37    pub skills_path: &'a Path,
38    pub services_path: &'a Path,
39    pub output_dir_override: Option<&'a str>,
40}
41
42pub(super) fn execute(args: &GenerateArgs, _config: &CliConfig) -> Result<CommandOutput> {
43    let profile = systemprompt_config::ProfileBootstrap::get().context("Failed to get profile")?;
44    let plugins_path = PathBuf::from(profile.paths.plugins());
45    let skills_path = PathBuf::from(profile.paths.skills());
46    let services_path = PathBuf::from(&profile.paths.services);
47
48    let plugin_ids = match &args.id {
49        Some(id) => {
50            let plugin_dir = plugins_path.join(id);
51            if !plugin_dir.exists() {
52                return Err(anyhow!("Plugin '{}' not found", id));
53            }
54            vec![id.clone()]
55        },
56        None => collect_plugin_ids(&plugins_path)?,
57    };
58
59    let ctx = PluginGenerateContext {
60        plugins_path: &plugins_path,
61        skills_path: &skills_path,
62        services_path: &services_path,
63        output_dir_override: args.output_dir.as_deref(),
64    };
65
66    let mut results = Vec::new();
67
68    for plugin_id in &plugin_ids {
69        let result = generate_plugin(plugin_id, &ctx)?;
70        results.push(result);
71    }
72
73    let plugins_output_path = services_path
74        .join("..")
75        .join("storage")
76        .join("files")
77        .join("plugins");
78    marketplace::generate_marketplace_json(&plugins_path, &plugins_output_path)?;
79
80    let install_hint = extract_install_command(profile);
81
82    let output = PluginGenerateAllOutput {
83        results,
84        install_command: install_hint,
85    };
86
87    Ok(CommandOutput::card_value(
88        "Plugin Generation Complete",
89        &output,
90    ))
91}
92
93pub fn collect_plugin_ids(plugins_path: &Path) -> Result<Vec<String>> {
94    if !plugins_path.exists() {
95        return Ok(Vec::new());
96    }
97
98    let mut ids = Vec::new();
99    for entry in std::fs::read_dir(plugins_path)? {
100        let entry = entry?;
101        if entry.path().is_dir()
102            && entry.path().join("config.yaml").exists()
103            && let Some(name) = entry.file_name().to_str()
104        {
105            ids.push(name.to_owned());
106        }
107    }
108    ids.sort();
109    Ok(ids)
110}
111
112pub fn generate_plugin(
113    plugin_id: &str,
114    ctx: &PluginGenerateContext<'_>,
115) -> Result<PluginGenerateOutput> {
116    let config_path = ctx.plugins_path.join(plugin_id).join("config.yaml");
117    let content = std::fs::read_to_string(&config_path)
118        .with_context(|| format!("Failed to read {}", config_path.display()))?;
119    let plugin_file: PluginConfigFile = serde_yaml::from_str(&content)
120        .with_context(|| format!("Failed to parse {}", config_path.display()))?;
121    let plugin = &plugin_file.plugin;
122
123    let output_dir = ctx.output_dir_override.map_or_else(
124        || {
125            ctx.services_path
126                .join("..")
127                .join("storage")
128                .join("files")
129                .join("plugins")
130                .join(plugin_id)
131        },
132        PathBuf::from,
133    );
134
135    let mut files_generated = Vec::new();
136
137    skills::generate_skills(plugin, ctx.skills_path, &output_dir, &mut files_generated)?;
138    agents::generate_agents(plugin, ctx.services_path, &output_dir, &mut files_generated)?;
139    mcp::generate_mcp_json(plugin, ctx.services_path, &output_dir, &mut files_generated)?;
140    marketplace::copy_scripts(
141        plugin,
142        ctx.plugins_path,
143        plugin_id,
144        &output_dir,
145        &mut files_generated,
146    )?;
147    marketplace::generate_plugin_json(plugin, &output_dir, &mut files_generated)?;
148
149    Ok(PluginGenerateOutput {
150        plugin_id: systemprompt_identifiers::PluginId::new(plugin_id),
151        files_generated,
152        marketplace_path: output_dir.to_string_lossy().to_string(),
153    })
154}
155
156pub fn extract_install_command(profile: &systemprompt_models::Profile) -> Option<String> {
157    let github_link = profile.site.github_link.as_deref()?;
158    let repo_path = github_link
159        .trim_end_matches('/')
160        .trim_end_matches(".git")
161        .rsplit("github.com/")
162        .next()?;
163
164    if repo_path.contains('/') {
165        Some(format!("/plugin marketplace add {}", repo_path))
166    } else {
167        None
168    }
169}