Create robust CLI apps with Bashly



This content originally appeared on DEV Community and was authored by meleu

In this article we’re going to know Bashly, a framework that lets you create robust CLI applications using just bash.

We will do it with a hands-on approach, developing a very simple application, but with a solid look and feel.

Why Bashly?

Imagine this scenario…

We want to create a random number generator. In bash this is so simple that we can just do echo $RANDOM.

We don’t even need a script for that, but we started to think in new features. Example: we want to specify the maximum number to be generated.

Consider our program is called rndm, we could have something like this:

# simulating a dice roll
rndm --max 6

# tossing a coin
rndm --max 2

Maybe you already know how to generate random numbers between a specific range, the logic for this is not that complicated. But if you already wrote a bash program parsing command line --options, then you know what happens: our simple program will explode in complexity just because of the code needed to handle such options.

Ah! As you added options, you now have to provide a --help, so your users know how to use the available options.

Also, if you are going to accept user input, it’s important to validate what they are sending to the program.

At the end of the day you’ll probably spend more energy handling all these details than with the problem you really want to solve: generate random numbers.

That’s why Bashly was created! With it we can easily:

  • parse --options
  • create help messages
  • validate input
  • check dependencies
  • other typical things required from a robust CLI application.

Delegate such tedious tasks to Bashly and focus on the logic of the problem you really want to solve.

To illustrate how to create a robust CLI with Bashly, we are going to develop a random number generator. It starts simple but will gradually get more interesting features.

NOTE: in order to use Bashly it’s assumed you know how to handle YAML files (which is very simple)

Installing Bashly

Bashly is a Ruby gem. In the Ruby ecosystem we call the packages as a gem (like a npm package for NodeJS, or a create for Rust).

Note: although it’s developed in Ruby:

  • you do not need to know any Ruby to use Bashly.
  • the users of your program do not need Ruby installed.

Bashly depends on Ruby 3.2+. If you run ruby --version and see a version equal or greater than 3.2, then you’re good. Otherwise, you’ll need to install a proper version.

I like to use “runtime version managers”, like mise (I use and recommend) or asdf to install interpreters and compilers in different versions. I suggest you to do the same to install a proper Ruby version.

Here’s an example using mise:

# installing ruby 3.4 and setting as the default
mise use --global ruby@3.4

Once Ruby is installed, we can install Bashly:

gem install bashly

Just checking:

$ bashly --version
1.3.2

At the time of this writing Bashly version is 1.3.2.

Bash Version

The final Bash code generated by Bashly make use of associative arrays and other features that came to bash in the version 4.2 (which was released in 2011).

If you’re using a Linux distro, then you probably already have a compatible version.

If you’re on MacOS, your Bash is probably “frozen” in version 3.2.57. Don’t worry, a simple brew install bash can fix the problem (I’m assuming you have Homebrew installed).

Starting a Project

Let’s start with a directory for our project:

mkdir rndm
cd rndm

A way to start a Bashly project is by running bashly init, which creates a file named src/bashly.yml. If you do it you’ll note that the file comes filled with some data, but that can be confusing for our first start.

Here we’re going to write our bashly.yml from scratch, learning each configuration. Therefore, open your src/bashly.yml, remove all its contents and add this:

name: rndm
help: Prints a random number
version: 0.0.1

Now we can run bashly generate and see an output like this:

$ bashly generate
creating user files in src
created src/root_command.sh
created ./rndm
run ./rndm --help to test your bash script

Let’s do exactly what it’s suggesting:

$ ./rndm --help
rndm - Prints a random number

Usage:
  rndm
  rndm --help | -h
  rndm --version | -v

Options:
  --help, -h
    Show this help

  --version, -v
    Show version number

🤩 – Look at that!

We didn’t write a single bash line and look at that beautiful help message we already have!

