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