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::{ArgMatches, Command};
17
18use crate::{
19    ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec,
20    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    flags::{
29        GlobalFlags, default_output_format, derive_bool_flags, derive_value_flags,
30        extract_command_path, extract_output_format, extract_search_query,
31        global_flags_from_matches, has_true_schema_flag, register_global_flags,
32    },
33    guide::guide_content,
34    module::{Module, ModuleContext},
35    output::{
36        HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry, format_help_section,
37        global_human_view_registry_snapshot, global_schema_registry_snapshot,
38    },
39    search::{SearchDocument, SearchIndex},
40};
41
42use builtins::{
43    completion_args, completion_command, guide_args, guide_command, help_args, help_command,
44};
45use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE};
46pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human};
47
48/// Build metadata shown by the root `--version` flag.
49#[derive(Clone, Debug, Default, Eq, PartialEq)]
50pub struct BuildInfo {
51    /// Semantic version or other release label.
52    pub version: String,
53    /// Optional source control commit identifier.
54    pub commit: Option<String>,
55    /// Optional build date string.
56    pub date: Option<String>,
57}
58
59impl BuildInfo {
60    /// Creates build metadata with only a version string.
61    #[must_use]
62    pub fn new(version: impl Into<String>) -> Self {
63        Self {
64            version: version.into(),
65            commit: None,
66            date: None,
67        }
68    }
69
70    /// Adds a commit identifier to the version string shown by `--version`.
71    #[must_use]
72    pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
73        self.commit = Some(commit.into());
74        self
75    }
76
77    /// Adds a build date to the version string shown by `--version`.
78    #[must_use]
79    pub fn with_date(mut self, date: impl Into<String>) -> Self {
80        self.date = Some(date.into());
81        self
82    }
83
84    /// Returns the rendered version string used by the root `--version` flag.
85    #[must_use]
86    pub fn version_string(&self) -> String {
87        let commit = self.commit.as_deref().unwrap_or_default();
88        let date = self.date.as_deref().unwrap_or_default();
89
90        if commit.is_empty() && date.is_empty() {
91            self.version.clone()
92        } else {
93            format!("{} (commit {commit}, built {date})", self.version)
94        }
95    }
96}
97
98/// Late dependency initializer run once before real command execution.
99pub type InitDeps = Arc<dyn Fn(&mut Middleware) -> Result<()> + Send + Sync>;
100/// Hook used to add application-specific global flags to the root `clap` command.
101pub type RegisterFlags = Arc<dyn Fn(Command) -> Command + Send + Sync>;
102/// Hook used to copy parsed application-specific flags into middleware.
103pub type ApplyFlags = Arc<dyn Fn(&ArgMatches, &mut Middleware) -> Result<()> + Send + Sync>;
104/// Hook run immediately before executable commands and built-ins.
105pub type PreRun =
106    Arc<dyn Fn(&mut Middleware, &str, &crate::middleware::ValueMap) -> Result<()> + Send + Sync>;
107/// Hook used to adjust command metadata globally before middleware executes.
108pub type ResolveMeta = Arc<dyn Fn(&str, CommandMeta) -> CommandMeta + Send + Sync>;
109/// Hook called after a CLI run completes.
110pub type OnShutdown = Arc<dyn Fn() + Send + Sync>;
111/// Hook that contributes extra root-scope `--search` documents.
112pub type ExtraSearchDocs = Arc<dyn Fn() -> Vec<SearchDocument> + Send + Sync>;
113/// Hook that supplies the suggested next actions shown when the CLI is invoked
114/// with no subcommand (bare root). The same actions drive a human "Next actions"
115/// section and the JSON discovery envelope.
116pub type RootNextActions = Arc<dyn Fn() -> Vec<NextAction> + Send + Sync>;
117
118/// Default name for the admin help category, under which the engine files the
119/// built-in `auth` command when a consumer does not override it via
120/// [`CliConfig::with_admin_category`].
121const DEFAULT_ADMIN_CATEGORY: &str = "Admin";
122
123/// Maximum number of chained `argv0` dispatch hand-offs before the engine
124/// refuses to recurse further. Real multi-call nesting is zero or one level;
125/// this bounds a pathologically long explicit `argv0 … argv0 …` chain so it
126/// errors cleanly instead of overflowing the stack.
127const MAX_ARGV0_DEPTH: usize = 16;
128
129/// How the engine behaves when invoked under a registered alternative `argv[0]`
130/// name (busybox/git-style multi-call dispatch).
131///
132/// A route is selected when the binary's `argv[0]` basename — or the name given
133/// to the hidden `argv0` command — matches a key registered via
134/// [`CliConfig::with_argv0_alias`] or [`CliConfig::with_argv0_personality`]. An
135/// `argv[0]` that matches no route falls through to the default CLI, so existing
136/// applications that register no routes are unaffected.
137///
138/// Non-exhaustive: more route kinds may be added in future releases. Register
139/// routes through the [`CliConfig`] builders rather than matching on variants.
140#[derive(Clone)]
141#[non_exhaustive]
142pub enum Argv0Route {
143    /// Rewrite the invocation into these canonical subcommand tokens and run it
144    /// through the normal command tree, with the real argument tail appended.
145    ///
146    /// For example, an `Alias(vec!["project".into(), "list".into()])` registered
147    /// under `pl` makes `pl --team x` behave exactly like `project list --team x`.
148    Alias(Vec<String>),
149    /// Run an entirely separate CLI application built from the returned
150    /// [`CliConfig`] (its own root name, commands, flags, and auth). The
151    /// configuration is built lazily, only when the route is actually dispatched,
152    /// so registering a personality costs nothing for invocations that never hit it.
153    Personality(Arc<dyn Fn() -> CliConfig + Send + Sync>),
154}
155
156impl std::fmt::Debug for Argv0Route {
157    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        match self {
159            Self::Alias(tokens) => formatter.debug_tuple("Alias").field(tokens).finish(),
160            Self::Personality(_) => formatter.write_str("Personality(..)"),
161        }
162    }
163}
164
165/// On-disk mechanism used by [`Cli::create_link`] to materialize an alternative
166/// `argv[0]` name so the binary can be invoked under it.
167///
168/// Installers pick the mechanism that suits the platform and environment;
169/// self-healing code can re-run [`Cli::create_link`] to restore a deleted link.
170///
171/// Non-exhaustive: more link mechanisms may be added in future releases.
172#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173#[non_exhaustive]
174pub enum Argv0LinkMethod {
175    /// A symbolic link to the target executable (`<name>` on Unix, `<name>.exe`
176    /// on Windows). On Windows this may require Developer Mode or elevation.
177    SoftLink,
178    /// A hard link to the target executable (`<name>` on Unix, `<name>.exe` on
179    /// Windows). The link must live on the same volume as the target.
180    HardLink,
181    /// A small shim script that forwards to the target via the `argv0` command:
182    /// a `<name>.cmd` batch file on Windows, or an executable `<name>` shell
183    /// script on Unix. Useful when links are unavailable or inconvenient.
184    Script,
185}
186
187/// Top-level subcommand names that are reserved by the engine and must not be
188/// used as module group names.  [`Cli::add_module_group`] rejects a group whose
189/// name matches a reserved name so the engine's built-in command always wins.
190pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 4] = ["help", "guide", "tree", "completion"];
191
192/// Declarative configuration for a CLI application.
193///
194/// Use [`CliConfig::new`] for the common path and chain `with_*` methods for
195/// modules, auth providers, guides, views, and lifecycle hooks. Direct struct
196/// literals remain available for advanced setup and tests.
197#[derive(Clone, Default)]
198pub struct CliConfig {
199    /// Root command name shown in usage output.
200    pub name: String,
201    /// One-line root command description.
202    pub short: String,
203    /// Optional longer root command description. Defaults to `short`.
204    pub long: Option<String>,
205    /// Version/build metadata for `--version`.
206    pub build: BuildInfo,
207    /// Application id stored in middleware and output metadata.
208    pub app_id: String,
209    /// Fallback auth provider when a command does not select one explicitly.
210    pub default_auth_provider: Option<String>,
211    /// Domain modules mounted under the root command.
212    pub modules: Vec<Module>,
213    /// Additional top-level runtime commands.
214    pub commands: Vec<RuntimeCommandSpec>,
215    /// Global guide entries mounted under `guide`.
216    pub guides: Vec<GuideEntry>,
217    /// Global human output views.
218    pub views: Vec<HumanViewDef>,
219    /// Providers registered before command execution starts.
220    pub auth_providers: Vec<Arc<dyn AuthProvider>>,
221    /// Optional override for the process-wide outbound User-Agent. When unset,
222    /// the engine derives `name/version` from this config. See
223    /// [`CliConfig::user_agent_string`].
224    pub user_agent: Option<String>,
225    /// Extra HTTP header names to redact in `--debug transport` output, on top
226    /// of the built-in sensitive set (`authorization`, `proxy-authorization`,
227    /// `cookie`, `set-cookie`, `x-api-key`). Set CLI-specific secret-bearing
228    /// headers here — e.g. a custom API-key header an auth injector adds.
229    /// Populate via [`CliConfig::with_redacted_debug_headers`].
230    pub redacted_debug_headers: Vec<String>,
231    /// Optional authorization gatekeeper injected into middleware.
232    pub authz: Option<Arc<dyn Authorizer>>,
233    /// Optional audit recorder injected into middleware.
234    pub auditor: Option<Arc<dyn Auditor>>,
235    /// Optional activity event sink injected into middleware.
236    pub activity: Option<Arc<dyn ActivityEmitter>>,
237    /// Optional late initializer for runtime dependencies.
238    pub init_deps: Option<InitDeps>,
239    /// Optional hook for adding application-specific global flags.
240    pub register_flags: Option<RegisterFlags>,
241    /// Optional hook for applying parsed application-specific flags.
242    pub apply_flags: Option<ApplyFlags>,
243    /// Optional hook run before executable commands and built-ins.
244    pub pre_run: Option<PreRun>,
245    /// Optional hook for global command metadata adjustments.
246    pub meta_resolver: Option<ResolveMeta>,
247    /// Optional hook called after each run.
248    pub on_shutdown: Option<OnShutdown>,
249    /// Optional root-scope search document provider.
250    pub extra_search_docs: Option<ExtraSearchDocs>,
251    /// Optional provider for the bare-root suggested next actions.
252    pub root_next_actions: Option<RootNextActions>,
253    /// Name of the admin help category. The engine files its built-in `auth`
254    /// command under this heading; apps should use the same name for their own
255    /// admin modules (e.g. godaddy's `env`). When unset, defaults to `"Admin"`;
256    /// set it to match a consumer's own taxonomy (e.g. gdx's "Administration").
257    pub admin_category: Option<String>,
258    /// Whether to mount the built-in `config` command group (`config
259    /// get`/`set`/`path`/`list`). Off by default to avoid colliding with a
260    /// consumer's own `config` noun. Enable via
261    /// [`CliConfig::with_config_commands`].
262    pub config_commands: bool,
263    /// Alternative `argv[0]` names this binary may be invoked as, mapped to the
264    /// behavior the engine should take (busybox/git-style multi-call dispatch).
265    ///
266    /// Keyed by the bare alternative name (no path, no extension). Empty by
267    /// default, in which case argv0 dispatch is inert and behavior is identical
268    /// to a binary that never opted in. Populate via [`CliConfig::with_argv0_alias`]
269    /// and [`CliConfig::with_argv0_personality`].
270    pub argv0_routes: BTreeMap<String, Argv0Route>,
271    /// Optional first-class environment system.
272    ///
273    /// Registered via [`CliConfig::with_environments`]. When set, the engine
274    /// registers a global `--env` flag, seeds the active environment into
275    /// middleware, and exposes it to handlers through
276    /// [`CommandContext::environment`](crate::command::CommandContext::environment).
277    pub environments: Option<Arc<crate::environments::Environments>>,
278}
279
280impl CliConfig {
281    /// Creates the minimum useful CLI configuration.
282    #[must_use]
283    pub fn new(
284        name: impl Into<String>,
285        short: impl Into<String>,
286        app_id: impl Into<String>,
287    ) -> Self {
288        Self {
289            name: name.into(),
290            short: short.into(),
291            app_id: app_id.into(),
292            ..Self::default()
293        }
294    }
295
296    /// Sets root long help text.
297    #[must_use]
298    pub fn with_long(mut self, long: impl Into<String>) -> Self {
299        self.long = Some(long.into());
300        self
301    }
302
303    /// Sets build metadata used by `--version`.
304    #[must_use]
305    pub fn with_build(mut self, build: BuildInfo) -> Self {
306        self.build = build;
307        self
308    }
309
310    /// Sets the fallback auth provider for commands that do not name one.
311    #[must_use]
312    pub fn with_default_auth_provider(mut self, provider: impl Into<String>) -> Self {
313        self.default_auth_provider = Some(provider.into());
314        self
315    }
316
317    /// Registers a first-class environment system.
318    ///
319    /// When set, [`Cli::new`] registers a global `--env` flag, seeds the active
320    /// environment into middleware (explicit `--env` > persisted active >
321    /// configured default), and exposes the resolved environment to handlers via
322    /// [`CommandContext::environment`](crate::command::CommandContext::environment).
323    ///
324    /// The [`Environments`](crate::environments::Environments) is stored as-is, so
325    /// the consumer is responsible for configuring it before wrapping it in an
326    /// `Arc`:
327    ///
328    /// - Call
329    ///   [`Environments::with_app_id`](crate::environments::Environments::with_app_id)
330    ///   with the **same** `app_id` passed to [`CliConfig::new`], so the config
331    ///   file and active-environment persistence resolve to the application's
332    ///   config directory. (An empty `app_id` makes
333    ///   [`Environments::config_file_path`](crate::environments::Environments::config_file_path)
334    ///   return `None`, silently disabling the `environments.toml` file layer.)
335    /// - Call
336    ///   [`Environments::with_config_file(true)`](crate::environments::Environments::with_config_file)
337    ///   if the application loads a user-editable `environments.toml`.
338    /// - **Share the same `Arc`** with any `PkceAuthProvider::with_environments`
339    ///   (available with the `pkce-auth` feature):
340    ///   the provider's OAuth file layer and active-environment persistence must
341    ///   resolve against the identical, `app_id`-stamped instance the engine sees,
342    ///   or a file-defined environment (or a file override of a compiled
343    ///   environment's `client_id`) will be visible to `env info` yet invisible to
344    ///   the actual OAuth login.
345    #[must_use]
346    pub fn with_environments(
347        mut self,
348        environments: Arc<crate::environments::Environments>,
349    ) -> Self {
350        self.environments = Some(environments);
351        self
352    }
353
354    /// Overrides the outbound User-Agent string for all HTTP traffic.
355    ///
356    /// When unset, the engine derives `name/version` from this config (see
357    /// [`CliConfig::user_agent_string`]). Set this when the upstream APIs expect
358    /// a specific product token. The resolved value is applied process-wide on
359    /// execution via [`crate::transport::set_default_user_agent`], so it reaches
360    /// both command [`HttpClient`](crate::transport::HttpClient)s and the
361    /// engine's own OAuth token requests.
362    #[must_use]
363    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
364        self.user_agent = Some(user_agent.into());
365        self
366    }
367
368    /// Adds HTTP header names to redact in `--debug transport` output, on top of
369    /// the built-in sensitive set.
370    ///
371    /// Use this for CLI-specific secret-bearing headers that are not standard
372    /// auth headers — for example a custom API-key header that an
373    /// [`AuthInjector`](crate::transport::AuthInjector) sets. Matching is
374    /// case-insensitive and additive: the built-in set is always redacted.
375    /// Calls accumulate. Names are trimmed and empty entries are dropped, so a
376    /// mistyped value with stray whitespace cannot silently disable redaction.
377    #[must_use]
378    pub fn with_redacted_debug_headers(
379        mut self,
380        names: impl IntoIterator<Item = impl Into<String>>,
381    ) -> Self {
382        self.redacted_debug_headers
383            .extend(names.into_iter().filter_map(|name| {
384                let name = name.into().trim().to_owned();
385                (!name.is_empty()).then_some(name)
386            }));
387        self
388    }
389
390    /// Returns the outbound User-Agent string the CLI presents on HTTP requests.
391    ///
392    /// Resolution order:
393    /// 1. an explicit [`with_user_agent`](Self::with_user_agent) override;
394    /// 2. otherwise `name/version` (for example `gdx/1.2.3`);
395    /// 3. otherwise just `name` when no build version is set.
396    #[must_use]
397    pub fn user_agent_string(&self) -> String {
398        if let Some(user_agent) = &self.user_agent {
399            return user_agent.clone();
400        }
401        if self.build.version.is_empty() {
402            self.name.clone()
403        } else {
404            format!("{}/{}", self.name, self.build.version)
405        }
406    }
407
408    /// Adds one domain module.
409    ///
410    /// # Reserved group names
411    ///
412    /// The top-level group names `help`, `guide`, `tree`, and `completion` are
413    /// reserved by the engine.  A module whose root group uses one of these
414    /// names will be rejected at registration time (logged as a warning) so
415    /// the engine's own built-in always takes precedence in the command tree.
416    #[must_use]
417    pub fn with_module(mut self, module: Module) -> Self {
418        self.modules.push(module);
419        self
420    }
421
422    /// Adds several domain modules.
423    ///
424    /// See [`with_module`](Self::with_module) for the list of reserved group names.
425    #[must_use]
426    pub fn with_modules(mut self, modules: impl IntoIterator<Item = Module>) -> Self {
427        self.modules.extend(modules);
428        self
429    }
430
431    /// Adds a top-level runtime command outside a module.
432    #[must_use]
433    pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
434        self.commands.push(command);
435        self
436    }
437
438    /// Adds one global guide.
439    #[must_use]
440    pub fn with_guide(mut self, guide: GuideEntry) -> Self {
441        self.guides.push(guide);
442        self
443    }
444
445    /// Adds several global guides.
446    #[must_use]
447    pub fn with_guides(mut self, guides: impl IntoIterator<Item = GuideEntry>) -> Self {
448        self.guides.extend(guides);
449        self
450    }
451
452    /// Adds one global human view.
453    #[must_use]
454    pub fn with_view(mut self, view: HumanViewDef) -> Self {
455        self.views.push(view);
456        self
457    }
458
459    /// Registers one auth provider.
460    #[must_use]
461    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
462        self.auth_providers.push(provider);
463        self
464    }
465
466    /// Sets the authorization gatekeeper.
467    #[must_use]
468    pub fn with_authz(mut self, authz: Arc<dyn Authorizer>) -> Self {
469        self.authz = Some(authz);
470        self
471    }
472
473    /// Sets the audit recorder.
474    #[must_use]
475    pub fn with_auditor(mut self, auditor: Arc<dyn Auditor>) -> Self {
476        self.auditor = Some(auditor);
477        self
478    }
479
480    /// Sets the activity event sink.
481    #[must_use]
482    pub fn with_activity(mut self, activity: Arc<dyn ActivityEmitter>) -> Self {
483        self.activity = Some(activity);
484        self
485    }
486
487    /// Sets the late dependency initializer.
488    #[must_use]
489    pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self {
490        self.init_deps = Some(init_deps);
491        self
492    }
493
494    /// Sets the application-specific global flag registration hook.
495    #[must_use]
496    pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self {
497        self.register_flags = Some(register_flags);
498        self
499    }
500
501    /// Sets the application-specific parsed flag application hook.
502    #[must_use]
503    pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self {
504        self.apply_flags = Some(apply_flags);
505        self
506    }
507
508    /// Sets the pre-run hook.
509    #[must_use]
510    pub fn with_pre_run(mut self, pre_run: PreRun) -> Self {
511        self.pre_run = Some(pre_run);
512        self
513    }
514
515    /// Sets the command metadata resolver hook.
516    #[must_use]
517    pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self {
518        self.meta_resolver = Some(meta_resolver);
519        self
520    }
521
522    /// Sets the shutdown hook.
523    #[must_use]
524    pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self {
525        self.on_shutdown = Some(on_shutdown);
526        self
527    }
528
529    /// Sets the provider for additional root-scope search documents.
530    #[must_use]
531    pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self {
532        self.extra_search_docs = Some(extra_search_docs);
533        self
534    }
535
536    /// Sets the provider for the bare-root suggested next actions.
537    #[must_use]
538    pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self {
539        self.root_next_actions = Some(root_next_actions);
540        self
541    }
542
543    /// Sets the name of the admin help category. The engine files the built-in
544    /// `auth` command there; apps should use the same name for their own admin
545    /// modules (e.g. godaddy's `env`). Optional: defaults to `"Admin"`.
546    #[must_use]
547    pub fn with_admin_category(mut self, category: impl Into<String>) -> Self {
548        self.admin_category = Some(category.into());
549        self
550    }
551
552    /// Mounts the built-in `config` command group (`config get`/`set`/`path`/
553    /// `list`) for reading and writing the per-application config file.
554    ///
555    /// Off by default so it never collides with a consumer's own `config` noun;
556    /// the group is filed under the admin help category when enabled.
557    #[must_use]
558    pub fn with_config_commands(mut self) -> Self {
559        self.config_commands = true;
560        self
561    }
562
563    /// Registers an alternative `argv[0]` name that acts as a shortcut to a
564    /// command path on this same CLI.
565    ///
566    /// When the binary is invoked under `name` (via symlink, hardlink, copy, or
567    /// the hidden `argv0` command), the engine behaves as if the user had typed
568    /// `command_path` followed by the real argument tail, routed through the
569    /// normal command tree. For example:
570    ///
571    /// ```
572    /// use cli_engine::CliConfig;
573    ///
574    /// // Invoking the binary as `pl --team platform` runs `project list --team platform`.
575    /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli")
576    ///     .with_argv0_alias("pl", ["project", "list"]);
577    /// ```
578    ///
579    /// `name` must be a simple token: non-empty and composed only of ASCII
580    /// letters, digits, `-`, or `_` (no dots, spaces, path separators, or shell
581    /// metacharacters), and it must differ from the CLI's own name. These are
582    /// debug-asserted. The restriction keeps the name usable as a link/shim
583    /// filename and an `argv[0]` basename (which is matched with its extension
584    /// stripped, so a dot would break matching).
585    #[must_use]
586    pub fn with_argv0_alias(
587        mut self,
588        name: impl Into<String>,
589        command_path: impl IntoIterator<Item = impl Into<String>>,
590    ) -> Self {
591        let name = name.into();
592        debug_assert!(
593            is_valid_argv0_name(&name),
594            "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
595        );
596        debug_assert!(
597            name != self.name,
598            "argv0 route name {name:?} must differ from the CLI's own name {:?}",
599            self.name
600        );
601        let tokens = command_path.into_iter().map(Into::into).collect();
602        self.argv0_routes.insert(name, Argv0Route::Alias(tokens));
603        self
604    }
605
606    /// Registers an alternative `argv[0]` name that runs an entirely separate CLI
607    /// application.
608    ///
609    /// When the binary is invoked under `name`, the engine builds a fresh
610    /// [`CliConfig`] from `build` and runs that application instead — its own root
611    /// name, commands, flags, and auth. The closure runs lazily, only when the
612    /// route is dispatched, so unused personalities cost nothing. The personality
613    /// presents the name from its own [`CliConfig`] in help and usage output.
614    ///
615    /// ```
616    /// use cli_engine::CliConfig;
617    ///
618    /// let config = CliConfig::new("my-cli", "Team CLI", "my-cli")
619    ///     .with_argv0_personality("legacy-tool", || {
620    ///         CliConfig::new("legacy-tool", "Legacy compatibility shim", "legacy-tool")
621    ///     });
622    /// ```
623    ///
624    /// `name` follows the same contract as [`CliConfig::with_argv0_alias`]: a
625    /// simple `[A-Za-z0-9_-]` token, differing from the CLI's own name
626    /// (debug-asserted).
627    #[must_use]
628    pub fn with_argv0_personality(
629        mut self,
630        name: impl Into<String>,
631        build: impl Fn() -> CliConfig + Send + Sync + 'static,
632    ) -> Self {
633        let name = name.into();
634        debug_assert!(
635            is_valid_argv0_name(&name),
636            "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
637        );
638        debug_assert!(
639            name != self.name,
640            "argv0 route name {name:?} must differ from the CLI's own name {:?}",
641            self.name
642        );
643        self.argv0_routes
644            .insert(name, Argv0Route::Personality(Arc::new(build)));
645        self
646    }
647}
648
649impl std::fmt::Debug for CliConfig {
650    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651        formatter
652            .debug_struct("CliConfig")
653            .field("name", &self.name)
654            .field("short", &self.short)
655            .field("long", &self.long)
656            .field("build", &self.build)
657            .field("app_id", &self.app_id)
658            .field("default_auth_provider", &self.default_auth_provider)
659            .field("modules", &self.modules)
660            .field("commands", &self.commands)
661            .field("guides", &self.guides)
662            .field("views", &self.views)
663            .field("auth_providers_len", &self.auth_providers.len())
664            .field("has_authz", &self.authz.is_some())
665            .field("has_auditor", &self.auditor.is_some())
666            .field("has_activity", &self.activity.is_some())
667            .field("has_init_deps", &self.init_deps.is_some())
668            .field("has_register_flags", &self.register_flags.is_some())
669            .field("has_apply_flags", &self.apply_flags.is_some())
670            .field("has_pre_run", &self.pre_run.is_some())
671            .field("has_meta_resolver", &self.meta_resolver.is_some())
672            .field("has_on_shutdown", &self.on_shutdown.is_some())
673            .field("has_extra_search_docs", &self.extra_search_docs.is_some())
674            .field("has_root_next_actions", &self.root_next_actions.is_some())
675            .field("admin_category", &self.admin_category)
676            .field(
677                "argv0_routes",
678                &self.argv0_routes.keys().collect::<Vec<_>>(),
679            )
680            .finish()
681    }
682}
683
684/// Captured result of running a CLI in tests or embedding contexts.
685#[derive(Clone, Debug, PartialEq)]
686pub struct CliRunOutput {
687    /// Process-style exit code.
688    pub exit_code: i32,
689    /// Rendered stdout or stderr payload.
690    pub rendered: String,
691}
692
693impl From<crate::middleware::MiddlewareOutput> for CliRunOutput {
694    fn from(o: crate::middleware::MiddlewareOutput) -> Self {
695        Self {
696            exit_code: o.exit_code,
697            rendered: o.rendered,
698        }
699    }
700}
701
702/// Configured CLI application.
703///
704/// A `Cli` owns the `clap` command tree, middleware, registered runtime
705/// commands, guides, schemas, and built-ins. Consumer binaries normally create
706/// one `Cli` and call [`Cli::execute`].
707#[derive(Clone)]
708pub struct Cli {
709    config: CliConfig,
710    middleware: Middleware,
711    root: Command,
712    commands: BTreeMap<String, RuntimeCommandSpec>,
713    module_entries: Vec<ModuleHelpEntry>,
714    guide_entries: Vec<GuideEntry>,
715    init_deps: Option<InitDeps>,
716    apply_flags: Option<ApplyFlags>,
717    pre_run: Option<PreRun>,
718    meta_resolver: Option<ResolveMeta>,
719    on_shutdown: Option<OnShutdown>,
720    extra_search_docs: Option<ExtraSearchDocs>,
721    root_next_actions: Option<RootNextActions>,
722    init_state: Arc<Mutex<Option<std::result::Result<Middleware, InitFailure>>>>,
723}
724
725#[derive(Clone, Debug, Eq, PartialEq)]
726struct InitFailure {
727    message: String,
728    code: String,
729    system: String,
730    request_id: String,
731    exit_code: i32,
732}
733
734impl InitFailure {
735    fn capture(err: &CliCoreError) -> Self {
736        let envelope = crate::output::build_error_envelope(err, "");
737        let (code, system, request_id) = envelope.error.map_or_else(
738            || ("ERROR".to_owned(), String::new(), String::new()),
739            |error| (error.code, error.system, error.request_id),
740        );
741        Self {
742            message: err.to_string(),
743            code,
744            system,
745            request_id,
746            exit_code: exit_code_for_error(err),
747        }
748    }
749
750    fn into_error(self) -> CliCoreError {
751        CliCoreError::with_exit_code(
752            self.exit_code,
753            CliCoreError::SystemMessage {
754                message: self.message,
755                system: self.system,
756                code: self.code,
757                request_id: self.request_id,
758            },
759        )
760    }
761}
762
763impl std::fmt::Debug for Cli {
764    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765        formatter
766            .debug_struct("Cli")
767            .field("config", &self.config)
768            .field("middleware", &self.middleware)
769            .field("root", &self.root)
770            .field("commands", &self.commands)
771            .field("module_entries", &self.module_entries)
772            .field("guide_entries", &self.guide_entries)
773            .field("has_init_deps", &self.init_deps.is_some())
774            .field("has_apply_flags", &self.apply_flags.is_some())
775            .field("has_pre_run", &self.pre_run.is_some())
776            .field("has_meta_resolver", &self.meta_resolver.is_some())
777            .field("has_on_shutdown", &self.on_shutdown.is_some())
778            .field("has_extra_search_docs", &self.extra_search_docs.is_some())
779            .field("has_root_next_actions", &self.root_next_actions.is_some())
780            .finish()
781    }
782}
783
784impl Cli {
785    /// Builds a CLI application from declarative configuration.
786    #[must_use]
787    pub fn new(config: CliConfig) -> Self {
788        let auth_providers = config.auth_providers.clone();
789        let guides = config.guides.clone();
790        let views = config.views.clone();
791        let modules = config.modules.clone();
792        let commands = config.commands.clone();
793        let init_deps = config.init_deps.clone();
794        let apply_flags = config.apply_flags.clone();
795        let pre_run = config.pre_run.clone();
796        let meta_resolver = config.meta_resolver.clone();
797        let on_shutdown = config.on_shutdown.clone();
798        let extra_search_docs = config.extra_search_docs.clone();
799        let root_next_actions = config.root_next_actions.clone();
800        let mut root = Command::new(config.name.clone())
801            .about(config.short.clone())
802            .disable_help_subcommand(true)
803            .version(config.build.version_string());
804        if let Some(long) = &config.long
805            && !long.is_empty()
806        {
807            root = root.long_about(long.clone());
808        }
809        root = register_global_flags(root)
810            .subcommand(help_command())
811            .subcommand(guide_command())
812            .subcommand(Command::new("tree").about("Display full command tree"))
813            .subcommand(completion_command());
814        if let Some(register_flags) = &config.register_flags {
815            root = register_flags(root);
816        }
817        if config.environments.is_some() {
818            root = root.arg(
819                clap::Arg::new("env")
820                    .long("env")
821                    .global(true)
822                    .value_name("ENV")
823                    .help("Override the active environment (see: env list)"),
824            );
825        }
826        let intro = config
827            .long
828            .as_deref()
829            .filter(|long| !long.is_empty())
830            .unwrap_or(config.short.as_str());
831        root = root
832            .long_about(build_root_long(intro, &[], false))
833            .help_template(ROOT_HELP_TEMPLATE);
834
835        let mut middleware = Middleware::new();
836        middleware.app_id = config.app_id.clone();
837        // Load the per-application config file once at startup; cloned into each
838        // per-run middleware so handlers and module registration share it.
839        middleware.config = Arc::new(crate::config::ConfigFile::load(&config.app_id));
840        middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default();
841        middleware.authz = config.authz.clone();
842        middleware.auditor = config.auditor.clone();
843        middleware.activity = config.activity.clone();
844        middleware
845            .schema_registry
846            .merge(&global_schema_registry_snapshot());
847        middleware
848            .human_views
849            .merge(&global_human_view_registry_snapshot());
850        if let Some(environments) = &config.environments {
851            // Seed the sticky/default active environment now; the global `--env`
852            // flag overrides it per invocation in `run_with_depth`. The same
853            // `Arc` the consumer shared with any `PkceAuthProvider` is reused, so
854            // the file layer and active-env persistence resolve consistently.
855            middleware.env = environments.effective_active(None, &middleware.config);
856            middleware.environments = Some(Arc::clone(environments));
857        }
858
859        let mut cli = Self {
860            config,
861            middleware,
862            root,
863            commands: BTreeMap::new(),
864            module_entries: Vec::new(),
865            guide_entries: Vec::new(),
866            init_deps,
867            apply_flags,
868            pre_run,
869            meta_resolver,
870            on_shutdown,
871            extra_search_docs,
872            root_next_actions,
873            init_state: Arc::new(Mutex::new(None)),
874        };
875        for provider in auth_providers {
876            cli.register_auth_provider(provider);
877        }
878        if cli.middleware.default_auth_provider.is_empty()
879            && let Some(provider) = cli.middleware.auth.registered_names().first()
880        {
881            cli.middleware.default_auth_provider = provider.clone();
882        }
883        if !cli.middleware.default_auth_provider.is_empty() {
884            cli.ensure_auth_command();
885        }
886        for view in views {
887            cli.middleware.human_views.register(view);
888        }
889        cli.add_guides(guides);
890        for module in modules {
891            cli.add_module(module);
892        }
893        for command in commands {
894            cli.add_command(command);
895        }
896        if cli.config.config_commands {
897            cli.ensure_config_command();
898        }
899        if cli.config.environments.is_some() {
900            cli.ensure_env_command();
901        }
902        cli
903    }
904
905    /// Lists the auto-registered `auth` command under the admin help category so
906    /// it is never uncategorized once clap's auto subcommand list is suppressed.
907    /// Defaults to [`DEFAULT_ADMIN_CATEGORY`]; `admin_category` overrides it to
908    /// align with a consumer's own taxonomy.
909    fn register_auth_help_entry(&mut self) {
910        let category = self
911            .config
912            .admin_category
913            .clone()
914            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
915        let already_listed = self.module_entries.iter().any(|entry| entry.name == "auth");
916        let short = self
917            .root
918            .find_subcommand("auth")
919            .filter(|auth| !auth.is_hide_set())
920            .map(|auth| {
921                auth.get_about()
922                    .map(ToString::to_string)
923                    .unwrap_or_default()
924            });
925        if !already_listed && let Some(short) = short {
926            self.module_entries.push(ModuleHelpEntry {
927                category,
928                name: "auth".to_owned(),
929                short,
930            });
931        }
932        self.refresh_root_long();
933    }
934
935    /// Returns the shared middleware template.
936    #[must_use]
937    pub fn middleware(&self) -> &Middleware {
938        &self.middleware
939    }
940
941    /// Returns mutable middleware for advanced application setup.
942    pub fn middleware_mut(&mut self) -> &mut Middleware {
943        &mut self.middleware
944    }
945
946    /// Executes the CLI with process arguments and process stdout/stderr.
947    pub async fn execute(&self) -> ExitCode {
948        let mut stdout = std::io::stdout().lock();
949        let mut stderr = std::io::stderr().lock();
950        match self
951            .execute_from(std::env::args_os(), &mut stdout, &mut stderr)
952            .await
953        {
954            Ok(code) => code,
955            Err(err) => {
956                drop(writeln!(stderr, "{err}"));
957                ExitCode::from(1)
958            }
959        }
960    }
961
962    /// Executes the CLI with caller-provided args and output writers.
963    pub async fn execute_from<I, S, O, E>(
964        &self,
965        args: I,
966        stdout: &mut O,
967        stderr: &mut E,
968    ) -> std::io::Result<ExitCode>
969    where
970        I: IntoIterator<Item = S>,
971        S: Into<std::ffi::OsString> + Clone,
972        O: Write,
973        E: Write,
974    {
975        self.execute_from_until_signal(args, stdout, stderr, shutdown_signal())
976            .await
977    }
978
979    /// Executes the CLI until either command completion or a shutdown signal future resolves.
980    pub async fn execute_from_until_signal<I, S, O, E, Shutdown>(
981        &self,
982        args: I,
983        stdout: &mut O,
984        stderr: &mut E,
985        shutdown: Shutdown,
986    ) -> std::io::Result<ExitCode>
987    where
988        I: IntoIterator<Item = S>,
989        S: Into<std::ffi::OsString> + Clone,
990        O: Write,
991        E: Write,
992        Shutdown: Future<Output = ()>,
993    {
994        self.install_default_user_agent();
995        let output = run_until_signal(self.run(args), shutdown).await;
996        if output.exit_code == 130
997            && output.rendered == "command interrupted\n"
998            && let Some(on_shutdown) = &self.on_shutdown
999        {
1000            on_shutdown();
1001        }
1002        if output.exit_code == 0 {
1003            stdout.write_all(output.rendered.as_bytes())?;
1004        } else {
1005            stderr.write_all(output.rendered.as_bytes())?;
1006        }
1007        Ok(process_exit_code(output.exit_code))
1008    }
1009
1010    /// Publishes the configured outbound User-Agent process-wide so that
1011    /// command [`HttpClient`](crate::transport::HttpClient)s and the engine's
1012    /// own OAuth token requests share it.
1013    ///
1014    /// Called from the execution entrypoints rather than [`Cli::new`] so that
1015    /// merely constructing a `Cli` (as tests do in bulk) does not mutate global
1016    /// state. See [`CliConfig::user_agent_string`] for resolution order.
1017    fn install_default_user_agent(&self) {
1018        crate::transport::set_default_user_agent(self.config.user_agent_string());
1019    }
1020
1021    /// Registers an auth provider after construction.
1022    pub fn register_auth_provider(&mut self, provider: Arc<dyn AuthProvider>) -> &mut Self {
1023        self.middleware.auth.register(provider);
1024        self.ensure_auth_command();
1025        self.refresh_root_long();
1026        self
1027    }
1028
1029    /// Returns the built `clap` root command.
1030    #[must_use]
1031    pub fn root_command(&self) -> &Command {
1032        &self.root
1033    }
1034
1035    /// Adds one runtime module group after construction.
1036    pub fn add_module_group(
1037        &mut self,
1038        category: impl Into<String>,
1039        group: RuntimeGroupSpec,
1040    ) -> &mut Self {
1041        // Prevent consumer modules from shadowing engine built-ins in the clap
1042        // command tree.  A reserved group name would override the engine's own
1043        // subcommand (last-writer-wins in clap) and corrupt the dispatch path.
1044        if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) {
1045            tracing::warn!(
1046                name = %group.group.name,
1047                "module group name is reserved by cli-engine built-ins; the group will not be registered"
1048            );
1049            return self;
1050        }
1051        let category = category.into();
1052        if !group.group.hidden {
1053            self.module_entries.push(ModuleHelpEntry {
1054                category,
1055                name: group.group.name.clone(),
1056                short: group.group.short.clone(),
1057            });
1058        }
1059
1060        let mut prefix = Vec::new();
1061        register_runtime_group_metadata(
1062            &group,
1063            &mut prefix,
1064            &mut self.middleware.schema_registry,
1065            &mut self.middleware.human_views,
1066        );
1067        let mut prefix = Vec::new();
1068        group.register_commands(&mut prefix, &mut self.commands);
1069        let mut prefix = Vec::new();
1070        let clap_group = runtime_group_clap_command_with_schema_help(
1071            &group,
1072            &mut prefix,
1073            &self.middleware.schema_registry,
1074        );
1075        self.root = self.root.clone().subcommand(clap_group);
1076        self.refresh_root_long();
1077        self
1078    }
1079
1080    /// Adds one module after construction.
1081    pub fn add_module(&mut self, module: Module) -> &mut Self {
1082        for view in module.views.clone() {
1083            self.middleware.human_views.register(view);
1084        }
1085        self.add_guides(module.guides.clone());
1086        let mut context = ModuleContext::new(&mut self.middleware);
1087        let group = (module.register)(&mut context);
1088        let (guides, views) = context.into_parts();
1089        for view in views {
1090            self.middleware.human_views.register(view);
1091        }
1092        self.add_guides(guides);
1093        self.add_module_group(module.category, group)
1094    }
1095
1096    /// Adds one top-level runtime command after construction.
1097    pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self {
1098        let name = command.spec.name.clone();
1099        register_command_schema(&command.spec, &name, &mut self.middleware.schema_registry);
1100        self.commands.insert(name, command.clone());
1101        self.root = self
1102            .root
1103            .clone()
1104            .subcommand(command_clap_command_with_schema_help(
1105                &command.spec,
1106                &command.spec.name,
1107                &self.middleware.schema_registry,
1108            ));
1109        self
1110    }
1111
1112    /// Controls whether the built-in `guide` command is advertised.
1113    pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self {
1114        if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1115            self.root = self.root.clone().subcommand(guide_command());
1116        }
1117        self.refresh_root_long();
1118        self
1119    }
1120
1121    /// Adds guide entries after construction.
1122    pub fn add_guides(&mut self, entries: impl IntoIterator<Item = GuideEntry>) -> &mut Self {
1123        let mut seen = self
1124            .guide_entries
1125            .iter()
1126            .map(|entry| entry.name.clone())
1127            .collect::<BTreeSet<_>>();
1128        for entry in entries {
1129            if seen.insert(entry.name.clone()) {
1130                self.guide_entries.push(entry);
1131            }
1132        }
1133        if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1134            self.root = self.root.clone().subcommand(guide_command());
1135        }
1136        self.refresh_root_long();
1137        self
1138    }
1139
1140    /// Resolves busybox/git-style `argv[0]` dispatch before the normal pipeline.
1141    ///
1142    /// Returns [`Argv0Outcome::Proceed`] with the (possibly rewritten) argument
1143    /// vector to feed the normal command pipeline, or [`Argv0Outcome::Handled`]
1144    /// with a fully rendered result when a personality ran or an explicit `argv0`
1145    /// invocation was rejected. When no routes are registered this is inert and
1146    /// returns the arguments unchanged. `depth` counts chained hand-offs and
1147    /// bounds recursion via [`MAX_ARGV0_DEPTH`].
1148    async fn resolve_argv0(&self, text_args: Vec<String>, depth: usize) -> Argv0Outcome {
1149        if self.config.argv0_routes.is_empty() {
1150            return Argv0Outcome::Proceed(text_args);
1151        }
1152
1153        if depth > MAX_ARGV0_DEPTH {
1154            return Argv0Outcome::Handled(
1155                self.render_argv0_error(&text_args, "argv0 dispatch recursion limit exceeded"),
1156            );
1157        }
1158
1159        // The hidden `argv0` meta-command (`<bin> argv0 <name> [args...]`) forces
1160        // a route without an actual symlink. It is recognized positionally as the
1161        // first argument after the program name and is never registered with clap,
1162        // so it stays absent from `--help`, `tree`, and `--search`.
1163        let explicit = text_args.get(1).map(String::as_str) == Some("argv0");
1164        let (name, rest) = if explicit {
1165            match text_args.get(2) {
1166                None => {
1167                    return Argv0Outcome::Handled(self.render_argv0_error(
1168                        &text_args,
1169                        "the argv0 command requires a name to dispatch as",
1170                    ));
1171                }
1172                // Normalize the explicit name the same way as a symlink basename
1173                // so a route registered as `whatever` matches whether the caller
1174                // passed `whatever`, `whatever.exe`, or a `.cmd` shim's `whatever.cmd`.
1175                Some(name) => (
1176                    program_basename(name),
1177                    text_args
1178                        .get(3..)
1179                        .map(<[String]>::to_vec)
1180                        .unwrap_or_default(),
1181                ),
1182            }
1183        } else {
1184            let name = text_args
1185                .first()
1186                .map(|arg| program_basename(arg))
1187                .unwrap_or_default();
1188            let rest = text_args
1189                .get(1..)
1190                .map(<[String]>::to_vec)
1191                .unwrap_or_default();
1192            (name, rest)
1193        };
1194
1195        match self.config.argv0_routes.get(&name) {
1196            Some(Argv0Route::Alias(tokens)) => {
1197                // Rewrite as `<canonical-name> <tokens...> <rest...>`. Element 0 is
1198                // the canonical name so the downstream program-name skip applies.
1199                let mut rewritten = Vec::with_capacity(1 + tokens.len() + rest.len());
1200                rewritten.push(self.config.name.clone());
1201                rewritten.extend(tokens.iter().cloned());
1202                rewritten.extend(rest);
1203                Argv0Outcome::Proceed(rewritten)
1204            }
1205            Some(Argv0Route::Personality(build)) => {
1206                // Hand off to an independent CLI built lazily from the route. Its
1207                // own config name leads so its help/usage and program-name skip
1208                // render correctly. `Box::pin` breaks the recursive `async fn`;
1209                // `depth + 1` bounds a pathological chain of hand-offs.
1210                let config = build();
1211                let bin = config.name.clone();
1212                let alt = Self::new(config);
1213                let mut alt_args = Vec::with_capacity(1 + rest.len());
1214                alt_args.push(bin);
1215                alt_args.extend(rest);
1216                Argv0Outcome::Handled(Box::pin(alt.run_with_depth(alt_args, depth + 1)).await)
1217            }
1218            None if explicit => Argv0Outcome::Handled(self.render_argv0_error(
1219                &text_args,
1220                format!(
1221                    "{name:?} is not a registered argv0 name; known names: {}",
1222                    self.known_argv0_names()
1223                ),
1224            )),
1225            None => {
1226                // Unregistered name (e.g. the binary renamed to something we do not
1227                // recognize): fall through to the default CLI. Normalizing element 0
1228                // to the canonical name lets a renamed binary parse as the default
1229                // application instead of treating its name as a command token.
1230                let mut rewritten = Vec::with_capacity(1 + rest.len());
1231                rewritten.push(self.config.name.clone());
1232                rewritten.extend(rest);
1233                Argv0Outcome::Proceed(rewritten)
1234            }
1235        }
1236    }
1237
1238    /// Comma-separated, sorted list of registered alternative `argv[0]` names,
1239    /// used in the error shown for an unknown explicit `argv0` invocation.
1240    fn known_argv0_names(&self) -> String {
1241        self.config
1242            .argv0_routes
1243            .keys()
1244            .cloned()
1245            .collect::<Vec<_>>()
1246            .join(", ")
1247    }
1248
1249    /// Renders an `argv0`-dispatch error through the engine's structured error
1250    /// envelope so it honors `--output` (parsed from the raw args, since dispatch
1251    /// runs before clap) and the shared exit-code mapping, matching every other
1252    /// CLI error rather than emitting bare text.
1253    fn render_argv0_error(&self, text_args: &[String], message: impl Into<String>) -> CliRunOutput {
1254        let mut middleware = self.middleware.clone();
1255        middleware.output_format =
1256            extract_output_format(text_args, &default_output_format(&self.config.app_id));
1257        let err = CliCoreError::message(message);
1258        self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id))
1259    }
1260
1261    /// Returns the registered alternative `argv[0]` names, sorted.
1262    ///
1263    /// Useful for install or self-healing code that iterates the names and calls
1264    /// [`Cli::create_link`] for each.
1265    #[must_use]
1266    pub fn argv0_names(&self) -> Vec<&str> {
1267        self.config
1268            .argv0_routes
1269            .keys()
1270            .map(String::as_str)
1271            .collect()
1272    }
1273
1274    /// Creates an on-disk link in `dir` that lets the binary be invoked under the
1275    /// registered alternative `argv[0]` name `name`, using `method`.
1276    ///
1277    /// `target` is the executable the link points at; pass `None` to use the
1278    /// current executable ([`std::env::current_exe`]), which is the common choice
1279    /// for install and self-healing code. The file name follows the platform and
1280    /// method: a symlink or hard link is `<name>` on Unix and `<name>.exe` on
1281    /// Windows; a [`Argv0LinkMethod::Script`] shim is `<name>.cmd` on Windows and
1282    /// an executable `<name>` shell script on Unix.
1283    ///
1284    /// The call ensures the desired state idempotently: if the destination already
1285    /// matches what would be created (a symlink to `target`, a hard link with the
1286    /// same contents, or a shim with identical contents) it is left untouched and
1287    /// its path returned; if it exists but differs (wrong kind, stale target, or
1288    /// edited shim) it is replaced. This makes the call safe to re-run as install
1289    /// or self-healing code, restoring both deleted and corrupted links. The
1290    /// directory is created if necessary.
1291    ///
1292    /// # Errors
1293    ///
1294    /// Returns an error if `name` is not a registered route, if the current
1295    /// executable cannot be resolved (when `target` is `None`), or if the
1296    /// directory or link cannot be created or replaced (e.g. insufficient
1297    /// privilege for a Windows symlink, or a hard link across volumes).
1298    pub fn create_link(
1299        &self,
1300        name: &str,
1301        dir: impl AsRef<Path>,
1302        target: Option<&Path>,
1303        method: Argv0LinkMethod,
1304    ) -> std::io::Result<PathBuf> {
1305        if !self.config.argv0_routes.contains_key(name) {
1306            return Err(std::io::Error::new(
1307                std::io::ErrorKind::InvalidInput,
1308                format!("{name:?} is not a registered argv0 name"),
1309            ));
1310        }
1311
1312        let dir = dir.as_ref();
1313        std::fs::create_dir_all(dir)?;
1314        let link = dir.join(argv0_link_file_name(name, method));
1315
1316        // Resolve the target up front so an existing entry can be compared against it.
1317        let resolved_target;
1318        let target = match target {
1319            Some(target) => target,
1320            None => {
1321                resolved_target = std::env::current_exe()?;
1322                resolved_target.as_path()
1323            }
1324        };
1325
1326        // Ensure-desired-state. `symlink_metadata` does not follow links, so a
1327        // present-but-dangling link still counts as existing. A matching entry is
1328        // left untouched (idempotent); a differing one is removed and recreated.
1329        if std::fs::symlink_metadata(&link).is_ok() {
1330            if argv0_link_matches(&link, target, name, method)? {
1331                return Ok(link);
1332            }
1333            std::fs::remove_file(&link)?;
1334        }
1335
1336        match method {
1337            Argv0LinkMethod::SoftLink => create_symlink(target, &link)?,
1338            Argv0LinkMethod::HardLink => std::fs::hard_link(target, &link)?,
1339            Argv0LinkMethod::Script => {
1340                std::fs::write(&link, argv0_script_contents(target, name))?;
1341                make_executable(&link)?;
1342            }
1343        }
1344        Ok(link)
1345    }
1346
1347    /// Runs the CLI with provided args and captures the rendered result.
1348    pub async fn run<I, S>(&self, args: I) -> CliRunOutput
1349    where
1350        I: IntoIterator<Item = S>,
1351        S: Into<std::ffi::OsString> + Clone,
1352    {
1353        self.run_with_depth(args, 0).await
1354    }
1355
1356    /// Runs the CLI like [`Cli::run`], threading the `argv0` dispatch recursion
1357    /// `depth` so a chain of personality hand-offs is bounded by [`MAX_ARGV0_DEPTH`].
1358    async fn run_with_depth<I, S>(&self, args: I, depth: usize) -> CliRunOutput
1359    where
1360        I: IntoIterator<Item = S>,
1361        S: Into<std::ffi::OsString> + Clone,
1362    {
1363        let raw_args = args
1364            .into_iter()
1365            .map(Into::into)
1366            .collect::<Vec<std::ffi::OsString>>();
1367        let text_args = raw_args
1368            .iter()
1369            .map(|arg| arg.to_string_lossy().into_owned())
1370            .collect::<Vec<_>>();
1371        let text_args = match self.resolve_argv0(text_args, depth).await {
1372            Argv0Outcome::Handled(output) => return output,
1373            Argv0Outcome::Proceed(args) => args,
1374        };
1375        let mut clap_args = normalize_optional_global_flags_before_command(&self.root, &text_args);
1376        if has_root_version_flag(&text_args, &self.root, &self.config.name) {
1377            return self.finish_run(CliRunOutput {
1378                exit_code: 0,
1379                rendered: format!(
1380                    "{} version {}\n",
1381                    self.config.name,
1382                    self.config.build.version_string()
1383                ),
1384            });
1385        }
1386        if let Some(output) = self.try_run_schema_bypass(&text_args) {
1387            return output;
1388        }
1389        if let Some(output) = self.try_run_search_bypass(&text_args) {
1390            return output;
1391        }
1392        // Resolve the positional command path once and share it between the
1393        // group-help rewrite and the unknown-command check below.
1394        let bool_flags = derive_bool_flags(&self.root);
1395        let value_flags = derive_value_flags(&self.root);
1396        let positionals =
1397            positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags);
1398        // Positional tokens after a `--` separator are literal operands, not
1399        // command keywords, so the group-help shim must not treat a `help`
1400        // among them as a help request. Count the positionals that precede any
1401        // `--` to mark where genuine command keywords end.
1402        let command_keyword_count = match text_args.iter().position(|arg| arg == "--") {
1403            Some(end) => positional_command_tokens(
1404                &text_args[..end],
1405                &self.config.name,
1406                &bool_flags,
1407                &value_flags,
1408            )
1409            .len(),
1410            None => positionals.len(),
1411        };
1412        if let Some(parts) =
1413            group_help_target_parts(&self.root, &positionals, command_keyword_count)
1414        {
1415            // Rewrite `<group> help [sub...]` into the canonical
1416            // `help <group> [sub...]` so it flows through the curated root
1417            // `help` command, which also runs global-flag parsing and the
1418            // `pre_run` hook (matching `help <group>` and bare-group help).
1419            // Only the positional command tokens are reordered; every flag and
1420            // its value is preserved in place so e.g. `--output json` survives.
1421            clap_args = rewrite_group_help_args(
1422                &clap_args,
1423                &self.config.name,
1424                &bool_flags,
1425                &value_flags,
1426                &parts,
1427            );
1428        } else if let Some(message) = unknown_group_command_message(&self.root, &positionals) {
1429            return self.finish_run(CliRunOutput {
1430                exit_code: 1,
1431                rendered: message,
1432            });
1433        }
1434
1435        let matches = match self.root.clone().try_get_matches_from(clap_args) {
1436            Ok(matches) => matches,
1437            Err(err) => {
1438                return self.finish_run(CliRunOutput {
1439                    exit_code: err.exit_code(),
1440                    rendered: err.to_string(),
1441                });
1442            }
1443        };
1444
1445        let default_format = default_output_format(&self.config.app_id);
1446        let flags = global_flags_from_matches(&matches, &default_format);
1447        // Publish the --credential-store override so auth providers resolving
1448        // their storage backend see it at the top of the precedence chain.
1449        crate::config::set_credential_store_flag(flags.credential_store);
1450        let command_timeout = match parse_command_timeout(&flags.timeout) {
1451            Ok(timeout) => timeout,
1452            Err(err) => {
1453                return self.finish_run(render_cli_error(
1454                    &self.middleware,
1455                    &err,
1456                    &self.config.app_id,
1457                ));
1458            }
1459        };
1460        let mut middleware = self.middleware.clone();
1461        apply_global_flags(&mut middleware, &flags, command_timeout);
1462        install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1463        if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1464            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1465        }
1466        // Validate and apply `--env` for built-in paths (help/tree/guide/group
1467        // help) so they reflect the selected environment and reject unknowns.
1468        if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1469            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1470        }
1471
1472        let command_path = command_path_from_matches(&self.config.name, &matches);
1473        if command_path == "help" {
1474            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &help_args(&matches))
1475            {
1476                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1477            }
1478            return self.finish_run(self.render_help_command(&matches));
1479        }
1480        if command_path == "tree" {
1481            if let Err(err) = self.run_pre_run(
1482                &mut middleware,
1483                &command_path,
1484                &crate::middleware::ValueMap::new(),
1485            ) {
1486                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1487            }
1488            return self.finish_run(tree_render::render_tree(
1489                &self.root,
1490                &self.config.app_id,
1491                &middleware,
1492            ));
1493        }
1494        if command_path == "guide" {
1495            if let Err(err) =
1496                self.run_pre_run(&mut middleware, &command_path, &guide_args(&matches))
1497            {
1498                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1499            }
1500            return self.finish_run(self.render_guide(&matches));
1501        }
1502        if command_path == "completion" {
1503            let args = completion_args(&matches);
1504            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1505                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1506            }
1507            let install = args
1508                .get("install")
1509                .and_then(|v| v.as_bool())
1510                .unwrap_or(false);
1511            let shell_opt = args
1512                .get("shell")
1513                .and_then(|v| v.as_str())
1514                .map(str::to_owned);
1515            if install {
1516                use crate::cli::completion::{detect_shell, parse_shell};
1517                let shell = match shell_opt {
1518                    Some(ref s) => match parse_shell(s) {
1519                        Ok(s) => s,
1520                        Err(e) => {
1521                            return self.finish_run(render_cli_error(
1522                                &middleware,
1523                                &e,
1524                                &self.config.app_id,
1525                            ));
1526                        }
1527                    },
1528                    None => match detect_shell() {
1529                        Ok(s) => s,
1530                        Err(e) => {
1531                            return self.finish_run(render_cli_error(
1532                                &middleware,
1533                                &e,
1534                                &self.config.app_id,
1535                            ));
1536                        }
1537                    },
1538                };
1539                return self.finish_run(
1540                    completion::install(&self.root, &self.config.name, shell)
1541                        .await
1542                        .unwrap_or_else(|e| render_cli_error(&middleware, &e, &self.config.app_id)),
1543                );
1544            }
1545            return self.finish_run(self.render_completion_print(shell_opt, &middleware));
1546        }
1547        let Some(command) = self.commands.get(&command_path) else {
1548            if !command_path.is_empty()
1549                && let Some(group) = find_command_by_colon_path(&self.root, &command_path)
1550                && group.get_subcommands().next().is_some()
1551            {
1552                if let Err(err) = self.run_pre_run(
1553                    &mut middleware,
1554                    &command_path,
1555                    &crate::middleware::ValueMap::new(),
1556                ) {
1557                    return self.finish_run(render_cli_error(
1558                        &middleware,
1559                        &err,
1560                        &self.config.app_id,
1561                    ));
1562                }
1563                return self.finish_run(CliRunOutput {
1564                    exit_code: 0,
1565                    rendered: group.clone().render_long_help().to_string(),
1566                });
1567            }
1568            if command_path.is_empty()
1569                && let Some(root_next_actions) = &self.root_next_actions
1570            {
1571                // Bare-root discovery is static (help text / metadata + action
1572                // pointers) and must always be available as a cold-start entry
1573                // point, so we skip `pre_run` here — matching the no-hook
1574                // bare-root path below, which also renders help without it.
1575                let actions = root_next_actions();
1576                return self.finish_run(self.render_root(&middleware, actions));
1577            }
1578            return self.finish_run(CliRunOutput {
1579                exit_code: if command_path.is_empty() { 0 } else { 1 },
1580                rendered: if command_path.is_empty() {
1581                    self.root.clone().render_long_help().to_string()
1582                } else {
1583                    format!("unknown command {command_path:?}")
1584                },
1585            });
1586        };
1587
1588        let mut middleware = match self.initialized_middleware() {
1589            Ok(middleware) => middleware,
1590            Err(err) => {
1591                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1592            }
1593        };
1594        apply_global_flags(&mut middleware, &flags, command_timeout);
1595        install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1596        if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1597            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1598        }
1599        // The global `--env` flag overrides the seeded active environment for
1600        // this invocation; an unknown name surfaces as an error envelope.
1601        if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1602            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1603        }
1604
1605        let leaf = leaf_matches(&matches);
1606        let args = command_args_from_matches(leaf, &command.spec, false);
1607        let user_args = command_args_from_matches(leaf, &command.spec, true);
1608        if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1609            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1610        }
1611        let meta = self.resolve_meta(&command_path, command.spec.metadata());
1612        let default_fields = command.spec.default_fields.clone().unwrap_or_default();
1613        let system = command.spec.system.clone().unwrap_or_default();
1614        // The human view this command declared: an explicit shared id wins;
1615        // otherwise an inline `with_view` was registered under the command path
1616        // at build time, so reference it by that path. `None` renders generic
1617        // human output.
1618        let view_id = command
1619            .spec
1620            .view_id
1621            .clone()
1622            .or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));
1623
1624        if let Some(streaming_handler) = command.streaming_handler.clone() {
1625            let result = run_with_timeout(
1626                command_timeout,
1627                &flags.timeout,
1628                run_streaming_command(
1629                    &middleware,
1630                    MiddlewareRequest {
1631                        meta,
1632                        command_path: &command_path,
1633                        system: &system,
1634                        user_args,
1635                        args,
1636                        default_fields: &default_fields,
1637                        view_id: view_id.as_deref(),
1638                        auth: command.spec.auth,
1639                    },
1640                    Arc::new(leaf.clone()),
1641                    streaming_handler,
1642                ),
1643            )
1644            .await;
1645            return self.finish_run(match result {
1646                Ok(output) => output,
1647                Err(err) => render_cli_error(&middleware, &err, &self.config.app_id),
1648            });
1649        }
1650
1651        let handler = command.handler.clone();
1652        let args_for_handler = args.clone();
1653        let user_args_for_handler = user_args.clone();
1654        let handler_path = command_path.clone();
1655        let middleware_for_handler = middleware.clone();
1656        let raw_matches_for_handler = Arc::new(leaf.clone());
1657        let result = run_with_timeout(
1658            command_timeout,
1659            &flags.timeout,
1660            middleware.run(
1661                MiddlewareRequest {
1662                    meta,
1663                    command_path: &command_path,
1664                    system: &system,
1665                    user_args,
1666                    args,
1667                    default_fields: &default_fields,
1668                    view_id: view_id.as_deref(),
1669                    auth: command.spec.auth,
1670                },
1671                async move |credential| {
1672                    handler(CommandContext {
1673                        credential,
1674                        args: args_for_handler,
1675                        user_args: user_args_for_handler,
1676                        command_path: handler_path,
1677                        middleware: middleware_for_handler,
1678                        raw_matches: raw_matches_for_handler,
1679                    })
1680                    .await
1681                },
1682            ),
1683        )
1684        .await;
1685
1686        match result {
1687            Ok(output) => self.finish_run(output.into()),
1688            Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)),
1689        }
1690    }
1691
1692    fn try_run_search_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
1693        let query = extract_search_query(args);
1694        if query.is_empty() {
1695            return None;
1696        }
1697        let scope = self.search_scope(args);
1698        let output_format =
1699            extract_output_format(args, &default_output_format(&self.config.app_id));
1700        Some(self.render_search(&query, &scope, &output_format))
1701    }
1702
1703    fn try_run_schema_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
1704        if !has_true_schema_flag(args) {
1705            return None;
1706        }
1707        let bool_flags = derive_bool_flags(&self.root);
1708        let value_flags = derive_value_flags(&self.root);
1709        let command_path =
1710            self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
1711        // `--schema` is an inspection flag and must not require the command's own
1712        // arguments, so it short-circuits before clap validates them. Only fire
1713        // for a real leaf command, though: unknown paths and groups fall through
1714        // so clap and `unknown_group_command_message` can report them as usual.
1715        let command = find_command_by_colon_path(&self.root, &command_path)?;
1716        if command.get_subcommands().next().is_some() {
1717            return None;
1718        }
1719        let output_format =
1720            extract_output_format(args, &default_output_format(&self.config.app_id));
1721        // When no schema is registered, report that rather than running the
1722        // command — matching the middleware's no-schema response so the public
1723        // path and the lower layer agree even when required args are missing.
1724        match self.middleware.schema_registry.get_by_path(&command_path) {
1725            Some(schema) => Some(self.render_schema(schema, &output_format)),
1726            None => Some(self.render_schema(
1727                crate::output::no_schema_response(&command_path),
1728                &output_format,
1729            )),
1730        }
1731    }
1732
1733    fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
1734        let format: crate::output::OutputFormat = match output_format.parse() {
1735            Ok(format) => format,
1736            Err(err) => {
1737                return CliRunOutput {
1738                    exit_code: exit_code_for_error(&err),
1739                    rendered: err.to_string(),
1740                };
1741            }
1742        };
1743        let envelope =
1744            crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
1745        match crate::output::render(format, &envelope) {
1746            Ok(rendered) => CliRunOutput {
1747                exit_code: 0,
1748                rendered,
1749            },
1750            Err(err) => CliRunOutput {
1751                exit_code: exit_code_for_error(&err),
1752                rendered: err.to_string(),
1753            },
1754        }
1755    }
1756
1757    fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
1758        let format: crate::output::OutputFormat = match output_format.parse() {
1759            Ok(format) => format,
1760            Err(err) => {
1761                return CliRunOutput {
1762                    exit_code: exit_code_for_error(&err),
1763                    rendered: err.to_string(),
1764                };
1765            }
1766        };
1767        let docs = self.search_documents(scope);
1768        let results = SearchIndex::new(docs).search(query, 10);
1769        let envelope =
1770            crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render("");
1771        match crate::output::render(format, &envelope) {
1772            Ok(rendered) => CliRunOutput {
1773                exit_code: 0,
1774                rendered,
1775            },
1776            Err(err) => CliRunOutput {
1777                exit_code: exit_code_for_error(&err),
1778                rendered: err.to_string(),
1779            },
1780        }
1781    }
1782
1783    /// Renders the bare-root response. For human output, renders long help plus
1784    /// a "Next actions" section so a human invoking the CLI with no arguments
1785    /// gets readable guidance; for machine-readable output, emits a discovery
1786    /// envelope (light metadata + next actions). The output format has already
1787    /// resolved the TTY/env/flag policy, so this just branches on it.
1788    fn render_root(&self, middleware: &Middleware, actions: Vec<NextAction>) -> CliRunOutput {
1789        // Reject an invalid explicit `--output` here too, matching the normal
1790        // command path (`Middleware::render_envelope`). `OutputFormat::from_str`
1791        // is infallible and would otherwise silently coerce an unrecognized
1792        // value (e.g. `--output yaml`) to JSON instead of reporting the error.
1793        if !crate::output::is_valid_output_format(&middleware.output_format) {
1794            let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone());
1795            return CliRunOutput {
1796                exit_code: exit_code_for_error(&err),
1797                rendered: err.to_string(),
1798            };
1799        }
1800        let format = middleware
1801            .output_format
1802            .parse()
1803            .unwrap_or(crate::output::OutputFormat::Json);
1804        if format == crate::output::OutputFormat::Human {
1805            // Fold the suggested actions into the root long-about so they render
1806            // alongside the other curated sections (before Usage) instead of
1807            // dangling beneath clap's options dump.
1808            let base_long = self
1809                .root
1810                .get_long_about()
1811                .map(ToString::to_string)
1812                .unwrap_or_default();
1813            let long = format!("{base_long}{}", render_next_actions_human(&actions));
1814            let rendered = self
1815                .root
1816                .clone()
1817                .long_about(long)
1818                .render_long_help()
1819                .to_string();
1820            return CliRunOutput {
1821                exit_code: 0,
1822                rendered,
1823            };
1824        }
1825        let description = self
1826            .config
1827            .long
1828            .as_deref()
1829            .filter(|long| !long.is_empty())
1830            .unwrap_or(self.config.short.as_str());
1831        let data = serde_json::json!({
1832            "description": description,
1833            "version": self.config.build.version,
1834        });
1835        let envelope = crate::Envelope::success(data, self.config.app_id.clone())
1836            .with_next_actions(actions)
1837            .prepare_for_render(&middleware.verbose);
1838        match crate::output::render(format, &envelope) {
1839            Ok(rendered) => CliRunOutput {
1840                exit_code: 0,
1841                rendered,
1842            },
1843            Err(err) => CliRunOutput {
1844                exit_code: exit_code_for_error(&err),
1845                rendered: err.to_string(),
1846            },
1847        }
1848    }
1849
1850    fn search_documents(&self, scope: &str) -> Vec<SearchDocument> {
1851        let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope)
1852            .unwrap_or((&self.root, Vec::new()));
1853        let mut docs = Vec::new();
1854        let mut aliases = Vec::new();
1855        append_command_alias_terms(scoped, &mut aliases);
1856        collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs);
1857        if scope.is_empty() {
1858            for entry in &self.guide_entries {
1859                docs.push(SearchDocument {
1860                    id: format!("guide:{}", entry.name),
1861                    kind: "guide".to_owned(),
1862                    title: format!("guide {}", entry.name),
1863                    summary: entry.summary.clone(),
1864                    content: format!("{} {}", entry.summary, entry.content),
1865                });
1866            }
1867            if let Some(extra_search_docs) = &self.extra_search_docs {
1868                docs.extend(extra_search_docs());
1869            }
1870        }
1871        docs
1872    }
1873
1874    fn search_scope(&self, args: &[String]) -> String {
1875        let parts = extract_search_scope_parts(args);
1876        canonical_path_from_parts(&self.root, &parts).unwrap_or_default()
1877    }
1878
1879    fn canonical_command_path(&self, command_path: &str) -> String {
1880        find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else(
1881            || command_path.to_owned(),
1882            |(_, canonical)| canonical.join(":"),
1883        )
1884    }
1885
1886    fn render_guide(&self, matches: &ArgMatches) -> CliRunOutput {
1887        let leaf = leaf_matches(matches);
1888        let topic = leaf.get_one::<String>("topic").map(String::as_str);
1889        match guide_content(&self.guide_entries, topic) {
1890            Ok(rendered) => CliRunOutput {
1891                exit_code: 0,
1892                rendered,
1893            },
1894            Err(err) => CliRunOutput {
1895                exit_code: 1,
1896                rendered: err,
1897            },
1898        }
1899    }
1900
1901    fn render_completion_print(
1902        &self,
1903        shell_opt: Option<String>,
1904        middleware: &Middleware,
1905    ) -> CliRunOutput {
1906        use crate::cli::completion::{detect_shell, generate_script, parse_shell};
1907        let shell = match shell_opt {
1908            Some(s) => match parse_shell(&s) {
1909                Ok(s) => s,
1910                Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
1911            },
1912            None => match detect_shell() {
1913                Ok(s) => s,
1914                Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
1915            },
1916        };
1917        match generate_script(&self.root, &self.config.name, shell) {
1918            Ok(script) => CliRunOutput {
1919                exit_code: 0,
1920                rendered: script,
1921            },
1922            Err(e) => render_cli_error(middleware, &e, &self.config.app_id),
1923        }
1924    }
1925
1926    fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
1927        let leaf = leaf_matches(matches);
1928        let parts = leaf
1929            .get_many::<String>("command")
1930            .map(|values| values.map(String::as_str).collect::<Vec<_>>())
1931            .unwrap_or_default();
1932        self.render_help_for_parts(&parts)
1933    }
1934
1935    /// Renders the curated help text for a resolved command path.
1936    ///
1937    /// Empty `parts` render the root help. A path that resolves to a group or
1938    /// command renders that command's long help; an unresolved path returns the
1939    /// standard "unknown command" guidance with a non-zero exit code. Shared by
1940    /// the root `help <path>` command and the `<group> help` subcommand form.
1941    fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput {
1942        if parts.is_empty() {
1943            return CliRunOutput {
1944                exit_code: 0,
1945                rendered: self.root.clone().render_long_help().to_string(),
1946            };
1947        }
1948        let Some(command) = find_help_target(&self.root, parts) else {
1949            return CliRunOutput {
1950                exit_code: 1,
1951                rendered: format!(
1952                    "unknown command {:?} — run '{} help' for available commands",
1953                    parts.join(" "),
1954                    self.config.name
1955                ),
1956            };
1957        };
1958        CliRunOutput {
1959            exit_code: 0,
1960            rendered: command.clone().render_long_help().to_string(),
1961        }
1962    }
1963
1964    fn refresh_root_long(&mut self) {
1965        // Module-categorized entries, plus any visible top-level command that is
1966        // neither categorized nor an engine built-in, listed under a generic
1967        // "Commands" section. This keeps every command discoverable once clap's
1968        // auto subcommand list is suppressed by the root help template.
1969        let builtins = BUILTIN_COMMAND_NAMES;
1970        let categorized: BTreeSet<&str> = self
1971            .module_entries
1972            .iter()
1973            .map(|entry| entry.name.as_str())
1974            .collect();
1975        let mut generic: Vec<ModuleHelpEntry> = self
1976            .root
1977            .get_subcommands()
1978            .filter(|command| !command.is_hide_set())
1979            .filter(|command| !builtins.contains(&command.get_name()))
1980            .filter(|command| !categorized.contains(command.get_name()))
1981            .map(|command| ModuleHelpEntry {
1982                category: "Commands".to_owned(),
1983                name: command.get_name().to_owned(),
1984                short: command
1985                    .get_about()
1986                    .map(ToString::to_string)
1987                    .unwrap_or_default(),
1988            })
1989            .collect();
1990        generic.sort_by(|left, right| left.name.cmp(&right.name));
1991
1992        let mut entries = self.module_entries.clone();
1993        entries.extend(generic);
1994        let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide");
1995        let intro = self
1996            .config
1997            .long
1998            .as_deref()
1999            .filter(|long| !long.is_empty())
2000            .unwrap_or(self.config.short.as_str());
2001        self.root = self
2002            .root
2003            .clone()
2004            .long_about(build_root_long(intro, &entries, has_guide));
2005    }
2006
2007    fn ensure_auth_command(&mut self) {
2008        let default_provider = self.default_auth_provider();
2009        let registered_names = self.middleware.auth.registered_names();
2010        if default_provider.is_empty() && registered_names.is_empty() {
2011            return;
2012        }
2013        let replacing_builtin = self.commands.contains_key("auth:login");
2014        if has_subcommand(&self.root, "auth") && !replacing_builtin {
2015            return;
2016        }
2017        let group = auth_command_group(&default_provider, &registered_names);
2018        let mut prefix = Vec::new();
2019        group.register_commands(&mut prefix, &mut self.commands);
2020        let mut prefix = Vec::new();
2021        let clap_group = runtime_group_clap_command_with_schema_help(
2022            &group,
2023            &mut prefix,
2024            &self.middleware.schema_registry,
2025        );
2026        self.root = if replacing_builtin {
2027            self.root.clone().mut_subcommand("auth", |_| clap_group)
2028        } else {
2029            self.root.clone().subcommand(clap_group)
2030        };
2031        // Categorize `auth` wherever it is ensured (construction or a later
2032        // `register_auth_provider`), so it never falls into the generic
2033        // "Commands" bucket. Idempotent via the `already_listed` guard.
2034        self.register_auth_help_entry();
2035    }
2036
2037    /// Mounts the built-in `config` command group and files it under the admin
2038    /// help category. Idempotent and yields to a consumer-defined `config`
2039    /// subcommand if one already exists.
2040    fn ensure_config_command(&mut self) {
2041        if has_subcommand(&self.root, "config") {
2042            return;
2043        }
2044        let group = crate::config_commands::config_command_group();
2045        let mut prefix = Vec::new();
2046        group.register_commands(&mut prefix, &mut self.commands);
2047        let mut prefix = Vec::new();
2048        let clap_group = runtime_group_clap_command_with_schema_help(
2049            &group,
2050            &mut prefix,
2051            &self.middleware.schema_registry,
2052        );
2053        self.root = self.root.clone().subcommand(clap_group);
2054        let category = self
2055            .config
2056            .admin_category
2057            .clone()
2058            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2059        if !self
2060            .module_entries
2061            .iter()
2062            .any(|entry| entry.name == "config")
2063        {
2064            self.module_entries.push(ModuleHelpEntry {
2065                category,
2066                name: "config".to_owned(),
2067                short: "Read and write the CLI config file".to_owned(),
2068            });
2069        }
2070        self.refresh_root_long();
2071    }
2072
2073    /// Mounts the built-in `env` command group and files it under the admin
2074    /// help category. Idempotent and yields to a consumer-defined `env`
2075    /// subcommand if one already exists.
2076    fn ensure_env_command(&mut self) {
2077        if has_subcommand(&self.root, "env") {
2078            return;
2079        }
2080        let group = crate::env_commands::env_command_group();
2081        let mut prefix = Vec::new();
2082        group.register_commands(&mut prefix, &mut self.commands);
2083        let mut prefix = Vec::new();
2084        let clap_group = runtime_group_clap_command_with_schema_help(
2085            &group,
2086            &mut prefix,
2087            &self.middleware.schema_registry,
2088        );
2089        self.root = self.root.clone().subcommand(clap_group);
2090        let category = self
2091            .config
2092            .admin_category
2093            .clone()
2094            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2095        if !self.module_entries.iter().any(|e| e.name == "env") {
2096            self.module_entries.push(ModuleHelpEntry {
2097                category,
2098                name: "env".to_owned(),
2099                short: "Manage the active environment".to_owned(),
2100            });
2101        }
2102        self.refresh_root_long();
2103    }
2104
2105    fn default_auth_provider(&self) -> String {
2106        if !self.middleware.default_auth_provider.is_empty() {
2107            return self.middleware.default_auth_provider.clone();
2108        }
2109        self.middleware
2110            .auth
2111            .registered_names()
2112            .into_iter()
2113            .next()
2114            .unwrap_or_default()
2115    }
2116
2117    fn initialized_middleware(&self) -> Result<Middleware> {
2118        let Some(init_deps) = &self.init_deps else {
2119            return Ok(self.middleware.clone());
2120        };
2121        let mut guard = self
2122            .init_state
2123            .lock()
2124            .map_err(|_| CliCoreError::message("init deps lock poisoned"))?;
2125        if let Some(result) = guard.as_ref() {
2126            return result.clone().map_err(InitFailure::into_error);
2127        }
2128        let mut middleware = self.middleware.clone();
2129        let result = init_deps(&mut middleware)
2130            .map(|()| middleware)
2131            .map_err(|err| InitFailure::capture(&err));
2132        *guard = Some(result.clone());
2133        result.map_err(InitFailure::into_error)
2134    }
2135
2136    fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2137        if let Some(apply_flags) = &self.apply_flags {
2138            apply_flags(matches, middleware)?;
2139        }
2140        Ok(())
2141    }
2142
2143    /// Applies the global `--env` override to a per-run middleware snapshot.
2144    ///
2145    /// The flag is only registered when environments are configured, so when it
2146    /// is present `middleware.environments` is set too. Validates the requested
2147    /// name against the registered environments and updates `middleware.env`,
2148    /// returning an error for an unknown environment.
2149    fn apply_env_flag(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2150        // Guard on the environment system FIRST. The `--env` arg is only
2151        // registered when environments are configured (the same condition that
2152        // sets `middleware.environments`); calling `matches.get_one("env")` for
2153        // an arg that was never registered panics in clap, which would break
2154        // every CLI that does not use environments.
2155        let Some(environments) = middleware.environments.as_ref() else {
2156            return Ok(());
2157        };
2158        if let Some(env) = matches.get_one::<String>("env") {
2159            environments.resolve(env)?;
2160            middleware.env = env.clone();
2161        }
2162        Ok(())
2163    }
2164
2165    fn run_pre_run(
2166        &self,
2167        middleware: &mut Middleware,
2168        command_path: &str,
2169        args: &crate::middleware::ValueMap,
2170    ) -> Result<()> {
2171        if let Some(pre_run) = &self.pre_run {
2172            pre_run(middleware, command_path, args)?;
2173        }
2174        Ok(())
2175    }
2176
2177    fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta {
2178        if let Some(resolver) = &self.meta_resolver {
2179            resolver(command_path, meta)
2180        } else {
2181            meta
2182        }
2183    }
2184
2185    fn finish_run(&self, output: CliRunOutput) -> CliRunOutput {
2186        // Clear the per-thread credential-store flag so it does not leak into
2187        // subsequent sequential runs on the same thread.
2188        crate::config::clear_credential_store_flag();
2189        if let Some(on_shutdown) = &self.on_shutdown {
2190            on_shutdown();
2191        }
2192        output
2193    }
2194}
2195
2196fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option<Duration>) {
2197    middleware.output_format = flags.output_format.clone();
2198    middleware.verbose = flags.verbose.clone();
2199    middleware.dry_run = flags.dry_run;
2200    middleware.fields = flags.fields.clone();
2201    middleware.filter = flags.filter.clone();
2202    middleware.expr = flags.expr.clone();
2203    middleware.limit = flags.limit;
2204    middleware.offset = flags.offset;
2205    middleware.reason = flags.reason.clone();
2206    middleware.schema = flags.schema;
2207    middleware.timeout = timeout;
2208    middleware.debug = flags.debug.clone();
2209    middleware.search = flags.search.clone();
2210}
2211
2212/// Installs (or clears) the process-wide transport debug logger from the parsed
2213/// `--debug` pattern.
2214///
2215/// When `--debug` selects the `transport` component the engine publishes a
2216/// [`StderrTransportLogger`](crate::transport::StderrTransportLogger) — extended
2217/// with any [`CliConfig::with_redacted_debug_headers`] entries — which every
2218/// [`HttpClient`](crate::transport::HttpClient) built afterward picks up
2219/// automatically, with no per-command wiring. The logger is reset to a noop when
2220/// `transport` is not selected so the explicit setting always reflects the
2221/// current invocation rather than a stale process-global from an earlier one.
2222fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) {
2223    let logger: Arc<dyn crate::transport::TransportLogger> =
2224        if crate::debug_component_enabled(debug, "transport") {
2225            Arc::new(
2226                crate::transport::StderrTransportLogger::new()
2227                    .with_redacted_headers(extra_redacted.iter().cloned()),
2228            )
2229        } else {
2230            Arc::new(crate::transport::NoopTransportLogger)
2231        };
2232    crate::transport::set_default_transport_logger(logger);
2233}
2234
2235async fn run_with_timeout<F, T>(
2236    timeout: Option<Duration>,
2237    timeout_label: &str,
2238    future: F,
2239) -> Result<T>
2240where
2241    F: Future<Output = Result<T>>,
2242{
2243    let Some(timeout) = timeout else {
2244        return future.await;
2245    };
2246    match tokio::time::timeout(timeout, future).await {
2247        Ok(result) => result,
2248        Err(_) => Err(CliCoreError::message(format!(
2249            "command timed out after {timeout_label}"
2250        ))),
2251    }
2252}
2253
2254async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
2255where
2256    Run: Future<Output = CliRunOutput>,
2257    Shutdown: Future<Output = ()>,
2258{
2259    tokio::pin!(run);
2260    tokio::pin!(shutdown);
2261    tokio::select! {
2262        output = &mut run => output,
2263        () = &mut shutdown => CliRunOutput {
2264            exit_code: 130,
2265            rendered: "command interrupted\n".to_owned(),
2266        },
2267    }
2268}
2269
2270#[cfg(unix)]
2271async fn shutdown_signal() {
2272    let ctrl_c = tokio::signal::ctrl_c();
2273    match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2274        Ok(mut sigterm) => {
2275            tokio::select! {
2276                _ = ctrl_c => {},
2277                _ = sigterm.recv() => {},
2278            }
2279        }
2280        Err(_) => {
2281            drop(ctrl_c.await);
2282        }
2283    }
2284}
2285
2286#[cfg(not(unix))]
2287async fn shutdown_signal() {
2288    drop(tokio::signal::ctrl_c().await);
2289}
2290
2291fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
2292    let raw = raw.trim();
2293    if raw.is_empty() {
2294        return Ok(Some(Duration::from_secs(60)));
2295    }
2296    let Some(seconds) = parse_duration_seconds(raw) else {
2297        return Err(CliCoreError::message(format!(
2298            "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
2299        )));
2300    };
2301    if seconds <= 0.0 {
2302        Ok(None)
2303    } else {
2304        Ok(Some(Duration::from_secs_f64(seconds)))
2305    }
2306}
2307
2308fn parse_duration_seconds(raw: &str) -> Option<f64> {
2309    for (suffix, seconds) in [
2310        ("ns", 0.000_000_001_f64),
2311        ("us", 0.000_001_f64),
2312        ("µs", 0.000_001_f64),
2313        ("ms", 0.001_f64),
2314        ("s", 1.0_f64),
2315        ("m", 60.0_f64),
2316        ("h", 3600.0_f64),
2317    ] {
2318        if let Some(number) = raw.strip_suffix(suffix) {
2319            let value = number.parse::<f64>().ok()?;
2320            if !value.is_finite() {
2321                return None;
2322            }
2323            return Some(value * seconds);
2324        }
2325    }
2326    None
2327}
2328
2329fn render_cli_error(
2330    middleware: &Middleware,
2331    err: &(dyn std::error::Error + 'static),
2332    system: &str,
2333) -> CliRunOutput {
2334    let format = middleware
2335        .output_format
2336        .parse::<crate::output::OutputFormat>()
2337        .unwrap_or(crate::output::OutputFormat::Json);
2338    let envelope =
2339        crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
2340    match crate::output::render(format, &envelope) {
2341        Ok(rendered) => CliRunOutput {
2342            exit_code: exit_code_for_error(err),
2343            rendered,
2344        },
2345        Err(render_err) => CliRunOutput {
2346            exit_code: exit_code_for_error(err),
2347            rendered: render_err.to_string(),
2348        },
2349    }
2350}
2351
2352fn find_command_by_colon_path<'command>(
2353    root: &'command Command,
2354    path: &str,
2355) -> Option<&'command Command> {
2356    find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
2357}
2358
2359fn find_help_target<'command>(
2360    root: &'command Command,
2361    parts: &[&str],
2362) -> Option<&'command Command> {
2363    let mut current = root;
2364    let mut matched_any = false;
2365    for part in parts {
2366        let Some(next) = current.find_subcommand(part) else {
2367            break;
2368        };
2369        current = next;
2370        matched_any = true;
2371    }
2372    matched_any.then_some(current)
2373}
2374
2375fn find_command_and_canonical_path_by_colon_path<'command>(
2376    root: &'command Command,
2377    path: &str,
2378) -> Option<(&'command Command, Vec<String>)> {
2379    if path.is_empty() {
2380        return Some((root, Vec::new()));
2381    }
2382    let mut current = root;
2383    let mut canonical = Vec::new();
2384    for part in path.split(':') {
2385        current = current.find_subcommand(part)?;
2386        canonical.push(current.get_name().to_owned());
2387    }
2388    Some((current, canonical))
2389}
2390
2391fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
2392    if parts.is_empty() {
2393        return Some(String::new());
2394    }
2395    let mut current = root;
2396    let mut canonical = Vec::new();
2397    for part in parts {
2398        current = current.find_subcommand(part)?;
2399        canonical.push(current.get_name().to_owned());
2400    }
2401    Some(canonical.join(":"))
2402}
2403
2404fn extract_search_scope_parts(args: &[String]) -> Vec<String> {
2405    let mut parts = Vec::new();
2406    let mut index = 1;
2407    while index < args.len() {
2408        let arg = &args[index];
2409        if arg == "--search" || arg.starts_with("--search=") {
2410            break;
2411        }
2412        if arg.starts_with('-') {
2413            if !arg.contains('=') && index + 1 < args.len() && !args[index + 1].starts_with('-') {
2414                index += 2;
2415            } else {
2416                index += 1;
2417            }
2418            continue;
2419        }
2420        parts.push(arg.clone());
2421        index += 1;
2422    }
2423    parts
2424}
2425
2426fn collect_command_search_documents(
2427    command: &Command,
2428    prefix: &mut Vec<String>,
2429    aliases: &mut Vec<String>,
2430    docs: &mut Vec<SearchDocument>,
2431) {
2432    if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
2433        return;
2434    }
2435    if command.get_subcommands().next().is_some() {
2436        for child in command.get_subcommands() {
2437            prefix.push(child.get_name().to_owned());
2438            let alias_len = aliases.len();
2439            append_command_alias_terms(child, aliases);
2440            collect_command_search_documents(child, prefix, aliases, docs);
2441            aliases.truncate(alias_len);
2442            prefix.pop();
2443        }
2444        return;
2445    }
2446    if prefix.is_empty() {
2447        prefix.push(command.get_name().to_owned());
2448        append_command_alias_terms(command, aliases);
2449    }
2450    let path = prefix.join(" ");
2451    let alias_text = aliases.join(" ");
2452    docs.push(SearchDocument {
2453        id: format!("cmd:{path}"),
2454        kind: "command".to_owned(),
2455        title: path,
2456        summary: command
2457            .get_about()
2458            .map(ToString::to_string)
2459            .unwrap_or_default(),
2460        content: format!(
2461            "{} {} {} {}",
2462            command
2463                .get_about()
2464                .map(ToString::to_string)
2465                .unwrap_or_default(),
2466            command
2467                .get_long_about()
2468                .map(ToString::to_string)
2469                .unwrap_or_default(),
2470            command_flag_text(command),
2471            alias_text
2472        ),
2473    });
2474    if prefix.len() == 1 && prefix[0] == command.get_name() {
2475        prefix.pop();
2476    }
2477}
2478
2479fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
2480    aliases.extend(command.get_all_aliases().map(str::to_owned));
2481    aliases.extend(
2482        command
2483            .get_all_short_flag_aliases()
2484            .map(|alias| alias.to_string()),
2485    );
2486    aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
2487}
2488
2489fn command_flag_text(command: &Command) -> String {
2490    command
2491        .get_arguments()
2492        .filter_map(|arg| {
2493            let mut names = Vec::new();
2494            if let Some(short) = arg.get_short() {
2495                names.push(format!("-{short}"));
2496            }
2497            if let Some(long) = arg.get_long() {
2498                names.push(format!("--{long}"));
2499            }
2500            if let Some(short_aliases) = arg.get_all_short_aliases() {
2501                names.extend(
2502                    short_aliases
2503                        .into_iter()
2504                        .map(|short_alias| format!("-{short_alias}")),
2505                );
2506            }
2507            if let Some(aliases) = arg.get_all_aliases() {
2508                names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
2509            }
2510            (!names.is_empty()).then(|| names.join(" "))
2511        })
2512        .collect::<Vec<_>>()
2513        .join(" ")
2514}
2515
2516fn has_subcommand(command: &Command, name: &str) -> bool {
2517    command
2518        .get_subcommands()
2519        .any(|child| child.get_name() == name)
2520}
2521
2522fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
2523    let bool_flags = derive_bool_flags(root);
2524    let value_flags = derive_value_flags(root);
2525    let mut iter = args.iter().peekable();
2526    if iter
2527        .peek()
2528        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
2529    {
2530        iter.next();
2531    }
2532
2533    while let Some(arg) = iter.next() {
2534        match arg.as_str() {
2535            "--version" | "-v" => return true,
2536            "--" => return false,
2537            value if value.contains('=') || bool_flags.contains(value) => continue,
2538            value
2539                if value_flags.contains(value)
2540                    || unknown_flag_consumes_value(value, iter.peek()) =>
2541            {
2542                iter.next();
2543            }
2544            value if value.starts_with('-') => {}
2545            _ => return false,
2546        }
2547    }
2548    false
2549}
2550
2551fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
2552    let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
2553    let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
2554    let mut normalized = Vec::with_capacity(args.len());
2555    let mut index = 0;
2556    let mut current = root;
2557    while index < args.len() {
2558        let arg = &args[index];
2559        if index == 0 && arg_matches_root_name(arg, root.get_name()) {
2560            normalized.push(arg.clone());
2561            index += 1;
2562            continue;
2563        }
2564
2565        if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
2566            normalized.push(format!("{arg}={default}"));
2567            index += 1;
2568            continue;
2569        }
2570
2571        if let Some(default) = optional_string_defaults.get(arg.as_str()) {
2572            match args.get(index + 1) {
2573                None => {
2574                    normalized.push(format!("{arg}={default}"));
2575                    index += 1;
2576                    continue;
2577                }
2578                Some(next)
2579                    if current.get_name() == root.get_name()
2580                        || next.starts_with('-')
2581                        || direct_subcommand(current, next).is_some() =>
2582                {
2583                    normalized.push(format!("{arg}={default}"));
2584                    index += 1;
2585                    continue;
2586                }
2587                Some(next) => {
2588                    normalized.push(arg.clone());
2589                    normalized.push(next.clone());
2590                    index += 2;
2591                    continue;
2592                }
2593            }
2594        }
2595
2596        normalized.push(arg.clone());
2597        if !arg.starts_with('-')
2598            && let Some(next_command) = direct_subcommand(current, arg)
2599        {
2600            current = next_command;
2601        }
2602        index += 1;
2603    }
2604    normalized
2605}
2606
2607fn direct_subcommand<'command>(
2608    command: &'command Command,
2609    token: &str,
2610) -> Option<&'command Command> {
2611    command.get_subcommands().find(|child| {
2612        child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
2613    })
2614}
2615
2616fn unknown_group_command_message(root: &Command, positionals: &[String]) -> Option<String> {
2617    if positionals.is_empty() {
2618        return None;
2619    }
2620
2621    let mut current = root;
2622    let mut path = vec![root.get_name().to_owned()];
2623    for token in positionals {
2624        if let Some(next) = current.find_subcommand(token) {
2625            current = next;
2626            path.push(next.get_name().to_owned());
2627            continue;
2628        }
2629        if current.get_subcommands().next().is_some() {
2630            return Some(format!(
2631                "unknown command {token:?} for {:?}",
2632                path.join(" ")
2633            ));
2634        }
2635        return None;
2636    }
2637    None
2638}
2639
2640/// Detects the `<group> help [sub...]` form and returns the command path whose
2641/// help should be rendered.
2642///
2643/// The engine ships a curated root `help` command, so it disables clap's
2644/// auto-generated help subcommand on the root. That setting propagates to every
2645/// subcommand and cannot be re-enabled per child, so `<group> help` would
2646/// otherwise hit clap's "unrecognized subcommand" error even though the group's
2647/// help listing advertises a `help` entry. We recognize the form here so the
2648/// caller can route it through the curated help renderer, matching clap's
2649/// documented equivalence between `cmd group help sub` and `cmd help group sub`.
2650///
2651/// Only groups (commands that have subcommands) are matched: a group is pure
2652/// subcommand dispatch, so a `help` token in that position is unambiguously a
2653/// help request. Leaf commands may accept a literal `help` positional argument,
2654/// so they are left for clap to parse (`<leaf> --help` still works). A group
2655/// that registers its own real `help` subcommand is likewise deferred to clap,
2656/// which dispatches the user-defined command (only auto-generated help is
2657/// suppressed).
2658///
2659/// `command_keyword_count` is the number of leading positionals that are
2660/// genuine command keywords (those before any `--`). A `help` at or beyond that
2661/// index is a literal operand after `--`, not a help request, so it is ignored.
2662fn group_help_target_parts(
2663    root: &Command,
2664    positionals: &[String],
2665    command_keyword_count: usize,
2666) -> Option<Vec<String>> {
2667    let help_index = positionals.iter().position(|token| token == "help")?;
2668    // A leading `help` is the curated root help command; let it flow through.
2669    if help_index == 0 {
2670        return None;
2671    }
2672    // A `help` after a `--` separator is a literal operand; leave it for clap.
2673    if help_index >= command_keyword_count {
2674        return None;
2675    }
2676    let prefix = &positionals[..help_index];
2677    let mut current = root;
2678    for token in prefix {
2679        current = current.find_subcommand(token)?;
2680    }
2681    // The token before `help` must resolve to a group; leaves are left to clap.
2682    current.get_subcommands().next()?;
2683    // Defer to clap when the group defines a real `help` subcommand of its own.
2684    if current.find_subcommand("help").is_some() {
2685        return None;
2686    }
2687    // `<group> help <sub...>` shows help for `<group> <sub...>`.
2688    let suffix = &positionals[help_index + 1..];
2689    Some(prefix.iter().chain(suffix).cloned().collect())
2690}
2691
2692/// Rewrites a `<group> help [sub...]` invocation into the canonical
2693/// `help <group> [sub...]` argument vector.
2694///
2695/// Only the positional command tokens are reordered (from `[group..., help,
2696/// sub...]` to `[help, group..., sub...]`); every flag — including `key=value`
2697/// forms, value-consuming flags, unknown flags that consume a value, and
2698/// anything after `--` — is preserved in its original place. Reordering keeps
2699/// the positional count unchanged, so the rewritten stream is filled slot for
2700/// slot. `parts` is the resolved command path (group + subcommand) from
2701/// [`group_help_target_parts`].
2702fn rewrite_group_help_args(
2703    clap_args: &[String],
2704    root_name: &str,
2705    bool_flags: &BTreeSet<String>,
2706    value_flags: &BTreeSet<String>,
2707    parts: &[String],
2708) -> Vec<String> {
2709    // New positional order: the curated `help` command, then the command path.
2710    let mut next_positional = std::iter::once("help".to_owned())
2711        .chain(parts.iter().cloned())
2712        .peekable();
2713    let mut out = Vec::with_capacity(clap_args.len());
2714    let mut iter = clap_args.iter().peekable();
2715    if iter
2716        .peek()
2717        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
2718        && let Some(program) = iter.next()
2719    {
2720        out.push(program.clone());
2721    }
2722
2723    let mut take_positional =
2724        |fallback: &String| next_positional.next().unwrap_or(fallback.clone());
2725
2726    while let Some(arg) = iter.next() {
2727        if arg == "--" {
2728            out.push(arg.clone());
2729            // Everything after `--` is positional.
2730            for rest in iter.by_ref() {
2731                out.push(take_positional(rest));
2732            }
2733            break;
2734        }
2735        if arg.contains('=') || bool_flags.contains(arg) {
2736            out.push(arg.clone());
2737            continue;
2738        }
2739        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
2740            out.push(arg.clone());
2741            if let Some(value) = iter.next() {
2742                out.push(value.clone());
2743            }
2744            continue;
2745        }
2746        if arg.starts_with('-') {
2747            out.push(arg.clone());
2748            continue;
2749        }
2750        out.push(take_positional(arg));
2751    }
2752    // Defensive: emit any positionals not yet placed (counts normally match).
2753    out.extend(next_positional);
2754    out
2755}
2756
2757fn positional_command_tokens(
2758    args: &[String],
2759    root_name: &str,
2760    bool_flags: &BTreeSet<String>,
2761    value_flags: &BTreeSet<String>,
2762) -> Vec<String> {
2763    let mut tokens = Vec::new();
2764    let mut iter = args.iter().peekable();
2765    if iter
2766        .peek()
2767        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
2768    {
2769        iter.next();
2770    }
2771
2772    while let Some(arg) = iter.next() {
2773        if arg == "--" {
2774            tokens.extend(iter.cloned());
2775            break;
2776        }
2777        if arg.contains('=') {
2778            continue;
2779        }
2780        if bool_flags.contains(arg) {
2781            continue;
2782        }
2783        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
2784            iter.next();
2785            continue;
2786        }
2787        if arg.starts_with('-') {
2788            continue;
2789        }
2790        tokens.push(arg.clone());
2791    }
2792    tokens
2793}
2794
2795fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
2796    arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
2797}
2798
2799fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
2800    arg == root_name
2801        || Path::new(arg)
2802            .file_stem()
2803            .and_then(|n| n.to_str())
2804            .is_some_and(|n| n == root_name)
2805}
2806
2807/// Outcome of [`Cli::resolve_argv0`]: either rewritten arguments to feed the
2808/// normal pipeline, or a fully rendered result to return immediately.
2809enum Argv0Outcome {
2810    /// Continue the normal run pipeline with these arguments.
2811    Proceed(Vec<String>),
2812    /// Return this already-rendered result without further processing.
2813    Handled(CliRunOutput),
2814}
2815
2816/// Extracts the bare program name from an `argv[0]` value, dropping any directory
2817/// path and file extension (e.g. `/usr/bin/pl` or `pl.exe` both yield `pl`).
2818/// Falls back to the raw value when no file stem can be derived.
2819fn program_basename(arg: &str) -> String {
2820    Path::new(arg)
2821        .file_stem()
2822        .and_then(|stem| stem.to_str())
2823        .map_or_else(|| arg.to_owned(), ToOwned::to_owned)
2824}
2825
2826/// Returns `true` when `name` is a valid alternative `argv[0]` route name: a
2827/// non-empty token of ASCII letters, digits, `-`, or `_`. This keeps the name
2828/// safe as a link/shim filename and as an `argv[0]` basename (which is matched
2829/// with its extension stripped, so an embedded dot would break matching).
2830fn is_valid_argv0_name(name: &str) -> bool {
2831    !name.is_empty()
2832        && name.chars().all(|character| {
2833            character.is_ascii_alphanumeric() || character == '-' || character == '_'
2834        })
2835}
2836
2837/// Returns `true` when the entry at `link` already matches what [`Cli::create_link`]
2838/// would produce for `method`/`target`/`name`, so it can be left untouched. A
2839/// mismatch (wrong kind, stale symlink target, or differing contents) returns
2840/// `false` so the caller replaces it.
2841fn argv0_link_matches(
2842    link: &Path,
2843    target: &Path,
2844    name: &str,
2845    method: Argv0LinkMethod,
2846) -> std::io::Result<bool> {
2847    let metadata = std::fs::symlink_metadata(link)?;
2848    match method {
2849        Argv0LinkMethod::SoftLink => {
2850            Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target)
2851        }
2852        Argv0LinkMethod::HardLink => {
2853            if metadata.file_type().is_symlink() {
2854                return Ok(false);
2855            }
2856            // A correct hard link is indistinguishable from the target by content;
2857            // comparing bytes also accepts an identical copy, which is harmless.
2858            Ok(std::fs::read(link)? == std::fs::read(target)?)
2859        }
2860        Argv0LinkMethod::Script => {
2861            if metadata.file_type().is_symlink() {
2862                return Ok(false);
2863            }
2864            Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name)))
2865        }
2866    }
2867}
2868
2869/// File name for an alternative `argv[0]` link, per method and host platform.
2870fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String {
2871    let extension = match method {
2872        Argv0LinkMethod::Script if cfg!(windows) => ".cmd",
2873        // Unix scripts are extension-less executables; links carry `.exe` on Windows.
2874        Argv0LinkMethod::Script => "",
2875        _ if cfg!(windows) => ".exe",
2876        _ => "",
2877    };
2878    format!("{name}{extension}")
2879}
2880
2881/// Contents of an alternative `argv[0]` shim script that forwards to `target`
2882/// via the explicit `argv0` command. A `.cmd` batch file on Windows, an
2883/// executable POSIX shell script elsewhere.
2884fn argv0_script_contents(target: &Path, name: &str) -> String {
2885    let target = target.display();
2886    if cfg!(windows) {
2887        format!("@\"{target}\" argv0 {name} %*\r\n")
2888    } else {
2889        format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n")
2890    }
2891}
2892
2893#[cfg(unix)]
2894fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2895    std::os::unix::fs::symlink(target, link)
2896}
2897
2898#[cfg(windows)]
2899fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2900    std::os::windows::fs::symlink_file(target, link)
2901}
2902
2903#[cfg(not(any(unix, windows)))]
2904fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
2905    Err(std::io::Error::new(
2906        std::io::ErrorKind::Unsupported,
2907        "symlink creation is not supported on this platform",
2908    ))
2909}
2910
2911/// Marks a freshly written shim script executable on Unix; a no-op elsewhere.
2912#[cfg(unix)]
2913fn make_executable(path: &Path) -> std::io::Result<()> {
2914    use std::os::unix::fs::PermissionsExt;
2915    let mut permissions = std::fs::metadata(path)?.permissions();
2916    permissions.set_mode(0o755);
2917    std::fs::set_permissions(path, permissions)
2918}
2919
2920#[cfg(not(unix))]
2921fn make_executable(_path: &Path) -> std::io::Result<()> {
2922    Ok(())
2923}
2924
2925fn register_runtime_group_metadata(
2926    group: &RuntimeGroupSpec,
2927    prefix: &mut Vec<String>,
2928    schemas: &mut SchemaRegistry,
2929    views: &mut HumanViewRegistry,
2930) {
2931    prefix.push(group.group.name.clone());
2932    for child_group in &group.groups {
2933        register_runtime_group_metadata(child_group, prefix, schemas, views);
2934    }
2935    for child in &group.commands {
2936        prefix.push(child.spec.name.clone());
2937        let command_path = prefix.join(":");
2938        register_command_schema(&child.spec, &command_path, schemas);
2939        // An inline `with_view` is registered under the command's own path; the
2940        // dispatch references it by that path. A `with_view_id` takes precedence
2941        // (dispatch uses it instead), so skip the inline registration when one is
2942        // set — registering it would leave an unused entry. Shared views are
2943        // registered separately by the module/CLI.
2944        if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
2945            views.register(HumanViewDef::new(
2946                command_path,
2947                child.spec.view_columns.clone(),
2948            ));
2949        }
2950        prefix.pop();
2951    }
2952    prefix.pop();
2953}
2954
2955fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
2956    if let Some(schema) = &spec.output_schema {
2957        schemas.register_info(command_path.to_owned(), schema.clone());
2958    }
2959}
2960
2961fn runtime_group_clap_command_with_schema_help(
2962    group: &RuntimeGroupSpec,
2963    prefix: &mut Vec<String>,
2964    schemas: &SchemaRegistry,
2965) -> Command {
2966    let mut command = group_clap_command_without_children(&group.group);
2967    prefix.push(group.group.name.clone());
2968    for child_group in &group.groups {
2969        command = command.subcommand(runtime_group_clap_command_with_schema_help(
2970            child_group,
2971            prefix,
2972            schemas,
2973        ));
2974    }
2975    for child in &group.commands {
2976        prefix.push(child.spec.name.clone());
2977        let command_path = prefix.join(":");
2978        command = command.subcommand(command_clap_command_with_schema_help(
2979            &child.spec,
2980            &command_path,
2981            schemas,
2982        ));
2983        prefix.pop();
2984    }
2985    prefix.pop();
2986    command
2987}
2988
2989fn group_clap_command_without_children(group: &GroupSpec) -> Command {
2990    let mut command = Command::new(group.name.clone())
2991        .about(group.short.clone())
2992        .help_template(GROUP_HELP_TEMPLATE);
2993    if let Some(long) = &group.long
2994        && !long.is_empty()
2995    {
2996        command = command.long_about(long.clone());
2997    }
2998    for alias in &group.aliases {
2999        command = command.alias(alias.clone());
3000    }
3001    if group.hidden {
3002        command = command.hide(true);
3003    }
3004    command
3005}
3006
3007fn command_clap_command_with_schema_help(
3008    spec: &CommandSpec,
3009    command_path: &str,
3010    schemas: &SchemaRegistry,
3011) -> Command {
3012    let mut command = spec.clap_command();
3013    let Some(schema) = schemas.get_by_path(command_path) else {
3014        return command;
3015    };
3016    let schema_help = format_help_section(&schema.fields);
3017    if schema_help.is_empty() {
3018        return command;
3019    }
3020    let base = spec
3021        .long
3022        .as_ref()
3023        .filter(|long| !long.is_empty())
3024        .cloned()
3025        .unwrap_or_else(|| spec.short.clone());
3026    let long = if base.is_empty() {
3027        schema_help
3028    } else {
3029        format!("{base}\n\n{schema_help}")
3030    };
3031    command = command.long_about(long);
3032    command
3033}
3034
3035fn process_exit_code(code: i32) -> ExitCode {
3036    if code == 0 {
3037        return ExitCode::SUCCESS;
3038    }
3039    match u8::try_from(code) {
3040        Ok(code) if code != 0 => ExitCode::from(code),
3041        Ok(_) | Err(_) => ExitCode::from(1),
3042    }
3043}
3044
3045async fn run_streaming_command(
3046    middleware: &Middleware,
3047    request: MiddlewareRequest<'_>,
3048    raw_matches: Arc<ArgMatches>,
3049    streaming_handler: crate::command::StreamingCommandHandler,
3050) -> Result<CliRunOutput> {
3051    use tokio::{io::AsyncWriteExt, sync::mpsc};
3052
3053    let args_for_handler = request.args.clone();
3054    let user_args_for_handler = request.user_args.clone();
3055    let handler_path = request.command_path.to_owned();
3056    let middleware_for_handler = middleware.clone();
3057    let raw_matches_for_handler = raw_matches;
3058
3059    let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
3060    let sender = StreamSender(tx);
3061
3062    // Drain the channel concurrently so the handler's sends don't stall
3063    // while the writer flushes to stdout. If stdout is under backpressure
3064    // the bounded channel can still fill and the handler will await send.
3065    let writer = tokio::spawn(async move {
3066        let mut stdout = tokio::io::stdout();
3067        while let Some(event) = rx.recv().await {
3068            let Ok(line) = serde_json::to_string(&event) else {
3069                continue;
3070            };
3071            if stdout.write_all(line.as_bytes()).await.is_err()
3072                || stdout.write_all(b"\n").await.is_err()
3073                || stdout.flush().await.is_err()
3074            {
3075                break;
3076            }
3077        }
3078    });
3079
3080    let output = middleware
3081        .run(request, async move |credential| {
3082            streaming_handler(
3083                CommandContext {
3084                    credential,
3085                    args: args_for_handler,
3086                    user_args: user_args_for_handler,
3087                    command_path: handler_path,
3088                    middleware: middleware_for_handler,
3089                    raw_matches: raw_matches_for_handler,
3090                },
3091                sender,
3092            )
3093            .await?;
3094            Ok(crate::CommandResult::new(serde_json::Value::Null))
3095        })
3096        .await;
3097
3098    // Handler has completed; its sender is dropped, which closes the channel.
3099    // Wait for the writer task to flush all remaining events.
3100    let _write_result = writer.await;
3101
3102    match output {
3103        Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
3104            exit_code: 0,
3105            rendered: String::new(),
3106        }),
3107        Ok(out) => Ok(out.into()),
3108        Err(err) => Ok(CliRunOutput {
3109            exit_code: exit_code_for_error(&err),
3110            rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
3111        }),
3112    }
3113}
3114
3115#[cfg(test)]
3116mod user_agent_tests {
3117    use super::*;
3118
3119    #[test]
3120    fn user_agent_string_derives_name_and_version_by_default() {
3121        let config =
3122            CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3"));
3123        assert_eq!(config.user_agent_string(), "gdx/1.2.3");
3124    }
3125
3126    #[test]
3127    fn user_agent_string_prefers_explicit_override() {
3128        let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx")
3129            .with_build(BuildInfo::new("1.2.3"))
3130            .with_user_agent("gdx-cli/9.9 (custom)");
3131        assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)");
3132    }
3133
3134    #[test]
3135    fn user_agent_string_omits_version_when_absent() {
3136        let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx");
3137        assert_eq!(config.user_agent_string(), "gdx");
3138    }
3139
3140    #[test]
3141    fn install_default_user_agent_publishes_config_value() {
3142        let _guard = crate::transport::client::UA_TEST_LOCK
3143            .lock()
3144            .unwrap_or_else(std::sync::PoisonError::into_inner);
3145        let _restore = crate::transport::client::RestoreDefaultUserAgent;
3146        crate::transport::set_default_user_agent("cli/dev");
3147        let cli = Cli::new(
3148            CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
3149        );
3150        cli.install_default_user_agent();
3151        assert_eq!(
3152            crate::transport::client::default_user_agent(),
3153            "uatest/4.5.6"
3154        );
3155    }
3156
3157    #[test]
3158    fn install_debug_transport_logger_tracks_the_debug_pattern() {
3159        let _guard = crate::transport::client::TRANSPORT_LOGGER_TEST_LOCK
3160            .lock()
3161            .unwrap_or_else(std::sync::PoisonError::into_inner);
3162        let _restore = crate::transport::client::RestoreDefaultTransportLogger;
3163
3164        // `transport` selected -> an active (enabled) logger is published.
3165        install_debug_transport_logger("transport", &[]);
3166        assert!(crate::transport::default_transport_logger().enabled());
3167
3168        // Wildcard with transport excluded -> back to a disabled (noop) logger.
3169        install_debug_transport_logger("*,-transport", &[]);
3170        assert!(!crate::transport::default_transport_logger().enabled());
3171
3172        // Empty pattern -> disabled (noop).
3173        install_debug_transport_logger("transport", &[]);
3174        install_debug_transport_logger("", &[]);
3175        assert!(!crate::transport::default_transport_logger().enabled());
3176    }
3177}
3178
3179#[cfg(test)]
3180mod env_config_tests {
3181    use super::*;
3182
3183    #[test]
3184    fn with_environments_stores_shared_arc_with_consumer_app_id() {
3185        // The consumer sets app_id on the Environments before sharing the Arc;
3186        // CliConfig stores it as-is, so the file path resolves only because the
3187        // consumer stamped the matching app_id (not because the engine did).
3188        let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new(
3189            crate::environments::Environments::new("prod")
3190                .with_app_id("gddy")
3191                .with_config_file(true),
3192        ));
3193        let envs = cfg.environments.as_ref().expect("environments set");
3194        assert!(envs.config_file_path().is_some());
3195    }
3196
3197    #[tokio::test]
3198    async fn env_flag_overrides_default_and_reaches_middleware_env() {
3199        use crate::{CommandResult, CommandSpec, RuntimeCommandSpec};
3200        use serde_json::json;
3201        let mut cli = Cli::new(
3202            CliConfig::new("envtest", "Env test", "envtest").with_environments(Arc::new(
3203                crate::environments::Environments::new("prod")
3204                    .with_environment("prod", crate::environments::EnvironmentDef::new())
3205                    .with_environment("ote", crate::environments::EnvironmentDef::new()),
3206            )),
3207        );
3208        cli.add_command(RuntimeCommandSpec::new_with_context(
3209            CommandSpec::new("whichenv", "echo env").no_auth(true),
3210            async |ctx| {
3211                Ok(CommandResult::new(
3212                    json!({ "env": ctx.environment()?.name }),
3213                ))
3214            },
3215        ));
3216        let out = cli
3217            .run(["envtest", "whichenv", "--env", "ote", "--output", "json"])
3218            .await;
3219        assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
3220        assert!(out.rendered.contains("\"env\""));
3221        assert!(out.rendered.contains("ote"));
3222    }
3223
3224    #[tokio::test]
3225    async fn unknown_env_flag_produces_error_envelope() {
3226        let cli = Cli::new(
3227            CliConfig::new("envtest2", "Env test", "envtest2").with_environments(Arc::new(
3228                crate::environments::Environments::new("prod")
3229                    .with_environment("prod", crate::environments::EnvironmentDef::new()),
3230            )),
3231        );
3232        let out = cli.run(["envtest2", "tree", "--env", "nope"]).await;
3233        assert_ne!(out.exit_code, 0);
3234        assert!(out.rendered.contains("nope"));
3235    }
3236}