chprompt

bash prompt manager.

chprompt is a shell function implemented as a UNIX-style CLI utility. In its simplest usage pattern, it allows declaring the desired state of your bash PS1 prompt as a sequence of tokens.

Demo

Source code

The source code for this project is hosted on GitHub.

Table of contents

  1. One-off usage
  2. Long-term usage
    1. All systems
    2. Homebrew installer (macOS)
  3. Getting started
  4. Plugins
    1. Plugin namespace
    2. Plugin renderers
  5. Native plugins
    1. emoji
    2. env
    3. special_character
  6. Standard plugins
  7. Styling
  8. Hooks
  9. Hacking guide
    1. Using a custom renderer
    2. Using the data_tuples renderer
    3. Overriding the data_tuples renderer
    4. Disabling styling
  10. tmux integration
    1. Starting sessions with custom token lists
    2. Modifying a session's custom token list on the fly
  11. FAQ
    1. Why was chprompt created?
    2. Why bash?
    3. Why POSIX shell?
    4. Is it compatible with zsh?
    5. Why should I use it if zsh exists?
    6. Who should use chprompt?

One-off usage

From a bash shell session, running the following command will load chprompt and all its supporting functions, as well as set the new PS1 prompt to the default rendering of an empty token list.

eval "$(curl https://get.chprompt.org/latest/chprompt.sh)"

If you'd like to also enable tab completion:

eval "$(curl https://get.chprompt.org/latest/chprompt-completion.bash)"

Alternatively, if you have Docker installed and don't necessarily trust running the eval commands above, you can run the commands below to load and use chprompt in a fresh container:

docker run -it alpine sh
apk add bash curl
bash
eval "$(curl https://get.chprompt.org/latest/chprompt.sh)"
eval "$(curl https://get.chprompt.org/latest/chprompt-completion.bash)"

Long-term usage

If you'd like to use chprompt to manage the PS1 prompt of all interactive bash sessions, then it makes sense to store both the main bundle and the tab completion source files somewhere in your file system and have your ~/.bash_profile source them.

All systems

Download the source code and move the files somewhere permanent (e.g. ~/bin):

for file in chprompt.sh chprompt-completion.bash; do
    curl -fsS -o "$file" "https://get.chprompt.org/latest/$file"
done

Make sure that these files get sourced by all your shell sessions by adding the following lines to your ~/.bash_profile:

source /path/to/my/chprompt.sh
source /path/to/my/chprompt-completion.bash

Homebrew installer (macOS)

If you are using macOS, the following command will install chprompt.sh and chprompt-completion.bash in your system:

brew tap ronchi-oss/tap && brew install chprompt

While the tab completion will be immediately available, since chprompt.sh is not an executable, you must source it somewhere in your ~/.bash_profile:

eval "$(brew shellenv)"
source "$HOMEBREW_PREFIX/opt/chprompt/src/chprompt.sh"

Getting started

Use chprompt help and chprompt help <command> to print general help and command-specific help usage, respectively.

For convenience, the examples below are executed in a Docker container. The following commands should start a container for the alpine image and then load the chprompt function in a bash session.

docker run -it alpine sh
apk add bash curl
bash
eval "$(curl https://get.chprompt.org/latest/chprompt.sh)"

Once bash evaluates the chprompt.sh bundle, it will load not only the entrypoint chprompt function but also several __chprompt-prefixed functions (which chprompt delegates most of its actual work to). Most importantly, the bundle will redefine and export the special bash variable PROMPT_COMMAND as:

export PROMPT_COMMAND="__chprompt_render_prompt"

For more information see controlling the prompt (bash official manual).

From that point forward, just before printing the PS1 prompt, bash will invoke __chprompt_render_prompt, which applies its token rendering logic to all tokens defined in its special variable CHPROMPT_FORMAT and exports it as PS1. Its default value is not set, so the initial token list is empty. Due to default styling however, the initial PS1 prompt will have a length of two characters: an arrow and a space. Your terminal emulator program will also add the cursor indicator right after the space character.

Next we load the chprompt tab completions into the current shell session, which will improve discoverability.

eval "$(curl https://get.chprompt.org/latest/chprompt-completion.bash)"

If completion loaded correctly, typing chprompt <TAB><TAB> (the word "chprompt", then pressing the SPACE key, then pressing the TAB key twice) should list all available commands:

append
edit
list
lpop
prepend
remove
rpop
set
version

Except for version, all chprompt commands will operate on the current token list. Each command also responds to tab completion, therefore typing:

chprompt append <TAB><TAB>

should print the following:

bash
chprompt
emoji
env
exit_status
exit_status_dwim
exit_status_reaction
git
git_extended
special_character
user_at_host

