systemprompt-cli 0.1.22

systemprompt.io OS - CLI for agent orchestration, AI operations, and system management
Documentation
use anyhow::{Context, Result, anyhow};
use clap::Args;
use dialoguer::Select;
use dialoguer::theme::ColorfulTheme;
use std::fs;
use std::path::Path;

use super::types::SkillEditOutput;
use crate::CliConfig;
use crate::interactive::resolve_required;
use crate::shared::CommandResult;
use systemprompt_logging::CliService;
use systemprompt_models::ProfileBootstrap;

#[derive(Debug, Args)]
pub struct EditArgs {
    #[arg(help = "Skill name to edit")]
    pub name: Option<String>,

    #[arg(
        long = "set",
        value_name = "KEY=VALUE",
        help = "Set a configuration value"
    )]
    pub set_values: Vec<String>,

    #[arg(long, help = "Enable the skill", conflicts_with = "disable")]
    pub enable: bool,

    #[arg(long, help = "Disable the skill", conflicts_with = "enable")]
    pub disable: bool,

    #[arg(long, help = "Update instructions")]
    pub instructions: Option<String>,

    #[arg(long, help = "File containing updated instructions")]
    pub instructions_file: Option<String>,
}

pub fn execute(args: &EditArgs, config: &CliConfig) -> Result<CommandResult<SkillEditOutput>> {
    let skills_path = get_skills_path()?;

    let name = resolve_required(args.name.clone(), "name", config, || {
        prompt_skill_selection(&skills_path)
    })?;

    let skill_dir = skills_path.join(&name);
    if !skill_dir.exists() {
        return Err(anyhow!("Skill '{}' not found", name));
    }

    edit_skill(&skill_dir, &name, args)
}

fn edit_skill(
    skill_dir: &Path,
    name: &str,
    args: &EditArgs,
) -> Result<CommandResult<SkillEditOutput>> {
    let md_path = skill_dir.join("SKILL.md");

    if !md_path.exists() {
        return Err(anyhow!("Skill '{}' has no SKILL.md file", name));
    }

    let content = fs::read_to_string(&md_path)
        .with_context(|| format!("Failed to read {}", md_path.display()))?;

    let (mut frontmatter, instructions) = parse_markdown(&content)?;

    let mut changes = Vec::new();

    if args.enable {
        frontmatter.insert(
            serde_yaml::Value::String("enabled".to_string()),
            serde_yaml::Value::Bool(true),
        );
        changes.push("enabled: true".to_string());
    }

    if args.disable {
        frontmatter.insert(
            serde_yaml::Value::String("enabled".to_string()),
            serde_yaml::Value::Bool(false),
        );
        changes.push("enabled: false".to_string());
    }

    for set_value in &args.set_values {
        let parts: Vec<&str> = set_value.splitn(2, '=').collect();
        if parts.len() != 2 {
            return Err(anyhow!(
                "Invalid --set format: '{}'. Expected key=value",
                set_value
            ));
        }
        apply_set_value(&mut frontmatter, parts[0], parts[1])?;
        changes.push(format!("{}: {}", parts[0], parts[1]));
    }

    let final_instructions = resolve_new_instructions(args, &instructions)?;
    if final_instructions != instructions {
        changes.push("instructions: updated".to_string());
    }

    if changes.is_empty() {
        return Err(anyhow!("No changes specified"));
    }

    CliService::info(&format!("Updating skill '{}'...", name));

    let new_content = rebuild_markdown(&frontmatter, &final_instructions)?;
    fs::write(&md_path, new_content)
        .with_context(|| format!("Failed to write {}", md_path.display()))?;

    CliService::success(&format!("Skill '{}' updated successfully", name));

    let output = SkillEditOutput {
        skill_id: name.to_string(),
        message: format!(
            "Skill '{}' updated successfully with {} change(s)",
            name,
            changes.len()
        ),
        changes,
    };

    Ok(CommandResult::text(output).with_title(format!("Edit Skill: {}", name)))
}

fn get_skills_path() -> Result<std::path::PathBuf> {
    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
    Ok(std::path::PathBuf::from(profile.paths.skills()))
}

fn parse_markdown(content: &str) -> Result<(serde_yaml::Mapping, String)> {
    let parts: Vec<&str> = content.splitn(3, "---").collect();
    if parts.len() < 3 {
        return Err(anyhow!("Invalid frontmatter format"));
    }

    let frontmatter: serde_yaml::Mapping =
        serde_yaml::from_str(parts[1]).context("Invalid YAML frontmatter")?;

    Ok((frontmatter, parts[2].trim().to_string()))
}

fn rebuild_markdown(frontmatter: &serde_yaml::Mapping, instructions: &str) -> Result<String> {
    let yaml = serde_yaml::to_string(frontmatter).context("Failed to serialize frontmatter")?;

    Ok(format!("---\n{}---\n\n{}\n", yaml, instructions))
}

fn apply_set_value(frontmatter: &mut serde_yaml::Mapping, key: &str, value: &str) -> Result<()> {
    match key {
        "title" | "name" => {
            frontmatter.insert(
                serde_yaml::Value::String("title".to_string()),
                serde_yaml::Value::String(value.to_string()),
            );
        },
        "description" => {
            frontmatter.insert(
                serde_yaml::Value::String("description".to_string()),
                serde_yaml::Value::String(value.to_string()),
            );
        },
        "category" => {
            frontmatter.insert(
                serde_yaml::Value::String("category".to_string()),
                serde_yaml::Value::String(value.to_string()),
            );
        },
        "tags" | "keywords" => {
            let tags: Vec<serde_yaml::Value> = value
                .split(',')
                .map(|s| serde_yaml::Value::String(s.trim().to_string()))
                .collect();
            frontmatter.insert(
                serde_yaml::Value::String("keywords".to_string()),
                serde_yaml::Value::Sequence(tags),
            );
        },
        "enabled" => {
            let enabled = value.parse::<bool>().map_err(|_| {
                anyhow!(
                    "Invalid boolean value for enabled: '{}'. Use true or false",
                    value
                )
            })?;
            frontmatter.insert(
                serde_yaml::Value::String("enabled".to_string()),
                serde_yaml::Value::Bool(enabled),
            );
        },
        _ => {
            return Err(anyhow!(
                "Unknown configuration key: '{}'. Supported: title, description, category, tags, \
                 enabled",
                key
            ));
        },
    }
    Ok(())
}

fn resolve_new_instructions(args: &EditArgs, current: &str) -> Result<String> {
    if let Some(i) = &args.instructions {
        return Ok(i.clone());
    }

    if let Some(file) = &args.instructions_file {
        let path = Path::new(file);
        return fs::read_to_string(path)
            .with_context(|| format!("Failed to read instructions file: {}", path.display()));
    }

    Ok(current.to_string())
}

fn prompt_skill_selection(skills_path: &Path) -> Result<String> {
    let mut skills: Vec<String> = Vec::new();

    if skills_path.exists() {
        for entry in fs::read_dir(skills_path)? {
            let entry = entry?;
            let path = entry.path();

            if !path.is_dir() {
                continue;
            }

            let has_skill_file = path.join("SKILL.md").exists();

            if has_skill_file {
                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                    skills.push(name.to_string());
                }
            }
        }
    }

    if skills.is_empty() {
        return Err(anyhow!("No skills found"));
    }

    skills.sort();

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Select skill to edit")
        .items(&skills)
        .default(0)
        .interact()
        .context("Failed to get skill selection")?;

    Ok(skills[selection].clone())
}