Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions cli-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ toml_edit = { version = "0.22", features = ["serde"] }
tokio = { version = "1.48.0", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
tracing = "0.1.43"
termimad = "0.34.1"
inquire = "0.9.4"

[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.1", optional = true, default-features = false, features = ["async-secret-service", "tokio", "crypto-rust"] }
Expand Down
39 changes: 34 additions & 5 deletions cli-engine/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1696,13 +1696,41 @@ impl Cli {
});
}

let matches = match self.root.clone().try_get_matches_from(clap_args) {
let matches = match self.root.clone().try_get_matches_from(&clap_args) {
Ok(matches) => matches,
Err(err) => {
return self.finish_run(CliRunOutput {
exit_code: err.exit_code(),
rendered: err.to_string(),
});
// Attempt interactive recovery for missing required arguments.
if let Some(recovery) = crate::prompt::try_recover_missing_args(
&err,
&clap_args,
&self.root,
&self.config.name,
) {
match recovery {
crate::prompt::RecoveryResult::Recovered { args } => {
match self.root.clone().try_get_matches_from(args) {
Ok(m) => m,
Err(retry_err) => {
return self.finish_run(CliRunOutput {
exit_code: retry_err.exit_code(),
rendered: retry_err.to_string(),
});
}
}
}
crate::prompt::RecoveryResult::Cancelled { resume } => {
return self.finish_run(CliRunOutput {
exit_code: 130,
rendered: format!("Cancelled. Resume with:\n {resume}\n"),
});
}
}
} else {
return self.finish_run(CliRunOutput {
exit_code: err.exit_code(),
rendered: err.to_string(),
});
}
}
};

Expand Down Expand Up @@ -2583,6 +2611,7 @@ fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout:
middleware.schema = flags.schema;
middleware.timeout = timeout;
middleware.debug = flags.debug.clone();
middleware.interactive = flags.interactive;
}

/// Sets `middleware.limit`/`middleware.offset` from a paginating command's own
Expand Down
19 changes: 19 additions & 0 deletions cli-engine/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,25 @@ impl CommandContext {
self.middleware.dry_run
}

/// Returns the resolved interactivity mode for this invocation.
///
/// Use this to decide whether to prompt for missing inputs, show progress
/// spinners, or offer interactive choices. When `false`, the command should
/// fail with a descriptive error if required inputs are missing.
#[must_use]
pub fn is_interactive(&self) -> bool {
self.middleware.interactive
}

/// Returns the resolved [`InteractivityMode`](crate::InteractivityMode).
///
/// Equivalent to [`is_interactive`](Self::is_interactive) but returns the
/// enum for pattern matching.
#[must_use]
pub fn interactivity_mode(&self) -> crate::InteractivityMode {
self.middleware.interactive.into()
}

/// Resolves the active environment's merged TOML table for this
/// invocation, as an [`EnvSource`](crate::env_config::EnvSource).
///
Expand Down
141 changes: 139 additions & 2 deletions cli-engine/src/flags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,55 @@ use std::io::IsTerminal;

use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser};

/// Returns `true` when the process appears to be running interactively:
/// stdin and stderr are both TTYs.
///
/// Checking stdin ensures that piped input (`echo "" | gddy ...`) is detected
/// as non-interactive. Checking stderr ensures prompts can be displayed (since
/// `inquire` renders to stderr). Stdout is intentionally not checked — a user
/// piping output (`gddy ... | jq`) still has an interactive terminal for
/// prompts.
///
/// Used as the default for `GlobalFlags::interactive` when the user does not
/// pass `--interactive` or `--non-interactive` explicitly.
#[must_use]
pub fn detect_interactive() -> bool {
std::io::stdin().is_terminal() && std::io::stderr().is_terminal()
}

/// Interactivity mode for a CLI invocation.
///
/// Commands and middleware can inspect this to decide whether to prompt for
/// missing inputs, display progress spinners, or fall back to error messages
/// suitable for scripts and CI.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InteractivityMode {
/// The user explicitly requested interactive prompts (`--interactive`), or
/// the process is running in a TTY without CI indicators.
Interactive,
/// The user explicitly disabled prompts (`--non-interactive`), or the
/// process is running in a non-TTY / CI context.
NonInteractive,
}

impl InteractivityMode {
/// Returns `true` when prompts and interactive flows are appropriate.
#[must_use]
pub fn is_interactive(self) -> bool {
self == Self::Interactive
}
}

impl From<bool> for InteractivityMode {
fn from(interactive: bool) -> Self {
if interactive {
Self::Interactive
} else {
Self::NonInteractive
}
}
}

/// Parsed framework-global flags.
///
/// Applications can add their own global flags, but these are the built-in
Expand Down Expand Up @@ -31,6 +80,9 @@ pub struct GlobalFlags {
pub debug: String,
/// Credential storage override from `--credential-store`, if supplied.
pub credential_store: Option<crate::config::CredentialStore>,
/// Interactivity mode: `true` enables prompts for missing inputs,
/// `false` disables them. Auto-detected from TTY when neither flag is given.
pub interactive: bool,
}

