From 6f7206d61b48fa9ea738f063616b778b7faa017c Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:38:10 -0700 Subject: [PATCH] Add a dispatch export command to save a session as Markdown or JSON Adds 'dispatch export ' with --format md|json (default md), --stdout to print instead of writing a file, and --out to choose the destination directory. Wires the command into arg handling, shell completion, and usage output. Closes #221 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 13 +++ cmd/dispatch/cli.go | 13 ++- cmd/dispatch/export.go | 207 ++++++++++++++++++++++++++++++++++++ cmd/dispatch/export_test.go | 157 +++++++++++++++++++++++++++ cmd/dispatch/main.go | 6 ++ 5 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 cmd/dispatch/export.go create mode 100644 cmd/dispatch/export_test.go diff --git a/README.md b/README.md index 3f92c50..6f8ea26 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,19 @@ Flags: - `--calendar` adds a GitHub-style activity heatmap of sessions per day, with an intensity legend. It honors the `--repo`, `--branch`, `--since`, and `--until` filters. - `--repo`, `--branch`, `--folder`, `--since`, and `--until` narrow which sessions are counted. +### Export + +Save a full session (metadata and the complete conversation) to a file with `dispatch export `: + +```sh +dispatch export 0a1b2c3d +dispatch export 0a1b2c3d --format json +dispatch export 0a1b2c3d --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, `--stdout` to print to the terminal instead of writing a file, and `--out ` to choose the destination directory. `--stdout` and `--out` cannot be combined. + ### Key Bindings #### Navigation diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 66aea5b..66d4700 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -111,6 +111,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, "", nil + case "export": + if eErr := runExport(os.Stdout, args); eErr != nil { + fmt.Fprintf(os.Stderr, "export: %v\n", eErr) + return true, cleanup, "", eErr + } + return true, cleanup, "", nil + case "--demo": c, demoErr := setupDemo() if demoErr != nil { @@ -188,7 +195,7 @@ func runCompletion(w io.Writer, shell string) error { const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" - local commands="help version open doctor update completion stats config" + local commands="help version open doctor update completion stats config export" local flags="-h --help -v --version --demo --clear-cache --reindex" if [[ "${COMP_CWORD}" -eq 1 ]]; then @@ -212,7 +219,7 @@ complete -F _dispatch_completion dispatch disp const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { local -a commands shells flags configsubs - commands=(help version open doctor update completion stats config) + commands=(help version open doctor update completion stats config export) shells=(bash zsh powershell) configsubs=(list get set path) flags=(-h --help -v --version --demo --clear-cache --reindex) @@ -236,7 +243,7 @@ _dispatch_completion "$@" ` const powershellCompletionScript = `# PowerShell completion for dispatch -$script:DispatchCommands = @('help', 'version', 'open', 'doctor', 'update', 'completion', 'stats', 'config') +$script:DispatchCommands = @('help', 'version', 'open', 'doctor', 'update', 'completion', 'stats', 'config', 'export') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex') $script:DispatchShells = @('bash', 'zsh', 'powershell') $script:DispatchConfigSubcommands = @('list', 'get', 'set', 'path') diff --git a/cmd/dispatch/export.go b/cmd/dispatch/export.go new file mode 100644 index 0000000..f9453ef --- /dev/null +++ b/cmd/dispatch/export.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/jongio/dispatch/internal/data" +) + +// Function variables allow test substitution of external calls, matching the +// pattern used elsewhere in this package (see cli.go and open.go). +var ( + exportGetDetailFn = defaultExportGetDetail + exportDirFn = data.ExportDir +) + +// exportOptions holds the parsed flags for the export command. +type exportOptions struct { + id string + format string // "md" or "json" + stdout bool + outDir string +} + +// 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". +func runExport(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + opts, err := parseExportArgs(args) + if err != nil { + return err + } + + detail, err := exportGetDetailFn(opts.id) + if err != nil { + return err + } + if detail == nil { + return fmt.Errorf("session %q not found", opts.id) + } + + content, err := renderExport(detail, opts.format) + if err != nil { + return err + } + + if opts.stdout { + _, err := io.WriteString(w, content) + return err + } + + dir := opts.outDir + if dir == "" { + dir, err = exportDirFn() + if err != nil { + return err + } + } + + path, err := writeExportFile(dir, detail.Session.ID, opts.format, content) + if err != nil { + return err + } + fmt.Fprintf(w, "Exported session to %s\n", path) + return nil +} + +// parseExportArgs extracts the session ID and options from the "export" +// subcommand arguments. args[0] is expected to be "export". +func parseExportArgs(args []string) (exportOptions, error) { + opts := exportOptions{format: "md"} + + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop the "export" token + } + + takeValue := func(i int, name, inline string) (string, int, error) { + if inline != "" { + return inline, i, nil + } + if i+1 >= len(rest) { + return "", i, fmt.Errorf("%s requires a value", name) + } + return rest[i+1], i + 1, nil + } + + var positionals []string + for i := 0; i < len(rest); i++ { + arg := rest[i] + name, inline, hasInline := splitFlag(arg) + + switch { + case name == "--format" || name == "-f": + v, ni, err := takeValue(i, "--format", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + f, err := normalizeExportFormat(v) + if err != nil { + return exportOptions{}, err + } + opts.format = f + i = ni + case name == "--out" || name == "-o": + v, ni, err := takeValue(i, "--out", inlineOrEmpty(inline, hasInline)) + if err != nil { + return exportOptions{}, err + } + opts.outDir = v + i = ni + case name == "--stdout": + opts.stdout = true + case strings.HasPrefix(arg, "-"): + return exportOptions{}, fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + + switch len(positionals) { + case 0: + return exportOptions{}, errors.New("export requires a session ID") + case 1: + opts.id = positionals[0] + default: + return exportOptions{}, fmt.Errorf("export accepts a single session ID, got %d arguments", len(positionals)) + } + + if opts.stdout && opts.outDir != "" { + return exportOptions{}, errors.New("--stdout and --out cannot be used together") + } + + return opts, nil +} + +// normalizeExportFormat maps a user-facing format string to "md" or "json". +func normalizeExportFormat(format string) (string, error) { + switch strings.ToLower(format) { + case "md", "markdown": + return "md", nil + case "json": + return "json", nil + default: + return "", fmt.Errorf("invalid format %q (want md or json)", format) + } +} + +// renderExport produces the export content for the given format. +func renderExport(detail *data.SessionDetail, format string) (string, error) { + switch format { + case "json": + b, err := json.MarshalIndent(detail, "", " ") + if err != nil { + return "", fmt.Errorf("encoding session as JSON: %w", err) + } + return string(b) + "\n", nil + default: + return data.RenderMarkdown(detail), nil + } +} + +// writeExportFile writes content to /. and returns the path. +func writeExportFile(dir, id, format, content string) (string, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("creating export directory: %w", err) + } + ext := "md" + if format == "json" { + ext = "json" + } + path := filepath.Join(dir, data.SafeFilename(id)+"."+ext) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + return "", fmt.Errorf("writing export file: %w", err) + } + return path, nil +} + +// defaultExportGetDetail loads a full session detail by ID from the default +// session store. It returns (nil, nil) when no session with that ID exists. +func defaultExportGetDetail(id string) (*data.SessionDetail, error) { + store, err := data.Open() + if err != nil { + return nil, fmt.Errorf("opening session store: %w", err) + } + defer store.Close() //nolint:errcheck // read-only, best-effort close + + detail, err := store.GetSession(context.Background(), id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return detail, nil +} diff --git a/cmd/dispatch/export_test.go b/cmd/dispatch/export_test.go new file mode 100644 index 0000000..590d68f --- /dev/null +++ b/cmd/dispatch/export_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/data" +) + +func withExportDetail(t *testing.T, fn func(string) (*data.SessionDetail, error)) { + t.Helper() + prev := exportGetDetailFn + exportGetDetailFn = fn + t.Cleanup(func() { exportGetDetailFn = prev }) +} + +func sampleDetail() *data.SessionDetail { + return &data.SessionDetail{ + Session: data.Session{ + ID: "ses-001", + Summary: "Fix the widget", + Cwd: "/tmp/project", + Repository: "jongio/dispatch", + Branch: "main", + CreatedAt: "2026-01-05T10:00:00Z", + TurnCount: 2, + FileCount: 1, + }, + Turns: []data.Turn{ + {UserMessage: "hello", AssistantResponse: "hi"}, + }, + Refs: []data.SessionRef{ + {RefType: "pr", RefValue: "42"}, + }, + } +} + +func TestParseExportArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantID string + wantFormat string + wantStdout bool + wantOut string + wantErr bool + }{ + {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 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}, + {name: "out dir", args: []string{"export", "abc", "--out", "/tmp/x"}, wantID: "abc", wantFormat: "md", wantOut: "/tmp/x"}, + {name: "missing id", args: []string{"export"}, wantErr: true}, + {name: "two ids", args: []string{"export", "a", "b"}, wantErr: true}, + {name: "unknown flag", args: []string{"export", "--nope", "a"}, wantErr: true}, + {name: "invalid format", args: []string{"export", "a", "--format", "yaml"}, wantErr: true}, + {name: "format without value", args: []string{"export", "a", "--format"}, wantErr: true}, + {name: "stdout and out", args: []string{"export", "a", "--stdout", "--out", "/tmp/x"}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts, err := parseExportArgs(tc.args) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %+v", opts) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if opts.id != tc.wantID { + t.Errorf("id = %q, want %q", opts.id, tc.wantID) + } + if opts.format != tc.wantFormat { + t.Errorf("format = %q, want %q", opts.format, tc.wantFormat) + } + if opts.stdout != tc.wantStdout { + t.Errorf("stdout = %v, want %v", opts.stdout, tc.wantStdout) + } + if opts.outDir != tc.wantOut { + t.Errorf("outDir = %q, want %q", opts.outDir, tc.wantOut) + } + }) + } +} + +func TestRunExport_StdoutMarkdown(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"}); err != nil { + t.Fatalf("runExport: %v", err) + } + out := buf.String() + if !strings.Contains(out, "# Session: Fix the widget") { + t.Errorf("markdown output missing title, got:\n%s", out) + } +} + +func TestRunExport_StdoutJSON(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", "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) + } + if got.Session.ID != "ses-001" { + t.Errorf("json ID = %q, want %q", got.Session.ID, "ses-001") + } +} + +func TestRunExport_WritesFile(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", "json"}); err != nil { + t.Fatalf("runExport: %v", err) + } + path := filepath.Join(dir, "ses-001.json") + 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()) + } +} + +func TestRunExport_NotFound(t *testing.T) { + withExportDetail(t, func(string) (*data.SessionDetail, error) { return nil, nil }) + + err := runExport(&bytes.Buffer{}, []string{"export", "missing"}) + if err == nil { + t.Fatal("expected error for missing session") + } +} + +func TestRunExport_LoadError(t *testing.T) { + sentinel := errors.New("boom") + withExportDetail(t, func(string) (*data.SessionDetail, error) { return nil, sentinel }) + + err := runExport(&bytes.Buffer{}, []string{"export", "abc"}) + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want %v", err, sentinel) + } +} diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index d1995a4..48a2666 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -98,6 +98,7 @@ Commands: stats [flags] Print session totals and breakdowns config [get|set|list|path] Read or change preferences (see Config commands) + export [flags] Export a session as Markdown or JSON update Update dispatch to the latest release Stats flags: @@ -115,6 +116,11 @@ Config commands: config set Validate and save one setting config path Print the config file path +Export flags: + --format md|json Output format (default md) + --out Write to a directory instead of the exports folder + --stdout Print to stdout instead of writing a file + Flags: -h, --help Show this help message -v, --version Print the version