Matheion

MA-CLI1 · Module 1.13

Shell scripting basics

Topics: Scripts · Shebang · chmod +x

Overview

Module 1.13 — Shell scripting basics

A shell script is nothing more than a text file of commands you already know, saved so you can run them again with one line. In this module Sam turns a few of the commands from earlier modules into a small tidewatch helper that checks a data file, reports what it finds, and stops safely when something is wrong.

By the end you should be able to:

  • write a script that starts with a shebang such as #!/bin/sh;
  • make it executable with chmod u+x and run it with ./;
  • use variables, sensible quoting, and the first argument $1;
  • write a simple if / then / fi test such as [ -f file ];
  • read an exit status with $? and add set -e as a gentle safety habit.

The key idea to keep in mind: you are not learning a new language so much as writing down what you would type anyway.

Scroll Core ideas → worked examples → Quiz. Plan on roughly 45–55 minutes.

Concepts

Core ideas — saving commands so you can run them again

By now Sam types a lot of small, sensible commands: check that a file exists, look at its first line, copy it somewhere, list the result. Often the same sequence is needed again tomorrow, with a different file. Retyping it each time is slow, and every retype is a chance for a slip.

A shell script solves this. It is simply a plain text file that contains commands, one after another, exactly as you would type them at the prompt. When you run the file, the shell reads it from top to bottom and carries out each line in turn.

That is the most important thing to understand, so it is worth saying plainly: there is nothing mysterious inside a script. If you can type a command at the prompt, you can put it in a script. Everything else in this module (variables, tests, exit codes) is a small set of extra tools for making those saved commands flexible and safe.


The smallest possible script

Create a file called hello_tide.sh inside ~/cli-practice/tidewatch/scripts/, containing two lines:

#!/bin/sh
echo "Hello from tidewatch"

You can write it with any text editor, or straight from the terminal:

printf '#!/bin/sh\necho "Hello from tidewatch"\n' > scripts/hello_tide.sh

The .sh ending is a convention that tells humans, "this is a shell script." The system does not require it, but it is a good habit.


The shebang line

Look at the first line: #!/bin/sh. It is called the shebang (the characters #! are sometimes read aloud as "hash-bang"). It tells the system which program should read and run this file. Here it says: "use the shell located at /bin/sh."

Two shebangs you will see most often:

| First line | Meaning | |------------|---------| | #!/bin/sh | run with the basic, widely available shell | | #!/bin/bash | run with bash, which offers extra conveniences |

For the beginner material in this module, either works, and #!/bin/sh has the benefit of being available almost everywhere. Choose one and put it on the very first line, with nothing before it.

The # symbol does double duty. Everywhere except that special first line, a # starts a comment: the shell ignores everything from the # to the end of the line. Use comments to leave notes for your future self:

# check the gauge file before syncing

Making it runnable — chmod u+x

Save the file and try to run it:

./scripts/hello_tide.sh
zsh: permission denied: ./scripts/hello_tide.sh

This is not a scripting problem; it is the permission system from module 1.5. A newly created file is not executable by default. You must give yourself the execute permission, which for the user (the owner) is written u+x:

chmod u+x scripts/hello_tide.sh
ls -l scripts/hello_tide.sh
-rwxr--r--  1 sam  crew  42 Oct 14 09:40 hello_tide.sh

The x in the owner section tells you that Sam may now run it. Then:

./scripts/hello_tide.sh
Hello from tidewatch

Remember from module 1.10 why the ./ is needed: the script's folder is not on PATH, so you point at it explicitly. You can also run a script without the execute permission by handing it to the shell yourself, as in sh scripts/hello_tide.sh. That works, but the shebang-plus-chmod route is the normal way and lets the file behave like any other command.


Variables inside scripts

Variables work exactly as in module 1.10: a name, an equals sign, no spaces, and a $ when you read it back.

#!/bin/sh
station="North Pier"
echo "Station: $station"

Variables make a script easier to change, because a value that appears several times only needs editing in one place.

Quoting, in three sentences

Quotes decide how the shell treats spaces and special characters:

  • Double quotes "..." keep the text together as one piece but still replace $variables with their values.
  • Single quotes '...' keep everything exactly as typed, so $station stays the literal five characters rather than being replaced.
  • With no quotes, a value containing spaces is split into separate words, which is a frequent source of bugs.
