Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,24 @@ Available Commands:

Flags:
--as-member string Member ID to perform action as
--output string Output format: text, json (default "text")
-v, --verbose Enable verbose logging

Use "dbxcli [command] --help" for more information about a command.
```

### Output formats

Text output is the default. JSON output is available through the global `--output` flag as commands are migrated:

```sh
$ dbxcli <command> --output=json
```

JSON support is rolling out command by command. Commands that have not been migrated return `structured output is not supported for this command yet` when used with `--output=json`.

Command results are written to stdout. Status, progress, warnings, diagnostics, errors, and verbose logs are written to stderr. JSON errors are not wrapped in a JSON response object.

### Authentication

By default, `dbxcli` stores OAuth credentials in `~/.config/dbxcli/auth.json`.
Expand Down Expand Up @@ -314,6 +327,7 @@ Available Commands:

Global Flags:
--as-member string Member ID to perform action as
--output string Output format: text, json (default "text")
-v, --verbose Enable verbose logging

Use "dbxcli team [command] --help" for more information about a command.
Expand Down
60 changes: 60 additions & 0 deletions cmd/json_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package cmd

import (
"time"

"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
)

type jsonMetadata struct {
Type string `json:"type"`
PathDisplay string `json:"path_display,omitempty"`
PathLower string `json:"path_lower,omitempty"`
ID string `json:"id,omitempty"`
Rev string `json:"rev,omitempty"`
Size *uint64 `json:"size,omitempty"`
ServerModified *string `json:"server_modified,omitempty"`
ClientModified *string `json:"client_modified,omitempty"`
Deleted bool `json:"deleted,omitempty"`
}

func jsonMetadataFromDropbox(metadata files.IsMetadata) jsonMetadata {
switch m := metadata.(type) {
case *files.FileMetadata:
size := m.Size
return jsonMetadata{
Type: "file",
PathDisplay: m.PathDisplay,
PathLower: m.PathLower,
ID: m.Id,
Rev: m.Rev,
Size: &size,
ServerModified: jsonTime(m.ServerModified),
ClientModified: jsonTime(m.ClientModified),
}
case *files.FolderMetadata:
return jsonMetadata{
Type: "folder",
PathDisplay: m.PathDisplay,
PathLower: m.PathLower,
ID: m.Id,
}
case *files.DeletedMetadata:
return jsonMetadata{
Type: "deleted",
PathDisplay: m.PathDisplay,
PathLower: m.PathLower,
Deleted: true,
}
default:
return jsonMetadata{Type: "unknown"}
}
}

func jsonTime(t time.Time) *string {
if t.IsZero() {
return nil
}
value := t.UTC().Format(time.RFC3339)
return &value
}
95 changes: 95 additions & 0 deletions cmd/json_metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package cmd

import (
"encoding/json"
"strings"
"testing"
"time"

"github.com/dropbox/dropbox-sdk-go-unofficial/v6/dropbox/files"
)

func TestJSONMetadataFromDropboxFile(t *testing.T) {
clientModified := time.Date(2026, 6, 21, 10, 30, 0, 0, time.FixedZone("test", -7*60*60))
serverModified := time.Date(2026, 6, 22, 11, 45, 0, 0, time.UTC)
metadata := &files.FileMetadata{
Metadata: files.Metadata{
PathDisplay: "/Reports/File.txt",
PathLower: "/reports/file.txt",
},
Id: "id:abc",
Rev: "rev123",
Size: 0,
ClientModified: clientModified,
ServerModified: serverModified,
}

got := jsonMetadataFromDropbox(metadata)

if got.Type != "file" {
t.Fatalf("Type = %q, want file", got.Type)
}
if got.PathDisplay != "/Reports/File.txt" {
t.Fatalf("PathDisplay = %q", got.PathDisplay)
}
if got.Size == nil || *got.Size != 0 {
t.Fatalf("Size = %v, want pointer to 0", got.Size)
}
if got.ClientModified == nil || *got.ClientModified != "2026-06-21T17:30:00Z" {
t.Fatalf("ClientModified = %v", got.ClientModified)
}
if got.ServerModified == nil || *got.ServerModified != "2026-06-22T11:45:00Z" {
t.Fatalf("ServerModified = %v", got.ServerModified)
}

encoded, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "is_downloadable") {
t.Fatalf("encoded metadata exposes SDK-specific fields: %s", encoded)
}
if strings.Contains(string(encoded), "deleted") {
t.Fatalf("encoded non-deleted metadata should omit deleted field: %s", encoded)
}
}

func TestJSONMetadataFromDropboxFolder(t *testing.T) {
metadata := &files.FolderMetadata{
Metadata: files.Metadata{
PathDisplay: "/Reports",
PathLower: "/reports",
},
Id: "id:folder",
}

got := jsonMetadataFromDropbox(metadata)

if got.Type != "folder" {
t.Fatalf("Type = %q, want folder", got.Type)
}
if got.ID != "id:folder" {
t.Fatalf("ID = %q, want id:folder", got.ID)
}
if got.Size != nil {
t.Fatalf("Size = %v, want nil for folder", got.Size)
}
}

func TestJSONMetadataFromDropboxDeleted(t *testing.T) {
metadata := &files.DeletedMetadata{
Metadata: files.Metadata{
PathDisplay: "/Reports/Old.txt",
PathLower: "/reports/old.txt",
},
}

got := jsonMetadataFromDropbox(metadata)

if got.Type != "deleted" {
t.Fatalf("Type = %q, want deleted", got.Type)
}
if !got.Deleted {
t.Fatal("Deleted = false, want true")
}
}
2 changes: 1 addition & 1 deletion cmd/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ By default, login stores credentials for regular Dropbox user commands.
Use "team-access" for --as-member commands or "team-manage" for team commands.`,
Args: cobra.MaximumNArgs(1),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return nil
return validateOutputFormat(cmd)
},
RunE: login,
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ var logoutCmd = &cobra.Command{
Use: "logout [flags]",
Short: "Log out of the current session",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return nil
return validateOutputFormat(cmd)
},
RunE: logout,
}
Expand Down
66 changes: 59 additions & 7 deletions cmd/output.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package cmd

