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",