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