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