Trying to explain, in simple terms, what Bashly just did:

  1. read the src/bashly.yml file
  2. understood that we want to create a script named rndm
  3. read the script’s description in the help: directive
  4. read the script’s version in version:
  5. created a file named src/root_command.sh
  6. generated the final script rndm

One thing we already noticed is that the rndm script was already generated with a --help and --version feature.

The final rndm script is “self-contained”, which means that you can distribute it to anyone with bash available to run it (as long as you don’t introduce external dependencies, which we’ll talk about soon).

Let’s take a look at the src/root_command.sh:

echo "# This file is located at 'src/root_command.sh'."
echo "# It contains the implementation for the 'rndm' command."
echo "# The code you write here will be wrapped by a function named 'root_command()'."
echo "# Feel free to edit this file; your changes will persist when regenerating."
inspect_args

Let’s talk a bit more about this…

When we ran bashly generate the file src/root_command.sh was created, and that’s where we should put our program’s logic.

In the final rndm script all the contents of this file will be wrapped in a function named root_command(). And if we check the final script we’ll confirm that the function is there (maybe around line 10):

#!/usr/bin/env bash
# ...

root_command() {
  # src/root_command.sh
  echo "# This file is located at 'src/root_command.sh'."
  echo "# It contains the implementation for the 'rndm' command."
  echo "# The code you write here will be wrapped by a function named 'root_command()'."
  echo "# Feel free to edit this file; your changes will persist when regenerating."
  inspect_args

}

# ...

Once the src/root_command.sh file is created, Bashly doesn’t change it anymore. We can edit it at will that our code will remain untouched, even after a new bashly generate.

Well, the initial contents of this file exist just to tell us these things, now we can start implementing our feature. But first, I think it’s a good idea to version control our project…

Version Control

With a version control system we can create safe “checkpoints” of the evolution of our project. Therefore let’s start a new git repository and commit what we have until now:

git init
git add .
git commit -m 'Starting bashly project'

Note: for a better reading experience I’m not going to worry about updating the version: 0.0.1 in our src/bashly.yml.

Generating Random Numbers

We can now delete everything in src/root_command.sh and finally put our great random number generator code:

echo "$RANDOM"

Regenerate the script and check the results:

$ bashly generate
creating user files in src
skipped src/root_command.sh (exists)
created ./rndm
run ./rndm --help to test your bash script

Note that this time Bashly skipped the src/root_command.sh creation (obviously, because it already exists). Just the final rndm script was generated again, this time with our new code.

If we run the program a few tims we’ll see a different random number after each execution:

$ ./rndm
8783

$ ./rndm
32008

$ ./rndm
12550

✅ Done! That’s all we want for now!

Commit the changes and let’s pick another feature.

Handling --options

Some people take randomness very seriously (especially those who deal with cryptography). There’s even a web service called random.org self-described as “a true random number service that generates randomness via atmospheric noise”. Well, I don’t know exactly what “atmospheric noise” means, but as the site exists since 1998 and is still running, I’m assuming they’re good at randomness.

A cool thing about the site is that they offer an endpoint from where we can get random numbers. Here’s an example of how we can get one:

curl "https://www.random.org/integers/?num=1&min=0&max=32767&col=1&base=10&format=plain"

If you want to understand each parameter being passed to the endpoint, you can check the official documentation. But if you want to keep your focus on learning Bashly, stay with me…

Let’s assume some of our users want True Randomness™, instead of a simple echo $RANDOM. Then let’s make our program pick numbers from random.org.

What I have in mind is to provide the --web option so we can tell our program to get the number from the web. The first step is to put this in our src/bashly.yml:

name: rndm
help: Prints a random number
version: 0.0.1

# specifying flags
flags:
    # long version:
  - long: --web
    # also a short version:
    short: -w
    help: Get the random number from <https://random.org>.

With just this YAML we can run bashly generate and check the help message:

$ bashly generate
creating user files in src
skipped src/root_command.sh (exists)
created ./rndm
run ./rndm --help to test your bash script

