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.fields_explicit = flags.fields_explicit;
2711    middleware.filter = flags.filter.clone();
2712    middleware.expr = flags.expr.clone();
2713    middleware.reason = flags.reason.clone();
2714    middleware.schema = flags.schema;
2715    middleware.timeout = timeout;
2716    middleware.debug = flags.debug.clone();
2717    middleware.interactive = flags.interactive;
2718}
2719
2720/// Sets `middleware.limit`/`middleware.offset` from a paginating command's own
2721/// `--limit`/`--offset`
2722fn apply_pagination_flags(middleware: &mut Middleware, spec: &CommandSpec, leaf: &ArgMatches) {
2723    let Some(pagination) = spec.pagination else {
2724        return;
2725    };
2726    middleware.limit = leaf
2727        .get_one::<i64>("limit")
2728        .copied()
2729        .unwrap_or(pagination.default_limit);
2730    middleware.offset = leaf.get_one::<i64>("offset").copied().unwrap_or(0);
2731}
2732
2733/// Replays a paginating command's own explicit args, plus the global
2734/// `--filter`/`--expr`/`--fields` flags, as `--flag value` text, prefixed
2735/// with the CLI's binary name — the base a "view the next page"
2736/// [`crate::NextAction`] is built from once the response's
2737/// [`crate::PaginationMeta`] is known. Leading with the binary name keeps the
2738/// suggested command copy-pastable rather than a fragment starting at the
2739/// noun/verb path.
2740///
2741/// `--filter`/`--expr`/`--fields` sit in the same output pipeline as
2742/// pagination itself (filter -> paginate -> expr -> fields) and change what
2743/// data comes back, so dropping them would make the suggested next-page
2744/// command return different results than the command the user actually ran.
2745/// Other global flags (`--output`, `--verbose`, `--env`, ...) don't affect
2746/// *which* data is returned, so they're intentionally left out — the caller
2747/// is already running under them.
2748///
2749/// Best-effort, not a fully general clap-args reconstruction: it uses each
2750/// arg's real `get_long()`/`get_short()` name (never the value-map key,
2751/// which for derive-based args can differ from the flag — e.g. id
2752/// `page_size` vs flag `--page-size`), replays a multi-value arg as one
2753/// flag occurrence per value (round-trips correctly whether the arg is a
2754/// plain repeatable `ArgAction::Append` or also sets a `value_delimiter`),
2755/// and quotes/escapes values containing whitespace or shell metacharacters
2756/// (see `quote_pagination_value`). Deliberately omits `--limit`/`--offset` —
2757/// those are added by the caller once it knows the
2758/// next page's offset.
2759fn pagination_command_base(
2760    binary_name: &str,
2761    command_path: &str,
2762    spec: &CommandSpec,
2763    user_args: &crate::middleware::ValueMap,
2764    flags: &GlobalFlags,
2765) -> String {
2766    let mut parts = vec![
2767        quote_pagination_value(binary_name),
2768        command_path.replace(':', " "),
2769    ];
2770    for arg in &spec.args {
2771        let id = arg.get_id().as_str();
2772        if let Some(value) = user_args.get(id) {
2773            push_pagination_arg(&mut parts, arg, value);
2774        }
2775    }
2776    for (flag, value) in [
2777        ("--filter", &flags.filter),
2778        ("--expr", &flags.expr),
2779        ("--fields", &flags.fields),
2780    ] {
2781        if !value.is_empty() {
2782            parts.push(flag.to_owned());
2783            parts.push(quote_pagination_value(value));
2784        }
2785    }
2786    parts.join(" ")
2787}
2788
2789fn push_pagination_arg(parts: &mut Vec<String>, arg: &Arg, value: &serde_json::Value) {
2790    let flag = arg
2791        .get_long()
2792        .map(|long| format!("--{long}"))
2793        .or_else(|| arg.get_short().map(|short| format!("-{short}")));
2794    match value {
2795        serde_json::Value::Bool(enabled) => {
2796            if matches!(
2797                arg.get_action(),
2798                clap::ArgAction::SetTrue | clap::ArgAction::SetFalse
2799            ) {
2800                // A switch-style flag's presence in `user_args` already means
2801                // the user typed exactly this flag — `SetTrue` implies `true`,
2802                // `SetFalse` implies `false` (e.g. a `--no-foo`-style arg) —
2803                // and neither accepts an explicit `=value` token, so replay
2804                // the bare flag rather than appending one.
2805                if let Some(flag) = flag {
2806                    parts.push(flag);
2807                }
2808            } else {
2809                // A custom bool-valued arg (`ArgAction::Set` with a bool
2810                // value parser) takes an explicit token, so replay it like
2811                // any other scalar.
2812                push_flagged_value(parts, flag, &enabled.to_string());
2813            }
2814        }
2815        serde_json::Value::Array(items) => {
2816            // Repeat the flag once per value rather than joining into one
2817            // comma-separated token: clap collects a repeatable flag
2818            // (`ArgAction::Append`, the common way a command declares a
2819            // multi-value arg) the same way whether or not it also sets
2820            // `value_delimiter(',')`, so `--scope a --scope b` round-trips
2821            // correctly either way. A single `--scope a,b` only works when
2822            // a delimiter was configured — for a plain `Append` arg it's
2823            // parsed as one literal value, changing the replay's meaning.
2824            for item in items {
2825                push_flagged_value(parts, flag.clone(), &pagination_arg_display(item));
2826            }
2827        }
2828        serde_json::Value::Null => {}
2829        other => push_flagged_value(parts, flag, &pagination_arg_display(other)),
2830    }
2831}
2832
2833fn push_flagged_value(parts: &mut Vec<String>, flag: Option<String>, value: &str) {
2834    if let Some(flag) = flag {
2835        parts.push(flag);
2836    }
2837    parts.push(quote_pagination_value(value));
2838}
2839
2840fn pagination_arg_display(value: &serde_json::Value) -> String {
2841    match value {
2842        serde_json::Value::String(text) => text.clone(),
2843        other => other.to_string(),
2844    }
2845}
2846
2847/// Quotes a value for the suggested next-page command, if it contains
2848/// anything beyond a small safe-unquoted allowlist. Whitespace and shell
2849/// metacharacters (`|`, `&`, `;`, `<`, `>`, ...) all fall outside that
2850/// allowlist and so trigger quoting; once quoted, `\`, `"`, `$`, and `` ` ``
2851/// are backslash-escaped (backslash first, so escaping the others doesn't
2852/// re-escape the backslashes it just inserted) so the value can't break out
2853/// of the double quotes or trigger POSIX-shell expansion (`$VAR`, `$(...)`,
2854/// backticks) if the suggestion is copy-pasted into a shell.
2855fn quote_pagination_value(value: &str) -> String {
2856    let safe_unquoted =
2857        |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '@');
2858    if value.is_empty() || !value.chars().all(safe_unquoted) {
2859        let escaped = value
2860            .replace('\\', "\\\\")
2861            .replace('"', "\\\"")
2862            .replace('$', "\\$")
2863            .replace('`', "\\`");
2864        format!("\"{escaped}\"")
2865    } else {
2866        value.to_owned()
2867    }
2868}
2869
2870/// Builds the transport debug logger implied by a parsed `--debug` pattern,
2871/// without publishing it anywhere.
2872///
2873/// Pure so tests can assert on the decision (`--debug` pattern -> enabled or
2874/// not) without touching the process-wide default logger, which every
2875/// [`Cli::run`] call republishes — including the many unrelated tests that
2876/// exercise `cli.run(...)` with no `--debug` flag and would otherwise race
2877/// with an assertion on the shared global.
2878fn debug_transport_logger_for(
2879    debug: &str,
2880    extra_redacted: &[String],
2881) -> Arc<dyn crate::transport::TransportLogger> {
2882    if crate::debug_component_enabled(debug, "transport") {
2883        Arc::new(
2884            crate::transport::StderrTransportLogger::new()
2885                .with_redacted_headers(extra_redacted.iter().cloned()),
2886        )
2887    } else {
2888        Arc::new(crate::transport::NoopTransportLogger)
2889    }
2890}
2891
2892/// Installs (or clears) the process-wide transport debug logger from the parsed
2893/// `--debug` pattern.
2894///
2895/// When `--debug` selects the `transport` component the engine publishes a
2896/// [`StderrTransportLogger`](crate::transport::StderrTransportLogger) — extended
2897/// with any [`CliConfig::with_redacted_debug_headers`] entries — which every
2898/// [`HttpClient`](crate::transport::HttpClient) built afterward picks up
2899/// automatically, with no per-command wiring. The logger is reset to a noop when
2900/// `transport` is not selected so the explicit setting always reflects the
2901/// current invocation rather than a stale process-global from an earlier one.
2902fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) {
2903    crate::transport::set_default_transport_logger(debug_transport_logger_for(
2904        debug,
2905        extra_redacted,
2906    ));
2907}
2908
2909async fn run_with_timeout<F, T>(
2910    timeout: Option<Duration>,
2911    timeout_label: &str,
2912    future: F,
2913) -> Result<T>
2914where
2915    F: Future<Output = Result<T>>,
2916{
2917    let Some(timeout) = timeout else {
2918        return future.await;
2919    };
2920    match tokio::time::timeout(timeout, future).await {
2921        Ok(result) => result,
2922        Err(_) => Err(CliCoreError::message(format!(
2923            "command timed out after {timeout_label}"
2924        ))),
2925    }
2926}
2927
2928async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
2929where
2930    Run: Future<Output = CliRunOutput>,
2931    Shutdown: Future<Output = ()>,
2932{
2933    tokio::pin!(run);
2934    tokio::pin!(shutdown);
2935    tokio::select! {
2936        output = &mut run => output,
2937        () = &mut shutdown => CliRunOutput {
2938            exit_code: 130,
2939            rendered: "command interrupted\n".to_owned(),
2940        },
2941    }
2942}
2943
2944#[cfg(unix)]
2945async fn shutdown_signal() {
2946    let ctrl_c = tokio::signal::ctrl_c();
2947    match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2948        Ok(mut sigterm) => {
2949            tokio::select! {
2950                _ = ctrl_c => {},
2951                _ = sigterm.recv() => {},
2952            }
2953        }
2954        Err(_) => {
2955            drop(ctrl_c.await);
2956        }
2957    }
2958}
2959
2960#[cfg(not(unix))]
2961async fn shutdown_signal() {
2962    drop(tokio::signal::ctrl_c().await);
2963}
2964
2965fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
2966    let raw = raw.trim();
2967    if raw.is_empty() {
2968        return Ok(Some(Duration::from_secs(60)));
2969    }
2970    let Some(seconds) = parse_duration_seconds(raw) else {
2971        return Err(CliCoreError::message(format!(
2972            "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
2973        )));
2974    };
2975    if seconds <= 0.0 {
2976        Ok(None)
2977    } else {
2978        Ok(Some(Duration::from_secs_f64(seconds)))
2979    }
2980}
2981
2982fn parse_duration_seconds(raw: &str) -> Option<f64> {
2983    for (suffix, seconds) in [
2984        ("ns", 0.000_000_001_f64),
2985        ("us", 0.000_001_f64),
2986        ("µs", 0.000_001_f64),
2987        ("ms", 0.001_f64),
2988        ("s", 1.0_f64),
2989        ("m", 60.0_f64),
2990        ("h", 3600.0_f64),
2991    ] {
2992        if let Some(number) = raw.strip_suffix(suffix) {
2993            let value = number.parse::<f64>().ok()?;
2994            if !value.is_finite() {
2995                return None;
2996            }
2997            return Some(value * seconds);
2998        }
2999    }
3000    None
3001}
3002
3003/// Reads the global `${APP_ID}_MIN_STAGE` override (see [`min_stage_env_var`]).
3004///
3005/// Best-effort, like [`crate::config::ConfigFile::load`]'s handling of a
3006/// malformed config file: returns `None` when the var is unset, and also
3007/// `None` (after logging a warning) when it is set but fails to parse as a
3008/// [`Stage`], so a typo'd value cannot take the CLI down.
3009fn global_min_stage_override(app_id: &str) -> Option<Stage> {
3010    let var = min_stage_env_var(app_id);
3011    let value = std::env::var(&var).ok()?;
3012    value.parse::<Stage>().map_or_else(
3013        |err| {
3014            tracing::warn!(var = %var, value = %value, error = %err, "ignoring invalid min-stage override");
3015            None
3016        },
3017        Some,
3018    )
3019}
3020
3021/// Pure scan over an arg iterator for the last `--env <value>`/`--env=<value>`
3022/// occurrence — used only to seed [`Cli::new`]'s `flag_policy` (and therefore
3023/// which flagged commands get pruned) before the command tree is built, since
3024/// that decision can't be revisited once real argv is parsed. The real,
3025/// per-invocation `--env` value used for dispatch still comes from
3026/// `apply_env_flag`'s clap-based parse, unchanged; this scan never replaces
3027/// it, only decides tree shape earlier than clap otherwise could
3028/// (clap's own [`clap::Command::ignore_errors`] does not help here — it
3029/// still requires the rest of the argv to parse against a *known* subcommand
3030/// structure, and at prescan time no domain modules are registered yet, so a
3031/// real command path makes it bail on capturing global flags too).
3032///
3033/// Scans the *entire* argv and keeps the *last* non-empty `--env`/`--env=`
3034/// value, rather than stopping at the first match — a global `--env` and a
3035/// command-local one sharing the same arg id can both appear in one
3036/// invocation, and whichever clap resolves as the effective value
3037/// (empirically, the last one) is the one this scan must agree with. An
3038/// empty value (`--env=` with nothing after the `=`, or `--env` immediately
3039/// followed by another flag with nothing captured) is ignored rather than
3040/// becoming a literal empty-string candidate.
3041fn prescan_env_flag(mut args: impl Iterator<Item = String>) -> Option<String> {
3042    let mut result = None;
3043    while let Some(arg) = args.next() {
3044        // clap's end-of-options sentinel: everything after a bare `--` is a
3045        // positional argument, never a flag, no matter what it looks like.
3046        // This scan must agree, or `app cmd -- --env dev` would be
3047        // misread as a real `--env` override.
3048        if arg == "--" {
3049            break;
3050        }
3051        let value = if let Some(v) = arg.strip_prefix("--env=") {
3052            Some(v.to_owned())
3053        } else if arg == "--env" {
3054            // A space-separated value that itself looks like another flag
3055            // (starts with `-`) is not a value at all — clap rejects this
3056            // outright ("a value is required for '--env <ENV>' but none was
3057            // supplied"), so this scan must not treat it as one either. An
3058            // explicit `--env=-foo` is unambiguous and still accepted, same
3059            // as clap's own disambiguation rule.
3060            args.next().filter(|v| !v.starts_with('-'))
3061        } else {
3062            None
3063        };
3064        if let Some(v) = value.filter(|v| !v.is_empty()) {
3065            result = Some(v);
3066        }
3067    }
3068    result
3069}
3070
3071fn render_cli_error(
3072    middleware: &Middleware,
3073    err: &(dyn std::error::Error + 'static),
3074    system: &str,
3075) -> CliRunOutput {
3076    let format = middleware
3077        .output_format
3078        .parse::<crate::output::OutputFormat>()
3079        .unwrap_or(crate::output::OutputFormat::Json);
3080    let envelope =
3081        crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
3082    match crate::output::render(format, &envelope) {
3083        Ok(rendered) => CliRunOutput {
3084            exit_code: exit_code_for_error(err),
3085            rendered,
3086        },
3087        Err(render_err) => CliRunOutput {
3088            exit_code: exit_code_for_error(err),
3089            rendered: render_err.to_string(),
3090        },
3091    }
3092}
3093
3094fn find_command_by_colon_path<'command>(
3095    root: &'command Command,
3096    path: &str,
3097) -> Option<&'command Command> {
3098    find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
3099}
3100
3101fn find_help_target<'command>(
3102    root: &'command Command,
3103    parts: &[&str],
3104) -> Option<&'command Command> {
3105    let mut current = root;
3106    let mut matched_any = false;
3107    for part in parts {
3108        let Some(next) = current.find_subcommand(part) else {
3109            break;
3110        };
3111        current = next;
3112        matched_any = true;
3113    }
3114    matched_any.then_some(current)
3115}
3116
3117fn find_command_and_canonical_path_by_colon_path<'command>(
3118    root: &'command Command,
3119    path: &str,
3120) -> Option<(&'command Command, Vec<String>)> {
3121    if path.is_empty() {
3122        return Some((root, Vec::new()));
3123    }
3124    let mut current = root;
3125    let mut canonical = Vec::new();
3126    for part in path.split(':') {
3127        current = current.find_subcommand(part)?;
3128        canonical.push(current.get_name().to_owned());
3129    }
3130    Some((current, canonical))
3131}
3132
3133fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
3134    if parts.is_empty() {
3135        return Some(String::new());
3136    }
3137    let mut current = root;
3138    let mut canonical = Vec::new();
3139    for part in parts {
3140        current = current.find_subcommand(part)?;
3141        canonical.push(current.get_name().to_owned());
3142    }
3143    Some(canonical.join(":"))
3144}
3145
3146/// Best-effort stderr hint for a `--scope` value that didn't resolve to a
3147/// known command path — `resolve_search_scope` still searches everything
3148/// (matching a bare `search` with no `--scope` at all), so this is the only
3149/// signal the user gets that their scope was ignored rather than applied.
3150/// Written directly to a locked stderr handle (not `eprintln!`), matching
3151/// the transport module's own `StderrTransportLogger` convention for this
3152/// kind of side-channel diagnostic: best-effort, so a write failure is
3153/// discarded rather than surfaced as a command error.
3154fn warn_unresolvable_search_scope(scope_path: &str) {
3155    let mut stderr = std::io::stderr().lock();
3156    stderr
3157        .write_all(
3158            format!(
3159                "warning: --scope {scope_path:?} did not match a known command path; searching everything instead\n"
3160            )
3161            .as_bytes(),
3162        )
3163        .ok();
3164}
3165
3166fn collect_command_search_documents(
3167    command: &Command,
3168    prefix: &mut Vec<String>,
3169    aliases: &mut Vec<String>,
3170    docs: &mut Vec<SearchDocument>,
3171) {
3172    if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
3173        return;
3174    }
3175    if command.get_subcommands().next().is_some() {
3176        for child in command.get_subcommands() {
3177            prefix.push(child.get_name().to_owned());
3178            let alias_len = aliases.len();
3179            append_command_alias_terms(child, aliases);
3180            collect_command_search_documents(child, prefix, aliases, docs);
3181            aliases.truncate(alias_len);
3182            prefix.pop();
3183        }
3184        return;
3185    }
3186    if prefix.is_empty() {
3187        prefix.push(command.get_name().to_owned());
3188        append_command_alias_terms(command, aliases);
3189    }
3190    let path = prefix.join(" ");
3191    let alias_text = aliases.join(" ");
3192    docs.push(SearchDocument {
3193        id: format!("cmd:{path}"),
3194        kind: "command".to_owned(),
3195        title: path,
3196        summary: command
3197            .get_about()
3198            .map(ToString::to_string)
3199            .unwrap_or_default(),
3200        content: format!(
3201            "{} {} {} {}",
3202            command
3203                .get_about()
3204                .map(ToString::to_string)
3205                .unwrap_or_default(),
3206            command
3207                .get_long_about()
3208                .map(ToString::to_string)
3209                .unwrap_or_default(),
3210            command_flag_text(command),
3211            alias_text
3212        ),
3213    });
3214    if prefix.len() == 1 && prefix[0] == command.get_name() {
3215        prefix.pop();
3216    }
3217}
3218
3219fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
3220    aliases.extend(command.get_all_aliases().map(str::to_owned));
3221    aliases.extend(
3222        command
3223            .get_all_short_flag_aliases()
3224            .map(|alias| alias.to_string()),
3225    );
3226    aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
3227}
3228
3229fn command_flag_text(command: &Command) -> String {
3230    command
3231        .get_arguments()
3232        .filter(|arg| !arg.is_hide_set())
3233        .filter_map(|arg| {
3234            let mut names = Vec::new();
3235            if let Some(short) = arg.get_short() {
3236                names.push(format!("-{short}"));
3237            }
3238            if let Some(long) = arg.get_long() {
3239                names.push(format!("--{long}"));
3240            }
3241            if let Some(short_aliases) = arg.get_all_short_aliases() {
3242                names.extend(
3243                    short_aliases
3244                        .into_iter()
3245                        .map(|short_alias| format!("-{short_alias}")),
3246                );
3247            }
3248            if let Some(aliases) = arg.get_all_aliases() {
3249                names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
3250            }
3251            (!names.is_empty()).then(|| names.join(" "))
3252        })
3253        .collect::<Vec<_>>()
3254        .join(" ")
3255}
3256
3257fn has_subcommand(command: &Command, name: &str) -> bool {
3258    command
3259        .get_subcommands()
3260        .any(|child| child.get_name() == name)
3261}
3262
3263fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
3264    let bool_flags = derive_bool_flags(root);
3265    let value_flags = derive_value_flags(root);
3266    let mut iter = args.iter().peekable();
3267    if iter
3268        .peek()
3269        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3270    {
3271        iter.next();
3272    }
3273
3274    while let Some(arg) = iter.next() {
3275        match arg.as_str() {
3276            "--version" | "-v" => return true,
3277            "--" => return false,
3278            value if value.contains('=') || bool_flags.contains(value) => continue,
3279            value
3280                if value_flags.contains(value)
3281                    || unknown_flag_consumes_value(value, iter.peek()) =>
3282            {
3283                iter.next();
3284            }
3285            value if value.starts_with('-') => {}
3286            _ => return false,
3287        }
3288    }
3289    false
3290}
3291
3292fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
3293    let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
3294    let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
3295    let mut normalized = Vec::with_capacity(args.len());
3296    let mut index = 0;
3297    let mut current = root;
3298    while index < args.len() {
3299        let arg = &args[index];
3300        if index == 0 && arg_matches_root_name(arg, root.get_name()) {
3301            normalized.push(arg.clone());
3302            index += 1;
3303            continue;
3304        }
3305
3306        if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
3307            normalized.push(format!("{arg}={default}"));
3308            index += 1;
3309            continue;
3310        }
3311
3312        if let Some(default) = optional_string_defaults.get(arg.as_str()) {
3313            match args.get(index + 1) {
3314                None => {
3315                    normalized.push(format!("{arg}={default}"));
3316                    index += 1;
3317                    continue;
3318                }
3319                Some(next)
3320                    if current.get_name() == root.get_name()
3321                        || next.starts_with('-')
3322                        || direct_subcommand(current, next).is_some() =>
3323                {
3324                    normalized.push(format!("{arg}={default}"));
3325                    index += 1;
3326                    continue;
3327                }
3328                Some(next) => {
3329                    normalized.push(arg.clone());
3330                    normalized.push(next.clone());
3331                    index += 2;
3332                    continue;
3333                }
3334            }
3335        }
3336
3337        normalized.push(arg.clone());
3338        if !arg.starts_with('-')
3339            && let Some(next_command) = direct_subcommand(current, arg)
3340        {
3341            current = next_command;
3342        }
3343        index += 1;
3344    }
3345    normalized
3346}
3347
3348fn direct_subcommand<'command>(
3349    command: &'command Command,
3350    token: &str,
3351) -> Option<&'command Command> {
3352    command.get_subcommands().find(|child| {
3353        child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
3354    })
3355}
3356
3357/// Appends a `— did you mean "…"?` suffix to an unknown-command error clause.
3358fn format_did_you_mean(base: &str, suggestion: &str) -> String {
3359    format!("{base} — did you mean {suggestion:?}?")
3360}
3361
3362/// First unknown group token (`unknown command "X" for "Y"`, no hint suffix).
3363struct UnknownGroupCommand {
3364    base: String,
3365}
3366
3367/// Reports the first unknown token under a group. `positionals` must be pre-`--`
3368/// command keywords (slice to `command_keyword_count` like the group-help path).
3369fn detect_unknown_group_command(
3370    root: &Command,
3371    positionals: &[String],
3372) -> Option<UnknownGroupCommand> {
3373    if positionals.is_empty() {
3374        return None;
3375    }
3376
3377    let mut current = root;
3378    let mut path = vec![root.get_name().to_owned()];
3379    for token in positionals {
3380        if let Some(next) = current.find_subcommand(token) {
3381            current = next;
3382            path.push(next.get_name().to_owned());
3383            continue;
3384        }
3385        if current.get_subcommands().next().is_some() {
3386            let base = format!("unknown command {token:?} for {:?}", path.join(" "));
3387            return Some(UnknownGroupCommand { base });
3388        }
3389        return None;
3390    }
3391    None
3392}
3393
3394/// Counts positional command tokens that precede any `--` separator.
3395fn command_keyword_count(
3396    args: &[String],
3397    root_name: &str,
3398    bool_flags: &BTreeSet<String>,
3399    value_flags: &BTreeSet<String>,
3400) -> usize {
3401    let positionals = positional_command_tokens(args, root_name, bool_flags, value_flags);
3402    match args.iter().position(|arg| arg == "--") {
3403        Some(end) => {
3404            positional_command_tokens(&args[..end], root_name, bool_flags, value_flags).len()
3405        }
3406        None => positionals.len(),
3407    }
3408}
3409
3410/// Rewrites `<group> help [sub...]` into `help <group> [sub...]` when the form
3411/// is present; otherwise returns `clap_args` unchanged.
3412fn rewrite_group_help_if_needed(
3413    root: &Command,
3414    clap_args: &[String],
3415    root_name: &str,
3416    bool_flags: &BTreeSet<String>,
3417    value_flags: &BTreeSet<String>,
3418) -> Vec<String> {
3419    let positionals = positional_command_tokens(clap_args, root_name, bool_flags, value_flags);
3420    let keyword_count = command_keyword_count(clap_args, root_name, bool_flags, value_flags);
3421    let Some(parts) = group_help_target_parts(root, &positionals, keyword_count) else {
3422        return clap_args.to_vec();
3423    };
3424    rewrite_group_help_args(clap_args, root_name, bool_flags, value_flags, &parts)
3425}
3426
3427/// Rewrites the `target`-th positional command token to `replacement`, preserving
3428/// flags. Token classification mirrors [`positional_command_tokens`].
3429fn replace_positional_command_token(
3430    args: &[String],
3431    root_name: &str,
3432    bool_flags: &BTreeSet<String>,
3433    value_flags: &BTreeSet<String>,
3434    target: usize,
3435    replacement: &str,
3436) -> Vec<String> {
3437    let mut out = args.to_vec();
3438    let mut index = 0;
3439    if out
3440        .first()
3441        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3442    {
3443        index = 1;
3444    }
3445
3446    let mut positional = 0;
3447    while index < out.len() {
3448        let arg = &out[index];
3449        if arg == "--" {
3450            break;
3451        }
3452        if arg.contains('=') {
3453            index += 1;
3454            continue;
3455        }
3456        if bool_flags.contains(arg) {
3457            index += 1;
3458            continue;
3459        }
3460        if value_flags.contains(arg)
3461            || unknown_flag_consumes_value(arg, out.get(index + 1).as_ref())
3462        {
3463            index += 2;
3464            continue;
3465        }
3466        if arg.starts_with('-') {
3467            index += 1;
3468            continue;
3469        }
3470        if positional == target {
3471            out[index] = replacement.to_owned();
3472            break;
3473        }
3474        positional += 1;
3475        index += 1;
3476    }
3477    out
3478}
3479
3480/// Finds the closest visible subcommand name or alias within edit-distance
3481/// `max(1, token_len / 3)`. Returns the canonical name; ties break alphabetically.
3482fn nearest_subcommand(command: &Command, token: &str) -> Option<String> {
3483    let token = token.to_ascii_lowercase();
3484    let max_distance = 1.max(token.chars().count() / 3);
3485
3486    command
3487        .get_subcommands()
3488        .filter(|child| !child.is_hide_set())
3489        .filter_map(|child| {
3490            let best = std::iter::once(child.get_name())
3491                .chain(child.get_all_aliases())
3492                .map(|candidate| strsim::osa_distance(&token, &candidate.to_ascii_lowercase()))
3493                .min()?;
3494            (best <= max_distance).then(|| (best, child.get_name().to_owned()))
3495        })
3496        .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)))
3497        .map(|(_, name)| name)
3498}
3499
3500/// Corrects every unknown group token to its nearest subcommand. Returns `None`
3501/// when any token has no near match, or when there is nothing to correct.
3502/// Stops at a leaf operand, curated `<group> help`, or an unfixable token.
3503fn full_command_correction(root: &Command, positionals: &[String]) -> Option<Vec<(usize, String)>> {
3504    let mut current = root;
3505    let mut corrections = Vec::new();
3506    for (index, token) in positionals.iter().enumerate() {
3507        if let Some(next) = current.find_subcommand(token) {
3508            current = next;
3509            continue;
3510        }
3511        if current.get_subcommands().next().is_none() {
3512            break;
3513        }
3514        if token == "help" && current.find_subcommand("help").is_none() {
3515            break;
3516        }
3517        let suggestion = nearest_subcommand(current, token)?;
3518        let next = current.find_subcommand(&suggestion)?;
3519        corrections.push((index, suggestion));
3520        current = next;
3521    }
3522    (!corrections.is_empty()).then_some(corrections)
3523}
3524
3525/// Prompt/display text for a correction. Last-token-only fixes show the bare
3526/// token; anything else shows the full corrected command path.
3527fn correction_display(
3528    root_name: &str,
3529    positionals: &[String],
3530    corrections: &[(usize, String)],
3531) -> String {
3532    if let [(index, only)] = corrections
3533        && *index + 1 == positionals.len()
3534    {
3535        return only.clone();
3536    }
3537    let mut tokens = vec![root_name.to_owned()];
3538    for (index, token) in positionals.iter().enumerate() {
3539        let corrected = corrections
3540            .iter()
3541            .find(|(i, _)| *i == index)
3542            .map(|(_, replacement)| replacement.clone())
3543            .unwrap_or_else(|| token.clone());
3544        tokens.push(corrected);
3545    }
3546    tokens.join(" ")
3547}
3548
3549#[cfg(test)]
3550mod unknown_command_suggestion_tests {
3551    use super::*;
3552
3553    fn sample_group() -> Command {
3554        Command::new("gddy").subcommand(
3555            Command::new("domain")
3556                .alias("dns-domain")
3557                .subcommand(Command::new("list"))
3558                .subcommand(Command::new("available")),
3559        )
3560    }
3561
3562    #[test]
3563    fn osa_distance_treats_adjacent_transposition_as_one_edit() {
3564        // Guard against swapping to `strsim::levenshtein`, which counts swaps as two edits.
3565        assert_eq!(strsim::osa_distance("domain", "domain"), 0);
3566        assert_eq!(strsim::osa_distance("domian", "domain"), 1);
3567        assert_eq!(strsim::osa_distance("lst", "list"), 1);
3568        assert_eq!(strsim::osa_distance("lsit", "list"), 1);
3569        assert_eq!(strsim::osa_distance("cat", "set"), 2);
3570    }
3571
3572    #[test]
3573    fn nearest_subcommand_matches_close_typos() {
3574        let root = sample_group();
3575        let domain = root.find_subcommand("domain").expect("domain registered");
3576        assert_eq!(nearest_subcommand(domain, "lst").as_deref(), Some("list"));
3577        assert_eq!(nearest_subcommand(domain, "ilst").as_deref(), Some("list"));
3578        assert_eq!(
3579            nearest_subcommand(domain, "avaliable").as_deref(),
3580            Some("available")
3581        );
3582    }
3583
3584    #[test]
3585    fn nearest_subcommand_rejects_unrelated_tokens() {
3586        let root = sample_group();
3587        let domain = root.find_subcommand("domain").expect("domain registered");
3588        assert_eq!(nearest_subcommand(domain, "missing"), None);
3589    }
3590
3591    #[test]
3592    fn nearest_subcommand_returns_canonical_name_for_alias_typos() {
3593        let root = sample_group();
3594        assert_eq!(
3595            nearest_subcommand(&root, "dns-domian").as_deref(),
3596            Some("domain")
3597        );
3598    }
3599
3600    #[test]
3601    fn nearest_subcommand_skips_hidden_commands() {
3602        let root = Command::new("gddy")
3603            .subcommand(Command::new("visible"))
3604            .subcommand(Command::new("hiddeen").hide(true));
3605        assert_eq!(nearest_subcommand(&root, "hidden"), None);
3606    }
3607
3608    #[test]
3609    fn nearest_subcommand_rejects_short_unrelated_tokens() {
3610        let root = Command::new("gddy").subcommand(
3611            Command::new("config")
3612                .subcommand(Command::new("get"))
3613                .subcommand(Command::new("set"))
3614                .subcommand(Command::new("add")),
3615        );
3616        let config = root.find_subcommand("config").expect("config registered");
3617        assert_eq!(nearest_subcommand(config, "cat"), None);
3618        assert_eq!(nearest_subcommand(config, "x"), None);
3619        assert_eq!(nearest_subcommand(config, "st").as_deref(), Some("set"));
3620    }
3621
3622    #[test]
3623    fn unknown_group_command_formats_did_you_mean_suffix() {
3624        let root = sample_group();
3625        let unknown = detect_unknown_group_command(&root, &["domian".to_owned()])
3626            .expect("domian is an unknown top-level command");
3627        assert_eq!(unknown.base, "unknown command \"domian\" for \"gddy\"");
3628        assert_eq!(
3629            format_did_you_mean(&unknown.base, "domain"),
3630            "unknown command \"domian\" for \"gddy\" — did you mean \"domain\"?"
3631        );
3632    }
3633
3634    #[test]
3635    fn detect_unknown_group_command_reports_nested_typos() {
3636        let root = sample_group();
3637        let unknown = detect_unknown_group_command(&root, &["domain".to_owned(), "lst".to_owned()])
3638            .expect("lst is an unknown subcommand of domain");
3639        assert_eq!(unknown.base, "unknown command \"lst\" for \"gddy domain\"");
3640        assert_eq!(
3641            format_did_you_mean(&unknown.base, "list"),
3642            "unknown command \"lst\" for \"gddy domain\" — did you mean \"list\"?"
3643        );
3644    }
3645
3646    #[test]
3647    fn detect_unknown_group_command_omits_hint_for_unrelated_tokens() {
3648        let root = sample_group();
3649        let unknown = detect_unknown_group_command(&root, &["missing".to_owned()])
3650            .expect("missing is an unknown top-level command");
3651        assert_eq!(unknown.base, "unknown command \"missing\" for \"gddy\"");
3652    }
3653
3654    #[test]
3655    fn full_command_correction_fixes_a_single_group_typo() {
3656        let root = sample_group();
3657        let corrections = full_command_correction(&root, &["domian".to_owned()])
3658            .expect("domian is correctable to domain");
3659        assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3660    }
3661
3662    #[test]
3663    fn full_command_correction_fixes_every_typo_in_a_nested_path() {
3664        let root = sample_group();
3665        let corrections = full_command_correction(&root, &["domian".to_owned(), "lst".to_owned()])
3666            .expect("both tokens are correctable");
3667        assert_eq!(
3668            corrections,
3669            vec![(0, "domain".to_owned()), (1, "list".to_owned())]
3670        );
3671    }
3672
3673    #[test]
3674    fn full_command_correction_bails_when_a_token_has_no_near_match() {
3675        let root = sample_group();
3676        assert_eq!(
3677            full_command_correction(&root, &["domain".to_owned(), "missing".to_owned()]),
3678            None
3679        );
3680    }
3681
3682    #[test]
3683    fn full_command_correction_is_none_when_there_is_nothing_to_correct() {
3684        let root = sample_group();
3685        assert_eq!(full_command_correction(&root, &["domain".to_owned()]), None);
3686        assert_eq!(full_command_correction(&root, &[]), None);
3687    }
3688
3689    #[test]
3690    fn full_command_correction_corrects_the_group_before_curated_help() {
3691        let root = sample_group();
3692        let corrections = full_command_correction(&root, &["domian".to_owned(), "help".to_owned()])
3693            .expect("domian is correctable even ahead of a help token");
3694        assert_eq!(corrections, vec![(0, "domain".to_owned())]);
3695    }
3696
3697    #[test]
3698    fn full_command_correction_keeps_corrections_when_a_leaf_is_followed_by_an_operand() {
3699        let root = sample_group();
3700        let corrections = full_command_correction(
3701            &root,
3702            &[
3703                "domain".to_owned(),
3704                "avaliable".to_owned(),
3705                "example.com".to_owned(),
3706            ],
3707        )
3708        .expect("avaliable is correctable to available");
3709        assert_eq!(corrections, vec![(1, "available".to_owned())]);
3710    }
3711
3712    #[test]
3713    fn correction_display_shows_the_bare_token_for_a_single_fix() {
3714        let corrections = vec![(1, "list".to_owned())];
3715        assert_eq!(
3716            correction_display(
3717                "gddy",
3718                &["domain".to_owned(), "lst".to_owned()],
3719                &corrections
3720            ),
3721            "list"
3722        );
3723    }
3724
3725    #[test]
3726    fn correction_display_shows_the_full_command_when_a_single_fix_is_not_the_last_token() {
3727        let corrections = vec![(0, "domain".to_owned())];
3728        assert_eq!(
3729            correction_display(
3730                "gddy",
3731                &["domian".to_owned(), "list".to_owned()],
3732                &corrections
3733            ),
3734            "gddy domain list"
3735        );
3736    }
3737
3738    #[test]
3739    fn correction_display_shows_the_full_command_for_multiple_fixes() {
3740        let corrections = vec![(0, "domain".to_owned()), (1, "list".to_owned())];
3741        assert_eq!(
3742            correction_display(
3743                "gddy",
3744                &["domian".to_owned(), "lst".to_owned()],
3745                &corrections
3746            ),
3747            "gddy domain list"
3748        );
3749    }
3750
3751    #[test]
3752    fn replace_positional_command_token_rewrites_only_the_target() {
3753        let bool_flags: BTreeSet<String> = ["--verbose".to_owned()].into_iter().collect();
3754        let value_flags: BTreeSet<String> = ["--output".to_owned()].into_iter().collect();
3755        let args = vec![
3756            "gddy".to_owned(),
3757            "--output".to_owned(),
3758            "json".to_owned(),
3759            "domain".to_owned(),
3760            "lst".to_owned(),
3761        ];
3762        let corrected =
3763            replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 1, "list");
3764        assert_eq!(
3765            corrected,
3766            vec!["gddy", "--output", "json", "domain", "list"]
3767        );
3768    }
3769
3770    #[test]
3771    fn rewrite_group_help_if_needed_runs_after_typo_correction() {
3772        let root = sample_group();
3773        let bool_flags = derive_bool_flags(&root);
3774        let value_flags = derive_value_flags(&root);
3775        let args = vec!["gddy".to_owned(), "domian".to_owned(), "help".to_owned()];
3776        let corrected =
3777            replace_positional_command_token(&args, "gddy", &bool_flags, &value_flags, 0, "domain");
3778        assert_eq!(corrected, vec!["gddy", "domain", "help"]);
3779        let rewritten =
3780            rewrite_group_help_if_needed(&root, &corrected, "gddy", &bool_flags, &value_flags);
3781        assert_eq!(rewritten, vec!["gddy", "help", "domain"]);
3782    }
3783}
3784
3785/// Detects the `<group> help [sub...]` form and returns the command path whose
3786/// help should be rendered.
3787///
3788/// The engine ships a curated root `help` command, so it disables clap's
3789/// auto-generated help subcommand on the root. That setting propagates to every
3790/// subcommand and cannot be re-enabled per child, so `<group> help` would
3791/// otherwise hit clap's "unrecognized subcommand" error even though the group's
3792/// help listing advertises a `help` entry. We recognize the form here so the
3793/// caller can route it through the curated help renderer, matching clap's
3794/// documented equivalence between `cmd group help sub` and `cmd help group sub`.
3795///
3796/// Only groups (commands that have subcommands) are matched: a group is pure
3797/// subcommand dispatch, so a `help` token in that position is unambiguously a
3798/// help request. Leaf commands may accept a literal `help` positional argument,
3799/// so they are left for clap to parse (`<leaf> --help` still works). A group
3800/// that registers its own real `help` subcommand is likewise deferred to clap,
3801/// which dispatches the user-defined command (only auto-generated help is
3802/// suppressed).
3803///
3804/// `command_keyword_count` is the number of leading positionals that are
3805/// genuine command keywords (those before any `--`). A `help` at or beyond that
3806/// index is a literal operand after `--`, not a help request, so it is ignored.
3807fn group_help_target_parts(
3808    root: &Command,
3809    positionals: &[String],
3810    command_keyword_count: usize,
3811) -> Option<Vec<String>> {
3812    let help_index = positionals.iter().position(|token| token == "help")?;
3813    // A leading `help` is the curated root help command; let it flow through.
3814    if help_index == 0 {
3815        return None;
3816    }
3817    // A `help` after a `--` separator is a literal operand; leave it for clap.
3818    if help_index >= command_keyword_count {
3819        return None;
3820    }
3821    let prefix = &positionals[..help_index];
3822    let mut current = root;
3823    for token in prefix {
3824        current = current.find_subcommand(token)?;
3825    }
3826    // The token before `help` must resolve to a group; leaves are left to clap.
3827    current.get_subcommands().next()?;
3828    // Defer to clap when the group defines a real `help` subcommand of its own.
3829    if current.find_subcommand("help").is_some() {
3830        return None;
3831    }
3832    // `<group> help <sub...>` shows help for `<group> <sub...>`.
3833    let suffix = &positionals[help_index + 1..];
3834    Some(prefix.iter().chain(suffix).cloned().collect())
3835}
3836
3837/// Rewrites a `<group> help [sub...]` invocation into the canonical
3838/// `help <group> [sub...]` argument vector.
3839///
3840/// Only the positional command tokens are reordered (from `[group..., help,
3841/// sub...]` to `[help, group..., sub...]`); every flag — including `key=value`
3842/// forms, value-consuming flags, unknown flags that consume a value, and
3843/// anything after `--` — is preserved in its original place. Reordering keeps
3844/// the positional count unchanged, so the rewritten stream is filled slot for
3845/// slot. `parts` is the resolved command path (group + subcommand) from
3846/// [`group_help_target_parts`].
3847fn rewrite_group_help_args(
3848    clap_args: &[String],
3849    root_name: &str,
3850    bool_flags: &BTreeSet<String>,
3851    value_flags: &BTreeSet<String>,
3852    parts: &[String],
3853) -> Vec<String> {
3854    // New positional order: the curated `help` command, then the command path.
3855    let mut next_positional = std::iter::once("help".to_owned())
3856        .chain(parts.iter().cloned())
3857        .peekable();
3858    let mut out = Vec::with_capacity(clap_args.len());
3859    let mut iter = clap_args.iter().peekable();
3860    if iter
3861        .peek()
3862        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3863        && let Some(program) = iter.next()
3864    {
3865        out.push(program.clone());
3866    }
3867
3868    let mut take_positional =
3869        |fallback: &String| next_positional.next().unwrap_or(fallback.clone());
3870
3871    while let Some(arg) = iter.next() {
3872        if arg == "--" {
3873            out.push(arg.clone());
3874            // Everything after `--` is positional.
3875            for rest in iter.by_ref() {
3876                out.push(take_positional(rest));
3877            }
3878            break;
3879        }
3880        if arg.contains('=') || bool_flags.contains(arg) {
3881            out.push(arg.clone());
3882            continue;
3883        }
3884        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3885            out.push(arg.clone());
3886            if let Some(value) = iter.next() {
3887                out.push(value.clone());
3888            }
3889            continue;
3890        }
3891        if arg.starts_with('-') {
3892            out.push(arg.clone());
3893            continue;
3894        }
3895        out.push(take_positional(arg));
3896    }
3897    // Defensive: emit any positionals not yet placed (counts normally match).
3898    out.extend(next_positional);
3899    out
3900}
3901
3902fn positional_command_tokens(
3903    args: &[String],
3904    root_name: &str,
3905    bool_flags: &BTreeSet<String>,
3906    value_flags: &BTreeSet<String>,
3907) -> Vec<String> {
3908    let mut tokens = Vec::new();
3909    let mut iter = args.iter().peekable();
3910    if iter
3911        .peek()
3912        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
3913    {
3914        iter.next();
3915    }
3916
3917    while let Some(arg) = iter.next() {
3918        if arg == "--" {
3919            tokens.extend(iter.cloned());
3920            break;
3921        }
3922        if arg.contains('=') {
3923            continue;
3924        }
3925        if bool_flags.contains(arg) {
3926            continue;
3927        }
3928        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
3929            iter.next();
3930            continue;
3931        }
3932        if arg.starts_with('-') {
3933            continue;
3934        }
3935        tokens.push(arg.clone());
3936    }
3937    tokens
3938}
3939
3940fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
3941    arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
3942}
3943
3944fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
3945    arg == root_name
3946        || Path::new(arg)
3947            .file_stem()
3948            .and_then(|n| n.to_str())
3949            .is_some_and(|n| n == root_name)
3950}
3951
3952/// Outcome of [`Cli::resolve_argv0`]: either rewritten arguments to feed the
3953/// normal pipeline, or a fully rendered result to return immediately.
3954enum Argv0Outcome {
3955    /// Continue the normal run pipeline with these arguments.
3956    Proceed(Vec<String>),
3957    /// Return this already-rendered result without further processing.
3958    Handled(CliRunOutput),
3959}
3960
3961/// Extracts the bare program name from an `argv[0]` value, dropping any directory
3962/// path and file extension (e.g. `/usr/bin/pl` or `pl.exe` both yield `pl`).
3963/// Falls back to the raw value when no file stem can be derived.
3964fn program_basename(arg: &str) -> String {
3965    Path::new(arg)
3966        .file_stem()
3967        .and_then(|stem| stem.to_str())
3968        .map_or_else(|| arg.to_owned(), ToOwned::to_owned)
3969}
3970
3971/// Returns `true` when `name` is a valid alternative `argv[0]` route name: a
3972/// non-empty token of ASCII letters, digits, `-`, or `_`. This keeps the name
3973/// safe as a link/shim filename and as an `argv[0]` basename (which is matched
3974/// with its extension stripped, so an embedded dot would break matching).
3975fn is_valid_argv0_name(name: &str) -> bool {
3976    !name.is_empty()
3977        && name.chars().all(|character| {
3978            character.is_ascii_alphanumeric() || character == '-' || character == '_'
3979        })
3980}
3981
3982/// Returns `true` when the entry at `link` already matches what [`Cli::create_link`]
3983/// would produce for `method`/`target`/`name`, so it can be left untouched. A
3984/// mismatch (wrong kind, stale symlink target, or differing contents) returns
3985/// `false` so the caller replaces it.
3986fn argv0_link_matches(
3987    link: &Path,
3988    target: &Path,
3989    name: &str,
3990    method: Argv0LinkMethod,
3991) -> std::io::Result<bool> {
3992    let metadata = std::fs::symlink_metadata(link)?;
3993    match method {
3994        Argv0LinkMethod::SoftLink => {
3995            Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target)
3996        }
3997        Argv0LinkMethod::HardLink => {
3998            if metadata.file_type().is_symlink() {
3999                return Ok(false);
4000            }
4001            // A correct hard link is indistinguishable from the target by content;
4002            // comparing bytes also accepts an identical copy, which is harmless.
4003            Ok(std::fs::read(link)? == std::fs::read(target)?)
4004        }
4005        Argv0LinkMethod::Script => {
4006            if metadata.file_type().is_symlink() {
4007                return Ok(false);
4008            }
4009            Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name)))
4010        }
4011    }
4012}
4013
4014/// File name for an alternative `argv[0]` link, per method and host platform.
4015fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String {
4016    let extension = match method {
4017        Argv0LinkMethod::Script if cfg!(windows) => ".cmd",
4018        // Unix scripts are extension-less executables; links carry `.exe` on Windows.
4019        Argv0LinkMethod::Script => "",
4020        _ if cfg!(windows) => ".exe",
4021        _ => "",
4022    };
4023    format!("{name}{extension}")
4024}
4025
4026/// Contents of an alternative `argv[0]` shim script that forwards to `target`
4027/// via the explicit `argv0` command. A `.cmd` batch file on Windows, an
4028/// executable POSIX shell script elsewhere.
4029fn argv0_script_contents(target: &Path, name: &str) -> String {
4030    let target = target.display();
4031    if cfg!(windows) {
4032        format!("@\"{target}\" argv0 {name} %*\r\n")
4033    } else {
4034        format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n")
4035    }
4036}
4037
4038#[cfg(unix)]
4039fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4040    std::os::unix::fs::symlink(target, link)
4041}
4042
4043#[cfg(windows)]
4044fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
4045    std::os::windows::fs::symlink_file(target, link)
4046}
4047
4048#[cfg(not(any(unix, windows)))]
4049fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
4050    Err(std::io::Error::new(
4051        std::io::ErrorKind::Unsupported,
4052        "symlink creation is not supported on this platform",
4053    ))
4054}
4055
4056/// Marks a freshly written shim script executable on Unix; a no-op elsewhere.
4057#[cfg(unix)]
4058fn make_executable(path: &Path) -> std::io::Result<()> {
4059    use std::os::unix::fs::PermissionsExt;
4060    let mut permissions = std::fs::metadata(path)?.permissions();
4061    permissions.set_mode(0o755);
4062    std::fs::set_permissions(path, permissions)
4063}
4064
4065#[cfg(not(unix))]
4066fn make_executable(_path: &Path) -> std::io::Result<()> {
4067    Ok(())
4068}
4069
4070/// Walks a runtime group tree, resolving each node's effective feature flag by
4071/// cascading from `inherited` — a node's own [`GroupSpec::feature_flag`] or
4072/// [`CommandSpec::feature_flag`] wins if set, otherwise it inherits the
4073/// nearest ancestor's effective flag, otherwise (nothing in the ancestor
4074/// chain declared a flag) it implicitly resolves to [`Stage::Ga`] with no key.
4075/// Every node that resolves to a *named* flag (own or inherited) is recorded
4076/// into `registry` under its colon-separated path, together with whether
4077/// `policy` judged it visible. Nodes that resolve to the implicit no-flag
4078/// default are not recorded (there is nothing to introspect) and are always
4079/// visible.
4080///
4081/// Returns `None` when this group itself should be dropped from the tree —
4082/// either because its effective flag is not visible under `policy`, or
4083/// because every one of its commands and subgroups was pruned away, leaving
4084/// an empty group with nothing to mount. An emptied-out group is dropped
4085/// unconditionally, even if its own flag was visible: a `clap` subcommand
4086/// group with zero children is useless either way, so this simplifies the
4087/// pruning logic rather than threading through a "was this group itself
4088/// visible but empty" distinction that no caller needs.
4089///
4090/// Note that an invisible ancestor short-circuits before its children are
4091/// even visited: a more permissive flag on a descendant cannot resurrect a
4092/// subtree whose enclosing group already failed the visibility check.
4093fn prune_feature_flag_tree(
4094    mut group: RuntimeGroupSpec,
4095    inherited: Option<&FeatureFlag>,
4096    policy: &FlagPolicy,
4097    prefix: &mut Vec<String>,
4098    registry: &mut FlagRegistry,
4099) -> Option<RuntimeGroupSpec> {
4100    prefix.push(group.group.name.clone());
4101
4102    let effective = group
4103        .group
4104        .feature_flag
4105        .clone()
4106        .or_else(|| inherited.cloned());
4107    if !record_and_check_visibility(effective.as_ref(), policy, prefix, registry) {
4108        prefix.pop();
4109        return None;
4110    }
4111
4112    let mut kept_groups = Vec::with_capacity(group.groups.len());
4113    for child in std::mem::take(&mut group.groups) {
4114        if let Some(pruned) =
4115            prune_feature_flag_tree(child, effective.as_ref(), policy, prefix, registry)
4116        {
4117            kept_groups.push(pruned);
4118        }
4119    }
4120    group.groups = kept_groups;
4121
4122    let mut kept_commands = Vec::with_capacity(group.commands.len());
4123    for command in std::mem::take(&mut group.commands) {
4124        prefix.push(command.spec.name.clone());
4125        let command_effective = command
4126            .spec
4127            .feature_flag
4128            .clone()
4129            .or_else(|| effective.clone());
4130        let visible =
4131            record_and_check_visibility(command_effective.as_ref(), policy, prefix, registry);
4132        prefix.pop();
4133        if visible {
4134            kept_commands.push(command);
4135        }
4136    }
4137    group.commands = kept_commands;
4138
4139    prefix.pop();
4140
4141    if group.commands.is_empty() && group.groups.is_empty() {
4142        None
4143    } else {
4144        Some(group)
4145    }
4146}
4147
4148/// Records `effective` at the current `prefix` path into `registry` (only
4149/// when it names a flag key — the implicit Ga default is not recorded) and
4150/// returns whether the node is visible under `policy`.
4151fn record_and_check_visibility(
4152    effective: Option<&FeatureFlag>,
4153    policy: &FlagPolicy,
4154    prefix: &[String],
4155    registry: &mut FlagRegistry,
4156) -> bool {
4157    let Some(flag) = effective else {
4158        return true;
4159    };
4160    let visible = policy.visible(Some(flag.key.as_str()), flag.stage);
4161    registry.record(FlagEntry {
4162        path: prefix.join(":"),
4163        key: flag.key.clone(),
4164        stage: flag.stage,
4165        visible,
4166    });
4167    visible
4168}
4169
4170fn register_runtime_group_metadata(
4171    group: &RuntimeGroupSpec,
4172    prefix: &mut Vec<String>,
4173    schemas: &mut SchemaRegistry,
4174    views: &mut HumanViewRegistry,
4175) {
4176    prefix.push(group.group.name.clone());
4177    for child_group in &group.groups {
4178        register_runtime_group_metadata(child_group, prefix, schemas, views);
4179    }
4180    for child in &group.commands {
4181        prefix.push(child.spec.name.clone());
4182        let command_path = prefix.join(":");
4183        register_command_schema(&child.spec, &command_path, schemas);
4184        // An inline `with_view` is registered under the command's own path; the
4185        // dispatch references it by that path. A `with_view_id` takes precedence
4186        // (dispatch uses it instead), so skip the inline registration when one is
4187        // set — registering it would leave an unused entry. Shared views are
4188        // registered separately by the module/CLI.
4189        if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
4190            views.register(HumanViewDef::new(
4191                command_path,
4192                child.spec.view_columns.clone(),
4193            ));
4194        }
4195        prefix.pop();
4196    }
4197    prefix.pop();
4198}
4199
4200fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
4201    if let Some(schema) = &spec.output_schema {
4202        schemas.register_info(command_path.to_owned(), schema.clone());
4203    }
4204}
4205
4206fn runtime_group_clap_command_with_schema_help(
4207    group: &RuntimeGroupSpec,
4208    prefix: &mut Vec<String>,
4209    schemas: &SchemaRegistry,
4210) -> Command {
4211    let mut command = group_clap_command_without_children(&group.group);
4212    prefix.push(group.group.name.clone());
4213    for child_group in &group.groups {
4214        command = command.subcommand(runtime_group_clap_command_with_schema_help(
4215            child_group,
4216            prefix,
4217            schemas,
4218        ));
4219    }
4220    for child in &group.commands {
4221        prefix.push(child.spec.name.clone());
4222        let command_path = prefix.join(":");
4223        command = command.subcommand(command_clap_command_with_schema_help(
4224            &child.spec,
4225            &command_path,
4226            schemas,
4227        ));
4228        prefix.pop();
4229    }
4230    prefix.pop();
4231    command
4232}
4233
4234fn group_clap_command_without_children(group: &GroupSpec) -> Command {
4235    let mut command = Command::new(group.name.clone())
4236        .about(group.short.clone())
4237        .help_template(GROUP_HELP_TEMPLATE);
4238    if let Some(long) = &group.long
4239        && !long.is_empty()
4240    {
4241        command = command.long_about(long.clone());
4242    }
4243    for alias in &group.aliases {
4244        command = command.alias(alias.clone());
4245    }
4246    if group.hidden {
4247        command = command.hide(true);
4248    }
4249    command
4250}
4251
4252fn command_clap_command_with_schema_help(
4253    spec: &CommandSpec,
4254    command_path: &str,
4255    schemas: &SchemaRegistry,
4256) -> Command {
4257    debug_assert!(
4258        !(spec.raw_output && spec.pagination.is_some()),
4259        "command {:?} sets both raw_output and with_pagination; a single verbatim string \
4260         has no pages, so the two are mutually exclusive",
4261        spec.name
4262    );
4263    let mut command = spec.clap_command();
4264    command = apply_dry_run_visibility(command, spec);
4265    command = apply_pagination_args(command, spec);
4266    let schema = schemas.get_by_path(command_path);
4267    let default_fields = default_field_names(spec);
4268    command = apply_fields_arg(
4269        command,
4270        spec,
4271        schema.as_ref().map(|schema| schema.fields.as_slice()),
4272        &default_fields,
4273    );
4274    command = apply_output_format_visibility(command, spec);
4275    let filter_expr_fields = schema
4276        .as_ref()
4277        .map_or(&[][..], |schema| schema.fields.as_slice());
4278    apply_filter_and_expr_examples(command, spec, filter_expr_fields)
4279}
4280
4281/// Hides this command's inherited `--output` flag when it opted into
4282/// [`CommandSpec::raw_output`].
4283fn apply_output_format_visibility(command: Command, spec: &CommandSpec) -> Command {
4284    if !spec.raw_output {
4285        return command;
4286    }
4287    use std::io::IsTerminal;
4288    command.arg(
4289        Arg::new("output")
4290            .long("output")
4291            .short('o')
4292            .value_name("FORMAT")
4293            .default_value(if std::io::stdout().is_terminal() {
4294                "human"
4295            } else {
4296                "json"
4297            })
4298            .conflicts_with_all(["json", "toon", "human"])
4299            .display_order(crate::flags::global_flag_order::OUTPUT)
4300            .hide(true)
4301            .help("Ignored — this command always prints raw text"),
4302    )
4303}
4304
4305/// Hides this command's inherited `--dry-run` flag when the command isn't
4306/// mutating (per [`CommandSpec::metadata`]'s `dry_run_prompt` — mirrored
4307/// here rather than reused, since that method returns the broader
4308/// [`CommandMeta`], not this one bool). `--dry-run` only ever does anything
4309/// for a command that opted in via `.mutates(true)`/`.with_tier(...)` (see
4310/// `Middleware::render_envelope`'s `meta.dry_run_prompt` gate), so showing
4311/// it on every other command is noise. The override still parses `--dry-run`
4312/// identically (same value parser, same defaults) in case a caller passes
4313/// it anyway — hidden only changes what `--help` shows, never behavior.
4314fn apply_dry_run_visibility(command: Command, spec: &CommandSpec) -> Command {
4315    let mutates = spec.mutates || spec.tier.is_some_and(crate::Tier::is_mutating);
4316    if mutates {
4317        return command;
4318    }
4319    command.arg(
4320        Arg::new("dry-run")
4321            .long("dry-run")
4322            .num_args(0..=1)
4323            .require_equals(true)
4324            .default_missing_value("true")
4325            .default_value("false")
4326            .value_parser(crate::flags::compat_bool_value_parser())
4327            .display_order(crate::flags::global_flag_order::DRY_RUN)
4328            .hide(true)
4329            .help("Preview mutations without executing"),
4330    )
4331}
4332
4333/// Registers `--limit`/`--offset` on this command's own `Command` when its
4334/// spec opted in via [`CommandSpec::with_pagination`], and leaves the command
4335/// untouched otherwise so a non-paginating command never sees those flags —
4336/// in `--help` or on its command line. See [`flags::apply_pagination_args`].
4337fn apply_pagination_args(command: Command, spec: &CommandSpec) -> Command {
4338    let Some(pagination) = spec.pagination else {
4339        return command;
4340    };
4341    crate::flags::apply_pagination_args(command, pagination.default_limit, pagination.max_limit)
4342}
4343
4344/// Splits a command's raw `default_fields` string into individual field
4345/// names, dropping the `all`/`*` sentinels that mean "every field" rather
4346/// than naming a real field.
4347fn default_field_names(spec: &CommandSpec) -> Vec<&str> {
4348    spec.default_fields
4349        .as_deref()
4350        .map(|fields| {
4351            fields
4352                .split(',')
4353                .map(str::trim)
4354                .filter(|field| !field.is_empty() && *field != "all" && *field != "*")
4355                .collect()
4356        })
4357        .unwrap_or_default()
4358}
4359
4360/// Overrides this command's `--fields` flag with everything specific to this
4361/// command: its own `default_fields` as a native clap default value (so
4362/// `--help` shows `[default: ...]` on the flag itself, the same way
4363/// `--dry-run` shows `[default: false]`), and, when a schema is registered,
4364/// the output-field summary table appended to the flag's own help text
4365/// instead of the command's description — a long field table there used to
4366/// push `Usage:` far down the page. Global args apply to every subcommand,
4367/// but a subcommand-local arg of the same name takes precedence, so this
4368/// only affects the one command being built here.
4369fn apply_fields_arg(
4370    command: Command,
4371    spec: &CommandSpec,
4372    schema_fields: Option<&[FieldInfo]>,
4373    default_fields: &[&str],
4374) -> Command {
4375    if spec.raw_output {
4376        return command.arg(
4377            Arg::new("fields")
4378                .long("fields")
4379                .value_name("FIELDS")
4380                .display_order(crate::flags::global_flag_order::FIELDS)
4381                .hide(true)
4382                .help("Ignored — this command always prints raw text"),
4383        );
4384    }
4385    let default_value = spec
4386        .default_fields
4387        .as_deref()
4388        .filter(|fields| !fields.is_empty());
4389    let table = schema_fields
4390        .filter(|fields| !fields.is_empty())
4391        .map(|fields| format_help_section(fields, default_fields));
4392    if default_value.is_none() && table.is_none() {
4393        return command;
4394    }
4395
4396    let mut help = String::from(
4397        "Comma-separated fields to include in output (use 'all' or '*' for everything)",
4398    );
4399    if let Some(table) = &table {
4400        help.push_str("\n\n");
4401        help.push_str(table.trim_end());
4402    }
4403
4404    let mut arg = Arg::new("fields")
4405        .long("fields")
4406        .value_name("FIELDS")
4407        // Must match `global_flag_order::FIELDS` — this re-registers the
4408        // same flag with contextual help, not a new one, and needs to keep
4409        // its place among the other global flags rather than falling back
4410        // to this subcommand's own low, command-specific counter value.
4411        .display_order(crate::flags::global_flag_order::FIELDS)
4412        .help(help);
4413    if let Some(default_value) = default_value {
4414        arg = arg.default_value(default_value.to_owned());
4415    }
4416    command.arg(arg)
4417}
4418
4419/// Overrides this command's `--filter` and `--expr` flags with help text
4420/// carrying usage examples built from its own output fields, so `--help`
4421/// shows them right under the flag instead of in a separate "Filter
4422/// examples:"/"Expr examples:" section disconnected from the flags they
4423/// demonstrate. Mirrors [`apply_fields_arg`]: a subcommand-local arg of the
4424/// same name shadows the framework's global one, and must carry the same
4425/// `global_flag_order` value as that global one for the same reason.
4426fn apply_filter_and_expr_examples(
4427    mut command: Command,
4428    spec: &CommandSpec,
4429    fields: &[FieldInfo],
4430) -> Command {
4431    if spec.raw_output {
4432        return command
4433            .arg(
4434                Arg::new("filter")
4435                    .long("filter")
4436                    .value_name("EXPR")
4437                    .display_order(crate::flags::global_flag_order::FILTER)
4438                    .hide(true)
4439                    .help("Ignored — this command always prints raw text"),
4440            )
4441            .arg(
4442                Arg::new("expr")
4443                    .long("expr")
4444                    .value_name("EXPR")
4445                    .display_order(crate::flags::global_flag_order::EXPR)
4446                    .hide(true)
4447                    .help("Ignored — this command always prints raw text"),
4448            );
4449    }
4450    if fields.is_empty() {
4451        return command;
4452    }
4453    let first_string = fields
4454        .iter()
4455        .find(|field| field.field_type == "string")
4456        .map(|field| field.name.as_str());
4457    let first_bool = fields
4458        .iter()
4459        .find(|field| field.field_type == "bool")
4460        .map(|field| field.name.as_str());
4461
4462    if first_string.is_some() || first_bool.is_some() {
4463        let mut help = String::from("Per-item JMESPath predicate for list data");
4464        if let Some(name) = first_string {
4465            help.push_str(&format!("\ne.g. --filter \"contains({name}, 'example')\""));
4466        }
4467        if let Some(name) = first_bool {
4468            help.push_str(&format!("\ne.g. --filter '{name}'"));
4469        }
4470        command = command.arg(
4471            Arg::new("filter")
4472                .long("filter")
4473                .value_name("EXPR")
4474                .display_order(crate::flags::global_flag_order::FILTER)
4475                .help(help),
4476        );
4477    }
4478
4479    let mut expr_help = String::from("JMESPath query applied to the whole result");
4480    expr_help.push_str("\ne.g. --expr 'length(@)'");
4481    if let Some(name) = first_string {
4482        expr_help.push_str(&format!("\ne.g. --expr '[].{name}'"));
4483    }
4484    command.arg(
4485        Arg::new("expr")
4486            .long("expr")
4487            .value_name("EXPR")
4488            .display_order(crate::flags::global_flag_order::EXPR)
4489            .help(expr_help),
4490    )
4491}
4492
4493fn process_exit_code(code: i32) -> ExitCode {
4494    if code == 0 {
4495        return ExitCode::SUCCESS;
4496    }
4497    match u8::try_from(code) {
4498        Ok(code) if code != 0 => ExitCode::from(code),
4499        Ok(_) | Err(_) => ExitCode::from(1),
4500    }
4501}
4502
4503async fn run_streaming_command(
4504    middleware: &Middleware,
4505    request: MiddlewareRequest<'_>,
4506    raw_matches: Arc<ArgMatches>,
4507    streaming_handler: crate::command::StreamingCommandHandler,
4508) -> Result<CliRunOutput> {
4509    use tokio::{io::AsyncWriteExt, sync::mpsc};
4510
4511    let args_for_handler = request.args.clone();
4512    let user_args_for_handler = request.user_args.clone();
4513    let handler_path = request.command_path.to_owned();
4514    let middleware_for_handler = middleware.clone();
4515    let raw_matches_for_handler = raw_matches;
4516
4517    let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
4518    let sender = StreamSender(tx);
4519
4520    // Drain the channel concurrently so the handler's sends don't stall
4521    // while the writer flushes to stdout. If stdout is under backpressure
4522    // the bounded channel can still fill and the handler will await send.
4523    let writer = tokio::spawn(async move {
4524        let mut stdout = tokio::io::stdout();
4525        while let Some(event) = rx.recv().await {
4526            let Ok(line) = serde_json::to_string(&event) else {
4527                continue;
4528            };
4529            if stdout.write_all(line.as_bytes()).await.is_err()
4530                || stdout.write_all(b"\n").await.is_err()
4531                || stdout.flush().await.is_err()
4532            {
4533                break;
4534            }
4535        }
4536    });
4537
4538    let output = middleware
4539        .run(request, async move |credential| {
4540            streaming_handler(
4541                CommandContext {
4542                    credential,
4543                    args: args_for_handler,
4544                    user_args: user_args_for_handler,
4545                    command_path: handler_path,
4546                    middleware: middleware_for_handler,
4547                    raw_matches: raw_matches_for_handler,
4548                },
4549                sender,
4550            )
4551            .await?;
4552            Ok(crate::CommandResult::new(serde_json::Value::Null))
4553        })
4554        .await;
4555
4556    // Handler has completed; its sender is dropped, which closes the channel.
4557    // Wait for the writer task to flush all remaining events.
4558    let _write_result = writer.await;
4559
4560    match output {
4561        Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
4562            exit_code: 0,
4563            rendered: String::new(),
4564        }),
4565        Ok(out) => Ok(out.into()),
4566        Err(err) => Ok(CliRunOutput {
4567            exit_code: exit_code_for_error(&err),
4568            rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
4569        }),
4570    }
4571}
4572
4573#[cfg(test)]
4574mod user_agent_tests {
4575    use super::*;
4576
4577    #[test]
4578    fn user_agent_string_derives_name_and_version_by_default() {
4579        let config =
4580            CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3"));
4581        assert_eq!(config.user_agent_string(), "gdx/1.2.3");
4582    }
4583
4584    #[test]
4585    fn user_agent_string_prefers_explicit_override() {
4586        let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx")
4587            .with_build(BuildInfo::new("1.2.3"))
4588            .with_user_agent("gdx-cli/9.9 (custom)");
4589        assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)");
4590    }
4591
4592    #[test]
4593    fn user_agent_string_omits_version_when_absent() {
4594        let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx");
4595        assert_eq!(config.user_agent_string(), "gdx");
4596    }
4597
4598    #[test]
4599    fn install_default_user_agent_publishes_config_value() {
4600        let _guard = crate::transport::client::UA_TEST_LOCK
4601            .lock()
4602            .unwrap_or_else(std::sync::PoisonError::into_inner);
4603        let _restore = crate::transport::client::RestoreDefaultUserAgent;
4604        crate::transport::set_default_user_agent("cli/dev");
4605        let cli = Cli::new(
4606            CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
4607        );
4608        cli.install_default_user_agent();
4609        assert_eq!(
4610            crate::transport::client::default_user_agent(),
4611            "uatest/4.5.6"
4612        );
4613    }
4614
4615    #[test]
4616    fn install_debug_transport_logger_tracks_the_debug_pattern() {
4617        // Asserts on `debug_transport_logger_for`'s decision directly rather
4618        // than publishing to and reading back the process-wide default
4619        // logger, which `Cli::run` republishes on every call — including the
4620        // many unrelated tests that call `cli.run(...)` with no `--debug`
4621        // flag and would otherwise race with this assertion.
4622
4623        // `transport` selected -> an active (enabled) logger is built.
4624        assert!(debug_transport_logger_for("transport", &[]).enabled());
4625
4626        // Wildcard with transport excluded -> a disabled (noop) logger.
4627        assert!(!debug_transport_logger_for("*,-transport", &[]).enabled());
4628
4629        // Empty pattern -> disabled (noop).
4630        assert!(!debug_transport_logger_for("", &[]).enabled());
4631    }
4632}
4633
4634#[cfg(test)]
4635mod env_config_tests {
4636    use super::*;
4637
4638    #[test]
4639    fn with_environments_stores_shared_arc_with_consumer_app_id() {
4640        // The consumer sets app_id on the Environments before sharing the Arc;
4641        // CliConfig stores it as-is, so the file path resolves only because the
4642        // consumer stamped the matching app_id (not because the engine did).
4643        let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new(
4644            crate::environments::Environments::new("prod")
4645                .with_app_id("gddy")
4646                .with_config_file(true),
4647        ));
4648        let envs = cfg.environments.as_ref().expect("environments set");
4649        assert!(envs.config_file_path().is_some());
4650    }
4651
4652    #[tokio::test]
4653    async fn env_flag_overrides_default_and_reaches_middleware_env() {
4654        use crate::{CommandResult, CommandSpec, RuntimeCommandSpec};
4655        use serde_json::json;
4656        let mut cli = Cli::new(
4657            CliConfig::new("envtest", "Env test", "envtest")
4658                .with_environments(Arc::new(
4659                    crate::environments::Environments::new("prod")
4660                        .with_environment("prod", crate::environments::EnvTable::new())
4661                        .with_environment("ote", crate::environments::EnvTable::new()),
4662                ))
4663                .with_startup_args(Vec::<&str>::new()),
4664        );
4665        cli.add_command(RuntimeCommandSpec::new_with_context(
4666            CommandSpec::new("whichenv", "echo env").no_auth(true),
4667            async |ctx| {
4668                Ok(CommandResult::new(
4669                    json!({ "env": ctx.environment()?.name().to_owned() }),
4670                ))
4671            },
4672        ));
4673        let out = cli
4674            .run(["envtest", "whichenv", "--env", "ote", "--output", "json"])
4675            .await;
4676        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
4677        assert!(out.rendered.contains("\"env\""));
4678        assert!(out.rendered.contains("ote"));
4679    }
4680
4681    #[tokio::test]
4682    async fn unknown_env_flag_produces_error_envelope() {
4683        let cli = Cli::new(
4684            CliConfig::new("envtest2", "Env test", "envtest2")
4685                .with_environments(Arc::new(
4686                    crate::environments::Environments::new("prod")
4687                        .with_environment("prod", crate::environments::EnvTable::new()),
4688                ))
4689                .with_startup_args(Vec::<&str>::new()),
4690        );
4691        let out = cli.run(["envtest2", "tree", "--env", "nope"]).await;
4692        assert_ne!(out.exit_code, 0);
4693        assert!(out.rendered.contains("nope"));
4694    }
4695}
4696
4697#[cfg(test)]
4698mod prescan_env_flag_tests {
4699    use super::*;
4700
4701    fn argv(args: &[&str]) -> impl Iterator<Item = String> {
4702        args.iter()
4703            .map(|s| s.to_string())
4704            .collect::<Vec<_>>()
4705            .into_iter()
4706    }
4707
4708    #[test]
4709    fn finds_space_separated_value() {
4710        assert_eq!(
4711            prescan_env_flag(argv(&["--dry-run", "--env", "dev", "list"])),
4712            Some("dev".to_owned())
4713        );
4714    }
4715
4716    #[test]
4717    fn finds_equals_separated_value() {
4718        assert_eq!(
4719            prescan_env_flag(argv(&["--env=dev", "list"])),
4720            Some("dev".to_owned())
4721        );
4722    }
4723
4724    #[test]
4725    fn is_none_without_the_flag() {
4726        assert_eq!(prescan_env_flag(argv(&["env", "list"])), None);
4727    }
4728
4729    #[test]
4730    fn trailing_env_flag_with_no_value_is_none() {
4731        assert_eq!(prescan_env_flag(argv(&["--env"])), None);
4732    }
4733
4734    #[test]
4735    fn keeps_the_last_of_multiple_occurrences() {
4736        // A global `--env` and a command-local one sharing the same arg id
4737        // can both appear (e.g. `app --env bar sub --env foo ...`); clap
4738        // resolves the *last* one as effective, so this scan must too.
4739        assert_eq!(
4740            prescan_env_flag(argv(&["--env", "bar", "sub", "cmd", "--env", "foo", "arg"])),
4741            Some("foo".to_owned())
4742        );
4743    }
4744
4745    #[test]
4746    fn ignores_an_empty_equals_value() {
4747        assert_eq!(prescan_env_flag(argv(&["--env="])), None);
4748    }
4749
4750    #[test]
4751    fn empty_occurrence_does_not_clobber_an_earlier_real_value() {
4752        assert_eq!(
4753            prescan_env_flag(argv(&["--env", "dev", "--env="])),
4754            Some("dev".to_owned())
4755        );
4756    }
4757
4758    #[test]
4759    fn space_separated_value_starting_with_dash_is_not_a_value() {
4760        // clap rejects `--env --dry-run` outright ("a value is required for
4761        // '--env <ENV>' but none was supplied") rather than treating
4762        // `--dry-run` as the value; this scan must agree.
4763        assert_eq!(prescan_env_flag(argv(&["--env", "--dry-run"])), None);
4764    }
4765
4766    #[test]
4767    fn equals_form_accepts_a_value_starting_with_dash() {
4768        // `--env=-foo` is unambiguous (unlike the space-separated form) and
4769        // still accepted, matching clap's own disambiguation rule.
4770        assert_eq!(
4771            prescan_env_flag(argv(&["--env=-foo"])),
4772            Some("-foo".to_owned())
4773        );
4774    }
4775
4776    #[test]
4777    fn stops_at_the_end_of_options_sentinel() {
4778        // Everything after a bare `--` is positional to clap, never a flag —
4779        // `app cmd -- --env dev` must not be read as a real `--env` override.
4780        assert_eq!(prescan_env_flag(argv(&["cmd", "--", "--env", "dev"])), None);
4781    }
4782
4783    #[test]
4784    fn a_real_flag_before_the_sentinel_is_still_found() {
4785        assert_eq!(
4786            prescan_env_flag(argv(&["--env", "dev", "--", "positional"])),
4787            Some("dev".to_owned())
4788        );
4789    }
4790}
4791
4792#[cfg(test)]
4793mod feature_flag_pruning_tests {
4794    use super::*;
4795    use crate::CommandResult;
4796
4797    fn trivial_command(name: &str) -> RuntimeCommandSpec {
4798        RuntimeCommandSpec::new(
4799            CommandSpec::new(name, "short").no_auth(true),
4800            async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
4801        )
4802    }
4803
4804    fn flagged_command(name: &str, key: &str, stage: Stage) -> RuntimeCommandSpec {
4805        let mut command = trivial_command(name);
4806        command.spec = command.spec.with_feature_flag(key, stage);
4807        command
4808    }
4809
4810    fn empty_policy() -> FlagPolicy {
4811        FlagPolicy::default()
4812    }
4813
4814    #[test]
4815    fn no_flags_anywhere_keeps_everything() {
4816        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4817            .with_command(trivial_command("a"))
4818            .with_command(trivial_command("b"))
4819            .with_group(
4820                RuntimeGroupSpec::new(GroupSpec::new("child", "short"))
4821                    .with_command(trivial_command("c")),
4822            );
4823
4824        let mut prefix = Vec::new();
4825        let mut registry = FlagRegistry::new();
4826        let pruned =
4827            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
4828
4829        let pruned = pruned.expect("unflagged tree should never be dropped");
4830        assert_eq!(pruned.commands.len(), 2);
4831        assert_eq!(pruned.groups.len(), 1);
4832        assert_eq!(pruned.groups[0].commands.len(), 1);
4833        assert!(registry.entries().is_empty());
4834    }
4835
4836    #[test]
4837    fn experimental_command_is_pruned_sibling_is_not() {
4838        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4839            .with_command(flagged_command("gated", "gated-flag", Stage::Experimental))
4840            .with_command(trivial_command("sibling"));
4841
4842        let mut prefix = Vec::new();
4843        let mut registry = FlagRegistry::new();
4844        let pruned =
4845            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry)
4846                .expect("group still has a visible command left");
4847
4848        assert_eq!(pruned.commands.len(), 1);
4849        assert_eq!(pruned.commands[0].spec.name, "sibling");
4850
4851        let entries = registry.entries();
4852        assert_eq!(entries.len(), 1);
4853        assert_eq!(entries[0].path, "root:gated");
4854        assert_eq!(entries[0].key, "gated-flag");
4855        assert!(!entries[0].visible);
4856    }
4857
4858    #[test]
4859    fn beta_group_pruned_under_ga_min_stage_kept_under_beta_min_stage() {
4860        let build_tree = || {
4861            RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4862                .with_command(trivial_command("keep-me"))
4863                .with_group(
4864                    RuntimeGroupSpec::new(
4865                        GroupSpec::new("flagged-group", "short")
4866                            .with_feature_flag("group-flag", Stage::Beta),
4867                    )
4868                    .with_command(trivial_command("cmd-default"))
4869                    .with_command(flagged_command(
4870                        "cmd-ga",
4871                        "cmd-ga-flag",
4872                        Stage::Ga,
4873                    )),
4874                )
4875        };
4876
4877        // Default policy (min_stage: Ga) drops the whole Beta subtree, including
4878        // both its undeclared and explicitly-Ga-declared children, because the
4879        // ancestor group itself already fails visibility before children are
4880        // even visited.
4881        let mut prefix = Vec::new();
4882        let mut registry = FlagRegistry::new();
4883        let pruned = prune_feature_flag_tree(
4884            build_tree(),
4885            None,
4886            &empty_policy(),
4887            &mut prefix,
4888            &mut registry,
4889        )
4890        .expect("root keeps its unflagged sibling command");
4891        assert!(pruned.groups.is_empty());
4892        assert_eq!(pruned.commands.len(), 1);
4893        assert_eq!(pruned.commands[0].spec.name, "keep-me");
4894        // Only the group itself was recorded; its children were never visited.
4895        assert_eq!(registry.entries().len(), 1);
4896        assert_eq!(registry.entries()[0].path, "root:flagged-group");
4897        assert!(!registry.entries()[0].visible);
4898
4899        // A Beta-permissive policy keeps the group and both of its children.
4900        let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
4901        let mut prefix = Vec::new();
4902        let mut registry = FlagRegistry::new();
4903        let pruned =
4904            prune_feature_flag_tree(build_tree(), None, &policy, &mut prefix, &mut registry)
4905                .expect("root is kept");
4906        assert_eq!(pruned.groups.len(), 1);
4907        assert_eq!(pruned.groups[0].commands.len(), 2);
4908        assert!(registry.entries().iter().all(|entry| entry.visible));
4909    }
4910
4911    #[test]
4912    fn ancestor_invisibility_short_circuits_before_children_are_visited() {
4913        // The child declares its own, more permissive Ga flag under a distinct
4914        // key. Per the documented pruning semantics, an invisible ancestor drops
4915        // its whole subtree unconditionally: the child's own flag is never even
4916        // considered, because `prune_feature_flag_tree` returns `None` for the
4917        // ancestor as soon as its own effective flag fails visibility, before
4918        // recursing into commands or subgroups at all.
4919        let group = RuntimeGroupSpec::new(
4920            GroupSpec::new("ancestor", "short").with_feature_flag("ancestor-flag", Stage::Beta),
4921        )
4922        .with_command(flagged_command("child", "child-flag", Stage::Ga));
4923
4924        let mut prefix = Vec::new();
4925        let mut registry = FlagRegistry::new();
4926        let pruned =
4927            prune_feature_flag_tree(group, None, &empty_policy(), &mut prefix, &mut registry);
4928
4929        assert!(
4930            pruned.is_none(),
4931            "invisible ancestor drops its whole subtree"
4932        );
4933        // The child was never visited, so nothing about it was recorded.
4934        assert_eq!(registry.entries().len(), 1);
4935        assert_eq!(registry.entries()[0].path, "ancestor");
4936        assert!(registry.by_key("child-flag").is_empty());
4937    }
4938
4939    #[test]
4940    fn cascading_inherited_flag_key_and_stage_reach_unflagged_descendants() {
4941        // Simulates a module-level flag with no per-group/per-command
4942        // declaration anywhere below it: `inherited` here stands in for
4943        // `Module::feature_flag`, exactly as `add_module_group_inner` passes it.
4944        let module_flag = FeatureFlag::new("module-flag", Stage::Beta);
4945        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4946            .with_command(trivial_command("unflagged-child"));
4947
4948        let policy = FlagPolicy::default().with_min_stage(Stage::Beta);
4949        let mut prefix = Vec::new();
4950        let mut registry = FlagRegistry::new();
4951        let pruned = prune_feature_flag_tree(
4952            group,
4953            Some(&module_flag),
4954            &policy,
4955            &mut prefix,
4956            &mut registry,
4957        )
4958        .expect("Beta-permissive policy keeps a Beta-inherited tree");
4959        assert_eq!(pruned.commands.len(), 1);
4960
4961        // Both the group and the descendant command recorded the *same*
4962        // inherited key/stage, proving real cascading rather than an implicit
4963        // Ga default at either level.
4964        let entries = registry.entries();
4965        assert_eq!(entries.len(), 2);
4966        assert_eq!(entries[0].path, "root");
4967        assert_eq!(entries[0].key, "module-flag");
4968        assert_eq!(entries[0].stage, Stage::Beta);
4969        assert_eq!(entries[1].path, "root:unflagged-child");
4970        assert_eq!(entries[1].key, "module-flag");
4971        assert_eq!(entries[1].stage, Stage::Beta);
4972
4973        // Under the default (Ga) policy the same inherited Beta flag makes the
4974        // whole tree invisible together, since the group and its unflagged
4975        // child resolve to the identical effective flag.
4976        let mut prefix = Vec::new();
4977        let mut registry = FlagRegistry::new();
4978        let pruned = prune_feature_flag_tree(
4979            RuntimeGroupSpec::new(GroupSpec::new("root", "short"))
4980                .with_command(trivial_command("unflagged-child")),
4981            Some(&module_flag),
4982            &empty_policy(),
4983            &mut prefix,
4984            &mut registry,
4985        );
4986        assert!(pruned.is_none());
4987    }
4988
4989    #[test]
4990    fn registry_records_only_named_flags_not_unflagged_nodes() {
4991        let group = RuntimeGroupSpec::new(GroupSpec::new("root", "short")).with_group(
4992            RuntimeGroupSpec::new(
4993                GroupSpec::new("g", "short").with_feature_flag("g-flag", Stage::Beta),
4994            )
4995            .with_command(trivial_command("c1"))
4996            .with_command(flagged_command("c2", "c2-flag", Stage::Ga)),
4997        );
4998
4999        // Permissive enough that nothing is pruned, so every node is visited.
5000        let policy = FlagPolicy::default().with_min_stage(Stage::Experimental);
5001        let mut prefix = Vec::new();
5002        let mut registry = FlagRegistry::new();
5003        let pruned = prune_feature_flag_tree(group, None, &policy, &mut prefix, &mut registry)
5004            .expect("permissive policy keeps everything");
5005        assert_eq!(pruned.groups[0].commands.len(), 2);
5006
5007        let entries = registry.entries();
5008        assert_eq!(entries.len(), 3, "root has no flag and is not recorded");
5009        assert_eq!(entries[0].path, "root:g");
5010        assert_eq!(entries[0].key, "g-flag");
5011        assert_eq!(entries[1].path, "root:g:c1");
5012        assert_eq!(entries[1].key, "g-flag");
5013        assert_eq!(entries[1].stage, Stage::Beta);
5014        assert_eq!(entries[2].path, "root:g:c2");
5015        assert_eq!(entries[2].key, "c2-flag");
5016        assert_eq!(entries[2].stage, Stage::Ga);
5017        assert!(entries.iter().all(|entry| entry.visible));
5018    }
5019
5020    #[test]
5021    fn module_feature_flag_cascades_into_its_group_via_add_module() {
5022        // Regression test for the bug this task fixes: `add_module` used to
5023        // discard `module.feature_flag` entirely, so a module-level flag could
5024        // never reach its group/commands. `Module::new` returns a group with an
5025        // unflagged command; the module itself declares Experimental, and the
5026        // default (Ga) policy must prune the whole group away.
5027        let module = Module::new("Test Category", |_ctx| {
5028            RuntimeGroupSpec::new(GroupSpec::new("gated-mod", "short"))
5029                .with_command(trivial_command("list"))
5030        })
5031        .with_feature_flag("module-flag", Stage::Experimental);
5032
5033        let mut cli = Cli::new(CliConfig::new("modtest", "Module test", "modtest"));
5034        cli.add_module(module);
5035
5036        assert!(
5037            !cli.commands.contains_key("gated-mod:list"),
5038            "module-level Experimental flag should have pruned the whole group under the default Ga policy"
5039        );
5040        assert!(
5041            !has_subcommand(&cli.root, "gated-mod"),
5042            "the pruned group must not be mounted in the clap tree either"
5043        );
5044    }
5045
5046    #[test]
5047    fn module_feature_flag_keeps_group_when_policy_allows_it() {
5048        let module = Module::new("Test Category", |_ctx| {
5049            RuntimeGroupSpec::new(GroupSpec::new("gated-mod-2", "short"))
5050                .with_command(trivial_command("list"))
5051        })
5052        .with_feature_flag("module-flag-2", Stage::Experimental);
5053
5054        let mut cli = Cli::new(
5055            CliConfig::new("modtest2", "Module test", "modtest2")
5056                .with_min_stage(Stage::Experimental),
5057        );
5058        cli.add_module(module);
5059
5060        assert!(cli.commands.contains_key("gated-mod-2:list"));
5061        assert!(has_subcommand(&cli.root, "gated-mod-2"));
5062    }
5063
5064    #[test]
5065    fn active_environment_min_stage_loosens_consumer_level_policy() {
5066        // The CliConfig itself leaves min_stage at its Ga default, which would
5067        // normally prune this Experimental-flagged group. The active ("prod")
5068        // environment's compiled min_stage override should reach
5069        // `middleware.flag_policy` before pruning runs and keep it instead.
5070        let module = Module::new("Test Category", |_ctx| {
5071            RuntimeGroupSpec::new(GroupSpec::new("gated-mod-3", "short"))
5072                .with_command(trivial_command("list"))
5073        })
5074        .with_feature_flag("module-flag-3", Stage::Experimental);
5075
5076        let mut cli = Cli::new(
5077            CliConfig::new("modtest3", "Module test", "modtest3")
5078                .with_environments(Arc::new(
5079                    crate::environments::Environments::new("prod").with_environment(
5080                        "prod",
5081                        crate::environments::EnvTable::new().with("min_stage", "experimental"),
5082                    ),
5083                ))
5084                .with_startup_args(Vec::<&str>::new()),
5085        );
5086        cli.add_module(module);
5087
5088        assert!(cli.commands.contains_key("gated-mod-3:list"));
5089        assert!(has_subcommand(&cli.root, "gated-mod-3"));
5090    }
5091
5092    /// The direct proof of the startup `--env` prescan (see `Cli::new`):
5093    /// unlike [`active_environment_min_stage_loosens_consumer_level_policy`]
5094    /// (which exercises the *default* active environment), here "prod" is
5095    /// the default and carries no override, while "dev" loosens `min_stage`.
5096    /// A `--env dev` supplied via `with_startup_args` — standing in for real
5097    /// process argv — must be consulted before `add_module` prunes the tree,
5098    /// in the *same* construction, not just update `middleware.env` for a
5099    /// later run.
5100    #[test]
5101    fn startup_env_flag_reveals_beta_and_experimental_modules_for_the_named_env() {
5102        fn gated_module() -> Module {
5103            Module::new("Test Category", |_ctx| {
5104                RuntimeGroupSpec::new(GroupSpec::new("gated-mod-4", "short"))
5105                    .with_command(trivial_command("list"))
5106            })
5107            .with_feature_flag("module-flag-4", Stage::Experimental)
5108        }
5109        fn environments() -> Arc<crate::environments::Environments> {
5110            Arc::new(
5111                crate::environments::Environments::new("prod")
5112                    .with_environment("prod", crate::environments::EnvTable::new())
5113                    .with_environment(
5114                        "dev",
5115                        crate::environments::EnvTable::new().with("min_stage", "experimental"),
5116                    ),
5117            )
5118        }
5119
5120        let mut with_dev_flag = Cli::new(
5121            CliConfig::new("modtest4a", "Module test", "modtest4a")
5122                .with_environments(environments())
5123                .with_startup_args(["modtest4a", "--env", "dev"]),
5124        );
5125        with_dev_flag.add_module(gated_module());
5126        assert!(
5127            with_dev_flag.commands.contains_key("gated-mod-4:list"),
5128            "--env dev in startup_args should reveal the Experimental module"
5129        );
5130        assert!(has_subcommand(&with_dev_flag.root, "gated-mod-4"));
5131
5132        // Negative counterpart: with no `--env` at all, the default ("prod",
5133        // no override) still governs — nothing changed for the common case.
5134        let mut without_flag = Cli::new(
5135            CliConfig::new("modtest4b", "Module test", "modtest4b")
5136                .with_environments(environments())
5137                .with_startup_args(Vec::<&str>::new()),
5138        );
5139        without_flag.add_module(gated_module());
5140        assert!(
5141            !without_flag.commands.contains_key("gated-mod-4:list"),
5142            "without --env, the default env's Ga policy should still prune the module"
5143        );
5144        assert!(!has_subcommand(&without_flag.root, "gated-mod-4"));
5145    }
5146
5147    static GLOBAL_MIN_STAGE_ENV_LOCK: Mutex<()> = Mutex::new(());
5148
5149    /// RAII guard that restores (or removes) an env var on drop, even if a
5150    /// test panics.
5151    struct GlobalMinStageEnvGuard {
5152        key: &'static str,
5153        prev: Option<std::ffi::OsString>,
5154    }
5155    impl GlobalMinStageEnvGuard {
5156        /// Sets `key` to `value`. Caller must hold [`GLOBAL_MIN_STAGE_ENV_LOCK`]
5157        /// for the guard's entire lifetime.
5158        #[allow(unsafe_code)]
5159        fn set(key: &'static str, value: &str) -> Self {
5160            let prev = std::env::var_os(key);
5161            // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard
5162            // restores/removes on any exit incl. panic.
5163            unsafe { std::env::set_var(key, value) };
5164            Self { key, prev }
5165        }
5166
5167        /// Removes `key` (if set). Caller must hold
5168        /// [`GLOBAL_MIN_STAGE_ENV_LOCK`] for the guard's entire lifetime.
5169        #[allow(unsafe_code)]
5170        fn unset(key: &'static str) -> Self {
5171            let prev = std::env::var_os(key);
5172            // SAFETY: serialized by GLOBAL_MIN_STAGE_ENV_LOCK; guard restores
5173            // on any exit incl. panic.
5174            unsafe { std::env::remove_var(key) };
5175            Self { key, prev }
5176        }
5177    }
5178    impl Drop for GlobalMinStageEnvGuard {
5179        #[allow(unsafe_code)]
5180        fn drop(&mut self) {
5181            // SAFETY: test holds GLOBAL_MIN_STAGE_ENV_LOCK; restore/clean up
5182            // on any exit including panic.
5183            unsafe {
5184                match &self.prev {
5185                    Some(v) => std::env::set_var(self.key, v),
5186                    None => std::env::remove_var(self.key),
5187                }
5188            }
5189        }
5190    }
5191
5192    #[test]
5193    #[allow(unsafe_code)]
5194    fn global_min_stage_override_is_a_noop_when_unset() {
5195        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5196            .lock()
5197            .unwrap_or_else(std::sync::PoisonError::into_inner);
5198        const VAR: &str = "UNSET_MIN_STAGE_APP_MIN_STAGE";
5199        // Explicitly unset (and restored on drop) rather than assumed absent,
5200        // so the test is hermetic even if a developer/CI happens to have this
5201        // var set.
5202        let _guard = GlobalMinStageEnvGuard::unset(VAR);
5203
5204        assert_eq!(global_min_stage_override("unset-min-stage-app"), None);
5205    }
5206
5207    #[test]
5208    #[allow(unsafe_code)]
5209    fn global_min_stage_override_parses_a_valid_value() {
5210        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5211            .lock()
5212            .unwrap_or_else(std::sync::PoisonError::into_inner);
5213        const VAR: &str = "VALID_MIN_STAGE_APP_MIN_STAGE";
5214        let _guard = GlobalMinStageEnvGuard::set(VAR, "beta");
5215
5216        assert_eq!(
5217            global_min_stage_override("valid-min-stage-app"),
5218            Some(Stage::Beta)
5219        );
5220    }
5221
5222    #[test]
5223    #[allow(unsafe_code)]
5224    fn global_min_stage_override_ignores_a_malformed_value() {
5225        let _g = GLOBAL_MIN_STAGE_ENV_LOCK
5226            .lock()
5227            .unwrap_or_else(std::sync::PoisonError::into_inner);
5228        const VAR: &str = "BAD_MIN_STAGE_APP_MIN_STAGE";
5229        let _guard = GlobalMinStageEnvGuard::set(VAR, "nightly");
5230
5231        assert_eq!(global_min_stage_override("bad-min-stage-app"), None);
5232    }
5233}
5234
5235#[cfg(test)]
5236mod flags_command_tests {
5237    use super::*;
5238    use crate::CommandResult;
5239
5240    /// Builds a module with one flagged group containing one flagged (via
5241    /// inheritance) `list` command, so `flag_registry` has something to
5242    /// introspect once the module is mounted.
5243    fn flagged_module(group_name: &'static str, key: &'static str, stage: Stage) -> Module {
5244        Module::new("Test Category", move |_ctx| {
5245            RuntimeGroupSpec::new(GroupSpec::new(group_name, "short")).with_command(
5246                RuntimeCommandSpec::new(
5247                    CommandSpec::new("list", "short").no_auth(true),
5248                    async |_, _| Ok(CommandResult::new(serde_json::Value::Null)),
5249                ),
5250            )
5251        })
5252        .with_feature_flag(key, stage)
5253    }
5254
5255    #[tokio::test]
5256    async fn flags_list_reports_flagged_entries() {
5257        let mut cli = Cli::new(
5258            CliConfig::new("flagtest", "Flag test", "flagtest").with_min_stage(Stage::Beta),
5259        );
5260        cli.add_module(flagged_module("flagged-mod", "list-flag", Stage::Beta));
5261
5262        let out = cli
5263            .run(["flagtest", "flags", "list", "--output", "json"])
5264            .await;
5265        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5266        let rendered: serde_json::Value =
5267            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5268        let entries = rendered["data"].as_array().expect("data should be array");
5269        let command_entry = entries
5270            .iter()
5271            .find(|entry| entry["path"] == "flagged-mod:list")
5272            .expect("flagged command entry should be present");
5273        assert_eq!(command_entry["key"], "list-flag");
5274        assert_eq!(command_entry["stage"], "beta");
5275        assert_eq!(command_entry["visible"], true);
5276    }
5277
5278    #[tokio::test]
5279    async fn flags_info_returns_policy_and_entries_for_known_key() {
5280        let mut cli = Cli::new(
5281            CliConfig::new("flagtest2", "Flag test", "flagtest2").with_min_stage(Stage::Beta),
5282        );
5283        cli.add_module(flagged_module("flagged-mod-2", "info-flag", Stage::Beta));
5284
5285        let out = cli
5286            .run([
5287                "flagtest2",
5288                "flags",
5289                "info",
5290                "info-flag",
5291                "--output",
5292                "json",
5293            ])
5294            .await;
5295        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5296        let rendered: serde_json::Value =
5297            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5298        let data = &rendered["data"];
5299        assert_eq!(data["key"], "info-flag");
5300        assert_eq!(data["policy"]["min_stage"], "beta");
5301        assert!(data["policy"]["override"].is_null());
5302        let entries = data["entries"].as_array().expect("entries should be array");
5303        assert!(!entries.is_empty());
5304        assert!(entries.iter().any(|entry| {
5305            entry["path"] == "flagged-mod-2:list" && entry["decided_by"] == "min_stage"
5306        }));
5307    }
5308
5309    #[tokio::test]
5310    async fn flags_info_reports_override_decided_by() {
5311        // The module declares Experimental, which the default Ga policy would
5312        // normally hide; the override forces Ga instead, so the entries stay
5313        // visible even though `entry.stage` still reports the node's own
5314        // (Experimental) declaration, not the override.
5315        let mut cli = Cli::new(
5316            CliConfig::new("flagtest3", "Flag test", "flagtest3")
5317                .with_feature_override("override-flag", Stage::Ga),
5318        );
5319        cli.add_module(flagged_module(
5320            "flagged-mod-3",
5321            "override-flag",
5322            Stage::Experimental,
5323        ));
5324
5325        let out = cli
5326            .run([
5327                "flagtest3",
5328                "flags",
5329                "info",
5330                "override-flag",
5331                "--output",
5332                "json",
5333            ])
5334            .await;
5335        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
5336        let rendered: serde_json::Value =
5337            serde_json::from_str(&out.rendered).expect("stdout should contain json");
5338        let data = &rendered["data"];
5339        assert_eq!(data["policy"]["min_stage"], "ga");
5340        assert_eq!(data["policy"]["override"], "ga");
5341        let entries = data["entries"].as_array().expect("entries should be array");
5342        assert!(!entries.is_empty());
5343        assert!(
5344            entries
5345                .iter()
5346                .all(|entry| entry["decided_by"] == "override")
5347        );
5348        assert!(entries.iter().all(|entry| entry["visible"] == true));
5349        assert!(entries.iter().all(|entry| entry["stage"] == "experimental"));
5350    }
5351
5352    #[tokio::test]
5353    async fn flags_info_unknown_key_errors() {
5354        let cli = Cli::new(CliConfig::new("flagtest4", "Flag test", "flagtest4"));
5355
5356        let out = cli
5357            .run(["flagtest4", "flags", "info", "no-such-flag"])
5358            .await;
5359        assert_ne!(out.exit_code, 0);
5360        assert!(out.rendered.contains("no such flag"));
5361    }
5362}