Skip to main content

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

1//! Skill markdown generation for plugin bundles.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::Result;
7use std::path::{Path, PathBuf};
8use systemprompt_models::{ComponentFilter, ComponentSource, PluginConfig, strip_frontmatter};
9
10pub fn generate_skills(
11    plugin: &PluginConfig,
12    skills_path: &Path,
13    output_dir: &Path,
14    files_generated: &mut Vec<String>,
15) -> Result<()> {
16    let resolved_skills = resolve_skills(plugin, skills_path)?;
17
18    for (skill_id, skill_dir) in &resolved_skills {
19        let kebab_name = skill_id.replace('_', "-");
20        let output_skill_dir = output_dir.join("skills").join(&kebab_name);
21        std::fs::create_dir_all(&output_skill_dir)?;
22
23        let skill_md_content = build_skill_md(skill_id, skill_dir)?;
24        let skill_md_path = output_skill_dir.join("SKILL.md");
25        std::fs::write(&skill_md_path, skill_md_content)?;
26        files_generated.push(skill_md_path.to_string_lossy().to_string());
27    }
28
29    Ok(())
30}
31
32fn resolve_skills(plugin: &PluginConfig, skills_path: &Path) -> Result<Vec<(String, PathBuf)>> {
33    let mut resolved = Vec::new();
34
35    if plugin.skills.source == ComponentSource::Explicit {
36        for skill_id in &plugin.skills.include {
37            let skill_dir = skills_path.join(skill_id);
38            if skill_dir.exists() {
39                resolved.push((skill_id.clone(), skill_dir));
40            }
41        }
42        return Ok(resolved);
43    }
44
45    if !skills_path.exists() {
46        return Ok(resolved);
47    }
48
49    for entry in std::fs::read_dir(skills_path)? {
50        let entry = entry?;
51        let path = entry.path();
52        if !path.is_dir() {
53            continue;
54        }
55
56        let skill_id = entry.file_name().to_string_lossy().to_string();
57
58        if plugin.skills.exclude.contains(&skill_id) {
59            continue;
60        }
61
62        if plugin.skills.filter == Some(ComponentFilter::Enabled) {
63            let config_path = path.join("config.yaml");
64            if config_path.exists() {
65                let cfg_text = std::fs::read_to_string(&config_path)?;
66                let cfg: serde_yaml::Value = serde_yaml::from_str(&cfg_text)?;
67                let enabled = cfg
68                    .get("enabled")
69                    .and_then(serde_yaml::Value::as_bool)
70                    .unwrap_or(true);
71                if !enabled {
72                    continue;
73                }
74            }
75        }
76
77        resolved.push((skill_id, path));
78    }
79
80    resolved.sort_by(|a, b| a.0.cmp(&b.0));
81    Ok(resolved)
82}
83
84fn build_skill_md(skill: &str, skill_dir: &Path) -> Result<String> {
85    let index_md = skill_dir.join("index.md");
86    let skill_md_path = skill_dir.join("SKILL.md");
87
88    let config_path = skill_dir.join("config.yaml");
89    let (name, description) = if config_path.exists() {
90        let cfg_text = std::fs::read_to_string(&config_path)?;
91        let cfg: serde_yaml::Value = serde_yaml::from_str(&cfg_text)?;
92        let name = cfg
93            .get("name")
94            .and_then(|v| v.as_str())
95            .unwrap_or(skill)
96            .to_owned();
97        let desc = cfg
98            .get("description")
99            .and_then(|v| v.as_str())
100            .unwrap_or("")
101            .to_owned();
102        (name, desc)
103    } else {
104        (skill.to_owned(), String::new())
105    };
106
107    let body = if index_md.exists() {
108        let content = std::fs::read_to_string(&index_md)?;
109        strip_frontmatter(&content)
110    } else if skill_md_path.exists() {
111        let content = std::fs::read_to_string(&skill_md_path)?;
112        strip_frontmatter(&content)
113    } else {
114        format!(
115            "$(systemprompt core skills show {} --raw 2>/dev/null || echo \"Skill not available\")",
116            skill
117        )
118    };
119
120    Ok(format!(
121        "---\nname: \"{}\"\ndescription: \"{}\"\n---\n\n{}\n",
122        name,
123        description.replace('"', "\\\""),
124        body.trim()
125    ))
126}