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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <n>` 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

Expand Down
5 changes: 3 additions & 2 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Commands:
completion <shell> 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
Expand Down Expand Up @@ -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> Text to match (also accepted as a positional argument)
--deep Search turns, checkpoints, files, and refs too
--repo <name> Only include sessions for a repository
Expand Down
35 changes: 34 additions & 1 deletion cmd/dispatch/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}

Expand Down
54 changes: 54 additions & 0 deletions cmd/dispatch/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down