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 std::path::PathBuf;

use anyhow::{Context, Result, bail};
use dialoguer::Select;
use dialoguer::theme::ColorfulTheme;
use systemprompt_cloud::{ProfilePath, ProjectContext};
use systemprompt_loader::ProfileLoader;
use systemprompt_logging::CliService;
use systemprompt_models::Profile;

use crate::cli_settings::CliConfig;

use super::{SkillsSyncArgs, skills};

#[derive(Debug, Clone, Copy)]
pub enum SyncType {
    Push,
    Pull,
    LocalSkills,
}

pub struct ProfileSelection {
    pub name: String,
    pub path: PathBuf,
    pub profile: Profile,
}

pub async fn execute(config: &CliConfig) -> Result<()> {
    CliService::section("Sync Menu");

    let sync_type = select_sync_type()?;

    let source = select_profile("Select source profile")?;

    match sync_type {
        SyncType::Push | SyncType::Pull => execute_cloud_sync(sync_type, &source).await,
        SyncType::LocalSkills => execute_local_skills_sync(&source, config).await,
    }
}

fn select_sync_type() -> Result<SyncType> {
    let options = vec![
        "Push to cloud (Local → Cloud)",
        "Pull from cloud (Cloud → Local)",
        "Sync skills (Disk ↔ Database)",
    ];

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

    match selection {
        0 => Ok(SyncType::Push),
        1 => Ok(SyncType::Pull),
        2 => Ok(SyncType::LocalSkills),
        _ => bail!("Invalid selection"),
    }
}

fn select_profile(prompt: &str) -> Result<ProfileSelection> {
    let profiles = discover_profiles()?;

    if profiles.is_empty() {
        bail!(
            "No profiles found.\nCreate a profile with: systemprompt cloud profile create <name>"
        );
    }

    let options: Vec<String> = profiles
        .iter()
        .map(|p| {
            let cloud_status = if p.profile.cloud.is_some() {
                "cloud"
            } else {
                "local"
            };
            format!("{} ({})", p.name, cloud_status)
        })
        .collect();

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt(prompt)
        .items(&options)
        .default(0)
        .interact()
        .context("Failed to select profile")?;

    profiles
        .into_iter()
        .nth(selection)
        .ok_or_else(|| anyhow::anyhow!("Invalid selection index"))
}

fn discover_profiles() -> Result<Vec<ProfileSelection>> {
    let ctx = ProjectContext::discover();
    let profiles_dir = ctx.profiles_dir();

    if !profiles_dir.exists() {
        return Ok(Vec::new());
    }

    let entries = std::fs::read_dir(&profiles_dir).with_context(|| {
        format!(
            "Failed to read profiles directory: {}",
            profiles_dir.display()
        )
    })?;

    let profiles: Vec<ProfileSelection> = entries
        .filter_map(std::result::Result::ok)
        .filter(|e| e.path().is_dir())
        .filter_map(|entry| {
            let profile_yaml = ProfilePath::Config.resolve(&entry.path());
            if !profile_yaml.exists() {
                return None;
            }

            let name = entry.file_name().to_string_lossy().to_string();
            let profile = ProfileLoader::load_from_path(&profile_yaml).ok()?;

            Some(ProfileSelection {
                name,
                path: profile_yaml,
                profile,
            })
        })
        .collect();

    Ok(profiles)
}

async fn execute_cloud_sync(sync_type: SyncType, source: &ProfileSelection) -> Result<()> {
    use systemprompt_cloud::{CloudPath, CredentialsBootstrap, TenantStore, get_cloud_paths};
    use systemprompt_sync::{SyncConfig, SyncDirection, SyncOperationResult, SyncService};

    let creds = CredentialsBootstrap::require()
        .context("Cloud sync requires credentials. Run 'systemprompt cloud auth login'")?;

    let cloud = source
        .profile
        .cloud
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("Profile has no cloud configuration"))?;

    let tenant_id = cloud
        .tenant_id
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("No tenant configured in profile"))?;

    let cloud_paths = get_cloud_paths();
    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
    let store = TenantStore::load_from_path(&tenants_path).unwrap_or_else(|e| {
        CliService::warning(&format!("Failed to load tenant store: {}", e));
        TenantStore::default()
    });
    let tenant = store.find_tenant(tenant_id);

    let (hostname, sync_token) =
        tenant.map_or((None, None), |t| (t.hostname.clone(), t.sync_token.clone()));

    let direction = match sync_type {
        SyncType::Push => SyncDirection::Push,
        SyncType::Pull => SyncDirection::Pull,
        SyncType::LocalSkills => bail!("Invalid sync type for cloud sync"),
    };

    let config = SyncConfig {
        direction,
        dry_run: false,
        verbose: false,
        tenant_id: systemprompt_identifiers::TenantId::new(tenant_id),
        api_url: creds.api_url.clone(),
        api_token: creds.api_token.clone(),
        services_path: source.profile.paths.services.clone(),
        hostname,
        sync_token,
        local_database_url: None,
    };

    let dir_label = match direction {
        SyncDirection::Push => "Local → Cloud",
        SyncDirection::Pull => "Cloud → Local",
    };
    CliService::key_value("Direction", dir_label);

    let service = SyncService::new(config)?;
    let mut results: Vec<SyncOperationResult> = Vec::new();

    let spinner = CliService::spinner("Syncing files...");
    let files_result = service.sync_files().await?;
    spinner.finish_and_clear();
    results.push(files_result);

    for result in &results {
        if result.success {
            CliService::success(&format!(
                "{} - Synced {} items",
                result.operation, result.items_synced
            ));
        } else {
            CliService::error(&format!(
                "{} - Failed with {} errors",
                result.operation,
                result.errors.len()
            ));
        }
    }

    Ok(())
}

async fn execute_local_skills_sync(source: &ProfileSelection, config: &CliConfig) -> Result<()> {
    use systemprompt_models::ProfileBootstrap;

    ProfileBootstrap::init_from_path(&source.path)
        .context("Failed to initialize profile for skills sync")?;

    let args = SkillsSyncArgs {
        direction: None,
        database_url: None,
        skill: None,
        dry_run: false,
        delete_orphans: false,
        yes: false,
    };

    skills::execute(args, config).await
}