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`], [`profile_routing`]). [`run`] is the
5//! process entry point.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10pub mod args;
11mod bootstrap;
12mod db_url;
13pub mod profile_routing;
14pub mod routing;
15pub mod routing_decision;
16mod structured_output;
17
18use anyhow::{Context, Result, bail};
19use clap::Parser;
20use systemprompt_loader::subprocess::{self, ApiServerStamp};
21use systemprompt_logging::set_startup_mode;
22use systemprompt_runtime::DatabaseContext;
23
24use crate::cli_settings::{CliConfig, OutputFormat};
25use crate::commands::{admin, analytics, cloud, core, infrastructure, plugins, web};
26use crate::context::CommandContext;
27use crate::descriptor::{CommandDescriptor, DescribeCommand};
28use crate::env_overrides::EnvOverrides;
29
30pub async fn run() -> Result<()> {
31    install_tls_crypto_provider();
32    let outcome = Box::pin(run_inner()).await;
33    structured_output::finalize(&outcome);
34    outcome
35}
36
37// Why: reqwest 0.13 is built with `rustls-no-provider` so the tree carries ring
38// only (sqlx already uses it); rustls then needs a process-wide provider before
39// the first client is built, or reqwest panics.
40fn install_tls_crypto_provider() {
41    if rustls::crypto::CryptoProvider::get_default().is_some() {
42        return;
43    }
44    if rustls::crypto::ring::default_provider()
45        .install_default()
46        .is_err()
47    {
48        tracing::debug!("rustls crypto provider was installed concurrently");
49    }
50}
51
52async fn run_inner() -> Result<()> {
53    let cli = args::Cli::parse();
54    stamp_api_server_identity(&cli)?;
55
56    set_startup_mode(cli.command.is_none());
57
58    let env = EnvOverrides::from_process_env();
59    let cli_config = args::build_cli_config(&cli, &env);
60    systemprompt_logging::set_structured_output(cli_config.output_format() != OutputFormat::Table);
61
62    if cli.display.no_color || !cli_config.should_use_color() {
63        console::set_colors_enabled(false);
64    }
65
66    if let Some(database_url) = cli.database.database_url.clone() {
67        match cli.command.as_ref().map(args::Commands::db_url_routing) {
68            Some(db_url::DbUrlRouting::Direct) => {
69                return Box::pin(run_with_database_url(
70                    cli.command,
71                    cli_config,
72                    env,
73                    &database_url,
74                ))
75                .await;
76            },
77            Some(db_url::DbUrlRouting::Unsupported) => bail!(
78                "This command cannot run with --database-url; it requires full profile \
79                 initialization. Remove --database-url."
80            ),
81            Some(db_url::DbUrlRouting::ProfileDriven) | None => {},
82        }
83    }
84
85    let desc = cli
86        .command
87        .as_ref()
88        .map_or(CommandDescriptor::FULL, DescribeCommand::descriptor);
89
90    if !desc.database() {
91        let effective_level = resolve_log_level(&cli_config, &env);
92        systemprompt_logging::init_console_logging_with_level(effective_level.as_deref());
93    }
94
95    if desc.profile() {
96        match profile_routing::bootstrap_profile(&cli, &desc, &cli_config, &env).await? {
97            profile_routing::BootstrapOutcome::RemoteExecuted => return Ok(()),
98            profile_routing::BootstrapOutcome::ExternalDbUrl(external_db_url) => {
99                return Box::pin(run_with_database_url(
100                    cli.command,
101                    cli_config,
102                    env,
103                    &external_db_url,
104                ))
105                .await;
106            },
107            profile_routing::BootstrapOutcome::ContinueLocal => {},
108        }
109    }
110
111    let ctx = CommandContext::new(cli_config, env);
112    Box::pin(dispatch_command(cli.command, &ctx)).await
113}
114
115fn resolve_log_level(cli_config: &CliConfig, env: &EnvOverrides) -> Option<String> {
116    if env.rust_log.is_some() {
117        return None;
118    }
119
120    if let Some(level) = cli_config.verbosity.as_tracing_filter() {
121        return Some(level.to_owned());
122    }
123
124    if let Ok(resolved) = bootstrap::resolve_profile(cli_config.profile_override.as_deref(), env)
125        && let Some(log_level) = bootstrap::try_load_log_level(&resolved.path)
126    {
127        return Some(log_level.as_tracing_filter().to_owned());
128    }
129
130    Some("warn".to_owned())
131}
132
133async fn dispatch_command(command: Option<args::Commands>, ctx: &CommandContext) -> Result<()> {
134    if ctx.is_database_scoped() {
135        match &command {
136            Some(
137                args::Commands::Core(_)
138                | args::Commands::Infra(_)
139                | args::Commands::Admin(_)
140                | args::Commands::Analytics(_),
141            ) => {},
142            Some(_) => bail!(
143                "This command requires full profile initialization. Remove --database-url flag."
144            ),
145            None => bail!("No subcommand provided. Use --help to see available commands."),
146        }
147    }
148
149    match command {
150        Some(args::Commands::Core(cmd)) => core::execute(cmd, ctx).await?,
151        Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, ctx).await?,
152        Some(args::Commands::Admin(cmd)) => Box::pin(admin::execute(cmd, ctx)).await?,
153        Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, ctx).await?,
154        Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, ctx).await?,
155        Some(args::Commands::Web(cmd)) => web::execute(cmd, ctx)?,
156        Some(args::Commands::Plugins(cmd)) => Box::pin(plugins::execute(cmd, ctx)).await?,
157        Some(args::Commands::Build(cmd)) => {
158            crate::commands::build::execute(cmd, ctx)?;
159        },
160        None => {
161            args::Cli::parse_from(["systemprompt", "--help"]);
162        },
163    }
164
165    Ok(())
166}
167
168async fn run_with_database_url(
169    command: Option<args::Commands>,
170    cli_config: CliConfig,
171    env: EnvOverrides,
172    database_url: &str,
173) -> Result<()> {
174    let db_ctx = DatabaseContext::from_url(database_url)
175        .await
176        .context("Failed to connect to database")?;
177
178    systemprompt_logging::init_logging(&db_ctx.db_pool_arc());
179
180    let ctx = CommandContext::with_database(cli_config, env, db_ctx, database_url.to_owned());
181    Box::pin(dispatch_command(command, &ctx)).await
182}
183
184fn stamp_api_server_identity(cli: &args::Cli) -> Result<()> {
185    if !cli.command.as_ref().is_some_and(args::Commands::serves_api) {
186        return Ok(());
187    }
188    match subprocess::stamp_api_server()
189        .context("Failed to re-execute the API server with its identity marker")?
190    {
191        ApiServerStamp::Stamped | ApiServerStamp::Unverifiable => Ok(()),
192    }
193}