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:
- read the
src/bashly.yml
file - understood that we want to create a script named
rndm
- read the script’s description in the
help:
directive - read the script’s version in
version:
- created a file named
src/root_command.sh
- 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:
- understand that the
--max
flag requires an argument - 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 thesrc/bashly.yml
file and generates the final script- Use
bashly generate --watch
to monitor changes and automatically generate.
- Use
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:
.
- We can specify dependencies via
- 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
- Values are stored in
- 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.
- In the flag configuration:
Reference
This content originally appeared on DEV Community and was authored by meleu