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:
| Command | Mean time |
|---|---|
grep conf /tmp/allfiles.txt | 3.4 ms |
fzf --filter conf < /tmp/allfiles.txt | 41.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 = trueandupdate_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.
| Command | Stored? |
|---|---|
export API_TOKEN=... | Yes |
curl -H 'Authorization: Bearer ...' | Yes |
mysql -u root -pHunter2 | Yes |
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
| Tool | Replaces | Verdict |
|---|---|---|
| zoxide | cd (for repeat visits) | Worth the one config line, remember the last-component rule |
| fzf | Ctrl-R, manual file picking | Install it for the key bindings, not a grep replacement |
| atuin | ~/.bash_history | Best interface, needs bash-preexec and care with secrets |
The next part covers the network tools: gping, trippy and xh.