$ ./rndm --help
rndm - Prints a random number

Usage:
  rndm [OPTIONS]
  rndm --help | -h
  rndm --version | -v

Options:
  --web, -w
    Get the random number from <https://random.org>.

  --help, -h
    Show this help

  --version, -v
    Show version number

How cool is that?! A neat help message generated with only a few lines in our YAML!

Yeah, that’s cool. But we still need to implement the feature.

Let’s understand how we can get this --web flag in our code.

When we pass a flag for our program, Bashly put it in an associative array named $args, where each key is precisely the name of the flag. So, as we used --web, in our code we access this data with ${args[--web]}. And as it’s a boolean flag, with no arguments, the value here is 1 to mean “true, the --web option was passed”.

Note: even if we use the short version -w, in our code we still access the value via ${args[--web]}.

Let’s use it in our code:

# src/root_command.sh

# if we use `rndm --web` or `rndm -w`,
# the ${args[--web]} will be '1'
if [[ "${args[--web]}" == 1 ]]; then
  curl \
    --silent \
    --location \
    "https://www.random.org/integers/?num=1&min=0&max=32767&col=1&base=10&format=plain"
else
  echo "$RANDOM"
fi

We can now regenerate the script

Smarter way to bashly generate.

You’ll soon notice that we constantly run bashly generate after each file change. This can be tedious and we can make it simpler. Open a new terminal and run:

bashly generate --watch

Now Bashly will watch for changes and regenerate after any change is detected.

Let’s confirm the --web option works:

$ # numbers generated locally
$ ./rndm
2934

$ ./rndm
16891

$ # numbers coming from random.org
$ ./rndm --web
18253

$ ./rndm -w
137

If you run the commands above you’ll notice that when we run rndm --web the answer takes some milliseconds more than the local version. Such latency is expected when we’re using a distributed system. There’s nothing we can do in our code to solve that.

We can consider this feature as ready. So, now is a good moment for a commit.

Listing Dependencies

When we added the option to get a number from the web we ended up introducing a dependency for our program: the curl command.

If w run our program in an environment without curl, it’s going to crash with a message like this:

$ # environment without 'curl'
$ ./rndm --web
./rndm: line 17: curl: command not found

Indeed, without curl our program is not able to send a request to random.org. But we have better ways to tell our users they need to have curl installed.

We can edit our src/bashly.yml and add this:

help: Prints a random number
version: 0.0.1

# specifying the dependencies
dependencies:
  - curl

flags:
  - long: --web
    short: -w
    help: Get the random number from <https://random.org>.

NOTE: from now on I’m assuming you’re using bashly generate --watch and I won’t be telling you to regenerate after each change.

Let’s check the output:

$ # environment without 'curl'
$ ./rndm --web
missing dependency: curl

That’s slightly better than the “command not found at line 17”, isn’t it?

Time for a new commit and move on…

Making Our Code Modular

Let’s check our code again:

# src/root_command.sh

if [[ "${args[--web]}" == 1 ]]; then
  curl \
    --silent \
    --location \
    "https://www.random.org/integers/?num=1&min=0&max=32767&col=1&base=10&format=plain"
else
  echo "$RANDOM"
fi

Although it’s simple, I’m willing to give names to these operations. Example: rather than that big curl I want to call get_random_number_from_web.

To reach such goal we need to create functions, and for that we’re going to create a directory:

mkdir -p src/lib/

Now we create a file named src/lib/random_number_functions.sh and create the functions there, this way:

# src/lib/random_number_functions.sh

generate_random_number() {
  echo "$RANDOM"
}

get_random_number_from_web() {
  curl \
    --silent \
    --location \
    "https://www.random.org/integers/?num=1&min=0&max=32767&col=1&base=10&format=plain"
}

After doing this 👆 we can now make our src/root_command.sh much more pleasant to read:

# src/root_command.sh