impl Default for GlobalFlags {
Expand All @@ -47,6 +99,7 @@ impl Default for GlobalFlags {
timeout: "0s".to_owned(),
debug: String::new(),
credential_store: None,
interactive: detect_interactive(),
}
}
}
Expand Down Expand Up @@ -103,8 +156,9 @@ pub(crate) mod global_flag_order {
pub(crate) const JSON: usize = 1013;
pub(crate) const TOON: usize = 1014;
pub(crate) const HUMAN: usize = 1015;
pub(crate) const REASON: usize = 1016;
pub(crate) const ENV: usize = 1017;
pub(crate) const INTERACTIVE: usize = 1016;
pub(crate) const REASON: usize = 1017;
pub(crate) const ENV: usize = 1018;
}

/// Registers framework-global flags on a `clap` command.
Expand Down Expand Up @@ -244,6 +298,26 @@ pub fn register_global_flags(command: Command) -> Command {
.value_parser(|s: &str| s.parse::<crate::config::CredentialStore>())
.help("Credential storage: auto|keyring|file (overrides env and config)"),
)
.arg(
Arg::new("interactive")
.long("interactive")
.short('i')
.global(true)
.action(ArgAction::SetTrue)
.conflicts_with("non-interactive")
.display_order(global_flag_order::INTERACTIVE)
.help("Force interactive prompts for missing inputs (default when TTY is detected)"),
)
.arg(
Arg::new("non-interactive")
.long("non-interactive")
.global(true)
.action(ArgAction::SetTrue)
.conflicts_with("interactive")
.hide(true)
.display_order(global_flag_order::INTERACTIVE)
.help("Disable interactive prompts; fail on missing required inputs"),
)
.arg(
Arg::new("json")
.long("json")
Expand Down Expand Up @@ -511,6 +585,13 @@ pub fn global_flags_from_matches(matches: &ArgMatches, default_format: &str) ->
credential_store: matches
.get_one::<crate::config::CredentialStore>("credential-store")
.copied(),
interactive: if matches.get_flag("non-interactive") {
false
} else if matches.get_flag("interactive") {
true
} else {
detect_interactive()
},
}
}

Expand Down Expand Up @@ -852,4 +933,60 @@ mod tests {
help_text(&["testcli", "sub", "--help"])
);
}

#[test]
fn interactivity_mode_from_bool() {
use super::InteractivityMode;
assert_eq!(
InteractivityMode::from(true),
InteractivityMode::Interactive
);
assert_eq!(
InteractivityMode::from(false),
InteractivityMode::NonInteractive
);
assert!(InteractivityMode::Interactive.is_interactive());
assert!(!InteractivityMode::NonInteractive.is_interactive());
}

#[test]
fn interactive_flag_parsing_explicit_interactive() {
use super::global_flags_from_matches;
let cmd = register_global_flags(Command::new("test"));
let matches = cmd
.try_get_matches_from(["test", "--interactive"])
.expect("should parse");
let flags = global_flags_from_matches(&matches, "json");
assert!(flags.interactive);
}

#[test]
fn interactive_flag_parsing_explicit_non_interactive() {
use super::global_flags_from_matches;
let cmd = register_global_flags(Command::new("test"));
let matches = cmd
.try_get_matches_from(["test", "--non-interactive"])
.expect("should parse");
let flags = global_flags_from_matches(&matches, "json");
assert!(!flags.interactive);
}

#[test]
fn interactive_flag_conflicts() {
let cmd = register_global_flags(Command::new("test"));
let result = cmd.try_get_matches_from(["test", "--interactive", "--non-interactive"]);
assert!(result.is_err());
}

#[test]
fn detect_interactive_is_consistent_with_tty_state() {
// detect_interactive checks stdin + stderr TTY state.
// In CI (no real TTY), both are typically non-terminals → false.
// Locally in a real terminal, both are terminals → true.
// Either way, it should not panic and should be consistent.
let result = super::detect_interactive();
let stdin_tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
let stderr_tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
assert_eq!(result, stdin_tty && stderr_tty);
}
}
10 changes: 6 additions & 4 deletions cli-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ pub mod module;
/// Structured output envelopes, renderers, schemas, and field projection.
pub mod output;
/// Search indexing for commands, guides, and extra documents.
pub mod prompt;
pub mod search;
/// Command risk tiers used by authentication, authorization, and dry-run.
pub mod tier;
Expand Down Expand Up @@ -148,10 +149,11 @@ pub use error::{
};
pub use feature_flags::{FeatureFlag, FlagEntry, FlagPolicy, FlagRegistry, Stage};
pub use flags::{
GlobalFlags, app_id_env_prefix, debug_component_enabled, default_output_format,
derive_bool_flags, derive_value_flags, extract_command_path, extract_output_format,
global_flags_from_matches, has_true_schema_flag, min_stage_env_var, output_env_var,
register_global_flags, register_reason_flag, resolve_default_output_format,
GlobalFlags, InteractivityMode, app_id_env_prefix, debug_component_enabled,
default_output_format, derive_bool_flags, derive_value_flags, detect_interactive,
extract_command_path, extract_output_format, global_flags_from_matches, has_true_schema_flag,
min_stage_env_var, output_env_var, register_global_flags, register_reason_flag,
resolve_default_output_format,
};
pub use guide::{GuideEntry, parse_guides, parse_guides_from_markdown};
pub use middleware::{
Expand Down
2 changes: 2 additions & 0 deletions cli-engine/src/middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,8 @@ pub struct Middleware {
pub timeout: Option<Duration>,
/// Debug selector, interpreted by applications.
pub debug: String,
/// Whether the invocation is running in interactive mode.
pub interactive: bool,
/// Output schema registry.
pub schema_registry: SchemaRegistry,
/// Human output view registry.
Expand Down
Loading