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    let outcome = Box::pin(run_inner()).await;
32    structured_output::finalize(&outcome);
33    outcome
34}
35
36async fn run_inner() -> Result<()> {
37    let cli = args::Cli::parse();
38    stamp_api_server_identity(&cli)?;
39
40    set_startup_mode(cli.command.is_none());
41
42    let env = EnvOverrides::from_process_env();
43    let cli_config = args::build_cli_config(&cli, &env);
44    systemprompt_logging::set_structured_output(cli_config.output_format() != OutputFormat::Table);
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        match cli.command.as_ref().map(args::Commands::db_url_routing) {
52            Some(db_url::DbUrlRouting::Direct) => {
53                return Box::pin(run_with_database_url(
54                    cli.command,
55                    cli_config,
56                    env,
57                    &database_url,
58                ))
59                .await;
60            },
61            Some(db_url::DbUrlRouting::Unsupported) => bail!(
62                "This command cannot run with --database-url; it requires full profile \
63                 initialization. Remove --database-url."
64            ),
65            Some(db_url::DbUrlRouting::ProfileDriven) | None => {},
66        }
67    }
68
69    let desc = cli
70        .command
71        .as_ref()
72        .map_or(CommandDescriptor::FULL, DescribeCommand::descriptor);
73
74    if !desc.database() {
75        let effective_level = resolve_log_level(&cli_config, &env);
76        systemprompt_logging::init_console_logging_with_level(effective_level.as_deref());
77    }
78
79    if desc.profile() {
80        match profile_routing::bootstrap_profile(&cli, &desc, &cli_config, &env).await? {
81            profile_routing::BootstrapOutcome::RemoteExecuted => return Ok(()),
82            profile_routing::BootstrapOutcome::ExternalDbUrl(external_db_url) => {
83                return Box::pin(run_with_database_url(
84                    cli.command,
85                    cli_config,
86                    env,
87                    &external_db_url,
88                ))
89                .await;
90            },
91            profile_routing::BootstrapOutcome::ContinueLocal => {},
92        }
93    }
94
95    let ctx = CommandContext::new(cli_config, env);
96    Box::pin(dispatch_command(cli.command, &ctx)).await
97}
98
99fn resolve_log_level(cli_config: &CliConfig, env: &EnvOverrides) -> Option<String> {
100    if env.rust_log.is_some() {
101        return None;
102    }
103
104    if let Some(level) = cli_config.verbosity.as_tracing_filter() {
105        return Some(level.to_owned());
106    }
107
108    if let Ok(resolved) = bootstrap::resolve_profile(cli_config.profile_override.as_deref(), env)
109        && let Some(log_level) = bootstrap::try_load_log_level(&resolved.path)
110    {
111        return Some(log_level.as_tracing_filter().to_owned());
112    }
113
114    Some("warn".to_owned())
115}
116
117async fn dispatch_command(command: Option<args::Commands>, ctx: &CommandContext) -> Result<()> {
118    if ctx.is_database_scoped() {
119        match &command {
120            Some(
121                args::Commands::Core(_)
122                | args::Commands::Infra(_)
123                | args::Commands::Admin(_)
124                | args::Commands::Analytics(_),
125            ) => {},
126            Some(_) => bail!(
127                "This command requires full profile initialization. Remove --database-url flag."
128            ),
129            None => bail!("No subcommand provided. Use --help to see available commands."),
130        }
131    }
132
133    match command {
134        Some(args::Commands::Core(cmd)) => core::execute(cmd, ctx).await?,
135        Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, ctx).await?,
136        Some(args::Commands::Admin(cmd)) => Box::pin(admin::execute(cmd, ctx)).await?,
137        Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, ctx).await?,
138        Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, ctx).await?,
139        Some(args::Commands::Web(cmd)) => web::execute(cmd, ctx)?,
140        Some(args::Commands::Plugins(cmd)) => Box::pin(plugins::execute(cmd, ctx)).await?,
141        Some(args::Commands::Build(cmd)) => {
142            crate::commands::build::execute(cmd, ctx)?;
143        },
144        None => {
145            args::Cli::parse_from(["systemprompt", "--help"]);
146        },
147    }
148
149    Ok(())
150}
151
152async fn run_with_database_url(
153    command: Option<args::Commands>,
154    cli_config: CliConfig,
155    env: EnvOverrides,
156    database_url: &str,
157) -> Result<()> {
158    let db_ctx = DatabaseContext::from_url(database_url)
159        .await
160        .context("Failed to connect to database")?;
161
162    systemprompt_logging::init_logging(&db_ctx.db_pool_arc());
163
164    let ctx = CommandContext::with_database(cli_config, env, db_ctx, database_url.to_owned());
165    Box::pin(dispatch_command(command, &ctx)).await
166}
167
168fn stamp_api_server_identity(cli: &args::Cli) -> Result<()> {
169    if !cli.command.as_ref().is_some_and(args::Commands::serves_api) {
170        return Ok(());
171    }
172    match subprocess::stamp_api_server()
173        .context("Failed to re-execute the API server with its identity marker")?
174    {
175        ApiServerStamp::Stamped | ApiServerStamp::Unverifiable => Ok(()),
176    }
177}