Skip to main content

cli_engine/
cli.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    future::Future,
4    io::Write,
5    path::{Path, PathBuf},
6    process::ExitCode,
7    sync::{Arc, Mutex},
8    time::Duration,
9};
10
11mod builtins;
12mod completion;
13mod help;
14mod tree_render;
15
16use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser};
17
18use crate::{
19    ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec,
20    FeatureFlag, GroupSpec, GuideEntry, Middleware, MiddlewareRequest, Result, RuntimeCommandSpec,
21    RuntimeGroupSpec,
22    auth::commands::auth_command_group,
23    command::{
24        CommandContext, StreamSender, command_args_from_matches, command_path_from_matches,
25        leaf_matches,
26    },
27    error::exit_code_for_error,
28    feature_flags::{FlagEntry, FlagPolicy, FlagRegistry, Stage},
29    flags::{
30        GlobalFlags, derive_bool_flags, derive_value_flags, extract_command_path,
31        extract_output_format, global_flags_from_matches, has_true_schema_flag, min_stage_env_var,
32        output_env_var, register_global_flags, register_reason_flag, resolve_default_output_format,
33    },
34    guide::{guide_content, render_guide_human},
35    module::{Module, ModuleContext},
36    output::{
37        FieldInfo, HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry,
38        format_help_section, global_human_view_registry_snapshot, global_schema_registry_snapshot,
39    },
40    search::{SearchDocument, SearchIndex},
41};
42
43use builtins::{
44    completion_args, completion_command, guide_args, guide_command, help_args, help_command,
45    search_args, search_command,
46};
47use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE};
48pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human};
49
50/// Build metadata shown by the root `--version` flag.
51#[derive(Clone, Debug, Default, Eq, PartialEq)]
52pub struct BuildInfo {
53    /// Semantic version or other release label.
54    pub version: String,
55    /// Optional source control commit identifier.
56    pub commit: Option<String>,
57    /// Optional build date string.
58    pub date: Option<String>,
59}
60
61impl BuildInfo {
62    /// Creates build metadata with only a version string.
63    #[must_use]
64    pub fn new(version: impl Into<String>) -> Self {
65        Self {
66            version: version.into(),
67            commit: None,
68            date: None,
69        }
70    }
71
72    /// Adds a commit identifier to the version string shown by `--version`.
73    #[must_use]
74    pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
75        self.commit = Some(commit.into());
76        self
77    }
78
79    /// Adds a build date to the version string shown by `--version`.
80    #[must_use]
81    pub fn with_date(mut self, date: impl Into<String>) -> Self {
82        self.date = Some(date.into());
83        self
84    }
85
86    /// Returns the rendered version string used by the root `--version` flag.
87    #[must_use]
88    pub fn version_string(&self) -> String {
89        let commit = self.commit.as_deref().unwrap_or_default();
90        let date = self.date.as_deref().unwrap_or_default();
91
92        if commit.is_empty() && date.is_empty() {
93            self.version.clone()
94        } else {
95            format!("{} (commit {commit}, built {date})", self.version)
96        }
97    }
98}
99
100/// Late dependency initializer run once before real command execution.
101pub type InitDeps = Arc<dyn Fn(&mut Middleware) -> Result<()> + Send + Sync>;
102/// Hook used to add application-specific global flags to the root `clap` command.
103pub type RegisterFlags = Arc<dyn Fn(Command) -> Command + Send + Sync>;
104/// Hook used to copy parsed application-specific flags into middleware.
105pub type ApplyFlags = Arc<dyn Fn(&ArgMatches, &mut Middleware) -> Result<()> + Send + Sync>;
106/// Hook run immediately before executable commands and built-ins.
107pub type PreRun =
108    Arc<dyn Fn(&mut Middleware, &str, &crate::middleware::ValueMap) -> Result<()> + Send + Sync>;
109/// Hook used to adjust command metadata globally before middleware executes.
110pub type ResolveMeta = Arc<dyn Fn(&str, CommandMeta) -> CommandMeta + Send + Sync>;
111/// Hook called after a CLI run completes.
112pub type OnShutdown = Arc<dyn Fn() + Send + Sync>;
113/// Hook that contributes extra root-scope `search` documents.
114pub type ExtraSearchDocs = Arc<dyn Fn() -> Vec<SearchDocument> + Send + Sync>;
115/// Hook that supplies the suggested next actions shown when the CLI is invoked
116/// with no subcommand (bare root). The same actions drive a human "Next actions"
117/// section and the JSON discovery envelope.
118pub type RootNextActions = Arc<dyn Fn() -> Vec<NextAction> + Send + Sync>;
119
120/// Default name for the admin help category, under which the engine files the
121/// built-in `auth` command when a consumer does not override it via
122/// [`CliConfig::with_admin_category`].
123const DEFAULT_ADMIN_CATEGORY: &str = "Admin";
124
125/// Maximum number of chained `argv0` dispatch hand-offs before the engine
126/// refuses to recurse further. Real multi-call nesting is zero or one level;
127/// this bounds a pathologically long explicit `argv0 … argv0 …` chain so it
128/// errors cleanly instead of overflowing the stack.
129const MAX_ARGV0_DEPTH: usize = 16;
130
131/// How the engine behaves when invoked under a registered alternative `argv[0]`
132/// name (busybox/git-style multi-call dispatch).
133///
134/// A route is selected when the binary's `argv[0]` basename — or the name given
135/// to the hidden `argv0` command — matches a key registered via
136/// [`CliConfig::with_argv0_alias`] or [`CliConfig::with_argv0_personality`]. An
137/// `argv[0]` that matches no route falls through to the default CLI, so existing
138/// applications that register no routes are unaffected.
139///
140/// Non-exhaustive: more route kinds may be added in future releases. Register
141/// routes through the [`CliConfig`] builders rather than matching on variants.
142#[derive(Clone)]
143#[non_exhaustive]
144pub enum Argv0Route {
145    /// Rewrite the invocation into these canonical subcommand tokens and run it
146    /// through the normal command tree, with the real argument tail appended.
147    ///
148    /// For example, an `Alias(vec!["project".into(), "list".into()])` registered
149    /// under `pl` makes `pl --team x` behave exactly like `project list --team x`.
150    Alias(Vec<String>),
151    /// Run an entirely separate CLI application built from the returned
152    /// [`CliConfig`] (its own root name, commands, flags, and auth). The
153    /// configuration is built lazily, only when the route is actually dispatched,
154    /// so registering a personality costs nothing for invocations that never hit it.
155    Personality(Arc<dyn Fn() -> CliConfig + Send + Sync>),
156}
157
158impl std::fmt::Debug for Argv0Route {
159    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            Self::Alias(tokens) => formatter.debug_tuple("Alias").field(tokens).finish(),
162            Self::Personality(_) => formatter.write_str("Personality(..)"),
163        }
164    }
165}
166
167/// On-disk mechanism used by [`Cli::create_link`] to materialize an alternative
168/// `argv[0]` name so the binary can be invoked under it.
169///
170/// Installers pick the mechanism that suits the platform and environment;
171/// self-healing code can re-run [`Cli::create_link`] to restore a deleted link.
172///
173/// Non-exhaustive: more link mechanisms may be added in future releases.
174#[derive(Clone, Copy, Debug, Eq, PartialEq)]
175#[non_exhaustive]
176pub enum Argv0LinkMethod {
177    /// A symbolic link to the target executable (`<name>` on Unix, `<name>.exe`
178    /// on Windows). On Windows this may require Developer Mode or elevation.
179    SoftLink,
180    /// A hard link to the target executable (`<name>` on Unix, `<name>.exe` on
181    /// Windows). The link must live on the same volume as the target.
182    HardLink,
183    /// A small shim script that forwards to the target via the `argv0` command:
184    /// a `<name>.cmd` batch file on Windows, or an executable `<name>` shell
185    /// script on Unix. Useful when links are unavailable or inconvenient.
186    Script,
187}
188
189/// Top-level subcommand names that are reserved by the engine and must not be
190/// used as module group names.  [`Cli::add_module_group`] rejects a group whose
191/// name matches a reserved name so the engine's built-in command always wins.
192pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 5] =
193    ["help", "guide", "tree", "completion", "search"];
194
195/// Declarative configuration for a CLI application.
196///
197/// Use [`CliConfig::new`] for the common path and chain `with_*` methods for
198/// modules, auth providers, guides, views, and lifecycle hooks. Direct struct
199/// literals remain available for advanced setup and tests.
200#[derive(Clone, Default)]
201pub struct CliConfig {
202    /// Root command name shown in usage output.
203    pub name: String,
204    /// One-line root command description.
205    pub short: String,
206    /// Optional longer root command description. Defaults to `short`.
207    pub long: Option<String>,
208    /// Version/build metadata for `--version`.
209    pub build: BuildInfo,
210    /// Application id stored in middleware and output metadata.
211    pub app_id: String,
212    /// Fallback auth provider when a command does not select one explicitly.
213    pub default_auth_provider: Option<String>,
214    /// Domain modules mounted under the root command.
215    pub modules: Vec<Module>,
216    /// Additional top-level runtime commands.
217    pub commands: Vec<RuntimeCommandSpec>,
218    /// Additional commands mounted as siblings of the built-in `auth`
219    /// group's `login`/`status`/`logout` (e.g. `auth scopes`). Populate via
220    /// [`CliConfig::with_auth_extra_commands`]; folded in internally after
221    /// the built-in group is built, so the built-ins are never lost or
222    /// overwritten.
223    pub auth_extra_commands: Vec<RuntimeCommandSpec>,
224    /// Global guide entries mounted under `guide`.
225    pub guides: Vec<GuideEntry>,
226    /// Global human output views.
227    pub views: Vec<HumanViewDef>,
228    /// Providers registered before command execution starts.
229    pub auth_providers: Vec<Arc<dyn AuthProvider>>,
230    /// Optional override for the process-wide outbound User-Agent. When unset,
231    /// the engine derives `name/version` from this config. See
232    /// [`CliConfig::user_agent_string`].
233    pub user_agent: Option<String>,
234    /// Extra HTTP header names to redact in `--debug transport` output, on top
235    /// of the built-in sensitive set (`authorization`, `proxy-authorization`,
236    /// `cookie`, `set-cookie`, `x-api-key`). Set CLI-specific secret-bearing
237    /// headers here — e.g. a custom API-key header an auth injector adds.
238    /// Populate via [`CliConfig::with_redacted_debug_headers`].
239    pub redacted_debug_headers: Vec<String>,
240    /// Optional authorization gatekeeper injected into middleware.
241    pub authz: Option<Arc<dyn Authorizer>>,
242    /// Optional audit recorder injected into middleware.
243    pub auditor: Option<Arc<dyn Auditor>>,
244    /// Optional activity event sink injected into middleware.
245    pub activity: Option<Arc<dyn ActivityEmitter>>,
246    /// Optional late initializer for runtime dependencies.
247    pub init_deps: Option<InitDeps>,
248    /// Optional hook for adding application-specific global flags.
249    pub register_flags: Option<RegisterFlags>,
250    /// Optional hook for applying parsed application-specific flags.
251    pub apply_flags: Option<ApplyFlags>,
252    /// Optional hook run before executable commands and built-ins.
253    pub pre_run: Option<PreRun>,
254    /// Optional hook for global command metadata adjustments.
255    pub meta_resolver: Option<ResolveMeta>,
256    /// Optional hook called after each run.
257    pub on_shutdown: Option<OnShutdown>,
258    /// Optional root-scope search document provider.
259    pub extra_search_docs: Option<ExtraSearchDocs>,
260    /// Optional provider for the bare-root suggested next actions.
261    pub root_next_actions: Option<RootNextActions>,
262    /// Name of the admin help category. The engine files its built-in `auth`
263    /// command under this heading; apps should use the same name for their own
264    /// admin modules (e.g. godaddy's `env`). When unset, defaults to `"Admin"`;
265    /// set it to match a consumer's own taxonomy (e.g. gdx's "Administration").
266    pub admin_category: Option<String>,
267    /// Whether to mount the built-in `config` command group (`config
268    /// get`/`set`/`path`/`list`). Off by default to avoid colliding with a
269    /// consumer's own `config` noun. Enable via
270    /// [`CliConfig::with_config_commands`].
271    pub config_commands: bool,
272    /// Alternative `argv[0]` names this binary may be invoked as, mapped to the
273    /// behavior the engine should take (busybox/git-style multi-call dispatch).
274    ///
275    /// Keyed by the bare alternative name (no path, no extension). Empty by
276    /// default, in which case argv0 dispatch is inert and behavior is identical
277    /// to a binary that never opted in. Populate via [`CliConfig::with_argv0_alias`]
278    /// and [`CliConfig::with_argv0_personality`].
279    pub argv0_routes: BTreeMap<String, Argv0Route>,
280    /// Optional first-class environment system.
281    ///
282    /// Registered via [`CliConfig::with_environments`]. When set, the engine
283    /// registers a global `--env` flag, seeds the active environment into
284    /// middleware, and exposes it to handlers through
285    /// [`CommandContext::environment`](crate::command::CommandContext::environment).
286    pub environments: Option<Arc<crate::environments::Environments>>,
287    /// Explicit argv override for [`Cli::new`]'s startup `--env` prescan,
288    /// mainly used to make tests hermetic.
289    pub startup_args: Option<Vec<std::ffi::OsString>>,
290    /// Minimum feature stage required for a flagged command, group, or module
291    /// to remain mounted.
292    ///
293    /// Defaults to [`Stage::Ga`] via [`Stage`]'s own `Default`, which combined
294    /// with an empty [`feature_overrides`](Self::feature_overrides) is the
295    /// zero-config behavior: nothing is gated unless a command/group/module
296    /// opts in with `.with_feature_flag(...)`, and even then it stays visible
297    /// until this is lowered. Lower it (e.g. to [`Stage::Beta`] or
298    /// [`Stage::Experimental`]) to opt a build or environment into
299    /// pre-release commands. Set via [`CliConfig::with_min_stage`].
300    pub min_stage: Stage,
301    /// Per-key stage overrides that substitute a forced stage for a flag
302    /// key's own declared stage before comparing against
303    /// [`min_stage`](Self::min_stage).
304    ///
305    /// Empty by default. Populate via [`CliConfig::with_feature_override`] to
306    /// force one named flag to a specific effective stage — e.g. forcing a
307    /// single flag to [`Stage::Ga`] to turn it on for internal testing without
308    /// lowering [`min_stage`](Self::min_stage) for every other flagged
309    /// command, or forcing it to [`Stage::Experimental`] to disable it even
310    /// under a permissive `min_stage`. See [`FlagPolicy::visible`] for the
311    /// exact comparison.
312    pub feature_overrides: BTreeMap<String, Stage>,
313    /// Whether to auto-enable interactive mode when a TTY is detected.
314    ///
315    /// When `false` (the default), commands only run interactively if the user
316    /// passes `--interactive` explicitly. When `true`, the engine auto-detects
317    /// a TTY (stdin + stderr) and defaults to interactive mode — meaning
318    /// missing required arguments will be prompted for instead of erroring.
319    ///
320    /// Set via [`CliConfig::with_auto_interactive`]. Start with `false` for
321    /// backwards compatibility; flip to `true` once the CLI's commands have
322    /// been tested under interactive prompting.
323    pub auto_interactive: bool,
324}
325
326impl CliConfig {
327    /// Creates the minimum useful CLI configuration.
328    #[must_use]
329    pub fn new(
330        name: impl Into<String>,
331        short: impl Into<String>,
332        app_id: impl Into<String>,
333    ) -> Self {
334        Self {
335            name: name.into(),
336            short: short.into(),
337            app_id: app_id.into(),
338            ..Self::default()
339        }
340    }
341
342    /// Sets root long help text.
343    #[must_use]
344    pub fn with_long(mut self, long: impl Into<String>) -> Self {
345        self.long = Some(long.into());
346        self
347    }
348
349    /// Sets build metadata used by `--version`.
350    #[must_use]
351    pub fn with_build(mut self, build: BuildInfo) -> Self {
352        self.build = build;
353        self
354    }
355
356    /// Sets the fallback auth provider for commands that do not name one.
357    #[must_use]
358    pub fn with_default_auth_provider(mut self, provider: impl Into<String>) -> Self {
359        self.default_auth_provider = Some(provider.into());
360        self
361    }
362
363    /// Registers a first-class environment system.
364    ///
365    /// When set, [`Cli::new`] registers a global `--env` flag, seeds the active
366    /// environment into middleware (explicit `--env` > persisted active >
367    /// configured default), and exposes the resolved environment to handlers via
368    /// [`CommandContext::environment`](crate::command::CommandContext::environment).
369    ///
370    /// The [`Environments`](crate::environments::Environments) is stored as-is, so
371    /// the consumer is responsible for configuring it before wrapping it in an
372    /// `Arc`:
373    ///
374    /// - Call
375    ///   [`Environments::with_app_id`](crate::environments::Environments::with_app_id)
376    ///   with the **same** `app_id` passed to [`CliConfig::new`], so the config
377    ///   file and active-environment persistence resolve to the application's
378    ///   config directory. (An empty `app_id` makes
379    ///   [`Environments::config_file_path`](crate::environments::Environments::config_file_path)
380    ///   return `None`, silently disabling the `environments.toml` file layer.)
381    /// - Call
382    ///   [`Environments::with_config_file(true)`](crate::environments::Environments::with_config_file)
383    ///   if the application loads a user-editable `environments.toml`.
384    /// - **Share the same `Arc`** with any `PkceAuthProvider::with_environments`
385    ///   (available with the `pkce-auth` feature):
386    ///   the provider's OAuth file layer and active-environment persistence must
387    ///   resolve against the identical, `app_id`-stamped instance the engine sees,
388    ///   or a file-defined environment (or a file override of a compiled
389    ///   environment's `client_id`) will be visible to `env info` yet invisible to
390    ///   the actual OAuth login.
391    #[must_use]
392    pub fn with_environments(
393        mut self,
394        environments: Arc<crate::environments::Environments>,
395    ) -> Self {
396        self.environments = Some(environments);
397        self
398    }
399
400    /// Overrides the argv [`Cli::new`] prescans for `--env` before pruning the
401    /// command tree, instead of the real process argv.
402    ///
403    /// Only meaningful alongside [`with_environments`](Self::with_environments)
404    /// — otherwise `Cli::new` never registers `--env` or does the prescan at
405    /// all, so this is silently unused. Element `0` is treated as the program
406    /// name and skipped, the same convention [`Cli::run`]/[`Cli::execute_from`]
407    /// use for their own `args` parameter.
408    ///
409    /// This matters beyond tests: tree pruning is decided once, at `Cli::new`
410    /// time, from either this override or real process argv — never from the
411    /// `args` a later [`Cli::run`]/[`Cli::execute_from`] call receives. Any
412    /// caller that builds the `Cli` once and later runs it with a synthetic
413    /// argv (e.g. a wrapper binary invoking it programmatically, or a fixed
414    /// argument list unrelated to `std::env::args_os()`) should pass the same
415    /// `--env` here too, or an environment named only in the later call's
416    /// argv won't have been consulted for pruning, and a flagged command that
417    /// environment would reveal (or hide) can disagree with what actually
418    /// dispatches. A test that configures `with_environments` should call
419    /// this (even with an empty iterator) to keep construction hermetic;
420    /// without it, `Cli::new` reads whatever real argv the test binary itself
421    /// was invoked with.
422    #[must_use]
423    pub fn with_startup_args<I, S>(mut self, args: I) -> Self
424    where
425        I: IntoIterator<Item = S>,
426        S: Into<std::ffi::OsString>,
427    {
428        self.startup_args = Some(args.into_iter().map(Into::into).collect());
429        self
430    }
431
432    /// Sets the minimum feature stage required for a flagged command, group,
433    /// or module to remain mounted.
434    ///
435    /// See [`min_stage`](Self::min_stage) for the default and [`FlagPolicy`]
436    /// for how it combines with [`feature_overrides`](Self::feature_overrides)
437    /// during command-tree pruning.
438    #[must_use]
439    pub fn with_min_stage(mut self, stage: Stage) -> Self {
440        self.min_stage = stage;
441        self
442    }
443
444    /// Enables auto-interactive mode: when a TTY is detected, the CLI
445    /// defaults to interactive prompting for missing required arguments.
446    ///
447    /// Off by default for backwards compatibility. Enable once commands have
448    /// been tested under interactive prompting. `--interactive` still works as
449    /// an explicit override regardless of this setting.
450    #[must_use]
451    pub fn with_auto_interactive(mut self, enabled: bool) -> Self {
452        self.auto_interactive = enabled;
453        self
454    }
455
456    /// Adds (or replaces) a per-key feature-flag stage override.
457    ///
458    /// See [`feature_overrides`](Self::feature_overrides) for how the
459    /// override participates in the [`FlagPolicy::visible`] comparison.
460    #[must_use]
461    pub fn with_feature_override(mut self, key: impl Into<String>, stage: Stage) -> Self {
462        self.feature_overrides.insert(key.into(), stage);
463        self
464    }
465
466    /// Builds the merged [`FlagPolicy`] used for command-tree pruning from
467    /// this config's `min_stage` and `feature_overrides`.
468    fn flag_policy(&self) -> FlagPolicy {
469        FlagPolicy {
470            min_stage: self.min_stage,
471            overrides: self.feature_overrides.clone(),
472        }
473    }
474
475    /// Overrides the outbound User-Agent string for all HTTP traffic.
476    ///
477    /// When unset, the engine derives `name/version` from this config (see
478    /// [`CliConfig::user_agent_string`]). Set this when the upstream APIs expect
479    /// a specific product token. The resolved value is applied process-wide on
480    /// execution via [`crate::transport::set_default_user_agent`], so it reaches
481    /// both command [`HttpClient`](crate::transport::HttpClient)s and the
482    /// engine's own OAuth token requests.
483    #[must_use]
484    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
485        self.user_agent = Some(user_agent.into());
486        self
487    }
488
489    /// Adds HTTP header names to redact in `--debug transport` output, on top of
490    /// the built-in sensitive set.
491    ///
492    /// Use this for CLI-specific secret-bearing headers that are not standard
493    /// auth headers — for example a custom API-key header that an
494    /// [`AuthInjector`](crate::transport::AuthInjector) sets. Matching is
495    /// case-insensitive and additive: the built-in set is always redacted.
496    /// Calls accumulate. Names are trimmed and empty entries are dropped, so a
497    /// mistyped value with stray whitespace cannot silently disable redaction.
498    #[must_use]
499    pub fn with_redacted_debug_headers(
500        mut self,
501        names: impl IntoIterator<Item = impl Into<String>>,
502    ) -> Self {
503        self.redacted_debug_headers
504            .extend(names.into_iter().filter_map(|name| {
505                let name = name.into().trim().to_owned();
506                (!name.is_empty()).then_some(name)
507            }));
508        self
509    }
510
511    /// Returns the outbound User-Agent string the CLI presents on HTTP requests.
512    ///
513    /// Resolution order:
514    /// 1. an explicit [`with_user_agent`](Self::with_user_agent) override;
515    /// 2. otherwise `name/version` (for example `gdx/1.2.3`);
516    /// 3. otherwise just `name` when no build version is set.
517    #[must_use]
518    pub fn user_agent_string(&self) -> String {
519        if let Some(user_agent) = &self.user_agent {
520            return user_agent.clone();
521        }
522        if self.build.version.is_empty() {
523            self.name.clone()
524        } else {
525            format!("{}/{}", self.name, self.build.version)
526        }
527    }
528
529    /// Adds one domain module.
530    ///
531    /// # Reserved group names
532    ///
533    /// The top-level group names `help`, `guide`, `tree`, and `completion` are
534    /// reserved by the engine.  A module whose root group uses one of these
535    /// names will be rejected at registration time (logged as a warning) so
536    /// the engine's own built-in always takes precedence in the command tree.
537    #[must_use]
538    pub fn with_module(mut self, module: Module) -> Self {
539        self.modules.push(module);
540        self
541    }
542
543    /// Adds several domain modules.
544    ///
545    /// See [`with_module`](Self::with_module) for the list of reserved group names.
546    #[must_use]
547    pub fn with_modules(mut self, modules: impl IntoIterator<Item = Module>) -> Self {
548        self.modules.extend(modules);
549        self
550    }
551
552    /// Adds a top-level runtime command outside a module.
553    #[must_use]
554    pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
555        self.commands.push(command);
556        self
557    }
558
559    /// Adds commands mounted as siblings of the built-in `auth` group's
560    /// `login`/`status`/`logout`.
561    ///
562    /// Use this to extend `auth` with consumer-specific subcommands (e.g.
563    /// `auth scopes`) without losing or duplicating the built-ins — unlike
564    /// pre-registering an `auth` [`Module`], which either drops the built-ins
565    /// entirely or has them silently overwrite any extra command added this
566    /// way, these are folded in additively after building the built-in group.
567    #[must_use]
568    pub fn with_auth_extra_commands(
569        mut self,
570        commands: impl IntoIterator<Item = RuntimeCommandSpec>,
571    ) -> Self {
572        self.auth_extra_commands.extend(commands);
573        self
574    }
575
576    /// Adds one global guide.
577    #[must_use]
578    pub fn with_guide(mut self, guide: GuideEntry) -> Self {
579        self.guides.push(guide);
580        self
581    }
582
583    /// Adds several global guides.
584    #[must_use]
585    pub fn with_guides(mut self, guides: impl IntoIterator<Item = GuideEntry>) -> Self {
586        self.guides.extend(guides);
587        self
588    }
589
590    /// Adds one global human view.
591    #[must_use]
592    pub fn with_view(mut self, view: HumanViewDef) -> Self {
593        self.views.push(view);
594        self
595    }
596
597    /// Registers one auth provider.
598    #[must_use]
599    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
600        self.auth_providers.push(provider);
601        self
602    }
603
604    /// Sets the authorization gatekeeper.
605    #[must_use]
606    pub fn with_authz(mut self, authz: Arc<dyn Authorizer>) -> Self {
607        self.authz = Some(authz);
608        self
609    }
610
611    /// Sets the audit recorder.
612    #[must_use]
613    pub fn with_auditor(mut self, auditor: Arc<dyn Auditor>) -> Self {
614        self.auditor = Some(auditor);
615        self
616    }
617
618    /// Sets the activity event sink.
619    #[must_use]
620    pub fn with_activity(mut self, activity: Arc<dyn ActivityEmitter>) -> Self {
621        self.activity = Some(activity);
622        self
623    }
624
625    /// Sets the late dependency initializer.
626    #[must_use]
627    pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self {
628        self.init_deps = Some(init_deps);
629        self
630    }
631
632    /// Sets the application-specific global flag registration hook.
633    #[must_use]
634    pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self {
635        self.register_flags = Some(register_flags);
636        self
637    }
638
639    /// Sets the application-specific parsed flag application hook.
640    #[must_use]
641    pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self {
642        self.apply_flags = Some(apply_flags);
643        self
644    }
645
646    /// Sets the pre-run hook.
647    #[must_use]
648    pub fn with_pre_run(mut self, pre_run: PreRun) -> Self {
649        self.pre_run = Some(pre_run);
650        self
651    }
652
653    /// Sets the command metadata resolver hook.
654    #[must_use]
655    pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self {
656        self.meta_resolver = Some(meta_resolver);
657        self
658    }
659
660    /// Sets the shutdown hook.
661    #[must_use]
662    pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self {
663        self.on_shutdown = Some(on_shutdown);
664        self
665    }
666
667    /// Sets the provider for additional root-scope search documents.
668    #[must_use]
669    pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self {
670        self.extra_search_docs = Some(extra_search_docs);
671        self
672    }
673
674    /// Sets the provider for the bare-root suggested next actions.
675    #[must_use]
676    pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self {
677        self.root_next_actions = Some(root_next_actions);
678        self
679    }
680
681    /// Sets the name of the admin help category. The engine files the built-in
682    /// `auth` command there; apps should use the same name for their own admin
683    /// modules (e.g. godaddy's `env`). Optional: defaults to `"Admin"`.
684    #[must_use]
685    pub fn with_admin_category(mut self, category: impl Into<String>) -> Self {
686        self.admin_category = Some(category.into());
687        self
688    }
689
690    /// Mounts the built-in `config` command group (`config get`/`set`/`path`/
691    /// `list`) for reading and writing the per-application config file.
692    ///
693    /// Off by default so it never collides with a consumer's own `config` noun;
694    /// the group is filed under the admin help category when enabled.
695    #[must_use]
696    pub fn with_config_commands(mut self) -> Self {
697        self.config_commands = true;
698        self
699    }
700
701    /// Registers an alternative `argv[0]` name that acts as a shortcut to a
702    /// command path on this same CLI.
703    ///
704    /// When the binary is invoked under `name` (via symlink, hardlink, copy, or
705    /// the hidden `argv0` command), the engine behaves as if the user had typed
706    /// `command_path` followed by the real argument tail, routed through the
707    /// normal command tree. For example:
708    ///
709    /// ```
710    /// use cli_engine::CliConfig;
711    ///
712    /// // Invoking the binary as `pl --team platform` runs `project list --team platform`.
713    /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli")
714    ///     .with_argv0_alias("pl", ["project", "list"]);
715    /// ```
716    ///
717    /// `name` must be a simple token: non-empty and composed only of ASCII
718    /// letters, digits, `-`, or `_` (no dots, spaces, path separators, or shell
719    /// metacharacters), and it must differ from the CLI's own name. These are
720    /// debug-asserted. The restriction keeps the name usable as a link/shim
721    /// filename and an `argv[0]` basename (which is matched with its extension
722    /// stripped, so a dot would break matching).
723    #[must_use]
724    pub fn with_argv0_alias(
725        mut self,
726        name: impl Into<String>,
727        command_path: impl IntoIterator<Item = impl Into<String>>,
728    ) -> Self {
729        let name = name.into();
730        debug_assert!(
731            is_valid_argv0_name(&name),
732            "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
733        );
734        debug_assert!(
735            name != self.name,
736            "argv0 route name {name:?} must differ from the CLI's own name {:?}",
737            self.name
738        );
739        let tokens = command_path.into_iter().map(Into::into).collect();
740        self.argv0_routes.insert(name, Argv0Route::Alias(tokens));
741        self
742    }
743
744    /// Registers an alternative `argv[0]` name that runs an entirely separate CLI
745    /// application.
746    ///
747    /// When the binary is invoked under `name`, the engine builds a fresh
748    /// [`CliConfig`] from `build` and runs that application instead — its own root
749    /// name, commands, flags, and auth. The closure runs lazily, only when the
750    /// route is dispatched, so unused personalities cost nothing. The personality
751    /// presents the name from its own [`CliConfig`] in help and usage output.
752    ///
753    /// ```
754    /// use cli_engine::CliConfig;
755    ///
756    /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli")
757    ///     .with_argv0_personality("legacy-tool", || {
758    ///         CliConfig::new("legacy-tool", "Legacy compatibility shim", "legacy-tool")
759    ///     });
760    /// ```
761    ///
762    /// `name` follows the same contract as [`CliConfig::with_argv0_alias`]: a
763    /// simple `[A-Za-z0-9_-]` token, differing from the CLI's own name
764    /// (debug-asserted).
765    #[must_use]
766    pub fn with_argv0_personality(
767        mut self,
768        name: impl Into<String>,
769        build: impl Fn() -> CliConfig + Send + Sync + 'static,
770    ) -> Self {
771        let name = name.into();
772        debug_assert!(
773            is_valid_argv0_name(&name),
774            "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
775        );
776        debug_assert!(
777            name != self.name,
778            "argv0 route name {name:?} must differ from the CLI's own name {:?}",
779            self.name
780        );
781        self.argv0_routes
782            .insert(name, Argv0Route::Personality(Arc::new(build)));
783        self
784    }
785}
786
787impl std::fmt::Debug for CliConfig {
788    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
789        formatter
790            .debug_struct("CliConfig")
791            .field("name", &self.name)
792            .field("short", &self.short)
793            .field("long", &self.long)
794            .field("build", &self.build)
795            .field("app_id", &self.app_id)
796            .field("default_auth_provider", &self.default_auth_provider)
797            .field("modules", &self.modules)
798            .field("commands", &self.commands)
799            .field("guides", &self.guides)
800            .field("views", &self.views)
801            .field("auth_providers_len", &self.auth_providers.len())
802            .field("has_authz", &self.authz.is_some())
803            .field("has_auditor", &self.auditor.is_some())
804            .field("has_activity", &self.activity.is_some())
805            .field("has_init_deps", &self.init_deps.is_some())
806            .field("has_register_flags", &self.register_flags.is_some())
807            .field("has_apply_flags", &self.apply_flags.is_some())
808            .field("has_pre_run", &self.pre_run.is_some())
809            .field("has_meta_resolver", &self.meta_resolver.is_some())
810            .field("has_on_shutdown", &self.on_shutdown.is_some())
811            .field("has_extra_search_docs", &self.extra_search_docs.is_some())
812            .field("has_root_next_actions", &self.root_next_actions.is_some())
813            .field("admin_category", &self.admin_category)
814            .field(
815                "argv0_routes",
816                &self.argv0_routes.keys().collect::<Vec<_>>(),
817            )
818            .field("min_stage", &self.min_stage)
819            .field("feature_overrides", &self.feature_overrides)
820            .finish()
821    }
822}
823
824/// Captured result of running a CLI in tests or embedding contexts.
825#[derive(Clone, Debug, PartialEq)]
826pub struct CliRunOutput {
827    /// Process-style exit code.
828    pub exit_code: i32,
829    /// Rendered stdout or stderr payload.
830    pub rendered: String,
831}
832
833impl From<crate::middleware::MiddlewareOutput> for CliRunOutput {
834    fn from(o: crate::middleware::MiddlewareOutput) -> Self {
835        Self {
836            exit_code: o.exit_code,
837            rendered: o.rendered,
838        }
839    }
840}
841
842/// Configured CLI application.
843///
844/// A `Cli` owns the `clap` command tree, middleware, registered runtime
845/// commands, guides, schemas, and built-ins. Consumer binaries normally create
846/// one `Cli` and call [`Cli::execute`].
847#[derive(Clone)]
848pub struct Cli {
849    config: CliConfig,
850    middleware: Middleware,
851    root: Command,
852    commands: BTreeMap<String, RuntimeCommandSpec>,
853    module_entries: Vec<ModuleHelpEntry>,
854    guide_entries: Vec<GuideEntry>,
855    init_deps: Option<InitDeps>,
856    apply_flags: Option<ApplyFlags>,
857    pre_run: Option<PreRun>,
858    meta_resolver: Option<ResolveMeta>,
859    on_shutdown: Option<OnShutdown>,
860    extra_search_docs: Option<ExtraSearchDocs>,
861    root_next_actions: Option<RootNextActions>,
862    init_state: Arc<Mutex<Option<std::result::Result<Middleware, InitFailure>>>>,
863}
864
865#[derive(Clone, Debug, Eq, PartialEq)]
866struct InitFailure {
867    message: String,
868    code: String,
869    system: String,
870    request_id: String,
871    fix: Option<String>,
872    exit_code: i32,
873}
874
875impl InitFailure {
876    fn capture(err: &CliCoreError) -> Self {
877        let envelope = crate::output::build_error_envelope(err, "");
878        let (code, system, request_id) = envelope.error.map_or_else(
879            || ("ERROR".to_owned(), String::new(), String::new()),
880            |error| (error.code, error.system, error.request_id),
881        );
882        Self {
883            message: err.to_string(),
884            code,
885            system,
886            request_id,
887            fix: envelope.fix,
888            exit_code: exit_code_for_error(err),
889        }
890    }
891
892    fn into_error(self) -> CliCoreError {
893        let message = CliCoreError::SystemMessage {
894            message: self.message,
895            system: self.system,
896            code: self.code,
897            request_id: self.request_id,
898        };
899        CliCoreError::with_exit_code(
900            self.exit_code,
901            CliCoreError::with_fix(self.fix.unwrap_or_default(), message),
902        )
903    }
904}
905
906impl std::fmt::Debug for Cli {
907    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908        formatter
909            .debug_struct("Cli")
910            .field("config", &self.config)
911            .field("middleware", &self.middleware)
912            .field("root", &self.root)
913            .field("commands", &self.commands)
914            .field("module_entries", &self.module_entries)
915            .field("guide_entries", &self.guide_entries)
916            .field("has_init_deps", &self.init_deps.is_some())
917            .field("has_apply_flags", &self.apply_flags.is_some())
918            .field("has_pre_run", &self.pre_run.is_some())
919            .field("has_meta_resolver", &self.meta_resolver.is_some())
920            .field("has_on_shutdown", &self.on_shutdown.is_some())
921            .field("has_extra_search_docs", &self.extra_search_docs.is_some())
922            .field("has_root_next_actions", &self.root_next_actions.is_some())
923            .finish()
924    }
925}
926
927impl Cli {
928    /// Builds a CLI application from declarative configuration.
929    #[must_use]
930    pub fn new(config: CliConfig) -> Self {
931        let auth_providers = config.auth_providers.clone();
932        let guides = config.guides.clone();
933        let views = config.views.clone();
934        let modules = config.modules.clone();
935        let commands = config.commands.clone();
936        let init_deps = config.init_deps.clone();
937        let apply_flags = config.apply_flags.clone();
938        let pre_run = config.pre_run.clone();
939        let meta_resolver = config.meta_resolver.clone();
940        let on_shutdown = config.on_shutdown.clone();
941        let extra_search_docs = config.extra_search_docs.clone();
942        let root_next_actions = config.root_next_actions.clone();
943        let mut root = Command::new(config.name.clone())
944            .about(config.short.clone())
945            .disable_help_subcommand(true)
946            .version(config.build.version_string());
947        if let Some(long) = &config.long
948            && !long.is_empty()
949        {
950            root = root.long_about(long.clone());
951        }
952        root = register_global_flags(root)
953            .subcommand(help_command())
954            .subcommand(guide_command())
955            .subcommand(Command::new("tree").about("Display full command tree"))
956            .subcommand(completion_command())
957            .subcommand(search_command());
958        if let Some(register_flags) = &config.register_flags {
959            root = register_flags(root);
960        }
961        // `--reason` is only meaningful when something actually consumes it —
962        // an authorizer, auditor, or activity emitter. Apps with none of those
963        // registered never see the flag at all, rather than a flag whose value
964        // is captured and silently discarded. This checks the eager `CliConfig`
965        // fields only: an authorizer/auditor/activity emitter installed later via
966        // `init_deps` runs per-request, after flag registration, so it can't be
967        // observed here. Apps that want `--reason` must set `authz`/`auditor`/
968        // `activity` directly on `CliConfig`, not exclusively through `init_deps`.
969        if config.authz.is_some() || config.auditor.is_some() || config.activity.is_some() {
970            root = register_reason_flag(root);
971        }
972        if config.environments.is_some() {
973            root = root.arg(
974                Arg::new("env")
975                    .long("env")
976                    .global(true)
977                    .value_name("ENV")
978                    .display_order(crate::flags::global_flag_order::ENV)
979                    .help("Override the active environment (see: env list)"),
980            );
981        }
982        let intro = config
983            .long
984            .as_deref()
985            .filter(|long| !long.is_empty())
986            .unwrap_or(config.short.as_str());
987        root = root
988            .long_about(build_root_long(intro, &[], false))
989            .help_template(ROOT_HELP_TEMPLATE);
990
991        let mut middleware = Middleware::new();
992        middleware.app_id = config.app_id.clone();
993        // One-time, macOS-only: move any pre-existing $HOME/.config/<app_id>
994        // contents to $HOME/Library/Application Support/<app_id> before the
995        // config file below is loaded from its (possibly new) location.
996        crate::fs::migrate_macos_config_dir(&config.app_id);
997        // Load the per-application config file once at startup; cloned into each
998        // per-run middleware so handlers and module registration share it.
999        middleware.config = Arc::new(crate::config::ConfigFile::load(&config.app_id));
1000        middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default();
1001        middleware.authz = config.authz.clone();
1002        middleware.auditor = config.auditor.clone();
1003        middleware.activity = config.activity.clone();
1004        middleware
1005            .schema_registry
1006            .merge(&global_schema_registry_snapshot());
1007        middleware
1008            .human_views
1009            .merge(&global_human_view_registry_snapshot());
1010        if let Some(environments) = &config.environments {
1011            // Seed the sticky/default active environment now, but let a
1012            // startup `--env` win over it if one is present: `prescan_env_flag`
1013            // scans `startup_args` (or, when unset, the real process argv) the
1014            // same way `apply_env_flag` will parse it for real per invocation
1015            // — this is what lets a same-invocation `--env <name>` affect the
1016            // `flag_policy` computed below (and therefore which flagged
1017            // commands get pruned), not just `middleware.env`. The real,
1018            // per-invocation value used for dispatch still comes from
1019            // `apply_env_flag`'s clap parse in `run_with_depth`; this prescan
1020            // only decides tree shape earlier than clap otherwise could,
1021            // since that decision can't be revisited once the tree is built.
1022            let startup_args = config
1023                .startup_args
1024                .clone()
1025                .unwrap_or_else(|| std::env::args_os().collect());
1026            let startup_env_flag = prescan_env_flag(
1027                startup_args
1028                    .iter()
1029                    .skip(1) // argv[0] is the program name, same convention `run`/`execute_from` use
1030                    .map(|arg| arg.to_string_lossy().into_owned()),
1031            );
1032            // The same `Arc` the consumer shared with any `PkceAuthProvider` is
1033            // reused, so the file layer and active-env persistence resolve
1034            // consistently.
1035            middleware.env =
1036                environments.effective_active(startup_env_flag.as_deref(), &middleware.config);
1037            middleware.environments = Some(Arc::clone(environments));
1038        }
1039        let mut flag_policy = config.flag_policy();
1040        if let Some(min_stage) = global_min_stage_override(&config.app_id) {
1041            flag_policy.min_stage = min_stage;
1042        }
1043        if let Some(environments) = &middleware.environments
1044            && let Ok(source) = environments.source(&middleware.env)
1045        {
1046            let chain = crate::env_config::SourceChain::new().push(&source);
1047            match crate::env_config::resolve_field::<Stage>(
1048                &chain,
1049                "min_stage",
1050                "min_stage",
1051                None,
1052                false,
1053                crate::env_config::default_from_toml::<Stage>,
1054                |_raw: &str| -> std::result::Result<Stage, String> { Err(String::new()) },
1055            ) {
1056                Ok(Some(min_stage)) => flag_policy.min_stage = min_stage,
1057                Ok(None) => {}
1058                Err(err) => {
1059                    tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment min_stage");
1060                }
1061            }
1062            match crate::env_config::resolve_field::<BTreeMap<String, Stage>>(
1063                &chain,
1064                "feature_overrides",
1065                "feature_overrides",
1066                None,
1067                false,
1068                crate::env_config::default_from_toml::<BTreeMap<String, Stage>>,
1069                |_raw: &str| -> std::result::Result<BTreeMap<String, Stage>, String> {
1070                    Err(String::new())
1071                },
1072            ) {
1073                Ok(Some(overrides)) => flag_policy.overrides.extend(overrides),
1074                Ok(None) => {}
1075                Err(err) => {
1076                    tracing::warn!(env = %middleware.env, error = %err, "ignoring invalid environment feature_overrides");
1077                }
1078            }
1079        }
1080        middleware.flag_policy = flag_policy;
1081
1082        let mut cli = Self {
1083            config,
1084            middleware,
1085            root,
1086            commands: BTreeMap::new(),
1087            module_entries: Vec::new(),
1088            guide_entries: Vec::new(),
1089            init_deps,
1090            apply_flags,
1091            pre_run,
1092            meta_resolver,
1093            on_shutdown,
1094            extra_search_docs,
1095            root_next_actions,
1096            init_state: Arc::new(Mutex::new(None)),
1097        };
1098        for provider in auth_providers {
1099            cli.register_auth_provider(provider);
1100        }
1101        if cli.middleware.default_auth_provider.is_empty()
1102            && let Some(provider) = cli.middleware.auth.registered_names().first()
1103        {
1104            cli.middleware.default_auth_provider = provider.clone();
1105        }
1106        if !cli.middleware.default_auth_provider.is_empty() {
1107            cli.ensure_auth_command();
1108        }
1109        for view in views {
1110            cli.middleware.human_views.register(view);
1111        }
1112        cli.add_guides(guides);
1113        for module in modules {
1114            cli.add_module(module);
1115        }
1116        for command in commands {
1117            cli.add_command(command);
1118        }
1119        if cli.config.config_commands {
1120            cli.ensure_config_command();
1121        }
1122        if cli.config.environments.is_some() {
1123            cli.ensure_env_command();
1124        }
1125        cli.ensure_flags_command();
1126        cli
1127    }
1128
1129    /// Lists the auto-registered `auth` command under the admin help category so
1130    /// it is never uncategorized once clap's auto subcommand list is suppressed.
1131    /// Defaults to [`DEFAULT_ADMIN_CATEGORY`]; `admin_category` overrides it to
1132    /// align with a consumer's own taxonomy.
1133    fn register_auth_help_entry(&mut self) {
1134        let category = self
1135            .config
1136            .admin_category
1137            .clone()
1138            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
1139        let already_listed = self.module_entries.iter().any(|entry| entry.name == "auth");
1140        let short = self
1141            .root
1142            .find_subcommand("auth")
1143            .filter(|auth| !auth.is_hide_set())
1144            .map(|auth| {
1145                auth.get_about()
1146                    .map(ToString::to_string)
1147                    .unwrap_or_default()
1148            });
1149        if !already_listed && let Some(short) = short {
1150            self.module_entries.push(ModuleHelpEntry {
1151                category,
1152                name: "auth".to_owned(),
1153                short,
1154            });
1155        }
1156        self.refresh_root_long();
1157    }
1158
1159    /// Returns the shared middleware template.
1160    #[must_use]
1161    pub fn middleware(&self) -> &Middleware {
1162        &self.middleware
1163    }
1164
1165    /// Returns mutable middleware for advanced application setup.
1166    pub fn middleware_mut(&mut self) -> &mut Middleware {
1167        &mut self.middleware
1168    }
1169
1170    /// Executes the CLI with process arguments and process stdout/stderr.
1171    pub async fn execute(&self) -> ExitCode {
1172        let mut stdout = std::io::stdout().lock();
1173        let mut stderr = std::io::stderr().lock();
1174        match self
1175            .execute_from(std::env::args_os(), &mut stdout, &mut stderr)
1176            .await
1177        {
1178            Ok(code) => code,
1179            Err(err) => {
1180                drop(writeln!(stderr, "{err}"));
1181                ExitCode::from(1)
1182            }
1183        }
1184    }
1185
1186    /// Executes the CLI with caller-provided args and output writers.
1187    ///
1188    /// If `args` carries a synthetic `--env` unrelated to real process argv
1189    /// (or to whatever [`CliConfig::with_startup_args`] this `Cli` was built
1190    /// with), command-tree pruning — decided once, at construction time —
1191    /// won't reflect it; see `with_startup_args`'s doc for why.
1192    pub async fn execute_from<I, S, O, E>(
1193        &self,
1194        args: I,
1195        stdout: &mut O,
1196        stderr: &mut E,
1197    ) -> std::io::Result<ExitCode>
1198    where
1199        I: IntoIterator<Item = S>,
1200        S: Into<std::ffi::OsString> + Clone,
1201        O: Write,
1202        E: Write,
1203    {
1204        self.execute_from_until_signal(args, stdout, stderr, shutdown_signal())
1205            .await
1206    }
1207
1208    /// Executes the CLI until either command completion or a shutdown signal future resolves.
1209    pub async fn execute_from_until_signal<I, S, O, E, Shutdown>(
1210        &self,
1211        args: I,
1212        stdout: &mut O,
1213        stderr: &mut E,
1214        shutdown: Shutdown,
1215    ) -> std::io::Result<ExitCode>
1216    where
1217        I: IntoIterator<Item = S>,
1218        S: Into<std::ffi::OsString> + Clone,
1219        O: Write,
1220        E: Write,
1221        Shutdown: Future<Output = ()>,
1222    {
1223        self.install_default_user_agent();
1224        let output = run_until_signal(self.run(args), shutdown).await;
1225        if output.exit_code == 130
1226            && output.rendered == "command interrupted\n"
1227            && let Some(on_shutdown) = &self.on_shutdown
1228        {
1229            on_shutdown();
1230        }
1231        if output.exit_code == 0 {
1232            stdout.write_all(output.rendered.as_bytes())?;
1233        } else {
1234            stderr.write_all(output.rendered.as_bytes())?;
1235        }
1236        Ok(process_exit_code(output.exit_code))
1237    }
1238
1239    /// Publishes the configured outbound User-Agent process-wide so that
1240    /// command [`HttpClient`](crate::transport::HttpClient)s and the engine's
1241    /// own OAuth token requests share it.
1242    ///
1243    /// Called from the execution entrypoints rather than [`Cli::new`] so that
1244    /// merely constructing a `Cli` (as tests do in bulk) does not mutate global
1245    /// state. See [`CliConfig::user_agent_string`] for resolution order.
1246    fn install_default_user_agent(&self) {
1247        crate::transport::set_default_user_agent(self.config.user_agent_string());
1248    }
1249
1250    /// Registers an auth provider after construction.
1251    pub fn register_auth_provider(&mut self, provider: Arc<dyn AuthProvider>) -> &mut Self {
1252        self.middleware.auth.register(provider);
1253        self.ensure_auth_command();
1254        self.refresh_root_long();
1255        self
1256    }
1257
1258    /// Returns the built `clap` root command.
1259    #[must_use]
1260    pub fn root_command(&self) -> &Command {
1261        &self.root
1262    }
1263
1264    /// Adds one runtime module group after construction.
1265    pub fn add_module_group(
1266        &mut self,
1267        category: impl Into<String>,
1268        group: RuntimeGroupSpec,
1269    ) -> &mut Self {
1270        self.add_module_group_inner(category, group, None)
1271    }
1272
1273    /// Shared implementation behind [`add_module_group`](Self::add_module_group)
1274    /// and [`add_module`](Self::add_module). `inherited` is the effective
1275    /// feature flag the group's enclosing module declared (if any), so a
1276    /// module-level flag cascades down to the group even though
1277    /// `add_module_group` itself has no concept of a module.
1278    fn add_module_group_inner(
1279        &mut self,
1280        category: impl Into<String>,
1281        group: RuntimeGroupSpec,
1282        inherited: Option<FeatureFlag>,
1283    ) -> &mut Self {
1284        // Prevent consumer modules from shadowing engine built-ins in the clap
1285        // command tree.  A reserved group name would override the engine's own
1286        // subcommand (last-writer-wins in clap) and corrupt the dispatch path.
1287        if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) {
1288            tracing::warn!(
1289                name = %group.group.name,
1290                "module group name is reserved by cli-engine built-ins; the group will not be registered"
1291            );
1292            return self;
1293        }
1294
1295        let mut prefix = Vec::new();
1296        let Some(group) = prune_feature_flag_tree(
1297            group,
1298            inherited.as_ref(),
1299            &self.middleware.flag_policy,
1300            &mut prefix,
1301            &mut self.middleware.flag_registry,
1302        ) else {
1303            return self;
1304        };
1305
1306        let category = category.into();
1307        if !group.group.hidden {
1308            self.module_entries.push(ModuleHelpEntry {
1309                category,
1310                name: group.group.name.clone(),
1311                short: group.group.short.clone(),
1312            });
1313        }
1314
1315        let mut prefix = Vec::new();
1316        register_runtime_group_metadata(
1317            &group,
1318            &mut prefix,
1319            &mut self.middleware.schema_registry,
1320            &mut self.middleware.human_views,
1321        );
1322        let mut prefix = Vec::new();
1323        group.register_commands(&mut prefix, &mut self.commands);
1324        let mut prefix = Vec::new();
1325        let clap_group = runtime_group_clap_command_with_schema_help(
1326            &group,
1327            &mut prefix,
1328            &self.middleware.schema_registry,
1329        );
1330        self.root = self.root.clone().subcommand(clap_group);
1331        self.refresh_root_long();
1332        self
1333    }
1334
1335    /// Adds one module after construction.
1336    pub fn add_module(&mut self, module: Module) -> &mut Self {
1337        for view in module.views.clone() {
1338            self.middleware.human_views.register(view);
1339        }
1340        self.add_guides(module.guides.clone());
1341        let mut context = ModuleContext::new(&mut self.middleware);
1342        let group = (module.register)(&mut context);
1343        let (guides, views) = context.into_parts();
1344        for view in views {
1345            self.middleware.human_views.register(view);
1346        }
1347        self.add_guides(guides);
1348        self.add_module_group_inner(module.category, group, module.feature_flag.clone())
1349    }
1350
1351    /// Adds one top-level runtime command after construction.
1352    pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self {
1353        let name = command.spec.name.clone();
1354        register_command_schema(&command.spec, &name, &mut self.middleware.schema_registry);
1355        self.commands.insert(name, command.clone());
1356        self.root = self
1357            .root
1358            .clone()
1359            .subcommand(command_clap_command_with_schema_help(
1360                &command.spec,
1361                &command.spec.name,
1362                &self.middleware.schema_registry,
1363            ));
1364        self
1365    }
1366
1367    /// Controls whether the built-in `guide` command is advertised.
1368    pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self {
1369        if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1370            self.root = self.root.clone().subcommand(guide_command());
1371        }
1372        self.sync_guide_topic_values();
1373        self.refresh_root_long();
1374        self
1375    }
1376
1377    /// Adds guide entries after construction.
1378    pub fn add_guides(&mut self, entries: impl IntoIterator<Item = GuideEntry>) -> &mut Self {
1379        let mut seen = self
1380            .guide_entries
1381            .iter()
1382            .map(|entry| entry.name.clone())
1383            .collect::<BTreeSet<_>>();
1384        for entry in entries {
1385            if seen.insert(entry.name.clone()) {
1386                self.guide_entries.push(entry);
1387            }
1388        }
1389        if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1390            self.root = self.root.clone().subcommand(guide_command());
1391        }
1392        self.sync_guide_topic_values();
1393        self.refresh_root_long();
1394        self
1395    }
1396
1397    /// Re-attaches the `guide` subcommand's `topic` arg possible values from
1398    /// the current [`Self::guide_entries`], so shell completion knows about
1399    /// guide names, which are not all registered up front.
1400    fn sync_guide_topic_values(&mut self) {
1401        if self.guide_entries.is_empty() {
1402            return;
1403        }
1404        let names = self
1405            .guide_entries
1406            .iter()
1407            .map(|entry| entry.name.clone())
1408            .collect::<Vec<_>>();
1409        if let Some(guide_cmd) = self.root.find_subcommand_mut("guide") {
1410            let taken = std::mem::replace(guide_cmd, Command::new("guide"));
1411            *guide_cmd = taken.mut_arg("topic", |arg| {
1412                arg.value_parser(PossibleValuesParser::new(names))
1413            });
1414        }
1415    }
1416
1417    /// Resolves busybox/git-style `argv[0]` dispatch before the normal pipeline.
1418    ///
1419    /// Returns [`Argv0Outcome::Proceed`] with the (possibly rewritten) argument
1420    /// vector to feed the normal command pipeline, or [`Argv0Outcome::Handled`]
1421    /// with a fully rendered result when a personality ran or an explicit `argv0`
1422    /// invocation was rejected. When no routes are registered this is inert and
1423    /// returns the arguments unchanged. `depth` counts chained hand-offs and
1424    /// bounds recursion via [`MAX_ARGV0_DEPTH`].
1425    async fn resolve_argv0(&self, text_args: Vec<String>, depth: usize) -> Argv0Outcome {
1426        if self.config.argv0_routes.is_empty() {
1427            return Argv0Outcome::Proceed(text_args);
1428        }
1429
1430        if depth > MAX_ARGV0_DEPTH {
1431            return Argv0Outcome::Handled(
1432                self.render_argv0_error(&text_args, "argv0 dispatch recursion limit exceeded"),
1433            );
1434        }
1435
1436        // The hidden `argv0` meta-command (`<bin> argv0 <name> [args...]`) forces
1437        // a route without an actual symlink. It is recognized positionally as the
1438        // first argument after the program name and is never registered with clap,
1439        // so it stays absent from `--help`, `tree`, and the `search` command.
1440        let explicit = text_args.get(1).map(String::as_str) == Some("argv0");
1441        let (name, rest) = if explicit {
1442            match text_args.get(2) {
1443                None => {
1444                    return Argv0Outcome::Handled(self.render_argv0_error(
1445                        &text_args,
1446                        "the argv0 command requires a name to dispatch as",
1447                    ));
1448                }
1449                // Normalize the explicit name the same way as a symlink basename
1450                // so a route registered as `whatever` matches whether the caller
1451                // passed `whatever`, `whatever.exe`, or a `.cmd` shim's `whatever.cmd`.
1452                Some(name) => (
1453                    program_basename(name),
1454                    text_args
1455                        .get(3..)
1456                        .map(<[String]>::to_vec)
1457                        .unwrap_or_default(),
1458                ),
1459            }
1460        } else {
1461            let name = text_args
1462                .first()
1463                .map(|arg| program_basename(arg))
1464                .unwrap_or_default();
1465            let rest = text_args
1466                .get(1..)
1467                .map(<[String]>::to_vec)
1468                .unwrap_or_default();
1469            (name, rest)
1470        };
1471
1472        match self.config.argv0_routes.get(&name) {
1473            Some(Argv0Route::Alias(tokens)) => {
1474                // Rewrite as `<canonical-name> <tokens...> <rest...>`. Element 0 is
1475                // the canonical name so the downstream program-name skip applies.
1476                let mut rewritten = Vec::with_capacity(1 + tokens.len() + rest.len());
1477                rewritten.push(self.config.name.clone());
1478                rewritten.extend(tokens.iter().cloned());
1479                rewritten.extend(rest);
1480                Argv0Outcome::Proceed(rewritten)
1481            }
1482            Some(Argv0Route::Personality(build)) => {
1483                // Hand off to an independent CLI built lazily from the route. Its
1484                // own config name leads so its help/usage and program-name skip
1485                // render correctly. `Box::pin` breaks the recursive `async fn`;
1486                // `depth + 1` bounds a pathological chain of hand-offs.
1487                let config = build();
1488                let bin = config.name.clone();
1489                let alt = Self::new(config);
1490                let mut alt_args = Vec::with_capacity(1 + rest.len());
1491                alt_args.push(bin);
1492                alt_args.extend(rest);
1493                Argv0Outcome::Handled(Box::pin(alt.run_with_depth(alt_args, depth + 1)).await)
1494            }
1495            None if explicit => Argv0Outcome::Handled(self.render_argv0_error(
1496                &text_args,
1497                format!(
1498                    "{name:?} is not a registered argv0 name; known names: {}",
1499                    self.known_argv0_names()
1500                ),
1501            )),
1502            None => {
1503                // Unregistered name (e.g. the binary renamed to something we do not
1504                // recognize): fall through to the default CLI. Normalizing element 0
1505                // to the canonical name lets a renamed binary parse as the default
1506                // application instead of treating its name as a command token.
1507                let mut rewritten = Vec::with_capacity(1 + rest.len());
1508                rewritten.push(self.config.name.clone());
1509                rewritten.extend(rest);
1510                Argv0Outcome::Proceed(rewritten)
1511            }
1512        }
1513    }
1514
1515    /// Computes the default output format for this run — the fallback used
1516    /// when no explicit `--output`/`--json`/`--human`/`--toon` is given.
1517    fn resolve_run_output_format(&self) -> String {
1518        use std::io::IsTerminal;
1519
1520        let env = std::env::var(output_env_var(&self.config.app_id)).ok();
1521        let engine_config = self.middleware.config.engine();
1522        resolve_default_output_format(
1523            env.as_deref(),
1524            engine_config.output.format.as_deref(),
1525            std::io::stdout().is_terminal(),
1526        )
1527    }
1528
1529    /// Comma-separated, sorted list of registered alternative `argv[0]` names,
1530    /// used in the error shown for an unknown explicit `argv0` invocation.
1531    fn known_argv0_names(&self) -> String {
1532        self.config
1533            .argv0_routes
1534            .keys()
1535            .cloned()
1536            .collect::<Vec<_>>()
1537            .join(", ")
1538    }
1539
1540    /// Renders an `argv0`-dispatch error through the engine's structured error
1541    /// envelope so it honors `--output` (parsed from the raw args, since dispatch
1542    /// runs before clap) and the shared exit-code mapping, matching every other
1543    /// CLI error rather than emitting bare text.
1544    fn render_argv0_error(&self, text_args: &[String], message: impl Into<String>) -> CliRunOutput {
1545        let mut middleware = self.middleware.clone();
1546        middleware.output_format =
1547            extract_output_format(text_args, &self.resolve_run_output_format());
1548        let err = CliCoreError::message(message);
1549        self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id))
1550    }
1551
1552    /// Returns the registered alternative `argv[0]` names, sorted.
1553    ///
1554    /// Useful for install or self-healing code that iterates the names and calls
1555    /// [`Cli::create_link`] for each.
1556    #[must_use]
1557    pub fn argv0_names(&self) -> Vec<&str> {
1558        self.config
1559            .argv0_routes
1560            .keys()
1561            .map(String::as_str)
1562            .collect()
1563    }
1564
1565    /// Creates an on-disk link in `dir` that lets the binary be invoked under the
1566    /// registered alternative `argv[0]` name `name`, using `method`.
1567    ///
1568    /// `target` is the executable the link points at; pass `None` to use the
1569    /// current executable ([`std::env::current_exe`]), which is the common choice
1570    /// for install and self-healing code. The file name follows the platform and
1571    /// method: a symlink or hard link is `<name>` on Unix and `<name>.exe` on
1572    /// Windows; a [`Argv0LinkMethod::Script`] shim is `<name>.cmd` on Windows and
1573    /// an executable `<name>` shell script on Unix.
1574    ///
1575    /// The call ensures the desired state idempotently: if the destination already
1576    /// matches what would be created (a symlink to `target`, a hard link with the
1577    /// same contents, or a shim with identical contents) it is left untouched and
1578    /// its path returned; if it exists but differs (wrong kind, stale target, or
1579    /// edited shim) it is replaced. This makes the call safe to re-run as install
1580    /// or self-healing code, restoring both deleted and corrupted links. The
1581    /// directory is created if necessary.
1582    ///
1583    /// # Errors
1584    ///
1585    /// Returns an error if `name` is not a registered route, if the current
1586    /// executable cannot be resolved (when `target` is `None`), or if the
1587    /// directory or link cannot be created or replaced (e.g. insufficient
1588    /// privilege for a Windows symlink, or a hard link across volumes).
1589    pub fn create_link(
1590        &self,
1591        name: &str,
1592        dir: impl AsRef<Path>,
1593        target: Option<&Path>,
1594        method: Argv0LinkMethod,
1595    ) -> std::io::Result<PathBuf> {
1596        if !self.config.argv0_routes.contains_key(name) {
1597            return Err(std::io::Error::new(
1598                std::io::ErrorKind::InvalidInput,
1599                format!("{name:?} is not a registered argv0 name"),
1600            ));
1601        }
1602
1603        let dir = dir.as_ref();
1604        std::fs::create_dir_all(dir)?;
1605        let link = dir.join(argv0_link_file_name(name, method));
1606
1607        // Resolve the target up front so an existing entry can be compared against it.
1608        let resolved_target;
1609        let target = match target {
1610            Some(target) => target,
1611            None => {
1612                resolved_target = std::env::current_exe()?;
1613                resolved_target.as_path()
1614            }
1615        };
1616
1617        // Ensure-desired-state. `symlink_metadata` does not follow links, so a
1618        // present-but-dangling link still counts as existing. A matching entry is
1619        // left untouched (idempotent); a differing one is removed and recreated.
1620        if std::fs::symlink_metadata(&link).is_ok() {
1621            if argv0_link_matches(&link, target, name, method)? {
1622                return Ok(link);
1623            }
1624            std::fs::remove_file(&link)?;
1625        }
1626
1627        match method {
1628            Argv0LinkMethod::SoftLink => create_symlink(target, &link)?,
1629            Argv0LinkMethod::HardLink => std::fs::hard_link(target, &link)?,
1630            Argv0LinkMethod::Script => {
1631                std::fs::write(&link, argv0_script_contents(target, name))?;
1632                make_executable(&link)?;
1633            }
1634        }
1635        Ok(link)
1636    }
1637
1638    /// Runs the CLI with provided args and captures the rendered result.
1639    ///
1640    /// Same `--env`/tree-pruning caveat as [`Cli::execute_from`]: see
1641    /// [`CliConfig::with_startup_args`].
1642    pub async fn run<I, S>(&self, args: I) -> CliRunOutput
1643    where
1644        I: IntoIterator<Item = S>,
1645        S: Into<std::ffi::OsString> + Clone,
1646    {
1647        self.run_with_depth(args, 0).await
1648    }
1649
1650    /// Runs the CLI like [`Cli::run`], threading the `argv0` dispatch recursion
1651    /// `depth` so a chain of personality hand-offs is bounded by [`MAX_ARGV0_DEPTH`].
1652    async fn run_with_depth<I, S>(&self, args: I, depth: usize) -> CliRunOutput
1653    where
1654        I: IntoIterator<Item = S>,
1655        S: Into<std::ffi::OsString> + Clone,
1656    {
1657        let raw_args = args
1658            .into_iter()
1659            .map(Into::into)
1660            .collect::<Vec<std::ffi::OsString>>();
1661        let text_args = raw_args
1662            .iter()
1663            .map(|arg| arg.to_string_lossy().into_owned())
1664            .collect::<Vec<_>>();
1665        let text_args = match self.resolve_argv0(text_args, depth).await {
1666            Argv0Outcome::Handled(output) => return output,
1667            Argv0Outcome::Proceed(args) => args,
1668        };
1669        let mut clap_args = normalize_optional_global_flags_before_command(&self.root, &text_args);
1670        if has_root_version_flag(&text_args, &self.root, &self.config.name) {
1671            return self.finish_run(CliRunOutput {
1672                exit_code: 0,
1673                rendered: format!(
1674                    "{} version {}\n",
1675                    self.config.name,
1676                    self.config.build.version_string()
1677                ),
1678            });
1679        }
1680        if let Some(output) = self.try_run_schema_bypass(&text_args) {
1681            return output;
1682        }
1683        // Resolve the positional command path once and share it between the
1684        // group-help rewrite and the unknown-command check below.
1685        let bool_flags = derive_bool_flags(&self.root);
1686        let value_flags = derive_value_flags(&self.root);
1687        let positionals =
1688            positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags);
1689        let command_keyword_count =
1690            command_keyword_count(&text_args, &self.config.name, &bool_flags, &value_flags);
1691        if let Some(parts) =
1692            group_help_target_parts(&self.root, &positionals, command_keyword_count)
1693        {
1694            // Rewrite `<group> help [sub...]` into the canonical
1695            // `help <group> [sub...]` so it flows through the curated root
1696            // `help` command, which also runs global-flag parsing and the
1697            // `pre_run` hook (matching `help <group>` and bare-group help).
1698            // Only the positional command tokens are reordered; every flag and
1699            // its value is preserved in place so e.g. `--output json` survives.
1700            clap_args = rewrite_group_help_args(
1701                &clap_args,
1702                &self.config.name,
1703                &bool_flags,
1704                &value_flags,
1705                &parts,
1706            );
1707        } else if let Some(unknown) =
1708            detect_unknown_group_command(&self.root, &positionals[..command_keyword_count])
1709        {
1710            // Hint/re-dispatch only when the whole path resolves to one command.
1711            if let Some(corrections) =
1712                full_command_correction(&self.root, &positionals[..command_keyword_count])
1713            {
1714                let display = correction_display(
1715                    &self.config.name,
1716                    &positionals[..command_keyword_count],
1717                    &corrections,
1718                );
1719                let full_fix_message = format_did_you_mean(&unknown.base, &display);
1720                match crate::prompt::confirm_command_correction(
1721                    &clap_args,
1722                    &display,
1723                    self.config.auto_interactive,
1724                ) {
1725                    crate::prompt::CommandCorrection::Accepted => {
1726                        for (index, replacement) in &corrections {
1727                            clap_args = replace_positional_command_token(
1728                                &clap_args,
1729                                &self.config.name,
1730                                &bool_flags,
1731                                &value_flags,
1732                                *index,
1733                                replacement,
1734                            );
1735                        }
1736                        clap_args = rewrite_group_help_if_needed(
1737                            &self.root,
1738                            &clap_args,
1739                            &self.config.name,
1740                            &bool_flags,
1741                            &value_flags,
1742                        );
1743                    }
1744                    crate::prompt::CommandCorrection::Declined => {
1745                        return self.finish_run(CliRunOutput {
1746                            exit_code: 1,
1747                            rendered: full_fix_message,
1748                        });
1749                    }
1750                    crate::prompt::CommandCorrection::Cancelled => {
1751                        return self.finish_run(CliRunOutput {
1752                            exit_code: 130,
1753                            rendered: "Cancelled.".to_owned(),
1754                        });
1755                    }
1756                }
1757            } else {
1758                return self.finish_run(CliRunOutput {
1759                    exit_code: 1,
1760                    rendered: unknown.base,
1761                });
1762            }
1763        }
1764
1765        let matches = match self.root.clone().try_get_matches_from(&clap_args) {
1766            Ok(matches) => matches,
1767            Err(err) => {
1768                // Attempt interactive recovery for missing required arguments.
1769                if let Some(recovery) = crate::prompt::try_recover_missing_args(
1770                    &err,
1771                    &clap_args,
1772                    &self.root,
1773                    &self.config.name,
1774                    self.config.auto_interactive,
1775                ) {
1776                    match recovery {
1777                        crate::prompt::RecoveryResult::Recovered { args } => {
1778                            match self.root.clone().try_get_matches_from(args) {
1779                                Ok(m) => m,
1780                                Err(retry_err) => {
1781                                    return self.finish_run(CliRunOutput {
1782                                        exit_code: retry_err.exit_code(),
1783                                        rendered: retry_err.to_string(),
1784                                    });
1785                                }
1786                            }
1787                        }
1788                        crate::prompt::RecoveryResult::Cancelled { resume } => {
1789                            return self.finish_run(CliRunOutput {
1790                                exit_code: 130,
1791                                rendered: format!("Cancelled. Resume with:\n  {resume}\n"),
1792                            });
1793                        }
1794                    }
1795                } else {
1796                    return self.finish_run(CliRunOutput {
1797                        exit_code: err.exit_code(),
1798                        rendered: err.to_string(),
1799                    });
1800                }
1801            }
1802        };
1803
1804        let default_format = self.resolve_run_output_format();
1805        let flags =
1806            global_flags_from_matches(&matches, &default_format, self.config.auto_interactive);
1807        // Publish the --credential-store override so auth providers resolving
1808        // their storage backend see it at the top of the precedence chain.
1809        crate::config::set_credential_store_flag(flags.credential_store);
1810        let command_timeout = match parse_command_timeout(&flags.timeout) {
1811            Ok(timeout) => timeout,
1812            Err(err) => {
1813                return self.finish_run(render_cli_error(
1814                    &self.middleware,
1815                    &err,
1816                    &self.config.app_id,
1817                ));
1818            }
1819        };
1820        let mut middleware = self.middleware.clone();
1821        apply_global_flags(&mut middleware, &flags, command_timeout);
1822        install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1823        if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1824            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1825        }
1826        // Validate and apply `--env` for built-in paths (help/tree/guide/group
1827        // help) so they reflect the selected environment and reject unknowns.
1828        if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1829            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1830        }
1831
1832        let command_path = command_path_from_matches(&self.config.name, &matches);
1833        if command_path == "help" {
1834            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &help_args(&matches))
1835            {
1836                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1837            }
1838            return self.finish_run(self.render_help_command(&matches));
1839        }
1840        if command_path == "tree" {
1841            if let Err(err) = self.run_pre_run(
1842                &mut middleware,
1843                &command_path,
1844                &crate::middleware::ValueMap::new(),
1845            ) {
1846                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1847            }
1848            return self.finish_run(tree_render::render_tree(
1849                &self.root,
1850                &self.config.app_id,
1851                &middleware,
1852            ));
1853        }
1854        if command_path == "guide" {
1855            if let Err(err) =
1856                self.run_pre_run(&mut middleware, &command_path, &guide_args(&matches))
1857            {
1858                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1859            }
1860            return self.finish_run(self.render_guide(&matches, &flags.output_format));
1861        }
1862        if command_path == "search" {
1863            let args = search_args(&matches);
1864            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1865                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1866            }
1867            let query = args
1868                .get("query")
1869                .and_then(|v| v.as_str())
1870                .unwrap_or_default();
1871            let scope_path = args
1872                .get("scope")
1873                .and_then(|v| v.as_str())
1874                .unwrap_or_default();
1875            let scope = self.resolve_search_scope(scope_path);
1876            return self.finish_run(self.render_search(query, &scope, &flags.output_format));
1877        }
1878        if command_path == "completion" {
1879            let args = completion_args(&matches);
1880            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1881                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1882            }
1883            let install = args
1884                .get("install")
1885                .and_then(|v| v.as_bool())
1886                .unwrap_or(false);
1887            let shell_opt = args
1888                .get("shell")
1889                .and_then(|v| v.as_str())
1890                .map(str::to_owned);
1891            if install {
1892                use crate::cli::completion::{detect_shell, parse_shell};
1893                let shell = match shell_opt {
1894                    Some(ref s) => match parse_shell(s) {
1895                        Ok(s) => s,
1896                        Err(e) => {
1897                            return self.finish_run(render_cli_error(
1898                                &middleware,
1899                                &e,
1900                                &self.config.app_id,
1901                            ));
1902                        }
1903                    },
1904                    None => match detect_shell() {
1905                        Ok(s) => s,
1906                        Err(e) => {
1907                            return self.finish_run(render_cli_error(
1908                                &middleware,
1909                                &e,
1910                                &self.config.app_id,
1911                            ));
1912                        }
1913                    },
1914                };
1915                return self.finish_run(
1916                    completion::install(&self.root, &self.config.name, shell)
1917                        .await
1918                        .unwrap_or_else(|e| render_cli_error(&middleware, &e, &self.config.app_id)),
1919                );
1920            }
1921            return self.finish_run(self.render_completion_print(shell_opt, &middleware));
1922        }
1923        let Some(command) = self.commands.get(&command_path) else {
1924            if !command_path.is_empty()
1925                && let Some(group) = find_command_by_colon_path(&self.root, &command_path)
1926                && group.get_subcommands().next().is_some()
1927            {
1928                if let Err(err) = self.run_pre_run(
1929                    &mut middleware,
1930                    &command_path,
1931                    &crate::middleware::ValueMap::new(),
1932                ) {
1933                    return self.finish_run(render_cli_error(
1934                        &middleware,
1935                        &err,
1936                        &self.config.app_id,
1937                    ));
1938                }
1939                return self.finish_run(self.render_bare_group_discovery(
1940                    group,
1941                    &command_path,
1942                    &middleware,
1943                ));
1944            }
1945            if command_path.is_empty()
1946                && let Some(root_next_actions) = &self.root_next_actions
1947            {
1948                // Bare-root discovery is static (help text / metadata + action
1949                // pointers) and must always be available as a cold-start entry
1950                // point, so we skip `pre_run` here — matching the no-hook
1951                // bare-root path below, which also renders help without it.
1952                let actions = root_next_actions();
1953                return self.finish_run(self.render_root(&middleware, actions));
1954            }
1955            return self.finish_run(CliRunOutput {
1956                exit_code: if command_path.is_empty() { 0 } else { 1 },
1957                rendered: if command_path.is_empty() {
1958                    self.root.clone().render_long_help().to_string()
1959                } else {
1960                    format!("unknown command {command_path:?}")
1961                },
1962            });
1963        };
1964
1965        let mut middleware = match self.initialized_middleware() {
1966            Ok(middleware) => middleware,
1967            Err(err) => {
1968                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1969            }
1970        };
1971        apply_global_flags(&mut middleware, &flags, command_timeout);
1972        install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1973        if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1974            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1975        }
1976        // The global `--env` flag overrides the seeded active environment for
1977        // this invocation; an unknown name surfaces as an error envelope.
1978        if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1979            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1980        }
1981
1982        let leaf = leaf_matches(&matches);
1983        apply_pagination_flags(&mut middleware, &command.spec, leaf);
1984        let args = command_args_from_matches(leaf, &command.spec, false);
1985        let user_args = command_args_from_matches(leaf, &command.spec, true);
1986        let pagination_command = command.spec.pagination.is_some().then(|| {
1987            pagination_command_base(
1988                &self.config.name,
1989                &command_path,
1990                &command.spec,
1991                &user_args,
1992                &flags,
1993            )
1994        });
1995        if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1996            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1997        }
1998        let meta = self.resolve_meta(&command_path, command.spec.metadata());
1999        let default_fields = command.spec.default_fields.clone().unwrap_or_default();
2000        let system = command.spec.system.clone().unwrap_or_default();
2001        // The human view this command declared: an explicit shared id wins;
2002        // otherwise an inline `with_view` was registered under the command path
2003        // at build time, so reference it by that path. `None` renders generic
2004        // human output.
2005        let view_id = command
2006            .spec
2007            .view_id
2008            .clone()
2009            .or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));
2010
2011        if let Some(streaming_handler) = command.streaming_handler.clone() {
2012            let result = run_with_timeout(
2013                command_timeout,
2014                &flags.timeout,
2015                run_streaming_command(
2016                    &middleware,
2017                    MiddlewareRequest {
2018                        meta,
2019                        command_path: &command_path,
2020                        system: &system,
2021                        user_args,
2022                        args,
2023                        default_fields: &default_fields,
2024                        view_id: view_id.as_deref(),
2025                        auth: command.spec.auth,
2026                        raw_output: command.spec.raw_output,
2027                        pagination_command,
2028                    },
2029                    Arc::new(leaf.clone()),
2030                    streaming_handler,
2031                ),
2032            )
2033            .await;
2034            return self.finish_run(match result {
2035                Ok(output) => output,
2036                Err(err) => render_cli_error(&middleware, &err, &self.config.app_id),
2037            });
2038        }
2039
2040        let handler = command.handler.clone();
2041        let args_for_handler = args.clone();
2042        let user_args_for_handler = user_args.clone();
2043        let handler_path = command_path.clone();
2044        let middleware_for_handler = middleware.clone();
2045        let raw_matches_for_handler = Arc::new(leaf.clone());
2046        let result = run_with_timeout(
2047            command_timeout,
2048            &flags.timeout,
2049            middleware.run(
2050                MiddlewareRequest {
2051                    meta,
2052                    command_path: &command_path,
2053                    system: &system,
2054                    user_args,
2055                    args,
2056                    default_fields: &default_fields,
2057                    view_id: view_id.as_deref(),
2058                    auth: command.spec.auth,
2059                    raw_output: command.spec.raw_output,
2060                    pagination_command,
2061                },
2062                async move |credential| {
2063                    handler(CommandContext {
2064                        credential,
2065                        args: args_for_handler,
2066                        user_args: user_args_for_handler,
2067                        command_path: handler_path,
2068                        middleware: middleware_for_handler,
2069                        raw_matches: raw_matches_for_handler,
2070                    })
2071                    .await
2072                },
2073            ),
2074        )
2075        .await;
2076
2077        match result {
2078            Ok(output) => self.finish_run(output.into()),
2079            Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)),
2080        }
2081    }
2082
2083    fn try_run_schema_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
2084        if !has_true_schema_flag(args) {
2085            return None;
2086        }
2087        let bool_flags = derive_bool_flags(&self.root);
2088        let value_flags = derive_value_flags(&self.root);
2089        let command_path =
2090            self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
2091        // `--schema` is an inspection flag and must not require the command's own
2092        // arguments, so it short-circuits before clap validates them. Only fire
2093        // for a real leaf command, though: unknown paths and groups fall through
2094        // so clap and `detect_unknown_group_command` can report them as usual.
2095        let command = find_command_by_colon_path(&self.root, &command_path)?;
2096        if command.get_subcommands().next().is_some() {
2097            return None;
2098        }
2099        let output_format = extract_output_format(args, &self.resolve_run_output_format());
2100        // When no schema is registered, report that rather than running the
2101        // command — matching the middleware's no-schema response so the public
2102        // path and the lower layer agree even when required args are missing.
2103        match self.middleware.schema_registry.get_by_path(&command_path) {
2104            Some(schema) => Some(self.render_schema(schema, &output_format)),
2105            None => Some(self.render_schema(
2106                crate::output::no_schema_response(&command_path),
2107                &output_format,
2108            )),
2109        }
2110    }
2111
2112    fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
2113        let format: crate::output::OutputFormat = match output_format.parse() {
2114            Ok(format) => format,
2115            Err(err) => {
2116                return CliRunOutput {
2117                    exit_code: exit_code_for_error(&err),
2118                    rendered: err.to_string(),
2119                };
2120            }
2121        };
2122        let envelope =
2123            crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
2124        match crate::output::render(format, &envelope) {
2125            Ok(rendered) => CliRunOutput {
2126                exit_code: 0,
2127                rendered,
2128            },
2129            Err(err) => CliRunOutput {
2130                exit_code: exit_code_for_error(&err),
2131                rendered: err.to_string(),
2132            },
2133        }
2134    }
2135
2136    /// Renders a bare group invocation (no subcommand given).
2137    ///
2138    /// Human output keeps the existing clap help text; every other format,
2139    /// explicit `--output json`/`--toon`, or the non-TTY default an agent
2140    /// sees with no `--output` flag at all — gets an explicit JSON
2141    /// command-tree subset scoped to this group, built with the same
2142    /// [`crate::tree`] machinery as the top-level `tree` command.
2143    fn render_bare_group_discovery(
2144        &self,
2145        group: &Command,
2146        command_path: &str,
2147        middleware: &Middleware,
2148    ) -> CliRunOutput {
2149        let format: crate::output::OutputFormat = match middleware.output_format.parse() {
2150            Ok(format) => format,
2151            Err(err) => {
2152                return CliRunOutput {
2153                    exit_code: exit_code_for_error(&err),
2154                    rendered: err.to_string(),
2155                };
2156            }
2157        };
2158        if format == crate::output::OutputFormat::Human {
2159            return CliRunOutput {
2160                exit_code: 0,
2161                rendered: group.clone().render_long_help().to_string(),
2162            };
2163        }
2164        let path = format!("{} {}", self.config.name, command_path.replace(':', " "));
2165        let tree = crate::tree::build_tree_from_clap_with_path(group, path);
2166        tree_render::render_tree_envelope(tree, &self.config.app_id, middleware, format)
2167    }
2168
2169    fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
2170        let format: crate::output::OutputFormat = match output_format.parse() {
2171            Ok(format) => format,
2172            Err(err) => {
2173                return CliRunOutput {
2174                    exit_code: exit_code_for_error(&err),
2175                    rendered: err.to_string(),
2176                };
2177            }
2178        };
2179        let docs = self.search_documents(scope);
2180        let results = SearchIndex::new(docs).search(query, 10);
2181        let envelope =
2182            crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render("");
2183        match crate::output::render(format, &envelope) {
2184            Ok(rendered) => CliRunOutput {
2185                exit_code: 0,
2186                rendered,
2187            },
2188            Err(err) => CliRunOutput {
2189                exit_code: exit_code_for_error(&err),
2190                rendered: err.to_string(),
2191            },
2192        }
2193    }
2194
2195    /// Renders the bare-root response. For human output, renders long help plus
2196    /// a "Next actions" section so a human invoking the CLI with no arguments
2197    /// gets readable guidance; for machine-readable output, emits a discovery
2198    /// envelope (light metadata + next actions). The output format has already
2199    /// resolved the TTY/env/flag policy, so this just branches on it.
2200    fn render_root(&self, middleware: &Middleware, actions: Vec<NextAction>) -> CliRunOutput {
2201        // Reject an invalid explicit `--output` here too, matching the normal
2202        // command path (`Middleware::render_envelope`). `OutputFormat::from_str`
2203        // is infallible and would otherwise silently coerce an unrecognized
2204        // value (e.g. `--output yaml`) to JSON instead of reporting the error.
2205        if !crate::output::is_valid_output_format(&middleware.output_format) {
2206            let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone());
2207            return CliRunOutput {
2208                exit_code: exit_code_for_error(&err),
2209                rendered: err.to_string(),
2210            };
2211        }
2212        let format = middleware
2213            .output_format
2214            .parse()
2215            .unwrap_or(crate::output::OutputFormat::Json);
2216        if format == crate::output::OutputFormat::Human {
2217            // Fold the suggested actions into the root long-about so they render
2218            // alongside the other curated sections (before Usage) instead of
2219            // dangling beneath clap's options dump.
2220            let base_long = self
2221                .root
2222                .get_long_about()
2223                .map(ToString::to_string)
2224                .unwrap_or_default();
2225            let long = format!("{base_long}{}", render_next_actions_human(&actions));
2226            let rendered = self
2227                .root
2228                .clone()
2229                .long_about(long)
2230                .render_long_help()
2231                .to_string();
2232            return CliRunOutput {
2233                exit_code: 0,
2234                rendered,
2235            };
2236        }
2237        let description = self
2238            .config
2239            .long
2240            .as_deref()
2241            .filter(|long| !long.is_empty())
2242            .unwrap_or(self.config.short.as_str());
2243        let data = serde_json::json!({
2244            "description": description,
2245            "version": self.config.build.version,
2246        });
2247        let envelope = crate::Envelope::success(data, self.config.app_id.clone())
2248            .with_next_actions(actions)
2249            .prepare_for_render(&middleware.verbose);
2250        match crate::output::render(format, &envelope) {
2251            Ok(rendered) => CliRunOutput {
2252                exit_code: 0,
2253                rendered,
2254            },
2255            Err(err) => CliRunOutput {
2256                exit_code: exit_code_for_error(&err),
2257                rendered: err.to_string(),
2258            },
2259        }
2260    }
2261
2262    fn search_documents(&self, scope: &str) -> Vec<SearchDocument> {
2263        let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope)
2264            .unwrap_or((&self.root, Vec::new()));
2265        let mut docs = Vec::new();
2266        let mut aliases = Vec::new();
2267        append_command_alias_terms(scoped, &mut aliases);
2268        collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs);
2269        if scope.is_empty() {
2270            for entry in &self.guide_entries {
2271                docs.push(SearchDocument {
2272                    id: format!("guide:{}", entry.name),
2273                    kind: "guide".to_owned(),
2274                    title: format!("guide {}", entry.name),
2275                    summary: entry.summary.clone(),
2276                    content: format!("{} {}", entry.summary, entry.content),
2277                });
2278            }
2279            if let Some(extra_search_docs) = &self.extra_search_docs {
2280                docs.extend(extra_search_docs());
2281            }
2282        }
2283        docs
2284    }
2285
2286    /// Resolves `--scope`'s colon-separated path (e.g. `domain` or
2287    /// `domain:list`) to the canonical scope string [`Self::search_documents`]
2288    /// expects, matching aliases the same way a real command path would (via
2289    /// [`canonical_path_from_parts`]'s `find_subcommand` walk). An empty or
2290    /// unresolvable scope falls back to an unscoped (root) search rather than
2291    /// erroring — `search` staying permissive here matches how a typo in a
2292    /// search *query* just yields fewer results instead of a hard failure.
2293    /// An unresolvable (non-empty) scope prints a best-effort stderr hint
2294    /// first, so a typo like `--scope doamin` doesn't silently widen the
2295    /// search with no explanation for the extra results.
2296    fn resolve_search_scope(&self, scope_path: &str) -> String {
2297        if scope_path.is_empty() {
2298            return String::new();
2299        }
2300        let parts: Vec<String> = scope_path.split(':').map(str::to_owned).collect();
2301        match canonical_path_from_parts(&self.root, &parts) {
2302            Some(scope) => scope,
2303            None => {
2304                warn_unresolvable_search_scope(scope_path);
2305                String::new()
2306            }
2307        }
2308    }
2309
2310    fn canonical_command_path(&self, command_path: &str) -> String {
2311        find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else(
2312            || command_path.to_owned(),
2313            |(_, canonical)| canonical.join(":"),
2314        )
2315    }
2316
2317    fn render_guide(&self, matches: &ArgMatches, output_format: &str) -> CliRunOutput {
2318        use std::io::IsTerminal;
2319
2320        // Reject an invalid explicit `--output` here too, matching the normal
2321        // command path and `render_root`; otherwise an unrecognized value (e.g.
2322        // `--output yaml`) would silently fall through and emit raw content.
2323        if !crate::output::is_valid_output_format(output_format) {
2324            let err = CliCoreError::InvalidOutputFormat(output_format.to_owned());
2325            return CliRunOutput {
2326                exit_code: exit_code_for_error(&err),
2327                rendered: err.to_string(),
2328            };
2329        }
2330
2331        let leaf = leaf_matches(matches);
2332        let topic = leaf.get_one::<String>("topic").map(String::as_str);
2333        match guide_content(&self.guide_entries, topic) {
2334            Ok(rendered) => {
2335                // Only reflow an actual guide topic body, and only for human output.
2336                // The topic list is plain text (not markdown) and json/toon keep the
2337                // raw markdown so their output stays deterministic.
2338                let rendered = if topic.is_some() && output_format == "human" {
2339                    let is_tty = std::io::stdout().is_terminal();
2340                    render_guide_human(&rendered, crate::output::terminal_width(), is_tty)
2341                } else {
2342                    rendered
2343                };
2344                CliRunOutput {
2345                    exit_code: 0,
2346                    rendered,
2347                }
2348            }
2349            Err(err) => CliRunOutput {
2350                exit_code: 1,
2351                rendered: err,
2352            },
2353        }
2354    }
2355
2356    fn render_completion_print(
2357        &self,
2358        shell_opt: Option<String>,
2359        middleware: &Middleware,
2360    ) -> CliRunOutput {
2361        use crate::cli::completion::{detect_shell, generate_script, parse_shell};
2362        let shell = match shell_opt {
2363            Some(s) => match parse_shell(&s) {
2364                Ok(s) => s,
2365                Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
2366            },
2367            None => match detect_shell() {
2368                Ok(s) => s,
2369                Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
2370            },
2371        };
2372        match generate_script(&self.root, &self.config.name, shell) {
2373            Ok(script) => CliRunOutput {
2374                exit_code: 0,
2375                rendered: script,
2376            },
2377            Err(e) => render_cli_error(middleware, &e, &self.config.app_id),
2378        }
2379    }
2380
2381    fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
2382        let leaf = leaf_matches(matches);
2383        let parts = leaf
2384            .get_many::<String>("command")
2385            .map(|values| values.map(String::as_str).collect::<Vec<_>>())
2386            .unwrap_or_default();
2387        self.render_help_for_parts(&parts)
2388    }
2389
2390    /// Renders the curated help text for a resolved command path.
2391    ///
2392    /// Empty `parts` render the root help. A path that resolves to a group or
2393    /// command renders that command's long help; an unresolved path returns the
2394    /// standard "unknown command" guidance with a non-zero exit code. Shared by
2395    /// the root `help <path>` command and the `<group> help` subcommand form.
2396    fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput {
2397        if parts.is_empty() {
2398            return CliRunOutput {
2399                exit_code: 0,
2400                rendered: self.root.clone().render_long_help().to_string(),
2401            };
2402        }
2403        let Some(command) = find_help_target(&self.root, parts) else {
2404            return CliRunOutput {
2405                exit_code: 1,
2406                rendered: format!(
2407                    "unknown command {:?} — run '{} help' for available commands",
2408                    parts.join(" "),
2409                    self.config.name
2410                ),
2411            };
2412        };
2413        CliRunOutput {
2414            exit_code: 0,
2415            rendered: command.clone().render_long_help().to_string(),
2416        }
2417    }
2418
2419    fn refresh_root_long(&mut self) {
2420        // Module-categorized entries, plus any visible top-level command that is
2421        // neither categorized nor an engine built-in, listed under a generic
2422        // "Commands" section. This keeps every command discoverable once clap's
2423        // auto subcommand list is suppressed by the root help template.
2424        let builtins = BUILTIN_COMMAND_NAMES;
2425        let categorized: BTreeSet<&str> = self
2426            .module_entries
2427            .iter()
2428            .map(|entry| entry.name.as_str())
2429            .collect();
2430        let mut generic: Vec<ModuleHelpEntry> = self
2431            .root
2432            .get_subcommands()
2433            .filter(|command| !command.is_hide_set())
2434            .filter(|command| !builtins.contains(&command.get_name()))
2435            .filter(|command| !categorized.contains(command.get_name()))
2436            .map(|command| ModuleHelpEntry {
2437                category: "Commands".to_owned(),
2438                name: command.get_name().to_owned(),
2439                short: command
2440                    .get_about()
2441                    .map(ToString::to_string)
2442                    .unwrap_or_default(),
2443            })
2444            .collect();
2445        generic.sort_by(|left, right| left.name.cmp(&right.name));
2446
2447        let mut entries = self.module_entries.clone();
2448        entries.extend(generic);
2449        let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide");
2450        let intro = self
2451            .config
2452            .long
2453            .as_deref()
2454            .filter(|long| !long.is_empty())
2455            .unwrap_or(self.config.short.as_str());
2456        self.root = self
2457            .root
2458            .clone()
2459            .long_about(build_root_long(intro, &entries, has_guide));
2460    }
2461
2462    fn ensure_auth_command(&mut self) {
2463        let default_provider = self.default_auth_provider();
2464        let registered_names = self.middleware.auth.registered_names();
2465        if default_provider.is_empty() && registered_names.is_empty() {
2466            return;
2467        }
2468        let replacing_builtin = self.commands.contains_key("auth:login");
2469        if has_subcommand(&self.root, "auth") && !replacing_builtin {
2470            return;
2471        }
2472        let mut group = auth_command_group(&default_provider, &registered_names);
2473        let mut seen_names: std::collections::HashSet<String> =
2474            group.commands.iter().map(|c| c.spec.name.clone()).collect();
2475        for extra in self.config.auth_extra_commands.clone() {
2476            if !seen_names.insert(extra.spec.name.clone()) {
2477                tracing::warn!(
2478                    command = %extra.spec.name,
2479                    "auth_extra_commands entry collides with a built-in auth subcommand or an \
2480                     earlier auth_extra_commands entry; ignoring"
2481                );
2482                continue;
2483            }
2484            group = group.with_command(extra);
2485        }
2486        let mut prefix = Vec::new();
2487        register_runtime_group_metadata(
2488            &group,
2489            &mut prefix,
2490            &mut self.middleware.schema_registry,
2491            &mut self.middleware.human_views,
2492        );
2493        let mut prefix = Vec::new();
2494        group.register_commands(&mut prefix, &mut self.commands);
2495        let mut prefix = Vec::new();
2496        let clap_group = runtime_group_clap_command_with_schema_help(
2497            &group,
2498            &mut prefix,
2499            &self.middleware.schema_registry,
2500        );
2501        self.root = if replacing_builtin {
2502            self.root.clone().mut_subcommand("auth", |_| clap_group)
2503        } else {
2504            self.root.clone().subcommand(clap_group)
2505        };
2506        // Categorize `auth` wherever it is ensured (construction or a later
2507        // `register_auth_provider`), so it never falls into the generic
2508        // "Commands" bucket. Idempotent via the `already_listed` guard.
2509        self.register_auth_help_entry();
2510    }
2511
2512    /// Mounts the built-in `config` command group and files it under the admin
2513    /// help category. Idempotent and yields to a consumer-defined `config`
2514    /// subcommand if one already exists.
2515    fn ensure_config_command(&mut self) {
2516        if has_subcommand(&self.root, "config") {
2517            return;
2518        }
2519        let group = crate::config_commands::config_command_group();
2520        let mut prefix = Vec::new();
2521        group.register_commands(&mut prefix, &mut self.commands);
2522        let mut prefix = Vec::new();
2523        let clap_group = runtime_group_clap_command_with_schema_help(
2524            &group,
2525            &mut prefix,
2526            &self.middleware.schema_registry,
2527        );
2528        self.root = self.root.clone().subcommand(clap_group);
2529        let category = self
2530            .config
2531            .admin_category
2532            .clone()
2533            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2534        if !self
2535            .module_entries
2536            .iter()
2537            .any(|entry| entry.name == "config")
2538        {
2539            self.module_entries.push(ModuleHelpEntry {
2540                category,
2541                name: "config".to_owned(),
2542                short: "Read and write the CLI config file".to_owned(),
2543            });
2544        }
2545        self.refresh_root_long();
2546    }
2547
2548    /// Mounts the built-in `env` command group and files it under the admin
2549    /// help category. Idempotent and yields to a consumer-defined `env`
2550    /// subcommand if one already exists.
2551    fn ensure_env_command(&mut self) {
2552        if has_subcommand(&self.root, "env") {
2553            return;
2554        }
2555        let group = crate::env_commands::env_command_group();
2556        let mut prefix = Vec::new();
2557        group.register_commands(&mut prefix, &mut self.commands);
2558        let mut prefix = Vec::new();
2559        let clap_group = runtime_group_clap_command_with_schema_help(
2560            &group,
2561            &mut prefix,
2562            &self.middleware.schema_registry,
2563        );
2564        self.root = self.root.clone().subcommand(clap_group);
2565        let category = self
2566            .config
2567            .admin_category
2568            .clone()
2569            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2570        if !self.module_entries.iter().any(|e| e.name == "env") {
2571            self.module_entries.push(ModuleHelpEntry {
2572                category,
2573                name: "env".to_owned(),
2574                short: "Manage the active environment".to_owned(),
2575            });
2576        }
2577        self.refresh_root_long();
2578    }
2579
2580    /// Mounts the built-in `flags` command group and files it under the admin
2581    /// help category. Idempotent and yields to a consumer-defined `flags`
2582    /// subcommand if one already exists. Unlike [`Self::ensure_env_command`],
2583    /// this is mounted unconditionally: feature-flag introspection does not
2584    /// depend on any opt-in system, so it is always available.
2585    fn ensure_flags_command(&mut self) {
2586        if has_subcommand(&self.root, "flags") {
2587            return;
2588        }
2589        let group = crate::flag_commands::flags_command_group();
2590        let mut prefix = Vec::new();
2591        group.register_commands(&mut prefix, &mut self.commands);
2592        let mut prefix = Vec::new();
2593        let clap_group = runtime_group_clap_command_with_schema_help(
2594            &group,
2595            &mut prefix,
2596            &self.middleware.schema_registry,
2597        );
2598        self.root = self.root.clone().subcommand(clap_group);
2599        let category = self
2600            .config
2601            .admin_category
2602            .clone()
2603            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2604        if !self.module_entries.iter().any(|e| e.name == "flags") {
2605            self.module_entries.push(ModuleHelpEntry {
2606                category,
2607                name: "flags".to_owned(),
2608                short: "Inspect declared feature flags".to_owned(),
2609            });
2610        }
2611        self.refresh_root_long();
2612    }
2613
2614    fn default_auth_provider(&self) -> String {
2615        if !self.middleware.default_auth_provider.is_empty() {
2616            return self.middleware.default_auth_provider.clone();
2617        }
2618        self.middleware
2619            .auth
2620            .registered_names()
2621            .into_iter()
2622            .next()
2623            .unwrap_or_default()
2624    }
2625
2626    fn initialized_middleware(&self) -> Result<Middleware> {
2627        let Some(init_deps) = &self.init_deps else {
2628            return Ok(self.middleware.clone());
2629        };
2630        let mut guard = self
2631            .init_state
2632            .lock()
2633            .map_err(|_| CliCoreError::message("init deps lock poisoned"))?;
2634        if let Some(result) = guard.as_ref() {
2635            return result.clone().map_err(InitFailure::into_error);
2636        }
2637        let mut middleware = self.middleware.clone();
2638        let result = init_deps(&mut middleware)
2639            .map(|()| middleware)
2640            .map_err(|err| InitFailure::capture(&err));
2641        *guard = Some(result.clone());
2642        result.map_err(InitFailure::into_error)
2643    }
2644
2645    fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2646        if let Some(apply_flags) = &self.apply_flags {
2647            apply_flags(matches, middleware)?;
2648        }
2649        Ok(())
2650    }
2651
2652    /// Applies the global `--env` override to a per-run middleware snapshot.
2653    ///
2654    /// The flag is only registered when environments are configured, so when it
2655    /// is present `middleware.environments` is set too. Validates the requested
2656    /// name against the registered environments and updates `middleware.env`,
2657    /// returning an error for an unknown environment.
2658    fn apply_env_flag(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2659        // Guard on the environment system FIRST. The `--env` arg is only
2660        // registered when environments are configured (the same condition that
2661        // sets `middleware.environments`); calling `matches.get_one("env")` for
2662        // an arg that was never registered panics in clap, which would break
2663        // every CLI that does not use environments.
2664        let Some(environments) = middleware.environments.as_ref() else {
2665            return Ok(());
2666        };
2667        if let Some(env) = matches.get_one::<String>("env") {
2668            environments.source(env)?;
2669            middleware.env = env.clone();
2670        }
2671        Ok(())
2672    }
2673
2674    fn run_pre_run(
2675        &self,
2676        middleware: &mut Middleware,
2677        command_path: &str,
2678        args: &crate::middleware::ValueMap,
2679    ) -> Result<()> {
2680        if let Some(pre_run) = &self.pre_run {
2681            pre_run(middleware, command_path, args)?;
2682        }
2683        Ok(())
2684    }
2685
2686    fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta {
2687        if let Some(resolver) = &self.meta_resolver {
2688            resolver(command_path, meta)
2689        } else {
2690            meta
2691        }
2692    }
2693
2694    fn finish_run(&self, output: CliRunOutput) -> CliRunOutput {
2695        // Clear the per-thread credential-store flag so it does not leak into
2696        // subsequent sequential runs on the same thread.
2697        crate::config::clear_credential_store_flag();
2698        if let Some(on_shutdown) = &self.on_shutdown {
2699            on_shutdown();
2700        }
2701        output
2702    }
2703}
2704
2705fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option<Duration>) {
2706    middleware.output_format = flags.output_format.clone();
2707    middleware.verbose = flags.verbose.clone();
2708    middleware.dry_run = flags.dry_run;
2709    middleware.fields = flags.fields.clone();
2710    middleware.filter = flags.filter.clone();
2711    middleware.expr = flags.expr.clone();
2712    middleware.reason = flags.reason.clone();
2713    middleware.schema = flags.schema;
2714    middleware.timeout = timeout;
2715    middleware.debug = flags.debug.clone();
2716    middleware.interactive = flags.interactive;
2717}
2718
2719/// Sets `middleware.limit`/`middleware.offset` from a paginating command's own
2720/// `--limit`/`--offset`
2721fn apply_pagination_flags(middleware: &mut Middleware, spec: &CommandSpec, leaf: &ArgMatches) {
2722    let Some(pagination) = spec.pagination else {
2723        return;
2724    };
2725    middleware.limit = leaf
2726        .get_one::<i64>("limit")
2727        .copied()
2728        .unwrap_or(pagination.default_limit);
2729    middleware.offset = leaf.get_one::<i64>("offset").copied().unwrap_or(0);
2730}
2731
2732/// Replays a paginating command's own explicit args, plus the global
2733/// `--filter`/`--expr`/`--fields` flags, as `--flag value` text, prefixed
2734/// with the CLI's binary name — the base a "view the next page"
2735/// [`crate::NextAction`] is built from once the response's
2736/// [`crate::PaginationMeta`] is known. Leading with the binary name keeps the
2737/// suggested command copy-pastable rather than a fragment starting at the
2738/// noun/verb path.
2739///
2740/// `--filter`/`--expr`/`--fields` sit in the same output pipeline as
2741/// pagination itself (filter -> paginate -> expr -> fields) and change what
2742/// data comes back, so dropping them would make the suggested next-page
2743/// command return different results than the command the user actually ran.
2744/// Other global flags (`--output`, `--verbose`, `--env`, ...) don't affect
2745/// *which* data is returned, so they're intentionally left out — the caller
2746/// is already running under them.
2747///
2748/// Best-effort, not a fully general clap-args reconstruction: it uses each
2749/// arg's real `get_long()`/`get_short()` name (never the value-map key,
2750/// which for derive-based args can differ from the flag — e.g. id
2751/// `page_size` vs flag `--page-size`), replays a multi-value arg as one
2752/// flag occurrence per value (round-trips correctly whether the arg is a
2753/// plain repeatable `ArgAction::Append` or also sets a `value_delimiter`),
2754/// and quotes/escapes values containing whitespace or shell metacharacters
2755/// (see `quote_pagination_value`). Deliberately omits `--limit`/`--offset` —
2756/// those are added by the caller once it knows the
2757/// next page's offset.
2758fn pagination_command_base(
2759    binary_name: &str,
2760    command_path: &str,
2761    spec: &CommandSpec,
2762    user_args: &crate::middleware::ValueMap,
2763    flags: &GlobalFlags,
2764) -> String {
2765    let mut parts = vec![
2766        quote_pagination_value(binary_name),
2767        command_path.replace(':', " "),
2768    ];
2769    for arg in &spec.args {
2770        let id = arg.get_id().as_str();
2771        if let Some(value) = user_args.get(id) {
2772            push_pagination_arg(&mut parts, arg, value);
2773        }
2774    }
2775    for (flag, value) in [
2776        ("--filter", &flags.filter),
2777        ("--expr", &flags.expr),
2778        ("--fields", &flags.fields),
2779    ] {
2780        if !value.is_empty() {
2781            parts.push(flag.to_owned());
2782            parts.push(quote_pagination_value(value));
2783        }
2784    }
2785    parts.join(" ")
2786}
2787
2788fn push_pagination_arg(parts: &mut Vec<String>, arg: &Arg, value: &serde_json::Value) {
2789    let flag = arg
2790        .get_long()
2791        .map(|long| format!("--{long}"))
2792        .or_else(|| arg.get_short().map(|short| format!("-{short}")));
2793    match value {
2794        serde_json::Value::Bool(enabled) => {
2795            if matches!(
2796                arg.get_action(),
2797                clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
2798            ) {
2799                // A switch-style flag's presence in `user_args` already means
2800                // the user typed exactly this flag — `SetTrue` implies `true`,
2801                // `SetFalse` implies `false` (e.g. a `--no-foo`-style arg) —
2802                // and neither accepts an explicit `=value` token, so replay
2803                // the bare flag rather than appending one.
2804                if let Some(flag) = flag {
2805                    parts.push(flag);
2806                }
2807            } else {
2808                // A custom bool-valued arg (`ArgAction::Set` with a bool
2809                // value parser) takes an explicit token, so replay it like
2810                // any other scalar.
2811                push_flagged_value(parts, flag, &enabled.to_string());
2812            }
2813        }
2814        serde_json::Value::Array(items) => {
2815            // Repeat the flag once per value rather than joining into one
2816            // comma-separated token: clap collects a repeatable flag
2817            // (`ArgAction::Append`, the common way a command declares a
2818            // multi-value arg) the same way whether or not it also sets
2819            // `value_delimiter(',')`, so `--scope a --scope b` round-trips
2820            // correctly either way. A single `--scope a,b` only works when
2821            // a delimiter was configured — for a plain `Append` arg it's
2822            // parsed as one literal value, changing the replay's meaning.
2823            for item in items {
2824                push_flagged_value(parts, flag.clone(), &pagination_arg_display(item));
2825            }
2826        }
2827        serde_json::Value::Null => {}
2828        other => push_flagged_value(parts, flag, &pagination_arg_display(other)),
2829    }
2830}
2831
2832fn push_flagged_value(parts: &mut Vec<String>, flag: Option<String>, value: &str) {
2833    if let Some(flag) = flag {
2834        parts.push(flag);
2835    }
2836    parts.push(quote_pagination_value(value));
2837}
2838
2839fn pagination_arg_display(value: &serde_json::Value) -> String {
2840    match value {
2841        serde_json::Value::String(text) => text.clone(),
2842        other => other.to_string(),
2843    }
2844}
2845
2846/// Quotes a value for the suggested next-page command, if it contains
2847/// anything beyond a small safe-unquoted allowlist. Whitespace and shell
2848/// metacharacters (`|`, `&`, `;`, `<`, `>`, ...) all fall outside that
2849/// allowlist and so trigger quoting; once quoted, `\`, `"`, `$`, and `` ` ``
2850/// are backslash-escaped (backslash first, so escaping the others doesn't
2851/// re-escape the backslashes it just inserted) so the value can't break out
2852/// of the double quotes or trigger POSIX-shell expansion (`$VAR`, `$(...)`,
2853/// backticks) if the suggestion is copy-pasted into a shell.
2854fn quote_pagination_value(value: &str) -> String {
2855    let safe_unquoted =
2856        |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@');
2857    if value.is_empty() || !value.chars().all(safe_unquoted) {
2858        let escaped = value
2859            .replace('\\', "\\\\")
2860            .replace('"', "\\\"")
2861            .replace('$', "\\$")
2862            .replace('`', "\\`");
2863        format!("\"{escaped}\"")
2864    } else {
2865        value.to_owned()
2866    }
2867}
2868
2869/// Builds the transport debug logger implied by a parsed `--debug` pattern,
2870/// without publishing it anywhere.
2871///
2872/// Pure so tests can assert on the decision (`--debug` pattern -> enabled or
2873/// not) without touching the process-wide default logger, which every
2874/// [`Cli::run`] call republishes — including the many unrelated tests that
2875/// exercise `cli.run(...)` with no `--debug` flag and would otherwise race
2876/// with an assertion on the shared global.
2877fn debug_transport_logger_for(
2878    debug: &str,
2879    extra_redacted: &[String],
2880) -> Arc<dyn crate::transport::TransportLogger> {
2881    if crate::debug_component_enabled(debug, "transport") {
2882        Arc::new(
2883            crate::transport::StderrTransportLogger::new()
2884                .with_redacted_headers(extra_redacted.iter().cloned()),
2885        )
2886    } else {
2887        Arc::new(crate::transport::NoopTransportLogger)
2888    }
2889}
2890
2891/// Installs (or clears) the process-wide transport debug logger from the parsed
2892/// `--debug` pattern.
2893///
2894/// When `--debug` selects the `transport` component the engine publishes a
2895/// [`StderrTransportLogger`](crate::transport::StderrTransportLogger) — extended
2896/// with any [`CliConfig::with_redacted_debug_headers`] entries — which every
2897/// [`HttpClient`](crate::transport::HttpClient) built afterward picks up
2898/// automatically, with no per-command wiring. The logger is reset to a noop when
2899/// `transport` is not selected so the explicit setting always reflects the
2900/// current invocation rather than a stale process-global from an earlier one.
2901fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) {
2902    crate::transport::set_default_transport_logger(debug_transport_logger_for(
2903        debug,
2904        extra_redacted,
2905    ));
2906}
2907
2908async fn run_with_timeout<F, T>(
2909    timeout: Option<Duration>,
2910    timeout_label: &str,
2911    future: F,
2912) -> Result<T>
2913where
2914    F: Future<Output = Result<T>>,
2915{
2916    let Some(timeout) = timeout else {
2917        return future.await;
2918    };
2919    match tokio::time::timeout(timeout, future).await {
2920        Ok(result) => result,
2921        Err(_) => Err(CliCoreError::message(format!(
2922            "command timed out after {timeout_label}"
2923        ))),
2924    }
2925}
2926
2927async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
2928where
2929    Run: Future<Output = CliRunOutput>,
2930    Shutdown: Future<Output = ()>,
2931{
2932    tokio::pin!(run);
2933    tokio::pin!(shutdown);
2934    tokio::select! {
2935        output = &mut run => output,
2936        () = &mut shutdown => CliRunOutput {
2937            exit_code: 130,
2938            rendered: "command interrupted\n".to_owned(),
2939        },
2940    }
2941}
2942
2943#[cfg(unix)]
2944async fn shutdown_signal() {
2945    let ctrl_c = tokio::signal::ctrl_c();
2946    match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2947        Ok(mut sigterm) => {
2948            tokio::select! {
2949                _ = ctrl_c => {},
2950                _ = sigterm.recv() => {},
2951            }
2952        }
2953        Err(_) => {
2954            drop(ctrl_c.await);
2955        }
2956    }
2957}
2958
2959#[cfg(not(unix))]
2960async fn shutdown_signal() {
2961    drop(tokio::signal::ctrl_c().await);
2962}
2963
2964fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
2965    let raw = raw.trim();
2966    if raw.is_empty() {
2967        return Ok(Some(Duration::from_secs(60)));
2968    }
2969    let Some(seconds) = parse_duration_seconds(raw) else {
2970        return Err(CliCoreError::message(format!(
2971            "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
2972        )));
2973    };
2974    if seconds <= 0.0 {
2975        Ok(None)
2976    } else {
2977        Ok(Some(Duration::from_secs_f64(seconds)))
2978    }
2979}
2980
2981fn parse_duration_seconds(raw: &str) -> Option<f64> {
2982    for (suffix, seconds) in [
2983        ("ns", 0.000_000_001_f64),
2984        ("us", 0.000_001_f64),
2985        ("µs", 0.000_001_f64),
2986        ("ms", 0.001_f64),
2987        ("s", 1.0_f64),
2988        ("m", 60.0_f64),
2989        ("h", 3600.0_f64),
2990    ] {
2991        if let Some(number) = raw.strip_suffix(suffix) {
2992            let value = number.parse::<f64>().ok()?;
2993            if !value.is_finite() {
2994                return None;
2995            }
2996            return Some(value * seconds);
2997        }
2998    }
2999    None
3000}
3001
3002/// Reads the global `${APP_ID}_MIN_STAGE` override (see [`min_stage_env_var`]).
3003///
3004/// Best-effort, like [`crate::config::ConfigFile::load`]'s handling of a
3005/// malformed config file: returns `None` when the var is unset, and also
3006/// `None` (after logging a warning) when it is set but fails to parse as a
3007/// [`Stage`], so a typo'd value cannot take the CLI down.
3008fn global_min_stage_override(app_id: &str) -> Option<Stage> {
3009    let var = min_stage_env_var(app_id);
3010    let value = std::env::var(&var).ok()?;
3011    value.parse::<Stage>().map_or_else(
3012        |err| {
3013            tracing::warn!(var = %var, value = %value, error = %err, "ignoring invalid min-stage override");
3014            None
3015        },
3016        Some,
3017    )
3018}
3019
3020/// Pure scan over an arg iterator for the last `--env <value>`/`--env=<value>`
3021/// occurrence — used only to seed [`Cli::new`]'s `flag_policy` (and therefore
3022/// which flagged commands get pruned) before the command tree is built, since
3023/// that decision can't be revisited once real argv is parsed. The real,
3024/// per-invocation `--env` value used for dispatch still comes from
3025/// `apply_env_flag`'s clap-based parse, unchanged; this scan never replaces
3026/// it, only decides tree shape earlier than clap otherwise could
3027/// (clap's own [`clap::Command::ignore_errors`] does not help here — it
3028/// still requires the rest of the argv to parse against a *known* subcommand
3029/// structure, and at prescan time no domain modules are registered yet, so a
3030/// real command path makes it bail on capturing global flags too).
3031///
3032/// Scans the *entire* argv and keeps the *last* non-empty `--env`/`--env=`
3033/// value, rather than stopping at the first match — a global `--env` and a
3034/// command-local one sharing the same arg id can both appear in one
3035/// invocation, and whichever clap resolves as the effective value
3036/// (empirically, the last one) is the one this scan must agree with. An
3037/// empty value (`--env=` with nothing after the `=`, or `--env` immediately
3038/// followed by another flag with nothing captured) is ignored rather than
3039/// becoming a literal empty-string candidate.
3040fn prescan_env_flag(mut args: impl Iterator<Item = String>) -> Option<String> {
3041    let mut result = None;
3042    while let Some(arg) = args.next() {
3043        // clap's end-of-options sentinel: everything after a bare `--` is a
3044        // positional argument, never a flag, no matter what it looks like.
3045        // This scan must agree, or `app cmd -- --env dev` would be
3046        // misread as a real `--env` override.
3047        if arg == "--" {
3048            break;
3049        }
3050        let value = if let Some(v) = arg.strip_prefix("--env=") {
3051            Some(v.to_owned())
3052        } else if arg == "--env" {
3053            // A space-separated value that itself looks like another flag
3054            // (starts with `-`) is not a value at all — clap rejects this
3055            // outright ("a value is required for '--env <ENV>' but none was
3056            // supplied"), so this scan must not treat it as one either. An
3057            // explicit `--env=-foo` is unambiguous and still accepted, same
3058            // as clap's own disambiguation rule.
3059            args.next().filter(|v| !v.starts_with('-'))
3060        } else {
3061            None
3062        };
3063        if let Some(v) = value.filter(|v| !v.is_empty()) {
3064            result = Some(v);
3065        }
3066    }
3067    result
3068}
3069
3070fn render_cli_error(
3071    middleware: &Middleware,
3072    err: &(dyn std::error::Error + 'static),
3073    system: &str,
3074) -> CliRunOutput {
3075    let format = middleware
3076        .output_format
3077        .parse::<crate::output::OutputFormat>()
3078        .unwrap_or(crate::output::OutputFormat::Json);
3079    let envelope =
3080        crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
3081    match crate::output::render(format, &envelope) {
3082        Ok(rendered) => CliRunOutput {
3083            exit_code: exit_code_for_error(err),
3084            rendered,
3085        },
3086        Err(render_err) => CliRunOutput {
3087            exit_code: exit_code_for_error(err),
3088            rendered: render_err.to_string(),
3089        },
3090    }
3091}
3092
3093fn find_command_by_colon_path<'command>(
3094    root: &'command Command,
3095    path: &str,
3096) -> Option<&'command Command> {
3097    find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
3098}
3099
3100fn find_help_target<'command>(
3101    root: &'command Command,
3102    parts: &[&str],
3103) -> Option<&'command Command> {
3104    let mut current = root;
3105    let mut matched_any = false;
3106    for part in parts {
3107        let Some(next) = current.find_subcommand(part) else {
3108            break;
3109        };
3110        current = next;
3111        matched_any = true;
3112    }
3113    matched_any.then_some(current)
3114}
3115
3116fn find_command_and_canonical_path_by_colon_path<'command>(
3117    root: &'command Command,
3118    path: &str,
3119) -> Option<(&'command Command, Vec<String>)> {
3120    if path.is_empty() {
3121        return Some((root, Vec::new()));
3122    }
3123    let mut current = root;
3124    let mut canonical = Vec::new();
3125    for part in path.split(':') {
3126        current = current.find_subcommand(part)?;
3127        canonical.push(current.get_name().to_owned());
3128    }
3129    Some((current, canonical))
3130}
3131
3132fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
3133    if parts.is_empty() {
3134        return Some(String::new());
3135    }
3136    let mut current = root;
3137    let mut canonical = Vec::new();
3138    for part in parts {
3139        current = current.find_subcommand(part)?;
3140        canonical.push(current.get_name().to_owned());
3141    }
3142    Some(canonical.join(":"))
3143}
3144
3145/// Best-effort stderr hint for a `--scope` value that didn't resolve to a
3146/// known command path — `resolve_search_scope` still searches everything
3147/// (matching a bare `search` with no `--scope` at all), so this is the only
3148/// signal the user gets that their scope was ignored rather than applied.
3149/// Written directly to a locked stderr handle (not `eprintln!`), matching
3150/// the transport module's own `StderrTransportLogger` convention for this
3151/// kind of side-channel diagnostic: best-effort, so a write failure is
3152/// discarded rather than surfaced as a command error.
3153fn warn_unresolvable_search_scope(scope_path: &str) {
3154    let mut stderr = std::io::stderr().lock();
3155    stderr
3156        .write_all(
3157            format!(
3158                "warning: --scope {scope_path:?} did not match a known command path; searching everything instead\n"
3159            )
3160            .as_bytes(),
3161        )
3162        .ok();
3163}
3164
3165fn collect_command_search_documents(
3166    command: &Command,
3167    prefix: &mut Vec<String>,
3168    aliases: &mut Vec<String>,
3169    docs: &mut Vec<SearchDocument>,
3170) {
3171    if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
3172        return;
3173    }
3174    if command.get_subcommands().next().is_some() {
3175        for child in command.get_subcommands() {
3176            prefix.push(child.get_name().to_owned());
3177            let alias_len = aliases.len();
3178            append_command_alias_terms(child, aliases);
3179            collect_command_search_documents(child, prefix, aliases, docs);
3180            aliases.truncate(alias_len);
3181            prefix.pop();
3182        }
3183        return;
3184    }
3185    if prefix.is_empty() {
3186        prefix.push(command.get_name().to_owned());
3187        append_command_alias_terms(command, aliases);
3188    }
3189    let path = prefix.join(" ");
3190    let alias_text = aliases.join(" ");
3191    docs.push(SearchDocument {
3192        id: format!("cmd:{path}"),
3193        kind: "command".to_owned(),
3194        title: path,
3195        summary: command
3196            .get_about()
3197            .map(ToString::to_string)
3198            .unwrap_or_default(),
3199        content: format!(
3200            "{} {} {} {}",
3201            command
3202                .get_about()
3203                .map(ToString::to_string)
3204                .unwrap_or_default(),
3205            command
3206                .get_long_about()
3207                .map(ToString::to_string)
3208                .unwrap_or_default(),
3209            command_flag_text(command),
3210            alias_text
3211        ),
3212    });
3213    if prefix.len() == 1 && prefix[0] == command.get_name() {
3214        prefix.pop();
3215    }
3216}
3217
3218fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
3219    aliases.extend(command.get_all_aliases().map(str::to_owned));
3220    aliases.extend(
3221        command
3222            .get_all_short_flag_aliases()
3223            .map(|alias| alias.to_string()),
3224    );
3225    aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
3226}
3227
3228fn command_flag_text(command: &Command) -> String {
3229    command
3230        .get_arguments()
3231        .filter(|arg| !arg.is_hide_set())
3232        .filter_map(|arg| {
3233            let mut names = Vec::new();
3234            if let Some(short) = arg.get_short() {
3235                names.push(format!("-{short}"));
3236            }
3237            if let Some(long) = arg.get_long() {
3238                names.push(format!("--{long}"));
3239            }
3240            if let Some(short_aliases) = arg.get_all_short_aliases() {
3241                names.extend(
3242                    short_aliases
3243                        .into_iter()
3244                        .map(|short_alias| format!("-{short_alias}")),
3245                );
3246            }
3247            if let Some(aliases) = arg.get_all_aliases() {
3248                names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
3249            }
3250            (!names.is_empty()).then(|| names.join(" "))
3251        })
3252        .collect::<Vec<_>>()
3253        .join(" ")
3254}
3255
3256fn has_subcommand(command: &Command, name: &str) -> bool {
3257    command
3258        .get_subcommands()
3259        .any(|child| child.get_name() == name)
3260}
3261
3262fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
3263    let bool_flags = derive_bool_flags(root);
3264    let value_flags = derive_value_flags(root);
3265    let mut iter = args.iter().peekable();
3266    if iter
3267        .peek()
3268        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3269    {
3270        iter.next();
3271    }
3272
3273    while let Some(arg) = iter.next() {
3274        match arg.as_str() {
3275            "--version" | "-v" => return true,
3276            "--" => return false,
3277            value if value.contains('=') || bool_flags.contains(value) => continue,
3278            value
3279                if value_flags.contains(value)
3280                    || unknown_flag_consumes_value(value, iter.peek()) =>
3281            {
3282                iter.next();
3283            }
3284            value if value.starts_with('-') => {}
3285            _ => return false,
3286        }
3287    }
3288    false
3289}
3290
3291fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
3292    let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
3293    let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
3294    let mut normalized = Vec::with_capacity(args.len());
3295    let mut index = 0;
3296    let mut current = root;
3297    while index < args.len() {
3298        let arg = &args[index];
3299        if index == 0 && arg_matches_root_name(arg, root.get_name()) {
3300            normalized.push(arg.clone());
3301            index += 1;
3302            continue;
3303        }
3304
3305        if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
3306            normalized.push(format!("{arg}={default}"));
3307            index += 1;
3308            continue;
3309        }
3310
3311        if let Some(default) = optional_string_defaults.get(arg.as_str()) {
3312            match args.get(index + 1) {
3313                None => {
3314                    normalized.push(format!("{arg}={default}"));
3315                    index += 1;
3316                    continue;
3317                }
3318                Some(next)
3319                    if current.get_name() == root.get_name()
3320                        || next.starts_with('-')
3321                        || direct_subcommand(current, next).is_some() =>
3322                {
3323                    normalized.push(format!("{arg}={default}"));
3324                    index += 1;
3325                    continue;
3326                }
3327                Some(next) => {
3328                    normalized.push(arg.clone());
3329                    normalized.push(next.clone());
3330                    index += 2;
3331                    continue;
3332                }
3333            }
3334        }
3335
3336        normalized.push(arg.clone());
3337        if !arg.starts_with('-')
3338            && let Some(next_command) = direct_subcommand(current, arg)
3339        {
3340            current = next_command;
3341        }
3342        index += 1;
3343    }
3344    normalized
3345}
3346
3347fn direct_subcommand<'command>(
3348    command: &'command Command,
3349    token: &str,
3350) -> Option<&'command Command> {
3351    command.get_subcommands().find(|child| {
3352        child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
3353    })
3354}
3355
3356/// Appends a `— did you mean "…"?` suffix to an unknown-command error clause.
3357fn format_did_you_mean(base: &str, suggestion: &str) -> String {
3358    format!("{base} — did you mean {suggestion:?}?")
3359}
3360
3361/// First unknown group token (`unknown command "X" for "Y"`, no hint suffix).
3362struct UnknownGroupCommand {
3363    base: String,
3364}
3365
3366/// Reports the first unknown token under a group. `positionals` must be pre-`--`
3367/// command keywords (slice to `command_keyword_count` like the group-help path).
3368fn detect_unknown_group_command(
3369    root: &Command,
3370    positionals: &[String],
3371) -> Option<UnknownGroupCommand> {
3372    if positionals.is_empty() {
3373        return None;
3374    }
3375
3376    let mut current = root;
3377    let mut path = vec![root.get_name().to_owned()];
3378    for token in positionals {
3379        if let Some(next) = current.find_subcommand(token) {
3380            current = next;
3381            path.push(next.get_name().to_owned());
3382            continue;
3383        }
3384        if current.get_subcommands().next().is_some() {
3385            let base = format!("unknown command {token:?} for {:?}", path.join(" "));
3386            return Some(UnknownGroupCommand { base });
3387        }
3388        return None;
3389    }
3390    None
3391}
3392
3393/// Counts positional command tokens that precede any `--` separator.
3394fn command_keyword_count(
3395    args: &[String],
3396    root_name: &str,
3397    bool_flags: &BTreeSet<String>,
3398    value_flags: &BTreeSet<String>,
3399) -> usize {
3400    let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags);
3401    match args.iter().position(|arg| arg == "--") {
3402        Some(end) => {
3403            positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len()
3404        }
3405        None => positionals.len(),
3406    }
3407}
3408
3409/// Rewrites `<group> help [sub...]` into `help <group> [sub...]` when the form
3410/// is present; otherwise returns `clap_args` unchanged.
3411fn rewrite_group_help_if_needed(
3412    root: &Command,
3413    clap_args: &[String],
3414    root_name: &str,
3415    bool_flags: &BTreeSet<String>,
3416    value_flags: &BTreeSet<String>,
3417) -> Vec<String> {
3418    let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags);
3419    let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags);
3420    let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else {
3421        return clap_args.to_vec();
3422    };
3423    rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts)
3424}
3425
3426/// Rewrites the `target`-th positional command token to `replacement`, preserving
3427/// flags. Token classification mirrors [`positional_command_tokens`].
3428fn replace_positional_command_token(
3429    args: &[String],
3430    root_name: &str,
3431    bool_flags: &BTreeSet<String>,
3432    value_flags: &BTreeSet<String>,
3433    target: usize,
3434    replacement: &str,
3435) -> Vec<String> {
3436    let mut out = args.to_vec();
3437    let mut index = 0;
3438    if out
3439        .first()
3440        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3441    {
3442        index = 1;
3443    }
3444
3445    let mut positional = 0;
3446    while index < out.len() {
3447        let arg = &out[index];
3448        if arg == "--" {
3449            break;
3450        }
3451        if arg.contains('=') {
3452            index += 1;
3453            continue;
3454        }
3455        if bool_flags.contains(arg) {
3456            index += 1;
3457            continue;
3458        }
3459        if value_flags.contains(arg)
3460            || unknown_flag_consumes_value(arg, out.get(index + 1).as_ref())
3461        {
3462            index += 2;
3463            continue;
3464        }
3465        if arg.starts_with('-') {
3466            index += 1;
3467            continue;
3468        }
3469        if positional == target {
3470            out[index] = replacement.to_owned();
3471            break;
3472        }
3473        positional += 1;
3474        index += 1;
3475    }
3476    out
3477}
3478
3479/// Finds the closest visible subcommand name or alias within edit-distance
3480/// `max(1, token_len / 3)`. Returns the canonical name; ties break alphabetically.
3481fn nearest_subcommand(command: &Command, token: &str) -> Option<String> {
3482    let token = token.to_ascii_lowercase();
3483    let max_distance = 1.max(token.chars().count() / 3);
3484
3485    command
3486        .get_subcommands()
3487        .filter(|child| !child.is_hide_set())
3488        .filter_map(|child| {
3489            let best = std::iter::once(child.get_name())
3490                .chain(child.get_all_aliases())
3491                .map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase()))
3492                .min()?;
3493            (best <= max_distance).then(|| (best, child.get_name().to_owned()))
3494        })
3495        .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)))
3496        .map(|(_, name)| name)
3497}
3498
3499/// Corrects every unknown group token to its nearest subcommand. Returns `None`
3500/// when any token has no near match, or when there is nothing to correct.
3501/// Stops at a leaf operand, curated `<group> help`, or an unfixable token.
3502fn full_command_correction(root: &Command, positionals: &[String]) -> Option<Vec<(usize, String)>> {
3503    let mut current = root;
3504    let mut corrections = Vec::new();
3505    for (index, token) in positionals.iter().enumerate() {
3506        if let Some(next) = current.find_subcommand(token) {
3507            current = next;
3508            continue;
3509        }
3510        if current.get_subcommands().next().is_none() {
3511            break;
3512        }
3513        if token == "help" && current.find_subcommand("help").is_none() {
3514            break;
3515        }
3516        let suggestion = nearest_subcommand(current, token)?;
3517        let next = current.find_subcommand(&suggestion)?;
3518        corrections.push((index, suggestion));
3519        current = next;
3520    }
3521    (!corrections.is_empty()).then_some(corrections)
3522}
3523
3524/// Prompt/display text for a correction. Last-token-only fixes show the bare
3525/// token; anything else shows the full corrected command path.
3526fn correction_display(
3527    root_name: &str,
3528    positionals: &[String],
3529    corrections: &[(usize, String)],
3530) -> String {
3531    if let [(index, only)] = corrections
3532        && *index + 1 == positionals.len()
3533    {
3534        return only.clone();
3535    }
3536    let mut tokens = vec![root_name.to_owned()];
3537    for (index, token) in positionals.iter().enumerate() {
3538        let corrected = corrections
3539            .iter()
3540            .find(|(i, _)| *i == index)
3541            .map(|(_, replacement)| replacement.clone())
3542            .unwrap_or_else(|| token.clone());
3543        tokens.push(corrected);
3544    }
3545    tokens.join(" ")
3546}
3547
3548#[cfg(test)]
3549mod unknown_command_suggestion_tests {
3550    use super::*;
3551
3552    fn sample_group() -> Command {
3553        Command::new("gddy").subcommand(
3554            Command::new("domain")
3555                .alias("dns-domain")
3556                .subcommand(Command::new("list"))
3557                .subcommand(Command::new("available")),
3558        )
3559    }
3560
3561    #[test]
3562    fn osa_distance_treats_adjacent_transposition_as_one_edit() {
3563        // Guard against swapping to `strsim::levenshtein`, which counts swaps as two edits.
3564        assert_eq!(strsim::osa_distance("domain", "domain"), 0);
3565        assert_eq!(strsim::osa_distance("domian", "domain"), 1);
3566        assert_eq!(strsim::osa_distance("lst", "list"), 1);
3567        assert_eq!(strsim::osa_distance("lsit", "list"), 1);
3568        assert_eq!(strsim::osa_distance("cat", "set"), 2);
3569    }
3570
3571    #[test]
3572    fn nearest_subcommand_matches_close_typos() {
3573        let root = sample_group();
3574        let domain = root.find_subcommand("domain").expect("domain registered");
3575        assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list"));
3576        assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list"));
3577        assert_eq!(
3578            nearest_subcommand(domain, "avaliable").as_deref(),
3579            Some("available")
3580        );
3581    }
3582
3583    #[test]
3584    fn nearest_subcommand_rejects_unrelated_tokens() {
3585        let root = sample_group();
3586        let domain = root.find_subcommand("domain").expect("domain registered");
3587        assert_eq!(nearest_subcommand(domain, "missing"), None);
3588    }
3589
3590    #[test]
3591    fn nearest_subcommand_returns_canonical_name_for_alias_typos() {
3592        let root = sample_group();
3593        assert_eq!(
3594            nearest_subcommand(&root, "dns-domian").as_deref(),
3595            Some("domain")
3596        );
3597    }
3598
3599    #[test]
3600    fn nearest_subcommand_skips_hidden_commands() {
3601        let root = Command::new("gddy")
3602            .subcommand(Command::new("visible"))
3603            .subcommand(Command::new("hiddeen").hide(true));
3604        assert_eq!(nearest_subcommand(&root, "hidden"), None);
3605    }
3606
3607    #[test]
3608    fn nearest_subcommand_rejects_short_unrelated_tokens() {
3609        let root = Command::new("gddy").subcommand(
3610            Command::new("config")
3611                .subcommand(Command::new("get"))
3612                .subcommand(Command::new("set"))
3613                .subcommand(Command::new("add")),
3614        );
3615        let config = root.find_subcommand("config").expect("config registered");
3616        assert_eq!(nearest_subcommand(config, "cat"), None);
3617        assert_eq!(nearest_subcommand(config, "x"), None);
3618        assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set"));
3619    }
3620
3621    #[test]
3622    fn unknown_group_command_formats_did_you_mean_suffix() {
3623        let root = sample_group();
3624        let unknown = detect_unknown_group_command(&root, &["domian".to_owned()])
3625            .expect("domian is an unknown top-level command");
3626        assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\"");
3627        assert_eq!(
3628            format_did_you_mean(&unknown.base, "domain"),
3629            "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?"
3630        );
3631    }
3632
3633    #[test]
3634    fn detect_unknown_group_command_reports_nested_typos() {
3635        let root = sample_group();
3636        let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()])
3637            .expect("lst is an unknown subcommand of domain");
3638        assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\"");
3639        assert_eq!(
3640            format_did_you_mean(&unknown.base, "list"),
3641            "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?"
3642        );
3643    }
3644
3645    #[test]
3646    fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() {
3647        let root = sample_group();
3648        let unknown = detect_unknown_group_command(&root, &["missing".to_owned()])
3649            .expect("missing is an unknown top-level command");
3650        assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\"");
3651    }
3652
3653    #[test]
3654    fn full_command_correction_fixes_a_single_group_typo() {
3655        let root = sample_group();
3656        let corrections = full_command_correction(&root, &["domian".to_owned()])
3657            .expect("domian is correctable to domain");
3658        assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3659    }
3660
3661    #[test]
3662    fn full_command_correction_fixes_every_typo_in_a_nested_path() {
3663        let root = sample_group();
3664        let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()])
3665            .expect("both tokens are correctable");
3666        assert_eq!(
3667            corrections,
3668            vec![(0, "domain".to_owned()), (1, "list".to_owned())]
3669        );
3670    }
3671
3672    #[test]
3673    fn full_command_correction_bails_when_a_token_has_no_near_match() {
3674        let root = sample_group();
3675        assert_eq!(
3676            full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]),
3677            None
3678        );
3679    }
3680
3681    #[test]
3682    fn full_command_correction_is_none_when_there_is_nothing_to_correct() {
3683        let root = sample_group();
3684        assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None);
3685        assert_eq!(full_command_correction(&root, &[]), None);
3686    }
3687
3688    #[test]
3689    fn full_command_correction_corrects_the_group_before_curated_help() {
3690        let root = sample_group();
3691        let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()])
3692            .expect("domian is correctable even ahead of a help token");
3693        assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3694    }
3695
3696    #[test]
3697    fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() {
3698        let root = sample_group();
3699        let corrections = full_command_correction(
3700            &root,
3701            &[
3702                "domain".to_owned(),
3703                "avaliable".to_owned(),
3704                "example.com".to_owned(),
3705            ],
3706        )
3707        .expect("avaliable is correctable to available");
3708        assert_eq!(corrections, vec![(1, "available".to_owned())]);
3709    }
3710
3711    #[test]
3712    fn correction_display_shows_the_bare_token_for_a_single_fix() {
3713        let corrections = vec![(1, "list".to_owned())];
3714        assert_eq!(
3715            correction_display(
3716                "gddy",
3717                &["domain".to_owned(), "lst".to_owned()],
3718                &corrections
3719            ),
3720            "list"
3721        );
3722    }
3723
3724    #[test]
3725    fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() {
3726        let corrections = vec![(0, "domain".to_owned())];
3727        assert_eq!(
3728            correction_display(
3729                "gddy",
3730                &["domian".to_owned(), "list".to_owned()],
3731                &corrections
3732            ),
3733            "gddy domain list"
3734        );
3735    }
3736
3737    #[test]
3738    fn correction_display_shows_the_full_command_for_multiple_fixes() {
3739        let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())];
3740        assert_eq!(
3741            correction_display(
3742                "gddy",
3743                &["domian".to_owned(), "lst".to_owned()],
3744                &corrections
3745            ),
3746            "gddy domain list"
3747        );
3748    }
3749
3750    #[test]
3751    fn replace_positional_command_token_rewrites_only_the_target() {
3752        let bool_flags: BTreeSet<String> = ["--verbose".to_owned()].into_iter().collect();
3753        let value_flags: BTreeSet<String> = ["--output".to_owned()].into_iter().collect();
3754        let args = vec![
3755            "gddy".to_owned(),
3756            "--output".to_owned(),
3757            "json".to_owned(),
3758            "domain".to_owned(),
3759            "lst".to_owned(),
3760        ];
3761        let corrected =
3762            replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list");
3763        assert_eq!(
3764            corrected,
3765            vec!["gddy", "--output", "json", "domain", "list"]
3766        );
3767    }
3768
3769    #[test]
3770    fn rewrite_group_help_if_needed_runs_after_typo_correction() {
3771        let root = sample_group();
3772        let bool_flags = derive_bool_flags(&root);
3773        let value_flags = derive_value_flags(&root);
3774        let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()];
3775        let corrected =
3776            replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain");
3777        assert_eq!(corrected, vec!["gddy", "domain", "help"]);
3778        let rewritten =
3779            rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags);
3780        assert_eq!(rewritten, vec!["gddy", "help", "domain"]);
3781    }
3782}
3783
3784/// Detects the `<group> help [sub...]` form and returns the command path whose
3785/// help should be rendered.
3786///
3787/// The engine ships a curated root `help` command, so it disables clap's
3788/// auto-generated help subcommand on the root. That setting propagates to every
3789/// subcommand and cannot be re-enabled per child, so `<group> help` would
3790/// otherwise hit clap's "unrecognized subcommand" error even though the group's
3791/// help listing advertises a `help` entry. We recognize the form here so the
3792/// caller can route it through the curated help renderer, matching clap's
3793/// documented equivalence between `cmd group help sub` and `cmd help group sub`.
3794///
3795/// Only groups (commands that have subcommands) are matched: a group is pure
3796/// subcommand dispatch, so a `help` token in that position is unambiguously a
3797/// help request. Leaf commands may accept a literal `help` positional argument,
3798/// so they are left for clap to parse (`<leaf> --help` still works). A group
3799/// that registers its own real `help` subcommand is likewise deferred to clap,
3800/// which dispatches the user-defined command (only auto-generated help is
3801/// suppressed).
3802///
3803/// `command_keyword_count` is the number of leading positionals that are
3804/// genuine command keywords (those before any `--`). A `help` at or beyond that
3805/// index is a literal operand after `--`, not a help request, so it is ignored.
3806fn group_help_target_parts(
3807    root: &Command,
3808    positionals: &[String],
3809    command_keyword_count: usize,
3810) -> Option<Vec<String>> {
3811    let help_index = positionals.iter().position(|token| token == "help")?;
3812    // A leading `help` is the curated root help command; let it flow through.
3813    if help_index == 0 {
3814        return None;
3815    }
3816    // A `help` after a `--` separator is a literal operand; leave it for clap.
3817    if help_index >= command_keyword_count {
3818        return None;
3819    }
3820    let prefix = &positionals[..help_index];
3821    let mut current = root;
3822    for token in prefix {
3823        current = current.find_subcommand(token)?;
3824    }
3825    // The token before `help` must resolve to a group; leaves are left to clap.
3826    current.get_subcommands().next()?;
3827    // Defer to clap when the group defines a real `help` subcommand of its own.
3828    if current.find_subcommand("help").is_some() {
3829        return None;
3830    }
3831    // `<group> help <sub...>` shows help for `<group> <sub...>`.
3832    let suffix = &positionals[help_index + 1..];
3833    Some(prefix.iter().chain(suffix).cloned().collect())
3834}
3835
3836/// Rewrites a `<group> help [sub...]` invocation into the canonical
3837/// `help <group> [sub...]` argument vector.
3838///
3839/// Only the positional command tokens are reordered (from `[group..., help,
3840/// sub...]` to `[help, group..., sub...]`); every flag — including `key=value`
3841/// forms, value-consuming flags, unknown flags that consume a value, and
3842/// anything after `--` — is preserved in its original place. Reordering keeps
3843/// the positional count unchanged, so the rewritten stream is filled slot for
3844/// slot. `parts` is the resolved command path (group + subcommand) from
3845/// [`group_help_target_parts`].
3846fn rewrite_group_help_args(
3847    clap_args: &[String],
3848    root_name: &str,
3849    bool_flags: &BTreeSet<String>,
3850    value_flags: &BTreeSet<String>,
3851    parts: &[String],
3852) -> Vec<String> {
3853    // New positional order: the curated `help` command, then the command path.
3854    let mut next_positional = std::iter::once("help".to_owned())
3855        .chain(parts.iter().cloned())
3856        .peekable();
3857    let mut out = Vec::with_capacity(clap_args.len());
3858    let mut iter = clap_args.iter().peekable();
3859    if iter
3860        .peek()
3861        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3862        && let Some(program) = iter.next()
3863    {
3864        out.push(program.clone());
3865    }
3866
3867    let mut take_positional =
3868        |fallback: &String| next_positional.next().unwrap_or(fallback.clone());
3869
3870    while let Some(arg) = iter.next() {
3871        if arg == "--" {
3872            out.push(arg.clone());
3873            // Everything after `--` is positional.
3874            for rest in iter.by_ref() {
3875                out.push(take_positional(rest));
3876            }
3877            break;
3878        }
3879        if arg.contains('=') || bool_flags.contains(arg) {
3880            out.push(arg.clone());
3881            continue;
3882        }
3883        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3884            out.push(arg.clone());
3885            if let Some(value) = iter.next() {
3886                out.push(value.clone());
3887            }
3888            continue;
3889        }
3890        if arg.starts_with('-') {
3891            out.push(arg.clone());
3892            continue;
3893        }
3894        out.push(take_positional(arg));
3895    }
3896    // Defensive: emit any positionals not yet placed (counts normally match).
3897    out.extend(next_positional);
3898    out
3899}
3900
3901fn positional_command_tokens(
3902    args: &[String],
3903    root_name: &str,
3904    bool_flags: &BTreeSet<String>,
3905    value_flags: &BTreeSet<String>,
3906) -> Vec<String> {
3907    let mut tokens = Vec::new();
3908    let mut iter = args.iter().peekable();
3909    if iter
3910        .peek()
3911        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3912    {
3913        iter.next();
3914    }
3915
3916    while let Some(arg) = iter.next() {
3917        if arg == "--" {
3918            tokens.extend(iter.cloned());
3919            break;
3920        }
3921        if arg.contains('=') {
3922            continue;
3923        }
3924        if bool_flags.contains(arg) {
3925            continue;
3926        }
3927        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3928            iter.next();
3929            continue;
3930        }
3931        if arg.starts_with('-') {
3932            continue;
3933        }
3934        tokens.push(arg.clone());
3935    }
3936    tokens
3937}
3938
3939fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
3940    arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
3941}
3942
3943fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
3944    arg == root_name
3945        || Path::new(arg)
3946            .file_stem()
3947            .and_then(|n| n.to_str())
3948            .is_some_and(|n| n == root_name)
3949}
3950
3951/// Outcome of [`Cli::resolve_argv0`]: either rewritten arguments to feed the
3952/// normal pipeline, or a fully rendered result to return immediately.
3953enum Argv0Outcome {
3954    /// Continue the normal run pipeline with these arguments.
3955    Proceed(Vec<String>),
3956    /// Return this already-rendered result without further processing.
3957    Handled(CliRunOutput),
3958}
3959
3960/// Extracts the bare program name from an `argv[0]` value, dropping any directory
3961/// path and file extension (e.g. `/usr/bin/pl` or `pl.exe` both yield `pl`).
3962/// Falls back to the raw value when no file stem can be derived.
3963fn program_basename(arg: &str) -> String {
3964    Path::new(arg)
3965        .file_stem()
3966        .and_then(|stem| stem.to_str())
3967        .map_or_else(|| arg.to_owned(), ToOwned::to_owned)
3968}
3969
3970/// Returns `true` when `name` is a valid alternative `argv[0]` route name: a
3971/// non-empty token of ASCII letters, digits, `-`, or `_`. This keeps the name
3972/// safe as a link/shim filename and as an `argv[0]` basename (which is matched
3973/// with its extension stripped, so an embedded dot would break matching).
3974fn is_valid_argv0_name(name: &str) -> bool {
3975    !name.is_empty()
3976        && name.chars().all(|character| {
3977            character.is_ascii_alphanumeric() || character == '-' || character == '_'
3978        })
3979}
3980
3981/// Returns `true` when the entry at `link` already matches what [`Cli::create_link`]
3982/// would produce for `method`/`target`/`name`, so it can be left untouched. A
3983/// mismatch (wrong kind, stale symlink target, or differing contents) returns
3984/// `false` so the caller replaces it.
3985fn argv0_link_matches(
3986    link: &Path,
3987    target: &Path,
3988    name: &str,
3989    method: Argv0LinkMethod,
3990) -> std::io::Result<bool> {
3991    let metadata = std::fs::symlink_metadata(link)?;
3992    match method {
3993        Argv0LinkMethod::SoftLink => {
3994            Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target)
3995        }
3996        Argv0LinkMethod::HardLink => {
3997            if metadata.file_type().is_symlink() {
3998                return Ok(false);
3999            }
4000            // A correct hard link is indistinguishable from the target by content;
4001            // comparing bytes also accepts an identical copy, which is harmless.
4002            Ok(std::fs::read(link)? == std::fs::read(target)?)
4003        }
4004        Argv0LinkMethod::Script => {
4005            if metadata.file_type().is_symlink() {
4006                return Ok(false);
4007            }
4008            Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name)))
4009        }
4010    }
4011}
4012
4013/// File name for an alternative `argv[0]` link, per method and host platform.
4014fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String {
4015    let extension = match method {
4016        Argv0LinkMethod::Script if cfg!(windows) => ".cmd",
4017        // Unix scripts are extension-less executables; links carry `.exe` on Windows.
4018        Argv0LinkMethod::Script => "",
4019        _ if cfg!(windows) => ".exe",
4020        _ => "",
4021    };
4022    format!("{name}{extension}")
4023}
4024
4025/// Contents of an alternative `argv[0]` shim script that forwards to `target`
4026/// via the explicit `argv0` command. A `.cmd` batch file on Windows, an
4027/// executable POSIX shell script elsewhere.
4028fn argv0_script_contents(target: &Path, name: &str) -> String {
4029    let target = target.display();
4030    if cfg!(windows) {
4031        format!("@\"{target}\" argv0 {name} %*\r\n")
4032    } else {
4033        format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n")
4034    }
4035}
4036
4037#[cfg(unix)]
4038fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4039    std::os::unix::fs::symlink(target, link)
4040}
4041
4042#[cfg(windows)]
4043fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4044    std::os::windows::fs::symlink_file(target, link)
4045}
4046
4047#[cfg(not(any(unix, windows)))]
4048fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
4049    Err(std::io::Error::new(
4050        std::io::ErrorKind::Unsupported,
4051        "symlink creation is not supported on this platform",
4052    ))
4053}
4054
4055/// Marks a freshly written shim script executable on Unix; a no-op elsewhere.
4056#[cfg(unix)]
4057fn make_executable(path: &Path) -> std::io::Result<()> {
4058    use std::os::unix::fs::PermissionsExt;
4059    let mut permissions = std::fs::metadata(path)?.permissions();
4060    permissions.set_mode(0o755);
4061    std::fs::set_permissions(path, permissions)
4062}
4063
4064#[cfg(not(unix))]
4065fn make_executable(_path: &Path) -> std::io::Result<()> {
4066    Ok(())
4067}
4068
4069/// Walks a runtime group tree, resolving each node's effective feature flag by
4070/// cascading from `inherited` — a node's own [`GroupSpec::feature_flag`] or
4071/// [`CommandSpec::feature_flag`] wins if set, otherwise it inherits the
4072/// nearest ancestor's effective flag, otherwise (nothing in the ancestor
4073/// chain declared a flag) it implicitly resolves to [`Stage::Ga`] with no key.
4074/// Every node that resolves to a *named* flag (own or inherited) is recorded
4075/// into `registry` under its colon-separated path, together with whether
4076/// `policy` judged it visible. Nodes that resolve to the implicit no-flag
4077/// default are not recorded (there is nothing to introspect) and are always
4078/// visible.
4079///
4080/// Returns `None` when this group itself should be dropped from the tree —
4081/// either because its effective flag is not visible under `policy`, or
4082/// because every one of its commands and subgroups was pruned away, leaving
4083/// an empty group with nothing to mount. An emptied-out group is dropped
4084/// unconditionally, even if its own flag was visible: a `clap` subcommand
4085/// group with zero children is useless either way, so this simplifies the
4086/// pruning logic rather than threading through a "was this group itself
4087/// visible but empty" distinction that no caller needs.
4088///
4089/// Note that an invisible ancestor short-circuits before its children are
4090/// even visited: a more permissive flag on a descendant cannot resurrect a
4091/// subtree whose enclosing group already failed the visibility check.
4092fn prune_feature_flag_tree(
4093    mut group: RuntimeGroupSpec,
4094    inherited: Option<&FeatureFlag>,
4095    policy: &FlagPolicy,
4096    prefix: &mut Vec<String>,
4097    registry: &mut FlagRegistry,
4098) -> Option<RuntimeGroupSpec> {
4099    prefix.push(group.group.name.clone());
4100
4101    let effective = group
4102        .group
4103        .feature_flag
4104        .clone()
4105        .or_else(|| inherited.cloned());
4106    if !record_and_check_visibility(effective.as_ref(), policy, prefix, registry) {
4107        prefix.pop();
4108        return None;
4109    }
4110
4111    let mut kept_groups = Vec::with_capacity(group.groups.len());
4112    for child in std::mem::take(&mut group.groups) {
4113        if let Some(pruned) =
4114            prune_feature_flag_tree(child, effective.as_ref(), policy, prefix, registry)
4115        {
4116            kept_groups.push(pruned);
4117        }
4118    }
4119    group.groups = kept_groups;
4120
4121    let mut kept_commands = Vec::with_capacity(group.commands.len());
4122    for command in std::mem::take(&mut group.commands) {
4123        prefix.push(command.spec.name.clone());
4124        let command_effective = command
4125            .spec
4126            .feature_flag
4127            .clone()
4128            .or_else(|| effective.clone());
4129        let visible =
4130            record_and_check_visibility(command_effective.as_ref(), policy, prefix, registry);
4131        prefix.pop();
4132        if visible {
4133            kept_commands.push(command);
4134        }
4135    }
4136    group.commands = kept_commands;
4137
4138    prefix.pop();
4139
4140    if group.commands.is_empty() && group.groups.is_empty() {
4141        None
4142    } else {
4143        Some(group)
4144    }
4145}
4146
4147/// Records `effective` at the current `prefix` path into `registry` (only
4148/// when it names a flag key — the implicit Ga default is not recorded) and
4149/// returns whether the node is visible under `policy`.
4150fn record_and_check_visibility(
4151    effective: Option<&FeatureFlag>,
4152    policy: &FlagPolicy,
4153    prefix: &[String],
4154    registry: &mut FlagRegistry,
4155) -> bool {
4156    let Some(flag) = effective else {
4157        return true;
4158    };
4159    let visible = policy.visible(Some(flag.key.as_str()), flag.stage);
4160    registry.record(FlagEntry {
4161        path: prefix.join(":"),
4162        key: flag.key.clone(),
4163        stage: flag.stage,
4164        visible,
4165    });
4166    visible
4167}
4168
4169fn register_runtime_group_metadata(
4170    group: &RuntimeGroupSpec,
4171    prefix: &mut Vec<String>,
4172    schemas: &mut SchemaRegistry,
4173    views: &mut HumanViewRegistry,
4174) {
4175    prefix.push(group.group.name.clone());
4176    for child_group in &group.groups {
4177        register_runtime_group_metadata(child_group, prefix, schemas, views);
4178    }
4179    for child in &group.commands {
4180        prefix.push(child.spec.name.clone());
4181        let command_path = prefix.join(":");
4182        register_command_schema(&child.spec, &command_path, schemas);
4183        // An inline `with_view` is registered under the command's own path; the
4184        // dispatch references it by that path. A `with_view_id` takes precedence
4185        // (dispatch uses it instead), so skip the inline registration when one is
4186        // set — registering it would leave an unused entry. Shared views are
4187        // registered separately by the module/CLI.
4188        if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
4189            views.register(HumanViewDef::new(
4190                command_path,
4191                child.spec.view_columns.clone(),
4192            ));
4193        }
4194        prefix.pop();
4195    }
4196    prefix.pop();
4197}
4198
4199fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
4200    if let Some(schema) = &spec.output_schema {
4201        schemas.register_info(command_path.to_owned(), schema.clone());
4202    }
4203}
4204
4205fn runtime_group_clap_command_with_schema_help(
4206    group: &RuntimeGroupSpec,
4207    prefix: &mut Vec<String>,
4208    schemas: &SchemaRegistry,
4209) -> Command {
4210    let mut command = group_clap_command_without_children(&group.group);
4211    prefix.push(group.group.name.clone());
4212    for child_group in &group.groups {
4213        command = command.subcommand(runtime_group_clap_command_with_schema_help(
4214            child_group,
4215            prefix,
4216            schemas,
4217        ));
4218    }
4219    for child in &group.commands {
4220        prefix.push(child.spec.name.clone());
4221        let command_path = prefix.join(":");
4222        command = command.subcommand(command_clap_command_with_schema_help(
4223            &child.spec,
4224            &command_path,
4225            schemas,
4226        ));
4227        prefix.pop();
4228    }
4229    prefix.pop();
4230    command
4231}
4232
4233fn group_clap_command_without_children(group: &GroupSpec) -> Command {
4234    let mut command = Command::new(group.name.clone())
4235        .about(group.short.clone())
4236        .help_template(GROUP_HELP_TEMPLATE);
4237    if let Some(long) = &group.long
4238        && !long.is_empty()
4239    {
4240        command = command.long_about(long.clone());
4241    }
4242    for alias in &group.aliases {
4243        command = command.alias(alias.clone());
4244    }
4245    if group.hidden {
4246        command = command.hide(true);
4247    }
4248    command
4249}
4250
4251fn command_clap_command_with_schema_help(
4252    spec: &CommandSpec,
4253    command_path: &str,
4254    schemas: &SchemaRegistry,
4255) -> Command {
4256    debug_assert!(
4257        !(spec.raw_output && spec.pagination.is_some()),
4258        "command {:?} sets both raw_output and with_pagination; a single verbatim string \
4259         has no pages, so the two are mutually exclusive",
4260        spec.name
4261    );
4262    let mut command = spec.clap_command();
4263    command = apply_dry_run_visibility(command, spec);
4264    command = apply_pagination_args(command, spec);
4265    let schema = schemas.get_by_path(command_path);
4266    let default_fields = default_field_names(spec);
4267    command = apply_fields_arg(
4268        command,
4269        spec,
4270        schema.as_ref().map(|schema| schema.fields.as_slice()),
4271        &default_fields,
4272    );
4273    command = apply_output_format_visibility(command, spec);
4274    let filter_expr_fields = schema
4275        .as_ref()
4276        .map_or(&[][..], |schema| schema.fields.as_slice());
4277    apply_filter_and_expr_examples(command, spec, filter_expr_fields)
4278}
4279
4280/// Hides this command's inherited `--output` flag when it opted into
4281/// [`CommandSpec::raw_output`].
4282fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command {
4283    if !spec.raw_output {
4284        return command;
4285    }
4286    use std::io::IsTerminal;
4287    command.arg(
4288        Arg::new("output")
4289            .long("output")
4290            .short('o')
4291            .value_name("FORMAT")
4292            .default_value(if std::io::stdout().is_terminal() {
4293                "human"
4294            } else {
4295                "json"
4296            })
4297            .conflicts_with_all(["json", "toon", "human"])
4298            .display_order(crate::flags::global_flag_order::OUTPUT)
4299            .hide(true)
4300            .help("Ignored — this command always prints raw text"),
4301    )
4302}
4303
4304/// Hides this command's inherited `--dry-run` flag when the command isn't
4305/// mutating (per [`CommandSpec::metadata`]'s `dry_run_prompt` — mirrored
4306/// here rather than reused, since that method returns the broader
4307/// [`CommandMeta`], not this one bool). `--dry-run` only ever does anything
4308/// for a command that opted in via `.mutates(true)`/`.with_tier(...)` (see
4309/// `Middleware::render_envelope`'s `meta.dry_run_prompt` gate), so showing
4310/// it on every other command is noise. The override still parses `--dry-run`
4311/// identically (same value parser, same defaults) in case a caller passes
4312/// it anyway — hidden only changes what `--help` shows, never behavior.
4313fn apply_dry_run_visibility(command: Command, spec: &CommandSpec) -> Command {
4314    let mutates = spec.mutates || spec.tier.is_some_and(crate::Tier::is_mutating);
4315    if mutates {
4316        return command;
4317    }
4318    command.arg(
4319        Arg::new("dry-run")
4320            .long("dry-run")
4321            .num_args(0..=1)
4322            .require_equals(true)
4323            .default_missing_value("true")
4324            .default_value("false")
4325            .value_parser(crate::flags::compat_bool_value_parser())
4326            .display_order(crate::flags::global_flag_order::DRY_RUN)
4327            .hide(true)
4328            .help("Preview mutations without executing"),
4329    )
4330}
4331
4332/// Registers `--limit`/`--offset` on this command's own `Command` when its
4333/// spec opted in via [`CommandSpec::with_pagination`], and leaves the command
4334/// untouched otherwise so a non-paginating command never sees those flags —
4335/// in `--help` or on its command line. See [`flags::apply_pagination_args`].
4336fn apply_pagination_args(command: Command, spec: &CommandSpec) -> Command {
4337    let Some(pagination) = spec.pagination else {
4338        return command;
4339    };
4340    crate::flags::apply_pagination_args(command, pagination.default_limit, pagination.max_limit)
4341}
4342
4343/// Splits a command's raw `default_fields` string into individual field
4344/// names, dropping the `all`/`*` sentinels that mean "every field" rather
4345/// than naming a real field.
4346fn default_field_names(spec: &CommandSpec) -> Vec<&str> {
4347    spec.default_fields
4348        .as_deref()
4349        .map(|fields| {
4350            fields
4351                .split(',')
4352                .map(str::trim)
4353                .filter(|field| !field.is_empty() && *field != "all" && *field != "*")
4354                .collect()
4355        })
4356        .unwrap_or_default()
4357}
4358
4359/// Overrides this command's `--fields` flag with everything specific to this
4360/// command: its own `default_fields` as a native clap default value (so
4361/// `--help` shows `[default: ...]` on the flag itself, the same way
4362/// `--dry-run` shows `[default: false]`), and, when a schema is registered,
4363/// the output-field summary table appended to the flag's own help text
4364/// instead of the command's description — a long field table there used to
4365/// push `Usage:` far down the page. Global args apply to every subcommand,
4366/// but a subcommand-local arg of the same name takes precedence, so this
4367/// only affects the one command being built here.
4368fn apply_fields_arg(
4369    command: Command,
4370    spec: &CommandSpec,
4371    schema_fields: Option<&[FieldInfo]>,
4372    default_fields: &[&str],
4373) -> Command {
4374    if spec.raw_output {
4375        return command.arg(
4376            Arg::new("fields")
4377                .long("fields")
4378                .value_name("FIELDS")
4379                .display_order(crate::flags::global_flag_order::FIELDS)
4380                .hide(true)
4381                .help("Ignored — this command always prints raw text"),
4382        );
4383    }
4384    let default_value = spec
4385        .default_fields
4386        .as_deref()
4387        .filter(|fields| !fields.is_empty());
4388    let table = schema_fields
4389        .filter(|fields| !fields.is_empty())
4390        .map(|fields| format_help_section(fields, default_fields));
4391    if default_value.is_none() && table.is_none() {
4392        return command;
4393    }
4394
4395    let mut help = String::from(
4396        "Comma-separated fields to include in output (use 'all' or '*' for everything)",
4397    );
4398    if let Some(table) = &table {
4399        help.push_str("\n\n");
4400        help.push_str(table.trim_end());
4401    }
4402
4403    let mut arg = Arg::new("fields")
4404        .long("fields")
4405        .value_name("FIELDS")
4406        // Must match `global_flag_order::FIELDS` — this re-registers the
4407        // same flag with contextual help, not a new one, and needs to keep
4408        // its place among the other global flags rather than falling back
4409        // to this subcommand's own low, command-specific counter value.
4410        .display_order(crate::flags::global_flag_order::FIELDS)
4411        .help(help);
4412    if let Some(default_value) = default_value {
4413        arg = arg.default_value(default_value.to_owned());
4414    }
4415    command.arg(arg)
4416}
4417
4418/// Overrides this command's `--filter` and `--expr` flags with help text
4419/// carrying usage examples built from its own output fields, so `--help`
4420/// shows them right under the flag instead of in a separate "Filter
4421/// examples:"/"Expr examples:" section disconnected from the flags they
4422/// demonstrate. Mirrors [`apply_fields_arg`]: a subcommand-local arg of the
4423/// same name shadows the framework's global one, and must carry the same
4424/// `global_flag_order` value as that global one for the same reason.
4425fn apply_filter_and_expr_examples(
4426    mut command: Command,
4427    spec: &CommandSpec,
4428    fields: &[FieldInfo],
4429) -> Command {
4430    if spec.raw_output {
4431        return command
4432            .arg(
4433                Arg::new("filter")
4434                    .long("filter")
4435                    .value_name("EXPR")
4436                    .display_order(crate::flags::global_flag_order::FILTER)
4437                    .hide(true)
4438                    .help("Ignored — this command always prints raw text"),
4439            )
4440            .arg(
4441                Arg::new("expr")
4442                    .long("expr")
4443                    .value_name("EXPR")
4444                    .display_order(crate::flags::global_flag_order::EXPR)
4445                    .hide(true)
4446                    .help("Ignored — this command always prints raw text"),
4447            );
4448    }
4449    if fields.is_empty() {
4450        return command;
4451    }
4452    let first_string = fields
4453        .iter()
4454        .find(|field| field.field_type == "string")
4455        .map(|field| field.name.as_str());
4456    let first_bool = fields
4457        .iter()
4458        .find(|field| field.field_type == "bool")
4459        .map(|field| field.name.as_str());
4460
4461    if first_string.is_some() || first_bool.is_some() {
4462        let mut help = String::from("Per-item JMESPath predicate for list data");
4463        if let Some(name) = first_string {
4464            help.push_str(&format!("\ne.g. --filter \"contains({name}, 'example')\""));
4465        }
4466        if let Some(name) = first_bool {
4467            help.push_str(&format!("\ne.g. --filter '{name}'"));
4468        }
4469        command = command.arg(
4470            Arg::new("filter")
4471                .long("filter")
4472                .value_name("EXPR")
4473                .display_order(crate::flags::global_flag_order::FILTER)
4474                .help(help),
4475        );
4476    }
4477
4478    let mut expr_help = String::from("JMESPath query applied to the whole result");
4479    expr_help.push_str("\ne.g. --expr 'length(@)'");
4480    if let Some(name) = first_string {
4481        expr_help.push_str(&format!("\ne.g. --expr '[].{name}'"));
4482    }
4483    command.arg(
4484        Arg::new("expr")
4485            .long("expr")
4486            .value_name("EXPR")
4487            .display_order(crate::flags::global_flag_order::EXPR)
4488            .help(expr_help),
4489    )
4490}
4491
4492fn process_exit_code(code: i32) -> ExitCode {
4493    if code == 0 {
4494        return ExitCode::SUCCESS;
4495    }
4496    match u8::try_from(code) {
4497        Ok(code) if code != 0 => ExitCode::from(code),
4498        Ok(_) | Err(_) => ExitCode::from(1),
4499    }
4500}
4501
4502async fn run_streaming_command(
4503    middleware: &Middleware,
4504    request: MiddlewareRequest<'_>,
4505    raw_matches: Arc<ArgMatches>,
4506    streaming_handler: crate::command::StreamingCommandHandler,
4507) -> Result<CliRunOutput> {
4508    use tokio::{io::AsyncWriteExt, sync::mpsc};
4509
4510    let args_for_handler = request.args.clone();
4511    let user_args_for_handler = request.user_args.clone();
4512    let handler_path = request.command_path.to_owned();
4513    let middleware_for_handler = middleware.clone();
4514    let raw_matches_for_handler = raw_matches;
4515
4516    let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
4517    let sender = StreamSender(tx);
4518
4519    // Drain the channel concurrently so the handler's sends don't stall
4520    // while the writer flushes to stdout. If stdout is under backpressure
4521    // the bounded channel can still fill and the handler will await send.
4522    let writer = tokio::spawn(async move {
4523        let mut stdout = tokio::io::stdout();
4524        while let Some(event) = rx.recv().await {
4525            let Ok(line) = serde_json::to_string(&event) else {
4526                continue;
4527            };
4528            if stdout.write_all(line.as_bytes()).await.is_err()
4529                || stdout.write_all(b"\n").await.is_err()
4530                || stdout.flush().await.is_err()
4531            {
4532                break;
4533            }
4534        }
4535    });
4536
4537    let output = middleware
4538        .run(request, async move |credential| {
4539            streaming_handler(
4540                CommandContext {
4541                    credential,
4542                    args: args_for_handler,
4543                    user_args: user_args_for_handler,
4544                    command_path: handler_path,
4545                    middleware: middleware_for_handler,
4546                    raw_matches: raw_matches_for_handler,
4547                },
4548                sender,
4549            )
4550            .await?;
4551            Ok(crate::CommandResult::new(serde_json::Value::Null))
4552        })
4553        .await;
4554
4555    // Handler has completed; its sender is dropped, which closes the channel.
4556    // Wait for the writer task to flush all remaining events.
4557    let _write_result = writer.await;
4558
4559    match output {
4560        Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
4561            exit_code: 0,
4562            rendered: String::new(),
4563        }),
4564        Ok(out) => Ok(out.into()),
4565        Err(err) => Ok(CliRunOutput {
4566            exit_code: exit_code_for_error(&err),
4567            rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
4568        }),
4569    }
4570}
4571
4572#[cfg(test)]
4573mod user_agent_tests {
4574    use super::*;
4575
4576    #[test]
4577    fn user_agent_string_derives_name_and_version_by_default() {
4578        let config =
4579            CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3"));
4580        assert_eq!(config.user_agent_string(), "gdx/1.2.3");
4581    }
4582
4583    #[test]
4584    fn user_agent_string_prefers_explicit_override() {
4585        let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx")
4586            .with_build(BuildInfo::new("1.2.3"))
4587            .with_user_agent("gdx-cli/9.9 (custom)");
4588        assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)");
4589    }
4590
4591    #[test]
4592    fn user_agent_string_omits_version_when_absent() {
4593        let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx");
4594        assert_eq!(config.user_agent_string(), "gdx");
4595    }
4596
4597    #[test]
4598    fn install_default_user_agent_publishes_config_value() {
4599        let _guard = crate::transport::client::UA_TEST_LOCK
4600            .lock()
4601            .unwrap_or_else(std::sync::PoisonError::into_inner);
4602        let _restore = crate::transport::client::RestoreDefaultUserAgent;
4603        crate::transport::set_default_user_agent("cli/dev");
4604        let cli = Cli::new(
4605            CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
4606        );
4607        cli.install_default_user_agent();
4608        assert_eq!(
4609            crate::transport::client::default_user_agent(),
4610            "uatest/4.5.6"
4611        );
4612    }
4613
4614    #[test]
4615    fn install_debug_transport_logger_tracks_the_debug_pattern() {
4616        // Asserts on `debug_transport_logger_for`'s decision directly rather
4617        // than publishing to and reading back the process-wide default
4618        // logger, which `Cli::run` republishes on every call — including the
4619        // many unrelated tests that call `cli.run(...)` with no `--debug`
4620        // flag and would otherwise race with this assertion.
4621
4622        // `transport` selected -> an active (enabled) logger is built.
4623        assert!(debug_transport_logger_for("transport", &[]).enabled());
4624
4625        // Wildcard with transport excluded -> a disabled (noop) logger.
4626        assert!(!debug_transport_logger_for("*,-transport", &[]).enabled());
4627
4628        // Empty pattern -> disabled (noop).
4629        assert!(!debug_transport_logger_for("", &[]).enabled());
4630    }
4631}
4632
4633#[cfg(test)]
4634mod env_config_tests {
4635    use super::*;
4636
4637    #[test]
4638    fn with_environments_stores_shared_arc_with_consumer_app_id() {
4639        // The consumer sets app_id on the Environments before sharing the Arc;
4640        // CliConfig stores it as-is, so the file path resolves only because the
4641        // consumer stamped the matching app_id (not because the engine did).
4642        let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new(
4643            crate::environments::Environments::new("prod")
4644                .with_app_id("gddy")
4645                .with_config_file(true),
4646        ));
4647        let envs = cfg.environments.as_ref().expect("environments set");
4648        assert!(envs.config_file_path().is_some());
4649    }
4650
4651    #[tokio::test]
4652    async fn env_flag_overrides_default_and_reaches_middleware_env() {
4653        use crate::{CommandResult, CommandSpec, RuntimeCommandSpec};
4654        use serde_json::json;
4655        let mut cli = Cli::new(
4656            CliConfig::new("envtest", "Env test", "envtest")
4657                .with_environments(Arc::new(
4658                    crate::environments::Environments::new("prod")
4659                        .with_environment("prod", crate::environments::EnvTable::new())
4660                        .with_environment("ote", crate::environments::EnvTable::new()),
4661                ))
4662                .with_startup_args(Vec::<&str>::new()),
4663        );
4664        cli.add_command(RuntimeCommandSpec::new_with_context(
4665            CommandSpec::new("whichenv", "echo env").no_auth(true),
4666            async |ctx| {
4667                Ok(CommandResult::new(
4668                    json!({ "env": ctx.environment()?.name().to_owned() }),
4669                ))
4670            },
4671        ));
4672        let out = cli
4673            .run(["envtest", "whichenv", "--env", "ote", "--output", "json"])
4674            .await;
4675        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
4676        assert!(out.rendered.contains("\"env\""));
4677        assert!(out.rendered.contains("ote"));
4678    }
4679
4680    #[tokio::test]
4681    async fn unknown_env_flag_produces_error_envelope() {
4682        let cli = Cli::new(
4683            CliConfig::new("envtest2", "Env test", "envtest2")
4684                .with_environments(Arc::new(
4685                    crate::environments::Environments::new("prod")
4686                        .with_environment("prod", crate::environments::EnvTable::new()),
4687                ))
4688                .with_startup_args(Vec::<&str>::new()),
4689        );
4690        let out = cli.run(["envtest2", "tree", "--env", "nope"]).await;
4691        assert_ne!(out.exit_code, 0);
4692        assert!(out.rendered.contains("nope"));
4693    }
4694}
4695
4696#[cfg(test)]
4697mod prescan_env_flag_tests {
4698    use super::*;
4699
4700    fn argv(args: &[&str]) -> impl Iterator<Item = String> {
4701        args.iter()
4702            .map(|s| s.to_string())
4703            .collect::<Vec<_>>()
4704            .into_iter()
4705    }
4706
4707    #[test]
4708    fn finds_space_separated_value() {
4709        assert_eq!(
4710            prescan_env_flag(argv(&["--dry-run", "--env", "dev", "list"])),
4711            Some("dev".to_owned())
4712        );
4713    }
4714
4715    #[test]
4716    fn finds_equals_separated_value() {
4717        assert_eq!(
4718            prescan_env_flag(argv(&["--env=dev", "list"])),
4719            Some("dev".to_owned())
4720        );
4721    }
4722
4723    #[test]
4724    fn is_none_without_the_flag() {
4725        assert_eq!(prescan_env_flag(argv(&["env", "list"])), None);
4726    }
4727
4728    #[test]
4729    fn trailing_env_flag_with_no_value_is_none() {
4730        assert_eq!(prescan_env_flag(argv(&["--env"])), None);
4731    }
4732
4733    #[test]
4734    fn keeps_the_last_of_multiple_occurrences() {
4735        // A global `--env` and a command-local one sharing the same arg id
4736        // can both appear (e.g. `app --env bar sub --env foo ...`); clap
4737        // resolves the *last* one as effective, so this scan must too.
4738        assert_eq!(
4739            prescan_env_flag(argv(&["--env", "bar", "sub", "cmd", "--env", "foo", "arg"])),
4740            Some("foo".to_owned())
4741        );
4742    }
4743
4744    #[test]
4745    fn ignores_an_empty_equals_value() {
4746        assert_eq!(prescan_env_flag(argv(&["--env="])), None);
4747    }
4748
4749    #[test]
4750    fn empty_occurrence_does_not_clobber_an_earlier_real_value() {
4751        assert_eq!(
4752            prescan_env_flag(argv(&["--env", "dev", "--env="])),
4753            Some("dev".to_owned())
4754        );
4755    }
4756
4757    #[test]
4758    fn space_separated_value_starting_with_dash_is_not_a_value() {
4759        // clap rejects `--env --dry-run` outright ("a value is required for
4760        // '--env <ENV>' but none was supplied") rather than treating
4761        // `--dry-run` as the value; this scan must agree.
4762        assert_eq!(prescan_env_flag(argv(&["--env", "--dry-run"])), None);
4763    }
4764
4765    #[test]
4766    fn equals_form_accepts_a_value_starting_with_dash() {
4767        // `--env=-foo` is unambiguous (unlike the space-separated form) and
4768        // still accepted, matching clap's own disambiguation rule.
4769        assert_eq!(
4770            prescan_env_flag(argv(&["--env=-foo"])),
4771            Some("-foo".to_owned())
4772        );
4773    }
4774
4775    #[test]
4776    fn stops_at_the_end_of_options_sentinel() {
4777        // Everything after a bare `--` is positional to clap, never a flag —
4778        // `app cmd -- --env dev` must not be read as a real `--env` override.
4779        assert_eq!(prescan_env_flag(argv(&["cmd", "--", "--env", "dev"])), None);
4780    }
4781
4782    #[test]
4783    fn a_real_flag_before_the_sentinel_is_still_found() {
4784        assert_eq!(
4785            prescan_env_flag(argv(&["--env", "dev", "--", "positional"])),
4786            Some("dev".to_owned())
4787        );
4788    }
4789}
4790
4791#[cfg(test)]
4792mod feature_flag_pruning_tests {
4793    use super::*;
4794    use crate::CommandResult;
4795
4796    fn trivial_command(name: &str) -> RuntimeCommandSpec {
4797        RuntimeCommandSpec::new(
4798            CommandSpec::new(name, "short").no_auth(true),
4799            async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
4800        )
4801    }
4802
4803    fn flagged_command(name: &str, key: &str, stage: Stage) -> RuntimeCommandSpec {
4804        let mut command = trivial_command(name);
4805        command.spec = command.spec.with_feature_flag(key, stage);
4806        command
4807    }
4808
4809    fn empty_policy() -> FlagPolicy {
4810        FlagPolicy::default()
4811    }
4812
4813    #[test]
4814    fn no_flags_anywhere_keeps_everything() {
4815        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4816            .with_command(trivial_command("a"))
4817            .with_command(trivial_command("b"))
4818            .with_group(
4819                RuntimeGroupSpec::new(GroupSpec::new("child", "short"))
4820                    .with_command(trivial_command("c")),
4821            );
4822
4823        let mut prefix = Vec::new();
4824        let mut registry = FlagRegistry::new();
4825        let pruned =
4826            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
4827
4828        let pruned = pruned.expect("unflagged tree should never be dropped");
4829        assert_eq!(pruned.commands.len(), 2);
4830        assert_eq!(pruned.groups.len(), 1);
4831        assert_eq!(pruned.groups[0].commands.len(), 1);
4832        assert!(registry.entries().is_empty());
4833    }
4834
4835    #[test]
4836    fn experimental_command_is_pruned_sibling_is_not() {
4837        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4838            .with_command(flagged_command("gated", "gated-flag", Stage::Experimental))
4839            .with_command(trivial_command("sibling"));
4840
4841        let mut prefix = Vec::new();
4842        let mut registry = FlagRegistry::new();
4843        let pruned =
4844            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry)
4845                .expect("group still has a visible command left");
4846
4847        assert_eq!(pruned.commands.len(), 1);
4848        assert_eq!(pruned.commands[0].spec.name, "sibling");
4849
4850        let entries = registry.entries();
4851        assert_eq!(entries.len(), 1);
4852        assert_eq!(entries[0].path, "root:gated");
4853        assert_eq!(entries[0].key, "gated-flag");
4854        assert!(!entries[0].visible);
4855    }
4856
4857    #[test]
4858    fn beta_group_pruned_under_ga_min_stage_kept_under_beta_min_stage() {
4859        let build_tree = || {
4860            RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4861                .with_command(trivial_command("keep-me"))
4862                .with_group(
4863                    RuntimeGroupSpec::new(
4864                        GroupSpec::new("flagged-group", "short")
4865                            .with_feature_flag("group-flag", Stage::Beta),
4866                    )
4867                    .with_command(trivial_command("cmd-default"))
4868                    .with_command(flagged_command(
4869                        "cmd-ga",
4870                        "cmd-ga-flag",
4871                        Stage::Ga,
4872                    )),
4873                )
4874        };
4875
4876        // Default policy (min_stage: Ga) drops the whole Beta subtree, including
4877        // both its undeclared and explicitly-Ga-declared children, because the
4878        // ancestor group itself already fails visibility before children are
4879        // even visited.
4880        let mut prefix = Vec::new();
4881        let mut registry = FlagRegistry::new();
4882        let pruned = prune_feature_flag_tree(
4883            build_tree(),
4884            None,
4885            &empty_policy(),
4886            &mut prefix,
4887            &mut registry,
4888        )
4889        .expect("root keeps its unflagged sibling command");
4890        assert!(pruned.groups.is_empty());
4891        assert_eq!(pruned.commands.len(), 1);
4892        assert_eq!(pruned.commands[0].spec.name, "keep-me");
4893        // Only the group itself was recorded; its children were never visited.
4894        assert_eq!(registry.entries().len(), 1);
4895        assert_eq!(registry.entries()[0].path, "root:flagged-group");
4896        assert!(!registry.entries()[0].visible);
4897
4898        // A Beta-permissive policy keeps the group and both of its children.
4899        let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
4900        let mut prefix = Vec::new();
4901        let mut registry = FlagRegistry::new();
4902        let pruned =
4903            prune_feature_flag_tree(build_tree(), None, &policy, &mut prefix, &mut registry)
4904                .expect("root is kept");
4905        assert_eq!(pruned.groups.len(), 1);
4906        assert_eq!(pruned.groups[0].commands.len(), 2);
4907        assert!(registry.entries().iter().all(|entry| entry.visible));
4908    }
4909
4910    #[test]
4911    fn ancestor_invisibility_short_circuits_before_children_are_visited() {
4912        // The child declares its own, more permissive Ga flag under a distinct
4913        // key. Per the documented pruning semantics, an invisible ancestor drops
4914        // its whole subtree unconditionally: the child's own flag is never even
4915        // considered, because `prune_feature_flag_tree` returns `None` for the
4916        // ancestor as soon as its own effective flag fails visibility, before
4917        // recursing into commands or subgroups at all.
4918        let group = RuntimeGroupSpec::new(
4919            GroupSpec::new("ancestor", "short").with_feature_flag("ancestor-flag", Stage::Beta),
4920        )
4921        .with_command(flagged_command("child", "child-flag", Stage::Ga));
4922
4923        let mut prefix = Vec::new();
4924        let mut registry = FlagRegistry::new();
4925        let pruned =
4926            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
4927
4928        assert!(
4929            pruned.is_none(),
4930            "invisible ancestor drops its whole subtree"
4931        );
4932        // The child was never visited, so nothing about it was recorded.
4933        assert_eq!(registry.entries().len(), 1);
4934        assert_eq!(registry.entries()[0].path, "ancestor");
4935        assert!(registry.by_key("child-flag").is_empty());
4936    }
4937
4938    #[test]
4939    fn cascading_inherited_flag_key_and_stage_reach_unflagged_descendants() {
4940        // Simulates a module-level flag with no per-group/per-command
4941        // declaration anywhere below it: `inherited` here stands in for
4942        // `Module::feature_flag`, exactly as `add_module_group_inner` passes it.
4943        let module_flag = FeatureFlag::new("module-flag", Stage::Beta);
4944        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4945            .with_command(trivial_command("unflagged-child"));
4946
4947        let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
4948        let mut prefix = Vec::new();
4949        let mut registry = FlagRegistry::new();
4950        let pruned = prune_feature_flag_tree(
4951            group,
4952            Some(&module_flag),
4953            &policy,
4954            &mut prefix,
4955            &mut registry,
4956        )
4957        .expect("Beta-permissive policy keeps a Beta-inherited tree");
4958        assert_eq!(pruned.commands.len(), 1);
4959
4960        // Both the group and the descendant command recorded the *same*
4961        // inherited key/stage, proving real cascading rather than an implicit
4962        // Ga default at either level.
4963        let entries = registry.entries();
4964        assert_eq!(entries.len(), 2);
4965        assert_eq!(entries[0].path, "root");
4966        assert_eq!(entries[0].key, "module-flag");
4967        assert_eq!(entries[0].stage, Stage::Beta);
4968        assert_eq!(entries[1].path, "root:unflagged-child");
4969        assert_eq!(entries[1].key, "module-flag");
4970        assert_eq!(entries[1].stage, Stage::Beta);
4971
4972        // Under the default (Ga) policy the same inherited Beta flag makes the
4973        // whole tree invisible together, since the group and its unflagged
4974        // child resolve to the identical effective flag.
4975        let mut prefix = Vec::new();
4976        let mut registry = FlagRegistry::new();
4977        let pruned = prune_feature_flag_tree(
4978            RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4979                .with_command(trivial_command("unflagged-child")),
4980            Some(&module_flag),
4981            &empty_policy(),
4982            &mut prefix,
4983            &mut registry,
4984        );
4985        assert!(pruned.is_none());
4986    }
4987
4988    #[test]
4989    fn registry_records_only_named_flags_not_unflagged_nodes() {
4990        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")).with_group(
4991            RuntimeGroupSpec::new(
4992                GroupSpec::new("g", "short").with_feature_flag("g-flag", Stage::Beta),
4993            )
4994            .with_command(trivial_command("c1"))
4995            .with_command(flagged_command("c2", "c2-flag", Stage::Ga)),
4996        );
4997
4998        // Permissive enough that nothing is pruned, so every node is visited.
4999        let policy = FlagPolicy::default().with_min_stage(Stage::Experimental);
5000        let mut prefix = Vec::new();
5001        let mut registry = FlagRegistry::new();
5002        let pruned = prune_feature_flag_tree(group, None, &policy, &mut prefix, &mut registry)
5003            .expect("permissive policy keeps everything");
5004        assert_eq!(pruned.groups[0].commands.len(), 2);
5005
5006        let entries = registry.entries();
5007        assert_eq!(entries.len(), 3, "root has no flag and is not recorded");
5008        assert_eq!(entries[0].path, "root:g");
5009        assert_eq!(entries[0].key, "g-flag");
5010        assert_eq!(entries[1].path, "root:g:c1");
5011        assert_eq!(entries[1].key, "g-flag");
5012        assert_eq!(entries[1].stage, Stage::Beta);
5013        assert_eq!(entries[2].path, "root:g:c2");
5014        assert_eq!(entries[2].key, "c2-flag");
5015        assert_eq!(entries[2].stage, Stage::Ga);
5016        assert!(entries.iter().all(|entry| entry.visible));
5017    }
5018
5019    #[test]
5020    fn module_feature_flag_cascades_into_its_group_via_add_module() {
5021        // Regression test for the bug this task fixes: `add_module` used to
5022        // discard `module.feature_flag` entirely, so a module-level flag could
5023        // never reach its group/commands. `Module::new` returns a group with an
5024        // unflagged command; the module itself declares Experimental, and the
5025        // default (Ga) policy must prune the whole group away.
5026        let module = Module::new("Test Category", |_ctx| {
5027            RuntimeGroupSpec::new(GroupSpec::new("gated-mod", "short"))
5028                .with_command(trivial_command("list"))
5029        })
5030        .with_feature_flag("module-flag", Stage::Experimental);
5031
5032        let mut cli = Cli::new(CliConfig::new("modtest", "Module test", "modtest"));
5033        cli.add_module(module);
5034
5035        assert!(
5036            !cli.commands.contains_key("gated-mod:list"),
5037            "module-level Experimental flag should have pruned the whole group under the default Ga policy"
5038        );
5039        assert!(
5040            !has_subcommand(&cli.root, "gated-mod"),
5041            "the pruned group must not be mounted in the clap tree either"
5042        );
5043    }
5044
5045    #[test]
5046    fn module_feature_flag_keeps_group_when_policy_allows_it() {
5047        let module = Module::new("Test Category", |_ctx| {
5048            RuntimeGroupSpec::new(GroupSpec::new("gated-mod-2", "short"))
5049                .with_command(trivial_command("list"))
5050        })
5051        .with_feature_flag("module-flag-2", Stage::Experimental);
5052
5053        let mut cli = Cli::new(
5054            CliConfig::new("modtest2", "Module test", "modtest2")
5055                .with_min_stage(Stage::Experimental),
5056        );
5057        cli.add_module(module);
5058
5059        assert!(cli.commands.contains_key("gated-mod-2:list"));
5060        assert!(has_subcommand(&cli.root, "gated-mod-2"));
5061    }
5062
5063    #[test]
5064    fn active_environment_min_stage_loosens_consumer_level_policy() {
5065        // The CliConfig itself leaves min_stage at its Ga default, which would
5066        // normally prune this Experimental-flagged group. The active ("prod")
5067        // environment's compiled min_stage override should reach
5068        // `middleware.flag_policy` before pruning runs and keep it instead.
5069        let module = Module::new("Test Category", |_ctx| {
5070            RuntimeGroupSpec::new(GroupSpec::new("gated-mod-3", "short"))
5071                .with_command(trivial_command("list"))
5072        })
5073        .with_feature_flag("module-flag-3", Stage::Experimental);
5074
5075        let mut cli = Cli::new(
5076            CliConfig::new("modtest3", "Module test", "modtest3")
5077                .with_environments(Arc::new(
5078                    crate::environments::Environments::new("prod").with_environment(
5079                        "prod",
5080                        crate::environments::EnvTable::new().with("min_stage", "experimental"),
5081                    ),
5082                ))
5083                .with_startup_args(Vec::<&str>::new()),
5084        );
5085        cli.add_module(module);
5086
5087        assert!(cli.commands.contains_key("gated-mod-3:list"));
5088        assert!(has_subcommand(&cli.root, "gated-mod-3"));
5089    }
5090
5091    /// The direct proof of the startup `--env` prescan (see `Cli::new`):
5092    /// unlike [`active_environment_min_stage_loosens_consumer_level_policy`]
5093    /// (which exercises the *default* active environment), here "prod" is
5094    /// the default and carries no override, while "dev" loosens `min_stage`.
5095    /// A `--env dev` supplied via `with_startup_args` — standing in for real
5096    /// process argv — must be consulted before `add_module` prunes the tree,
5097    /// in the *same* construction, not just update `middleware.env` for a
5098    /// later run.
5099    #[test]
5100    fn startup_env_flag_reveals_beta_and_experimental_modules_for_the_named_env() {
5101        fn gated_module() -> Module {
5102            Module::new("Test Category", |_ctx| {
5103                RuntimeGroupSpec::new(GroupSpec::new("gated-mod-4", "short"))
5104                    .with_command(trivial_command("list"))
5105            })
5106            .with_feature_flag("module-flag-4", Stage::Experimental)
5107        }
5108        fn environments() -> Arc<crate::environments::Environments> {
5109            Arc::new(
5110                crate::environments::Environments::new("prod")
5111                    .with_environment("prod", crate::environments::EnvTable::new())
5112                    .with_environment(
5113                        "dev",
5114                        crate::environments::EnvTable::new().with("min_stage", "experimental"),
5115                    ),
5116            )
5117        }
5118
5119        let mut with_dev_flag = Cli::new(
5120            CliConfig::new("modtest4a", "Module test", "modtest4a")
5121                .with_environments(environments())
5122                .with_startup_args(["modtest4a", "--env", "dev"]),
5123        );
5124        with_dev_flag.add_module(gated_module());
5125        assert!(
5126            with_dev_flag.commands.contains_key("gated-mod-4:list"),
5127            "--env dev in startup_args should reveal the Experimental module"
5128        );
5129        assert!(has_subcommand(&with_dev_flag.root, "gated-mod-4"));
5130
5131        // Negative counterpart: with no `--env` at all, the default ("prod",
5132        // no override) still governs — nothing changed for the common case.
5133        let mut without_flag = Cli::new(
5134            CliConfig::new("modtest4b", "Module test", "modtest4b")
5135                .with_environments(environments())
5136                .with_startup_args(Vec::<&str>::new()),
5137        );
5138        without_flag.add_module(gated_module());
5139        assert!(
5140            !without_flag.commands.contains_key("gated-mod-4:list"),
5141            "without --env, the default env's Ga policy should still prune the module"
5142        );
5143        assert!(!has_subcommand(&without_flag.root, "gated-mod-4"));
5144    }
5145
5146    static GLOBAL_MIN_STAGE_ENV_LOCK: Mutex<()> = Mutex::new(());
5147
5148    /// RAII guard that restores (or removes) an env var on drop, even if a
5149    /// test panics.
5150    struct GlobalMinStageEnvGuard {
5151        key: &'static str,
5152        prev: Option<std::ffi::OsString>,
5153    }
5154    impl GlobalMinStageEnvGuard {
5155        /// Sets `key` to `value`. Caller must hold [`GLOBAL_MIN_STAGE_ENV_LOCK`]
5156        /// for the guard's entire lifetime.
5157        #[allow(unsafe_code)]
5158        fn set(key: &'static str, value: &str) -> Self {
5159            let prev = std::env::var_os(key);
5160            // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard
5161            // restores/removes on any exit incl. panic.
5162            unsafe { std::env::set_var(key, value) };
5163            Self { key, prev }
5164        }
5165
5166        /// Removes `key` (if set). Caller must hold
5167        /// [`GLOBAL_MIN_STAGE_ENV_LOCK`] for the guard's entire lifetime.
5168        #[allow(unsafe_code)]
5169        fn unset(key: &'static str) -> Self {
5170            let prev = std::env::var_os(key);
5171            // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard restores
5172            // on any exit incl. panic.
5173            unsafe { std::env::remove_var(key) };
5174            Self { key, prev }
5175        }
5176    }
5177    impl Drop for GlobalMinStageEnvGuard {
5178        #[allow(unsafe_code)]
5179        fn drop(&mut self) {
5180            // SAFETY: test holds GLOBAL_MIN_STAGE_ENV_LOCK; restore/clean up
5181            // on any exit including panic.
5182            unsafe {
5183                match &self.prev {
5184                    Some(v) => std::env::set_var(self.key, v),
5185                    None => std::env::remove_var(self.key),
5186                }
5187            }
5188        }
5189    }
5190
5191    #[test]
5192    #[allow(unsafe_code)]
5193    fn global_min_stage_override_is_a_noop_when_unset() {
5194        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5195            .lock()
5196            .unwrap_or_else(std::sync::PoisonError::into_inner);
5197        const VAR: &str = "UNSET_MIN_STAGE_APP_MIN_STAGE";
5198        // Explicitly unset (and restored on drop) rather than assumed absent,
5199        // so the test is hermetic even if a developer/CI happens to have this
5200        // var set.
5201        let _guard = GlobalMinStageEnvGuard::unset(VAR);
5202
5203        assert_eq!(global_min_stage_override("unset-min-stage-app"), None);
5204    }
5205
5206    #[test]
5207    #[allow(unsafe_code)]
5208    fn global_min_stage_override_parses_a_valid_value() {
5209        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5210            .lock()
5211            .unwrap_or_else(std::sync::PoisonError::into_inner);
5212        const VAR: &str = "VALID_MIN_STAGE_APP_MIN_STAGE";
5213        let _guard = GlobalMinStageEnvGuard::set(VAR, "beta");
5214
5215        assert_eq!(
5216            global_min_stage_override("valid-min-stage-app"),
5217            Some(Stage::Beta)
5218        );
5219    }
5220
5221    #[test]
5222    #[allow(unsafe_code)]
5223    fn global_min_stage_override_ignores_a_malformed_value() {
5224        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5225            .lock()
5226            .unwrap_or_else(std::sync::PoisonError::into_inner);
5227        const VAR: &str = "BAD_MIN_STAGE_APP_MIN_STAGE";
5228        let _guard = GlobalMinStageEnvGuard::set(VAR, "nightly");
5229
5230        assert_eq!(global_min_stage_override("bad-min-stage-app"), None);
5231    }
5232}
5233
5234#[cfg(test)]
5235mod flags_command_tests {
5236    use super::*;
5237    use crate::CommandResult;
5238
5239    /// Builds a module with one flagged group containing one flagged (via
5240    /// inheritance) `list` command, so `flag_registry` has something to
5241    /// introspect once the module is mounted.
5242    fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module {
5243        Module::new("Test Category", move |_ctx| {
5244            RuntimeGroupSpec::new(GroupSpec::new(group_name, "short")).with_command(
5245                RuntimeCommandSpec::new(
5246                    CommandSpec::new("list", "short").no_auth(true),
5247                    async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
5248                ),
5249            )
5250        })
5251        .with_feature_flag(key, stage)
5252    }
5253
5254    #[tokio::test]
5255    async fn flags_list_reports_flagged_entries() {
5256        let mut cli = Cli::new(
5257            CliConfig::new("flagtest", "Flag test", "flagtest").with_min_stage(Stage::Beta),
5258        );
5259        cli.add_module(flagged_module("flagged-mod", "list-flag", Stage::Beta));
5260
5261        let out = cli
5262            .run(["flagtest", "flags", "list", "--output", "json"])
5263            .await;
5264        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5265        let rendered: serde_json::Value =
5266            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5267        let entries = rendered["data"].as_array().expect("data should be array");
5268        let command_entry = entries
5269            .iter()
5270            .find(|entry| entry["path"] == "flagged-mod:list")
5271            .expect("flagged command entry should be present");
5272        assert_eq!(command_entry["key"], "list-flag");
5273        assert_eq!(command_entry["stage"], "beta");
5274        assert_eq!(command_entry["visible"], true);
5275    }
5276
5277    #[tokio::test]
5278    async fn flags_info_returns_policy_and_entries_for_known_key() {
5279        let mut cli = Cli::new(
5280            CliConfig::new("flagtest2", "Flag test", "flagtest2").with_min_stage(Stage::Beta),
5281        );
5282        cli.add_module(flagged_module("flagged-mod-2", "info-flag", Stage::Beta));
5283
5284        let out = cli
5285            .run([
5286                "flagtest2",
5287                "flags",
5288                "info",
5289                "info-flag",
5290                "--output",
5291                "json",
5292            ])
5293            .await;
5294        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5295        let rendered: serde_json::Value =
5296            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5297        let data = &rendered["data"];
5298        assert_eq!(data["key"], "info-flag");
5299        assert_eq!(data["policy"]["min_stage"], "beta");
5300        assert!(data["policy"]["override"].is_null());
5301        let entries = data["entries"].as_array().expect("entries should be array");
5302        assert!(!entries.is_empty());
5303        assert!(entries.iter().any(|entry| {
5304            entry["path"] == "flagged-mod-2:list" && entry["decided_by"] == "min_stage"
5305        }));
5306    }
5307
5308    #[tokio::test]
5309    async fn flags_info_reports_override_decided_by() {
5310        // The module declares Experimental, which the default Ga policy would
5311        // normally hide; the override forces Ga instead, so the entries stay
5312        // visible even though `entry.stage` still reports the node's own
5313        // (Experimental) declaration, not the override.
5314        let mut cli = Cli::new(
5315            CliConfig::new("flagtest3", "Flag test", "flagtest3")
5316                .with_feature_override("override-flag", Stage::Ga),
5317        );
5318        cli.add_module(flagged_module(
5319            "flagged-mod-3",
5320            "override-flag",
5321            Stage::Experimental,
5322        ));
5323
5324        let out = cli
5325            .run([
5326                "flagtest3",
5327                "flags",
5328                "info",
5329                "override-flag",
5330                "--output",
5331                "json",
5332            ])
5333            .await;
5334        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5335        let rendered: serde_json::Value =
5336            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5337        let data = &rendered["data"];
5338        assert_eq!(data["policy"]["min_stage"], "ga");
5339        assert_eq!(data["policy"]["override"], "ga");
5340        let entries = data["entries"].as_array().expect("entries should be array");
5341        assert!(!entries.is_empty());
5342        assert!(
5343            entries
5344                .iter()
5345                .all(|entry| entry["decided_by"] == "override")
5346        );
5347        assert!(entries.iter().all(|entry| entry["visible"] == true));
5348        assert!(entries.iter().all(|entry| entry["stage"] == "experimental"));
5349    }
5350
5351    #[tokio::test]
5352    async fn flags_info_unknown_key_errors() {
5353        let cli = Cli::new(CliConfig::new("flagtest4", "Flag test", "flagtest4"));
5354
5355        let out = cli
5356            .run(["flagtest4", "flags", "info", "no-such-flag"])
5357            .await;
5358        assert_ne!(out.exit_code, 0);
5359        assert!(out.rendered.contains("no such flag"));
5360    }
5361}