station="North Pier"
echo "Station: $station"      # Station: North Pier
echo 'Station: $station'      # Station: $station

A reliable habit: put double quotes around every $variable you use, as in "$station" and "$file". It costs nothing and prevents a whole class of mistakes, especially with file names that contain spaces.


Arguments: $1, $2, and $0

A script becomes truly reusable when you can hand it different inputs each time. Words you type after the script name are called arguments, and inside the script they are available as positional parameters:

| Name | Holds | |------|-------| | $1 | the first argument | | $2 | the second argument | | $0 | the name the script was run as |

Suppose greet.sh contains:

#!/bin/sh
echo "Checking gauge: $1"

Running it like this:

./greet.sh gauge_a

prints Checking gauge: gauge_a. The word gauge_a arrived as $1. Run with no argument, $1 is simply empty. Many scripts begin by copying $1 into a named variable, such as file="$1", which reads far more clearly than a bare number further down.


Making a decision — if, then, fi

Sometimes a script should do one thing when a condition is true and another when it is not. The shape is:

if [ -f "$file" ]; then
  echo "found it"
else
  echo "not found"
fi

Read it aloud as a sentence: "if the file exists, then say found, else say not found, and fi." The word fi is if spelled backwards, and it marks the end of the block. The else part is optional.

The part inside the square brackets is the test. A few that are useful right away:

| Test | True when | |------|-----------| | [ -f "$x" ] | $x is an existing regular file | | [ -d "$x" ] | $x is an existing directory | | [ -z "$x" ] | $x is empty (zero length) |

These are the same checks you have been doing by eye with ls; the script just does them automatically.

