diff --git a/cmd/api/api.go b/cmd/api/api.go index 368fd6cfdb..ab544948ba 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -40,6 +40,7 @@ type APIOptions struct { PageLimit int PageDelay int Format string + JSON bool JqExpr string DryRun bool File string @@ -88,6 +89,11 @@ Examples: opts.Cmd = cmd opts.Ctx = cmd.Context() opts.As = core.Identity(asStr) + format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON) + if err != nil { + return err + } + opts.Format = format if runF != nil { return runF(opts) } @@ -103,8 +109,8 @@ Examples: cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)") cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") - cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") - cmd.Flags().Bool("json", false, "shorthand for --format json") + cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage()) + cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json") cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)") @@ -116,7 +122,7 @@ Examples: return nil, cobra.ShellCompDirectiveNoFileComp } cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp + return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp }) cmdutil.SetRisk(cmd, "write") @@ -263,10 +269,7 @@ func apiRun(opts *APIOptions) error { } out := f.IOStreams.Out - format, formatOK := output.ParseFormat(opts.Format) - if !formatOK { - fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) - } + format, _ := output.ParseFormat(opts.Format) if opts.PageAll { return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(), @@ -343,6 +346,24 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp } switch format { + case output.FormatPretty: + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return errs.MarkRaw(err) + } + if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil { + output.FormatValue(out, result, output.FormatPretty) + return errs.MarkRaw(apiErr) + } + scanResult := output.ScanForSafety(commandPath, result, errOut) + if scanResult.Blocked { + return errs.MarkRaw(scanResult.BlockErr) + } + if scanResult.Alert != nil { + output.WriteAlertWarning(errOut, scanResult.Alert) + } + output.FormatValue(out, result, output.FormatPretty) + return nil case output.FormatNDJSON, output.FormatTable, output.FormatCSV: pf := output.NewPaginatedFormatter(out, format) result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index ae0628f951..c5e0f3bd10 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -68,6 +68,42 @@ func TestApiCmd_FlagParsing(t *testing.T) { } } +func TestApiCmd_OutputFormatResolution(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "json shorthand", args: []string{"--json"}, want: "json"}, + {name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"}, + {name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + var gotOpts *APIOptions + cmd := newTestApiCmd(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + args := []string{"GET", "/open-apis/test", "--as", "bot"} + cmd.SetArgs(append(args, tt.args...)) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected options to be captured") + } + if gotOpts.Format != tt.want { + t.Fatalf("format = %q, want %q", gotOpts.Format, tt.want) + } + }) + } +} + func TestApiCmd_DryRun(t *testing.T) { f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -169,6 +205,43 @@ func TestApiCmd_BotMode(t *testing.T) { } } +func TestApiCmd_PrettyFormatsRealResponse(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"name": "Alice"}}, + }, + }, + }) + + cmd := newTestApiCmd(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out) + } + data, ok := got["data"].(map[string]interface{}) + if !ok { + t.Fatalf("pretty output data = %#v", got["data"]) + } + items, ok := data["items"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("pretty output data.items = %#v", data["items"]) + } + if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") { + t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out) + } +} + func TestApiCmd_MissingArgs(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -571,6 +644,46 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) { } } +func TestApiCmd_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/contact/v3/users", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}}, + "has_more": false, + }, + }, + }) + + cmd := newTestApiCmd(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "pretty"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out) + } + data, ok := got["data"].(map[string]interface{}) + if !ok { + t.Fatalf("page-all pretty output data = %#v", got["data"]) + } + items, ok := data["items"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("page-all pretty output data.items = %#v", data["items"]) + } + if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") { + t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out) + } +} + type apiContentSafetyProvider struct { called bool path string diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index f633a61433..33f5bb2535 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -355,7 +355,31 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) { } } -func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) { +func TestAuthScopesCmd_JSONShorthand(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *ScopesOptions + cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected options to be captured") + } + if !gotOpts.JSON || gotOpts.Format != "json" { + t.Fatalf("JSON = %v, format = %q; want true, json", gotOpts.JSON, gotOpts.Format) + } +} + +func TestAuthScopesCmd_ExplicitFormatWinsOverJSONShorthand(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) @@ -376,8 +400,8 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) { if !gotOpts.JSON { t.Error("expected JSON=true") } - if gotOpts.Format != "json" { - t.Errorf("expected format json, got %s", gotOpts.Format) + if gotOpts.Format != "pretty" { + t.Errorf("expected explicit format pretty, got %s", gotOpts.Format) } } diff --git a/cmd/auth/scopes.go b/cmd/auth/scopes.go index 91f290f980..4dc50efb1c 100644 --- a/cmd/auth/scopes.go +++ b/cmd/auth/scopes.go @@ -31,9 +31,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr Short: "Query scopes enabled for the app", RunE: func(cmd *cobra.Command, args []string) error { opts.Ctx = cmd.Context() - if opts.JSON { - opts.Format = "json" + format, err := output.JSONPrettyFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON) + if err != nil { + return err } + opts.Format = format if runF != nil { return runF(opts) } @@ -41,8 +43,11 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr }, } - cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") - cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") + cmd.Flags().StringVar(&opts.Format, "format", "json", output.JSONPrettyFormats.Usage()) + cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json") + cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return output.JSONPrettyFormats.Names(), cobra.ShellCompDirectiveNoFileComp + }) cmdutil.SetRisk(cmd, "read") return cmd @@ -75,10 +80,10 @@ func authScopesRun(opts *ScopesOptions) error { "failed to get app scope info: %v", err).WithCause(err) } if opts.Format == "pretty" { - fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID) - fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes)) + fmt.Fprintf(f.IOStreams.Out, "App ID: %s\n", config.AppID) + fmt.Fprintf(f.IOStreams.Out, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes)) for _, s := range appInfo.UserScopes { - fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s) + fmt.Fprintf(f.IOStreams.Out, " • %s\n", s) } } else { output.PrintJson(f.IOStreams.Out, map[string]interface{}{ diff --git a/cmd/auth/scopes_test.go b/cmd/auth/scopes_test.go index 9ab748d6ba..31efee5716 100644 --- a/cmd/auth/scopes_test.go +++ b/cmd/auth/scopes_test.go @@ -4,9 +4,11 @@ package auth import ( + "bytes" "context" "errors" "fmt" + "strings" "testing" "github.com/larksuite/cli/errs" @@ -26,6 +28,35 @@ func stubGetAppInfoErr(t *testing.T, errToReturn error) { t.Cleanup(func() { getAppInfoFn = prev }) } +func TestAuthScopesRun_PrettyWritesBulletedScopesToStdout(t *testing.T) { + prev := getAppInfoFn + getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) { + return &appInfo{UserScopes: []string{"im:message"}}, nil + } + t.Cleanup(func() { getAppInfoFn = prev }) + + opts := scopesTestFactory(t) + opts.Format = "pretty" + out, ok := opts.Factory.IOStreams.Out.(*bytes.Buffer) + if !ok { + t.Fatalf("stdout type = %T, want *bytes.Buffer", opts.Factory.IOStreams.Out) + } + errOut, ok := opts.Factory.IOStreams.ErrOut.(*bytes.Buffer) + if !ok { + t.Fatalf("stderr type = %T, want *bytes.Buffer", opts.Factory.IOStreams.ErrOut) + } + + if err := authScopesRun(opts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), " • im:message\n") { + t.Fatalf("stdout missing bulleted scope: %q", out.String()) + } + if strings.Contains(errOut.String(), "im:message") { + t.Fatalf("scope should remain on stdout, stderr = %q", errOut.String()) + } +} + // scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive // authScopesRun. Config has a non-empty AppID so we get past the config gate // and reach the getAppInfoFn call. diff --git a/cmd/build.go b/cmd/build.go index 55f89304c4..24ceba780d 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -234,6 +234,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B groupRootCommands(rootCmd) installUnknownSubcommandGuard(rootCmd) + installCobraValidationGuards(rootCmd) // Bare `lark-cli` in an interactive terminal offers an interactive upgrade // before printing help; non-bare invocations and non-TTY are unaffected. installRootUpgradePrompt(f, rootCmd) diff --git a/cmd/build_test.go b/cmd/build_test.go index a143a69194..20ddbe426a 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -4,11 +4,15 @@ package cmd import ( + "bytes" "context" + "errors" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) @@ -26,6 +30,85 @@ func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) { } } +func buildValidationTestRoot(t *testing.T) *cobra.Command { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + return Build(context.Background(), cmdutil.InvocationContext{}, + WithIO(strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}), + WithoutPlugins(), + WithoutServiceCommands(), + WithoutStrictMode(), + ) +} + +func TestBuiltRoot_TopLevelTypoReturnsStructuredSuggestion(t *testing.T) { + root := buildValidationTestRoot(t) + root.SetArgs([]string{"imm"}) + + err := root.Execute() + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "imm" { + t.Fatalf("params = %v, want one entry named imm", validationErr.Params) + } + found := false + for _, candidate := range validationErr.Params[0].Suggestions { + if candidate == "im" { + found = true + } + } + if !found { + t.Fatalf("suggestions = %v, want im", validationErr.Params[0].Suggestions) + } +} + +func TestBuiltRoot_SheetsOneRequiredGroupReturnsValidationExit(t *testing.T) { + root := buildValidationTestRoot(t) + root.SetArgs([]string{ + "sheets", "+csv-put", + "--url", "https://example.com/sheets/token", + "--sheet-name", "Sheet1", + "--csv", "a,b", + }) + + err := root.Execute() + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want %d", got, output.ExitValidation) + } +} + +func TestBuiltRoot_MailUnknownFlagUsesSharedSuggestions(t *testing.T) { + root := buildValidationTestRoot(t) + root.SetArgs([]string{"mail", "+send", "--tos", "alice@example.com"}) + + err := root.Execute() + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "--tos" { + t.Fatalf("params = %v, want one entry named --tos", validationErr.Params) + } + found := false + for _, candidate := range validationErr.Params[0].Suggestions { + if candidate == "--to" { + found = true + } + } + if !found { + t.Fatalf("suggestions = %v, want --to", validationErr.Params[0].Suggestions) + } +} + func findCommand(root *cobra.Command, path string) *cobra.Command { parts := strings.Fields(path) cmd := root diff --git a/cmd/flag_suggest_test.go b/cmd/flag_suggest_test.go index 08b113a8fc..6f63f5ba4c 100644 --- a/cmd/flag_suggest_test.go +++ b/cmd/flag_suggest_test.go @@ -9,28 +9,18 @@ import ( "testing" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/output" "github.com/spf13/cobra" ) -func TestUnknownFlagName(t *testing.T) { - cases := []struct { - in string - name string - ok bool - }{ - {"unknown flag: --query", "query", true}, - {"unknown flag: --with-styles", "with-styles", true}, - {"unknown shorthand flag: 'z' in -z", "", false}, - {"flag needs an argument: --find", "", false}, - {`invalid argument "x" for "--count"`, "", false}, - } - for _, c := range cases { - name, ok := unknownFlagName(errors.New(c.in)) - if name != c.name || ok != c.ok { - t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok) - } +func parseFlagError(t *testing.T, c *cobra.Command, args ...string) error { + t.Helper() + err := c.Flags().Parse(args) + if err == nil { + t.Fatalf("Parse(%v) returned nil", args) } + return err } func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) { @@ -39,7 +29,7 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) { c.Flags().String("find", "", "") c.Flags().Bool("dry-run", false, "") - err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range + err := flagDidYouMean(c, parseFlagError(t, c, "--rang")) // typo of --range var verr *errs.ValidationError if !errors.As(err, &verr) { t.Fatalf("expected *errs.ValidationError, got %T", err) @@ -82,23 +72,86 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) { func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) { c := &cobra.Command{Use: "demo"} - err := flagDidYouMean(c, errors.New("flag needs an argument: --find")) + c.Flags().String("find", "", "") + err := flagDidYouMean(c, parseFlagError(t, c, "--find")) var verr *errs.ValidationError if !errors.As(err, &verr) { t.Fatalf("expected *errs.ValidationError, got %T", err) } - // Non-unknown-flag errors stay generic: invalid_argument subtype, no - // structured param, generic --help hint (no "did you mean" suggestion). + // Non-unknown-flag errors retain the same validation shape and identify the + // flag from pflag's typed ValueRequiredError. if verr.Subtype != errs.SubtypeInvalidArgument { t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype) } if code := output.ExitCodeOf(err); code != output.ExitValidation { t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) } - if verr.Param != "" || len(verr.Params) != 0 { - t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params) + if len(verr.Params) != 1 || verr.Params[0].Name != "--find" { + t.Errorf("Params=%v, want one entry named --find", verr.Params) } if strings.Contains(verr.Hint, "did you mean") { t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint) } } + +func TestFlagDidYouMean_SheetsListsVisibleFlags(t *testing.T) { + root := &cobra.Command{Use: "root"} + sheets := &cobra.Command{Use: "sheets"} + cmdmeta.SetDomain(sheets, "sheets") + root.AddCommand(sheets) + sheets.Flags().String("range", "", "") + sheets.Flags().Int("width", 0, "") + + err := flagDidYouMean(sheets, parseFlagError(t, sheets, "--cols")) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + for _, want := range []string{"--range", "--width"} { + if !strings.Contains(validationErr.Hint, want) { + t.Errorf("hint should include %q, got %q", want, validationErr.Hint) + } + } +} + +func TestFlagDidYouMean_InvalidValueTypedError(t *testing.T) { + c := &cobra.Command{Use: "demo"} + c.Flags().Int("width", 0, "") + + // A non-numeric value for a typed flag surfaces pflag's InvalidValueError. + err := flagDidYouMean(c, parseFlagError(t, c, "--width=abc")) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", verr.Subtype) + } + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if len(verr.Params) != 1 || verr.Params[0].Name != "--width" || verr.Params[0].Reason != "invalid flag value" { + t.Errorf("Params = %v, want one --width entry with reason 'invalid flag value'", verr.Params) + } +} + +func TestFlagDidYouMean_InvalidSyntaxTypedError(t *testing.T) { + c := &cobra.Command{Use: "demo"} + c.Flags().String("range", "", "") + + // An empty flag name is bad flag syntax and surfaces pflag's InvalidSyntaxError. + err := flagDidYouMean(c, parseFlagError(t, c, "--=oops")) + var verr *errs.ValidationError + if !errors.As(err, &verr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if verr.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", verr.Subtype) + } + if code := output.ExitCodeOf(err); code != output.ExitValidation { + t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation) + } + if len(verr.Params) != 1 || verr.Params[0].Reason != "invalid flag syntax" { + t.Errorf("Params = %v, want one entry with reason 'invalid flag syntax'", verr.Params) + } +} diff --git a/cmd/root.go b/cmd/root.go index 7e8244fd35..305f3f3e68 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -241,10 +241,10 @@ func configureFlagCompletions(args []string) { // dispatcher no longer promotes any legacy shape here. // 2. PartialFailure / BareError signals: the result envelope is already on // stdout; honor the exit code and write nothing to stderr. -// 3. Residual cobra usage errors (missing required flag, unknown command, -// argument validation): typed as an invalid_argument envelope (exit 2), -// matching the explicit flag/subcommand guards. Flag parse errors are -// already typed upstream by the root FlagErrorFunc. +// 3. Any untyped error that reaches this boundary is an internal fault. +// Cobra argument, required-flag and flag-group errors are typed at their +// execution stages by installCobraValidationGuards; flag parse errors are +// typed by the root FlagErrorFunc. func handleRootError(f *cmdutil.Factory, err error) int { errOut := f.IOStreams.ErrOut @@ -283,57 +283,14 @@ func handleRootError(f *cmdutil.Factory, err error) int { return bareErr.Code } - // Errors reaching here are untyped: every RunE returns a typed errs.* error - // and flag-parse errors are typed by the root FlagErrorFunc. The remainder - // is either a cobra usage mistake (missing required flag, unknown command, - // wrong arg count), which cobra surfaces as a plain error identified by its - // stable text — the same external contract unknownFlagName relies on — or an - // untyped error that leaked past the typed boundary. Classify the former as - // invalid_argument (exit 2, like the explicit guards); treat the latter as an - // internal fault (exit 5) rather than blaming the user's input. The message - // is preserved either way, and the typed envelope still carries any pending - // deprecation notice. - var fallback error - if isCobraUsageError(err) { - fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()) - } else { - fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err) - } + // Every user-input stage is typed before execution. A bare error here has + // crossed that boundary unexpectedly and must remain visible as an internal + // fault instead of being guessed from English message fragments. + fallback := errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err) output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity)) return output.ExitCodeOf(fallback) } -// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag -// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown -// command / flag, wrong argument count. Cobra surfaces these as plain errors, -// not a typed value we can match on, so the dispatcher recognizes them by text; -// this is the same external contract unknownFlagName already depends on. A -// residual error matching none of these has leaked the typed boundary and is -// treated as an internal fault, not a user error. -var cobraUsageErrorMarkers = []string{ - "unknown command ", - "unknown flag: ", - "unknown shorthand", - "required flag(s) ", - "flag needs an argument", - "bad flag syntax:", - "no such flag ", - "invalid argument ", - "arg(s), ", // accepts / requires N arg(s), received / only received M -} - -// isCobraUsageError reports whether err is a cobra / pflag usage mistake, -// identified by the stable error text of the pinned cobra version. -func isCobraUsageError(err error) bool { - msg := err.Error() - for _, m := range cobraUsageErrorMarkers { - if strings.Contains(msg, m) { - return true - } - } - return false -} - // installUnknownSubcommandGuard replaces cobra's silent help fallback on // group commands (no Run/RunE) with an unknown_subcommand error. // @@ -345,6 +302,10 @@ func isCobraUsageError(err error) bool { // with reason_code=risk_not_annotated. func installUnknownSubcommandGuard(cmd *cobra.Command) { if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { + // Cobra's legacy Args fallback rejects an unknown top-level token before + // RunE can produce ranked suggestions. Explicitly accepting positional + // tokens lets every pure group, including root, reach the shared guard. + cmd.Args = cobra.ArbitraryArgs cmd.RunE = unknownSubcommandRunE // Route an unknown subcommand to unknownSubcommandRunE even when flags // are also present (e.g. `sheets +cells-find --url ...`). A pure group @@ -362,6 +323,132 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) { } } +// installCobraValidationGuards types errors at the stage where Cobra knows +// they are user input: positional argument validation, required flags and flag +// groups. This removes the need for final-boundary message matching. +func installCobraValidationGuards(cmd *cobra.Command) { + if cmd == nil { + return + } + if validateArgs := cmd.Args; validateArgs != nil { + cmd.Args = func(c *cobra.Command, args []string) error { + err := validateArgs(c, args) + if err == nil || errs.IsTyped(err) { + return err + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err) + } + } + + previousPreRunE := cmd.PreRunE + previousPreRun := cmd.PreRun + cmd.PreRunE = func(c *cobra.Command, args []string) error { + if previousPreRunE != nil { + if err := previousPreRunE(c, args); err != nil { + return err + } + } else if previousPreRun != nil { + previousPreRun(c, args) + } + if err := c.ValidateRequiredFlags(); err != nil { + validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err) + c.Flags().VisitAll(func(flag *pflag.Flag) { + if flag.Changed || len(flag.Annotations[cobra.BashCompOneRequiredFlag]) == 0 { + return + } + validationErr.WithParams(errs.InvalidParam{Name: "--" + flag.Name, Reason: "required flag is missing"}) + }) + return validationErr.WithHint("run `%s --help` to see required flags", c.CommandPath()) + } + if err := c.ValidateFlagGroups(); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()). + WithParams(invalidFlagGroupParams(err.Error())...). + WithHint("run `%s --help` to see valid flag combinations", c.CommandPath()). + WithCause(err) + } + return nil + } + cmd.PreRun = nil + + for _, child := range cmd.Commands() { + installCobraValidationGuards(child) + } +} + +type flagGroupConstraint int + +const ( + flagGroupRequiredTogether flagGroupConstraint = iota + flagGroupOneRequired + flagGroupMutuallyExclusive +) + +// invalidFlagGroupParams extracts the offending flag group from Cobra's +// flag-group validation error. The message always names the group as +// "[flag-a flag-b ...]" and its wording identifies the constraint, so both are +// read straight from the message instead of re-deriving Cobra's private group +// state. Pinned to cobra v1.10.2's message format (see go.mod). +func invalidFlagGroupParams(message string) []errs.InvalidParam { + names := flagGroupNamesFromMessage(message) + if len(names) == 0 { + return nil + } + return buildFlagGroupParams(names, flagGroupConstraintFromMessage(message)) +} + +func buildFlagGroupParams(names []string, constraint flagGroupConstraint) []errs.InvalidParam { + flagNames := make([]string, 0, len(names)) + for _, name := range names { + name = strings.TrimLeft(name, "-") + if name != "" { + flagNames = append(flagNames, "--"+name) + } + } + if len(flagNames) == 0 { + return nil + } + group := "[" + strings.Join(flagNames, " ") + "]" + reason := "invalid flag combination in " + group + switch constraint { + case flagGroupRequiredTogether: + reason = "all of " + group + " required together" + case flagGroupOneRequired: + reason = "one of " + group + " required" + case flagGroupMutuallyExclusive: + reason = "only one of " + group + " allowed" + } + params := make([]errs.InvalidParam, 0, len(flagNames)) + for _, name := range flagNames { + params = append(params, errs.InvalidParam{Name: name, Reason: reason}) + } + return params +} + +func flagGroupNamesFromMessage(message string) []string { + start := strings.IndexByte(message, '[') + if start < 0 { + return nil + } + end := strings.IndexByte(message[start+1:], ']') + if end < 0 { + return nil + } + return strings.Fields(message[start+1 : start+1+end]) +} + +func flagGroupConstraintFromMessage(message string) flagGroupConstraint { + switch { + case strings.Contains(message, "at least one of the flags"): + return flagGroupOneRequired + case strings.Contains(message, "must all be set"): + return flagGroupRequiredTogether + case strings.Contains(message, "none of the others can be"): + return flagGroupMutuallyExclusive + default: + return flagGroupConstraint(-1) + } +} + // unknownSubcommandRunE replaces cobra's silent help fallback on group commands // with a typed *errs.ValidationError: a flag that belongs to a missing // subcommand, a misplaced subcommand-only flag, or an unknown subcommand name @@ -592,17 +679,21 @@ func isLarkDomain(c *cobra.Command) bool { return cmdmeta.Domain(c) != "" } -// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It -// converts cobra's flag-parse errors into a typed validation envelope: an -// unknown flag gets a focused "did you mean" hint (so agents recover even when -// the typo is semantic, e.g. --query vs --find, where edit distance alone finds -// nothing) and the offending flag in `params`. Other flag errors stay typed -// but generic. +// flagDidYouMean is the single FlagErrorFunc inherited by all commands. It +// classifies pflag's typed parse errors and emits one stable validation shape. func flagDidYouMean(c *cobra.Command, ferr error) error { - name, isUnknown := unknownFlagName(ferr) - if !isUnknown { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()). - WithHint("run `%s --help` for valid flags", c.CommandPath()) + if ferr == nil { + return nil + } + var notExist *pflag.NotExistError + if !errors.As(ferr, ¬Exist) { + return typedFlagParseError(c, ferr) + } + + name := notExist.GetSpecifiedName() + rawName := "--" + name + if notExist.GetSpecifiedShortnames() != "" { + rawName = "-" + name } valid := visibleFlagNames(c) suggestions := suggest.Closest(name, valid, 3) @@ -614,36 +705,69 @@ func flagDidYouMean(c *cobra.Command, ferr error) error { hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)", strings.Join(suggestions, ", "), c.CommandPath()) } - // The ranked candidates ride on the param as machine-readable Suggestions so - // an agent can retry without parsing the hint; the hint carries the same - // candidates as prose. The full valid-flag list stays recoverable via --help. + if cmdmeta.Domain(c) == "sheets" { + if list := inlineVisibleFlags(valid); list != "" { + if len(suggestions) > 0 { + hint = fmt.Sprintf("did you mean %s? valid flags: %s", strings.Join(suggestions, ", "), list) + } else { + hint = "valid flags: " + list + } + } + } return errs.NewValidationError(errs.SubtypeInvalidArgument, - "unknown flag %q for %q", "--"+name, c.CommandPath()). - WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}). - WithHint("%s", hint) + "unknown flag %q for %q", rawName, c.CommandPath()). + WithParams(errs.InvalidParam{Name: rawName, Reason: "unknown flag", Suggestions: suggestions}). + WithHint("%s", hint). + WithCause(ferr) } -// unknownFlagName extracts the offending long-flag name from cobra's flag-parse -// error text ("unknown flag: --query" → "query"). Returns ok=false for anything -// else (missing argument, invalid value, unknown shorthand) so the caller keeps -// those structured but generic — hallucinated flags are essentially always long. -// -// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod -// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match -// silently fails and unknown flags degrade to a generic flag_error — re-verify -// this prefix when bumping cobra. -func unknownFlagName(err error) (string, bool) { - const p = "unknown flag: --" - msg := err.Error() - i := strings.Index(msg, p) - if i < 0 { - return "", false - } - rest := msg[i+len(p):] - if j := strings.IndexAny(rest, " \t"); j >= 0 { - rest = rest[:j] - } - return rest, true +func typedFlagParseError(c *cobra.Command, ferr error) error { + param := "" + reason := "flag parse error" + var valueRequired *pflag.ValueRequiredError + var invalidValue *pflag.InvalidValueError + var invalidSyntax *pflag.InvalidSyntaxError + switch { + case errors.As(ferr, &valueRequired): + param = "--" + valueRequired.GetSpecifiedName() + if valueRequired.GetSpecifiedShortnames() != "" { + param = "-" + valueRequired.GetSpecifiedName() + } + reason = "flag value is required" + case errors.As(ferr, &invalidValue): + if invalidValue.GetFlag() != nil { + param = "--" + invalidValue.GetFlag().Name + } + reason = "invalid flag value" + case errors.As(ferr, &invalidSyntax): + param = invalidSyntax.GetSpecifiedFlag() + reason = "invalid flag syntax" + } + validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()). + WithHint("run `%s --help` for valid flags", c.CommandPath()). + WithCause(ferr) + if param != "" { + validationErr.WithParams(errs.InvalidParam{Name: param, Reason: reason}) + } + return validationErr +} + +func inlineVisibleFlags(names []string) string { + const limit = 25 + if len(names) == 0 { + return "" + } + shown := names + suffix := "" + if len(shown) > limit { + shown = shown[:limit] + suffix = fmt.Sprintf(", ... (%d more; see --help)", len(names)-limit) + } + flags := make([]string, len(shown)) + for i, name := range shown { + flags[i] = "--" + name + } + return strings.Join(flags, ", ") + suffix } // visibleFlagNames lists the non-hidden flag names of c (for suggestions and diff --git a/cmd/root_test.go b/cmd/root_test.go index e7f3f5e738..87db014c5b 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -6,6 +6,7 @@ package cmd import ( "bytes" "encoding/json" + "errors" "fmt" "io" "strings" @@ -284,9 +285,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) { deprecation.SetPending(&deprecation.Notice{ Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets", }) - // The bare error shape cobra's ValidateRequiredFlags produces: not a typed - // errs.* error, so it reaches the deprecation fallback. - exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) + err := errs.NewValidationError(errs.SubtypeInvalidArgument, `required flag(s) %q not set`, "values"). + WithParam("--values") + exit := handleRootError(f, err) out := errOut.String() if strings.HasPrefix(strings.TrimSpace(out), "Error:") { @@ -381,10 +382,9 @@ func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any { return errObj } -// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra -// usage error (missing required flag) is typed as invalid_argument with exit 2 -// even with no deprecation pending — never cobra's plain "Error:" line. -func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) { +// TestCobraValidationGuardTypesRequiredFlag pins that required-flag errors are +// typed at the Cobra validation stage, before the final dispatcher. +func TestCobraValidationGuardTypesRequiredFlag(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Cleanup(func() { deprecation.SetPending(nil) }) deprecation.SetPending(nil) @@ -393,7 +393,15 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) { errOut := &bytes.Buffer{} f.IOStreams.ErrOut = errOut - exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values")) + cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }} + cmd.Flags().String("values", "", "") + cmd.MarkFlagRequired("values") + installCobraValidationGuards(cmd) + err := cmd.Execute() + if err == nil { + t.Fatal("expected missing required flag error") + } + exit := handleRootError(f, err) out := errOut.String() if strings.HasPrefix(strings.TrimSpace(out), "Error:") { @@ -411,6 +419,93 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) { } } +func TestCobraValidationGuardTypesFlagGroupErrorsWithParams(t *testing.T) { + tests := []struct { + name string + mark func(*cobra.Command) + args []string + wantNames []string + wantReason string + }{ + { + name: "one required", + mark: func(cmd *cobra.Command) { + cmd.MarkFlagsOneRequired("start-cell", "range") + }, + wantNames: []string{"--start-cell", "--range"}, + wantReason: "one of [--start-cell --range] required", + }, + { + name: "required together", + mark: func(cmd *cobra.Command) { + cmd.MarkFlagsRequiredTogether("start-cell", "range") + }, + args: []string{"--start-cell", "A1"}, + wantNames: []string{"--start-cell", "--range"}, + wantReason: "all of [--start-cell --range] required together", + }, + { + name: "mutually exclusive", + mark: func(cmd *cobra.Command) { + cmd.MarkFlagsMutuallyExclusive("start-cell", "range") + }, + args: []string{"--start-cell", "A1", "--range", "B2"}, + wantNames: []string{"--start-cell", "--range"}, + wantReason: "only one of [--start-cell --range] allowed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "demo", RunE: func(*cobra.Command, []string) error { return nil }} + cmd.Flags().String("start-cell", "", "") + cmd.Flags().String("range", "", "") + tt.mark(cmd) + installCobraValidationGuards(cmd) + cmd.SetArgs(tt.args) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected flag group error") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype) + } + if got := output.ExitCodeOf(err); got != output.ExitValidation { + t.Fatalf("exit code = %d, want %d", got, output.ExitValidation) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if len(validationErr.Params) != len(tt.wantNames) { + t.Fatalf("params = %v, want %d entries", validationErr.Params, len(tt.wantNames)) + } + for i, wantName := range tt.wantNames { + if validationErr.Params[i].Name != wantName || validationErr.Params[i].Reason != tt.wantReason { + t.Errorf("params[%d] = %+v, want name=%q reason=%q", i, validationErr.Params[i], wantName, tt.wantReason) + } + } + }) + } +} + +func TestInvalidFlagGroupParamsFromMessage(t *testing.T) { + got := invalidFlagGroupParams("at least one of the flags in the group [start-cell range] is required") + if len(got) != 2 { + t.Fatalf("params = %v, want two entries", got) + } + for i, want := range []string{"--start-cell", "--range"} { + if got[i].Name != want || got[i].Reason != "one of [--start-cell --range] required" { + t.Errorf("params[%d] = %+v", i, got[i]) + } + } +} + // TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped // error that does NOT match a cobra usage shape (i.e. one that leaked past the // typed boundary from a helper) is classified as an internal fault (exit 5), diff --git a/cmd/service/service.go b/cmd/service/service.go index f08a252495..c4bd6c80de 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -140,6 +140,7 @@ type ServiceMethodOptions struct { PageLimit int PageDelay int Format string + JSON bool JqExpr string DryRun bool File string // --file flag value @@ -268,6 +269,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm opts.Cmd = cmd opts.Ctx = cmd.Context() opts.As = core.Identity(asStr) + format, err := output.StandardFormats.Resolve(opts.Format, cmd.Flags().Changed("format"), opts.JSON) + if err != nil { + return err + } + opts.Format = format if runF != nil { return runF(opts) } @@ -299,8 +305,8 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm _ = cmd.Flags().MarkHidden(name) } } - cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") - cmd.Flags().Bool("json", false, "shorthand for --format json") + cmd.Flags().StringVar(&opts.Format, "format", "json", output.StandardFormats.Usage()) + cmd.Flags().BoolVar(&opts.JSON, "json", false, "shorthand for --format json") cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") if spec.risk == cmdutil.RiskHighRiskWrite { @@ -311,7 +317,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.") } cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp + return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp }) // Registered last so the collision guard sees the standard flags above. @@ -420,10 +426,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { } out := f.IOStreams.Out - format, formatOK := output.ParseFormat(opts.Format) - if !formatOK { - fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) - } + format, _ := output.ParseFormat(opts.Format) // Scope-insufficient (99991679) and all other Lark API codes route through // errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError @@ -706,6 +709,24 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R } switch format { + case output.FormatPretty: + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return err + } + if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil { + output.FormatValue(out, result, output.FormatPretty) + return apiErr + } + scanResult := output.ScanForSafety(commandPath, result, errOut) + if scanResult.Blocked { + return scanResult.BlockErr + } + if scanResult.Alert != nil { + output.WriteAlertWarning(errOut, scanResult.Alert) + } + output.FormatValue(out, result, output.FormatPretty) + return nil case output.FormatNDJSON, output.FormatTable, output.FormatCSV: pf := output.NewPaginatedFormatter(out, format) result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error { diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 79df1e6c5a..09caab07a0 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -201,6 +201,42 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) { } } +func TestServiceMethod_OutputFormatResolution(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "json shorthand", args: []string{"--json"}, want: "json"}, + {name: "explicit format wins", args: []string{"--format", "table", "--json"}, want: "table"}, + {name: "pretty format", args: []string{"--format", "PRETTY"}, want: "pretty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + var captured *ServiceMethodOptions + cmd := NewCmdServiceMethod(f, driveSpec(), + meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", + func(opts *ServiceMethodOptions) error { + captured = opts + return nil + }) + args := []string{"--as", "bot"} + cmd.SetArgs(append(args, tt.args...)) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured == nil { + t.Fatal("expected options to be captured") + } + if captured.Format != tt.want { + t.Fatalf("format = %q, want %q", captured.Format, tt.want) + } + }) + } +} + // ── dry-run / buildServiceRequest ── func TestServiceMethod_DryRun_PathParam(t *testing.T) { @@ -462,6 +498,43 @@ func TestServiceMethod_BotMode_Success(t *testing.T) { } } +func TestServiceMethod_PrettyFormatsRealResponse(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, testConfig) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"name": "Alice"}}, + }, + }, + }) + + spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}) + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("pretty output should be valid JSON: %v\n%s", err, out) + } + data, ok := got["data"].(map[string]interface{}) + if !ok { + t.Fatalf("pretty output data = %#v", got["data"]) + } + items, ok := data["items"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("pretty output data.items = %#v", data["items"]) + } + if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") { + t.Fatalf("pretty output should be indented JSON rather than a table, got:\n%s", out) + } +} + func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu, @@ -503,6 +576,48 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) { } } +func TestServiceMethod_PageAll_PrettyAggregatesIndentedJSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-service-pageall-pretty", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}}, + "has_more": false, + }, + }, + }) + + spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}) + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "pretty"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + var got map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("page-all pretty output should be valid JSON: %v\n%s", err, out) + } + data, ok := got["data"].(map[string]interface{}) + if !ok { + t.Fatalf("page-all pretty output data = %#v", got["data"]) + } + items, ok := data["items"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("page-all pretty output data.items = %#v", data["items"]) + } + if !strings.Contains(out, "\n \"data\": {") || strings.Contains(out, "─") { + t.Fatalf("page-all pretty output should be aggregated indented JSON, got:\n%s", out) + } +} + type serviceContentSafetyProvider struct { called bool path string @@ -795,26 +910,23 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) } } -func TestServiceMethod_UnknownFormat_Warning(t *testing.T) { - f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ +func TestServiceMethod_UnknownFormat_ReturnsValidationError(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu, }) - reg.Register(&httpmock.Stub{ - URL: "/open-apis/svc/v1/items", - Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, - }) - spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}) method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) + err := cmd.Execute() + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) } - if !strings.Contains(stderr.String(), "warning: unknown format") { - t.Errorf("expected format warning in stderr, got:\n%s", stderr.String()) + if validationErr.Param != "--format" { + t.Errorf("param = %q, want --format", validationErr.Param) } } diff --git a/cmd/unknown_subcommand_test.go b/cmd/unknown_subcommand_test.go index 8a33e04b41..835d695dc3 100644 --- a/cmd/unknown_subcommand_test.go +++ b/cmd/unknown_subcommand_test.go @@ -45,11 +45,38 @@ func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) { if files.RunE == nil { t.Error("files should have RunE installed") } + if root.Args == nil { + t.Error("root should explicitly accept positional tokens so unknown commands reach RunE") + } if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil { t.Errorf("leaf +search RunE should be untouched, got error %v", err) } } +func TestTopLevelUnknownCommandReturnsStructuredSuggestion(t *testing.T) { + root, _, _ := newGroupTree() + installUnknownSubcommandGuard(root) + root.SetArgs([]string{"driv"}) + + err := root.Execute() + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if len(validationErr.Params) != 1 || validationErr.Params[0].Name != "driv" { + t.Fatalf("params = %v, want one entry named driv", validationErr.Params) + } + found := false + for _, candidate := range validationErr.Params[0].Suggestions { + if candidate == "drive" { + found = true + } + } + if !found { + t.Fatalf("suggestions = %v, want drive", validationErr.Params[0].Suggestions) + } +} + func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) { root := &cobra.Command{Use: "lark-cli"} called := false diff --git a/go.mod b/go.mod index 26497138b2..a6bc947cbf 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/sergi/go-diff v1.4.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 - github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName + github.com/spf13/cobra v1.10.2 // typed flag errors are classified in cmd/root.go github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 diff --git a/internal/output/capabilities.go b/internal/output/capabilities.go new file mode 100644 index 0000000000..b91b8439c7 --- /dev/null +++ b/internal/output/capabilities.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "slices" + "strings" + + "github.com/larksuite/cli/errs" +) + +// FormatCapabilities is the single description of the output formats a +// command supports. Help text, shell completion, shorthand normalization and +// runtime validation all consume the same value so they cannot drift apart. +type FormatCapabilities struct { + names []string +} + +var ( + // StandardFormats applies to API, service and ordinary shortcut commands. + StandardFormats = NewFormatCapabilities("json", "pretty", "table", "ndjson", "csv") + // JSONPrettyFormats applies to commands with a dedicated human renderer and + // no streaming/tabular output, such as auth scopes. + JSONPrettyFormats = NewFormatCapabilities("json", "pretty") +) + +// NewFormatCapabilities constructs an immutable format capability set. +func NewFormatCapabilities(names ...string) FormatCapabilities { + return FormatCapabilities{names: append([]string(nil), names...)} +} + +// Names returns a copy suitable for completion candidates. +func (c FormatCapabilities) Names() []string { + return append([]string(nil), c.names...) +} + +// Usage returns the canonical help text for a --format flag. +func (c FormatCapabilities) Usage() string { + return "output format: " + strings.Join(c.names, "|") +} + +// Supports reports whether name is part of this command's output contract. +func (c FormatCapabilities) Supports(name string) bool { + return slices.Contains(c.names, strings.ToLower(name)) +} + +// Resolve applies the --json shorthand and validates the selected format. +// An explicit --format always wins over --json. +func (c FormatCapabilities) Resolve(format string, formatExplicit, jsonShorthand bool) (string, error) { + if jsonShorthand && !formatExplicit { + format = "json" + } + format = strings.ToLower(format) + if c.Supports(format) { + return format, nil + } + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "unsupported output format %q; supported formats: %s", format, strings.Join(c.names, ", ")). + WithParam("--format") +} diff --git a/internal/output/capabilities_test.go b/internal/output/capabilities_test.go new file mode 100644 index 0000000000..6e24e98f73 --- /dev/null +++ b/internal/output/capabilities_test.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "errors" + "reflect" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestFormatCapabilitiesResolve(t *testing.T) { + tests := []struct { + name string + format string + formatExplicit bool + jsonShorthand bool + want string + wantErr bool + }{ + {name: "default", format: "json", want: "json"}, + {name: "json shorthand", format: "table", jsonShorthand: true, want: "json"}, + {name: "explicit format wins", format: "table", formatExplicit: true, jsonShorthand: true, want: "table"}, + {name: "case normalized", format: "PRETTY", formatExplicit: true, want: "pretty"}, + {name: "unsupported", format: "xml", formatExplicit: true, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := StandardFormats.Resolve(tt.format, tt.formatExplicit, tt.jsonShorthand) + if tt.wantErr { + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" { + t.Fatalf("Resolve() error = %T %v, want invalid_argument --format validation error", err, err) + } + return + } + if err != nil || got != tt.want { + t.Fatalf("Resolve() = %q, %v; want %q, nil", got, err, tt.want) + } + }) + } +} + +func TestFormatCapabilitiesDriveHelpAndCompletion(t *testing.T) { + if got, want := StandardFormats.Usage(), "output format: json|pretty|table|ndjson|csv"; got != want { + t.Fatalf("Usage() = %q, want %q", got, want) + } + if got, want := StandardFormats.Names(), []string{"json", "pretty", "table", "ndjson", "csv"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Names() = %v, want %v", got, want) + } +} diff --git a/internal/output/format.go b/internal/output/format.go index 7bb4088f7f..db30ffdf1e 100644 --- a/internal/output/format.go +++ b/internal/output/format.go @@ -103,6 +103,9 @@ func ExtractItems(data interface{}) []interface{} { func FormatValue(w io.Writer, data interface{}, format Format) { data = toGeneric(data) switch format { + case FormatPretty: + PrintJson(w, data) + case FormatNDJSON: items := ExtractItems(data) if items != nil { @@ -149,6 +152,9 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter { // FormatPage formats one page of items. func (pf *PaginatedFormatter) FormatPage(data interface{}) { switch pf.Format { + case FormatPretty: + PrintJson(pf.W, data) + case FormatJSON, FormatNDJSON: if arr, ok := data.([]interface{}); ok { PrintNdjson(pf.W, arr) diff --git a/internal/output/format_test.go b/internal/output/format_test.go index f86034560f..3e1b7f1a64 100644 --- a/internal/output/format_test.go +++ b/internal/output/format_test.go @@ -73,6 +73,30 @@ func TestFormatValue_Table(t *testing.T) { } } +func TestFormatValue_Pretty(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + }, + }, + } + + var buf bytes.Buffer + FormatValue(&buf, data, FormatPretty) + out := buf.String() + + if !json.Valid([]byte(out)) { + t.Fatalf("pretty output should be valid JSON, got:\n%s", out) + } + if !strings.Contains(out, "\n \"data\": {") || !strings.Contains(out, `"name": "Alice"`) { + t.Fatalf("pretty output should be indented JSON, got:\n%s", out) + } + if strings.Contains(out, "─") { + t.Fatalf("pretty output should not render a table, got:\n%s", out) + } +} + func TestFormatValue_CSV(t *testing.T) { data := map[string]interface{}{ "data": map[string]interface{}{ @@ -149,6 +173,20 @@ func TestPaginatedFormatter_Table(t *testing.T) { } } +func TestPaginatedFormatter_Pretty(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatPretty) + + pf.FormatPage([]interface{}{map[string]interface{}{"name": "Alice"}}) + out := buf.String() + if !json.Valid([]byte(out)) { + t.Fatalf("paginated pretty output should be valid JSON, got:\n%s", out) + } + if !strings.Contains(out, "\n {") || !strings.Contains(out, `"name": "Alice"`) { + t.Fatalf("paginated pretty output should be indented JSON, got:\n%s", out) + } +} + func TestPaginatedFormatter_CSV(t *testing.T) { var buf bytes.Buffer pf := NewPaginatedFormatter(&buf, FormatCSV) diff --git a/internal/output/format_type.go b/internal/output/format_type.go index c78db9141b..cb12104473 100644 --- a/internal/output/format_type.go +++ b/internal/output/format_type.go @@ -10,6 +10,7 @@ type Format int const ( FormatJSON Format = iota + FormatPretty FormatNDJSON FormatTable FormatCSV @@ -22,6 +23,8 @@ func ParseFormat(s string) (Format, bool) { switch strings.ToLower(s) { case "json", "": return FormatJSON, true + case "pretty": + return FormatPretty, true case "ndjson": return FormatNDJSON, true case "table": @@ -36,6 +39,8 @@ func ParseFormat(s string) (Format, bool) { // String returns the string representation of a Format. func (f Format) String() string { switch f { + case FormatPretty: + return "pretty" case FormatNDJSON: return "ndjson" case FormatTable: diff --git a/internal/output/format_type_test.go b/internal/output/format_type_test.go index 1c57f1142c..41a5c9ca4f 100644 --- a/internal/output/format_type_test.go +++ b/internal/output/format_type_test.go @@ -14,6 +14,9 @@ func TestParseFormat(t *testing.T) { {"json", FormatJSON, true}, {"JSON", FormatJSON, true}, {"Json", FormatJSON, true}, + {"pretty", FormatPretty, true}, + {"PRETTY", FormatPretty, true}, + {"Pretty", FormatPretty, true}, {"ndjson", FormatNDJSON, true}, {"NDJSON", FormatNDJSON, true}, {"Ndjson", FormatNDJSON, true}, @@ -52,6 +55,7 @@ func TestFormatString(t *testing.T) { want string }{ {FormatJSON, "json"}, + {FormatPretty, "pretty"}, {FormatNDJSON, "ndjson"}, {FormatTable, "table"}, {FormatCSV, "csv"}, diff --git a/internal/qualitygate/cmd/manifest-export/collect.go b/internal/qualitygate/cmd/manifest-export/collect.go index 88273d4058..fdd96faf09 100644 --- a/internal/qualitygate/cmd/manifest-export/collect.go +++ b/internal/qualitygate/cmd/manifest-export/collect.go @@ -11,6 +11,7 @@ import ( rootcmd "github.com/larksuite/cli/cmd" "github.com/larksuite/cli/internal/cmdmeta" + "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/qualitygate/manifest" "github.com/larksuite/cli/internal/registry" @@ -94,7 +95,7 @@ func commandFromCobra(c *cobra.Command, defaultFields map[string][]string) manif Short: c.Short, Example: c.Example, Hidden: c.Hidden, - Runnable: c.Runnable(), + Runnable: c.Runnable() && !cmdpolicy.IsPureGroup(c), Source: source, Generated: cmdmeta.Generated(c), Identities: cmdmeta.Identities(c), diff --git a/internal/qualitygate/cmd/manifest-export/main_test.go b/internal/qualitygate/cmd/manifest-export/main_test.go index 644736e858..3fc125d6bd 100644 --- a/internal/qualitygate/cmd/manifest-export/main_test.go +++ b/internal/qualitygate/cmd/manifest-export/main_test.go @@ -90,6 +90,20 @@ func TestCollectContainsDocsFetchAndDryRunFlag(t *testing.T) { } } +func TestCollectMarksPureNavigationGroupsNonRunnable(t *testing.T) { + got, err := collectCommandIndex(context.Background()) + if err != nil { + t.Fatalf("collectCommandIndex() error = %v", err) + } + cmd := findManifestCommand(&got, "approval") + if cmd == nil { + t.Fatalf("approval group not found") + } + if cmd.Runnable { + t.Fatalf("approval is a navigation group and must not be exported as runnable") + } +} + func TestCollectExcludesGeneratedServiceCommands(t *testing.T) { got, err := collectHandAuthored(context.Background()) if err != nil { diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go index 5453ee682e..d33ad1f332 100644 --- a/shortcuts/calendar/calendar_test.go +++ b/shortcuts/calendar/calendar_test.go @@ -1222,7 +1222,7 @@ func TestAgenda_Success(t *testing.T) { "+agenda", "--start", "2025-03-21", "--end", "2025-03-21", - "--format", "prettry", + "--format", "pretty", "--as", "bot", }, f, stdout) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 047dc2e3d5..ef2a2e3b1f 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -752,12 +752,9 @@ func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, pre } func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) { - outFn := ctx.Out - if raw { - outFn = ctx.OutRaw - } + emitJSON := func() { ctx.emit(data, meta, raw, true) } if ctx.JqExpr != "" { - outFn(data, meta) + emitJSON() return } switch ctx.Format { @@ -773,10 +770,10 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty if prettyFn != nil { prettyFn(ctx.IO().Out) } else { - outFn(data, meta) + output.FormatValue(ctx.IO().Out, data, output.FormatPretty) } case "json", "": - outFn(data, meta) + emitJSON() default: // table, csv, ndjson — pass data directly; FormatValue handles both // plain arrays and maps with array fields (e.g. {"members":[…]}) @@ -790,7 +787,11 @@ func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, pretty } format, formatOK := output.ParseFormat(ctx.Format) if !formatOK { - fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format) + ctx.outputErrOnce.Do(func() { + ctx.outputErr = errs.NewValidationError(errs.SubtypeInvalidArgument, + "unsupported output format %q", ctx.Format).WithParam("--format") + }) + return } output.FormatValue(ctx.IO().Out, data, format) } @@ -928,6 +929,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo } } + // Output format validation is local and must happen before identity, + // configuration or credential work. Invalid input therefore produces the + // same typed validation error even when no account is configured. + if _, err := normalizeShortcutOutputFormat(cmd, s); err != nil { + return err + } + as, err := resolveShortcutIdentity(cmd, f, s) if err != nil { return err @@ -1027,8 +1035,11 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf } rctx.larkSDK = sdk - applyJSONShorthand(cmd, s) - rctx.Format = rctx.Str("format") + format, err := normalizeShortcutOutputFormat(cmd, s) + if err != nil { + return nil, err + } + rctx.Format = format rctx.JqExpr, _ = cmd.Flags().GetString("jq") return rctx, nil } @@ -1200,15 +1211,23 @@ func shortcutDeclaresJSONFlag(s *Shortcut) bool { } // shortcutFormatSupportsJSON reports whether the command's format flag accepts -// "json": a self-declared format supports it only when its Enum lists "json"; -// a framework-injected default format (no format entry in s.Flags) always does. +// "json". It derives the answer from the same capability set used everywhere +// else (shortcutFormatCapabilities), so a format flag that declares no Enum but +// defaults to "json" is correctly recognized as JSON-capable. func shortcutFormatSupportsJSON(s *Shortcut) bool { + return shortcutFormatCapabilities(s).Supports("json") +} + +func shortcutFormatCapabilities(s *Shortcut) output.FormatCapabilities { for _, fl := range s.Flags { if fl.Name == "format" { - return slices.Contains(fl.Enum, "json") + if len(fl.Enum) > 0 { + return output.NewFormatCapabilities(fl.Enum...) + } + return output.NewFormatCapabilities(fl.Default) } } - return true // framework-injected: json (default) | pretty | table | ndjson | csv + return output.StandardFormats } // ensureJSONShorthand registers --json as a shorthand for --format json when: @@ -1243,15 +1262,32 @@ func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) { // shorthand only fills in when the user did not choose a format). Shortcuts // that declare their own "json" flag keep its custom semantics untouched. func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) { - if shortcutDeclaresJSONFlag(s) { - return + _, _ = normalizeShortcutOutputFormat(cmd, s) +} + +func normalizeShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) { + format, err := resolveShortcutOutputFormat(cmd, s) + if err != nil { + return "", err } - if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") { - return + if cmd.Flags().Lookup("format") != nil && cmd.Flags().Lookup("format").Value.String() != format { + if err := cmd.Flags().Set("format", format); err != nil { + return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to normalize --format: %v", err).WithCause(err) + } + } + return format, nil +} + +func resolveShortcutOutputFormat(cmd *cobra.Command, s *Shortcut) (string, error) { + format, err := cmd.Flags().GetString("format") + if err != nil { + return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to read --format: %v", err).WithCause(err) } - if set, _ := cmd.Flags().GetBool("json"); set { - _ = cmd.Flags().Set("format", "json") + jsonShorthand := false + if !shortcutDeclaresJSONFlag(s) && cmd.Flags().Lookup("json") != nil { + jsonShorthand, _ = cmd.Flags().GetBool("json") } + return shortcutFormatCapabilities(s).Resolve(format, cmd.Flags().Changed("format"), jsonShorthand) } func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) { @@ -1313,9 +1349,9 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f cmd.Flags().Bool("dry-run", false, "print request without executing") if cmd.Flags().Lookup("format") == nil { - cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv") + cmd.Flags().String("format", "json", output.StandardFormats.Usage()) cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp + return output.StandardFormats.Names(), cobra.ShellCompDirectiveNoFileComp }) } ensureJSONShorthand(cmd, s) diff --git a/shortcuts/common/runner_format_universal_test.go b/shortcuts/common/runner_format_universal_test.go index 777cd2e47f..7cec22eeaa 100644 --- a/shortcuts/common/runner_format_universal_test.go +++ b/shortcuts/common/runner_format_universal_test.go @@ -5,9 +5,13 @@ package common import ( "context" + "encoding/json" + "errors" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" "github.com/spf13/cobra" ) @@ -37,3 +41,87 @@ func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) { t.Errorf("--format default = %q, want %q", flag.DefValue, "json") } } + +func TestRuntimeContextOutKeepsJSONEnvelopeForPrettyFormat(t *testing.T) { + cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu} + f, stdout, _, _ := cmdutil.TestFactory(t, cfg) + rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot) + rctx.Format = "pretty" + + rctx.Out(map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"name": "Alice"}}, + }, nil) + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Items []map[string]interface{} `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("Out should emit a JSON envelope: %v\n%s", err, stdout.String()) + } + if !envelope.OK || len(envelope.Data.Items) != 1 || envelope.Data.Items[0]["name"] != "Alice" { + t.Fatalf("unexpected envelope: %#v", envelope) + } +} + +func TestRuntimeContextOutRawKeepsJSONEnvelopeForPrettyFormat(t *testing.T) { + cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu} + f, stdout, _, _ := cmdutil.TestFactory(t, cfg) + rctx := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+read"}, cfg, f, core.AsBot) + rctx.Format = "pretty" + + rctx.OutRaw(map[string]interface{}{"body": "
hello
"}, nil) + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Body string `json:"body"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("OutRaw should emit a JSON envelope: %v\n%s", err, stdout.String()) + } + if !envelope.OK || envelope.Data.Body != "hello
" { + t.Fatalf("unexpected envelope: %#v", envelope) + } +} + +func TestShortcutMount_UnsupportedFormatFailsBeforeExecution(t *testing.T) { + cfg := &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu} + f, _, _, _ := cmdutil.TestFactory(t, cfg) + parent := &cobra.Command{Use: "root"} + executed := false + shortcut := Shortcut{ + Service: "test", + Command: "+read", + Description: "read data", + AuthTypes: []string{"bot"}, + Execute: func(context.Context, *RuntimeContext) error { + executed = true + return nil + }, + } + shortcut.Mount(parent, f) + + cmd, _, err := parent.Find([]string{"+read"}) + if err != nil { + t.Fatalf("Find() error = %v", err) + } + if cmd == nil { + t.Fatal("expected mounted shortcut command") + } + parent.SetArgs([]string{"+read", "--format", "xml"}) + err = parent.Execute() + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if validationErr.Param != "--format" { + t.Fatalf("param = %q, want --format", validationErr.Param) + } + if executed { + t.Fatal("shortcut must not execute with an unsupported format") + } +} diff --git a/shortcuts/mail/flag_suggest.go b/shortcuts/mail/flag_suggest.go deleted file mode 100644 index b0538bbcc9..0000000000 --- a/shortcuts/mail/flag_suggest.go +++ /dev/null @@ -1,286 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package mail - -import ( - "fmt" - "sort" - "strings" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - - "github.com/larksuite/cli/errs" -) - -// flagName is a package-private snapshot of a pflag.Flag's identity. -type flagName struct { - long, short string - hidden bool -} - -// Candidate is a single suggested flag returned to the user when an -// unknown flag is detected. -type Candidate struct { - // Flag is the long-form spelling of the suggested flag, e.g. "--to". - Flag string `json:"flag"` - // Shorthand is the single-character shorthand (without the leading - // dash) when the suggested flag has one; empty otherwise. - Shorthand string `json:"shorthand,omitempty"` - // Distance is the Levenshtein edit distance to the unknown token. - // Zero indicates a bidirectional prefix hit (Reason == "prefix"). - Distance int `json:"distance"` - // Reason explains how the candidate was matched: "prefix" for - // bidirectional prefix hits, "edit_distance" for fuzzy matches. - Reason string `json:"reason"` -} - -// maxCandidates caps the number of suggestions returned per error so -// the JSON envelope stays compact and the user-visible hint remains -// scannable. -const maxCandidates = 5 - -// InstallOnMail attaches the unknown-flag fuzzy-match hook on the mail -// service cobra parent command. It is invoked exactly once from -// shortcuts/register.go inside the `service == "mail"` branch. -// -// Cobra's FlagErrorFunc walks up the parent chain looking for the nearest -// non-nil hook, so every mail subcommand inherits this behaviour without -// any per-shortcut wiring. -func InstallOnMail(svc *cobra.Command) { - if svc == nil { - return - } - svc.SetFlagErrorFunc(flagSuggestErrorFunc) -} - -// flagSuggestErrorFunc converts pflag's unknown-flag errors into a typed -// validation error carrying candidate suggestions. Any other error is passed -// through unchanged so cobra's existing handling kicks in. -func flagSuggestErrorFunc(c *cobra.Command, err error) error { - if err == nil { - return nil - } - token, isShorthand, ok := parseUnknownToken(err.Error()) - if !ok { - // Non unknown-flag errors (e.g. "required flag(s) ... not set") - // pass through to cmd/root.go::handleRootError's fallback path. - return err - } - names := collectFlags(c) - var matches []Candidate - if isShorthand { - matches = suggestShorthand(token, names) - } else { - matches = suggest(token, names) - } - // Normalise to a non-nil slice so the JSON envelope always emits - // `candidates: []` instead of `null`, keeping the wire shape stable - // for downstream parsers regardless of command-state. - if matches == nil { - matches = []Candidate{} - } - hint := buildHint(c, matches) - params := []errs.InvalidParam{{ - Name: rawUnknownToken(token, isShorthand), - Reason: "unknown flag", - }} - for _, match := range matches { - reason := fmt.Sprintf("candidate (%s, distance=%d)", match.Reason, match.Distance) - if match.Shorthand != "" { - reason += fmt.Sprintf(", shorthand=-%s", match.Shorthand) - } - params = append(params, errs.InvalidParam{Name: match.Flag, Reason: reason}) - } - return errs.NewValidationError(errs.SubtypeInvalidArgument, err.Error()). - WithHint("%s", hint). - WithParam(rawUnknownToken(token, isShorthand)). - WithParams(params...) -} - -// parseUnknownToken extracts the offending flag name from a pflag error -// string. Recognised forms: -// -// - "unknown flag: --tos" -// - "unknown flag: --bogus=val" -// - "unknown shorthand flag: 'X' in -Xyz" -// -// Anything else returns (_, _, false) so the caller can pass the error -// through unchanged. -func parseUnknownToken(errMsg string) (token string, isShorthand bool, ok bool) { - const longPrefix = "unknown flag: --" - const shortPrefix = "unknown shorthand flag: '" - switch { - case strings.HasPrefix(errMsg, longPrefix): - rest := errMsg[len(longPrefix):] - if eq := strings.IndexAny(rest, "= \t"); eq >= 0 { - rest = rest[:eq] - } - return rest, false, rest != "" - case strings.HasPrefix(errMsg, shortPrefix): - rest := errMsg[len(shortPrefix):] - end := strings.IndexByte(rest, '\'') - if end <= 0 { - return "", false, false - } - return rest[:end], true, true - } - return "", false, false -} - -// rawUnknownToken re-attaches the leading dash(es) to a bare token so the -// JSON envelope echoes the user-visible spelling. -func rawUnknownToken(token string, isShorthand bool) string { - if isShorthand { - return "-" + token - } - return "--" + token -} - -// collectFlags snapshots the merged local + persistent + inherited flag -// set of cmd. The hidden bit is preserved on each entry; the suggest -// helpers apply the actual filter so the slice stays reusable. -func collectFlags(cmd *cobra.Command) []flagName { - if cmd == nil { - return nil - } - var out []flagName - cmd.Flags().VisitAll(func(f *pflag.Flag) { - out = append(out, flagName{long: f.Name, short: f.Shorthand, hidden: f.Hidden}) - }) - return out -} - -// suggest produces top-N long-flag candidates for an unknown token, using -// bidirectional prefix matching first and Levenshtein distance for the -// remainder. Hidden flags and empty long names are skipped. Results are -// stably sorted by (Distance asc, Flag asc) and capped at maxCandidates. -func suggest(unknown string, names []flagName) []Candidate { - if unknown == "" || len(names) == 0 { - return nil - } - threshold := levThreshold(unknown) - out := make([]Candidate, 0, len(names)) - seen := make(map[string]struct{}, len(names)) - - // Priority 1: bidirectional prefix match. - for _, n := range names { - if n.hidden || n.long == "" { - continue - } - if strings.HasPrefix(n.long, unknown) || strings.HasPrefix(unknown, n.long) { - out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"}) - seen[n.long] = struct{}{} - } - } - // Priority 2: Levenshtein distance, skipping already-matched names. - for _, n := range names { - if n.hidden || n.long == "" { - continue - } - if _, ok := seen[n.long]; ok { - continue - } - if d := levenshtein(unknown, n.long); d <= threshold { - out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: d, Reason: "edit_distance"}) - } - } - sort.SliceStable(out, func(i, j int) bool { - if out[i].Distance != out[j].Distance { - return out[i].Distance < out[j].Distance - } - return out[i].Flag < out[j].Flag - }) - if len(out) > maxCandidates { - out = out[:maxCandidates] - } - return out -} - -// suggestShorthand produces candidates for an unknown single-character -// shorthand. It first looks for exact f.Shorthand matches; if there are -// none, it falls back to long names that begin with the same character. -// Levenshtein is deliberately not used here since single-char edit -// distance would match almost every flag. -func suggestShorthand(c string, names []flagName) []Candidate { - if c == "" || len(names) == 0 { - return nil - } - out := make([]Candidate, 0) - for _, n := range names { - if n.hidden { - continue - } - if n.short == c { - out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"}) - } - } - if len(out) == 0 { - for _, n := range names { - if n.hidden || n.long == "" { - continue - } - if strings.HasPrefix(n.long, c) { - out = append(out, Candidate{Flag: "--" + n.long, Shorthand: n.short, Distance: 0, Reason: "prefix"}) - } - } - } - sort.SliceStable(out, func(i, j int) bool { return out[i].Flag < out[j].Flag }) - if len(out) > maxCandidates { - out = out[:maxCandidates] - } - return out -} - -// buildHint returns a one-line hint suitable for a typed error's Hint field. -// When at least one candidate exists, the top hit is named; otherwise -// the user is directed to --help. -func buildHint(c *cobra.Command, matches []Candidate) string { - if len(matches) == 0 { - return fmt.Sprintf("Run `%s --help` to view available flags", c.CommandPath()) - } - return fmt.Sprintf("Did you mean: %s ?", matches[0].Flag) -} - -// levThreshold returns the maximum acceptable Levenshtein distance for a -// token of the given length, clamped to [1, 4]. -func levThreshold(s string) int { - t := len(s)/3 + 1 - if t < 1 { - return 1 - } - if t > 4 { - return 4 - } - return t -} - -// levenshtein computes the standard Levenshtein edit distance between -// two ASCII strings using a 2-row dynamic-programming table. -func levenshtein(a, b string) int { - la, lb := len(a), len(b) - if la == 0 { - return lb - } - if lb == 0 { - return la - } - prev := make([]int, lb+1) - curr := make([]int, lb+1) - for j := 0; j <= lb; j++ { - prev[j] = j - } - for i := 1; i <= la; i++ { - curr[0] = i - for j := 1; j <= lb; j++ { - cost := 1 - if a[i-1] == b[j-1] { - cost = 0 - } - curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost) - } - prev, curr = curr, prev - } - return prev[lb] -} diff --git a/shortcuts/mail/flag_suggest_test.go b/shortcuts/mail/flag_suggest_test.go deleted file mode 100644 index 9ce5d44d55..0000000000 --- a/shortcuts/mail/flag_suggest_test.go +++ /dev/null @@ -1,353 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package mail - -import ( - "errors" - "testing" - - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/larksuite/cli/errs" -) - -// --- suggest (long-flag) --- - -func TestSuggest_Prefix(t *testing.T) { - names := []flagName{ - {long: "to", short: "t"}, - {long: "cc"}, - {long: "subject", short: "s"}, - } - got := suggest("tos", names) - require.NotEmpty(t, got) - // "tos" has --to as a prefix → bidirectional prefix hit, Distance=0. - assert.Equal(t, "--to", got[0].Flag) - assert.Equal(t, 0, got[0].Distance) - assert.Equal(t, "prefix", got[0].Reason) -} - -func TestSuggest_Levenshtein(t *testing.T) { - names := []flagName{ - {long: "subject"}, - {long: "body"}, - {long: "to"}, - } - // Distance 1 from "subject". - got := suggest("subjec", names) - require.NotEmpty(t, got) - // "subjec" is prefix of "subject" → bidirectional prefix. - assert.Equal(t, "--subject", got[0].Flag) - assert.Equal(t, "prefix", got[0].Reason) - - // True edit-distance: "subjeect" is not a prefix either way of "subject". - got = suggest("subjeect", names) - require.NotEmpty(t, got) - assert.Equal(t, "--subject", got[0].Flag) - assert.Equal(t, "edit_distance", got[0].Reason) - assert.GreaterOrEqual(t, got[0].Distance, 1) -} - -func TestSuggest_HiddenSkipped(t *testing.T) { - names := []flagName{ - {long: "internal-debug", hidden: true}, - {long: "interactive"}, - } - got := suggest("internal", names) - for _, c := range got { - assert.NotEqual(t, "--internal-debug", c.Flag, "hidden flag must not appear in suggestions") - } -} - -func TestSuggest_TopNAndStableSort(t *testing.T) { - // 6 names all within threshold and at the same distance (1) from the - // unknown token so that the lexicographic tiebreak and maxCandidates - // cap are both exercised. (Earlier the names were 3-distance from - // "zzz" which is above the threshold of 2 — suggest returned empty - // and the assertions trivially passed.) - names := []flagName{ - {long: "aaab"}, - {long: "aaac"}, - {long: "aaad"}, - {long: "aaae"}, - {long: "aaaf"}, - {long: "aaag"}, - } - got := suggest("aaaa", names) - require.Len(t, got, maxCandidates, "must cap at maxCandidates") - // All distances equal → lex ordering by Flag asc, top 5 alphabetically. - wantFlags := []string{"--aaab", "--aaac", "--aaad", "--aaae", "--aaaf"} - gotFlags := []string{got[0].Flag, got[1].Flag, got[2].Flag, got[3].Flag, got[4].Flag} - assert.Equal(t, wantFlags, gotFlags, "tiebreak must order by Flag asc") -} - -// --- suggestShorthand --- - -func TestSuggestShorthand_Exact(t *testing.T) { - names := []flagName{ - {long: "to", short: "t"}, - {long: "cc", short: "c"}, - {long: "subject", short: "s"}, - } - got := suggestShorthand("t", names) - require.NotEmpty(t, got) - assert.Equal(t, "--to", got[0].Flag) - assert.Equal(t, "t", got[0].Shorthand) - assert.Equal(t, "prefix", got[0].Reason) -} - -func TestSuggestShorthand_PrefixFallback(t *testing.T) { - // No short matches "x"; fall back to long names starting with "x". - names := []flagName{ - {long: "xargs"}, - {long: "xterm"}, - {long: "yargs"}, - } - got := suggestShorthand("x", names) - require.NotEmpty(t, got) - flags := make([]string, 0, len(got)) - for _, c := range got { - flags = append(flags, c.Flag) - } - assert.Contains(t, flags, "--xargs") - assert.Contains(t, flags, "--xterm") - assert.NotContains(t, flags, "--yargs") -} - -// --- parseUnknownToken --- - -func TestParseUnknownToken_Long(t *testing.T) { - tok, isShort, ok := parseUnknownToken("unknown flag: --tos") - assert.True(t, ok) - assert.False(t, isShort) - assert.Equal(t, "tos", tok) - - tok, isShort, ok = parseUnknownToken("unknown flag: --bogus=val") - assert.True(t, ok) - assert.False(t, isShort) - assert.Equal(t, "bogus", tok, "must strip =value tail") - - tok, _, ok = parseUnknownToken("unknown flag: --bogus value") - assert.True(t, ok) - assert.Equal(t, "bogus", tok, "must strip whitespace tail") -} - -func TestParseUnknownToken_Shorthand(t *testing.T) { - tok, isShort, ok := parseUnknownToken("unknown shorthand flag: 'X' in -X") - assert.True(t, ok) - assert.True(t, isShort) - assert.Equal(t, "X", tok) - - tok, isShort, ok = parseUnknownToken("unknown shorthand flag: 'q' in -qrs") - assert.True(t, ok) - assert.True(t, isShort) - assert.Equal(t, "q", tok) -} - -func TestParseUnknownToken_NotMatch(t *testing.T) { - cases := []string{ - `required flag(s) "to" not set`, - "some unrelated error", - "", - "unknown command \"foo\" for \"mail\"", - } - for _, in := range cases { - tok, isShort, ok := parseUnknownToken(in) - assert.False(t, ok, "input %q must not match", in) - assert.False(t, isShort) - assert.Equal(t, "", tok) - } -} - -// --- flagSuggestErrorFunc --- - -// newFakeMailCmd builds a cobra command tree resembling the mail parent -// with a handful of flags exercised by the hook tests. -func newFakeMailCmd() *cobra.Command { - c := &cobra.Command{Use: "mail"} - c.Flags().String("to", "", "recipients") - c.Flags().String("cc", "", "cc recipients") - c.Flags().String("subject", "", "subject") - c.Flags().StringP("body", "b", "", "body") - return c -} - -func requireFlagSuggestValidation(t *testing.T, got error) *errs.ValidationError { - t.Helper() - var validationErr *errs.ValidationError - require.True(t, errors.As(got, &validationErr), "expected *errs.ValidationError, got %T", got) - p, ok := errs.ProblemOf(got) - require.True(t, ok, "expected typed Problem") - assert.Equal(t, errs.CategoryValidation, p.Category) - assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype) - return validationErr -} - -func paramReason(params []errs.InvalidParam, name string) (string, bool) { - for _, p := range params { - if p.Name == name { - return p.Reason, true - } - } - return "", false -} - -func TestFlagSuggestErrorFunc_LongUnknown_ReturnsTypedValidation(t *testing.T) { - cmd := newFakeMailCmd() - got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos")) - - validationErr := requireFlagSuggestValidation(t, got) - assert.Equal(t, "unknown flag: --tos", validationErr.Message) - assert.Equal(t, "--tos", validationErr.Param) - assert.Contains(t, validationErr.Hint, "--to") - - reason, ok := paramReason(validationErr.Params, "--tos") - require.True(t, ok, "unknown flag should be included in params") - assert.Equal(t, "unknown flag", reason) - reason, ok = paramReason(validationErr.Params, "--to") - require.True(t, ok, "expected --to in candidate params") - assert.Contains(t, reason, "candidate (prefix") -} - -func TestFlagSuggestErrorFunc_NotUnknownFlag_PassesThrough(t *testing.T) { - cmd := newFakeMailCmd() - in := errors.New(`required flag(s) "to" not set`) - got := flagSuggestErrorFunc(cmd, in) - // Identity passthrough: same error pointer. - assert.Same(t, in, got, "non-unknown-flag errors must be returned unchanged") -} - -func TestFlagSuggestErrorFunc_TypedCategoryAndSubtype(t *testing.T) { - cmd := newFakeMailCmd() - got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos")) - p, ok := errs.ProblemOf(got) - require.True(t, ok) - assert.Equal(t, errs.CategoryValidation, p.Category) - assert.Equal(t, errs.SubtypeInvalidArgument, p.Subtype) -} - -// --- edge-case coverage --- - -func TestInstallOnMail_NilIsNoop(t *testing.T) { - // Must not panic; the nil-guard is the contract. - InstallOnMail(nil) -} - -func TestInstallOnMail_InstallsHook(t *testing.T) { - c := newFakeMailCmd() - InstallOnMail(c) - require.NotNil(t, c.FlagErrorFunc()) - got := c.FlagErrorFunc()(c, errors.New("unknown flag: --tos")) - validationErr := requireFlagSuggestValidation(t, got) - assert.Equal(t, "--tos", validationErr.Param) -} - -func TestFlagSuggestErrorFunc_NilError(t *testing.T) { - cmd := newFakeMailCmd() - assert.NoError(t, flagSuggestErrorFunc(cmd, nil)) -} - -func TestFlagSuggestErrorFunc_LongUnknown_StripsValueTail(t *testing.T) { - cmd := newFakeMailCmd() - got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --tos=alice@example.com")) - validationErr := requireFlagSuggestValidation(t, got) - assert.Equal(t, "--tos", validationErr.Param, "value tail must be stripped before echoing") - reason, ok := paramReason(validationErr.Params, "--tos") - require.True(t, ok) - assert.Equal(t, "unknown flag", reason) -} - -func TestFlagSuggestErrorFunc_ShorthandUnknown(t *testing.T) { - cmd := newFakeMailCmd() - got := flagSuggestErrorFunc(cmd, errors.New("unknown shorthand flag: 'b' in -bXY")) - validationErr := requireFlagSuggestValidation(t, got) - assert.Equal(t, "-b", validationErr.Param) - reason, ok := paramReason(validationErr.Params, "-b") - require.True(t, ok) - assert.Equal(t, "unknown flag", reason) - // newFakeMailCmd has --body/-b; exact shorthand hit expected. - reason, ok = paramReason(validationErr.Params, "--body") - require.True(t, ok) - assert.Contains(t, reason, "candidate (prefix") - assert.Contains(t, reason, "shorthand=-b") -} - -func TestFlagSuggestErrorFunc_ParamsAlwaysPresent(t *testing.T) { - // A cobra command with no flags forces collectFlags → empty names → - // suggest → nil. The typed validation error must still expose the unknown - // flag in Params so downstream parsers have a stable structured field. - bare := &cobra.Command{Use: "mail"} - got := flagSuggestErrorFunc(bare, errors.New("unknown flag: --bogus")) - validationErr := requireFlagSuggestValidation(t, got) - assert.NotNil(t, validationErr.Params) - require.Len(t, validationErr.Params, 1) - assert.Equal(t, "--bogus", validationErr.Params[0].Name) - assert.Equal(t, "unknown flag", validationErr.Params[0].Reason) -} - -func TestFlagSuggestErrorFunc_NoCandidatesUsesHelpHint(t *testing.T) { - cmd := newFakeMailCmd() - // Token with no plausible neighbor in {to, cc, subject, body}. - got := flagSuggestErrorFunc(cmd, errors.New("unknown flag: --zzzzzzz")) - validationErr := requireFlagSuggestValidation(t, got) - assert.Contains(t, validationErr.Hint, "--help") -} - -func TestParseUnknownToken_EmptyAndMalformed(t *testing.T) { - // Long form with empty token after the prefix. - _, _, ok := parseUnknownToken("unknown flag: --") - assert.False(t, ok, "empty long token must not match") - - // Shorthand with no closing quote. - _, _, ok = parseUnknownToken("unknown shorthand flag: 'q") - assert.False(t, ok, "shorthand without closing quote must not match") - - // Shorthand with empty char between quotes. - _, _, ok = parseUnknownToken("unknown shorthand flag: '' in -") - assert.False(t, ok, "empty shorthand token must not match") -} - -func TestSuggest_EmptyInputs(t *testing.T) { - assert.Nil(t, suggest("", []flagName{{long: "to"}})) - assert.Nil(t, suggest("foo", nil)) -} - -func TestSuggestShorthand_EmptyInputs(t *testing.T) { - assert.Nil(t, suggestShorthand("", []flagName{{long: "to", short: "t"}})) - assert.Nil(t, suggestShorthand("x", nil)) -} - -func TestSuggestShorthand_HiddenSkipped(t *testing.T) { - names := []flagName{ - {long: "secret", short: "s", hidden: true}, - {long: "subject", short: "s"}, - } - got := suggestShorthand("s", names) - for _, c := range got { - assert.NotEqual(t, "--secret", c.Flag, "hidden shorthand must not be suggested") - } -} - -func TestCollectFlags_NilSafe(t *testing.T) { - assert.Nil(t, collectFlags(nil)) -} - -func TestLevThreshold_Clamp(t *testing.T) { - // len 0 → 0/3+1 = 1 - assert.Equal(t, 1, levThreshold("")) - // len 3 → 2 - assert.Equal(t, 2, levThreshold("abc")) - // Long token caps at 4. - assert.Equal(t, 4, levThreshold("aaaaaaaaaaaaaaaaaaaa")) -} - -func TestLevenshtein_EmptyAndIdentical(t *testing.T) { - assert.Equal(t, 0, levenshtein("", "")) - assert.Equal(t, 3, levenshtein("", "abc")) - assert.Equal(t, 3, levenshtein("abc", "")) - assert.Equal(t, 0, levenshtein("abc", "abc")) - assert.Equal(t, 1, levenshtein("abc", "abd")) -} diff --git a/shortcuts/mail/mail_json_shorthand_test.go b/shortcuts/mail/mail_json_shorthand_test.go index dec3f5d207..45930b2477 100644 --- a/shortcuts/mail/mail_json_shorthand_test.go +++ b/shortcuts/mail/mail_json_shorthand_test.go @@ -103,8 +103,8 @@ func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) { if ve.Param != "--format" { t.Fatalf("param = %q, want --format", ve.Param) } - if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) { - t.Fatalf("message = %q, want enum validation message", problem.Message) + if !strings.Contains(problem.Message, `unsupported output format "bogus"`) { + t.Fatalf("message = %q, want unsupported format message", problem.Message) } if !strings.Contains(problem.Message, "table, json, data") { t.Fatalf("message = %q, want allowed values list", problem.Message) diff --git a/shortcuts/register.go b/shortcuts/register.go index 7d458ca185..7bfa1468a8 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -157,9 +157,6 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f if service == "apps" { apps.InstallOnApps(svc, f) } - if service == "mail" { - mail.InstallOnMail(svc) - } if service == "sheets" { applySheetsCompatGroups(svc) } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index a5688103ee..a5a7400e4f 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -7,14 +7,12 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "os" "path/filepath" "strings" "testing" - "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -400,68 +398,6 @@ func TestRegisterShortcutsReusesExistingServiceCommand(t *testing.T) { } } -// TestRegisterShortcutsInstallsMailFlagSuggestHook is the end-to-end -// wiring guard for the mail unknown-flag fuzzy-match feature: it ensures -// the `if service == "mail" { mail.InstallOnMail(svc) }` branch in -// RegisterShortcutsWithContext is actually exercised, so a future refactor -// that drops the branch (or breaks the import) will fail this test rather -// than silently regressing the structured-error contract. -func TestRegisterShortcutsInstallsMailFlagSuggestHook(t *testing.T) { - program := &cobra.Command{Use: "root"} - RegisterShortcuts(program, newRegisterTestFactory(t)) - - mailCmd, _, err := program.Find([]string{"mail"}) - if err != nil { - t.Fatalf("find mail command: %v", err) - } - if mailCmd == nil || mailCmd.Name() != "mail" { - t.Fatalf("mail command not mounted: %#v", mailCmd) - } - - // The FlagErrorFunc lookup walks up to the nearest non-nil hook, so - // invoking it on the mail parent (or any of its children) must yield - // a typed validation problem for the unknown flag. - got := mailCmd.FlagErrorFunc()(mailCmd, errors.New("unknown flag: --bogus")) - var validationErr *errs.ValidationError - if !errors.As(got, &validationErr) { - t.Fatalf("expected *errs.ValidationError, got %T (%v)", got, got) - } - if validationErr.Param != "--bogus" { - t.Fatalf("expected Param=--bogus, got %q", validationErr.Param) - } - problem, ok := errs.ProblemOf(got) - if !ok { - t.Fatalf("expected typed problem, got %T (%v)", got, got) - } - if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { - t.Fatalf("expected validation/invalid_argument, got %s/%s", problem.Category, problem.Subtype) - } -} - -// TestRegisterShortcutsLeavesNonMailFlagErrorUntouched confirms the -// install is scoped: a non-mail service must keep the default cobra -// pass-through behaviour, otherwise an accidental fall-through in -// register.go would silently change every domain's error envelope. -func TestRegisterShortcutsLeavesNonMailFlagErrorUntouched(t *testing.T) { - program := &cobra.Command{Use: "root"} - RegisterShortcuts(program, newRegisterTestFactory(t)) - - baseCmd, _, err := program.Find([]string{"base"}) - if err != nil { - t.Fatalf("find base command: %v", err) - } - in := errors.New("unknown flag: --bogus") - got := baseCmd.FlagErrorFunc()(baseCmd, in) - // Default cobra hook is identity — anything else means the mail hook - // (which wraps into a typed *errs.ValidationError) leaked across domains. - if errs.IsTyped(got) { - t.Fatalf("base service unexpectedly produced a typed error: %#v", got) - } - if got != in { - t.Fatalf("base service should pass through original error pointer, got %T (%v)", got, got) - } -} - func TestGenerateShortcutsJSON(t *testing.T) { output := os.Getenv("SHORTCUTS_OUTPUT") if output == "" { diff --git a/shortcuts/sheets/flag_ergonomics.go b/shortcuts/sheets/flag_ergonomics.go index 0888e7493f..e06badd708 100644 --- a/shortcuts/sheets/flag_ergonomics.go +++ b/shortcuts/sheets/flag_ergonomics.go @@ -4,16 +4,12 @@ package sheets import ( - "fmt" "slices" - "sort" "strings" - "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/suggest" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" - "github.com/spf13/pflag" ) // ─── sheets flag ergonomics ───────────────────────────────────────────── @@ -28,106 +24,17 @@ import ( // needs no change at all and no other domain's behavior shifts. // withFlagErgonomics wraps an optional PostMount so that, after it runs, -// the command gets the sheets-specific unknown-flag error (valid flags -// inlined) and enum-value normalization (canonical vocabulary auto-applied, -// typos suggested). +// the command gets enum-value normalization. Unknown-flag handling is owned by +// cmd/root.go's single FlagErrorFunc, which retains the sheets inline flag list. func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) { return func(cmd *cobra.Command) { if prev != nil { prev(cmd) } - cmd.SetFlagErrorFunc(sheetsFlagErrorFunc) chainEnumNormalization(cmd) } } -// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands. -// It keeps the root behavior (typed error, did-you-mean suggestions, the -// offending flag on params) and additionally inlines the full valid-flag -// set: hallucinated sheets flags are usually semantic guesses (--cols for -// --range) that edit distance can't rank, and a --help round trip costs an -// agent a full extra call. One line here lets it re-issue the command -// immediately. -func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error { - name, isUnknown := unknownFlagFromParseError(ferr) - if !isUnknown { - return common.ValidationErrorf("%s", ferr.Error()). - WithHint("run `%s --help` for valid flags", c.CommandPath()) - } - valid := visibleFlagNames(c) - suggestions := suggest.Closest(name, valid, 3) - for i := range suggestions { - suggestions[i] = "--" + suggestions[i] - } - hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath()) - if list := inlineFlagList(valid); list != "" { - hint = "valid flags: " + list - if len(suggestions) > 0 { - hint = fmt.Sprintf("did you mean %s? valid flags: %s", - strings.Join(suggestions, ", "), list) - } - } - return errs.NewValidationError(errs.SubtypeInvalidArgument, - "unknown flag %q for %q", "--"+name, c.CommandPath()). - WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}). - WithHint("%s", hint) -} - -// unknownFlagFromParseError extracts the offending long-flag name from -// cobra's flag-parse error text ("unknown flag: --query" → "query"). -// Returns ok=false for anything else (missing argument, invalid value, -// unknown shorthand) so those stay structured but generic. Mirrors the -// root-level parser in cmd; the prefix contract is cobra's English wording. -func unknownFlagFromParseError(err error) (string, bool) { - const p = "unknown flag: --" - msg := err.Error() - i := strings.Index(msg, p) - if i < 0 { - return "", false - } - rest := msg[i+len(p):] - if j := strings.IndexAny(rest, " \t"); j >= 0 { - rest = rest[:j] - } - return rest, true -} - -// visibleFlagNames lists the non-hidden flag names registered on c, sorted. -func visibleFlagNames(c *cobra.Command) []string { - var names []string - c.Flags().VisitAll(func(f *pflag.Flag) { - if !f.Hidden { - names = append(names, f.Name) - } - }) - sort.Strings(names) - return names -} - -// inlineFlagListLimit caps how many flag names ride inline on an -// unknown-flag hint. Sheets shortcuts stay well under it. -const inlineFlagListLimit = 25 - -// inlineFlagList renders valid flag names as one comma-separated line for -// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when -// there is nothing to list. -func inlineFlagList(names []string) string { - if len(names) == 0 { - return "" - } - shown := names - var suffix string - if len(names) > inlineFlagListLimit { - shown = names[:inlineFlagListLimit] - suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit) - } - parts := make([]string, len(shown)) - for i, n := range shown { - parts[i] = "--" + n - } - return strings.Join(parts, ", ") + suffix -} - // ─── enum vocabulary normalization ────────────────────────────────────── // enumAliases maps habitual values agents import from CSS / Excel / Google diff --git a/shortcuts/sheets/flag_ergonomics_test.go b/shortcuts/sheets/flag_ergonomics_test.go index 058412ac11..08bceba116 100644 --- a/shortcuts/sheets/flag_ergonomics_test.go +++ b/shortcuts/sheets/flag_ergonomics_test.go @@ -5,7 +5,6 @@ package sheets import ( "errors" - "fmt" "strings" "testing" @@ -14,122 +13,6 @@ import ( "github.com/spf13/cobra" ) -func TestUnknownFlagFromParseError(t *testing.T) { - t.Parallel() - cases := []struct { - in string - name string - ok bool - }{ - {"unknown flag: --cols", "cols", true}, - {"unknown flag: --with-styles", "with-styles", true}, - {"unknown shorthand flag: 'z' in -z", "", false}, - {"flag needs an argument: --find", "", false}, - {`invalid argument "x" for "--count"`, "", false}, - } - for _, c := range cases { - name, ok := unknownFlagFromParseError(errors.New(c.in)) - if name != c.name || ok != c.ok { - t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok) - } - } -} - -// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets -// override of the root unknown-flag error: --cols is a semantic guess for -// --range that edit distance can't rank, so the hint must inline the full -// valid-flag list instead of deferring to a --help round trip. -func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) { - t.Parallel() - c := &cobra.Command{Use: "demo"} - c.Flags().String("range", "", "") - c.Flags().Int("width", 0, "") - - err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols")) - var verr *errs.ValidationError - if !errors.As(err, &verr) { - t.Fatalf("expected *errs.ValidationError, got %T", err) - } - if verr.Subtype != errs.SubtypeInvalidArgument { - t.Errorf("subtype = %q, want invalid_argument", verr.Subtype) - } - if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" { - t.Errorf("Params = %v, want one entry named --cols", verr.Params) - } - if strings.Contains(verr.Hint, "--help") { - t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint) - } - for _, want := range []string{"--range", "--width"} { - if !strings.Contains(verr.Hint, want) { - t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint) - } - } -} - -// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior -// (did-you-mean suggestion, machine-readable Suggestions) is preserved by -// the sheets override, with the valid-flag list appended. -func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) { - t.Parallel() - c := &cobra.Command{Use: "demo"} - c.Flags().String("range", "", "") - c.Flags().Bool("dry-run", false, "") - - err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang")) - var verr *errs.ValidationError - if !errors.As(err, &verr) { - t.Fatalf("expected *errs.ValidationError, got %T", err) - } - found := false - for _, s := range verr.Params[0].Suggestions { - if s == "--range" { - found = true - } - } - if !found { - t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions) - } - for _, want := range []string{"did you mean", "--range", "--dry-run"} { - if !strings.Contains(verr.Hint, want) { - t.Errorf("hint should contain %q, got %q", want, verr.Hint) - } - } -} - -func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) { - t.Parallel() - c := &cobra.Command{Use: "demo"} - err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find")) - var verr *errs.ValidationError - if !errors.As(err, &verr) { - t.Fatalf("expected *errs.ValidationError, got %T", err) - } - if verr.Param != "" || len(verr.Params) != 0 { - t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params) - } - if strings.Contains(verr.Hint, "did you mean") { - t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint) - } -} - -func TestInlineFlagList_TruncatesPastLimit(t *testing.T) { - t.Parallel() - if got := inlineFlagList(nil); got != "" { - t.Errorf("inlineFlagList(nil) = %q, want empty", got) - } - names := make([]string, inlineFlagListLimit+5) - for i := range names { - names[i] = fmt.Sprintf("flag-%02d", i) - } - got := inlineFlagList(names) - if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") { - t.Errorf("truncated list should count the overflow and defer to --help, got %q", got) - } - if strings.Contains(got, names[inlineFlagListLimit]) { - t.Errorf("list should stop at the limit, got %q", got) - } -} - func TestCanonicalEnumValue(t *testing.T) { t.Parallel() cases := []struct { @@ -278,19 +161,4 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) { } }) - t.Run("unknown flag inlines valid flags", func(t *testing.T) { - t.Parallel() - sc := shortcutFromRegistry(t, "+cols-resize") - _, _, err := runShortcutCapturingErr(t, sc, []string{ - "--url", testURL, - "--sheet-name", "s", - "--cols", "A:D", - }) - ve := requireValidation(t, err, `unknown flag "--cols"`) - for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} { - if !strings.Contains(ve.Hint, want) { - t.Errorf("hint should contain %q, got %q", want, ve.Hint) - } - } - }) }