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