diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index f9f5bcc..3617a45 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -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. @@ -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 @@ -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) @@ -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)" @@ -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') diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index ebf7839..775448f 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -127,6 +127,7 @@ Commands: config [get|set|list|edit|path] Read or change preferences (see Config commands) export [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: diff --git a/cmd/dispatch/man.go b/cmd/dispatch/man.go new file mode 100644 index 0000000..b5b0862 --- /dev/null +++ b/cmd/dispatch/man.go @@ -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 [--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 ", "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 ", "Read or change preferences."}, + {"export [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 ", "Filter to sessions under a folder (base dir for --current)."}, + {"--repo ", "Filter to a repository (owner/repo)."}, + {"--branch ", "Filter to a branch."}, + {"--query ", "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 --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 +} diff --git a/cmd/dispatch/man_test.go b/cmd/dispatch/man_test.go new file mode 100644 index 0000000..f0cefe2 --- /dev/null +++ b/cmd/dispatch/man_test.go @@ -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 +}