A Local-First Bash Toolkit for History, Prompts, and Directory Jumps

2026-09-07

When a developer sees a small annoyance, there is one obvious response: rewrite it in Rust! I went the other way: I took the main features I use from fzf, atuin, oh-my-posh, and zoxide and rewrote them in Bash, with SQLite doing the remembering. The result is a native prompt, searchable Ctrl-R history, smart directory jumps, and a time tracker — all local, with no account or daemon, though you can sync the history over “ssh” to a remote computer.

The “tt” script and “battape.sh” hooks live in my dotfiles repository. The goal is a capable, standalone alternative for people who prefer local data, a small dependency set, and scripts they can inspect and adapt.

Want to try it first? Skip to installation or configuration.

Prompt: Replacing Oh My Posh

The prompt shows a shortened working directory, Git branch, dirty repository state, unpushed or unpulled work, how long the previous command took, and whether it succeeded. Paths longer than three components are shortened from the left.

For example, after a successful command in a clean checkout, the prompt might look like this:

superwoman ~/…/git/dotfiles (main) ✓

A dirty checkout is red, a branch that is ahead of or behind its upstream is magenta, and a failed command gets a red cross. Colour is omitted from these examples:

superwoman ~/src/customer-api (fix-login) 12s ✗

The host name is included when working remotely, making it harder to mistake a production shell for a local one.

Command history: Replacing fzf and Atuin

Press Ctrl-R to open battape’s history picker. Start typing to filter commands; the search is case-insensitive and keeps words in order. Use Up/Down, Ctrl-P/Ctrl-N, or Ctrl-K/Ctrl-J to choose an entry, then press Enter to put it on the command line. Nothing runs until you press Enter a second time at the normal prompt.

Here is a representative search for git log:

? git log
> git log --oneline -5
  git status && git log --stat
  git log -- templates/blog/rewrite-it-in-bash.md

The selected command is the newest matching command from the current terminal when possible. Duplicate commands are hidden, failed commands are visually marked, and older entries are dimmed. The picker also accepts UTF-8 input and pasted text.

Unlike a conventional shell history file, battape records the command, its start and end time, working directory, hostname, terminal, and exit status. The data stays local in a SQLite file. There is no account, daemon, or network service.

Smart directory changes: Replacing zoxide

Every recorded command makes its working directory a candidate for cd. Regular Bash path handling still wins, so cd .., cd /tmp, CDPATH, and ordinary relative paths work just as before. If Bash cannot find a one-word destination, battape searches directories from command history and picks the most frequently and recently used match.

$ cd dotfiles
$ pwd
/home/superwoman/git/dotfiles

Multi-word terms match path components in order, so cd customer api can find a directory such as ~/src/customer-api. Directories that no longer exist are skipped.

Time tracking with tt

tt records time entries in the same database as command history. An entry has an ID, times, account, description, tags, host, and user. Start one with an account, description, and optional tags:

$ tt start --account Acme --description 'Fix login bug' --tags 'web urgent'

  ID          Date        Account  Start  Stop  Duration  Tags
  ——————————————————————————————————————————————————————————————————
  QkYy8uYJyG  2026-09-06  Acme     09:15  -     0h 0m     urgent,web

Stop the active entry when you are done:

$ tt stop

  ID          Date        Account  Start  Stop   Duration  Tags
  ——————————————————————————————————————————————————————————————————
  QkYy8uYJyG  2026-09-06  Acme     09:15  10:05  0h 50m   urgent,web

IDs are random, so yours will differ. tt status shows entries from today, while tt report can select a date range, account, tag, or entry ID. It can also group the result by day or month:

$ tt report --start -1m --group day

  Period      Accounts  Duration  Tags
  —————————————————————————————————————————————————
  2026-09-06  Acme      1h 30m    urgent,web,review

  Period:   2026-08-07 -> 2026-09-06
  Account:  any
  Tag:      any
  Events:   2
  Total:    1h 30m

Run tt commands to see a summary of commands executed during matching entries, or add --full for every invocation. This is handy when filling in a timesheet or reconstructing what happened during an incident.

$ tt commands --full

  Date        Start  Command                 Duration  Exit
  —————————————————————————————————————————————————————————
  2026-09-06  09:19  git status -sb          1s        0
  2026-09-06  09:26  nvim app/login.js       18m 42s   0
  2026-09-06  09:47  npm test -- login       2m 11s    0

