cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 92cbd690a18ce6938284c4e779843f631a6809ea
parent 1ba0dc0838b08d5072a8c16084f1cf18480592bc
Author: triesap <tyson@radroots.org>
Date:   Thu, 16 Apr 2026 19:44:45 +0000

add cli output and interaction contract

Diffstat:
Msrc/cli.rs | 285+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/commands/runtime.rs | 12++++++++++--
Msrc/domain/runtime.rs | 11+++++++++++
Msrc/main.rs | 1-
Msrc/render/mod.rs | 37+++++++++++++++++++++++++++++++++++--
Msrc/runtime/config.rs | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/runtime/provider.rs | 14+++++++++++---
Mtests/runtime_show.rs | 9++++++++-
8 files changed, 530 insertions(+), 22 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -1,12 +1,35 @@ -use clap::{ArgAction, Args, Parser, Subcommand}; +use clap::{error::ErrorKind, ArgAction, Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use std::ffi::{OsStr, OsString}; use std::path::PathBuf; use crate::runtime::config::OutputFormat; +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum OutputFormatArg { + Human, + Json, + Ndjson, +} + +impl OutputFormatArg { + pub fn as_output_format(self) -> OutputFormat { + match self { + Self::Human => OutputFormat::Human, + Self::Json => OutputFormat::Json, + Self::Ndjson => OutputFormat::Ndjson, + } + } +} + #[derive(Debug, Parser, Clone)] #[command(name = "radroots")] #[command(version)] +#[command( + after_help = "Global output: use --output <human|json|ndjson>. Existing --json and --ndjson aliases remain supported." +)] pub struct CliArgs { + #[arg(skip)] + pub output_format: Option<OutputFormatArg>, #[arg(long, global = true, action = ArgAction::SetTrue)] pub json: bool, #[arg(long, global = true, action = ArgAction::SetTrue)] @@ -23,6 +46,15 @@ pub struct CliArgs { pub dry_run: bool, #[arg(long = "no-color", global = true, action = ArgAction::SetTrue)] pub no_color: bool, + #[arg( + long = "no-input", + global = true, + visible_alias = "non-interactive", + action = ArgAction::SetTrue + )] + pub no_input: bool, + #[arg(long, global = true, action = ArgAction::SetTrue)] + pub yes: bool, #[arg(long, global = true)] pub log_filter: Option<String>, #[arg(long, global = true)] @@ -51,6 +83,166 @@ pub struct CliArgs { pub command: Command, } +impl CliArgs { + pub fn parse() -> Self { + Self::try_parse().unwrap_or_else(|error| error.exit()) + } + + pub fn try_parse() -> Result<Self, clap::Error> { + Self::try_parse_from(std::env::args_os()) + } + + #[cfg(test)] + pub fn parse_from<I, T>(itr: I) -> Self + where + I: IntoIterator<Item = T>, + T: Into<OsString> + Clone, + { + Self::try_parse_from(itr).unwrap_or_else(|error| error.exit()) + } + + pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error> + where + I: IntoIterator<Item = T>, + T: Into<OsString> + Clone, + { + let args = itr.into_iter().map(Into::into).collect::<Vec<_>>(); + let (filtered_args, output_format) = extract_global_output_format(args)?; + let mut parsed = <Self as Parser>::try_parse_from(filtered_args)?; + parsed.output_format = output_format; + Ok(parsed) + } + + fn command_error(message: impl Into<String>, kind: ErrorKind) -> clap::Error { + let mut command = Self::command(); + command.error(kind, message.into()) + } +} + +fn extract_global_output_format( + args: Vec<OsString>, +) -> Result<(Vec<OsString>, Option<OutputFormatArg>), clap::Error> { + let mut iter = args.into_iter(); + let Some(program) = iter.next() else { + return Ok((Vec::new(), None)); + }; + + let mut filtered_args = vec![program]; + let mut output_format = None; + let mut command_tokens = Vec::new(); + let mut skip_known_global_value = false; + + while let Some(arg) = iter.next() { + if skip_known_global_value { + filtered_args.push(arg); + skip_known_global_value = false; + continue; + } + + if let Some((flag, value)) = split_long_option(arg.as_os_str()) { + if flag == "output" && !matches_local_output_context(command_tokens.as_slice()) { + output_format = Some(parse_output_format_value(value)?); + continue; + } + + if matches_known_global_value_option(flag) { + filtered_args.push(arg); + continue; + } + } + + if arg == OsStr::new("--output") { + if matches_local_output_context(command_tokens.as_slice()) { + filtered_args.push(arg); + continue; + } + + let Some(value) = iter.next() else { + return Err(CliArgs::command_error( + "`--output` requires a value", + ErrorKind::InvalidValue, + )); + }; + output_format = Some(parse_output_format_value(value.as_os_str())?); + continue; + } + + if let Some(flag) = long_option_name(arg.as_os_str()) { + if matches_known_global_value_option(flag) { + skip_known_global_value = true; + } + } + + if let Some(token) = arg.to_str() { + if !token.starts_with('-') { + command_tokens.push(token.to_owned()); + } + } + + filtered_args.push(arg); + } + + Ok((filtered_args, output_format)) +} + +fn parse_output_format_value(value: &OsStr) -> Result<OutputFormatArg, clap::Error> { + let Some(value) = value.to_str() else { + return Err(CliArgs::command_error( + "`--output` must be one of: human, json, ndjson", + ErrorKind::InvalidUtf8, + )); + }; + + OutputFormatArg::from_str(value, false).map_err(|_| { + CliArgs::command_error( + format!("invalid value `{value}` for `--output`; expected one of: human, json, ndjson"), + ErrorKind::InvalidValue, + ) + }) +} + +fn long_option_name(arg: &OsStr) -> Option<&str> { + let token = arg.to_str()?; + token.strip_prefix("--").map(|rest| { + rest.split_once('=') + .map_or(rest, |(flag, _value)| flag) + }) +} + +fn split_long_option(arg: &OsStr) -> Option<(&str, &OsStr)> { + let token = arg.to_str()?; + let (flag, value) = token.strip_prefix("--")?.split_once('=')?; + Some((flag, OsStr::new(value))) +} + +fn matches_known_global_value_option(flag: &str) -> bool { + matches!( + flag, + "env-file" + | "log-filter" + | "log-dir" + | "account" + | "identity-path" + | "signer" + | "relay" + | "myc-executable" + | "hyf-executable" + ) +} + +fn matches_local_output_context(command_tokens: &[String]) -> bool { + matches!( + command_tokens, + [local, export, ..] if local == "local" && export == "export" + ) || matches!( + command_tokens, + [local, backup, ..] if local == "local" && backup == "backup" + ) || matches!( + command_tokens, + [listing, new, ..] if listing == "listing" && new == "new" + ) +} + #[derive(Debug, Clone, Subcommand)] pub enum Command { Account(AccountArgs), @@ -618,12 +810,10 @@ mod tests { use super::{ AccountCommand, CliArgs, Command, ConfigCommand, FarmCommand, FarmScopeArg, JobCommand, JobWatchArgs, ListingCommand, LocalCommand, LocalExportFormatArg, MycCommand, NetCommand, - OrderCommand, OrderWatchArgs, RelayCommand, RpcCommand, RuntimeCommand, + OrderCommand, OrderWatchArgs, OutputFormatArg, RelayCommand, RpcCommand, RuntimeCommand, RuntimeConfigCommand, SignerCommand, SyncCommand, SyncWatchArgs, }; use crate::runtime::config::OutputFormat; - use clap::Parser; - #[test] fn parses_config_show_command() { let parsed = CliArgs::parse_from(["radroots", "config", "show"]); @@ -639,10 +829,14 @@ mod tests { fn parses_global_runtime_flags() { let parsed = CliArgs::parse_from([ "radroots", + "--output", + "json", "--json", "--verbose", "--dry-run", "--no-color", + "--no-input", + "--yes", "--env-file", ".env.local", "--account", @@ -668,10 +862,13 @@ mod tests { "config", "show", ]); + assert_eq!(parsed.output_format, Some(OutputFormatArg::Json)); assert!(parsed.json); assert!(parsed.verbose); assert!(parsed.dry_run); assert!(parsed.no_color); + assert!(parsed.no_input); + assert!(parsed.yes); assert_eq!( parsed.env_file.as_deref().and_then(|path| path.to_str()), Some(".env.local") @@ -716,6 +913,86 @@ mod tests { } #[test] + fn parses_output_format_and_interaction_flags() { + let parsed = CliArgs::parse_from([ + "radroots", + "--output", + "ndjson", + "--non-interactive", + "--yes", + "config", + "show", + ]); + assert_eq!(parsed.output_format, Some(OutputFormatArg::Ndjson)); + assert!(parsed.no_input); + assert!(parsed.yes); + } + + #[test] + fn parses_output_format_after_non_conflicting_subcommand() { + let parsed = CliArgs::parse_from(["radroots", "config", "show", "--output", "json"]); + assert_eq!(parsed.output_format, Some(OutputFormatArg::Json)); + match parsed.command { + Command::Config(config) => match config.command { + ConfigCommand::Show => {} + }, + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn low_level_output_flags_remain_command_local() { + let parsed = CliArgs::parse_from([ + "radroots", + "--output", + "json", + "listing", + "new", + "--output", + "listing.toml", + ]); + assert_eq!(parsed.output_format, Some(OutputFormatArg::Json)); + match parsed.command { + Command::Listing(listing) => match listing.command { + ListingCommand::New(args) => { + assert_eq!( + args.output.as_deref().and_then(|path| path.to_str()), + Some("listing.toml") + ); + } + _ => panic!("unexpected listing subcommand"), + }, + _ => panic!("unexpected command variant"), + } + } + + #[test] + fn command_group_output_flag_can_still_target_global_format() { + let parsed = CliArgs::parse_from([ + "radroots", + "listing", + "--output", + "json", + "new", + "--output", + "listing.toml", + ]); + assert_eq!(parsed.output_format, Some(OutputFormatArg::Json)); + match parsed.command { + Command::Listing(listing) => match listing.command { + ListingCommand::New(args) => { + assert_eq!( + args.output.as_deref().and_then(|path| path.to_str()), + Some("listing.toml") + ); + } + _ => panic!("unexpected listing subcommand"), + }, + _ => panic!("unexpected command variant"), + } + } + + #[test] fn parses_account_commands() { let new = CliArgs::parse_from(["radroots", "account", "new"]); match new.command { diff --git a/src/commands/runtime.rs b/src/commands/runtime.rs @@ -2,8 +2,8 @@ use crate::cli::{RuntimeConfigSetArgs, RuntimeTargetArgs}; use crate::domain::runtime::{ AccountRuntimeView, AccountSecretRuntimeView, CapabilityBindingRuntimeView, CommandOutput, CommandView, ConfigFilesRuntimeView, ConfigShowView, HyfProviderRuntimeView, HyfRuntimeView, - LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView, MigrationRuntimeView, - MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, + InteractionRuntimeView, LegacyPathRuntimeView, LocalRuntimeView, LoggingRuntimeView, + MigrationRuntimeView, MycRuntimeView, OutputRuntimeView, PathsRuntimeView, RelayRuntimeView, ResolvedProviderRuntimeView, RpcRuntimeView, SignerRuntimeView, WorkflowRuntimeView, WritePlaneRuntimeView, }; @@ -36,6 +36,14 @@ pub fn show( color: config.output.color, dry_run: config.output.dry_run, }, + interaction: InteractionRuntimeView { + input_enabled: config.interaction.input_enabled, + assume_yes: config.interaction.assume_yes, + stdin_tty: config.interaction.stdin_tty, + stdout_tty: config.interaction.stdout_tty, + prompts_allowed: config.interaction.prompts_allowed, + confirmations_allowed: config.interaction.confirmations_allowed, + }, config_files: ConfigFilesRuntimeView { user_present: config.paths.app_config_path.exists(), workspace_present: config.paths.workspace_config_path.exists(), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -129,6 +129,7 @@ pub enum CommandView { pub struct ConfigShowView { pub source: String, pub output: OutputRuntimeView, + pub interaction: InteractionRuntimeView, pub config_files: ConfigFilesRuntimeView, pub paths: PathsRuntimeView, pub migration: MigrationRuntimeView, @@ -293,6 +294,16 @@ pub struct OutputRuntimeView { } #[derive(Debug, Clone, Serialize)] +pub struct InteractionRuntimeView { + pub input_enabled: bool, + pub assume_yes: bool, + pub stdin_tty: bool, + pub stdout_tty: bool, + pub prompts_allowed: bool, + pub confirmations_allowed: bool, +} + +#[derive(Debug, Clone, Serialize)] pub struct ConfigFilesRuntimeView { pub user_present: bool, pub workspace_present: bool, diff --git a/src/main.rs b/src/main.rs @@ -6,7 +6,6 @@ mod domain; mod render; mod runtime; -use clap::Parser; use std::io::Write; use std::process::ExitCode; diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -555,6 +555,21 @@ fn render_config_show( ("dry run", yes_no(view.output.dry_run)), ], )?; + render_pairs( + stdout, + "interaction", + &[ + ("input enabled", yes_no(view.interaction.input_enabled)), + ("assume yes", yes_no(view.interaction.assume_yes)), + ("stdin tty", yes_no(view.interaction.stdin_tty)), + ("stdout tty", yes_no(view.interaction.stdout_tty)), + ("prompts allowed", yes_no(view.interaction.prompts_allowed)), + ( + "confirmations allowed", + yes_no(view.interaction.confirmations_allowed), + ), + ], + )?; let user_config = format!( "{} ยท {}", present_absent(view.config_files.user_present), @@ -2840,8 +2855,8 @@ mod tests { }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, LocalConfig, - LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, - RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + InteractionConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, }; use crate::runtime::logging::LoggingState; @@ -2858,6 +2873,14 @@ mod tests { color: true, dry_run: false, }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: true, + stdout_tty: true, + prompts_allowed: true, + confirmations_allowed: true, + }, paths: PathsConfig { profile: "interactive_user".into(), profile_source: "default".into(), @@ -2943,6 +2966,8 @@ mod tests { ) .expect("runtime show"); assert_eq!(view.output.format, "human"); + assert!(view.interaction.input_enabled); + assert!(view.interaction.prompts_allowed); assert_eq!(view.paths.profile, "interactive_user"); assert_eq!(view.paths.app_namespace, "apps/cli"); assert_eq!(view.paths.shared_accounts_namespace, "shared/accounts"); @@ -3004,6 +3029,14 @@ mod tests { color: false, dry_run: true, }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: true, + stdout_tty: true, + prompts_allowed: true, + confirmations_allowed: true, + }, paths: PathsConfig { profile: "interactive_user".into(), profile_source: "default".into(), diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; use std::fs; +use std::io::IsTerminal; use std::path::Path; use std::path::PathBuf; @@ -117,6 +118,16 @@ pub struct OutputConfig { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct InteractionConfig { + pub input_enabled: bool, + pub assume_yes: bool, + pub stdin_tty: bool, + pub stdout_tty: bool, + pub prompts_allowed: bool, + pub confirmations_allowed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct LoggingConfig { pub filter: String, pub directory: Option<PathBuf>, @@ -307,6 +318,7 @@ pub struct RpcConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeConfig { pub output: OutputConfig, + pub interaction: InteractionConfig, pub paths: PathsConfig, pub migration: MigrationConfig, pub logging: LoggingConfig, @@ -410,6 +422,8 @@ pub(crate) trait Environment { fn var(&self, key: &str) -> Option<String>; fn current_dir(&self) -> Result<PathBuf, RuntimeError>; fn path_resolver(&self) -> RadrootsPathResolver; + fn stdin_is_tty(&self) -> bool; + fn stdout_is_tty(&self) -> bool; } pub struct SystemEnvironment; @@ -428,6 +442,14 @@ impl Environment for SystemEnvironment { fn path_resolver(&self) -> RadrootsPathResolver { RadrootsPathResolver::current() } + + fn stdin_is_tty(&self) -> bool { + std::io::stdin().is_terminal() + } + + fn stdout_is_tty(&self) -> bool { + std::io::stdout().is_terminal() + } } impl RuntimeConfig { @@ -467,6 +489,7 @@ impl RuntimeConfig { color: !args.no_color, dry_run: args.dry_run, }, + interaction: resolve_interaction_config(args, env), paths: paths.clone(), migration, logging: LoggingConfig { @@ -1083,15 +1106,23 @@ fn resolve_output_format( env: &dyn Environment, env_file: &EnvFileValues, ) -> Result<OutputFormat, RuntimeError> { - match (args.json, args.ndjson) { - (true, true) => { + if args.output_format.is_some() && (args.json || args.ndjson) { + return Err(RuntimeError::Config( + "flags --output, --json, and --ndjson cannot be used together".to_owned(), + )); + } + + match (args.output_format, args.json, args.ndjson) { + (_, true, true) => { return Err(RuntimeError::Config( "flags --json and --ndjson cannot be used together".to_owned(), )); } - (true, false) => return Ok(OutputFormat::Json), - (false, true) => return Ok(OutputFormat::Ndjson), - (false, false) => {} + (Some(format), false, false) => return Ok(format.as_output_format()), + (None, true, false) => return Ok(OutputFormat::Json), + (None, false, true) => return Ok(OutputFormat::Ndjson), + (None, false, false) => {} + (Some(_), true, false) | (Some(_), false, true) => unreachable!(), } match env_value(env, env_file, &[ENV_OUTPUT]) { Some(value) => parse_output_format(value.as_str()), @@ -1121,6 +1152,22 @@ fn resolve_verbosity(args: &CliArgs) -> Result<Verbosity, RuntimeError> { } } +fn resolve_interaction_config(args: &CliArgs, env: &dyn Environment) -> InteractionConfig { + let stdin_tty = env.stdin_is_tty(); + let stdout_tty = env.stdout_is_tty(); + let input_enabled = !args.no_input; + let prompts_allowed = input_enabled && stdin_tty && stdout_tty; + let confirmations_allowed = prompts_allowed && !args.yes; + InteractionConfig { + input_enabled, + assume_yes: args.yes, + stdin_tty, + stdout_tty, + prompts_allowed, + confirmations_allowed, + } +} + fn resolve_bool_pair( positive_flag: bool, negative_flag: bool, @@ -1306,12 +1353,11 @@ mod tests { use super::{ AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, CapabilityBindingSource, CapabilityBindingTargetKind, EnvFileValues, Environment, - HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, OutputConfig, OutputFormat, PathsConfig, - RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, Verbosity, - parse_env_file_values, + HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, InteractionConfig, OutputConfig, OutputFormat, + PathsConfig, RelayConfigSource, RelayPublishPolicy, RuntimeConfig, SignerBackend, + Verbosity, parse_env_file_values, }; use crate::cli::CliArgs; - use clap::Parser; use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform}; use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; use std::collections::BTreeMap; @@ -1323,6 +1369,8 @@ mod tests { values: BTreeMap<String, String>, current_dir: PathBuf, path_resolver: RadrootsPathResolver, + stdin_tty: bool, + stdout_tty: bool, } impl MapEnvironment { @@ -1337,8 +1385,16 @@ mod tests { ..RadrootsHostEnvironment::default() }, ), + stdin_tty: false, + stdout_tty: false, } } + + fn with_tty(mut self, stdin_tty: bool, stdout_tty: bool) -> Self { + self.stdin_tty = stdin_tty; + self.stdout_tty = stdout_tty; + self + } } impl Environment for MapEnvironment { @@ -1353,6 +1409,14 @@ mod tests { fn path_resolver(&self) -> RadrootsPathResolver { self.path_resolver.clone() } + + fn stdin_is_tty(&self) -> bool { + self.stdin_tty + } + + fn stdout_is_tty(&self) -> bool { + self.stdout_tty + } } #[test] @@ -1409,6 +1473,17 @@ mod tests { } ); assert_eq!( + resolved.interaction, + InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + } + ); + assert_eq!( resolved.paths, PathsConfig { profile: "interactive_user".to_owned(), @@ -1526,6 +1601,17 @@ mod tests { dry_run: false, } ); + assert_eq!( + resolved.interaction, + InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + } + ); assert_eq!(resolved.logging.filter, "debug,cli=trace"); assert_eq!( resolved.logging.directory, @@ -1612,6 +1698,23 @@ mod tests { .to_string() .contains("--quiet, --verbose, and --trace") ); + + let conflicting_aliases = CliArgs::parse_from([ + "radroots", + "--output", + "json", + "--json", + "config", + "show", + ]); + let error = + RuntimeConfig::resolve_with_env_file(&conflicting_aliases, &env, &EnvFileValues::default()) + .expect_err("conflicting output aliases"); + assert!( + error + .to_string() + .contains("--output, --json, and --ndjson") + ); } #[test] @@ -1673,6 +1776,58 @@ RADROOTS_HYF_EXECUTABLE=bin/hyfd } #[test] + fn explicit_output_flag_overrides_environment_output() { + let args = CliArgs::parse_from(["radroots", "--output", "ndjson", "find", "eggs"]); + let env = MapEnvironment::new(BTreeMap::from([( + "RADROOTS_OUTPUT".to_owned(), + "json".to_owned(), + )])); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve runtime config"); + assert_eq!(resolved.output.format, OutputFormat::Ndjson); + } + + #[test] + fn interaction_config_reflects_tty_and_flags() { + let args = CliArgs::parse_from(["radroots", "--no-input", "--yes", "config", "show"]); + let env = MapEnvironment::new(BTreeMap::new()).with_tty(true, true); + + let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) + .expect("resolve runtime config"); + assert_eq!( + resolved.interaction, + InteractionConfig { + input_enabled: false, + assume_yes: true, + stdin_tty: true, + stdout_tty: true, + prompts_allowed: false, + confirmations_allowed: false, + } + ); + + let interactive_args = CliArgs::parse_from(["radroots", "config", "show"]); + let interactive = RuntimeConfig::resolve_with_env_file( + &interactive_args, + &env, + &EnvFileValues::default(), + ) + .expect("resolve interactive runtime config"); + assert_eq!( + interactive.interaction, + InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: true, + stdout_tty: true, + prompts_allowed: true, + confirmations_allowed: true, + } + ); + } + + #[test] fn process_environment_overrides_env_file_values() { let args = CliArgs::parse_from(["radroots", "config", "show"]); let env = MapEnvironment::new(BTreeMap::from([ @@ -1723,6 +1878,8 @@ RADROOTS_CLI_LOGGING_STDOUT=false ..RadrootsHostEnvironment::default() }, ), + stdin_tty: false, + stdout_tty: false, }; let args = CliArgs::parse_from(["radroots", "config", "show"]); @@ -1767,6 +1924,8 @@ RADROOTS_CLI_LOGGING_STDOUT=false ..RadrootsHostEnvironment::default() }, ), + stdin_tty: false, + stdout_tty: false, }; let args = CliArgs::parse_from(["radroots", "config", "show"]); @@ -1821,6 +1980,8 @@ target = "bin/user-hyfd" ..RadrootsHostEnvironment::default() }, ), + stdin_tty: false, + stdout_tty: false, }; let args = CliArgs::parse_from(["radroots", "config", "show"]); @@ -1871,6 +2032,8 @@ target = "https://rpc.workspace.test/jsonrpc" ..RadrootsHostEnvironment::default() }, ), + stdin_tty: false, + stdout_tty: false, }; let args = CliArgs::parse_from(["radroots", "config", "show"]); @@ -1951,6 +2114,8 @@ target = "https://rpc.workspace.test/jsonrpc" ..RadrootsHostEnvironment::default() }, ), + stdin_tty: false, + stdout_tty: false, }; let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -735,9 +735,9 @@ mod tests { use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, CapabilityBindingSource, CapabilityBindingTargetKind, HyfConfig, IdentityConfig, - LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, - SignerBackend, SignerConfig, Verbosity, + InteractionConfig, LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, + OutputFormat, PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, + RuntimeConfig, SignerBackend, SignerConfig, Verbosity, }; fn sample_config(bindings: Vec<CapabilityBindingConfig>, hyf_enabled: bool) -> RuntimeConfig { @@ -748,6 +748,14 @@ mod tests { color: true, dry_run: false, }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: true, + stdout_tty: true, + prompts_allowed: true, + confirmations_allowed: true, + }, paths: PathsConfig { profile: "interactive_user".into(), profile_source: "default".into(), diff --git a/tests/runtime_show.rs b/tests/runtime_show.rs @@ -118,6 +118,8 @@ fn config_show_json_reports_default_bootstrap_state() { assert_eq!(json["output"]["verbosity"], "normal"); assert_eq!(json["output"]["color"], true); assert_eq!(json["output"]["dry_run"], false); + assert_eq!(json["interaction"]["input_enabled"], true); + assert_eq!(json["interaction"]["assume_yes"], false); assert_eq!(json["paths"]["profile"], "interactive_user"); assert_eq!(json["paths"]["profile_source"], "default"); assert_eq!(json["paths"]["root_source"], "host_defaults"); @@ -508,10 +510,13 @@ fn config_show_json_reflects_global_output_flags() { let dir = tempdir().expect("tempdir"); let output = runtime_show_command_in(dir.path()) .args([ - "--json", + "--output", + "json", "--trace", "--dry-run", "--no-color", + "--no-input", + "--yes", "config", "show", ]) @@ -525,6 +530,8 @@ fn config_show_json_reflects_global_output_flags() { assert_eq!(json["output"]["verbosity"], "trace"); assert_eq!(json["output"]["color"], false); assert_eq!(json["output"]["dry_run"], true); + assert_eq!(json["interaction"]["input_enabled"], false); + assert_eq!(json["interaction"]["assume_yes"], true); } #[test]