From 31ad49b56bc1a54056ab4fd9b234afaf8507a972 Mon Sep 17 00:00:00 2001 From: Andrey Markelov Date: Fri, 26 Jun 2026 15:54:44 -0700 Subject: [PATCH] Add JSON help command manifest with --help --output=json Enable structured JSON output for help via `--help --output=json` and `help --output=json`. Each command in the subtree is described with path, flags, auth modes, destructive level, and structured output support. The help command skips auth, provides shell completions, and preserves text help behavior when --output=json is absent. --- README.md | 5 +- cmd/add-member.go | 1 + cmd/help_json.go | 415 ++++++++++ cmd/help_json_test.go | 711 ++++++++++++++++++ cmd/json_contract_test.go | 92 ++- cmd/remove-member.go | 1 + cmd/rm.go | 1 + cmd/root.go | 10 + .../json_contract/success_outputs.json | 1 + .../json_contract/success_schemas.json | 36 + docs/json-schema/v1/README.md | 12 +- docs/json-schema/v1/commands.json | 36 + 12 files changed, 1284 insertions(+), 37 deletions(-) create mode 100644 cmd/help_json.go create mode 100644 cmd/help_json_test.go diff --git a/README.md b/README.md index d513060..0526f05 100644 --- a/README.md +++ b/README.md @@ -152,10 +152,11 @@ The complete generated command reference is available in [docs/commands/dbxcli.m ### Output formats -Text output is the default. JSON output is available through the global `--output` flag for migrated commands: +Text output is the default. JSON output is available through the global `--output` flag for migrated commands and for help manifests: ```sh $ dbxcli ls --output=json / +$ dbxcli ls --help --output=json ``` Command results are written to stdout. Status, progress, human-facing warnings, diagnostics, and verbose logs are written to stderr. @@ -210,7 +211,7 @@ In JSON mode, error responses are written to stdout and the process exits with a } ``` -The full JSON command catalog, stable error codes, and schemas live in [docs/json-schema/v1](https://github.com/dropbox/dbxcli/tree/master/docs/json-schema/v1). Commands that intentionally do not support JSON output yet include `login`, `logout`, and `completion`. Help output and shell-completion protocol commands are text-only. +The full JSON command catalog, stable error codes, and schemas live in [docs/json-schema/v1](https://github.com/dropbox/dbxcli/tree/master/docs/json-schema/v1). Commands that intentionally do not support structured command-result JSON yet include `login`, `logout`, and `completion`, but their help is available as JSON with `--help --output=json`. Shell-completion protocol commands remain text-only. ### Authentication diff --git a/cmd/add-member.go b/cmd/add-member.go index a4b404d..6bab5e7 100644 --- a/cmd/add-member.go +++ b/cmd/add-member.go @@ -67,4 +67,5 @@ var addMemberCmd = &cobra.Command{ func init() { teamCmd.AddCommand(addMemberCmd) enableStructuredOutput(addMemberCmd) + setCommandDestructiveLevel(addMemberCmd, destructiveLevelAdmin) } diff --git a/cmd/help_json.go b/cmd/help_json.go new file mode 100644 index 0000000..15ef18b --- /dev/null +++ b/cmd/help_json.go @@ -0,0 +1,415 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/dropbox/dbxcli/internal/output" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const ( + jsonHelpStatusDescribed = "described" + jsonHelpKindCommand = "command" + + destructiveLevelNone = "none" + destructiveLevelDelete = "delete" + destructiveLevelAdmin = "admin" + + commandAuthModesAnnotation = "dbxcli.authModes" + commandDestructiveLevelAnnotation = "dbxcli.destructiveLevel" + commandJSONHelpAnnotation = "dbxcli.jsonHelpCommand" +) + +type jsonHelpInput struct { + Help bool `json:"help"` + Path string `json:"path"` +} + +type jsonCommandManifest struct { + Path string `json:"path"` + Use string `json:"use"` + Short string `json:"short"` + Aliases []string `json:"aliases"` + Runnable bool `json:"runnable"` + Flags []jsonCommandFlag `json:"flags"` + SupportsStructuredOutput bool `json:"supports_structured_output"` + AuthModes []string `json:"auth_modes"` + DestructiveLevel string `json:"destructive_level"` +} + +type jsonCommandFlag struct { + Name string `json:"name"` + Type string `json:"type"` + Default string `json:"default"` + Usage string `json:"usage"` + Inherited bool `json:"inherited"` +} + +func installJSONHelp(root *cobra.Command) { + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if shouldRenderJSONHelpForHelpFlag(cmd) { + if err := renderJSONHelp(cmd, cmd); err != nil { + renderCommandError(cmd, err) + } + return + } + defaultHelp(cmd, args) + }) + root.SetHelpCommand(newJSONAwareHelpCommand(root)) +} + +func newJSONAwareHelpCommand(root *cobra.Command) *cobra.Command { + cmd := &cobra.Command{ + Use: "help [command]", + Short: "Help about any command", + Long: "Help provides help for any command in the application.\nSimply type dbxcli help [path to command] for full details.", + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { + var completions []cobra.Completion + target, _, err := root.Find(args) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + if target == nil { + target = root + } + for _, child := range target.Commands() { + if (child.IsAvailableCommand() || child == cmd) && strings.HasPrefix(child.Name(), toComplete) { + completions = append(completions, cobra.CompletionWithDesc(child.Name(), child.Short)) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp + }, + RunE: func(cmd *cobra.Command, args []string) error { + target, _, err := root.Find(args) + if target == nil || err != nil { + if shouldRenderJSONHelpForHelpCommand(cmd) { + if err != nil { + return err + } + return fmt.Errorf("unknown help topic %#q", args) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Unknown help topic %#q\n", args) + return root.Usage() + } + if shouldRenderJSONHelpForHelpCommand(cmd) { + return renderJSONHelp(cmd, target) + } + target.InitDefaultHelpFlag() + target.InitDefaultVersionFlag() + return target.Help() + }, + } + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + cmd.Annotations[commandJSONHelpAnnotation] = "true" + return cmd +} + +func shouldRenderJSONHelpForHelpFlag(cmd *cobra.Command) bool { + if !jsonHelpOutputRequested(cmd) { + return false + } + flag := cmd.Flags().Lookup("help") + return flag != nil && flag.Changed +} + +func shouldRenderJSONHelpForHelpCommand(cmd *cobra.Command) bool { + return isJSONHelpCommand(cmd) && jsonHelpOutputRequested(cmd) +} + +func jsonHelpOutputRequested(cmd *cobra.Command) bool { + format, err := commandOutputFormatE(cmd) + return err == nil && format == output.FormatJSON +} + +func commandIsJSONHelp(cmd *cobra.Command) bool { + return shouldRenderJSONHelpForHelpCommand(cmd) +} + +func isJSONHelpCommand(cmd *cobra.Command) bool { + return cmd != nil && cmd.Annotations[commandJSONHelpAnnotation] == "true" +} + +func rawArgsRequestJSONHelp(args []string) bool { + return outputJSONRequested(args) && (rawArgsHaveHelpFlag(args) || rawArgsFirstCommand(args) == "help") +} + +func rawArgsHaveHelpFlag(args []string) bool { + for _, arg := range args { + switch arg { + case "--": + return false + case "--help", "-h": + return true + } + } + return false +} + +func rawArgsFirstCommand(args []string) string { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + return "" + case arg == "--output" || arg == "--as-member": + i++ + continue + case arg == "--verbose" || arg == "-v" || strings.HasPrefix(arg, "--output=") || strings.HasPrefix(arg, "--as-member="): + continue + case strings.HasPrefix(arg, "-"): + continue + default: + return arg + } + } + return "" +} + +func temporarilyClearDeprecatedCommands(root *cobra.Command) func() { + if root == nil { + return func() {} + } + + type deprecatedCommand struct { + cmd *cobra.Command + deprecated string + } + + var changed []deprecatedCommand + var walk func(*cobra.Command) + walk = func(cmd *cobra.Command) { + if cmd.Deprecated != "" { + changed = append(changed, deprecatedCommand{ + cmd: cmd, + deprecated: cmd.Deprecated, + }) + cmd.Deprecated = "" + } + for _, child := range cmd.Commands() { + walk(child) + } + } + walk(root) + + return func() { + for _, item := range changed { + item.cmd.Deprecated = item.deprecated + } + } +} + +func renderJSONHelp(invocation *cobra.Command, target *cobra.Command) error { + input := jsonHelpInput{ + Help: true, + Path: jsonHelpInputPath(target), + } + results := jsonHelpOperationResults(target) + out := newJSONCommandOperationOutput(target, input, results, nil) + return commandOutput(invocation).Render(nil, out) +} + +func jsonHelpInputPath(cmd *cobra.Command) string { + if cmd == nil || cmd.Root() == cmd { + return "" + } + return jsonCommandPath(cmd) +} + +func jsonHelpOperationResults(cmd *cobra.Command) []jsonOperationResult { + commands := publicCommandSubtree(cmd) + results := make([]jsonOperationResult, 0, len(commands)) + for _, command := range commands { + results = append(results, newJSONOperationResult( + jsonHelpStatusDescribed, + jsonHelpKindCommand, + nil, + jsonCommandManifestFor(command), + )) + } + return results +} + +func publicCommandSubtree(cmd *cobra.Command) []*cobra.Command { + if cmd == nil { + return nil + } + var commands []*cobra.Command + var walk func(*cobra.Command) + walk = func(current *cobra.Command) { + if !current.Hidden { + commands = append(commands, current) + } + children := append([]*cobra.Command{}, current.Commands()...) + sort.Slice(children, func(i, j int) bool { + return jsonCommandPath(children[i]) < jsonCommandPath(children[j]) + }) + for _, child := range children { + if child.Hidden { + continue + } + walk(child) + } + } + walk(cmd) + return commands +} + +func jsonCommandManifestFor(cmd *cobra.Command) jsonCommandManifest { + cmd.InitDefaultHelpFlag() + cmd.InitDefaultVersionFlag() + + return jsonCommandManifest{ + Path: jsonManifestCommandPath(cmd), + Use: cmd.UseLine(), + Short: cmd.Short, + Aliases: sortedCopyStringSlice(cmd.Aliases), + Runnable: cmd.Runnable(), + Flags: jsonCommandFlags(cmd), + SupportsStructuredOutput: commandSupportsStructuredOutput(cmd), + AuthModes: commandManifestAuthModes(cmd), + DestructiveLevel: commandManifestDestructiveLevel(cmd), + } +} + +func jsonManifestCommandPath(cmd *cobra.Command) string { + if cmd == nil { + return "" + } + if cmd.Root() == cmd { + return cmd.Name() + } + return jsonCommandPath(cmd) +} + +func jsonCommandFlags(cmd *cobra.Command) []jsonCommandFlag { + flagsByName := make(map[string]jsonCommandFlag) + addFlags := func(flags *pflag.FlagSet, inherited bool) { + if flags == nil { + return + } + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Hidden { + return + } + if _, exists := flagsByName[flag.Name]; exists { + return + } + flagType := "" + if flag.Value != nil { + flagType = flag.Value.Type() + } + flagsByName[flag.Name] = jsonCommandFlag{ + Name: flag.Name, + Type: flagType, + Default: flag.DefValue, + Usage: flag.Usage, + Inherited: inherited, + } + }) + } + + addFlags(cmd.NonInheritedFlags(), false) + addFlags(cmd.InheritedFlags(), true) + + names := make([]string, 0, len(flagsByName)) + for name := range flagsByName { + names = append(names, name) + } + sort.Strings(names) + + result := make([]jsonCommandFlag, 0, len(names)) + for _, name := range names { + result = append(result, flagsByName[name]) + } + return result +} + +func setCommandAuthModes(cmd *cobra.Command, modes ...string) { + setCommandAnnotationList(cmd, commandAuthModesAnnotation, modes) +} + +func setCommandDestructiveLevel(cmd *cobra.Command, level string) { + setCommandAnnotationList(cmd, commandDestructiveLevelAnnotation, []string{level}) +} + +func setCommandAnnotationList(cmd *cobra.Command, key string, values []string) { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + cmd.Annotations[key] = strings.Join(values, ",") +} + +func commandManifestAuthModes(cmd *cobra.Command) []string { + if modes := commandAnnotationList(cmd, commandAuthModesAnnotation); modes != nil { + return modes + } + if cmd == nil || cmd.Root() == cmd || !cmd.Runnable() || commandSkipsAuth(cmd) || isNoAuthCommand(cmd) { + return []string{} + } + if commandHasTopLevelName(cmd, "team") { + return []string{authTokenTypeName(tokenTeamManage)} + } + return []string{authTokenTypeName(tokenPersonal), authTokenTypeName(tokenTeamAccess)} +} + +func commandManifestDestructiveLevel(cmd *cobra.Command) string { + if levels := commandAnnotationList(cmd, commandDestructiveLevelAnnotation); len(levels) > 0 { + return levels[0] + } + return destructiveLevelNone +} + +func commandAnnotationList(cmd *cobra.Command, key string) []string { + if cmd == nil || cmd.Annotations == nil { + return nil + } + value, ok := cmd.Annotations[key] + if !ok { + return nil + } + if value == "" { + return []string{} + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + sort.Strings(result) + return result +} + +func isNoAuthCommand(cmd *cobra.Command) bool { + switch cmd.Name() { + case "login", "logout": + return true + default: + return false + } +} + +func commandHasTopLevelName(cmd *cobra.Command, name string) bool { + var topLevel *cobra.Command + for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() { + topLevel = c + } + return topLevel != nil && topLevel.Name() == name +} + +func sortedCopyStringSlice(values []string) []string { + if values == nil { + return []string{} + } + copied := append([]string{}, values...) + sort.Strings(copied) + return copied +} diff --git a/cmd/help_json_test.go b/cmd/help_json_test.go new file mode 100644 index 0000000..578746a --- /dev/null +++ b/cmd/help_json_test.go @@ -0,0 +1,711 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +type jsonHelpOutputForTest struct { + OK bool `json:"ok"` + SchemaVersion string `json:"schema_version"` + Command string `json:"command"` + Input jsonHelpInput `json:"input"` + Results []jsonHelpResultForTest `json:"results"` + Warnings []jsonWarning `json:"warnings"` +} + +type jsonHelpResultForTest struct { + Status string `json:"status"` + Kind string `json:"kind"` + Input map[string]any `json:"input"` + Result jsonCommandManifest `json:"result"` +} + +func TestJSONHelpSupportedForms(t *testing.T) { + tests := []struct { + name string + args []string + wantCommand string + wantPath string + wantResults []string + }{ + { + name: "root help output after", + args: []string{"--help", "--output=json"}, + wantCommand: "dbxcli", + wantPath: "", + wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "ls", "rm", "team", "team add-member", "team info"}, + }, + { + name: "root help output before", + args: []string{"--output=json", "--help"}, + wantCommand: "dbxcli", + wantPath: "", + wantResults: []string{"dbxcli", "completion", "completion bash", "completion fish", "completion powershell", "completion zsh", "help", "login", "ls", "rm", "team", "team add-member", "team info"}, + }, + { + name: "command help output after", + args: []string{"ls", "--help", "--output=json"}, + wantCommand: "ls", + wantPath: "ls", + wantResults: []string{"ls"}, + }, + { + name: "command help output before", + args: []string{"ls", "--output=json", "--help"}, + wantCommand: "ls", + wantPath: "ls", + wantResults: []string{"ls"}, + }, + { + name: "help command output after", + args: []string{"help", "ls", "--output=json"}, + wantCommand: "ls", + wantPath: "ls", + wantResults: []string{"ls"}, + }, + { + name: "help command output before", + args: []string{"--output=json", "help", "ls"}, + wantCommand: "ls", + wantPath: "ls", + wantResults: []string{"ls"}, + }, + { + name: "help command describes help", + args: []string{"help", "help", "--output=json"}, + wantCommand: "help", + wantPath: "help", + wantResults: []string{"help"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, stderr, err := executeJSONHelpTestRoot(t, tt.args) + if err != nil { + t.Fatalf("Execute returned error: %v\nstderr: %s", err, stderr) + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + got := decodeJSONHelpOutput(t, stdout) + if !got.OK { + t.Fatalf("ok = false, want true") + } + if got.SchemaVersion != jsonSchemaVersion { + t.Fatalf("schema_version = %q, want %q", got.SchemaVersion, jsonSchemaVersion) + } + if got.Command != tt.wantCommand { + t.Fatalf("command = %q, want %q", got.Command, tt.wantCommand) + } + if got.Input != (jsonHelpInput{Help: true, Path: tt.wantPath}) { + t.Fatalf("input = %+v, want help path %q", got.Input, tt.wantPath) + } + assertJSONHelpResultPaths(t, got, tt.wantResults) + for _, result := range got.Results { + if result.Status != jsonHelpStatusDescribed { + t.Fatalf("status = %q, want %q", result.Status, jsonHelpStatusDescribed) + } + if result.Kind != jsonHelpKindCommand { + t.Fatalf("kind = %q, want %q", result.Kind, jsonHelpKindCommand) + } + if len(result.Input) != 0 { + t.Fatalf("result input = %+v, want empty object", result.Input) + } + } + }) + } +} + +func TestJSONHelpCommandSubtree(t *testing.T) { + stdout, stderr, err := executeJSONHelpTestRoot(t, []string{"team", "--help", "--output=json"}) + if err != nil { + t.Fatalf("Execute returned error: %v\nstderr: %s", err, stderr) + } + got := decodeJSONHelpOutput(t, stdout) + assertJSONHelpResultPaths(t, got, []string{"team", "team add-member", "team info"}) +} + +func TestJSONHelpRealRootManifestIncludesPublicCommands(t *testing.T) { + RootCmd.InitDefaultHelpCmd() + + var got []string + for _, cmd := range publicCommandSubtree(RootCmd) { + got = append(got, jsonCommandManifestFor(cmd).Path) + } + want := []string{ + "dbxcli", + "account", + "completion", + "completion bash", + "completion fish", + "completion powershell", + "completion zsh", + "cp", + "du", + "get", + "help", + "login", + "logout", + "ls", + "mkdir", + "mv", + "put", + "restore", + "revs", + "rm", + "search", + "share", + "share list", + "share list folder", + "share list link", + "share-link", + "share-link create", + "share-link download", + "share-link info", + "share-link list", + "share-link revoke", + "share-link update", + "team", + "team add-member", + "team info", + "team list-groups", + "team list-members", + "team remove-member", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("real root manifest paths = %v, want %v", got, want) + } +} + +func TestJSONHelpManifestFields(t *testing.T) { + stdout, _, err := executeJSONHelpTestRoot(t, []string{"--help", "--output=json"}) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + got := decodeJSONHelpOutput(t, stdout) + + ls := jsonHelpManifestByPath(t, got, "ls") + if !ls.SupportsStructuredOutput { + t.Fatal("ls supports_structured_output = false, want true") + } + assertStringSliceEqual(t, "ls auth modes", ls.AuthModes, []string{"personal", "team-access"}) + if ls.DestructiveLevel != destructiveLevelNone { + t.Fatalf("ls destructive_level = %q, want none", ls.DestructiveLevel) + } + assertJSONHelpFlagNames(t, ls.Flags, []string{"as-member", "help", "include-deleted", "output", "verbose"}) + if jsonHelpHasFlag(ls.Flags, "domain") { + t.Fatal("hidden domain flag should be omitted") + } + outputFlag := jsonHelpFlagByName(t, ls.Flags, "output") + if !outputFlag.Inherited { + t.Fatal("output flag should be inherited on ls") + } + if outputFlag.Type != "string" || outputFlag.Default != "text" { + t.Fatalf("output flag = %+v, want string text default", outputFlag) + } + + login := jsonHelpManifestByPath(t, got, "login") + if login.SupportsStructuredOutput { + t.Fatal("login supports_structured_output = true, want false") + } + if len(login.AuthModes) != 0 { + t.Fatalf("login auth_modes = %v, want empty", login.AuthModes) + } + + rm := jsonHelpManifestByPath(t, got, "rm") + if rm.DestructiveLevel != destructiveLevelDelete { + t.Fatalf("rm destructive_level = %q, want delete", rm.DestructiveLevel) + } + + teamInfo := jsonHelpManifestByPath(t, got, "team info") + assertStringSliceEqual(t, "team info auth modes", teamInfo.AuthModes, []string{"team-manage"}) + + teamAdd := jsonHelpManifestByPath(t, got, "team add-member") + if teamAdd.DestructiveLevel != destructiveLevelAdmin { + t.Fatalf("team add-member destructive_level = %q, want admin", teamAdd.DestructiveLevel) + } + + helpFromRoot := jsonHelpManifestByPath(t, got, "help") + if !strings.Contains(helpFromRoot.Use, "[flags]") { + t.Fatalf("help use from root manifest = %q, want flags in use line", helpFromRoot.Use) + } + stdout, _, err = executeJSONHelpTestRoot(t, []string{"help", "help", "--output=json"}) + if err != nil { + t.Fatalf("Execute help help returned error: %v", err) + } + helpFromCommand := jsonHelpManifestByPath(t, decodeJSONHelpOutput(t, stdout), "help") + if helpFromCommand.Use != helpFromRoot.Use { + t.Fatalf("help use differs between manifests: root %q, command %q", helpFromRoot.Use, helpFromCommand.Use) + } +} + +func TestJSONHelpIsDeterministic(t *testing.T) { + first, _, err := executeJSONHelpTestRoot(t, []string{"--help", "--output=json"}) + if err != nil { + t.Fatalf("first Execute returned error: %v", err) + } + second, _, err := executeJSONHelpTestRoot(t, []string{"--output=json", "--help"}) + if err != nil { + t.Fatalf("second Execute returned error: %v", err) + } + if first != second { + t.Fatalf("JSON help output differs between equivalent invocations\nfirst: %s\nsecond: %s", first, second) + } + + got := decodeJSONHelpOutput(t, first) + for _, result := range got.Results { + var names []string + for _, flag := range result.Result.Flags { + names = append(names, flag.Name) + } + sorted := append([]string{}, names...) + sortStrings(sorted) + if !reflect.DeepEqual(names, sorted) { + t.Fatalf("flags for %s are not sorted: %v", result.Result.Path, names) + } + } +} + +func TestJSONHelpDoesNotRequireAuth(t *testing.T) { + t.Setenv(envAccessToken, "") + t.Setenv(envAuthFile, filepath.Join(t.TempDir(), "missing-auth.json")) + + stdout, stderr, err := executeJSONHelpTestRoot(t, []string{"help", "ls", "--output=json"}) + if err != nil { + t.Fatalf("Execute returned error: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) + } + got := decodeJSONHelpOutput(t, stdout) + assertJSONHelpResultPaths(t, got, []string{"ls"}) +} + +func TestJSONHelpAuthModeInferenceUsesTopLevelTeam(t *testing.T) { + root := &cobra.Command{Use: "dbxcli"} + tools := &cobra.Command{Use: "tools"} + nestedTeam := &cobra.Command{ + Use: "team", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(tools) + tools.AddCommand(nestedTeam) + + assertStringSliceEqual( + t, + "nested non-top-level team auth modes", + commandManifestAuthModes(nestedTeam), + []string{"personal", "team-access"}, + ) +} + +func TestJSONHelpAuthModeAnnotationOverridesInference(t *testing.T) { + root := &cobra.Command{Use: "dbxcli"} + cmd := &cobra.Command{ + Use: "custom", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(cmd) + setCommandAuthModes(cmd, "team-manage") + + assertStringSliceEqual( + t, + "explicit auth modes", + commandManifestAuthModes(cmd), + []string{"team-manage"}, + ) +} + +func TestJSONHelpAuthBypassRequiresInstalledHelpCommand(t *testing.T) { + root := &cobra.Command{Use: "dbxcli"} + root.PersistentFlags().String(outputFlag, "text", "") + if err := root.PersistentFlags().Set(outputFlag, "json"); err != nil { + t.Fatal(err) + } + + fakeHelp := &cobra.Command{ + Use: "help", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(fakeHelp) + if commandIsJSONHelp(fakeHelp) { + t.Fatal("non-annotated command named help should not bypass auth") + } + + jsonRoot := &cobra.Command{Use: "dbxcli"} + jsonRoot.PersistentFlags().String(outputFlag, "text", "") + if err := jsonRoot.PersistentFlags().Set(outputFlag, "json"); err != nil { + t.Fatal(err) + } + jsonHelp := newJSONAwareHelpCommand(jsonRoot) + jsonRoot.AddCommand(jsonHelp) + if !commandIsJSONHelp(jsonHelp) { + t.Fatal("installed JSON-aware help command should bypass auth for JSON help") + } +} + +func TestJSONHelpRawArgsDetection(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + { + name: "root help json", + args: []string{"--help", "--output=json"}, + want: true, + }, + { + name: "command help json", + args: []string{"share", "list", "link", "--help", "--output=json"}, + want: true, + }, + { + name: "help command json", + args: []string{"--output=json", "help", "share", "list", "link"}, + want: true, + }, + { + name: "normal command json", + args: []string{"share", "list", "link", "--output=json"}, + want: false, + }, + { + name: "text help", + args: []string{"share", "list", "link", "--help"}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rawArgsRequestJSONHelp(tt.args); got != tt.want { + t.Fatalf("rawArgsRequestJSONHelp(%v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} + +func TestJSONHelpSuppressesCobraDeprecationWarning(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + root := &cobra.Command{ + Use: "dbxcli", + SilenceUsage: true, + SilenceErrors: true, + } + root.PersistentFlags().String(outputFlag, "text", "Output format: text, json") + deprecated := &cobra.Command{ + Use: "old", + Short: "Old command", + Deprecated: "use new instead", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(deprecated) + installJSONHelp(root) + + args := []string{"old", "--help", "--output=json"} + restoreDeprecated := func() {} + if rawArgsRequestJSONHelp(args) { + restoreDeprecated = temporarilyClearDeprecatedCommands(root) + } + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if got := stderr.String(); got != "" { + t.Fatalf("stderr = %q, want no deprecation warning", got) + } + got := decodeJSONHelpOutput(t, stdout.String()) + assertJSONHelpResultPaths(t, got, []string{"old"}) + if deprecated.Deprecated != "" { + t.Fatalf("deprecated restored too early: %q", deprecated.Deprecated) + } + restoreDeprecated() + if deprecated.Deprecated != "use new instead" { + t.Fatalf("deprecated = %q, want restored original message", deprecated.Deprecated) + } +} + +func TestJSONHelpForUnsupportedStructuredCommand(t *testing.T) { + stdout, _, err := executeJSONHelpTestRoot(t, []string{"login", "--help", "--output=json"}) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + got := decodeJSONHelpOutput(t, stdout) + login := jsonHelpManifestByPath(t, got, "login") + if login.SupportsStructuredOutput { + t.Fatal("login supports_structured_output = true, want false") + } +} + +func TestJSONHelpPreservesTextHelpAndRootDefaultHelp(t *testing.T) { + stdout, stderr, err := executeJSONHelpTestRoot(t, []string{"--help"}) + if err != nil { + t.Fatalf("text help returned error: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "Usage:") { + t.Fatalf("stdout = %q, want text help", stdout) + } + if strings.Contains(stdout, `"ok"`) { + t.Fatalf("stdout = %q, want no JSON envelope", stdout) + } + + stdout, stderr, err = executeJSONHelpTestRoot(t, []string{"--output=json"}) + if err != nil { + t.Fatalf("root default help returned error: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "Usage:") || strings.Contains(stdout, `"ok"`) { + t.Fatalf("stdout = %q, want text root help", stdout) + } + + stdout, stderr, err = executeJSONHelpTestRoot(t, []string{"help", "help"}) + if err != nil { + t.Fatalf("help command text help returned error: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "dbxcli help [command]") || strings.Contains(stdout, `"ok"`) { + t.Fatalf("stdout = %q, want text help-command help", stdout) + } + + stdout, stderr, err = executeJSONHelpTestRoot(t, []string{"help", "missing"}) + if err != nil { + t.Fatalf("unknown help topic returned error: %v\nstderr: %s", err, stderr) + } + if !strings.Contains(stdout, "Unknown help topic") || !strings.Contains(stdout, "missing") || !strings.Contains(stdout, "Usage:") || strings.Contains(stdout, `"ok"`) { + t.Fatalf("stdout = %q, want Cobra-style unknown help topic text", stdout) + } +} + +func TestJSONHelpPreservesHelpCommandCompletions(t *testing.T) { + root := newJSONHelpTestRoot(t) + root.InitDefaultHelpCmd() + helpCmd, _, err := root.Find([]string{"help"}) + if err != nil { + t.Fatalf("find help command: %v", err) + } + if helpCmd.ValidArgsFunction == nil { + t.Fatal("help command should provide command-name completions") + } + + completions, directive := helpCmd.ValidArgsFunction(helpCmd, nil, "l") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("directive = %v, want no-file-completion", directive) + } + want := []cobra.Completion{ + cobra.CompletionWithDesc("login", "Log in and save Dropbox credentials"), + cobra.CompletionWithDesc("ls", "List files and folders"), + } + if !reflect.DeepEqual(completions, want) { + t.Fatalf("completions = %v, want %v", completions, want) + } + + completions, directive = helpCmd.ValidArgsFunction(helpCmd, nil, "h") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("help directive = %v, want no-file-completion", directive) + } + want = []cobra.Completion{ + cobra.CompletionWithDesc("help", "Help about any command"), + } + if !reflect.DeepEqual(completions, want) { + t.Fatalf("help completions = %v, want %v", completions, want) + } +} + +func TestJSONHelpDoesNotChangeCommandExecution(t *testing.T) { + var stdout bytes.Buffer + called := false + root := newJSONHelpTestRoot(t) + ls, _, _ := root.Find([]string{"ls"}) + ls.RunE = func(cmd *cobra.Command, args []string) error { + called = true + _, _ = cmd.OutOrStdout().Write([]byte("executed\n")) + return nil + } + root.PersistentPreRunE = nil + root.SetOut(&stdout) + root.SetArgs([]string{"ls", "--output=json", "/"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if !called { + t.Fatal("ls command did not execute") + } + if got := stdout.String(); got != "executed\n" { + t.Fatalf("stdout = %q, want command output", got) + } +} + +func newJSONHelpTestRoot(t *testing.T) *cobra.Command { + t.Helper() + + root := &cobra.Command{ + Use: "dbxcli", + Short: "A command line tool for Dropbox users and team admins", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: initDbx, + } + root.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose logging") + root.PersistentFlags().String(outputFlag, "text", "Output format: text, json") + root.PersistentFlags().String("as-member", "", "Member ID to perform action as") + root.PersistentFlags().String("domain", "", "Override default Dropbox domain, useful for testing") + if err := root.PersistentFlags().MarkHidden("domain"); err != nil { + t.Fatalf("hide domain flag: %v", err) + } + + ls := &cobra.Command{ + Use: "ls [flags] []", + Short: "List files and folders", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + enableStructuredOutput(ls) + ls.Flags().BoolP("include-deleted", "d", false, "Include deleted files") + + login := &cobra.Command{ + Use: "login", + Short: "Log in and save Dropbox credentials", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + + rm := &cobra.Command{ + Use: "rm [flags] ", + Short: "Remove files or folders", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + enableStructuredOutput(rm) + setCommandDestructiveLevel(rm, destructiveLevelDelete) + + team := &cobra.Command{ + Use: "team", + Short: "Team management commands", + } + teamInfo := &cobra.Command{ + Use: "info", + Short: "Get team information", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + enableStructuredOutput(teamInfo) + teamAdd := &cobra.Command{ + Use: "add-member", + Short: "Add a new member to a team", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + enableStructuredOutput(teamAdd) + setCommandDestructiveLevel(teamAdd, destructiveLevelAdmin) + team.AddCommand(teamAdd, teamInfo) + + hidden := &cobra.Command{ + Use: "hidden", + Short: "Hidden command", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + + root.AddCommand(ls, login, rm, team, hidden) + installJSONHelp(root) + return root +} + +func executeJSONHelpTestRoot(t *testing.T, args []string) (string, string, error) { + t.Helper() + + t.Setenv(envAccessToken, "") + t.Setenv(envAuthFile, filepath.Join(t.TempDir(), "missing-auth.json")) + + var stdout bytes.Buffer + var stderr bytes.Buffer + root := newJSONHelpTestRoot(t) + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(args) + + err := root.Execute() + return stdout.String(), stderr.String(), err +} + +func decodeJSONHelpOutput(t *testing.T, stdout string) jsonHelpOutputForTest { + t.Helper() + + var got jsonHelpOutputForTest + if err := json.Unmarshal([]byte(stdout), &got); err != nil { + t.Fatalf("decode JSON help output: %v\noutput: %s", err, stdout) + } + if got.Warnings == nil { + t.Fatalf("warnings = nil, want empty array") + } + if len(got.Warnings) != 0 { + t.Fatalf("warnings = %+v, want empty", got.Warnings) + } + return got +} + +func assertJSONHelpResultPaths(t *testing.T, got jsonHelpOutputForTest, want []string) { + t.Helper() + + var paths []string + for _, result := range got.Results { + paths = append(paths, result.Result.Path) + } + if !reflect.DeepEqual(paths, want) { + t.Fatalf("result paths = %v, want %v", paths, want) + } +} + +func jsonHelpManifestByPath(t *testing.T, got jsonHelpOutputForTest, path string) jsonCommandManifest { + t.Helper() + + for _, result := range got.Results { + if result.Result.Path == path { + return result.Result + } + } + t.Fatalf("manifest for %q not found", path) + return jsonCommandManifest{} +} + +func assertJSONHelpFlagNames(t *testing.T, flags []jsonCommandFlag, want []string) { + t.Helper() + + var got []string + for _, flag := range flags { + got = append(got, flag.Name) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("flag names = %v, want %v", got, want) + } +} + +func jsonHelpHasFlag(flags []jsonCommandFlag, name string) bool { + for _, flag := range flags { + if flag.Name == name { + return true + } + } + return false +} + +func jsonHelpFlagByName(t *testing.T, flags []jsonCommandFlag, name string) jsonCommandFlag { + t.Helper() + + for _, flag := range flags { + if flag.Name == name { + return flag + } + } + t.Fatalf("flag %q not found", name) + return jsonCommandFlag{} +} + +func sortStrings(values []string) { + sort.Strings(values) +} diff --git a/cmd/json_contract_test.go b/cmd/json_contract_test.go index 0e6b4b6..30f74ad 100644 --- a/cmd/json_contract_test.go +++ b/cmd/json_contract_test.go @@ -32,24 +32,24 @@ func TestStructuredOutputCommandAudit(t *testing.T) { } func TestStructuredOutputSuccessFixtureAudit(t *testing.T) { - structuredCommands := structuredOutputCommandPathsWithVersion() - structuredSet := make(map[string]bool, len(structuredCommands)) - for _, command := range structuredCommands { - structuredSet[command] = true + contractCommands := jsonContractCommandPathsWithVersion() + contractSet := make(map[string]bool, len(contractCommands)) + for _, command := range contractCommands { + contractSet[command] = true } fixtures := jsonSuccessFixtureCoverage() - for _, command := range structuredCommands { + for _, command := range contractCommands { if fixtures[command].file == "" { - t.Errorf("structured command %q has no success JSON fixture coverage entry", command) + t.Errorf("JSON contract command %q has no success JSON fixture coverage entry", command) } } for command, fixture := range fixtures { if fixture.file == "" { t.Errorf("fixture coverage for %q has empty file", command) } - if !structuredSet[command] { - t.Errorf("fixture coverage includes non-structured command %q", command) + if !contractSet[command] { + t.Errorf("fixture coverage includes non-contract command %q", command) } if len(fixture.tests) == 0 { t.Errorf("fixture coverage for %q has no test functions", command) @@ -72,22 +72,22 @@ func TestStructuredOutputGoldenSchemaAudit(t *testing.T) { assertStringSliceMapEqual(t, "schema definitions", contract.Definitions, jsonContractDefinitions()) - structuredCommands := structuredOutputCommandPathsWithVersion() - structuredSet := make(map[string]bool, len(structuredCommands)) - for _, command := range structuredCommands { - structuredSet[command] = true + contractCommands := jsonContractCommandPathsWithVersion() + contractSet := make(map[string]bool, len(contractCommands)) + for _, command := range contractCommands { + contractSet[command] = true } want := jsonCommandSchemas() - for _, command := range structuredCommands { + for _, command := range contractCommands { gotSchema, ok := contract.Commands[command] if !ok { - t.Errorf("structured command %q has no golden schema", command) + t.Errorf("JSON contract command %q has no golden schema", command) continue } wantSchema, ok := want[command] if !ok { - t.Errorf("structured command %q has no code-derived schema", command) + t.Errorf("JSON contract command %q has no code-derived schema", command) continue } assertGoldenCommandSchemaEqual(t, command, gotSchema, wantSchema) @@ -95,15 +95,15 @@ func TestStructuredOutputGoldenSchemaAudit(t *testing.T) { assertGoldenCommandStatuses(t, command, gotSchema) } for command, schema := range contract.Commands { - if !structuredSet[command] { - t.Errorf("golden schema includes non-structured command %q", command) + if !contractSet[command] { + t.Errorf("golden schema includes non-contract command %q", command) } assertGoldenCommandSchemaReferences(t, command, schema, contract.Definitions) assertGoldenCommandStatuses(t, command, schema) } for command := range want { - if !structuredSet[command] { - t.Errorf("code-derived schema includes non-structured command %q", command) + if !contractSet[command] { + t.Errorf("code-derived schema includes non-contract command %q", command) } } } @@ -112,35 +112,35 @@ func TestStructuredOutputGoldenSuccessOutputAudit(t *testing.T) { fixtures := loadJSONGoldenSuccessOutputs(t) examples := jsonGoldenSuccessOutputExamples() - structuredCommands := structuredOutputCommandPathsWithVersion() - structuredSet := make(map[string]bool, len(structuredCommands)) - for _, command := range structuredCommands { - structuredSet[command] = true + contractCommands := jsonContractCommandPathsWithVersion() + contractSet := make(map[string]bool, len(contractCommands)) + for _, command := range contractCommands { + contractSet[command] = true } - for _, command := range structuredCommands { + for _, command := range contractCommands { fixture, ok := fixtures[command] if !ok { - t.Errorf("structured command %q has no golden success output", command) + t.Errorf("JSON contract command %q has no golden success output", command) continue } example, ok := examples[command] if !ok { - t.Errorf("structured command %q has no code-derived success output example", command) + t.Errorf("JSON contract command %q has no code-derived success output example", command) continue } assertGoldenJSONEqual(t, command, fixture, example) assertGoldenSuccessOutputStatuses(t, command, fixture) } for command := range fixtures { - if !structuredSet[command] { - t.Errorf("golden success output includes non-structured command %q", command) + if !contractSet[command] { + t.Errorf("golden success output includes non-contract command %q", command) } assertGoldenSuccessOutputStatuses(t, command, fixtures[command]) } for command := range examples { - if !structuredSet[command] { - t.Errorf("code-derived success output example includes non-structured command %q", command) + if !contractSet[command] { + t.Errorf("code-derived success output example includes non-contract command %q", command) } } } @@ -217,6 +217,11 @@ func structuredOutputCommandPathsWithVersion() []string { return append(paths, NewVersionCommand("test").Name()) } +func jsonContractCommandPathsWithVersion() []string { + paths := structuredOutputCommandPathsWithVersion() + return append(paths, "help") +} + func expectedStructuredOutputCommands() []string { return []string{ "account", @@ -302,6 +307,10 @@ func jsonSuccessFixtureCoverage() map[string]jsonSuccessFixture { file: "get_test.go", tests: []string{"TestGetJSONFileOutputsDownloadedResult", "TestGetJSONRecursiveOutputsDirectoryAndFileResults"}, }, + "help": { + file: "help_json_test.go", + tests: []string{"TestJSONHelpSupportedForms", "TestJSONHelpManifestFields"}, + }, "ls": { file: "ls_test.go", tests: []string{"TestLsJSONListsResultsAndInput", "TestLsJSONDeletedEntryIsStructured"}, @@ -576,6 +585,25 @@ func jsonGoldenSuccessOutputExamples() map[string]jsonOperationOutput { "get": newJSONOperationOutput(getCommandInput{Source: "/Reports/old.pdf", Target: "old.pdf", Recursive: false, Stdout: false}, []jsonOperationResult{ newJSONOperationResult(getStatusDownloaded, getKindFile, getResultInput{Source: "/Reports/old.pdf", Target: "old.pdf"}, file), }, nil), + "help": newJSONOperationOutput(jsonHelpInput{Help: true, Path: "ls"}, []jsonOperationResult{ + newJSONOperationResult(jsonHelpStatusDescribed, jsonHelpKindCommand, nil, jsonCommandManifest{ + Path: "ls", + Use: "dbxcli ls [flags] []", + Short: "List files and folders", + Aliases: []string{}, + Runnable: true, + Flags: []jsonCommandFlag{{ + Name: "output", + Type: "string", + Default: "text", + Usage: "Output format: text, json", + Inherited: true, + }}, + SupportsStructuredOutput: true, + AuthModes: []string{"personal", "team-access"}, + DestructiveLevel: destructiveLevelNone, + }), + }, nil), "ls": newJSONOperationOutput(lsInput{Path: "/Reports", Recursive: false, IncludeDeleted: true, OnlyDeleted: false, Long: true, Sort: "name", Reverse: false, Time: "server", TimeFormat: "2006-01-02"}, []jsonOperationResult{ newJSONOperationResult(lsJSONStatusListed, file.Type, nil, file), }, nil), @@ -787,8 +815,11 @@ func jsonContractDefinitions() map[string][]string { "du_allocation": jsonFieldNames[duAllocation](), "du_output": jsonFieldNames[duOutput](), "empty": {}, + "command_flag": jsonFieldNames[jsonCommandFlag](), + "command_manifest": jsonFieldNames[jsonCommandManifest](), "get_input": jsonFieldNames[getCommandInput](), "get_result_input": jsonFieldNames[getResultInput](), + "help_input": jsonFieldNames[jsonHelpInput](), "ls_input": jsonFieldNames[lsInput](), "metadata": jsonFieldNames[jsonMetadata](), "mkdir_input": jsonFieldNames[mkdirInput](), @@ -829,6 +860,7 @@ func jsonCommandSchemas() map[string]jsonGoldenCommandSchema { "cp": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusCopied}, metadataKinds(), nil), "du": operationSchema("empty", schemaRef("empty"), "du_output", []string{duJSONStatusReported}, []string{duKindSpaceUsage}, nil), "get": operationSchema("get_input", schemaRef("get_result_input"), "metadata", []string{getStatusCreated, getStatusDownloaded, getStatusExisting}, []string{getKindFile, getKindFolder}, nil), + "help": operationSchema("help_input", schemaRef("empty"), "command_manifest", []string{jsonHelpStatusDescribed}, []string{jsonHelpKindCommand}, nil), "ls": operationSchema("ls_input", schemaRef("empty"), "metadata", []string{lsJSONStatusListed}, metadataKinds(), nil), "mkdir": operationSchema("mkdir_input", schemaRef("mkdir_input"), "metadata", []string{mkdirStatusCreated, mkdirStatusExisting}, []string{mkdirKindFolder}, nil), "mv": operationSchema("empty", schemaRef("relocation_input"), "metadata", []string{relocationJSONStatusMoved}, metadataKinds(), nil), diff --git a/cmd/remove-member.go b/cmd/remove-member.go index 21a84eb..a182044 100644 --- a/cmd/remove-member.go +++ b/cmd/remove-member.go @@ -61,4 +61,5 @@ var removeMemberCmd = &cobra.Command{ func init() { teamCmd.AddCommand(removeMemberCmd) enableStructuredOutput(removeMemberCmd) + setCommandDestructiveLevel(removeMemberCmd, destructiveLevelAdmin) } diff --git a/cmd/rm.go b/cmd/rm.go index 1450da4..8a1eb66 100644 --- a/cmd/rm.go +++ b/cmd/rm.go @@ -246,6 +246,7 @@ var rmCmd = &cobra.Command{ func init() { RootCmd.AddCommand(rmCmd) enableStructuredOutput(rmCmd) + setCommandDestructiveLevel(rmCmd, destructiveLevelDelete) rmCmd.Flags().BoolP("force", "f", false, "Allow removing non-empty folders; same as --recursive") rmCmd.Flags().BoolP("recursive", "r", false, "Recursively remove folders") rmCmd.Flags().Bool("permanent", false, "Permanently delete instead of moving to Dropbox trash") diff --git a/cmd/root.go b/cmd/root.go index 2440599..c9fd24d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -145,6 +145,9 @@ func makeDropboxConfig(token string, verbose bool, asMember string, domain strin } func initDbx(cmd *cobra.Command, args []string) (err error) { + if commandIsJSONHelp(cmd) { + return nil + } if err := validateOutputFormat(cmd); err != nil { return err } @@ -224,6 +227,12 @@ manage your team and more. It is easy, scriptable and works on all platforms!`, // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { jsonErrorOutput := outputJSONRequested(os.Args[1:]) + restoreDeprecatedCommands := func() {} + if rawArgsRequestJSONHelp(os.Args[1:]) { + restoreDeprecatedCommands = temporarilyClearDeprecatedCommands(RootCmd) + } + defer restoreDeprecatedCommands() + cmd, err := RootCmd.ExecuteC() if err != nil { renderCommandErrorWithJSON(cmd, err, jsonErrorOutput) @@ -246,4 +255,5 @@ func init() { _ = RootCmd.PersistentFlags().MarkHidden("domain") loadOAuthCredentialsFromEnv() + installJSONHelp(RootCmd) } diff --git a/cmd/testdata/json_contract/success_outputs.json b/cmd/testdata/json_contract/success_outputs.json index 38e8c55..a1ac0a3 100644 --- a/cmd/testdata/json_contract/success_outputs.json +++ b/cmd/testdata/json_contract/success_outputs.json @@ -3,6 +3,7 @@ "cp": {"ok":true,"schema_version":"1","command":"cp","input":{},"results":[{"input":{"from_path":"/Reports/old.pdf","to_path":"/Reports/copy.pdf"},"result":{"type":"file","path_display":"/Reports/copy.pdf","path_lower":"/reports/copy.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"copied","kind":"file"}],"warnings":[]}, "du": {"ok":true,"schema_version":"1","command":"du","input":{},"results":[{"kind":"space_usage","input":{},"result":{"used":2048,"allocation":{"type":"team","allocated":1000000,"used":2048,"user_within_team_space_allocated":500000,"user_within_team_space_used_cached":1024,"user_within_team_space_limit_type":"fixed"}},"status":"reported"}],"warnings":[]}, "get": {"ok":true,"schema_version":"1","command":"get","input":{"source":"/Reports/old.pdf","target":"old.pdf","recursive":false,"stdout":false},"results":[{"status":"downloaded","kind":"file","input":{"source":"/Reports/old.pdf","target":"old.pdf"},"result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"}}],"warnings":[]}, + "help": {"ok":true,"schema_version":"1","command":"help","input":{"help":true,"path":"ls"},"results":[{"status":"described","kind":"command","input":{},"result":{"path":"ls","use":"dbxcli ls [flags] []","short":"List files and folders","aliases":[],"runnable":true,"flags":[{"name":"output","type":"string","default":"text","usage":"Output format: text, json","inherited":true}],"supports_structured_output":true,"auth_modes":["personal","team-access"],"destructive_level":"none"}}],"warnings":[]}, "ls": {"ok":true,"schema_version":"1","command":"ls","input":{"path":"/Reports","recursive":false,"include_deleted":true,"only_deleted":false,"long":true,"sort":"name","reverse":false,"time":"server","time_format":"2006-01-02"},"results":[{"status":"listed","kind":"file","result":{"type":"file","path_display":"/Reports/old.pdf","path_lower":"/reports/old.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"input":{}}],"warnings":[]}, "mkdir": {"ok":true,"schema_version":"1","command":"mkdir","input":{"path":"/Reports/new","parents":true},"results":[{"status":"created","kind":"folder","input":{"path":"/Reports/new","parents":true},"result":{"type":"folder","path_display":"/Reports/new","path_lower":"/reports/new","id":"id:folder"}}],"warnings":[]}, "mv": {"ok":true,"schema_version":"1","command":"mv","input":{},"results":[{"input":{"from_path":"/Reports/copy.pdf","to_path":"/Reports/moved.pdf"},"result":{"type":"file","path_display":"/Reports/moved.pdf","path_lower":"/reports/moved.pdf","id":"id:file","rev":"015f","size":123,"server_modified":"2026-06-25T12:00:00Z","client_modified":"2026-06-25T11:00:00Z"},"status":"moved","kind":"file"}],"warnings":[]}, diff --git a/cmd/testdata/json_contract/success_schemas.json b/cmd/testdata/json_contract/success_schemas.json index 5923fc5..7a5d434 100644 --- a/cmd/testdata/json_contract/success_schemas.json +++ b/cmd/testdata/json_contract/success_schemas.json @@ -31,6 +31,24 @@ "member_id", "name" ], + "command_flag": [ + "default", + "inherited", + "name", + "type", + "usage" + ], + "command_manifest": [ + "aliases", + "auth_modes", + "destructive_level", + "flags", + "path", + "runnable", + "short", + "supports_structured_output", + "use" + ], "du_allocation": [ "allocated", "type", @@ -54,6 +72,10 @@ "source", "target" ], + "help_input": [ + "help", + "path" + ], "ls_input": [ "include_deleted", "long", @@ -349,6 +371,20 @@ ], "warnings": [] }, + "help": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "help_input", + "result_input": "empty", + "result": "command_manifest", + "statuses": [ + "described" + ], + "kinds": [ + "command" + ], + "warnings": [] + }, "ls": { "top_level": "operation_output", "result_wrapper": "operation_result", diff --git a/docs/json-schema/v1/README.md b/docs/json-schema/v1/README.md index 000b0fb..a152a5f 100644 --- a/docs/json-schema/v1/README.md +++ b/docs/json-schema/v1/README.md @@ -42,11 +42,13 @@ human-facing warnings, diagnostics, and verbose logs are written to stderr. In JSON mode, error responses are written to stdout and the process exits with a non-zero status. -Commands that intentionally do not support JSON output yet include `login`, -`logout`, and `completion`. Cobra help output and shell-completion protocol -commands are also text-only: `dbxcli --help --output=json`, `dbxcli --output=json` -without a command, and command-specific help such as -`dbxcli version --help --output=json` print text help. +Commands that intentionally do not support structured command-result JSON yet +include `login`, `logout`, and `completion`. Their help output is still +available as a JSON command manifest with `--help --output=json`; for example, +`dbxcli --help --output=json`, `dbxcli version --help --output=json`, and +`dbxcli --output=json help version`. `dbxcli --output=json` without `--help` +continues to print text help, and shell-completion protocol commands remain +text-only. Current JSON-enabled command paths include `version`, `account`, `du`, `ls`, `search`, `revs`, `cp`, `mv`, `put`, `get`, `share-link create`, diff --git a/docs/json-schema/v1/commands.json b/docs/json-schema/v1/commands.json index 5923fc5..7a5d434 100644 --- a/docs/json-schema/v1/commands.json +++ b/docs/json-schema/v1/commands.json @@ -31,6 +31,24 @@ "member_id", "name" ], + "command_flag": [ + "default", + "inherited", + "name", + "type", + "usage" + ], + "command_manifest": [ + "aliases", + "auth_modes", + "destructive_level", + "flags", + "path", + "runnable", + "short", + "supports_structured_output", + "use" + ], "du_allocation": [ "allocated", "type", @@ -54,6 +72,10 @@ "source", "target" ], + "help_input": [ + "help", + "path" + ], "ls_input": [ "include_deleted", "long", @@ -349,6 +371,20 @@ ], "warnings": [] }, + "help": { + "top_level": "operation_output", + "result_wrapper": "operation_result", + "input": "help_input", + "result_input": "empty", + "result": "command_manifest", + "statuses": [ + "described" + ], + "kinds": [ + "command" + ], + "warnings": [] + }, "ls": { "top_level": "operation_output", "result_wrapper": "operation_result",