Skip to main content

systemprompt_cli/runner/
mod.rs

1//! CLI runtime entry point and bootstrap helpers.
2//!
3//! Owns argument parsing (`args`), profile/secrets bootstrap (`bootstrap`),
4//! and cloud routing (`routing`). The public surface is just [`run`]; every
5//! other symbol stays scoped to the runner subtree.
6
7mod args;
8mod bootstrap;
9mod routing;
10
11use anyhow::{Context, Result, bail};
12use clap::Parser;
13use systemprompt_config::{ProfileBootstrap, SecretsBootstrap};
14use systemprompt_logging::set_startup_mode;
15use systemprompt_runtime::DatabaseContext;
16
17use crate::cli_settings::{self, CliConfig};
18use crate::commands::{admin, analytics, cloud, core, infrastructure, plugins, web};
19use crate::descriptor::{CommandDescriptor, DescribeCommand};
20
21enum RoutingAction {
22    ContinueLocal,
23    ExternalDbUrl(String),
24}
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) = bootstrap_profile(&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 bootstrap_profile(
70    cli: &args::Cli,
71    desc: &CommandDescriptor,
72    cli_config: &CliConfig,
73) -> Result<Option<String>> {
74    let has_export = has_local_export_flag(cli.command.as_ref());
75    let ctx = bootstrap::resolve_and_display_profile(cli_config, has_export)?;
76
77    enforce_routing_policy(&ctx, cli, desc).await?;
78
79    let needs_cloud = is_cloud_bypass_command(cli.command.as_ref());
80    match initialize_post_routing(&ctx, desc, needs_cloud).await? {
81        RoutingAction::ExternalDbUrl(url) => Ok(Some(url)),
82        RoutingAction::ContinueLocal => Ok(None),
83    }
84}
85
86async fn enforce_routing_policy(
87    ctx: &bootstrap::ProfileContext,
88    cli: &args::Cli,
89    desc: &CommandDescriptor,
90) -> Result<()> {
91    if !ctx.env.is_fly && desc.remote_eligible() && !ctx.has_export {
92        let profile = ProfileBootstrap::get()?;
93        try_remote_routing(cli, profile).await?;
94        return Ok(());
95    }
96
97    if ctx.has_export && ctx.is_cloud && !ctx.external_db_access {
98        bail!(
99            "Export with cloud profile '{}' requires external database access.\nEnable \
100             external_db_access in the profile or use a local profile.",
101            ctx.profile_name
102        );
103    }
104
105    if ctx.is_cloud
106        && !ctx.env.is_fly
107        && !ctx.external_db_access
108        && !is_cloud_bypass_command(cli.command.as_ref())
109    {
110        bail!(
111            "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
112             a local profile with --profile <name> or enable external database access.",
113            ctx.profile_name
114        );
115    }
116
117    Ok(())
118}
119
120const fn is_cloud_bypass_command(command: Option<&args::Commands>) -> bool {
121    matches!(
122        command,
123        Some(args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_)))
124    )
125}
126
127async fn initialize_post_routing(
128    ctx: &bootstrap::ProfileContext,
129    desc: &CommandDescriptor,
130    needs_cloud: bool,
131) -> Result<RoutingAction> {
132    // Why: only commands that hit the cloud control plane should consult
133    // cloud credentials. `external_db_access` is preserved because that
134    // path resolves the DB URL from cloud-issued creds even from a local
135    // CLI.
136    if needs_cloud || (ctx.is_cloud && ctx.external_db_access) {
137        bootstrap::init_credentials_gracefully(needs_cloud).await?;
138    }
139
140    if desc.secrets() {
141        bootstrap::init_secrets()?;
142    }
143
144    if ctx.is_cloud && ctx.external_db_access && desc.paths() && !ctx.env.is_fly {
145        let secrets = SecretsBootstrap::get().context("Secrets required for external DB access")?;
146        let db_url = secrets.effective_database_url(true).to_owned();
147        return Ok(RoutingAction::ExternalDbUrl(db_url));
148    }
149
150    if desc.paths() {
151        bootstrap::init_paths()?;
152        if !desc.skip_validation() {
153            bootstrap::run_validation()?;
154        }
155    }
156
157    if !ctx.is_cloud {
158        bootstrap::validate_cloud_credentials(&ctx.env);
159    }
160
161    Ok(RoutingAction::ContinueLocal)
162}
163
164async fn try_remote_routing(cli: &args::Cli, profile: &systemprompt_models::Profile) -> Result<()> {
165    let is_cloud = profile.target.is_cloud();
166
167    match routing::determine_execution_target() {
168        Ok(routing::ExecutionTarget::Remote {
169            hostname,
170            token,
171            context,
172        }) => {
173            let args = args::reconstruct_args(cli);
174            let exit_code = routing::remote::execute_remote(
175                &hostname,
176                token.as_str(),
177                context.as_str(),
178                &args,
179                300,
180            )
181            .await?;
182            if exit_code != 0 {
183                bail!("Remote command exited with code {}", exit_code);
184            }
185            return Ok(());
186        },
187        Ok(routing::ExecutionTarget::Local) if is_cloud => {
188            require_external_db_access(profile, "no tenant is configured")?;
189        },
190        Err(e) if is_cloud => {
191            require_external_db_access(profile, &format!("routing failed: {}", e))?;
192        },
193        _ => {},
194    }
195
196    Ok(())
197}
198
199fn require_external_db_access(profile: &systemprompt_models::Profile, reason: &str) -> Result<()> {
200    if profile.database.external_db_access {
201        tracing::debug!(
202            profile_name = %profile.name,
203            reason = reason,
204            "Cloud profile allowing local execution via external_db_access"
205        );
206        Ok(())
207    } else {
208        bail!(
209            "Cloud profile '{}' requires remote execution but {}.\nRun 'systemprompt admin \
210             session login' to authenticate.",
211            profile.name,
212            reason
213        )
214    }
215}
216
217fn resolve_log_level(cli_config: &CliConfig) -> Option<String> {
218    if std::env::var("RUST_LOG").is_ok() {
219        return None;
220    }
221
222    if let Some(level) = cli_config.verbosity.as_tracing_filter() {
223        return Some(level.to_owned());
224    }
225
226    if let Ok(profile_path) = bootstrap::resolve_profile(cli_config.profile_override.as_deref()) {
227        if let Some(log_level) = bootstrap::try_load_log_level(&profile_path) {
228            return Some(log_level.as_tracing_filter().to_owned());
229        }
230    }
231
232    Some("warn".to_owned())
233}
234
235async fn dispatch_command(command: Option<args::Commands>, config: &CliConfig) -> Result<()> {
236    match command {
237        Some(args::Commands::Core(cmd)) => core::execute(cmd, config).await?,
238        Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, config).await?,
239        Some(args::Commands::Admin(cmd)) => admin::execute(cmd, config).await?,
240        Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, config).await?,
241        Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, config).await?,
242        Some(args::Commands::Web(cmd)) => web::execute(cmd)?,
243        Some(args::Commands::Plugins(cmd)) => plugins::execute(cmd, config).await?,
244        Some(args::Commands::Build(cmd)) => {
245            crate::commands::build::execute(cmd, config)?;
246        },
247        None => {
248            args::Cli::parse_from(["systemprompt", "--help"]);
249        },
250    }
251
252    Ok(())
253}
254
255async fn run_with_database_url(
256    command: Option<args::Commands>,
257    config: &CliConfig,
258    database_url: &str,
259) -> Result<()> {
260    let db_ctx = DatabaseContext::from_url(database_url)
261        .await
262        .context("Failed to connect to database")?;
263
264    systemprompt_logging::init_logging(db_ctx.db_pool_arc());
265
266    match command {
267        Some(args::Commands::Core(cmd)) => core::execute_with_db(cmd, &db_ctx, config).await,
268        Some(args::Commands::Infra(cmd)) => {
269            infrastructure::execute_with_db(cmd, &db_ctx, config).await
270        },
271        Some(args::Commands::Admin(cmd)) => admin::execute_with_db(cmd, &db_ctx, config).await,
272        Some(args::Commands::Analytics(cmd)) => {
273            analytics::execute_with_db(cmd, &db_ctx, config).await
274        },
275        Some(args::Commands::Cloud(cloud::CloudCommands::Db(cmd))) => {
276            cloud::db::execute_with_database_url(cmd, database_url, config).await
277        },
278        Some(_) => {
279            bail!("This command requires full profile initialization. Remove --database-url flag.")
280        },
281        None => bail!("No subcommand provided. Use --help to see available commands."),
282    }
283}