I am the kind of person who loves cli tools, if a program has a command line interface I am supper happy with it. However I never remember what options I should be giving different commands that I don’t use frequently, or I am often too lazy to type out the entire argument.
In comes tab completion to my rescue!! Tab completion lets you type a command (or part of a command) and then press tab twice to get a list of the options you can use to fill out the rest of the command, and will also auto fill it out when possible. It is super useful when typing long file paths or adding some arguments for long commands.
Recently I started working on an awesome project, called liquidctl that is a cli tool that allows me to control my all-in-one liquid cooler and the fan speeds and RGB on my desktop when I am using my main Linux OS.
Since, the software that is provided by the device manufacturers only works on windows, and other open source projects like OpenRBG have a graphical user interface that I didn’t really want to use.
But to use liquidctl
I need to pass a bunch of parameters to initialize the device, set the fan speeds, and most importantly set the RGB.
Here are the 3 commands that I need to commonly need to send.
$ liquidctl initialize all
$ liquidctl -m H100i set fan speed 20 20 30 50 40 70 50 100
$ liquidctl -m H100i set led color fixed ff0000
And as I have said before in the Introduction post my spelling it atrocious, and initialize
is one of those words that I often seem to spell slightly wrong it second guess my self for.
And it started driving me nuts every time I needed to initialize my devices.
Enter my long mission to add tab completion to the project, because what is a programmer if not someone who will spend weeks on a task to save themselves the couple of seconds of tedious labor of typing in entire words? See this xkcd comic if you don’t know what I mean (or have never met a programmer before).
Anyway before I go on to tell you about my enlightening journey of figuring out how to actually make one of these bash completion scripts I feel it is prudent to answer this question first:
- Why not just use a better shell then bash that will automatically create the completions for you?
Regarding using a “better” shell. No. My preferred shell is bash, maybe one day I will switch to some thing else like zsh, or fish. And even if I did switch there are still some completions that can not be automatically generated by the fancy other shells.
Now we can go back to the original point of this post, bash completions. There are a ton of other tutorials, blog posts, and guides that can be found on-line about the programmable bash completions that are built in to bash. Some links for these are: gnu.org, tldp.org, and here are some of the good ones that I found that were super helpful in figuring this all out.
Because of those fabulous tutorials I am not going to go that far into the basics of getting a bash completion setup, rather I’m going to focus a bit more on the challenges I faced and the features I tried to add to mine that I couldn’t seem to find good guides for.
What are these difficulties you may ask. Well I shall tell you.
- adding completions for sub commands, or flags that take additional arguments
- don’t keep telling me about all the flags that I have already passed to the command
The program
Before we can start adding those fancy features to a completion script, we kind of need to have one to start with.
For the rest of this example we will be using a made up program called awesome
and it takes the following command line arguments.
awesome [options] initialize
awesome [options] list
awesome [options] set <channel> color <mode>
options:
-v --verbose
-d --debug
-m --name <name>
--usb
--cool-arg [abc | xyz]
--uav
Don’t think about the names they don’t matter very much for the sake of this example. We will be starting with the following completion script that is shown below that is setup with the base auto fill for the options, and sub commands.
The initial completion script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#!/usr/bin/env bash
_list_name_options () {
awesome list -v | grep 'Name:' | cut -d ':' -f 2 | sort -u
}
_awesome_main() {
local commands="
set
initialize
list
"
local boolean_options="
--verbose -v
--debug -d
--version
--help
--usb
--uav
"
local options_with_args="
-m --name
--cool-arg
"
# generate options list
options="$options_with_args $boolean_options"
# This part will check if it is currently completing a flag
local previous=$3
local cur="${COMP_WORDS[COMP_CWORD]}"
case "$previous" in
--cool-arg)
COMPREPLY=($(compgen -W "abc xyz" -- "$cur"))
return
;;
--name | -m )
COMPREPLY=($(compgen -W "$(_list_name_options)" -- "$cur"))
return
;;
esac
# This will handle auto completing arguments even if they are given at the end of the command
case "$cur" in
-*)
COMPREPLY=($(compgen -W "$options" -- "$cur"))
return
;;
esac
local i=1 cmd
# find the subcommand - first word after the flags
while [[ "$i" -lt "$COMP_CWORD" ]]
do
local s="${COMP_WORDS[i]}"
case "$s" in
--help | --version)
COMPREPLY=()
return
;;
-*) ;;
initialize | list | set )
cmd="$s"
break
;;
esac
(( i++ ))
done
# return early if we're still completing the 'current' command
if [[ "$i" -eq "$COMP_CWORD" ]]
then
COMPREPLY=($(compgen -W "$commands $options" -- "$cur"))
return
fi
}
complete -F _awesome_main awesome
Remove already given flags
Using the current version of the completion script we would get the following:
$ awesome --ver<tab><tab>
--verbose --version
which is what we expect. Then:
$ awesome --version --ver<tab><tab>
--verbose --version
is not what we expect, we already gave the --version
flag we don’t want to have to it as an option again.
We can correct that by adding a couple lines of code to remove any flag that has already been given from the list of options.
By replacing line 29
with this:
# generate options list
options=($options_with_args $boolean_options)
for i in "${!options[@]}";
do
if [[ "${COMP_WORDS[@]}" =~ "${options[i]}" ]]; then
unset 'options[i]'
fi
done;
options=$(echo "${options[@]}")
This will go through every item in the list of options and see if it is in the list of completed words.
If it is, then it will be removed from the list, finally all of the ‘holes’ in the list are removed.
Using unset
will not remove the array index. ie.
$ a=(1 2 3 4 5)
$ unset 'a[2]'
$ echo ${a[@]}
1 2 4 5
$ echo ${!a[@]}
0 1 3 4
The last line will remove any of the ‘holes’ from the list after the items are removed.
One thing to note is that for options that have both a short version and a long version of the flag, like -m
and --name
in this example, both flags are not removed, only the one that was given.
Next we will modify this script to remove any flags that have already been suggested from being suggested again.
Add sub commands
Earlier I had mentioned that we will make it so that the completion script can also handle sub commands.
In this example that would be awesome set <channel> color <mode>
command.
There are multiple different channels that can be given and multiple different modes.
The next steps to modify the script to handle the completion of sub commands is to identify what the current command is that is being completed.
Again we will be working from the same base completion script shown above.
This can be done by using the cmd
variable that got set on line 67
, and then adding a new block of code at the end of the _awesome_main()
function to provide different completion options depending on the command that was given.
For this we will be adding the following code bloclk:
case "$cmd" in
list) COMPREPLY="" ;;
initialize) COMPREPLY="" ;;
set) _awesome_set_command ;;
*) ;;
esac
In this block of code you can see that if the command is list
or initialize
no completion options are provided.
But, for the set
command there is another function that is called.
This function is responsible for for generating the completion for the rest of that sub command.
If you had other sub commands you could easily create more functions to complete them as well.
You may have noticed that for the commands I specified nothing in the COMPREPLY
variable, and you may ask, but I want to be able to auto complete the flags after the command.
Fear not, that case has been thought of as well.
If the user were to run:
$ awesome list <tab><tab>
Nothing shows up. But:
$ awesome list -<tab><tab>
--cool-arg --help --uav --verbose -d -v
--debug --name --usb --version -m
That way if you want to see if there are sub commands or extra options to fill in you see that there are none, but if you just add a -
to indicate you want more flags they are shown as you would expect.
I find this to be a nice way to show them without cluttering the output.
So we added a new function call to complete the awesome set
sub command, now I shall tell you what is in it.
Don’t worry if it looks a bit daunting at first we shall go over what it does.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
_awesome_set_command ()
{
local i=1 subcommand_index=0
# find the sub command (either a fan or an led to set)
while [[ $i -lt $COMP_CWORD ]]; do
local s="${COMP_WORDS[i]}"
case "$s" in
led[1-2] | led)
subcommand_index=$i
break
;;
esac
(( i++ ))
done
# check if it is an LED that is being set
if [[ "$subcommand_index" -ne "0" ]]
then
_awesome_set_led
else
# no trailing space here so that the fan number can be appended
compopt -o nospace
# possibly use some command here to get a list of all the possible channels from awesome
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "led led1 led2" -- "$cur"))
fi
}
This function is a lot like what was in the _awesome_main()
function.
It will search through the completion word list to see if it finds the sub command and will then do the completion for it.
The first part of that function, up until line 15
will loop through the words to see if the options for the channel have been specified yet.
From line 9
we can see that the valid options are led1
, led2
, and led
, if one of those words are found it will keep track of where the word was.
The next task is to perform a different action based on the different sub command that was given.
In this case there is only one option for the sub command, but it is trivial to add more, the logic for that is handled on lines 18-27
.
It will check to see if a sub command is given, this could easily be changed to see which sub command was given, and do an action based on that.
In this case it will call another function.
If a sub command is not found then that means that it is still completing the sub command and so the list of sub commands should be given which can be seen on line 26
.
The full finished completion script
And now that we have completed those additions we have a fully working completion script which is shown below.
All that is left to do is to source
it in your current shell, or you can place it in a folder such as /etc/bash_completions/
where it will automatically be loaded when the shell starts up if bash-completion
is installed.
#!/usr/bin/env bash
_list_name_options () {
awesome list -v | grep 'Name:' | cut -d ':' -f 2 | sort -u
}
_awesome_main() {
local commands="
set
initialize
list
"
local boolean_options="
--verbose -v
--debug -d
--version
--help
--usb
--uav
"
local options_with_args="
-m --name
--cool-arg
"
# generate options list
options=($options_with_args $boolean_options)
for i in "${!options[@]}";
do
if [[ "${COMP_WORDS[@]}" =~ "${options[i]}" ]]; then
unset 'options[i]'
fi
done;
options=$(echo "${options[@]}")
# This part will check if it is currently completing a flag
local previous=$3
local cur="${COMP_WORDS[COMP_CWORD]}"
case "$previous" in
--cool-arg)
COMPREPLY=($(compgen -W "abc xyz" -- "$cur"))
return
;;
--name | -m )
COMPREPLY=($(compgen -W "$(_list_name_options)" -- "$cur"))
return
;;
esac
# This will handle auto completing arguments even if they are given at the end of the command
case "$cur" in
-*)
COMPREPLY=($(compgen -W "$options" -- "$cur"))
return
;;
esac
local i=1 cmd
# find the subcommand - first word after the flags
while [[ "$i" -lt "$COMP_CWORD" ]]
do
local s="${COMP_WORDS[i]}"
case "$s" in
--help | --version)
COMPREPLY=()
return
;;
-*) ;;
initialize | list | set )
cmd="$s"
break
;;
esac
(( i++ ))
done
# return early if we're still completing the 'current' command
if [[ "$i" -eq "$COMP_CWORD" ]]
then
COMPREPLY=($(compgen -W "$commands $options" -- "$cur"))
return
fi
# we've completed the 'current' command and now need to call the next completion function
# subcommands have their own completion functions
case "$cmd" in
list) COMPREPLY="" ;;
initialize) COMPREPLY="" ;;
set) _awesome_set_command ;;
*) ;;
esac
}
_awesome_set_command ()
{
local i=1 subcommand_index=0
# find the sub command (either a fan or an led to set)
while [[ $i -lt $COMP_CWORD ]]; do
local s="${COMP_WORDS[i]}"
case "$s" in
led[1-2] | led)
subcommand_index=$i
break
;;
esac
(( i++ ))
done
# check if it is an LED that is being set
if [[ "$subcommand_index" -ne "0" ]]
then
_awesome_set_led
else
# no trailing space here so that the fan number can be appended
compopt -o nospace
# possibly use some command here to get a list of all the possible channels from awesome
local cur="${COMP_WORDS[COMP_CWORD]}"
COMPREPLY=($(compgen -W "led led1 led2" -- "$cur"))
fi
}
_awesome_set_led ()
{
local i=1 found=0
# find the sub command (either a fan or an led to set)
while [[ $i -lt $COMP_CWORD ]]; do
local s="${COMP_WORDS[i]}"
if [[ "$s" = "color" ]]; then
found=1
break
fi
(( i++ ))
done
# check if it is a fan or an LED that is being set
if [[ $found = 1 ]]; then
COMPREPLY=""
else
COMPREPLY="color"
fi
}
complete -F _awesome_main awesome
Conclusion
Here we briefly went over how to add some cool features to your bash completion script to make your life, and anyone else who uses your cli applications lives easier. Hopefully if you are trying to create a bash completion script this helps, or if you are me trying to do this again later these notes make some semblance of sense to future me.