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
26pub async fn run() -> Result<()> {
27    let cli = args::Cli::parse();
28
29    set_startup_mode(cli.command.is_none());
30
31    let cli_config = args::build_cli_config(&cli);
32    cli_settings::set_global_config(cli_config.clone());
33
34    if cli.display.no_color || !cli_config.should_use_color() {
35        console::set_colors_enabled(false);
36    }
37
38    if let Some(database_url) = cli.database.database_url.clone() {
39        return run_with_database_url(cli.command, &cli_config, &database_url).await;
40    }
41
42    let desc = cli
43        .command
44        .as_ref()
45        .map_or(CommandDescriptor::FULL, DescribeCommand::descriptor);
46
47    if !desc.database {
48        systemprompt_logging::init_console_logging();
49    }
50
51    if desc.profile {
52        init_profile_and_route(&cli, &desc, &cli_config).await?;
53    }
54
55    dispatch_command(cli.command, &cli_config).await
56}
57
58async fn init_profile_and_route(
59    cli: &args::Cli,
60    desc: &CommandDescriptor,
61    cli_config: &CliConfig,
62) -> Result<()> {
63    let profile_path = bootstrap::resolve_profile(cli_config.profile_override.as_deref())?;
64    bootstrap::init_profile(&profile_path)?;
65
66    let profile = ProfileBootstrap::get()?;
67
68    if cli_config.output_format == OutputFormat::Table
69        && cli_config.verbosity != VerbosityLevel::Quiet
70    {
71        let tenant = profile.cloud.as_ref().and_then(|c| c.tenant_id.as_deref());
72        CliService::profile_banner(&profile.name, profile.target.is_cloud(), tenant);
73    }
74
75    let is_cloud = profile.target.is_cloud();
76    let env = environment::ExecutionEnvironment::detect();
77
78    if !env.is_fly && desc.remote_eligible {
79        try_remote_routing(cli, profile).await?;
80    } else if is_cloud
81        && !env.is_fly
82        && !profile.database.external_db_access
83        && !matches!(
84            cli.command.as_ref(),
85            Some(
86                args::Commands::Cloud(_) | args::Commands::Admin(admin::AdminCommands::Session(_))
87            )
88        )
89    {
90        bail!(
91            "Cloud profile '{}' selected but this command doesn't support remote execution.\nUse \
92             a local profile with --profile <name> or enable external database access.",
93            profile.name
94        );
95    }
96
97    if !is_cloud || profile.database.external_db_access {
98        if let Err(e) = bootstrap::init_credentials().await {
99            let is_file_not_found = e
100                .downcast_ref::<CredentialsBootstrapError>()
101                .is_some_and(|ce| matches!(ce, CredentialsBootstrapError::FileNotFound { .. }));
102
103            if is_file_not_found {
104                tracing::debug!(error = %e, "Credentials file not found, continuing in local-only mode");
105            } else {
106                return Err(e.context("Credential initialization failed"));
107            }
108        }
109    }
110
111    if desc.secrets {
112        bootstrap::init_secrets()?;
113    }
114
115    if desc.paths {
116        bootstrap::init_paths()?;
117        if !desc.skip_validation {
118            bootstrap::run_validation()?;
119        }
120    }
121
122    if !is_cloud {
123        bootstrap::validate_cloud_credentials(&env);
124    }
125
126    Ok(())
127}
128
129async fn try_remote_routing(cli: &args::Cli, profile: &systemprompt_models::Profile) -> Result<()> {
130    let is_cloud = profile.target.is_cloud();
131
132    match routing::determine_execution_target() {
133        Ok(routing::ExecutionTarget::Remote {
134            hostname,
135            token,
136            context_id,
137        }) => {
138            let args = args::reconstruct_args(cli);
139            let exit_code =
140                routing::remote::execute_remote(&hostname, &token, &context_id, &args, 300).await?;
141            #[allow(clippy::exit)]
142            std::process::exit(exit_code);
143        },
144        Ok(routing::ExecutionTarget::Local) if is_cloud => {
145            require_external_db_access(profile, "no tenant is configured")?;
146        },
147        Err(e) if is_cloud => {
148            require_external_db_access(profile, &format!("routing failed: {}", e))?;
149        },
150        _ => {},
151    }
152
153    Ok(())
154}
155
156fn require_external_db_access(profile: &systemprompt_models::Profile, reason: &str) -> Result<()> {
157    if profile.database.external_db_access {
158        tracing::debug!(
159            profile_name = %profile.name,
160            reason = reason,
161            "Cloud profile allowing local execution via external_db_access"
162        );
163        Ok(())
164    } else {
165        bail!(
166            "Cloud profile '{}' requires remote execution but {}.\nRun 'systemprompt admin \
167             session login' to authenticate.",
168            profile.name,
169            reason
170        )
171    }
172}
173
174async fn dispatch_command(command: Option<args::Commands>, config: &CliConfig) -> Result<()> {
175    match command {
176        Some(args::Commands::Core(cmd)) => core::execute(cmd, config).await?,
177        Some(args::Commands::Infra(cmd)) => infrastructure::execute(cmd, config).await?,
178        Some(args::Commands::Admin(cmd)) => admin::execute(cmd, config).await?,
179        Some(args::Commands::Cloud(cmd)) => cloud::execute(cmd, config).await?,
180        Some(args::Commands::Analytics(cmd)) => analytics::execute(cmd, config).await?,
181        Some(args::Commands::Web(cmd)) => web::execute(cmd)?,
182        Some(args::Commands::Plugins(cmd)) => plugins::execute(cmd, config).await?,
183        Some(args::Commands::Build(cmd)) => {
184            build::execute(cmd, config)?;
185        },
186        None => {
187            args::Cli::parse_from(["systemprompt", "--help"]);
188        },
189    }
190
191    Ok(())
192}
193
194async fn run_with_database_url(
195    command: Option<args::Commands>,
196    config: &CliConfig,
197    database_url: &str,
198) -> Result<()> {
199    let db_ctx = DatabaseContext::from_url(database_url)
200        .await
201        .context("Failed to connect to database")?;
202
203    systemprompt_logging::init_logging(db_ctx.db_pool_arc());
204
205    match command {
206        Some(args::Commands::Core(cmd)) => core::execute_with_db(cmd, &db_ctx, config).await,
207        Some(args::Commands::Infra(cmd)) => {
208            infrastructure::execute_with_db(cmd, &db_ctx, config).await
209        },
210        Some(args::Commands::Admin(cmd)) => admin::execute_with_db(cmd, &db_ctx, config).await,
211        Some(args::Commands::Analytics(cmd)) => {
212            analytics::execute_with_db(cmd, &db_ctx, config).await
213        },
214        Some(_) => {
215            bail!("This command requires full profile initialization. Remove --database-url flag.")
216        },
217        None => bail!("No subcommand provided. Use --help to see available commands."),
218    }
219}