Open-source project
rcaloras/bash-preexec avatar
rcaloras/bash-preexec

bash-preexec: Zsh style hooks for Bash

⚡ preexec and precmd functions for Bash just like Zsh.

1,060 stars104 forksShellMIT

At a glance

What is it?
bash-preexec gives Bash the two hook points that Zsh has had for years, preexec just before a command runs and precmd just before the prompt, with arrays so several tools can register their own hooks without clobbering each other. It is used in production by iTerm2, Ghostty, WezTerm and Atuin, and it is a single sourced shell file rather than a package.
Who is it for?
Adopt bash-preexec if you write Bash and want to run something before each command or before each prompt, especially if several of your own scripts want a hook, because the array mechanism lets them coexist where a single PROMPT_COMMAND assignment would not. Do not adopt it for Zsh, where the hooks are built in, and read the subshell section before you rely on it, because preexec is not fired for subshells by default and the experimental switch is off for a reason.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 60 days ago.
What is it written in?
Mainly Shell, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The two hook points and when they fire

bash-preexec provides preexec and precmd hook functions for Bash 3.1 and later, in the style of Zsh, and the README says the aim is to emulate the behaviour described in the Zsh documentation.

The two functions differ in exactly when they run, and the definitions are short.

preexec is executed just after a command has been read and is about to be executed, and the string the user typed is passed as the first argument. precmd is executed just before each prompt.

That distinction is the whole reason the library exists. Bash has one such hook, PROMPT_COMMAND, which fires before the prompt and nothing else. Zsh has two, one before the command and one before the prompt, and the difference matters for anything that wants to know what the user is about to run rather than what they just ran.

The README describes precmd as equivalent to PROMPT_COMMAND, but more flexible and resilient, and the resilience is a real property: PROMPT_COMMAND is a single variable, so any two tools that both want to use it are in a fight, and a mistake in one of them breaks the prompt for everyone.

The quick start is a curl into your home directory, a source, and two function definitions.

bash
source ~/.bash-preexec.sh
preexec() { echo "just typed $1"; }
precmd() { echo "printing the prompt"; }

The output shows the ordering clearly, with the typed command echoed before the command's own output and the prompt message after it.

The install, and the ordering rule that matters

The install is a curl and a line appended to your profile, and the second step has a constraint that is easy to get wrong.

bash
curl https://raw.githubusercontent.com/rcaloras/bash-preexec/master/bash-preexec.sh -o ~/.bash-preexec.sh
bash
echo '[[ -f ~/.bash-preexec.sh ]] && source ~/.bash-preexec.sh' >> ~/.bashrc

The README is explicit that it must be the last thing imported in your bash profile, and the reason follows from what the library does. It installs the machinery that makes the hooks fire, and if a later line in the profile redefines the prompt or otherwise interferes with the trap, the hooks stop working. The guard in the appended line, testing for the file before sourcing it, is there so that a machine without the file does not print an error on every new shell.

Since the file is sourced rather than executed, and it is sourced into every interactive shell, the file itself is the entire distribution. There is no package to install and no build step, and the repository is correspondingly small, with the shell script, a test directory, a scripts directory and the workflow that runs the tests.

That has an obvious trade-off. Curling a file from a repository into a shell profile means the file runs with your privileges every time you open a terminal, and it means the version you get is whatever the branch holds at the moment you run the command. The project publishes numbered releases with names, the most recent being 0.7.0, and the last push was on 2026-08-24, so there is something to pin to if you prefer that.

Function arrays, and why they are the interesting part

Beyond the two functions, there are two arrays, and they are what makes the library usable by more than one tool.

The preexec_functions array holds functions invoked by preexec, and the precmd_functions array holds functions invoked by precmd. Both preexec and precmd themselves are added to these by default, so they do not need to be added manually.

Registering a function is an append.

bash
preexec_hello_world() { echo "You just entered $1"; }
preexec_functions+=(preexec_hello_world)

And more than one can be registered, and the README states that they are invoked in the order they were added to their arrays.

bash
precmd_hello_one() { echo "This is invoked on precmd first"; }
precmd_hello_two() { echo "This is invoked on precmd second"; }
precmd_functions+=(precmd_hello_one)
precmd_functions+=(precmd_hello_two)

That is the difference between this and the approach it replaces. With PROMPT_COMMAND, a tool that wants to run something at the prompt either overwrites the variable or has to append a string to it, and both approaches are fragile, and a tool that overwrites silently breaks the tools that were there first. With arrays, each tool appends its own function and the order is defined, and nothing has to know about anything else.

The contents can be inspected by echoing the arrays, which is a small thing that makes debugging a broken hook much faster than it would otherwise be.

bash
echo ${preexec_functions[@]}
echo ${precmd_functions[@]}

The subshell limitation, and the switch that is off by default

There is one limitation and one experimental escape hatch, and the README explains the reason for both.

bash-preexec does not support invoking preexec for subshells by default. It must be enabled by setting a variable.

