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