Programming by Stealth

A blog and podcast series by Bart Busschots & Allison Sheridan.

PBS 144 of X — Bash: Basic Shell Script IO

In the previous instalment we learned how to collect terminal commands into reusable shell scripts. Those scripts did exactly the same thing every time they were run because they had no way of accepting any input. The obvious next step is to learn how to send information into your scripts.

There are actually three ways of passing information into scripts, and arguably three forms of output too. You can pass command line arguments to scripts, you can prompt the user for input, your script will always finish with an exit code, and you can use unix-style pipes to redirect input to your scripts, and your scripts will produce both output and error output streams which can also be redirected. For a detailed look at what I call terminal plumbing, check out Taming the Terminal Instalment 15.

For reasons that will become obvious, we need to learn about flow of control (conditionals and loops) before we can fully use the streams, so we’ll actually put a pin in those until the end of our short excursion into shell scripting. In this instalment we’ll learn about the other three forms of IO, specifically, we’ll learn to pass arguments to our scripts, to ask the user for input, and how exit codes get generated. Exit codes are vital to understanding conditionals and loops in shell scripts, so that will tee us up perfectly for the next instalment.

Matching Podcast Episode

Listen along to this instalment on episode 758 of the Chit Chat Across the Pond Podcast.

You can also Download the MP3

Read an unedited, auto-generated transcript: CCATP_2023_02_04

Episode Resources

Command line Arguments

By far the simplest way to get some data in is to accept command line arguments, i.e. space-separated strings after the name of your script.

Bash makes command line arguments available to us in our scripts through special variables, $1 is the first argument, $2 the second, and so on. The command used to invoke the script is available as $0 in case you’re wondering why computer scientists were counting from 1 like regular humans for once 😉.

The script pbs144a-args.sh shows this in action:

#!/usr/bin/env bash

echo "I am '$0', my first arg is '$1', and my second is '$2'"

If we run the script with the command ./pbs144a-args.sh pancakes waffles we get:

bart@bartsmacstudio pbs144 % ./pbs144a-args.sh pancakes waffles
I am './pbs144a-args.sh', my first arg is 'pancakes', and my second is 'waffles'
bart@bartsmacstudio pbs144 %

Prompting the User

The Bash read command can read input from any stream, and saves it to a variable. By default, it reads a single line from the standard input (STDIN) which is generally the keyboard (unless you’re dealing with pipes, but we’re ignoring that for now). Read requires one argument, the name of the variable the read text should be saved to.

We could just use read in conjunction with echo to ask the user for input:

echo "What's your name?"
read name

This would print a line of text, then read a line of input, and save it to a variable named name, which we would access as $name, e.g. echo "Hi $name"'!' (note that we double-quote the bit of the string with the variable, then single-quote the bit with the trailing exclamation point which would be a special character within double quotes).

Since asking users for input is a very common use-case for read, there’s no need to use two commands, instead, we can specify a prompt for the user with the -p flag. As an example, here’s pbs144b-prompt.sh:

#!/usr/bin/env bash

read -p "What's your name? " name
read -p "What's your favourite fruit? " fruit
echo "Hi $name, have some $fruit"'s 🙂'

When we run it and enter ‘Bart’ and ‘apple’ we get the following:

bart@bartsmacstudio pbs144 % ./pbs144b-prompt.sh
What's your name? Bart
What's your favourite friut? apple
Hi Bart, have some apples 🙂
bart@bartsmacstudio pbs144 %

Again, note the mix of quoting to allow the trailing s after the variable to be added without Bash wrongly assuming you meant to print the variable $fruits instead of $fruit.

Exit Codes

