A hands-on adventure through the terminal — from first commands to scripting mastery
$ echo "Let's roll initiative on some shell scripts"
This is your personal roadmap for learning shell scripting. Everything here is designed so you can learn by doing — open your Terminal on your Mac, follow along, and try every command yourself.
You'll be using zsh (the default shell on macOS) and learning bash-compatible scripting — the skills transfer between both. We'll start with navigating the terminal and editing files with nano, then level up from there.
Each module is a new chapter. Work through them in order. Don't rush — the goal is understanding, not speed.
Click any module header to expand it. Each module has lessons, commands to try in your terminal, and a quest (a mini-project) at the end. Check off items as you go!
On your Mac, open Terminal (search for it in Spotlight with Cmd + Space, then type "Terminal"). This is where you'll do everything.
When it opens, you'll see something called a prompt. It might look like:
eli@macbook ~ %
That % at the end means you're in zsh. Bash uses $. Both work almost identically for what we'll be doing.
The terminal always has a "current directory" — think of it as the room you're standing in.
# Print Working Directory — shows where you are
pwd
/Users/eli
# List what's in the current directory
ls
Desktop Documents Downloads Music ...
# List with more detail (long format, human-readable sizes)
ls -lh
# Change Directory — move into a folder
cd Documents
# Go back up one level
cd ..
# Go straight home from anywhere
cd ~
pwd — what directory are you in?ls to see what's therels -lh — notice the extra info (permissions, sizes, dates)cd Desktop, then pwd again to confirm you movedcd ~ to go back home# Create a new directory (folder)
mkdir shell-quest
# Move into it
cd shell-quest
# Create an empty file
touch hello.txt
# Verify it's there
ls
hello.txt
# Remove a file (careful — no trash can!)
rm hello.txt
# Remove a directory (must be empty, or use -r)
rmdir some-empty-folder
rm -r some-folder-with-stuff
rm is permanent. There's no undo, no Trash. Double-check before you delete. A good habit: run ls first to make sure you know what you're about to remove.
# Print the entire file to the screen
cat somefile.txt
# Scroll through a long file (press q to quit)
less somefile.txt
# Just see the first 10 lines
head somefile.txt
# Just see the last 10 lines
tail somefile.txt
# See a specific number of lines
head -n 5 somefile.txt
Tab — autocompletes file/folder names (use this constantly!)Up Arrow — scrolls through your previous commandsCtrl + C — cancel/stop the current running commandCtrl + L — clear the screen (or type clear)Ctrl + A — jump to the beginning of the lineCtrl + E — jump to the end of the lineCreate your workspace for this entire curriculum:
cd ~)shell-questscripts, notes, projectsls -R shell-quest to see the whole structure (-R means recursive)quest-log.txt inside notes~/shell-questscripts, notes, projects inside itls -Rquest-log.txtWhen you're working on a remote server, SSHing into a machine, or just want to stay in the flow, opening a graphical text editor slows you down. Terminal editors let you create and modify files without ever leaving the command line.
nano is the friendliest terminal editor — it shows its controls right on screen. Think of it as the sound board's "easy mode" before you graduate to the full mixing console (vim, eventually).
# Open (or create) a file
nano myfile.txt
# Open a file with line numbers shown
nano -l myfile.txt
When nano opens, you'll see a cursor and a bar at the bottom showing keyboard shortcuts. The ^ symbol means Ctrl.
| Shortcut | What It Does |
|---|---|
Ctrl + O | Save ("Write Out") — press Enter to confirm |
Ctrl + X | Exit nano (will ask to save if modified) |
Ctrl + K | Cut the current line |
Ctrl + U | Paste ("Uncut") the cut line |
Ctrl + W | Search for text |
Ctrl + G | Help screen |
Ctrl + / | Go to a specific line number |
cd ~/shell-quest/notesnano quest-log.txtCtrl + O, then EnterCtrl + Xcat quest-log.txtCtrl + K repeatedly — they all get added to the paste bufferCtrl + W then Ctrl + R inside the search to do search-and-replace~/.nanorc — but don't worry about that yetSound engineers keep cue sheets for shows. Create one!
~/shell-quest/notes/cue-sheet.txthead -n 3 cue-sheet.txt to see just the first 3 cuesCtrl + W to search for one of your cuesA shell script is just a text file full of commands that the shell runs in order. Instead of typing 10 commands one by one, you put them in a file and run it once. It's like programming a macro on a sound board — set it up once, trigger it anytime.
Every script starts with a special first line that tells the system which program should run it:
#!/bin/bash
This is called the shebang (hash + bang). Even though you're using zsh day-to-day, writing scripts with #!/bin/bash makes them portable — they'll work on almost any Unix/Linux system.
Your interactive shell is zsh, but your scripts will use bash. This is totally normal. The commands are 95% the same. We'll note the rare differences when they come up. You could also use #!/bin/zsh if you specifically want zsh features.
# Create the file
nano ~/shell-quest/scripts/hello.sh
Type this into nano:
#!/bin/bash
# My first shell script!
echo "Hello, Eli!"
echo "Today is $(date +%A), $(date +%B) $(date +%d)."
echo "You are logged in as: $(whoami)"
echo "Your current directory is: $(pwd)"
Save and exit. Now make it executable and run it:
# Make the script executable
chmod +x ~/shell-quest/scripts/hello.sh
# Run it!
~/shell-quest/scripts/hello.sh
Hello, Eli!
Today is Saturday, February 22.
You are logged in as: eli
Your current directory is: /Users/eli
echo — prints text to the screen$(command) — runs a command and inserts its output into the string. This is called command substitutionchmod +x — gives the file "execute" permission so you can run it as a program# — lines starting with # are comments (ignored by the shell). The shebang is a special exceptionVariables store values you can reuse. Think of them as labeled faders on a sound board.
#!/bin/bash
# Setting variables (NO spaces around the = sign!)
name="Eli"
character_class="Wizard"
level=17
# Using variables (prefix with $)
echo "$name the $character_class, Level $level"
Eli the Wizard, Level 17
# Curly braces help when text is right next to the variable
echo "${name}'s spell slots are full"
name = "Eli" (with spaces) does NOT work. Bash/zsh will think name is a command. It must be name="Eli" with no spaces around =.
Create a script called character-sheet.sh in your scripts folder:
echo to print a formatted character sheet, like:
============================
CHARACTER SHEET
============================
Name: Thaldrin
Race: Half-Elf
Class: Ranger
Level: 5
HP: 42
============================
A script that only prints static text is like a sound board you can't adjust. Let's make it interactive.
#!/bin/bash
echo "What is your character's name?"
read char_name
echo "Welcome, $char_name! Your quest begins."
read pauses the script and waits for the user to type something, then stores it in the variable.
# read with a prompt on the same line
read -p "Enter your character's name: " char_name
# read a secret (password) without showing it
read -sp "Enter the secret passphrase: " passphrase
echo # add a newline since -s hides the Enter
Scripts can make decisions just like choosing which sound cue to fire based on what's happening on stage.
#!/bin/bash
read -p "Roll for initiative (enter a number 1-20): " roll
if [ $roll -ge 15 ]; then
echo "Excellent roll! You act first."
elif [ $roll -ge 8 ]; then
echo "Decent. You're somewhere in the middle."
else
echo "Oof. The goblins go before you."
fi
Inside [ ] (called "test brackets"), you use special operators for numbers:
| Operator | Meaning | Example |
|---|---|---|
-eq | equal to | [ $a -eq 5 ] |
-ne | not equal to | [ $a -ne 5 ] |
-gt | greater than | [ $a -gt 5 ] |
-lt | less than | [ $a -lt 5 ] |
-ge | greater/equal | [ $a -ge 5 ] |
-le | less/equal | [ $a -le 5 ] |
For strings, use = and !=:
if [ "$answer" = "yes" ]; then
echo "Onward!"
fi
Always put variables in double quotes inside test brackets: [ "$var" -gt 5 ]. Without quotes, if the variable is empty, the shell sees [ -gt 5 ] which is a syntax error.
Bash gives you a built-in random number generator — perfect for dice rolls!
# $RANDOM gives a number from 0-32767
# Use modulo (%) to get a range, then add 1
# Roll a d20
d20=$(( (RANDOM % 20) + 1 ))
echo "You rolled a $d20!"
# Roll a d6
d6=$(( (RANDOM % 6) + 1 ))
echo "Damage: $d6"
$(( )) is arithmetic expansion — it does math inside a script.
Create dungeon-door.sh — a mini text adventure:
$RANDOM to generate a d20 roll for the attemptread for player input$RANDOM for the dice rollA for loop repeats a block of commands for each item in a list. Like running through a cue list on the sound board — one cue at a time.
#!/bin/bash
# Loop through a list of words
for creature in goblin orc dragon beholder; do
echo "A wild $creature appears!"
done
# Loop through a range of numbers
for i in {1..5}; do
echo "Round $i of combat!"
done
# Loop through files in a directory
for file in ~/shell-quest/notes/*; do
echo "Found file: $file"
done
A while loop keeps going as long as a condition is true.
#!/bin/bash
hp=20
while [ $hp -gt 0 ]; do
damage=$(( (RANDOM % 6) + 1 ))
hp=$(( hp - damage ))
echo "Hit for $damage damage! HP remaining: $hp"
done
echo "The monster is defeated!"
# break - exit the loop entirely
while true; do
read -p "Enter command (quit to exit): " cmd
if [ "$cmd" = "quit" ]; then
break
fi
echo "You entered: $cmd"
done
# continue - skip to the next loop iteration
for i in {1..10}; do
if [ $(( i % 2 )) -eq 0 ]; then
continue # skip even numbers
fi
echo "Odd number: $i"
done
{10..1})Create tennis-scorer.sh — a script that tracks points in a tennis game:
Bonus challenge: Handle deuce (40-40) and advantage!
On a sound board, you set up cue groups you can trigger whenever you need them. Functions work the same way in scripts — write a block of code once, call it by name.
#!/bin/bash
# Define a function
roll_d20() {
local result=$(( (RANDOM % 20) + 1 ))
echo $result
}
# Call it
my_roll=$(roll_d20)
echo "You rolled: $my_roll"
# Function with parameters
roll_dice() {
local sides=$1 # first argument
local count=$2 # second argument
local total=0
for i in $(seq 1 "$count"); do
total=$(( total + (RANDOM % sides) + 1 ))
done
echo $total
}
# Roll 3d6 (three six-sided dice)
damage=$(roll_dice 6 3)
echo "Fireball damage: $damage"
The local keyword keeps a variable inside the function so it doesn't leak out and accidentally overwrite things in the rest of your script. Always use local for variables that only matter inside a function.
You can pass arguments to a script when you run it from the command line:
./greet.sh Eli Wizard
Inside the script:
#!/bin/bash
echo "Script name: $0" # ./greet.sh
echo "First argument: $1" # Eli
echo "Second argument: $2" # Wizard
echo "All arguments: $@" # Eli Wizard
echo "Number of args: $#" # 2
Every command returns an exit code: 0 means success, anything else means failure. You can check the last exit code with $?:
ls /nonexistent-folder
echo "Exit code: $?" # probably 1 (error)
ls ~
echo "Exit code: $?" # 0 (success)
You can set your own exit code with exit:
if [ $# -eq 0 ]; then
echo "Usage: $0 <name>"
exit 1
fi
Create dice-roller.sh — a flexible dice rolling script:
./dice-roller.sh 2d8)IFS or cut)roll_dice function that takes sides and countExample run:
./dice-roller.sh 3d6
Rolling 3d6...
Roll 1: 4
Roll 2: 6
Roll 3: 2
Total: 12
You can send command output to a file instead of the screen:
# Write output to a file (creates or overwrites)
echo "Session notes" > notes.txt
# Append to a file (doesn't overwrite)
echo "More notes" >> notes.txt
# Redirect errors to a file
ls /fake/path 2> errors.txt
# Redirect both output and errors
ls /fake/path > output.txt 2>&1
The pipe (|) sends the output of one command as input to another. It's like routing audio from one channel to another on the sound board.
# Count how many files are in a directory
ls | wc -l
# Search through output
cat cue-sheet.txt | grep "Scene 2"
# Sort lines alphabetically
cat names.txt | sort
# Remove duplicate lines
cat names.txt | sort | uniq
# Chain multiple pipes
cat server.log | grep "ERROR" | wc -l
# ^ reads the log, filters for ERROR lines, counts them
| Command | What It Does |
|---|---|
grep "pattern" file | Search for lines matching a pattern |
wc -l | Count lines |
sort | Sort lines alphabetically |
uniq | Remove adjacent duplicates (sort first!) |
cut -d',' -f1 | Extract a field from delimited text |
tr 'a-z' 'A-Z' | Translate/replace characters |
sed 's/old/new/g' | Find and replace in text |
Create a script called show-report.sh that processes a show log:
show-log.txt with entries like:
CUE,1,lights,SUCCESS
CUE,2,sound,SUCCESS
CUE,3,sound,MISSED
CUE,4,lights,SUCCESS
CUE,5,sound,SUCCESS
CUE,6,projection,MISSED
An array holds a list of values. Think of it as a playlist on the sound board.
#!/bin/bash
# Create an array
party=("Thaldrin" "Mira" "Bork" "Zephyr")
# Access elements (0-indexed)
echo "Leader: ${party[0]}" # Thaldrin
echo "Healer: ${party[2]}" # Bork
# All elements
echo "Full party: ${party[@]}"
# Number of elements
echo "Party size: ${#party[@]}"
# Add to array
party+=("NPC Guide")
# Loop through array
for member in "${party[@]}"; do
echo "- $member"
done
Case statements are a clean way to handle multiple choices — much nicer than a long chain of if/elif:
#!/bin/bash
read -p "Choose your class (warrior/mage/rogue): " class
case $class in
warrior|fighter)
echo "Strength is your ally. +5 HP"
;;
mage|wizard)
echo "Knowledge is power. +3 spell slots"
;;
rogue|thief)
echo "Stealth is key. +2 sneak attack"
;;
*)
echo "Unknown class: $class"
;;
esac
The | lets you match multiple patterns. The *) is the default/catch-all. Each option ends with ;;.
This one's right up your alley! Create sound-board.sh:
♫ Playing: Thunder ♫
[====== ] 60%
[==========] 100% - Done!
Check if files and directories exist before working with them:
#!/bin/bash
if [ -f "config.txt" ]; then
echo "Config file found!"
else
echo "Config missing, creating default..."
echo "volume=80" > config.txt
fi
if [ -d "backups" ]; then
echo "Backup directory exists"
fi
if [ -x "myscript.sh" ]; then
echo "Script is executable"
fi
| Test | Checks If... |
|---|---|
-f file | File exists (and is a regular file) |
-d dir | Directory exists |
-e path | Anything exists at that path |
-r file | File is readable |
-w file | File is writable |
-x file | File is executable |
-s file | File exists and is not empty |
# Current date and time
date
# Formatted date
date +"%Y-%m-%d" # 2026-02-22
date +"%H:%M:%S" # 14:30:05
# Great for backup file names
backup_name="backup_$(date +%Y%m%d_%H%M%S).tar.gz"
echo "$backup_name"
backup_20260222_143005.tar.gz
#!/bin/bash
source_dir="$HOME/shell-quest"
backup_dir="$HOME/backups"
timestamp=$(date +%Y%m%d_%H%M%S)
backup_file="shell-quest-backup_${timestamp}.tar.gz"
# Create backup directory if it doesn't exist
if [ ! -d "$backup_dir" ]; then
mkdir -p "$backup_dir"
echo "Created backup directory"
fi
# Create the backup
tar -czf "$backup_dir/$backup_file" -C "$HOME" "shell-quest"
if [ $? -eq 0 ]; then
echo "Backup created: $backup_file"
echo "Size: $(du -h "$backup_dir/$backup_file" | cut -f1)"
else
echo "Backup failed!"
exit 1
fi
Create organize.sh — a script that organizes files in a directory by type:
images, documents, scripts, other.jpg .png .gif → images.txt .pdf .doc → documents.sh .py .js → scriptsTest it on a folder you fill with dummy files first!
You've learned the fundamentals. Now it's time to build something real. Pick one of these projects (or pitch your own):
Build a turn-based combat system in bash:
Build a full show management tool:
Build a tournament bracket system: