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