mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
feat(shortcuts): typed []string flags, per-flag hidden, @file help hint
Close the last legacy/typed capability gaps so migration never downgrades: - []string fields: registerLeaf/bindLeaf gain a Slice case. Default maps to cobra StringSlice (comma-separated, repeatable); `split:"none"` opts into StringArray (repeatable, no comma split). Non-[]string slices and split on non-slice fields error at Mount time. bucketLeafValue handles slices in OneOf/group too. - per-flag hidden: `hidden:"true"` tag → fieldSpec.Hidden → MarkHidden; typed help skips hidden flags (parity with legacy Flag.Hidden). - @file/stdin discoverability: typed help now appends a (supports @file / - for stdin) hint for input-tagged flags, matching legacy. Adds binder_slice_hidden_test.go (11 cases).
This commit is contained in:
@@ -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)
|
||||
|
||||
192
shortcuts/common/binder_slice_hidden_test.go
Normal file
192
shortcuts/common/binder_slice_hidden_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user