systemprompt-cli 0.2.1

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
mod cancel;
mod create;
mod delete;
mod docker;
mod edit;
mod list;
mod rotate;
mod select;
mod show;
mod validation;

pub use cancel::cancel_subscription;
pub use create::{
    create_cloud_tenant, create_external_tenant, create_local_tenant, swap_to_external_host,
};
pub use delete::delete_tenant;
pub use docker::wait_for_postgres_healthy;
pub use edit::edit_tenant;
pub use list::list_tenants;
pub use rotate::{rotate_credentials, rotate_sync_token};
pub use select::{get_credentials, resolve_tenant_id};
pub use show::show_tenant;
pub use validation::{check_build_ready, find_services_config};

use anyhow::Result;
use clap::{Args, Subcommand};
use dialoguer::Select;
use dialoguer::theme::ColorfulTheme;
use systemprompt_cloud::{CloudPath, TenantStore, get_cloud_paths};
use systemprompt_logging::CliService;

use crate::cli_settings::CliConfig;
use crate::shared::render_result;

#[derive(Debug, Subcommand)]
pub enum TenantCommands {
    #[command(about = "Create a new tenant (local or cloud)")]
    Create {
        #[arg(long, default_value = "iad")]
        region: String,
    },

    #[command(
        about = "List all tenants",
        after_help = "EXAMPLES:\n  systemprompt cloud tenant list\n  systemprompt cloud tenant \
                      list --json"
    )]
    List,

    #[command(about = "Show tenant details")]
    Show { id: Option<String> },

    #[command(about = "Delete a tenant")]
    Delete(TenantDeleteArgs),

    #[command(about = "Edit tenant configuration")]
    Edit { id: Option<String> },

    #[command(about = "Rotate database credentials")]
    RotateCredentials(TenantRotateArgs),

    #[command(about = "Rotate sync token")]
    RotateSyncToken(TenantRotateArgs),

    #[command(about = "Cancel subscription and destroy tenant (IRREVERSIBLE)")]
    Cancel(TenantCancelArgs),
}

