diff --git a/lib/crates/fabro-cli/src/command_context.rs b/lib/crates/fabro-cli/src/command_context.rs index 9eadc5c0b..4f4108147 100644 --- a/lib/crates/fabro-cli/src/command_context.rs +++ b/lib/crates/fabro-cli/src/command_context.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use fabro_types::settings::SettingsFile; +use fabro_types::settings::{CliSettings, SettingsFile}; use tokio::sync::OnceCell; use crate::args::{ServerConnectionArgs, ServerTargetArgs}; @@ -25,6 +25,7 @@ pub(crate) struct CommandContext { cwd: PathBuf, base_config_path: PathBuf, machine_settings: SettingsFile, + cli_settings: CliSettings, server_mode: ServerMode, server: OnceCell>, } @@ -57,11 +58,13 @@ impl CommandContext { .. } => user_config::load_settings_with_storage_dir(storage_dir_override.as_deref())?, }; + let cli_settings = user_config::resolve_cli_settings(&machine_settings)?; Ok(Self { cwd, base_config_path, machine_settings, + cli_settings, server_mode, server: OnceCell::new(), }) @@ -79,6 +82,10 @@ impl CommandContext { &self.machine_settings } + pub(crate) fn cli_settings(&self) -> &CliSettings { + &self.cli_settings + } + pub(crate) async fn server(&self) -> Result> { let server_mode = self.server_mode.clone(); let base_config_path = self.base_config_path.clone(); diff --git a/lib/crates/fabro-cli/src/commands/exec.rs b/lib/crates/fabro-cli/src/commands/exec.rs index 9511e1a9d..f2b8e6b69 100644 --- a/lib/crates/fabro-cli/src/commands/exec.rs +++ b/lib/crates/fabro-cli/src/commands/exec.rs @@ -3,6 +3,7 @@ use fabro_agent::cli::{OutputFormat, run_with_args, run_with_args_and_client}; use fabro_llm::client::Client; use fabro_llm::providers::FabroServerAdapter; use fabro_types::settings::InterpString; +use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat; use fabro_types::settings::run::McpEntryLayer; use std::collections::HashMap; use std::sync::Arc; @@ -99,29 +100,35 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result< use fabro_types::settings::run::AgentPermissions; let cli_settings = user_config::load_settings()?; + let resolved_cli = user_config::resolve_cli_settings(&cli_settings)?; #[cfg(feature = "sleep_inhibitor")] - let _sleep_guard = crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled()); - let exec_defaults = cli_settings.cli_exec(); - let exec_model = exec_defaults.and_then(|e| e.model.as_ref()); - let exec_agent = exec_defaults.and_then(|e| e.agent.as_ref()); - let provider_str = exec_model - .and_then(|m| m.provider.as_ref()) + let _sleep_guard = crate::sleep_inhibitor::guard(resolved_cli.exec.prevent_idle_sleep); + let provider_str = resolved_cli + .exec + .model + .provider + .as_ref() .map(InterpString::as_source); - let model_str = exec_model - .and_then(|m| m.name.as_ref()) + let model_str = resolved_cli + .exec + .model + .name + .as_ref() .map(InterpString::as_source); - let permissions = exec_agent - .and_then(|agent| agent.permissions) - .map(|p| match p { - AgentPermissions::ReadOnly => AgentPermissionLevel::ReadOnly, - AgentPermissions::ReadWrite => AgentPermissionLevel::ReadWrite, - AgentPermissions::Full => AgentPermissionLevel::Full, - }); + let permissions = resolved_cli.exec.agent.permissions.map(|p| match p { + AgentPermissions::ReadOnly => AgentPermissionLevel::ReadOnly, + AgentPermissions::ReadWrite => AgentPermissionLevel::ReadWrite, + AgentPermissions::Full => AgentPermissionLevel::Full, + }); + let output_format = Some(match resolved_cli.output.format { + SettingsOutputFormat::Text => OutputFormat::Text, + SettingsOutputFormat::Json => OutputFormat::Json, + }); args.agent.apply_cli_defaults( provider_str.as_deref(), model_str.as_deref(), permissions, - None, + output_format, ); if globals.json { args.agent.output_format = Some(OutputFormat::Json); @@ -130,52 +137,48 @@ pub(crate) async fn execute(mut args: ExecArgs, globals: &GlobalArgs) -> Result< // v2 MCPs live under `cli.exec.agent.mcps` (owner-specific) or // `run.agent.mcps`. For `fabro exec` we use the cli.exec path, falling // back to run.agent.mcps if unset. - let mcp_servers: Vec = if let Some(mcps) = exec_agent - .map(|agent| &agent.mcps) - .filter(|mcps| !mcps.is_empty()) - { - mcps.iter() - .map(|(name, entry)| runtime_mcp_server(name, entry)) - .collect() - } else { - fabro_config::resolve_run_from_file(&cli_settings) - .map(|settings| { - settings - .agent - .mcps - .values() - .map(|server| fabro_mcp::config::McpServerSettings { - name: server.name.clone(), - transport: match &server.transport { - fabro_types::settings::run::McpTransport::Stdio { command, env } => { - fabro_mcp::config::McpTransport::Stdio { - command: command.clone(), - env: env.clone(), - } - } - fabro_types::settings::run::McpTransport::Http { url, headers } => { - fabro_mcp::config::McpTransport::Http { - url: url.clone(), - headers: headers.clone(), - } - } - fabro_types::settings::run::McpTransport::Sandbox { - command, - port, - env, - } => fabro_mcp::config::McpTransport::Sandbox { - command: command.clone(), - port: *port, - env: env.clone(), - }, - }, - startup_timeout_secs: server.startup_timeout_secs, - tool_timeout_secs: server.tool_timeout_secs, - }) - .collect() - }) - .unwrap_or_default() - }; + let mcp_servers: Vec = + if !resolved_cli.exec.agent.mcps.is_empty() { + resolved_cli + .exec + .agent + .mcps + .values() + .map(|server| fabro_mcp::config::McpServerSettings { + name: server.name.clone(), + transport: server.transport.clone(), + startup_timeout_secs: server.startup_timeout_secs, + tool_timeout_secs: server.tool_timeout_secs, + }) + .collect() + } else if let Some(mcps) = cli_settings + .cli + .as_ref() + .and_then(|cli| cli.exec.as_ref()) + .and_then(|exec| exec.agent.as_ref()) + .map(|agent| &agent.mcps) + .filter(|mcps| !mcps.is_empty()) + { + mcps.iter() + .map(|(name, entry)| runtime_mcp_server(name, entry)) + .collect() + } else { + fabro_config::resolve_run_from_file(&cli_settings) + .map(|settings| { + settings + .agent + .mcps + .values() + .map(|server| fabro_mcp::config::McpServerSettings { + name: server.name.clone(), + transport: server.transport.clone(), + startup_timeout_secs: server.startup_timeout_secs, + tool_timeout_secs: server.tool_timeout_secs, + }) + .collect() + }) + .unwrap_or_default() + }; if let Some(target) = server_target { tracing::info!(transport = "server", "Agent session starting"); let provider_name = args diff --git a/lib/crates/fabro-cli/src/commands/preflight.rs b/lib/crates/fabro-cli/src/commands/preflight.rs index d15bffb6e..899f10046 100644 --- a/lib/crates/fabro-cli/src/commands/preflight.rs +++ b/lib/crates/fabro-cli/src/commands/preflight.rs @@ -1,5 +1,6 @@ use anyhow::bail; use fabro_config::ConfigLayer; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, PreflightArgs}; @@ -13,7 +14,7 @@ use crate::shared::print_json_pretty; pub(crate) async fn execute(mut args: PreflightArgs, globals: &GlobalArgs) -> anyhow::Result<()> { let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let ctx = CommandContext::for_target(&args.target)?; - args.verbose = args.verbose || ctx.machine_settings().verbose_enabled(); + args.verbose = args.verbose || ctx.cli_settings().output.verbosity == OutputVerbosity::Verbose; let manifest = build_run_manifest(ManifestBuildInput { workflow: args.workflow.clone(), diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 221b20e1e..20b0bb5b7 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -12,6 +12,7 @@ use fabro_types::{EventBody, RunEvent, RunId}; use fabro_api::types; use fabro_interview::{AnswerValue, ConsoleInterviewer, Question, QuestionOption, QuestionType}; use fabro_store::EventEnvelope; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::json::normalize_json_value; use fabro_util::terminal::Styles; use fabro_workflow::outcome::StageStatus; @@ -69,10 +70,11 @@ pub(crate) async fn attach_run_with_client( }) .unwrap_or(false) }); - let verbose = state - .run - .as_ref() - .is_some_and(|record| record.settings.verbose_enabled()); + let verbose = state.run.as_ref().is_some_and(|record| { + fabro_config::resolve_cli_from_file(&record.settings) + .map(|settings| settings.output.verbosity == OutputVerbosity::Verbose) + .unwrap_or(false) + }); let events = client.list_run_events(run_id, None, None).await?; let replay_events = events.clone(); let next_seq = events.last().map_or(1, |event| event.seq.saturating_add(1)); diff --git a/lib/crates/fabro-cli/src/commands/run/command.rs b/lib/crates/fabro-cli/src/commands/run/command.rs index cc3c054fb..0dd791505 100644 --- a/lib/crates/fabro-cli/src/commands/run/command.rs +++ b/lib/crates/fabro-cli/src/commands/run/command.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, RunArgs}; @@ -10,10 +11,10 @@ pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<( let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); let ctx = CommandContext::for_target(&args.target)?; let cli = settings_layer_with_storage_dir(None)?; - args.verbose = args.verbose || ctx.machine_settings().verbose_enabled(); + args.verbose = args.verbose || ctx.cli_settings().output.verbosity == OutputVerbosity::Verbose; let quiet = args.detach; - let prevent_idle_sleep = ctx.machine_settings().prevent_idle_sleep_enabled(); + let prevent_idle_sleep = ctx.cli_settings().exec.prevent_idle_sleep; let created_run = Box::pin(super::create::create_run(&ctx, &args, cli, styles, quiet)).await?; #[cfg(feature = "sleep_inhibitor")] diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index 30f0f2262..4f186895d 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -97,7 +97,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = { let ctx = CommandContext::for_target(&args.server)?; - crate::sleep_inhibitor::guard(ctx.machine_settings().prevent_idle_sleep_enabled()) + crate::sleep_inhibitor::guard(ctx.cli_settings().exec.prevent_idle_sleep) }; resume::resume_command(args, styles, globals).await } diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index b479d5832..bbafa8b70 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -18,6 +18,7 @@ use args::{Commands, GlobalArgs, LONG_VERSION, RunCommands, ServerCommand, Serve use clap::{CommandFactory, Parser}; use fabro_config::user::load_settings_config; use fabro_telemetry::{git, panic as tel_panic, sanitize, sender}; +use fabro_types::settings::cli::OutputVerbosity; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use rustls::crypto::ring::default_provider; @@ -145,14 +146,10 @@ async fn main_inner() -> (String, Result<()>) { } } else { match user_config::load_settings() { - Ok(cli_settings) => ( - cli_settings - .cli - .as_ref() - .and_then(|c| c.logging.as_ref()) - .and_then(|l| l.level.clone()), - cli_settings.upgrade_check_enabled(), - ), + Ok(cli_settings) => match user_config::resolve_cli_settings(&cli_settings) { + Ok(resolved_cli) => (resolved_cli.logging.level, resolved_cli.updates.check), + Err(err) => return (command_name, Err(err)), + }, Err(err) => return (command_name, Err(err)), } } @@ -207,7 +204,11 @@ async fn main_inner() -> (String, Result<()>) { } Commands::Doctor(args) => { let cli_settings = user_config::load_settings()?; - let verbose = args.verbose || cli_settings.verbose_enabled(); + let verbose = args.verbose + || user_config::resolve_cli_settings(&cli_settings)? + .output + .verbosity + == OutputVerbosity::Verbose; let exit_code = commands::doctor::run_doctor(&args, verbose, &globals).await?; std::process::exit(exit_code); } diff --git a/lib/crates/fabro-cli/src/user_config.rs b/lib/crates/fabro-cli/src/user_config.rs index 28a0391af..561513752 100644 --- a/lib/crates/fabro-cli/src/user_config.rs +++ b/lib/crates/fabro-cli/src/user_config.rs @@ -4,7 +4,8 @@ pub(crate) use fabro_config::user::*; use anyhow::{Result, bail}; use fabro_config::ConfigLayer; -use fabro_types::settings::SettingsFile; +use fabro_types::settings::cli::CliTargetSettings; +use fabro_types::settings::{CliSettings, SettingsFile}; use fabro_util::version::FABRO_VERSION; use serde::{Deserialize, Serialize}; use tracing::debug; @@ -50,6 +51,21 @@ pub(crate) fn load_settings_with_config_and_storage_dir( Ok(settings_layer_with_config_and_storage_dir(config_path, storage_dir)?.into()) } +fn render_resolve_errors(errors: Vec) -> anyhow::Error { + anyhow::anyhow!( + "failed to resolve cli settings:\n{}", + errors + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("\n") + ) +} + +pub(crate) fn resolve_cli_settings(file: &SettingsFile) -> anyhow::Result { + fabro_config::resolve_cli_from_file(file).map_err(render_resolve_errors) +} + pub(crate) fn apply_storage_dir_override( mut layer: ConfigLayer, storage_dir: Option<&Path>, @@ -77,36 +93,29 @@ pub(crate) enum ServerTarget { UnixSocket(PathBuf), } -/// Pull the CLI target configuration out of the v2 `[cli.target]` stanza. +/// Pull the resolved CLI target configuration out of `[cli.target]`. /// Returns `(target_string, tls)` where `target_string` is either an -/// http(s) URL or a unix socket path. `tls` is the CLI-side client TLS -/// settings extracted from `[cli.target.http.tls]`. -fn cli_target_from_v2(settings: &SettingsFile) -> Option<(String, Option)> { - use fabro_types::settings::cli::CliTargetLayer; - use fabro_types::settings::interp::InterpString; - - let target = settings.cli.as_ref()?.target.as_ref()?; +/// http(s) URL or a unix socket path. +fn cli_target_from_settings(settings: &CliSettings) -> Option<(String, Option)> { + let target = settings.target.as_ref()?; match target { - CliTargetLayer::Http { url, tls } => { - let url_str = url.as_ref().map(InterpString::as_source)?; + CliTargetSettings::Http { url, tls } => { let tls_settings = tls.as_ref().and_then(|tls| { Some(ClientTlsSettings { - cert: PathBuf::from(tls.cert.as_ref().map(InterpString::as_source)?), - key: PathBuf::from(tls.key.as_ref().map(InterpString::as_source)?), - ca: PathBuf::from(tls.ca.as_ref().map(InterpString::as_source)?), + cert: PathBuf::from(tls.cert.as_source()), + key: PathBuf::from(tls.key.as_source()), + ca: PathBuf::from(tls.ca.as_source()), }) }); - Some((url_str, tls_settings)) + Some((url.as_source(), tls_settings)) } - CliTargetLayer::Unix { path } => path - .as_ref() - .map(InterpString::as_source) - .map(|path_str| (path_str, None)), + CliTargetSettings::Unix { path } => Some((path.as_source(), None)), } } fn configured_server_target(settings: &SettingsFile) -> Result> { - let Some((value, tls)) = cli_target_from_v2(settings) else { + let cli_settings = resolve_cli_settings(settings)?; + let Some((value, tls)) = cli_target_from_settings(&cli_settings) else { return Ok(None); }; parse_server_target(&value, tls).map(Some) @@ -136,9 +145,13 @@ fn explicit_server_target( args: &ServerTargetArgs, settings: &SettingsFile, ) -> Result> { + let cli_settings = resolve_cli_settings(settings)?; args.as_deref() .map(|value| { - parse_server_target(value, cli_target_from_v2(settings).and_then(|(_, tls)| tls)) + parse_server_target( + value, + cli_target_from_settings(&cli_settings).and_then(|(_, tls)| tls), + ) }) .transpose() } diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 1b7d53a21..847b8e2c5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -35,6 +35,10 @@ fn parse_settings(stdout: &[u8]) -> SettingsFile { serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsFile") } +fn resolve_cli(settings: &SettingsFile) -> fabro_types::settings::CliSettings { + fabro_config::resolve_cli_from_file(settings).expect("cli settings should resolve") +} + fn server_settings_fixture() -> SettingsFile { ConfigLayer::parse( r#" @@ -582,7 +586,10 @@ name = "legacy-model" .stderr(predicate::str::contains("Rename it to")); let cfg = parse_settings(&assert.get_output().stdout); - assert!(!cfg.verbose_enabled()); + assert_eq!( + resolve_cli(&cfg).output.verbosity, + fabro_types::settings::cli::OutputVerbosity::Normal + ); assert!(cfg.run_model().is_none()); } @@ -756,7 +763,10 @@ shared = "cli" cfg.server_storage_root_str().as_deref(), Some("/srv/fabro-server") ); - assert!(cfg.verbose_enabled()); + assert_eq!( + resolve_cli(&cfg).output.verbosity, + fabro_types::settings::cli::OutputVerbosity::Verbose + ); // R22: run.inputs replaces wholesale across layers. Project is the // highest-precedence layer that sets inputs, so project's vars win diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index b4200f118..825ec7c2a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -353,6 +353,7 @@ fn create_persists_requested_overrides_into_store() { "team": run_record.labels.get("team"), }); let settings = &run_record.settings; + let cli_settings = fabro_config::resolve_cli_from_file(settings).expect("cli settings"); let compact = json!({ "workflow_slug": run_record.workflow_slug, "settings": { @@ -360,7 +361,7 @@ fn create_persists_requested_overrides_into_store() { "dry_run": settings.dry_run_enabled(), "auto_approve": settings.auto_approve_enabled(), "no_retro": settings.no_retro_enabled(), - "verbose": settings.verbose_enabled(), + "verbose": cli_settings.output.verbosity == fabro_types::settings::cli::OutputVerbosity::Verbose, "llm": { "model": settings.run_model_name_str(), "provider": settings.run_model_provider_str(), diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index cf3e0d949..c9987da8c 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -15,7 +15,8 @@ pub use config::ConfigLayer; pub use fabro_util::path::expand_tilde; pub use home::Home; pub use resolve::{ - ResolveError, resolve_run, resolve_run_from_file, resolve_server, resolve_server_from_file, + ResolveError, resolve_cli, resolve_cli_from_file, resolve_run, resolve_run_from_file, + resolve_server, resolve_server_from_file, }; pub use storage::{RunScratch, ServerState, Storage}; diff --git a/lib/crates/fabro-config/src/resolve/cli.rs b/lib/crates/fabro-config/src/resolve/cli.rs new file mode 100644 index 000000000..b2ba0decf --- /dev/null +++ b/lib/crates/fabro-config/src/resolve/cli.rs @@ -0,0 +1,95 @@ +use fabro_types::settings::cli::{ + CliAuthSettings, CliExecAgentSettings, CliExecLayer, CliExecModelSettings, CliExecSettings, + CliLayer, CliLoggingSettings, CliOutputSettings, CliSettings, CliTargetLayer, + CliTargetSettings, CliTargetTlsSettings, CliUpdatesSettings, OutputFormat, OutputVerbosity, +}; + +use super::{ResolveError, require_interp}; + +pub fn resolve_cli(layer: &CliLayer, errors: &mut Vec) -> CliSettings { + CliSettings { + target: resolve_target(layer.target.as_ref(), errors), + auth: CliAuthSettings { + strategy: layer.auth.as_ref().and_then(|auth| auth.strategy), + }, + exec: resolve_exec(layer.exec.as_ref()), + output: CliOutputSettings { + format: layer + .output + .as_ref() + .and_then(|output| output.format) + .unwrap_or(OutputFormat::Text), + verbosity: layer + .output + .as_ref() + .and_then(|output| output.verbosity) + .unwrap_or(OutputVerbosity::Normal), + }, + updates: CliUpdatesSettings { + check: layer + .updates + .as_ref() + .and_then(|updates| updates.check) + .unwrap_or(true), + }, + logging: CliLoggingSettings { + level: layer + .logging + .as_ref() + .and_then(|logging| logging.level.clone()), + }, + } +} + +fn resolve_target( + target: Option<&CliTargetLayer>, + errors: &mut Vec, +) -> Option { + match target { + Some(CliTargetLayer::Http { url, tls }) => Some(CliTargetSettings::Http { + url: require_interp(url.as_ref(), "cli.target.url", errors), + tls: tls.as_ref().map(|tls| CliTargetTlsSettings { + cert: require_interp(tls.cert.as_ref(), "cli.target.tls.cert", errors), + key: require_interp(tls.key.as_ref(), "cli.target.tls.key", errors), + ca: require_interp(tls.ca.as_ref(), "cli.target.tls.ca", errors), + }), + }), + Some(CliTargetLayer::Unix { path }) => Some(CliTargetSettings::Unix { + path: require_interp(path.as_ref(), "cli.target.path", errors), + }), + None => None, + } +} + +fn resolve_exec(exec: Option<&CliExecLayer>) -> CliExecSettings { + let Some(exec) = exec else { + return CliExecSettings::default(); + }; + + CliExecSettings { + prevent_idle_sleep: exec.prevent_idle_sleep.unwrap_or(false), + model: CliExecModelSettings { + provider: exec.model.as_ref().and_then(|model| model.provider.clone()), + name: exec.model.as_ref().and_then(|model| model.name.clone()), + }, + agent: CliExecAgentSettings { + permissions: exec.agent.as_ref().and_then(|agent| agent.permissions), + mcps: exec + .agent + .as_ref() + .map(|agent| { + agent + .mcps + .iter() + .map(|(name, entry)| { + ( + name.clone(), + super::run::resolve_mcp_entry(name.as_str(), entry), + ) + }) + .collect() + }) + .unwrap_or_default(), + }, + } +} diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 9e8ad7692..ff952aaf4 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -1,13 +1,26 @@ +mod cli; mod error; mod run; mod server; -use fabro_types::settings::{RunSettings, ServerSettings, SettingsFile}; +use fabro_types::settings::{CliSettings, RunSettings, ServerSettings, SettingsFile}; +pub use cli::resolve_cli; pub use error::ResolveError; pub use run::resolve_run; pub use server::resolve_server; +pub fn resolve_cli_from_file(file: &SettingsFile) -> Result> { + let mut errors = Vec::new(); + let layer = file.cli.as_ref().cloned().unwrap_or_default(); + let resolved = resolve_cli(&layer, &mut errors); + if errors.is_empty() { + Ok(resolved) + } else { + Err(errors) + } +} + pub fn resolve_server_from_file(file: &SettingsFile) -> Result> { let mut errors = Vec::new(); let layer = file.server.as_ref().cloned().unwrap_or_default(); diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index d9c13ecdc..264298f72 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -269,7 +269,7 @@ fn resolve_agent(agent: Option<&RunAgentLayer>) -> RunAgentSettings { } } -fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerSettings { +pub(crate) fn resolve_mcp_entry(name: &str, entry: &McpEntryLayer) -> McpServerSettings { let transport = match entry { McpEntryLayer::Stdio { script, diff --git a/lib/crates/fabro-config/tests/resolve_cli.rs b/lib/crates/fabro-config/tests/resolve_cli.rs new file mode 100644 index 000000000..107e5bd89 --- /dev/null +++ b/lib/crates/fabro-config/tests/resolve_cli.rs @@ -0,0 +1,93 @@ +use fabro_config::resolve_cli_from_file; +use fabro_types::settings::SettingsFile; +use fabro_types::settings::cli::{CliTargetSettings, OutputFormat, OutputVerbosity}; +use fabro_types::settings::run::AgentPermissions; + +#[test] +fn resolves_cli_defaults_from_empty_settings() { + let settings = SettingsFile::default(); + + let cli = resolve_cli_from_file(&settings).expect("empty settings should resolve"); + + assert!(cli.target.is_none()); + assert_eq!(cli.output.format, OutputFormat::Text); + assert_eq!(cli.output.verbosity, OutputVerbosity::Normal); + assert!(!cli.exec.prevent_idle_sleep); + assert!(cli.updates.check); + assert!(cli.logging.level.is_none()); +} + +#[test] +fn resolves_cli_target_exec_and_output_settings() { + let settings: SettingsFile = fabro_config::ConfigLayer::parse( + r#" +_version = 1 + +[cli.target] +type = "http" +url = "https://config.example.com" + +[cli.target.tls] +cert = "cert.pem" +key = "key.pem" +ca = "ca.pem" + +[cli.exec] +prevent_idle_sleep = true + +[cli.exec.model] +provider = "openai" +name = "gpt-5" + +[cli.exec.agent] +permissions = "read-only" + +[cli.exec.agent.mcps.fs] +type = "stdio" +command = ["echo", "cli"] + +[cli.output] +format = "json" +verbosity = "verbose" + +[cli.updates] +check = false + +[cli.logging] +level = "debug" +"#, + ) + .expect("fixture should parse") + .into(); + + let cli = resolve_cli_from_file(&settings).expect("cli settings should resolve"); + + let CliTargetSettings::Http { url, tls } = cli.target.expect("target") else { + panic!("expected http target"); + }; + assert_eq!(url.as_source(), "https://config.example.com"); + let tls = tls.expect("tls"); + assert_eq!(tls.cert.as_source(), "cert.pem"); + assert_eq!(tls.key.as_source(), "key.pem"); + assert_eq!(tls.ca.as_source(), "ca.pem"); + + assert!(cli.exec.prevent_idle_sleep); + assert_eq!( + cli.exec + .model + .provider + .as_ref() + .map(|value| value.as_source()), + Some("openai".to_string()) + ); + assert_eq!( + cli.exec.model.name.as_ref().map(|value| value.as_source()), + Some("gpt-5".to_string()) + ); + assert_eq!(cli.exec.agent.permissions, Some(AgentPermissions::ReadOnly)); + assert_eq!(cli.exec.agent.mcps["fs"].name, "fs"); + assert_eq!(cli.output.format, OutputFormat::Json); + assert_eq!(cli.output.verbosity, OutputVerbosity::Verbose); + assert!(!cli.updates.check); + assert_eq!(cli.logging.level.as_deref(), Some("debug")); +} diff --git a/lib/crates/fabro-types/src/settings/accessors.rs b/lib/crates/fabro-types/src/settings/accessors.rs index aef8b2bbb..4923edb51 100644 --- a/lib/crates/fabro-types/src/settings/accessors.rs +++ b/lib/crates/fabro-types/src/settings/accessors.rs @@ -8,7 +8,6 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use super::cli::{CliExecLayer, CliLayer, CliOutputLayer}; use super::interp::InterpString; use super::project::ProjectLayer; use super::run::{ @@ -284,49 +283,6 @@ impl SettingsFile { .unwrap_or(false) } - // ---------- cli-scope ---------- - - #[must_use] - pub fn cli_layer(&self) -> Option<&CliLayer> { - self.cli.as_ref() - } - - #[must_use] - pub fn cli_exec(&self) -> Option<&CliExecLayer> { - self.cli.as_ref().and_then(|c| c.exec.as_ref()) - } - - #[must_use] - pub fn cli_output(&self) -> Option<&CliOutputLayer> { - self.cli.as_ref().and_then(|c| c.output.as_ref()) - } - - #[must_use] - pub fn verbose_enabled(&self) -> bool { - use super::cli::OutputVerbosity; - matches!( - self.cli_output().and_then(|o| o.verbosity), - Some(OutputVerbosity::Verbose) - ) - } - - #[must_use] - pub fn prevent_idle_sleep_enabled(&self) -> bool { - self.cli_exec() - .and_then(|e| e.prevent_idle_sleep) - .unwrap_or(false) - } - - /// Upgrade check defaults to `true` when unset. - #[must_use] - pub fn upgrade_check_enabled(&self) -> bool { - self.cli - .as_ref() - .and_then(|c| c.updates.as_ref()) - .and_then(|u| u.check) - .unwrap_or(true) - } - // ---------- server-scope ---------- #[must_use] @@ -611,12 +567,6 @@ mod tests { ); } - #[test] - fn upgrade_check_defaults_to_true_when_unset() { - let file = SettingsFile::default(); - assert!(file.upgrade_check_enabled()); - } - #[test] fn all_labels_merges_project_workflow_run() { use crate::settings::project::ProjectLayer; diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs index 1e0dd37b0..f2ef6e0c1 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -9,7 +9,76 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use super::interp::InterpString; -use super::run::{AgentPermissions, McpEntryLayer}; +use super::run::{AgentPermissions, McpEntryLayer, McpServerSettings}; + +/// A structurally resolved `[cli]` view for consumers. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CliSettings { + pub target: Option, + pub auth: CliAuthSettings, + pub exec: CliExecSettings, + pub output: CliOutputSettings, + pub updates: CliUpdatesSettings, + pub logging: CliLoggingSettings, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CliTargetSettings { + Http { + url: InterpString, + tls: Option, + }, + Unix { + path: InterpString, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CliTargetTlsSettings { + pub cert: InterpString, + pub key: InterpString, + pub ca: InterpString, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CliAuthSettings { + pub strategy: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CliExecSettings { + pub prevent_idle_sleep: bool, + pub model: CliExecModelSettings, + pub agent: CliExecAgentSettings, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CliExecModelSettings { + pub provider: Option, + pub name: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CliExecAgentSettings { + pub permissions: Option, + pub mcps: HashMap, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CliOutputSettings { + pub format: OutputFormat, + pub verbosity: OutputVerbosity, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CliUpdatesSettings { + pub check: bool, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CliLoggingSettings { + pub level: Option, +} /// A sparse `[cli]` layer as it appears in a single settings file. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] @@ -115,17 +184,19 @@ pub struct CliOutputLayer { pub verbosity: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum OutputFormat { + #[default] Text, Json, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum OutputVerbosity { Quiet, + #[default] Normal, Verbose, } diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index fa1786637..c9c4aac3f 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -24,7 +24,11 @@ pub mod tree; pub mod version; pub mod workflow; -pub use cli::CliLayer; +pub use cli::{ + CliAuthSettings, CliExecAgentSettings, CliExecModelSettings, CliExecSettings, CliLayer, + CliLoggingSettings, CliOutputSettings, CliSettings, CliTargetSettings, CliTargetTlsSettings, + CliUpdatesSettings, +}; pub use duration::{Duration, ParseDurationError}; pub use features::FeaturesLayer; pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved};