From 16a2c2e9828adfcd492224a6e109829f05f9a520 Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:54:54 -0700 Subject: [PATCH] feat(cli): add CSV output to search Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 5 ++-- cmd/dispatch/main.go | 5 ++-- cmd/dispatch/search.go | 35 +++++++++++++++++++++++- cmd/dispatch/search_test.go | 54 +++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bf2de97..5f3f609 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,7 @@ dispatch search --deep refactor --json dispatch search auth --format ids dispatch search auth --ids dispatch search auth --table +dispatch search auth --csv ``` The query can be passed as a positional argument or with `--query`. Filters mirror the interactive search and the `stats` command: @@ -422,9 +423,9 @@ The query can be passed as a positional argument or with `--query`. Filters mirr - `--since` / `--until` accept `YYYY-MM-DD` or full RFC3339 timestamps. - `--deep` also searches turns, checkpoints, touched files, and refs. - `--limit ` caps the result count (default 50, `0` for no limit). -- `--format json|ids|table` chooses JSON output, one session ID per line, or a readable table. `--ids` and `--table` are shortcuts. +- `--format json|csv|ids|table` chooses JSON output, CSV output, one session ID per line, or a readable table. `--csv`, `--ids`, and `--table` are shortcuts. -Each JSON result includes `id`, `summary`, `cwd`, `repository`, `branch`, `created_at`, `updated_at`, `turn_count`, and `file_count`. No JSON matches prints `[]`; no ID matches prints nothing. Both exit 0. Invalid flags or an unreadable store exit non-zero with a message on stderr. +Each JSON or CSV result includes `id`, `summary`, `cwd`, `repository`, `branch`, `created_at`, `updated_at`, `turn_count`, and `file_count`. No JSON matches prints `[]`; no ID matches prints nothing. CSV always prints a header row. Invalid flags or an unreadable store exit non-zero with a message on stderr. ### Man diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 8931a16..67d4d12 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -126,7 +126,7 @@ Commands: completion Print shell completion (bash, zsh, fish, powershell) doctor [--json] Print environment diagnostics (--json for machine-readable output) stats [flags] Print session totals and breakdowns - search [query] [flags] Print matching sessions as JSON, IDs, or a table + search [query] [flags] Print matching sessions as JSON, CSV, IDs, or a table tags [--json] List tags in use with per-tag session counts aliases [--json] List session aliases with orphan detection notes [command] List, get, set, or clear session notes @@ -177,7 +177,8 @@ Search flags: --json Print results as JSON (default) --ids Print one session ID per line --table Print a readable table - --format json|ids|table Choose the output format + --format json|csv|ids|table + Choose the output format --query Text to match (also accepted as a positional argument) --deep Search turns, checkpoints, files, and refs too --repo Only include sessions for a repository diff --git a/cmd/dispatch/search.go b/cmd/dispatch/search.go index 5bb39d5..8f57bf4 100644 --- a/cmd/dispatch/search.go +++ b/cmd/dispatch/search.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/csv" "encoding/json" "fmt" "io" @@ -31,6 +32,7 @@ const ( searchFormatJSON searchOutputFormat = "json" searchFormatIDs searchOutputFormat = "ids" searchFormatTable searchOutputFormat = "table" + searchFormatCSV searchOutputFormat = "csv" ) // searchOptions holds the parsed flags for the search command. @@ -83,6 +85,9 @@ func runSearch(w io.Writer, args []string) error { if opts.format == searchFormatTable { return writeSearchTable(w, sessions) } + if opts.format == searchFormatCSV { + return writeSearchCSV(w, sessions) + } results := make([]searchSession, 0, len(sessions)) for _, s := range sessions { @@ -135,6 +140,30 @@ func writeSearchTable(w io.Writer, sessions []data.Session) error { return tw.Flush() } +func writeSearchCSV(w io.Writer, sessions []data.Session) error { + cw := csv.NewWriter(w) + if err := cw.Write([]string{"id", "summary", "cwd", "repository", "branch", "created_at", "updated_at", "turn_count", "file_count"}); err != nil { + return err + } + for _, s := range sessions { + if err := cw.Write([]string{ + s.ID, + s.Summary, + s.Cwd, + s.Repository, + s.Branch, + s.CreatedAt, + s.UpdatedAt, + strconv.Itoa(s.TurnCount), + strconv.Itoa(s.FileCount), + }); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} + func shortSearchID(id string) string { if len(id) <= 12 { return id @@ -194,6 +223,8 @@ func parseSearchArgs(args []string) (searchOptions, error) { opts.format = searchFormatIDs case name == "--table": opts.format = searchFormatTable + case name == "--csv": + opts.format = searchFormatCSV case name == "--format": v, ni, err := takeValue(i, "--format", inlineOrEmpty(inline, hasInline)) if err != nil { @@ -297,8 +328,10 @@ func parseSearchFormat(v string) (searchOutputFormat, error) { return searchFormatIDs, nil case string(searchFormatTable): return searchFormatTable, nil + case string(searchFormatCSV): + return searchFormatCSV, nil default: - return "", fmt.Errorf("invalid --format value %q (want json, ids, or table)", v) + return "", fmt.Errorf("invalid --format value %q (want json, ids, table, or csv)", v) } } diff --git a/cmd/dispatch/search_test.go b/cmd/dispatch/search_test.go index f3dc491..3c72fbe 100644 --- a/cmd/dispatch/search_test.go +++ b/cmd/dispatch/search_test.go @@ -94,6 +94,9 @@ func TestParseSearchArgsIDFormats(t *testing.T) { {name: "table shortcut", args: []string{"search", "--table"}}, {name: "format table separate", args: []string{"search", "--format", "table"}}, {name: "format table inline", args: []string{"search", "--format=table"}}, + {name: "csv shortcut", args: []string{"search", "--csv"}}, + {name: "format csv separate", args: []string{"search", "--format", "csv"}}, + {name: "format csv inline", args: []string{"search", "--format=csv"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -105,6 +108,9 @@ func TestParseSearchArgsIDFormats(t *testing.T) { if strings.Contains(strings.Join(tc.args, " "), "table") { want = searchFormatTable } + if strings.Contains(strings.Join(tc.args, " "), "csv") { + want = searchFormatCSV + } if opts.format != want { t.Errorf("format = %q, want %s", opts.format, want) } @@ -260,6 +266,54 @@ func TestRunSearchTableEmptyPrintsHeader(t *testing.T) { } } +func TestRunSearchCSVOutput(t *testing.T) { + sessions := []data.Session{ + { + ID: "session-a", + Summary: "fix, auth bug", + Cwd: "/code/app", + Repository: "jongio/dispatch", + Branch: "main", + CreatedAt: "2026-01-05T10:00:00Z", + UpdatedAt: "2026-01-06T10:00:00Z", + TurnCount: 5, + FileCount: 3, + }, + } + withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { + return sessions, nil + }) + + var buf bytes.Buffer + if err := runSearch(&buf, []string{"search", "--csv"}); err != nil { + t.Fatalf("runSearch returned error: %v", err) + } + + got := buf.String() + for _, want := range []string{ + "id,summary,cwd,repository,branch,created_at,updated_at,turn_count,file_count", + "session-a,\"fix, auth bug\",/code/app,jongio/dispatch,main,2026-01-05T10:00:00Z,2026-01-06T10:00:00Z,5,3", + } { + if !strings.Contains(got, want) { + t.Errorf("csv output missing %q:\n%s", want, got) + } + } +} + +func TestRunSearchCSVEmptyPrintsHeader(t *testing.T) { + withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { + return nil, nil + }) + + var buf bytes.Buffer + if err := runSearch(&buf, []string{"search", "--format", "csv"}); err != nil { + t.Fatalf("runSearch returned error: %v", err) + } + if got, want := buf.String(), "id,summary,cwd,repository,branch,created_at,updated_at,turn_count,file_count\n"; got != want { + t.Errorf("output = %q, want %q", got, want) + } +} + func TestRunSearchEmptyIsEmptyArray(t *testing.T) { withSearchList(t, func(data.FilterOptions, int) ([]data.Session, error) { return nil, nil