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}