Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>` 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 <dir>` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined.

#### Batch Export

Expand All @@ -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
```
Expand Down
20 changes: 13 additions & 7 deletions cmd/dispatch/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions cmd/dispatch/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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())
}
}
81 changes: 81 additions & 0 deletions internal/data/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down