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