bash
export __bp_enable_subshells="true"

The comment calls it experimental subshell support, and the reason it is off is given in the next sentence. It is disabled by default because of buggy situations related to functrace and Bash's DEBUG trap, with a link to issue 25 in the tracker.

The mechanism behind the hooks is the reason. To know what the user typed before a command runs, the library has to see the command as it is read, and the only hook Bash offers for that is the DEBUG trap, which is also what functrace uses. Running the two together produces the bugs, so the library ships with the subshell case switched off rather than pretending the combination is reliable.

That is a fair trade and a good example of a library documenting its own sharp edge instead of leaving it for you to discover. If your use case is a status line or a prompt that needs to know the last command, the default behaviour is what you want. If you need to know about commands run inside a subshell, the experimental flag exists, and you should read the linked issue before you rely on it.

Detecting the library from your own script

There is a short section addressed to library authors, and it exists because a tool that wants to add a hook has to know whether the library is loaded.

The advice is to use the Bash variable bash_preexec_imported.

bash
if [[ -n "${bash_preexec_imported:-}" ]]; then
    echo "Bash-preexec is loaded."
fi

The default expansion is what makes it safe, since the variable is unset in a shell where the library was never sourced, and the colon plus default form keeps the test from failing under a strict shell option.

This is a small feature with a real purpose, and it is a good example of a library thinking about its neighbours. A prompt tool that wants to register a precmd function has three possible states: the library is loaded, the library is not loaded and the tool should fall back to something else, or the library is not loaded and the tool should do nothing. A documented variable gives that tool a reliable answer instead of a guess based on whether a function happens to be defined.

The same mechanism is what lets the ecosystem compose. The README says the project is used in production by Bashhub, iTerm2, Ghostty, WezTerm and Atuin, and those are programs that all want to know what is happening in the shell and all want to add their own hook without fighting over a variable.

The alternative, and the state of the project

The comparison worth making is with the mechanism you already have, which is PROMPT_COMMAND.

What PROMPT_COMMAND gives you is a single command run before each prompt, which is enough to compute a status line or to change the prompt. What it does not give you is a hook before the command runs, which is what a tool needs when it wants to record the command the user typed, time it, or expand it. Getting that in plain Bash means installing a DEBUG trap yourself, and that is exactly the fragile path that this library wraps.

The difference in the other direction is multiplicity. PROMPT_COMMAND is one string, so combining two tools means one of them wins or they both edit the string and hope. The arrays in this library make the combination explicit and ordered.

So the library is not adding a feature Bash lacks, it is adding a feature Bash has badly and a second hook point it does not have at all.

On the state of the project, the release names are a nice touch, since the versions are called after animals, with 0.7.0 as Purple Boa in August 2026, 0.6.0 as Black Toad in August 2025 and 0.5.0 as Navy Goldfish in April 2022. The tests are written with Bats, and the README shows what a passing run looks like, with a case for each behaviour that matters, including that a preexec with no functions defined simply returns, that a precmd executes a function once, that preexec receives the last command in the history, and that multiple functions run in the order they were added.

That last set of tests is the interesting part of the project. A shell library that manipulates traps and history is exactly the kind of thing that breaks silently, and the test suite names the properties it guarantees.

Editorial conclusion

Adopt bash-preexec if you write Bash and want to run something before each command or before each prompt, especially if several of your own scripts want a hook, because the array mechanism lets them coexist where a single PROMPT_COMMAND assignment would not. Do not adopt it for Zsh, where the hooks are built in, and read the subshell section before you rely on it, because preexec is not fired for subshells by default and the experimental switch is off for a reason. Before you install it, check where in your profile it goes, because it has to be sourced last.

Frequently asked questions

What does bash-preexec do?

It provides preexec and precmd hook functions for Bash 3.1 and later, in the style of Zsh, aiming to emulate the behaviour described in the Zsh documentation. preexec runs just after a command has been read and is about to be executed and receives the typed string as its first argument, and precmd runs just before each prompt.

How do I install bash-preexec?

Curl the script from the repository into your home directory as a hidden file and source it from your bash profile. The README is explicit that it must be the last thing imported in your bash profile, because it installs the machinery that makes the hooks fire and later lines can interfere with it.

Can I register more than one hook function?

Yes. The preexec_functions and precmd_functions arrays hold the functions to invoke, and you append to them with the array append syntax. The functions are invoked in the order they were added to their arrays, and the preexec and precmd functions themselves are added to these arrays by default.

Does bash-preexec work in subshells?

Not by default. Subshell support has to be enabled by exporting __bp_enable_subshells as true, and it is described as experimental and disabled by default because of buggy situations related to functrace and Bash's DEBUG trap, with the tracking issue linked from the readme.

Official sources

  1. Issues
  2. License: MIT
  3. rcaloras/bash-preexec on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/rcaloras-bash-preexec.svg)](https://hysenlabs.com/projects/rcaloras-bash-preexec)

Was this useful?