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
15 changes: 11 additions & 4 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd
}
return true, cleanup, startupOptions{}, nil

case "man":
if mErr := runMan(os.Stdout); mErr != nil {
fmt.Fprintf(os.Stderr, "man: %v\n", mErr)
return true, cleanup, startupOptions{}, mErr
}
return true, cleanup, startupOptions{}, nil

case "__complete":
// Hidden helper used by the shell completion scripts to fetch
// dynamic candidates. Deliberately omitted from help and usage.
Expand Down Expand Up @@ -331,7 +338,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"
local commands="help version open new doctor update completion stats search tags config export man"
local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query"

if [[ "${COMP_CWORD}" -eq 1 ]]; then
Expand Down Expand Up @@ -365,7 +372,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)
commands=(help version open new doctor update completion stats search tags config export man)
configsubs=(list get set unset edit path)
flags=(-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query)

Expand Down Expand Up @@ -417,7 +424,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'
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 '-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)"
Expand All @@ -426,7 +433,7 @@ end
`

const powershellCompletionScript = `# PowerShell completion for dispatch
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export')
$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'config', 'export', 'man')
$script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query')
$script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path')

Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ Commands:
config [get|set|list|edit|path]
Read or change preferences (see Config commands)
export <id> [flags] Export a session as Markdown or JSON
man Write the man page (roff) to standard output
update Update dispatch to the latest release

Session IDs:
Expand Down
174 changes: 174 additions & 0 deletions cmd/dispatch/man.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package main

import (
"fmt"
"io"
"strings"
"time"

"github.com/jongio/dispatch/internal/version"
)

// manDateFn returns the date stamped into the man page header. It is a package
// variable so tests can pin it, matching the seam pattern used elsewhere in
// this package (see cli.go and open.go).
var manDateFn = func() string { return time.Now().UTC().Format("2006-01-02") }

// runMan writes a roff-formatted man page for dispatch to w. Packagers can
// redirect it to dispatch.1 for distribution (Homebrew, apt, and so on):
//
// dispatch man > dispatch.1
//
// The output targets section 1 (user commands) and mirrors the built-in usage
// text so the two stay close.
func runMan(w io.Writer) error {
if w == nil {
w = io.Discard
}
_, err := io.WriteString(w, renderManPage())
return err
}

// renderManPage builds the full roff document as a string.
func renderManPage() string {
var b strings.Builder

// Header. The double quotes group multi-word fields for the .TH macro.
fmt.Fprintf(&b, ".TH DISPATCH 1 %q %q %q\n",
manDateFn(), "dispatch "+version.Version, "User Commands")

manSection(&b, "NAME")
b.WriteString("dispatch \\- a terminal UI for browsing and launching GitHub Copilot CLI sessions\n")

manSection(&b, "SYNOPSIS")
b.WriteString(".B dispatch\n")
b.WriteString(".RI [ query ]\n")
b.WriteString(".br\n")
b.WriteString(".B dispatch\n")
b.WriteString(".RI [ command ]\n")
b.WriteString(".RI [ flags ]\n")

manSection(&b, "DESCRIPTION")
b.WriteString("Dispatch reads your local Copilot CLI session store and presents every past\n")
b.WriteString("session in a searchable, sortable, groupable TUI. Run it with no arguments to\n")
b.WriteString("open the interactive interface, or pass a query to pre-fill the search box.\n")
b.WriteString(".PP\n")
b.WriteString("The subcommands below run without starting the TUI, which makes them useful\n")
b.WriteString("for scripting and shell integration.\n")

manSection(&b, "COMMANDS")
for _, c := range manCommands {
manItem(&b, c.term, c.desc)
}

manSection(&b, "FLAGS")
for _, f := range manFlags {
manItem(&b, f.term, f.desc)
}

manSection(&b, "ENVIRONMENT")
for _, e := range manEnv {
manItem(&b, e.term, e.desc)
}

manSection(&b, "EXAMPLES")
for _, ex := range manExamples {
b.WriteString(".PP\n")
b.WriteString(manEscape(ex.desc) + "\n")
b.WriteString(".PP\n")
b.WriteString(".RS\n.EX\n")
b.WriteString(manEscape(ex.cmd) + "\n")
b.WriteString(".EE\n.RE\n")
}

manSection(&b, "SEE ALSO")
b.WriteString("Project home: https://github.com/jongio/dispatch\n")

return b.String()
}

// manEntry pairs a term (the tagged item) with its description paragraph.
type manEntry struct {
term string
desc string
}

// manCommands mirrors the "Commands" block of the usage banner.
var manCommands = []manEntry{
{"help", "Show the usage summary."},
{"version", "Print the version."},
{"open <id> [--mode M]", "Resume a session by ID or alias (M: inplace, tab, window, pane). Use --last for the most recent session and --print to write the resume command instead of launching."},
{"new [dir] [--mode M]", "Start a new session in a directory (default: current)."},
{"completion <shell>", "Print a shell completion script (bash, zsh, fish, powershell)."},
{"doctor [--json]", "Print environment diagnostics."},
{"stats [flags]", "Print session totals and breakdowns."},
{"search [query] [flags]", "Print matching sessions without the TUI."},
{"tags [--json]", "List tags in use with per-tag session counts."},
{"config <get|set|list|edit|path>", "Read or change preferences."},
{"export <id> [flags]", "Export a session as Markdown or JSON."},
{"man", "Write this man page in roff format to standard output."},
{"update", "Update dispatch to the latest release."},
}

