Skip to main content

cli_engine/
cli.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    future::Future,
4    io::Write,
5    process::ExitCode,
6    sync::{Arc, Mutex},
7    time::Duration,
8};
9
10mod builtins;
11mod help;
12mod tree_render;
13
14use clap::{ArgMatches, Command};
15
16use crate::{
17    ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec,
18    GroupSpec, GuideEntry, Middleware, MiddlewareRequest, Result, RuntimeCommandSpec,
19    RuntimeGroupSpec,
20    auth::commands::auth_command_group,
21    command::{
22        CommandContext, StreamSender, command_args_from_matches, command_path_from_matches,
23        leaf_matches,
24    },
25    error::exit_code_for_error,
26    flags::{
27        GlobalFlags, default_output_format, derive_bool_flags, derive_value_flags,
28        extract_command_path, extract_output_format, extract_search_query,
29        global_flags_from_matches, has_true_schema_flag, register_global_flags,
30    },
31    guide::guide_content,
32    module::{Module, ModuleContext},
33    output::{
34        HumanViewDef, NextAction, SchemaRegistry, format_help_section,
35        global_human_view_registry_snapshot, global_schema_registry_snapshot,
36    },
37    search::{SearchDocument, SearchIndex},
38};
39
40use builtins::{guide_args, guide_command, help_args, help_command};
41use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE};
42pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human};
43
44/// Build metadata shown by the root `--version` flag.
45#[derive(Clone, Debug, Default, Eq, PartialEq)]
46pub struct BuildInfo {
47    /// Semantic version or other release label.
48    pub version: String,
49    /// Optional source control commit identifier.
50    pub commit: Option<String>,
51    /// Optional build date string.
52    pub date: Option<String>,
53}
54
55impl BuildInfo {
56    /// Creates build metadata with only a version string.
57    #[must_use]
58    pub fn new(version: impl Into<String>) -> Self {
59        Self {
60            version: version.into(),
61            commit: None,
62            date: None,
63        }
64    }
65
66    /// Adds a commit identifier to the version string shown by `--version`.
67    #[must_use]
68    pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
69        self.commit = Some(commit.into());
70        self
71    }
72
73    /// Adds a build date to the version string shown by `--version`.
74    #[must_use]
75    pub fn with_date(mut self, date: impl Into<String>) -> Self {
76        self.date = Some(date.into());
77        self
78    }
79
80    /// Returns the rendered version string used by the root `--version` flag.
81    #[must_use]
82    pub fn version_string(&self) -> String {
83        let commit = self.commit.as_deref().unwrap_or_default();
84        let date = self.date.as_deref().unwrap_or_default();
85
86        if commit.is_empty() && date.is_empty() {
87            self.version.clone()
88        } else {
89            format!("{} (commit {commit}, built {date})", self.version)
90        }
91    }
92}
93
94/// Late dependency initializer run once before real command execution.
95pub type InitDeps = Arc<dyn Fn(&mut Middleware) -> Result<()> + Send + Sync>;
96/// Hook used to add application-specific global flags to the root `clap` command.
97pub type RegisterFlags = Arc<dyn Fn(Command) -> Command + Send + Sync>;
98/// Hook used to copy parsed application-specific flags into middleware.
99pub type ApplyFlags = Arc<dyn Fn(&ArgMatches, &mut Middleware) -> Result<()> + Send + Sync>;
100/// Hook run immediately before executable commands and built-ins.
101pub type PreRun =
102    Arc<dyn Fn(&mut Middleware, &str, &crate::middleware::ValueMap) -> Result<()> + Send + Sync>;
103/// Hook used to adjust command metadata globally before middleware executes.
104pub type ResolveMeta = Arc<dyn Fn(&str, CommandMeta) -> CommandMeta + Send + Sync>;
105/// Hook called after a CLI run completes.
106pub type OnShutdown = Arc<dyn Fn() + Send + Sync>;
107/// Hook that contributes extra root-scope `--search` documents.
108pub type ExtraSearchDocs = Arc<dyn Fn() -> Vec<SearchDocument> + Send + Sync>;
109/// Hook that supplies the suggested next actions shown when the CLI is invoked
110/// with no subcommand (bare root). The same actions drive a human "Next actions"
111/// section and the JSON discovery envelope.
112pub type RootNextActions = Arc<dyn Fn() -> Vec<NextAction> + Send + Sync>;
113
114/// Default name for the admin help category, under which the engine files the
115/// built-in `auth` command when a consumer does not override it via
116/// [`CliConfig::with_admin_category`].
117const DEFAULT_ADMIN_CATEGORY: &str = "Admin";
118
119/// Declarative configuration for a CLI application.
120///
121/// Use [`CliConfig::new`] for the common path and chain `with_*` methods for
122/// modules, auth providers, guides, views, and lifecycle hooks. Direct struct
123/// literals remain available for advanced setup and tests.
124#[derive(Clone, Default)]
125pub struct CliConfig {
126    /// Root command name shown in usage output.
127    pub name: String,
128    /// One-line root command description.
129    pub short: String,
130    /// Optional longer root command description. Defaults to `short`.
131    pub long: Option<String>,
132    /// Version/build metadata for `--version`.
133    pub build: BuildInfo,
134    /// Application id stored in middleware and output metadata.
135    pub app_id: String,
136    /// Fallback auth provider when a command does not select one explicitly.
137    pub default_auth_provider: Option<String>,
138    /// Domain modules mounted under the root command.
139    pub modules: Vec<Module>,
140    /// Additional top-level runtime commands.
141    pub commands: Vec<RuntimeCommandSpec>,
142    /// Global guide entries mounted under `guide`.
143    pub guides: Vec<GuideEntry>,
144    /// Global human output views.
145    pub views: Vec<HumanViewDef>,
146    /// Providers registered before command execution starts.
147    pub auth_providers: Vec<Arc<dyn AuthProvider>>,
148    /// Optional authorization gatekeeper injected into middleware.
149    pub authz: Option<Arc<dyn Authorizer>>,
150    /// Optional audit recorder injected into middleware.
151    pub auditor: Option<Arc<dyn Auditor>>,
152    /// Optional activity event sink injected into middleware.
153    pub activity: Option<Arc<dyn ActivityEmitter>>,
154    /// Optional late initializer for runtime dependencies.
155    pub init_deps: Option<InitDeps>,
156    /// Optional hook for adding application-specific global flags.
157    pub register_flags: Option<RegisterFlags>,
158    /// Optional hook for applying parsed application-specific flags.
159    pub apply_flags: Option<ApplyFlags>,
160    /// Optional hook run before executable commands and built-ins.
161    pub pre_run: Option<PreRun>,
162    /// Optional hook for global command metadata adjustments.
163    pub meta_resolver: Option<ResolveMeta>,
164    /// Optional hook called after each run.
165    pub on_shutdown: Option<OnShutdown>,
166    /// Optional root-scope search document provider.
167    pub extra_search_docs: Option<ExtraSearchDocs>,
168    /// Optional provider for the bare-root suggested next actions.
169    pub root_next_actions: Option<RootNextActions>,
170    /// Name of the admin help category. The engine files its built-in `auth`
171    /// command under this heading; apps should use the same name for their own
172    /// admin modules (e.g. godaddy's `env`). When unset, defaults to `"Admin"`;
173    /// set it to match a consumer's own taxonomy (e.g. gdx's "Administration").
174    pub admin_category: Option<String>,
175}
176
177impl CliConfig {
178    /// Creates the minimum useful CLI configuration.
179    #[must_use]
180    pub fn new(
181        name: impl Into<String>,
182        short: impl Into<String>,
183        app_id: impl Into<String>,
184    ) -> Self {
185        Self {
186            name: name.into(),
187            short: short.into(),
188            app_id: app_id.into(),
189            ..Self::default()
190        }
191    }
192
193    /// Sets root long help text.
194    #[must_use]
195    pub fn with_long(mut self, long: impl Into<String>) -> Self {
196        self.long = Some(long.into());
197        self
198    }
199
200    /// Sets build metadata used by `--version`.
201    #[must_use]
202    pub fn with_build(mut self, build: BuildInfo) -> Self {
203        self.build = build;
204        self
205    }
206
207    /// Sets the fallback auth provider for commands that do not name one.
208    #[must_use]
209    pub fn with_default_auth_provider(mut self, provider: impl Into<String>) -> Self {
210        self.default_auth_provider = Some(provider.into());
211        self
212    }
213
214    /// Adds one domain module.
215    #[must_use]
216    pub fn with_module(mut self, module: Module) -> Self {
217        self.modules.push(module);
218        self
219    }
220
221    /// Adds several domain modules.
222    #[must_use]
223    pub fn with_modules(mut self, modules: impl IntoIterator<Item = Module>) -> Self {
224        self.modules.extend(modules);
225        self
226    }
227
228    /// Adds a top-level runtime command outside a module.
229    #[must_use]
230    pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
231        self.commands.push(command);
232        self
233    }
234
235    /// Adds one global guide.
236    #[must_use]
237    pub fn with_guide(mut self, guide: GuideEntry) -> Self {
238        self.guides.push(guide);
239        self
240    }
241
242    /// Adds several global guides.
243    #[must_use]
244    pub fn with_guides(mut self, guides: impl IntoIterator<Item = GuideEntry>) -> Self {
245        self.guides.extend(guides);
246        self
247    }
248
249    /// Adds one global human view.
250    #[must_use]
251    pub fn with_view(mut self, view: HumanViewDef) -> Self {
252        self.views.push(view);
253        self
254    }
255
256    /// Registers one auth provider.
257    #[must_use]
258    pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
259        self.auth_providers.push(provider);
260        self
261    }
262
263    /// Sets the authorization gatekeeper.
264    #[must_use]
265    pub fn with_authz(mut self, authz: Arc<dyn Authorizer>) -> Self {
266        self.authz = Some(authz);
267        self
268    }
269
270    /// Sets the audit recorder.
271    #[must_use]
272    pub fn with_auditor(mut self, auditor: Arc<dyn Auditor>) -> Self {
273        self.auditor = Some(auditor);
274        self
275    }
276
277    /// Sets the activity event sink.
278    #[must_use]
279    pub fn with_activity(mut self, activity: Arc<dyn ActivityEmitter>) -> Self {
280        self.activity = Some(activity);
281        self
282    }
283
284    /// Sets the late dependency initializer.
285    #[must_use]
286    pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self {
287        self.init_deps = Some(init_deps);
288        self
289    }
290
291    /// Sets the application-specific global flag registration hook.
292    #[must_use]
293    pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self {
294        self.register_flags = Some(register_flags);
295        self
296    }
297
298    /// Sets the application-specific parsed flag application hook.
299    #[must_use]
300    pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self {
301        self.apply_flags = Some(apply_flags);
302        self
303    }
304
305    /// Sets the pre-run hook.
306    #[must_use]
307    pub fn with_pre_run(mut self, pre_run: PreRun) -> Self {
308        self.pre_run = Some(pre_run);
309        self
310    }
311
312    /// Sets the command metadata resolver hook.
313    #[must_use]
314    pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self {
315        self.meta_resolver = Some(meta_resolver);
316        self
317    }
318
319    /// Sets the shutdown hook.
320    #[must_use]
321    pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self {
322        self.on_shutdown = Some(on_shutdown);
323        self
324    }
325
326    /// Sets the provider for additional root-scope search documents.
327    #[must_use]
328    pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self {
329        self.extra_search_docs = Some(extra_search_docs);
330        self
331    }
332
333    /// Sets the provider for the bare-root suggested next actions.
334    #[must_use]
335    pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self {
336        self.root_next_actions = Some(root_next_actions);
337        self
338    }
339
340    /// Sets the name of the admin help category. The engine files the built-in
341    /// `auth` command there; apps should use the same name for their own admin
342    /// modules (e.g. godaddy's `env`). Optional: defaults to `"Admin"`.
343    #[must_use]
344    pub fn with_admin_category(mut self, category: impl Into<String>) -> Self {
345        self.admin_category = Some(category.into());
346        self
347    }
348}
349
350impl std::fmt::Debug for CliConfig {
351    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        formatter
353            .debug_struct("CliConfig")
354            .field("name", &self.name)
355            .field("short", &self.short)
356            .field("long", &self.long)
357            .field("build", &self.build)
358            .field("app_id", &self.app_id)
359            .field("default_auth_provider", &self.default_auth_provider)
360            .field("modules", &self.modules)
361            .field("commands", &self.commands)
362            .field("guides", &self.guides)
363            .field("views", &self.views)
364            .field("auth_providers_len", &self.auth_providers.len())
365            .field("has_authz", &self.authz.is_some())
366            .field("has_auditor", &self.auditor.is_some())
367            .field("has_activity", &self.activity.is_some())
368            .field("has_init_deps", &self.init_deps.is_some())
369            .field("has_register_flags", &self.register_flags.is_some())
370            .field("has_apply_flags", &self.apply_flags.is_some())
371            .field("has_pre_run", &self.pre_run.is_some())
372            .field("has_meta_resolver", &self.meta_resolver.is_some())
373            .field("has_on_shutdown", &self.on_shutdown.is_some())
374            .field("has_extra_search_docs", &self.extra_search_docs.is_some())
375            .field("has_root_next_actions", &self.root_next_actions.is_some())
376            .field("admin_category", &self.admin_category)
377            .finish()
378    }
379}
380
381/// Captured result of running a CLI in tests or embedding contexts.
382#[derive(Clone, Debug, PartialEq)]
383pub struct CliRunOutput {
384    /// Process-style exit code.
385    pub exit_code: i32,
386    /// Rendered stdout or stderr payload.
387    pub rendered: String,
388}
389
390impl From<crate::middleware::MiddlewareOutput> for CliRunOutput {
391    fn from(o: crate::middleware::MiddlewareOutput) -> Self {
392        Self {
393            exit_code: o.exit_code,
394            rendered: o.rendered,
395        }
396    }
397}
398
399/// Configured CLI application.
400///
401/// A `Cli` owns the `clap` command tree, middleware, registered runtime
402/// commands, guides, schemas, and built-ins. Consumer binaries normally create
403/// one `Cli` and call [`Cli::execute`].
404#[derive(Clone)]
405pub struct Cli {
406    config: CliConfig,
407    middleware: Middleware,
408    root: Command,
409    commands: BTreeMap<String, RuntimeCommandSpec>,
410    module_entries: Vec<ModuleHelpEntry>,
411    guide_entries: Vec<GuideEntry>,
412    init_deps: Option<InitDeps>,
413    apply_flags: Option<ApplyFlags>,
414    pre_run: Option<PreRun>,
415    meta_resolver: Option<ResolveMeta>,
416    on_shutdown: Option<OnShutdown>,
417    extra_search_docs: Option<ExtraSearchDocs>,
418    root_next_actions: Option<RootNextActions>,
419    init_state: Arc<Mutex<Option<std::result::Result<Middleware, InitFailure>>>>,
420}
421
422#[derive(Clone, Debug, Eq, PartialEq)]
423struct InitFailure {
424    message: String,
425    code: String,
426    system: String,
427    request_id: String,
428    exit_code: i32,
429}
430
431impl InitFailure {
432    fn capture(err: &CliCoreError) -> Self {
433        let envelope = crate::output::build_error_envelope(err, "");
434        let (code, system, request_id) = envelope.error.map_or_else(
435            || ("ERROR".to_owned(), String::new(), String::new()),
436            |error| (error.code, error.system, error.request_id),
437        );
438        Self {
439            message: err.to_string(),
440            code,
441            system,
442            request_id,
443            exit_code: exit_code_for_error(err),
444        }
445    }
446
447    fn into_error(self) -> CliCoreError {
448        CliCoreError::with_exit_code(
449            self.exit_code,
450            CliCoreError::SystemMessage {
451                message: self.message,
452                system: self.system,
453                code: self.code,
454                request_id: self.request_id,
455            },
456        )
457    }
458}
459
460impl std::fmt::Debug for Cli {
461    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        formatter
463            .debug_struct("Cli")
464            .field("config", &self.config)
465            .field("middleware", &self.middleware)
466            .field("root", &self.root)
467            .field("commands", &self.commands)
468            .field("module_entries", &self.module_entries)
469            .field("guide_entries", &self.guide_entries)
470            .field("has_init_deps", &self.init_deps.is_some())
471            .field("has_apply_flags", &self.apply_flags.is_some())
472            .field("has_pre_run", &self.pre_run.is_some())
473            .field("has_meta_resolver", &self.meta_resolver.is_some())
474            .field("has_on_shutdown", &self.on_shutdown.is_some())
475            .field("has_extra_search_docs", &self.extra_search_docs.is_some())
476            .field("has_root_next_actions", &self.root_next_actions.is_some())
477            .finish()
478    }
479}
480
481impl Cli {
482    /// Builds a CLI application from declarative configuration.
483    #[must_use]
484    pub fn new(config: CliConfig) -> Self {
485        let auth_providers = config.auth_providers.clone();
486        let guides = config.guides.clone();
487        let views = config.views.clone();
488        let modules = config.modules.clone();
489        let commands = config.commands.clone();
490        let init_deps = config.init_deps.clone();
491        let apply_flags = config.apply_flags.clone();
492        let pre_run = config.pre_run.clone();
493        let meta_resolver = config.meta_resolver.clone();
494        let on_shutdown = config.on_shutdown.clone();
495        let extra_search_docs = config.extra_search_docs.clone();
496        let root_next_actions = config.root_next_actions.clone();
497        let mut root = Command::new(config.name.clone())
498            .about(config.short.clone())
499            .disable_help_subcommand(true)
500            .version(config.build.version_string());
501        if let Some(long) = &config.long
502            && !long.is_empty()
503        {
504            root = root.long_about(long.clone());
505        }
506        root = register_global_flags(root)
507            .subcommand(help_command())
508            .subcommand(guide_command())
509            .subcommand(Command::new("tree").about("Display full command tree"));
510        if let Some(register_flags) = &config.register_flags {
511            root = register_flags(root);
512        }
513        let intro = config
514            .long
515            .as_deref()
516            .filter(|long| !long.is_empty())
517            .unwrap_or(config.short.as_str());
518        root = root
519            .long_about(build_root_long(intro, &[], false))
520            .help_template(ROOT_HELP_TEMPLATE);
521
522        let mut middleware = Middleware::new();
523        middleware.app_id = config.app_id.clone();
524        middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default();
525        middleware.authz = config.authz.clone();
526        middleware.auditor = config.auditor.clone();
527        middleware.activity = config.activity.clone();
528        middleware
529            .schema_registry
530            .merge(&global_schema_registry_snapshot());
531        middleware
532            .human_views
533            .merge(&global_human_view_registry_snapshot());
534
535        let mut cli = Self {
536            config,
537            middleware,
538            root,
539            commands: BTreeMap::new(),
540            module_entries: Vec::new(),
541            guide_entries: Vec::new(),
542            init_deps,
543            apply_flags,
544            pre_run,
545            meta_resolver,
546            on_shutdown,
547            extra_search_docs,
548            root_next_actions,
549            init_state: Arc::new(Mutex::new(None)),
550        };
551        for provider in auth_providers {
552            cli.register_auth_provider(provider);
553        }
554        if cli.middleware.default_auth_provider.is_empty()
555            && let Some(provider) = cli.middleware.auth.registered_names().first()
556        {
557            cli.middleware.default_auth_provider = provider.clone();
558        }
559        if !cli.middleware.default_auth_provider.is_empty() {
560            cli.ensure_auth_command();
561        }
562        for view in views {
563            cli.middleware.human_views.register(view);
564        }
565        cli.add_guides(guides);
566        for module in modules {
567            cli.add_module(module);
568        }
569        for command in commands {
570            cli.add_command(command);
571        }
572        cli
573    }
574
575    /// Lists the auto-registered `auth` command under the admin help category so
576    /// it is never uncategorized once clap's auto subcommand list is suppressed.
577    /// Defaults to [`DEFAULT_ADMIN_CATEGORY`]; `admin_category` overrides it to
578    /// align with a consumer's own taxonomy.
579    fn register_auth_help_entry(&mut self) {
580        let category = self
581            .config
582            .admin_category
583            .clone()
584            .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
585        let already_listed = self.module_entries.iter().any(|entry| entry.name == "auth");
586        let short = self
587            .root
588            .find_subcommand("auth")
589            .filter(|auth| !auth.is_hide_set())
590            .map(|auth| {
591                auth.get_about()
592                    .map(ToString::to_string)
593                    .unwrap_or_default()
594            });
595        if !already_listed && let Some(short) = short {
596            self.module_entries.push(ModuleHelpEntry {
597                category,
598                name: "auth".to_owned(),
599                short,
600            });
601        }
602        self.refresh_root_long();
603    }
604
605    /// Returns the shared middleware template.
606    #[must_use]
607    pub fn middleware(&self) -> &Middleware {
608        &self.middleware
609    }
610
611    /// Returns mutable middleware for advanced application setup.
612    pub fn middleware_mut(&mut self) -> &mut Middleware {
613        &mut self.middleware
614    }
615
616    /// Executes the CLI with process arguments and process stdout/stderr.
617    pub async fn execute(&self) -> ExitCode {
618        let mut stdout = std::io::stdout().lock();
619        let mut stderr = std::io::stderr().lock();
620        match self
621            .execute_from(std::env::args_os(), &mut stdout, &mut stderr)
622            .await
623        {
624            Ok(code) => code,
625            Err(err) => {
626                drop(writeln!(stderr, "{err}"));
627                ExitCode::from(1)
628            }
629        }
630    }
631
632    /// Executes the CLI with caller-provided args and output writers.
633    pub async fn execute_from<I, S, O, E>(
634        &self,
635        args: I,
636        stdout: &mut O,
637        stderr: &mut E,
638    ) -> std::io::Result<ExitCode>
639    where
640        I: IntoIterator<Item = S>,
641        S: Into<std::ffi::OsString> + Clone,
642        O: Write,
643        E: Write,
644    {
645        self.execute_from_until_signal(args, stdout, stderr, shutdown_signal())
646            .await
647    }
648
649    /// Executes the CLI until either command completion or a shutdown signal future resolves.
650    pub async fn execute_from_until_signal<I, S, O, E, Shutdown>(
651        &self,
652        args: I,
653        stdout: &mut O,
654        stderr: &mut E,
655        shutdown: Shutdown,
656    ) -> std::io::Result<ExitCode>
657    where
658        I: IntoIterator<Item = S>,
659        S: Into<std::ffi::OsString> + Clone,
660        O: Write,
661        E: Write,
662        Shutdown: Future<Output = ()>,
663    {
664        let output = run_until_signal(self.run(args), shutdown).await;
665        if output.exit_code == 130
666            && output.rendered == "command interrupted\n"
667            && let Some(on_shutdown) = &self.on_shutdown
668        {
669            on_shutdown();
670        }
671        if output.exit_code == 0 {
672            stdout.write_all(output.rendered.as_bytes())?;
673        } else {
674            stderr.write_all(output.rendered.as_bytes())?;
675        }
676        Ok(process_exit_code(output.exit_code))
677    }
678
679    /// Registers an auth provider after construction.
680    pub fn register_auth_provider(&mut self, provider: Arc<dyn AuthProvider>) -> &mut Self {
681        self.middleware.auth.register(provider);
682        self.ensure_auth_command();
683        self.refresh_root_long();
684        self
685    }
686
687    /// Returns the built `clap` root command.
688    #[must_use]
689    pub fn root_command(&self) -> &Command {
690        &self.root
691    }
692
693    /// Adds one runtime module group after construction.
694    pub fn add_module_group(
695        &mut self,
696        category: impl Into<String>,
697        group: RuntimeGroupSpec,
698    ) -> &mut Self {
699        let category = category.into();
700        if !group.group.hidden {
701            self.module_entries.push(ModuleHelpEntry {
702                category,
703                name: group.group.name.clone(),
704                short: group.group.short.clone(),
705            });
706        }
707
708        let mut prefix = Vec::new();
709        register_runtime_group_schemas(&group, &mut prefix, &mut self.middleware.schema_registry);
710        let mut prefix = Vec::new();
711        group.register_commands(&mut prefix, &mut self.commands);
712        let mut prefix = Vec::new();
713        let clap_group = runtime_group_clap_command_with_schema_help(
714            &group,
715            &mut prefix,
716            &self.middleware.schema_registry,
717        );
718        self.root = self.root.clone().subcommand(clap_group);
719        self.refresh_root_long();
720        self
721    }
722
723    /// Adds one module after construction.
724    pub fn add_module(&mut self, module: Module) -> &mut Self {
725        for view in module.views.clone() {
726            self.middleware.human_views.register(view);
727        }
728        self.add_guides(module.guides.clone());
729        let mut context = ModuleContext::new(&mut self.middleware);
730        let group = (module.register)(&mut context);
731        let (guides, views) = context.into_parts();
732        for view in views {
733            self.middleware.human_views.register(view);
734        }
735        self.add_guides(guides);
736        self.add_module_group(module.category, group)
737    }
738
739    /// Adds one top-level runtime command after construction.
740    pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self {
741        let name = command.spec.name.clone();
742        register_command_schema(&command.spec, &name, &mut self.middleware.schema_registry);
743        self.commands.insert(name, command.clone());
744        self.root = self
745            .root
746            .clone()
747            .subcommand(command_clap_command_with_schema_help(
748                &command.spec,
749                &command.spec.name,
750                &self.middleware.schema_registry,
751            ));
752        self
753    }
754
755    /// Controls whether the built-in `guide` command is advertised.
756    pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self {
757        if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
758            self.root = self.root.clone().subcommand(guide_command());
759        }
760        self.refresh_root_long();
761        self
762    }
763
764    /// Adds guide entries after construction.
765    pub fn add_guides(&mut self, entries: impl IntoIterator<Item = GuideEntry>) -> &mut Self {
766        let mut seen = self
767            .guide_entries
768            .iter()
769            .map(|entry| entry.name.clone())
770            .collect::<BTreeSet<_>>();
771        for entry in entries {
772            if seen.insert(entry.name.clone()) {
773                self.guide_entries.push(entry);
774            }
775        }
776        if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
777            self.root = self.root.clone().subcommand(guide_command());
778        }
779        self.refresh_root_long();
780        self
781    }
782
783    /// Runs the CLI with provided args and captures the rendered result.
784    pub async fn run<I, S>(&self, args: I) -> CliRunOutput
785    where
786        I: IntoIterator<Item = S>,
787        S: Into<std::ffi::OsString> + Clone,
788    {
789        let raw_args = args
790            .into_iter()
791            .map(Into::into)
792            .collect::<Vec<std::ffi::OsString>>();
793        let text_args = raw_args
794            .iter()
795            .map(|arg| arg.to_string_lossy().into_owned())
796            .collect::<Vec<_>>();
797        let mut clap_args = normalize_optional_global_flags_before_command(&self.root, &text_args);
798        if has_root_version_flag(&text_args, &self.root, &self.config.name) {
799            return self.finish_run(CliRunOutput {
800                exit_code: 0,
801                rendered: format!(
802                    "{} version {}\n",
803                    self.config.name,
804                    self.config.build.version_string()
805                ),
806            });
807        }
808        if let Some(output) = self.try_run_schema_bypass(&text_args) {
809            return output;
810        }
811        if let Some(output) = self.try_run_search_bypass(&text_args) {
812            return output;
813        }
814        // Resolve the positional command path once and share it between the
815        // group-help rewrite and the unknown-command check below.
816        let bool_flags = derive_bool_flags(&self.root);
817        let value_flags = derive_value_flags(&self.root);
818        let positionals =
819            positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags);
820        // Positional tokens after a `--` separator are literal operands, not
821        // command keywords, so the group-help shim must not treat a `help`
822        // among them as a help request. Count the positionals that precede any
823        // `--` to mark where genuine command keywords end.
824        let command_keyword_count = match text_args.iter().position(|arg| arg == "--") {
825            Some(end) => positional_command_tokens(
826                &text_args[..end],
827                &self.config.name,
828                &bool_flags,
829                &value_flags,
830            )
831            .len(),
832            None => positionals.len(),
833        };
834        if let Some(parts) =
835            group_help_target_parts(&self.root, &positionals, command_keyword_count)
836        {
837            // Rewrite `<group> help [sub...]` into the canonical
838            // `help <group> [sub...]` so it flows through the curated root
839            // `help` command, which also runs global-flag parsing and the
840            // `pre_run` hook (matching `help <group>` and bare-group help).
841            // Only the positional command tokens are reordered; every flag and
842            // its value is preserved in place so e.g. `--output json` survives.
843            clap_args = rewrite_group_help_args(
844                &clap_args,
845                &self.config.name,
846                &bool_flags,
847                &value_flags,
848                &parts,
849            );
850        } else if let Some(message) = unknown_group_command_message(&self.root, &positionals) {
851            return self.finish_run(CliRunOutput {
852                exit_code: 1,
853                rendered: message,
854            });
855        }
856
857        let matches = match self.root.clone().try_get_matches_from(clap_args) {
858            Ok(matches) => matches,
859            Err(err) => {
860                return self.finish_run(CliRunOutput {
861                    exit_code: err.exit_code(),
862                    rendered: err.to_string(),
863                });
864            }
865        };
866
867        let default_format = default_output_format(&self.config.app_id);
868        let flags = global_flags_from_matches(&matches, &default_format);
869        let command_timeout = match parse_command_timeout(&flags.timeout) {
870            Ok(timeout) => timeout,
871            Err(err) => {
872                return self.finish_run(render_cli_error(
873                    &self.middleware,
874                    &err,
875                    &self.config.app_id,
876                ));
877            }
878        };
879        let mut middleware = self.middleware.clone();
880        apply_global_flags(&mut middleware, &flags, command_timeout);
881        if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
882            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
883        }
884
885        let command_path = command_path_from_matches(&self.config.name, &matches);
886        if command_path == "help" {
887            if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &help_args(&matches))
888            {
889                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
890            }
891            return self.finish_run(self.render_help_command(&matches));
892        }
893        if command_path == "tree" {
894            if let Err(err) = self.run_pre_run(
895                &mut middleware,
896                &command_path,
897                &crate::middleware::ValueMap::new(),
898            ) {
899                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
900            }
901            return self.finish_run(tree_render::render_tree(
902                &self.root,
903                &self.config.app_id,
904                &middleware,
905            ));
906        }
907        if command_path == "guide" {
908            if let Err(err) =
909                self.run_pre_run(&mut middleware, &command_path, &guide_args(&matches))
910            {
911                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
912            }
913            return self.finish_run(self.render_guide(&matches));
914        }
915        let Some(command) = self.commands.get(&command_path) else {
916            if !command_path.is_empty()
917                && let Some(group) = find_command_by_colon_path(&self.root, &command_path)
918                && group.get_subcommands().next().is_some()
919            {
920                if let Err(err) = self.run_pre_run(
921                    &mut middleware,
922                    &command_path,
923                    &crate::middleware::ValueMap::new(),
924                ) {
925                    return self.finish_run(render_cli_error(
926                        &middleware,
927                        &err,
928                        &self.config.app_id,
929                    ));
930                }
931                return self.finish_run(CliRunOutput {
932                    exit_code: 0,
933                    rendered: group.clone().render_long_help().to_string(),
934                });
935            }
936            if command_path.is_empty()
937                && let Some(root_next_actions) = &self.root_next_actions
938            {
939                // Bare-root discovery is static (help text / metadata + action
940                // pointers) and must always be available as a cold-start entry
941                // point, so we skip `pre_run` here — matching the no-hook
942                // bare-root path below, which also renders help without it.
943                let actions = root_next_actions();
944                return self.finish_run(self.render_root(&middleware, actions));
945            }
946            return self.finish_run(CliRunOutput {
947                exit_code: if command_path.is_empty() { 0 } else { 1 },
948                rendered: if command_path.is_empty() {
949                    self.root.clone().render_long_help().to_string()
950                } else {
951                    format!("unknown command {command_path:?}")
952                },
953            });
954        };
955
956        let mut middleware = match self.initialized_middleware() {
957            Ok(middleware) => middleware,
958            Err(err) => {
959                return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
960            }
961        };
962        apply_global_flags(&mut middleware, &flags, command_timeout);
963        if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
964            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
965        }
966
967        let leaf = leaf_matches(&matches);
968        let args = command_args_from_matches(leaf, &command.spec, false);
969        let user_args = command_args_from_matches(leaf, &command.spec, true);
970        if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
971            return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
972        }
973        let meta = self.resolve_meta(&command_path, command.spec.metadata());
974        let default_fields = command.spec.default_fields.clone().unwrap_or_default();
975        let system = command.spec.system.clone().unwrap_or_default();
976
977        if let Some(streaming_handler) = command.streaming_handler.clone() {
978            let result = run_with_timeout(
979                command_timeout,
980                &flags.timeout,
981                run_streaming_command(
982                    &middleware,
983                    MiddlewareRequest {
984                        meta,
985                        command_path: &command_path,
986                        system: &system,
987                        user_args,
988                        args,
989                        default_fields: &default_fields,
990                        auth: command.spec.auth,
991                    },
992                    Arc::new(leaf.clone()),
993                    streaming_handler,
994                ),
995            )
996            .await;
997            return self.finish_run(match result {
998                Ok(output) => output,
999                Err(err) => render_cli_error(&middleware, &err, &self.config.app_id),
1000            });
1001        }
1002
1003        let handler = command.handler.clone();
1004        let args_for_handler = args.clone();
1005        let user_args_for_handler = user_args.clone();
1006        let handler_path = command_path.clone();
1007        let middleware_for_handler = middleware.clone();
1008        let raw_matches_for_handler = Arc::new(leaf.clone());
1009        let result = run_with_timeout(
1010            command_timeout,
1011            &flags.timeout,
1012            middleware.run(
1013                MiddlewareRequest {
1014                    meta,
1015                    command_path: &command_path,
1016                    system: &system,
1017                    user_args,
1018                    args,
1019                    default_fields: &default_fields,
1020                    auth: command.spec.auth,
1021                },
1022                async move |credential| {
1023                    handler(CommandContext {
1024                        credential,
1025                        args: args_for_handler,
1026                        user_args: user_args_for_handler,
1027                        command_path: handler_path,
1028                        middleware: middleware_for_handler,
1029                        raw_matches: raw_matches_for_handler,
1030                    })
1031                    .await
1032                },
1033            ),
1034        )
1035        .await;
1036
1037        match result {
1038            Ok(output) => self.finish_run(output.into()),
1039            Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)),
1040        }
1041    }
1042
1043    fn try_run_search_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
1044        let query = extract_search_query(args);
1045        if query.is_empty() {
1046            return None;
1047        }
1048        let scope = self.search_scope(args);
1049        let output_format =
1050            extract_output_format(args, &default_output_format(&self.config.app_id));
1051        Some(self.render_search(&query, &scope, &output_format))
1052    }
1053
1054    fn try_run_schema_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
1055        if !has_true_schema_flag(args) {
1056            return None;
1057        }
1058        let bool_flags = derive_bool_flags(&self.root);
1059        let value_flags = derive_value_flags(&self.root);
1060        let command_path =
1061            self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
1062        let schema = self.middleware.schema_registry.get_by_path(&command_path)?;
1063        let output_format =
1064            extract_output_format(args, &default_output_format(&self.config.app_id));
1065        Some(self.render_schema(schema, &output_format))
1066    }
1067
1068    fn render_schema(
1069        &self,
1070        schema: crate::output::SchemaInfo,
1071        output_format: &str,
1072    ) -> CliRunOutput {
1073        let format: crate::output::OutputFormat = match output_format.parse() {
1074            Ok(format) => format,
1075            Err(err) => {
1076                return CliRunOutput {
1077                    exit_code: exit_code_for_error(&err),
1078                    rendered: err.to_string(),
1079                };
1080            }
1081        };
1082        let envelope =
1083            crate::Envelope::success(schema, self.config.app_id.clone()).prepare_for_render("");
1084        match crate::output::render(format, &envelope) {
1085            Ok(rendered) => CliRunOutput {
1086                exit_code: 0,
1087                rendered,
1088            },
1089            Err(err) => CliRunOutput {
1090                exit_code: exit_code_for_error(&err),
1091                rendered: err.to_string(),
1092            },
1093        }
1094    }
1095
1096    fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
1097        let format: crate::output::OutputFormat = match output_format.parse() {
1098            Ok(format) => format,
1099            Err(err) => {
1100                return CliRunOutput {
1101                    exit_code: exit_code_for_error(&err),
1102                    rendered: err.to_string(),
1103                };
1104            }
1105        };
1106        let docs = self.search_documents(scope);
1107        let results = SearchIndex::new(docs).search(query, 10);
1108        let envelope =
1109            crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render("");
1110        match crate::output::render(format, &envelope) {
1111            Ok(rendered) => CliRunOutput {
1112                exit_code: 0,
1113                rendered,
1114            },
1115            Err(err) => CliRunOutput {
1116                exit_code: exit_code_for_error(&err),
1117                rendered: err.to_string(),
1118            },
1119        }
1120    }
1121
1122    /// Renders the bare-root response. For human output, renders long help plus
1123    /// a "Next actions" section so a human invoking the CLI with no arguments
1124    /// gets readable guidance; for machine-readable output, emits a discovery
1125    /// envelope (light metadata + next actions). The output format has already
1126    /// resolved the TTY/env/flag policy, so this just branches on it.
1127    fn render_root(&self, middleware: &Middleware, actions: Vec<NextAction>) -> CliRunOutput {
1128        // Reject an invalid explicit `--output` here too, matching the normal
1129        // command path (`Middleware::render_envelope`). `OutputFormat::from_str`
1130        // is infallible and would otherwise silently coerce an unrecognized
1131        // value (e.g. `--output yaml`) to JSON instead of reporting the error.
1132        if !crate::output::is_valid_output_format(&middleware.output_format) {
1133            let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone());
1134            return CliRunOutput {
1135                exit_code: exit_code_for_error(&err),
1136                rendered: err.to_string(),
1137            };
1138        }
1139        let format = middleware
1140            .output_format
1141            .parse()
1142            .unwrap_or(crate::output::OutputFormat::Json);
1143        if format == crate::output::OutputFormat::Human {
1144            // Fold the suggested actions into the root long-about so they render
1145            // alongside the other curated sections (before Usage) instead of
1146            // dangling beneath clap's options dump.
1147            let base_long = self
1148                .root
1149                .get_long_about()
1150                .map(ToString::to_string)
1151                .unwrap_or_default();
1152            let long = format!("{base_long}{}", render_next_actions_human(&actions));
1153            let rendered = self
1154                .root
1155                .clone()
1156                .long_about(long)
1157                .render_long_help()
1158                .to_string();
1159            return CliRunOutput {
1160                exit_code: 0,
1161                rendered,
1162            };
1163        }
1164        let description = self
1165            .config
1166            .long
1167            .as_deref()
1168            .filter(|long| !long.is_empty())
1169            .unwrap_or(self.config.short.as_str());
1170        let data = serde_json::json!({
1171            "description": description,
1172            "version": self.config.build.version,
1173        });
1174        let envelope = crate::Envelope::success(data, self.config.app_id.clone())
1175            .with_next_actions(actions)
1176            .prepare_for_render(&middleware.verbose);
1177        match crate::output::render(format, &envelope) {
1178            Ok(rendered) => CliRunOutput {
1179                exit_code: 0,
1180                rendered,
1181            },
1182            Err(err) => CliRunOutput {
1183                exit_code: exit_code_for_error(&err),
1184                rendered: err.to_string(),
1185            },
1186        }
1187    }
1188
1189    fn search_documents(&self, scope: &str) -> Vec<SearchDocument> {
1190        let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope)
1191            .unwrap_or((&self.root, Vec::new()));
1192        let mut docs = Vec::new();
1193        let mut aliases = Vec::new();
1194        append_command_alias_terms(scoped, &mut aliases);
1195        collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs);
1196        if scope.is_empty() {
1197            for entry in &self.guide_entries {
1198                docs.push(SearchDocument {
1199                    id: format!("guide:{}", entry.name),
1200                    kind: "guide".to_owned(),
1201                    title: format!("guide {}", entry.name),
1202                    summary: entry.summary.clone(),
1203                    content: format!("{} {}", entry.summary, entry.content),
1204                });
1205            }
1206            if let Some(extra_search_docs) = &self.extra_search_docs {
1207                docs.extend(extra_search_docs());
1208            }
1209        }
1210        docs
1211    }
1212
1213    fn search_scope(&self, args: &[String]) -> String {
1214        let parts = extract_search_scope_parts(args);
1215        canonical_path_from_parts(&self.root, &parts).unwrap_or_default()
1216    }
1217
1218    fn canonical_command_path(&self, command_path: &str) -> String {
1219        find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else(
1220            || command_path.to_owned(),
1221            |(_, canonical)| canonical.join(":"),
1222        )
1223    }
1224
1225    fn render_guide(&self, matches: &ArgMatches) -> CliRunOutput {
1226        let leaf = leaf_matches(matches);
1227        let topic = leaf.get_one::<String>("topic").map(String::as_str);
1228        match guide_content(&self.guide_entries, topic) {
1229            Ok(rendered) => CliRunOutput {
1230                exit_code: 0,
1231                rendered,
1232            },
1233            Err(err) => CliRunOutput {
1234                exit_code: 1,
1235                rendered: err,
1236            },
1237        }
1238    }
1239
1240    fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
1241        let leaf = leaf_matches(matches);
1242        let parts = leaf
1243            .get_many::<String>("command")
1244            .map(|values| values.map(String::as_str).collect::<Vec<_>>())
1245            .unwrap_or_default();
1246        self.render_help_for_parts(&parts)
1247    }
1248
1249    /// Renders the curated help text for a resolved command path.
1250    ///
1251    /// Empty `parts` render the root help. A path that resolves to a group or
1252    /// command renders that command's long help; an unresolved path returns the
1253    /// standard "unknown command" guidance with a non-zero exit code. Shared by
1254    /// the root `help <path>` command and the `<group> help` subcommand form.
1255    fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput {
1256        if parts.is_empty() {
1257            return CliRunOutput {
1258                exit_code: 0,
1259                rendered: self.root.clone().render_long_help().to_string(),
1260            };
1261        }
1262        let Some(command) = find_help_target(&self.root, parts) else {
1263            return CliRunOutput {
1264                exit_code: 1,
1265                rendered: format!(
1266                    "unknown command {:?} — run '{} help' for available commands",
1267                    parts.join(" "),
1268                    self.config.name
1269                ),
1270            };
1271        };
1272        CliRunOutput {
1273            exit_code: 0,
1274            rendered: command.clone().render_long_help().to_string(),
1275        }
1276    }
1277
1278    fn refresh_root_long(&mut self) {
1279        // Module-categorized entries, plus any visible top-level command that is
1280        // neither categorized nor an engine built-in, listed under a generic
1281        // "Commands" section. This keeps every command discoverable once clap's
1282        // auto subcommand list is suppressed by the root help template.
1283        const BUILTINS: [&str; 4] = ["help", "guide", "tree", "completion"];
1284        let categorized: BTreeSet<&str> = self
1285            .module_entries
1286            .iter()
1287            .map(|entry| entry.name.as_str())
1288            .collect();
1289        let mut generic: Vec<ModuleHelpEntry> = self
1290            .root
1291            .get_subcommands()
1292            .filter(|command| !command.is_hide_set())
1293            .filter(|command| !BUILTINS.contains(&command.get_name()))
1294            .filter(|command| !categorized.contains(command.get_name()))
1295            .map(|command| ModuleHelpEntry {
1296                category: "Commands".to_owned(),
1297                name: command.get_name().to_owned(),
1298                short: command
1299                    .get_about()
1300                    .map(ToString::to_string)
1301                    .unwrap_or_default(),
1302            })
1303            .collect();
1304        generic.sort_by(|left, right| left.name.cmp(&right.name));
1305
1306        let mut entries = self.module_entries.clone();
1307        entries.extend(generic);
1308        let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide");
1309        let intro = self
1310            .config
1311            .long
1312            .as_deref()
1313            .filter(|long| !long.is_empty())
1314            .unwrap_or(self.config.short.as_str());
1315        self.root = self
1316            .root
1317            .clone()
1318            .long_about(build_root_long(intro, &entries, has_guide));
1319    }
1320
1321    fn ensure_auth_command(&mut self) {
1322        let default_provider = self.default_auth_provider();
1323        let registered_names = self.middleware.auth.registered_names();
1324        if default_provider.is_empty() && registered_names.is_empty() {
1325            return;
1326        }
1327        let replacing_builtin = self.commands.contains_key("auth:login");
1328        if has_subcommand(&self.root, "auth") && !replacing_builtin {
1329            return;
1330        }
1331        let group = auth_command_group(&default_provider, &registered_names);
1332        let mut prefix = Vec::new();
1333        group.register_commands(&mut prefix, &mut self.commands);
1334        let mut prefix = Vec::new();
1335        let clap_group = runtime_group_clap_command_with_schema_help(
1336            &group,
1337            &mut prefix,
1338            &self.middleware.schema_registry,
1339        );
1340        self.root = if replacing_builtin {
1341            self.root.clone().mut_subcommand("auth", |_| clap_group)
1342        } else {
1343            self.root.clone().subcommand(clap_group)
1344        };
1345        // Categorize `auth` wherever it is ensured (construction or a later
1346        // `register_auth_provider`), so it never falls into the generic
1347        // "Commands" bucket. Idempotent via the `already_listed` guard.
1348        self.register_auth_help_entry();
1349    }
1350
1351    fn default_auth_provider(&self) -> String {
1352        if !self.middleware.default_auth_provider.is_empty() {
1353            return self.middleware.default_auth_provider.clone();
1354        }
1355        self.middleware
1356            .auth
1357            .registered_names()
1358            .into_iter()
1359            .next()
1360            .unwrap_or_default()
1361    }
1362
1363    fn initialized_middleware(&self) -> Result<Middleware> {
1364        let Some(init_deps) = &self.init_deps else {
1365            return Ok(self.middleware.clone());
1366        };
1367        let mut guard = self
1368            .init_state
1369            .lock()
1370            .map_err(|_| CliCoreError::message("init deps lock poisoned"))?;
1371        if let Some(result) = guard.as_ref() {
1372            return result.clone().map_err(InitFailure::into_error);
1373        }
1374        let mut middleware = self.middleware.clone();
1375        let result = init_deps(&mut middleware)
1376            .map(|()| middleware)
1377            .map_err(|err| InitFailure::capture(&err));
1378        *guard = Some(result.clone());
1379        result.map_err(InitFailure::into_error)
1380    }
1381
1382    fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
1383        if let Some(apply_flags) = &self.apply_flags {
1384            apply_flags(matches, middleware)?;
1385        }
1386        Ok(())
1387    }
1388
1389    fn run_pre_run(
1390        &self,
1391        middleware: &mut Middleware,
1392        command_path: &str,
1393        args: &crate::middleware::ValueMap,
1394    ) -> Result<()> {
1395        if let Some(pre_run) = &self.pre_run {
1396            pre_run(middleware, command_path, args)?;
1397        }
1398        Ok(())
1399    }
1400
1401    fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta {
1402        if let Some(resolver) = &self.meta_resolver {
1403            resolver(command_path, meta)
1404        } else {
1405            meta
1406        }
1407    }
1408
1409    fn finish_run(&self, output: CliRunOutput) -> CliRunOutput {
1410        if let Some(on_shutdown) = &self.on_shutdown {
1411            on_shutdown();
1412        }
1413        output
1414    }
1415}
1416
1417fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option<Duration>) {
1418    middleware.output_format = flags.output_format.clone();
1419    middleware.verbose = flags.verbose.clone();
1420    middleware.dry_run = flags.dry_run;
1421    middleware.fields = flags.fields.clone();
1422    middleware.filter = flags.filter.clone();
1423    middleware.expr = flags.expr.clone();
1424    middleware.limit = flags.limit;
1425    middleware.offset = flags.offset;
1426    middleware.reason = flags.reason.clone();
1427    middleware.schema = flags.schema;
1428    middleware.timeout = timeout;
1429    middleware.debug = flags.debug.clone();
1430    middleware.search = flags.search.clone();
1431}
1432
1433async fn run_with_timeout<F, T>(
1434    timeout: Option<Duration>,
1435    timeout_label: &str,
1436    future: F,
1437) -> Result<T>
1438where
1439    F: Future<Output = Result<T>>,
1440{
1441    let Some(timeout) = timeout else {
1442        return future.await;
1443    };
1444    match tokio::time::timeout(timeout, future).await {
1445        Ok(result) => result,
1446        Err(_) => Err(CliCoreError::message(format!(
1447            "command timed out after {timeout_label}"
1448        ))),
1449    }
1450}
1451
1452async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
1453where
1454    Run: Future<Output = CliRunOutput>,
1455    Shutdown: Future<Output = ()>,
1456{
1457    tokio::pin!(run);
1458    tokio::pin!(shutdown);
1459    tokio::select! {
1460        output = &mut run => output,
1461        () = &mut shutdown => CliRunOutput {
1462            exit_code: 130,
1463            rendered: "command interrupted\n".to_owned(),
1464        },
1465    }
1466}
1467
1468#[cfg(unix)]
1469async fn shutdown_signal() {
1470    let ctrl_c = tokio::signal::ctrl_c();
1471    match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
1472        Ok(mut sigterm) => {
1473            tokio::select! {
1474                _ = ctrl_c => {},
1475                _ = sigterm.recv() => {},
1476            }
1477        }
1478        Err(_) => {
1479            drop(ctrl_c.await);
1480        }
1481    }
1482}
1483
1484#[cfg(not(unix))]
1485async fn shutdown_signal() {
1486    drop(tokio::signal::ctrl_c().await);
1487}
1488
1489fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
1490    let raw = raw.trim();
1491    if raw.is_empty() {
1492        return Ok(Some(Duration::from_secs(60)));
1493    }
1494    let Some(seconds) = parse_duration_seconds(raw) else {
1495        return Err(CliCoreError::message(format!(
1496            "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
1497        )));
1498    };
1499    if seconds <= 0.0 {
1500        Ok(None)
1501    } else {
1502        Ok(Some(Duration::from_secs_f64(seconds)))
1503    }
1504}
1505
1506fn parse_duration_seconds(raw: &str) -> Option<f64> {
1507    for (suffix, seconds) in [
1508        ("ns", 0.000_000_001_f64),
1509        ("us", 0.000_001_f64),
1510        ("µs", 0.000_001_f64),
1511        ("ms", 0.001_f64),
1512        ("s", 1.0_f64),
1513        ("m", 60.0_f64),
1514        ("h", 3600.0_f64),
1515    ] {
1516        if let Some(number) = raw.strip_suffix(suffix) {
1517            let value = number.parse::<f64>().ok()?;
1518            if !value.is_finite() {
1519                return None;
1520            }
1521            return Some(value * seconds);
1522        }
1523    }
1524    None
1525}
1526
1527fn render_cli_error(
1528    middleware: &Middleware,
1529    err: &(dyn std::error::Error + 'static),
1530    system: &str,
1531) -> CliRunOutput {
1532    let format = middleware
1533        .output_format
1534        .parse::<crate::output::OutputFormat>()
1535        .unwrap_or(crate::output::OutputFormat::Json);
1536    let envelope =
1537        crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
1538    match crate::output::render(format, &envelope) {
1539        Ok(rendered) => CliRunOutput {
1540            exit_code: exit_code_for_error(err),
1541            rendered,
1542        },
1543        Err(render_err) => CliRunOutput {
1544            exit_code: exit_code_for_error(err),
1545            rendered: render_err.to_string(),
1546        },
1547    }
1548}
1549
1550fn find_command_by_colon_path<'command>(
1551    root: &'command Command,
1552    path: &str,
1553) -> Option<&'command Command> {
1554    find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
1555}
1556
1557fn find_help_target<'command>(
1558    root: &'command Command,
1559    parts: &[&str],
1560) -> Option<&'command Command> {
1561    let mut current = root;
1562    let mut matched_any = false;
1563    for part in parts {
1564        let Some(next) = current.find_subcommand(part) else {
1565            break;
1566        };
1567        current = next;
1568        matched_any = true;
1569    }
1570    matched_any.then_some(current)
1571}
1572
1573fn find_command_and_canonical_path_by_colon_path<'command>(
1574    root: &'command Command,
1575    path: &str,
1576) -> Option<(&'command Command, Vec<String>)> {
1577    if path.is_empty() {
1578        return Some((root, Vec::new()));
1579    }
1580    let mut current = root;
1581    let mut canonical = Vec::new();
1582    for part in path.split(':') {
1583        current = current.find_subcommand(part)?;
1584        canonical.push(current.get_name().to_owned());
1585    }
1586    Some((current, canonical))
1587}
1588
1589fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
1590    if parts.is_empty() {
1591        return Some(String::new());
1592    }
1593    let mut current = root;
1594    let mut canonical = Vec::new();
1595    for part in parts {
1596        current = current.find_subcommand(part)?;
1597        canonical.push(current.get_name().to_owned());
1598    }
1599    Some(canonical.join(":"))
1600}
1601
1602fn extract_search_scope_parts(args: &[String]) -> Vec<String> {
1603    let mut parts = Vec::new();
1604    let mut index = 1;
1605    while index < args.len() {
1606        let arg = &args[index];
1607        if arg == "--search" || arg.starts_with("--search=") {
1608            break;
1609        }
1610        if arg.starts_with('-') {
1611            if !arg.contains('=') && index + 1 < args.len() && !args[index + 1].starts_with('-') {
1612                index += 2;
1613            } else {
1614                index += 1;
1615            }
1616            continue;
1617        }
1618        parts.push(arg.clone());
1619        index += 1;
1620    }
1621    parts
1622}
1623
1624fn collect_command_search_documents(
1625    command: &Command,
1626    prefix: &mut Vec<String>,
1627    aliases: &mut Vec<String>,
1628    docs: &mut Vec<SearchDocument>,
1629) {
1630    if command.is_hide_set() || command.get_name() == "completion" {
1631        return;
1632    }
1633    if command.get_subcommands().next().is_some() {
1634        for child in command.get_subcommands() {
1635            prefix.push(child.get_name().to_owned());
1636            let alias_len = aliases.len();
1637            append_command_alias_terms(child, aliases);
1638            collect_command_search_documents(child, prefix, aliases, docs);
1639            aliases.truncate(alias_len);
1640            prefix.pop();
1641        }
1642        return;
1643    }
1644    if prefix.is_empty() {
1645        prefix.push(command.get_name().to_owned());
1646        append_command_alias_terms(command, aliases);
1647    }
1648    let path = prefix.join(" ");
1649    let alias_text = aliases.join(" ");
1650    docs.push(SearchDocument {
1651        id: format!("cmd:{path}"),
1652        kind: "command".to_owned(),
1653        title: path,
1654        summary: command
1655            .get_about()
1656            .map(ToString::to_string)
1657            .unwrap_or_default(),
1658        content: format!(
1659            "{} {} {} {}",
1660            command
1661                .get_about()
1662                .map(ToString::to_string)
1663                .unwrap_or_default(),
1664            command
1665                .get_long_about()
1666                .map(ToString::to_string)
1667                .unwrap_or_default(),
1668            command_flag_text(command),
1669            alias_text
1670        ),
1671    });
1672    if prefix.len() == 1 && prefix[0] == command.get_name() {
1673        prefix.pop();
1674    }
1675}
1676
1677fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
1678    aliases.extend(command.get_all_aliases().map(str::to_owned));
1679    aliases.extend(
1680        command
1681            .get_all_short_flag_aliases()
1682            .map(|alias| alias.to_string()),
1683    );
1684    aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
1685}
1686
1687fn command_flag_text(command: &Command) -> String {
1688    command
1689        .get_arguments()
1690        .filter_map(|arg| {
1691            let mut names = Vec::new();
1692            if let Some(short) = arg.get_short() {
1693                names.push(format!("-{short}"));
1694            }
1695            if let Some(long) = arg.get_long() {
1696                names.push(format!("--{long}"));
1697            }
1698            if let Some(short_aliases) = arg.get_all_short_aliases() {
1699                names.extend(
1700                    short_aliases
1701                        .into_iter()
1702                        .map(|short_alias| format!("-{short_alias}")),
1703                );
1704            }
1705            if let Some(aliases) = arg.get_all_aliases() {
1706                names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
1707            }
1708            (!names.is_empty()).then(|| names.join(" "))
1709        })
1710        .collect::<Vec<_>>()
1711        .join(" ")
1712}
1713
1714fn has_subcommand(command: &Command, name: &str) -> bool {
1715    command
1716        .get_subcommands()
1717        .any(|child| child.get_name() == name)
1718}
1719
1720fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
1721    let bool_flags = derive_bool_flags(root);
1722    let value_flags = derive_value_flags(root);
1723    let mut iter = args.iter().peekable();
1724    if iter
1725        .peek()
1726        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
1727    {
1728        iter.next();
1729    }
1730
1731    while let Some(arg) = iter.next() {
1732        match arg.as_str() {
1733            "--version" | "-v" => return true,
1734            "--" => return false,
1735            value if value.contains('=') || bool_flags.contains(value) => continue,
1736            value
1737                if value_flags.contains(value)
1738                    || unknown_flag_consumes_value(value, iter.peek()) =>
1739            {
1740                iter.next();
1741            }
1742            value if value.starts_with('-') => {}
1743            _ => return false,
1744        }
1745    }
1746    false
1747}
1748
1749fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
1750    let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
1751    let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
1752    let mut normalized = Vec::with_capacity(args.len());
1753    let mut index = 0;
1754    let mut current = root;
1755    while index < args.len() {
1756        let arg = &args[index];
1757        if index == 0 && arg_matches_root_name(arg, root.get_name()) {
1758            normalized.push(arg.clone());
1759            index += 1;
1760            continue;
1761        }
1762
1763        if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
1764            normalized.push(format!("{arg}={default}"));
1765            index += 1;
1766            continue;
1767        }
1768
1769        if let Some(default) = optional_string_defaults.get(arg.as_str()) {
1770            match args.get(index + 1) {
1771                None => {
1772                    normalized.push(format!("{arg}={default}"));
1773                    index += 1;
1774                    continue;
1775                }
1776                Some(next)
1777                    if current.get_name() == root.get_name()
1778                        || next.starts_with('-')
1779                        || direct_subcommand(current, next).is_some() =>
1780                {
1781                    normalized.push(format!("{arg}={default}"));
1782                    index += 1;
1783                    continue;
1784                }
1785                Some(next) => {
1786                    normalized.push(arg.clone());
1787                    normalized.push(next.clone());
1788                    index += 2;
1789                    continue;
1790                }
1791            }
1792        }
1793
1794        normalized.push(arg.clone());
1795        if !arg.starts_with('-')
1796            && let Some(next_command) = direct_subcommand(current, arg)
1797        {
1798            current = next_command;
1799        }
1800        index += 1;
1801    }
1802    normalized
1803}
1804
1805fn direct_subcommand<'command>(
1806    command: &'command Command,
1807    token: &str,
1808) -> Option<&'command Command> {
1809    command.get_subcommands().find(|child| {
1810        child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
1811    })
1812}
1813
1814fn unknown_group_command_message(root: &Command, positionals: &[String]) -> Option<String> {
1815    if positionals.is_empty() {
1816        return None;
1817    }
1818
1819    let mut current = root;
1820    let mut path = vec![root.get_name().to_owned()];
1821    for token in positionals {
1822        if let Some(next) = current.find_subcommand(token) {
1823            current = next;
1824            path.push(next.get_name().to_owned());
1825            continue;
1826        }
1827        if current.get_subcommands().next().is_some() {
1828            return Some(format!(
1829                "unknown command {token:?} for {:?}",
1830                path.join(" ")
1831            ));
1832        }
1833        return None;
1834    }
1835    None
1836}
1837
1838/// Detects the `<group> help [sub...]` form and returns the command path whose
1839/// help should be rendered.
1840///
1841/// The engine ships a curated root `help` command, so it disables clap's
1842/// auto-generated help subcommand on the root. That setting propagates to every
1843/// subcommand and cannot be re-enabled per child, so `<group> help` would
1844/// otherwise hit clap's "unrecognized subcommand" error even though the group's
1845/// help listing advertises a `help` entry. We recognize the form here so the
1846/// caller can route it through the curated help renderer, matching clap's
1847/// documented equivalence between `cmd group help sub` and `cmd help group sub`.
1848///
1849/// Only groups (commands that have subcommands) are matched: a group is pure
1850/// subcommand dispatch, so a `help` token in that position is unambiguously a
1851/// help request. Leaf commands may accept a literal `help` positional argument,
1852/// so they are left for clap to parse (`<leaf> --help` still works). A group
1853/// that registers its own real `help` subcommand is likewise deferred to clap,
1854/// which dispatches the user-defined command (only auto-generated help is
1855/// suppressed).
1856///
1857/// `command_keyword_count` is the number of leading positionals that are
1858/// genuine command keywords (those before any `--`). A `help` at or beyond that
1859/// index is a literal operand after `--`, not a help request, so it is ignored.
1860fn group_help_target_parts(
1861    root: &Command,
1862    positionals: &[String],
1863    command_keyword_count: usize,
1864) -> Option<Vec<String>> {
1865    let help_index = positionals.iter().position(|token| token == "help")?;
1866    // A leading `help` is the curated root help command; let it flow through.
1867    if help_index == 0 {
1868        return None;
1869    }
1870    // A `help` after a `--` separator is a literal operand; leave it for clap.
1871    if help_index >= command_keyword_count {
1872        return None;
1873    }
1874    let prefix = &positionals[..help_index];
1875    let mut current = root;
1876    for token in prefix {
1877        current = current.find_subcommand(token)?;
1878    }
1879    // The token before `help` must resolve to a group; leaves are left to clap.
1880    current.get_subcommands().next()?;
1881    // Defer to clap when the group defines a real `help` subcommand of its own.
1882    if current.find_subcommand("help").is_some() {
1883        return None;
1884    }
1885    // `<group> help <sub...>` shows help for `<group> <sub...>`.
1886    let suffix = &positionals[help_index + 1..];
1887    Some(prefix.iter().chain(suffix).cloned().collect())
1888}
1889
1890/// Rewrites a `<group> help [sub...]` invocation into the canonical
1891/// `help <group> [sub...]` argument vector.
1892///
1893/// Only the positional command tokens are reordered (from `[group..., help,
1894/// sub...]` to `[help, group..., sub...]`); every flag — including `key=value`
1895/// forms, value-consuming flags, unknown flags that consume a value, and
1896/// anything after `--` — is preserved in its original place. Reordering keeps
1897/// the positional count unchanged, so the rewritten stream is filled slot for
1898/// slot. `parts` is the resolved command path (group + subcommand) from
1899/// [`group_help_target_parts`].
1900fn rewrite_group_help_args(
1901    clap_args: &[String],
1902    root_name: &str,
1903    bool_flags: &BTreeSet<String>,
1904    value_flags: &BTreeSet<String>,
1905    parts: &[String],
1906) -> Vec<String> {
1907    // New positional order: the curated `help` command, then the command path.
1908    let mut next_positional = std::iter::once("help".to_owned())
1909        .chain(parts.iter().cloned())
1910        .peekable();
1911    let mut out = Vec::with_capacity(clap_args.len());
1912    let mut iter = clap_args.iter().peekable();
1913    if iter
1914        .peek()
1915        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
1916        && let Some(program) = iter.next()
1917    {
1918        out.push(program.clone());
1919    }
1920
1921    let mut take_positional =
1922        |fallback: &String| next_positional.next().unwrap_or(fallback.clone());
1923
1924    while let Some(arg) = iter.next() {
1925        if arg == "--" {
1926            out.push(arg.clone());
1927            // Everything after `--` is positional.
1928            for rest in iter.by_ref() {
1929                out.push(take_positional(rest));
1930            }
1931            break;
1932        }
1933        if arg.contains('=') || bool_flags.contains(arg) {
1934            out.push(arg.clone());
1935            continue;
1936        }
1937        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
1938            out.push(arg.clone());
1939            if let Some(value) = iter.next() {
1940                out.push(value.clone());
1941            }
1942            continue;
1943        }
1944        if arg.starts_with('-') {
1945            out.push(arg.clone());
1946            continue;
1947        }
1948        out.push(take_positional(arg));
1949    }
1950    // Defensive: emit any positionals not yet placed (counts normally match).
1951    out.extend(next_positional);
1952    out
1953}
1954
1955fn positional_command_tokens(
1956    args: &[String],
1957    root_name: &str,
1958    bool_flags: &BTreeSet<String>,
1959    value_flags: &BTreeSet<String>,
1960) -> Vec<String> {
1961    let mut tokens = Vec::new();
1962    let mut iter = args.iter().peekable();
1963    if iter
1964        .peek()
1965        .is_some_and(|arg| arg_matches_root_name(arg, root_name))
1966    {
1967        iter.next();
1968    }
1969
1970    while let Some(arg) = iter.next() {
1971        if arg == "--" {
1972            tokens.extend(iter.cloned());
1973            break;
1974        }
1975        if arg.contains('=') {
1976            continue;
1977        }
1978        if bool_flags.contains(arg) {
1979            continue;
1980        }
1981        if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
1982            iter.next();
1983            continue;
1984        }
1985        if arg.starts_with('-') {
1986            continue;
1987        }
1988        tokens.push(arg.clone());
1989    }
1990    tokens
1991}
1992
1993fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
1994    arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
1995}
1996
1997fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
1998    arg == root_name
1999        || std::path::Path::new(arg)
2000            .file_stem()
2001            .and_then(|n| n.to_str())
2002            .is_some_and(|n| n == root_name)
2003}
2004
2005fn register_runtime_group_schemas(
2006    group: &RuntimeGroupSpec,
2007    prefix: &mut Vec<String>,
2008    schemas: &mut SchemaRegistry,
2009) {
2010    prefix.push(group.group.name.clone());
2011    for child_group in &group.groups {
2012        register_runtime_group_schemas(child_group, prefix, schemas);
2013    }
2014    for child in &group.commands {
2015        prefix.push(child.spec.name.clone());
2016        register_command_schema(&child.spec, &prefix.join(":"), schemas);
2017        prefix.pop();
2018    }
2019    prefix.pop();
2020}
2021
2022fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
2023    if let Some(schema) = &spec.output_schema {
2024        schemas.register_info(command_path.to_owned(), schema.clone());
2025    }
2026}
2027
2028fn runtime_group_clap_command_with_schema_help(
2029    group: &RuntimeGroupSpec,
2030    prefix: &mut Vec<String>,
2031    schemas: &SchemaRegistry,
2032) -> Command {
2033    let mut command = group_clap_command_without_children(&group.group);
2034    prefix.push(group.group.name.clone());
2035    for child_group in &group.groups {
2036        command = command.subcommand(runtime_group_clap_command_with_schema_help(
2037            child_group,
2038            prefix,
2039            schemas,
2040        ));
2041    }
2042    for child in &group.commands {
2043        prefix.push(child.spec.name.clone());
2044        let command_path = prefix.join(":");
2045        command = command.subcommand(command_clap_command_with_schema_help(
2046            &child.spec,
2047            &command_path,
2048            schemas,
2049        ));
2050        prefix.pop();
2051    }
2052    prefix.pop();
2053    command
2054}
2055
2056fn group_clap_command_without_children(group: &GroupSpec) -> Command {
2057    let mut command = Command::new(group.name.clone())
2058        .about(group.short.clone())
2059        .help_template(GROUP_HELP_TEMPLATE);
2060    if let Some(long) = &group.long
2061        && !long.is_empty()
2062    {
2063        command = command.long_about(long.clone());
2064    }
2065    for alias in &group.aliases {
2066        command = command.alias(alias.clone());
2067    }
2068    if group.hidden {
2069        command = command.hide(true);
2070    }
2071    command
2072}
2073
2074fn command_clap_command_with_schema_help(
2075    spec: &CommandSpec,
2076    command_path: &str,
2077    schemas: &SchemaRegistry,
2078) -> Command {
2079    let mut command = spec.clap_command();
2080    let Some(schema) = schemas.get_by_path(command_path) else {
2081        return command;
2082    };
2083    let schema_help = format_help_section(&schema.fields);
2084    if schema_help.is_empty() {
2085        return command;
2086    }
2087    let base = spec
2088        .long
2089        .as_ref()
2090        .filter(|long| !long.is_empty())
2091        .cloned()
2092        .unwrap_or_else(|| spec.short.clone());
2093    let long = if base.is_empty() {
2094        schema_help
2095    } else {
2096        format!("{base}\n\n{schema_help}")
2097    };
2098    command = command.long_about(long);
2099    command
2100}
2101
2102fn process_exit_code(code: i32) -> ExitCode {
2103    if code == 0 {
2104        return ExitCode::SUCCESS;
2105    }
2106    match u8::try_from(code) {
2107        Ok(code) if code != 0 => ExitCode::from(code),
2108        Ok(_) | Err(_) => ExitCode::from(1),
2109    }
2110}
2111
2112async fn run_streaming_command(
2113    middleware: &Middleware,
2114    request: MiddlewareRequest<'_>,
2115    raw_matches: Arc<ArgMatches>,
2116    streaming_handler: crate::command::StreamingCommandHandler,
2117) -> Result<CliRunOutput> {
2118    use tokio::{io::AsyncWriteExt, sync::mpsc};
2119
2120    let args_for_handler = request.args.clone();
2121    let user_args_for_handler = request.user_args.clone();
2122    let handler_path = request.command_path.to_owned();
2123    let middleware_for_handler = middleware.clone();
2124    let raw_matches_for_handler = raw_matches;
2125
2126    let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
2127    let sender = StreamSender(tx);
2128
2129    // Drain the channel concurrently so the handler's sends don't stall
2130    // while the writer flushes to stdout. If stdout is under backpressure
2131    // the bounded channel can still fill and the handler will await send.
2132    let writer = tokio::spawn(async move {
2133        let mut stdout = tokio::io::stdout();
2134        while let Some(event) = rx.recv().await {
2135            let Ok(line) = serde_json::to_string(&event) else {
2136                continue;
2137            };
2138            if stdout.write_all(line.as_bytes()).await.is_err()
2139                || stdout.write_all(b"\n").await.is_err()
2140                || stdout.flush().await.is_err()
2141            {
2142                break;
2143            }
2144        }
2145    });
2146
2147    let output = middleware
2148        .run(request, async move |credential| {
2149            streaming_handler(
2150                CommandContext {
2151                    credential,
2152                    args: args_for_handler,
2153                    user_args: user_args_for_handler,
2154                    command_path: handler_path,
2155                    middleware: middleware_for_handler,
2156                    raw_matches: raw_matches_for_handler,
2157                },
2158                sender,
2159            )
2160            .await?;
2161            Ok(crate::CommandResult::new(serde_json::Value::Null))
2162        })
2163        .await;
2164
2165    // Handler has completed; its sender is dropped, which closes the channel.
2166    // Wait for the writer task to flush all remaining events.
2167    let _write_result = writer.await;
2168
2169    match output {
2170        Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
2171            exit_code: 0,
2172            rendered: String::new(),
2173        }),
2174        Ok(out) => Ok(out.into()),
2175        Err(err) => Ok(CliRunOutput {
2176            exit_code: exit_code_for_error(&err),
2177            rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
2178        }),
2179    }
2180}