The output above represents the list of all plugins loaded in the current shell session. A plugin is just a shell function that follows a naming convention and prints something to standard output. For instance, consider the definition of the user_at_host plugin:

__chprompt_plugin__user_at_host() {
    echo '\u@\h'
}

In order to add a plugin instance to the current token list, we must refer to it by its name, which is its function name minus the __chprompt_plugin__ prefix. Therefore, running:

chprompt append user_at_host

should cause the new PS1 prompt to be:

root@5f17f0ea5201 →

Not all plugins are created the same though. While emoji, env and special_character are implemented as function plugins, they can't be directly added to the token list. If we try:

chprompt append emoji

then chprompt returns with a status greater than zero after printing the following to standard error:

error: token 'emoji' can't be added to the prompt directly; use a literal value instead.

A plugin that can only be added to the token list via a literal value is called a native plugin. For instance, running the following command:

export SOME_VAR=basic-usage
chprompt append :dog: SOME_VAR '\w'

should cause the new PS1 prompt to be:

root@5f17f0ea5201 🐶 env(SOME_VAR:basic-usage) / → 

Here's what happened: before testing whether a token name corresponds to a loaded __chprompt_plugin__ function, it tests whether its literal value (:dog:, SOME_VAR and \w in this example) match any of the predefined native plugin patterns. When there's a match, chprompt calls the respective plugin function passing <token> as its only argument. The native plugin function then decides if and how it can render the given token.

Let's try the self-explanatory list command:

chprompt list
user_at_host
:dog:
SOME_VAR
\w

Let's say that we feel like removing all tokens but the first one (user_at_host). Given the current token list, all the following commands would be equivalent:

chprompt set user_at_host
chprompt rpop 3
chprompt remove :dog: SOME_VAR '\w'

If we run either one of the commands above, our PS1 prompt will once again look like the following:

root@5f17f0ea5201 →

We have now covered all commands that either set (set), remove (remove) or change the right-hand side of the token list (append, rpop). prepend and lpop do the same as append and rpop, except they operate on the left-hand side of the token list.

We will conclude this basic usage guide by creating a more coherent and useful two-line PS1 prompt using a combination of native and standard plugins. If we run the following command:

chprompt set '\w' bash '\n' exit_status_dwim

we will get the following prompt:

╭ / bash(v:5.2.21)
╰ 😎 →

Did you notice that some prompt characters don't obviously map to any token? Those are:

For more information see styling.

Plugins

A plugin is the simplest way to extend the default functionality of chprompt. Consider the following shell session:

🐶 → chprompt list
:dog:
🐶 → __chprompt_plugin__say_hello() { echo "Hello"; }
🐶 → chprompt append say_hello
🐶 Hello → chprompt list
:dog:
say_hello
🐶 Hello →

We start with a token list of length one, therefore our PS1 prompt consists of a dog emoji followed by an arrow (see styling). We then define a function in the current shell session:

__chprompt_plugin__say_hello() {
    echo "Hello"
}

That function is now a plugin because:

  1. Its name starts with the __chprompt_plugin__ prefix;
  2. It prints something to standard output.

In all chprompt commands that modify the token list, a plugin function is referenced by its function name minus the prefix, therefore say_hello is a valid token in the current shell session, as can be assessed with:

chprompt append say_hello

which successfully appends say_hello to the token list, then renders it as part of the new PS1 prompt.

For more information see native plugins and standard plugins.

Plugin namespace

Plugin functions may perform their work with a single line of code, like say_hello above, or may require more lines. If a plugin function needs to call another function in order to create some useful output, it should comply with the following convention:

__chprompt_plugin__my_plugin() {}
__chprompt_plugin__my_plugin__helper_function() {}

In other words, any function that's prefixed by a plugin function name plus __ (two underscore characters) will be seen by chprompt as part of a plugin's "namespace", instead of as another plugin. That means that chprompt commands that offer tab completion will ignore these plugin helper functions.

Plugin renderers

While a plugin function may be as simple as say_hello, as soon as we need to make its output nicer to read (e.g. by surrounding it with terminal escape sequences for colors) the code becomes hard to follow. Additionally, we may have a group of plugins that we want to render in the same way (e.g. surround by braces, in bold red etc.). In order to keep the plugin code that creates data separate from the code that formats it, the renderer concept was created.

By default all plugin functions will have their output go through a renderer before its added to the PS1 prompt string. That default renderer function is defined as follows:

__chprompt_renderer__default() {
    cat -
}

In other words, it reads what it gets from standard input and writes it without modification to standard output. It serves no purpose other than allowing the user of chprompt to redefine it and change the rendering of all plugins that don't declare another renderer. Though not obvious in this implementation, all renderer functions receive one argument: the token.

You may have noticed that some plugins in this document render themselves in a structured way. For instance, the token EDITOR is mapped to the env native plugin which renders it as:

env(EDITOR:vim)

Let's look at the definition of the env plugin:

__chprompt_plugin__env() {
    match="$(env | grep "^$1=")"
    if test $? -ne 0 || test -z "${match#*=}"; then
        echo "$1:_"
    else
        echo "$1:${match#*=}"
    fi
}

What this function outputs for the example above is:

EDITOR:vim

The function that creates that final structured output is the data_tuples renderer, defined as follows:

__chprompt_renderer__data_tuples() {
    plugin="${1:?'error: missing argument <plugin-name>'}" || return 1
    context_tuples=''
    while IFS=: read -r key val; do
        context_tuples="$context_tuples $key:\[\033[36;1m\]$val\[\033[0m\]"
    done
    echo "$plugin(${context_tuples# })"
}

This function expects:

For more information see using a custom renderer and using the data_tuples renderer.

Native plugins

A native plugin is a special kind of plugin that can only be added to the token list indirectly by using a literal value. Its plugin function definition also differs from standard plugins, since it expects <token> (i.e. the literal value) as its only argument.

Whether native plugins are a good idea or not remains to be seen; their main goal is to save typing and to keep the definition of a <token> as simple as possible.

The following table illustrates what native plugins try to achieve (the form in the second column is not supported):

Literal values as short-hand syntax for one-argument functions
Literal value Meaning
:cat: emoji(cat)
SOME_VAR env(SOME_VAR)
\h special_character(\h)

Like standard plugins, native plugins print their value to standard output.

emoji

The emoji native plugin doesn't serve any practical purpose; it's only meant to print an emoji in the prompt. For instance:

chprompt set :dog:

will set the prompt to a dog emoji.

This plugin was designed with overriding in mind: while the default set of supported emojis is small, it can be overriden by re-defining the following function:

__chprompt_plugin__emoji__list() {
}

The function must print a space-separated pair of semicolon-separated tuples formatted as <emoji>:<name>. The following definition would support three custom emojis:

__chprompt_plugin__emoji__list() {
    echo "🫠:melting-face 😉:wink 🤯:exploding-head"
}

You could then add them to the prompt by using :name: literals:

chprompt set :melting-face: :wink: :exploding-head:

the command above would change the PS1 prompt to:

🫠 😉 🤯 →

env

The env native plugin renders the name and value of an environment variable in the prompt using the data_tuples renderer.

For instance:

export SOME_VAR='hello'
export ANOTHER_VAR=''
chprompt set SOME_VAR ANOTHER_VAR

would produce the following prompt:

env(SOME_VAR:hello) env(ANOTHER_VAR:_) →

special_character

The special_character native plugin serves the purpose of supporting the characters that the bash PS1 prompt expands into values such as the working directory (\w) or the hostname (\h).

For instance:

chprompt set '\u' '\w'

would produce the following prompt (according to environment):

root / →

Concatenating multiple special characters as a single token isn't supported, therefore the following wouldn't work:

chprompt set '\u@\h'

In such cases, defining a standard plugin is the recommended way to go. For the case above, the built-in user_at_host standard plugin achieves the same result.

Standard plugins

A standard plugin is a shell function which expects no arguments and prints something to standard output. For example, a standard plugin day_of_week would be defined as follows:

__chprompt_plugin__day_of_week() {
    date '+%A'
}

One of the most useful chprompt standard plugins is exit_status_dwim (exit status "do what I mean"):

__chprompt_plugin__exit_status_dwim() {
    if test -z "$CHPROMPT_LAST_EXIT_STATUS"; then
        echo _
    elif test "$CHPROMPT_LAST_EXIT_STATUS" -gt 0; then
        printf "\[\033[31;1m\]%s\[\033[0m\] %s\n" "$CHPROMPT_LAST_EXIT_STATUS" 🚨
    else
        echo 😎
    fi
}

You may have seen this plugin being added to the token list multiple times throughout this document wondering why all it does is print the same boring emoji. It does more though: as long as the exit status of the previously executed command is zero, it prints the "all is fine" emoji. Otherwise, it prints the exit status in bold red next to the "police car light siren" emoji. Check it out:

→ chprompt set exit_status_dwim
😎 → chprompt list
exit_status_dwim
😎 → some-invalid-command
bash: some-invalid-command: command not found
127 🚨 →

Styling

Styling is a function that chprompt applies to the ready-to-display prompt string right before setting and exporting the PS1 variable. Its current implementation is:

