From 49286f9c69e4f81839407764b37b8b3829d9063b Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:58:11 -0700 Subject: [PATCH] Add JSONL output to search Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e02217cd-ffed-496e-b8ea-848a781cda64 --- cmd/dispatch/main.go | 5 +-- cmd/dispatch/search.go | 46 +++++++++++++++++++------- cmd/dispatch/search_test.go | 64 +++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index d689ff5..3fff606 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, CSV, IDs, or a table + search [query] [flags] Print matching sessions as JSON, JSONL, CSV, IDs, or a table tags [--json] List tags in use with per-tag session counts aliases [--json] List session aliases with orphan detection alias Set, reassign, clear (--clear), or remove (--remove) a session alias @@ -179,9 +179,10 @@ Stats flags: Search flags: --json Print results as JSON (default) + --jsonl Print one JSON object per line --ids Print one session ID per line --table Print a readable table - --format json|csv|ids|table + --format json|jsonl|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 diff --git a/cmd/dispatch/search.go b/cmd/dispatch/search.go index 4e6c591..e1377c2 100644 --- a/cmd/dispatch/search.go +++ b/cmd/dispatch/search.go @@ -30,6 +30,7 @@ type searchOutputFormat string const ( searchFormatJSON searchOutputFormat = "json" + searchFormatJSONL searchOutputFormat = "jsonl" searchFormatIDs searchOutputFormat = "ids" searchFormatTable searchOutputFormat = "table" searchFormatCSV searchOutputFormat = "csv" @@ -94,20 +95,13 @@ func runSearch(w io.Writer, args []string) error { if opts.format == searchFormatCSV { return writeSearchCSV(w, sessions) } + if opts.format == searchFormatJSONL { + return writeSearchJSONL(w, sessions) + } results := make([]searchSession, 0, len(sessions)) for _, s := range sessions { - results = append(results, searchSession{ - ID: s.ID, - Summary: s.Summary, - Cwd: s.Cwd, - Repository: s.Repository, - Branch: s.Branch, - CreatedAt: s.CreatedAt, - UpdatedAt: s.UpdatedAt, - TurnCount: s.TurnCount, - FileCount: s.FileCount, - }) + results = append(results, newSearchSession(s)) } enc := json.NewEncoder(w) @@ -115,6 +109,30 @@ func runSearch(w io.Writer, args []string) error { return enc.Encode(results) } +func writeSearchJSONL(w io.Writer, sessions []data.Session) error { + enc := json.NewEncoder(w) + for _, s := range sessions { + if err := enc.Encode(newSearchSession(s)); err != nil { + return err + } + } + return nil +} + +func newSearchSession(s data.Session) searchSession { + return searchSession{ + ID: s.ID, + Summary: s.Summary, + Cwd: s.Cwd, + Repository: s.Repository, + Branch: s.Branch, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + TurnCount: s.TurnCount, + FileCount: s.FileCount, + } +} + func writeSearchIDs(w io.Writer, sessions []data.Session) error { for _, s := range sessions { if _, err := fmt.Fprintln(w, s.ID); err != nil { @@ -229,6 +247,8 @@ func parseSearchArgs(args []string) (searchOptions, error) { switch { case name == "--json": opts.format = searchFormatJSON + case name == "--jsonl": + opts.format = searchFormatJSONL case name == "--ids": opts.format = searchFormatIDs case name == "--table": @@ -399,6 +419,8 @@ func parseSearchFormat(v string) (searchOutputFormat, error) { switch strings.ToLower(strings.TrimSpace(v)) { case string(searchFormatJSON): return searchFormatJSON, nil + case string(searchFormatJSONL): + return searchFormatJSONL, nil case string(searchFormatIDs): return searchFormatIDs, nil case string(searchFormatTable): @@ -406,7 +428,7 @@ func parseSearchFormat(v string) (searchOutputFormat, error) { case string(searchFormatCSV): return searchFormatCSV, nil default: - return "", fmt.Errorf("invalid --format value %q (want json, ids, table, or csv)", v) + return "", fmt.Errorf("invalid --format value %q (want json, jsonl, ids, table, or csv)", v) } } diff --git a/cmd/dispatch/search_test.go b/cmd/dispatch/search_test.go index 3462671..854538a 100644 --- a/cmd/dispatch/search_test.go +++ b/cmd/dispatch/search_test.go @@ -105,6 +105,9 @@ func TestParseSearchArgsIDFormats(t *testing.T) { args []string }{ {name: "ids shortcut", args: []string{"search", "--ids"}}, + {name: "jsonl shortcut", args: []string{"search", "--jsonl"}}, + {name: "format jsonl separate", args: []string{"search", "--format", "jsonl"}}, + {name: "format jsonl inline", args: []string{"search", "--format=jsonl"}}, {name: "format ids separate", args: []string{"search", "--format", "ids"}}, {name: "format ids inline", args: []string{"search", "--format=ids"}}, {name: "table shortcut", args: []string{"search", "--table"}}, @@ -121,6 +124,9 @@ func TestParseSearchArgsIDFormats(t *testing.T) { t.Fatalf("parseSearchArgs returned error: %v", err) } want := searchFormatIDs + if strings.Contains(strings.Join(tc.args, " "), "jsonl") { + want = searchFormatJSONL + } if strings.Contains(strings.Join(tc.args, " "), "table") { want = searchFormatTable } @@ -360,6 +366,64 @@ func TestRunSearchCSVEmptyPrintsHeader(t *testing.T) { } } +func TestRunSearchJSONLOutput(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, + }, + {ID: "session-b"}, + } + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { + return sessions, nil + }) + + var buf bytes.Buffer + if err := runSearch(&buf, []string{"search", "--jsonl"}); err != nil { + t.Fatalf("runSearch returned error: %v", err) + } + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d JSONL lines, want 2: %q", len(lines), buf.String()) + } + var first searchSession + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("first line is not valid JSON: %v\n%s", err, lines[0]) + } + if first.ID != "session-a" || first.Repository != "jongio/dispatch" || first.TurnCount != 5 { + t.Errorf("first line = %+v", first) + } + var second searchSession + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("second line is not valid JSON: %v\n%s", err, lines[1]) + } + if second.ID != "session-b" { + t.Errorf("second line ID = %q, want session-b", second.ID) + } +} + +func TestRunSearchJSONLEmptyIsEmpty(t *testing.T) { + withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { + return nil, nil + }) + + var buf bytes.Buffer + if err := runSearch(&buf, []string{"search", "--format", "jsonl"}); err != nil { + t.Fatalf("runSearch returned error: %v", err) + } + if got := buf.String(); got != "" { + t.Errorf("output = %q, want empty", got) + } +} + func TestRunSearchEmptyIsEmptyArray(t *testing.T) { withSearchList(t, func(data.FilterOptions, data.SortOptions, int) ([]data.Session, error) { return nil, nil