Skip to content

Creating a CLI Tool Using Bash

Posted on:December 18, 2023 at 12:00 AM

Introduction

In the world of software development, efficiency and simplicity often go hand in hand. Today, I’m excited to share with you how you can build a powerful Command Line Interface (CLI) tool using just a single bash script. This approach is not just about writing fewer lines of code; it’s about quickly harnessing the power of bash scripting to create something elegant yet functional.

Why Bash?

Bash, or the Bourne Again SHell, is more than just a command interpreter; it’s a powerful programming environment widely used in the Unix and Linux worlds. Its simplicity, combined with its capability to interact directly with the system, makes it an ideal choice for creating CLI tools.

There are a few things I find myself using bash to do. Usually it involves a reading a CSV or JSON file, filter some data, then formatting the data to make an api call. Or it could be the inverse, pulling data from an api, filtering it, and writing it to file to be sent to someone, or to be used as input for another script or tool.

This is is kinda the space that bash lives in. This space between, streaming the output on one tool as the input of another. But as many who have used bash know, it can get a bit out of hand if you don’t keep your scripts organized.

A Basic Framework

I like to start with this basic framework for my bash scripts. It supports the following features:

Subcommands

I follow the pattern of declaring functions in my scripts, the invoking them based on the first argument passed to the script. This allows me to create subcommands.

#!/usr/bin/env bash

function subcommand1() {
  echo "subcommand1"
}

"$@"

After saving the file as script.sh, I can run it like this:

# make sure the script is executable
chmod +x script.sh

./script.sh subcommand1
# subcommand1

This is a simple start, but it can be improved.

Private Helpers

Private functions are useful because we can declare reusable logic, without exposing to the CLI users directly. We can do this by following the convention of declaring function names with a leading _. At the end of the script, we invoke the subcommand if it does not start with an underscore.

#!/usr/bin/env bash

function subcommand1() {
  echo "subcommand1"
}

function _helper1() {
  echo "helper1"
}

if [[ "$1" != "_"* ]]; then
  "$@"
else
  echo "Unknown subcommand: $1"
fi

Test it out:

./script.sh subcommand1
# subcommand1

./script.sh _helper1
# Unknown subcommand: _helper1

Named Arguments

Next, we can add support for named arguments. We can do this by declaring an associative array to hold the arguments. Then we can loop through the arguments and parse them. You don’t need to use an associative array, but I find it makes the code easier to read, and it keeps user provided arguments separate from other variables you might declare.

#!/usr/bin/env bash

function subcommand1() {
  declare -A args=(
    ["file"]=""
    ["url"]="http://localhost:3000/posts" # include default
  )

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --file|-f)
        args["file"]="$2"
        shift 2
        ;;
      --url|-u)
        args["url"]="$2"
        shift 2
        ;;
      *)
        echo "Unknown option: $1"
        exit 1
        ;;
    esac
  done

  echo "subcommand1 file: ${args["file"]} url: ${args["url"]}"
}

function _helper1() {
  echo "helper1"
}

if [[ "$1" != "_"* ]]; then
  "$@"
else
  echo "Unknown subcommand: $1"
fi

Notice we have added support for both short and log options. Generally, I like having both options available when using cli tools. I like writing documentation that uses the log options; this helps keep things clear. But when I use the tool myself, I prefer to use the short options to save time.

./script.sh subcommand1 --file data.csv
# subcommand1 file: data.csv url: http://localhost:3000/posts

./script.sh subcommand1 --file data.csv --url https://example.com/posts
# subcommand1 file: data.csv url: http://localhost:3000/posts

Argument Validation

Finally, we can add argument validation. We can do this by checking if the required arguments are present, or running more complex validation if needed.

#!/usr/bin/env bash

