use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use anyhow::{Context as _, Result, bail}; use fabro_auth::SqlVaultCredentialSource; use fabro_config::{CliLayer, Storage, load_llm_overlay}; use fabro_llm::credentials::CredentialProvider; use fabro_llm::lithos_catalog::Catalog; use fabro_types::UserSettings; use fabro_types::settings::RunNamespace; use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_util::error::SharedError; use fabro_util::printer::Printer; use fabro_vault::SecretStore; use tokio::sync::OnceCell; use crate::args::{ ServerConnectionArgs, ServerTargetArgs, printer_from_verbosity, require_no_json_override, }; use crate::server_client::Client; use crate::user_config::{LoadedSettings, RunSettingsKeyPresence}; use crate::{server_client, user_config}; #[derive(Clone, Debug)] pub(crate) enum ServerMode { None, ByTarget { target_override: Option, }, ByStorageDir { target_override: Option, storage_dir_override: Option, }, } pub(crate) struct CommandContext { printer: Printer, process_local_json: bool, cwd: PathBuf, base_config_path: PathBuf, cli_layer: CliLayer, storage_dir: PathBuf, run_settings: std::result::Result, user_settings: UserSettings, run_settings_key_presence: RunSettingsKeyPresence, server_mode: ServerMode, server: OnceCell>, llm_source: OnceCell>, catalog: OnceLock>, } impl CommandContext { pub(crate) fn from_disk(cli_layer: &CliLayer, process_local_json: bool) -> Result { let resolved_settings = load_merged_settings(cli_layer, &ServerMode::None)?; let printer = printer_from_verbosity(resolved_settings.user_settings.cli.output.verbosity); let cwd = std::env::current_dir().context("Failed to get current directory")?; let base_config_path = user_config::active_settings_path(None); Ok(Self { printer, process_local_json, cwd, base_config_path, cli_layer: cli_layer.clone(), storage_dir: resolved_settings.storage_dir, run_settings: resolved_settings.run_settings, user_settings: resolved_settings.user_settings, run_settings_key_presence: resolved_settings.run_settings_key_presence, server_mode: ServerMode::None, server: OnceCell::new(), llm_source: OnceCell::new(), catalog: OnceLock::new(), }) } pub(crate) fn with_target(&self, args: &ServerTargetArgs) -> Result { self.with_server_mode(ServerMode::ByTarget { target_override: args.server.clone(), }) } pub(crate) fn with_connection(&self, args: &ServerConnectionArgs) -> Result { self.with_server_mode(ServerMode::ByStorageDir { target_override: args.target.server.clone(), storage_dir_override: args.storage_dir.clone_path(), }) } pub(crate) fn printer(&self) -> Printer { self.printer } pub(crate) fn explicit_json_requested(&self) -> bool { self.process_local_json } pub(crate) fn require_no_json_override(&self) -> Result<()> { require_no_json_override(self.process_local_json) } pub(crate) fn cwd(&self) -> &Path { &self.cwd } pub(crate) fn storage_dir(&self) -> &Path { &self.storage_dir } pub(crate) fn run_settings(&self) -> Result<&RunNamespace> { self.run_settings .as_ref() .map_err(|err| anyhow::Error::new(err.clone())) } pub(crate) fn user_settings(&self) -> &UserSettings { &self.user_settings } pub(crate) fn run_settings_key_presence(&self) -> &RunSettingsKeyPresence { &self.run_settings_key_presence } pub(crate) fn base_config_path(&self) -> &Path { &self.base_config_path } pub(crate) fn json_output(&self) -> bool { self.user_settings.cli.output.format == OutputFormat::Json } pub(crate) fn verbose(&self) -> bool { self.user_settings.cli.output.verbosity == OutputVerbosity::Verbose } pub(crate) async fn server(&self) -> Result> { let server_mode = self.server_mode.clone(); let base_config_path = self.base_config_path.clone(); let storage_dir = self.storage_dir.clone(); let user_settings = self.user_settings.clone(); let client = self .server .get_or_try_init(|| async move { let target = match server_mode { ServerMode::None => bail!("This command context does not have server access"), ServerMode::ByTarget { target_override } | ServerMode::ByStorageDir { target_override, .. } => ServerTargetArgs { server: target_override, }, }; server_client::connect_server_with_settings( &target, &user_settings, &storage_dir, &base_config_path, ) .await .map(Arc::new) }) .await?; Ok(Arc::clone(client)) } pub(crate) async fn llm_source(&self) -> Result> { let storage_dir = self.storage_dir.clone(); let source = self .llm_source .get_or_try_init(|| async move { let storage = Storage::new(&storage_dir); let store = SecretStore::open(storage.sqlite_path(), storage.secrets_path()) .await .context("opening the Fabro secret store")?; let source: Arc = Arc::new(SqlVaultCredentialSource::new(Arc::new(store))); Ok::, anyhow::Error>(source) }) .await?; Ok(Arc::clone(source)) } pub(crate) fn catalog(&self) -> Result> { if let Some(catalog) = self.catalog.get() { return Ok(Arc::clone(catalog)); } let catalog = Arc::new(load_cli_catalog().context("building LLM catalog")?); if self.catalog.set(Arc::clone(&catalog)).is_ok() { return Ok(catalog); } Ok(Arc::clone( self.catalog .get() .expect("catalog must exist after failed OnceLock set"), )) } fn with_server_mode(&self, server_mode: ServerMode) -> Result { // Always reload settings for the requested derivation mode so the result // depends only on the requested mode, not on whichever derived context // happened to call into this helper. let resolved_settings = load_merged_settings(&self.cli_layer, &server_mode)?; Ok(Self { printer: self.printer, process_local_json: self.process_local_json, cwd: self.cwd.clone(), base_config_path: self.base_config_path.clone(), cli_layer: self.cli_layer.clone(), storage_dir: resolved_settings.storage_dir, run_settings: resolved_settings.run_settings, user_settings: resolved_settings.user_settings, run_settings_key_presence: resolved_settings.run_settings_key_presence, server_mode, server: OnceCell::new(), llm_source: OnceCell::new(), catalog: OnceLock::new(), }) } } fn load_merged_settings(cli_layer: &CliLayer, server_mode: &ServerMode) -> Result { match server_mode { ServerMode::None | ServerMode::ByTarget { .. } => { user_config::load_resolved_settings(None, None, Some(cli_layer)) } ServerMode::ByStorageDir { storage_dir_override, .. } => user_config::load_resolved_settings( None, storage_dir_override.as_deref(), Some(cli_layer), ), } } /// The catalog CLI commands run against: lithos built-ins, Fabro policy, and /// the operator `[llm]` overlay from the active settings file. #[expect( clippy::disallowed_methods, reason = "The CLI honors OPENAI_BASE_URL from the process environment." )] pub(crate) fn load_cli_catalog() -> Result { let overlay = load_llm_overlay(None).context("loading the LLM settings overlay")?; fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok()) .context("building the LLM catalog") } #[cfg(test)] mod tests { use std::path::PathBuf; use std::sync::OnceLock; use fabro_config::{CliLayer, CliOutputLayer}; use fabro_types::settings::cli::{OutputFormat, OutputVerbosity}; use fabro_util::printer::Printer; use tokio::sync::OnceCell; use super::{CommandContext, ServerMode}; use crate::user_config; fn cli_layer_with_json_and_verbose() -> CliLayer { CliLayer { output: Some(CliOutputLayer { format: Some(OutputFormat::Json), verbosity: Some(OutputVerbosity::Verbose), }), ..CliLayer::default() } } fn synthetic_context(process_local_json: bool, printer: Printer) -> CommandContext { let cli_layer = cli_layer_with_json_and_verbose(); let resolved_settings = user_config::load_resolved_settings_from_toml("_version = 1\n", None, Some(&cli_layer)) .expect("settings should resolve"); CommandContext { printer, process_local_json, cwd: PathBuf::from("/tmp/workspace"), base_config_path: PathBuf::from("/tmp/settings.toml"), cli_layer, storage_dir: resolved_settings.storage_dir, run_settings: resolved_settings.run_settings, user_settings: resolved_settings.user_settings, run_settings_key_presence: resolved_settings.run_settings_key_presence, server_mode: ServerMode::None, server: OnceCell::new(), llm_source: OnceCell::new(), catalog: OnceLock::new(), } } #[test] fn context_exposes_resolved_output_and_explicit_json_state() { let ctx = synthetic_context(true, Printer::Default); assert_eq!(ctx.user_settings().cli.output.format, OutputFormat::Json); assert_eq!( ctx.user_settings().cli.output.verbosity, OutputVerbosity::Verbose ); assert!(ctx.explicit_json_requested()); assert_eq!(ctx.printer(), Printer::Default); } #[test] fn storage_dir_override_only_changes_storage_root_in_merged_settings() { let cli_layer = cli_layer_with_json_and_verbose(); let base_settings = user_config::load_resolved_settings_from_toml( r#" _version = 1 [server.storage] root = "/srv/fabro/default" "#, None, Some(&cli_layer), ) .expect("base settings should resolve"); let connection_settings = user_config::load_resolved_settings_from_toml( r#" _version = 1 [server.storage] root = "/srv/fabro/default" "#, Some(std::path::Path::new("/srv/fabro/override")), Some(&cli_layer), ) .expect("connection settings should resolve"); assert_eq!( base_settings.user_settings, connection_settings.user_settings ); assert_eq!( base_settings.user_settings.cli.output.format, OutputFormat::Json ); assert_eq!( base_settings.storage_dir, PathBuf::from("/srv/fabro/default") ); assert_eq!( connection_settings.storage_dir, PathBuf::from("/srv/fabro/override") ); assert_eq!(base_settings.run_settings.unwrap().agent.mcps.len(), 0); assert_eq!( connection_settings.run_settings.unwrap().agent.mcps.len(), 0 ); } #[test] fn storage_dir_stays_available_when_server_settings_do_not_resolve() { let loaded = user_config::load_resolved_settings_from_toml( r#" _version = 1 [server.storage] root = "/srv/fabro" "#, None, Some(&CliLayer::default()), ) .expect("settings should resolve"); assert!( loaded.server_settings.is_err(), "fixture intentionally omits [server.auth] so server_settings should fail to resolve" ); assert_eq!(loaded.storage_dir, PathBuf::from("/srv/fabro")); assert!(loaded.run_settings.is_ok()); } #[test] fn run_settings_include_run_agent_mcps() { let resolved = user_config::load_resolved_settings_from_toml( r#" _version = 1 [run.agent.mcps.demo] type = "stdio" command = ["demo-mcp"] "#, None, Some(&CliLayer::default()), ) .expect("settings should resolve"); let run_settings = resolved.run_settings.expect("run settings should resolve"); assert!(run_settings.agent.mcps.contains_key("demo")); } #[test] fn explicit_json_guard_uses_invocation_flag_not_resolved_output_format() { let json_ctx = synthetic_context(true, Printer::Default); let text_ctx = synthetic_context(false, Printer::Default); assert!(json_ctx.require_no_json_override().is_err()); assert!(text_ctx.require_no_json_override().is_ok()); } }