#[derive(Debug, Args)]
pub struct TenantRotateArgs {
    pub id: Option<String>,

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

#[derive(Debug, Args)]
pub struct TenantDeleteArgs {
    pub id: Option<String>,

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

#[derive(Debug, Args)]
pub struct TenantCancelArgs {
    pub id: Option<String>,
}

pub async fn execute(cmd: Option<TenantCommands>, config: &CliConfig) -> Result<()> {
    if let Some(cmd) = cmd {
        execute_command(cmd, config).await.map(drop)
    } else {
        if !config.is_interactive() {
            return Err(anyhow::anyhow!(
                "Tenant subcommand required in non-interactive mode"
            ));
        }
        while let Some(cmd) = select_operation()? {
            if execute_command(cmd, config).await? {
                break;
            }
        }
        Ok(())
    }
}

async fn execute_command(cmd: TenantCommands, config: &CliConfig) -> Result<bool> {
    match cmd {
        TenantCommands::Create { region } => tenant_create(&region, config).await.map(|()| true),
        TenantCommands::List => {
            let result = list_tenants(config).await?;
            render_result(&result);
            Ok(false)
        },
        TenantCommands::Show { id } => {
            let result = show_tenant(id.as_ref(), config)?;
            render_result(&result);
            Ok(false)
        },
        TenantCommands::Delete(args) => {
            let result = delete_tenant(args, config).await?;
            render_result(&result);
            Ok(false)
        },
        TenantCommands::Edit { id } => {
            let result = edit_tenant(id, config)?;
            render_result(&result);
            Ok(false)
        },
        TenantCommands::RotateCredentials(args) => {
            let result =
                rotate_credentials(args.id, args.yes || !config.is_interactive(), config).await?;
            render_result(&result);
            Ok(false)
        },
        TenantCommands::RotateSyncToken(args) => {
            let result =
                rotate_sync_token(args.id, args.yes || !config.is_interactive(), config).await?;
            render_result(&result);
            Ok(false)
        },
        TenantCommands::Cancel(args) => {
            let result = cancel_subscription(args, config).await?;
            render_result(&result);
            Ok(false)
        },
    }
}

fn select_operation() -> Result<Option<TenantCommands>> {
    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 has_tenants = !store.tenants.is_empty();

    let edit_label = if has_tenants {
        "Edit".to_string()
    } else {
        "Edit (unavailable - no tenants configured)".to_string()
    };
    let delete_label = if has_tenants {
        "Delete".to_string()
    } else {
        "Delete (unavailable - no tenants configured)".to_string()
    };

    let operations = vec![
        "Create".to_string(),
        "List".to_string(),
        edit_label,
        delete_label,
        "Done".to_string(),
    ];

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Tenant operation")
        .items(&operations)
        .default(0)
        .interact()?;

    let cmd = match selection {
        0 => Some(TenantCommands::Create {
            region: "iad".to_string(),
        }),
        1 => Some(TenantCommands::List),
        2 | 3 if !has_tenants => {
            CliService::warning("No tenants configured");
            CliService::info(
                "Run 'systemprompt cloud tenant create' (or 'just tenant') to create one.",
            );
            return Ok(Some(TenantCommands::List));
        },
        2 => Some(TenantCommands::Edit { id: None }),
        3 => Some(TenantCommands::Delete(TenantDeleteArgs {
            id: None,
            yes: false,
        })),
        4 => None,
        _ => unreachable!(),
    };

    Ok(cmd)
}

async fn tenant_create(default_region: &str, config: &CliConfig) -> Result<()> {
    if !config.is_interactive() {
        return Err(anyhow::anyhow!(
            "Tenant creation requires interactive mode.\nUse specific tenant type commands in \
             non-interactive mode (not yet implemented)."
        ));
    }

    CliService::section("Create Tenant");

    let creds = get_credentials()?;

    let build_result = check_build_ready();
    let cloud_option = match &build_result {
        Ok(()) => "Cloud (requires subscription at systemprompt.io)".to_string(),
        Err(_) => "Cloud (unavailable - release build required)".to_string(),
    };

    let options = vec![
        "Local (creates PostgreSQL container automatically)".to_string(),
        cloud_option,
    ];

    let selection = Select::with_theme(&ColorfulTheme::default())
        .with_prompt("Tenant type")
        .items(&options)
        .default(0)
        .interact()?;

    let tenant = match selection {
        0 => {
            let db_options = vec![
                "Docker (creates PostgreSQL container automatically)",
                "External PostgreSQL (use your own database)",
            ];

            let db_selection = Select::with_theme(&ColorfulTheme::default())
                .with_prompt("Database source")
                .items(&db_options)
                .default(0)
                .interact()?;

            match db_selection {
                0 => create_local_tenant().await?,
                _ => create_external_tenant().await?,
            }
        },
        _ if build_result.is_err() => {
            CliService::warning("Cloud tenant creation requires a release build.");
            CliService::info("");
            CliService::info("Run the following command to build:");
            CliService::info("  cargo build --release --workspace");
            CliService::info("");
            if let Err(err) = &build_result {
                CliService::info("Specific issue:");
                CliService::error(err);
            }
            return Ok(());
        },
        _ => create_cloud_tenant(&creds, default_region).await?,
    };

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

    if let Some(existing) = store.tenants.iter_mut().find(|t| t.id == tenant.id) {
        *existing = tenant.clone();
    } else {
        store.tenants.push(tenant.clone());
    }
    store.save_to_path(&tenants_path)?;

    CliService::success("Tenant created");
    CliService::key_value("ID", &tenant.id);
    CliService::key_value("Name", &tenant.name);
    CliService::key_value("Type", &format!("{:?}", tenant.tenant_type));

    if let Some(ref url) = tenant.database_url {
        if !url.is_empty() {
            CliService::key_value("Database URL", url);
        }
    }

    Ok(())
}