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