systemprompt-cli 0.20.0

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

use super::shared::AgentArgs;
use super::types::AgentCreateOutput;
use crate::CliConfig;
use crate::interactive::{Prompter, resolve_required};
use crate::shared::CommandOutput;
use systemprompt_agent::services::config_authoring::{
    AgentConfigAuthoringService, AgentCreateRequest,
};
use systemprompt_config::ProfileBootstrap;
use systemprompt_loader::ConfigLoader;
use systemprompt_logging::CliService;

#[derive(Debug, Args)]
pub struct CreateArgs {
    #[arg(long, help = "Agent name")]
    pub name: Option<String>,

    #[arg(long, help = "Enable the agent after creation")]
    pub enabled: bool,

    #[command(flatten)]
    pub agent: AgentArgs,
}

struct ResolvedAgentInput {
    name: String,
    port: u16,
    display_name: String,
    description: String,
    system_prompt: String,
}

pub(super) fn execute(
    args: CreateArgs,
    prompter: &dyn Prompter,
    config: &CliConfig,
) -> Result<CommandOutput> {
    let mut agent_args = args.agent;

    let name = resolve_required(args.name, "name", config, || prompt_name(prompter))?;
    AgentConfigAuthoringService::validate_agent_name(&name)?;

    let port = resolve_required(agent_args.port, "port", config, || prompt_port(prompter))?;
    AgentConfigAuthoringService::validate_port(port)?;

    let display_name =
        resolve_display_name(agent_args.display_name.take(), &name, prompter, config);
    let description = resolve_description(agent_args.description.take(), prompter, config);
    let system_prompt = AgentConfigAuthoringService::resolve_system_prompt(
        agent_args.system_prompt_file.as_deref(),
        agent_args.system_prompt.take(),
        &display_name,
        &description,
    )?;

    CliService::info(&format!(
        "Creating agent '{}' on port {} (display: {})...",
        name, port, display_name
    ));

    let input = ResolvedAgentInput {
        name: name.clone(),
        port,
        display_name,
        description,
        system_prompt,
    };
    let request = build_create_request(input, args.enabled, agent_args);

    let agent_file = write_agent_config(request)?;

    CliService::success(&format!(
        "Agent '{}' created at {}",
        name,
        agent_file.display()
    ));

    let output = AgentCreateOutput {
        name: name.clone(),
        message: format!(
            "Agent '{}' created successfully at {}",
            name,
            agent_file.display()
        ),
    };

    Ok(CommandOutput::card_value("Agent Created", &output))
}

fn resolve_display_name(
    arg: Option<String>,
    name: &str,
    prompter: &dyn Prompter,
    config: &CliConfig,
) -> String {
    arg.unwrap_or_else(|| {
        if config.is_interactive() {
            prompt_display_name(prompter, name).unwrap_or_else(|_| name.to_owned())
        } else {
            name.to_owned()
        }
    })
}

fn resolve_description(arg: Option<String>, prompter: &dyn Prompter, config: &CliConfig) -> String {
    arg.unwrap_or_else(|| {
        if config.is_interactive() {
            prompt_description(prompter).unwrap_or_else(|e| {
                tracing::warn!(error = %e, "Failed to prompt for description");
                String::new()
            })
        } else {
            String::new()
        }
    })
}

fn build_create_request(
    input: ResolvedAgentInput,
    enabled: bool,
    agent: AgentArgs,
) -> AgentCreateRequest {
    AgentCreateRequest {
        name: input.name,
        port: input.port,
        display_name: input.display_name,
        description: input.description,
        system_prompt: input.system_prompt,
        enabled,
        endpoint: agent.endpoint,
        dev_only: agent.dev_only,
        is_primary: agent.is_primary,
        default: agent.default,
        version: agent.version,
        icon_url: agent.icon_url,
        documentation_url: agent.documentation_url,
        streaming: agent.streaming,
        push_notifications: agent.push_notifications,
        state_transition_history: agent.state_transition_history,
        provider: agent.provider,
        model: agent.model,
        mcp_servers: agent.mcp_servers,
        skills: agent.skills,
    }
}

fn write_agent_config(request: AgentCreateRequest) -> Result<std::path::PathBuf> {
    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
    let services_dir = Path::new(&profile.paths.services);
    let name = request.name.clone();

    let service = AgentConfigAuthoringService::new(services_dir);
    let agent_file = service
        .create(request)
        .with_context(|| format!("Failed to create agent '{}'", name))?;

    ConfigLoader::load().with_context(|| {
        format!(
            "Agent file created at {} but validation failed. Please check the configuration.",
            agent_file.display()
        )
    })?;

    Ok(agent_file)
}

pub fn validate_name_input(input: &str) -> Result<(), &'static str> {
    if input.len() < 3 {
        return Err("Name must be at least 3 characters");
    }
    if !input
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
    {
        return Err("Name must be lowercase alphanumeric with underscores only");
    }
    Ok(())
}

pub const fn validate_port_input(port: u16) -> Result<(), &'static str> {
    if port == 0 {
        return Err("Port cannot be 0");
    }
    if port < 1024 {
        return Err("Port should be >= 1024 (non-privileged)");
    }
    Ok(())
}

pub fn prompt_name(prompter: &dyn Prompter) -> Result<String> {
    loop {
        let input = prompter
            .input("Agent name")
            .context("Failed to get agent name")?;
        match validate_name_input(&input) {
            Ok(()) => return Ok(input),
            Err(msg) => CliService::warning(msg),
        }
    }
}

pub fn prompt_port(prompter: &dyn Prompter) -> Result<u16> {
    loop {
        let raw = prompter
            .input_with_default("Port", "8001")
            .context("Failed to get port")?;
        let port: u16 = match raw.trim().parse() {
            Ok(port) => port,
            Err(e) => {
                CliService::warning(&format!("'{}' is not a valid port: {}", raw.trim(), e));
                continue;
            },
        };
        match validate_port_input(port) {
            Ok(()) => return Ok(port),
            Err(msg) => CliService::warning(msg),
        }
    }
}

fn prompt_display_name(prompter: &dyn Prompter, default: &str) -> Result<String> {
    prompter
        .input_with_default("Display name", default)
        .context("Failed to get display name")
}

fn prompt_description(prompter: &dyn Prompter) -> Result<String> {
    prompter
        .input_with_default("Description", "")
        .context("Failed to get description")
}