Skip to main content

cli_engine/
cli.rs

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