diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index ddaf685..5673acb 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -191,6 +191,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "info": + if iErr := runInfo(os.Stdout, args); iErr != nil { + fmt.Fprintf(os.Stderr, "info: %v\n", iErr) + return true, cleanup, startupOptions{}, iErr + } + return true, cleanup, startupOptions{}, nil + case "man": if mErr := runMan(os.Stdout); mErr != nil { fmt.Fprintf(os.Stderr, "man: %v\n", mErr) @@ -203,7 +210,6 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd // dynamic candidates. Deliberately omitted from help and usage. runComplete(os.Stdout, args) return true, cleanup, startupOptions{}, nil - case "--demo": c, demoErr := setupDemo() if demoErr != nil { @@ -352,7 +358,7 @@ const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" local bin="${COMP_WORDS[0]}" - local commands="help version open new doctor update completion stats search tags config export man" + local commands="help version open new doctor update completion stats search tags config export info man" local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query" if [[ "${COMP_CWORD}" -eq 1 ]]; then @@ -386,7 +392,7 @@ const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { local -a commands flags configsubs shells aliases configkeys local bin=${words[1]} - commands=(help version open new doctor update completion stats search tags config export man) + commands=(help version open new doctor update completion stats search tags config export info man) configsubs=(list get set unset edit path) flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query) @@ -438,7 +444,7 @@ end for bin in dispatch disp complete -c $bin -f - complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export man' + complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags config export info man' complete -c $bin -n '__dispatch_needs_command' -a '-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query' complete -c $bin -n '__dispatch_after completion' -a "($bin __complete shells)" complete -c $bin -n '__dispatch_after open' -a "($bin __complete aliases)" @@ -447,7 +453,7 @@ end ` const powershellCompletionScript = `# PowerShell completion for dispatch -$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export', 'man') +$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export', 'info', 'man') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query') $script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path') diff --git a/cmd/dispatch/info.go b/cmd/dispatch/info.go new file mode 100644 index 0000000..5a7cadc --- /dev/null +++ b/cmd/dispatch/info.go @@ -0,0 +1,181 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/jongio/dispatch/internal/data" +) + +// infoGetDetailFn loads a session's aggregated detail by ID. It is a seam so +// tests can substitute the store lookup. It reuses the export loader, which +// returns (nil, nil) when no session with the given ID exists. +var infoGetDetailFn = defaultExportGetDetail + +// sessionInfo is the concise, count-oriented view of a session emitted by the +// info command. Unlike export, it summarizes the conversation with counts +// rather than including every turn. +type sessionInfo struct { + ID string `json:"id"` + Summary string `json:"summary,omitempty"` + Repository string `json:"repository,omitempty"` + Branch string `json:"branch,omitempty"` + Directory string `json:"directory,omitempty"` + HostType string `json:"host_type,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + LastActiveAt string `json:"last_active_at,omitempty"` + Turns int `json:"turns"` + Files int `json:"files"` + Checkpoints int `json:"checkpoints"` + Commits int `json:"commits"` + PRs int `json:"prs"` + Issues int `json:"issues"` +} + +// runInfo prints a concise summary of a single session as text, or as JSON +// with --json. args is the full argument slice with args[0] == "info". +func runInfo(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + id, asJSON, err := parseInfoArgs(args) + if err != nil { + return err + } + + detail, err := infoGetDetailFn(id) + if err != nil { + return err + } + if detail == nil { + return fmt.Errorf("session %q not found", id) + } + + info := buildSessionInfo(detail) + if asJSON { + return writeInfoJSON(w, info) + } + return writeInfoText(w, info) +} + +// parseInfoArgs extracts the session ID and the --json flag from the info +// subcommand arguments. args[0] is expected to be "info". +func parseInfoArgs(args []string) (id string, asJSON bool, err error) { + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop the "info" token + } + + var positionals []string + for _, arg := range rest { + switch { + case arg == "--json": + asJSON = true + case strings.HasPrefix(arg, "-"): + return "", false, fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + + switch len(positionals) { + case 0: + return "", false, errors.New("info requires a session ID") + case 1: + return positionals[0], asJSON, nil + default: + return "", false, fmt.Errorf("info accepts a single session ID, got %d arguments", len(positionals)) + } +} + +// buildSessionInfo reduces a full SessionDetail to the concise info view, +// counting external references by type. +func buildSessionInfo(detail *data.SessionDetail) sessionInfo { + s := detail.Session + info := sessionInfo{ + ID: s.ID, + Summary: s.Summary, + Repository: s.Repository, + Branch: s.Branch, + Directory: s.Cwd, + HostType: s.HostType, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + LastActiveAt: s.LastActiveAt, + Turns: s.TurnCount, + Files: s.FileCount, + Checkpoints: len(detail.Checkpoints), + } + for _, ref := range detail.Refs { + switch strings.ToLower(ref.RefType) { + case "commit": + info.Commits++ + case "pr": + info.PRs++ + case "issue": + info.Issues++ + } + } + return info +} + +// writeInfoJSON encodes info as indented JSON. +func writeInfoJSON(w io.Writer, info sessionInfo) error { + b, err := json.MarshalIndent(info, "", " ") + if err != nil { + return fmt.Errorf("encoding session info as JSON: %w", err) + } + _, err = fmt.Fprintln(w, string(b)) + return err +} + +// writeInfoText prints a human-readable summary, one field per line. Optional +// string fields are omitted when empty; counts always print. +func writeInfoText(w io.Writer, info sessionInfo) error { + var b strings.Builder + fmt.Fprintf(&b, "Session %s\n", info.ID) + + writeField := func(label, value string) { + if value != "" { + fmt.Fprintf(&b, " %-12s %s\n", label, value) + } + } + writeField("Summary:", info.Summary) + writeField("Repository:", info.Repository) + writeField("Branch:", info.Branch) + writeField("Directory:", info.Directory) + writeField("Host:", info.HostType) + writeField("Created:", info.CreatedAt) + writeField("Updated:", info.UpdatedAt) + writeField("Last active:", info.LastActiveAt) + + fmt.Fprintf(&b, " %-12s %d\n", "Turns:", info.Turns) + fmt.Fprintf(&b, " %-12s %d\n", "Files:", info.Files) + fmt.Fprintf(&b, " %-12s %d\n", "Checkpoints:", info.Checkpoints) + fmt.Fprintf(&b, " %-12s %s\n", "Refs:", formatRefCounts(info)) + + _, err := io.WriteString(w, b.String()) + return err +} + +// formatRefCounts renders the reference breakdown as a compact summary such as +// "3 commits, 1 pr, 0 issues", using singular labels when a count is one. +func formatRefCounts(info sessionInfo) string { + return fmt.Sprintf("%s, %s, %s", + pluralize(info.Commits, "commit", "commits"), + pluralize(info.PRs, "pr", "prs"), + pluralize(info.Issues, "issue", "issues")) +} + +// pluralize renders n with a singular label when n is exactly one. +func pluralize(n int, singular, plural string) string { + if n == 1 { + return "1 " + singular + } + return fmt.Sprintf("%d %s", n, plural) +} diff --git a/cmd/dispatch/info_test.go b/cmd/dispatch/info_test.go new file mode 100644 index 0000000..b540aaa --- /dev/null +++ b/cmd/dispatch/info_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/data" + "github.com/jongio/dispatch/internal/update" +) + +// withInfoDetail swaps the info detail loader seam for the duration of a test. +func withInfoDetail(t *testing.T, fn func(string) (*data.SessionDetail, error)) { + t.Helper() + prev := infoGetDetailFn + infoGetDetailFn = fn + t.Cleanup(func() { infoGetDetailFn = prev }) +} + +func infoSampleDetail() *data.SessionDetail { + return &data.SessionDetail{ + Session: data.Session{ + ID: "ses-info-1", + Summary: "Fix the widget", + Cwd: "/tmp/project", + Repository: "jongio/dispatch", + Branch: "main", + HostType: "github", + CreatedAt: "2026-01-05T10:00:00Z", + UpdatedAt: "2026-01-06T11:00:00Z", + LastActiveAt: "2026-01-06T11:30:00Z", + TurnCount: 5, + FileCount: 3, + }, + Checkpoints: []data.Checkpoint{{CheckpointNumber: 1}, {CheckpointNumber: 2}}, + Refs: []data.SessionRef{ + {RefType: "commit", RefValue: "abc123"}, + {RefType: "commit", RefValue: "def456"}, + {RefType: "pr", RefValue: "42"}, + {RefType: "issue", RefValue: "7"}, + {RefType: "PR", RefValue: "43"}, // case-insensitive + }, + } +} + +func TestParseInfoArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantID string + wantJSON bool + wantErr bool + }{ + {name: "id only", args: []string{"info", "abc"}, wantID: "abc"}, + {name: "id with json", args: []string{"info", "abc", "--json"}, wantID: "abc", wantJSON: true}, + {name: "json before id", args: []string{"info", "--json", "abc"}, wantID: "abc", wantJSON: true}, + {name: "missing id", args: []string{"info"}, wantErr: true}, + {name: "two ids", args: []string{"info", "a", "b"}, wantErr: true}, + {name: "unknown flag", args: []string{"info", "abc", "--nope"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, asJSON, err := parseInfoArgs(tt.args) + if tt.wantErr { + if err == nil { + t.Fatal("expected an error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != tt.wantID { + t.Errorf("id = %q, want %q", id, tt.wantID) + } + if asJSON != tt.wantJSON { + t.Errorf("asJSON = %v, want %v", asJSON, tt.wantJSON) + } + }) + } +} + +func TestBuildSessionInfo_CountsRefsByType(t *testing.T) { + info := buildSessionInfo(infoSampleDetail()) + + if info.Turns != 5 || info.Files != 3 || info.Checkpoints != 2 { + t.Errorf("counts = turns %d files %d checkpoints %d, want 5/3/2", + info.Turns, info.Files, info.Checkpoints) + } + if info.Commits != 2 { + t.Errorf("commits = %d, want 2", info.Commits) + } + if info.PRs != 2 { + t.Errorf("prs = %d, want 2 (case-insensitive)", info.PRs) + } + if info.Issues != 1 { + t.Errorf("issues = %d, want 1", info.Issues) + } +} + +func TestRunInfo_Text(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return infoSampleDetail(), nil + }) + + var buf bytes.Buffer + if err := runInfo(&buf, []string{"info", "ses-info-1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "Session ses-info-1", + "Fix the widget", + "jongio/dispatch", + "github", + "Turns:", + "Checkpoints: 2", + "2 commits, 2 prs, 1 issue", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q, got:\n%s", want, out) + } + } +} + +func TestRunInfo_TextOmitsEmptyFields(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return &data.SessionDetail{Session: data.Session{ID: "bare"}}, nil + }) + + var buf bytes.Buffer + if err := runInfo(&buf, []string{"info", "bare"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + + if strings.Contains(out, "Repository:") || strings.Contains(out, "Branch:") { + t.Errorf("empty optional fields should be omitted, got:\n%s", out) + } + // Counts always print, even at zero, and use singular for one. + if !strings.Contains(out, "Turns:") || !strings.Contains(out, "0 commits, 0 prs, 0 issues") { + t.Errorf("counts should always print, got:\n%s", out) + } +} + +func TestRunInfo_JSON(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return infoSampleDetail(), nil + }) + + var buf bytes.Buffer + if err := runInfo(&buf, []string{"info", "ses-info-1", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got sessionInfo + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String()) + } + if got.ID != "ses-info-1" || got.Turns != 5 || got.Commits != 2 || got.PRs != 2 || got.Issues != 1 { + t.Errorf("decoded info = %+v", got) + } +} + +func TestRunInfo_NotFound(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return nil, nil // loader returns (nil, nil) when the ID is unknown + }) + + if err := runInfo(&bytes.Buffer{}, []string{"info", "missing"}); err == nil { + t.Fatal("expected a not-found error") + } +} + +func TestRunInfo_LoaderError(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return nil, errors.New("store boom") + }) + + if err := runInfo(&bytes.Buffer{}, []string{"info", "x"}); err == nil { + t.Fatal("expected the loader error to propagate") + } +} + +func TestRunInfo_MissingID(t *testing.T) { + if err := runInfo(&bytes.Buffer{}, []string{"info"}); err == nil { + t.Fatal("expected an error when no session ID is given") + } +} + +func TestPluralize(t *testing.T) { + cases := []struct { + n int + want string + }{ + {0, "0 prs"}, + {1, "1 pr"}, + {2, "2 prs"}, + } + for _, c := range cases { + if got := pluralize(c.n, "pr", "prs"); got != c.want { + t.Errorf("pluralize(%d) = %q, want %q", c.n, got, c.want) + } + } +} + +func TestHandleArgs_Info(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return infoSampleDetail(), nil + }) + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, cleanup, _, err := handleArgs([]string{"info", "ses-info-1"}, io.Discard, ch) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !done { + t.Error("expected done=true for info") + } + if cleanup != nil { + t.Error("expected cleanup=nil for info") + } +} + +func TestHandleArgs_InfoError(t *testing.T) { + withInfoDetail(t, func(string) (*data.SessionDetail, error) { + return nil, nil // unknown ID + }) + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + done, _, _, err := handleArgs([]string{"info", "missing"}, io.Discard, ch) + if !done { + t.Error("expected done=true for info") + } + if err == nil { + t.Error("expected an error for an unknown session ID") + } +} diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index fd8954d..ac8e7d8 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -131,6 +131,7 @@ Commands: config [get|set|list|edit|path] Read or change preferences (see Config commands) export [flags] Export a session as Markdown or JSON + info [--json] Print a concise session summary (--json for machine-readable output) man Write the man page (roff) to standard output update Update dispatch to the latest release