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::{Context, Result, bail};
18use clap::Parser;
19use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
20use systemprompt_logging::set_startup_mode;
21use systemprompt_runtime::DatabaseContext;
22
23use crate::descriptor::{CommandDescriptor, DescribeCommand};
24
25enum RoutingAction {
26 ContinueLocal,
27 ExternalDbUrl(String),
28}
29
30fn has_local_export_flag(command: Option<&args::Commands>) -> bool {
31 let is_analytics = matches!(command, Some(args::Commands::Analytics(_)));
32 if !is_analytics {
33 return false;
34 }
35 std::env::args().any(|arg| arg == "--export" || arg.starts_with("--export="))
36}
37
38pub async fn run() -> Result<()> {
39 let cli = args::Cli::parse();
40
41 set_startup_mode(cli.command.is_none());
42
43 let cli_config = args::build_cli_config(&cli);
44 cli_settings::set_global_config(cli_config.clone());
45
46 if cli.display.no_color || !cli_config.should_use_color() {
47 console::set_colors_enabled(false);
48 }
49
50 if let Some(database_url) = cli.database.database_url.clone() {
51 return run_with_database_url(cli.command, &cli_config, &database_url).await;
52 }
53
54 let desc = cli
55 .command
56 .as_ref()
57 .map_or(CommandDescriptor::FULL, DescribeCommand::descriptor);
58
59 if !desc.database() {
60 let effective_level = resolve_log_level(&cli_config);
61 systemprompt_logging::init_console_logging_with_level(effective_level.as_deref());
62 }
63
64 if desc.profile() {
65 if let Some(external_db_url) = bootstrap_profile(&cli, &desc, &cli_config).await? {
66 return run_with_database_url(cli.command, &cli_config, &external_db_url).await;
67 }
68 }
69
70 dispatch_command(cli.command, &cli_config).await
71}
72
73async fn bootstrap_profile(
74 cli: &args::Cli,
75 desc: &CommandDescriptor,
76 cli_config: &CliConfig,
77) -> Result<Option<String>> {
78 let has_export = has_local_export_flag(cli.command.as_ref());
79 let ctx = bootstrap::resolve_and_display_profile(cli_config, has_export)?;
80
81 enforce_routing_policy(&ctx, cli, desc).await?;
82
83 let needs_cloud = is_cloud_bypass_command(cli.command.as_ref());
84 match initialize_post_routing(&ctx, desc, needs_cloud).await? {
85 RoutingAction::ExternalDbUrl(url) => Ok(Some(url)),
86 RoutingAction::ContinueLocal => Ok(None),
87 }
88}
89
90async fn enforce_routing_policy(
91 ctx: &bootstrap::ProfileContext,
92 cli: &args::Cli,
93 desc: &CommandDescriptor,
94) -> Result<()> {
95 if !ctx.env.is_fly && desc.remote_eligible() && !ctx.has_export {
96 let profile = ProfileBootstrap::get()?;
97 try_remote_routing(cli, profile).await?;
98 return Ok(());
99 }
100
101 if ctx.has_export && ctx.is_cloud && !ctx.external_db_access {
102 bail!(
103 "Export with cloud profile '{}' requires external database access.\nEnable \
104 external_db_access in the profile or use a local profile.",
105 ctx.profile_name
106 );
107 }
108
109 if ctx.is_cloud
110 && !ctx.env.is_fly
111 && !ctx.external_db_access
112 && !is_cloud_bypass_command(cli.command.as_ref())
113 {
114 bail!(
115 "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
116 a local profile with --profile <name> or enable external database access.",
117 ctx.profile_name
118 );
119 }
120
121 Ok(())
122}
123
124const fn is_cloud_bypass_command(command: Option<&args::Commands>) -> bool {
125 matches!(
126 command,
127 Some(args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_)))
128 )
129}
130
131async fn initialize_post_routing(
132 ctx: &bootstrap::ProfileContext,
133 desc: &CommandDescriptor,
134 needs_cloud: bool,
135) -> Result<RoutingAction> {
136 if needs_cloud || (ctx.is_cloud && ctx.external_db_access) {
141 bootstrap::init_credentials_gracefully(needs_cloud).await?;
142 }
143
144 if desc.secrets() {
145 bootstrap::init_secrets()?;
146 }
147
148 if ctx.is_cloud && ctx.external_db_access && desc.paths() && !ctx.env.is_fly {
149 let secrets = SecretsBootstrap::get().context("Secrets required for external DB access")?;
150 let db_url = secrets.effective_database_url(true).to_string();
151 return Ok(RoutingAction::ExternalDbUrl(db_url));
152 }
153
154 if desc.paths() {
155 bootstrap::init_paths()?;
156 if !desc.skip_validation() {
157 bootstrap::run_validation()?;
158 }
159 }
160
161 if !ctx.is_cloud {
162 bootstrap::validate_cloud_credentials(&ctx.env);
163 }
164
165 Ok(RoutingAction::ContinueLocal)
166}
167
168async fn try_remote_routing(cli: &args::Cli, profile: &systemprompt_models::Profile) -> Result<()> {
169 let is_cloud = profile.target.is_cloud();
170
171 match routing::determine_execution_target() {
172 Ok(routing::ExecutionTarget::Remote {
173 hostname,
174 token,
175 context,
176 }) => {
177 let args = args::reconstruct_args(cli);
178 let exit_code = routing::remote::execute_remote(
179 &hostname,
180 token.as_str(),
181 context.as_str(),
182 &args,
183 300,
184 )
185 .await?;
186 if exit_code != 0 {
187 bail!("Remote command exited with code {}", exit_code);
188 }
189 return Ok(());
190 },
191 Ok(routing::ExecutionTarget::Local) if is_cloud => {
192 require_external_db_access(profile, "no tenant is configured")?;
193 },
194 Err(e) if is_cloud => {
195 require_external_db_access(profile, &format!("routing failed: {}", e))?;
196 },
197 _ => {},
198 }
199
200 Ok(())
201}
202
203fn require_external_db_access(profile: &systemprompt_models::Profile, reason: &str) -> Result<()> {
204 if profile.database.external_db_access {
205 tracing::debug!(
206 profile_name = %profile.name,
207 reason = reason,
208 "Cloud profile allowing local execution via external_db_access"
209 );
210 Ok(())
211 } else {
212 bail!(
213 "Cloud profile '{}' requires remote execution but {}.\nRun 'systemprompt admin \
214 session login' to authenticate.",
215 profile.name,
216 reason
217 )
218 }
219}
220
221fn resolve_log_level(cli_config: &CliConfig) -> Option<String> {
222 if std::env::var("RUST_LOG").is_ok() {
223 return None;
224 }
225
226 if let Some(level) = cli_config.verbosity.as_tracing_filter() {
227 return Some(level.to_string());
228 }
229
230 if let Ok(profile_path) = bootstrap::resolve_profile(cli_config.profile_override.as_deref()) {
231 if let Some(log_level) = bootstrap::try_load_log_level(&profile_path) {
232 return Some(log_level.as_tracing_filter().to_string());
233 }
234 }
235
236 Some("warn".to_string())
237}
238
239async fn dispatch_command(command: Option<args::Commands>, config: &CliConfig) -> Result<()> {
240 match command {
241 Some(args::Commands::Core(cmd)) => core::execute(cmd, config).await?,
242 Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, config).await?,
243 Some(args::Commands::Admin(cmd)) => admin::execute(cmd, config).await?,
244 Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, config).await?,
245 Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, config).await?,
246 Some(args::Commands::Web(cmd)) => web::execute(cmd)?,
247 Some(args::Commands::Plugins(cmd)) => plugins::execute(cmd, config).await?,
248 Some(args::Commands::Build(cmd)) => {
249 build::execute(cmd, config)?;
250 },
251 None => {
252 args::Cli::parse_from(["systemprompt", "--help"]);
253 },
254 }
255
256 Ok(())
257}
258
259async fn run_with_database_url(
260 command: Option<args::Commands>,
261 config: &CliConfig,
262 database_url: &str,
263) -> Result<()> {
264 let db_ctx = DatabaseContext::from_url(database_url)
265 .await
266 .context("Failed to connect to database")?;
267
268 systemprompt_logging::init_logging(db_ctx.db_pool_arc());
269
270 match command {
271 Some(args::Commands::Core(cmd)) => core::execute_with_db(cmd, &db_ctx, config).await,
272 Some(args::Commands::Infra(cmd)) => {
273 infrastructure::execute_with_db(cmd, &db_ctx, config).await
274 },
275 Some(args::Commands::Admin(cmd)) => admin::execute_with_db(cmd, &db_ctx, config).await,
276 Some(args::Commands::Analytics(cmd)) => {
277 analytics::execute_with_db(cmd, &db_ctx, config).await
278 },
279 Some(args::Commands::Cloud(cloud::CloudCommands::Db(cmd))) => {
280 cloud::db::execute_with_database_url(cmd, database_url, config).await
281 },
282 Some(_) => {
283 bail!("This command requires full profile initialization. Remove --database-url flag.")
284 },
285 None => bail!("No subcommand provided. Use --help to see available commands."),
286 }
287}