Skip to main content

systemprompt_cli/commands/cloud/profile/
mod.rs

1//! `cloud profile` subcommands for managing deployment profiles.
2//!
3//! Dispatches [`ProfileCommands`] to create, list, show, edit, and delete
4//! profiles, and drives the interactive operation picker when no subcommand is
5//! given.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10pub mod api_keys;
11mod args;
12mod create;
13mod create_setup;
14pub mod create_tenant;
15pub(super) mod delete;
16mod edit;
17mod edit_secrets;
18pub mod edit_settings;
19mod list;
20pub mod profile_steps;
21mod show;
22pub mod show_display;
23pub mod show_types;
24pub mod templates;
25
26pub use api_keys::{ApiKeys, collect_api_keys};
27pub use args::{CreateArgs, DeleteArgs, EditArgs, ProfileCommands, ShowFilter, TenantTypeArg};
28pub use create::{CreatedProfile, create_profile_for_tenant};
29pub use create_setup::handle_local_tenant_setup;
30pub use show_types::redact_database_url;
31
32use crate::context::CommandContext;
33use crate::interactive::Prompter;
34use crate::shared::render_result;
35use anyhow::Result;
36use systemprompt_cloud::{ProfilePath, ProjectContext};
37use systemprompt_logging::CliService;
38
39pub async fn execute(cmd: Option<ProfileCommands>, ctx: &CommandContext) -> Result<()> {
40    if let Some(cmd) = cmd {
41        execute_command(cmd, ctx).await.map(drop)
42    } else {
43        if !ctx.cli.is_interactive() {
44            return Err(anyhow::anyhow!(
45                "Profile subcommand required in non-interactive mode"
46            ));
47        }
48        while let Some(cmd) = select_operation(ctx.prompter())? {
49            if execute_command(cmd, ctx).await? {
50                break;
51            }
52        }
53        Ok(())
54    }
55}
56
57async fn execute_command(cmd: ProfileCommands, ctx: &CommandContext) -> Result<bool> {
58    match cmd {
59        ProfileCommands::Create(args) => {
60            create::execute(&args.normalized(), ctx.prompter(), &ctx.cli)
61                .await
62                .map(|()| true)
63        },
64        ProfileCommands::List => {
65            let result = list::execute(ctx).await?;
66            render_result(&result, &ctx.cli);
67            Ok(false)
68        },
69        ProfileCommands::Show {
70            name,
71            filter,
72            json,
73            yaml,
74        } => show::execute(name.as_deref(), filter, json, yaml, ctx)
75            .await
76            .map(|()| false),
77        ProfileCommands::Delete(args) => {
78            let result = delete::execute(&args, ctx.prompter(), &ctx.cli)?;
79            render_result(&result, &ctx.cli);
80            Ok(false)
81        },
82        ProfileCommands::Edit(args) => edit::execute(&args, ctx).map(|()| false),
83    }
84}
85
86fn select_operation(prompter: &dyn Prompter) -> Result<Option<ProfileCommands>> {
87    let ctx = ProjectContext::discover();
88    let profiles_dir = ctx.profiles_dir();
89    let has_profiles = profiles_dir.exists()
90        && std::fs::read_dir(&profiles_dir).is_ok_and(|entries| {
91            entries
92                .filter_map(Result::ok)
93                .any(|e| e.path().is_dir() && ProfilePath::Config.resolve(&e.path()).exists())
94        });
95
96    let edit_label = if has_profiles {
97        "Edit".to_owned()
98    } else {
99        "Edit (unavailable - no profiles)".to_owned()
100    };
101    let delete_label = if has_profiles {
102        "Delete".to_owned()
103    } else {
104        "Delete (unavailable - no profiles)".to_owned()
105    };
106
107    let operations = vec![
108        "List".to_owned(),
109        edit_label,
110        delete_label,
111        "Done".to_owned(),
112    ];
113
114    let selection = prompter.select("Profile operation", &operations)?;
115
116    let cmd = match selection {
117        0 => Some(ProfileCommands::List),
118        1 | 2 if !has_profiles => {
119            CliService::warning("No profiles found");
120            CliService::info(
121                "Run 'systemprompt cloud tenant create' (or 'just tenant') to create a tenant \
122                 with a profile.",
123            );
124            return Ok(Some(ProfileCommands::List));
125        },
126        1 => Some(ProfileCommands::Edit(EditArgs {
127            name: None,
128            set_anthropic_key: None,
129            set_openai_key: None,
130            set_gemini_key: None,
131            set_github_token: None,
132            set_database_url: None,
133            set_external_url: None,
134            set_host: None,
135            set_port: None,
136        })),
137        2 => select_profile(prompter, "Select profile to delete")?
138            .map(|name| ProfileCommands::Delete(DeleteArgs { name, yes: false })),
139        3 => None,
140        other => return Err(anyhow::anyhow!("unexpected menu selection: {other}")),
141    };
142
143    Ok(cmd)
144}
145
146fn select_profile(prompter: &dyn Prompter, prompt: &str) -> Result<Option<String>> {
147    let ctx = ProjectContext::discover();
148    let profiles_dir = ctx.profiles_dir();
149
150    if !profiles_dir.exists() {
151        CliService::warning("No profiles directory found.");
152        return Ok(None);
153    }
154
155    let profiles: Vec<String> = std::fs::read_dir(&profiles_dir)?
156        .filter_map(Result::ok)
157        .filter(|e| e.path().is_dir() && ProfilePath::Config.resolve(&e.path()).exists())
158        .filter_map(|e| e.file_name().to_str().map(String::from))
159        .collect();
160
161    if profiles.is_empty() {
162        CliService::warning("No profiles found.");
163        return Ok(None);
164    }
165
166    let selection = prompter.select(prompt, &profiles)?;
167
168    Ok(Some(profiles[selection].clone()))
169}