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