import (
"fmt"

"github.com/dropbox/dbxcli/internal/output"
"github.com/spf13/cobra"
)

const jsonOutputFlag = "json"
const (
outputFlag = "output"
structuredOutputSupportedAnnotation = "dbxcli.supportsStructuredOutput"
)

func commandOutput(cmd *cobra.Command) *output.Renderer {
if cmd == nil {
Expand All @@ -16,17 +21,64 @@ func commandOutput(cmd *cobra.Command) *output.Renderer {
}

func commandOutputFormat(cmd *cobra.Command) output.Format {
jsonOutput, err := cmd.Flags().GetBool(jsonOutputFlag)
format, err := commandOutputFormatE(cmd)
if err != nil {
jsonOutput, err = cmd.InheritedFlags().GetBool(jsonOutputFlag)
return output.FormatText
}
return format
}

func commandOutputFormatE(cmd *cobra.Command) (output.Format, error) {
value := string(output.FormatText)
if cmd != nil {
value = commandOutputFlagValue(cmd)
}
return parseOutputFormat(value)
}

func commandOutputFlagValue(cmd *cobra.Command) string {
value, err := cmd.Flags().GetString(outputFlag)
if err == nil {
return value
}
value, err = cmd.InheritedFlags().GetString(outputFlag)
if err == nil {
return value
}
return string(output.FormatText)
}

func parseOutputFormat(value string) (output.Format, error) {
switch output.Format(value) {
case output.FormatText:
return output.FormatText, nil
case output.FormatJSON:
return output.FormatJSON, nil
default:
return "", fmt.Errorf("unsupported output format %q: use text or json", value)
}
}

func validateOutputFormat(cmd *cobra.Command) error {
format, err := commandOutputFormatE(cmd)
if err != nil {
jsonOutput, err = cmd.PersistentFlags().GetBool(jsonOutputFlag)
return err
}
if err == nil && jsonOutput {
return output.FormatJSON
if format == output.FormatJSON && !commandSupportsStructuredOutput(cmd) {
return output.ErrStructuredOutputUnsupported
}
return nil
}

func commandSupportsStructuredOutput(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[structuredOutputSupportedAnnotation] == "true"
}

func enableStructuredOutput(cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}
return output.FormatText
cmd.Annotations[structuredOutputSupportedAnnotation] = "true"
}

func commandVerbose(cmd *cobra.Command) bool {
Expand Down
Loading