Modern Linux Tools Part 4: Navigation and History With zoxide, fzf and atuin

This is Part 4 of the Modern Linux Tools series. Part 3 covered search and replace. This part covers moving around and finding old commands: zoxide against cd, fzf against the default history search, and atuin against ~/.bash_history.

Same rules as before. A clean Debian 13 VM with 2 vCPUs and 2 GB of RAM, everything installed from the Debian repositories, bash as the shell. The one exception is a helper script for atuin, which I cover in its section.

sudo apt install zoxide fzf atuin

The versions are zoxide 0.9.7, fzf 0.60.3 and atuin 18.6.1. All three need a line in ~/.bashrc before they do anything, so none of them works the moment it is installed.

zoxide against cd

zoxide remembers the directories you visit and lets you jump to them with part of the name. You enable it with one line:

eval "$(zoxide init bash)"

After that, every cd is recorded, and z jumps. I visited a few directories in my test tree, then jumped by keyword:

$ cd ~/tree/site07/dev07 && cd ~
$ z dev07
$ pwd
/home/anon/tree/site07/dev07

zoxide query -ls shows what it has learned and the score for each directory. The score is based on how often and how recently you visited it.

The rule that catches you out

z only knows directories you have actually been to, and the last keyword must match the last component of a path. I had visited site12/dev20 but never site12 itself, so this failed:

$ z site12
zoxide: no match found

z site12 dev20 or just z dev20 works. Once you know the rule it makes sense, but on the first day you will type the name of a parent directory and wonder what broke.

Several keywords narrow the match. z 03 dev printed zoxide: you are already in the only match when the only matching directory was the one I was standing in. That is a message, not an error, and it is a good one.

My verdict: zoxide is a real time saver for directories you use often, and it costs one line of config. It is also useless in a new place until you have been there. For a one-off jump, cd or tab completion is still the way.

fzf

fzf is not a replacement for grep. It is an interactive filter: you give it a list, you type a few characters, and it narrows the list as you type. On Debian the shell bindings are in /usr/share/doc/fzf/examples/, and you enable them with:

source /usr/share/doc/fzf/examples/key-bindings.bash

That gives you three key bindings: Ctrl-R for history, Ctrl-T to insert a file path, and Alt-C to change directory.

Ctrl-R

The terms can be in any order. With sudo systemctl restart ssh in my history, typing ssh restart found it:

> ssh restart
  2/10
> sudo systemctl restart ssh

The built-in Ctrl-R only matches the exact text you type, in order. fzf matches the words separately, so you do not have to remember the order you typed them in.

Ctrl-T and fuzzy matching

Fuzzy matching means the characters you type need to appear in order, not next to each other. Searching the 105,003-file test tree for dev07 042 conf found dev07/file142.conf:

  1/5026
> dev07 042 conf
  site01/dev07/file142.conf

The counter says 1 of 5,026. The file I wanted was file042, and the match was file142. The 0 in 042 matched the 0 in dev07, the 4 and 2 were in the file name, and the 1 did not matter. The result looks wrong at first glance and is correct by the rules. Fuzzy search is forgiving, and the price is that it will sometimes show you something you did not ask for.

It is not grep

I also timed fzf --filter, the non-interactive mode, against grep on a list of 105,000 paths:

CommandMean time
grep conf /tmp/allfiles.txt3.4 ms
fzf --filter conf < /tmp/allfiles.txt41.7 ms

grep is about 12 times faster, and it does less. That is the right way to think about the two. fzf is for picking one item from a list, by eye. For anything in a script, use grep or rg.

My verdict: install fzf. The Ctrl-R binding on its own is worth the package. Do not use it where you want a result without looking at it.

atuin

atuin replaces your shell history file with a SQLite database. It stores the exit code, the working directory, the time and the duration for each command, and it gives you a search interface on Ctrl-R. It can also sync history between machines through a server.

First run

The first run creates ~/.config/atuin/config.toml and a database under ~/.local/share/atuin. Before I used it I read the defaults, because a history tool sees everything you type. These are the ones that matter:

  • auto_sync = true and update_check = true.
  • sync_address = "https://api.atuin.sh".
  • search_mode = "fuzzy".

auto_sync only does something once you have registered or logged in, so a fresh install does not send anything. To confirm that, I ran strace on atuin status, stats, search and doctor, and on atuin import bash, and looked for outbound connections. I saw none.

