# Modern CLI tools for your terminal A series of 9 article(s), in reading order. # The jq utility for Linux > Use the powerful Linux jq utility to manipulate JSON data, prettify output, and filter nodes directly from your command line and shell scripts. --- ![The jq utility for Linux](/img/v2/json.webp) **TL;DR** This article introduces `jq`, a command-line JSON processor: piping raw API output (using the random-user API as an example) through `jq` alone pretty-prints it, and adding a filter expression like `jq '.results[0].name'` extracts just a specific node from the JSON structure. `jq` is a powerful utility for Linux that lets you manipulate JSON data from the command line and can be integrated into shell scripts. ## The same answer, twice By running `curl https://randomuser.me/api/` you'll get something like ```bash $ curl https://randomuser.me/api/ ``` ```json {"results":[{"gender":"female","name":{"title":"Mademoiselle","first":"Milena","last":"Martin"},"location":{"street":{"number":9831,"name":"Rue de L'Abbé-Migne"},"city":"Lengnau (Ag)","state":"Basel-Landschaft","country":"Switzerland","postcode":3789,"coordinates":{"latitude":"-60.0739","longitude":"135.1462"},"timezone":{"offset":"+7:00","description":"Bangkok, Hanoi, Jakarta"}},"email":"milena.martin@example.com","login":{"uuid":"bafdf972-4183-484a-903a-84f2654f0fec","username":"purpleleopard344","password":"cameron","salt":"gGoFrP1a","md5":"af359ca6697c3ac68f4c190583544619","sha1":"19033bc1630d96bba29726823ef91f53800e67d1","sha256":"cac6a35f5135f14707b8b3ec48617f23b4105b1c5e33a2b64597e7a0c7c891a0"},"dob":{"date":"1998-10-10T02:42:04.525Z","age":25},"registered":{"date":"2005-05-01T01:26:36.354Z","age":18},"phone":"079 098 73 86","cell":"077 411 83 18","id":{"name":"AVS","value":"756.9632.2579.59"},"picture":{"large":"https://randomuser.me/api/portraits/women/37.jpg","medium":"https://randomuser.me/api/portraits/med/women/37.jpg","thumbnail":"https://randomuser.me/api/portraits/thumb/women/37.jpg"},"nat":"CH"}],"info":{"seed":"213f93e3e854d33c","results":1,"page":1,"version":"1.4"}} ``` The JSON is just echoed on the screen, not really readable. But, as soon as we are redirecting to `jq`, it'll be better: ```bash $ curl --silent https://randomuser.me/api/ | jq ``` ```json { "results": [ { "gender": "female", "name": { "title": "Mademoiselle", "first": "Salomé", "last": "Roy" }, "location": { "street": { "number": 2361, "name": "Rue Dumenge" }, "city": "Dierikon", [...] }, "email": "salome.roy@example.com", [...] } ], "info": { "seed": "b7ef95d3b252edca", "results": 1, "page": 1, "version": "1.4" } } ``` Same bytes, same API, one extra word on the command line. That already earns `jq` its place in a pipe, but the reason I really use it is below. ## Filtering the output Imagine we just need to retrieve the name. To do this, we need to better understand the JSON object. ```json { //highlight-next-line "results": [ { "gender": "female", //highlight-next-line "name": { "title": "Mademoiselle", "first": "Salomé", "last": "Roy" }, "location": { [...] }, [...] ], [...] } ``` The output is a JSON representation having a root property called `results`. That property is an array. Each element in that array has a `name` node with a few properties like `title`, `first` and `last`. If we just want to retrieve the `name` node, the filter to use with `jq` is `.results[0].name`. ```bash $ curl --silent https://randomuser.me/api/ | jq '.results[0].name' ``` ```json { "title": "Ms", "first": "Brooke", "last": "Morgan" } ``` Learn more about [jq filtering](https://jqlang.github.io/jq/tutorial/). ## Seeing It in Action with Docker Before installing anything, try both commands from above in a throwaway container. It ships `jq` and a static snapshot of the `randomuser.me` response used throughout this article, so the demo works even without network access — though the live API is still reachable if you want to try it for real. > **Why Docker first?:** > > The Dockerfile below installs `jq` and drops a ready-to-use `sample.json` in the working > directory. Nothing to type, download or configure — just pipe it through `jq`. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install -y --no-install-recommends \ jq \ curl \ ca-certificates && \ rm -rf /var/lib/apt/lists/* # A static snapshot of the randomuser.me response used throughout this # article, so the demo works even without network access. The live API is # still reachable from inside the container if you want to try it for real. RUN mkdir -p /root/demo && \ printf '%s' '{"results":[{"gender":"female","name":{"title":"Mademoiselle","first":"Salomé","last":"Roy"},"location":{"street":{"number":2361,"name":"Rue Dumenge"},"city":"Dierikon","state":"Basel-Stadt","country":"Switzerland","postcode":5075,"coordinates":{"latitude":"-58.3405","longitude":"134.0131"},"timezone":{"offset":"+4:30","description":"Kabul"}},"email":"salome.roy@example.com","dob":{"date":"1960-05-12T07:27:42.104Z","age":63},"registered":{"date":"2021-05-02T04:18:14.996Z","age":2},"phone":"077 228 21 12","cell":"078 051 38 48","picture":{"large":"https://randomuser.me/api/portraits/women/74.jpg"},"nat":"CH"}],"info":{"seed":"b7ef95d3b252edca","results":1,"page":1,"version":"1.4"}}' \ > /root/demo/sample.json WORKDIR /root/demo # Signal clearly that this shell runs inside a container RUN echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> /root/.bashrc CMD ["/bin/bash", "-i"] ``` Build and run it: ```bash $ docker build -t jq-demo . [+] Building 12.1s (7/7) FINISHED ✔ exporting to image $ docker run --rm -it jq-demo 🐳 root ~/demo # cat sample.json | jq '.results[0].name' { "title": "Mademoiselle", "first": "Salomé", "last": "Roy" } ``` Same filter as above, same result, no `curl` even required. Drop the filter and run `cat sample.json | jq` alone to see the whole object prettified, or, since the container has network access, try the live version too: `curl --silent https://randomuser.me/api/ | jq`. ## Installing jq To verify if `jq` is already installed on your system, simply run `which jq`. If you get `jq not found` as an answer: Prerequisite: jq ```bash $ sudo apt-get update && sudo apt-get install jq ``` Verify: ```bash $ jq --version jq-1.7.1 ``` ## Let's play `jq` can be used without any arguments and in that case, the JSON output will be prettified. To reproduce the examples above, we'll use a simple JSON free API like `https://randomuser.me/api/`. If you wish to use another API tool, take a look on ; free ones are those having `No` in the `Auth` columns. > **randomuser API return every time a new user:** > > On each request, the `randomuser` API is returning a new object. Don't be surprised if your names differ from mine. **The full prettified answer (click for the details)** Here is the complete object returned by the API, as `jq` renders it: ```json { "results": [ { "gender": "female", "name": { "title": "Mademoiselle", "first": "Salomé", "last": "Roy" }, "location": { "street": { "number": 2361, "name": "Rue Dumenge" }, "city": "Dierikon", "state": "Basel-Stadt", "country": "Switzerland", "postcode": 5075, "coordinates": { "latitude": "-58.3405", "longitude": "134.0131" }, "timezone": { "offset": "+4:30", "description": "Kabul" } }, "email": "salome.roy@example.com", "login": { "uuid": "dfda3e32-b8b1-44c9-9aa9-194fc17833d3", "username": "organiccat614", "password": "nacked", "salt": "HRepWJ2P", "md5": "643f7a890ba9c9fbe04ea971287f57ba", "sha1": "9407fd8fdf0c344ac1990a89d8d0190f6baabd19", "sha256": "1317478dbfc61c3feaccea4485a367fb6b195009f15eecd9175f2d1a9215d5df" }, "dob": { "date": "1960-05-12T07:27:42.104Z", "age": 63 }, "registered": { "date": "2021-05-02T04:18:14.996Z", "age": 2 }, "phone": "077 228 21 12", "cell": "078 051 38 48", "id": { "name": "AVS", "value": "756.1071.1525.27" }, "picture": { "large": "https://randomuser.me/api/portraits/women/74.jpg", "medium": "https://randomuser.me/api/portraits/med/women/74.jpg", "thumbnail": "https://randomuser.me/api/portraits/thumb/women/74.jpg" }, "nat": "CH" } ], "info": { "seed": "b7ef95d3b252edca", "results": 1, "page": 1, "version": "1.4" } } ``` ## Conclusion Two things to remember: `jq` alone makes any JSON readable, and `jq '.some.path'` extracts exactly the node you need — which is what turns an API answer into something a shell script can consume. `jq` quickly becomes a reflex: I use it in [Troubleshooting for Docker containers](/blog/docker-networking-troubleshooting) to read `docker inspect` output, and in [building a blog post analyzer with Ollama](/blog/docusaurus-ollama-tags) to format the local LLM's JSON responses, for instance. And on the days when the filter you need — a `group_by`, a `select` on a date — just won't come back to you, [ai-data](/blog/ollama-ai-data) asks a local LLM for five `jq` commands tailored to your own file, and drops the one you pick at your prompt, ready to read before you run it. For the times when the console is not the right tool for JSON, this blog has three more: [Rendering a JSON file as a mind map](/blog/json-crack), [JSON - Online linter](/blog/json-lint) and [JSON - Faker & Mockup](/blog/json-faker). --- # The xmlstarlet utility for Linux > Master XML data manipulation on the Linux command line with xmlstarlet. This guide shows you how to beautify XML output and filter nodes using XPath expressions --- ![The xmlstarlet utility for Linux](/img/v2/bash.webp) **TL;DR** This article introduces `xmlstarlet`, the XML equivalent of `jq`: pretty-printing minified XML with `xmlstarlet format --indent-spaces 4`, and extracting or filtering nodes with XPath expressions via `xmlstarlet sel -t -v`, including attribute-based filters like `//book[@category='children']/title`. `xmlstarlet` is a powerful utility for Linux that lets you manipulate XML data from the command line and can be integrated into shell scripts. *It is to XML what [`jq`](/blog/linux-jq) is to JSON.* Using `xmlstarlet` you can beautify XML output but also filter it like f.i. showing only a given node. ## A wall of XML in, one word out Here is the file we'll play with, `data.xml`, exactly as a machine would have written it; everything on the same line, no format at all: **data.xml** ```xml Everyday ItalianGiada De Laurentiis200530.00Harry PotterJ K. Rowling200529.99XQuery Kick StartJames McGovernPer BothnerKurt CagleJames LinnVaidyanathan Nagarajan200349.99Learning XMLErik T. Ray200339.95 ``` Now let's ask it a question: *give me the title of the book filed under the `children` category*. ```bash $ cat "data.xml" | xmlstarlet sel -t -v "//book[@category='children']/title" Harry Potter ``` One command, one answer, and not a single line of parsing code. ## Beautifying the file The second thing you'll use every day: making that wall readable, using the `format` action. ```bash $ cat "data.xml" | xmlstarlet format --indent-spaces 4 ``` ```xml Everyday Italian Giada De Laurentiis 2005 30.00 Harry Potter J K. Rowling 2005 29.99 XQuery Kick Start James McGovern Per Bothner Kurt Cagle James Linn Vaidyanathan Nagarajan 2003 49.99 Learning XML Erik T. Ray 2003 39.95 ``` ## Seeing It in Action with Docker Before installing anything, try both commands above in a throwaway container — `data.xml`, byte for byte the same file shown at the top of this article, is already waiting in the working directory. > **Why Docker first?:** > > The Dockerfile below installs `xmlstarlet` and writes `data.xml` for you. Nothing to create, > paste or download — just pipe it through `xmlstarlet`. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install -y --no-install-recommends \ xmlstarlet && \ rm -rf /var/lib/apt/lists/* # The exact data.xml used throughout this article, already in place RUN mkdir -p /root/demo && \ printf '%s' 'Everyday ItalianGiada De Laurentiis200530.00Harry PotterJ K. Rowling200529.99XQuery Kick StartJames McGovernPer BothnerKurt CagleJames LinnVaidyanathan Nagarajan200349.99Learning XMLErik T. Ray200339.95' \ > /root/demo/data.xml WORKDIR /root/demo # Signal clearly that this shell runs inside a container RUN echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> /root/.bashrc CMD ["/bin/bash", "-i"] ``` Build and run it: ```bash $ docker build -t xmlstarlet-demo . [+] Building 9.4s (6/6) FINISHED ✔ exporting to image $ docker run --rm -it xmlstarlet-demo 🐳 root ~/demo # cat data.xml | xmlstarlet sel -t -v "//book[@category='children']/title" Harry Potter ``` Same file, same filter, same answer — no `mkdir`, no copy-pasting XML into a new file. Try `cat data.xml | xmlstarlet format --indent-spaces 4` next to see the beautified version, or change the XPath expression to pull a different node. ## Installing xmlstarlet To verify if `xmlstarlet` is already installed on your system, simply run `which xmlstarlet`. If you get `xmlstarlet not found` as an answer: Prerequisite: xmlstarlet ```bash $ sudo apt-get update && sudo apt-get -y install xmlstarlet ``` Verify: ```bash $ xmlstarlet --version 1.6.1 ``` ## Let's play To reproduce the two commands above on your machine, please start a Linux shell and run `mkdir -p /tmp/xmlstarlet && cd $_`, then create a new file called `data.xml` with the content displayed at the top of this article. ## Understanding the XPath expression An XPath expression is just a path in the tree. Our root node is called `bookstore`, then we have one or more `book` and each book has a `title`: ```xml Everyday Italian [...] [...] ``` Walk that path from the root and you get every title of the file: ```bash $ cat "data.xml" | xmlstarlet sel -t -v "/bookstore/book/title" Everyday Italian Harry Potter XQuery Kick Start Learning XML ``` The expression used at the beginning of this article, `//book[@category='children']/title`, is the filtering version: give me each `book`; it doesn't matter where the book node is located (that's the `//` prefix); but only if it has an attribute named `category` and whose value is `children`. Then, if found, display its `title`. ## Conclusion Two actions cover most of the day-to-day needs: `format` to make an XML file human-readable, and `sel -t -v` with an XPath expression to pull exactly the node you need out of it; both usable in a pipe, so both usable in a shell script. Read the [official documentation](https://xmlstar.sourceforge.net/docs.php) to learn more about xmlstarlet, and if your next file is a `.json` one, [the jq utility for Linux](/blog/linux-jq) is the very same story with a different syntax. --- # Introduction to fzf - Fuzzy Finder > Master the Linux command line with fzf (Fuzzy Finder). Discover its powerful fuzzy search for history (CTRL+R) and file selection (CTRL+T) to boost your productivity. --- ![Introduction to fzf - Fuzzy Finder](/img/v2/linux_tips.webp) **TL;DR** This article introduces `fzf` (Fuzzy Finder), which upgrades the console's `CTRL`+`R` history search with fuzzy, any-order-letter matching, and adds `CTRL`+`T` for interactively fuzzy-selecting one or more files (usable with commands like `rm` or `head`) and `ALT`+`C` to jump into a folder. As you probably know, `CTRL`+`R` in the console will give you access to your HISTORY i.e. you'll retrieve the list of the commands you previously typed in your console. Just like using `UP` or `DOWN` keys but with a very small search engine. And it makes the job but, honestly, this is really basic, no? The Fuzzy Finder (aka `fzf`) command line utility will explode the possibilities linked to searching in the history but, in reality, this is just one of the consequences of installing fzf, which is so much more powerful. *Three articles on this blog build directly on `fzf`: [FZF + ripgrep: Interactive Code Search with Live Preview](/blog/fzf-ripgrep), [Master your ssh command and select the host from a list](/blog/ssh-with-fuzzy-finder) and [ZSH Functions - Customizing Your Shell for Docker Management](/blog/zsh-docker-functions).* Let's have a look. By pressing `CTRL`+`R`, you'll natively get a *reverse search* for your history. Start to type a command like `ls` in my example below and Linux will show you the last command used. ![Using CTRL-R](./images/ctrl_r.webp) ## Seeing It in Action with Docker Before cloning anything on your machine, spin up a throwaway container with `fzf` already wired in and a small playground already built. > **Why Docker first?:** > > The Dockerfile below installs `fzf`, enables its key bindings in `bash`, pre-seeds a shell history > (so `CTRL`+`R` has something to search) and creates a handful of log files and > nested folders (so `CTRL`+`T` and `ALT`+`C` have something to > browse). Nothing to prepare yourself. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install -y --no-install-recommends \ bash \ git \ curl \ ca-certificates && \ rm -rf /var/lib/apt/lists/* # Install fzf and wire its key bindings + fuzzy completion into bash RUN git clone --depth 1 https://github.com/junegunn/fzf.git /root/.fzf && \ /root/.fzf/install --key-bindings --completion --no-update-rc && \ echo '[ -f ~/.fzf.bash ] && source ~/.fzf.bash' >> /root/.bashrc # Build a playground: log files to fuzzy-select with Ctrl+T, nested folders # to jump into with Alt+C RUN mkdir -p /root/demo/logs /root/demo/scripts /root/demo/nested/deep/folder && \ for i in 1 2 3 4; do \ printf 'run%s.sh.log — line 1\nline 2\nline 3\nline 4\nline 5\n' "$i" \ > /root/demo/logs/run"$i".sh.log; \ done && \ touch /root/demo/scripts/deploy.sh /root/demo/scripts/backup.sh # Pre-seed bash history so Ctrl+R has something real to fuzzy-search: type # "github" and the git-clone line below turns up, exactly like the article's # own screenshots. RUN printf '%s\n' \ 'cd /root/demo' \ 'ls -la' \ 'git clone https://github.com/junegunn/fzf-git.sh.git' \ 'docker compose up --detach' \ 'head -n 5 logs/run1.sh.log' \ > /root/.bash_history WORKDIR /root/demo # Signal clearly that this shell runs inside a container RUN echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> /root/.bashrc CMD ["/bin/bash", "-i"] ``` Build and run it: ```bash $ docker build -t fzf-demo . [+] Building 24.6s (9/9) FINISHED ✔ exporting to image $ docker run --rm -it fzf-demo 🐳 root ~/demo # ``` You're already in `/root/demo`. Try the three shortcuts right away: * Press `CTRL`+`R` and type `github` — the pre-seeded `git clone https://github.com/junegunn/fzf-git.sh.git` line turns up, exactly like the screenshots below. * Press `CTRL`+`T` and type `run` — the four `run*.sh.log` files in `logs/` appear, filtered live as you type. * Press `ALT`+`C` to fuzzy-jump into `nested/deep/folder` without typing a single `cd`. * Run `fzf -m | xargs head -n 5`, filter on `run`, select two or three files with `TAB`, press `ENTER` — the multi-select scenario from the "Getting a list of files" section below, ready to try without deleting anything real. ## Installation The installation of `fzf` is simple, you just need to run `git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf && ~/.fzf/install` and answer by `Y`es to two questions. When done, close and restart a new console (so Linux will take into account changes made to your configuration files). ## Playing with the history Now, press `CTRL`+`R` again and you'll get something much better: ![Using CTRL-R once FZF has been installed](./images/ctrl_r_fzf.webp) Just like previously, start to type the command you would like to retrieve; in my case, I know, once, I've tried to install a repo from github but don't remember the instruction so I'll start to type `github`: ![Filtering on github](./images/ctrl_r_github.webp) If you look at the image, you'll see that `fzf` is searching for the `g`, `i`, `t`, `h`, `u` and `b` letters, not for the word. So the first line has the word `git` then there is a `h` somewhere and the word `public` so, `ub` are there. This is handy since you don't need to remember the exact order of words in your previous command line, just type words in any particular order. For me, just for this usage, the installation of `fzf` is already fully justified. ## Getting a list of files Imagine you wish to display the content of a file so, you start to type `cat` and, oh, you don't remember well the exact filename. By pressing `CTRL`+`T` you'll get a list of files from the current directory and subdirectories: ![Using CTRL-T](./images/ctrl_t.webp) You can navigate using `UP` and `DOWN` but also type some letters to filter the list. On my machine, by starting to type `doc` I'll get the list of files, anywhere in the tree structure, with `doc`. I can continue and type `doc.md` to force the presence of letters `md` and thus, in my case, search for articles about Docker and having the extension `.md`. ![Using CTRL-T and filtering on doc](./images/ctrl_t_doc.webp) Such use is handy since I don't first need to retrieve the exact name of the file I wanted to use. In this example it was for `cat` but, of course, it works for anything: type your command and press `CTRL`+`T`. > **`**` followed by TAB is an alias:** > > Not sure it's something to remember, but `cat **` followed by `TAB` will work just like pressing `CTRL`+`T`. Here, `**` will be expanded to `CTRL`+`T`. Here, using `CTRL`+`T`, you'll be able to select just one file. Imagine you wish to select more than one? For instance, you wish to run `rm` to remove more than one file. By running `fzf -m | xargs rm` followed by `ENTER`, Fuzzy Finder will display the list of all files present in the current directory. Like previously, I can start to type and filter the list. In my example below, I've typed `run.sh.log` to retrieve logs and, in the list, I press `TAB` to select four files (see the red `>` character at the left). Then I press `ENTER` to validate my choice and ... files have been removed since my command was `rm`. ![Removing several files](./images/rm_several_files.webp) If I rerun the same command and the same filter, I can see that I now only retrieve three files, the ones I didn't select. The `rm` has worked correctly. ![Removing several files, second run](./images/rm_several_files_bis.webp) Now, instead of killing files, I'll just display the first five lines of each selected file: To do this, I'll execute `fzf -m | xargs head -n 5`. Filter on `run.sh.log` again, select the three files using `TAB`, press `ENTER` to validate my choice and tadaaa: ![Selecting several files](./images/head_several_files.webp) ## Keybindings | Shortcut | Description | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Ctrl`+`R` | Show the list of commands you've previously typed (your history thus), allow you to select a command by pressing ENTER. | | `Ctrl`+`T` | Display the list of files in the current directory and sub-folders, allow you to select one or more files (just press TAB for a multiple selection) then press ENTER to return the list to the console. | | `Alt`+`C` | Get the list of folders recursively of the current directory, allow you to select one and by pressing ENTER, fzf will jump in that folder. | Once these basics feel natural, fzf really shines when combined with other tools: see [FZF + ripgrep](/blog/fzf-ripgrep) for interactive code search with a live preview, or [Master your ssh command and select the host from a list](/blog/ssh-with-fuzzy-finder) to stop typing hostnames from memory. If you specifically want richer history search — timestamps, exit codes, and working directory per command — Atuin is worth a look: it replaces the flat `~/.bash_history` file with a SQLite database while keeping the same `Ctrl+R` shortcut. --- # Let's revisit the ls command thanks to eza > Tired of typing ls -alh? Discover eza, the modern, feature-rich replacement for the basic Linux ls command. Learn how to install it and set up a powerful new alias for a better command-line experience. --- ![Let's revisit the ls command thanks to eza](/img/v2/linux_tips.webp) **TL;DR** This article introduces `eza`, a modern replacement for the `ls` command (formerly known as `exa`), offering better defaults, icons, and formatting. It shows how to install it with `apt-get` and set up an alias with a useful combination of flags (`--all --long --group --group-directories-first --icons --header --time-style long-iso`) so `ls` automatically uses `eza` with a richer file listing. Which CLI command would you say you use most on Linux? Most definitely `ls` to display the list of files in the current directory. > **I don't know about you, but I rarely use `ls` without any parameters. Almost without thinking, I add `-alh` every time. It's become mechanical.:** And then you'll say to me, well, all you have to do is create an alias `alias ls="ls -alh"`; of course, but let's go further and revisit this basic command and add some functionality to it. `eza` is a modern replacement for `ls` as stated on their website: . *It belongs to the same family of "modern rewrites of the classics" as [ripgrep](/blog/ripgrep) (for `grep`) and [delta](/blog/git-delta) (for `git diff`). While you're customizing your console, [Customize your Linux prompt with Powerlevel10k](/blog/powerlevel10k_sandbox) is worth ten minutes too.* > **eza was first called exa:** > > is abandoned; see [this issue](https://github.com/ogham/exa/issues/1243). > > **exa is now eza**: ## Result Once `ls` is aliased to `eza` with a few flags, this is what a plain `ls` looks like: ![eza](./images/eza.webp) ## Seeing It in Action with Docker Before touching your `~/.bashrc`, try the alias in a throwaway container with a small, varied folder already set up — a hidden file, a sub-folder or two, files of different sizes — so the listing actually has something to show off. > **Why Docker first?:** > > The Dockerfile below installs `eza`, sets up the exact `ls` alias from this article, and builds a > demo folder for you. Nothing changes on your own machine. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install -y --no-install-recommends \ curl \ ca-certificates && \ rm -rf /var/lib/apt/lists/* # Install eza from a prebuilt release (avoids adding eza's own apt repository # just for a demo). Pin the version so this image keeps building the same # way; bump it during a /freshness pass. RUN curl -fsSL \ https://github.com/eza-community/eza/releases/download/v0.23.5/eza_x86_64-unknown-linux-gnu.tar.gz \ | tar -xz -C /usr/local/bin # Recreate the exact alias from the article RUN echo "alias ls='eza --all --long --group --group-directories-first --icons --header --time-style long-iso'" \ >> /root/.bashrc # Build a small, varied playground so the listing actually looks interesting: # sub-folders, a dotfile, a text file and a binary-ish file of a different size RUN mkdir -p /root/demo/src /root/demo/docs && \ touch /root/demo/.env && \ echo "# Notes" > /root/demo/README.md && \ printf 'console.log("hi")\n' > /root/demo/src/index.js && \ dd if=/dev/zero of=/root/demo/docs/report.pdf bs=1024 count=200 status=none WORKDIR /root/demo # Signal clearly that this shell runs inside a container RUN echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> /root/.bashrc CMD ["/bin/bash", "-i"] ``` Build and run it: ```bash $ docker build -t eza-demo . [+] Building 14.2s (7/7) FINISHED ✔ exporting to image $ docker run --rm -it eza-demo 🐳 root ~/demo # ls Permissions Size User Date Modified Name drwxr-xr-x - root 2026-08-22 docs drwxr-xr-x - root 2026-08-22 src .rw-r--r-- 0 root 2026-08-22 .env .rw-r--r-- 8 root 2026-08-22 README.md ``` That's `ls` — really `eza` under the hood, thanks to the alias baked into the image. Icons, grouped folders first, human-readable dates: the same result as the screenshot above, without having installed anything on your own machine yet. ## Install eza The installation is simple, you just need to run `sudo apt-get update && sudo apt-get install eza`; nothing more. From now, simply run `eza` on the command line and you'll get the list of files with all the defaults presets. On my computer, I've chosen for this list of parameters: `--all --long --group --group-directories-first --icons --header --time-style long-iso` and, for sure, I've updated my `~/.bashrc` file by adding an alias (if you're on ZSH, [give that alias its own file](/blog/modular-zsh-workflow) rather than growing a monolithic `.zshrc`): ```bash $ alias ls='eza --all --long --group --group-directories-first --icons --header --time-style long-iso' ``` And, now, by just typing `ls`, you'll get the listing shown above. --- # Master your ssh command and select the host from a list > Build an interactive, searchable SSH host selector using FZF. Learn to modularize your SSH configurations, add rich documentation, and trigger advanced custom actions via a fast Terminal User Interface. --- ![Master your ssh command and select the host from a list](/img/v2/sshf.webp) **TL;DR**Managing dozens of server connections in a monolithic `~/.ssh/config` file quickly becomes unmaintainable and relies too heavily on memorization. This post demonstrates a cleaner approach by breaking your configuration into project-specific files within a `~/.ssh/conf.d/` directory, allowing you to attach rich, styled documentation directly above your host definitions. To tie it all together, you will implement a custom shell function (sshf) powered by FZF. This creates a fast, searchable Terminal User Interface (TUI) that lets you instantly filter hosts by name, project, or metadata. It also goes a step further by introducing custom keybindings, enabling you to trigger advanced contextual actions—like pinging servers, modifying configurations, or generating system inventories—directly from the search menu. On my machine, I've accumulated more and more hosts in my `~/.ssh/config` file. While I can type `ssh server4` (see [zsh-ssh-config-suggestions](/blog/zsh-plugin-ssh-config-suggestions)) to jump to that server, I still have to remember the list of available servers. This is manageable when dealing with just a few, but I currently have around 50 servers, and relying solely on memory is no longer practical. It would be much more convenient to view the full list of hosts through a TUI (*Terminal User Interface*), select the desired host, and simply press `Enter`. Furthermore, it would be extremely helpful to access contextual information—such as the URL hosted on that server, whether a database is running, the PHP version, and more. Additionally, creating one `.conf` file per project would eliminate the need to maintain a massive, monolithic configuration file containing all 50 servers. Let's explore how to achieve this setup in this post. *This builds on [Introduction to fzf - Fuzzy Finder](/blog/linux-fzf-introduction) and on the aliases created in [How to connect to your hosting server using SSH](/blog/connect-using-ssh-to-your-hosting-server). It applies to `~/.ssh/config` the same "one file per concern" philosophy as [Beyond the Monolith - Organizing Your ZSH Workflow Like a Pro](/blog/modular-zsh-workflow).* Before diving into the technical details, here is a preview of the final result: ![Our sshf TUI](./images/tui.webp) On the left side, there is a search box (allowing me to type `amazing` to filter on that pattern) above a list of hosts. On the right side, contextual information about the selected host is displayed. Once a host is selected, I simply press `Enter` to open an SSH terminal session on that host—without having to enter my credentials manually. Let's get started... ## Seeing It in Action with Docker Before touching your own `~/.ssh/config`, try the whole TUI in a throwaway container — the exact `amazing.conf`/`legacy.conf` files and the `sshf` function from further down are already in place. > **Why Docker first?:** > > The Dockerfile below installs `fzf`, `perl` and `openssh-client`, writes the exact SSH config > files shown further down, and appends the exact `sshf` function to `~/.bashrc`. Nothing on your > own machine changes, and — since `1.2.3.4` and friends are the same placeholder IPs used > throughout this article — pressing `Enter` on a host won't actually connect anywhere. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive # Ubuntu's docker image only enables "main" + "restricted" by default; fzf # lives in "universe". RUN sed -i 's/^Components: main restricted$/Components: main restricted universe multiverse/' \ /etc/apt/sources.list.d/ubuntu.sources && \ apt-get update && \ apt-get install -y --no-install-recommends \ fzf \ perl \ openssh-client && \ rm -rf /var/lib/apt/lists/* # ~/.ssh/conf.d/ — the two exact config files from this article RUN mkdir -p /root/.ssh/conf.d && chmod 700 /root/.ssh RUN printf '%s\n' \ '# MyAmazingApp - Production Environment' \ '#' \ '# https://my.amazing_app.be' \ '# /var/www/amazing' \ '# pgsql: host=1.2.3.4 port=5438 dbname=amazing username=christophe' \ '#' \ '# Backend: PHP 8.5' \ '# Note: Part of TremendousApp.' \ '#' \ '# CRITICAL - This is a production server!' \ '# INFO - Database is PostgreSQL' \ 'Host MyAmazingApp_PROD' \ ' Hostname 1.2.3.4' \ ' User usr_app_prod' \ '' \ '# MyAmazingApp - Tests Environment' \ '#' \ '# https://my.tests_amazing_app.be' \ '# /var/www/tests_amazing' \ '# pgsql: host=1.2.3.4 port=5438 dbname=amazing_newx username=christophe' \ '#' \ '# Backend: PHP 8.3' \ '# Note: This is the TESTS environment' \ '#' \ '# INFO - Database is PostgreSQL' \ 'Host MyAmazingApp_TEST' \ ' Hostname 6.7.8.9' \ ' User usr_app_test' \ '' \ 'Host YourAmazingApp' \ ' Hostname 1.2.3.4' \ ' User you' \ > /root/.ssh/conf.d/amazing.conf RUN printf '%s\n' \ 'Host LegacyApp' \ ' Hostname 1.2.3.4' \ ' User legacy' \ > /root/.ssh/conf.d/legacy.conf # ~/.ssh/config — includes both conf.d files RUN printf '%s\n' \ 'Include conf.d/amazing.conf' \ 'Include conf.d/legacy.conf' \ > /root/.ssh/config && \ chmod 600 /root/.ssh/config /root/.ssh/conf.d/*.conf # ~/.bashrc — the exact sshf() function from this article RUN printf '%s\n' \ '# ------------------------------------------------------------------------------' \ '# FUNCTION: sshf' \ '#' \ '# DESCRIPTION:' \ '# An interactive SSH launcher using fzf and Perl. It parses SSH configuration' \ '# files (~/.ssh/config and ~/.ssh/conf.d/*.conf) to present a searchable' \ '# list of aliases. It supports multi-line comments and rich-text' \ '# formatting (colors, backgrounds) in the preview pane.' \ '#' \ '# FEATURES:' \ '# - Deep search: Filters by Alias, HostName, or any word in the comments.' \ '# - ANSI Support: Supports tags like , , , etc.' \ '# - Non-cluttered UI: Keeps the list clean while showing full docs on the right.' \ '# - Robust Parsing: Handles complex multi-line SSH comment blocks.' \ '#' \ '# INSTALLATION:' \ '# 1. Save this file as ~/.zsh/sshf.zsh' \ '# 2. Add the following line to your ~/.zshrc:' \ '# [[ -f ~/.zsh/sshf.zsh ]] && source ~/.zsh/sshf.zsh' \ '# 3. Restart your terminal or run: source ~/.zshrc' \ '#' \ '# USAGE:' \ '# $ sshf # Opens interactive list' \ '# $ sshf query # Opens interactive list pre-filtered by "query"' \ '#' \ '# DOCUMENTATION TAGS (Use in SSH config comments):' \ '# text, text, text, text' \ '# highlight, warning, important' \ '# ------------------------------------------------------------------------------' \ 'sshf() {' \ ' local selected' \ ' # Step 1: Data extraction with Perl' \ ' # Aggregates comments and attaches them to the subsequent Host definition.' \ ' selected=$(cat ~/.ssh/config ~/.ssh/conf.d/*.conf 2>/dev/null | perl -ne '\''' \ ' BEGIN { $sep = " ;; "; }' \ ' s/\r//g; chomp;' \ '' \ ' # Capture comment lines' \ ' if (/^\s*#/) {' \ ' s/^\s*#\s?//;' \ ' s/\|/-/;' \ ' $buf .= ($buf eq "" ? "" : $sep) . $_;' \ ' next;' \ ' }' \ '' \ ' # Match Host alias (ignoring wildcards)' \ ' if (/^\s*Host\s+([^*\s]+)/i) {' \ ' $new_h = $1;' \ ' if ($h) { print_line($h, $ip, $d); }' \ ' $h = $new_h; $d = $buf; $buf = ""; $ip = "N/A";' \ ' next;' \ ' }' \ '' \ ' # Capture HostName/IP' \ ' if (/^\s*HostName\s+(.+)/i) { $ip = $1; }' \ '' \ ' # Flush the last entry' \ ' END { if ($h) { print_line($h, $ip, $d); } }' \ '' \ ' sub print_line {' \ ' my ($h, $i, $d) = @_;' \ ' $d = "No description" unless $d;' \ ' # 150-space padding prevents description spillover in the main list' \ ' my $padding = " " x 150;' \ ' printf "%-20s | %-15s %s ##### %s\n", $h, $i, $padding, $d;' \ ' }' \ ' '\'' | \' \ ' fzf --query="$1" \' \ ' --reverse \' \ ' --delimiter="#####" \' \ ' --no-hscroll \' \ ' --exact \' \ ' --ansi \' \ ' --header="ALIAS | HOSTNAME" \' \ ' --prompt="Search > " \' \ ' --preview-window="right:70%:wrap" \' \ ' --preview="echo {2} | perl -pe '\''' \ ' s/ ;; /\n/g;' \ ' s//\e[31m/g; s/<\/red>/\e[0m/g;' \ ' s//\e[32m/g; s/<\/green>/\e[0m/g;' \ ' s//\e[33m/g; s/<\/yellow>/\e[0m/g;' \ ' s//\e[41;37m/g; s/<\/bg-red>/\e[0m/g;' \ ' s//\e[43;30m/g; s/<\/bg-yellow>/\e[0m/g;' \ ' s//\e[1m/g; s/<\/bold>/\e[0m/g;' \ ' '\''" | \' \ ' awk '\''{print $1}'\'')' \ '' \ ' # Step 2: Establish SSH Connection' \ ' if [ -n "$selected" ]; then' \ ' # Use ssh -G to get the computed values for this alias' \ ' local ssh_info=$(ssh -G "$selected")' \ ' local real_user=$(echo "$ssh_info" | awk '\''/^user / {print $2}'\'')' \ ' local real_host=$(echo "$ssh_info" | awk '\''/^hostname / {print $2}'\'')' \ ' local real_key=$(echo "$ssh_info" | awk '\''/^identityfile / {print $2}'\'' | head -n 1)' \ '' \ ' # Print the fully expanded command in bold cyan' \ ' printf "\n\e[1;36m>> Executing: ssh -i %s %s@%s\e[0m\n\n" "$real_key" "$real_user" "$real_host"' \ '' \ ' ssh "$selected"' \ ' fi' \ '}' \ >> /root/.bashrc WORKDIR /root # Signal clearly that this shell runs inside a container RUN echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> /root/.bashrc CMD ["/bin/bash", "-i"] ``` Build and run it: ```bash $ docker build -t sshf-demo . [+] Building 26.4s (8/8) FINISHED ✔ exporting to image $ docker run --rm -it sshf-demo 🐳 root ~ # sshf ``` The same TUI from the screenshot at the top of this article opens, listing all four hosts across both `conf.d` files with their doc-blocks. Type `prod` or `php` to filter, exactly like the "Using filtering options" section below — no real server is ever contacted. ## Install FZF Check out my previous [Fuzzy Finder](/blog/linux-fzf-introduction) blog post if you need more detailed information. Otherwise, simply run the command below and complete the installation by answering "Yes" to all prompts, following the instructions displayed in your terminal. ```bash $ git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf ; ~/.fzf/install ``` While this isn't strictly related to SSH, you can now press `CTRL`+`R` to enjoy much easier access to your console history. This is just one of the great features of FZF. ## SSH configuration The goal here is to organize your configuration more intelligently. Let's create an `~/.ssh/conf.d` folder where your configuration files will reside. ```bash $ mkdir -p ~/.ssh/conf.d ``` For instance, create an `~/.ssh/conf.d/amazing.conf` file with the following content: **amazing.conf** ```conf # MyAmazingApp - Production Environment # # https://my.amazing_app.be # /var/www/amazing # pgsql: host=1.2.3.4 port=5438 dbname=amazing username=christophe # # Backend: PHP 8.5 # Note: Part of TremendousApp. # # CRITICAL - This is a production server! # INFO - Database is PostgreSQL Host MyAmazingApp_PROD Hostname 1.2.3.4 User usr_app_prod # MyAmazingApp - Tests Environment # # https://my.tests_amazing_app.be # /var/www/tests_amazing # pgsql: host=1.2.3.4 port=5438 dbname=amazing_newx username=christophe # # Backend: PHP 8.3 # Note: This is the TESTS environment # # INFO - Database is PostgreSQL Host MyAmazingApp_TEST Hostname 6.7.8.9 User usr_app_test Host YourAmazingApp Hostname 1.2.3.4 User you ``` Then, create a second file named `~/.ssh/conf.d/legacy.conf` with this content: **legacy.conf** ```conf Host LegacyApp Hostname 1.2.3.4 User legacy ``` Now, update your main `~/.ssh/config` file to include these new files: ```conf Include conf.d/amazing.conf Include conf.d/legacy.conf ``` This approach simply allows you to group hosts by project, making them easier to manage. ### Look at the amazing.conf file We've defined three `Host` entries: `MyAmazingApp_PROD`, `MyAmazingApp_TEST`, and `YourAmazingApp`. Notice that we've added some comments immediately before the first two `Host` lines (the placement is crucial). This "doc-block" is plain text, so you can write whatever you like. For convenience, it supports markers like `blue`, `green`, `red`, `yellow`, `bg-red`, `bg-yellow`, and `bold`. When the `MyAmazingApp_PROD` host is selected in the TUI, its associated doc-block will be displayed automatically. ## Add the sshf function `sshf` gets its own file, in a folder your shell loads at startup (the organisation explained in [Beyond the Monolith](/blog/modular-zsh-workflow)): your rc file stays short, and removing the tool is deleting one file. **sshf.sh** ```bash # ------------------------------------------------------------------------------ # FUNCTION: sshf # # DESCRIPTION: # An interactive SSH launcher using fzf and Perl. It parses SSH configuration # files (~/.ssh/config and ~/.ssh/conf.d/*.conf) to present a searchable # list of aliases. It supports multi-line comments and rich-text # formatting (colors, backgrounds) in the preview pane. # # FEATURES: # - Deep search: Filters by Alias, HostName, or any word in the comments. # - ANSI Support: Supports tags like , , , etc. # - Non-cluttered UI: Keeps the list clean while showing full docs on the right. # - Robust Parsing: Handles complex multi-line SSH comment blocks. # # INSTALLATION (zsh and bash, same code): # 1. Save this file as ~/.zsh/fns/sshf.zsh (zsh) or ~/.bash/fns/sshf.sh (bash) # 2. Load that folder from ~/.zshrc or ~/.bashrc with a "for fn_file ..." loop # (see https://www.avonture.be/blog/ssh-with-fuzzy-finder) # 3. Open a new terminal # # USAGE: # $ sshf # Opens interactive list # $ sshf query # Opens interactive list pre-filtered by "query" # # DOCUMENTATION TAGS (Use in SSH config comments): # text, text, text, text # highlight, warning, important # ------------------------------------------------------------------------------ sshf() { local selected # Step 1: Data extraction with Perl # Aggregates comments and attaches them to the subsequent Host definition. selected=$(cat ~/.ssh/config ~/.ssh/conf.d/*.conf 2>/dev/null | perl -ne ' BEGIN { $sep = " ;; "; } s/\r//g; chomp; # Capture comment lines if (/^\s*#/) { s/^\s*#\s?//; s/\|/-/; $buf .= ($buf eq "" ? "" : $sep) . $_; next; } # Match Host alias (ignoring wildcards) if (/^\s*Host\s+([^*\s]+)/i) { $new_h = $1; if ($h) { print_line($h, $ip, $d); } $h = $new_h; $d = $buf; $buf = ""; $ip = "N/A"; next; } # Capture HostName/IP if (/^\s*HostName\s+(.+)/i) { $ip = $1; } # Flush the last entry END { if ($h) { print_line($h, $ip, $d); } } sub print_line { my ($h, $i, $d) = @_; $d = "No description" unless $d; # 150-space padding prevents description spillover in the main list my $padding = " " x 150; printf "%-20s | %-15s %s ##### %s\n", $h, $i, $padding, $d; } ' | \ fzf --query="$1" \ --reverse \ --delimiter="#####" \ --no-hscroll \ --exact \ --ansi \ --header="ALIAS | HOSTNAME" \ --prompt="Search > " \ --preview-window="right:70%:wrap" \ --preview="echo {2} | perl -pe ' s/ ;; /\n/g; s//\e[31m/g; s/<\/red>/\e[0m/g; s//\e[32m/g; s/<\/green>/\e[0m/g; s//\e[33m/g; s/<\/yellow>/\e[0m/g; s//\e[41;37m/g; s/<\/bg-red>/\e[0m/g; s//\e[43;30m/g; s/<\/bg-yellow>/\e[0m/g; s//\e[1m/g; s/<\/bold>/\e[0m/g; '" | \ awk '{print $1}') # Step 2: Establish SSH Connection if [ -n "$selected" ]; then # Use ssh -G to get the computed values for this alias local ssh_info=$(ssh -G "$selected") local real_user=$(echo "$ssh_info" | awk '/^user / {print $2}') local real_host=$(echo "$ssh_info" | awk '/^hostname / {print $2}') local real_key=$(echo "$ssh_info" | awk '/^identityfile / {print $2}' | head -n 1) # Print the fully expanded command in bold cyan printf "\n\e[1;36m>> Executing: ssh -i %s %s@%s\e[0m\n\n" "$real_key" "$real_user" "$real_host" ssh "$selected" fi } ``` The code is the same for zsh and bash; only the folder and the loader differ. **1. Create the file** in `~/.zsh/fns/`: **Project: \~/.zsh/fns** **sshf.zsh** ```bash # ------------------------------------------------------------------------------ # FUNCTION: sshf # # DESCRIPTION: # An interactive SSH launcher using fzf and Perl. It parses SSH configuration # files (~/.ssh/config and ~/.ssh/conf.d/*.conf) to present a searchable # list of aliases. It supports multi-line comments and rich-text # formatting (colors, backgrounds) in the preview pane. # # FEATURES: # - Deep search: Filters by Alias, HostName, or any word in the comments. # - ANSI Support: Supports tags like , , , etc. # - Non-cluttered UI: Keeps the list clean while showing full docs on the right. # - Robust Parsing: Handles complex multi-line SSH comment blocks. # # INSTALLATION (zsh and bash, same code): # 1. Save this file as ~/.zsh/fns/sshf.zsh (zsh) or ~/.bash/fns/sshf.sh (bash) # 2. Load that folder from ~/.zshrc or ~/.bashrc with a "for fn_file ..." loop # (see https://www.avonture.be/blog/ssh-with-fuzzy-finder) # 3. Open a new terminal # # USAGE: # $ sshf # Opens interactive list # $ sshf query # Opens interactive list pre-filtered by "query" # # DOCUMENTATION TAGS (Use in SSH config comments): # text, text, text, text # highlight, warning, important # ------------------------------------------------------------------------------ sshf() { local selected # Step 1: Data extraction with Perl # Aggregates comments and attaches them to the subsequent Host definition. selected=$(cat ~/.ssh/config ~/.ssh/conf.d/*.conf 2>/dev/null | perl -ne ' BEGIN { $sep = " ;; "; } s/\r//g; chomp; # Capture comment lines if (/^\s*#/) { s/^\s*#\s?//; s/\|/-/; $buf .= ($buf eq "" ? "" : $sep) . $_; next; } # Match Host alias (ignoring wildcards) if (/^\s*Host\s+([^*\s]+)/i) { $new_h = $1; if ($h) { print_line($h, $ip, $d); } $h = $new_h; $d = $buf; $buf = ""; $ip = "N/A"; next; } # Capture HostName/IP if (/^\s*HostName\s+(.+)/i) { $ip = $1; } # Flush the last entry END { if ($h) { print_line($h, $ip, $d); } } sub print_line { my ($h, $i, $d) = @_; $d = "No description" unless $d; # 150-space padding prevents description spillover in the main list my $padding = " " x 150; printf "%-20s | %-15s %s ##### %s\n", $h, $i, $padding, $d; } ' | \ fzf --query="$1" \ --reverse \ --delimiter="#####" \ --no-hscroll \ --exact \ --ansi \ --header="ALIAS | HOSTNAME" \ --prompt="Search > " \ --preview-window="right:70%:wrap" \ --preview="echo {2} | perl -pe ' s/ ;; /\n/g; s//\e[31m/g; s/<\/red>/\e[0m/g; s//\e[32m/g; s/<\/green>/\e[0m/g; s//\e[33m/g; s/<\/yellow>/\e[0m/g; s//\e[41;37m/g; s/<\/bg-red>/\e[0m/g; s//\e[43;30m/g; s/<\/bg-yellow>/\e[0m/g; s//\e[1m/g; s/<\/bold>/\e[0m/g; '" | \ awk '{print $1}') # Step 2: Establish SSH Connection if [ -n "$selected" ]; then # Use ssh -G to get the computed values for this alias local ssh_info=$(ssh -G "$selected") local real_user=$(echo "$ssh_info" | awk '/^user / {print $2}') local real_host=$(echo "$ssh_info" | awk '/^hostname / {print $2}') local real_key=$(echo "$ssh_info" | awk '/^identityfile / {print $2}' | head -n 1) # Print the fully expanded command in bold cyan printf "\n\e[1;36m>> Executing: ssh -i %s %s@%s\e[0m\n\n" "$real_key" "$real_user" "$real_host" ssh "$selected" fi } ``` **2. Load the folder from `~/.zshrc`.** Already done if you followed one of my ["Ollama daily use"](/series/ollama-daily-use) articles. Otherwise, add at the end of `~/.zshrc`: ```zsh title="~/.zshrc" for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` **3. Open a new terminal** (or run `exec zsh`). **1. Create the file** in `~/.bash/fns/`: **Project: \~/.bash/fns** **sshf.sh** ```bash # ------------------------------------------------------------------------------ # FUNCTION: sshf # # DESCRIPTION: # An interactive SSH launcher using fzf and Perl. It parses SSH configuration # files (~/.ssh/config and ~/.ssh/conf.d/*.conf) to present a searchable # list of aliases. It supports multi-line comments and rich-text # formatting (colors, backgrounds) in the preview pane. # # FEATURES: # - Deep search: Filters by Alias, HostName, or any word in the comments. # - ANSI Support: Supports tags like , , , etc. # - Non-cluttered UI: Keeps the list clean while showing full docs on the right. # - Robust Parsing: Handles complex multi-line SSH comment blocks. # # INSTALLATION (zsh and bash, same code): # 1. Save this file as ~/.zsh/fns/sshf.zsh (zsh) or ~/.bash/fns/sshf.sh (bash) # 2. Load that folder from ~/.zshrc or ~/.bashrc with a "for fn_file ..." loop # (see https://www.avonture.be/blog/ssh-with-fuzzy-finder) # 3. Open a new terminal # # USAGE: # $ sshf # Opens interactive list # $ sshf query # Opens interactive list pre-filtered by "query" # # DOCUMENTATION TAGS (Use in SSH config comments): # text, text, text, text # highlight, warning, important # ------------------------------------------------------------------------------ sshf() { local selected # Step 1: Data extraction with Perl # Aggregates comments and attaches them to the subsequent Host definition. selected=$(cat ~/.ssh/config ~/.ssh/conf.d/*.conf 2>/dev/null | perl -ne ' BEGIN { $sep = " ;; "; } s/\r//g; chomp; # Capture comment lines if (/^\s*#/) { s/^\s*#\s?//; s/\|/-/; $buf .= ($buf eq "" ? "" : $sep) . $_; next; } # Match Host alias (ignoring wildcards) if (/^\s*Host\s+([^*\s]+)/i) { $new_h = $1; if ($h) { print_line($h, $ip, $d); } $h = $new_h; $d = $buf; $buf = ""; $ip = "N/A"; next; } # Capture HostName/IP if (/^\s*HostName\s+(.+)/i) { $ip = $1; } # Flush the last entry END { if ($h) { print_line($h, $ip, $d); } } sub print_line { my ($h, $i, $d) = @_; $d = "No description" unless $d; # 150-space padding prevents description spillover in the main list my $padding = " " x 150; printf "%-20s | %-15s %s ##### %s\n", $h, $i, $padding, $d; } ' | \ fzf --query="$1" \ --reverse \ --delimiter="#####" \ --no-hscroll \ --exact \ --ansi \ --header="ALIAS | HOSTNAME" \ --prompt="Search > " \ --preview-window="right:70%:wrap" \ --preview="echo {2} | perl -pe ' s/ ;; /\n/g; s//\e[31m/g; s/<\/red>/\e[0m/g; s//\e[32m/g; s/<\/green>/\e[0m/g; s//\e[33m/g; s/<\/yellow>/\e[0m/g; s//\e[41;37m/g; s/<\/bg-red>/\e[0m/g; s//\e[43;30m/g; s/<\/bg-yellow>/\e[0m/g; s//\e[1m/g; s/<\/bold>/\e[0m/g; '" | \ awk '{print $1}') # Step 2: Establish SSH Connection if [ -n "$selected" ]; then # Use ssh -G to get the computed values for this alias local ssh_info=$(ssh -G "$selected") local real_user=$(echo "$ssh_info" | awk '/^user / {print $2}') local real_host=$(echo "$ssh_info" | awk '/^hostname / {print $2}') local real_key=$(echo "$ssh_info" | awk '/^identityfile / {print $2}' | head -n 1) # Print the fully expanded command in bold cyan printf "\n\e[1;36m>> Executing: ssh -i %s %s@%s\e[0m\n\n" "$real_key" "$real_user" "$real_host" ssh "$selected" fi } ``` **2. Load the folder from `~/.bashrc`.** Add at the end of `~/.bashrc` (the `-r` test keeps an empty folder from printing an error): ```bash title="~/.bashrc" for fn_file in ~/.bash/fns/*.sh; do [[ -r "$fn_file" ]] && source "$fn_file" done ``` **3. Open a new terminal** (or run `exec bash`). Check that the shell knows the function: `type sshf` must answer `sshf is a shell function`. Return to your terminal, type `sshf`, and press `Enter`. The magic will now unfold. You will see the complete list of defined hosts. By typing a few letters, you will instantly apply filters. For example, simply type `prod` to filter the list and show only production servers. Easy, isn't it? ## Using filtering options You can filter based on any pattern. For instance, if you type `php`: ![Filtering on PHP servers](./images/php.webp) FZF searches across all available data, not just the hostnames. ## My real use case At work, I've taken this well beyond simply displaying a list of hosts. FZF supports custom keybindings, which means you can assign actions to keys other than just `Enter`. You can define actions triggered by `CTRL`+`A`, `CTRL`+`I`, or even a single letter like `E`. This is where it gets truly powerful! | Shortcut | Description | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Ctrl`+`A` | I’ve set up a secondary screen that displays a list of actions to execute on the selected host. | | `Ctrl`+`I` | I run an inventory management script that scans all my hosts (or just the filtered ones) and generates a web page with an up-to-date inventory of installed software (such as PHP, Python, PostgreSQL versions). | | `E` | You could, for example, open an editor to directly modify the configuration file of the selected host. | ```bash # ------------------------------------------------------------------------------ # FUNCTION: sshf_actions # DESCRIPTION: Internal router for specific host actions # ------------------------------------------------------------------------------ sshf_actions() { local action_key=$1 local target=$2 [[ -z "$target" ]] && return case "$action_key" in ctrl-a) local cmd=$(echo -e "Ping\nCopy-ID\nEdit-Config" | fzf --reverse --header="Action for $target") case "$cmd" in Ping) ping $(ssh -G "$target" | awk '/^hostname / {print $2}') ;; Copy-ID) ssh-copy-id "$target" ;; Edit-Config) ${EDITOR:-vim} ~/.ssh/config ;; esac ;; ctrl-h) clear printf "\e[1;34m>> Raw configuration for: %s\e[0m\n" "$target" ssh -G "$target" printf "\n\e[1;30mPress any key to return to list...\e[0m" read -k 1 -s sshf "" ;; esac } # ------------------------------------------------------------------------------ # FUNCTION: sshf # # DESCRIPTION: # An interactive SSH launcher using fzf and Perl. It parses SSH configuration # files (~/.ssh/config and ~/.ssh/conf.d/*.conf) to present a searchable # list of aliases. It supports multi-line comments and rich-text # formatting (colors, backgrounds) in the preview pane. # # FEATURES: # - Deep search: Filters by Alias, HostName, or any word in the comments. # - ANSI Support: Supports tags like , , , etc. # - Non-cluttered UI: Keeps the list clean while showing full docs on the right. # - Robust Parsing: Handles complex multi-line SSH comment blocks. # - Shortcuts: CTRL-A for Actions, CTRL-H for Details. # # INSTALLATION (zsh and bash, same code): # 1. Save this file as ~/.zsh/fns/sshf.zsh (zsh) or ~/.bash/fns/sshf.sh (bash) # 2. Load that folder from ~/.zshrc or ~/.bashrc with a "for fn_file ..." loop # (see https://www.avonture.be/blog/ssh-with-fuzzy-finder) # 3. Open a new terminal # # USAGE: # $ sshf # Opens interactive list # $ sshf query # Opens interactive list pre-filtered by "query" # # DOCUMENTATION TAGS (Use in SSH config comments): # text, text, text, text # highlight, warning, important # ------------------------------------------------------------------------------ sshf() { local out # Step 1: Data extraction with Perl # Aggregates comments and attaches them to the subsequent Host definition. out=$(cat ~/.ssh/config ~/.ssh/conf.d/*.conf 2>/dev/null | perl -ne ' BEGIN { $sep = " ;; "; } s/\r//g; chomp; # Capture comment lines if (/^\s*#/) { s/^\s*#\s?//; s/\|/-/; $buf .= ($buf eq "" ? "" : $sep) . $_; next; } # Match Host alias (ignoring wildcards) if (/^\s*Host\s+([^*\s]+)/i) { $new_h = $1; if ($h) { print_line($h, $ip, $d); } $h = $new_h; $d = $buf; $buf = ""; $ip = "N/A"; next; } # Capture HostName/IP if (/^\s*HostName\s+(.+)/i) { $ip = $1; } # Flush the last entry END { if ($h) { print_line($h, $ip, $d); } } sub print_line { my ($h, $i, $d) = @_; $d = "No description" unless $d; # 150-space padding prevents description spillover in the main list my $padding = " " x 150; printf "%-20s | %-15s %s ##### %s\n", $h, $i, $padding, $d; } ' | \ fzf --query="$1" \ --reverse \ --delimiter="#####" \ --no-hscroll \ --exact \ --ansi \ --header="────────────────────────────────────────────────────────────── ENTER : Connect to host CTRL-A : Open actions menu (Ping, MTR, etc.) CTRL-H : Display raw SSH configuration ────────────────────────────────────────────────────────────── ALIAS | HOSTNAME" \ --prompt="Search > " \ --expect="ctrl-a,ctrl-h" \ --preview-window="right:70%:wrap" \ --preview="echo {2} | perl -pe ' s/ ;; /\n/g; s//\e[31m/g; s/<\/red>/\e[0m/g; s//\e[32m/g; s/<\/green>/\e[0m/g; s//\e[33m/g; s/<\/yellow>/\e[0m/g; s//\e[41;37m/g; s/<\/bg-red>/\e[0m/g; s//\e[43;30m/g; s/<\/bg-yellow>/\e[0m/g; s//\e[1m/g; s/<\/bold>/\e[0m/g; '") # fzf with --expect returns the key on the first line and the selection on the second local key=$(head -n1 <<< "$out") local selected_line=$(sed -n '2p' <<< "$out") local selected=$(echo "$selected_line" | awk '{print $1}') if [ -n "$key" ]; then # A shortcut was pressed sshf_actions "$key" "$selected" elif [ -n "$selected" ]; then # Step 2: Establish SSH Connection (Default Enter) local ssh_info=$(ssh -G "$selected") local real_user=$(echo "$ssh_info" | awk '/^user / {print $2}') local real_host=$(echo "$ssh_info" | awk '/^hostname / {print $2}') local real_key=$(echo "$ssh_info" | awk '/^identityfile / {print $2}' | head -n 1) printf "\n\e[1;36m>> Executing: ssh -i %s %s@%s\e[0m\n\n" "$real_key" "$real_user" "$real_host" ssh "$selected" fi } ``` ![Using Key bindings](./images/tui_with_actions.webp) Your imagination is the only limit! --- # FZF + ripgrep: Interactive Code Search with Live Preview > Combine ripgrep and fzf with a bat syntax-highlighted preview panel to build an interactive code search — then open the result directly in VSCode at the exact matching line. --- ![FZF + ripgrep: Interactive Code Search with Live Preview](/img/v2/fzf_ripgrep.webp) **TL;DR** `grep -r` returns a flat list — no context, no preview, no way to navigate without opening the file. By piping `ripgrep` into `fzf` and adding a `bat` preview panel, you get an interactive, syntax-highlighted code search that feels like an IDE search box, but lives entirely in your terminal. This article builds a ZSH function `rgf` that searches your codebase, shows a live preview of the matching file, and jumps straight to the right line in VSCode when you press `Enter`. You're hunting for where a particular environment variable is read, or which file still has that hardcoded URL you meant to clean up. You run `grep -rn "DB_PASSWORD" .` and get back forty lines of filenames, line numbers, and snippets — all jumbled together. You pick one, open the file, scroll to the right line, realize it is not the one you wanted, close it, and try again. There is a better way. **Quick Jump:** [Seeing the Preview Panel](#seeing-the-preview-panel) | [Step 3 — The rgf function](#step-3--the-rgf-function) ## Seeing the Preview Panel Pipe `ripgrep` into `fzf` with a `bat` preview attached, and this is the result — a live, syntax-highlighted panel that updates as you move the selection: ![Searching for DB\_PASSWORD with preview](./images/rg_fzf_bat.webp) As you move the cursor up and down the result list, the right panel updates live — showing the file with syntax highlighting, the matching line highlighted in yellow, and a few lines of surrounding context. ## Why ripgrep instead of grep? `grep -r` is fine for small codebases. `ripgrep` is built for the real world: * It **ignores `.gitignore`** entries automatically — no more results from `node_modules`, `vendor`, or build folders. * It is **significantly faster** on large projects, thanks to parallel processing and smarter file traversal. * Its output format (`file:line:content`) is designed for tooling integration. A quick comparison — searching for `TODO` in a Node.js project with `node_modules` present: ```bash $ time grep -rn "TODO" . ... hundreds of results from node_modules ... real 0m4.2s $ time rg "TODO" src/auth/login.ts:47: // TODO: add rate limiting src/api/users.ts:123: // TODO: validate email format src/components/Header.tsx:8: // TODO: make logo clickable real 0m0.08s ``` The speed and noise-reduction alone make it worth switching. ## Prerequisites This article builds on top of [fzf](/blog/linux-fzf-introduction), which you should already have installed. Two additional tools are needed: **ripgrep** and **bat**. ### ripgrep > [ripgrep](https://github.com/BurntSushi/ripgrep) is a fast command-line search tool that recursively searches files for text patterns using Rust-powered performance, while automatically respecting `.gitignore` and similar ignore rules. *I've dedicated a full article to it: [ripgrep — The Search Tool That Changed My WSL2 Workflow](/blog/ripgrep).* Prerequisite: ripgrep ```bash $ sudo apt install ripgrep ``` Verify: ```bash $ rg --version ripgrep 14.1.0 ``` ### bat [bat](https://github.com/sharkdp/bat) (batcat on Debian/Ubuntu) is a modern replacement for cat that displays files with syntax highlighting, Git integration, paging, and other developer-friendly features. It is used here to power the preview panel. Prerequisite: bat ```bash $ sudo apt install bat ``` Verify: ```bash $ bat --version bat 0.24.0 ``` > **batcat on Ubuntu/Debian:** > > On some Ubuntu/Debian systems, the binary is named `batcat` instead of `bat`. If that's the case for you, simply replace *bat* with *batcat* in the rest of this article. ## Seeing It in Action with Docker Before installing `ripgrep`, `fzf` and `bat` on your machine, try `rgf` in a throwaway container that already has all three, the function itself wired via real ZSH autoload, and a small demo project matching the "Real-world scenarios" section further down: a leaking `DB_PASSWORD`, three `TODO`/`FIXME`/`HACK` markers, a `sendEmail` call site, a hardcoded port, and a TypeScript interface. > **Why Docker first?:** > > The Dockerfile below reproduces `rgf` byte-for-byte from the Snippet further down, plus the > `fpath`/`autoload` wiring described in Step 3. No VSCode is needed to try it: `rgf` falls back to > printing `file:line` to the terminal when `code` is not in `$PATH`, which is exactly the case > inside this container. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive # Ubuntu's docker image only enables "main" + "restricted" by default; # fzf and bat live in "universe". RUN sed -i 's/^Components: main restricted$/Components: main restricted universe multiverse/' \ /etc/apt/sources.list.d/ubuntu.sources && \ apt-get update && \ apt-get install -y --no-install-recommends \ zsh \ ripgrep \ fzf \ bat && \ rm -rf /var/lib/apt/lists/* # ~/.zsh/fns/rgf — the exact function from this article, wired via real # zsh autoload (fpath + autoload -Uz), no VSCode required: it falls back to # printing "file:line" when `code` is not in $PATH. RUN mkdir -p /root/.zsh/fns RUN printf '%s\n' \ '#!/bin/zsh' \ '# Description: Interactive code search using ripgrep + fzf + bat preview.' \ '# Opens the selected result in VSCode at the exact matching line.' \ '# Usage: rgf [rg options] [path]' \ '# Place in: ~/.zsh/fns/rgf (no extension)' \ '' \ '_rgf_check_deps() {' \ ' local missing_dep=0' \ ' local os_type' \ ' os_type=$(uname -s)' \ '' \ ' if ! command -v fzf >/dev/null 2>&1; then' \ ' printf "\033[0;31m[Error]\033[0m '\''fzf'\'' is not installed.\n"' \ ' missing_dep=1' \ ' fi' \ '' \ ' if ! command -v rg >/dev/null 2>&1; then' \ ' printf "\033[0;31m[Error]\033[0m '\''rg'\'' (ripgrep) is not installed.\n"' \ ' missing_dep=1' \ ' fi' \ '' \ ' if ! command -v bat >/dev/null 2>&1 && ! command -v batcat >/dev/null 2>&1; then' \ ' printf "\033[0;31m[Error]\033[0m '\''bat'\'' is not installed.\n"' \ ' missing_dep=1' \ ' fi' \ '' \ ' if [[ "$missing_dep" -eq 1 ]]; then' \ ' printf "\033[0;33mHow to install:\033[0m\n"' \ ' if [[ "$os_type" == "Darwin" ]]; then' \ ' printf " brew install fzf ripgrep bat\n"' \ ' elif [[ -f /etc/debian_version ]]; then' \ ' printf " sudo apt update && sudo apt install fzf ripgrep bat\n"' \ ' elif [[ -f /etc/fedora-release ]]; then' \ ' printf " sudo dnf install fzf ripgrep bat\n"' \ ' else' \ ' printf " Please install '\''fzf'\'', '\''rg'\'' and '\''bat'\'' using your package manager.\n"' \ ' fi' \ ' return 1' \ ' fi' \ '' \ ' return 0' \ '}' \ '' \ '# 1. Run the dependency check' \ 'if ! _rgf_check_deps; then' \ ' return 1' \ 'fi' \ '' \ '# 2. Determine the correct bat binary (Ubuntu/Debian ship it as '\''batcat'\'')' \ 'local bat_bin' \ 'if command -v bat >/dev/null 2>&1; then' \ ' bat_bin="bat"' \ 'else' \ ' bat_bin="batcat"' \ 'fi' \ '' \ '# 3. Search and preview — write fzf'\''s output to a temp file so that fzf runs' \ '# in the foreground with full terminal access. Using $(...) would block the' \ '# TUI in WSL subshells.' \ 'local tmpfile' \ 'tmpfile=$(mktemp /tmp/rgf.XXXXXX)' \ '' \ 'rg --color=always --line-number --no-heading --smart-case "${@:-}" \' \ ' | fzf --ansi \' \ ' --layout=reverse \' \ ' --exact \' \ ' --delimiter='\'':'\'' \' \ ' --nth=3.. \' \ ' --preview="${bat_bin} --color=always --pager=never --highlight-line {2} -- {1}" \' \ ' --preview-window='\''right:60%:+{2}+3/3:wrap'\'' > "$tmpfile"' \ '' \ 'local fzf_exit=$?' \ 'local selected' \ 'selected=$(<"$tmpfile")' \ 'rm -f "$tmpfile"' \ '' \ '# fzf exits 130 on Esc/CTRL-C, 1 when no match' \ '[[ "$fzf_exit" -ne 0 || -z "$selected" ]] && return' \ '' \ '# 4. Strip ANSI color codes before parsing file path and line number' \ 'local file line' \ 'file=$(echo "$selected" | sed $'\''s/\033\[[0-9;]*m//g'\'' | cut -d: -f1)' \ 'line=$(echo "$selected" | sed $'\''s/\033\[[0-9;]*m//g'\'' | cut -d: -f2)' \ '' \ '[[ -n "$file" ]] || return' \ '' \ '# 5. Open in VSCode at the exact matching line' \ 'if command -v code >/dev/null 2>&1; then' \ ' code --goto "$file:$line"' \ 'else' \ ' printf "\033[0;33m[Warning]\033[0m '\''code'\'' (VSCode) not found. Result: %s:%s\n" "$file" "$line"' \ 'fi' \ > /root/.zsh/fns/rgf RUN printf '%s\n' \ 'fpath=(~/.zsh/fns $fpath)' \ 'autoload -Uz rgf' \ >> /root/.zshrc # Build a demo project matching the "real-world scenarios" section: a # leaking DB_PASSWORD, TODO/FIXME/HACK markers, a sendEmail call site, a # hardcoded port, and a TypeScript interface RUN mkdir -p /root/demo/src/components /root/demo/config && \ printf 'DB_PASSWORD=demo-secret\n' > /root/demo/config/.env && \ printf 'function sendEmail(user) {\n // TODO: retry on failure\n}\n' \ > /root/demo/src/mailer.js && \ printf 'import { sendEmail } from "./mailer";\n\n// FIXME: this still calls the old name in one place\nsendEmail(currentUser);\n' \ > /root/demo/src/notifications.js && \ printf '// HACK: temporary workaround for #482\nfunction legacyRetry() {}\n' \ > /root/demo/src/legacy.js && \ printf 'services:\n db:\n image: postgres:16\n ports:\n - "5432:5432"\n' \ > /root/demo/compose.yml && \ printf 'export interface UserProps {\n id: number;\n name: string;\n}\n' \ > /root/demo/src/components/UserProfile.tsx WORKDIR /root/demo # Signal clearly that this shell runs inside a container RUN echo "PROMPT='%B%F{yellow}🐳 %F{cyan}%n%f%b %~ %# '" >> /root/.zshrc CMD ["/bin/zsh", "-i", "-l"] ``` Build and run it: ```bash $ docker build -t rgf-demo . [+] Building 32.5s (11/11) FINISHED ✔ exporting to image $ docker run --rm -it rgf-demo 🐳 root ~/demo # ``` Now try the exact scenarios from "Real-world scenarios" below, live: ```bash rgf "DB_PASSWORD" # the leaking secret in config/.env rgf "TODO|FIXME|HACK" # all three markers across the demo project rgf "sendEmail" # the call site in notifications.js rgf -t ts "interface UserProps" ``` Each one opens the same interactive list-plus-preview panel as the screenshot at the top of this article — navigate with `↑`/`↓`, filter by typing, press `Enter` to select. Since there's no VSCode inside the container, `rgf` prints `file:line` to the terminal instead of opening an editor — everything else behaves exactly as it would on your own machine. ## Step 1 — Connect ripgrep to fzf The simplest possible combination already beats plain `grep`: ```bash rg "DB_PASSWORD" | fzf ``` ![Searching for DB\_PASSWORD without preview](./images/rg_fzf.webp) You get an interactive, filterable list. Type a few letters to narrow down results. Press `Enter` to print the selected line. Press `Esc` to exit without selecting. But this only shows the matching line — no context, no preview of the surrounding code. ## Step 2 — Add a bat preview panel This is where it gets interesting. `fzf` has a `--preview` option that runs an arbitrary command for each highlighted line and displays its output in a side panel. ```bash rg --color=always --line-number --no-heading --smart-case "DB_PASSWORD" \ | fzf --ansi \ --delimiter=':' \ --preview='bat --color=always --highlight-line {2} -- {1}' \ --preview-window='right:60%:+{2}+3/3:~3' ``` That's the command behind the screenshot at the top of this article. Let's break down what each flag does: **Flag Reference** * **`--color=always`** (rg) — Forces colored output even when piping. Without this, rg detects the pipe and strips all colors, leaving fzf with plain text. * **`--line-number --no-heading`** (rg) — Formats each result as `filename:linenumber:content` on a single line. This is the format fzf will parse. * **`--smart-case`** (rg) — Case-insensitive when the query is all lowercase, case-sensitive when it contains uppercase. Best default for code search. * **`--ansi`** (fzf) — Tells fzf to interpret and render ANSI color codes from rg's output. * **`--delimiter=':'`** (fzf) — Splits each line on `:` so that `{1}` = filename, `{2}` = line number, `{3}` = content. These are then referenced in `--preview`. * **`--highlight-line {2}`** (bat) — Highlights the specific matching line in the preview. `{2}` is resolved by fzf to the line number field. * **`-- {1}`** — The double dash separates bat options from the filename argument. `{1}` is resolved to the filename. * **`--preview-window='right:60%:+{2}+3/3:~3'`** — Opens the preview on the right, taking 60% of the terminal width. `+{2}+3/3` scrolls the panel so the matching line is visible, centered with context. `~3` keeps 3 header lines sticky. ## Step 3 — The `rgf` function Typing that full pipeline every time is not practical. The solution follows the same pattern as the [modular ZSH workflow](/blog/modular-zsh-workflow) article: a standalone file in `~/.zsh/fns/`, lazy-loaded by ZSH — no `.zshrc` editing required. The function checks all dependencies on startup, detects `bat` vs `batcat` automatically, and opens the selected result in VSCode at the exact matching line when you press `Enter`. If `code` is not in `$PATH`, it falls back to printing `file:line` to the terminal. **Project: \~/.zsh/fns** > **Note:** > > The filename must be exactly "rgf" — no extension. ZSH autoload uses the filename as the command name. **rgf** ``` #!/bin/zsh # Description: Interactive code search using ripgrep + fzf + bat preview. # Opens the selected result in VSCode at the exact matching line. # Usage: rgf [rg options] [path] # Place in: ~/.zsh/fns/rgf (no extension) _rgf_check_deps() { local missing_dep=0 local os_type os_type=$(uname -s) if ! command -v fzf >/dev/null 2>&1; then printf "\033[0;31m[Error]\033[0m 'fzf' is not installed.\n" missing_dep=1 fi if ! command -v rg >/dev/null 2>&1; then printf "\033[0;31m[Error]\033[0m 'rg' (ripgrep) is not installed.\n" missing_dep=1 fi if ! command -v bat >/dev/null 2>&1 && ! command -v batcat >/dev/null 2>&1; then printf "\033[0;31m[Error]\033[0m 'bat' is not installed.\n" missing_dep=1 fi if [[ "$missing_dep" -eq 1 ]]; then printf "\033[0;33mHow to install:\033[0m\n" if [[ "$os_type" == "Darwin" ]]; then printf " brew install fzf ripgrep bat\n" elif [[ -f /etc/debian_version ]]; then printf " sudo apt update && sudo apt install fzf ripgrep bat\n" elif [[ -f /etc/fedora-release ]]; then printf " sudo dnf install fzf ripgrep bat\n" else printf " Please install 'fzf', 'rg' and 'bat' using your package manager.\n" fi return 1 fi return 0 } # 1. Run the dependency check if ! _rgf_check_deps; then return 1 fi # 2. Determine the correct bat binary (Ubuntu/Debian ship it as 'batcat') local bat_bin if command -v bat >/dev/null 2>&1; then bat_bin="bat" else bat_bin="batcat" fi # 3. Search and preview — write fzf's output to a temp file so that fzf runs # in the foreground with full terminal access. Using $(...) would block the # TUI in WSL subshells. local tmpfile tmpfile=$(mktemp /tmp/rgf.XXXXXX) rg --color=always --line-number --no-heading --smart-case "${@:-}" \ | fzf --ansi \ --layout=reverse \ --exact \ --delimiter=':' \ --nth=3.. \ --preview="${bat_bin} --color=always --pager=never --highlight-line {2} -- {1}" \ --preview-window='right:60%:+{2}+3/3:wrap' > "$tmpfile" local fzf_exit=$? local selected selected=$(<"$tmpfile") rm -f "$tmpfile" # fzf exits 130 on Esc/CTRL-C, 1 when no match [[ "$fzf_exit" -ne 0 || -z "$selected" ]] && return # 4. Strip ANSI color codes before parsing file path and line number local file line file=$(echo "$selected" | sed $'s/\033\[[0-9;]*m//g' | cut -d: -f1) line=$(echo "$selected" | sed $'s/\033\[[0-9;]*m//g' | cut -d: -f2) [[ -n "$file" ]] || return # 5. Open in VSCode at the exact matching line if command -v code >/dev/null 2>&1; then code --goto "$file:$line" else printf "\033[0;33m[Warning]\033[0m 'code' (VSCode) not found. Result: %s:%s\n" "$file" "$line" fi ``` > **Why a temp file instead of $(...):** > > Running `selected=$(rg ... | fzf ...)` captures fzf's stdout and prevents it from displaying its TUI in WSL — the command just hangs. Writing fzf's output to a temp file lets it run in the foreground with full terminal access; the selection is read from the file after fzf exits. If `~/.zsh/fns` is already in your `fpath` (see the [modular ZSH workflow](/blog/modular-zsh-workflow) article), reload ZSH and run: ```bash rgf "database" ``` Navigate with `↑` / `↓`, filter by typing, press `Enter` to jump to the matching line in VSCode, `Esc` to cancel. ## Real-world scenarios Here are four situations where `rgf` becomes part of your daily workflow. ### Find all TODOs in the project ```bash rgf "TODO|FIXME|HACK" ``` rg supports regex by default. This searches for any of the three markers. Navigate through all of them, pick the one you want to tackle, and land directly in the editor. ### Track down where a function is called You renamed `sendEmail` to `sendNotification` but something still crashes. Find every call site: ```bash rgf "sendEmail" ``` Each result shows the call in context. You see immediately whether it is an import, a call, or a test — without opening files one by one. ### Locate a hardcoded value Your Docker Compose file used to have a hardcoded port. Is it still somewhere in the codebase? ```bash rgf "5432" ``` The preview shows the surrounding lines — you can tell at a glance if it is a default value, a comment, or an active connection string. ### Search within a specific folder `rgf` passes all its arguments directly to `rg`, so path scoping works as expected: ```bash rgf "useEffect" src/components/ ``` Only files under `src/components/` are searched. The same applies to file type filters: ```bash rgf -t ts "interface UserProps" ``` The `-t ts` flag tells rg to search only TypeScript files. ## Going further ### Bind rgf to a keyboard shortcut If you want `rgf` to be one keystroke away, add a ZSH keybinding. This binds `CTRL`+`F` to launch `rgf` with your current command-line content as the initial query: ```zsh # In ~/.zshrc rgf-widget() { rgf "$BUFFER"; zle reset-prompt } zle -N rgf-widget bindkey '^F' rgf-widget ``` ### Combine with the repo navigator If you have the `repo` function from the [modular ZSH workflow](/blog/modular-zsh-workflow) article, a natural workflow emerges: `repo` to jump to the project, `rgf` to find the code, `Enter` to open it. Three keystrokes to any line in any project. ### Use ripgrep standalone `rg` is also worth learning on its own — even without `fzf`. Common options you will reach for quickly: ```bash rg -l "pattern" # filenames only, no content rg -c "pattern" # count of matches per file rg --type-list # list all supported file types rg -t py "def connect" # Python files only rg -g "*.yml" "image:" # glob filter — only YAML files ``` --- # delta: a Syntax-Highlighted Pager for git diff > Stop squinting at raw git diff outputs. Learn how to install and configure Delta to add syntax highlighting and side-by-side views in under 5 minutes. --- ![delta: a Syntax-Highlighted Pager for git diff](/img/v2/git-delta.webp) **TL;DR** `git diff` output is functional but hard to read: no syntax highlighting, no side-by-side view, line numbers buried in `@@ -47,6 +47,8 @@` noise. `delta` is a drop-in pager replacement that transforms every `git diff`, `git show`, and `git log -p` into a syntax-highlighted, side-by-side view with word-level diffs — with zero changes to your git commands. You've been staring at `git diff` output for thirty seconds trying to figure out what actually changed. The plus and minus lines blur together, every file looks the same shade of red and green, and the line numbers are buried in a header that takes conscious effort to parse. `delta` fixes it in five minutes. *It joins the same family of modern rewrites as [ripgrep](/blog/ripgrep) (for `grep`) and [eza](/blog/linux-eza) (for `ls`). Outside a git repository, plain [`diff`](/blog/linux-diff-file-folder) remains the tool of choice.* ## Before and after ### A changed line without delta ```diff - const expiry = token.exp * 1000; + const expiry = token.exp * 1000 + TOKEN_GRACE_MS; ``` Everything is the same weight. You scan the entire line looking for the difference. ### The same line with delta Delta highlights `+ TOKEN_GRACE_MS` at the character level — the rest of the line stays dim. Your eye goes straight to the change, not to the unchanged context around it. This word-level diff is especially valuable when: * A variable was renamed (`getUserById` → `findUserById`) * A string had a typo fixed * A flag was added or removed from a long function call Without word-level diff, each of those scenarios produces a fully-red removed line and a fully-green added line. With delta, only the changed word is highlighted. ## What is delta? [delta](https://github.com/dandavison/delta) is a syntax-highlighting pager that sits between git and your terminal. It intercepts raw diff output and renders it with: * Syntax highlighting per language — a TypeScript change looks like TypeScript, a YAML change looks like YAML * Side-by-side view — old version on the left, new version on the right, aligned line by line * Word-level diff — highlights the exact characters that changed within a line, not the entire line * Line numbers — always visible, consistently aligned * Navigation — jump between diff hunks with `n` and `N` The key point: **you don't change a single git command**. You still type `git diff`, `git show`, `git log -p`. Delta intercepts the output automatically. ## Seeing It in Action with Docker You've just read the "before" diff above. Here's a container where delta is already installed, configured, and pointed at a real, unstaged change — so you can see the "after" without touching your own `.gitconfig`. > **Why Docker first?:** > > The Dockerfile below installs `git` and `git-delta`, writes the full `.gitconfig` block from this > article, and pre-builds a tiny repo with exactly the kind of one-word change (`+ TOKEN_GRACE_MS`) > that makes delta's word-level diff worth seeing. Nothing changes on your own machine. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive # Ubuntu's docker image only enables "main" + "restricted" by default; # git-delta lives in "universe". RUN sed -i 's/^Components: main restricted$/Components: main restricted universe multiverse/' \ /etc/apt/sources.list.d/ubuntu.sources && \ apt-get update && \ apt-get install -y --no-install-recommends \ git \ git-delta && \ rm -rf /var/lib/apt/lists/* # Configure git to use delta — the exact block from this article RUN git config --global core.pager delta && \ git config --global interactive.diffFilter "delta --color-only" && \ git config --global delta.navigate true && \ git config --global delta.side-by-side true && \ git config --global delta.line-numbers true && \ git config --global delta.syntax-theme "Dracula" && \ git config --global merge.conflictstyle diff3 && \ git config --global diff.colorMoved default && \ git config --global user.email "demo@example.com" && \ git config --global user.name "Demo" # Pre-build a repo with a real, word-level-diff-friendly change already # unstaged, so `git diff` has something worth looking at immediately RUN mkdir -p /root/demo && cd /root/demo && \ git init -q && \ printf 'function refreshToken(token) {\n const expiry = token.exp * 1000;\n return expiry;\n}\n' > auth.js && \ git add auth.js && \ git commit -q -m "Add refreshToken helper" && \ sed -i 's/const expiry = token.exp \* 1000;/const expiry = token.exp * 1000 + TOKEN_GRACE_MS;/' auth.js WORKDIR /root/demo # Signal clearly that this shell runs inside a container RUN echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> /root/.bashrc CMD ["/bin/bash", "-i"] ``` Build and run it: ```bash $ docker build -t delta-demo . [+] Building 26.7s (8/8) FINISHED ✔ exporting to image $ docker run --rm -it delta-demo 🐳 root ~/demo # git diff ``` `git diff` opens in side-by-side view, syntax-highlighted, with `TOKEN_GRACE_MS` picked out at the character level instead of the whole line turning green. Press `q` to close the pager, then try `git log -p`, `git show HEAD`, or edit `auth.js` yourself and diff again — the same config applies to every command. ## Install Prerequisite: git-delta ```bash $ sudo apt install git-delta ``` Verify: ```bash $ delta --version delta 0.18.2 ``` > **On older Ubuntu/Debian releases:** > > The package might be named `delta` rather than `git-delta`. If `apt install git-delta` fails, try `sudo apt install delta` — or install the latest binary from the [GitHub releases page](https://github.com/dandavison/delta/releases). ## Configure git to use delta Delta works as a `core.pager` replacement. Open your `~/.gitconfig` and add: ```ini [core] pager = delta [interactive] diffFilter = delta --color-only [delta] navigate = true side-by-side = true line-numbers = true syntax-theme = Dracula [merge] conflictstyle = diff3 [diff] colorMoved = default ``` Save the file. No shell restart required — the pager is invoked per-command. Let's look at what each option does. **Configuration reference** * **`core.pager = delta`** — tells git to pipe all paginated output through delta instead of `less`. This covers `git diff`, `git show`, `git log -p`, `git stash show -p`, and more. * **`interactive.diffFilter = delta --color-only`** — applies delta's color rendering inside interactive commands like `git add -p`, without breaking the input handling. * **`navigate = true`** — press `n` to jump to the next changed file, `N` to go back. Essential when reviewing a diff with many files. * **`side-by-side = true`** — displays the old and new versions in two columns. The single biggest readability improvement. * **`line-numbers = true`** — shows line numbers in a dedicated column on each side. No more parsing `@@ -47,6 +47,8 @@` headers. * **`syntax-theme = Dracula`** — sets the color theme for syntax highlighting. Change this to match your terminal's theme. Run `delta --list-syntax-themes` to see all options. * **`merge.conflictstyle = diff3`** — when combined with delta, shows all three versions during a merge conflict (ours, theirs, and the common ancestor). Much easier to resolve. ## Side-by-side mode With `side-by-side = true`, delta splits each changed file into two columns: * **Left** — the old version, with removed lines in red * **Right** — the new version, with added lines in green * **Both** — unchanged context lines are shown on both sides, aligned For a renamed function called in ten places, you no longer read two interleaved streams of red and green. You read the before on the left and the after on the right, like a code review tool. If your terminal is narrow and the two columns become cramped, you can toggle off side-by-side for a single command: ```bash git -c delta.side-by-side=false diff ``` Or add a short alias to `.gitconfig`: ```ini [alias] dw = -c delta.side-by-side=false diff ``` `git dw` gives you single-column mode; `git diff` keeps the default. ## Themes Delta uses the same theme engine as `bat` and `syntect`. To list all available themes: ```bash $ delta --list-syntax-themes ``` To preview a theme without changing your config: ```bash $ git diff | delta --syntax-theme=gruvbox-dark ``` Good starting points: **Theme picks by terminal background** * **Dark background** — `Dracula`, `gruvbox-dark`, `TwoDark`, `Nord` * **Light background** — `GitHub`, `Monokai Extended Light`, `OneHalfLight` ## Navigation between hunks With `navigate = true`, delta adds keyboard navigation inside the pager: **Navigation keys** * **n** — Jump to the next changed file * **N** — Jump to the previous changed file * **q** — Quit * **Space** — Page down This is most useful with `git log -p` or `git diff HEAD~10` where the diff spans many files. ## Delta works everywhere git does Once `core.pager = delta` is set, delta applies to every git command that pages output: ```bash git show HEAD # last commit, with highlighting git log -p # full history with diffs git stash show -p # what's in a stash entry git diff HEAD~3 # compare to 3 commits ago git diff main...feature # compare branches ``` No extra flags. No wrapper functions. It just works. ## Going further If you also use [`git diff` aliases in your `.gitconfig`](/blog/git-config), those aliases automatically benefit from delta since the pager is applied at the output stage, not the command stage. For an even richer TUI git experience, delta works as the diff renderer inside [lazygit](https://github.com/jesseduffield/lazygit) and [tig](https://github.com/jonas/tig) — both TUI git clients that respect `core.pager`. ## Conclusion Thirty seconds of squinting at a raw `git diff` was the whole problem — no syntax highlighting, no side-by-side, line numbers buried in `@@` noise. `delta` fixes all three with an `apt install` and a handful of `.gitconfig` lines, and every git command you already know keeps working exactly as typed. If you also work through history with aliases, [configuring `git diff` aliases in `.gitconfig`](/blog/git-config) is the natural next step — they inherit delta's rendering for free. --- # ripgrep — The Search Tool That Changed My WSL2 Workflow > Discover ripgrep, the blazingly fast grep alternative written in Rust. Installation on Ubuntu/WSL2, ZSH configuration, practical shell functions, and real-world use cases that will transform how you search code every day. --- ![ripgrep — The Search Tool That Changed My WSL2 Workflow](/img/v2/ripgrep.webp) **TL;DR** ripgrep (`rg`) is a line-oriented search tool written in Rust that dramatically outperforms traditional grep — especially on large codebases. It respects `.gitignore` by default, skips binary files, and handles Unicode natively. This article covers installation on Ubuntu/WSL2, a `~/.ripgreprc` configuration file, ZSH aliases and functions for your `~/.zshrc` and `~/.zsh/fns/` directory, and real-world use cases that will make you wonder how you ever lived without it. Damn. I have typed that command so many times I could do it in my sleep: `grep -rn --include="*.php" "getUser" . | grep -v vendor`. It works — grep is a classic, it gets the job done. But over the years I have accumulated so many flags, so many aliases, so many muscle-memory workarounds for grep's quirks... At some point, you have to ask yourself: is there something better out there? > **What does that command actually do?:** > > `grep -rn --include="*.php" "getUser" .` breaks down as: `-r` searches recursively through all subdirectories, `-n` prints the line number of each match, `--include="*.php"` restricts the search to `.php` files only, `"getUser"` is the pattern to find, and `.` means start from the current directory. The trailing `| grep -v vendor` pipes the results through a second grep that filters OUT (`-v`) any line containing `vendor` — a common workaround to exclude the `vendor/` dependencies folder that grep has no built-in way to skip. There is. It is called [ripgrep](https://github.com/BurntSushi/ripgrep), and once you start using it, there is no going back. *It belongs to the same family of modern rewrites as [eza](/blog/linux-eza) (for `ls`) and [delta](/blog/git-delta) (for `git diff`).* ## ripgrep vs grep — A Side-by-Side Look Let me show you why ripgrep clicks so immediately. Here are the same searches expressed in both tools: **With grep:** ```bash grep -rn --include="*.php" "getUser" . \ | grep -v vendor ``` **With ripgrep:** ```bash rg "getUser" --type php ``` **With grep:** ```bash grep -rn -i "TODO" . \ --include="*.py" \ --include="*.pyi" \ --exclude-dir=.venv \ --exclude-dir=dist ``` **With ripgrep:** ```bash rg -i "TODO" --type py ``` **With grep:** ```bash grep -rn "DB_PASSWORD" . \ --include="*.env" \ --include="*.yml" \ --include="*.yaml" ``` **With ripgrep:** ```bash rg "DB_PASSWORD" -t yaml -t sh ``` For sure, grep's flags are powerful once you know them by heart. But ripgrep's syntax is just cleaner — and by default it already ignores `vendor/`, `node_modules/`, and everything in your `.gitignore`, without any extra flags. ## What is ripgrep? ripgrep (`rg` on the command line) is a line-oriented search tool built in Rust by Andrew Gallant (BurntSushi). Speed is its headline feature — benchmarks consistently show it outperforming grep, ag (the Silver Searcher), and ack by a wide margin on large codebases — and [hyperfine](/blog/hyperfine) lets you check that claim on your own machine. But raw speed is only part of the story. What makes ripgrep genuinely better for day-to-day use is its **smart defaults**: * It automatically respects `.gitignore`, `.ignore`, and `.rgignore` files — so `vendor/`, `node_modules/`, `dist/`, and `.git/` are skipped without any `--exclude-dir` gymnastics. * It skips binary files by default. * It colors and groups output by filename out of the box. * It uses Unicode by default — no more garbled output on accented filenames or content. * It supports modern regex syntax, including PCRE2 with the `-P` flag. * It searches hidden files only when you explicitly ask for it (`--hidden`). That last output behavior is underrated. Readable, grouped, colorized results — right away, no configuration required. So cool, no? ## Seeing It in Action with Docker Before touching your own `~/.zshrc`, try ripgrep in a throwaway container that already ships the `~/.ripgreprc` config, the `rgtodo`/`rgenv` functions, and a small fake project built specifically to show off the smart defaults: a `vendor/` folder that must stay invisible, TODO/FIXME comments, a leaking `DB_PASSWORD`, and a `console.log(token)` call worth flagging. > **Why Docker first?:** > > The Dockerfile below reproduces the exact `.ripgreprc`, `.zsh/fns/` functions, and `.zshrc` section > from this article, then builds the demo project. Nothing changes on your own machine. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install -y --no-install-recommends \ zsh \ ripgrep \ git \ ca-certificates && \ rm -rf /var/lib/apt/lists/* # ~/.ripgreprc — the exact config file from this article RUN printf '%s\n' \ '--colors=match:fg:red' \ '--colors=match:style:bold' \ '--colors=path:fg:blue' \ '--follow' \ '--glob=!.cache/*' \ '--glob=!.git/*' \ '--glob=!.output/*' \ '--glob=!.yarn/*' \ '--glob=!build/*' \ '--glob=!dist/*' \ '--glob=!node_modules/*' \ '--glob=!vendor/*' \ '--hidden' \ '--max-filesize=10M' \ '--smart-case' \ > /root/.ripgreprc # ~/.zsh/fns/ — the two custom functions from this article RUN mkdir -p /root/.zsh/fns RUN printf '%s\n' \ '# rgtodo — surface all TODO/FIXME/HACK/NOTE comments in the project' \ 'rgtodo() {' \ ' local dir="${1:-.}"' \ ' rg --color=always --line-number --smart-case "(TODO|FIXME|HACK|NOTE):?\s" "${dir}" | sort' \ '}' \ > /root/.zsh/fns/rgtodo.zsh RUN printf '%s\n' \ '# rgenv — find all references to an environment variable' \ 'rgenv() {' \ ' local var="${1}"' \ ' [[ -z "${var}" ]] && { echo "Usage: rgenv "; return 1; }' \ " rg --type-add 'config:*.{env,cfg,ini,conf}' --type config --type yaml --type sh --type php --type py \"\${var}\"" \ '}' \ > /root/.zsh/fns/rgenv.zsh # ~/.zshrc — the exact ripgrep section from this article RUN printf '%s\n' \ 'export RIPGREP_CONFIG_PATH="$HOME/.ripgreprc"' \ 'for fn_file in ~/.zsh/fns/*.zsh; do source "${fn_file}"; done' \ 'alias rgp="rg --type php"' \ 'alias rgj="rg --type js"' \ 'alias rgy="rg --type yaml"' \ 'alias rgd="rg --glob \"Dockerfile*\" --glob \"*.dockerfile\""' \ >> /root/.zshrc # Build a small demo project: a `vendor/` folder that must stay invisible, a # PHP controller worth finding, TODO/FIXME comments, a leaking JWT in a JS # file, and a compose file with image references RUN mkdir -p /root/demo/vendor/legacy /root/demo/src && \ printf ' /root/demo/vendor/legacy/User.php && \ printf ' /root/demo/src/UserController.php && \ printf ' /root/demo/src/legacy.php && \ printf 'DB_PASSWORD=demo-secret\n' > /root/demo/.env && \ printf 'services:\n app:\n image: php:8.3-fpm\n db:\n image: postgres:16\n' \ > /root/demo/compose.yml && \ printf 'function refreshToken(token) {\n console.log(token);\n}\n\nfunction checkout(cart) {\n for (const item of cart) {\n console.log(item);\n }\n}\n' \ > /root/demo/src/app.js && \ printf 'FROM php:8.3-fpm\nCOPY . /app\n' > /root/demo/Dockerfile.app && \ printf 'vendor/\n' > /root/demo/.gitignore && \ git -C /root/demo init -q && \ git -C /root/demo -c user.email=demo@example.com -c user.name=Demo \ add -A && \ git -C /root/demo -c user.email=demo@example.com -c user.name=Demo \ commit -q -m "Demo project" WORKDIR /root/demo # Signal clearly that this shell runs inside a container RUN echo "PROMPT='%B%F{yellow}🐳 %F{cyan}%n%f%b %~ %# '" >> /root/.zshrc CMD ["/bin/zsh", "-i", "-l"] ``` Build and run it: ```bash $ docker build -t ripgrep-demo . [+] Building 21.3s (10/10) FINISHED ✔ exporting to image $ docker run --rm -it ripgrep-demo 🐳 root ~/demo # ``` Now play with the exact scenarios described above, live: ```bash $ rg "getUser" src/UserController.php 2:function getUser($id) { src/legacy.php 4:function getAllUsers() { $ rgtodo src/legacy.php:2:// TODO: cache this lookup src/legacy.php:3:// FIXME: N+1 query below $ rgenv DB_PASSWORD .env 1:DB_PASSWORD=demo-secret $ rg -C 2 "console.log" src/app.js 2-function refreshToken(token) { 3: console.log(token); 4-} 7- for (const item of cart) { 8: console.log(item); 9- } ``` Notice `vendor/legacy/User.php` — which also defines `getUser`, on purpose — never shows up in any of these searches: the `.gitignore` and the `--glob=!vendor/*` config entry both exclude it, exactly as described above. Try `rg --no-ignore "getUser"` to see it reappear. ## Installation on Ubuntu / WSL2 ripgrep ships in Ubuntu's default package repositories, so getting it is a one-liner: Prerequisite: ripgrep ```bash $ sudo apt update && sudo apt install ripgrep -y Reading package lists... Done Building dependency tree... Done 0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded. ``` Verify: ```bash $ rg --version ripgrep 14.1.0 -rev 0 (rev 4649aa9700 2024-03-30) features: +SIMD +AVX (compiled), +SIMD +AVX (runtime) ``` > **Want the latest version?:** > > The Ubuntu repositories sometimes lag a few releases behind upstream ripgrep. For the most recent version, grab the latest `.deb` package directly from the [GitHub releases page](https://github.com/BurntSushi/ripgrep/releases) and install it with `sudo dpkg -i ripgrep_*.deb`. From version 14 onwards you also benefit from improved PCRE2 support and faster directory traversal. ## Configuration — \~/.ripgreprc Like most Unix tools, ripgrep can be configured via a dedicated file. You point to it with the `RIPGREP_CONFIG_PATH` environment variable. Here an example of a `~/.ripgreprc` file: **\~/.ripgreprc** ``` --colors=match:fg:red --colors=match:style:bold --colors=path:fg:blue --follow --glob=!.cache/* --glob=!.git/* --glob=!.output/* --glob=!.yarn/* --glob=!build/* --glob=!dist/* --glob=!node_modules/* --glob=!vendor/* --hidden --max-filesize=10M --smart-case ``` Each line is a default flag applied to every `rg` invocation. A few highlights worth explaining: * `--hidden` makes ripgrep search inside hidden files and folders (anything prefixed with a dot). Combined with the `--glob=!.git/*` exclusion, this means you can search `.env` files, `.zshrc`, `.gitignore`, and similar without opening the entire `.git/` internals. * `--max-filesize=10M` silently skips very large files (log dumps, generated assets) that would otherwise flood your results. * `--smart-case` is probably the most impactful setting: searches are case-insensitive by default, but the moment your pattern contains an uppercase letter ripgrep switches to exact case matching — exactly the behavior I want 95% of the time. ## Shell Setup — \~/.zshrc or \~/.bashrc With the config file in place, I add the environment variable, the function loader, and a handful of type-specific aliases to my shell's rc file. The content is the same for zsh and bash, except for the loader loop: **\~/.zshrc (ripgrep section)** ```bash export RIPGREP_CONFIG_PATH="$HOME/.ripgreprc" # If you don't already have such loop... # Load custom ZSH functions from ~/.zsh/fns/ for fn_file in ~/.zsh/fns/*.zsh; do source "${fn_file}" done # Type-specific shortcuts alias rgp='rg --type php' alias rgj='rg --type js' alias rgt='rg --type ts' alias rgm='rg --type md' alias rgy='rg --type yaml' alias rgb='rg --type sh' alias rgd='rg --glob "Dockerfile*" --glob "*.dockerfile"' ``` **\~/.bashrc (ripgrep section)** ```bash export RIPGREP_CONFIG_PATH="$HOME/.ripgreprc" # If you don't already have such loop... # Load custom bash functions from ~/.bash/fns/ for fn_file in ~/.bash/fns/*.sh; do [[ -r "${fn_file}" ]] && source "${fn_file}" done # Type-specific shortcuts alias rgp='rg --type php' alias rgj='rg --type js' alias rgt='rg --type ts' alias rgm='rg --type md' alias rgy='rg --type yaml' alias rgb='rg --type sh' alias rgd='rg --glob "Dockerfile*" --glob "*.dockerfile"' ``` The `-r` test keeps an empty `~/.bash/fns/` folder from printing an error at every shell start. The `for fn_file` loop sources every function file in that directory automatically — so adding a new function is as simple as dropping a new file there, no need to touch your rc file again. The type-specific aliases (`rgp` for PHP, `rgt` for TypeScript, `rgm` for Markdown, and so on) save keystrokes when you know exactly what kind of file you are looking in. I reach for `rgp "getUser"` or `rgm "## Installation"` dozens of times per day. ## Shell Functions — \~/.zsh/fns/ or \~/.bash/fns/ Now this is where it gets fun. Here are three functions I have installed in my `~/.zsh/fns/` directory, each solving a real problem. ### rgf — Interactive Search with fzf I covered this combination in detail in a dedicated article: [FZF + ripgrep: Interactive Code Search with Live Preview](/blog/fzf-ripgrep). The short version: pipe `rg` into `fzf` with a `bat` preview panel and you get an interactive fuzzy finder that searches file *content*, previews matches with syntax highlighting, and opens your editor at the exact matching line. If you have not read that article yet, it is worth the detour. ### rgtodo — Surface All Technical Debt at Once Every codebase has them lurking in comments: `TODO`, `FIXME`, `HACK`, `NOTE`, `XXX`. This function brings them all to the surface: **rgtodo.sh** ```bash # rgtodo — surface all TODO/FIXME/HACK/NOTE comments in the project rgtodo() { local dir="${1:-.}" rg \ --color=always \ --line-number \ --smart-case \ '(TODO|FIXME|HACK|NOTE):?\s' \ "${dir}" \ | sort } ``` Run `rgtodo` at the root of any project and you get a sorted, colorized list of every comment marker. Run `rgtodo src/` to narrow it to a specific folder. Eye-opening on codebases that have been around for a while. ```bash $ rgtodo src/ src/Api/UserController.php:47: // TODO: replace with a proper permission check src/Api/UserController.php:112: // FIXME: this bypasses rate limiting on retry src/Services/MailService.php:23: // HACK: temporary workaround for #4521 ``` ### rgenv — Track Down Environment Variable Usage In Docker-heavy projects, environment variables end up scattered across `.env` files, `compose.yml`, PHP bootstraps, and Python configs. This function searches all of them in one shot: **rgenv.sh** ```bash # rgenv — find all references to an environment variable across config and source files rgenv() { local var="${1}" if [[ -z "${var}" ]]; then echo "Usage: rgenv " return 1 fi rg \ --type-add 'config:*.{env,cfg,ini,conf}' \ --type config \ --type yaml \ --type sh \ --type php \ --type py \ "${var}" } ``` `rgenv DB_PASSWORD` instantly shows every file that references that variable — whether it is defining it, consuming it, or passing it as a Docker secret. Invaluable when debugging configuration drift in complex multi-container setups. ```bash $ rgenv DB_PASSWORD .env:3:DB_PASSWORD=supersecret compose.yml:18: - DB_PASSWORD=${DB_PASSWORD} src/Config/database.php:14: 'password' => env('DB_PASSWORD'), ``` ### Installing rgtodo and rgenv Both functions are plain shell code: the same files work in zsh and bash, only the folder differs. The loader loop is already in your rc file since the previous section. **1. Create the files** in `~/.zsh/fns/`: **Project: \~/.zsh/fns** **rgtodo.zsh** ```bash # rgtodo — surface all TODO/FIXME/HACK/NOTE comments in the project rgtodo() { local dir="${1:-.}" rg \ --color=always \ --line-number \ --smart-case \ '(TODO|FIXME|HACK|NOTE):?\s' \ "${dir}" \ | sort } ``` **rgenv.zsh** ```bash # rgenv — find all references to an environment variable across config and source files rgenv() { local var="${1}" if [[ -z "${var}" ]]; then echo "Usage: rgenv " return 1 fi rg \ --type-add 'config:*.{env,cfg,ini,conf}' \ --type config \ --type yaml \ --type sh \ --type php \ --type py \ "${var}" } ``` **2. Open a new terminal** (or run `exec zsh`). **1. Create the files** in `~/.bash/fns/`: **Project: \~/.bash/fns** **rgtodo.sh** ```bash # rgtodo — surface all TODO/FIXME/HACK/NOTE comments in the project rgtodo() { local dir="${1:-.}" rg \ --color=always \ --line-number \ --smart-case \ '(TODO|FIXME|HACK|NOTE):?\s' \ "${dir}" \ | sort } ``` **rgenv.sh** ```bash # rgenv — find all references to an environment variable across config and source files rgenv() { local var="${1}" if [[ -z "${var}" ]]; then echo "Usage: rgenv " return 1 fi rg \ --type-add 'config:*.{env,cfg,ini,conf}' \ --type config \ --type yaml \ --type sh \ --type php \ --type py \ "${var}" } ``` **2. Open a new terminal** (or run `exec bash`). Check that the shell knows them: `type rgtodo rgenv` must answer `… is a shell function` twice. ## Real-World Use Cases Let me walk through the searches I run most often in my day-to-day WSL2 workflow. ### Finding a Function Definition Across a PHP Project ```bash rg "function getUser" --type php ``` Without ripgrep: add `--include="*.php"`, `--exclude-dir=vendor`, `-rn`. With ripgrep: just type it. The `vendor/` directory is already excluded because it is in `.gitignore`. ### Searching All Docker Compose Files for an Image Reference ```bash rgy "image:" ``` That is our `rgy` alias (`rg --type yaml`). Useful when auditing a repository for outdated image references or finding all services that share a base image. ### Context Lines — Understanding Code Around a Match The `-C N` flag shows N lines of context around each match — a feature I use constantly: ```bash $ rg "DB_HOST" -C 3 --type php src/Config/Database.php 15- 'driver' => 'mysql', 16: 'host' => env('DB_HOST', '127.0.0.1'), 17- 'database' => env('DB_DATABASE', 'forge'), 18- 'username' => env('DB_USERNAME', 'forge'), 19- 'password' => env('DB_PASSWORD', ''), ``` You can also use `-A N` (after) and `-B N` (before) separately, exactly like with grep. A more common scenario: a teammate leaves `console.log()` calls in JavaScript before pushing to production. Without context, you only see the line itself — you know it exists, but not how serious it is. With `-C 2`: ```bash $ rg "console\.log" -C 2 --type js src/auth/login.js 40- const authToken = generateToken(user) 41- const refreshToken = generateRefresh(user) 42: console.log("token:", authToken) 43- return { authToken, refreshToken } src/payment/checkout.js 85- for (const item of cartItems) { 86- const price = calculatePrice(item) 87: console.log("debug") 88- total += price ``` Now you can tell the difference. The first match logs a JWT token in plain text — that is a security issue, not just noise. The second sits inside a `for` loop over a cart: one checkout call would flood the console with hundreds of lines. The context turns a list of matches into actionable information. ### Counting Occurrences Per File ```bash rg -c "console.log" --type js ``` This outputs the match count per file — perfect for identifying which source files still have debug statements before a release. ### Bulk Rename with rg + sed ripgrep is intentionally read-only (no `-i` in-place mode), but it pairs cleanly with `sed` for bulk refactoring: ```bash $ rg -l "oldFunctionName" --type php | xargs sed -i 's/oldFunctionName/newFunctionName/g' ``` `rg -l` lists only the filenames with matches. Piping to `xargs sed -i` then rewrites exactly those files — nothing more, nothing less. Much safer than a raw `find | xargs sed` that has no idea which files actually match. ### Searching Multiple File Types at Once ```bash rg "API_KEY" -t php -t yaml -t sh ``` Multiple `-t` flags work together — ripgrep searches all matching types in a single pass, with unified colored output. ### Limiting Search Depth ```bash rg "FROM" --glob "Dockerfile*" --max-depth 3 ``` `--max-depth` is great when you want to stay close to the current directory without diving into every nested subdirectory. ## Key Takeaways **ripgrep quick reference** * **Install** — `sudo apt install ripgrep` on Ubuntu/WSL2 * **Configure** — create `~/.ripgreprc` and point to it via `RIPGREP_CONFIG_PATH` in `~/.zshrc` * **Type flags** — `-t php` instead of `--include="*.php"` — shorter and smarter * **Smart case** — `--smart-case` gives you case-insensitive searches that auto-switch to exact when you use uppercase * **gitignore-aware** — `vendor/`, `node_modules/`, `.git/` are excluded automatically; no flags needed * **Context lines** — `-C 3` shows 3 lines above and below each match * **File list mode** — `rg -l` returns filenames only; pipe to `xargs` for bulk operations * **Interactive mode** — `rgf "pattern"` combines ripgrep with fzf for live-preview search; full setup in [FZF + ripgrep](/blog/fzf-ripgrep) ## Conclusion At the beginning of this article, I mentioned typing that `grep -rn --include="*.php" ... | grep -v vendor` command so many times it became muscle memory. Now, every time I open a terminal in WSL2, I reach for `rg` without even thinking. The `vendor/` exclusion is automatic, the output is readable without any flags, and the search is genuinely faster on large codebases. Between the `~/.ripgreprc` configuration, the type aliases in `~/.zshrc`, functions like `rgtodo` and `rgenv` in `~/.zsh/fns/`, and the `rgf` interactive search covered in [a dedicated article](/blog/fzf-ripgrep), ripgrep has stopped being a tool I consciously choose and started being the invisible backbone of how I navigate code every day. If you are still on bare grep in your WSL2 environment, give yourself twenty minutes with ripgrep — that is all it takes. --- # yq — The YAML Processor You Already Know How to Use > yq is to YAML what jq is to JSON. Query and edit your Docker Compose files, CI configs, and any YAML document from the command line — no installation required beyond Docker. --- ![yq — The YAML Processor You Already Know How to Use](/img/v2/linux-yq.webp) **TL;DR** `yq` is a command-line YAML processor with the same syntax as `jq`. If you already use `jq` for JSON, you already know how to use `yq`. Run it without installing anything using a one-line Docker alias, then query and edit your `compose.yaml`, CI pipelines, and any YAML file directly from the terminal. You have a `compose.yaml` with ten services. You want to know what image the `db` service uses without opening an editor. You want to bump a version number across a CI file without a sed one-liner that you'll never remember. You want to extract all service names into a list. These are `jq` problems. But your files are YAML, not JSON. Meet `yq`. ## What yq is — and which one There are two tools named `yq`. This article covers [Mike Farah's `yq`](https://github.com/mikefarah/yq) (Go, Docker image `mikefarah/yq`), which has native YAML support and a `jq`-compatible syntax. The other one (`kislyuk/yq`) wraps `jq` after converting YAML to JSON — similar idea, different behavior for YAML-specific features like comments and multi-document files. On Debian and Ubuntu, `apt install yq` installs that other one (`kislyuk/yq` 3.x), not the tool used here. If you have the Go version installed locally (`yq --version` returns something like `yq (https://github.com/mikefarah/yq/) version v4.x`), you're all set. Otherwise, the Docker approach below requires nothing extra. ## Seeing It in Action with Docker Before installing anything (or even setting up the alias below), try `yq` in a throwaway container — the exact `compose.yaml` from this article is already there. > **Why Docker first?:** > > The Dockerfile below installs the `yq` binary directly and drops the exact `compose.yaml` in the > working directory. Nothing to create, paste, or download. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install -y \ curl \ ca-certificates && \ rm -rf /var/lib/apt/lists/* # Install the yq static binary in a plain Ubuntu image, so the demo opens a # bash shell with yq on the PATH. TARGETARCH is set by BuildKit (amd64 or # arm64, Apple Silicon included). Pin the version so this image keeps # building the same way; bump it during a /freshness pass. ARG TARGETARCH=amd64 RUN curl -fsSL -o /usr/local/bin/yq \ "https://github.com/mikefarah/yq/releases/download/v4.54.1/yq_linux_${TARGETARCH}" && \ chmod +x /usr/local/bin/yq # The exact compose.yaml used throughout this article RUN mkdir -p /root/demo RUN printf '%s\n' \ 'services:' \ ' web:' \ ' image: nginx:1.25' \ ' ports:' \ ' - "8080:80"' \ ' environment:' \ ' APP_ENV: production' \ ' LOG_LEVEL: info' \ ' volumes:' \ ' - ./html:/usr/share/nginx/html:ro' \ ' depends_on:' \ ' - db' \ '' \ ' db:' \ ' image: postgres:16' \ ' environment:' \ ' POSTGRES_USER: myapp' \ ' POSTGRES_PASSWORD: s3cr3t' \ ' POSTGRES_DB: myapp_db' \ ' volumes:' \ ' - db_data:/var/lib/postgresql/data' \ '' \ 'volumes:' \ ' db_data:' \ > /root/demo/compose.yaml WORKDIR /root/demo # Signal clearly that this shell runs inside a container RUN echo "PS1='\n\e[0;33m🐳 \e[0;36m\$(whoami)\e[0m \w # '" >> /root/.bashrc CMD ["/bin/bash", "-i"] ``` Build and run it: ```bash $ docker build -t yq-demo . [+] Building 8.7s (7/7) FINISHED ✔ exporting to image $ docker run --rm -it yq-demo 🐳 root ~/demo # yq '.services.web.image' compose.yaml nginx:1.25 ``` Same file, same query, same result — try the rest of the "Reading values" and "Editing in place" sections below directly against it, no setup left to do. ## Install — or just alias A package manager installs the Go version on every platform: ```bash $ brew install yq # macOS and Linux (Homebrew) $ winget install MikeFarah.yq # Windows ``` No installation at all is also an option: a small `yq` function runs the official image instead, in its own file in a folder your shell loads at startup (the organisation explained in [my modular ZSH workflow](/blog/modular-zsh-workflow)): **yq.sh** ```bash # yq: run mikefarah/yq through Docker on the files of the current folder, no # local install. Works in zsh and bash: save it as ~/.zsh/fns/yq.zsh or # ~/.bash/fns/yq.sh (see https://www.avonture.be/blog/linux-yq). # # Runs with your own user ID, so files edited in place with -i keep their owner. # The image is pulled automatically the first time. yq() { if ! command -v docker >/dev/null 2>&1; then printf '\e[31mError: docker is not installed or not in your PATH.\e[0m\n' >&2 return 1 fi docker run --rm -i \ -u "$(id -u):$(id -g)" \ -v "${PWD}:/workdir" \ mikefarah/yq:4 "$@" } ``` It mounts your current directory into the container and runs `yq` transparently, with your own user ID so that files edited with `-i` keep their owner. Every `yq` command below works with it exactly as written. The code is the same for zsh and bash; only the folder and the loader differ. **1. Create the file** in `~/.zsh/fns/`: **Project: \~/.zsh/fns** **yq.zsh** ```bash # yq: run mikefarah/yq through Docker on the files of the current folder, no # local install. Works in zsh and bash: save it as ~/.zsh/fns/yq.zsh or # ~/.bash/fns/yq.sh (see https://www.avonture.be/blog/linux-yq). # # Runs with your own user ID, so files edited in place with -i keep their owner. # The image is pulled automatically the first time. yq() { if ! command -v docker >/dev/null 2>&1; then printf '\e[31mError: docker is not installed or not in your PATH.\e[0m\n' >&2 return 1 fi docker run --rm -i \ -u "$(id -u):$(id -g)" \ -v "${PWD}:/workdir" \ mikefarah/yq:4 "$@" } ``` **2. Load the folder from `~/.zshrc`.** Already done if you followed one of my ["Ollama daily use"](/series/ollama-daily-use) articles. Otherwise, add at the end of `~/.zshrc`: ```zsh title="~/.zshrc" for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` **3. Open a new terminal** (or run `exec zsh`). **1. Create the file** in `~/.bash/fns/`: **Project: \~/.bash/fns** **yq.sh** ```bash # yq: run mikefarah/yq through Docker on the files of the current folder, no # local install. Works in zsh and bash: save it as ~/.zsh/fns/yq.zsh or # ~/.bash/fns/yq.sh (see https://www.avonture.be/blog/linux-yq). # # Runs with your own user ID, so files edited in place with -i keep their owner. # The image is pulled automatically the first time. yq() { if ! command -v docker >/dev/null 2>&1; then printf '\e[31mError: docker is not installed or not in your PATH.\e[0m\n' >&2 return 1 fi docker run --rm -i \ -u "$(id -u):$(id -g)" \ -v "${PWD}:/workdir" \ mikefarah/yq:4 "$@" } ``` **2. Load the folder from `~/.bashrc`.** Add at the end of `~/.bashrc` (the `-r` test keeps an empty folder from printing an error): ```bash title="~/.bashrc" for fn_file in ~/.bash/fns/*.sh; do [[ -r "$fn_file" ]] && source "$fn_file" done ``` **3. Open a new terminal** (or run `exec bash`). Check that the shell knows the function: `type yq` must answer `yq is a shell function`. The [releases page](https://github.com/mikefarah/yq/releases) also provides static binaries for Linux, macOS, and Windows — no runtime dependencies. ## Reading values Let's work with a typical `compose.yaml`: ```yaml services: web: image: nginx:1.25 ports: - "8080:80" environment: APP_ENV: production LOG_LEVEL: info volumes: - ./html:/usr/share/nginx/html:ro depends_on: - db db: image: postgres:16 environment: POSTGRES_USER: myapp POSTGRES_PASSWORD: s3cr3t POSTGRES_DB: myapp_db volumes: - db_data:/var/lib/postgresql/data volumes: db_data: ``` Read a single value: ```bash yq '.services.web.image' compose.yaml ``` ```bash nginx:1.25 ``` Read a nested value: ```bash yq '.services.db.environment.POSTGRES_DB' compose.yaml ``` ```bash myapp_db ``` List all service names: ```bash yq '.services | keys' compose.yaml ``` ```bash - web - db ``` Get all images across services: ```bash yq '.services.*.image' compose.yaml ``` ```bash nginx:1.25 postgres:16 ``` ## Filtering and selecting Get only services that have a `ports` key defined: ```bash yq '.services | with_entries(select(.value | has("ports")))' compose.yaml ``` ```yaml web: image: nginx:1.25 ports: - "8080:80" ... ``` List all environment variable names for the `web` service: ```bash yq '.services.web.environment | keys' compose.yaml ``` ```bash - APP_ENV - LOG_LEVEL ``` ## Editing in place Update the `web` image version. The `-i` flag edits the file in place, just like [`sed -i`](/blog/update-env-files-cli): ```bash yq -i '.services.web.image = "nginx:1.27"' compose.yaml ``` Add a new environment variable: ```bash yq -i '.services.web.environment.DEBUG = "false"' compose.yaml ``` Delete a key: ```bash yq -i 'del(.services.db.environment.POSTGRES_PASSWORD)' compose.yaml ``` > **yq -i reformats the file:** > > `yq -i` keeps comments and quotes, but removes blank lines — the empty line between `web` and `db` is gone after the first edit. Check the diff before committing a hand-formatted file. ## Reading from stdin and piping `yq` reads from stdin when no file is given, making it easy to chain with other commands: ```bash cat compose.yaml | yq '.services | keys' ``` Combine with `curl` to parse YAML APIs or configs downloaded on the fly: ```bash curl -s https://example.com/config.yaml | yq '.database.host' ``` ## Practical use cases ### Extract all port mappings ```bash yq '.services.*.ports[]' compose.yaml ``` ```bash 8080:80 ``` ### Check if a key exists before using it ```bash yq '.services | has("redis")' compose.yaml ``` ```bash false ``` ### Update a version across a CI file Given a `.gitlab-ci.yml` that references an image version in multiple places, `..` walks every node of the file, whatever its depth: ```bash yq -i '(.. | select(. == "node:20")) = "node:22"' .gitlab-ci.yml ``` ### Merge two YAML files ```bash yq '. *= load("overrides.yaml")' base.yaml ``` This deep-merges `overrides.yaml` into `base.yaml` and prints the result — the source files are unchanged unless you add `-i`. ### Convert YAML to JSON When you need to pass YAML data to a tool that only accepts JSON: ```bash yq -o json '.services.web' compose.yaml ``` ```json { "image": "nginx:1.25", "ports": [ "8080:80" ], "environment": { "APP_ENV": "production", "LOG_LEVEL": "info" }, "volumes": [ "./html:/usr/share/nginx/html:ro" ], "depends_on": [ "db" ] } ``` You can then pipe that output directly to `jq` for further processing. ## The jq crossover If you already use [`jq`](/blog/linux-jq) for JSON, the `yq` syntax will feel immediately familiar. The everyday `jq` expressions work as-is: | Task | jq (JSON) | yq (YAML) | | -------------- | ----------------------------- | ----------------------------- | | Read key | `jq '.key'` | `yq '.key'` | | Filter array | `jq '.[] \| select(.active)'` | `yq '.[] \| select(.active)'` | | Get keys | `jq 'keys'` | `yq 'keys'` | | Edit in place | — | `yq -i '.key = "value"'` | | Convert format | — | `yq -o json '.'` | The main differences: `yq` adds `-i` for in-place edits and `-o` for output format, and handles YAML-native features like anchors and multi-document files (`---` separators). ## Conclusion `yq` fills the gap that every `jq` user eventually feels: the same tool, but for YAML. With a Docker alias, there's nothing to install, and the syntax transfers directly from everything you already know. If your workflow involves `compose.yaml`, GitLab CI, GitHub Actions, or Kubernetes manifests, `yq` belongs in your toolkit alongside `jq` and its XML counterpart, [`xmlstarlet`](/blog/linux-xmlstarlet). The next time you reach for a text editor to check an image version or update a config key, reach for `yq` instead. ---