Skip to main content

systemprompt_cli/commands/admin/agents/
create.rs

1//! `admin agents create` command with prompted inputs.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use anyhow::{Context, Result};
7use clap::Args;
8use std::path::Path;
9
10use super::shared::AgentArgs;
11use super::types::AgentCreateOutput;
12use crate::CliConfig;
13use crate::interactive::{Prompter, resolve_required};
14use crate::shared::CommandOutput;
15use systemprompt_agent::services::config_authoring::{
16    AgentConfigAuthoringService, AgentCreateRequest,
17};
18use systemprompt_config::ProfileBootstrap;
19use systemprompt_loader::ConfigLoader;
20use systemprompt_logging::CliService;
21
22#[derive(Debug, Args)]
23pub struct CreateArgs {
24    #[arg(long, help = "Agent name")]
25    pub name: Option<String>,
26
27    #[arg(long, help = "Enable the agent after creation")]
28    pub enabled: bool,
29
30    #[command(flatten)]
31    pub agent: AgentArgs,
32}
33
34struct ResolvedAgentInput {
35    name: String,
36    port: u16,
37    display_name: String,
38    description: String,
39    system_prompt: String,
40}
41
42pub(super) fn execute(
43    args: CreateArgs,
44    prompter: &dyn Prompter,
45    config: &CliConfig,
46) -> Result<CommandOutput> {
47    let mut agent_args = args.agent;
48
49    let name = resolve_required(args.name, "name", config, || prompt_name(prompter))?;
50    AgentConfigAuthoringService::validate_agent_name(&name)?;
51
52    let port = resolve_required(agent_args.port, "port", config, || prompt_port(prompter))?;
53    AgentConfigAuthoringService::validate_port(port)?;
54
55    let display_name =
56        resolve_display_name(agent_args.display_name.take(), &name, prompter, config);
57    let description = resolve_description(agent_args.description.take(), prompter, config);
58    let system_prompt = AgentConfigAuthoringService::resolve_system_prompt(
59        agent_args.system_prompt_file.as_deref(),
60        agent_args.system_prompt.take(),
61        &display_name,
62        &description,
63    )?;
64
65    CliService::info(&format!(
66        "Creating agent '{}' on port {} (display: {})...",
67        name, port, display_name
68    ));
69
70    let input = ResolvedAgentInput {
71        name: name.clone(),
72        port,
73        display_name,
74        description,
75        system_prompt,
76    };
77    let request = build_create_request(input, args.enabled, agent_args);
78
79    let agent_file = write_agent_config(request)?;
80
81    CliService::success(&format!(
82        "Agent '{}' created at {}",
83        name,
84        agent_file.display()
85    ));
86
87    let output = AgentCreateOutput {
88        name: name.clone(),
89        message: format!(
90            "Agent '{}' created successfully at {}",
91            name,
92            agent_file.display()
93        ),
94    };
95
96    Ok(CommandOutput::card_value("Agent Created", &output))
97}
98
99fn resolve_display_name(
100    arg: Option<String>,
101    name: &str,
102    prompter: &dyn Prompter,
103    config: &CliConfig,
104) -> String {
105    arg.unwrap_or_else(|| {
106        if config.is_interactive() {
107            prompt_display_name(prompter, name).unwrap_or_else(|_| name.to_owned())
108        } else {
109            name.to_owned()
110        }
111    })
112}
113
114fn resolve_description(arg: Option<String>, prompter: &dyn Prompter, config: &CliConfig) -> String {
115    arg.unwrap_or_else(|| {
116        if config.is_interactive() {
117            prompt_description(prompter).unwrap_or_else(|e| {
118                tracing::warn!(error = %e, "Failed to prompt for description");
119                String::new()
120            })
121        } else {
122            String::new()
123        }
124    })
125}
126
127fn build_create_request(
128    input: ResolvedAgentInput,
129    enabled: bool,
130    agent: AgentArgs,
131) -> AgentCreateRequest {
132    AgentCreateRequest {
133        name: input.name,
134        port: input.port,
135        display_name: input.display_name,
136        description: input.description,
137        system_prompt: input.system_prompt,
138        enabled,
139        endpoint: agent.endpoint,
140        dev_only: agent.dev_only,
141        is_primary: agent.is_primary,
142        default: agent.default,
143        version: agent.version,
144        icon_url: agent.icon_url,
145        documentation_url: agent.documentation_url,
146        streaming: agent.streaming,
147        push_notifications: agent.push_notifications,
148        state_transition_history: agent.state_transition_history,
149        provider: agent.provider,
150        model: agent.model,
151        mcp_servers: agent.mcp_servers,
152        skills: agent.skills,
153    }
154}
155
156fn write_agent_config(request: AgentCreateRequest) -> Result<std::path::PathBuf> {
157    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
158    let services_dir = Path::new(&profile.paths.services);
159    let name = request.name.clone();
160
161    let service = AgentConfigAuthoringService::new(services_dir);
162    let agent_file = service
163        .create(request)
164        .with_context(|| format!("Failed to create agent '{}'", name))?;
165
166    ConfigLoader::load().with_context(|| {
167        format!(
168            "Agent file created at {} but validation failed. Please check the configuration.",
169            agent_file.display()
170        )
171    })?;
172
173    Ok(agent_file)
174}
175
176pub fn validate_name_input(input: &str) -> Result<(), &'static str> {
177    if input.len() < 3 {
178        return Err("Name must be at least 3 characters");
179    }
180    if !input
181        .chars()
182        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
183    {
184        return Err("Name must be lowercase alphanumeric with underscores only");
185    }
186    Ok(())
187}
188
189pub const fn validate_port_input(port: u16) -> Result<(), &'static str> {
190    if port == 0 {
191        return Err("Port cannot be 0");
192    }
193    if port < 1024 {
194        return Err("Port should be >= 1024 (non-privileged)");
195    }
196    Ok(())
197}
198
199pub fn prompt_name(prompter: &dyn Prompter) -> Result<String> {
200    loop {
201        let input = prompter
202            .input("Agent name")
203            .context("Failed to get agent name")?;
204        match validate_name_input(&input) {
205            Ok(()) => return Ok(input),
206            Err(msg) => CliService::warning(msg),
207        }
208    }
209}
210
211pub fn prompt_port(prompter: &dyn Prompter) -> Result<u16> {
212    loop {
213        let raw = prompter
214            .input_with_default("Port", "8001")
215            .context("Failed to get port")?;
216        let port: u16 = match raw.trim().parse() {
217            Ok(port) => port,
218            Err(e) => {
219                CliService::warning(&format!("'{}' is not a valid port: {}", raw.trim(), e));
220                continue;
221            },
222        };
223        match validate_port_input(port) {
224            Ok(()) => return Ok(port),
225            Err(msg) => CliService::warning(msg),
226        }
227    }
228}
229
230fn prompt_display_name(prompter: &dyn Prompter, default: &str) -> Result<String> {
231    prompter
232        .input_with_default("Display name", default)
233        .context("Failed to get display name")
234}
235
236fn prompt_description(prompter: &dyn Prompter) -> Result<String> {
237    prompter
238        .input_with_default("Description", "")
239        .context("Failed to get description")
240}