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
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}