__chprompt_styling__apply() {
    __chprompt_styling__trim_line_feed \
        | __chprompt_styling__double_decker \
        | __chprompt_styling__arrow
}

trim_line_feed removes a whitespace added after a line break character; as chprompt renders each token, it separates them with a whitespace character. Instead of creating an exception for it in the prompt building function, it's done here. double_decker prepends a multi-line prompt with two characters that convey a visual binding between the prompt lines. arrow appends an arrow character to all prompts.

For more information see disabling styling.

Hooks

The environment variable CHPROMPT_HOOKS, if defined, is expected to contain a (space-separated) list of executables which are invoked right after chprompt resets the PS1 variable. Each hook is invoked with the token list set as the $@ argument.

For a practical use of hooks see modifying a session's custom token list on the fly.

Hacking guide

Though chprompt can be installed and used by anyone with minimal shell language knowledge, it will shine and maybe "make sense" to users who take the time to learn how to make it their own.

chprompt takes full advantage of the fact that it executes in the current shell environment: all its functionality is implemented in short shell functions that the user can redefine.

With the following ~/.bash_profile code in place, there's no need to change the original chprompt.sh file. All customizations can be implemented in a file (e.g. custom-chprompt.sh) that you can have under version control.

source /path/to/chprompt.sh
source /path/to/chprompt-completion.bash
source /path/to/my/custom-chprompt.sh

The reader is expected to be familiarized with the following concepts before trying out the hacks provided in this section:

Using a custom renderer

A standard plugin will use the default renderer unless it provides its own under its plugin namespace. A plugin can define only one renderer and it must be named renderer, as in the following example:

__chprompt_plugin__say_hello() {
    echo "Hello"
}

__chprompt_plugin__say_hello__renderer() {
    while read -r line; do
        echo "$line!" | tr [:lower:] [:upper:]
    done
}

Let's see it in action:

→ chprompt set '\w'
/ → chprompt append say_hello
/ HELLO! →

Using the data_tuples renderer

If your plugin prints structured output, it may be a good candidate for using the data_tuples renderer. For instance, consider the following plugin that prints three pieces of data about today's local weather: the minimum, current and maximum temperatures.

__chprompt_plugin__weather() {
    echo "min 5 degrees / cur 9 degrees / max 15 degrees"
}

When mixed with other plugin output in the PS1 prompt, it's certain that this long string would not be ideal. One ready-to-use solution is to structure the plugin function output as semicolon-separated data tuples, one per line, and tell chprompt to apply the data_tuples renderer function to it. Here's how:

__chprompt_plugin__weather() {
    echo "min:5"
    echo "cur:9"
    echo "max:15"
}

__chprompt_plugin__weather__renderer() {
    __chprompt_renderer__data_tuples "$@"
}

Let's see it in action:

→ chprompt set user_at_host weather
root@5f17f0ea5201 weather(min:5 cur:9 max:15) →

Overriding the data_tuples renderer

While the code listings in this document don't show it, the data_tuples renderer surrounds each tuple value with terminal escape sequences that make it bold and use a cyan foreground color. While this isn't configurable, the renderer itself can be redefined.

For instance, this is a plain version:

__chprompt_renderer__data_tuples() {
    plugin="${1:?'error: missing argument <plugin-name>'}" || return 1
    context_tuples=''
    while IFS=: read -r key val; do
        context_tuples="$context_tuples $key:$val"
    done
    echo "$plugin(${context_tuples# })"
}

Disabling styling

All styling functions, including apply, read from standard input and write to standard output. Therefore, in order to disable an individual styling function, we can replace the function body with cat - like so:

__chprompt_styling__arrow() {
    cat -
}

Or, to disable all styles:

__chprompt_styling__apply() {
    cat -
}

tmux integration

Starting sessions with custom token lists

Let's consider the following ~/.bash_profile:

source /path/to/chprompt.sh
# ...
chprompt set ${CHPROMPT_FORMAT-pwd bash '\n' exit_status_dwim}

It sets the initial token list of all sessions to the value of CHPROMPT_FORMAT (if set), or else to the a four token list.

For software developers, it can make sense to adopt one token list per project. Let's assume the following local project structure:

~/git/go-project/
~/git/js-project/
~/git/sh-project/
~/git/tf-project/

The following ~/.tmux.conf defines a menu that creates a new tmux session for the selected item. The session will set the current working directory of all its shell sessions to the project root directory. More interestingly, it sets the CHPROMPT_FORMAT tmux session environment variable.

