5.9 KiB
zsh-style hooks (precmd and preexec)
An experimental feature. 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 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.
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, toPROMPT_COMMAND, or to the next command.- A hook that fails has its error reported; the remaining hooks still run. Under
set -ea non-zero return exits the shell instead, exactly as it does fromPROMPT_COMMAND. - A hook that calls
exitexits the shell. Nothing after it runs: no later hook, noPROMPT_COMMAND, and not the command linepreexecwas 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 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:
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.