Learning by rewriting - bash, jq and fzf details

| 7 min read

One of the ways I learn is by reading and sometimes rewriting other people's scripts. Here I learn more about jq by rewriting a friend's password CLI script.

My friend Christian Drumm published a nice post this week on Adapting the Bitwarden CLI with Shell Scripting, where he shared a script he wrote to conveniently grab passwords into his paste buffer at the command line.

It's a good read and contains some nice CLI animations too. In the summary, Christian remarks that there may be some areas for improvement. I don't know about that, and I'm certainly no "shell scripting magician" but I thought I'd have a go at modifying the script to perhaps introduce some further Bash shell, jq and fzf features to dig into.

Emulating the CLI

I don't have Bitwarden, so I created a quick "database" of login information that took the form of what the Bitwarden CLI bw produced. First, then, is the contents of the items.json file:

[
{ "name": "E45 S4HANA 2020 Sandbox", "login": { "username": "e45user", "password": "sappass" } },
{ "name": "space user", "login": { "username": "spaceuser", "password": "in space" } },
{ "name": "foo", "login": { "username": "foouser", "password": "foopass" } },
{ "name": "bar", "login": { "username": "baruser", "password": "sekrit!" } },
{ "name": "baz", "login": { "username": "bazuser", "password": "hunter2" } }
]

Then I needed to emulate the bw list items --search command that Christian uses to search for an entry. As far as I can tell, it returns an array, regardless of whether a single entry is found, or more than one. I'm also assuming it returns an empty array if nothing is found, but that's less important here as you'll see.

I did this by creating a script bw-list-items-search which looks like this:

#!/usr/bin/env bash

# Emulates 'bw list items --search $1'

jq --arg name "$1" 'map(select(.name | test($name; "i")))' ./items.json

Perhaps unironically I'm using jq to emulate the behaviour, because the data being searched is a JSON array (in items.json). I map over the entries in the array, and use the select function to return only those entries that satisfy the boolean expression passed to it:

.name | test($name; "i")

This pipes the value of the name property (e.g. "E45 S4HANA 2020 Sandbox", "space user", "foo" etc) into the test function which can take a regular expression, along with one or more flags if required.

Here, we're just taking the value passed into the script, via the argument that was passed to the jq invocation with --arg name "$1". This is then available within the jq script as the binding $name. The second parameter supplied here, "i", is the "case insensitive match" flag.

The result means that I can emulate what I think bw list items --search does:

; ./bw-list-items-search e45
[
{
"name": "E45 S4HANA 2020 Sandbox",
"login": {
"username": "e45user",
"password": "sappass"
}
}
]

Here's an example of where more than one result is found:

; ./bw-list-items-search ba
[
{
"name": "bar",
"login": {
"username": "baruser",
"password": "sekrit!"
}
},
{
"name": "baz",
"login": {
"username": "bazuser",
"password": "hunter2"
}
}
]

The main script

Now I could turn my attention to the main script. Here it is in its entirety; I'll describe it section by section.

#!/usr/bin/env bash

set -e

pbcopy() { true; }

copy_uname_and_passwd() {

  local login=$1

  echo "> Copying Username"
  jq -r '.username' <<< "$login"

  echo "> Press any key to copy password..."
  read
  echo "> Copying Password"
  jq -r '.password' <<< "$login"

}

main() {

  local searchterm=$1
  local selection logins
  logins="$(./bw-list-items-search $searchterm)"

  selection="$(jq -r '.[] | "\(.name)\t\(.login)"' <<< "$logins" \
    | fzf --reverse --with-nth=1 --delimiter="\t" --select-1 --exit-0
  )"

  [[ -n $selection ]] \
    && echo "Name: ${selection%%$'\t'*}" \
    && copy_uname_and_passwd "${selection#*$'\t'}"

}

main "$@"

Overall structure and the main function

For the last few months, my preference for laying out non-trivial scripts has been to use the approach that one often finds in other languages, and that is to define a main function, and right at the bottom, call that to start things off.

This call is main "$@" which just passes on any and all values that were specified in the script's invocation - they're available in the special parameter $@ which "expands to the positional parameters, starting from one" (see Special Parameters).

The main function

I like to qualify my variables, so use local here, which is a synonym for declare. I wrote about this in Understanding declare in case you want to dig in further.

Because I have my emulator earlier, I can make almost the same-shaped call to the Bitwarden CLI, passing what was specified in searchterm and retrieving the results (a JSON array) in the logins variable.

