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, SecretsBootstrap};
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 if let Some(external_db_url) = init_profile_and_route(&cli, &desc, &cli_config).await? {
62 return run_with_database_url(cli.command, &cli_config, &external_db_url).await;
63 }
64 }
65
66 dispatch_command(cli.command, &cli_config).await
67}
68
69async fn init_profile_and_route(
70 cli: &args::Cli,
71 desc: &CommandDescriptor,
72 cli_config: &CliConfig,
73) -> Result<Option<String>> {
74 let profile_path = bootstrap::resolve_profile(cli_config.profile_override.as_deref())?;
75 bootstrap::init_profile(&profile_path)?;
76
77 let profile = ProfileBootstrap::get()?;
78
79 if cli_config.output_format == OutputFormat::Table
80 && cli_config.verbosity != VerbosityLevel::Quiet
81 {
82 let tenant = profile.cloud.as_ref().and_then(|c| c.tenant_id.as_deref());
83 CliService::profile_banner(&profile.name, profile.target.is_cloud(), tenant);
84 }
85
86 let is_cloud = profile.target.is_cloud();
87 let env = environment::ExecutionEnvironment::detect();
88 let has_export = has_local_export_flag(cli.command.as_ref());
89
90 if !env.is_fly && desc.remote_eligible && !has_export {
91 try_remote_routing(cli, profile).await?;
92 } else if has_export && is_cloud && !profile.database.external_db_access {
93 bail!(
94 "Export with cloud profile '{}' requires external database access.\nEnable \
95 external_db_access in the profile or use a local profile.",
96 profile.name
97 );
98 } else if is_cloud
99 && !env.is_fly
100 && !profile.database.external_db_access
101 && !matches!(
102 cli.command.as_ref(),
103 Some(
104 args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_))
105 )
106 )
107 {
108 bail!(
109 "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
110 a local profile with --profile <name> or enable external database access.",
111 profile.name
112 );
113 }
114
115 if !is_cloud || profile.database.external_db_access {
116 if let Err(e) = bootstrap::init_credentials().await {
117 let is_file_not_found = e
118 .downcast_ref::<CredentialsBootstrapError>()
119 .is_some_and(|ce| matches!(ce, CredentialsBootstrapError::FileNotFound { .. }));
120
121 if is_file_not_found {
122 tracing::debug!(error = %e, "Credentials file not found, continuing in local-only mode");
123 } else {
124 return Err(e.context("Credential initialization failed"));
125 }
126 }
127 }
128
129 if desc.secrets {
130 bootstrap::init_secrets()?;
131 }
132
133 if is_cloud && profile.database.external_db_access && desc.paths {
134 let secrets = SecretsBootstrap::get()
135 .map_err(|e| anyhow::anyhow!("Secrets required for external DB access: {}", e))?;
136 let db_url = secrets.effective_database_url(true).to_string();
137 return Ok(Some(db_url));
138 }
139
140 if desc.paths {
141 bootstrap::init_paths()?;
142 if !desc.skip_validation {
143 bootstrap::run_validation()?;
144 }
145 }
146
147 if !is_cloud {
148 bootstrap::validate_cloud_credentials(&env);
149 }
150
151 Ok(None)
152}
153
154async fn try_remote_routing(cli: &args::Cli, profile: &systemprompt_models::Profile) -> Result<()> {
155 let is_cloud = profile.target.is_cloud();
156
157 match routing::determine_execution_target() {
158 Ok(routing::ExecutionTarget::Remote {
159 hostname,
160 token,
161 context_id,
162 }) => {
163 let args = args::reconstruct_args(cli);
164 let exit_code =
165 routing::remote::execute_remote(&hostname, &token, &context_id, &args, 300).await?;
166 #[allow(clippy::exit)]
167 std::process::exit(exit_code);
168 },
169 Ok(routing::ExecutionTarget::Local) if is_cloud => {
170 require_external_db_access(profile, "no tenant is configured")?;
171 },
172 Err(e) if is_cloud => {
173 require_external_db_access(profile, &format!("routing failed: {}", e))?;
174 },
175 _ => {},
176 }
177
178 Ok(())
179}
180
181fn require_external_db_access(profile: &systemprompt_models::Profile, reason: &str) -> Result<()> {
182 if profile.database.external_db_access {
183 tracing::debug!(
184 profile_name = %profile.name,
185 reason = reason,
186 "Cloud profile allowing local execution via external_db_access"
187 );
188 Ok(())
189 } else {
190 bail!(
191 "Cloud profile '{}' requires remote execution but {}.\nRun 'systemprompt admin \
192 session login' to authenticate.",
193 profile.name,
194 reason
195 )
196 }
197}
198
199fn resolve_log_level(cli_config: &CliConfig) -> Option<String> {
200 if std::env::var("RUST_LOG").is_ok() {
201 return None;
202 }
203
204 if let Some(level) = cli_config.verbosity.as_tracing_filter() {
205 return Some(level.to_string());
206 }
207
208 if let Ok(profile_path) = bootstrap::resolve_profile(cli_config.profile_override.as_deref()) {
209 if let Some(log_level) = bootstrap::try_load_log_level(&profile_path) {
210 return Some(log_level.as_tracing_filter().to_string());
211 }
212 }
213
214 None
215}
216
217async fn dispatch_command(command: Option<args::Commands>, config: &CliConfig) -> Result<()> {
218 match command {
219 Some(args::Commands::Core(cmd)) => core::execute(cmd, config).await?,
220 Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, config).await?,
221 Some(args::Commands::Admin(cmd)) => admin::execute(cmd, config).await?,
222 Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, config).await?,
223 Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, config).await?,
224 Some(args::Commands::Web(cmd)) => web::execute(cmd)?,
225 Some(args::Commands::Plugins(cmd)) => plugins::execute(cmd, config).await?,
226 Some(args::Commands::Build(cmd)) => {
227 build::execute(cmd, config)?;
228 },
229 None => {
230 args::Cli::parse_from(["systemprompt", "--help"]);
231 },
232 }
233
234 Ok(())
235}
236
237async fn run_with_database_url(
238 command: Option<args::Commands>,
239 config: &CliConfig,
240 database_url: &str,
241) -> Result<()> {
242 let db_ctx = DatabaseContext::from_url(database_url)
243 .await
244 .context("Failed to connect to database")?;
245
246 systemprompt_logging::init_logging(db_ctx.db_pool_arc());
247
248 match command {
249 Some(args::Commands::Core(cmd)) => core::execute_with_db(cmd, &db_ctx, config).await,
250 Some(args::Commands::Infra(cmd)) => {
251 infrastructure::execute_with_db(cmd, &db_ctx, config).await
252 },
253 Some(args::Commands::Admin(cmd)) => admin::execute_with_db(cmd, &db_ctx, config).await,
254 Some(args::Commands::Analytics(cmd)) => {
255 analytics::execute_with_db(cmd, &db_ctx, config).await
256 },
257 Some(args::Commands::Cloud(cloud::CloudCommands::Db(cmd))) => {
258 cloud::db::execute_with_database_url(cmd, database_url, config).await
259 },
260 Some(_) => {
261 bail!("This command requires full profile initialization. Remove --database-url flag.")
262 },
263 None => bail!("No subcommand provided. Use --help to see available commands."),
264 }
265}