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