1use std::{
2 collections::{BTreeMap, BTreeSet},
3 future::Future,
4 io::Write,
5 path::{Path, PathBuf},
6 process::ExitCode,
7 sync::{Arc, Mutex},
8 time::Duration,
9};
10
11mod builtins;
12mod completion;
13mod help;
14mod tree_render;
15
16use clap::{ArgMatches, Command};
17
18use crate::{
19 ActivityEmitter, Auditor, AuthProvider, Authorizer, CliCoreError, CommandMeta, CommandSpec,
20 GroupSpec, GuideEntry, Middleware, MiddlewareRequest, Result, RuntimeCommandSpec,
21 RuntimeGroupSpec,
22 auth::commands::auth_command_group,
23 command::{
24 CommandContext, StreamSender, command_args_from_matches, command_path_from_matches,
25 leaf_matches,
26 },
27 error::exit_code_for_error,
28 flags::{
29 GlobalFlags, default_output_format, derive_bool_flags, derive_value_flags,
30 extract_command_path, extract_output_format, extract_search_query,
31 global_flags_from_matches, has_true_schema_flag, register_global_flags,
32 },
33 guide::guide_content,
34 module::{Module, ModuleContext},
35 output::{
36 HumanViewDef, HumanViewRegistry, NextAction, SchemaRegistry, format_help_section,
37 global_human_view_registry_snapshot, global_schema_registry_snapshot,
38 },
39 search::{SearchDocument, SearchIndex},
40};
41
42use builtins::{
43 completion_args, completion_command, guide_args, guide_command, help_args, help_command,
44};
45use help::{GROUP_HELP_TEMPLATE, ROOT_HELP_TEMPLATE};
46pub use help::{ModuleHelpEntry, build_root_long, render_next_actions_human};
47
48#[derive(Clone, Debug, Default, Eq, PartialEq)]
50pub struct BuildInfo {
51 pub version: String,
53 pub commit: Option<String>,
55 pub date: Option<String>,
57}
58
59impl BuildInfo {
60 #[must_use]
62 pub fn new(version: impl Into<String>) -> Self {
63 Self {
64 version: version.into(),
65 commit: None,
66 date: None,
67 }
68 }
69
70 #[must_use]
72 pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
73 self.commit = Some(commit.into());
74 self
75 }
76
77 #[must_use]
79 pub fn with_date(mut self, date: impl Into<String>) -> Self {
80 self.date = Some(date.into());
81 self
82 }
83
84 #[must_use]
86 pub fn version_string(&self) -> String {
87 let commit = self.commit.as_deref().unwrap_or_default();
88 let date = self.date.as_deref().unwrap_or_default();
89
90 if commit.is_empty() && date.is_empty() {
91 self.version.clone()
92 } else {
93 format!("{} (commit {commit}, built {date})", self.version)
94 }
95 }
96}
97
98pub type InitDeps = Arc<dyn Fn(&mut Middleware) -> Result<()> + Send + Sync>;
100pub type RegisterFlags = Arc<dyn Fn(Command) -> Command + Send + Sync>;
102pub type ApplyFlags = Arc<dyn Fn(&ArgMatches, &mut Middleware) -> Result<()> + Send + Sync>;
104pub type PreRun =
106 Arc<dyn Fn(&mut Middleware, &str, &crate::middleware::ValueMap) -> Result<()> + Send + Sync>;
107pub type ResolveMeta = Arc<dyn Fn(&str, CommandMeta) -> CommandMeta + Send + Sync>;
109pub type OnShutdown = Arc<dyn Fn() + Send + Sync>;
111pub type ExtraSearchDocs = Arc<dyn Fn() -> Vec<SearchDocument> + Send + Sync>;
113pub type RootNextActions = Arc<dyn Fn() -> Vec<NextAction> + Send + Sync>;
117
118const DEFAULT_ADMIN_CATEGORY: &str = "Admin";
122
123const MAX_ARGV0_DEPTH: usize = 16;
128
129#[derive(Clone)]
141#[non_exhaustive]
142pub enum Argv0Route {
143 Alias(Vec<String>),
149 Personality(Arc<dyn Fn() -> CliConfig + Send + Sync>),
154}
155
156impl std::fmt::Debug for Argv0Route {
157 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 match self {
159 Self::Alias(tokens) => formatter.debug_tuple("Alias").field(tokens).finish(),
160 Self::Personality(_) => formatter.write_str("Personality(..)"),
161 }
162 }
163}
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
173#[non_exhaustive]
174pub enum Argv0LinkMethod {
175 SoftLink,
178 HardLink,
181 Script,
185}
186
187pub(crate) const BUILTIN_COMMAND_NAMES: [&str; 4] = ["help", "guide", "tree", "completion"];
191
192#[derive(Clone, Default)]
198pub struct CliConfig {
199 pub name: String,
201 pub short: String,
203 pub long: Option<String>,
205 pub build: BuildInfo,
207 pub app_id: String,
209 pub default_auth_provider: Option<String>,
211 pub modules: Vec<Module>,
213 pub commands: Vec<RuntimeCommandSpec>,
215 pub guides: Vec<GuideEntry>,
217 pub views: Vec<HumanViewDef>,
219 pub auth_providers: Vec<Arc<dyn AuthProvider>>,
221 pub user_agent: Option<String>,
225 pub redacted_debug_headers: Vec<String>,
231 pub authz: Option<Arc<dyn Authorizer>>,
233 pub auditor: Option<Arc<dyn Auditor>>,
235 pub activity: Option<Arc<dyn ActivityEmitter>>,
237 pub init_deps: Option<InitDeps>,
239 pub register_flags: Option<RegisterFlags>,
241 pub apply_flags: Option<ApplyFlags>,
243 pub pre_run: Option<PreRun>,
245 pub meta_resolver: Option<ResolveMeta>,
247 pub on_shutdown: Option<OnShutdown>,
249 pub extra_search_docs: Option<ExtraSearchDocs>,
251 pub root_next_actions: Option<RootNextActions>,
253 pub admin_category: Option<String>,
258 pub config_commands: bool,
263 pub argv0_routes: BTreeMap<String, Argv0Route>,
271 pub environments: Option<Arc<crate::environments::Environments>>,
278}
279
280impl CliConfig {
281 #[must_use]
283 pub fn new(
284 name: impl Into<String>,
285 short: impl Into<String>,
286 app_id: impl Into<String>,
287 ) -> Self {
288 Self {
289 name: name.into(),
290 short: short.into(),
291 app_id: app_id.into(),
292 ..Self::default()
293 }
294 }
295
296 #[must_use]
298 pub fn with_long(mut self, long: impl Into<String>) -> Self {
299 self.long = Some(long.into());
300 self
301 }
302
303 #[must_use]
305 pub fn with_build(mut self, build: BuildInfo) -> Self {
306 self.build = build;
307 self
308 }
309
310 #[must_use]
312 pub fn with_default_auth_provider(mut self, provider: impl Into<String>) -> Self {
313 self.default_auth_provider = Some(provider.into());
314 self
315 }
316
317 #[must_use]
346 pub fn with_environments(
347 mut self,
348 environments: Arc<crate::environments::Environments>,
349 ) -> Self {
350 self.environments = Some(environments);
351 self
352 }
353
354 #[must_use]
363 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
364 self.user_agent = Some(user_agent.into());
365 self
366 }
367
368 #[must_use]
378 pub fn with_redacted_debug_headers(
379 mut self,
380 names: impl IntoIterator<Item = impl Into<String>>,
381 ) -> Self {
382 self.redacted_debug_headers
383 .extend(names.into_iter().filter_map(|name| {
384 let name = name.into().trim().to_owned();
385 (!name.is_empty()).then_some(name)
386 }));
387 self
388 }
389
390 #[must_use]
397 pub fn user_agent_string(&self) -> String {
398 if let Some(user_agent) = &self.user_agent {
399 return user_agent.clone();
400 }
401 if self.build.version.is_empty() {
402 self.name.clone()
403 } else {
404 format!("{}/{}", self.name, self.build.version)
405 }
406 }
407
408 #[must_use]
417 pub fn with_module(mut self, module: Module) -> Self {
418 self.modules.push(module);
419 self
420 }
421
422 #[must_use]
426 pub fn with_modules(mut self, modules: impl IntoIterator<Item = Module>) -> Self {
427 self.modules.extend(modules);
428 self
429 }
430
431 #[must_use]
433 pub fn with_command(mut self, command: RuntimeCommandSpec) -> Self {
434 self.commands.push(command);
435 self
436 }
437
438 #[must_use]
440 pub fn with_guide(mut self, guide: GuideEntry) -> Self {
441 self.guides.push(guide);
442 self
443 }
444
445 #[must_use]
447 pub fn with_guides(mut self, guides: impl IntoIterator<Item = GuideEntry>) -> Self {
448 self.guides.extend(guides);
449 self
450 }
451
452 #[must_use]
454 pub fn with_view(mut self, view: HumanViewDef) -> Self {
455 self.views.push(view);
456 self
457 }
458
459 #[must_use]
461 pub fn with_auth_provider(mut self, provider: Arc<dyn AuthProvider>) -> Self {
462 self.auth_providers.push(provider);
463 self
464 }
465
466 #[must_use]
468 pub fn with_authz(mut self, authz: Arc<dyn Authorizer>) -> Self {
469 self.authz = Some(authz);
470 self
471 }
472
473 #[must_use]
475 pub fn with_auditor(mut self, auditor: Arc<dyn Auditor>) -> Self {
476 self.auditor = Some(auditor);
477 self
478 }
479
480 #[must_use]
482 pub fn with_activity(mut self, activity: Arc<dyn ActivityEmitter>) -> Self {
483 self.activity = Some(activity);
484 self
485 }
486
487 #[must_use]
489 pub fn with_init_deps(mut self, init_deps: InitDeps) -> Self {
490 self.init_deps = Some(init_deps);
491 self
492 }
493
494 #[must_use]
496 pub fn with_register_flags(mut self, register_flags: RegisterFlags) -> Self {
497 self.register_flags = Some(register_flags);
498 self
499 }
500
501 #[must_use]
503 pub fn with_apply_flags(mut self, apply_flags: ApplyFlags) -> Self {
504 self.apply_flags = Some(apply_flags);
505 self
506 }
507
508 #[must_use]
510 pub fn with_pre_run(mut self, pre_run: PreRun) -> Self {
511 self.pre_run = Some(pre_run);
512 self
513 }
514
515 #[must_use]
517 pub fn with_meta_resolver(mut self, meta_resolver: ResolveMeta) -> Self {
518 self.meta_resolver = Some(meta_resolver);
519 self
520 }
521
522 #[must_use]
524 pub fn with_on_shutdown(mut self, on_shutdown: OnShutdown) -> Self {
525 self.on_shutdown = Some(on_shutdown);
526 self
527 }
528
529 #[must_use]
531 pub fn with_extra_search_docs(mut self, extra_search_docs: ExtraSearchDocs) -> Self {
532 self.extra_search_docs = Some(extra_search_docs);
533 self
534 }
535
536 #[must_use]
538 pub fn with_root_next_actions(mut self, root_next_actions: RootNextActions) -> Self {
539 self.root_next_actions = Some(root_next_actions);
540 self
541 }
542
543 #[must_use]
547 pub fn with_admin_category(mut self, category: impl Into<String>) -> Self {
548 self.admin_category = Some(category.into());
549 self
550 }
551
552 #[must_use]
558 pub fn with_config_commands(mut self) -> Self {
559 self.config_commands = true;
560 self
561 }
562
563 #[must_use]
586 pub fn with_argv0_alias(
587 mut self,
588 name: impl Into<String>,
589 command_path: impl IntoIterator<Item = impl Into<String>>,
590 ) -> Self {
591 let name = name.into();
592 debug_assert!(
593 is_valid_argv0_name(&name),
594 "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
595 );
596 debug_assert!(
597 name != self.name,
598 "argv0 route name {name:?} must differ from the CLI's own name {:?}",
599 self.name
600 );
601 let tokens = command_path.into_iter().map(Into::into).collect();
602 self.argv0_routes.insert(name, Argv0Route::Alias(tokens));
603 self
604 }
605
606 #[must_use]
628 pub fn with_argv0_personality(
629 mut self,
630 name: impl Into<String>,
631 build: impl Fn() -> CliConfig + Send + Sync + 'static,
632 ) -> Self {
633 let name = name.into();
634 debug_assert!(
635 is_valid_argv0_name(&name),
636 "argv0 route name {name:?} must be non-empty and contain only ASCII letters, digits, '-', or '_'"
637 );
638 debug_assert!(
639 name != self.name,
640 "argv0 route name {name:?} must differ from the CLI's own name {:?}",
641 self.name
642 );
643 self.argv0_routes
644 .insert(name, Argv0Route::Personality(Arc::new(build)));
645 self
646 }
647}
648
649impl std::fmt::Debug for CliConfig {
650 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651 formatter
652 .debug_struct("CliConfig")
653 .field("name", &self.name)
654 .field("short", &self.short)
655 .field("long", &self.long)
656 .field("build", &self.build)
657 .field("app_id", &self.app_id)
658 .field("default_auth_provider", &self.default_auth_provider)
659 .field("modules", &self.modules)
660 .field("commands", &self.commands)
661 .field("guides", &self.guides)
662 .field("views", &self.views)
663 .field("auth_providers_len", &self.auth_providers.len())
664 .field("has_authz", &self.authz.is_some())
665 .field("has_auditor", &self.auditor.is_some())
666 .field("has_activity", &self.activity.is_some())
667 .field("has_init_deps", &self.init_deps.is_some())
668 .field("has_register_flags", &self.register_flags.is_some())
669 .field("has_apply_flags", &self.apply_flags.is_some())
670 .field("has_pre_run", &self.pre_run.is_some())
671 .field("has_meta_resolver", &self.meta_resolver.is_some())
672 .field("has_on_shutdown", &self.on_shutdown.is_some())
673 .field("has_extra_search_docs", &self.extra_search_docs.is_some())
674 .field("has_root_next_actions", &self.root_next_actions.is_some())
675 .field("admin_category", &self.admin_category)
676 .field(
677 "argv0_routes",
678 &self.argv0_routes.keys().collect::<Vec<_>>(),
679 )
680 .finish()
681 }
682}
683
684#[derive(Clone, Debug, PartialEq)]
686pub struct CliRunOutput {
687 pub exit_code: i32,
689 pub rendered: String,
691}
692
693impl From<crate::middleware::MiddlewareOutput> for CliRunOutput {
694 fn from(o: crate::middleware::MiddlewareOutput) -> Self {
695 Self {
696 exit_code: o.exit_code,
697 rendered: o.rendered,
698 }
699 }
700}
701
702#[derive(Clone)]
708pub struct Cli {
709 config: CliConfig,
710 middleware: Middleware,
711 root: Command,
712 commands: BTreeMap<String, RuntimeCommandSpec>,
713 module_entries: Vec<ModuleHelpEntry>,
714 guide_entries: Vec<GuideEntry>,
715 init_deps: Option<InitDeps>,
716 apply_flags: Option<ApplyFlags>,
717 pre_run: Option<PreRun>,
718 meta_resolver: Option<ResolveMeta>,
719 on_shutdown: Option<OnShutdown>,
720 extra_search_docs: Option<ExtraSearchDocs>,
721 root_next_actions: Option<RootNextActions>,
722 init_state: Arc<Mutex<Option<std::result::Result<Middleware, InitFailure>>>>,
723}
724
725#[derive(Clone, Debug, Eq, PartialEq)]
726struct InitFailure {
727 message: String,
728 code: String,
729 system: String,
730 request_id: String,
731 exit_code: i32,
732}
733
734impl InitFailure {
735 fn capture(err: &CliCoreError) -> Self {
736 let envelope = crate::output::build_error_envelope(err, "");
737 let (code, system, request_id) = envelope.error.map_or_else(
738 || ("ERROR".to_owned(), String::new(), String::new()),
739 |error| (error.code, error.system, error.request_id),
740 );
741 Self {
742 message: err.to_string(),
743 code,
744 system,
745 request_id,
746 exit_code: exit_code_for_error(err),
747 }
748 }
749
750 fn into_error(self) -> CliCoreError {
751 CliCoreError::with_exit_code(
752 self.exit_code,
753 CliCoreError::SystemMessage {
754 message: self.message,
755 system: self.system,
756 code: self.code,
757 request_id: self.request_id,
758 },
759 )
760 }
761}
762
763impl std::fmt::Debug for Cli {
764 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
765 formatter
766 .debug_struct("Cli")
767 .field("config", &self.config)
768 .field("middleware", &self.middleware)
769 .field("root", &self.root)
770 .field("commands", &self.commands)
771 .field("module_entries", &self.module_entries)
772 .field("guide_entries", &self.guide_entries)
773 .field("has_init_deps", &self.init_deps.is_some())
774 .field("has_apply_flags", &self.apply_flags.is_some())
775 .field("has_pre_run", &self.pre_run.is_some())
776 .field("has_meta_resolver", &self.meta_resolver.is_some())
777 .field("has_on_shutdown", &self.on_shutdown.is_some())
778 .field("has_extra_search_docs", &self.extra_search_docs.is_some())
779 .field("has_root_next_actions", &self.root_next_actions.is_some())
780 .finish()
781 }
782}
783
784impl Cli {
785 #[must_use]
787 pub fn new(config: CliConfig) -> Self {
788 let auth_providers = config.auth_providers.clone();
789 let guides = config.guides.clone();
790 let views = config.views.clone();
791 let modules = config.modules.clone();
792 let commands = config.commands.clone();
793 let init_deps = config.init_deps.clone();
794 let apply_flags = config.apply_flags.clone();
795 let pre_run = config.pre_run.clone();
796 let meta_resolver = config.meta_resolver.clone();
797 let on_shutdown = config.on_shutdown.clone();
798 let extra_search_docs = config.extra_search_docs.clone();
799 let root_next_actions = config.root_next_actions.clone();
800 let mut root = Command::new(config.name.clone())
801 .about(config.short.clone())
802 .disable_help_subcommand(true)
803 .version(config.build.version_string());
804 if let Some(long) = &config.long
805 && !long.is_empty()
806 {
807 root = root.long_about(long.clone());
808 }
809 root = register_global_flags(root)
810 .subcommand(help_command())
811 .subcommand(guide_command())
812 .subcommand(Command::new("tree").about("Display full command tree"))
813 .subcommand(completion_command());
814 if let Some(register_flags) = &config.register_flags {
815 root = register_flags(root);
816 }
817 if config.environments.is_some() {
818 root = root.arg(
819 clap::Arg::new("env")
820 .long("env")
821 .global(true)
822 .value_name("ENV")
823 .help("Override the active environment (see: env list)"),
824 );
825 }
826 let intro = config
827 .long
828 .as_deref()
829 .filter(|long| !long.is_empty())
830 .unwrap_or(config.short.as_str());
831 root = root
832 .long_about(build_root_long(intro, &[], false))
833 .help_template(ROOT_HELP_TEMPLATE);
834
835 let mut middleware = Middleware::new();
836 middleware.app_id = config.app_id.clone();
837 middleware.config = Arc::new(crate::config::ConfigFile::load(&config.app_id));
840 middleware.default_auth_provider = config.default_auth_provider.clone().unwrap_or_default();
841 middleware.authz = config.authz.clone();
842 middleware.auditor = config.auditor.clone();
843 middleware.activity = config.activity.clone();
844 middleware
845 .schema_registry
846 .merge(&global_schema_registry_snapshot());
847 middleware
848 .human_views
849 .merge(&global_human_view_registry_snapshot());
850 if let Some(environments) = &config.environments {
851 middleware.env = environments.effective_active(None, &middleware.config);
856 middleware.environments = Some(Arc::clone(environments));
857 }
858
859 let mut cli = Self {
860 config,
861 middleware,
862 root,
863 commands: BTreeMap::new(),
864 module_entries: Vec::new(),
865 guide_entries: Vec::new(),
866 init_deps,
867 apply_flags,
868 pre_run,
869 meta_resolver,
870 on_shutdown,
871 extra_search_docs,
872 root_next_actions,
873 init_state: Arc::new(Mutex::new(None)),
874 };
875 for provider in auth_providers {
876 cli.register_auth_provider(provider);
877 }
878 if cli.middleware.default_auth_provider.is_empty()
879 && let Some(provider) = cli.middleware.auth.registered_names().first()
880 {
881 cli.middleware.default_auth_provider = provider.clone();
882 }
883 if !cli.middleware.default_auth_provider.is_empty() {
884 cli.ensure_auth_command();
885 }
886 for view in views {
887 cli.middleware.human_views.register(view);
888 }
889 cli.add_guides(guides);
890 for module in modules {
891 cli.add_module(module);
892 }
893 for command in commands {
894 cli.add_command(command);
895 }
896 if cli.config.config_commands {
897 cli.ensure_config_command();
898 }
899 if cli.config.environments.is_some() {
900 cli.ensure_env_command();
901 }
902 cli
903 }
904
905 fn register_auth_help_entry(&mut self) {
910 let category = self
911 .config
912 .admin_category
913 .clone()
914 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
915 let already_listed = self.module_entries.iter().any(|entry| entry.name == "auth");
916 let short = self
917 .root
918 .find_subcommand("auth")
919 .filter(|auth| !auth.is_hide_set())
920 .map(|auth| {
921 auth.get_about()
922 .map(ToString::to_string)
923 .unwrap_or_default()
924 });
925 if !already_listed && let Some(short) = short {
926 self.module_entries.push(ModuleHelpEntry {
927 category,
928 name: "auth".to_owned(),
929 short,
930 });
931 }
932 self.refresh_root_long();
933 }
934
935 #[must_use]
937 pub fn middleware(&self) -> &Middleware {
938 &self.middleware
939 }
940
941 pub fn middleware_mut(&mut self) -> &mut Middleware {
943 &mut self.middleware
944 }
945
946 pub async fn execute(&self) -> ExitCode {
948 let mut stdout = std::io::stdout().lock();
949 let mut stderr = std::io::stderr().lock();
950 match self
951 .execute_from(std::env::args_os(), &mut stdout, &mut stderr)
952 .await
953 {
954 Ok(code) => code,
955 Err(err) => {
956 drop(writeln!(stderr, "{err}"));
957 ExitCode::from(1)
958 }
959 }
960 }
961
962 pub async fn execute_from<I, S, O, E>(
964 &self,
965 args: I,
966 stdout: &mut O,
967 stderr: &mut E,
968 ) -> std::io::Result<ExitCode>
969 where
970 I: IntoIterator<Item = S>,
971 S: Into<std::ffi::OsString> + Clone,
972 O: Write,
973 E: Write,
974 {
975 self.execute_from_until_signal(args, stdout, stderr, shutdown_signal())
976 .await
977 }
978
979 pub async fn execute_from_until_signal<I, S, O, E, Shutdown>(
981 &self,
982 args: I,
983 stdout: &mut O,
984 stderr: &mut E,
985 shutdown: Shutdown,
986 ) -> std::io::Result<ExitCode>
987 where
988 I: IntoIterator<Item = S>,
989 S: Into<std::ffi::OsString> + Clone,
990 O: Write,
991 E: Write,
992 Shutdown: Future<Output = ()>,
993 {
994 self.install_default_user_agent();
995 let output = run_until_signal(self.run(args), shutdown).await;
996 if output.exit_code == 130
997 && output.rendered == "command interrupted\n"
998 && let Some(on_shutdown) = &self.on_shutdown
999 {
1000 on_shutdown();
1001 }
1002 if output.exit_code == 0 {
1003 stdout.write_all(output.rendered.as_bytes())?;
1004 } else {
1005 stderr.write_all(output.rendered.as_bytes())?;
1006 }
1007 Ok(process_exit_code(output.exit_code))
1008 }
1009
1010 fn install_default_user_agent(&self) {
1018 crate::transport::set_default_user_agent(self.config.user_agent_string());
1019 }
1020
1021 pub fn register_auth_provider(&mut self, provider: Arc<dyn AuthProvider>) -> &mut Self {
1023 self.middleware.auth.register(provider);
1024 self.ensure_auth_command();
1025 self.refresh_root_long();
1026 self
1027 }
1028
1029 #[must_use]
1031 pub fn root_command(&self) -> &Command {
1032 &self.root
1033 }
1034
1035 pub fn add_module_group(
1037 &mut self,
1038 category: impl Into<String>,
1039 group: RuntimeGroupSpec,
1040 ) -> &mut Self {
1041 if BUILTIN_COMMAND_NAMES.contains(&group.group.name.as_str()) {
1045 tracing::warn!(
1046 name = %group.group.name,
1047 "module group name is reserved by cli-engine built-ins; the group will not be registered"
1048 );
1049 return self;
1050 }
1051 let category = category.into();
1052 if !group.group.hidden {
1053 self.module_entries.push(ModuleHelpEntry {
1054 category,
1055 name: group.group.name.clone(),
1056 short: group.group.short.clone(),
1057 });
1058 }
1059
1060 let mut prefix = Vec::new();
1061 register_runtime_group_metadata(
1062 &group,
1063 &mut prefix,
1064 &mut self.middleware.schema_registry,
1065 &mut self.middleware.human_views,
1066 );
1067 let mut prefix = Vec::new();
1068 group.register_commands(&mut prefix, &mut self.commands);
1069 let mut prefix = Vec::new();
1070 let clap_group = runtime_group_clap_command_with_schema_help(
1071 &group,
1072 &mut prefix,
1073 &self.middleware.schema_registry,
1074 );
1075 self.root = self.root.clone().subcommand(clap_group);
1076 self.refresh_root_long();
1077 self
1078 }
1079
1080 pub fn add_module(&mut self, module: Module) -> &mut Self {
1082 for view in module.views.clone() {
1083 self.middleware.human_views.register(view);
1084 }
1085 self.add_guides(module.guides.clone());
1086 let mut context = ModuleContext::new(&mut self.middleware);
1087 let group = (module.register)(&mut context);
1088 let (guides, views) = context.into_parts();
1089 for view in views {
1090 self.middleware.human_views.register(view);
1091 }
1092 self.add_guides(guides);
1093 self.add_module_group(module.category, group)
1094 }
1095
1096 pub fn add_command(&mut self, command: RuntimeCommandSpec) -> &mut Self {
1098 let name = command.spec.name.clone();
1099 register_command_schema(&command.spec, &name, &mut self.middleware.schema_registry);
1100 self.commands.insert(name, command.clone());
1101 self.root = self
1102 .root
1103 .clone()
1104 .subcommand(command_clap_command_with_schema_help(
1105 &command.spec,
1106 &command.spec.name,
1107 &self.middleware.schema_registry,
1108 ));
1109 self
1110 }
1111
1112 pub fn set_has_guide(&mut self, has_guide: bool) -> &mut Self {
1114 if has_guide && self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1115 self.root = self.root.clone().subcommand(guide_command());
1116 }
1117 self.refresh_root_long();
1118 self
1119 }
1120
1121 pub fn add_guides(&mut self, entries: impl IntoIterator<Item = GuideEntry>) -> &mut Self {
1123 let mut seen = self
1124 .guide_entries
1125 .iter()
1126 .map(|entry| entry.name.clone())
1127 .collect::<BTreeSet<_>>();
1128 for entry in entries {
1129 if seen.insert(entry.name.clone()) {
1130 self.guide_entries.push(entry);
1131 }
1132 }
1133 if !self.guide_entries.is_empty() && !has_subcommand(&self.root, "guide") {
1134 self.root = self.root.clone().subcommand(guide_command());
1135 }
1136 self.refresh_root_long();
1137 self
1138 }
1139
1140 async fn resolve_argv0(&self, text_args: Vec<String>, depth: usize) -> Argv0Outcome {
1149 if self.config.argv0_routes.is_empty() {
1150 return Argv0Outcome::Proceed(text_args);
1151 }
1152
1153 if depth > MAX_ARGV0_DEPTH {
1154 return Argv0Outcome::Handled(
1155 self.render_argv0_error(&text_args, "argv0 dispatch recursion limit exceeded"),
1156 );
1157 }
1158
1159 let explicit = text_args.get(1).map(String::as_str) == Some("argv0");
1164 let (name, rest) = if explicit {
1165 match text_args.get(2) {
1166 None => {
1167 return Argv0Outcome::Handled(self.render_argv0_error(
1168 &text_args,
1169 "the argv0 command requires a name to dispatch as",
1170 ));
1171 }
1172 Some(name) => (
1176 program_basename(name),
1177 text_args
1178 .get(3..)
1179 .map(<[String]>::to_vec)
1180 .unwrap_or_default(),
1181 ),
1182 }
1183 } else {
1184 let name = text_args
1185 .first()
1186 .map(|arg| program_basename(arg))
1187 .unwrap_or_default();
1188 let rest = text_args
1189 .get(1..)
1190 .map(<[String]>::to_vec)
1191 .unwrap_or_default();
1192 (name, rest)
1193 };
1194
1195 match self.config.argv0_routes.get(&name) {
1196 Some(Argv0Route::Alias(tokens)) => {
1197 let mut rewritten = Vec::with_capacity(1 + tokens.len() + rest.len());
1200 rewritten.push(self.config.name.clone());
1201 rewritten.extend(tokens.iter().cloned());
1202 rewritten.extend(rest);
1203 Argv0Outcome::Proceed(rewritten)
1204 }
1205 Some(Argv0Route::Personality(build)) => {
1206 let config = build();
1211 let bin = config.name.clone();
1212 let alt = Self::new(config);
1213 let mut alt_args = Vec::with_capacity(1 + rest.len());
1214 alt_args.push(bin);
1215 alt_args.extend(rest);
1216 Argv0Outcome::Handled(Box::pin(alt.run_with_depth(alt_args, depth + 1)).await)
1217 }
1218 None if explicit => Argv0Outcome::Handled(self.render_argv0_error(
1219 &text_args,
1220 format!(
1221 "{name:?} is not a registered argv0 name; known names: {}",
1222 self.known_argv0_names()
1223 ),
1224 )),
1225 None => {
1226 let mut rewritten = Vec::with_capacity(1 + rest.len());
1231 rewritten.push(self.config.name.clone());
1232 rewritten.extend(rest);
1233 Argv0Outcome::Proceed(rewritten)
1234 }
1235 }
1236 }
1237
1238 fn known_argv0_names(&self) -> String {
1241 self.config
1242 .argv0_routes
1243 .keys()
1244 .cloned()
1245 .collect::<Vec<_>>()
1246 .join(", ")
1247 }
1248
1249 fn render_argv0_error(&self, text_args: &[String], message: impl Into<String>) -> CliRunOutput {
1254 let mut middleware = self.middleware.clone();
1255 middleware.output_format =
1256 extract_output_format(text_args, &default_output_format(&self.config.app_id));
1257 let err = CliCoreError::message(message);
1258 self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id))
1259 }
1260
1261 #[must_use]
1266 pub fn argv0_names(&self) -> Vec<&str> {
1267 self.config
1268 .argv0_routes
1269 .keys()
1270 .map(String::as_str)
1271 .collect()
1272 }
1273
1274 pub fn create_link(
1299 &self,
1300 name: &str,
1301 dir: impl AsRef<Path>,
1302 target: Option<&Path>,
1303 method: Argv0LinkMethod,
1304 ) -> std::io::Result<PathBuf> {
1305 if !self.config.argv0_routes.contains_key(name) {
1306 return Err(std::io::Error::new(
1307 std::io::ErrorKind::InvalidInput,
1308 format!("{name:?} is not a registered argv0 name"),
1309 ));
1310 }
1311
1312 let dir = dir.as_ref();
1313 std::fs::create_dir_all(dir)?;
1314 let link = dir.join(argv0_link_file_name(name, method));
1315
1316 let resolved_target;
1318 let target = match target {
1319 Some(target) => target,
1320 None => {
1321 resolved_target = std::env::current_exe()?;
1322 resolved_target.as_path()
1323 }
1324 };
1325
1326 if std::fs::symlink_metadata(&link).is_ok() {
1330 if argv0_link_matches(&link, target, name, method)? {
1331 return Ok(link);
1332 }
1333 std::fs::remove_file(&link)?;
1334 }
1335
1336 match method {
1337 Argv0LinkMethod::SoftLink => create_symlink(target, &link)?,
1338 Argv0LinkMethod::HardLink => std::fs::hard_link(target, &link)?,
1339 Argv0LinkMethod::Script => {
1340 std::fs::write(&link, argv0_script_contents(target, name))?;
1341 make_executable(&link)?;
1342 }
1343 }
1344 Ok(link)
1345 }
1346
1347 pub async fn run<I, S>(&self, args: I) -> CliRunOutput
1349 where
1350 I: IntoIterator<Item = S>,
1351 S: Into<std::ffi::OsString> + Clone,
1352 {
1353 self.run_with_depth(args, 0).await
1354 }
1355
1356 async fn run_with_depth<I, S>(&self, args: I, depth: usize) -> CliRunOutput
1359 where
1360 I: IntoIterator<Item = S>,
1361 S: Into<std::ffi::OsString> + Clone,
1362 {
1363 let raw_args = args
1364 .into_iter()
1365 .map(Into::into)
1366 .collect::<Vec<std::ffi::OsString>>();
1367 let text_args = raw_args
1368 .iter()
1369 .map(|arg| arg.to_string_lossy().into_owned())
1370 .collect::<Vec<_>>();
1371 let text_args = match self.resolve_argv0(text_args, depth).await {
1372 Argv0Outcome::Handled(output) => return output,
1373 Argv0Outcome::Proceed(args) => args,
1374 };
1375 let mut clap_args = normalize_optional_global_flags_before_command(&self.root, &text_args);
1376 if has_root_version_flag(&text_args, &self.root, &self.config.name) {
1377 return self.finish_run(CliRunOutput {
1378 exit_code: 0,
1379 rendered: format!(
1380 "{} version {}\n",
1381 self.config.name,
1382 self.config.build.version_string()
1383 ),
1384 });
1385 }
1386 if let Some(output) = self.try_run_schema_bypass(&text_args) {
1387 return output;
1388 }
1389 if let Some(output) = self.try_run_search_bypass(&text_args) {
1390 return output;
1391 }
1392 let bool_flags = derive_bool_flags(&self.root);
1395 let value_flags = derive_value_flags(&self.root);
1396 let positionals =
1397 positional_command_tokens(&text_args, &self.config.name, &bool_flags, &value_flags);
1398 let command_keyword_count = match text_args.iter().position(|arg| arg == "--") {
1403 Some(end) => positional_command_tokens(
1404 &text_args[..end],
1405 &self.config.name,
1406 &bool_flags,
1407 &value_flags,
1408 )
1409 .len(),
1410 None => positionals.len(),
1411 };
1412 if let Some(parts) =
1413 group_help_target_parts(&self.root, &positionals, command_keyword_count)
1414 {
1415 clap_args = rewrite_group_help_args(
1422 &clap_args,
1423 &self.config.name,
1424 &bool_flags,
1425 &value_flags,
1426 &parts,
1427 );
1428 } else if let Some(message) = unknown_group_command_message(&self.root, &positionals) {
1429 return self.finish_run(CliRunOutput {
1430 exit_code: 1,
1431 rendered: message,
1432 });
1433 }
1434
1435 let matches = match self.root.clone().try_get_matches_from(clap_args) {
1436 Ok(matches) => matches,
1437 Err(err) => {
1438 return self.finish_run(CliRunOutput {
1439 exit_code: err.exit_code(),
1440 rendered: err.to_string(),
1441 });
1442 }
1443 };
1444
1445 let default_format = default_output_format(&self.config.app_id);
1446 let flags = global_flags_from_matches(&matches, &default_format);
1447 crate::config::set_credential_store_flag(flags.credential_store);
1450 let command_timeout = match parse_command_timeout(&flags.timeout) {
1451 Ok(timeout) => timeout,
1452 Err(err) => {
1453 return self.finish_run(render_cli_error(
1454 &self.middleware,
1455 &err,
1456 &self.config.app_id,
1457 ));
1458 }
1459 };
1460 let mut middleware = self.middleware.clone();
1461 apply_global_flags(&mut middleware, &flags, command_timeout);
1462 install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1463 if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1464 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1465 }
1466 if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1469 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1470 }
1471
1472 let command_path = command_path_from_matches(&self.config.name, &matches);
1473 if command_path == "help" {
1474 if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &help_args(&matches))
1475 {
1476 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1477 }
1478 return self.finish_run(self.render_help_command(&matches));
1479 }
1480 if command_path == "tree" {
1481 if let Err(err) = self.run_pre_run(
1482 &mut middleware,
1483 &command_path,
1484 &crate::middleware::ValueMap::new(),
1485 ) {
1486 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1487 }
1488 return self.finish_run(tree_render::render_tree(
1489 &self.root,
1490 &self.config.app_id,
1491 &middleware,
1492 ));
1493 }
1494 if command_path == "guide" {
1495 if let Err(err) =
1496 self.run_pre_run(&mut middleware, &command_path, &guide_args(&matches))
1497 {
1498 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1499 }
1500 return self.finish_run(self.render_guide(&matches));
1501 }
1502 if command_path == "completion" {
1503 let args = completion_args(&matches);
1504 if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1505 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1506 }
1507 let install = args
1508 .get("install")
1509 .and_then(|v| v.as_bool())
1510 .unwrap_or(false);
1511 let shell_opt = args
1512 .get("shell")
1513 .and_then(|v| v.as_str())
1514 .map(str::to_owned);
1515 if install {
1516 use crate::cli::completion::{detect_shell, parse_shell};
1517 let shell = match shell_opt {
1518 Some(ref s) => match parse_shell(s) {
1519 Ok(s) => s,
1520 Err(e) => {
1521 return self.finish_run(render_cli_error(
1522 &middleware,
1523 &e,
1524 &self.config.app_id,
1525 ));
1526 }
1527 },
1528 None => match detect_shell() {
1529 Ok(s) => s,
1530 Err(e) => {
1531 return self.finish_run(render_cli_error(
1532 &middleware,
1533 &e,
1534 &self.config.app_id,
1535 ));
1536 }
1537 },
1538 };
1539 return self.finish_run(
1540 completion::install(&self.root, &self.config.name, shell)
1541 .await
1542 .unwrap_or_else(|e| render_cli_error(&middleware, &e, &self.config.app_id)),
1543 );
1544 }
1545 return self.finish_run(self.render_completion_print(shell_opt, &middleware));
1546 }
1547 let Some(command) = self.commands.get(&command_path) else {
1548 if !command_path.is_empty()
1549 && let Some(group) = find_command_by_colon_path(&self.root, &command_path)
1550 && group.get_subcommands().next().is_some()
1551 {
1552 if let Err(err) = self.run_pre_run(
1553 &mut middleware,
1554 &command_path,
1555 &crate::middleware::ValueMap::new(),
1556 ) {
1557 return self.finish_run(render_cli_error(
1558 &middleware,
1559 &err,
1560 &self.config.app_id,
1561 ));
1562 }
1563 return self.finish_run(CliRunOutput {
1564 exit_code: 0,
1565 rendered: group.clone().render_long_help().to_string(),
1566 });
1567 }
1568 if command_path.is_empty()
1569 && let Some(root_next_actions) = &self.root_next_actions
1570 {
1571 let actions = root_next_actions();
1576 return self.finish_run(self.render_root(&middleware, actions));
1577 }
1578 return self.finish_run(CliRunOutput {
1579 exit_code: if command_path.is_empty() { 0 } else { 1 },
1580 rendered: if command_path.is_empty() {
1581 self.root.clone().render_long_help().to_string()
1582 } else {
1583 format!("unknown command {command_path:?}")
1584 },
1585 });
1586 };
1587
1588 let mut middleware = match self.initialized_middleware() {
1589 Ok(middleware) => middleware,
1590 Err(err) => {
1591 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1592 }
1593 };
1594 apply_global_flags(&mut middleware, &flags, command_timeout);
1595 install_debug_transport_logger(&flags.debug, &self.config.redacted_debug_headers);
1596 if let Err(err) = self.apply_config_flags(&matches, &mut middleware) {
1597 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1598 }
1599 if let Err(err) = self.apply_env_flag(&matches, &mut middleware) {
1602 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1603 }
1604
1605 let leaf = leaf_matches(&matches);
1606 let args = command_args_from_matches(leaf, &command.spec, false);
1607 let user_args = command_args_from_matches(leaf, &command.spec, true);
1608 if let Err(err) = self.run_pre_run(&mut middleware, &command_path, &args) {
1609 return self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id));
1610 }
1611 let meta = self.resolve_meta(&command_path, command.spec.metadata());
1612 let default_fields = command.spec.default_fields.clone().unwrap_or_default();
1613 let system = command.spec.system.clone().unwrap_or_default();
1614 let view_id = command
1619 .spec
1620 .view_id
1621 .clone()
1622 .or_else(|| (!command.spec.view_columns.is_empty()).then(|| command_path.clone()));
1623
1624 if let Some(streaming_handler) = command.streaming_handler.clone() {
1625 let result = run_with_timeout(
1626 command_timeout,
1627 &flags.timeout,
1628 run_streaming_command(
1629 &middleware,
1630 MiddlewareRequest {
1631 meta,
1632 command_path: &command_path,
1633 system: &system,
1634 user_args,
1635 args,
1636 default_fields: &default_fields,
1637 view_id: view_id.as_deref(),
1638 auth: command.spec.auth,
1639 },
1640 Arc::new(leaf.clone()),
1641 streaming_handler,
1642 ),
1643 )
1644 .await;
1645 return self.finish_run(match result {
1646 Ok(output) => output,
1647 Err(err) => render_cli_error(&middleware, &err, &self.config.app_id),
1648 });
1649 }
1650
1651 let handler = command.handler.clone();
1652 let args_for_handler = args.clone();
1653 let user_args_for_handler = user_args.clone();
1654 let handler_path = command_path.clone();
1655 let middleware_for_handler = middleware.clone();
1656 let raw_matches_for_handler = Arc::new(leaf.clone());
1657 let result = run_with_timeout(
1658 command_timeout,
1659 &flags.timeout,
1660 middleware.run(
1661 MiddlewareRequest {
1662 meta,
1663 command_path: &command_path,
1664 system: &system,
1665 user_args,
1666 args,
1667 default_fields: &default_fields,
1668 view_id: view_id.as_deref(),
1669 auth: command.spec.auth,
1670 },
1671 async move |credential| {
1672 handler(CommandContext {
1673 credential,
1674 args: args_for_handler,
1675 user_args: user_args_for_handler,
1676 command_path: handler_path,
1677 middleware: middleware_for_handler,
1678 raw_matches: raw_matches_for_handler,
1679 })
1680 .await
1681 },
1682 ),
1683 )
1684 .await;
1685
1686 match result {
1687 Ok(output) => self.finish_run(output.into()),
1688 Err(err) => self.finish_run(render_cli_error(&middleware, &err, &self.config.app_id)),
1689 }
1690 }
1691
1692 fn try_run_search_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
1693 let query = extract_search_query(args);
1694 if query.is_empty() {
1695 return None;
1696 }
1697 let scope = self.search_scope(args);
1698 let output_format =
1699 extract_output_format(args, &default_output_format(&self.config.app_id));
1700 Some(self.render_search(&query, &scope, &output_format))
1701 }
1702
1703 fn try_run_schema_bypass(&self, args: &[String]) -> Option<CliRunOutput> {
1704 if !has_true_schema_flag(args) {
1705 return None;
1706 }
1707 let bool_flags = derive_bool_flags(&self.root);
1708 let value_flags = derive_value_flags(&self.root);
1709 let command_path =
1710 self.canonical_command_path(&extract_command_path(args, &bool_flags, &value_flags));
1711 let command = find_command_by_colon_path(&self.root, &command_path)?;
1716 if command.get_subcommands().next().is_some() {
1717 return None;
1718 }
1719 let output_format =
1720 extract_output_format(args, &default_output_format(&self.config.app_id));
1721 match self.middleware.schema_registry.get_by_path(&command_path) {
1725 Some(schema) => Some(self.render_schema(schema, &output_format)),
1726 None => Some(self.render_schema(
1727 crate::output::no_schema_response(&command_path),
1728 &output_format,
1729 )),
1730 }
1731 }
1732
1733 fn render_schema(&self, data: impl serde::Serialize, output_format: &str) -> CliRunOutput {
1734 let format: crate::output::OutputFormat = match output_format.parse() {
1735 Ok(format) => format,
1736 Err(err) => {
1737 return CliRunOutput {
1738 exit_code: exit_code_for_error(&err),
1739 rendered: err.to_string(),
1740 };
1741 }
1742 };
1743 let envelope =
1744 crate::Envelope::success(data, self.config.app_id.clone()).prepare_for_render("");
1745 match crate::output::render(format, &envelope) {
1746 Ok(rendered) => CliRunOutput {
1747 exit_code: 0,
1748 rendered,
1749 },
1750 Err(err) => CliRunOutput {
1751 exit_code: exit_code_for_error(&err),
1752 rendered: err.to_string(),
1753 },
1754 }
1755 }
1756
1757 fn render_search(&self, query: &str, scope: &str, output_format: &str) -> CliRunOutput {
1758 let format: crate::output::OutputFormat = match output_format.parse() {
1759 Ok(format) => format,
1760 Err(err) => {
1761 return CliRunOutput {
1762 exit_code: exit_code_for_error(&err),
1763 rendered: err.to_string(),
1764 };
1765 }
1766 };
1767 let docs = self.search_documents(scope);
1768 let results = SearchIndex::new(docs).search(query, 10);
1769 let envelope =
1770 crate::Envelope::success(results, self.config.app_id.clone()).prepare_for_render("");
1771 match crate::output::render(format, &envelope) {
1772 Ok(rendered) => CliRunOutput {
1773 exit_code: 0,
1774 rendered,
1775 },
1776 Err(err) => CliRunOutput {
1777 exit_code: exit_code_for_error(&err),
1778 rendered: err.to_string(),
1779 },
1780 }
1781 }
1782
1783 fn render_root(&self, middleware: &Middleware, actions: Vec<NextAction>) -> CliRunOutput {
1789 if !crate::output::is_valid_output_format(&middleware.output_format) {
1794 let err = CliCoreError::InvalidOutputFormat(middleware.output_format.clone());
1795 return CliRunOutput {
1796 exit_code: exit_code_for_error(&err),
1797 rendered: err.to_string(),
1798 };
1799 }
1800 let format = middleware
1801 .output_format
1802 .parse()
1803 .unwrap_or(crate::output::OutputFormat::Json);
1804 if format == crate::output::OutputFormat::Human {
1805 let base_long = self
1809 .root
1810 .get_long_about()
1811 .map(ToString::to_string)
1812 .unwrap_or_default();
1813 let long = format!("{base_long}{}", render_next_actions_human(&actions));
1814 let rendered = self
1815 .root
1816 .clone()
1817 .long_about(long)
1818 .render_long_help()
1819 .to_string();
1820 return CliRunOutput {
1821 exit_code: 0,
1822 rendered,
1823 };
1824 }
1825 let description = self
1826 .config
1827 .long
1828 .as_deref()
1829 .filter(|long| !long.is_empty())
1830 .unwrap_or(self.config.short.as_str());
1831 let data = serde_json::json!({
1832 "description": description,
1833 "version": self.config.build.version,
1834 });
1835 let envelope = crate::Envelope::success(data, self.config.app_id.clone())
1836 .with_next_actions(actions)
1837 .prepare_for_render(&middleware.verbose);
1838 match crate::output::render(format, &envelope) {
1839 Ok(rendered) => CliRunOutput {
1840 exit_code: 0,
1841 rendered,
1842 },
1843 Err(err) => CliRunOutput {
1844 exit_code: exit_code_for_error(&err),
1845 rendered: err.to_string(),
1846 },
1847 }
1848 }
1849
1850 fn search_documents(&self, scope: &str) -> Vec<SearchDocument> {
1851 let (scoped, mut prefix) = find_command_and_canonical_path_by_colon_path(&self.root, scope)
1852 .unwrap_or((&self.root, Vec::new()));
1853 let mut docs = Vec::new();
1854 let mut aliases = Vec::new();
1855 append_command_alias_terms(scoped, &mut aliases);
1856 collect_command_search_documents(scoped, &mut prefix, &mut aliases, &mut docs);
1857 if scope.is_empty() {
1858 for entry in &self.guide_entries {
1859 docs.push(SearchDocument {
1860 id: format!("guide:{}", entry.name),
1861 kind: "guide".to_owned(),
1862 title: format!("guide {}", entry.name),
1863 summary: entry.summary.clone(),
1864 content: format!("{} {}", entry.summary, entry.content),
1865 });
1866 }
1867 if let Some(extra_search_docs) = &self.extra_search_docs {
1868 docs.extend(extra_search_docs());
1869 }
1870 }
1871 docs
1872 }
1873
1874 fn search_scope(&self, args: &[String]) -> String {
1875 let parts = extract_search_scope_parts(args);
1876 canonical_path_from_parts(&self.root, &parts).unwrap_or_default()
1877 }
1878
1879 fn canonical_command_path(&self, command_path: &str) -> String {
1880 find_command_and_canonical_path_by_colon_path(&self.root, command_path).map_or_else(
1881 || command_path.to_owned(),
1882 |(_, canonical)| canonical.join(":"),
1883 )
1884 }
1885
1886 fn render_guide(&self, matches: &ArgMatches) -> CliRunOutput {
1887 let leaf = leaf_matches(matches);
1888 let topic = leaf.get_one::<String>("topic").map(String::as_str);
1889 match guide_content(&self.guide_entries, topic) {
1890 Ok(rendered) => CliRunOutput {
1891 exit_code: 0,
1892 rendered,
1893 },
1894 Err(err) => CliRunOutput {
1895 exit_code: 1,
1896 rendered: err,
1897 },
1898 }
1899 }
1900
1901 fn render_completion_print(
1902 &self,
1903 shell_opt: Option<String>,
1904 middleware: &Middleware,
1905 ) -> CliRunOutput {
1906 use crate::cli::completion::{detect_shell, generate_script, parse_shell};
1907 let shell = match shell_opt {
1908 Some(s) => match parse_shell(&s) {
1909 Ok(s) => s,
1910 Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
1911 },
1912 None => match detect_shell() {
1913 Ok(s) => s,
1914 Err(e) => return render_cli_error(middleware, &e, &self.config.app_id),
1915 },
1916 };
1917 match generate_script(&self.root, &self.config.name, shell) {
1918 Ok(script) => CliRunOutput {
1919 exit_code: 0,
1920 rendered: script,
1921 },
1922 Err(e) => render_cli_error(middleware, &e, &self.config.app_id),
1923 }
1924 }
1925
1926 fn render_help_command(&self, matches: &ArgMatches) -> CliRunOutput {
1927 let leaf = leaf_matches(matches);
1928 let parts = leaf
1929 .get_many::<String>("command")
1930 .map(|values| values.map(String::as_str).collect::<Vec<_>>())
1931 .unwrap_or_default();
1932 self.render_help_for_parts(&parts)
1933 }
1934
1935 fn render_help_for_parts(&self, parts: &[&str]) -> CliRunOutput {
1942 if parts.is_empty() {
1943 return CliRunOutput {
1944 exit_code: 0,
1945 rendered: self.root.clone().render_long_help().to_string(),
1946 };
1947 }
1948 let Some(command) = find_help_target(&self.root, parts) else {
1949 return CliRunOutput {
1950 exit_code: 1,
1951 rendered: format!(
1952 "unknown command {:?} — run '{} help' for available commands",
1953 parts.join(" "),
1954 self.config.name
1955 ),
1956 };
1957 };
1958 CliRunOutput {
1959 exit_code: 0,
1960 rendered: command.clone().render_long_help().to_string(),
1961 }
1962 }
1963
1964 fn refresh_root_long(&mut self) {
1965 let builtins = BUILTIN_COMMAND_NAMES;
1970 let categorized: BTreeSet<&str> = self
1971 .module_entries
1972 .iter()
1973 .map(|entry| entry.name.as_str())
1974 .collect();
1975 let mut generic: Vec<ModuleHelpEntry> = self
1976 .root
1977 .get_subcommands()
1978 .filter(|command| !command.is_hide_set())
1979 .filter(|command| !builtins.contains(&command.get_name()))
1980 .filter(|command| !categorized.contains(command.get_name()))
1981 .map(|command| ModuleHelpEntry {
1982 category: "Commands".to_owned(),
1983 name: command.get_name().to_owned(),
1984 short: command
1985 .get_about()
1986 .map(ToString::to_string)
1987 .unwrap_or_default(),
1988 })
1989 .collect();
1990 generic.sort_by(|left, right| left.name.cmp(&right.name));
1991
1992 let mut entries = self.module_entries.clone();
1993 entries.extend(generic);
1994 let has_guide = !self.guide_entries.is_empty() || has_subcommand(&self.root, "guide");
1995 let intro = self
1996 .config
1997 .long
1998 .as_deref()
1999 .filter(|long| !long.is_empty())
2000 .unwrap_or(self.config.short.as_str());
2001 self.root = self
2002 .root
2003 .clone()
2004 .long_about(build_root_long(intro, &entries, has_guide));
2005 }
2006
2007 fn ensure_auth_command(&mut self) {
2008 let default_provider = self.default_auth_provider();
2009 let registered_names = self.middleware.auth.registered_names();
2010 if default_provider.is_empty() && registered_names.is_empty() {
2011 return;
2012 }
2013 let replacing_builtin = self.commands.contains_key("auth:login");
2014 if has_subcommand(&self.root, "auth") && !replacing_builtin {
2015 return;
2016 }
2017 let group = auth_command_group(&default_provider, ®istered_names);
2018 let mut prefix = Vec::new();
2019 group.register_commands(&mut prefix, &mut self.commands);
2020 let mut prefix = Vec::new();
2021 let clap_group = runtime_group_clap_command_with_schema_help(
2022 &group,
2023 &mut prefix,
2024 &self.middleware.schema_registry,
2025 );
2026 self.root = if replacing_builtin {
2027 self.root.clone().mut_subcommand("auth", |_| clap_group)
2028 } else {
2029 self.root.clone().subcommand(clap_group)
2030 };
2031 self.register_auth_help_entry();
2035 }
2036
2037 fn ensure_config_command(&mut self) {
2041 if has_subcommand(&self.root, "config") {
2042 return;
2043 }
2044 let group = crate::config_commands::config_command_group();
2045 let mut prefix = Vec::new();
2046 group.register_commands(&mut prefix, &mut self.commands);
2047 let mut prefix = Vec::new();
2048 let clap_group = runtime_group_clap_command_with_schema_help(
2049 &group,
2050 &mut prefix,
2051 &self.middleware.schema_registry,
2052 );
2053 self.root = self.root.clone().subcommand(clap_group);
2054 let category = self
2055 .config
2056 .admin_category
2057 .clone()
2058 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2059 if !self
2060 .module_entries
2061 .iter()
2062 .any(|entry| entry.name == "config")
2063 {
2064 self.module_entries.push(ModuleHelpEntry {
2065 category,
2066 name: "config".to_owned(),
2067 short: "Read and write the CLI config file".to_owned(),
2068 });
2069 }
2070 self.refresh_root_long();
2071 }
2072
2073 fn ensure_env_command(&mut self) {
2077 if has_subcommand(&self.root, "env") {
2078 return;
2079 }
2080 let group = crate::env_commands::env_command_group();
2081 let mut prefix = Vec::new();
2082 group.register_commands(&mut prefix, &mut self.commands);
2083 let mut prefix = Vec::new();
2084 let clap_group = runtime_group_clap_command_with_schema_help(
2085 &group,
2086 &mut prefix,
2087 &self.middleware.schema_registry,
2088 );
2089 self.root = self.root.clone().subcommand(clap_group);
2090 let category = self
2091 .config
2092 .admin_category
2093 .clone()
2094 .unwrap_or_else(|| DEFAULT_ADMIN_CATEGORY.to_owned());
2095 if !self.module_entries.iter().any(|e| e.name == "env") {
2096 self.module_entries.push(ModuleHelpEntry {
2097 category,
2098 name: "env".to_owned(),
2099 short: "Manage the active environment".to_owned(),
2100 });
2101 }
2102 self.refresh_root_long();
2103 }
2104
2105 fn default_auth_provider(&self) -> String {
2106 if !self.middleware.default_auth_provider.is_empty() {
2107 return self.middleware.default_auth_provider.clone();
2108 }
2109 self.middleware
2110 .auth
2111 .registered_names()
2112 .into_iter()
2113 .next()
2114 .unwrap_or_default()
2115 }
2116
2117 fn initialized_middleware(&self) -> Result<Middleware> {
2118 let Some(init_deps) = &self.init_deps else {
2119 return Ok(self.middleware.clone());
2120 };
2121 let mut guard = self
2122 .init_state
2123 .lock()
2124 .map_err(|_| CliCoreError::message("init deps lock poisoned"))?;
2125 if let Some(result) = guard.as_ref() {
2126 return result.clone().map_err(InitFailure::into_error);
2127 }
2128 let mut middleware = self.middleware.clone();
2129 let result = init_deps(&mut middleware)
2130 .map(|()| middleware)
2131 .map_err(|err| InitFailure::capture(&err));
2132 *guard = Some(result.clone());
2133 result.map_err(InitFailure::into_error)
2134 }
2135
2136 fn apply_config_flags(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2137 if let Some(apply_flags) = &self.apply_flags {
2138 apply_flags(matches, middleware)?;
2139 }
2140 Ok(())
2141 }
2142
2143 fn apply_env_flag(&self, matches: &ArgMatches, middleware: &mut Middleware) -> Result<()> {
2150 let Some(environments) = middleware.environments.as_ref() else {
2156 return Ok(());
2157 };
2158 if let Some(env) = matches.get_one::<String>("env") {
2159 environments.resolve(env)?;
2160 middleware.env = env.clone();
2161 }
2162 Ok(())
2163 }
2164
2165 fn run_pre_run(
2166 &self,
2167 middleware: &mut Middleware,
2168 command_path: &str,
2169 args: &crate::middleware::ValueMap,
2170 ) -> Result<()> {
2171 if let Some(pre_run) = &self.pre_run {
2172 pre_run(middleware, command_path, args)?;
2173 }
2174 Ok(())
2175 }
2176
2177 fn resolve_meta(&self, command_path: &str, meta: CommandMeta) -> CommandMeta {
2178 if let Some(resolver) = &self.meta_resolver {
2179 resolver(command_path, meta)
2180 } else {
2181 meta
2182 }
2183 }
2184
2185 fn finish_run(&self, output: CliRunOutput) -> CliRunOutput {
2186 crate::config::clear_credential_store_flag();
2189 if let Some(on_shutdown) = &self.on_shutdown {
2190 on_shutdown();
2191 }
2192 output
2193 }
2194}
2195
2196fn apply_global_flags(middleware: &mut Middleware, flags: &GlobalFlags, timeout: Option<Duration>) {
2197 middleware.output_format = flags.output_format.clone();
2198 middleware.verbose = flags.verbose.clone();
2199 middleware.dry_run = flags.dry_run;
2200 middleware.fields = flags.fields.clone();
2201 middleware.filter = flags.filter.clone();
2202 middleware.expr = flags.expr.clone();
2203 middleware.limit = flags.limit;
2204 middleware.offset = flags.offset;
2205 middleware.reason = flags.reason.clone();
2206 middleware.schema = flags.schema;
2207 middleware.timeout = timeout;
2208 middleware.debug = flags.debug.clone();
2209 middleware.search = flags.search.clone();
2210}
2211
2212fn install_debug_transport_logger(debug: &str, extra_redacted: &[String]) {
2223 let logger: Arc<dyn crate::transport::TransportLogger> =
2224 if crate::debug_component_enabled(debug, "transport") {
2225 Arc::new(
2226 crate::transport::StderrTransportLogger::new()
2227 .with_redacted_headers(extra_redacted.iter().cloned()),
2228 )
2229 } else {
2230 Arc::new(crate::transport::NoopTransportLogger)
2231 };
2232 crate::transport::set_default_transport_logger(logger);
2233}
2234
2235async fn run_with_timeout<F, T>(
2236 timeout: Option<Duration>,
2237 timeout_label: &str,
2238 future: F,
2239) -> Result<T>
2240where
2241 F: Future<Output = Result<T>>,
2242{
2243 let Some(timeout) = timeout else {
2244 return future.await;
2245 };
2246 match tokio::time::timeout(timeout, future).await {
2247 Ok(result) => result,
2248 Err(_) => Err(CliCoreError::message(format!(
2249 "command timed out after {timeout_label}"
2250 ))),
2251 }
2252}
2253
2254async fn run_until_signal<Run, Shutdown>(run: Run, shutdown: Shutdown) -> CliRunOutput
2255where
2256 Run: Future<Output = CliRunOutput>,
2257 Shutdown: Future<Output = ()>,
2258{
2259 tokio::pin!(run);
2260 tokio::pin!(shutdown);
2261 tokio::select! {
2262 output = &mut run => output,
2263 () = &mut shutdown => CliRunOutput {
2264 exit_code: 130,
2265 rendered: "command interrupted\n".to_owned(),
2266 },
2267 }
2268}
2269
2270#[cfg(unix)]
2271async fn shutdown_signal() {
2272 let ctrl_c = tokio::signal::ctrl_c();
2273 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2274 Ok(mut sigterm) => {
2275 tokio::select! {
2276 _ = ctrl_c => {},
2277 _ = sigterm.recv() => {},
2278 }
2279 }
2280 Err(_) => {
2281 drop(ctrl_c.await);
2282 }
2283 }
2284}
2285
2286#[cfg(not(unix))]
2287async fn shutdown_signal() {
2288 drop(tokio::signal::ctrl_c().await);
2289}
2290
2291fn parse_command_timeout(raw: &str) -> Result<Option<Duration>> {
2292 let raw = raw.trim();
2293 if raw.is_empty() {
2294 return Ok(Some(Duration::from_secs(60)));
2295 }
2296 let Some(seconds) = parse_duration_seconds(raw) else {
2297 return Err(CliCoreError::message(format!(
2298 "invalid timeout {raw:?}: expected duration like 60s, 5m, or 0s"
2299 )));
2300 };
2301 if seconds <= 0.0 {
2302 Ok(None)
2303 } else {
2304 Ok(Some(Duration::from_secs_f64(seconds)))
2305 }
2306}
2307
2308fn parse_duration_seconds(raw: &str) -> Option<f64> {
2309 for (suffix, seconds) in [
2310 ("ns", 0.000_000_001_f64),
2311 ("us", 0.000_001_f64),
2312 ("µs", 0.000_001_f64),
2313 ("ms", 0.001_f64),
2314 ("s", 1.0_f64),
2315 ("m", 60.0_f64),
2316 ("h", 3600.0_f64),
2317 ] {
2318 if let Some(number) = raw.strip_suffix(suffix) {
2319 let value = number.parse::<f64>().ok()?;
2320 if !value.is_finite() {
2321 return None;
2322 }
2323 return Some(value * seconds);
2324 }
2325 }
2326 None
2327}
2328
2329fn render_cli_error(
2330 middleware: &Middleware,
2331 err: &(dyn std::error::Error + 'static),
2332 system: &str,
2333) -> CliRunOutput {
2334 let format = middleware
2335 .output_format
2336 .parse::<crate::output::OutputFormat>()
2337 .unwrap_or(crate::output::OutputFormat::Json);
2338 let envelope =
2339 crate::output::build_error_envelope(err, system).prepare_for_render(&middleware.verbose);
2340 match crate::output::render(format, &envelope) {
2341 Ok(rendered) => CliRunOutput {
2342 exit_code: exit_code_for_error(err),
2343 rendered,
2344 },
2345 Err(render_err) => CliRunOutput {
2346 exit_code: exit_code_for_error(err),
2347 rendered: render_err.to_string(),
2348 },
2349 }
2350}
2351
2352fn find_command_by_colon_path<'command>(
2353 root: &'command Command,
2354 path: &str,
2355) -> Option<&'command Command> {
2356 find_command_and_canonical_path_by_colon_path(root, path).map(|(command, _)| command)
2357}
2358
2359fn find_help_target<'command>(
2360 root: &'command Command,
2361 parts: &[&str],
2362) -> Option<&'command Command> {
2363 let mut current = root;
2364 let mut matched_any = false;
2365 for part in parts {
2366 let Some(next) = current.find_subcommand(part) else {
2367 break;
2368 };
2369 current = next;
2370 matched_any = true;
2371 }
2372 matched_any.then_some(current)
2373}
2374
2375fn find_command_and_canonical_path_by_colon_path<'command>(
2376 root: &'command Command,
2377 path: &str,
2378) -> Option<(&'command Command, Vec<String>)> {
2379 if path.is_empty() {
2380 return Some((root, Vec::new()));
2381 }
2382 let mut current = root;
2383 let mut canonical = Vec::new();
2384 for part in path.split(':') {
2385 current = current.find_subcommand(part)?;
2386 canonical.push(current.get_name().to_owned());
2387 }
2388 Some((current, canonical))
2389}
2390
2391fn canonical_path_from_parts(root: &Command, parts: &[String]) -> Option<String> {
2392 if parts.is_empty() {
2393 return Some(String::new());
2394 }
2395 let mut current = root;
2396 let mut canonical = Vec::new();
2397 for part in parts {
2398 current = current.find_subcommand(part)?;
2399 canonical.push(current.get_name().to_owned());
2400 }
2401 Some(canonical.join(":"))
2402}
2403
2404fn extract_search_scope_parts(args: &[String]) -> Vec<String> {
2405 let mut parts = Vec::new();
2406 let mut index = 1;
2407 while index < args.len() {
2408 let arg = &args[index];
2409 if arg == "--search" || arg.starts_with("--search=") {
2410 break;
2411 }
2412 if arg.starts_with('-') {
2413 if !arg.contains('=') && index + 1 < args.len() && !args[index + 1].starts_with('-') {
2414 index += 2;
2415 } else {
2416 index += 1;
2417 }
2418 continue;
2419 }
2420 parts.push(arg.clone());
2421 index += 1;
2422 }
2423 parts
2424}
2425
2426fn collect_command_search_documents(
2427 command: &Command,
2428 prefix: &mut Vec<String>,
2429 aliases: &mut Vec<String>,
2430 docs: &mut Vec<SearchDocument>,
2431) {
2432 if command.is_hide_set() || BUILTIN_COMMAND_NAMES.contains(&command.get_name()) {
2433 return;
2434 }
2435 if command.get_subcommands().next().is_some() {
2436 for child in command.get_subcommands() {
2437 prefix.push(child.get_name().to_owned());
2438 let alias_len = aliases.len();
2439 append_command_alias_terms(child, aliases);
2440 collect_command_search_documents(child, prefix, aliases, docs);
2441 aliases.truncate(alias_len);
2442 prefix.pop();
2443 }
2444 return;
2445 }
2446 if prefix.is_empty() {
2447 prefix.push(command.get_name().to_owned());
2448 append_command_alias_terms(command, aliases);
2449 }
2450 let path = prefix.join(" ");
2451 let alias_text = aliases.join(" ");
2452 docs.push(SearchDocument {
2453 id: format!("cmd:{path}"),
2454 kind: "command".to_owned(),
2455 title: path,
2456 summary: command
2457 .get_about()
2458 .map(ToString::to_string)
2459 .unwrap_or_default(),
2460 content: format!(
2461 "{} {} {} {}",
2462 command
2463 .get_about()
2464 .map(ToString::to_string)
2465 .unwrap_or_default(),
2466 command
2467 .get_long_about()
2468 .map(ToString::to_string)
2469 .unwrap_or_default(),
2470 command_flag_text(command),
2471 alias_text
2472 ),
2473 });
2474 if prefix.len() == 1 && prefix[0] == command.get_name() {
2475 prefix.pop();
2476 }
2477}
2478
2479fn append_command_alias_terms(command: &Command, aliases: &mut Vec<String>) {
2480 aliases.extend(command.get_all_aliases().map(str::to_owned));
2481 aliases.extend(
2482 command
2483 .get_all_short_flag_aliases()
2484 .map(|alias| alias.to_string()),
2485 );
2486 aliases.extend(command.get_all_long_flag_aliases().map(str::to_owned));
2487}
2488
2489fn command_flag_text(command: &Command) -> String {
2490 command
2491 .get_arguments()
2492 .filter_map(|arg| {
2493 let mut names = Vec::new();
2494 if let Some(short) = arg.get_short() {
2495 names.push(format!("-{short}"));
2496 }
2497 if let Some(long) = arg.get_long() {
2498 names.push(format!("--{long}"));
2499 }
2500 if let Some(short_aliases) = arg.get_all_short_aliases() {
2501 names.extend(
2502 short_aliases
2503 .into_iter()
2504 .map(|short_alias| format!("-{short_alias}")),
2505 );
2506 }
2507 if let Some(aliases) = arg.get_all_aliases() {
2508 names.extend(aliases.into_iter().map(|alias| format!("--{alias}")));
2509 }
2510 (!names.is_empty()).then(|| names.join(" "))
2511 })
2512 .collect::<Vec<_>>()
2513 .join(" ")
2514}
2515
2516fn has_subcommand(command: &Command, name: &str) -> bool {
2517 command
2518 .get_subcommands()
2519 .any(|child| child.get_name() == name)
2520}
2521
2522fn has_root_version_flag(args: &[String], root: &Command, root_name: &str) -> bool {
2523 let bool_flags = derive_bool_flags(root);
2524 let value_flags = derive_value_flags(root);
2525 let mut iter = args.iter().peekable();
2526 if iter
2527 .peek()
2528 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
2529 {
2530 iter.next();
2531 }
2532
2533 while let Some(arg) = iter.next() {
2534 match arg.as_str() {
2535 "--version" | "-v" => return true,
2536 "--" => return false,
2537 value if value.contains('=') || bool_flags.contains(value) => continue,
2538 value
2539 if value_flags.contains(value)
2540 || unknown_flag_consumes_value(value, iter.peek()) =>
2541 {
2542 iter.next();
2543 }
2544 value if value.starts_with('-') => {}
2545 _ => return false,
2546 }
2547 }
2548 false
2549}
2550
2551fn normalize_optional_global_flags_before_command(root: &Command, args: &[String]) -> Vec<String> {
2552 let optional_string_defaults = BTreeMap::from([("--verbose", "all"), ("--debug", "*")]);
2553 let optional_bool_defaults = BTreeMap::from([("--dry-run", "true"), ("--schema", "true")]);
2554 let mut normalized = Vec::with_capacity(args.len());
2555 let mut index = 0;
2556 let mut current = root;
2557 while index < args.len() {
2558 let arg = &args[index];
2559 if index == 0 && arg_matches_root_name(arg, root.get_name()) {
2560 normalized.push(arg.clone());
2561 index += 1;
2562 continue;
2563 }
2564
2565 if let Some(default) = optional_bool_defaults.get(arg.as_str()) {
2566 normalized.push(format!("{arg}={default}"));
2567 index += 1;
2568 continue;
2569 }
2570
2571 if let Some(default) = optional_string_defaults.get(arg.as_str()) {
2572 match args.get(index + 1) {
2573 None => {
2574 normalized.push(format!("{arg}={default}"));
2575 index += 1;
2576 continue;
2577 }
2578 Some(next)
2579 if current.get_name() == root.get_name()
2580 || next.starts_with('-')
2581 || direct_subcommand(current, next).is_some() =>
2582 {
2583 normalized.push(format!("{arg}={default}"));
2584 index += 1;
2585 continue;
2586 }
2587 Some(next) => {
2588 normalized.push(arg.clone());
2589 normalized.push(next.clone());
2590 index += 2;
2591 continue;
2592 }
2593 }
2594 }
2595
2596 normalized.push(arg.clone());
2597 if !arg.starts_with('-')
2598 && let Some(next_command) = direct_subcommand(current, arg)
2599 {
2600 current = next_command;
2601 }
2602 index += 1;
2603 }
2604 normalized
2605}
2606
2607fn direct_subcommand<'command>(
2608 command: &'command Command,
2609 token: &str,
2610) -> Option<&'command Command> {
2611 command.get_subcommands().find(|child| {
2612 child.get_name() == token || child.get_all_aliases().any(|alias| alias == token)
2613 })
2614}
2615
2616fn unknown_group_command_message(root: &Command, positionals: &[String]) -> Option<String> {
2617 if positionals.is_empty() {
2618 return None;
2619 }
2620
2621 let mut current = root;
2622 let mut path = vec![root.get_name().to_owned()];
2623 for token in positionals {
2624 if let Some(next) = current.find_subcommand(token) {
2625 current = next;
2626 path.push(next.get_name().to_owned());
2627 continue;
2628 }
2629 if current.get_subcommands().next().is_some() {
2630 return Some(format!(
2631 "unknown command {token:?} for {:?}",
2632 path.join(" ")
2633 ));
2634 }
2635 return None;
2636 }
2637 None
2638}
2639
2640fn group_help_target_parts(
2663 root: &Command,
2664 positionals: &[String],
2665 command_keyword_count: usize,
2666) -> Option<Vec<String>> {
2667 let help_index = positionals.iter().position(|token| token == "help")?;
2668 if help_index == 0 {
2670 return None;
2671 }
2672 if help_index >= command_keyword_count {
2674 return None;
2675 }
2676 let prefix = &positionals[..help_index];
2677 let mut current = root;
2678 for token in prefix {
2679 current = current.find_subcommand(token)?;
2680 }
2681 current.get_subcommands().next()?;
2683 if current.find_subcommand("help").is_some() {
2685 return None;
2686 }
2687 let suffix = &positionals[help_index + 1..];
2689 Some(prefix.iter().chain(suffix).cloned().collect())
2690}
2691
2692fn rewrite_group_help_args(
2703 clap_args: &[String],
2704 root_name: &str,
2705 bool_flags: &BTreeSet<String>,
2706 value_flags: &BTreeSet<String>,
2707 parts: &[String],
2708) -> Vec<String> {
2709 let mut next_positional = std::iter::once("help".to_owned())
2711 .chain(parts.iter().cloned())
2712 .peekable();
2713 let mut out = Vec::with_capacity(clap_args.len());
2714 let mut iter = clap_args.iter().peekable();
2715 if iter
2716 .peek()
2717 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
2718 && let Some(program) = iter.next()
2719 {
2720 out.push(program.clone());
2721 }
2722
2723 let mut take_positional =
2724 |fallback: &String| next_positional.next().unwrap_or(fallback.clone());
2725
2726 while let Some(arg) = iter.next() {
2727 if arg == "--" {
2728 out.push(arg.clone());
2729 for rest in iter.by_ref() {
2731 out.push(take_positional(rest));
2732 }
2733 break;
2734 }
2735 if arg.contains('=') || bool_flags.contains(arg) {
2736 out.push(arg.clone());
2737 continue;
2738 }
2739 if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
2740 out.push(arg.clone());
2741 if let Some(value) = iter.next() {
2742 out.push(value.clone());
2743 }
2744 continue;
2745 }
2746 if arg.starts_with('-') {
2747 out.push(arg.clone());
2748 continue;
2749 }
2750 out.push(take_positional(arg));
2751 }
2752 out.extend(next_positional);
2754 out
2755}
2756
2757fn positional_command_tokens(
2758 args: &[String],
2759 root_name: &str,
2760 bool_flags: &BTreeSet<String>,
2761 value_flags: &BTreeSet<String>,
2762) -> Vec<String> {
2763 let mut tokens = Vec::new();
2764 let mut iter = args.iter().peekable();
2765 if iter
2766 .peek()
2767 .is_some_and(|arg| arg_matches_root_name(arg, root_name))
2768 {
2769 iter.next();
2770 }
2771
2772 while let Some(arg) = iter.next() {
2773 if arg == "--" {
2774 tokens.extend(iter.cloned());
2775 break;
2776 }
2777 if arg.contains('=') {
2778 continue;
2779 }
2780 if bool_flags.contains(arg) {
2781 continue;
2782 }
2783 if value_flags.contains(arg) || unknown_flag_consumes_value(arg, iter.peek()) {
2784 iter.next();
2785 continue;
2786 }
2787 if arg.starts_with('-') {
2788 continue;
2789 }
2790 tokens.push(arg.clone());
2791 }
2792 tokens
2793}
2794
2795fn unknown_flag_consumes_value(arg: &str, next: Option<&&String>) -> bool {
2796 arg.starts_with('-') && next.is_some_and(|value| !value.starts_with('-'))
2797}
2798
2799fn arg_matches_root_name(arg: &str, root_name: &str) -> bool {
2800 arg == root_name
2801 || Path::new(arg)
2802 .file_stem()
2803 .and_then(|n| n.to_str())
2804 .is_some_and(|n| n == root_name)
2805}
2806
2807enum Argv0Outcome {
2810 Proceed(Vec<String>),
2812 Handled(CliRunOutput),
2814}
2815
2816fn program_basename(arg: &str) -> String {
2820 Path::new(arg)
2821 .file_stem()
2822 .and_then(|stem| stem.to_str())
2823 .map_or_else(|| arg.to_owned(), ToOwned::to_owned)
2824}
2825
2826fn is_valid_argv0_name(name: &str) -> bool {
2831 !name.is_empty()
2832 && name.chars().all(|character| {
2833 character.is_ascii_alphanumeric() || character == '-' || character == '_'
2834 })
2835}
2836
2837fn argv0_link_matches(
2842 link: &Path,
2843 target: &Path,
2844 name: &str,
2845 method: Argv0LinkMethod,
2846) -> std::io::Result<bool> {
2847 let metadata = std::fs::symlink_metadata(link)?;
2848 match method {
2849 Argv0LinkMethod::SoftLink => {
2850 Ok(metadata.file_type().is_symlink() && std::fs::read_link(link)? == target)
2851 }
2852 Argv0LinkMethod::HardLink => {
2853 if metadata.file_type().is_symlink() {
2854 return Ok(false);
2855 }
2856 Ok(std::fs::read(link)? == std::fs::read(target)?)
2859 }
2860 Argv0LinkMethod::Script => {
2861 if metadata.file_type().is_symlink() {
2862 return Ok(false);
2863 }
2864 Ok(std::fs::read_to_string(link).ok() == Some(argv0_script_contents(target, name)))
2865 }
2866 }
2867}
2868
2869fn argv0_link_file_name(name: &str, method: Argv0LinkMethod) -> String {
2871 let extension = match method {
2872 Argv0LinkMethod::Script if cfg!(windows) => ".cmd",
2873 Argv0LinkMethod::Script => "",
2875 _ if cfg!(windows) => ".exe",
2876 _ => "",
2877 };
2878 format!("{name}{extension}")
2879}
2880
2881fn argv0_script_contents(target: &Path, name: &str) -> String {
2885 let target = target.display();
2886 if cfg!(windows) {
2887 format!("@\"{target}\" argv0 {name} %*\r\n")
2888 } else {
2889 format!("#!/bin/sh\nexec \"{target}\" argv0 {name} \"$@\"\n")
2890 }
2891}
2892
2893#[cfg(unix)]
2894fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2895 std::os::unix::fs::symlink(target, link)
2896}
2897
2898#[cfg(windows)]
2899fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
2900 std::os::windows::fs::symlink_file(target, link)
2901}
2902
2903#[cfg(not(any(unix, windows)))]
2904fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
2905 Err(std::io::Error::new(
2906 std::io::ErrorKind::Unsupported,
2907 "symlink creation is not supported on this platform",
2908 ))
2909}
2910
2911#[cfg(unix)]
2913fn make_executable(path: &Path) -> std::io::Result<()> {
2914 use std::os::unix::fs::PermissionsExt;
2915 let mut permissions = std::fs::metadata(path)?.permissions();
2916 permissions.set_mode(0o755);
2917 std::fs::set_permissions(path, permissions)
2918}
2919
2920#[cfg(not(unix))]
2921fn make_executable(_path: &Path) -> std::io::Result<()> {
2922 Ok(())
2923}
2924
2925fn register_runtime_group_metadata(
2926 group: &RuntimeGroupSpec,
2927 prefix: &mut Vec<String>,
2928 schemas: &mut SchemaRegistry,
2929 views: &mut HumanViewRegistry,
2930) {
2931 prefix.push(group.group.name.clone());
2932 for child_group in &group.groups {
2933 register_runtime_group_metadata(child_group, prefix, schemas, views);
2934 }
2935 for child in &group.commands {
2936 prefix.push(child.spec.name.clone());
2937 let command_path = prefix.join(":");
2938 register_command_schema(&child.spec, &command_path, schemas);
2939 if child.spec.view_id.is_none() && !child.spec.view_columns.is_empty() {
2945 views.register(HumanViewDef::new(
2946 command_path,
2947 child.spec.view_columns.clone(),
2948 ));
2949 }
2950 prefix.pop();
2951 }
2952 prefix.pop();
2953}
2954
2955fn register_command_schema(spec: &CommandSpec, command_path: &str, schemas: &mut SchemaRegistry) {
2956 if let Some(schema) = &spec.output_schema {
2957 schemas.register_info(command_path.to_owned(), schema.clone());
2958 }
2959}
2960
2961fn runtime_group_clap_command_with_schema_help(
2962 group: &RuntimeGroupSpec,
2963 prefix: &mut Vec<String>,
2964 schemas: &SchemaRegistry,
2965) -> Command {
2966 let mut command = group_clap_command_without_children(&group.group);
2967 prefix.push(group.group.name.clone());
2968 for child_group in &group.groups {
2969 command = command.subcommand(runtime_group_clap_command_with_schema_help(
2970 child_group,
2971 prefix,
2972 schemas,
2973 ));
2974 }
2975 for child in &group.commands {
2976 prefix.push(child.spec.name.clone());
2977 let command_path = prefix.join(":");
2978 command = command.subcommand(command_clap_command_with_schema_help(
2979 &child.spec,
2980 &command_path,
2981 schemas,
2982 ));
2983 prefix.pop();
2984 }
2985 prefix.pop();
2986 command
2987}
2988
2989fn group_clap_command_without_children(group: &GroupSpec) -> Command {
2990 let mut command = Command::new(group.name.clone())
2991 .about(group.short.clone())
2992 .help_template(GROUP_HELP_TEMPLATE);
2993 if let Some(long) = &group.long
2994 && !long.is_empty()
2995 {
2996 command = command.long_about(long.clone());
2997 }
2998 for alias in &group.aliases {
2999 command = command.alias(alias.clone());
3000 }
3001 if group.hidden {
3002 command = command.hide(true);
3003 }
3004 command
3005}
3006
3007fn command_clap_command_with_schema_help(
3008 spec: &CommandSpec,
3009 command_path: &str,
3010 schemas: &SchemaRegistry,
3011) -> Command {
3012 let mut command = spec.clap_command();
3013 let Some(schema) = schemas.get_by_path(command_path) else {
3014 return command;
3015 };
3016 let schema_help = format_help_section(&schema.fields);
3017 if schema_help.is_empty() {
3018 return command;
3019 }
3020 let base = spec
3021 .long
3022 .as_ref()
3023 .filter(|long| !long.is_empty())
3024 .cloned()
3025 .unwrap_or_else(|| spec.short.clone());
3026 let long = if base.is_empty() {
3027 schema_help
3028 } else {
3029 format!("{base}\n\n{schema_help}")
3030 };
3031 command = command.long_about(long);
3032 command
3033}
3034
3035fn process_exit_code(code: i32) -> ExitCode {
3036 if code == 0 {
3037 return ExitCode::SUCCESS;
3038 }
3039 match u8::try_from(code) {
3040 Ok(code) if code != 0 => ExitCode::from(code),
3041 Ok(_) | Err(_) => ExitCode::from(1),
3042 }
3043}
3044
3045async fn run_streaming_command(
3046 middleware: &Middleware,
3047 request: MiddlewareRequest<'_>,
3048 raw_matches: Arc<ArgMatches>,
3049 streaming_handler: crate::command::StreamingCommandHandler,
3050) -> Result<CliRunOutput> {
3051 use tokio::{io::AsyncWriteExt, sync::mpsc};
3052
3053 let args_for_handler = request.args.clone();
3054 let user_args_for_handler = request.user_args.clone();
3055 let handler_path = request.command_path.to_owned();
3056 let middleware_for_handler = middleware.clone();
3057 let raw_matches_for_handler = raw_matches;
3058
3059 let (tx, mut rx) = mpsc::channel::<serde_json::Value>(64);
3060 let sender = StreamSender(tx);
3061
3062 let writer = tokio::spawn(async move {
3066 let mut stdout = tokio::io::stdout();
3067 while let Some(event) = rx.recv().await {
3068 let Ok(line) = serde_json::to_string(&event) else {
3069 continue;
3070 };
3071 if stdout.write_all(line.as_bytes()).await.is_err()
3072 || stdout.write_all(b"\n").await.is_err()
3073 || stdout.flush().await.is_err()
3074 {
3075 break;
3076 }
3077 }
3078 });
3079
3080 let output = middleware
3081 .run(request, async move |credential| {
3082 streaming_handler(
3083 CommandContext {
3084 credential,
3085 args: args_for_handler,
3086 user_args: user_args_for_handler,
3087 command_path: handler_path,
3088 middleware: middleware_for_handler,
3089 raw_matches: raw_matches_for_handler,
3090 },
3091 sender,
3092 )
3093 .await?;
3094 Ok(crate::CommandResult::new(serde_json::Value::Null))
3095 })
3096 .await;
3097
3098 let _write_result = writer.await;
3101
3102 match output {
3103 Ok(out) if out.exit_code == 0 => Ok(CliRunOutput {
3104 exit_code: 0,
3105 rendered: String::new(),
3106 }),
3107 Ok(out) => Ok(out.into()),
3108 Err(err) => Ok(CliRunOutput {
3109 exit_code: exit_code_for_error(&err),
3110 rendered: render_cli_error(middleware, &err, middleware.app_id.as_str()).rendered,
3111 }),
3112 }
3113}
3114
3115#[cfg(test)]
3116mod user_agent_tests {
3117 use super::*;
3118
3119 #[test]
3120 fn user_agent_string_derives_name_and_version_by_default() {
3121 let config =
3122 CliConfig::new("gdx", "GoDaddy CLI", "gdx").with_build(BuildInfo::new("1.2.3"));
3123 assert_eq!(config.user_agent_string(), "gdx/1.2.3");
3124 }
3125
3126 #[test]
3127 fn user_agent_string_prefers_explicit_override() {
3128 let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx")
3129 .with_build(BuildInfo::new("1.2.3"))
3130 .with_user_agent("gdx-cli/9.9 (custom)");
3131 assert_eq!(config.user_agent_string(), "gdx-cli/9.9 (custom)");
3132 }
3133
3134 #[test]
3135 fn user_agent_string_omits_version_when_absent() {
3136 let config = CliConfig::new("gdx", "GoDaddy CLI", "gdx");
3137 assert_eq!(config.user_agent_string(), "gdx");
3138 }
3139
3140 #[test]
3141 fn install_default_user_agent_publishes_config_value() {
3142 let _guard = crate::transport::client::UA_TEST_LOCK
3143 .lock()
3144 .unwrap_or_else(std::sync::PoisonError::into_inner);
3145 let _restore = crate::transport::client::RestoreDefaultUserAgent;
3146 crate::transport::set_default_user_agent("cli/dev");
3147 let cli = Cli::new(
3148 CliConfig::new("uatest", "UA test", "uatest").with_build(BuildInfo::new("4.5.6")),
3149 );
3150 cli.install_default_user_agent();
3151 assert_eq!(
3152 crate::transport::client::default_user_agent(),
3153 "uatest/4.5.6"
3154 );
3155 }
3156
3157 #[test]
3158 fn install_debug_transport_logger_tracks_the_debug_pattern() {
3159 let _guard = crate::transport::client::TRANSPORT_LOGGER_TEST_LOCK
3160 .lock()
3161 .unwrap_or_else(std::sync::PoisonError::into_inner);
3162 let _restore = crate::transport::client::RestoreDefaultTransportLogger;
3163
3164 install_debug_transport_logger("transport", &[]);
3166 assert!(crate::transport::default_transport_logger().enabled());
3167
3168 install_debug_transport_logger("*,-transport", &[]);
3170 assert!(!crate::transport::default_transport_logger().enabled());
3171
3172 install_debug_transport_logger("transport", &[]);
3174 install_debug_transport_logger("", &[]);
3175 assert!(!crate::transport::default_transport_logger().enabled());
3176 }
3177}
3178
3179#[cfg(test)]
3180mod env_config_tests {
3181 use super::*;
3182
3183 #[test]
3184 fn with_environments_stores_shared_arc_with_consumer_app_id() {
3185 let cfg = CliConfig::new("gddy", "GoDaddy CLI", "gddy").with_environments(Arc::new(
3189 crate::environments::Environments::new("prod")
3190 .with_app_id("gddy")
3191 .with_config_file(true),
3192 ));
3193 let envs = cfg.environments.as_ref().expect("environments set");
3194 assert!(envs.config_file_path().is_some());
3195 }
3196
3197 #[tokio::test]
3198 async fn env_flag_overrides_default_and_reaches_middleware_env() {
3199 use crate::{CommandResult, CommandSpec, RuntimeCommandSpec};
3200 use serde_json::json;
3201 let mut cli = Cli::new(
3202 CliConfig::new("envtest", "Env test", "envtest").with_environments(Arc::new(
3203 crate::environments::Environments::new("prod")
3204 .with_environment("prod", crate::environments::EnvironmentDef::new())
3205 .with_environment("ote", crate::environments::EnvironmentDef::new()),
3206 )),
3207 );
3208 cli.add_command(RuntimeCommandSpec::new_with_context(
3209 CommandSpec::new("whichenv", "echo env").no_auth(true),
3210 async |ctx| {
3211 Ok(CommandResult::new(
3212 json!({ "env": ctx.environment()?.name }),
3213 ))
3214 },
3215 ));
3216 let out = cli
3217 .run(["envtest", "whichenv", "--env", "ote", "--output", "json"])
3218 .await;
3219 assert_eq!(out.exit_code, 0, "rendered: {}", out.rendered);
3220 assert!(out.rendered.contains("\"env\""));
3221 assert!(out.rendered.contains("ote"));
3222 }
3223
3224 #[tokio::test]
3225 async fn unknown_env_flag_produces_error_envelope() {
3226 let cli = Cli::new(
3227 CliConfig::new("envtest2", "Env test", "envtest2").with_environments(Arc::new(
3228 crate::environments::Environments::new("prod")
3229 .with_environment("prod", crate::environments::EnvironmentDef::new()),
3230 )),
3231 );
3232 let out = cli.run(["envtest2", "tree", "--env", "nope"]).await;
3233 assert_ne!(out.exit_code, 0);
3234 assert!(out.rendered.contains("nope"));
3235 }
3236}