From d1c334aa64f29768d2e49d3b9ce5f276c16ddf07 Mon Sep 17 00:00:00 2001 From: Andy Tran Date: Sun, 15 Mar 2026 13:49:58 -0400 Subject: [PATCH] feat: flatten JSON output for search and plans commands Embed Session/Plan structs directly in SearchResult and PlanMatch instead of nesting under a "session"/"plan" key. This makes fields like .id, .project_name, and .created accessible at the top level in jq, matching what users and agents naturally guess. Previously, 25/25 sessions using --json produced all-null output because consumers wrote .id instead of .session.id. After flattening, 4/4 test agents got field names right on the first try. Also adds JSON field list and jq example to search --help. Co-Authored-By: Claude Opus 4.6 --- internal/app/app_test.go | 4 ++-- internal/app/cli.go | 2 +- internal/app/plans.go | 8 ++++---- internal/index/index_test.go | 8 ++++---- internal/index/search.go | 6 +++--- internal/plan/plan.go | 2 +- internal/plan/plan_test.go | 4 ++-- internal/session/session.go | 4 ++-- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 46ad8f0..16db4c2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -155,8 +155,8 @@ func TestSearchCmd_JSON(t *testing.T) { if len(results) == 0 { t.Fatal("expected at least 1 search result") } - if _, ok := results[0]["session"]; !ok { - t.Fatal("expected session field in result") + if _, ok := results[0]["id"]; !ok { + t.Fatal("expected id field in result (flattened session)") } } diff --git a/internal/app/cli.go b/internal/app/cli.go index 99525c8..79bff4f 100644 --- a/internal/app/cli.go +++ b/internal/app/cli.go @@ -21,7 +21,7 @@ type CLI struct { Default DefaultCmd `cmd:"" default:"noargs" hidden:""` List ListCmd `cmd:"" help:"List recent sessions"` - Search SearchCmd `cmd:"" help:"Search session content"` + Search SearchCmd `cmd:"" help:"Search session content\n\nJSON fields: id, short_id, project_name, project_path, created, modified, first_prompt, git_branch, message_count, matches, score\n\nExample: cct search 'query' --json | jq '.[] | {short_id, project_name, created}'"` Info InfoCmd `cmd:"" help:"Show session metadata and first prompt"` Resume ResumeCmd `cmd:"" help:"Resume a session (auto-switches directory)"` Export ExportCmd `cmd:"" help:"Export session messages (with filtering)"` diff --git a/internal/app/plans.go b/internal/app/plans.go index 10bcee5..e80dd52 100644 --- a/internal/app/plans.go +++ b/internal/app/plans.go @@ -136,9 +136,9 @@ func (cmd *PlansSearchCmd) Run(globals *Globals) error { for _, m := range matches { tbl.Row( []string{ - output.Truncate(m.Plan.Name, tbl.ColWidth(0)), - output.FormatAge(m.Plan.Modified), - output.Truncate(m.Plan.Title, tbl.ColWidth(2)), + output.Truncate(m.Name, tbl.ColWidth(0)), + output.FormatAge(m.Modified), + output.Truncate(m.Title, tbl.ColWidth(2)), m.Snippet, }, []func(string) string{output.Dim, output.Dim, output.Bold, nil}, @@ -147,7 +147,7 @@ func (cmd *PlansSearchCmd) Run(globals *Globals) error { if len(matches) > 0 { fmt.Println() - fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct plans cp %s", matches[0].Plan.Name))) + fmt.Printf(" %s\n", output.Cyan(fmt.Sprintf("cct plans cp %s", matches[0].Name))) } fmt.Println() return nil diff --git a/internal/index/index_test.go b/internal/index/index_test.go index 12a1399..2d167c8 100644 --- a/internal/index/index_test.go +++ b/internal/index/index_test.go @@ -471,8 +471,8 @@ func TestSearch_CompoundTermFiltering(t *testing.T) { if len(results) != 1 { t.Fatalf("expected 1 result for 'pre-commit', got %d", len(results)) } - if results[0].Session.ID != "match111-2222-3333-4444-555555555555" { - t.Errorf("expected matching session, got %s", results[0].Session.ID) + if results[0].ID != "match111-2222-3333-4444-555555555555" { + t.Errorf("expected matching session, got %s", results[0].ID) } } @@ -534,8 +534,8 @@ func TestSearch_CrossMessageMultiTerm(t *testing.T) { if len(results) != 1 { t.Fatalf("expected 1 result for cross-message 'fix bug', got %d", len(results)) } - if results[0].Session.ID != "cross111-2222-3333-4444-555555555555" { - t.Errorf("expected cross-message session, got %s", results[0].Session.ID) + if results[0].ID != "cross111-2222-3333-4444-555555555555" { + t.Errorf("expected cross-message session, got %s", results[0].ID) } } diff --git a/internal/index/search.go b/internal/index/search.go index 2dc5de4..fc9cab0 100644 --- a/internal/index/search.go +++ b/internal/index/search.go @@ -33,9 +33,9 @@ type SearchOptions struct { } type SearchResult struct { - Session *session.Session `json:"session"` - Matches []session.Match `json:"matches"` - Score float64 `json:"score"` + *session.Session + Matches []session.Match `json:"matches"` + Score float64 `json:"score"` } type sessionInfo struct { diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 2a89bed..da0e95f 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -97,7 +97,7 @@ func SearchPlans(query string, snippetWidth int) ([]PlanMatch, error) { } type PlanMatch struct { - Plan Plan `json:"plan"` + Plan Snippet string `json:"snippet"` } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index cd14979..f69782b 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -122,8 +122,8 @@ func TestSearchPlans(t *testing.T) { if len(matches) != 1 { t.Fatalf("expected 1 match, got %d", len(matches)) } - if matches[0].Plan.Name != "auth-refactor" { - t.Errorf("matched plan = %q, want auth-refactor", matches[0].Plan.Name) + if matches[0].Name != "auth-refactor" { + t.Errorf("matched plan = %q, want auth-refactor", matches[0].Name) } }) diff --git a/internal/session/session.go b/internal/session/session.go index 9ef35b3..2a73d95 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -36,8 +36,8 @@ type Match struct { } type SearchResult struct { - Session *Session `json:"session"` - Matches []Match `json:"matches"` + *Session + Matches []Match `json:"matches"` } func ExtractIDFromFilename(path string) string {