systemprompt_cli/commands/cloud/profile/
mod.rs1mod api_keys;
2mod builders;
3mod create;
4mod create_setup;
5mod create_tenant;
6mod delete;
7mod edit;
8mod edit_secrets;
9mod edit_settings;
10mod list;
11mod profile_steps;
12mod show;
13mod show_display;
14mod show_types;
15pub mod templates;
16
17pub use api_keys::collect_api_keys;
18pub use create::{CreatedProfile, create_profile_for_tenant};
19pub use create_setup::{get_cloud_user, handle_local_tenant_setup};
20
21use crate::cli_settings::CliConfig;
22use crate::shared::render_result;
23use anyhow::Result;
24use clap::{Args, Subcommand, ValueEnum};
25use dialoguer::Select;
26use dialoguer::theme::ColorfulTheme;
27use systemprompt_cloud::{ProfilePath, ProjectContext};
28use systemprompt_logging::CliService;
29
30#[derive(Debug, Subcommand)]
31pub enum ProfileCommands {
32 #[command(about = "Create a new profile", hide = true)]
33 Create(CreateArgs),
34
35 #[command(about = "List all profiles")]
36 List,
37
38 #[command(
39 about = "Show profile configuration",
40 after_help = "EXAMPLES:\n systemprompt cloud profile show\n systemprompt cloud profile \
41 show --filter agents\n systemprompt cloud profile show --json"
42 )]
43 Show {
44 name: Option<String>,
45
46 #[arg(short, long, value_enum, default_value = "all")]
47 filter: ShowFilter,
48
49 #[arg(long, help = "Output as JSON")]
50 json: bool,
51
52 #[arg(long, help = "Output as YAML")]
53 yaml: bool,
54 },
55
56 #[command(about = "Delete a profile")]
57 Delete(DeleteArgs),
58
59 #[command(about = "Edit profile configuration")]
60 Edit(EditArgs),
61}
62
63#[derive(Debug, Args)]
64pub struct DeleteArgs {
65 pub name: String,
66
67 #[arg(short = 'y', long, help = "Skip confirmation prompts")]
68 pub yes: bool,
69}
70
71#[derive(Debug, Args)]
72pub struct CreateArgs {
73 pub name: String,
74
75 #[arg(
76 long,
77 env = "SYSTEMPROMPT_TENANT_ID",
78 help = "Tenant ID (required in non-interactive mode)"
79 )]
80 pub tenant_id: Option<String>,
81
82 #[arg(long, value_enum, default_value = "local", help = "Tenant type")]
83 pub tenant_type: TenantTypeArg,
84
85 #[arg(long, env = "ANTHROPIC_API_KEY", help = "Anthropic (Claude) API key")]
86 pub anthropic_key: Option<String>,
87
88 #[arg(long, env = "OPENAI_API_KEY", help = "OpenAI (GPT) API key")]
89 pub openai_key: Option<String>,
90
91 #[arg(long, env = "GEMINI_API_KEY", help = "Google AI (Gemini) API key")]
92 pub gemini_key: Option<String>,
93
94 #[arg(long, env = "GITHUB_TOKEN", help = "GitHub token (optional)")]
95 pub github_token: Option<String>,
96}
97
98impl CreateArgs {
99 pub const fn has_api_key(&self) -> bool {
100 self.anthropic_key.is_some() || self.openai_key.is_some() || self.gemini_key.is_some()
101 }
102}
103
104#[derive(Clone, Copy, Debug, ValueEnum)]
105pub enum TenantTypeArg {
106 Local,
107 Cloud,
108}
109
110#[derive(Debug, Args)]
111pub struct EditArgs {
112 pub name: Option<String>,
113
114 #[arg(long, help = "Set Anthropic API key")]
115 pub set_anthropic_key: Option<String>,
116
117 #[arg(long, help = "Set OpenAI API key")]
118 pub set_openai_key: Option<String>,
119
120 #[arg(long, help = "Set Gemini API key")]
121 pub set_gemini_key: Option<String>,
122
123 #[arg(long, help = "Set GitHub token")]
124 pub set_github_token: Option<String>,
125
126 #[arg(long, help = "Set database URL")]
127 pub set_database_url: Option<String>,
128
129 #[arg(long, help = "Set external URL (cloud profiles)")]
130 pub set_external_url: Option<String>,
131
132 #[arg(long, help = "Set server host")]
133 pub set_host: Option<String>,
134
135 #[arg(long, help = "Set server port")]
136 pub set_port: Option<u16>,
137}
138
139impl EditArgs {
140 pub const fn has_updates(&self) -> bool {
141 self.set_anthropic_key.is_some()
142 || self.set_openai_key.is_some()
143 || self.set_gemini_key.is_some()
144 || self.set_github_token.is_some()
145 || self.set_database_url.is_some()
146 || self.set_external_url.is_some()
147 || self.set_host.is_some()
148 || self.set_port.is_some()
149 }
150}
151
152#[derive(Clone, Copy, Debug, ValueEnum)]
153pub enum ShowFilter {
154 All,
155 Agents,
156 Mcp,
157 Skills,
158 Ai,
159 Web,
160 Content,
161 Env,
162 Settings,
163}
164
165pub async fn execute(cmd: Option<ProfileCommands>, config: &CliConfig) -> Result<()> {
166 if let Some(cmd) = cmd {
167 execute_command(cmd, config).await.map(drop)
168 } else {
169 if !config.is_interactive() {
170 return Err(anyhow::anyhow!(
171 "Profile subcommand required in non-interactive mode"
172 ));
173 }
174 while let Some(cmd) = select_operation()? {
175 if execute_command(cmd, config).await? {
176 break;
177 }
178 }
179 Ok(())
180 }
181}
182
183async fn execute_command(cmd: ProfileCommands, config: &CliConfig) -> Result<bool> {
184 match cmd {
185 ProfileCommands::Create(args) => create::execute(&args, config).await.map(|()| true),
186 ProfileCommands::List => {
187 let result = list::execute(config)?;
188 render_result(&result);
189 Ok(false)
190 },
191 ProfileCommands::Show {
192 name,
193 filter,
194 json,
195 yaml,
196 } => show::execute(name.as_deref(), filter, json, yaml, config).map(|()| false),
197 ProfileCommands::Delete(args) => {
198 let result = delete::execute(&args, config)?;
199 render_result(&result);
200 Ok(false)
201 },
202 ProfileCommands::Edit(args) => edit::execute(&args, config).map(|()| false),
203 }
204}
205
206fn select_operation() -> Result<Option<ProfileCommands>> {
207 let ctx = ProjectContext::discover();
208 let profiles_dir = ctx.profiles_dir();
209 let has_profiles = profiles_dir.exists()
210 && std::fs::read_dir(&profiles_dir).is_ok_and(|entries| {
211 entries
212 .filter_map(Result::ok)
213 .any(|e| e.path().is_dir() && ProfilePath::Config.resolve(&e.path()).exists())
214 });
215
216 let edit_label = if has_profiles {
217 "Edit".to_string()
218 } else {
219 "Edit (unavailable - no profiles)".to_string()
220 };
221 let delete_label = if has_profiles {
222 "Delete".to_string()
223 } else {
224 "Delete (unavailable - no profiles)".to_string()
225 };
226
227 let operations = vec![
228 "List".to_string(),
229 edit_label,
230 delete_label,
231 "Done".to_string(),
232 ];
233
234 let selection = Select::with_theme(&ColorfulTheme::default())
235 .with_prompt("Profile operation")
236 .items(&operations)
237 .default(0)
238 .interact()?;
239
240 let cmd = match selection {
241 0 => Some(ProfileCommands::List),
242 1 | 2 if !has_profiles => {
243 CliService::warning("No profiles found");
244 CliService::info(
245 "Run 'systemprompt cloud tenant create' (or 'just tenant') to create a tenant \
246 with a profile.",
247 );
248 return Ok(Some(ProfileCommands::List));
249 },
250 1 => Some(ProfileCommands::Edit(EditArgs {
251 name: None,
252 set_anthropic_key: None,
253 set_openai_key: None,
254 set_gemini_key: None,
255 set_github_token: None,
256 set_database_url: None,
257 set_external_url: None,
258 set_host: None,
259 set_port: None,
260 })),
261 2 => select_profile("Select profile to delete")?
262 .map(|name| ProfileCommands::Delete(DeleteArgs { name, yes: false })),
263 3 => None,
264 _ => unreachable!(),
265 };
266
267 Ok(cmd)
268}
269
270fn select_profile(prompt: &str) -> Result<Option<String>> {
271 let ctx = ProjectContext::discover();
272 let profiles_dir = ctx.profiles_dir();
273
274 if !profiles_dir.exists() {
275 CliService::warning("No profiles directory found.");
276 return Ok(None);
277 }
278
279 let profiles: Vec<String> = std::fs::read_dir(&profiles_dir)?
280 .filter_map(Result::ok)
281 .filter(|e| e.path().is_dir() && ProfilePath::Config.resolve(&e.path()).exists())
282 .filter_map(|e| e.file_name().to_str().map(String::from))
283 .collect();
284
285 if profiles.is_empty() {
286 CliService::warning("No profiles found.");
287 return Ok(None);
288 }
289
290 let selection = Select::with_theme(&ColorfulTheme::default())
291 .with_prompt(prompt)
292 .items(&profiles)
293 .default(0)
294 .interact()?;
295
296 Ok(Some(profiles[selection].clone()))
297}