systemprompt-cli 0.17.1

Unified CLI for systemprompt.io AI governance: agent orchestration, MCP governance, analytics, profiles, cloud deploy, and self-hosted operations.
Documentation
use crate::cli_settings::CliConfig;
use crate::shared::CommandOutput;
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use systemprompt_logging::CliService;
use systemprompt_mcp::services::McpOrchestrator;
use systemprompt_mcp::{HealthStatus, McpServiceStatus};
use systemprompt_models::mcp::McpServerType;
use systemprompt_runtime::{AppContext, StartupValidator, display_validation_report};
use systemprompt_scheduler::{
    RuntimeStatus, ServiceStateVerifier, ServiceType, VerifiedServiceState,
};

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub(super) struct ServiceStatusOutput {
    pub services: Vec<ServiceStatusRow>,
    pub summary: StatusSummary,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub(super) struct ServiceStatusRow {
    pub name: String,
    pub service_type: String,
    pub status: String,
    pub pid: Option<u32>,
    pub port: u16,
    pub action: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub health: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub endpoint: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub(super) struct StatusSummary {
    pub total: usize,
    pub running: usize,
    pub stopped: usize,
}

impl From<&VerifiedServiceState> for ServiceStatusRow {
    fn from(state: &VerifiedServiceState) -> Self {
        Self {
            name: state.name.clone(),
            service_type: state.service_type.to_string(),
            status: state.status_display().to_owned(),
            pid: state.pid,
            port: state.port,
            action: state.action_display().to_owned(),
            error: state.error.clone(),
            health: None,
            endpoint: None,
        }
    }
}

fn health_label(health: HealthStatus) -> String {
    if matches!(health, HealthStatus::Healthy | HealthStatus::Degraded) {
        "OK".to_owned()
    } else {
        "DEGRADED".to_owned()
    }
}

fn external_row(status: &McpServiceStatus) -> ServiceStatusRow {
    ServiceStatusRow {
        name: status.name.clone(),
        service_type: "mcp".to_owned(),
        status: "remote".to_owned(),
        pid: None,
        port: 0,
        action: "none".to_owned(),
        error: None,
        health: Some(health_label(status.health)),
        endpoint: status.endpoint.clone(),
    }
}

fn build_status_output(
    states: &[VerifiedServiceState],
    mcp_health: &HashMap<String, HealthStatus>,
    external: &[ServiceStatusRow],
    include_health: bool,
) -> ServiceStatusOutput {
    let mut services: Vec<ServiceStatusRow> = states
        .iter()
        .map(|state| {
            let mut row = ServiceStatusRow::from(state);
            if include_health {
                row.health = Some(managed_health_label(state, mcp_health));
            }
            row
        })
        .collect();

    services.extend(external.iter().cloned());

    let managed_running = states
        .iter()
        .filter(|s| s.runtime_status == RuntimeStatus::Running)
        .count();
    let external_running = external
        .iter()
        .filter(|r| r.health.as_deref() == Some("OK"))
        .count();
    let running = managed_running + external_running;
    let total = states.len() + external.len();

    ServiceStatusOutput {
        services,
        summary: StatusSummary {
            total,
            running,
            stopped: total - running,
        },
    }
}

fn managed_health_label(
    state: &VerifiedServiceState,
    mcp_health: &HashMap<String, HealthStatus>,
) -> String {
    if state.service_type == ServiceType::Mcp {
        return mcp_health
            .get(&state.name)
            .map_or_else(|| "DEGRADED".to_owned(), |h| health_label(*h));
    }
    if state.is_healthy() {
        "OK".to_owned()
    } else {
        "DEGRADED".to_owned()
    }
}

pub(super) async fn execute(
    detailed: bool,
    json: bool,
    health: bool,
    config: &CliConfig,
) -> Result<CommandOutput> {
    let ctx = Arc::new(AppContext::new().await?);

    let Ok(configs) = super::load_service_configs() else {
        let mut validator = StartupValidator::new();
        let report = validator.validate(ctx.config());
        if report.has_errors() {
            display_validation_report(&report);
            return Err(anyhow::anyhow!("Startup validation failed"));
        }
        return Err(anyhow::anyhow!("Failed to load service configs"));
    };

    let state_manager = ServiceStateVerifier::new(Arc::clone(ctx.db_pool()));
    let states = state_manager.get_verified_states(&configs).await?;

    let mcp_statuses = mcp_service_statuses(&ctx).await?;
    let mcp_health: HashMap<String, HealthStatus> = mcp_statuses
        .iter()
        .filter(|s| s.server_type == McpServerType::Internal)
        .map(|s| (s.name.clone(), s.health))
        .collect();
    let external: Vec<ServiceStatusRow> = mcp_statuses
        .iter()
        .filter(|s| s.server_type == McpServerType::External)
        .map(external_row)
        .collect();

    let output = build_status_output(&states, &mcp_health, &external, health);

    let result = CommandOutput::table_of(
        vec![
            "name",
            "service_type",
            "status",
            "pid",
            "endpoint",
            "action",
        ],
        &output.services,
    )
    .with_title("Service Status");

    if json || config.is_json_output() {
        return Ok(result);
    }

    if detailed {
        output_detailed(&states, &mcp_health, &external, health);
    } else {
        render_table_output(&output);
    }

    Ok(result.with_skip_render())
}

async fn mcp_service_statuses(ctx: &Arc<AppContext>) -> Result<Vec<McpServiceStatus>> {
    let manager = McpOrchestrator::new(
        Arc::clone(ctx.db_pool()),
        Arc::clone(ctx.app_paths_arc()),
        ctx.mcp_registry().clone(),
    )?;
    Ok(manager.service_statuses().await?)
}

fn render_table_output(output: &ServiceStatusOutput) {
    CliService::section("Service Status");

    for service in &output.services {
        let pid_str = service
            .pid
            .map_or_else(|| "-".to_owned(), |p| p.to_string());
        let locator = service
            .endpoint
            .as_deref()
            .map_or_else(|| format!("PID: {pid_str}"), |e| format!("endpoint: {e}"));
        CliService::key_value(
            &service.name,
            &format!(
                "{} | {} | {} | {}",
                service.service_type, service.status, locator, service.action
            ),
        );
    }

    CliService::info(&format!(
        "{}/{} services running",
        output.summary.running, output.summary.total
    ));
}

fn output_detailed(
    states: &[VerifiedServiceState],
    mcp_health: &HashMap<String, HealthStatus>,
    external: &[ServiceStatusRow],
    include_health: bool,
) {
    for state in states {
        CliService::section(&state.name);
        CliService::key_value("Type", &state.service_type.to_string());
        CliService::key_value("Status", state.status_display());
        CliService::key_value("Port", &state.port.to_string());

        if let Some(pid) = state.pid {
            CliService::key_value("PID", &pid.to_string());
        }

        CliService::key_value("Desired", &format!("{:?}", state.desired_status));
        CliService::key_value("Action", state.action_display());

        if let Some(error) = &state.error {
            CliService::error(&format!("Error: {}", error));
        }

        if include_health {
            CliService::key_value("Health", &managed_health_label(state, mcp_health));
        }
    }

    for row in external {
        CliService::section(&row.name);
        CliService::key_value("Type", &row.service_type);
        CliService::key_value("Status", &row.status);
        if let Some(endpoint) = &row.endpoint {
            CliService::key_value("Endpoint", endpoint);
        }
        if let Some(health) = &row.health {
            CliService::key_value("Health", health);
        }
    }
}