Skip to main content

cli_engine/
command.rs

1use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc};
2
3use clap::{Arg, ArgAction, ArgMatches, Command};
4use schemars::JsonSchema;
5use serde_json::{Number, Value};
6use tokio::sync::mpsc;
7
8use crate::{
9    AuthRequirement, CommandMeta, Credential, CredentialResolver, Middleware, OutputSchema, Result,
10    SchemaInfo, Tier,
11    middleware::ValueMap,
12    output::{NextAction, TableColumn},
13};
14
15/// Sender half for streaming command output.
16///
17/// Streaming handlers call [`StreamSender::send`] for each progress event.
18/// The engine drains the channel and writes each event as an NDJSON line.
19#[derive(Clone, Debug)]
20pub struct StreamSender(pub(crate) mpsc::Sender<Value>);
21
22impl StreamSender {
23    /// Sends one event. Silently drops the event if the receiver is gone.
24    pub async fn send(&self, event: Value) {
25        drop(self.0.send(event).await);
26    }
27}
28
29/// Boxed future returned by runtime command handlers.
30pub type CommandFuture = Pin<Box<dyn Future<Output = Result<CommandResult>> + Send>>;
31/// Shared command handler used by [`RuntimeCommandSpec`].
32pub type CommandHandler = Arc<dyn Fn(CommandContext) -> CommandFuture + Send + Sync>;
33
34/// Boxed future returned by streaming command handlers.
35pub type StreamingCommandFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
36/// Shared streaming handler: receives context and an event sender; returns when the stream ends.
37pub type StreamingCommandHandler =
38    Arc<dyn Fn(CommandContext, StreamSender) -> StreamingCommandFuture + Send + Sync>;
39
40/// Data returned by a command handler.
41///
42/// Command handlers should return renderable data and keep output metadata on
43/// [`CommandSpec`]. The metadata field is reserved for future command-result
44/// extensions that are not known when the command is registered.
45#[derive(Clone, Debug, PartialEq)]
46pub struct CommandResult {
47    /// JSON data rendered by the configured output formatter.
48    pub data: Value,
49    /// Optional command-result extension metadata.
50    pub metadata: CommandResultMetadata,
51}
52
53impl CommandResult {
54    /// Creates a command result from renderable JSON data.
55    #[must_use]
56    pub fn new(data: Value) -> Self {
57        Self {
58            data,
59            metadata: CommandResultMetadata::default(),
60        }
61    }
62
63    /// Attaches suggested follow-up actions to this result.
64    #[must_use]
65    pub fn with_next_actions(mut self, actions: Vec<NextAction>) -> Self {
66        self.metadata.next_actions = actions;
67        self
68    }
69}
70
71impl From<Value> for CommandResult {
72    fn from(data: Value) -> Self {
73        Self::new(data)
74    }
75}
76
77/// Optional metadata a command can attach to its result.
78#[non_exhaustive]
79#[derive(Clone, Debug, Default, Eq, PartialEq)]
80pub struct CommandResultMetadata {
81    /// Suggested follow-up actions for the caller.
82    pub next_actions: Vec<NextAction>,
83}
84
85/// Runtime context passed to advanced command handlers.
86///
87/// Most commands can use [`RuntimeCommandSpec::new`] and receive just the
88/// credential and effective args. Use this context when a command needs the
89/// colon path, user-supplied args, or a snapshot of middleware state.
90///
91/// This struct is constructed by the framework during command dispatch.
92/// Consumer code receives it in handler closures and should not construct it
93/// directly.
94#[derive(Clone, Debug)]
95#[non_exhaustive]
96pub struct CommandContext {
97    /// Lazy credential resolver.
98    pub credential: CredentialResolver,
99    /// Effective arguments, including defaults and framework-injected values.
100    pub args: ValueMap,
101    /// Arguments explicitly supplied by the user.
102    pub user_args: ValueMap,
103    /// Colon-separated command path such as `project:list`.
104    pub command_path: String,
105    /// Middleware snapshot for this invocation.
106    pub middleware: Middleware,
107    /// Raw `clap` matches for typed argument deserialization via derive.
108    pub raw_matches: Arc<ArgMatches>,
109}
110
111impl CommandContext {
112    /// Returns the per-application config file as loaded at startup.
113    ///
114    /// Read a consumer-owned section with
115    /// [`ConfigFile::section`](crate::config::ConfigFile::section), for example
116    /// `ctx.config().section::<DeployConfig>("deploy")?`. Engine-reserved
117    /// settings are available via
118    /// [`ConfigFile::engine`](crate::config::ConfigFile::engine).
119    ///
120    /// **Snapshot semantics**: this is the config loaded once when
121    /// [`crate::cli::Cli::new`] was called. Changes made by `config set` during the same process
122    /// invocation (e.g. from a previous `Cli::run`) are not reflected here;
123    /// restart the CLI (a new `Cli::new`) to pick them up. For a one-shot CLI
124    /// process this is always the current on-disk state.
125    #[must_use]
126    pub fn config(&self) -> &crate::config::ConfigFile {
127        &self.middleware.config
128    }
129
130    /// Resolves the active [`Environment`](crate::environments::Environment) for
131    /// this invocation.
132    ///
133    /// The active environment name is `self.middleware.env`, seeded at startup
134    /// from the persisted active environment or configured default and
135    /// overridden per invocation by the global `--env` flag. Resolution merges
136    /// the compiled-in definition, the `environments.toml` file layer, and
137    /// `<ENV>_*` environment-variable overrides.
138    ///
139    /// # Blocking
140    ///
141    /// When the `environments.toml` file layer is enabled, this performs
142    /// synchronous filesystem I/O via
143    /// [`Environments::resolve`](crate::environments::Environments::resolve).
144    /// Call it once per invocation and reuse the result rather than calling it
145    /// repeatedly inside an async handler on a latency-sensitive path.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error if no environment system was registered via
150    /// [`CliConfig::with_environments`](crate::CliConfig::with_environments) or
151    /// if the active name does not resolve to a known environment.
152    pub fn environment(&self) -> Result<crate::environments::Environment> {
153        let environments = self.middleware.environments.as_ref().ok_or_else(|| {
154            crate::error::CliCoreError::message("no environment system configured")
155        })?;
156        environments.resolve(&self.middleware.env)
157    }
158
159    /// Deserializes the raw argument matches into a typed args struct.
160    ///
161    /// Use this with `#[derive(clap::Args)]` structs to get type-safe access
162    /// to command arguments instead of working with the `ValueMap` directly.
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if the matches cannot be deserialized into `T`.
167    pub fn typed_args<T: clap::FromArgMatches>(&self) -> Result<T> {
168        T::from_arg_matches(self.raw_matches.as_ref())
169            .map_err(|e| crate::CliCoreError::Message(format!("argument parse error: {e}")))
170    }
171
172    /// Resolves the credential for this command, triggering the auth flow on
173    /// first use and memoizing the result.
174    ///
175    /// Convenience wrapper over [`self.credential.resolve()`](CredentialResolver::resolve).
176    ///
177    /// # Errors
178    ///
179    /// Returns an error when the command is marked `no_auth`, or when the auth
180    /// provider fails to produce a credential.
181    pub async fn credential(&self) -> Result<Credential> {
182        self.credential.resolve().await
183    }
184
185    /// Resolves the credential when one is available, returning `Ok(None)` for
186    /// no-auth commands.
187    ///
188    /// Convenience wrapper over [`self.credential.try_resolve()`](CredentialResolver::try_resolve).
189    ///
190    /// # Errors
191    ///
192    /// Propagates the auth provider error when resolution is attempted and fails.
193    pub async fn try_credential(&self) -> Result<Option<Credential>> {
194        self.credential.try_resolve().await
195    }
196
197    /// Resolves a credential that additionally covers `extra` scopes, on top of
198    /// the command's declared scopes.
199    ///
200    /// Use this when the required scopes are only known at runtime (for example
201    /// a generic API caller that derives scopes from the target endpoint). A
202    /// scope-aware auth provider re-authenticates when the cached token does not
203    /// already cover the requested set.
204    ///
205    /// Convenience wrapper over
206    /// [`self.credential.resolve_with_scopes()`](CredentialResolver::resolve_with_scopes).
207    ///
208    /// If the handler also issues HTTP requests through the transport bearer
209    /// injector, call this **before** the first request: the injector resolves
210    /// and caches a scope-unaware token, so stepping up afterwards would not
211    /// affect requests it already authorized. See
212    /// [`CredentialResolver::resolve_with_scopes`] for the full ordering note.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error when the command is marked `no_auth`, or when the auth
217    /// provider fails to produce a credential.
218    pub async fn credential_with_scopes(&self, extra: &[String]) -> Result<Credential> {
219        self.credential.resolve_with_scopes(extra).await
220    }
221}
222
223/// Declarative leaf command metadata and parser arguments.
224///
225/// `CommandSpec` intentionally keeps command metadata next to the command's
226/// handler. This is the primary copy/paste surface for teams adding commands.
227#[derive(Clone, Debug, Default)]
228pub struct CommandSpec {
229    /// Leaf command name.
230    pub name: String,
231    /// One-line command description.
232    pub short: String,
233    /// Optional long help text.
234    pub long: Option<String>,
235    /// Alternate command names accepted by the parser.
236    pub aliases: Vec<String>,
237    /// Whether the command runs but is hidden from help, tree, and search.
238    pub hidden: bool,
239    /// Backend/system id used in output metadata and generic error envelopes.
240    pub system: Option<String>,
241    /// Default comma-separated field projection.
242    pub default_fields: Option<String>,
243    /// Authentication requirement enforced by the engine for this command.
244    ///
245    /// Defaults to [`AuthRequirement::Required`] (fail-closed). Use
246    /// [`auth_optional`](CommandSpec::auth_optional) for commands that should run
247    /// logged out, or [`no_auth`](CommandSpec::no_auth) for commands that never
248    /// authenticate.
249    pub auth: AuthRequirement,
250    /// Auth provider name for this command.
251    pub auth_provider: Option<String>,
252    /// Risk tier used by authentication, authorization, and dry-run.
253    pub tier: Option<Tier>,
254    /// Explicit dry-run prompt marker for commands without a tier.
255    pub mutates: bool,
256    /// Provider-specific auth metadata.
257    pub auth_metadata: BTreeMap<String, String>,
258    /// Command-specific `clap` arguments.
259    pub args: Vec<Arg>,
260    /// Optional output schema published through `--schema` and help.
261    pub output_schema: Option<SchemaInfo>,
262    /// Inline human-output table columns assigned directly to this command.
263    ///
264    /// Set with [`with_view`](CommandSpec::with_view). When present (and
265    /// [`view_id`](CommandSpec::view_id) is unset), the engine registers these
266    /// columns under the command's own path so human output renders them.
267    pub view_columns: Vec<TableColumn>,
268    /// Id of a shared human view this command should use.
269    ///
270    /// Set with [`with_view_id`](CommandSpec::with_view_id). Names a
271    /// [`HumanViewDef`](crate::HumanViewDef) registered with `with_view` on the
272    /// module or CLI, so several commands can share one table. Takes precedence
273    /// over inline [`view_columns`](CommandSpec::view_columns).
274    pub view_id: Option<String>,
275}
276
277impl CommandSpec {
278    /// Creates a command spec with the required name and one-line help.
279    #[must_use]
280    pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
281        Self {
282            name: name.into(),
283            short: short.into(),
284            ..Self::default()
285        }
286    }
287
288    /// Creates a command spec from a `#[derive(clap::Args)]` struct.
289    ///
290    /// Extracts the argument definitions from the derive type and populates the
291    /// spec's args list. The command name and help text are still required since
292    /// `Args` types do not carry those.
293    #[must_use]
294    pub fn from_args<T: clap::Args>(name: impl Into<String>, short: impl Into<String>) -> Self {
295        let placeholder = Command::new("__placeholder");
296        let augmented = T::augment_args(placeholder);
297        let args: Vec<Arg> = augmented
298            .get_arguments()
299            .filter(|a| !matches!(a.get_id().as_str(), "help" | "version"))
300            .cloned()
301            .collect();
302        Self {
303            name: name.into(),
304            short: short.into(),
305            args,
306            ..Self::default()
307        }
308    }
309
310    /// Sets expanded command help.
311    #[must_use]
312    pub fn with_long(mut self, long: impl Into<String>) -> Self {
313        self.long = Some(long.into());
314        self
315    }
316
317    /// Adds one command alias.
318    #[must_use]
319    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
320        self.aliases.push(alias.into());
321        self
322    }
323
324    /// Hides or shows this command in discovery output.
325    #[must_use]
326    pub fn hidden(mut self, hidden: bool) -> Self {
327        self.hidden = hidden;
328        self
329    }
330
331    /// Sets the backend/system id for output metadata and error attribution.
332    #[must_use]
333    pub fn with_system(mut self, system: impl Into<String>) -> Self {
334        self.system = Some(system.into());
335        self
336    }
337
338    /// Sets the default field projection used when `--fields` is absent.
339    #[must_use]
340    pub fn with_default_fields(mut self, default_fields: impl Into<String>) -> Self {
341        self.default_fields = Some(default_fields.into());
342        self
343    }
344
345    /// Assigns an inline human-output table view to this command.
346    ///
347    /// The columns are registered under the command's own path, so human output
348    /// renders this table directly. Field selection still applies: `--fields`
349    /// (defaulting to [`default_fields`](CommandSpec::default_fields)) narrows
350    /// which of these columns show. Use
351    /// [`with_view_id`](CommandSpec::with_view_id) instead to point at a shared
352    /// view registered with `with_view` on the module or CLI.
353    #[must_use]
354    pub fn with_view(mut self, columns: impl Into<Vec<TableColumn>>) -> Self {
355        self.view_columns = columns.into();
356        self
357    }
358
359    /// Points this command at a shared human view by id.
360    ///
361    /// The id must match a [`HumanViewDef`](crate::HumanViewDef) registered with
362    /// `with_view` on the module or CLI, letting several commands share one
363    /// table. Takes precedence over inline [`with_view`](CommandSpec::with_view)
364    /// columns.
365    #[must_use]
366    pub fn with_view_id(mut self, id: impl Into<String>) -> Self {
367        self.view_id = Some(id.into());
368        self
369    }
370
371    /// Selects the auth provider for this command.
372    #[must_use]
373    pub fn with_auth_provider(mut self, provider: impl Into<String>) -> Self {
374        self.auth_provider = Some(provider.into());
375        self
376    }
377
378    /// Marks the command as no-auth.
379    ///
380    /// `no_auth(true)` sets [`AuthRequirement::None`]: the command never resolves
381    /// a credential and default-env injection is suppressed. `no_auth(false)`
382    /// restores the default [`AuthRequirement::Required`].
383    #[must_use]
384    pub fn no_auth(mut self, no_auth: bool) -> Self {
385        self.auth = if no_auth {
386            AuthRequirement::None
387        } else {
388            AuthRequirement::Required
389        };
390        self
391    }
392
393    /// Sets the command's [`AuthRequirement`] explicitly.
394    #[must_use]
395    pub fn auth(mut self, requirement: AuthRequirement) -> Self {
396        self.auth = requirement;
397        self
398    }
399
400    /// Marks authentication as optional ([`AuthRequirement::Optional`]).
401    ///
402    /// The engine does not resolve a credential before the handler runs; the
403    /// handler triggers the auth flow only by calling
404    /// [`CredentialResolver::resolve`]/[`try_resolve`](CredentialResolver::try_resolve).
405    /// Use for commands that should still run when the user is logged out.
406    #[must_use]
407    pub fn auth_optional(mut self) -> Self {
408        self.auth = AuthRequirement::Optional;
409        self
410    }
411
412    /// Sets the command risk tier.
413    #[must_use]
414    pub fn with_tier(mut self, tier: Tier) -> Self {
415        self.tier = Some(tier);
416        self
417    }
418
419    /// Adds provider-specific auth metadata.
420    #[must_use]
421    pub fn with_auth_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
422        self.auth_metadata.insert(key.into(), value.into());
423        self
424    }
425
426    /// Declares the OAuth scopes this command requires.
427    ///
428    /// Sugar over [`with_auth_metadata`](CommandSpec::with_auth_metadata) with the
429    /// `"scopes"` key (whitespace-joined). The scopes surface on
430    /// [`CommandMeta::scopes`](crate::CommandMeta) and reach the auth provider via
431    /// [`CredentialRequest`](crate::CredentialRequest); a provider that supports
432    /// scope step-up re-authenticates when the cached token lacks them.
433    #[must_use]
434    pub fn with_scopes(mut self, scopes: &[impl AsRef<str>]) -> Self {
435        let joined = scopes
436            .iter()
437            .map(AsRef::as_ref)
438            .collect::<Vec<_>>()
439            .join(" ");
440        // Mirror `CommandMeta::set_scopes`: an empty list clears the key rather
441        // than leaving an empty-but-present `auth_metadata["scopes"]`.
442        if joined.is_empty() {
443            self.auth_metadata.remove("scopes");
444        } else {
445            self.auth_metadata.insert("scopes".to_owned(), joined);
446        }
447        self
448    }
449
450    /// Adds a `clap` argument or option to this command.
451    #[must_use]
452    pub fn with_arg(mut self, arg: Arg) -> Self {
453        self.args.push(arg);
454        self
455    }
456
457    /// Adds a `clap` flag or option to this command.
458    #[must_use]
459    pub fn with_flag(self, flag: Arg) -> Self {
460        self.with_arg(flag)
461    }
462
463    /// Registers a compact framework schema from an [`OutputSchema`] type.
464    #[must_use]
465    pub fn with_output_schema<T: OutputSchema>(mut self) -> Self {
466        self.output_schema = Some(SchemaInfo {
467            command: String::new(),
468            fields: crate::output::fields_for::<T>(),
469            schema: None,
470        });
471        self
472    }
473
474    /// Registers JSON Schema generated from a Rust type with `schemars`.
475    #[must_use]
476    pub fn with_json_schema<T: JsonSchema>(mut self) -> Self {
477        self.output_schema = Some(crate::output::json_schema_info::<T>(""));
478        self
479    }
480
481    /// Marks whether the command should short-circuit under `--dry-run`.
482    #[must_use]
483    pub fn mutates(mut self, mutates: bool) -> Self {
484        self.mutates = mutates;
485        self
486    }
487
488    /// Builds middleware metadata from the spec.
489    #[must_use]
490    pub fn metadata(&self) -> CommandMeta {
491        let mut auth_metadata = self.auth_metadata.clone();
492        if let Some(provider) = &self.auth_provider
493            && !provider.is_empty()
494        {
495            auth_metadata.insert("provider".to_owned(), provider.clone());
496        }
497        if let Some(tier) = self.tier
498            && !auth_metadata.contains_key("tier")
499        {
500            auth_metadata.insert("tier".to_owned(), tier.to_string());
501        }
502        let scopes = auth_metadata
503            .get("scopes")
504            .map(|scopes| {
505                scopes
506                    .split_whitespace()
507                    .map(str::to_owned)
508                    .collect::<Vec<_>>()
509            })
510            .unwrap_or_default();
511
512        CommandMeta {
513            dry_run_prompt: self.mutates || self.tier.is_some_and(Tier::is_mutating),
514            auth_metadata,
515            scopes,
516        }
517    }
518
519    /// Builds the `clap` command for parser registration.
520    #[must_use]
521    pub fn clap_command(&self) -> Command {
522        let mut command = Command::new(self.name.clone()).about(self.short.clone());
523        if let Some(long) = &self.long
524            && !long.is_empty()
525        {
526            command = command.long_about(long.clone());
527        }
528        for alias in &self.aliases {
529            command = command.alias(alias.clone());
530        }
531        if self.hidden {
532            command = command.hide(true);
533        }
534        for arg in &self.args {
535            command = command.arg(arg.clone());
536        }
537        command
538    }
539}
540
541/// Declarative command group metadata.
542///
543/// Groups are noun-based containers. They do not run business logic directly;
544/// when invoked bare, the CLI renders group help.
545#[derive(Clone, Debug, Default)]
546pub struct GroupSpec {
547    /// Group command name.
548    pub name: String,
549    /// One-line group description.
550    pub short: String,
551    /// Optional long help text.
552    pub long: Option<String>,
553    /// Alternate group names accepted by the parser.
554    pub aliases: Vec<String>,
555    /// Whether the group runs but is hidden from discovery output.
556    pub hidden: bool,
557    /// Declarative child commands used for static tree construction.
558    pub commands: Vec<CommandSpec>,
559    /// Declarative nested groups used for static tree construction.
560    pub groups: Vec<GroupSpec>,
561}
562
563impl GroupSpec {
564    /// Creates a command group with the required name and one-line help.
565    #[must_use]
566    pub fn new(name: impl Into<String>, short: impl Into<String>) -> Self {
567        Self {
568            name: name.into(),
569            short: short.into(),
570            ..Self::default()
571        }
572    }
573
574    /// Sets expanded group help.
575    #[must_use]
576    pub fn with_long(mut self, long: impl Into<String>) -> Self {
577        self.long = Some(long.into());
578        self
579    }
580
581    /// Adds one group alias.
582    #[must_use]
583    pub fn with_alias(mut self, alias: impl Into<String>) -> Self {
584        self.aliases.push(alias.into());
585        self
586    }
587
588    /// Hides or shows this group in discovery output.
589    #[must_use]
590    pub fn hidden(mut self, hidden: bool) -> Self {
591        self.hidden = hidden;
592        self
593    }
594
595    /// Adds one declarative child command.
596    #[must_use]
597    pub fn with_command(mut self, command: CommandSpec) -> Self {
598        self.commands.push(command);
599        self
600    }
601
602    /// Adds one declarative nested group.
603    #[must_use]
604    pub fn with_group(mut self, group: GroupSpec) -> Self {
605        self.groups.push(group);
606        self
607    }
608
609    /// Builds the `clap` command for parser registration.
610    #[must_use]
611    pub fn clap_command(&self) -> Command {
612        let mut command = Command::new(self.name.clone()).about(self.short.clone());
613        if let Some(long) = &self.long
614            && !long.is_empty()
615        {
616            command = command.long_about(long.clone());
617        }
618        for alias in &self.aliases {
619            command = command.alias(alias.clone());
620        }
621        if self.hidden {
622            command = command.hide(true);
623        }
624        for group in &self.groups {
625            command = command.subcommand(group.clap_command());
626        }
627        for child in &self.commands {
628            command = command.subcommand(child.clap_command());
629        }
630        command
631    }
632}
633
634/// Executable leaf command.
635///
636/// `RuntimeCommandSpec` pairs a [`CommandSpec`] with async business logic.
637/// This split keeps metadata inspectable for help/search/schema generation
638/// before the handler ever runs.
639///
640/// Use [`RuntimeCommandSpec::new_streaming`] for commands that emit incremental
641/// NDJSON progress events (e.g. long-running deployments with `--follow`).
642#[derive(Clone)]
643pub struct RuntimeCommandSpec {
644    /// Declarative command metadata.
645    pub spec: CommandSpec,
646    /// Async command implementation.
647    pub handler: CommandHandler,
648    /// Optional streaming handler. When set, the engine writes NDJSON events
649    /// to stdout as they arrive instead of collecting a single envelope.
650    pub streaming_handler: Option<StreamingCommandHandler>,
651}
652
653impl std::fmt::Debug for RuntimeCommandSpec {
654    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655        formatter
656            .debug_struct("RuntimeCommandSpec")
657            .field("spec", &self.spec)
658            .field("is_streaming", &self.streaming_handler.is_some())
659            .finish_non_exhaustive()
660    }
661}
662
663impl RuntimeCommandSpec {
664    /// Creates a runtime command with the common handler shape.
665    ///
666    /// The handler receives a lazy [`CredentialResolver`] and the effective args.
667    /// Call `resolver.resolve().await?` only when the command actually needs a
668    /// credential; commands that ignore it never trigger an auth flow. The
669    /// handler returns [`CommandResult`], where `data` must be JSON-serializable.
670    #[must_use]
671    pub fn new<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
672    where
673        F: Fn(CredentialResolver, ValueMap) -> Fut + Send + Sync + 'static,
674        Fut: Future<Output = Result<Output>> + Send + 'static,
675        Output: Into<CommandResult> + Send + 'static,
676    {
677        Self {
678            spec,
679            streaming_handler: None,
680            handler: Arc::new(move |context| {
681                let future = handler(context.credential, context.args);
682                Box::pin(async move { future.await.map(Into::into) })
683            }),
684        }
685    }
686
687    /// Creates a runtime command with the full invocation context.
688    #[must_use]
689    pub fn new_with_context<F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
690    where
691        F: Fn(CommandContext) -> Fut + Send + Sync + 'static,
692        Fut: Future<Output = Result<Output>> + Send + 'static,
693        Output: Into<CommandResult> + Send + 'static,
694    {
695        Self {
696            spec,
697            streaming_handler: None,
698            handler: Arc::new(move |context| {
699                let future = handler(context);
700                Box::pin(async move { future.await.map(Into::into) })
701            }),
702        }
703    }
704
705    /// Creates a streaming command that emits NDJSON events to stdout.
706    ///
707    /// The handler receives context and a [`StreamSender`]. It should call
708    /// `sender.send(event).await` for each progress event, then return `Ok(())`.
709    /// The engine writes each event as a JSON line; stdout is flushed after each.
710    #[must_use]
711    pub fn new_streaming<F, Fut>(spec: CommandSpec, handler: F) -> Self
712    where
713        F: Fn(CommandContext, StreamSender) -> Fut + Send + Sync + 'static,
714        Fut: Future<Output = Result<()>> + Send + 'static,
715    {
716        let streaming: StreamingCommandHandler = Arc::new(move |context, sender| {
717            let future = handler(context, sender);
718            Box::pin(future)
719        });
720        Self {
721            spec,
722            streaming_handler: Some(streaming),
723            handler: Arc::new(|_context| Box::pin(async { Ok(CommandResult::new(Value::Null)) })),
724        }
725    }
726
727    /// Creates a runtime command with typed argument deserialization.
728    ///
729    /// The handler receives a lazy [`CredentialResolver`] and the deserialized
730    /// args struct. Use with `CommandSpec::from_args::<T>()` to get end-to-end
731    /// type safety from argument definition through handler consumption.
732    ///
733    /// If the handler also needs the command path, middleware, or user-supplied
734    /// args, use [`RuntimeCommandSpec::new_with_context`] with
735    /// [`CommandContext::typed_args`] instead.
736    #[must_use]
737    pub fn new_typed<T, F, Fut, Output>(spec: CommandSpec, handler: F) -> Self
738    where
739        T: clap::FromArgMatches + Send + 'static,
740        F: Fn(CredentialResolver, T) -> Fut + Send + Sync + 'static,
741        Fut: Future<Output = Result<Output>> + Send + 'static,
742        Output: Into<CommandResult> + Send + 'static,
743    {
744        let handler = Arc::new(handler);
745        Self {
746            spec,
747            handler: Arc::new(move |context| {
748                let credential = context.credential.clone();
749                let parsed = T::from_arg_matches(context.raw_matches.as_ref());
750                let handler = handler.clone();
751                Box::pin(async move {
752                    let args = parsed.map_err(|e| {
753                        crate::CliCoreError::Message(format!("argument parse error: {e}"))
754                    })?;
755                    handler(credential, args).await.map(Into::into)
756                })
757            }),
758            streaming_handler: None,
759        }
760    }
761}
762
763/// Executable command group with runtime children.
764#[derive(Clone, Debug, Default)]
765pub struct RuntimeGroupSpec {
766    /// Declarative group metadata.
767    pub group: GroupSpec,
768    /// Executable leaf commands under this group.
769    pub commands: Vec<RuntimeCommandSpec>,
770    /// Executable nested groups under this group.
771    pub groups: Vec<RuntimeGroupSpec>,
772}
773
774impl RuntimeGroupSpec {
775    /// Creates a runtime group from declarative group metadata.
776    #[must_use]
777    pub fn new(group: GroupSpec) -> Self {
778        Self {
779            group,
780            ..Self::default()
781        }
782    }
783
784    /// Adds one executable leaf command.
785    #[must_use]
786    pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
787        self.commands.push(command);
788        self
789    }
790
791    /// Adds one executable nested group.
792    #[must_use]
793    pub fn with_group(mut self, group: RuntimeGroupSpec) -> Self {
794        self.groups.push(group);
795        self
796    }
797
798    /// Builds the `clap` command for parser registration.
799    #[must_use]
800    pub fn clap_command(&self) -> Command {
801        let mut command = Command::new(self.group.name.clone()).about(self.group.short.clone());
802        if let Some(long) = &self.group.long
803            && !long.is_empty()
804        {
805            command = command.long_about(long.clone());
806        }
807        for alias in &self.group.aliases {
808            command = command.alias(alias.clone());
809        }
810        if self.group.hidden {
811            command = command.hide(true);
812        }
813        for group in &self.groups {
814            command = command.subcommand(group.clap_command());
815        }
816        for child in &self.commands {
817            command = command.subcommand(child.spec.clap_command());
818        }
819        command
820    }
821
822    pub(crate) fn register_commands(
823        &self,
824        prefix: &mut Vec<String>,
825        out: &mut BTreeMap<String, RuntimeCommandSpec>,
826    ) {
827        prefix.push(self.group.name.clone());
828        for group in &self.groups {
829            group.register_commands(prefix, out);
830        }
831        for command in &self.commands {
832            prefix.push(command.spec.name.clone());
833            out.insert(prefix.join(":"), command.clone());
834            prefix.pop();
835        }
836        prefix.pop();
837    }
838}
839
840/// Extracts the colon-separated command path from parsed `clap` matches.
841#[must_use]
842pub fn command_path_from_matches(root_name: &str, matches: &ArgMatches) -> String {
843    let mut parts = Vec::new();
844    let mut current = matches;
845    while let Some((name, submatches)) = current.subcommand() {
846        if name != root_name {
847            parts.push(name.to_owned());
848        }
849        current = submatches;
850    }
851    parts.join(":")
852}
853
854/// Builds a colon-separated command path from path parts.
855///
856/// The optional annotation is used only for isolated single-command tests.
857#[must_use]
858pub fn command_path_from_parts(parts: &[impl AsRef<str>], path_annotation: Option<&str>) -> String {
859    if parts.is_empty() {
860        return String::new();
861    }
862    if parts.len() > 1 {
863        return parts[1..]
864            .iter()
865            .map(AsRef::as_ref)
866            .collect::<Vec<_>>()
867            .join(":");
868    }
869    path_annotation
870        .filter(|annotation| !annotation.is_empty())
871        .map_or_else(|| parts[0].as_ref().to_owned(), ToOwned::to_owned)
872}
873
874/// Returns the deepest subcommand matches.
875#[must_use]
876pub fn leaf_matches(matches: &ArgMatches) -> &ArgMatches {
877    let mut current = matches;
878    while let Some((_, submatches)) = current.subcommand() {
879        current = submatches;
880    }
881    current
882}
883
884/// Converts parsed command arguments into the JSON-ish map consumed by middleware.
885///
886/// When `changed_only` is true, only arguments that came from the command line
887/// are included. This is the user-args map used by authz and audit.
888#[must_use]
889pub fn command_args_from_matches(
890    matches: &ArgMatches,
891    spec: &CommandSpec,
892    changed_only: bool,
893) -> ValueMap {
894    let mut args = ValueMap::new();
895    for arg in &spec.args {
896        let id = arg.get_id().to_string();
897        let changed = matches
898            .value_source(&id)
899            .is_some_and(|source| source == clap::parser::ValueSource::CommandLine);
900        if changed_only && !changed {
901            continue;
902        }
903        if let Some(value) = arg_value_from_matches(matches, arg, &id) {
904            args.insert(id, value);
905        }
906    }
907    args
908}
909
910fn arg_value_from_matches(matches: &ArgMatches, flag: &Arg, id: &str) -> Option<Value> {
911    matches.value_source(id)?;
912
913    if matches!(flag.get_action(), ArgAction::SetTrue | ArgAction::SetFalse)
914        && let Some(value) = matches.get_one::<bool>(id)
915    {
916        return Some(Value::Bool(*value));
917    }
918
919    if let Some(value) = typed_arg_value_from_matches(matches, id) {
920        return Some(value);
921    }
922
923    if let Some(values) = matches.get_raw(id) {
924        let rendered = values
925            .map(|value| value.to_string_lossy().into_owned())
926            .collect::<Vec<_>>();
927        return match rendered.as_slice() {
928            [] => None,
929            [single] => Some(Value::String(single.clone())),
930            _ => Some(Value::Array(
931                rendered.into_iter().map(Value::String).collect(),
932            )),
933        };
934    }
935
936    if let Some(value) = matches.get_one::<String>(id) {
937        return Some(Value::String(value.clone()));
938    }
939    if let Some(value) = matches.get_one::<usize>(id) {
940        return Some(serde_json::json!(value));
941    }
942    if let Some(value) = matches.get_one::<u64>(id) {
943        return Some(serde_json::json!(value));
944    }
945    if let Some(value) = matches.get_one::<i64>(id) {
946        return Some(serde_json::json!(value));
947    }
948    None
949}
950
951fn typed_arg_value_from_matches(matches: &ArgMatches, id: &str) -> Option<Value> {
952    typed_values::<bool>(matches, id, Value::Bool)
953        .or_else(|| typed_values::<i8>(matches, id, |value| Value::Number(value.into())))
954        .or_else(|| typed_values::<i16>(matches, id, |value| Value::Number(value.into())))
955        .or_else(|| typed_values::<i64>(matches, id, |value| Value::Number(value.into())))
956        .or_else(|| typed_values::<i32>(matches, id, |value| Value::Number(value.into())))
957        .or_else(|| typed_values::<u8>(matches, id, |value| Value::Number(value.into())))
958        .or_else(|| typed_values::<u16>(matches, id, |value| Value::Number(value.into())))
959        .or_else(|| typed_values::<u64>(matches, id, |value| Value::Number(value.into())))
960        .or_else(|| typed_values::<u32>(matches, id, |value| Value::Number(value.into())))
961        .or_else(|| {
962            typed_values::<usize>(matches, id, |value| {
963                u64::try_from(value).map_or(Value::Null, |value| Value::Number(value.into()))
964            })
965        })
966        .or_else(|| {
967            typed_values::<f64>(matches, id, |value| {
968                Number::from_f64(value).map_or(Value::Null, Value::Number)
969            })
970        })
971        .or_else(|| {
972            typed_values::<f32>(matches, id, |value| {
973                Number::from_f64(f64::from(value)).map_or(Value::Null, Value::Number)
974            })
975        })
976        .or_else(|| typed_values::<String>(matches, id, Value::String))
977}
978
979fn typed_values<T>(matches: &ArgMatches, id: &str, to_value: impl Fn(T) -> Value) -> Option<Value>
980where
981    T: Clone + Send + Sync + 'static,
982{
983    let Ok(Some(values)) = matches.try_get_many::<T>(id) else {
984        return None;
985    };
986    let values = values.cloned().map(to_value).collect::<Vec<_>>();
987    match values.as_slice() {
988        [] => None,
989        [single] => Some(single.clone()),
990        _ => Some(Value::Array(values)),
991    }
992}