tt understands epoch seconds, HH:MM, ISO timestamps, and offsets such as -1d or -1m. Use tt --help for the full command reference.

Syncing another machine

If your machines can reach each other over SSH, you can merge command history and time entries without a third-party service:

$ tt sync ssh.example.com
$ tt sync --pull ssh.example.com

The first command pushes this machine’s database to the remote host; the second pulls from it. Sync uses a SQLite backup and row IDs. If the same ID has conflicting data from another host, it reports the conflict instead of overwriting it silently.

What you need

The tools require Bash 4.4 or newer and sqlite3. Linux distributions generally package both. On macOS, the system Bash is too old, so install a current Bash and SQLite with Homebrew:

brew install bash sqlite;

Homebrew does not replace macOS’s /bin/bash; start the Homebrew Bash (or make it your preferred shell) before using the setup.

Download tt and battape.sh directly. This puts the executable in the usual user-local bin directory and the Bash module under ~/.config:

mkdir -p "$HOME/.local/bin" "$HOME/.config/battape";
curl -fsSLo "$HOME/.local/bin/tt" https://raw.githubusercontent.com/jhthorsen/dotfiles/main/bin/tt;
curl -fsSLo "$HOME/.config/battape/battape.sh" https://raw.githubusercontent.com/jhthorsen/dotfiles/main/config/bash/battape.sh;
chmod +x "$HOME/.local/bin/tt";

Add the following to ~/.bashrc, then open a new Bash session. The tools create their shared SQLite database automatically at ${XDG_DATA_HOME:-$HOME/.local/share}/battape/battape.sqlite.

export PATH="$HOME/.local/bin:$PATH";

source "$HOME/.config/battape/battape.sh";
battape_prompt_enable;

bind -m emacs -x '"\C-r":battape_render_history_ui';
bind -m vi-insert -x '"\C-r":battape_render_history_ui';
bind -m vi-command -x '"\C-r":battape_render_history_ui';

cd() { battape_cd "$@"; }

battape_prompt_enable creates the database, enables command recording, and installs the prompt. If you only want persistent command history, use battape_recorder_enable instead. Remove the bindings, the cd function, or the prompt-enable line for integrations you do not want.

Battape uses Bash’s DEBUG trap to measure commands. It intentionally refuses to enable recording when another DEBUG trap is already installed, rather than overwriting it.

Import Existing History Entries

After installing and sourcing battape.sh, import commands from an existing Bash history file with this script. It skips Bash’s optional timestamp lines and escapes single quotes before inserting commands into SQLite:

#!/usr/bin/env bash
history_file=${HISTFILE:-"$HOME/.bash_history"}
declare -A seen_commands=()

