Files
sausage/userland/brush/source/docs/reference/zsh-hooks.md
T
2026-11-11 22:04:16 +00:00

113 lines
5.9 KiB
Markdown

# zsh-style hooks (`precmd` and `preexec`)
An [experimental feature](experimental.md). Enable it persistently with
`zsh-hooks = true` under `[experimental]` in `config.toml`, or per-invocation
with `brush --enable-zsh-hooks`.
zsh defines these hooks, and prompt frameworks, command timers, and history
tools (atuin, starship, and friends) are built on them. brush provides a
bash-native adaptation: the hooks live in the shell itself, with bash semantics,
rather than in the `DEBUG`/`PROMPT_COMMAND` plumbing stock bash needs to
approximate them.
[bash-preexec](https://github.com/rcaloras/bash-preexec) is the adaptation of
the same hooks the ecosystem already builds on, so it is what settles the
details zsh leaves open for a bash shell, and what brush interoperates with —
see [below](#relationship-to-bash-preexec).
## The hooks
| Registry | When it runs | Arguments |
|----------|--------------|-----------|
| `precmd_functions` | before each prompt is displayed, ahead of `PROMPT_COMMAND` | none |
| `preexec_functions` | after a command line is read, before it runs | the command line, as `$1` |
The two arrays start out as `(precmd)` and `(preexec)`, so defining a function by
either name is enough. Register more by appending — `preexec_functions+=(my_fn)` —
and they run in array order. Each array is read once per dispatch, so a hook that
edits one is heeded from the next dispatch on.
Every entry names a **shell function**. The name is looked up as a single,
already-expanded word: never re-split, glob-expanded, or resolved against `PATH`.
An entry that isn't a shell function is skipped quietly, so a hook may be
registered before it is defined.
Hooks are active only in interactive shells, in the `$-` sense that also decides
whether rc files are loaded. `brush -s < script` does not qualify and claims none
of the bash-preexec state below; `brush -i -s < script` does, so a hook that reads
standard input there consumes the script's own lines.
`preexec` fires only for a line that runs a command: blank lines, comment-only
lines, and syntax errors dispatch nothing, matching the `DEBUG` trap bash-preexec
dispatches from. Only lines the user typed dispatch it — a command run from a key
binding does not.
## What a hook sees
- `$?`, `PIPESTATUS`, and `$_` hold what the last command left behind. All three
are restored before every hook and again afterwards, so nothing a hook runs is
visible to the next hook, to `PROMPT_COMMAND`, or to the next command.
- A hook that fails has its error reported; the remaining hooks still run. Under `set -e` a
non-zero return exits the shell instead, exactly as it does from `PROMPT_COMMAND`.
- A hook that calls `exit` exits the shell. Nothing after it runs: no later hook,
no `PROMPT_COMMAND`, and not the command line `preexec` was dispatched for.
## Relationship to bash-preexec
The two must not both be live: bash-preexec's `DEBUG` trap would dispatch the
same hooks a second time. So brush claims bash-preexec's interlock as well as
its behavior — the `bash_preexec_imported` and legacy `__bp_imported` inclusion
guards, along with the two arrays, are set before profile and rc files load. A
copy of bash-preexec sourced afterwards — including the one tools such as atuin inline into their
`init` output — finds itself already loaded and returns, leaving the hooks to
brush rather than installing its `DEBUG`-trap emulation.
[ble.sh](https://github.com/akinomyoga/ble.sh) takes the same approach.
All four are set the way `name=value` sets a variable: nothing is exported, so a
child bash still gets the real implementation, but one that already existed keeps
its attributes. A guard or registry inherited from the environment therefore stays
exported.
Because brush seeds the arrays before rc files rather than appending after them,
an rc file that appends gets `(precmd mine)`, where sourcing bash-preexec after
the same append would give `(mine precmd)`.
Clearing the guards is not a supported way to hand the hooks back. brush keeps dispatching,
so a bash-preexec sourced after they are cleared installs itself over the same registries and
every hook runs twice. To use the real bash-preexec, leave this feature off.
The claim covers bash-preexec's documented surface and no more: the two hook
names, the two arrays, and the inclusion guards. None of its `__bp_*` internals
exist, and `precmd` hooks are not reachable through `PROMPT_COMMAND` the way its
dispatcher is.
### `BP_PIPESTATUS` is not provided
bash-preexec offers `BP_PIPESTATUS` because its hooks run from `PROMPT_COMMAND`,
by which point the real `PIPESTATUS` is gone. brush restores `PIPESTATUS` itself,
so a hook reads it directly. There is no copy, and none is planned. A hook that
must work under both can fall back in one expansion:
```bash
statuses=("${BP_PIPESTATUS[@]:-${PIPESTATUS[@]}}")
```
## Differences
| | brush | zsh | bash-preexec |
|---|---|---|---|
| `preexec` `$1` | the line as typed, interior newlines included (the trailing one is trimmed) | the same | what `history 1` reports, which under `cmdhist` joins the command onto one line |
| `preexec` `$2` and `$3` | not passed | single-line and fully-expanded forms of the command | not passed |
| entries that aren't shell functions | skipped | skipped | run, when `type -t` finds a builtin or a program |
| `PIPESTATUS` inside a hook | the real one | n/a | gone; `BP_PIPESTATUS` holds a copy |
brush matches zsh on `$1`: zsh passes the string the user typed, verbatim
(verified against zsh 5.9). A hook that needs the command on one line can read
`history 1` itself — it is already in the history when `preexec` runs.
Only functions run because that is what zsh does and what bash-preexec's own
usage notes tell you to register. Admitting programs would let a misspelled or
not-yet-defined function name fall through to a same-named program on `PATH` and
run it before every prompt. Wrap such a command in a function
(`log_cmd() { logger "$1"; }`) and register that.