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::Select;
25use dialoguer::theme::ColorfulTheme;
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).is_ok_and(|entries| {
210            entries
211                .filter_map(Result::ok)
212                .any(|e| e.path().is_dir() && ProfilePath::Config.resolve(&e.path()).exists())
213        });
214
215    let edit_label = if has_profiles {
216        "Edit".to_string()
217    } else {
218        "Edit (unavailable - no profiles)".to_string()
219    };
220    let delete_label = if has_profiles {
221        "Delete".to_string()
222    } else {
223        "Delete (unavailable - no profiles)".to_string()
224    };
225
226    let operations = vec![
227        "List".to_string(),
228        edit_label,
229        delete_label,
230        "Done".to_string(),
231    ];
232
233    let selection = Select::with_theme(&ColorfulTheme::default())
234        .with_prompt("Profile operation")
235        .items(&operations)
236        .default(0)
237        .interact()?;
238
239    let cmd = match selection {
240        0 => Some(ProfileCommands::List),
241        1 | 2 if !has_profiles => {
242            CliService::warning("No profiles found");
243            CliService::info(
244                "Run 'systemprompt cloud tenant create' (or 'just tenant') to create a tenant \
245                 with a profile.",
246            );
247            return Ok(Some(ProfileCommands::List));
248        },
249        1 => Some(ProfileCommands::Edit(EditArgs {
250            name: None,
251            set_anthropic_key: None,
252            set_openai_key: None,
253            set_gemini_key: None,
254            set_github_token: None,
255            set_database_url: None,
256            set_external_url: None,
257            set_host: None,
258            set_port: None,
259        })),
260        2 => select_profile("Select profile to delete")?
261            .map(|name| ProfileCommands::Delete(DeleteArgs { name, yes: false })),
262        3 => None,
263        _ => unreachable!(),
264    };
265
266    Ok(cmd)
267}
268
269fn select_profile(prompt: &str) -> Result<Option<String>> {
270    let ctx = ProjectContext::discover();
271    let profiles_dir = ctx.profiles_dir();
272
273    if !profiles_dir.exists() {
274        CliService::warning("No profiles directory found.");
275        return Ok(None);
276    }
277
278    let profiles: Vec<String> = std::fs::read_dir(&profiles_dir)?
279        .filter_map(Result::ok)
280        .filter(|e| e.path().is_dir() && ProfilePath::Config.resolve(&e.path()).exists())
281        .filter_map(|e| e.file_name().to_str().map(String::from))
282        .collect();
283
284    if profiles.is_empty() {
285        CliService::warning("No profiles found.");
286        return Ok(None);
287    }
288
289    let selection = Select::with_theme(&ColorfulTheme::default())
290        .with_prompt(prompt)
291        .items(&profiles)
292        .default(0)
293        .interact()?;
294
295    Ok(Some(profiles[selection].clone()))
296}