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