1mod args;
2mod bootstrap;
3pub mod cli_settings;
4mod commands;
5pub mod descriptor;
6pub mod environment;
7pub mod interactive;
8pub mod paths;
9mod presentation;
10mod routing;
11pub mod session;
12pub mod shared;
13
14pub use cli_settings::{CliConfig, ColorMode, OutputFormat, VerbosityLevel};
15pub use commands::{admin, analytics, build, cloud, core, infrastructure, plugins, web};
16
17use anyhow::{bail, Context, Result};
18use clap::Parser;
19use systemprompt_cloud::CredentialsBootstrapError;
20use systemprompt_logging::{set_startup_mode, CliService};
21use systemprompt_models::ProfileBootstrap;
22use systemprompt_runtime::DatabaseContext;
23
24use crate::descriptor::{CommandDescriptor, DescribeCommand};
25
26fn has_local_export_flag(command: Option<&args::Commands>) -> bool {
27 let is_analytics = matches!(command, Some(args::Commands::Analytics(_)));
28 if !is_analytics {
29 return false;
30 }
31 std::env::args().any(|arg| arg == "--export" || arg.starts_with("--export="))
32}
33
34pub async fn run() -> Result<()> {
35 let cli = args::Cli::parse();
36
37 set_startup_mode(cli.command.is_none());
38
39 let cli_config = args::build_cli_config(&cli);
40 cli_settings::set_global_config(cli_config.clone());
41
42 if cli.display.no_color || !cli_config.should_use_color() {
43 console::set_colors_enabled(false);
44 }
45
46 if let Some(database_url) = cli.database.database_url.clone() {
47 return run_with_database_url(cli.command, &cli_config, &database_url).await;
48 }
49
50 let desc = cli
51 .command
52 .as_ref()
53 .map_or(CommandDescriptor::FULL, DescribeCommand::descriptor);
54
55 if !desc.database {
56 let effective_level = resolve_log_level(&cli_config);
57 systemprompt_logging::init_console_logging_with_level(effective_level.as_deref());
58 }
59
60 if desc.profile {
61 init_profile_and_route(&cli, &desc, &cli_config).await?;
62 }
63
64 dispatch_command(cli.command, &cli_config).await
65}
66
67async fn init_profile_and_route(
68 cli: &args::Cli,
69 desc: &CommandDescriptor,
70 cli_config: &CliConfig,
71) -> Result<()> {
72 let profile_path = bootstrap::resolve_profile(cli_config.profile_override.as_deref())?;
73 bootstrap::init_profile(&profile_path)?;
74
75 let profile = ProfileBootstrap::get()?;
76
77 if cli_config.output_format == OutputFormat::Table
78 && cli_config.verbosity != VerbosityLevel::Quiet
79 {
80 let tenant = profile.cloud.as_ref().and_then(|c| c.tenant_id.as_deref());
81 CliService::profile_banner(&profile.name, profile.target.is_cloud(), tenant);
82 }
83
84 let is_cloud = profile.target.is_cloud();
85 let env = environment::ExecutionEnvironment::detect();
86 let has_export = has_local_export_flag(cli.command.as_ref());
87
88 if !env.is_fly && desc.remote_eligible && !has_export {
89 try_remote_routing(cli, profile).await?;
90 } else if has_export && is_cloud && !profile.database.external_db_access {
91 bail!(
92 "Export with cloud profile '{}' requires external database access.\nEnable \
93 external_db_access in the profile or use a local profile.",
94 profile.name
95 );
96 } else if is_cloud
97 && !env.is_fly
98 && !profile.database.external_db_access
99 && !matches!(
100 cli.command.as_ref(),
101 Some(
102 args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_))
103 )
104 )
105 {
106 bail!(
107 "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
108 a local profile with --profile <name> or enable external database access.",
109 profile.name
110 );
111 }
112
113 if !is_cloud || profile.database.external_db_access {
114 if let Err(e) = bootstrap::init_credentials().await {
115 let is_file_not_found = e
116 .downcast_ref::<CredentialsBootstrapError>()
117 .is_some_and(|ce| matches!(ce, CredentialsBootstrapError::FileNotFound { .. }));
118
119 if is_file_not_found {
120 tracing::debug!(error = %e, "Credentials file not found, continuing in local-only mode");
121 } else {
122 return Err(e.context("Credential initialization failed"));
123 }
124 }
125 }
126
127 if desc.secrets {
128 bootstrap::init_secrets()?;
129 }
130
131 if desc.paths {
132 bootstrap::init_paths()?;
133 if !desc.skip_validation {
134 bootstrap::run_validation()?;
135 }
136 }
137
138 if !is_cloud {
139 bootstrap::validate_cloud_credentials(&env);
140 }
141
142 Ok(())
143}
144
145async fn try_remote_routing(cli: &args::Cli, profile: &systemprompt_models::Profile) -> Result<()> {
146 let is_cloud = profile.target.is_cloud();
147
148 match routing::determine_execution_target() {
149 Ok(routing::ExecutionTarget::Remote {
150 hostname,
151 token,
152 context_id,
153 }) => {
154 let args = args::reconstruct_args(cli);
155 let exit_code =
156 routing::remote::execute_remote(&hostname, &token, &context_id, &args, 300).await?;
157 #[allow(clippy::exit)]
158 std::process::exit(exit_code);
159 },
160 Ok(routing::ExecutionTarget::Local) if is_cloud => {
161 require_external_db_access(profile, "no tenant is configured")?;
162 },
163 Err(e) if is_cloud => {
164 require_external_db_access(profile, &format!("routing failed: {}", e))?;
165 },
166 _ => {},
167 }
168
169 Ok(())
170}
171
172fn require_external_db_access(profile: &systemprompt_models::Profile, reason: &str) -> Result<()> {
173 if profile.database.external_db_access {
174 tracing::debug!(
175 profile_name = %profile.name,
176 reason = reason,
177 "Cloud profile allowing local execution via external_db_access"
178 );
179 Ok(())
180 } else {
181 bail!(
182 "Cloud profile '{}' requires remote execution but {}.\nRun 'systemprompt admin \
183 session login' to authenticate.",
184 profile.name,
185 reason
186 )
187 }
188}
189
190fn resolve_log_level(cli_config: &CliConfig) -> Option<String> {
191 if std::env::var("RUST_LOG").is_ok() {
192 return None;
193 }
194
195 if let Some(level) = cli_config.verbosity.as_tracing_filter() {
196 return Some(level.to_string());
197 }
198
199 if let Ok(profile_path) = bootstrap::resolve_profile(cli_config.profile_override.as_deref()) {
200 if let Some(log_level) = bootstrap::try_load_log_level(&profile_path) {
201 return Some(log_level.as_tracing_filter().to_string());
202 }
203 }
204
205 None
206}
207
208async fn dispatch_command(command: Option<args::Commands>, config: &CliConfig) -> Result<()> {
209 match command {
210 Some(args::Commands::Core(cmd)) => core::execute(cmd, config).await?,
211 Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, config).await?,
212 Some(args::Commands::Admin(cmd)) => admin::execute(cmd, config).await?,
213 Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, config).await?,
214 Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, config).await?,
215 Some(args::Commands::Web(cmd)) => web::execute(cmd)?,
216 Some(args::Commands::Plugins(cmd)) => plugins::execute(cmd, config).await?,
217 Some(args::Commands::Build(cmd)) => {
218 build::execute(cmd, config)?;
219 },
220 None => {
221 args::Cli::parse_from(["systemprompt", "--help"]);
222 },
223 }
224
225 Ok(())
226}
227
228async fn run_with_database_url(
229 command: Option<args::Commands>,
230 config: &CliConfig,
231 database_url: &str,
232) -> Result<()> {
233 let db_ctx = DatabaseContext::from_url(database_url)
234 .await
235 .context("Failed to connect to database")?;
236
237 systemprompt_logging::init_logging(db_ctx.db_pool_arc());
238
239 match command {
240 Some(args::Commands::Core(cmd)) => core::execute_with_db(cmd, &db_ctx, config).await,
241 Some(args::Commands::Infra(cmd)) => {
242 infrastructure::execute_with_db(cmd, &db_ctx, config).await
243 },
244 Some(args::Commands::Admin(cmd)) => admin::execute_with_db(cmd, &db_ctx, config).await,
245 Some(args::Commands::Analytics(cmd)) => {
246 analytics::execute_with_db(cmd, &db_ctx, config).await
247 },
248 Some(_) => {
249 bail!("This command requires full profile initialization. Remove --database-url flag.")
250 },
251 None => bail!("No subcommand provided. Use --help to see available commands."),
252 }
253}