while IFS= read -r cmd || [[ -n $cmd ]]; do
  [[ -z $cmd || $cmd =~ ^#[0-9]+$ ]] && continue
  [[ -n ${seen_commands["$cmd"]+x} ]] && continue
  seen_commands["$cmd"]=1

  cmd=${cmd//\'/\'\'}
  sqlite3 "$HOME/.local/share/battape/battape.sqlite" "
    insert into history (id, start, end, hostname, tty, pwd, command, exit_status)
    values (
      substr(hex(randomblob(10)), 1, 10),
      0, 0,
      '$(hostname)',
      '/dev/tty',
      '$HOME',
      '$cmd',
      0)";
done < "$history_file"

Imported entries do not have their original working directory, terminal, duration, or exit status, so the script uses your home directory and zero values for those fields.

Import Directories for Quick Jump

Battape learns directories from recorded commands. To seed its directory index, run the following from a directory tree you want to include. This records directories up to two levels below the current directory:

#!/usr/bin/env bash

find . -type d -maxdepth 2 -mindepth 1 -print0 |
  while IFS= read -r -d '' dir; do
    (
      cd -- "$dir" || exit
      eval "$PROMPT_COMMAND"
    )
  done

The subshell keeps the script in its original directory, and -print0 handles directory names containing spaces or other special characters.

Configuration

You can override many options before sourcing battape.sh. The defaults work without any configuration; this is one possible adjustment for an ASCII-only prompt and a smaller history picker:

export BATTAPE_DATA_HOME="$HOME/.local/share/battape"
export BATTAPE_HISTORY_MAX_ROWS=8
export BATTAPE_PROMPT_HOST=never
export BATTAPE_PROMPT_PATH_DEPTH=2
export BATTAPE_PROMPT_SUCCESS='ok'
export BATTAPE_PROMPT_FAILURE='!'
export BATTAPE_PROMPT_SEPARATOR='>'

The following is the complete set of optional environment variables. Set them before sourcing battape.sh; tt reads the database and report settings when it starts.

VariableDefaultEffect
XDG_DATA_HOME$HOME/.local/shareStandard XDG base directory used to form the default data path.
BATTAPE_DATA_HOME${XDG_DATA_HOME:-$HOME/.local/share}/battapeDirectory that contains the database.
BATTAPE_DB$BATTAPE_DATA_HOME/battape.sqlitePath to the shared SQLite database.
BATTAPE_HISTORY_MAX_ROWS12Maximum number of matches in the history picker.
BATTAPE_HISTORY_COLOR_FAILredColour for failed commands in the picker.
BATTAPE_HISTORY_COLOR_SUCCESSgreenColour for successful commands in the picker.
BATTAPE_HISTORY_COLOR_RECENTbright whiteColour for recent entries.
BATTAPE_HISTORY_COLOR_OLDwhiteColour for older entries.
BATTAPE_HISTORY_COLOR_OLDESTbright blackColour for the oldest entries.
BATTAPE_HISTORY_COLOR_SELECTEDbold bright whiteColour for the selected entry.
BATTAPE_HISTORY_COLOR_RESETterminal resetEscape sequence that restores the picker colour.
BATTAPE_PROMPT_COLOR_BGdark greyPrompt background colour.
BATTAPE_PROMPT_COLOR_BG_RESETdefault backgroundEscape sequence that restores the background.
BATTAPE_PROMPT_COLOR_FGcyanNormal prompt foreground colour.
BATTAPE_PROMPT_COLOR_REDredFailure and dirty-Git colour.
BATTAPE_PROMPT_COLOR_MAGENTAmagentaAhead/behind Git colour.
BATTAPE_PROMPT_COLOR_SEPARATORdark greyForeground colour for the prompt separator.
BATTAPE_PROMPT_COLOR_RESETterminal resetEscape sequence that restores prompt colours.
BATTAPE_PROMPT_HOSTautoShow the short hostname: auto only over SSH, always, or never.
BATTAPE_PROMPT_PATH_DEPTH3Number of trailing path components to display; invalid values fall back to 3.
BATTAPE_PROMPT_GIT1Set to 0 to skip Git status checks.
BATTAPE_PROMPT_SUCCESSMarker after a successful command.
BATTAPE_PROMPT_FAILUREMarker after a failed command.
BATTAPE_PROMPT_SEPARATORCharacter at the right edge of the prompt.
BATTAPE_CD_OSC7autoEmit the OSC 7 working-directory escape sequence: auto, always, never, 0, or no. In auto, it is emitted only to a non-dumb terminal.
BATTAPE_CD_RECENCY_DAYS14Positive number of days used for the directory-ranking recency boost; invalid values fall back to 14.
BATTAPE_DEFAULT_ACCOUNTunsetAccount to use for tt start when --account is omitted.
BATTAPE_REPORT_LIMIT1000Default maximum rows for tt report and tt commands.

The colour variables accept terminal escape sequences. Prompt colour values must include Bash’s non-printing markers, for example export BATTAPE_PROMPT_COLOR_FG='\[\e[36m\]'; history-picker values do not use those markers, for example export BATTAPE_HISTORY_COLOR_SUCCESS=$'\e[36m'.

A small trade-off

This setup still uses standard Unix tools—especially SQLite, awk, sed, and tput—so it is not “pure Bash” in a strict sense. It targets SQLite 3.46.x, which is available in Debian. In return for fewer dependencies and a single local database, you take responsibility for adapting the scripts to your own preferences.

Conclusion

Battape is not a universal replacement for the tools it borrows ideas from, nor is it the fastest prompt on my machine. It is a small, local toolkit whose behaviour and data are easy to inspect, install, and change. If that sounds useful, install it, try the parts you want, and make it your own.