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:
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]