Skip to main content

systemprompt_cli/commands/cloud/profile/
mod.rs

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