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