systemprompt_cli/commands/admin/agents/
create.rs1use 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::reload().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}