diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,16 @@
 # Revision history for glirc
 
+## 2.40.1
+
+* Fix exception on /exec with a non-zero exit code
+* Externalize command documentation
+
+## 2.40.0.1
+
+* Build on GHC 9.0
+
+## 2.40
+
 * vty-6 support
 * deduplicated /url
 * locally processed /who
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,18 +1,46 @@
 GLIRC - Advanced Console IRC Client
 =============
 
-* **glirc** [![Hackage](https://img.shields.io/hackage/v/glirc.svg)](https://hackage.haskell.org/package/glirc) [![Build Status](https://secure.travis-ci.org/glguy/irc-core.svg)](http://travis-ci.org/glguy/irc-core)
+<a href="https://repology.org/project/glirc/versions">
+    <img src="https://repology.org/badge/vertical-allrepos/glirc.svg?exclude_unsupported=1" alt="Packaging status" align="right">
+</a>
+
+* **glirc** [![Hackage](https://img.shields.io/hackage/v/glirc.svg)](https://hackage.haskell.org/package/glirc)
 * **irc-core** [![Hackage](https://img.shields.io/hackage/v/irc-core.svg)](https://hackage.haskell.org/package/irc-core)
 * **hookup** [![Hackage](https://img.shields.io/hackage/v/hookup.svg)](https://hackage.haskell.org/package/hookup)
 
-[Wiki Documentation](https://github.com/glguy/irc-core/wiki)
+Client Features
+---------------
 
+* Support for [a wide variety of IRCv3 capabilities](https://ircv3.net/software/clients#desktop-clients).
+* Joins, parts, and quits fold into one line to save space without sacrificing context.
+Detailed view (F2) shows additional info, such as part/quit messages.
+* Ignore support that folds ignored messages into the joins and parts. Detailed view shows the messages.
+* Searchable channel list, who and names list, and list modes (beqI) views separate from chat messages.
+* Dynamic, in-place message and list view searching using `/grep`.
+* View and open long URLs with `/url`.
+* Context-sensitive tab completion and command hints.
+* WYSIWYG mIRC formatting input.
+* Multi-line editing; Enter sends one line at a time.
+* Atheme-flavored-ChanServ integration.
+* Nicknames in chat messages are colored to match messages from that nickname.
+* Correct handling of [STATUSMSG](https://modern.ircdocs.horse/#statusmsg-parameter), including CTCP ACTIONs.
+* Command macros.
+* Support for a wide variety of SASL authentication mechanisms, including `ECDSA-NIST256P-CHALLENGE`.
+* Split-screen view.
+* Configurable color palette.
+* Desktop notifications on important messages while unfocused. Can run arbitrary commands instead (or turn them off).
+* Extensions, a Lua plugin system, and trivial piping of IRC messages to arbitrary commands with `/exec`.
+* Plenty more, but this list is getting a bit long.
+
 ![](https://raw.githubusercontent.com/wiki/glguy/irc-core/images/screenshot.png)
 
 Building
 ========
 
-glirc uses recent versions of packages, make sure you package databases are
+[![Build Status](https://github.com/glguy/irc-core/actions/workflows/haskell-ci.yml/badge.svg)](https://github.com/glguy/irc-core/actions/workflows/haskell-ci.yml)
+
+glirc uses recent versions of packages, make sure your package databases are
 up-to-date:
 
 ```
@@ -32,37 +60,17 @@
 $ cabal build
 ```
 
-Client Features
-===============
-
-* All views and transformation are dynamic and don't change the underlying model.
-* Subsequent joins and parts fold into one line and do not scroll chat messages off the screen
-* Ignore support that folds ignored messages into the joins and parts. Toggle it off to see previously hidden messages
-* Detailed view to see all the messages in a channel in full detail with hostmask and timestamp (F2)
-* Context sensitive tab completion
-* Searchable ban, quiet, invex, and exception view separate from chat messages
-* Searchable user list, detailed view shows full hostmasks
-* WYSIWYG mIRC formatting input
-* Multi-line editing
-* Dynamic, in-place message searching
-* Chanserv integration
-* Nicknames in chat messages are colored to match messages from that nickname
-* Support for /STATUSMSG/ messages (messages only voice or op users can see)
-* Run commands upon connection
-* Command macros
-* CERTFP and SASL authentication
-* Split-screen view
-* Configurable color palette
-
 Startup
 =======
 
 ```
 glirc [FLAGS] INITIAL_NETWORKS...
-  -c PATH  --config=PATH  Configuration file path
-  -!       --noconnect    Disable autoconnecting
-  -h       --help         Show help
-  -v       --version      Show version
+  -c PATH  --config=PATH    Configuration file path
+  -!       --noconnect      Disable autoconnecting
+  -h       --help           Show help
+           --config-format  Show configuration file format
+  -v       --version        Show version
+           --full-version   Show version and versions of all linked Haskell libraries
 ```
 
 Environment variables
@@ -74,52 +82,50 @@
 Configuration file
 =================
 
-A configuration file can currently be used to provide some default values instead of
-using command line arguments. If any value is missing the default will be used.
-
-The default configuration file path is `~/.config/glirc/config`
+Most of glirc's settings are specified using a configuration file.
+The file format is [config-value](http://hackage.haskell.org/package/config-value),
+an indentation-sensitive format that resembles YAML.
+It has macros which are documented
+[here](https://hackage.haskell.org/package/config-value/docs/Config-Macro.html).
 
+The default configuration file path is `~/.config/glirc/config`.
 Relative paths are relative to the home directory.
 
-Learn more about this file format at [config-value](http://hackage.haskell.org/package/config-value)
+To view the full list of configuration variables,
+run `glirc --config-format | less`.
+If any variable is unspecified, a default value will be used instead.
 
 ```
 -- vim: filetype=config-value
 -- Grab the Vim syntax highlighting file from the config-value package
 
--- Learn more about these settings with `glirc --config-format`
-
 -- Defaults used when not specified on command line
 defaults:
   nick:            "yournick"
   username:        "yourusername"
   realname:        "Your real name"
-  password:        "IRC server password"
   tls:             yes -- or: no, or: starttls
                        -- enabling tls automatically uses port 6697
-  tls-verify:      yes -- or: no
 
 -- Override the defaults when connecting to specific servers
 servers:
   * name: "libera"
-    hostname:      "irc.libera.chat"
+    hostname:   "irc.libera.chat"
     sasl:
       username: "someuser"
       password: "somepass"
-    socks-host:    "socks5.example.com"
-    socks-port:    8080 -- defaults to 1080
-    log-dir:       "/home/myuser/ircLogs"
-
-  * name: "example"
-    hostname:      "example.com"
-    port:          7000 -- override the default port
+    log-dir:    "/home/myuser/ircLogs"
     connect-cmds:
-      * "join #favoritechannel,#otherchannel"
-      * "msg mybot another command"
+      * "join #glirc,#someotherchannel"
 
-    -- Specify additional certificates beyond the system CAs
-    -- relative to home directory
-    tls-server-cert: "extra/certificate.pem"
+  * name: "znc"
+    hostname:        "znc.example.com"
+    port:            7000 -- Override the default port
+    password:        "IRC server password"
+    tls-verify:      no
+    message-hooks:   ["buffextras"] -- Use this when using ZNC's "buffextra" module to get correct playback
+    flood-penalty:   -1 -- Disable flood controls
+    flood-threshold: -1
 
 macros:
   * name: "wipe"
@@ -129,20 +135,20 @@
 
   * name: "mysplits"
     commands:
-      * "splits fn:#haskell fn:#haskell-offtopic"
+      * "splits libera:#haskell libera:#haskell-offtopic"
 
   -- Example use of macro in combination with an extension
   * name: "extra"
     commands:
-      * "extension Lua some-parameter $network $channel"
+      * "extension Lua somecommand $network $channel"
 
-extra-highlights: ["glirc", "lens"]
+extra-highlights: ["glirc", "hello"]
 
 nick-padding:
-   side: left -- try right if you don't like left padding
+   side: left -- Try "right" if you don't like left padding
    width: 13
 
-url-opener: "open" -- This works on macOS, "xdg-open" for most Linuxes
+url-opener: "open" -- This works on macOS; use "xdg-open" for most Linuxes
 
 key-bindings:
   * bind: "C-M-b"
@@ -153,7 +159,7 @@
   time:
     fg: [10,10,10] -- RGB values for color for timestamps
     bg: blue
-  nick-colors:
+  identifier-colors: -- Used for nicknames and channel names
     [ cyan, magenta, green, yellow, blue
     , bright-cyan, bright-magenta, bright-green, bright-blue
     , 218,  88,  89, 124, 160, 205, 212, 224 -- reds
@@ -163,206 +169,72 @@
     ,  25,  27,  33,  39,  51,  80,  81,  75 -- blues
     ,  69,  61,  56,  54, 129,  93,  99, 147 -- purples
     ]
-```
 
-Configuration sections:
---------
-
-| setting                | type                | description                                                                                |
-|------------------------|---------------------|--------------------------------------------------------------------------------------------|
-| `defaults`             | server              | These settings are used for all connections                                                |
-| `servers`              | list of servers     | These settings are used to override defaults when the hostname matches                     |
-| `palette`              | palette             | Client color overrides                                                                     |
-| `window-names`         | text                | Names of windows (typically overridden on non QWERTY layouts)                              |
-| `nick-padding`         | nonnegative integer | Nicks are padded until they have the specified length                                      |
-| `indent-wrapped-lines` | nonnegative integer | How far to indent lines when they are wrapped                                              |
-| `extra-highlights`     | list of text        | Extra words/nicks to highlight                                                             |
-| `extensions`           | list of text        | Filenames of extension to load                                                             |
-| `url-opener`           | text                | Command to execute with URL parameter for `/url` e.g. xdg-open on most Linuxes or open on macOS |
-| `ignores`              | list of text        | Initial list of nicknames to ignore                                                        |
-| `activity-bar`         | yes or no           | Initial setting for visibility of activity bar (default no)                                |
-| `bell-on-mention`      | yes or no           | Sound terminal bell on transition from not mentioned to mentioned (default no)             |
-| `macros`               | list of macros      | User-configurable client commands                                                          |
-
-Server Settings
----------------
-
-| setting               | type                 | description                                                    |
-|-----------------------|----------------------|----------------------------------------------------------------|
-| `name`                | text                 | name of server entry, defaults to `hostname`                   |
-| `hostname`            | text                 | hostname used to connect and to specify the server             |
-| `port`                | number               | port number, defaults to 6667 without TLS and 6697 with TLS    |
-| `nick`                | text or list of text | nicknames to try in order                                      |
-| `username`            | text                 | server username                                                |
-| `realname`            | text                 | real name / GECOS                                              |
-| `password`            | text                 | server password                                                |
-| `sasl`                | sasl-settings        | SASL authentication settings                                   |
-| `tls`                 | yes/no/starttls      | use TLS to connect                                             |
-| `tls-verify`          | yes/no               | enable/ disable TLS certificate checks                         |
-| `tls-client-cert`     | text                 | path to TLS client certificate                                 |
-| `tls-client-key`      | text                 | path to TLS client key                                         |
-| `tls-server-cert`     | text                 | CA certificate to use when validating certificates             |
-| `tls-ciphers`         | text                 | OpenSSL cipher suite description string                        |
-| `connect-cmds`        | list of text         | client commands to send upon connection                        |
-| `socks-host`          | text                 | hostname of SOCKS proxy to connect through                     |
-| `socks-port`          | number               | port number of SOCKS proxy to connect through                  |
-| `chanserv-channels`   | list of text         | list of channels with chanserv op permission                   |
-| `flood-penalty`       | number               | cost in seconds per message                                    |
-| `flood-threshold`     | number               | threshold in seconds for burst                                 |
-| `message-hooks`       | list of text         | names of hooks to enable                                       |
-| `reconnect-attempts`  | int                  | number of reconnections to attempt on error                    |
-| `autoconnect`         | yes or no            | automatically connect at client startup                        |
-| `nick-completion`     | default or slack     | set this to slack to use `@` sigils when completing nicks      |
-
-SASL Settings
--------------
-
-By default SASL will use PLAIN mode, but you can specify one of: `plain`, `external`, or `ecdsa-nist256p-challenge`.
-
-| setting               | type                 | description                                                    |
-|-----------------------|----------------------|----------------------------------------------------------------|
-| `mechanism`           | optional mechanism   | SASL mechanism (defaults to PLAIN)                             |
-| `username`            | text                 | SASL username (PLAIN and ECDSA-NIST256P-CHALLENGE mode)        |
-| `password`            | text                 | SASL password (PLAIN mode)                                     |
-| `private-key`         | text                 | Path to ECDSA private key file (ECDSA-NIST256P-CHALLENGE mode) |
-| `authzid`             | text                 | Authorization identity (very rarely needed)                    |
-
-Palette
--------
-
-| entry                 | type         | description                              |
-|-----------------------|--------------|------------------------------------------|
-| `nick-colors`         | list of attr | Use for nick highlights                  |
-| `self`                | attr         | our own nickname(s) outside of mentions  |
-| `self-highlight`      | attr         | our own nickname(s) in mentions          |
-| `time`                | attr         | timestamp on messages                    |
-| `meta`                | attr         | metadata (joins/parts/etc.)              |
-| `sigil`               | attr         | sigils (+@)                              |
-| `label`               | attr         | information labels                       |
-| `latency`             | attr         | latency time                             |
-| `error`               | attr         | error messages                           |
-| `textbox`             | attr         | textbox edges (^$)                       |
-| `window-name`         | attr         | current window name                      |
-| `activity`            | attr         | activity notification                    |
-| `mention`             | attr         | mention notification                     |
-| `command`             | attr         | recognized command                       |
-| `command-prefix`      | attr         | prefix of known command                  |
-| `command-ready`       | attr         | recognized command with arguments filled |
-| `command-placeholder` | attr         | command argument placeholder             |
-| `window-divider`      | attr         | the dividing line between split windows  |
-| `line-marker`         | attr         | the dividing line for new messages       |
-
-Text Attributes
----------------
-
-Text attributes can be specified either as a single foreground color or section of attributes.
-
-* `<number>` - Maps to a terminal color
-* `<name>` - Direct selection of standard 16 terminal colors
-* `[red-number, blue-number, green-number]` - List of 3 numbers in range 0-255 map to an approximation of the RGB color.
-
-Attributes
-
-* `fg` - foreground color
-* `bg` - background color
-* `style` - single style or list of styles
-
-Styles
-
-* `blink`
-* `bold`
-* `dim`
-* `standout`
-* `reverse-video`
-* `underline`
+notifications: terminal-notifier -- Use terminal-notifier for nicer notifications (macOS only)
+```
 
 Commands
 ========
 
-Client commands
+glirc has built-in documentation for all of its commands.
+To view the full list of commands and what they do, use `/help`.
+To view help on a specific command, use `/help <command>`.
 
+Unlike some other clients, glirc does not send unknown commands to the server.
+Use `/quote` to send arbitrary IRC commands.
+
+The following is a curated list of commands for basic use:
+
 * `/help [command]` - Show in-client help
 * `/exit` - Terminate the client
-* `/quit` - Gracefully terminate connection to the current server
-* `/connect <name>` - Connect to the given server
-* `/disconnect` - Forcefully terminate connection to the current server
-* `/reconnect` - Reconnect to the current server
 * `/reload [path]` - Load a new configuration file (optional path)
 * `/palette` - Show the client palette
-* `/digraphs` - Show the table of digraphs
-* `/mentions` - Show all the highlighted lines across all windows
-* `/extension <extension name> <params...>` - Send the given params to the named extension
-* `/exec [-n network] [-c channel] <command> <arguments...>` - Execute a command, If no network or channel are provided send output to client window, if network and channel are provided send output as messages, if network is provided send output as raw IRC messages.
 * `/url [n]` - Execute url-opener on the nth URL in the current window (defaults to first)
-
-View toggles
-* `/toggle-detail` - toggle full detail view of messages
 * `/toggle-activity-bar` - toggle channel names in activity bar
-* `/toggle-metadata` - toggle visibility of channel metadata
-* `/toggle-layout` - toggle split-screen layout between 1 and 2 column view
+* `/toggle-detail` - toggle full detail view of messages
+* `/toggle-metadata` - toggle visibility of channel metadata (joins, parts, quits, nick changes, etc)
 
-Connection commands
+Connection
 
+* `/connect <name>` - Connect to the given server
+* `/quit [message]` - Gracefully terminate connection to the current server
+* `/reconnect` - Reconnect to the current server
 * `/nick <nick>` - Change nickname
-* `/away <message>` - Set away status
+* `/away [message]` - Set away status; no message removes away status
 
 Window management
 
 * `/windows [filter]` - List all open windows (filters: networks, channels, users)
-* `/focus <server>` - Change focus to server window
-* `/focus <server> <channel>` - Change focus to channel window
+* `/setname [letter]` - Assign a one-letter name to the given window.
+* `/channel <channel>` - Change focus to channel/user on current network (alias: `/c`)
+* `/channel <network>:[channel]` - Change focus to channel/user on the specified network (alias: `/c`)
 * `/clear [network] [channel]` - Clear contents of current or specified window
-* `/ignore` - Show all ignore masks
-* `/ignore <mask>...` - Toggle ignore status on a list of masks
-* `/channel <channel>` - Change focus to channel on current network (alias: `/c`)
 * `/splits [focuses...]` - Enable split-screen view. Focuses should be space delimited list of NETWORK:CHANNEL
 * `/splits+ [focuses...]` - Incremental addition to splits
 * `/splits- [focuses...]` - Incremental removal from splits
-
-Channel membership
-
-* `/join <channel>` - Join a channel (alias: `/j`)
-* `/part` - Part from current channel
+* `/toggle-layout` - toggle split-screen layout between 1 and 2 column view
 
 Chat commands
 
-* `/query <target> [<msg>]` - Switch focus to target window on current server, optionally send message
+* `/join <channel>` - Join a channel (alias: `/j`)
+* `/part [msg]` - Part from current channel
+* `/query <target> [msg]` - Switch focus to target window on current server, optionally send message (alias: `/q`)
 * `/msg <target> <msg>` - Send a message on the current server to target
-* `/notice <target> <msg>` - Send a notice message on the current server to target
-* `/ctcp <target> <command> <args>` - Send a ctcp command on the current server to target
 * `/me <msg>` - Send action message to channel
-* `/say <msg>` - Send normal message to channel
-
-Channel management
-
-* `/mode <mode> <params>` - Change modes on the current channel (advanced tab completion)
-* `/kick <nick>` - Kick a user
-* `/kickban <nick>` - Kick and ban a user
-* `/remove` - Gracefully kick a user
-* `/topic <topic>` - Change the topic (tab completion for topic)
-* `/invite <nick>` - Invite a user to the current channel
-
-Queries
-
-* `/who <query>` - Perform WHO query (use detailed view to see output)
-* `/whois <nick>` - Perform WHOIS query
-* `/whowas <nick>` - Perform WHOWAS query
-* `/ison <nick>` - Perform ISON query
-* `/userhost <nick>` - Perform USERHOST query
-* `/links <server>` - Perform LINKS query
-* `/time` - Perform TIME query
-* `/stats <query>` - Perform STATS query
+* `/say <msg>` - Send normal message to channel; useful for macros and messages starting with a slash
+* `/ignore <mask>...` - Toggle ignore status on a list of masks
+* `/topic [msg]` - Display or set the current topic of a channel
 
-Channel information
+Views
 
-* `/users` - Show channel user list
-* `/masks <mode>` - Show channel bans(b), quiets(q), exempts(e), or invex(I)
 * `/channelinfo` - Show channel topic, creation, url
-
-Window filters
-
-* `/grep` - Filter chat messages using a regular expression
-* `/grepi` - Filter chat messages using a case-insensitive regular expression on the message
+* `/grep [flags] <regex>` - Filter using a regular expression
+* `/ignore` - Show all ignore masks
+* `/list` - View the list of public channels on the network
+* `/masks <mode>` - Show channel bans(b), quiets(q), exempts(e), or invex(I)
+* `/mentions` - Show all the highlighted lines across all windows
+* `/names` - Show the user list for the current channel
+* `/who [channel] [options]` - Perform WHO query, sending options to the server, or show the results of the previous query.
 
 ZNC-specific
 
@@ -371,8 +243,11 @@
 * `/znc-playback <time>` - ZNC playback module - play everything start at the given time today
 * `/znc-playback <date> <time>` - ZNC playback module - play everything start at the given time
 
-Low-level
+Miscellaneous
 
+* `/dump <filename>` - Dump current window to file
+* `/extension <extension name> <params...>` - Send the given params to the named extension
+* `/exec [-n network] [-c channel] <command> <arguments...>` - Execute a command; if no network or channel are provided send output to client window, if network and channel are provided send output as messages, if network is provided send output as raw IRC messages.
 * `/quote <raw command>` - Send a raw IRC command to the server
 
 Keyboard Shortcuts
@@ -382,18 +257,35 @@
 control and press A". `M-a` means "hold meta key and press A". On most
 modern keyboards the *Meta* key is labeled *Alt* or *Option*.
 
-Window navigation
+To view the full list of keybindings and what they do,
+use `/keymap` from within glirc.
 
+The following is a curated list of default keybinds for basic use:
+
+Navigation
+
+* `Page Up` scroll up
+* `Page Down` scroll down
 * `C-n` next window
 * `C-p` previous window
-* `C-x` next network
-* `M-#` jump to window - `1234567890qwertyuiop!@#$%^&*()QWERTYUIOP`
+* `C-x` next network window
+* `M-<name>` jump to window with the given one-letter name
 * `M-a` jump to activity
 * `M-s` jump to previous window
 * `ESC` return to messages view (from userlist, masklist, help, etc)
 
 Editing
 
+* `C-b` bold
+* `C-c` color
+* `C-v` invert foreground/background
+* `C-_` underline
+* `C-]` italic
+* `C-o` reset formatting
+
+* `Tab` autocompletion
+* `M-k` replace 2 characters before the cursor with a character specified in `/digraphs`
+
 * `C-a` beginning of line
 * `C-e` end of line
 * `C-k` delete to end
@@ -409,29 +301,13 @@
 * `M-Backspace` delete word backwards
 * `M-d` delete word forwards
 * `M-Enter` insert newline
-* `M-k` insert digraph (2-characters before cursor)
 
-* `Tab` nickname completion
-
 Client settings
 
 * `F2` toggle detailed view
 * `F3` toggle detailed activity bar
 * `F4` toggle metadata visibility
-
-Scrolling
-
-* `Page Up` scroll up
-* `Page Down` scroll down
-
-Formatting
-
-* `C-b` bold
-* `C-c` color
-* `C-v` reverse video
-* `C-_` underline
-* `C-]` italic
-* `C-o` reset formatting
+* `F7` toggle Enter key lock
 
 Macros
 ======
@@ -462,12 +338,3 @@
 
 * `0` - first argument
 * `1` - second argument (etc.)
-
-Hooks
-=====
-
-buffextras
-----------
-
-Enable this hook when using ZNC and the `buffextra` module in order to reinterpret
-this module's messages natively in the client.
diff --git a/doc/cmds_chanop.adoc b/doc/cmds_chanop.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_chanop.adoc
@@ -0,0 +1,67 @@
+= Channel Management Commands
+:toc:
+
+== /invite
+
+Invite a user to the current channel.
+
+== /kick
+
+Kick a user from the current channel.
+
+See also: kickban, remove
+
+== /kickban
+
+Ban and kick a user from the current channel.
+
+Users are banned by hostname match.
+
+See also: kick, remove
+
+== /masks
+
+Show mask lists for current channel.
+
+The `mode` argument is typically one of the following:
+
+`b`: bans +
+`i`: invite exemptions (op view only) +
+`e`: ban exemptions (op view only) +
+`q`: quiets (on Charybdis/Solanum-based networks)
+
+== /mode
+
+Set IRC modes.
+
+When executed in a channel window, mode changes are applied to the channel.
+When executed in a network window, mode changes are applied to your user.
+
+This command has parameter sensitive tab-completion.
+
+See also: masks, channelinfo
+
+=== Examples
+
+`+/mode -t+`               - Remove the topic lock +
+`+/mode +b *!*@hostname+`  - Set a ban +
+`+/mode -q *!*@hostname+`  - Remove a quiet +
+`+/mode +vv user1 user2+`  - Voice two users +
+`+/mode +v-o user1 user1+` - Demote an op to voice
+
+== /remove
+
+Remove a user from the current channel.
+
+Remove works like /kick except it results in a PART.
+
+Not all servers support removal in this manner.
+Refer to your server/network's documentation.
+
+See also: kick, kickban
+
+== /topic
+
+View or set the topic of the current channel.
+
+Tab-completion with no `message` specified will load the current topic.
diff --git a/doc/cmds_chat.adoc b/doc/cmds_chat.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_chat.adoc
@@ -0,0 +1,203 @@
+= Chat Commands
+:toc:
+
+== /join
+
+Join the given channels.
+
+Multiple channels and keys may be provided as comma-separated lists.
+
+When keys are provided, they should occur in the same order as the channels.
+
+=== Examples
+
+`+/join #friends+` +
+`+/join #secret thekey+` +
+`+/join #secret1,#secret2,#public key1,key2+`
+
+See also: channel, clear, part
+
+== /me
+
+Sends an action message to the currently focused channel.
+Most clients will render these messages prefixed with
+only your nickname as though describing an action.
+
+=== Examples
+
+`+/me shrugs+`
+
+See also: notice, msg, say
+
+== /msg
+
+Send a chat message to a user or a channel.
+
+Multiple targets may be provided as a comma-separated list.
+
+On servers with STATUSMSG support,
+the channel name can be prefixed with a sigil to
+restrict the recipients to those with the given mode.
+
+=== Examples
+
+`+/msg buddy I'm sending you a message.+` +
+`+/msg #friends This message is for the whole channel.+` +
+`+/msg him,her I'm chatting with two people.+` +
+`+/msg @#users This message is only for ops!+`
+
+See also: notice, me, say
+
+== /part
+
+Leave the currently-focused channel,
+optionally with the provided message.
+
+=== Examples
+
+`+/part+` +
+`+/part It's not me, it's you+`
+
+See also: clear, join, quit
+
+== /query
+
+Switch the client focus to the given
+target and optionally send a message to that target.
+
+See also: msg, channel, focus
+
+=== Examples
+
+`+/q libera:#haskell+` +
+`+/q #haskell+` +
+`+/q lambdabot @messages+` +
+`+/q irc_friend How are you?+`
+
+== /say
+
+Send a message to the current chat window.
+
+This can be useful for sending a chat message with
+a leading '/' to the current chat window.
+
+=== Examples
+
+`+/say /help is the right place to start!+`
+
+See also: notice, me, msg
+
+== /away
+
+Mark yourself as away.
+The away message is used by the server to update
+status in /whois and to provide automated responses.
+
+Omit the `message` parameter to clear your away status.
+
+=== Examples
+
+`+/away Out getting some sun+` +
+`+/away+`
+
+== /channelinfo
+
+Show information about the current channel.
+Information includes topic, creation time, URL, and modes.
+
+See also: masks, mode, topic, users
+
+== /ctcp
+
+Client-to-client protocol (CTCP) commands can be used
+to query information from another user's client application
+directly. Common CTCP commands include: ACTION, PING, VERSION,
+USERINFO, CLIENTINFO, and TIME.
+
+glirc does not automatically respond to CTCP commands.
+
+=== Parameters
+
+`target`    - Comma-separated list of nicknames and channels +
+`command`   - CTCP command name +
+`arguments` - CTCP command arguments
+
+=== Examples
+
+`+/ctcp myfriend VERSION+` +
+`+/ctcp myfriend TIME+`
+
+== /knock
+
+Request entry to an invite-only channel.
+
+== /monitor
+
+Monitor is a protocol for getting server-side notifications
+when users become online/offline.
+
+=== Subcommands
+
+`+/monitor + target[,target2]*+` - Add nicknames to monitor list +
+`+/monitor - target[,target2]*+` - Remove nicknames to monitor list +
+`+/monitor C+`                   - Clear monitor list +
+`+/monitor L+`                   - Show monitor list +
+`+/monitor S+`                   - Show status of nicknames on monitor list
+
+== /names
+
+Show the user list for the current channel.
+Detailed view (default key F2) shows full hostmask.
+
+See also: channelinfo, masks
+
+== /nick
+
+Change your nickname.
+
+=== Examples
+
+`+/nick guest123+` +
+`+/nick better_nick+`
+
+== /notice
+
+Send a chat notice to a user or a channel.
+
+Notice messages were originally intended to be used by bots.
+Different clients will render these in different ways.
+
+Multiple targets may be provided as a comma-separated list.
+
+On servers with STATUSMSG support,
+the channel name can be prefixed with a sigil to
+restrict the recipients to those with the given mode.
+
+=== Examples
+
+`+/notice buddy I'm sending you a notice.+` +
+`+/notice #friends This notice is for the whole channel.+` +
+`+/notice him,her I'm informing two people.+` +
+`+/notice @#users This notice is only for ops!+`
+
+See also: me, msg, say
+
+== /operwall
+
+Send a network-wide WALLOPS message to opers only.
+
+See also: me, msg, say
+
+== /wallops
+
+Send a network-wide WALLOPS message.
+These messages go out to users who have the 'w' usermode set.
+
+See also: me, msg, say
+
+== /quote
+
+Send a raw IRC command.
+
+The argument to this command is sent as-is.
+No additional word-splitting is done.
diff --git a/doc/cmds_client.adoc b/doc/cmds_client.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_client.adoc
@@ -0,0 +1,88 @@
+= Client Commands
+:toc:
+
+== /exit
+
+Exit the client immediately.
+
+== /reload
+
+Reload the client configuration file.
+
+If `filename` is provided it will be used to reload.
+Otherwise the previously loaded configuration file will be reloaded.
+
+== /extension
+
+Run a command provided by an extension.
+
+`extension` should be the name of a loaded extension with
+a process_command callback.
+
+== /palette
+
+Show the current palette settings and a color chart to help pick new colors.
+
+== /digraphs
+
+Show the table of digraphs. A digraph is a pair of characters
+that can be used together to represent an uncommon character.
+Type the two-character digraph corresponding to the desired
+output character and then press M-k (default binding).
+
+The digraphs list is searchable with `+/grep+`.
+
+See also: grep
+
+== /keymap
+
+Show the key binding map.
+
+Key bindings can be changed in configuration file.
+Run `+glirc --config-format+` for details.
+
+== /rtsstats
+
+Show the GHC RTS statistics.
+
+== /exec
+
+Execute a command synchnonously, optionally sending the output to IRC.
+
+Arguments to this command are:
+`+[-n[network]] [-c[channel]] [-i input] command [arguments...]+`
+
+`input` is sent to the standard input of the command.
+
+When neither `network` nor `channel` are specified,
+output goes to client window (focus name "*").
+When `network` is specified,
+output is sent as raw IRC traffic to the network.
+When `channel` is specified,
+output is sent as chat to the given channel on the current network.
+When `network` and `channel` are specified,
+output is sent as chat to the given channel on the given network.
+
+`arguments` is split on spaces into words before being
+processed by getopt. Use Haskell string literal syntax to
+create arguments with escaped characters and spaces inside.
+
+== /url
+
+Open a URL seen in chat.
+
+The URL is opened using the executable configured under `url-opener`.
+
+When this command is active in the textbox,
+chat messages are filtered to only show ones with URLs.
+
+When `number` is omitted it defaults to 1.
+The number selects the URL to open, counting back from the most recent.
+
+== /help
+
+Show command documentation.
+
+When `command` is omitted, a list of all commands and macros is shown.
+When `command` is specified, detailed help for that command is shown.
+
diff --git a/doc/cmds_integration.adoc b/doc/cmds_integration.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_integration.adoc
@@ -0,0 +1,24 @@
+= Integrations
+:toc:
+
+== /znc
+
+Send command directly to ZNC.
+
+The advantage of this over `+/msg+` is that
+responses are not broadcast to call clients.
+
+== /znc-playback
+
+Request playback from the ZNC `playback` module.
+Note that the playback module is not installed in ZNC by default!
+
+When `date` is omitted,
+uses the most recent date in the past that makes sense.
+When both `time` and `date` are omitted,
+all playback is requested.
+
+Time format: `HOURS:MINUTES` (example: 7:00)
+
+Date format: `YEAR-MONTH-DAY` (example: 2016-06-16)
+
diff --git a/doc/cmds_net.adoc b/doc/cmds_net.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_net.adoc
@@ -0,0 +1,52 @@
+= Connection Commands
+:toc:
+
+== /connect
+
+Connect to `network` by name.
+
+If `network` does not correspond to the name of a server in the config,
+it is treated as a hostname.
+
+== /reconnect
+
+(Re)connect to the currently-focused server.
+
+== /quit
+
+Gracefully disconnect the current network connection,
+optionally sending the provided reason.
+
+See also: disconnect, exit
+
+== /disconnect
+
+Immediately terminate the current network connection.
+
+See also: quit, exit
+
+== /umode
+
+Apply a user-mode change. 
+
+== /cert
+
+Show the TLS certificate for the current connection.
+
+== /new-self-signed-cert
+
+Generate a new self-signed client certificate for
+network service identification using CertFP.
+
+The generated certificate can be used via the per-server
+`tls-client-cert` and `tls-client-key-password` (if applicable) keys
+in your config file. 
+
+This command generates an RSA key pair by default.
+This may change in the future.
+
+=== Parameters
+
+`filename`:   Certificate and private key PEM output +
+`keysize`:    Public-key size (default 2048, range 1024-8192) +
+`passphrase`: Optional AES-128 private key passphrase
diff --git a/doc/cmds_oper.adoc b/doc/cmds_oper.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_oper.adoc
@@ -0,0 +1,102 @@
+= Operator Commands
+:toc:
+
+== /oper
+
+Authenticate as a server operator.
+
+== /kill
+
+Kill a client connection to the server.
+
+== /kline
+
+Ban a client from the server.
+
+== /unkline
+
+Unban a client from the server.
+
+== /undline
+
+Unban a client from the server.
+
+== /unxline
+
+Unban a gecos from the server.
+
+== /unresv
+
+Unban a channel or nickname from the server.
+
+== /testline
+
+Check matching I/K/D lines for a `[[nick!]user@]host`.
+
+== /testkline
+
+Check matching K/D lines for a `[user@]host`.
+
+== /testgecos
+
+Check matching X lines for a `gecos`.
+
+== /testmask
+
+Test how many local and global clients match a mask.
+
+== /masktrace
+
+Outputs a list of local users matching the given masks.
+
+== /chantrace
+
+Outputs a list of channel members in etrace format.
+
+== /trace
+
+Outputs a list users on a server.
+
+== /etrace
+
+Outputs a list users on a server.
+
+== /map
+
+Display network map.
+
+== /sconnect
+
+Connect two servers together.
+
+== /squit
+
+Split a server away from your side of the network.
+
+== /modload
+
+Load an IRCd module.
+
+== /modunload
+
+Unload an IRCd module.
+
+== /modlist
+
+List loaded IRCd modules.
+
+== /modrestart
+
+Reload all IRCd modules.
+
+== /modreload
+
+Reload an IRCd module.
+
+== /grant
+
+Manually assign a privset to a user.
+
+== /privs
+
+Check operator privileges of the target.
diff --git a/doc/cmds_queries.adoc b/doc/cmds_queries.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_queries.adoc
@@ -0,0 +1,95 @@
+= Queries
+:toc:
+
+== /who
+
+Send WHO query to server with given arguments.
+
+If no query is provided, shows the results of the previous query.
+
+== /whois
+
+Send WHOIS query to server with given arguments.
+
+== /whowas
+
+Send WHOWAS query to server with given arguments.
+
+== /ison
+
+Send ISON query to server with given arguments.
+
+See also: monitor
+
+== /userhost
+
+Send USERHOST query to server with given arguments.
+
+== /time
+
+Send TIME query to server with given arguments.
+
+== /stats
+
+Send STATS query to server with given arguments.
+
+== /lusers
+
+Send LUSERS query to a given server.
+
+== /users
+
+Send USERS query to a given server.
+
+== /motd
+
+Send MOTD query to server.
+
+== /admin
+
+Send ADMIN query to server.
+
+== /rules
+
+Send RULES query to server.
+
+== /info
+
+Send INFO query to server.
+
+== /list
+
+View the list of public channels on the server.
+
+Sends a LIST query and caches the result;
+on larger networks, this may take several seconds to complete.
+The view may be exited while loading.
+
+`clientarg` is an optionally-comma-separated list of options.
+A single comma may be used to denote an empty list.
+
+`serverarg` is sent as-is to the server.
+It is generally used as the ELIST parameter to LIST.
+glirc does not validate this parameter against the ELIST ISUPPORT token.
+
+=== Client Options
+
+`>n`: Show only channels with more than `n` users. +
+`<n`: Show only channels with less than `n` users. +
+`~`: Force a refresh of the list.
+
+=== Examples
+
+`+/list+`          - List public channels.
+`+/list >99+`      - List public channels with at least 100 users.
+`+/list >50<1000+` - List public channels with between 51 and 999 users.
+`+/list ~ <20+`    - List public channels with fewer than 20 users. 
+`+/list , *-ops+`  - List public channels whose names end with "-ops".
+
+== /links
+
+Send LINKS query to server with given arguments.
+
+== /version
+
+Send VERSION query to server.
diff --git a/doc/cmds_toggles.adoc b/doc/cmds_toggles.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_toggles.adoc
@@ -0,0 +1,32 @@
+= Toggles
+:toc:
+
+== /toggle-detail
+
+Toggle detailed message view.
+
+== /toggle-activity-bar
+
+Toggle detailed detailed activity information in status bar.
+
+== /toggle-show-ping
+
+Toggle visibility of ping round-trip time.
+
+== /toggle-metadata
+
+Toggle visibility of metadata in chat windows.
+
+== /toggle-layout
+
+Toggle multi-window layout mode.
+
+== /toggle-editor
+
+Toggle between single-line and multi-line editor mode.
+
+== /toggle-edit-lock
+
+Toggle editor lock mode.
+
+When the editor is locked, Enter (or C-j) does nothing.
diff --git a/doc/cmds_window.adoc b/doc/cmds_window.adoc
new file mode 100644
--- /dev/null
+++ b/doc/cmds_window.adoc
@@ -0,0 +1,148 @@
+= Window Commands
+:toc:
+
+== /channel
+
+Set the current focused window.
+
+See also: focus
+
+=== Examples
+
+`+/c libera:#haskell+` +
+`+/c #haskell+` +
+`+/c libera:+`
+
+== /focus
+
+Set the current focused window.
+
+When only `network` is specified this switches to the network status window.
+When `network` and `target` are specified this switches to that chat window.
+
+Nickname and channels can be specified in the `target` parameter.
+
+See also: query, channel
+
+== /clear
+
+Clear a window.
+
+If no arguments are provided, the current window is cleared.
+If `channel` is not provided, the `network` window is cleared.
+If `channel` is `*`, all windows for `network` are cleared.
+If `channel` is provided, that chat window is cleared.
+
+If a window is cleared and no longer active (e.g. due to leaving a channel),
+that window will be removed entirely.
+
+== /windows
+
+Show a list of all windows.
+
+`kind`, if specified, is one of `networks`, `channels` or `users`,
+and limits the list to the type of window specified.
+
+== /splits
+
+This command defines the set of focuses that will
+always be visible, even when unfocused.
+If no focuses are listed, the set will be cleared.
+
+=== Examples
+
+`+/splits #haskell #haskell-lens nickserv+` +
+`+/splits * libera:#haskell libera:chanserv+` +
+`+/splits+`
+
+See also: splits+, splits-
+
+== /splits+
+
+Add focuses to the splits set.
+Omit the list of focuses to add the current window.
+
+=== Examples
+
+`+/splits+ #haskell #haskell-lens+` +
+`+/splits+ libera:#libera+` +
+`+/splits++`
+
+== /splits-
+
+Remove focuses from the splits set.
+Omit the list of focuses to remove the current window.
+
+== /ignore
+
+Toggle the soft-ignore on each of the space-delimited given
+nicknames. Ignores can use `*` (many) and `?` (one) wildcards.
+Masks can be of the form `nick[[!user]@host]`
+and use a case-insensitive comparison.
+
+If no masks are specified the current ignore list is displayed.
+
+=== Examples
+
+`+/ignore+` +
+`+/ignore nick1 nick2 nick3+` +
+`+/ignore nick@host+` +
+`+/ignore nick!user@host+` +
+`+/ignore *@host+` +
+`+/ignore *!baduser@*+`
+
+== /grep
+
+Filter view contents using a regular expression.
+
+Clear the regular expression by calling this without an argument.
+
+`/grep` is case-sensitive by default.
+
+=== Flags
+
+`-A n` - Show n messages after match
+`-B n` - Show n messages before match
+`-C n` - Show n messages before and after match
+`-F`   - Use plain-text match instead of regular expression
+`-i`   - Case insensitive match
+`-v`   - Invert pattern match
+`-m n` - Limit results to n matches
+`--`   - Stop processing flags
+
+== /dump
+
+Dump the current window's contents to a file.
+
+This command always outputs as if detailed mode is active.
+
+== /mentions
+
+Show a list of all messages that were highlighted as important.
+
+When using `/grep` the important messages are those matching
+the regular expression instead.
+
+== /setwindow
+
+Set window property.
+
+=== Properties
+
+`louder`: Upgrades normal messages to important. +
+`loud`: Uses default message importance. +
+`imponly`: Downgrades normal messages to boring. +
+`quiet`: Downgrades important messages to normal. +
+`quieter`: Downgrades message importance one step. +
+`silent`: Downgrades message importance to boring.
+
+`show` / `hide`: Toggles if window appears in window command shortcuts.
+
+== /setname
+
+Set window shortcut letter. If no letter is provided the next available
+letter will automatically be assigned.
+
+Available letters are configured in the `window-names` config setting,
+which defaults to the characters available from
+the top letter and number rows on a QWERTY keyboard.
diff --git a/glirc.cabal b/glirc.cabal
--- a/glirc.cabal
+++ b/glirc.cabal
@@ -1,6 +1,6 @@
 cabal-version:       2.2
 name:                glirc
-version:             2.40
+version:             2.40.1
 synopsis:            Console IRC client
 description:         Console IRC client
                      .
@@ -16,10 +16,10 @@
 category:            Network
 extra-source-files:  exec/linux_exported_symbols.txt
                      exec/macos_exported_symbols.txt
-extra-doc-files:     glirc.1 ChangeLog.md README.md
+extra-doc-files:     glirc.1 ChangeLog.md README.md doc/*.adoc
 homepage:            https://github.com/glguy/irc-core
 bug-reports:         https://github.com/glguy/irc-core/issues
-tested-with:         GHC==9.4.7
+tested-with:         GHC == 9.0.2, GHC == 9.4.7, GHC == 9.8.1
 
 custom-setup
   setup-depends: base     >=4.12 && <4.20,
@@ -76,6 +76,7 @@
     Client.Commands.Chat
     Client.Commands.Certificate
     Client.Commands.Connection
+    Client.Commands.Docs
     Client.Commands.Exec
     Client.Commands.Interpolation
     Client.Commands.Operator
@@ -93,6 +94,7 @@
     Client.Configuration.Notifications
     Client.Configuration.ServerSettings
     Client.Configuration.Sts
+    Client.Docs
     Client.EventLoop
     Client.EventLoop.Actions
     Client.EventLoop.Errors
@@ -163,7 +165,7 @@
     Build_glirc
 
   build-depends:
-    base                 >=4.11   && <4.20,
+    base                 >=4.15   && <4.20,
     HsOpenSSL            >=0.11   && <0.12,
     async                >=2.2    && <2.3,
     attoparsec           ^>=0.14,
@@ -179,7 +181,7 @@
     githash              ^>=0.1.6,
     hashable             >=1.2.4  && <1.5,
     hookup               ^>=0.8,
-    irc-core             ^>=2.11,
+    irc-core             ^>=2.12,
     kan-extensions       >=5.0    && <5.3,
     lens                 >=4.14   && <5.3,
     random               >=1.1    && <1.3,
@@ -192,7 +194,7 @@
     stm                  >=2.4    && <2.6,
     template-haskell     >=2.11   && <2.22,
     text                 >=1.2.2  && <2.2,
-    time                 >=1.10   && <1.14,
+    time                 >=1.9    && <1.14,
     transformers         >=0.5.2  && <0.7,
     unix                 >=2.7    && <2.9,
     unordered-containers >=0.2.11 && <0.3,
diff --git a/src/Client/Commands.hs b/src/Client/Commands.hs
--- a/src/Client/Commands.hs
+++ b/src/Client/Commands.hs
@@ -1,4 +1,4 @@
-{-# LANGUAGE BangPatterns, OverloadedStrings, ExistentialQuantification #-}
+{-# LANGUAGE BangPatterns, OverloadedStrings, TemplateHaskell, ExistentialQuantification #-}
 
 {-|
 Module      : Client.Commands
@@ -27,6 +27,7 @@
 
 import Client.Commands.Arguments.Parser (parse)
 import Client.Commands.Arguments.Spec (optionalArg, optionalNumberArg, remainingArg, simpleToken)
+import Client.Commands.Docs (clientDocs, cmdDoc)
 import Client.Commands.Exec
 import Client.Commands.Interpolation (resolveMacroExpansions, Macro(Macro), MacroSpec(MacroSpec))
 import Client.Commands.Recognizer (fromCommands, keys, recognize, Recognition(Exact), Recognizer)
@@ -227,93 +228,55 @@
   [ Command
       (pure "exit")
       (pure ())
-      "Exit the client immediately.\n"
+      $(clientDocs `cmdDoc` "exit")
     $ ClientCommand cmdExit noClientTab
 
   , Command
       (pure "reload")
       (optionalArg (simpleToken "[filename]"))
-      "Reload the client configuration file.\n\
-      \\n\
-      \If \^Bfilename\^B is provided it will be used to reload.\n\
-      \Otherwise the previously loaded configuration file will be reloaded.\n"
+      $(clientDocs `cmdDoc` "reload")
     $ ClientCommand cmdReload tabReload
 
   , Command
       (pure "extension")
       (liftA2 (,) (simpleToken "extension") (remainingArg "arguments"))
-      "Calls the process_command callback of the given extension.\n\
-      \\n\
-      \\^Bextension\^B should be the name of the loaded extension.\n"
+      $(clientDocs `cmdDoc` "extension")
     $ ClientCommand cmdExtension simpleClientTab
 
   , Command
       (pure "palette")
       (pure ())
-      "Show the current palette settings and a color chart to help pick new colors.\n"
+      $(clientDocs `cmdDoc` "palette")
     $ ClientCommand cmdPalette noClientTab
 
   , Command
       (pure "digraphs")
       (pure ())
-      "\^BDescription:\^B\n\
-      \\n\
-      \    Show the table of digraphs. A digraph is a pair of characters\n\
-      \    can be used together to represent an uncommon character. Type\n\
-      \    the two-character digraph corresponding to the desired output\n\
-      \    character and then press M-k (default binding).\n\
-      \\n\
-      \    Note that the digraphs list is searchable with /grep.\n\
-      \\n\
-      \\^BSee also:\^B grep\n"
+      $(clientDocs `cmdDoc` "digraphs")
     $ ClientCommand cmdDigraphs noClientTab
 
   , Command
       (pure "keymap")
       (pure ())
-      "Show the key binding map.\n\
-      \\n\
-      \Key bindings can be changed in configuration file. See `glirc --config-format`.\n"
+      $(clientDocs `cmdDoc` "keymap")
     $ ClientCommand cmdKeyMap noClientTab
 
   , Command
       (pure "rtsstats")
       (pure ())
-      "Show the GHC RTS statistics.\n"
+      $(clientDocs `cmdDoc` "rtsstats")
     $ ClientCommand cmdRtsStats noClientTab
 
   , Command
       (pure "exec")
       (remainingArg "arguments")
-      "Execute a command synchnonously sending the to a configuration destination.\n\
-      \\n\
-      \\^Barguments\^B: [-n[network]] [-c[channel]] [-i input] command [arguments...]\n\
-      \\n\
-      \When \^Binput\^B is specified it is sent to the stdin.\n\
-      \\n\
-      \When neither \^Bnetwork\^B nor \^Bchannel\^B are specified output goes to client window (*).\n\
-      \When \^Bnetwork\^B is specified output is sent as raw IRC traffic to the network.\n\
-      \When \^Bchannel\^B is specified output is sent as chat to the given channel on the current network.\n\
-      \When \^Bnetwork\^B and \^Bchannel\^B are specified output is sent as chat to the given channel on the given network.\n\
-      \\n\
-      \\^Barguments\^B is divided on spaces into words before being processed\
-      \ by getopt. Use Haskell string literal syntax to create arguments with\
-      \ escaped characters and spaces inside.\n\
-      \\n"
+      $(clientDocs `cmdDoc` "exec")
     $ ClientCommand cmdExec simpleClientTab
 
   , Command
       (pure "url")
       optionalNumberArg
-      "Open a URL seen in chat.\n\
-      \\n\
-      \The URL is opened using the executable configured under \^Burl-opener\^B.\n\
-      \\n\
-      \When this command is active in the textbox, chat messages are filtered to\
-      \ only show ones with URLs.\n\
-      \\n\
-      \When \^Bnumber\^B is omitted it defaults to \^B1\^B. The number selects the\
-      \ URL to open counting back from the most recent.\n"
+      $(clientDocs `cmdDoc` "url")
     $ ClientCommand cmdUrl noClientTab
 
   , newCertificateCommand
@@ -321,10 +284,7 @@
   , Command
       (pure "help")
       (optionalArg (simpleToken "[command]"))
-      "Show command documentation.\n\
-      \\n\
-      \When \^Bcommand\^B is omitted a list of all commands is displayed.\n\
-      \When \^Bcommand\^B is specified detailed help for that command is shown.\n"
+      $(clientDocs `cmdDoc` "help")
     $ ClientCommand cmdHelp tabHelp
 
   ------------------------------------------------------------------------
diff --git a/src/Client/Commands/Certificate.hs b/src/Client/Commands/Certificate.hs
--- a/src/Client/Commands/Certificate.hs
+++ b/src/Client/Commands/Certificate.hs
@@ -1,4 +1,4 @@
-{-# Language OverloadedStrings #-}
+{-# Language OverloadedStrings, TemplateHaskell #-}
 {-|
 Module      : Client.Commands.Certificate
 Description : Certificate management commands
@@ -10,6 +10,7 @@
 module Client.Commands.Certificate (newCertificateCommand) where
 
 import Client.Commands.Arguments.Spec
+import Client.Commands.Docs (netDocs, cmdDoc)
 import Client.Commands.TabCompletion (noClientTab)
 import Client.Commands.Types
 import Client.State (recordError, recordSuccess)
@@ -45,29 +46,7 @@
   Command
     (pure "new-self-signed-cert")
     (liftA2 (,) (simpleToken "filename") keysizeArg)
-      "\^BParameters:\^B\n\
-      \\n\
-      \    filename:   Certificate and private key PEM output\n\
-      \    keysize:    Public-key size (default 2048, range 1024-8192)\n\
-      \    passphrase: Optional AES-128 private key passphrase\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Generate a new self-signed certificate for network service\n\
-      \    identification.\n\
-      \\n\
-      \\^BExample command:\^B\n\
-      \\n\
-      \    /new-self-signed-cert /home/me/.glirc/config/my.pem 2048 SeCrEt\n\
-      \\n\
-      \\^BExample configuration:\^B\n\
-      \    servers:\n\
-      \      * name:                    \"fn\"\n\
-      \        hostname:                \"irc.libera.chat\"\n\
-      \        sasl: mechanism:         external\n\
-      \        tls:                     yes\n\
-      \        tls-client-cert:         \"my.pem\"\n\
-      \        tls-client-key-password: \"SeCrEt\"\n"
+    $(netDocs `cmdDoc` "new-self-signed-cert")
     (ClientCommand cmdNewCert noClientTab)
 
 cmdNewCert :: ClientCommand (String, Maybe (Int, String))
diff --git a/src/Client/Commands/Channel.hs b/src/Client/Commands/Channel.hs
--- a/src/Client/Commands/Channel.hs
+++ b/src/Client/Commands/Channel.hs
@@ -1,4 +1,4 @@
-{-# Language OverloadedStrings #-}
+{-# Language OverloadedStrings, TemplateHaskell #-}
 {-|
 Module      : Client.Commands.Channel
 Description : Channel management command implementations
@@ -10,6 +10,7 @@
 module Client.Commands.Channel (channelCommands) where
 
 import Client.Commands.Arguments.Spec
+import Client.Commands.Docs (chanopDocs, cmdDoc)
 import Client.Commands.TabCompletion (activeNicks, noChannelTab, simpleChannelTab, simpleTabCompletion)
 import Client.Commands.Types
 import Client.Commands.WordCompletion (plainWordCompleteMode)
@@ -39,76 +40,43 @@
   [ Command
       (pure "mode")
       (fromMaybe [] <$> optionalArg (extensionArg "[modes]" modeParamArgs))
-      "Sets IRC modes.\n\
-      \\n\
-      \Examples:\n\
-      \Setting a ban:           /mode +b *!*@hostname\n\
-      \Removing a quiet:        /mode -q *!*@hostname\n\
-      \Voicing two users:       /mode +vv user1 user2\n\
-      \Demoting an op to voice: /mode +v-o user1 user1\n\
-      \\n\
-      \When executed in a network window, mode changes are applied to your user.\n\
-      \When executed in a channel window, mode changes are applied to the channel.\n\
-      \\n\
-      \This command has parameter sensitive tab-completion.\n\
-      \\n\
-      \See also: /masks /channelinfo\n"
+      $(chanopDocs `cmdDoc` "mode")
     $ NetworkCommand cmdMode tabMode
 
   , Command
       (pure "masks")
       (simpleToken "mode")
-      "Show mask lists for current channel.\n\
-      \\n\
-      \Common \^Bmode\^B values:\n\
-      \\^Bb\^B: bans\n\
-      \\^Bq\^B: quiets\n\
-      \\^BI\^B: invite exemptions (op view only)\n\
-      \\^Be\^B: ban exemption (op view only)s\n\
-      \\n\
-      \To populate the mask lists for the first time use: /mode \^Bmode\^B\n\
-      \\n\
-      \See also: /mode\n"
+      $(chanopDocs `cmdDoc` "masks")
     $ ChannelCommand cmdMasks noChannelTab
 
   , Command
       (pure "invite")
       (simpleToken "nick")
-      "Invite a user to the current channel.\n"
+      $(chanopDocs `cmdDoc` "invite")
     $ ChannelCommand cmdInvite simpleChannelTab
 
   , Command
       (pure "topic")
       (remainingArg "message")
-      "Set the topic on the current channel.\n\
-      \\n\
-      \Tab-completion with no \^Bmessage\^B specified will load the current topic for editing.\n"
+      $(chanopDocs `cmdDoc` "topic")
     $ ChannelCommand cmdTopic tabTopic
 
   , Command
       (pure "kick")
       (liftA2 (,) (simpleToken "nick") (remainingArg "reason"))
-      "Kick a user from the current channel.\n\
-      \\n\
-      \See also: /kickban /remove\n"
+      $(chanopDocs `cmdDoc` "kick")
     $ ChannelCommand cmdKick simpleChannelTab
 
   , Command
       (pure "kickban")
       (liftA2 (,) (simpleToken "nick") (remainingArg "reason"))
-      "Ban and kick a user from the current channel.\n\
-      \\n\
-      \Users are banned by hostname match.\n\
-      \See also: /kick /remove\n"
+      $(chanopDocs `cmdDoc` "kickban")
     $ ChannelCommand cmdKickBan simpleChannelTab
 
   , Command
       (pure "remove")
       (liftA2 (,) (simpleToken "nick") (remainingArg "reason"))
-      "Remove a user from the current channel.\n\
-      \\n\
-      \Remove works like /kick except it results in a PART.\n\
-      \See also: /kick /kickban\n"
+      $(chanopDocs `cmdDoc` "remove")
     $ ChannelCommand cmdRemove simpleChannelTab
 
   ]
diff --git a/src/Client/Commands/Chat.hs b/src/Client/Commands/Chat.hs
--- a/src/Client/Commands/Chat.hs
+++ b/src/Client/Commands/Chat.hs
@@ -1,4 +1,4 @@
-{-# Language BangPatterns, OverloadedStrings #-}
+{-# Language BangPatterns, OverloadedStrings, TemplateHaskell #-}
 {-|
 Module      : Client.Commands.Chat
 Description : Common user IRC commands
@@ -31,6 +31,7 @@
 import Irc.Identifier (Identifier, idText, mkId)
 import Irc.Message (IrcMsg(Privmsg, Notice, Ctcp), Source(Source))
 import Irc.RawIrcMsg (RawIrcMsg, parseRawIrcMsg)
+import Client.Commands.Docs (chatDocs, cmdDoc)
 
 chatCommands :: CommandSection
 chatCommands = CommandSection "IRC commands"
@@ -39,319 +40,106 @@
   [ Command
       ("join" :| ["j"])
       (liftA2 (,) (simpleToken "channels") (optionalArg (simpleToken "[keys]")))
-      "\^BParameters:\^B\n\
-      \\n\
-      \    channels: Comma-separated list of channels\n\
-      \    keys:     Comma-separated list of keys\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Join the given channels. When keys are provided, they should\n\
-      \    occur in the same order as the channels.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /join #friends\n\
-      \    /join #secret thekey\n\
-      \    /join #secret1,#secret2 key1,key2\n\
-      \\n\
-      \\^BSee also:\^B channel, clear, part\n"
+      $(chatDocs `cmdDoc` "join")
     $ NetworkCommand cmdJoin simpleNetworkTab
 
   , Command
       (pure "part")
       (remainingArg "reason")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    reason: Optional message sent to channel as part reason\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Part from the current channel.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /part\n\
-      \    /part It's not me, it's you\n\
-      \\n\
-      \\^BSee also:\^B clear, join, quit\n"
+      $(chatDocs `cmdDoc` "part")
     $ ChannelCommand cmdPart simpleChannelTab
 
   , Command
       (pure "msg")
       (liftA2 (,) (simpleToken "target") (remainingArg "message"))
-      "\^BParameters:\^B\n\
-      \\n\
-      \    target:  Comma-separated list of nicknames and channels\n\
-      \    message: Formatted message body\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Send a chat message to a user or a channel. On servers\n\
-      \    with STATUSMSG support, the channel name can be prefixed\n\
-      \    with a sigil to restrict the recipients to those with the\n\
-      \    given mode.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /msg buddy I'm sending you a message.\n\
-      \    /msg #friends This message is for the whole channel.\n\
-      \    /msg him,her I'm chatting with two people.\n\
-      \    /msg @#users This message is only for ops!\n\
-      \\n\
-      \\^BSee also:\^B notice, me, say\n"
+      $(chatDocs `cmdDoc` "msg")
     $ NetworkCommand cmdMsg simpleNetworkTab
 
   , Command
       (pure "me")
       (remainingArg "message")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    message: Body of action message\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Sends an action message to the currently focused channel.\n\
-      \    Most clients will render these messages prefixed with\n\
-      \    only your nickname as though describing an action.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /me shrugs\n\
-      \\n\
-      \\^BSee also:\^B notice, msg, say\n"
+      $(chatDocs `cmdDoc` "me")
     $ ChatCommand cmdMe simpleChannelTab
 
   , Command
       (pure "say")
       (remainingArg "message")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    message: Body of message\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Send a message to the current chat window.  This can be useful\n\
-      \    for sending a chat message with a leading '/' to the current\n\
-      \    chat window.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /say /help is the right place to start!\n\
-      \\n\
-      \\^BSee also:\^B notice, me, msg\n"
+      $(chatDocs `cmdDoc` "say")
     $ ChatCommand cmdSay simpleChannelTab
 
   , Command
       ("query" :| ["q"])
       (liftA2 (,) (simpleToken "target") (remainingArg "message"))
-      "\^BParameters:\^B\n\
-      \\n\
-      \    target: Focus name\n\
-      \    message: Optional message\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    This command switches the client focus to the given\n\
-      \    target and optionally sends a message to that target.\n\
-      \\n\
-      \    Channel: \^_#channel\^_\n\
-      \    Channel: \^_network\^_:\^_#channel\^_\n\
-      \    User:    \^_nick\^_\n\
-      \    User:    \^_network\^_:\^_nick\^_\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /q fn:#haskell\n\
-      \    /q #haskell\n\
-      \    /q lambdabot @messages\n\
-      \    /q irc_friend How are you?\n\
-      \\n\
-      \\^BSee also:\^B msg channel focus\n"
+      $(chatDocs `cmdDoc` "query")
     $ ClientCommand cmdQuery simpleClientTab
 
   , Command
       (pure "notice")
       (liftA2 (,) (simpleToken "target") (remainingArg "message"))
-      "\^BParameters:\^B\n\
-      \\n\
-      \    target:  Comma-separated list of nicknames and channels\n\
-      \    message: Formatted message body\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Send a chat notice to a user or a channel. On servers\n\
-      \    with STATUSMSG support, the channel name can be prefixed\n\
-      \    with a sigil to restrict the recipients to those with the\n\
-      \    given mode. Notice messages were originally intended to be\n\
-      \    used by bots. Different clients will render these in different\n\
-      \    ways.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /notice buddy I'm sending you a message.\n\
-      \    /notice #friends This message is for the whole channel.\n\
-      \    /notice him,her I'm chatting with two people.\n\
-      \    /notice @#users This message is only for ops!\n\
-      \\n\
-      \\^BSee also:\^B me, msg, say\n"
+      $(chatDocs `cmdDoc` "notice")
     $ NetworkCommand cmdNotice simpleNetworkTab
 
   , Command
       (pure "wallops")
       (remainingArg "message to +w users")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    message: Formatted message body\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Send a network-wide WALLOPS message. These message go out\n\
-      \    to users who have the 'w' usermode set.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /wallops Hi everyone, thanks for using this network!\n\
-      \\n\
-      \\^BSee also:\^B me, msg, say\n"
+      $(chatDocs `cmdDoc` "wallops")
     $ NetworkCommand cmdWallops simpleNetworkTab
 
   , Command
       (pure "operwall")
       (remainingArg "message to +z opers")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    message: Formatted message body\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Send a network-wide WALLOPS message to opers. These message go\n\
-      \    out to opers who have the 'z' usermode set.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /operwall What's this even for?\n\
-      \\n\
-      \\^BSee also:\^B me, msg, say\n"
+      $(chatDocs `cmdDoc` "operwall")
     $ NetworkCommand cmdOperwall simpleNetworkTab
 
   , Command
       (pure "ctcp")
       (liftA3 (,,) (simpleToken "target") (simpleToken "command") (remainingArg "arguments"))
-      "\^BParameters:\^B\n\
-      \\n\
-      \    target:    Comma-separated list of nicknames and channels\n\
-      \    command:   CTCP command name\n\
-      \    arguments: CTCP command arguments\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Client-to-client protocol (CTCP) commands can be used\n\
-      \    to query information from another user's client application\n\
-      \    directly. Common CTCP commands include: ACTION, PING, VERSION,\n\
-      \    USERINFO, CLIENTINFO, and TIME. glirc does not automatically\n\
-      \    respond to CTCP commands.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /ctcp myfriend VERSION\n\
-      \    /ctcp myfriend CLIENTINFO\n"
+      $(chatDocs `cmdDoc` "ctcp")
     $ NetworkCommand cmdCtcp simpleNetworkTab
 
   , Command
       (pure "nick")
       (simpleToken "nick")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    nick: New nickname\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Change your nickname on the currently focused server.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /nick guest123\n\
-      \    /nick better_nick\n"
+      $(chatDocs `cmdDoc` "nick")
     $ NetworkCommand cmdNick simpleNetworkTab
 
   , Command
       (pure "away")
       (remainingArg "message")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    message: Optional away message\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Change your nickname on the currently focused server.\n\
-      \    Omit the message parameter to clear your away status.\n\
-      \    The away message is only used by the server to update\n\
-      \    status in /whois and to provide automated responses.\n\
-      \    It is not used by this client directly.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /away\n\
-      \    /away Out getting some sun\n"
+      $(chatDocs `cmdDoc` "away")
     $ NetworkCommand cmdAway simpleNetworkTab
 
   , Command
       (pure "names")
       (pure ())
-      "\^BDescription:\^B\n\
-      \\n\
-      \    Show the user list for the current channel.\n\
-      \    Detailed view (default key F2) shows full hostmask.\n\
-      \    Hostmasks can be populated with /who #channel.\n\
-      \    Press ESC to exit the userlist.\n\
-      \\n\
-      \\^BSee also:\^B channelinfo, masks\n"
+      $(chatDocs `cmdDoc` "names")
     $ ChannelCommand cmdChanNames noChannelTab
 
   , Command
       (pure "channelinfo")
       (pure ())
-      "\^BDescription:\^B\n\
-      \\n\
-      \    Show information about the current channel.\n\
-      \    Press ESC to exit the channel info window.\n\
-      \\n\
-      \    Information includes topic, creation time, URL, and modes.\n\
-      \\n\
-      \\^BSee also:\^B masks, mode, topic, users\n"
+      $(chatDocs `cmdDoc` "channelinfo")
     $ ChannelCommand cmdChannelInfo noChannelTab
 
   , Command
       (pure "knock")
       (liftA2 (,) (simpleToken "channel") (remainingArg "message"))
-      "Request entry to an invite-only channel.\n"
+      $(chatDocs `cmdDoc` "knock")
     $ NetworkCommand cmdKnock simpleNetworkTab
 
   , Command
       (pure "quote")
       (remainingArg "raw IRC command")
-      "Send a raw IRC command.\n"
+      $(chatDocs `cmdDoc` "quote")
     $ NetworkCommand cmdQuote simpleNetworkTab
 
   , Command
       (pure "monitor")
       (extensionArg "[+-CLS]" monitorArgs)
-      "\^BSubcommands:\^B\n\
-      \\n\
-      \    /monitor + target[,target2]* - Add nicknames to monitor list\n\
-      \    /monitor - target[,target2]* - Remove nicknames to monitor list\n\
-      \    /monitor C                   - Clear monitor list\n\
-      \    /monitor L                   - Show monitor list\n\
-      \    /monitor S                   - Show status of nicknames on monitor list\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Monitor is a protocol for getting server-side notifications\n\
-      \    when users become online/offline.\n"
+      $(chatDocs `cmdDoc` "monitor")
     $ NetworkCommand cmdMonitor simpleNetworkTab
 
-    ]
+  ]
 
 monitorArgs :: ClientState -> String -> Maybe (Args ClientState [String])
 monitorArgs _ str =
diff --git a/src/Client/Commands/Connection.hs b/src/Client/Commands/Connection.hs
--- a/src/Client/Commands/Connection.hs
+++ b/src/Client/Commands/Connection.hs
@@ -1,4 +1,4 @@
-{-# Language OverloadedStrings #-}
+{-# Language OverloadedStrings, TemplateHaskell #-}
 {-|
 Module      : Client.Commands.Connection
 Description : Connection command implementations
@@ -10,6 +10,7 @@
 module Client.Commands.Connection (connectionCommands) where
 
 import Client.Commands.Arguments.Spec ( remainingArg, simpleToken )
+import Client.Commands.Docs (netDocs, cmdDoc)
 import Client.Commands.TabCompletion
 import Client.Commands.Types
 import Client.Commands.WordCompletion ( plainWordCompleteMode )
@@ -28,45 +29,37 @@
   [ Command
       (pure "connect")
       (simpleToken "network")
-      "Connect to \^Bnetwork\^B by name.\n\
-      \\n\
-      \If no name is configured the hostname is the 'name'.\n"
+      $(netDocs `cmdDoc` "connect")
     $ ClientCommand cmdConnect tabConnect
 
   , Command
       (pure "reconnect")
       (pure ())
-      "Reconnect to the current network.\n"
+      $(netDocs `cmdDoc` "reconnect")
     $ ClientCommand cmdReconnect noClientTab
 
   , Command
       (pure "disconnect")
       (pure ())
-      "Immediately terminate the current network connection.\n\
-      \\n\
-      \See also: /quit /exit\n"
+      $(netDocs `cmdDoc` "disconnect")
     $ NetworkCommand cmdDisconnect noNetworkTab
 
   , Command
       (pure "quit")
       (remainingArg "reason")
-      "Gracefully disconnect the current network connection.\n\
-      \\n\
-      \\^Breason\^B: optional quit reason\n\
-      \\n\
-      \See also: /disconnect /exit\n"
+      $(netDocs `cmdDoc` "quit")
     $ NetworkCommand cmdQuit simpleNetworkTab
 
   , Command
       (pure "cert")
       (pure ())
-      "Show the TLS certificate for the current connection.\n"
+      $(netDocs `cmdDoc` "cert")
     $ NetworkCommand cmdCert noNetworkTab
 
   , Command
       (pure "umode")
       (remainingArg "modes")
-      "Apply a user-mode change.\n"
+      $(netDocs `cmdDoc` "umode")
     $ NetworkCommand cmdUmode noNetworkTab
   ]
 
diff --git a/src/Client/Commands/Docs.hs b/src/Client/Commands/Docs.hs
new file mode 100644
--- /dev/null
+++ b/src/Client/Commands/Docs.hs
@@ -0,0 +1,59 @@
+{-# Language OverloadedStrings, TemplateHaskell #-}
+
+{-|
+Module      : Client.Commands.Docs
+Description : Command documentation
+Copyright   : (c) TheDaemoness 2023
+License     : ISC
+Maintainer  : emertens@gmail.com
+
+This module contains definitions for all of glirc's command docs.
+-}
+module Client.Commands.Docs
+  ( cmdDoc
+  , chanopDocs
+  , chatDocs
+  , clientDocs
+  , integrationDocs
+  , netDocs
+  , operDocs
+  , queriesDocs
+  , togglesDocs
+  , windowDocs
+  ) where
+
+import Client.Docs (Docs, loadDoc, lookupDoc, makeHeader)
+import Language.Haskell.TH (Q, Exp)
+import Language.Haskell.TH.Syntax (lift)
+
+cmdDoc :: Docs -> String -> Q Exp
+cmdDoc docs key = lookupDoc (makeHeader "Description") ('/':key) docs
+
+-- TODO: Replace each id with something that splits off the command name.
+
+chanopDocs :: Docs
+chanopDocs = $(loadDoc id "cmds_chanop" >>= lift)
+
+chatDocs :: Docs
+chatDocs = $(loadDoc id "cmds_chat" >>= lift)
+
+clientDocs :: Docs
+clientDocs = $(loadDoc id "cmds_client" >>= lift)
+
+integrationDocs :: Docs
+integrationDocs = $(loadDoc id "cmds_integration" >>= lift)
+
+netDocs :: Docs
+netDocs = $(loadDoc id "cmds_net" >>= lift)
+
+operDocs :: Docs
+operDocs = $(loadDoc id "cmds_oper" >>= lift)
+
+queriesDocs :: Docs
+queriesDocs = $(loadDoc id "cmds_queries" >>= lift)
+
+togglesDocs :: Docs
+togglesDocs = $(loadDoc id "cmds_toggles" >>= lift)
+
+windowDocs :: Docs
+windowDocs = $(loadDoc id "cmds_window" >>= lift)
diff --git a/src/Client/Commands/Exec.hs b/src/Client/Commands/Exec.hs
--- a/src/Client/Commands/Exec.hs
+++ b/src/Client/Commands/Exec.hs
@@ -31,8 +31,8 @@
 import Data.List (unfoldr)
 import Data.Text qualified as Text
 import Data.Text.Encoding qualified as Text
-import System.Console.GetOpt (getOpt, ArgDescr(ReqArg, OptArg), ArgOrder(RequireOrder), OptDescr(..))
-import System.Process.Typed (byteStringInput, proc, readProcessStdout_, setStdin)
+import System.Console.GetOpt (getOpt, ArgDescr(ReqArg, OptArg, NoArg), ArgOrder(RequireOrder), OptDescr(..))
+import System.Process.Typed (byteStringInput, proc, readProcessStdout, setStdin, ExitCode (ExitFailure))
 
 -- | Settings for @/exec@ command.
 --
@@ -53,6 +53,7 @@
   , _execCommand       :: String        -- ^ command filename
   , _execStdIn         :: String        -- ^ stdin source
   , _execArguments     :: [String]      -- ^ command arguments
+  , _execIgnoreError   :: Bool          -- ^ ignore the process exit code
   }
   deriving (Read,Show)
 
@@ -69,6 +70,7 @@
   , _execCommand       = error "no default command"
   , _execStdIn         = ""
   , _execArguments     = []
+  , _execIgnoreError   = False
   }
 
 options :: [OptDescr (ExecCmd -> ExecCmd)]
@@ -83,6 +85,9 @@
   , Option "i" ["input"]
         (ReqArg (set execStdIn) "INPUT")
         "Use string as stdin"
+  , Option "e" ["error"]
+        (NoArg (set execIgnoreError True))
+        "Ignore process error codes"
   ]
 
 -- | Parse the arguments to @/exec@ looking for various flags
@@ -106,15 +111,17 @@
   ExecCmd                       {- ^ exec configuration          -} ->
   IO (Either [String] [String]) {- ^ error lines or output lines -}
 runExecCmd cmd =
-  do res <- try (readProcessStdout_
-                   (setStdin (byteStringInput (L.fromStrict (Text.encodeUtf8 (Text.pack (view execStdIn cmd)))))
-                   (proc (view execCommand   cmd) (view execArguments cmd))))
-     return $! case res of
-       Left er   -> Left [displayException (er :: IOError)]
-       Right out ->
-          case Text.decodeUtf8' (L.toStrict out) of
-             Right str -> Right (lines (Text.unpack str))
-             Left e -> Left [displayException e]
+ do res <-
+      readProcessStdout
+        (setStdin (byteStringInput (L.fromStrict (Text.encodeUtf8 (Text.pack (view execStdIn cmd)))))
+        (proc (view execCommand   cmd) (view execArguments cmd)))
+    return $! case res of
+      (ExitFailure code, _) | not (view execIgnoreError cmd) ->
+        Left ["Process failed with exit code " ++ show code]
+      (_, out) ->
+        case Text.decodeUtf8' (L.toStrict out) of
+          Right str -> Right (lines (Text.unpack str))
+          Left e -> Left [displayException e]
 
 -- | Power words is similar to 'words' except that when it encounters
 -- a word formatted as a Haskell 'String' literal it parses it as
diff --git a/src/Client/Commands/Operator.hs b/src/Client/Commands/Operator.hs
--- a/src/Client/Commands/Operator.hs
+++ b/src/Client/Commands/Operator.hs
@@ -1,4 +1,4 @@
-{-# Language OverloadedStrings #-}
+{-# Language OverloadedStrings, TemplateHaskell #-}
 {-|
 Module      : Client.Commands.Operator
 Description : Operator command implementations
@@ -10,6 +10,7 @@
 module Client.Commands.Operator (operatorCommands) where
 
 import Client.Commands.Arguments.Spec (optionalArg, remainingArg, simpleToken)
+import Client.Commands.Docs (operDocs, cmdDoc)
 import Client.Commands.TabCompletion (noNetworkTab, simpleNetworkTab)
 import Client.Commands.Types
 import Client.State.Network (sendMsg)
@@ -25,152 +26,153 @@
   [ Command
       (pure "oper")
       (liftA2 (,) (simpleToken "user") (simpleToken "password"))
-      "Authenticate as a server operator.\n"
+      $(operDocs `cmdDoc` "oper")
     $ NetworkCommand cmdOper noNetworkTab
 
   , Command
       (pure "kill")
       (liftA2 (,) (simpleToken "client") (remainingArg "reason"))
-      "Kill a client connection to the server.\n"
+      $(operDocs `cmdDoc` "kill")
     $ NetworkCommand cmdKill simpleNetworkTab
 
   , Command
       (pure "kline")
       (liftA3 (,,) (simpleToken "minutes") (simpleToken "user@host") (remainingArg "reason"))
-      "Ban a client from the server.\n"
+      $(operDocs `cmdDoc` "kline")
     $ NetworkCommand cmdKline simpleNetworkTab
 
   , Command
       (pure "unkline")
       (liftA2 (,) (simpleToken "[user@]host") (optionalArg (simpleToken "[servername]")))
-      "Unban a client from the server.\n"
+      $(operDocs `cmdDoc` "unkline")
     $ NetworkCommand cmdUnkline simpleNetworkTab
 
   , Command
       (pure "undline")
       (liftA2 (,) (simpleToken "host") (optionalArg (simpleToken "[servername]")))
-      "Unban a client from the server.\n"
+      $(operDocs `cmdDoc` "undline")
     $ NetworkCommand cmdUndline simpleNetworkTab
 
   , Command
       (pure "unxline")
       (liftA2 (,) (simpleToken "gecos") (optionalArg (simpleToken "[servername]")))
-      "Unban a gecos from the server.\n"
+      $(operDocs `cmdDoc` "unxline")
     $ NetworkCommand cmdUnxline simpleNetworkTab
 
   , Command
       (pure "unresv")
       (liftA2 (,) (simpleToken "channel|nick") (optionalArg (simpleToken "[servername]")))
-      "Unban a channel or nickname from the server.\n"
+      $(operDocs `cmdDoc` "unresv")
     $ NetworkCommand cmdUnresv simpleNetworkTab
 
   , Command
       (pure "testline")
       (simpleToken "[[nick!]user@]host")
-      "Check matching I/K/D lines for a [[nick!]user@]host\n"
+      $(operDocs `cmdDoc` "testline")
     $ NetworkCommand cmdTestline simpleNetworkTab
 
   , Command
       (pure "testkline")
       (simpleToken "[user@]host")
-      "Check matching K/D lines for a [user@]host\n"
+      $(operDocs `cmdDoc` "testkline")
     $ NetworkCommand cmdTestkline simpleNetworkTab
 
   , Command
       (pure "testgecos")
       (simpleToken "gecos")
-      "Check matching X lines for a gecos\n"
+      $(operDocs `cmdDoc` "testgecos")
     $ NetworkCommand cmdTestGecos simpleNetworkTab
 
   , Command
       (pure "testmask")
       (liftA2 (,) (simpleToken "[nick!]user@host") (remainingArg "[gecos]"))
-      "Test how many local and global clients match a mask.\n"
+      $(operDocs `cmdDoc` "testmask")
     $ NetworkCommand cmdTestmask simpleNetworkTab
 
   , Command
       (pure "masktrace")
       (liftA2 (,) (simpleToken "[nick!]user@host") (remainingArg "[gecos]"))
-      "Outputs a list of local users matching the given masks.\n"
+      $(operDocs `cmdDoc` "masktrace")
     $ NetworkCommand cmdMasktrace simpleNetworkTab
 
   , Command
       (pure "chantrace")
       (simpleToken "channel")
-      "Outputs a list of channel members in etrace format.\n"
+      $(operDocs `cmdDoc` "chantrace")
     $ NetworkCommand cmdChantrace simpleNetworkTab
 
   , Command
       (pure "trace")
       (optionalArg (liftA2 (,) (simpleToken "[server|nick]") (optionalArg (simpleToken "[location]"))))
-      "Outputs a list users on a server.\n"
+      $(operDocs `cmdDoc` "trace")
     $ NetworkCommand cmdTrace simpleNetworkTab
 
   , Command
       (pure "etrace")
       (optionalArg (simpleToken "[-full|-v4|-v6|nick]"))
-      "Outputs a list users on a server.\n"
+      $(operDocs `cmdDoc` "etrace")
     $ NetworkCommand cmdEtrace simpleNetworkTab
 
   , Command
       (pure "map")
       (pure ())
-      "Display network map.\n"
+      $(operDocs `cmdDoc` "map")
     $ NetworkCommand cmdMap simpleNetworkTab
 
   , Command
       (pure "sconnect")
       (liftA2 (,) (simpleToken "connect_to") (optionalArg (liftA2 (,) (simpleToken "[port]") (optionalArg (simpleToken "[remote]")))))
-      "Connect two servers together.\n"
+      $(operDocs `cmdDoc` "sconnect")
     $ NetworkCommand cmdSconnect simpleNetworkTab
 
   , Command
       (pure "squit")
       (liftA2 (,) (simpleToken "server") (remainingArg "[reason]"))
-      "Split a server away from your side of the network.\n"
+      $(operDocs `cmdDoc` "squit")
     $ NetworkCommand cmdSquit simpleNetworkTab
 
   , Command
       (pure "modload")
       (liftA2 (,) (simpleToken "[path/]module") (optionalArg (simpleToken "[remote]")))
-      "Load an IRCd module.\n"
+      $(operDocs `cmdDoc` "modload")
     $ NetworkCommand cmdModload simpleNetworkTab
 
   , Command
       (pure "modunload")
       (liftA2 (,) (simpleToken "module") (optionalArg (simpleToken "[remote]")))
-      "Unload an IRCd module.\n"
+      $(operDocs `cmdDoc` "modunload")
     $ NetworkCommand cmdModunload simpleNetworkTab
 
   , Command
       (pure "modlist")
       (optionalArg (liftA2 (,) (simpleToken "pattern") (optionalArg (simpleToken "[remote]"))))
-      "List loaded IRCd modules.\n"
+      $(operDocs `cmdDoc` "modlist")
     $ NetworkCommand cmdModlist simpleNetworkTab
 
   , Command
       (pure "modrestart")
       (optionalArg (simpleToken "[server]"))
-      "Reload all IRCd modules.\n"
+      $(operDocs `cmdDoc` "modrestart")
     $ NetworkCommand cmdModrestart simpleNetworkTab
 
   , Command
       (pure "modreload")
       (liftA2 (,) (simpleToken "module") (optionalArg (simpleToken "[remote]")))
-      "Reload an IRCd module.\n"
+      $(operDocs `cmdDoc` "modreload")
     $ NetworkCommand cmdModreload simpleNetworkTab
 
   , Command
       (pure "grant")
       (liftA2 (,) (simpleToken "target") (simpleToken "privset"))
-      "Manually assign a privset to a user.\n"
+      $(operDocs `cmdDoc` "grant")
     $ NetworkCommand cmdGrant simpleNetworkTab
 
   , Command
       (pure "privs")
       (optionalArg (simpleToken "[target]"))
-      "Check operator privileges of the target.\n"
+      $(operDocs `cmdDoc` "privs")
     $ NetworkCommand cmdPrivs simpleNetworkTab
+
   ]
 
 cmdGrant :: NetworkCommand (String, String)
diff --git a/src/Client/Commands/Queries.hs b/src/Client/Commands/Queries.hs
--- a/src/Client/Commands/Queries.hs
+++ b/src/Client/Commands/Queries.hs
@@ -1,4 +1,4 @@
-{-# Language OverloadedStrings #-}
+{-# Language OverloadedStrings, TemplateHaskell #-}
 {-# LANGUAGE BlockArguments #-}
 {-# LANGUAGE ViewPatterns #-}
 {-|
@@ -12,6 +12,7 @@
 module Client.Commands.Queries (queryCommands) where
 
 import Client.Commands.Arguments.Spec (optionalArg, remainingArg, simpleToken, extensionArg, Args)
+import Client.Commands.Docs (queriesDocs, cmdDoc)
 import Client.Commands.TabCompletion (noNetworkTab, simpleNetworkTab)
 import Client.Commands.Types (commandSuccess, commandSuccessUpdateCS, Command(Command), CommandImpl(NetworkCommand), CommandSection(CommandSection), NetworkCommand)
 import Client.State (changeSubfocus, ClientState)
@@ -31,115 +32,97 @@
   [ Command
       (pure "who")
       (optionalArg (liftA2 (,) (simpleToken "[channel|nick|mask]") (optionalArg (simpleToken "[options]"))))
-      "Send WHO query to server with given arguments, or just show the who view.\n"
+      $(queriesDocs `cmdDoc` "who")
     $ NetworkCommand cmdWho simpleNetworkTab
 
   , Command
       (pure "whois")
       (remainingArg "arguments")
-      "Send WHOIS query to server with given arguments.\n"
+      $(queriesDocs `cmdDoc` "whois")
     $ NetworkCommand cmdWhois simpleNetworkTab
 
   , Command
       (pure "whowas")
       (remainingArg "arguments")
-      "Send WHOWAS query to server with given arguments.\n"
+      $(queriesDocs `cmdDoc` "whowas")
     $ NetworkCommand cmdWhowas simpleNetworkTab
 
   , Command
       (pure "ison")
       (remainingArg "arguments")
-      "Send ISON query to server with given arguments.\n"
+      $(queriesDocs `cmdDoc` "ison")
     $ NetworkCommand cmdIson   simpleNetworkTab
 
   , Command
       (pure "userhost")
       (remainingArg "arguments")
-      "Send USERHOST query to server with given arguments.\n"
+      $(queriesDocs `cmdDoc` "userhost")
     $ NetworkCommand cmdUserhost simpleNetworkTab
 
   , Command
       (pure "time")
       (optionalArg (simpleToken "[servername]"))
-      "Send TIME query to server with given arguments.\n"
+      $(queriesDocs `cmdDoc` "time")
     $ NetworkCommand cmdTime simpleNetworkTab
 
   , Command
       (pure "stats")
       (remainingArg "arguments")
-      "Send STATS query to server with given arguments.\n"
+      $(queriesDocs `cmdDoc` "stats")
     $ NetworkCommand cmdStats simpleNetworkTab
 
   , Command
       (pure "lusers")
       (optionalArg (simpleToken "[servername]"))
-      "Send LUSERS query to a given server.\n"
+      $(queriesDocs `cmdDoc` "lusers")
     $ NetworkCommand cmdLusers simpleNetworkTab
 
   , Command
       (pure "users")
       (optionalArg (simpleToken "[servername]"))
-      "Send USERS query to a given server.\n"
+      $(queriesDocs `cmdDoc` "users")
     $ NetworkCommand cmdUsers simpleNetworkTab
 
   , Command
-      (pure "motd") (optionalArg (simpleToken "[servername]"))
-      "Send MOTD query to server.\n"
+      (pure "motd")
+      (optionalArg (simpleToken "[servername]"))
+      $(queriesDocs `cmdDoc` "motd")
     $ NetworkCommand cmdMotd simpleNetworkTab
 
   , Command
-      (pure "admin") (optionalArg (simpleToken "[servername]"))
-      "Send ADMIN query to server.\n"
+      (pure "admin")
+      (optionalArg (simpleToken "[servername]"))
+      $(queriesDocs `cmdDoc` "admin")
     $ NetworkCommand cmdAdmin simpleNetworkTab
 
   , Command
-      (pure "rules") (optionalArg (simpleToken "[servername]"))
-      "Send RULES query to server.\n"
+      (pure "rules")
+      (optionalArg (simpleToken "[servername]"))
+      $(queriesDocs `cmdDoc` "rules")
     $ NetworkCommand cmdRules simpleNetworkTab
 
   , Command
-      (pure "info") (pure ())
-      "Send INFO query to server.\n"
+      (pure "info")
+      (pure ())
+      $(queriesDocs `cmdDoc` "info")
     $ NetworkCommand cmdInfo noNetworkTab
 
   , Command
       (pure "list")
       (optionalArg (extensionArg "[clientarg]" listArgs))
-      "\^BParameters:\^B\n\
-      \\n\
-      \    clientarg: An optionally-comma-separated list of\n\
-      \               flags for controlling the list.\n\
-      \        ~: Always refresh the list.\n\
-      \        >n: Show only channels with more than \^Bn\^B users.\n\
-      \        <n: Show only channels with less than \^Bn\^B users.\n\
-      \\n\
-      \    serverarg: The ELIST argument to send to the server.\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    View the list of public channels on the server.\n\
-      \\n\
-      \    Sends a LIST query and caches the result;\n\
-      \    on larger networks on slower connections,\n\
-      \    this may take a while to complete.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /list\n\
-      \    /list >100\n\
-      \    /list ~ <20\n\
-      \    /list , *-ops"
+      $(queriesDocs `cmdDoc` "list")
     $ NetworkCommand cmdList simpleNetworkTab
 
   , Command
       (pure "links")
       (remainingArg "arguments")
-      "Send LINKS query to server with given arguments.\n"
+      $(queriesDocs `cmdDoc` "links")
     $ NetworkCommand cmdLinks simpleNetworkTab
 
   , Command
-      (pure "version") (optionalArg (simpleToken "[servername]"))
-      "Send VERSION query to server.\n"
+      (pure "version")
+      (optionalArg (simpleToken "[servername]"))
+      $(queriesDocs `cmdDoc` "version")
     $ NetworkCommand cmdVersion simpleNetworkTab
 
   ]
diff --git a/src/Client/Commands/Toggles.hs b/src/Client/Commands/Toggles.hs
--- a/src/Client/Commands/Toggles.hs
+++ b/src/Client/Commands/Toggles.hs
@@ -1,4 +1,4 @@
-{-# Language OverloadedStrings #-}
+{-# Language OverloadedStrings, TemplateHaskell #-}
 {-|
 Module      : Client.Commands.Toggles
 Description : View modality command implementations
@@ -9,6 +9,7 @@
 
 module Client.Commands.Toggles (togglesCommands) where
 
+import Client.Commands.Docs (togglesDocs, cmdDoc)
 import Client.Commands.TabCompletion (noClientTab)
 import Client.Commands.Types
 import Client.Configuration (EditMode(SingleLineEditor, MultiLineEditor), LayoutMode(OneColumn, TwoColumn))
@@ -21,43 +22,43 @@
   [ Command
       (pure "toggle-detail")
       (pure ())
-      "Toggle detailed message view.\n"
+      $(togglesDocs `cmdDoc` "toggle-detail")
     $ ClientCommand cmdToggleDetail noClientTab
 
   , Command
       (pure "toggle-activity-bar")
       (pure ())
-      "Toggle detailed detailed activity information in status bar.\n"
+      $(togglesDocs `cmdDoc` "toggle-activity-bar")
     $ ClientCommand cmdToggleActivityBar noClientTab
 
   , Command
       (pure "toggle-show-ping")
       (pure ())
-      "Toggle visibility of ping round-trip time.\n"
+      $(togglesDocs `cmdDoc` "toggle-show-ping")
     $ ClientCommand cmdToggleShowPing noClientTab
 
   , Command
       (pure "toggle-metadata")
       (pure ())
-      "Toggle visibility of metadata in chat windows.\n"
+      $(togglesDocs `cmdDoc` "toggle-metadata")
     $ ClientCommand cmdToggleMetadata noClientTab
 
   , Command
       (pure "toggle-layout")
       (pure ())
-      "Toggle multi-window layout mode.\n"
+      $(togglesDocs `cmdDoc` "toggle-layout")
     $ ClientCommand cmdToggleLayout noClientTab
 
   , Command
       (pure "toggle-editor")
       (pure ())
-      "Toggle between single-line and multi-line editor mode.\n"
+      $(togglesDocs `cmdDoc` "toggle-editor")
     $ ClientCommand cmdToggleEditor noClientTab
 
   , Command
       (pure "toggle-edit-lock")
       (pure ())
-      "Toggle editor lock mode. When editor is locked pressing Enter is disabled.\n"
+      $(togglesDocs `cmdDoc` "toggle-edit-lock")
     $ ClientCommand cmdToggleEditLock noClientTab
   ]
 
diff --git a/src/Client/Commands/Window.hs b/src/Client/Commands/Window.hs
--- a/src/Client/Commands/Window.hs
+++ b/src/Client/Commands/Window.hs
@@ -1,4 +1,4 @@
-{-# Language OverloadedStrings #-}
+{-# Language OverloadedStrings, TemplateHaskell #-}
 {-|
 Module      : Client.Commands.Window
 Description : Window command implementations
@@ -10,6 +10,7 @@
 module Client.Commands.Window (windowCommands, parseFocus) where
 
 import Client.Commands.Arguments.Spec
+import Client.Commands.Docs (windowDocs, cmdDoc)
 import Client.Commands.TabCompletion
 import Client.Commands.Types
 import Client.Commands.WordCompletion (plainWordCompleteMode)
@@ -41,216 +42,79 @@
   [ Command
       (pure "focus")
       (liftA2 (,) (simpleToken "network") (optionalArg (simpleToken "[target]")))
-      "Change the focused window.\n\
-      \\n\
-      \When only \^Bnetwork\^B is specified this switches to the network status window.\n\
-      \When \^Bnetwork\^B and \^Btarget\^B are specified this switches to that chat window.\n\
-      \\n\
-      \Nickname and channels can be specified in the \^Btarget\^B parameter.\n\
-      \See also: /query (aliased /c /channel) to switch to a target on the current network.\n"
+      $(windowDocs `cmdDoc` "focus")
     $ ClientCommand cmdFocus tabFocus
 
   , Command
       ("c" :| ["channel"])
       (simpleToken "focus")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    focuses: Focus name\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    This command sets the current window focus. When\n\
-      \    no network is specified, the current network will\n\
-      \    be used.\n\
-      \\n\
-      \    Client:  *\n\
-      \    Network: \^_network\^_:\n\
-      \    Channel: \^_#channel\^_\n\
-      \    Channel: \^_network\^_:\^_#channel\^_\n\
-      \    User:    \^_nick\^_\n\
-      \    User:    \^_network\^_:\^_nick\^_\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /c fn:#haskell\n\
-      \    /c #haskell\n\
-      \    /c fn:\n\
-      \    /c *:\n\
-      \\n\
-      \\^BSee also:\^B focus\n"
+      $(windowDocs `cmdDoc` "channel")
     $ ClientCommand cmdChannel tabChannel
 
   , Command
       (pure "clear")
       (optionalArg (liftA2 (,) (simpleToken "[network]") (optionalArg (simpleToken "[channel]"))))
-      "Clear a window.\n\
-      \\n\
-      \If no arguments are provided the current window is cleared.\n\
-      \If \^Bnetwork\^B is provided the that network window is cleared.\n\
-      \If \^Bnetwork\^B and \^Bchannel\^B are provided that chat window is cleared.\n\
-      \If \^Bnetwork\^B is provided and \^Bchannel\^B is \^B*\^O all windows for that network are cleared.\n\
-      \\n\
-      \If a window is cleared and no longer active that window will be removed from the client.\n"
+      $(windowDocs `cmdDoc` "clear")
     $ ClientCommand cmdClear tabFocus
 
   , Command
       (pure "windows")
       (optionalArg (simpleToken "[kind]"))
-      "Show a list of all windows with an optional argument to limit the kinds of windows listed.\n\
-      \\n\
-      \\^Bkind\^O: one of \^Bnetworks\^O, \^Bchannels\^O, \^Busers\^O\n\
-      \\n"
+      $(windowDocs `cmdDoc` "windows")
     $ ClientCommand cmdWindows tabWindows
 
   , Command
       (pure "splits")
       (remainingArg "focuses")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    focuses: List of focus names\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    This command sents the set of focuses that will always\n\
-      \    be visible, even when unfocused. When the client is focused\n\
-      \    to an active network, the network can be omitted when\n\
-      \    specifying a focus. If no focuses are listed, they will\n\
-      \    all be cleared.\n\
-      \\n\
-      \    Client:  *\n\
-      \    Network: \^_network\^_:\n\
-      \    Channel: \^_#channel\^_\n\
-      \    Channel: \^_network\^_:\^_#channel\^_\n\
-      \    User:    \^_nick\^_\n\
-      \    User:    \^_network\^_:\^_nick\^_\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /splits * fn:#haskell fn:chanserv\n\
-      \    /splits #haskell #haskell-lens nickserv\n\
-      \    /splits\n\
-      \\n\
-      \\^BSee also:\^B splits+, splits-\n"
+      $(windowDocs `cmdDoc` "splits")
     $ ClientCommand cmdSplits tabSplits
 
   , Command
       (pure "splits+")
       (remainingArg "focuses")
-      "Add windows to the splits list. Omit the list of focuses to add the\
-      \ current window.\n\
-      \\n\
-      \\^Bfocuses\^B: space delimited list of focus names.\n\
-      \\n\
-      \Client:  *\n\
-      \Network: \^BNETWORK\^B\n\
-      \Channel: \^BNETWORK\^B:\^B#CHANNEL\^B\n\
-      \User:    \^BNETWORK\^B:\^BNICK\^B\n\
-      \\n\
-      \If the network part is omitted, the current network will be used.\n"
+      $(windowDocs `cmdDoc` "splits")
     $ ClientCommand cmdSplitsAdd tabSplits
 
   , Command
       (pure "splits-")
       (remainingArg "focuses")
-      "Remove windows from the splits list. Omit the list of focuses to\
-      \ remove the current window.\n\
-      \\n\
-      \\^Bfocuses\^B: space delimited list of focus names.\n\
-      \\n\
-      \Client:  *\n\
-      \Network: \^BNETWORK\^B\n\
-      \Channel: \^BNETWORK\^B:\^B#CHANNEL\^B\n\
-      \User:    \^BNETWORK\^B:\^BNICK\^B\n\
-      \\n\
-      \If the network part is omitted, the current network will be used.\n"
+      $(windowDocs `cmdDoc` "splits")
     $ ClientCommand cmdSplitsDel tabActiveSplits
 
   , Command
       (pure "ignore")
       (remainingArg "masks")
-      "\^BParameters:\^B\n\
-      \\n\
-      \    masks: List of masks\n\
-      \\n\
-      \\^BDescription:\^B\n\
-      \\n\
-      \    Toggle the soft-ignore on each of the space-delimited given\n\
-      \    nicknames. Ignores can use \^B*\^B (many) and \^B?\^B (one) wildcards.\n\
-      \    Masks can be of the form: nick[[!user]@host]\n\
-      \    Masks use a case-insensitive comparison.\n\
-      \\n\
-      \    If no masks are specified the current ignore list is displayed.\n\
-      \\n\
-      \\^BExamples:\^B\n\
-      \\n\
-      \    /ignore\n\
-      \    /ignore nick1 nick2 nick3\n\
-      \    /ignore nick@host\n\
-      \    /ignore nick!user@host\n\
-      \    /ignore *@host\n\
-      \    /ignore *!baduser@*\n"
+      $(windowDocs `cmdDoc` "ignore")
     $ ClientCommand cmdIgnore tabIgnore
 
   , Command
       (pure "grep")
       (remainingArg "regular-expression")
-      "Set the persistent regular expression.\n\
-      \\n\
-      \\^BFlags:\^B\n\
-      \    -A n Show n messages after match\n\
-      \    -B n Show n messages before match\n\
-      \    -C n Show n messages before and after match\n\
-      \    -F   Use plain-text match instead of regular expression\n\
-      \    -i   Case insensitive match\n\
-      \    -v   Invert pattern match\n\
-      \    -m n Limit results to n matches\n\
-      \    --   Stop processing flags\n\
-      \\n\
-      \Clear the regular expression by calling this without an argument.\n\
-      \\n\
-      \\^B/grep\^O is case-sensitive.\n"
+      $(windowDocs `cmdDoc` "grep")
     $ ClientCommand cmdGrep simpleClientTab
 
   , Command
       (pure "dump")
       (simpleToken "filename")
-      "Dump current buffer to file.\n"
+      $(windowDocs `cmdDoc` "dump")
     $ ClientCommand cmdDump simpleClientTab
 
   , Command
       (pure "mentions")
       (pure ())
-      "Show a list of all message that were highlighted as important.\n\
-      \\n\
-      \When using \^B/grep\^B the important messages are those matching\n\
-      \the regular expression instead.\n"
+      $(windowDocs `cmdDoc` "mentions")
     $ ClientCommand cmdMentions noClientTab
 
   , Command
       (pure "setwindow")
       (simpleToken ("hide|show" ++ concatMap ('|':) activityFilterStrings))
-      "Set window property.\n\
-      \\n\
-      \\^Bsilent\^B / \^Bquieter\^B / \^Bquiet\^B / \^Bimponly\^B / \^Bloud\^B / \^Blouder\^B\n\
-      \    Changes the importance of normal and important messages:\n\
-      \      \^Blouder\^B: Upgrades normal to important.\n\
-      \      \^Bloud\^B: Uses default values.\n\
-      \      \^Bimponly\^B: Downgrades normal to boring.\n\
-      \      \^Bquiet\^B: Downgrades important to normal.\n\
-      \      \^Bquieter\^B: Downgrades both one step.\n\
-      \      \^Bsilent\^B: Downgrades both to boring.\n\
-      \\n\
-      \\^Bshow\^B / \^Bhide\^B\n\
-      \    Toggles if window appears in window command shortcuts.\n"
+      $(windowDocs `cmdDoc` "setwindow")
     $ ClientCommand cmdSetWindow tabSetWindow
 
   , Command
       (pure "setname")
       (optionalArg (simpleToken "[letter]"))
-      "Set window shortcut letter. If no letter is provided the next available\n\
-      \letter will automatically be assigned.\n\
-      \\n\
-      \Available letters are configured in the 'window-names' configuration setting.\n"
+      $(windowDocs `cmdDoc` "setname")
     $ ClientCommand cmdSetWindowName noClientTab
 
   ]
diff --git a/src/Client/Commands/ZNC.hs b/src/Client/Commands/ZNC.hs
--- a/src/Client/Commands/ZNC.hs
+++ b/src/Client/Commands/ZNC.hs
@@ -1,4 +1,4 @@
-{-# Language OverloadedStrings #-}
+{-# Language TemplateHaskell, OverloadedStrings #-}
 {-|
 Module      : Client.Commands.ZNC
 Description : ZNC command implementations
@@ -9,8 +9,9 @@
 
 module Client.Commands.ZNC (zncCommands) where
 
-import Control.Applicative (asum, liftA2)
+import Control.Applicative ((<|>), empty, liftA2)
 import Client.Commands.Arguments.Spec (optionalArg, remainingArg, simpleToken)
+import Client.Commands.Docs (integrationDocs, cmdDoc)
 import Client.Commands.TabCompletion (noNetworkTab, simpleNetworkTab)
 import Client.Commands.Types
 import Client.State.Network (sendMsg)
@@ -26,26 +27,13 @@
   [ Command
       (pure "znc")
       (remainingArg "arguments")
-      "Send command directly to ZNC.\n\
-      \\n\
-      \The advantage of this over /msg is that responses are not broadcast to call clients.\n"
+      $(integrationDocs `cmdDoc` "znc")
     $ NetworkCommand cmdZnc simpleNetworkTab
 
   , Command
       (pure "znc-playback")
       (optionalArg (liftA2 (,) (simpleToken "[time]") (optionalArg (simpleToken "[date]"))))
-      "Request playback from the ZNC 'playback' module.\n\
-      \\n\
-      \\^Btime\^B determines the time to playback since.\n\
-      \\^Bdate\^B determines the date to playback since.\n\
-      \\n\
-      \When both \^Btime\^B and \^Bdate\^B are omitted, all playback is requested.\n\
-      \When both \^Bdate\^B is omitted it is defaulted the most recent date in the past that makes sense.\n\
-      \\n\
-      \Time format: HOURS:MINUTES (example: 7:00)\n\
-      \Date format: YEAR-MONTH-DAY (example: 2016-06-16)\n\
-      \\n\
-      \Note that the playback module is not installed in ZNC by default!\n"
+      $(integrationDocs `cmdDoc` "znc-playback")
     $ NetworkCommand cmdZncPlayback noNetworkTab
 
   ]
@@ -91,7 +79,8 @@
     timeFormats = ["%k:%M:%S","%k:%M"]
     dateFormats = ["%F"]
     parseFormats formats str =
-      asum (map (parseTimeM False defaultTimeLocale ?? str) formats)
+      -- asum requires base >= 4.16
+      foldr (<|>) empty (map (parseTimeM False defaultTimeLocale ?? str) formats)
 
     successZoned = success . formatTime defaultTimeLocale "%s"
 
diff --git a/src/Client/Docs.hs b/src/Client/Docs.hs
new file mode 100644
--- /dev/null
+++ b/src/Client/Docs.hs
@@ -0,0 +1,121 @@
+{-# Language OverloadedStrings #-}
+
+{-|
+Module      : Client.Docs
+Description : Compile-time documentation injection
+Copyright   : (c) TheDaemoness 2023
+License     : ISC
+Maintainer  : emertens@gmail.com
+
+This module adds the requisite functions to load and parse
+a subset of AsciiDoc and embed it using Template Haskell.
+-}
+module Client.Docs
+  ( Docs
+  , loadDoc
+  , lookupDoc
+  , makeHeader
+  ) where
+
+import           Prelude hiding (readFile)
+
+import           Control.Applicative ((<|>))
+import qualified Data.Attoparsec.Text as Parse
+import           Data.ByteString (readFile)
+import           Data.Char (isSpace)
+import           Data.HashMap.Strict (HashMap)
+import qualified Data.HashMap.Strict as HashMap
+import           Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Lazy as LText
+import           Data.Text.Encoding (decodeUtf8)
+import           Language.Haskell.TH (Exp, Q, runIO)
+import           Language.Haskell.TH.Syntax (lift)
+import qualified Data.Text.Lazy.Builder as Builder
+
+type Docs = HashMap String LText.Text
+
+data Line
+  = Discarded
+  | Section Text
+  | Subsection Text
+  | Contents LText.Text
+
+makeHeader :: LText.Text -> LText.Text
+makeHeader header = LText.append "\^B" (LText.append header ":\^B\n")
+
+loadDoc :: (String -> String) -> FilePath -> Q Docs
+loadDoc keymod path = runIO (readFile splicePath >>= renderDoc)
+  where
+    splicePath = "doc/" ++ path ++ ".adoc"
+    renderDoc doc = case Parse.parseOnly lineParser $ decodeUtf8 doc of
+      Right docs -> return $ buildDocs keymod docs
+      Left errorMsg -> fail ("Parser failed on `" ++ splicePath ++ "`: " ++ errorMsg)
+
+lookupDoc :: LText.Text -> String -> Docs -> Q Exp
+lookupDoc header name docs =
+  case HashMap.lookup name docs of
+    Just doc -> lift $ LText.toStrict $ LText.append header doc
+    Nothing -> fail failMsg
+  where
+    failMsg = "No docs for `" ++ name ++ "` (have " ++ show (HashMap.keys docs) ++ ")"
+
+buildDocs :: (String -> String) -> [Line] -> Docs
+buildDocs keymod parsedLines = docs
+  where
+    folded = foldl (addLine keymod) (HashMap.empty, "", LText.empty) parsedLines
+    (docs, _, _) = addLine keymod folded (Section "")
+
+data RenderContentsState
+  = NormalState
+  | CodeStartState
+  | CodeEndState
+  | CodeBlockState
+
+renderContents :: RenderContentsState -> LText.Text -> LText.Text
+renderContents state = Builder.toLazyText . snd . foldl renderContents' (state, mempty) . LText.unpack
+  where
+    renderContents' (st, text) char = case (st, char) of
+       (CodeStartState, '+') -> (CodeBlockState, text <> Builder.fromText "\^_")
+       (CodeStartState, _  ) -> (NormalState,    text <> Builder.fromText "\^B" <> Builder.singleton char)
+       (CodeEndState,   '`') -> (NormalState,    text <> Builder.fromText "\^_")
+       (CodeEndState,   _  ) -> (CodeBlockState, text <> Builder.singleton '+' <> Builder.singleton char)
+       (NormalState,    '`') -> (CodeStartState, text)
+       (CodeBlockState, '+') -> (CodeEndState,   text)
+       (_,              _  ) -> (st,             text <> Builder.singleton char)
+
+addLine :: (String -> String) -> (Docs, Text, LText.Text) -> Line -> (Docs, Text, LText.Text)
+addLine _      (docs, section, text)      Discarded    = (docs, section, text)
+addLine _      (docs, "", _)              (Section s') = (docs, s', LText.empty)
+addLine _      (docs, "", text)           _            = (docs, "", text)
+addLine keymod (docs, section, text) line              = case line of
+  -- TODO: Keep renderContents state across lines.
+  -- Otherwise start in NormalState after each newline.
+  Contents text'   -> (docs, section, append' $ renderContents NormalState text')
+  Subsection text' -> (docs, section, append' (makeHeader (LText.fromStrict text')))
+  Section s'       -> (HashMap.insert (keymod $ Text.unpack section) text docs, s', LText.empty)
+  where
+    append' = LText.append text
+
+lineParser :: Parse.Parser [Line]
+lineParser = Parse.many1' (sectionParser <|> contentsParser) <* Parse.endOfInput
+  where
+    sectionParser = Parse.char '=' >> (sectionL2Parser <|> sectionL3Parser <|> return Discarded)
+      where
+        sectionL2Parser = do
+          _ <- Parse.string "= "
+          name <- Parse.takeWhile1 (not . isSpace)
+          eolParser
+          return (Section name)
+        sectionL3Parser = do
+          _ <- Parse.takeWhile1 (== '=')
+          Parse.skipWhile (== ' ')
+          chars <- Parse.manyTill Parse.anyChar eolParser
+          return (Subsection $ Text.pack chars)
+    contentsParser = do
+      chars <- Parse.manyTill Parse.anyChar eolParser
+      return $ Contents $ LText.fromChunks ["  ", Text.pack chars, "\n"]
+    eolParser = do
+      spaces <- Parse.takeWhile (== ' ')
+      _ <- if Text.null spaces then pure '+' else Parse.option '+' (Parse.char '+')
+      Parse.endOfLine
