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
16 changes: 10 additions & 6 deletions docs/site/commands/cu_export_tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ Export tasks to CSV, JSON, or Markdown format.

Examples:
# Export all tasks from a list to CSV
cu export tasks --list mylist --format csv --output tasks.csv
cu export tasks --list mylist --format csv --file tasks.csv

# Export tasks with specific status to JSON
cu export tasks --list mylist --status open --format json > open-tasks.json


# -o selects the format here, the same as everywhere else in cu
cu export tasks --list mylist -o json

# Generate a Markdown report of high priority tasks
cu export tasks --priority high --format markdown --output report.md
cu export tasks --priority high --format markdown --file report.md

```
cu export tasks [flags]
Expand All @@ -24,10 +27,10 @@ cu export tasks [flags]

```
--assignee string Filter by assignee
-F, --file string Write to a file instead of stdout
-f, --format string Export format (csv, json, markdown) (default "csv")
-h, --help help for tasks
-l, --list string List ID to export tasks from
-o, --output string Output file (default: stdout)
--priority string Filter by priority
-s, --space string Space ID to export tasks from
--status string Filter by status
Expand All @@ -38,10 +41,11 @@ cu export tasks [flags]
```
--config string config file (default is $HOME/.config/cu/config.yaml)
--debug enable debug mode
-o, --output string output format (table|json|yaml|csv) (default "table")
```

### SEE ALSO

* [cu export](cu_export.md) - Export data to various formats

