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
use anyhow::{Context, Result, anyhow};
use clap::Args;
use dialoguer::Input;
use dialoguer::theme::ColorfulTheme;
use std::fs;
use std::path::Path;

use super::shared::AgentArgs;
use super::types::AgentCreateOutput;
use crate::CliConfig;
use crate::interactive::resolve_required;
use crate::shared::CommandResult;
use systemprompt_loader::{ConfigLoader, ConfigWriter};
use systemprompt_logging::CliService;
use systemprompt_models::modules::ApiPaths;
use systemprompt_models::profile_bootstrap::ProfileBootstrap;
use systemprompt_models::services::{
    AgentCardConfig, AgentConfig, AgentMetadataConfig, CapabilitiesConfig, OAuthConfig,
};

#[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,
}

pub fn execute(args: CreateArgs, config: &CliConfig) -> Result<CommandResult<AgentCreateOutput>> {
    let name = resolve_required(args.name, "name", config, prompt_name)?;

    validate_agent_name(&name)?;

    let port = resolve_required(args.agent.port, "port", config, prompt_port)?;

    validate_port(port)?;

    let display_name = args.agent.display_name.unwrap_or_else(|| {
        if config.is_interactive() {
            prompt_display_name(&name).unwrap_or_else(|_| name.clone())
        } else {
            name.clone()
        }
    });

    let description = args.agent.description.unwrap_or_else(|| {
        if config.is_interactive() {
            prompt_description().unwrap_or_else(|e| {
                tracing::warn!(error = %e, "Failed to prompt for description");
                String::new()
            })
        } else {
            String::new()
        }
    });

    let system_prompt = if let Some(file_path) = &args.agent.system_prompt_file {
        let content = fs::read_to_string(file_path)
            .with_context(|| format!("Failed to read system prompt file: {}", file_path))?;
        Some(content)
    } else if let Some(prompt) = args.agent.system_prompt.clone() {
        Some(prompt)
    } else {
        let default_prompt = if description.is_empty() {
            format!("You are {}.", display_name)
        } else {
            format!("You are {}. {}", display_name, description)
        };
        Some(default_prompt)
    };

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

    let agent_config = AgentConfig {
        name: name.clone(),
        port,
        endpoint: args
            .agent
            .endpoint
            .unwrap_or_else(|| ApiPaths::agent_endpoint(&name)),
        enabled: args.enabled,
        dev_only: args.agent.dev_only,
        is_primary: args.agent.is_primary,
        default: args.agent.default,
        tags: Vec::new(),
        card: AgentCardConfig {
            protocol_version: "0.3.0".to_string(),
            name: Some(name.clone()),
            display_name,
            description,
            version: args.agent.version.unwrap_or_else(|| "1.0.0".to_string()),
            preferred_transport: "JSONRPC".to_string(),
            icon_url: args.agent.icon_url,
            documentation_url: args.agent.documentation_url,
            provider: None,
            capabilities: CapabilitiesConfig {
                streaming: args.agent.streaming.unwrap_or(true),
                push_notifications: args.agent.push_notifications.unwrap_or(false),
                state_transition_history: args.agent.state_transition_history.unwrap_or(true),
            },
            default_input_modes: vec!["text/plain".to_string()],
            default_output_modes: vec!["text/plain".to_string()],
            security_schemes: None,
            security: None,
            skills: vec![],
            supports_authenticated_extended_card: false,
        },
        metadata: AgentMetadataConfig {
            system_prompt,
            mcp_servers: args.agent.mcp_servers,
            skills: args.agent.skills,
            provider: Some(
                args.agent
                    .provider
                    .unwrap_or_else(|| "anthropic".to_string()),
            ),
            model: Some(
                args.agent
                    .model
                    .unwrap_or_else(|| "claude-3-5-sonnet-20241022".to_string()),
            ),
            ..Default::default()
        },
        oauth: OAuthConfig::default(),
    };

    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
    let services_dir = Path::new(&profile.paths.services);

    let agent_file = ConfigWriter::create_agent(&agent_config, services_dir)
        .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()
        )
    })?;

    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(CommandResult::text(output).with_title("Agent Created"))
}

fn validate_agent_name(name: &str) -> Result<()> {
    if name.len() < 3 || name.len() > 50 {
        return Err(anyhow!("Agent name must be between 3 and 50 characters"));
    }

    if !name
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
    {
        return Err(anyhow!(
            "Agent name must be lowercase alphanumeric with underscores only"
        ));
    }

    Ok(())
}

fn validate_port(port: u16) -> Result<()> {
    if port == 0 {
        return Err(anyhow!("Port cannot be 0"));
    }
    if port < 1024 {
        return Err(anyhow!("Port must be >= 1024 (non-privileged)"));
    }
    Ok(())
}

fn prompt_name() -> Result<String> {
    Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Agent name")
        .validate_with(|input: &String| -> Result<(), &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(())
        })
        .interact_text()
        .context("Failed to get agent name")
}

fn prompt_port() -> Result<u16> {
    Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Port")
        .default(8001u16)
        .validate_with(|input: &u16| -> Result<(), &str> {
            if *input == 0 {
                return Err("Port cannot be 0");
            }
            if *input < 1024 {
                return Err("Port should be >= 1024 (non-privileged)");
            }
            Ok(())
        })
        .interact()
        .context("Failed to get port")
}

fn prompt_display_name(default: &str) -> Result<String> {
    Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Display name")
        .default(default.to_string())
        .interact_text()
        .context("Failed to get display name")
}

fn prompt_description() -> Result<String> {
    Input::with_theme(&ColorfulTheme::default())
        .with_prompt("Description")
        .allow_empty(true)
        .interact_text()
        .context("Failed to get description")
}