refactor(settings): resolve cli settings

This commit is contained in:
Bryan Helmkamp 2026-04-10 07:04:34 -04:00
parent 5dd6b88415
commit a93bf1df9c
18 changed files with 427 additions and 161 deletions

View file

@ -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<Arc<ServerStoreClient>>,
}
@ -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<Arc<ServerStoreClient>> {
let server_mode = self.server_mode.clone();
let base_config_path = self.base_config_path.clone();

View file

@ -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<fabro_mcp::config::McpServerSettings> = 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<fabro_mcp::config::McpServerSettings> =
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

View file

@ -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(),

View file

@ -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));

View file

@ -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")]

View file

@ -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
}

View file

@ -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);
}

View file

@ -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<fabro_config::ResolveError>) -> anyhow::Error {
anyhow::anyhow!(
"failed to resolve cli settings:\n{}",
errors
.into_iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("\n")
)
}
pub(crate) fn resolve_cli_settings(file: &SettingsFile) -> anyhow::Result<CliSettings> {
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<ClientTlsSettings>)> {
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<ClientTlsSettings>)> {
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<Option<ServerTarget>> {
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<Option<ServerTarget>> {
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()
}

View file

@ -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

View file

@ -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(),

View file

@ -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};

View file

@ -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<ResolveError>) -> 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<ResolveError>,
) -> Option<CliTargetSettings> {
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(),
},
}
}

View file

@ -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<CliSettings, Vec<ResolveError>> {
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<ServerSettings, Vec<ResolveError>> {
let mut errors = Vec::new();
let layer = file.server.as_ref().cloned().unwrap_or_default();

View file

@ -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,

View file

@ -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"));
}

View file

@ -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;

View file

@ -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<CliTargetSettings>,
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<CliTargetTlsSettings>,
},
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<CliAuthStrategy>,
}
#[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<InterpString>,
pub name: Option<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CliExecAgentSettings {
pub permissions: Option<AgentPermissions>,
pub mcps: HashMap<String, McpServerSettings>,
}
#[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<String>,
}
/// 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<OutputVerbosity>,
}
#[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,
}

View file

@ -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};