###### Auto generated by spf13/cobra on 28-Aug-2026
###### Auto generated by spf13/cobra on 29-Aug-2026
93 changes: 79 additions & 14 deletions internal/cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,34 +28,32 @@ var exportTasksCmd = &cobra.Command{

Examples:
# Export all tasks from a list to CSV
cu export tasks --list mylist --format csv --output tasks.csv
cu export tasks --list mylist --format csv --file tasks.csv

# Export tasks with specific status to JSON
cu export tasks --list mylist --status open --format json > open-tasks.json


# -o selects the format here, the same as everywhere else in cu
cu export tasks --list mylist -o json

# Generate a Markdown report of high priority tasks
cu export tasks --priority high --format markdown --output report.md`,
cu export tasks --priority high --format markdown --file report.md`,
Run: func(cmd *cobra.Command, args []string) {
ctx := context.Background()

// Get flags
listID, _ := cmd.Flags().GetString("list")
spaceID, _ := cmd.Flags().GetString("space")
format, _ := cmd.Flags().GetString("format")
outputFile, _ := cmd.Flags().GetString("output")
outputFile, _ := cmd.Flags().GetString("file")
status, _ := cmd.Flags().GetString("status")
priority, _ := cmd.Flags().GetString("priority")
assignee, _ := cmd.Flags().GetString("assignee")

// Validate format
format = strings.ToLower(format)
if format != "csv" && format != "json" && format != "markdown" && format != "md" {
fmt.Fprintf(os.Stderr, "Invalid format: %s. Must be csv, json, or markdown\n", format)
format, err := resolveExportFormat(cmd)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if format == "md" {
format = "markdown"
}

// Create API client
client, err := api.NewClient()
Expand Down Expand Up @@ -185,6 +183,71 @@ Examples:
},
}

// exportFormats are the formats `export tasks` can produce, keyed by every
// spelling accepted for them. This is a different set from what the global
// --output offers elsewhere in cu: a task export has no "table" or "yaml"
// rendering, and "markdown" exists only here.
var exportFormats = map[string]string{
"csv": "csv",
"json": "json",
"markdown": "markdown",
"md": "markdown",
}

// resolveExportFormat picks the export format from --format and the global
// --output, which mean the same thing for this command.
//
// export used to define its own -o/--output as the *destination file*, which
// shadowed the global format flag: `cu export tasks -o json` wrote a file
// literally named "json". Freeing -o fixes that, but simply not reading it
// would replace one silent failure with another — a user who asked for JSON
// would receive the default CSV and no warning. So export honours it, and
// anything it cannot produce is a loud error rather than a quiet fallback.
func resolveExportFormat(cmd *cobra.Command) (string, error) {
formatFlag, _ := cmd.Flags().GetString("format")
outputFlag, _ := cmd.Flags().GetString("output")
formatSet := cmd.Flags().Changed("format")
outputSet := cmd.Flags().Changed("output")

if formatSet && outputSet {
f, fok := exportFormats[strings.ToLower(formatFlag)]
if !fok {
return "", unsupportedExportFormat("--format", formatFlag)
}
o, ook := exportFormats[strings.ToLower(outputFlag)]
if !ook {
return "", unsupportedExportFormat("--output", outputFlag)
}
// Both name a format, so disagreeing is ambiguous rather than
// resolvable — guessing a winner is how silently-wrong output happens.
if f != o {
return "", fmt.Errorf("conflicting formats: --format %s and --output %s select the same setting; pass only one", formatFlag, outputFlag)
}
return f, nil
}

flag, value := "--format", formatFlag
if outputSet {
flag, value = "--output", outputFlag
}
resolved, ok := exportFormats[strings.ToLower(value)]
if !ok {
return "", unsupportedExportFormat(flag, value)
}
return resolved, nil
}

// unsupportedExportFormat explains a rejected format, and recognises the one
// mistake this flag change makes likely: a value that looks like a path is
// almost certainly someone reaching for the old `-o <file>`.
func unsupportedExportFormat(flag, value string) error {
base := fmt.Errorf("%s %q is not an export format (csv, json, markdown)", flag, value)
if strings.ContainsAny(value, `/\.`) {
return fmt.Errorf("%w\nIf you meant a destination file, that is now --file %s", base, value)
}
return base
}

func filterTasksForExport(tasks []clickup.Task, status, priority, assignee string) []clickup.Task {
var filtered []clickup.Task

Expand Down Expand Up @@ -350,7 +413,9 @@ func init() {
exportTasksCmd.Flags().StringP("list", "l", "", "List ID to export tasks from")
exportTasksCmd.Flags().StringP("space", "s", "", "Space ID to export tasks from")
exportTasksCmd.Flags().StringP("format", "f", "csv", "Export format (csv, json, markdown)")
exportTasksCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
// Deliberately not "output"/-o: that is the global format flag, and
// redefining it here shadowed it, so `-o json` wrote a file named "json".
exportTasksCmd.Flags().StringP("file", "F", "", "Write to a file instead of stdout")
exportTasksCmd.Flags().String("status", "", "Filter by status")
exportTasksCmd.Flags().String("priority", "", "Filter by priority")
exportTasksCmd.Flags().String("assignee", "", "Filter by assignee")
Expand Down
101 changes: 98 additions & 3 deletions internal/cmd/export_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package cmd

import (
"io"
"os"
"strings"
"testing"

"github.com/raksul/go-clickup/clickup"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestExportCmd_Structure(t *testing.T) {
Expand Down Expand Up @@ -138,9 +141,101 @@ func TestExportCmd_CommandFlags(t *testing.T) {
assert.NotNil(t, formatFlag)
assert.Equal(t, "csv", formatFlag.DefValue)

outputFlag := cmd.Flags().Lookup("output")
assert.NotNil(t, outputFlag)
assert.Equal(t, "", outputFlag.DefValue)
fileFlag := cmd.LocalFlags().Lookup("file")
assert.NotNil(t, fileFlag, "the destination file lives on --file")
assert.Equal(t, "F", fileFlag.Shorthand)
assert.Equal(t, "", fileFlag.DefValue)
})

// The regression guard for #28. Defining a local "output" flag here shadowed
// the global format flag, so `cu export tasks -o json` wrote a file named
// "json" instead of emitting JSON. -o must stay inherited.
t.Run("output is not redefined locally", func(t *testing.T) {
assert.Nil(t, exportTasksCmd.LocalFlags().Lookup("output"),
"export must not shadow the global -o/--output format flag")
})
}

func TestResolveExportFormat(t *testing.T) {
// newCmd mirrors the real flag surface: --format is local to export, while
// --output is inherited from the root command.
newCmd := func(t *testing.T, args ...string) *cobra.Command {
t.Helper()
c := &cobra.Command{Use: "tasks", Run: func(*cobra.Command, []string) {}}
c.Flags().StringP("format", "f", "csv", "")
c.Flags().StringP("output", "o", "table", "")
c.SetArgs(args)
c.SetOut(io.Discard)
c.SetErr(io.Discard)
require.NoError(t, c.Execute())
return c
}

t.Run("defaults to csv", func(t *testing.T) {
got, err := resolveExportFormat(newCmd(t))
require.NoError(t, err)
assert.Equal(t, "csv", got)
})

t.Run("-o selects the format", func(t *testing.T) {
// The bug in #28: this used to write a file named "json".
got, err := resolveExportFormat(newCmd(t, "-o", "json"))
require.NoError(t, err)
assert.Equal(t, "json", got)
})

t.Run("--format still works", func(t *testing.T) {
got, err := resolveExportFormat(newCmd(t, "--format", "markdown"))
require.NoError(t, err)
assert.Equal(t, "markdown", got)
})

t.Run("md is an alias for markdown", func(t *testing.T) {
got, err := resolveExportFormat(newCmd(t, "--format", "md"))
require.NoError(t, err)
assert.Equal(t, "markdown", got)
})

t.Run("matching is case-insensitive", func(t *testing.T) {
got, err := resolveExportFormat(newCmd(t, "-o", "JSON"))
require.NoError(t, err)
assert.Equal(t, "json", got)
})

t.Run("agreeing flags are accepted", func(t *testing.T) {
got, err := resolveExportFormat(newCmd(t, "--format", "json", "-o", "json"))
require.NoError(t, err)
assert.Equal(t, "json", got)
})

t.Run("disagreeing flags are an error, not a guess", func(t *testing.T) {
_, err := resolveExportFormat(newCmd(t, "--format", "csv", "-o", "json"))
require.Error(t, err)
assert.Contains(t, err.Error(), "conflicting formats")
})

t.Run("a format export cannot produce is rejected", func(t *testing.T) {
// "yaml" and "table" are valid globally but meaningless for an export,
// so they must fail loudly rather than fall back to csv.
for _, v := range []string{"yaml", "table"} {
_, err := resolveExportFormat(newCmd(t, "-o", v))
require.Error(t, err, v)
assert.Contains(t, err.Error(), "is not an export format")
}
})

t.Run("a path-shaped value points at --file", func(t *testing.T) {
// The predictable mistake after this change: reaching for the old
// `-o <file>`. Say where the file flag went instead of just rejecting.
_, err := resolveExportFormat(newCmd(t, "-o", "tasks.csv"))
require.Error(t, err)
assert.Contains(t, err.Error(), "--file tasks.csv")
})

t.Run("a bad --format is rejected too", func(t *testing.T) {
_, err := resolveExportFormat(newCmd(t, "--format", "xlsx"))
require.Error(t, err)
assert.Contains(t, err.Error(), "--format")
})
}

Expand Down
Loading