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
99pub fn 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
114pub fn resolve_description(
115    arg: Option<String>,
116    prompter: &dyn Prompter,
117    config: &CliConfig,
118) -> String {
119    arg.unwrap_or_else(|| {
120        if config.is_interactive() {
121            prompt_description(prompter).unwrap_or_else(|e| {
122                tracing::warn!(error = %e, "Failed to prompt for description");
123                String::new()
124            })
125        } else {
126            String::new()
127        }
128    })
129}
130
131fn build_create_request(
132    input: ResolvedAgentInput,
133    enabled: bool,
134    agent: AgentArgs,
135) -> AgentCreateRequest {
136    AgentCreateRequest {
137        name: input.name,
138        port: input.port,
139        display_name: input.display_name,
140        description: input.description,
141        system_prompt: input.system_prompt,
142        enabled,
143        endpoint: agent.endpoint,
144        dev_only: agent.dev_only,
145        is_primary: agent.is_primary,
146        default: agent.default,
147        version: agent.version,
148        icon_url: agent.icon_url,
149        documentation_url: agent.documentation_url,
150        streaming: agent.streaming,
151        push_notifications: agent.push_notifications,
152        state_transition_history: agent.state_transition_history,
153        provider: agent.provider,
154        model: agent.model,
155        mcp_servers: agent.mcp_servers,
156        skills: agent.skills,
157    }
158}
159
160fn write_agent_config(request: AgentCreateRequest) -> Result<std::path::PathBuf> {
161    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
162    let services_dir = Path::new(&profile.paths.services);
163    let name = request.name.clone();
164
165    let service = AgentConfigAuthoringService::new(services_dir);
166    let agent_file = service
167        .create(request)
168        .with_context(|| format!("Failed to create agent '{}'", name))?;
169
170    ConfigLoader::load().with_context(|| {
171        format!(
172            "Agent file created at {} but validation failed. Please check the configuration.",
173            agent_file.display()
174        )
175    })?;
176
177    Ok(agent_file)
178}
179
180pub fn validate_name_input(input: &str) -> Result<(), &'static str> {
181    if input.len() < 3 {
182        return Err("Name must be at least 3 characters");
183    }
184    if !input
185        .chars()
186        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
187    {
188        return Err("Name must be lowercase alphanumeric with underscores only");
189    }
190    Ok(())
191}
192
193pub const fn validate_port_input(port: u16) -> Result<(), &'static str> {
194    if port == 0 {
195        return Err("Port cannot be 0");
196    }
197    if port < 1024 {
198        return Err("Port should be >= 1024 (non-privileged)");
199    }
200    Ok(())
201}
202
203pub fn prompt_name(prompter: &dyn Prompter) -> Result<String> {
204    loop {
205        let input = prompter
206            .input("Agent name")
207            .context("Failed to get agent name")?;
208        match validate_name_input(&input) {
209            Ok(()) => return Ok(input),
210            Err(msg) => CliService::warning(msg),
211        }
212    }
213}
214
215pub fn prompt_port(prompter: &dyn Prompter) -> Result<u16> {
216    loop {
217        let raw = prompter
218            .input_with_default("Port", "8001")
219            .context("Failed to get port")?;
220        let port: u16 = match raw.trim().parse() {
221            Ok(port) => port,
222            Err(e) => {
223                CliService::warning(&format!("'{}' is not a valid port: {}", raw.trim(), e));
224                continue;
225            },
226        };
227        match validate_port_input(port) {
228            Ok(()) => return Ok(port),
229            Err(msg) => CliService::warning(msg),
230        }
231    }
232}
233
234fn prompt_display_name(prompter: &dyn Prompter, default: &str) -> Result<String> {
235    prompter
236        .input_with_default("Display name", default)
237        .context("Failed to get display name")
238}
239
240fn prompt_description(prompter: &dyn Prompter) -> Result<String> {
241    prompter
242        .input_with_default("Description", "")
243        .context("Failed to get description")
244}