This is an attempt to systemize my own knowledge of options parsing in shell scripts. I mainly work in Zsh, but this is compatible with Bash and possibly others.

Let’s assume that we want the CLI interface to look like this:

myscript <key> [-v <value>] [-l]

Here we have:

  • required option
  • two optional keys, one of which with required parameter

There’s a very handy tool – getopt – which handles filtering and parsing command line options. Here’s how we’ll use it to define optional keys:

TEMP=`getopt lv: $@`

Here we set that we want to recognize only two options: l without arguments and v with one argument. Then we put whatever the script received as parameters as $@. The getopt command will set return status to 0 if parsing was successful and will return something else otherwise. We need to check if it’s fine and continue, or show the usage information and exit if not:

usage() {
  echo "USAGE: myscript <key> [-v <value>] [-l]"
  exit 1
}

if [ $? != 0 ]; then usage; fi

In case of success we move on. The result of getopt will be saved into the TEMP var. The way getopt works is it puts the recognized options first, then places -- and finishes the string by listing everything it didn’t understand.

Here are some examples to illustrate:

$ getopt lv: -l
-l --

$ getopt lv: -v test
-v test --

$ getopt lv: -v test -l
-v test -l --

$ getopt lv: -v test -l xyz
-v test -l -- xyz

However, if you specify an unknown option, it will return the status code 1 and output an error like this:

$ getopt lv: -v test -a
getopt: illegal option -- a
 -v test --

Let’s parse this output. First lets put the TEMP into $@ while respecting any possible quotations.

eval set -- $TEMP

Next, let’s go through the $@ item by item and see what we encounter.

for i
do
  case "$i"
  in
    -l)
      echo "Found -l option"
      shift;;

    -v)
      echo "Found -v option with parameter '$2'"
      shift 2;;

    --)
      shift;
      break;
  esac
done

As you can see, it all is pretty easy to grasp. Two things to notice:

for i
do
done

is basically equivalent to:

for i in "$@"
do
done

And the second is that after eval set -- $TEMP we have everything in our TEMP var assigned to $@ and the first nine arguments to $1 through $9. So first argument is in $1. When we iterate over the $@, we pick the first item, find the matching branch in case statement, act on it, then shift whatever number of arguments we consumed out of $@ and run another cycle. The exit condition is finding -- (in which case we also shift it out, in case we need the remainder).

Now that you know what this shift command does, let’s recall that we wanted to have the required first argument. Here’s the piece we should put at the very beginning after the usage function definition.

if [ $# -lt 1 ]; then usage; fi
key=$1
shift 1

It checks if we have enough arguments, picks the first one and shifts it out in preparation to optional arguments parsing.

Here’s the full version of the script:

usage() {
  echo "USAGE: myscript <key> [-v <value>] [-l]"
  exit 1
}

if [ $# -lt 1 ]; then usage; fi
key=$1
shift 1

TEMP=`getopt lv: $@`
if [ $? != 0 ]; then usage; fi

setting=0

for i
do
  case "$i"
  in
    -l)
      echo "Found -l option"
      shift;;

    -v)
      echo "Setting value of '$key' to '$2'"
      setting=1
      shift 2;;

    --)
      shift;
      break;
  esac
done

if [ $setting -eq 0 ]; then
  echo "Reading value of '$key'"
fi

Go ahead and try it:

$ ./myscript
$ ./myscript test
$ ./myscript test -v value
$ ./myscript test -l