diff --git a/shortcuts/common/binder.go b/shortcuts/common/binder.go index f365834ee..75d3e5014 100644 --- a/shortcuts/common/binder.go +++ b/shortcuts/common/binder.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" ) // fieldSpec is the binder's intermediate representation of one Args field. @@ -24,6 +25,7 @@ type fieldSpec struct { Description string DefaultValue string EnumValues []string + Input []string Required bool IsOneOfBkt bool IsGroup bool @@ -77,6 +79,18 @@ func parseFieldSpec(f reflect.StructField) (fieldSpec, error) { if _, has := f.Tag.Lookup("required"); has { spec.Required = true } + if input := f.Tag.Get("input"); input != "" { + for _, src := range strings.Split(input, ",") { + switch src = strings.TrimSpace(src); src { + case "": + // tolerate stray commas + case File, Stdin: + spec.Input = append(spec.Input, src) + default: + return spec, fmt.Errorf("field %s: unknown input source %q (allowed: %q, %q)", f.Name, src, File, Stdin) + } + } + } ft := f.Type spec.FieldType = ft if ft.Kind() == reflect.Ptr { @@ -92,6 +106,20 @@ func parseFieldSpec(f reflect.StructField) (fieldSpec, error) { } else { spec.IsGroup = true } + 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. + 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) + } + if len(spec.Input) > 0 { + return spec, fmt.Errorf("field %s: input tag is only supported on string fields", f.Name) + } } return spec, nil } @@ -141,6 +169,46 @@ func registerLeaf(cmd *cobra.Command, s fieldSpec, t reflect.Type) error { if s.Required { _ = cmd.MarkFlagRequired(s.FlagName) } + if len(s.EnumValues) > 0 { + vals := s.EnumValues + cmdutil.RegisterFlagCompletion(cmd, s.FlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return vals, cobra.ShellCompDirectiveNoFileComp + }) + } + return nil +} + +// resolveTypedInputs applies @file / stdin resolution to every leaf flag that +// declared an `input:"file,stdin"` tag, recursing into OneOf buckets and groups +// (cobra flags are flat, so a nested variant's flag resolves the same way). It +// runs before bindFlags so the file/stdin content is read back into the cobra +// flag and then bound into the Args struct. This is the typed counterpart of +// runShortcut's resolveInputFlags, which only sees the legacy shell's (empty) +// Flags slice — both ultimately call resolveInputForFlag. +func resolveTypedInputs(rctx *RuntimeContext, specs []fieldSpec) error { + stdinUsed := false + return resolveTypedInputsRec(rctx, specs, &stdinUsed) +} + +func resolveTypedInputsRec(rctx *RuntimeContext, specs []fieldSpec, stdinUsed *bool) error { + for _, s := range specs { + if s.IsOneOfBkt || s.IsGroup { + inner, err := walkArgs(reflect.PointerTo(s.StructType)) + if err != nil { + return err + } + if err := resolveTypedInputsRec(rctx, inner, stdinUsed); err != nil { + return err + } + continue + } + if s.FlagName == "" || len(s.Input) == 0 { + continue + } + if err := resolveInputForFlag(rctx, s.FlagName, s.Input, stdinUsed); err != nil { + return err + } + } return nil } diff --git a/shortcuts/common/binder_input_enum_test.go b/shortcuts/common/binder_input_enum_test.go new file mode 100644 index 000000000..a3b352ed0 --- /dev/null +++ b/shortcuts/common/binder_input_enum_test.go @@ -0,0 +1,237 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "os" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + _ "github.com/larksuite/cli/internal/vfs/localfileio" +) + +// newTypedInputRuntime registers the given specs on a fresh cobra command, +// parses argv, and returns a RuntimeContext wired with a fake stdin — the +// typed-binder analogue of newTestRuntimeWithStdin in runner_input_test.go. +func newTypedInputRuntime(t *testing.T, specs []fieldSpec, argv []string, stdin string) *RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "test"} + if err := registerFlags(cmd, specs); err != nil { + t.Fatalf("registerFlags: %v", err) + } + if err := cmd.ParseFlags(argv); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + return &RuntimeContext{ + Cmd: cmd, + Factory: &cmdutil.Factory{ + IOStreams: &cmdutil.IOStreams{In: strings.NewReader(stdin)}, + }, + } +} + +// --- @file / stdin on typed shortcuts ------------------------------------- + +type fileInputArgs struct { + Content string `flag:"content" input:"file,stdin"` +} + +func TestResolveTypedInputs_File(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + body := "## Title\n\nbody from a file\n" + if err := os.WriteFile("body.md", []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + specs, err := walkArgs(reflect.TypeOf(&fileInputArgs{})) + if err != nil { + t.Fatalf("walkArgs: %v", err) + } + rt := newTypedInputRuntime(t, specs, []string{"--content", "@body.md"}, "") + if err := resolveTypedInputs(rt, specs); err != nil { + t.Fatalf("resolveTypedInputs: %v", err) + } + out := &fileInputArgs{} + if err := bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil { + t.Fatalf("bindFlags: %v", err) + } + if out.Content != body { + t.Errorf("Content = %q, want file body %q", out.Content, body) + } +} + +func TestResolveTypedInputs_Stdin(t *testing.T) { + specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{})) + rt := newTypedInputRuntime(t, specs, []string{"--content", "-"}, "piped stdin body") + if err := resolveTypedInputs(rt, specs); err != nil { + t.Fatalf("resolveTypedInputs: %v", err) + } + out := &fileInputArgs{} + _ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs) + if out.Content != "piped stdin body" { + t.Errorf("Content = %q, want stdin body", out.Content) + } +} + +func TestResolveTypedInputs_PlainValueUnchanged(t *testing.T) { + specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{})) + rt := newTypedInputRuntime(t, specs, []string{"--content", "literal text"}, "") + if err := resolveTypedInputs(rt, specs); err != nil { + t.Fatalf("resolveTypedInputs: %v", err) + } + out := &fileInputArgs{} + _ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs) + if out.Content != "literal text" { + t.Errorf("Content = %q, want unchanged literal", out.Content) + } +} + +// A OneOf variant flag that declares @file/stdin must resolve too — the binder +// recurses into buckets because cobra flags are flat regardless of nesting. +type nestedInputVariant struct { + Body *string `flag:"body" input:"file,stdin"` + Raw *string `flag:"raw"` +} + +func (nestedInputVariant) OneOf() {} + +type nestedInputArgs struct { + Content nestedInputVariant +} + +func TestResolveTypedInputs_NestedInOneOf(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + body := "nested variant body\n" + if err := os.WriteFile("v.md", []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + specs, err := walkArgs(reflect.TypeOf(&nestedInputArgs{})) + if err != nil { + t.Fatalf("walkArgs: %v", err) + } + rt := newTypedInputRuntime(t, specs, []string{"--body", "@v.md"}, "") + if err := resolveTypedInputs(rt, specs); err != nil { + t.Fatalf("resolveTypedInputs: %v", err) + } + out := &nestedInputArgs{} + if err := bindBuckets(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil { + t.Fatalf("bindBuckets: %v", err) + } + if out.Content.Body == nil { + t.Fatal("Content.Body is nil — variant not bound") + } + if *out.Content.Body != body { + t.Errorf("Content.Body = %q, want file body %q", *out.Content.Body, body) + } +} + +// --- Mount-time validation: enum / input only on string leaves ------------ + +type enumOnIntArgs struct { + Level int `flag:"level" enum:"1,2,3"` +} + +func TestWalkArgs_EnumOnNonStringErrors(t *testing.T) { + _, err := walkArgs(reflect.TypeOf(&enumOnIntArgs{})) + if err == nil { + t.Fatal("expected error for enum on int field") + } + if !strings.Contains(err.Error(), "enum tag is only supported on string") { + t.Errorf("unexpected error: %v", err) + } +} + +type inputOnBoolArgs struct { + Flag bool `flag:"flag" input:"file"` +} + +func TestWalkArgs_InputOnNonStringErrors(t *testing.T) { + _, err := walkArgs(reflect.TypeOf(&inputOnBoolArgs{})) + if err == nil { + t.Fatal("expected error for input on bool field") + } + if !strings.Contains(err.Error(), "input tag is only supported on string") { + t.Errorf("unexpected error: %v", err) + } +} + +type unknownInputSrcArgs struct { + Content string `flag:"content" input:"bogus"` +} + +func TestWalkArgs_UnknownInputSource(t *testing.T) { + _, err := walkArgs(reflect.TypeOf(&unknownInputSrcArgs{})) + if err == nil { + t.Fatal("expected error for unknown input source") + } + if !strings.Contains(err.Error(), "unknown input source") { + t.Errorf("unexpected error: %v", err) + } +} + +// A string-alias enum field (e.g. an argstype primitive) must be accepted. +type enumOnStringArgs struct { + Priority string `flag:"priority" enum:"low,normal,high"` +} + +func TestWalkArgs_EnumOnStringOK(t *testing.T) { + specs, err := walkArgs(reflect.TypeOf(&enumOnStringArgs{})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(specs) != 1 || len(specs[0].EnumValues) != 3 { + t.Errorf("enum not parsed: %+v", specs) + } +} + +// --- enum shell completion + help candidate rendering --------------------- + +func TestRegisterLeaf_EnumCompletion(t *testing.T) { + prev := cmdutil.FlagCompletionsEnabled() + cmdutil.SetFlagCompletionsEnabled(true) + defer cmdutil.SetFlagCompletionsEnabled(prev) + + specs, _ := walkArgs(reflect.TypeOf(&enumOnStringArgs{})) + cmd := &cobra.Command{Use: "test"} + if err := registerFlags(cmd, specs); err != nil { + t.Fatalf("registerFlags: %v", err) + } + fn, ok := cmd.GetFlagCompletionFunc("priority") + if !ok || fn == nil { + t.Fatal("expected a completion func registered for --priority") + } + vals, _ := fn(cmd, nil, "") + want := map[string]bool{"low": true, "normal": true, "high": true} + if len(vals) != 3 { + t.Fatalf("completion candidates = %v, want low/normal/high", vals) + } + for _, v := range vals { + if !want[v] { + t.Errorf("unexpected completion candidate %q", v) + } + } +} + +func TestFormatLeafLine_EnumCandidates(t *testing.T) { + s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "normal", "high"}} + line := formatLeafLine(" ", s) + if !strings.Contains(line, "(one of: low|normal|high)") { + t.Errorf("help line missing enum candidates: %q", line) + } +} + +func TestFormatLeafLine_EnumAndDefault(t *testing.T) { + s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "high"}, DefaultValue: "low"} + line := formatLeafLine(" ", s) + if !strings.Contains(line, "(one of: low|high)") || !strings.Contains(line, `(default "low")`) { + t.Errorf("help line missing enum or default: %q", line) + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 8e4b198a5..962012c8f 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -855,56 +855,69 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error { stdinUsed := false for _, fl := range flags { - if len(fl.Input) == 0 { - continue + if err := resolveInputForFlag(rctx, fl.Name, fl.Input, &stdinUsed); err != nil { + return err } - raw, err := rctx.Cmd.Flags().GetString(fl.Name) + } + return nil +} + +// resolveInputForFlag applies @file / stdin / @@-escape resolution to a single +// string flag. sources lists the accepted inputs (File / Stdin); empty sources +// or an empty/plain flag value is a no-op. stdinUsed is shared across all flags +// of one invocation so stdin (-) is consumed by at most one flag. Both the +// legacy resolveInputFlags loop and the typed binder (resolveTypedInputs) call +// through here, so @file / stdin behaves identically on TypedShortcut[T]. +func resolveInputForFlag(rctx *RuntimeContext, name string, sources []string, stdinUsed *bool) error { + if len(sources) == 0 { + return nil + } + raw, err := rctx.Cmd.Flags().GetString(name) + if err != nil { + return FlagErrorf("--%s: Input is only supported for string flags", name) + } + if raw == "" { + return nil + } + + // stdin: - + if raw == "-" { + if !slices.Contains(sources, Stdin) { + return FlagErrorf("--%s does not support stdin (-)", name) + } + if *stdinUsed { + return FlagErrorf("--%s: stdin (-) can only be used by one flag", name) + } + *stdinUsed = true + data, err := io.ReadAll(rctx.IO().In) if err != nil { - return FlagErrorf("--%s: Input is only supported for string flags", fl.Name) - } - if raw == "" { - continue + return FlagErrorf("--%s: failed to read from stdin: %v", name, err) } + rctx.Cmd.Flags().Set(name, string(data)) + return nil + } - // stdin: - - if raw == "-" { - if !slices.Contains(fl.Input, Stdin) { - return FlagErrorf("--%s does not support stdin (-)", fl.Name) - } - if stdinUsed { - return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name) - } - stdinUsed = true - data, err := io.ReadAll(rctx.IO().In) - if err != nil { - return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err) - } - rctx.Cmd.Flags().Set(fl.Name, string(data)) - continue - } + // escape: @@ → literal @ + if strings.HasPrefix(raw, "@@") { + rctx.Cmd.Flags().Set(name, raw[1:]) // strip first @ + return nil + } - // escape: @@ → literal @ - if strings.HasPrefix(raw, "@@") { - rctx.Cmd.Flags().Set(fl.Name, raw[1:]) // strip first @ - continue + // file: @path + if strings.HasPrefix(raw, "@") { + if !slices.Contains(sources, File) { + return FlagErrorf("--%s does not support file input (@path)", name) } - - // file: @path - if strings.HasPrefix(raw, "@") { - if !slices.Contains(fl.Input, File) { - return FlagErrorf("--%s does not support file input (@path)", fl.Name) - } - path := strings.TrimSpace(raw[1:]) - if path == "" { - return FlagErrorf("--%s: file path cannot be empty after @", fl.Name) - } - data, err := cmdutil.ReadInputFile(rctx.FileIO(), path) - if err != nil { - return FlagErrorf("--%s: %v", fl.Name, err) - } - rctx.Cmd.Flags().Set(fl.Name, string(data)) - continue + path := strings.TrimSpace(raw[1:]) + if path == "" { + return FlagErrorf("--%s: file path cannot be empty after @", name) } + data, err := cmdutil.ReadInputFile(rctx.FileIO(), path) + if err != nil { + return FlagErrorf("--%s: %v", name, err) + } + rctx.Cmd.Flags().Set(name, string(data)) + return nil } return nil } diff --git a/shortcuts/common/typed_help.go b/shortcuts/common/typed_help.go index 8813e6a55..222db2498 100644 --- a/shortcuts/common/typed_help.go +++ b/shortcuts/common/typed_help.go @@ -48,6 +48,9 @@ func buildTypedHelp(specs []fieldSpec, examples []HelpExample) func(*cobra.Comma // same information density as cobra's legacy default help. func formatLeafLine(indent string, s fieldSpec) string { line := fmt.Sprintf("%s--%s %s", indent, s.FlagName, s.Description) + if len(s.EnumValues) > 0 { + line += fmt.Sprintf(" (one of: %s)", strings.Join(s.EnumValues, "|")) + } if s.DefaultValue != "" { line += fmt.Sprintf(" (default %q)", s.DefaultValue) } diff --git a/shortcuts/common/typed_shortcut.go b/shortcuts/common/typed_shortcut.go index dcfb52ce2..653d925d6 100644 --- a/shortcuts/common/typed_shortcut.go +++ b/shortcuts/common/typed_shortcut.go @@ -118,9 +118,12 @@ func (s TypedShortcut[T]) MountWithContext(ctx context.Context, parent *cobra.Co // // 1. identity / scopes / runtime — handled by Shortcut.runShortcut // 2. validateEnumFlags — Shortcut machinery -// 3. resolveInputFlags — @file / stdin +// 3. resolveInputFlags — @file / stdin (legacy shell only; the +// synthesized shell's Flags slice is empty, so this is a no-op for typed — +// typed flags declare inputs via the `input` tag, resolved in step 5) // 4. ValidateJqFlags — --jq -// 5. shell.Validate — binds T, runs Normalize / ValidateValue / +// 5. shell.Validate — resolveTypedInputs (@file / stdin for +// `input`-tagged flags), binds T, runs Normalize / ValidateValue / // framework rules / ArgsValidator / user-typed Validate // 6. --dry-run gate — shell.DryRun reads typed args from rt // 7. high-risk-write confirmation — when Risk == "high-risk-write" @@ -161,6 +164,12 @@ func mountTyped[T any](ctx context.Context, parent *cobra.Command, f *cmdutil.Fa } }, Validate: func(c context.Context, rt *RuntimeContext) error { + // @file / stdin resolution for flags that declared an `input` tag. + // Runs before bindFlags so the resolved file/stdin content is what + // gets bound into the Args struct. + if err := resolveTypedInputs(rt, specs); err != nil { + return err + } args := reflect.New(argsType.Elem()).Interface().(T) argsVal := reflect.ValueOf(args).Elem() if err := bindFlags(rt.Cmd, argsVal, specs); err != nil {