FZF is fantastic!
Well, of course it is. But it continues to amaze me how much mileage one can get from such a simple idea: a list selector that consumes stdin and prints to stdout.
At some point I hacked together an FZF-based workflow to work with kubectl
. In retrospect, kubectl-fzf might have done the job. But my workflow ended up very specific and ergonomic.
The setup
Let's not use the actual kubectl
(not that easy to quickly set up a meaningful playground). Instead let's assume a hypothetical setup of virtual machines that can be queried via a set of APIs. Our task is to log into one machine by executing a complex BASH command, say:
ssh -o ForwardAgent cluster-machine-name \ secrets-manager run \ bundle exec rails console
To construct such a command we need to:
- Pick the name of the cluster (
cluster
). - Pick a machine name from that cluster (
cluster-machine-name
). - Choose a command to run (
bundle exec rails console
).
Let's use BASH functions in place of the real API calls. It is probably a good idea to wrap the API calls with functions anyway.
list_clusters() { echo "north" echo "west" echo "south" echo "east" } list_machines_for_cluster() { for service in Rails Postgres Redis Nginx; do # The cluster name is part of the machine name. printf "$1-" cat /dev/urandom | base32 | head -c 5 printf "\t$service\tUp for 3d 14h\n" done }
Then we query a list clusters
$ list_clusters
north
west
south
east
And we can get a list of machines for a given cluster along with some extra information:
$ list_machines_for_cluster north north-X6OZD Rails Up for 3d 14h north-7Q5OD Postgres Up for 3d 14h north-2B6O3 Redis Up for 3d 14h north-ONF4B Nginx Up for 3d 14h
FZF to The Rescue
Let's first pick a cluster:
$ list_clusters | fzf --prompt='cluster: '
You can choose the cluster with arrows or fuzzy filter:
east
south
west
> north
4/4
cluster:
After you pick an option FZF prints it to stdout. And so, we have the cluster of choice.
$ cluster=$(list_clusters | fzf --prompt='cluster: ')
Next let's pick the machine to work with:
$ list_machines_for_cluster "$cluster" | \ fzf --prompt='machine: ' west-RM33M Rails Up for 3d 14h
Oh, we get too much output. Let's pick the first field only:
$ list_machines_for_cluster "$cluster" | \ fzf --prompt='machine: ' | \ awk '{ print $1 }' west-FDYBH
I often find myself waiting for a VM to start so that I could connect to it. Rerunning the script is rather annoying. I'd rather have a way to refresh the list of machines in place. First we need to export the function so that the BASH subprocess could see it, too.
$ export -f list_machines_for_cluster $ machine=$(\ list_machines_for_cluster "$cluster" | \ fzf --prompt='machine: ' \ --bind="ctrl-r:reload(bash -c \"list_machines_for_cluster \"$cluster\"\")" | \ awk '{ print $1 }')
Now you can hit "ctrl r" to refresh the list of machines. By the way, the bash -c
prefix is needed because list_machines_for_cluster
is not an executable but a function that only BASH knows about.
And so we have our machine name. What do we want to run on the machine?
$ remote_command=$(\ printf "bundle exec rails console\n/bin/bash" | \ fzf --prompt='command: ')
Let's construct the final command and run it:
$ ssh_command="ssh -o ForwardAgent $machine secrets-manager run $remote_command" $ bash -c "$ssh_command"
Custom Commands
The above is already useful. One of the extensions I made was to give the user (myself) a chance to tweak the command before running it (don't ask…). Instead of running it as part of our workflow we could print the command to the terminal and expect the user to copy-paste-tweak-run it. Fortunately, there is a more convenient way:
print_to_bash_input() { # Sends characters directly to the terminal input. python -c ' import fcntl, sys, termios for c in sys.argv[1]: fcntl.ioctl(0, termios.TIOCSTI, c) ' "$@" }
We can allow entering a custom command:
$ remote_command=$(\ printf "bundle exec rails console\n/bin/bash\na custom command" | \ fzf --prompt='command: ') $ if [ "$remote_command" = "a custom command" ]; then print_to_bash_input "ssh -o ForwardAgent $machine secrets-manager run " printf "\n\nManually complete the command and press <Enter> to run it\n" else ssh_command="ssh -o ForwardAgent $machine secrets-manager run $remote_command" bash -c "$ssh_command" fi
No more copy-pasting! Edit and run directly.
Conclusion
We barely scratched the surface of what FZF can do (man fzf
is not short). In real life I, too, used many more bells and whistles. But even the above significantly simplifies the workflow.
Here's the final script that you can save and run, in its entirety:
list_clusters() { echo "north" echo "west" echo "south" echo "east" } list_machines_for_cluster() { for service in Rails Postgres Redis Nginx; do printf "$1-" cat /dev/urandom | base32 | head -c 5 printf "\t$service\tUp for 3d 14h\n" done } export -f list_machines_for_cluster print_to_bash_input() { python -c ' import fcntl, sys, termios for c in sys.argv[1]: fcntl.ioctl(0, termios.TIOCSTI, c) ' "$@" } cluster=$(list_clusters | fzf --prompt='cluster: ') machine=$(list_machines_for_cluster "$cluster" | \ fzf --prompt='machine: ' --bind="ctrl-r:reload(bash -c \"list_machines_for_cluster \"$cluster\"\")" | \ awk '{ print $1 }') remote_command=$(printf "bundle exec rails console\n/bin/bash\na custom command" | fzf --prompt='command: ') if [ "$remote_command" = "a custom command" ]; then print_to_bash_input "ssh -o ForwardAgent $machine secrets-manager run " printf "\n\nManually complete the command and press <Enter> to run it\n" else ssh_command="ssh -o ForwardAgent $machine secrets-manager run $remote_command" bash -c "$ssh_command" fi