if [[ "${args[--web]}" == 1 ]]; then
  get_random_number_from_web
else
  generate_random_number
fi

Run your rndm (you regenerated it, right?) and make sure things are working as expected.

A cool thing to notice is that Bashly took the contents of src/lib/random_number_functions.sh and put in the final script (the rndm file). That’s why we can call the functions we’ve created with no need to source anything.

You can keep in mind that Bashly take the contents of any src/lib/*.sh file and puts them in the final script. Therefore, it’s a good way to make your code modular, allowing you to keep each file focused on solving one kind of problem (aka Separation of Concerns). Your bash code can be more readable and organized.

Using --options-with arguments

Sometimes we want to generate a random number up to a given limit. For example to simulate rolling a dice. For such situation I’d like to have a command like this:

# prints a random number between 1 and 6
rndm --max 6

To create such option we need to add in our YAML a flag that accepts an argument:

name: rndm
help: Prints a random number
version: 0.0.1

dependencies:
  - curl

flags:
  - long: --web
    short: -w
    help: Get the random number from <https://random.org>.

  # specifying a flag that accepts an argument
  - long: --max
    arg: max_num
    help: Specifies the maximum number to be generated

Before writing any code, let’s check the help message:

$ ./rndm --help
rndm - Prints a random number

Usage:
  rndm [OPTIONS]
  rndm --help | -h
  rndm --version | -v

Options:
  --web, -w
    Get the random number from <https://random.org>.

  --max MAX_NUM
    Specifies the maximum number to be generated

  --help, -h
    Show this help

  --version, -v
    Show version number

An interesting detail: as we passed arg: max_num in the YAML, Bashly does two things:

  1. understand that the --max flag requires an argument
  2. mentions such requirement in the help message

Although we used the name max_num, this name is not used in our code (it’s used only in the help message).

In our code we get the value given to the --max via ${args[--max]}. Let’s get this value in src/root_command.sh and then pass it to our functions:

# src/root_command.sh

# note that the '--max' argument is
# obtained via '${args[--max]}':
max_number="${args[--max]}"
# we're putting it in a 'max_number' variable
# just to use it more easily below.

if [[ "${args[--web]}" == 1 ]]; then
  get_random_number_from_web "$max_number"
else
  generate_random_number "$max_number"
fi

Now we need to make use if it in our functions:

# src/lib/random_number_functions.sh

generate_random_number() {
  local max_number="$1"
  # new logic to generate a number up to a number
  echo $((RANDOM % max_number + 1))
}

get_random_number_from_web() {
  local max_number="$1"
  curl \
    --silent \
    --location \
    "https://www.random.org/integers/?num=1&min=0&max=${max_number}&col=1&base=10&format=plain"
    # specify the maximum value passed to random.org 👆
}

Let’s run it sometimes:

$ ./rndm --max 6
5

$ ./rndm --max 6
1

$ ./rndm --max 6
4

$ # from the web
$ ./rndm --max 6 -w
6

$ ./rndm --max 6 -w
4

$ # not specifying a maximum value
$ ./rndm
./rndm: line 29: RANDOM % max_number + 1: division by 0 (error token is "max_number + 1")

$ ./rndm --web
Error: The maximum value must be an integer in the [-1000000000,1000000000] interval

😱 – What?! Scary bugs!

If the user doesn’t specify a --max value, our scripts crash.

Let’s solve it!

Assigning a Default Value to an Argument

As we saw, our code is buggy! We ended up making it mandatory a max number to be given.

We can solve this by defining a default value. The question is: which value should we use as default?

In the rndm --web error message we can see that the maximum is 1,000,000,000 (one billion). However, our local version is not that powerful…

In the Bash manpage we can see (in the “Shell Variables” session) that the $RANDOM generates an integer between 0 and 32,767. So, for consistency sake, let’s set the default as 32767.

The good news is that Bashly offers a simple way to define a default value:

name: rndm
help: Prints a random number
version: 0.0.1

dependencies:
  - curl

flags:
  - long: --web
    short: -w
    help: Get the random number from <https://random.org>.
  - long: --max
    arg: max
    help: Specifies the maximum number to be generated
    # Look how simple is that!
    # NOTE: the "double-quotes" are mandatory,
  #       so the value is seen as a string.
    default: "32767"

Check the help:

$ ./rndm --help
rndm - Prints a random number

Usage:
  rndm [OPTIONS]
  rndm --help | -h
  rndm --version | -v

Options:
  --web, -w
    Get the random number from <https://random.org>.

  --max MAX_NUMBER
    Specifies the maximum number to be generated
    Default: 32767

  --help, -h
    Show this help

  --version, -v
    Show version number

Cool! It puts clearly for the user what’s the default value! 👍

Now let’s see if it actually works:

$ ./rndm
8654

$ ./rndm
26564

$ ./rndm --web
9511

$ ./rndm --web --max 100
45

$ ./rndm --web --max 100
3

$ ./rndm --max 100
88

Apparently it’s fine, but let’s try to mess things up: 😈

$ ./rndm --max texto
./rndm: line 29: RANDOM % max_number + 1: division by 0 (error token is "max_number + 1")

$ ./rndm --max texto --web
Error: The maximum value must be an integer in the [-1000000000,1000000000] interval

😖 – Ouch!

This feature looked innocent but brought a lot of headaches for us to handle… 😓

Validating Arguments

To fix the invalid input bug we’ll need to add a validation logic. Such validation needs to ensure the argument is a positive integer.

Let’s solve this with this regular expression: ^[1-9][0-9]*$. Which means “a digit between 1 and 9 followed by any amount of digits between 0 and 9”.

Our code:

# src/root_command.sh

max_number="${args[--max]}"

# abort execution if max_number is not a positive integer
if ! [[ "$max_number" =~ ^[1-9][0-9]*$ ]]; then
  echo "The argument must be a positive integer. Given value: $max_number"
  exit 1
fi

if [[ "${args[--web]}" == 1 ]]; then
  get_random_number_from_web "$max_number"
else
  generate_random_number "$max_number"
fi

Confirm it works:

$ ./rndm --max texto
The argument must be a positive integer. Given value: texto

$ ./rndm --max -1
The argument must be a positive integer. Given value: -1

$ ./rndm
26509

Alright, apparently it’s fine. But I don’t like this logic polluting my main code. Let’s move it to its own file named src/lib/validations.sh:

# src/lib/validations.sh

validate_positive_integer() {
  local number="$1"

  if ! is_positive_integer "$number"; then
    echo "The argument must be a positive integer. Given value: $number"
    exit 1
  fi
}

# personal rule:
# if something is done with a regular expression,
# it needs to be wrapped in a function (or variable)
# with a meaningful name.
is_positive_integer() {
  [[ "$1" =~ ^[1-9][0-9]*$ ]]
}

Now our main code can be simpler:

# src/root_command.sh

max_number="${args[--max]}"

# 👇 calling validation here
validate_positive_integer "$max_number"

if [[ "${args[--web]}" == 1 ]]; then
  get_random_number_from_web "$max_number"
else
  generate_random_number "$max_number"
fi

Confirm it’s still working:

$ ./rndm --max texto
The argument must be a positive integer. Given value: texto

$ ./rndm --max -1
The argument must be a positive integer. Given value: -1

Now it’s a nice time for a new commit.

Validating Arguments the Bashly way

Although we are already validating the --max input by calling validate_positive_integer from our src/root_command.sh, Bashly offers a cleaner way to execute such validation. In a way where we remove the reference to the validations from our code and keep our code tidy and focused on random numbers generation.

The Bashly way to make validations works this way:

  • In the flag configuration we add a line like this: validate: function_name.
  • Create a function called validate_function_name, which will be automatically called before the user input is used.
  • If the function prints anything to stdout, that’s considered an error. The content is print as an error message and the program aborts.

So, let’s apply this to our program.

Step 1: add validate: positive_integer in our src/bashly.yml:

name: rndm
# ...

flags:
  # ...
  - long: --max
    # ...
    # 👇👇👇 just add this line
    validate: positive_integer

Step 2: create a function named validate_positive_integer.

We already did it in the previous section. The function is saved in src/lib/validations.sh.

Step 3: the function needs to print something to stdout to be considered a failure

Our function already does that. The only thing to be changed is that we don’t need an explicit exit 1, as this is going to be handled by Bashly. Therefore, the new version is:

# src/lib/validations.sh

validate_positive_integer() {
  local number="$1"
  if ! is_positive_integer "$number"; then
    echo "The argument must be a positive integer. Given value: $number"
  fi
}

# ...

Nice! Let’s test it:

$ ./rndm
26086

$ ./rndm --max 0
validation error in --max MAX_NUMBER:
The argument must be a positive integer. Given value: 0

$ ./rndm --max texto
validation error in --max MAX_NUMBER:
The argument must be a positive integer. Given value: texto

$ ./rndm --max -1
validation error in --max MAX_NUMBER:
The argument must be a positive integer. Given value: -1

Cool! Bashly even improved the error message, making it clear that the validation failure is related to the --max option.

OK, we did this validation a-la-Bashly but our main code is still calling the validation function (which is not needed anymore). Let’s clean that up:

# src/root_command.sh

max_number="${args[--max]}"

if [[ "${args[--web]}" == 1 ]]; then
  get_random_number_from_web "$max_number"
else
  generate_random_number "$max_number"
fi

Much easier on the eyes, isn’t it? 🤩

Make new tests and confirm everything works fine. Alright… Maybe you’ll find more edge-cases to be addressed. But for our purpose of showing how to start working with Bashly I think we’re done.

Make a new commit and we’re going to finish this first part of the tutorial.

Finishing (for now)

I’d like to call your attention for a moment and invite you to appreciate our src/root_command.sh again. Look how simple that code is.

Check also the directory structure of our project:

$ tree
.
├── rndm
└── src
    ├── bashly.yml
    ├── lib
    │   ├── random_number_functions.sh
    │   └── validations.sh
    └── root_command.sh

2 directories, 5 files

From those 5 files, only 3 (small) ones are actual bash code, with specific goals:

  • validations.sh: responsible for input validation
  • random_number_functions.sh: storing the functions that actually bring random numbers.
  • root_command.sh: the entrypoint of our application, calling the right function based on user input.

Now let’s remember all the details we didn’t need to spend energy on because Bashly solved for us:

  • a professional help message
  • dependency checking
  • --options parsing
  • call the right input validation
  • code modularization

This is just a short intro to Bashly. If you want me to write more about this topic, leave a comment below! Bashly has more features to explore!

Main Takeaways

The bashly command

  • Bashly is a Ruby gem that depends on Ruby 3.2+.
    • No Ruby knowledge is necessary to use Bashly
  • The bashly generate command reads the src/bashly.yml file and generates the final script
    • Use bashly generate --watch to monitor changes and automatically generate.

Code Files

  • The src/bashly.yml file is a YAML that acts like an interface contract between our application and the user.
    • We can specify dependencies via dependencies:.
  • The src/root_command.sh file is the application’s “entry point.”
  • Files in src/lib/*.sh are all included in the final script.

Command Line Arguments

  • Flags
    • Values are stored in ${args[--flag-name]}
    • Boolean flags have a value of 1 when used
  • Argument validation:
    • In the flag configuration: validate: function_name
    • The validate_function_name function will be executed before using the user’s input.
    • If validate_function_name prints anything to stdout, the program aborts.

Reference

Bashly official documentation


This content originally appeared on DEV Community and was authored by meleu