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::{bail, Context, Result};
18use clap::Parser;
19use systemprompt_cloud::CredentialsBootstrapError;
20use systemprompt_logging::{set_startup_mode, CliService};
21use systemprompt_models::ProfileBootstrap;
22use systemprompt_runtime::DatabaseContext;
23
24use crate::descriptor::{CommandDescriptor, DescribeCommand};
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        init_profile_and_route(&cli, &desc, &cli_config).await?;
62    }
63
64    dispatch_command(cli.command, &cli_config).await
65}
66
67async fn init_profile_and_route(
68    cli: &args::Cli,
69    desc: &CommandDescriptor,
70    cli_config: &CliConfig,
71) -> Result<()> {
72    let profile_path = bootstrap::resolve_profile(cli_config.profile_override.as_deref())?;
73    bootstrap::init_profile(&profile_path)?;
74
75    let profile = ProfileBootstrap::get()?;
76
77    if cli_config.output_format == OutputFormat::Table
78        && cli_config.verbosity != VerbosityLevel::Quiet
79    {
80        let tenant = profile.cloud.as_ref().and_then(|c| c.tenant_id.as_deref());
81        CliService::profile_banner(&profile.name, profile.target.is_cloud(), tenant);
82    }
83
84    let is_cloud = profile.target.is_cloud();
85    let env = environment::ExecutionEnvironment::detect();
86    let has_export = has_local_export_flag(cli.command.as_ref());
87
88    if !env.is_fly && desc.remote_eligible && !has_export {
89        try_remote_routing(cli, profile).await?;
90    } else if has_export && is_cloud && !profile.database.external_db_access {
91        bail!(
92            "Export with cloud profile '{}' requires external database access.\nEnable \
93             external_db_access in the profile or use a local profile.",
94            profile.name
95        );
96    } else if is_cloud
97        && !env.is_fly
98        && !profile.database.external_db_access
99        && !matches!(
100            cli.command.as_ref(),
101            Some(
102                args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_))
103            )
104        )
105    {
106        bail!(
107            "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
108             a local profile with --profile <name> or enable external database access.",
109            profile.name
110        );
111    }
112
113    if !is_cloud || profile.database.external_db_access {
114        if let Err(e) = bootstrap::init_credentials().await {
115            let is_file_not_found = e
116                .downcast_ref::<CredentialsBootstrapError>()
117                .is_some_and(|ce| matches!(ce, CredentialsBootstrapError::FileNotFound { .. }));
118
119            if is_file_not_found {
120                tracing::debug!(error = %e, "Credentials file not found, continuing in local-only mode");
121            } else {
122                return Err(e.context("Credential initialization failed"));
123            }
124        }
125    }
126
127    if desc.secrets {
128        bootstrap::init_secrets()?;
129    }
130
131    if desc.paths {
132        bootstrap::init_paths()?;
133        if !desc.skip_validation {
134            bootstrap::run_validation()?;
135        }
136    }
137
138    if !is_cloud {
139        bootstrap::validate_cloud_credentials(&env);
140    }
141
142    Ok(())
143}
144
145async fn try_remote_routing(cli: &args::Cli, profile: &systemprompt_models::Profile) -> Result<()> {
146    let is_cloud = profile.target.is_cloud();
147
148    match routing::determine_execution_target() {
149        Ok(routing::ExecutionTarget::Remote {
150            hostname,
151            token,
152            context_id,
153        }) => {
154            let args = args::reconstruct_args(cli);
155            let exit_code =
156                routing::remote::execute_remote(&hostname, &token, &context_id, &args, 300).await?;
157            #[allow(clippy::exit)]
158            std::process::exit(exit_code);
159        },
160        Ok(routing::ExecutionTarget::Local) if is_cloud => {
161            require_external_db_access(profile, "no tenant is configured")?;
162        },
163        Err(e) if is_cloud => {
164            require_external_db_access(profile, &format!("routing failed: {}", e))?;
165        },
166        _ => {},
167    }
168
169    Ok(())
170}
171
172fn require_external_db_access(profile: &systemprompt_models::Profile, reason: &str) -> Result<()> {
173    if profile.database.external_db_access {
174        tracing::debug!(
175            profile_name = %profile.name,
176            reason = reason,
177            "Cloud profile allowing local execution via external_db_access"
178        );
179        Ok(())
180    } else {
181        bail!(
182            "Cloud profile '{}' requires remote execution but {}.\nRun 'systemprompt admin \
183             session login' to authenticate.",
184            profile.name,
185            reason
186        )
187    }
188}
189
190fn resolve_log_level(cli_config: &CliConfig) -> Option<String> {
191    if std::env::var("RUST_LOG").is_ok() {
192        return None;
193    }
194
195    if let Some(level) = cli_config.verbosity.as_tracing_filter() {
196        return Some(level.to_string());
197    }
198
199    if let Ok(profile_path) = bootstrap::resolve_profile(cli_config.profile_override.as_deref()) {
200        if let Some(log_level) = bootstrap::try_load_log_level(&profile_path) {
201            return Some(log_level.as_tracing_filter().to_string());
202        }
203    }
204
205    None
206}
207
208async fn dispatch_command(command: Option<args::Commands>, config: &CliConfig) -> Result<()> {
209    match command {
210        Some(args::Commands::Core(cmd)) => core::execute(cmd, config).await?,
211        Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, config).await?,
212        Some(args::Commands::Admin(cmd)) => admin::execute(cmd, config).await?,
213        Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, config).await?,
214        Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, config).await?,
215        Some(args::Commands::Web(cmd)) => web::execute(cmd)?,
216        Some(args::Commands::Plugins(cmd)) => plugins::execute(cmd, config).await?,
217        Some(args::Commands::Build(cmd)) => {
218            build::execute(cmd, config)?;
219        },
220        None => {
221            args::Cli::parse_from(["systemprompt", "--help"]);
222        },
223    }
224
225    Ok(())
226}
227
228async fn run_with_database_url(
229    command: Option<args::Commands>,
230    config: &CliConfig,
231    database_url: &str,
232) -> Result<()> {
233    let db_ctx = DatabaseContext::from_url(database_url)
234        .await
235        .context("Failed to connect to database")?;
236
237    systemprompt_logging::init_logging(db_ctx.db_pool_arc());
238
239    match command {
240        Some(args::Commands::Core(cmd)) => core::execute_with_db(cmd, &db_ctx, config).await,
241        Some(args::Commands::Infra(cmd)) => {
242            infrastructure::execute_with_db(cmd, &db_ctx, config).await
243        },
244        Some(args::Commands::Admin(cmd)) => admin::execute_with_db(cmd, &db_ctx, config).await,
245        Some(args::Commands::Analytics(cmd)) => {
246            analytics::execute_with_db(cmd, &db_ctx, config).await
247        },
248        Some(_) => {
249            bail!("This command requires full profile initialization. Remove --database-url flag.")
250        },
251        None => bail!("No subcommand provided. Use --help to see available commands."),
252    }
253}