function subcommand1() {
  declare -A args=(
    ["file"]=""
    ["url"]="http://localhost:3000/posts" # include default
  )

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --file|-f)
        args["file"]="$2"
        shift 2
        ;;
      --url|-u)
        args["url"]="$2"
        shift 2
        ;;
      *)
        echo "Unknown option: $1"
        exit 1
        ;;
    esac
  done

  # validate args
  if [[ -z "${args["file"]}" ]]; then # check if argument was provided
    echo "Missing required argument: --file"
    exit 1
  elif [[ ! -f "${args["file"]}" ]]; then # check if file exists
    echo "File not found: ${args["file"]}"
    exit 1
  fi

  # validate url is a valid url. Use a helper function to do this
  if ! _is_valid_url "${args["url"]}"; then
    echo "Invalid url: ${args["url"]}"
    exit 1
  fi

  echo "subcommand1 file: ${args["file"]} url: ${args["url"]}"
}

function _is_valid_url() {
  regex='(https?|ftp|file)://[-[:alnum:]\+&@#/%?=~_|!:,.;]*[-[:alnum:]\+&@#/%=~_|]'
  if [[ $1 =~ $regex ]]
  then
    return 0
  else
    return 1
  fi
}

if [[ "$1" != "_"* ]]; then
  "$@"
else
  echo "Unknown subcommand: $1"
fi

I’ve converted the _helper1 to _is_valid_url to be used to validate the --url argument. Its a bit contrived to use a helper in this case, but I wanted to show how you can use a helper for validation. Lets test it out.

./script.sh subcommand1 --file data.csv
# subcommand1 file: data.csv url: http://localhost:3000/posts

./script.sh subcommand1 --file data.csv --url not-a-url/something
# Invalid url: not-a-url/something

Putting it all together

Heres the final script. It uses our framework to read from a CSV file and make api calls to create posts.

#!/usr/bin/env bash

function create_posts() {
  declare -A args=(
    ["file"]=""
    ["url"]="http://localhost:3000/posts"
  )

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --file|-f)
        args["file"]="$2"
        shift 2
        ;;
      --url|-u)
        args["url"]="$2"
        shift 2
        ;;
      *)
        echo "Unknown option: $1"
        exit 1
        ;;
    esac
  done

  if [[ -z "${args["file"]}" ]]; then
    echo "Missing required argument: --file"
    exit 1
  elif [[ ! -f "${args["file"]}" ]]; then
    echo "File not found: ${args["file"]}"
    exit 1
  fi

  if ! _is_valid_url "${args["url"]}"; then
    echo "Invalid url: ${args["url"]}"
    exit 1
  fi

  while IFS=, read -r title body
  do
    BODY=$(jq -n --arg title "$title" --arg body "$body" '{title: $title, body: $body}')
    curl -X POST -H "Content-Type: application/json" -d "$BODY" "${args["url"]}"
  done < "${args["file"]}"
}

function _is_valid_url() {
  regex='(https?|ftp|file)://[-[:alnum:]\+&@#/%?=~_|!:,.;]*[-[:alnum:]\+&@#/%=~_|]'
  if [[ $1 =~ $regex ]]
  then
    return 0
  else
    return 1
  fi
}

if [[ "$1" != "_"* ]]; then
  "$@"
else
  echo "Unknown subcommand: $1"
fi

I’m using jq here to make sure the strings are properly escaped. This is not strictly necessary, but it makes the script more robust.

Before we test it out, lets spin up a fake api using json-server. here is a starting db.json file. If you want to run the script without this, just add echo before the curl command.

For the CSV file, You can download the sample data I’m using.


# start the fake api server
npx json-server --watch db.json --port 3000

# create the posts
./script.sh create_posts --file posts.csv --url http://localhost:3000/posts
# {
#   "title": "Exploring the Wonders of Space",
#   "body": "Space has always been a source of wonder and mystery. This article delves into the latest discoveries and explorations in space science.",
#   "id": 4
# }
# ...

Challenge

Another common use case I have found is to pull data from an API and write it to a CSV file. Take the script we just created, add another subcommand that pulls all the posts and writes them to a file.

Conclusion

And thats it! We’ve created a powerful CLI tool using just a single bash script.

I’ve used this framework for managing my local development environment, migrating databases, running complex testing scenarios, and more. I hope you find it useful as well.