bind-key , display-menu -x W -y W -T 'New session:' \
  'go-project' 'g' { new-session -c $HOME/git/go-project -s go-project -e CHPROMPT_FORMAT='\w git go \n exit_status_dwim' } \
  'js-project' 'j' { new-session -c $HOME/git/js-project -s js-project -e CHPROMPT_FORMAT='\w git node npm \n exit_status_dwim' } \
  'sh-project' 's' { new-session -c $HOME/git/sh-project -s sh-project -e CHPROMPT_FORMAT='\w git bash \n exit_status_dwim' } \
  'tf-project' 't' { new-session -c $HOME/git/tf-project -s tf-project -e CHPROMPT_FORMAT='\w git terraform \n exit_status_dwim' }

Each tmux session would adopt a different project prompt:

╭ ~/git/go-project git(b:main) go(v:1.21.6)
╰ 😎 →
╭ ~/git/js-project git(b:main) node(v:21.6.1) npm(v:10.2.4)
╰ 😎 →
╭ ~/git/sh-project git(b:main) bash(v:5.2.21)
╰ 😎 →
╭ ~/git/tf-project git(b:main) terraform(v:1.7.1 w:chprompt-website)
╰ 😎 →

Modifying a session's custom token list on the fly

One handy use case for defining a hook is while using tmux. The following hook definition will write the token list to the tmux session variable CHPROMPT_FORMAT. If a user's ~/.bash_profile invokes chprompt set by providing it CHPROMPT_FORMAT (when set), then this hook definition will guarantee that new tmux panes created within the same tmux session will use the token list as defined by the latest shell session that had its PS1 evaluated (not necessarily the last created tmux session pane).

tmux_chprompt_hook () {
    tmux_session="$(tmux list-sessions | grep '(attached)$' | cut -d ':' -f 1)";
    tmux set-environment -t "=$tmux_session" CHPROMPT_FORMAT "$@"
}
test -n "$TMUX" && export CHPROMPT_HOOKS='tmux_chprompt_hook'
source /local/path/to/chprompt.sh
chprompt set ${CHPROMPT_FORMAT-pwd bash '\n' exit_status_dwim}

FAQ

Why was chprompt created?

After going back and forth between different set ups for the PS1 prompt over the last twenty years or so, I've realized that there isn't a single prompt that's ideal for all my use cases, all my coding projects, or even for all my personal moods. Sometimes an arrow is more than enough for a prompt. Sometimes it should have a cat emoji in it. Sometimes it should have colors.

It's fair to say, in all seriousness, that most people would rather just use the defaults instead of having to spend five minutes figuring out how to set up a decent bash PS1 prompt.

One day this idea hit me: I should be allowed to never decide on a single PS1 prompt. There should be a program that would support that very important life style (I'm being ironic but clearly I care about this a lot from time to time). That's when I started writing chprompt.

Why bash?

I have used different interactive shells in the recent years. When Apple adopted zsh, I was using it just because. Then I'd install oh-my-zsh on it and enable the "git" plugin. I'd not even store my .zshrc file in version control. I'd just recreate that every time. I did not really care for the shell though the prompt was somewhat important.

Some time in 2022 I moved to fish shell. I have used its built-in functionality to customize the prompt. It was probably the first time that it felt easy and fun to play with the prompt.

I eventually decided to learn POSIX shell, which led me back to using bash for all my interactive sessions. Since managing the PS1 in bash is quite maddening, I could name that as the main reason: I should not have to change to another shell just because of the bad user experience which is the customization of the PS1 prompt.

Apart from that:

Why POSIX shell?

Except for the tab completion code, which has to be specific to the shell program, everything else in chprompt is written with POSIX-compliance in mind (shellcheck helps a lot). Now, if chprompt only supports bash, why not just write it completely in bash script?

A few reasons:

Is it compatible with zsh?

No. zsh's default mode is not POSIX-compliant. Enabling POSIX-compliant mode causes zsh-specific syntax to not work. Therefore, chprompt in zsh is currently pointless. Of couse a zsh prompt manager can be implemented simply by following the zsh script language and hooking to its prompt rendering function.

Why should I use it if zsh exists?

If you're happy with zsh and you never think about the prompt, you probably shouldn't. Switching over to bash will definitely disrupt your work. Now, if you're only using zsh because of the git branch prompt fragment, then maybe you should.

Who should use chprompt?

The main target audience are everyone using bash as their interactive shell, whether by choice (personal computer) or by a work need (it's the only shell available when they log into a remote server). The first group would install it on their computer and have it loaded and configured in their personal ~/.bash_profile; the second group may load it by hand every time they start their bash session on a server:

eval "$(curl https://get.chprompt.org/latest/chprompt.sh)"