If you never plan to sync, set auto_sync = false and update_check = false anyway, so the config says what you intend.

The hook needs a dependency

atuin init bash prints the code that records your commands, and you load it with:

eval "$(atuin init bash)"

On bash that code does not capture commands by itself. It adds functions to preexec_functions and precmd_functions, which are arrays that bash does not run natively. They come from a helper, bash-preexec, or from the ble.sh line editor. Neither is installed by the Debian package, and bash-preexec is not packaged in Debian 13 at all.

Without one of them, nothing is recorded. On my VM PROMPT_COMMAND was not set, ATUIN_HISTORY_ID was empty, and Ctrl-R opened an interface that said “history count: 0”. The atuin package was installed, the eval line was in .bashrc, and the tool did nothing. There was no error either.

That is the single biggest trap with atuin on Debian, and it is the one place in this series where I step outside “packages only”. bash-preexec is a single shell script, from the rcaloras/bash-preexec repository on GitHub. I used v0.7.0 and loaded it before the atuin line, so the order in ~/.bashrc is:

source ~/.local/share/bash-preexec/bash-preexec.sh
eval "$(atuin init bash)"

With that in place it worked. In an interactive shell I ran echo hello-atuin, ls /nonexistent (which fails) and sleep 1, then opened Ctrl-R. This is the list, trimmed to the interesting lines, with the oldest at the top:

Atuin v18.6.1                                                         history count: 20

    0s    16m ago ssh anon@192.168.11.154
    0s    16m ago ssh -V
    5ms   16m ago false
    1us   16m ago export API_TOKEN=abcd1234secret
    1us   16m ago curl -H 'Authorization: Bearer abc123' https://x
    1us   16m ago mysql -u root -pHunter2 db
    1us   16m ago echo harmless_command
    2ms    1m ago echo hello-atuin
    7ms    1m ago ls /nonexistent
    1s     1m ago sleep 1
  > 15ms  45s ago atuin history list

Commands recorded live have real durations: sleep 1 shows 1s, and the quick ones show milliseconds. The older entries that came from an import show 0s. The count at the top read 20.

Read a script before you source it, even a small one. This one is 21,756 bytes. If you would rather not run it, ble.sh is the other route, and so is skipping atuin on bash.

Importing existing history

atuin import bash reads ~/.bash_history and loads it into the database:

atuin import bash

The imported commands have no duration, because ~/.bash_history never stored one, and they carry an exit code of -1. The search still works. Filters like --exit 1, which find commands that failed, only work on commands recorded live.

Secrets

This is the part of the test I would read twice. Shell history contains secrets, and atuin has a filter for them, enabled by default (secrets_filter). I fed it a list of fake secrets, using the manual atuin history start and end commands, and checked what was stored.

CommandStored?
export API_TOKEN=...Yes
curl -H 'Authorization: Bearer ...'Yes
mysql -u root -pHunter2Yes
An AWS access key (AKIA..., the documentation example)No
A GitHub personal access token (ghp_...)No

The filter caught the two token formats it knows by pattern. It did not catch a generic API_TOKEN variable, a bearer header or a password on a command line. You can see the three that were stored in the list above, searchable like any other command. Imported history is not filtered at all, so if you import a history file that has secrets in it, those secrets are now in a second place.

The practical advice is short. Do not type passwords or tokens on a command line, whatever tool records your history. Read through what is in the database before you turn on sync, because after that it leaves the machine.

Searching

atuin search takes a query and the modes work as described. The default is fuzzy, --search-mode prefix matches the start of the command, and --exit 1 lists failures. One quirk: atuin history list needs $ATUIN_SESSION, which only exists inside a shell with the hook loaded. Outside one, it errors.

My verdict: atuin has the best interface of the three, and it is the most work to adopt. The database, the context and the search are better than ~/.bash_history. But you need a hook script that Debian does not give you, a secrets filter that misses common cases, and a decision about sync. I would install it on a machine where I run a lot of commands and accept the manual bash-preexec step. On a server I log into occasionally, fzf and the plain history file are enough.

Summary

ToolReplacesVerdict
zoxidecd (for repeat visits)Worth the one config line, remember the last-component rule
fzfCtrl-R, manual file pickingInstall it for the key bindings, not a grep replacement
atuin~/.bash_historyBest interface, needs bash-preexec and care with secrets

The next part covers the network tools: gping, trippy and xh.