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