Skip to main content

systemprompt_cli/
lib.rs

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