From 94012f8e87942961d38a14076b4c5a9b34bca0ab Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:46:08 -0700 Subject: [PATCH] feat(cli): add text session exports Closes #344 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 +- cmd/dispatch/export.go | 20 +++++---- cmd/dispatch/export_test.go | 59 +++++++++++++++++++++++++++ internal/data/export.go | 81 +++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bf2de97..4c66060 100644 --- a/README.md +++ b/README.md @@ -308,12 +308,13 @@ Save a full session (metadata and the complete conversation) to a file with `dis dispatch export 0a1b2c3d dispatch export 0a1b2c3d --format json dispatch export 0a1b2c3d --format html +dispatch export 0a1b2c3d --format text dispatch export 0a1b2c3d --stdout dispatch export 0a1b2c3d --redact --stdout dispatch export 0a1b2c3d --out ./exports ``` -By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output or `--format html` for a self-contained web page you can open in a browser (styles are inlined, so there are no external files to manage). Use `--stdout` to print to the terminal instead of writing a file, `--out ` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined. +By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output, `--format html` for a self-contained web page you can open in a browser, or `--format text` for plain text. Use `--stdout` to print to the terminal instead of writing a file, `--out ` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined. #### Batch Export @@ -322,6 +323,7 @@ Export all sessions matching a scope filter at once: ```sh dispatch export --repo jongio/dispatch dispatch export --repo jongio/dispatch --format json --out ./backup +dispatch export --repo jongio/dispatch --format text --out ./backup dispatch export --branch main --since 2026-01-01 --until 2026-07-01 dispatch export --query "auth fix" --redact ``` diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go index 48b6fdd..c094ac9 100644 --- a/cmd/dispatch/export.go +++ b/cmd/dispatch/export.go @@ -26,16 +26,16 @@ var ( // exportOptions holds the parsed flags for the export command. type exportOptions struct { id string - format string // "md", "json", or "html" + format string // "md", "json", "html", or "text" stdout bool outDir string redact bool filter *data.FilterOptions // non-nil for batch mode } -// runExport writes a session's full content as Markdown or JSON. It writes to -// the exports directory by default, or to stdout with --stdout. args is the -// full argument slice with args[0] == "export". +// runExport writes a session's full content. It writes to the exports directory +// by default, or to stdout with --stdout. args is the full argument slice with +// args[0] == "export". func runExport(w io.Writer, args []string) error { if w == nil { w = io.Discard @@ -221,8 +221,8 @@ func parseExportArgs(args []string) (exportOptions, error) { return opts, nil } -// normalizeExportFormat maps a user-facing format string to "md", "json", or -// "html". +// normalizeExportFormat maps a user-facing format string to a canonical export +// format. func normalizeExportFormat(format string) (string, error) { switch strings.ToLower(format) { case "md", "markdown": @@ -231,8 +231,10 @@ func normalizeExportFormat(format string) (string, error) { return "json", nil case "html": return "html", nil + case "txt", "text": + return "text", nil default: - return "", fmt.Errorf("invalid format %q (want md, json, or html)", format) + return "", fmt.Errorf("invalid format %q (want md, json, html, or text)", format) } } @@ -293,6 +295,8 @@ func renderExport(detail *data.SessionDetail, format string) (string, error) { return string(b) + "\n", nil case "html": return data.RenderHTML(detail), nil + case "text": + return data.RenderText(detail), nil default: return data.RenderMarkdown(detail), nil } @@ -309,6 +313,8 @@ func writeExportFile(dir, id, format, content string) (string, error) { ext = "json" case "html": ext = "html" + case "text": + ext = "txt" } path := filepath.Join(dir, data.SafeFilename(id)+"."+ext) if err := os.WriteFile(path, []byte(content), 0o600); err != nil { diff --git a/cmd/dispatch/export_test.go b/cmd/dispatch/export_test.go index 8dbdfbd..25e26c7 100644 --- a/cmd/dispatch/export_test.go +++ b/cmd/dispatch/export_test.go @@ -55,6 +55,8 @@ func TestParseExportArgs(t *testing.T) { {name: "id only", args: []string{"export", "abc"}, wantID: "abc", wantFormat: "md"}, {name: "format json", args: []string{"export", "abc", "--format", "json"}, wantID: "abc", wantFormat: "json"}, {name: "format html", args: []string{"export", "abc", "--format", "html"}, wantID: "abc", wantFormat: "html"}, + {name: "format text", args: []string{"export", "abc", "--format", "text"}, wantID: "abc", wantFormat: "text"}, + {name: "format txt alias", args: []string{"export", "abc", "--format=txt"}, wantID: "abc", wantFormat: "text"}, {name: "format markdown alias", args: []string{"export", "abc", "--format=markdown"}, wantID: "abc", wantFormat: "md"}, {name: "short format", args: []string{"export", "-f", "json", "abc"}, wantID: "abc", wantFormat: "json"}, {name: "stdout", args: []string{"export", "abc", "--stdout"}, wantID: "abc", wantFormat: "md", wantStdout: true}, @@ -71,6 +73,7 @@ func TestParseExportArgs(t *testing.T) { {name: "batch repo", args: []string{"export", "--repo", "x/y"}, wantFormat: "md", wantFilter: true}, {name: "batch since until", args: []string{"export", "--since", "2026-01-01", "--until", "2026-02-01"}, wantFormat: "md", wantFilter: true}, {name: "batch with format", args: []string{"export", "--branch", "main", "--format", "json"}, wantFormat: "json", wantFilter: true}, + {name: "batch with text format", args: []string{"export", "--branch", "main", "--format", "text"}, wantFormat: "text", wantFilter: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -129,6 +132,7 @@ func TestRunExport_StdoutJSON(t *testing.T) { if err := runExport(&buf, []string{"export", "ses-001", "--stdout", "--format", "json"}); err != nil { t.Fatalf("runExport: %v", err) } + var got data.SessionDetail if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatalf("output is not valid JSON: %v", err) @@ -325,3 +329,58 @@ func TestRunExportBatch_StdoutForbidden(t *testing.T) { t.Fatalf("expected stdout-batch error, got: %v", err) } } + +func TestRunExport_StdoutText(t *testing.T) { + withExportDetail(t, func(string) (*data.SessionDetail, error) { return sampleDetail(), nil }) + + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "ses-001", "--stdout", "--format", "text"}); err != nil { + t.Fatalf("runExport: %v", err) + } + out := buf.String() + for _, want := range []string{"Session: Fix the widget", "Metadata", "Conversation", "User:", "References"} { + if !strings.Contains(out, want) { + t.Errorf("text output missing %q, got:\n%s", want, out) + } + } + if strings.Contains(out, "# Session") || strings.Contains(out, "| Field |") { + t.Errorf("text output should not contain Markdown formatting, got:\n%s", out) + } +} + +func TestRunExport_RedactsTextStdout(t *testing.T) { + detail := sampleDetail() + detail.Turns = []data.Turn{ + {UserMessage: "Authorization: ******", AssistantResponse: "API_TOKEN=super-secret-token"}, + } + withExportDetail(t, func(string) (*data.SessionDetail, error) { return detail, nil }) + + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "ses-001", "--stdout", "--format", "text", "--redact"}); err != nil { + t.Fatalf("runExport: %v", err) + } + out := buf.String() + if strings.Contains(out, "super-secret-token") { + t.Fatalf("redacted text export leaked a secret:\n%s", out) + } + if !strings.Contains(out, "[redacted]") { + t.Fatalf("redacted text export missing placeholder:\n%s", out) + } +} + +func TestRunExport_WritesTextFile(t *testing.T) { + withExportDetail(t, func(string) (*data.SessionDetail, error) { return sampleDetail(), nil }) + + dir := t.TempDir() + var buf bytes.Buffer + if err := runExport(&buf, []string{"export", "ses-001", "--out", dir, "--format", "text"}); err != nil { + t.Fatalf("runExport: %v", err) + } + path := filepath.Join(dir, "ses-001.txt") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected export file at %s: %v", path, err) + } + if !strings.Contains(buf.String(), path) { + t.Errorf("output should report the path %q, got %q", path, buf.String()) + } +} diff --git a/internal/data/export.go b/internal/data/export.go index 9bae89d..b3dbb95 100644 --- a/internal/data/export.go +++ b/internal/data/export.go @@ -125,6 +125,87 @@ func RenderMarkdown(detail *SessionDetail) string { return b.String() } +// RenderText formats a SessionDetail as plain text for systems that do not +// render Markdown or HTML. +func RenderText(detail *SessionDetail) string { + if detail == nil { + return "" + } + + s := detail.Session + var b strings.Builder + + b.WriteString("Session: " + s.Summary + "\n\n") + + b.WriteString("Metadata\n") + fmt.Fprintf(&b, "ID: %s\n", s.ID) + fmt.Fprintf(&b, "Folder: %s\n", s.Cwd) + if s.Repository != "" { + fmt.Fprintf(&b, "Repository: %s\n", s.Repository) + } + if s.Branch != "" { + fmt.Fprintf(&b, "Branch: %s\n", s.Branch) + } + fmt.Fprintf(&b, "Created: %s\n", s.CreatedAt) + fmt.Fprintf(&b, "Last Active: %s\n", s.LastActiveAt) + fmt.Fprintf(&b, "Turns: %d\n", s.TurnCount) + fmt.Fprintf(&b, "Files: %d\n\n", s.FileCount) + + if len(detail.Turns) > 0 { + b.WriteString("Conversation\n\n") + for _, turn := range detail.Turns { + if turn.UserMessage != "" { + b.WriteString("User:\n") + b.WriteString(turn.UserMessage + "\n\n") + } + if turn.AssistantResponse != "" { + b.WriteString("Assistant:\n") + b.WriteString(turn.AssistantResponse + "\n\n") + } + } + } + + if len(detail.Checkpoints) > 0 { + b.WriteString("Checkpoints\n\n") + for _, cp := range detail.Checkpoints { + fmt.Fprintf(&b, "%d. %s\n", cp.CheckpointNumber, cp.Title) + if cp.Overview != "" { + b.WriteString(cp.Overview + "\n") + } + b.WriteString("\n") + } + } + + if len(detail.Files) > 0 { + b.WriteString("Files Touched\n\n") + seen := make(map[string]struct{}) + for _, f := range detail.Files { + if _, ok := seen[f.FilePath]; ok { + continue + } + seen[f.FilePath] = struct{}{} + fmt.Fprintf(&b, "- %s (%s)\n", f.FilePath, f.ToolName) + } + b.WriteString("\n") + } + + if len(detail.Refs) > 0 { + b.WriteString("References\n\n") + seen := make(map[string]struct{}) + for _, ref := range detail.Refs { + key := ref.RefType + ":" + ref.RefValue + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + fmt.Fprintf(&b, "- %s: %s\n", ref.RefType, ref.RefValue) + } + b.WriteString("\n") + } + + return b.String() +} + // htmlExportStyle is the inline stylesheet for HTML exports. It is embedded in // the document so the file renders without any external requests. const htmlExportStyle = `body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.5;color:#1f2328;background:#fff;margin:0;padding:2rem;max-width:56rem;margin-left:auto;margin-right:auto}