Every terminal command you run finishes by returning an exit code to the operating shell to signal its success or failure. Zero means no error, and any other number means something went wrong, and each command/app gets to choose a meaning to assign to each code it returns. You’ll often find a section at the bottom on the manual pages for terminal commands titled something like EXIT STATUS or EXIT VALUES describing all the error codes the app could return, and what they mean. For example, in the man page for rsync (man rsync) you’ll find:

       0      Success

       1      Syntax or usage error

       2      Protocol incompatibility

       3      Errors selecting input/output files, dirs

       4      Requested action not supported: an attempt was made to manipulate 64-bit files on a platform that cannot support
              them; or an option was specified that is supported by the client and not by the server.

       5      Error starting client-server protocol

       6      Daemon unable to append to log-file

       10     Error in socket I/O
       11     Error in file I/O

       12     Error in rsync protocol data stream

       13     Errors with program diagnostics

       14     Error in IPC code

       20     Received SIGUSR1 or SIGINT

       21     Some error returned by waitpid()

       22     Error allocating core memory buffers
       23     Partial transfer due to error

       24     Partial transfer due to vanished source files

       25     The --max-delete limit stopped deletions

       30     Timeout in data send/receive

In most of computer science, 0 means false or fail or error or something bad, and 1 means true or something good, but POSIX-style shells like sh, bash, and zsh are different! To help keep this straight in my head, I mentally refer to exit codes as error codes, so 0 means no error. By all means do this in your own head, but don’t write it down that way or people will assume you’re some kind of terminal amateur 🙂

Anyway, these exit codes are returned to the shell, and they’re not generally shown to the user in any way. However, we can use the special $? variable to read the exit code returned by the previous command. For example, if we successfully list the contents the root directory one item per line we can see that the ls command returned an exit code of 0:

bart@bartsmacstudio pbs144 % ls -1 /
bart@bartsmacstudio pbs144 % echo $?
bart@bartsmacstudio pbs144 % 

Exit Codes in Scripts

When working with shell scripts, the exit code returned by the last command executed within a script becomes the script’s exit code.

We can also use the exit command to end our script with an explicit exit code (passed as the first argument). To end our script with an exit code of 101, simple use:

exit 101

Exit Codes as Pseudo-booleans

These exit cods are the closest Bash gets to traditional booleans, and they are the key to all control of flow in shell scripts — conditional statements branch based on the most recent exit code, and loops continue until some exit code becomes zero. Even the boolean operators && for a logical and and || for a logical or work with exit codes. In Bash the exit code from an && operator is very weird when you peep under the hood, but it works as expected when you say it out loud.

Exit code 1 Exit code 2 Result Description
0 0 0 Success and success is success
0 >0 1 Success and error is error
>0 0 1 Error and success is error
>0 >0 1 Error and error is error

The same is true for the || logical or:

Exit code 1 Exit code 2 Result Description
0 0 0 Success or success is success
0 >0 0 Success or error is success
>0 0 0 Error or success is success
>0 >0 1 Error or error is error

When you combine the above tables with the fact that Bash uses so-called lazy evaluation for both && and ||, you can use the exit code from one command to conditionally run another. Lazy evaluation is a method for evaluating boolean operations where you stop the moment you know what the answer will be. For example, if you’re doing an or, and you run the first command and the result is success, then the result of the second command could not change the outcome (both success or success and success or error evaluate to success), so the second command is not executed!

This gives us two very common patterns:

# only run the second command if the first fails
cmd1 || cmd2

# only run the second command if the first succeeds
cmd1 && cmd2

Those make more sense with real-world examples:

# exit with exit code 404 if nginx fails to restart
systemctl restart nginx.service || exit 404

# only restart nginx if the config is valid
nginx -t && systemctl restart nginx.service

This gives us some of the power of true conditionals like if, else, and elif, but only some.

Final Thoughts

Our exploration of basic shell scripting is progressing nicely, we can now write and run scripts, work with variables, take input from the user, and even do some basic conditional behaviour with exit codes and logical operators. We’re not ready to move into the most powerful thing we’ll learn in this mini-series, flow of control, specifically, true conditional statements, and loops. That will then set us up to properly process the standard POSIX streams used by the pipe operator.

Join the Community

Find us in the PBS channel on the Podfeet Slack.

Podfeet Slack