diff --git a/shortcuts/common/binder.go b/shortcuts/common/binder.go index 75d3e5014..f25d83516 100644 --- a/shortcuts/common/binder.go +++ b/shortcuts/common/binder.go @@ -27,6 +27,8 @@ type fieldSpec struct { EnumValues []string Input []string Required bool + Hidden bool + NoSplit bool IsOneOfBkt bool IsGroup bool IsPtr bool @@ -91,6 +93,17 @@ func parseFieldSpec(f reflect.StructField) (fieldSpec, error) { } } } + if _, has := f.Tag.Lookup("hidden"); has { + spec.Hidden = true + } + switch split := strings.TrimSpace(f.Tag.Get("split")); split { + case "", "comma": + // default: cobra StringSlice (comma-separated, also repeatable) + case "none": + spec.NoSplit = true // cobra StringArray: repeatable, no comma split + default: + return spec, fmt.Errorf("field %s: unknown split mode %q (allowed: comma, none)", f.Name, split) + } ft := f.Type spec.FieldType = ft if ft.Kind() == reflect.Ptr { @@ -108,11 +121,18 @@ func parseFieldSpec(f reflect.StructField) (fieldSpec, error) { } return spec, nil } - // Leaf field: enum / input tags only make sense on string-kinded leaves - // (plain string or string-alias types like ChatID). Reject them on - // int / bool / other kinds at Mount time instead of silently skipping the - // check at runtime — enum validation reads the flag via GetString and - // @file resolution requires a string flag. + // Leaf field validation. Multi-value flags are []string (cobra + // StringSlice / StringArray); any other slice element type is unsupported. + // enum / input only make sense on plain string leaves (string or a + // string-alias like ChatID); split only on []string. Reject mismatches at + // Mount time instead of silently skipping or panicking at runtime. + isStringSlice := ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.String + if ft.Kind() == reflect.Slice && !isStringSlice { + return spec, fmt.Errorf("field %s: only []string slices are supported, got %s", f.Name, ft) + } + if spec.NoSplit && !isStringSlice { + return spec, fmt.Errorf("field %s: split tag is only supported on []string fields", f.Name) + } if ft.Kind() != reflect.String { if len(spec.EnumValues) > 0 { return spec, fmt.Errorf("field %s: enum tag is only supported on string fields", f.Name) @@ -163,12 +183,23 @@ func registerLeaf(cmd *cobra.Command, s fieldSpec, t reflect.Type) error { def, _ = strconv.Atoi(s.DefaultValue) } cmd.Flags().Int(s.FlagName, def, s.Description) + case reflect.Slice: + // []string (validated at parse time). NoSplit → StringArray + // (repeatable, literal); default → StringSlice (comma-separated). + if s.NoSplit { + cmd.Flags().StringArray(s.FlagName, nil, s.Description) + } else { + cmd.Flags().StringSlice(s.FlagName, nil, s.Description) + } default: cmd.Flags().String(s.FlagName, s.DefaultValue, s.Description) } if s.Required { _ = cmd.MarkFlagRequired(s.FlagName) } + if s.Hidden { + _ = cmd.Flags().MarkHidden(s.FlagName) + } if len(s.EnumValues) > 0 { vals := s.EnumValues cmdutil.RegisterFlagCompletion(cmd, s.FlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { @@ -253,6 +284,14 @@ func bindLeaf(cmd *cobra.Command, argsVal reflect.Value, s fieldSpec) error { case reflect.Int, reflect.Int64: v, _ := cmd.Flags().GetInt(s.FlagName) setLeaf(fv, reflect.ValueOf(int64(v)).Convert(leafType)) + case reflect.Slice: + if s.NoSplit { + v, _ := cmd.Flags().GetStringArray(s.FlagName) + setLeaf(fv, reflect.ValueOf(v).Convert(leafType)) + } else { + v, _ := cmd.Flags().GetStringSlice(s.FlagName) + setLeaf(fv, reflect.ValueOf(v).Convert(leafType)) + } default: v, _ := cmd.Flags().GetString(s.FlagName) setLeaf(fv, reflect.ValueOf(v).Convert(leafType)) @@ -410,11 +449,11 @@ func bindBucketInner(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpe } elemType := fv.Type().Elem() ptr := reflect.New(elemType) - ptr.Elem().Set(bucketLeafValue(cmd, s.FlagName, elemType)) + ptr.Elem().Set(bucketLeafValue(cmd, s.FlagName, elemType, s.NoSplit)) fv.Set(ptr) continue } - fv.Set(bucketLeafValue(cmd, s.FlagName, fv.Type())) + fv.Set(bucketLeafValue(cmd, s.FlagName, fv.Type(), s.NoSplit)) } return nil } @@ -424,7 +463,7 @@ func bindBucketInner(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpe // bucket/group leaves typed as bool or int bind correctly instead of being // force-read through GetString (which would panic on reflect conversion of a // string into a numeric/bool type). -func bucketLeafValue(cmd *cobra.Command, flagName string, targetType reflect.Type) reflect.Value { +func bucketLeafValue(cmd *cobra.Command, flagName string, targetType reflect.Type, noSplit bool) reflect.Value { kind := targetType.Kind() if kind == reflect.Ptr { kind = targetType.Elem().Kind() @@ -436,6 +475,13 @@ func bucketLeafValue(cmd *cobra.Command, flagName string, targetType reflect.Typ case reflect.Int, reflect.Int64: v, _ := cmd.Flags().GetInt(flagName) return reflect.ValueOf(int64(v)).Convert(targetType) + case reflect.Slice: + if noSplit { + v, _ := cmd.Flags().GetStringArray(flagName) + return reflect.ValueOf(v).Convert(targetType) + } + v, _ := cmd.Flags().GetStringSlice(flagName) + return reflect.ValueOf(v).Convert(targetType) default: v, _ := cmd.Flags().GetString(flagName) return reflect.ValueOf(v).Convert(targetType) diff --git a/shortcuts/common/binder_slice_hidden_test.go b/shortcuts/common/binder_slice_hidden_test.go new file mode 100644 index 000000000..1ad312d73 --- /dev/null +++ b/shortcuts/common/binder_slice_hidden_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// --- multi-value ([]string) flags ----------------------------------------- + +type sliceArgs struct { + Ids []string `flag:"ids"` // default → StringSlice (comma-split) + Tags []string `flag:"tags" split:"none"` // StringArray (repeatable, no split) +} + +func TestSliceFlag_StringSliceDefault(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + specs, err := walkArgs(reflect.TypeOf(&sliceArgs{})) + if err != nil { + t.Fatalf("walkArgs: %v", err) + } + if err := registerFlags(cmd, specs); err != nil { + t.Fatalf("registerFlags: %v", err) + } + if err := cmd.ParseFlags([]string{"--ids", "a,b,c"}); err != nil { + t.Fatalf("parse: %v", err) + } + out := &sliceArgs{} + if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil { + t.Fatalf("bind: %v", err) + } + if !reflect.DeepEqual(out.Ids, []string{"a", "b", "c"}) { + t.Errorf("Ids = %#v, want [a b c] (comma-split)", out.Ids) + } +} + +func TestSliceFlag_StringArrayNoSplit(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{})) + _ = registerFlags(cmd, specs) + // repeated; a value containing a comma must NOT be split (StringArray) + if err := cmd.ParseFlags([]string{"--tags", "a,b", "--tags", "c"}); err != nil { + t.Fatalf("parse: %v", err) + } + out := &sliceArgs{} + _ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs) + if !reflect.DeepEqual(out.Tags, []string{"a,b", "c"}) { + t.Errorf("Tags = %#v, want [\"a,b\" \"c\"] (no split, repeatable)", out.Tags) + } +} + +func TestSliceFlag_UnsetIsEmpty(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{})) + _ = registerFlags(cmd, specs) + _ = cmd.ParseFlags(nil) + out := &sliceArgs{} + _ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs) + if len(out.Ids) != 0 { + t.Errorf("Ids = %#v, want empty when unset", out.Ids) + } +} + +type sliceGroup struct { + Items []string `flag:"items"` + Note string `flag:"note"` +} + +type groupSliceArgs struct { + G sliceGroup +} + +func TestSliceFlag_InGroup(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + specs, err := walkArgs(reflect.TypeOf(&groupSliceArgs{})) + if err != nil { + t.Fatalf("walkArgs: %v", err) + } + _ = registerFlags(cmd, specs) + if err := cmd.ParseFlags([]string{"--items", "x,y", "--note", "hi"}); err != nil { + t.Fatalf("parse: %v", err) + } + out := &groupSliceArgs{} + if err := bindGroups(cmd, reflect.ValueOf(out).Elem(), specs); err != nil { + t.Fatalf("bindGroups: %v", err) + } + if !reflect.DeepEqual(out.G.Items, []string{"x", "y"}) { + t.Errorf("G.Items = %#v, want [x y]", out.G.Items) + } +} + +// --- Mount-time validation for slices / split ----------------------------- + +type splitOnStringArgs struct { + S string `flag:"s" split:"none"` +} + +func TestWalkArgs_SplitOnNonSliceErrors(t *testing.T) { + _, err := walkArgs(reflect.TypeOf(&splitOnStringArgs{})) + if err == nil || !strings.Contains(err.Error(), "split tag is only supported on []string") { + t.Fatalf("expected split-on-non-slice error, got %v", err) + } +} + +type intSliceArgs struct { + N []int `flag:"n"` +} + +func TestWalkArgs_NonStringSliceErrors(t *testing.T) { + _, err := walkArgs(reflect.TypeOf(&intSliceArgs{})) + if err == nil || !strings.Contains(err.Error(), "only []string slices are supported") { + t.Fatalf("expected []int error, got %v", err) + } +} + +type unknownSplitArgs struct { + S []string `flag:"s" split:"bogus"` +} + +func TestWalkArgs_UnknownSplitMode(t *testing.T) { + _, err := walkArgs(reflect.TypeOf(&unknownSplitArgs{})) + if err == nil || !strings.Contains(err.Error(), "unknown split mode") { + t.Fatalf("expected unknown split mode error, got %v", err) + } +} + +// --- per-flag hidden ------------------------------------------------------ + +type hiddenArgs struct { + Visible string `flag:"visible"` + Secret string `flag:"secret" hidden:"true"` +} + +func TestHiddenFlag_RegisteredButHidden(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{})) + _ = registerFlags(cmd, specs) + f := cmd.Flags().Lookup("secret") + if f == nil { + t.Fatal("secret flag not registered") + } + if !f.Hidden { + t.Error("secret flag should be marked hidden") + } + // hidden does not mean disabled — it still binds. + _ = cmd.ParseFlags([]string{"--secret", "shh", "--visible", "v"}) + out := &hiddenArgs{} + _ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs) + if out.Secret != "shh" { + t.Errorf("Secret = %q, want shh (hidden but functional)", out.Secret) + } +} + +func TestHiddenFlag_SkippedInHelp(t *testing.T) { + specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{})) + cmd := &cobra.Command{Use: "test"} + _ = registerFlags(cmd, specs) + var buf bytes.Buffer + cmd.SetOut(&buf) + buildTypedHelp(specs, nil)(cmd, nil) + out := buf.String() + if !strings.Contains(out, "--visible") { + t.Errorf("help should show --visible:\n%s", out) + } + if strings.Contains(out, "--secret") { + t.Errorf("help must NOT show hidden --secret:\n%s", out) + } +} + +// --- @file / stdin help hint ---------------------------------------------- + +func TestFormatLeafLine_InputHintBoth(t *testing.T) { + s := fieldSpec{FlagName: "content", Description: "the content", Input: []string{File, Stdin}} + line := formatLeafLine(" ", s) + if !strings.Contains(line, "(supports @file, - for stdin)") { + t.Errorf("missing input hint: %q", line) + } +} + +func TestFormatLeafLine_InputHintFileOnly(t *testing.T) { + s := fieldSpec{FlagName: "content", Description: "c", Input: []string{File}} + line := formatLeafLine(" ", s) + if !strings.Contains(line, "(supports @file)") || strings.Contains(line, "stdin") { + t.Errorf("file-only hint wrong: %q", line) + } +} diff --git a/shortcuts/common/typed_help.go b/shortcuts/common/typed_help.go index 222db2498..1fcbbd523 100644 --- a/shortcuts/common/typed_help.go +++ b/shortcuts/common/typed_help.go @@ -51,6 +51,18 @@ func formatLeafLine(indent string, s fieldSpec) string { if len(s.EnumValues) > 0 { line += fmt.Sprintf(" (one of: %s)", strings.Join(s.EnumValues, "|")) } + if len(s.Input) > 0 { + var srcs []string + for _, src := range s.Input { + switch src { + case File: + srcs = append(srcs, "@file") + case Stdin: + srcs = append(srcs, "- for stdin") + } + } + line += fmt.Sprintf(" (supports %s)", strings.Join(srcs, ", ")) + } if s.DefaultValue != "" { line += fmt.Sprintf(" (default %q)", s.DefaultValue) } @@ -89,7 +101,7 @@ func renderFlagsInBucket(w io.Writer, specs []fieldSpec, indent string, rendered renderFlagsInBucket(w, grand, indent+" ", rendered) continue } - if leaf.FlagName == "" { + if leaf.FlagName == "" || leaf.Hidden { continue } fmt.Fprintln(w, formatLeafLine(indent, leaf)) @@ -97,7 +109,7 @@ func renderFlagsInBucket(w io.Writer, specs []fieldSpec, indent string, rendered } continue } - if child.FlagName == "" { + if child.FlagName == "" || child.Hidden { continue } fmt.Fprintln(w, formatLeafLine(indent, child)) @@ -115,7 +127,7 @@ func renderRequiredSection(w io.Writer, specs []fieldSpec, rendered map[string]s if s.IsOneOfBkt || s.IsGroup { continue } - if s.FlagName == "" || !s.Required { + if s.FlagName == "" || !s.Required || s.Hidden { continue } if !anyFlag { @@ -140,7 +152,7 @@ func renderOptionalSection(w io.Writer, specs []fieldSpec, rendered map[string]s if s.IsOneOfBkt || s.IsGroup { continue } - if s.FlagName == "" || s.Required { + if s.FlagName == "" || s.Required || s.Hidden { continue } if !anyFlag {