Next comes perhaps the most involved part of the script, which results in a value being stored in the selection variable (if nothing is selected or available, then this will be empty, which we'll deal with too).

Determining the selection part 1 - with jq

The value for selection is determined from a combination of jq and fzf, which are also the two commands that Christian uses.

This is the invocation:

jq -r '.[] | "\(.name)\t\(.login)"' <<< "$logins" \
    | fzf --reverse --with-nth=1 --delimiter="\t" --select-1 --exit-0

The first thing to notice is that I'm using <<< which is a here string - it's like a here document, but it's just the variable that gets expanded and fed to the STDIN of the command. This means that whatever is in logins gets expanded and passed to the STDIN of jq.

Given the emulation of the Bitwarden CLI above, a value that might be in logins looks like this:

[
{
"name": "bar",
"login": {
"username": "baruser",
"password": "sekrit!"
}
},
{
"name": "baz",
"login": {
"username": "bazuser",
"password": "hunter2"
}
}
]

Let's look at the jq script now, which is this:

.[] | "\(.name)\t\(.login)"

This iterates over the items passed in (i.e. it will process the first object containing the details for "bar" and then the second object containing the details for "baz") and pipes them into the creation of a literal string (enclosed in double quotes). This literal string is two values separated with a tab character (\t) ... but those values are the values of the respective properties, via jq's string interpolation).

It's worth noting that the value of .name is a scalar, e.g. "bar", but the value of .login is actually an object:

"login": {
"username": "baruser",
"password": "sekrit!"
}

but this gets turned into a string. If "bar" is selected, then the value in selection will be:

bar     {"username":"baruser","password":"sekrit!"}

where the whitespace between the name "bar" and the rest of the line is a tab character.

So given the two values (for "bar" and "baz") above which would have been extracted for the search string "ba", the following would be produced by the jq invocation:

bar     {"username":"baruser","password":"sekrit!"}
baz     {"username":"bazuser","password":"hunter2"}

Note that the -r option is supplied to jq to produce this raw output.

Determining the selection part 2 - with fzf

This is then passed to fzf, which is passed a few more options than we saw with Christian's script. Taking them one at a time:

  • --reverse - this is the same as Christian and is a layout option that causes the selection to be displayed from the top of the screen.
  • --delimiter="\t" - this tells fzf how the input fields are delimited, and as we're using a tab character to separate the name and login information, we need to tell fzf (using just spaces would give us issues with spaces in the values of the names).
  • --with-nth=1 - this says "only use the value of the first field in the selection list", where the fields are delimited as instructed (with the tab character here). This means that only the value of the "name" is presented, not the "login" (username and password) details.
  • --select-1 - this tells fzf that if there's only one item in the selection anyway, just automatically select it and don't show any selection dialogue.
  • --exit-0 - this tells fzf to just end if there's nothing to select from at all (which would be the case if the invocation to bw list items --search returned nothing, i.e. an empty array).

Here's what the selection looks like if no search string is specified, i.e. it's a presentation of all the possible names:

selection in fzf

Once we're done with determining the selection, we check to see that there is actually a value in selection and proceed to first show the name and then to call the copy_uname_and_passwd function.

Displaying the name and extracting the login details

It's worth highlighting that while fzf only presents the names in the selection list, it will return the entire line that was selected, which is what we want. In other words, given the selection in the screenshot above, if the name "E45 S4HANA 2020 Sandbox" is chosen, then fzf will emit this to STDOUT:

E45 S4HANA 2020 Sandbox {"username":"e45user","password":"sappass"}

(again, remember that there's a tab character between the name "E45 S4HANA 2020 Sandbox" and the JSON object with the login details).

So to just print the name, we can use shell parameter expansion to pick out the part we want. The ${parameter%%word} form is appropriate here; this will remove anything with longest matching pattern first.

In other words, the expression ${selection%%$'\t'*} means:

  • take the value of the selection variable
  • look at the trailing portion of that value
  • find the longest match of the pattern $'\t'*
  • and remove it

The $'...' way of quoting a string allows us to use special characters such as tab (\t) safely. The * means "anything". So the pattern is "a tab character and whatever follows it, if anything".

So if the value of selection is:

E45 S4HANA 2020 Sandbox {"username":"e45user","password":"sappass"}

then this expression will yield:

E45 S4HANA 2020 Sandbox

The expression in the next line, where we invoke the copy_uname_and_passwd function, is ${selection#*$'\t'} which is similar. It means:

  • take the value of the selection variable
  • look at the beginning portion of that value
  • find the shortest match of the pattern *$'\t'
  • and remove it

This pattern, then, is "anything, up to and including a tab character".

Given the same value as above, this expression will yield:

{"username":"e45user","password":"sappass"}

The copy_uname_and_passwd function

This is very similar to Christian's original script, except that we can use a "here string" again to pass the value of the login variable to jq each time. Given what we know from the main function, this value will be something like this:

{"username":"e45user","password":"sappass"}

which makes for a simpler extraction of the values we want (from the username and password properties).

The pbcopy function

While my main machine is a macOS device, I'm working in a (Linux based) dev container and therefore don't have access right now to the pbcopy command. As I wanted to leave calls to it in the script to reflect where it originally was, this function that does nothing will do the trick.

Wrapping up

There's always more to learn about Bash scripting and the tools we have at our disposal. And to use one of the sayings from the wonderful Perl community - TMOWTDI - "there's more than one way to do it". I'm sure you can come up with some alternatives too, and some improvements on what I've written.

Keep on learning and sharing.