1use std::collections::BTreeMap;
48
49use serde::{Deserialize, Serialize};
50
51use crate::core::command_def::{
52 ArgDef, CommandDef, CommandPolicyDef, FlagDef, ValueChoice, ValueKind,
53};
54use crate::core::command_policy::{CommandPath, CommandPolicy, VisibilityMode};
55
56pub const PLUGIN_PROTOCOL_V1: u32 = 1;
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct DescribeV1 {
62 pub protocol_version: u32,
64 pub plugin_id: String,
66 pub plugin_version: String,
68 pub min_osp_version: Option<String>,
70 pub commands: Vec<DescribeCommandV1>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct DescribeCommandV1 {
77 pub name: String,
79 #[serde(default)]
81 pub about: String,
82 #[serde(default)]
84 pub auth: Option<DescribeCommandAuthV1>,
85 #[serde(default)]
87 pub args: Vec<DescribeArgV1>,
88 #[serde(default)]
90 pub flags: BTreeMap<String, DescribeFlagV1>,
91 #[serde(default)]
93 pub subcommands: Vec<DescribeCommandV1>,
94}
95
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct DescribeCommandAuthV1 {
99 #[serde(default)]
101 pub visibility: Option<DescribeVisibilityModeV1>,
102 #[serde(default)]
104 pub required_capabilities: Vec<String>,
105 #[serde(default)]
107 pub feature_flags: Vec<String>,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum DescribeVisibilityModeV1 {
114 Public,
116 Authenticated,
118 CapabilityGated,
120 Hidden,
122}
123
124impl DescribeVisibilityModeV1 {
125 pub fn as_visibility_mode(self) -> VisibilityMode {
139 match self {
140 DescribeVisibilityModeV1::Public => VisibilityMode::Public,
141 DescribeVisibilityModeV1::Authenticated => VisibilityMode::Authenticated,
142 DescribeVisibilityModeV1::CapabilityGated => VisibilityMode::CapabilityGated,
143 DescribeVisibilityModeV1::Hidden => VisibilityMode::Hidden,
144 }
145 }
146
147 pub fn as_label(self) -> &'static str {
157 match self {
158 DescribeVisibilityModeV1::Public => "public",
159 DescribeVisibilityModeV1::Authenticated => "authenticated",
160 DescribeVisibilityModeV1::CapabilityGated => "capability_gated",
161 DescribeVisibilityModeV1::Hidden => "hidden",
162 }
163 }
164}
165
166impl DescribeCommandAuthV1 {
167 pub fn hint(&self) -> Option<String> {
189 let mut parts = Vec::new();
190
191 match self.visibility {
192 Some(DescribeVisibilityModeV1::Public) | None => {}
193 Some(DescribeVisibilityModeV1::Authenticated) => parts.push("auth".to_string()),
194 Some(DescribeVisibilityModeV1::CapabilityGated) => {
195 if self.required_capabilities.len() == 1 {
196 parts.push(format!("cap: {}", self.required_capabilities[0]));
197 } else if self.required_capabilities.is_empty() {
198 parts.push("cap".to_string());
199 } else {
200 parts.push(format!("caps: {}", self.required_capabilities.len()));
201 }
202 }
203 Some(DescribeVisibilityModeV1::Hidden) => parts.push("hidden".to_string()),
204 }
205
206 match self.feature_flags.as_slice() {
207 [] => {}
208 [feature] => parts.push(format!("feature: {feature}")),
209 features => parts.push(format!("features: {}", features.len())),
210 }
211
212 (!parts.is_empty()).then(|| parts.join("; "))
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum DescribeValueTypeV1 {
220 Path,
222}
223
224#[derive(Debug, Clone, Default, Serialize, Deserialize)]
226pub struct DescribeSuggestionV1 {
227 pub value: String,
229 #[serde(default)]
231 pub meta: Option<String>,
232 #[serde(default)]
234 pub display: Option<String>,
235 #[serde(default)]
237 pub sort: Option<String>,
238}
239
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
242pub struct DescribeArgV1 {
243 #[serde(default)]
245 pub name: Option<String>,
246 #[serde(default)]
248 pub about: Option<String>,
249 #[serde(default)]
251 pub multi: bool,
252 #[serde(default)]
254 pub value_type: Option<DescribeValueTypeV1>,
255 #[serde(default)]
257 pub suggestions: Vec<DescribeSuggestionV1>,
258}
259
260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
262pub struct DescribeFlagV1 {
263 #[serde(default)]
265 pub about: Option<String>,
266 #[serde(default)]
268 pub flag_only: bool,
269 #[serde(default)]
271 pub multi: bool,
272 #[serde(default)]
274 pub value_type: Option<DescribeValueTypeV1>,
275 #[serde(default)]
277 pub suggestions: Vec<DescribeSuggestionV1>,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct ResponseV1 {
283 pub protocol_version: u32,
285 pub ok: bool,
287 pub data: serde_json::Value,
289 pub error: Option<ResponseErrorV1>,
291 #[serde(default)]
293 pub messages: Vec<ResponseMessageV1>,
294 pub meta: ResponseMetaV1,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct ResponseErrorV1 {
301 pub code: String,
303 pub message: String,
305 #[serde(default)]
307 pub details: serde_json::Value,
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize, Default)]
312pub struct ResponseMetaV1 {
313 pub format_hint: Option<String>,
315 pub columns: Option<Vec<String>>,
317 #[serde(default)]
319 pub column_align: Vec<ColumnAlignmentV1>,
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
324#[serde(rename_all = "lowercase")]
325pub enum ColumnAlignmentV1 {
326 #[default]
328 Default,
329 Left,
331 Center,
333 Right,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339#[serde(rename_all = "lowercase")]
340pub enum ResponseMessageLevelV1 {
341 Error,
343 Warning,
345 Success,
347 Info,
349 Trace,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct ResponseMessageV1 {
356 pub level: ResponseMessageLevelV1,
358 pub text: String,
360}
361
362impl DescribeV1 {
363 #[cfg(feature = "clap")]
364 pub fn from_clap_command(
386 plugin_id: impl Into<String>,
387 plugin_version: impl Into<String>,
388 min_osp_version: Option<String>,
389 command: clap::Command,
390 ) -> Self {
391 Self::from_clap_commands(
392 plugin_id,
393 plugin_version,
394 min_osp_version,
395 std::iter::once(command),
396 )
397 }
398
399 #[cfg(feature = "clap")]
400 pub fn from_clap_commands(
429 plugin_id: impl Into<String>,
430 plugin_version: impl Into<String>,
431 min_osp_version: Option<String>,
432 commands: impl IntoIterator<Item = clap::Command>,
433 ) -> Self {
434 Self {
435 protocol_version: PLUGIN_PROTOCOL_V1,
436 plugin_id: plugin_id.into(),
437 plugin_version: plugin_version.into(),
438 min_osp_version,
439 commands: commands
440 .into_iter()
441 .map(CommandDef::from_clap)
442 .map(|command| DescribeCommandV1::from(&command))
443 .collect(),
444 }
445 }
446
447 pub fn validate_v1(&self) -> Result<(), String> {
478 if self.protocol_version != PLUGIN_PROTOCOL_V1 {
479 return Err(format!(
480 "unsupported describe protocol version: {}",
481 self.protocol_version
482 ));
483 }
484 if self.plugin_id.trim().is_empty() {
485 return Err("plugin_id must not be empty".to_string());
486 }
487 for command in &self.commands {
488 validate_command(command)?;
489 }
490 Ok(())
491 }
492}
493
494impl DescribeCommandV1 {
495 pub fn command_policy(&self, path: CommandPath) -> Option<CommandPolicy> {
538 let auth = self.auth.as_ref()?;
539 let mut policy = CommandPolicy::new(path);
540 if let Some(visibility) = auth.visibility {
541 policy = policy.visibility(visibility.as_visibility_mode());
542 }
543 for capability in &auth.required_capabilities {
544 policy = policy.require_capability(capability.clone());
545 }
546 for feature in &auth.feature_flags {
547 policy = policy.feature_flag(feature.clone());
548 }
549 Some(policy)
550 }
551}
552
553impl ResponseV1 {
554 pub fn validate_v1(&self) -> Result<(), String> {
588 if self.protocol_version != PLUGIN_PROTOCOL_V1 {
589 return Err(format!(
590 "unsupported response protocol version: {}",
591 self.protocol_version
592 ));
593 }
594 if self.ok && self.error.is_some() {
595 return Err("ok=true requires error=null".to_string());
596 }
597 if !self.ok && self.error.is_none() {
598 return Err("ok=false requires error payload".to_string());
599 }
600 if self
601 .messages
602 .iter()
603 .any(|message| message.text.trim().is_empty())
604 {
605 return Err("response messages must not contain empty text".to_string());
606 }
607 Ok(())
608 }
609}
610
611#[cfg(feature = "clap")]
612impl DescribeCommandV1 {
613 pub fn from_clap(command: clap::Command) -> Self {
637 Self::from(&CommandDef::from_clap(command))
638 }
639}
640
641impl From<&CommandDef> for DescribeCommandV1 {
642 fn from(command: &CommandDef) -> Self {
643 Self {
644 name: command.name.clone(),
645 about: command.about.clone().unwrap_or_default(),
646 auth: (!command.policy.is_empty()).then(|| DescribeCommandAuthV1 {
647 visibility: match command.policy.visibility {
648 VisibilityMode::Public => None,
649 VisibilityMode::Authenticated => Some(DescribeVisibilityModeV1::Authenticated),
650 VisibilityMode::CapabilityGated => {
651 Some(DescribeVisibilityModeV1::CapabilityGated)
652 }
653 VisibilityMode::Hidden => Some(DescribeVisibilityModeV1::Hidden),
654 },
655 required_capabilities: command.policy.required_capabilities.clone(),
656 feature_flags: command.policy.feature_flags.clone(),
657 }),
658 args: command.args.iter().map(DescribeArgV1::from).collect(),
659 flags: command
660 .flags
661 .iter()
662 .flat_map(describe_flag_entries)
663 .collect(),
664 subcommands: command
665 .subcommands
666 .iter()
667 .map(DescribeCommandV1::from)
668 .collect(),
669 }
670 }
671}
672
673impl From<&DescribeCommandV1> for CommandDef {
674 fn from(command: &DescribeCommandV1) -> Self {
675 Self {
676 name: command.name.clone(),
677 about: (!command.about.trim().is_empty()).then(|| command.about.clone()),
678 long_about: None,
679 usage: None,
680 before_help: None,
681 after_help: None,
682 aliases: Vec::new(),
683 hidden: matches!(
684 command.auth.as_ref().and_then(|auth| auth.visibility),
685 Some(DescribeVisibilityModeV1::Hidden)
686 ),
687 sort_key: None,
688 policy: command
689 .auth
690 .as_ref()
691 .map(command_policy_from_describe)
692 .unwrap_or_default(),
693 args: command.args.iter().map(ArgDef::from).collect(),
694 flags: collect_describe_flags(&command.flags),
695 subcommands: command.subcommands.iter().map(CommandDef::from).collect(),
696 }
697 }
698}
699
700impl From<&ArgDef> for DescribeArgV1 {
701 fn from(arg: &ArgDef) -> Self {
702 Self {
703 name: arg.value_name.clone().or_else(|| Some(arg.id.clone())),
704 about: arg.help.clone(),
705 multi: arg.multi,
706 value_type: describe_value_type(arg.value_kind),
707 suggestions: arg.choices.iter().map(DescribeSuggestionV1::from).collect(),
708 }
709 }
710}
711
712impl From<&FlagDef> for DescribeFlagV1 {
713 fn from(flag: &FlagDef) -> Self {
714 Self {
715 about: flag.help.clone(),
716 flag_only: !flag.takes_value,
717 multi: flag.multi,
718 value_type: describe_value_type(flag.value_kind),
719 suggestions: flag
720 .choices
721 .iter()
722 .map(DescribeSuggestionV1::from)
723 .collect(),
724 }
725 }
726}
727
728impl From<&DescribeArgV1> for ArgDef {
729 fn from(arg: &DescribeArgV1) -> Self {
730 let mut def = ArgDef::new(arg.name.clone().unwrap_or_else(|| "value".to_string()));
731 if let Some(value_name) = &arg.name {
732 def = def.value_name(value_name.clone());
733 }
734 if let Some(help) = &arg.about {
735 def = def.help(help.clone());
736 }
737 if arg.multi {
738 def = def.multi();
739 }
740 if let Some(value_kind) = command_value_kind(arg.value_type) {
741 def = def.value_kind(value_kind);
742 }
743 def.choices(arg.suggestions.iter().map(ValueChoice::from))
744 }
745}
746
747impl From<&DescribeFlagV1> for FlagDef {
748 fn from(flag: &DescribeFlagV1) -> Self {
749 let mut def = FlagDef::new("flag");
750 if let Some(help) = &flag.about {
751 def = def.help(help.clone());
752 }
753 if !flag.flag_only {
754 def = def.takes_value("value");
755 }
756 if flag.multi {
757 def = def.multi();
758 }
759 if let Some(value_kind) = command_value_kind(flag.value_type) {
760 def = def.value_kind(value_kind);
761 }
762 def.choices(flag.suggestions.iter().map(ValueChoice::from))
763 }
764}
765
766impl From<&ValueChoice> for DescribeSuggestionV1 {
767 fn from(choice: &ValueChoice) -> Self {
768 Self {
769 value: choice.value.clone(),
770 meta: choice.help.clone(),
771 display: choice.display.clone(),
772 sort: choice.sort_key.clone(),
773 }
774 }
775}
776
777impl From<&DescribeSuggestionV1> for ValueChoice {
778 fn from(entry: &DescribeSuggestionV1) -> Self {
779 Self {
780 value: entry.value.clone(),
781 help: entry.meta.clone(),
782 display: entry.display.clone(),
783 sort_key: entry.sort.clone(),
784 }
785 }
786}
787
788fn validate_command(command: &DescribeCommandV1) -> Result<(), String> {
789 if command.name.trim().is_empty() {
790 return Err("command name must not be empty".to_string());
791 }
792 if let Some(auth) = &command.auth {
793 validate_command_auth(auth)?;
794 }
795
796 for (name, flag) in &command.flags {
797 if !name.starts_with('-') {
798 return Err(format!("flag `{name}` must start with `-`"));
799 }
800 validate_suggestions(&flag.suggestions, &format!("flag `{name}`"))?;
801 }
802
803 for arg in &command.args {
804 validate_suggestions(&arg.suggestions, "argument")?;
805 }
806
807 for subcommand in &command.subcommands {
808 validate_command(subcommand)?;
809 }
810
811 Ok(())
812}
813
814fn validate_suggestions(suggestions: &[DescribeSuggestionV1], owner: &str) -> Result<(), String> {
815 if suggestions
816 .iter()
817 .any(|entry| entry.value.trim().is_empty())
818 {
819 return Err(format!("{owner} suggestions must not contain empty values"));
820 }
821 Ok(())
822}
823
824fn validate_command_auth(auth: &DescribeCommandAuthV1) -> Result<(), String> {
825 if auth
826 .required_capabilities
827 .iter()
828 .any(|value| value.trim().is_empty())
829 {
830 return Err("required_capabilities must not contain empty values".to_string());
831 }
832 if auth
833 .feature_flags
834 .iter()
835 .any(|value| value.trim().is_empty())
836 {
837 return Err("feature_flags must not contain empty values".to_string());
838 }
839 Ok(())
840}
841
842fn describe_flag_entries(flag: &FlagDef) -> Vec<(String, DescribeFlagV1)> {
843 let value = DescribeFlagV1::from(flag);
844 let mut names = Vec::new();
845 if let Some(long) = flag.long.as_deref() {
846 names.push(format!("--{long}"));
847 }
848 if let Some(short) = flag.short {
849 names.push(format!("-{short}"));
850 }
851 names.extend(flag.aliases.iter().cloned());
852 names
853 .into_iter()
854 .map(|name| (name, value.clone()))
855 .collect()
856}
857
858fn group_describe_flag((name, flag): (&String, &DescribeFlagV1)) -> Option<FlagDef> {
859 if !name.starts_with('-') {
860 return None;
861 }
862
863 let mut def = FlagDef::from(flag);
864 if let Some(long) = name.strip_prefix("--") {
865 def.long = Some(long.to_string());
866 def.id = long.to_string();
867 } else if let Some(short) = name.strip_prefix('-') {
868 def.short = short.chars().next();
869 def.id = short.to_string();
870 }
871 Some(def)
872}
873
874fn collect_describe_flags(flags: &BTreeMap<String, DescribeFlagV1>) -> Vec<FlagDef> {
875 let mut grouped: BTreeMap<String, Vec<(&String, &DescribeFlagV1)>> = BTreeMap::new();
876 for entry in flags.iter() {
877 let signature = serde_json::to_string(entry.1).unwrap_or_default();
878 grouped.entry(signature).or_default().push(entry);
879 }
880
881 grouped
882 .into_values()
883 .filter_map(|group| {
884 let mut iter = group.into_iter();
885 let first = iter.next()?;
886 let mut def = group_describe_flag(first)?;
887 for (name, _) in iter {
888 if let Some(long) = name.strip_prefix("--") {
889 if def.long.is_none() {
890 def.long = Some(long.to_string());
891 if def.id == "flag" {
892 def.id = long.to_string();
893 }
894 } else if Some(long) != def.long.as_deref() {
895 def.aliases.push(format!("--{long}"));
896 }
897 } else if let Some(short) = name.strip_prefix('-') {
898 let short_char = short.chars().next();
899 if def.short.is_none() {
900 def.short = short_char;
901 if def.id == "flag" {
902 def.id = short.to_string();
903 }
904 } else if short_char != def.short {
905 def.aliases.push(format!("-{short}"));
906 }
907 }
908 }
909 Some(def)
910 })
911 .collect()
912}
913
914fn command_policy_from_describe(auth: &DescribeCommandAuthV1) -> CommandPolicyDef {
915 CommandPolicyDef {
916 visibility: match auth.visibility {
917 Some(DescribeVisibilityModeV1::Authenticated) => VisibilityMode::Authenticated,
918 Some(DescribeVisibilityModeV1::CapabilityGated) => VisibilityMode::CapabilityGated,
919 Some(DescribeVisibilityModeV1::Hidden) => VisibilityMode::Hidden,
920 Some(DescribeVisibilityModeV1::Public) | None => VisibilityMode::Public,
921 },
922 required_capabilities: auth.required_capabilities.clone(),
923 feature_flags: auth.feature_flags.clone(),
924 }
925}
926
927fn describe_value_type(value_kind: Option<ValueKind>) -> Option<DescribeValueTypeV1> {
928 match value_kind {
929 Some(ValueKind::Path) => Some(DescribeValueTypeV1::Path),
930 Some(ValueKind::Enum | ValueKind::FreeText) | None => None,
931 }
932}
933
934fn command_value_kind(value_type: Option<DescribeValueTypeV1>) -> Option<ValueKind> {
935 value_type.map(|_| ValueKind::Path)
936}
937
938#[cfg(test)]
939mod tests {
940 use std::collections::BTreeMap;
941
942 use super::{
943 DescribeCommandAuthV1, DescribeCommandV1, DescribeVisibilityModeV1, validate_command_auth,
944 };
945 use crate::core::command_policy::{CommandPath, VisibilityMode};
946
947 #[test]
948 fn command_auth_converts_to_generic_command_policy_unit() {
949 let command = DescribeCommandV1 {
950 name: "orch".to_string(),
951 about: String::new(),
952 auth: Some(DescribeCommandAuthV1 {
953 visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
954 required_capabilities: vec!["orch.approval.decide".to_string()],
955 feature_flags: vec!["orch".to_string()],
956 }),
957 args: Vec::new(),
958 flags: BTreeMap::new(),
959 subcommands: Vec::new(),
960 };
961
962 let policy = command
963 .command_policy(CommandPath::new(["orch", "approval", "decide"]))
964 .expect("auth metadata should build a policy");
965 assert_eq!(policy.visibility, VisibilityMode::CapabilityGated);
966 assert!(
967 policy
968 .required_capabilities
969 .contains("orch.approval.decide")
970 );
971 assert!(policy.feature_flags.contains("orch"));
972 }
973
974 #[test]
975 fn command_auth_validation_rejects_blank_entries_unit() {
976 let err = validate_command_auth(&DescribeCommandAuthV1 {
977 visibility: None,
978 required_capabilities: vec![" ".to_string()],
979 feature_flags: Vec::new(),
980 })
981 .expect_err("blank capabilities should be rejected");
982 assert!(err.contains("required_capabilities"));
983 }
984
985 #[test]
986 fn command_auth_hint_stays_compact_and_stable_unit() {
987 let auth = DescribeCommandAuthV1 {
988 visibility: Some(DescribeVisibilityModeV1::CapabilityGated),
989 required_capabilities: vec!["orch.approval.decide".to_string()],
990 feature_flags: vec!["orch".to_string()],
991 };
992 assert_eq!(
993 auth.hint().as_deref(),
994 Some("cap: orch.approval.decide; feature: orch")
995 );
996 assert_eq!(
997 DescribeVisibilityModeV1::Authenticated.as_label(),
998 "authenticated"
999 );
1000 }
1001}
1002
1003#[cfg(all(test, feature = "clap"))]
1004mod clap_tests {
1005 use super::{DescribeCommandV1, DescribeV1, DescribeValueTypeV1};
1006 use clap::{Arg, ArgAction, Command, ValueHint};
1007
1008 #[test]
1009 fn clap_helper_captures_subcommands_flags_and_args() {
1010 let command = Command::new("ldap").about("LDAP plugin").subcommand(
1011 Command::new("user")
1012 .about("Lookup LDAP users")
1013 .arg(Arg::new("uid").help("User id"))
1014 .arg(
1015 Arg::new("attributes")
1016 .long("attributes")
1017 .short('a')
1018 .help("Attributes to fetch")
1019 .action(ArgAction::Set)
1020 .value_parser(["uid", "cn", "mail"]),
1021 )
1022 .arg(
1023 Arg::new("input")
1024 .long("input")
1025 .help("Read from file")
1026 .value_hint(ValueHint::FilePath),
1027 ),
1028 );
1029
1030 let describe =
1031 DescribeV1::from_clap_command("ldap", "0.1.0", Some("0.1.0".to_string()), command);
1032
1033 assert_eq!(describe.commands.len(), 1);
1034 let ldap = &describe.commands[0];
1035 assert_eq!(ldap.name, "ldap");
1036 assert_eq!(ldap.subcommands.len(), 1);
1037
1038 let user = &ldap.subcommands[0];
1039 assert_eq!(user.name, "user");
1040 assert_eq!(user.args[0].name.as_deref(), Some("uid"));
1041 assert!(user.flags.contains_key("--attributes"));
1042 assert!(user.flags.contains_key("-a"));
1043 assert_eq!(
1044 user.flags["--attributes"]
1045 .suggestions
1046 .iter()
1047 .map(|entry| entry.value.as_str())
1048 .collect::<Vec<_>>(),
1049 vec!["uid", "cn", "mail"]
1050 );
1051 assert_eq!(
1052 user.flags["--input"].value_type,
1053 Some(DescribeValueTypeV1::Path)
1054 );
1055 }
1056
1057 #[test]
1058 fn clap_command_conversion_skips_hidden_items() {
1059 let command = Command::new("ldap")
1060 .subcommand(Command::new("visible"))
1061 .subcommand(Command::new("hidden").hide(true))
1062 .arg(Arg::new("secret").long("secret").hide(true));
1063
1064 let describe = DescribeCommandV1::from_clap(command);
1065
1066 assert_eq!(
1067 describe
1068 .subcommands
1069 .iter()
1070 .map(|subcommand| subcommand.name.as_str())
1071 .collect::<Vec<_>>(),
1072 vec!["visible"]
1073 );
1074 assert!(!describe.flags.contains_key("--secret"));
1075 }
1076}