Two details trip up almost every beginner:

  1. Spaces are required inside the brackets. Write [ -f "$file" ], with a space after [ and before ]. The bracket is really a command named [, and commands need spaces to separate their words. [-f "$file"] fails.
  2. The ; before then (or putting then on its own line instead) separates the test from the keyword. Either style is fine; just be consistent.

Indentation inside the block is only for your eyes. The shell does not care, but two spaces make scripts readable.


Exit status — did that work? $?

Every command, when it finishes, reports a small number called its exit status. The convention is simple and worth memorising:

  • 0 means success.
  • Anything else (1, 2, and so on) means something went wrong.

The shell stores the status of the most recent command in the special variable $?. You can read it right away:

ls tidewatch
echo $?
ls no_such_folder
echo $?
data  notes  scripts
0
ls: no_such_folder: No such file or directory
1

The exact non-zero number can differ between systems (some report 2 here); what matters is that it is not 0. Note that $? is overwritten by every command, including the echo that prints it, so read it immediately.

This is also how if works underneath. The test [ -f "$file" ] is a command that exits with 0 if the file exists and non-zero if not, and if simply asks, "did that command succeed?"

Your own scripts have an exit status too. By default it is that of the last command run. You can choose it deliberately with the exit command:

exit 0     # finished successfully
exit 1     # finished, but something was wrong

Choosing a non-zero status when something fails lets other commands and scripts, and you, notice the failure with echo $?.


A gentle safety habit — set -e

By default a script keeps going after a command fails. Imagine this three-line script:

cp data/raw/gauge_z.csv backup/
echo "copy complete"
rm data/raw/gauge_a.csv

If gauge_z.csv does not exist, the cp fails, but the script prints "copy complete" anyway and then deletes the original. That is precisely the kind of surprise you want to avoid.

The line set -e, placed near the top, changes the rule: stop the script immediately if any command fails.

#!/bin/sh
set -e
cp data/raw/gauge_z.csv backup/
echo "copy complete"

Now, when cp fails, the script ends right there, and the false "copy complete" message never appears. Think of it as a seat belt: you rarely notice it, and on the one bad day it matters a great deal.

A few honest notes so you do not over-trust it:

  • set -e is a safety net, not a guarantee. It does not stop on failures inside an if test, which is intentional, since the test is meant to produce true or false.
  • It helps most in scripts that change things (copying, moving, deleting), where continuing after an error does harm.
  • If your script expects a command might fail and wants to handle it, do the check with if instead.

Scripts are your commands, written down

Step back and notice how little is new. Everything in a script is something you could type live:

| In a script | Where you already met it | |-------------|--------------------------| | cp, mv, mkdir, ls | modules 1.3 and 1.4 | | chmod u+x to make it runnable | module 1.5 | | cat, head, tail | module 1.6 | | $VARIABLE, export, ./ | module 1.10 | | exit status and kill outcomes | module 1.11 | | tar -czf in a backup script | module 1.12 |

The new parts are only the glue: a shebang to pick the shell, variables and arguments to flex, if to decide, and exit status to report. A good way to write a script is to first get the commands working by hand at the prompt, then paste the working lines into a file and add the glue.


Summary

  • A script is a text file of commands; the shell runs it from top to bottom.
  • Start with a shebang (#!/bin/sh or #!/bin/bash) on line one; # elsewhere begins a comment.
  • Make the file runnable with chmod u+x, then run it as ./name.sh.
  • Variables use name=value with no spaces; read them as "$name", double-quoted by habit.
  • $1, $2 hold arguments; $0 holds the script's name.
  • if [ -f "$file" ]; then ... fi makes simple decisions; spaces inside the brackets are mandatory.
  • $? holds the last exit status: 0 is success, anything else is failure; exit N sets your own.
  • set -e stops a script at the first failing command, which is a good habit for scripts that change files.

Examples

Worked examples — a small tidewatch helper, built step by step

Sam wants a helper that looks at a gauge data file and says whether it is ready for the day. He builds it in small pieces, each of which is just commands he already knows. Each scene gives a situation, the code, an interpretation, and a takeaway. Dates and permissions on your machine will differ slightly.

First, a small setup:

cd ~/cli-practice/tidewatch
mkdir -p scripts data/raw
printf 'gauge,time,level_cm\nA,06:00,112\nA,07:00,118\nA,08:00,131\n' > data/raw/gauge_a.csv

Scene 1 — A first script, and the permission wall

Scene. Sam saves a one-line script and tries to run it.

printf '#!/bin/sh\necho "gauge check starting"\n' > scripts/check_gauge.sh
./scripts/check_gauge.sh
zsh: permission denied: ./scripts/check_gauge.sh

Interpretation. The file exists and its content is fine. The problem is the permission system from module 1.5: new files are not executable. Sam grants the owner execute permission and tries again:

chmod u+x scripts/check_gauge.sh
./scripts/check_gauge.sh
gauge check starting

Takeaway. The recipe is write, chmod u+x, run with ./. A permission denied on a script you just wrote almost always means the execute bit is missing.


Scene 2 — Variables and quoting

Scene. Sam stores the station name in a variable and prints it two ways to see how quotes behave.

#!/bin/sh
station="North Pier"
echo "Double quotes: $station"
echo 'Single quotes: $station'
echo "Gauge file: data/raw/gauge_a.csv"
Double quotes: North Pier
Single quotes: $station
Gauge file: data/raw/gauge_a.csv

Interpretation. In double quotes the shell replaced $station with North Pier, keeping it as one piece. In single quotes nothing was replaced, so the dollar sign and name appear literally. Sam notes that the assignment line has no spaces around the =, and decides to use double quotes around every $variable from now on.

Takeaway. Double quotes substitute, single quotes preserve. Quote your variables by habit.


Scene 3 — An argument and an if test

Scene. Rather than hard-coding the file, Sam lets the person running the script choose it. He rewrites check_gauge.sh so the first argument is the file, and uses if to check that it exists.

#!/bin/sh
file="$1"

if [ -f "$file" ]; then
  echo "Found: $file"
  echo "Header: $(head -n 1 "$file")"
else
  echo "Missing: $file"
fi

The expression $(head -n 1 "$file") runs the command inside the brackets and substitutes its output into the line. It is a small bonus you can use whenever you want a command's result inside text. Sam saves the file and runs it twice:

./scripts/check_gauge.sh data/raw/gauge_a.csv
./scripts/check_gauge.sh data/raw/gauge_b.csv
Found: data/raw/gauge_a.csv
Header: gauge,time,level_cm
Missing: data/raw/gauge_b.csv

Interpretation. In the first run $1 was data/raw/gauge_a.csv, which exists, so the then branch ran and printed the header line. In the second run the file does not exist, so the else branch ran. The same script produced two different outcomes purely from its input, which is the point of arguments and tests.

Takeaway. file="$1" gives the argument a readable name, and if [ -f "$file" ]; then ... else ... fi handles both cases. Remember the spaces inside the brackets.


Scene 4 — Exit status and set -e

Scene. The script prints "Missing", but both runs ended quietly, and Sam would like callers to be able to tell success from failure. First he checks what the second run reported:

./scripts/check_gauge.sh data/raw/gauge_b.csv
echo $?
Missing: data/raw/gauge_b.csv
0

Interpretation. The status is 0, meaning "success," even though the file was missing. The script's last command was an echo, and that succeeded, so the script reported success. To tell the truth, Sam adds exit 1 to the else branch and exit 0 at the end of the then branch:

else
  echo "Missing: $file"
  exit 1
fi

After saving, running it again and checking $? gives:

Missing: data/raw/gauge_b.csv
1

Next, Sam adds a copy step to the helper, and wants the script to stop if the copy fails. He places set -e near the top, then tries a copy of a file that does not exist:

#!/bin/sh
set -e
echo "step 1: copying"
cp data/raw/gauge_z.csv /tmp/gauge_z_backup.csv
echo "step 2: this line should not appear"
step 1: copying
cp: data/raw/gauge_z.csv: No such file or directory

Interpretation. The cp failed, and because of set -e the script ended immediately. The "step 2" line never printed, so nothing later could act on the false assumption that the copy worked. (Without set -e, step 2 would have appeared. Try it by deleting that line.)

Takeaway. 0 means success and non-zero means failure, so choose your script's exit status deliberately. set -e stops a script at the first failing command.


Scene 5 — The finished helper, end to end

Scene. Sam combines everything into the final check_gauge.sh, with a usage message when no argument is given.

#!/bin/sh
# check_gauge.sh - report whether a gauge CSV is ready
set -e

file="$1"

if [ -z "$file" ]; then
  echo "usage: ./scripts/check_gauge.sh <csv-file>"
  exit 2
fi

if [ -f "$file" ]; then
  echo "Found: $file"
  echo "Header: $(head -n 1 "$file")"
  exit 0
else
  echo "Missing: $file"
  exit 1
fi

Sam saves it, makes sure it is executable, and runs three cases:

chmod u+x scripts/check_gauge.sh
./scripts/check_gauge.sh; echo "status: $?"
./scripts/check_gauge.sh data/raw/gauge_a.csv; echo "status: $?"
./scripts/check_gauge.sh data/raw/gauge_b.csv; echo "status: $?"
usage: ./scripts/check_gauge.sh <csv-file>
status: 2
Found: data/raw/gauge_a.csv
Header: gauge,time,level_cm
status: 0
Missing: data/raw/gauge_b.csv
status: 1

Interpretation. Each run took a different path through the script. With no argument, $1 was empty, so [ -z "$file" ] was true; the usage line printed, and the script ended with status 2. With a real file, the -f test passed and the script exited 0. With a missing file, it printed a message and exited 1. Every line of this script is something Sam could have typed by hand: echo, head, a couple of tests, and exit. The script just remembers them in order and handles the cases consistently.

Takeaway. A useful helper is small: a shebang, a few commands, one or two tests, and honest exit codes. Build it in steps, run it after every change, and keep the commands inside it ones you understand.

Practice check — shell scripting basics

Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.

  1. 1.What does the first line `#!/bin/sh` of a script do?

  2. 2.Sam saves `scripts/check_gauge.sh` and gets `permission denied` when running `./scripts/check_gauge.sh`. What is the usual fix?

  3. 3.Inside a script, `station="North Pier"` is followed by `echo 'Station: $station'`. What is printed?

  4. 4.A script is run as `./greet.sh gauge_a`. Inside the script, which variable holds `gauge_a`?

  5. 5.Which test line is written correctly to check that `$file` is an existing regular file?

  6. 6.Right after a command finishes, `echo $?` prints `0`. What does that mean?

  7. 7.Why might Sam put `set -e` near the top of a script that copies and then deletes files?