systemprompt-cli 0.2.2

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
use anyhow::{Context, Result};
use clap::{Args, ValueEnum};
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, Select};
use std::sync::Arc;

use super::types::SkillSyncOutput;
use crate::CliConfig;
use crate::shared::CommandResult;
use systemprompt_database::{Database, DbPool};
use systemprompt_loader::ConfigLoader;
use systemprompt_logging::CliService;
use systemprompt_models::{ProfileBootstrap, SecretsBootstrap};
use systemprompt_sync::{LocalSyncDirection, SkillsDiffResult, SkillsLocalSync};

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum SyncDirection {
    ToDb,
    ToDisk,
}

#[derive(Debug, Clone, Copy, Args)]
pub struct SyncArgs {
    #[arg(long, value_enum, help = "Sync direction")]
    pub direction: Option<SyncDirection>,

    #[arg(long, help = "Show what would happen without making changes")]
    pub dry_run: bool,

    #[arg(long, help = "Delete items that only exist in target")]
    pub delete_orphans: bool,

    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
    pub yes: bool,
}

pub async fn execute(args: SyncArgs, config: &CliConfig) -> Result<CommandResult<SkillSyncOutput>> {
    CliService::section("Skills Sync");

    let spinner = CliService::spinner("Connecting to database...");
    let db = create_db_provider().await?;
    spinner.finish_and_clear();

    let skills_path = get_skills_path()?;

    if !skills_path.exists() {
        anyhow::bail!(
            "Skills path does not exist: {}\nEnsure the path exists or update your profile",
            skills_path.display()
        );
    }

    let sync = SkillsLocalSync::new(Arc::clone(&db), skills_path.clone());
    let spinner = CliService::spinner("Calculating diff...");
    let diff = sync
        .calculate_diff()
        .await
        .context("Failed to calculate skills diff")?;
    spinner.finish_and_clear();

    display_diff_summary(&diff);

    if !diff.has_changes() {
        CliService::success("Skills are in sync - no changes needed");
        return Ok(CommandResult::text(SkillSyncOutput {
            direction: "none".to_string(),
            synced: 0,
            skipped: 0,
            deleted: 0,
            errors: vec![],
        })
        .with_title("Skills Sync"));
    }

    let direction = match args.direction {
        Some(SyncDirection::ToDisk) => LocalSyncDirection::ToDisk,
        Some(SyncDirection::ToDb) => LocalSyncDirection::ToDatabase,
        None => {
            if args.dry_run {
                LocalSyncDirection::ToDatabase
            } else if !config.is_interactive() {
                anyhow::bail!("--direction is required in non-interactive mode");
            } else {
                let Some(dir) = prompt_sync_direction()? else {
                    CliService::info("Sync cancelled");
                    return Ok(CommandResult::text(SkillSyncOutput {
                        direction: "cancelled".to_string(),
                        synced: 0,
                        skipped: 0,
                        deleted: 0,
                        errors: vec![],
                    })
                    .with_title("Skills Sync"));
                };
                dir
            }
        },
    };

    if args.dry_run {
        CliService::info("[Dry Run] No changes made");
        let direction_str = match direction {
            LocalSyncDirection::ToDisk => "to-disk",
            LocalSyncDirection::ToDatabase => "to-db",
        };
        return Ok(CommandResult::text(SkillSyncOutput {
            direction: format!("{} (dry-run)", direction_str),
            synced: 0,
            skipped: 0,
            deleted: 0,
            errors: vec![],
        })
        .with_title("Skills Sync (Dry Run)"));
    }

    if !args.yes && config.is_interactive() {
        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
            .with_prompt("Proceed with sync?")
            .default(false)
            .interact()
            .context("Failed to get confirmation")?;

        if !confirmed {
            CliService::info("Sync cancelled");
            return Ok(CommandResult::text(SkillSyncOutput {
                direction: "cancelled".to_string(),
                synced: 0,
                skipped: 0,
                deleted: 0,
                errors: vec![],
            })
            .with_title("Skills Sync"));
        }
    }

    let spinner = CliService::spinner("Syncing skills...");
    let result = match direction {
        LocalSyncDirection::ToDisk => sync.sync_to_disk(&diff, args.delete_orphans).await?,
        LocalSyncDirection::ToDatabase => {
            let services_config = ConfigLoader::load().context("Failed to load services config")?;
            sync.sync_to_db(&diff, &services_config.skills, args.delete_orphans)
                .await?
        },
    };
    spinner.finish_and_clear();

    CliService::section("Sync Complete");
    CliService::key_value("Direction", &result.direction.to_string());
    CliService::key_value("Synced", &result.items_synced.to_string());
    CliService::key_value("Deleted", &result.items_deleted.to_string());
    CliService::key_value("Skipped", &result.items_skipped.to_string());

    if !result.errors.is_empty() {
        CliService::warning(&format!("Errors ({})", result.errors.len()));
        for error in &result.errors {
            CliService::error(&format!("  {}", error));
        }
    }

    let output = SkillSyncOutput {
        direction: result.direction.to_string(),
        synced: result.items_synced,
        skipped: result.items_skipped,
        deleted: result.items_deleted,
        errors: result.errors,
    };

    Ok(CommandResult::text(output).with_title("Skills Sync"))
}

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()))
}

async fn create_db_provider() -> Result<DbPool> {
    let url = SecretsBootstrap::database_url()
        .context("Database URL not configured")?
        .to_string();

    let write_url = SecretsBootstrap::database_write_url()
        .ok()
        .flatten()
        .map(str::to_string);

    let database = Database::from_config_with_write("postgres", &url, write_url.as_deref())
        .await
        .context("Failed to connect to database")?;

    Ok(Arc::new(database))
}

fn display_diff_summary(diff: &SkillsDiffResult) {
    CliService::section("Skills Status");
    CliService::info(&format!("{} unchanged", diff.unchanged));

    if !diff.added.is_empty() {
        CliService::info(&format!("+ {} (on disk, not in DB)", diff.added.len()));
        for item in &diff.added {
            let name = item.name.as_deref().unwrap_or("unnamed");
            CliService::info(&format!("    + {} ({})", item.skill_id, name));
        }
    }

    if !diff.removed.is_empty() {
        CliService::info(&format!("- {} (in DB, not on disk)", diff.removed.len()));
        for item in &diff.removed {
            let name = item.name.as_deref().unwrap_or("unnamed");
            CliService::info(&format!("    - {} ({})", item.skill_id, name));
        }
    }

    if !diff.modified.is_empty() {
        CliService::info(&format!("~ {} (modified)", diff.modified.len()));
        for item in &diff.modified {
            let name = item.name.as_deref().unwrap_or("unnamed");
            CliService::info(&format!("    ~ {} ({})", item.skill_id, name));
        }
    }
}

fn prompt_sync_direction() -> Result<Option<LocalSyncDirection>> {
    let options = vec![
        "Sync to database (Disk -> DB)",
        "Sync to disk (DB -> Disk)",
        "Cancel",
    ];

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Choose sync direction")
        .items(&options)
        .default(0)
        .interact()
        .context("Failed to get direction selection")?;

    match selection {
        0 => Ok(Some(LocalSyncDirection::ToDatabase)),
        1 => Ok(Some(LocalSyncDirection::ToDisk)),
        _ => Ok(None),
    }
}