// manFlags mirrors the top-level flags of the usage banner.
var manFlags = []manEntry{
{"-h, --help", "Show the usage summary."},
{"-v, --version", "Print the version."},
{"--demo", "Launch with demo data."},
{"--clear-cache", "Reset config to defaults."},
{"--reindex", "Rebuild the session store index."},
{"--current", "Filter to the current git repo and branch (from cwd)."},
{"--cwd <path>", "Filter to sessions under a folder (base dir for --current)."},
{"--repo <name>", "Filter to a repository (owner/repo)."},
{"--branch <name>", "Filter to a branch."},
{"--query <text>", "Pre-fill the search box with free text."},
}

// manEnv mirrors the environment block of the usage banner.
var manEnv = []manEntry{
{"DISPATCH_DB", "Path to a custom session store database."},
{"DISPATCH_SESSION_STATE", "Path to a custom session state directory."},
{"DISPATCH_LOG", "Path to a log file (enables debug logging)."},
{"DISPATCH_NO_UPDATE_CHECK", "Skip the background release check when set to 1, true, yes, or on."},
}

// manExample gives a described, runnable command line.
type manExample struct {
desc string
cmd string
}

// manExamples gives a few runnable command lines.
var manExamples = []manExample{
{desc: "Launch the TUI filtered to the current repo and branch:", cmd: "dispatch --current"},
{desc: "Export a session as JSON to standard output:", cmd: "dispatch export <id> --format json --stdout"},
{desc: "Install the man page for the current user:", cmd: "dispatch man > ~/.local/share/man/man1/dispatch.1"},
}

// manSection writes a .SH section header.
func manSection(b *strings.Builder, name string) {
fmt.Fprintf(b, ".SH %s\n", name)
}

// manItem writes a tagged paragraph (.TP): a bold term followed by a wrapped
// description. An empty term is skipped so the description renders on its own.
func manItem(b *strings.Builder, term, desc string) {
if term == "" {
b.WriteString(manEscape(desc) + "\n")
return
}
b.WriteString(".TP\n")
fmt.Fprintf(b, ".B %s\n", manEscape(term))
b.WriteString(manEscape(desc) + "\n")
}

// manEscape makes a string safe to embed in roff output. It escapes the
// backslash (the roff control character) and a leading dot or apostrophe, which
// roff would otherwise read as a macro at the start of a line.
func manEscape(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
if strings.HasPrefix(s, ".") || strings.HasPrefix(s, "'") {
s = `\&` + s
}
return s
}
113 changes: 113 additions & 0 deletions cmd/dispatch/man_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package main

import (
"bytes"
"io"
"strings"
"testing"

"github.com/jongio/dispatch/internal/update"
)

func TestRenderManPage_Structure(t *testing.T) {
old := manDateFn
manDateFn = func() string { return "2026-01-02" }
t.Cleanup(func() { manDateFn = old })

out := renderManPage()

// Header line with a pinned date and a section-1 designation.
if !strings.HasPrefix(out, ".TH DISPATCH 1 \"2026-01-02\"") {
t.Errorf("man page should start with a .TH header, got:\n%s", firstLine(out))
}

wantSections := []string{
".SH NAME",
".SH SYNOPSIS",
".SH DESCRIPTION",
".SH COMMANDS",
".SH FLAGS",
".SH ENVIRONMENT",
".SH EXAMPLES",
".SH SEE ALSO",
}
for _, s := range wantSections {
if !strings.Contains(out, s) {
t.Errorf("man page missing section %q", s)
}
}
}

func TestRenderManPage_ListsEveryCommand(t *testing.T) {
out := renderManPage()
for _, c := range manCommands {
if !strings.Contains(out, ".B "+manEscape(c.term)) {
t.Errorf("man page missing command entry %q", c.term)
}
}
// The man command documents itself.
if !strings.Contains(out, ".B man") {
t.Error("man page should document the man command")
}
}

func TestRunMan_WritesToWriter(t *testing.T) {
var buf bytes.Buffer
if err := runMan(&buf); err != nil {
t.Fatalf("runMan returned error: %v", err)
}
if buf.Len() == 0 {
t.Fatal("runMan wrote nothing")
}
if !strings.Contains(buf.String(), ".TH DISPATCH 1") {
t.Error("runMan output missing .TH header")
}
}

func TestRunMan_NilWriter(t *testing.T) {
if err := runMan(nil); err != nil {
t.Errorf("runMan(nil) should be a no-op, got error: %v", err)
}
}

func TestManEscape(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{name: "plain text unchanged", in: "hello world", want: "hello world"},
{name: "backslash doubled", in: `a\b`, want: `a\\b`},
{name: "leading dot guarded", in: ".TP", want: `\&.TP`},
{name: "leading apostrophe guarded", in: "'quote", want: `\&'quote`},
{name: "interior dot unchanged", in: "a.b", want: "a.b"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := manEscape(tc.in); got != tc.want {
t.Errorf("manEscape(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

func TestHandleArgs_Man(t *testing.T) {
ch := make(chan *update.UpdateInfo, 1)
ch <- nil

done, _, _, err := handleArgs([]string{"man"}, io.Discard, ch)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !done {
t.Error("expected done=true for man")
}
}

// firstLine returns the first line of s, for readable error messages.
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}