Entry 003
Scripting in the Bourne Again Shell (Bash) on Mac: A Beginner Tutorial

This tutorial teaches shell scripting on macOS, ending with scripts that download audio, make decisions, and redirect input and output.
Luis currently uses the Warp terminal and likes its graphical interface, so the examples were written with Warp in mind. Nothing in the tutorial depends on anything specific to Warp: every command and script works the same in the built-in Terminal app or any other terminal on a Mac.
AI-assistance disclosure: this tutorial was drafted by Claude Sonnet 5.5, an AI model made by Anthropic, working from Luis’s outline. Luis tested the commands and scripts on his Mac, so they should work as written. If something doesn’t work for you, message him on Instagram at @pro.sumer.
What you will learn
- Seeing your username, hostname, and current directory in the terminal (with optional Warp prompt setup)
- Moving around with
cd, and using environment variables,echo, and the special variables$0,$1,$2 - Writing, permissioning, and running a first script (an audio downloader built on
yt-dlp) - Using control structures:
if,case, andfor - Redirecting input and output (Example 2)
Part 1: Terminal setup and shell basics
A shell script is a text file of terminal commands that run in order. Bash stands for “Bourne Again Shell,” a pun on the older Bourne shell (sh) it replaced.
Your Mac’s interactive terminal uses zsh (the Z shell) by default, so the prompt you type commands into is zsh. The scripts in this tutorial still run in Bash, because each one starts with #!/bin/bash. That first line tells macOS to run the file with Bash no matter which shell you typed the command in. Bash is already installed at /bin/bash. Apple ships an older version (3.2), and everything in this tutorial works with it. Running bash args.sh also starts Bash explicitly.
Open a terminal
Any terminal works. The built-in Terminal app (in Applications, under Utilities) is enough. Luis uses Warp, a terminal with a graphical interface that groups each command with its output into a block, and has a free plan. To follow along in Warp, install it from warp.dev or with Homebrew:
brew install --cask warp
Creating a Warp account is optional.
See your username, hostname, and directory
Most terminal prompts already show your username, the computer’s name, and the current folder. In Warp, you can choose what the prompt displays:
- Open Settings, then Appearance.
- Under Input, click the prompt preview.
- In Edit prompt, choose Warp terminal prompt.
- Drag the items you want, such as the working directory, into the dashed box, then click Save changes.
The available items depend on your Warp version. Setting Input type to Shell (PS1) instead uses your shell’s own prompt.
In any terminal, the same three facts are available as commands:
whoami # your username
hostname # your computer's name
pwd # your current directory
Go to the home directory
cd ~ # or just: cd
pwd # prints /Users/<your-username>
~ is shorthand for your home directory. cd with no argument does the same thing.
Environment variables
A variable stores a value under a name. Create one with = (no spaces around it), and read it by putting $ in front of the name:
GREETING="Hello from the terminal"
echo $GREETING
A plain variable exists only in the current shell. Adding export makes it an environment variable, which child programs and scripts can also see:
export PROJECT_DIR="$HOME/scripts"
echo $PROJECT_DIR
printenv PROJECT_DIR # prove it is in the environment
Built-in examples to try: echo $HOME, echo $USER, echo $SHELL, echo $PATH.
Special variables: $0, $1, $2
Inside a script, the shell fills in special variables for you:
| Variable | Meaning |
|---|---|
$0 |
The name of the script |
$1, $2, … |
The first, second, … argument given to the script |
$# |
How many arguments were given |
$@ |
All the arguments |
First, create a folder for all your scripts and move into it. The scripts in this tutorial live in ~/scripts:
mkdir -p ~/scripts
cd ~/scripts
pwd # prints /Users/<your-username>/scripts
Create a file called args.sh, for example with vim args.sh, and paste:
#!/bin/bash
echo "Script name: $0"
echo "First argument: $1"
echo "Second argument: $2"
echo "Number of arguments: $#"
Vim basics: Vim starts in normal mode. Press i to enter insert mode, then paste or type. Press Esc to go back to normal mode, then type :wq and press Enter to save and quit. To quit without saving, type :q! and press Enter.
Run it by handing the file to bash (Example 1 explains how to make it directly runnable):
bash args.sh apple banana
Expected output: the script name args.sh, then apple, banana, and 2.
Hands-on Example 1: your first script
Goal: a script that takes a YouTube or SoundCloud link as an argument, downloads audio only, and embeds the video’s thumbnail as cover art in the file. Shell is a good fit here because the script’s job is to coordinate existing programs rather than do heavy computation.
Only download audio you have the right to save, such as your own uploads, Creative Commons tracks, or content the creator permits.
Install the tools
The command is yt-dlp (note the spelling). It uses ffmpeg to convert audio and embed the thumbnail.
brew install yt-dlp ffmpeg
If brew is not found, install Homebrew first from brew.sh.
Write the script
Create a folder for scripts and a new file:
mkdir -p ~/scripts
cd ~/scripts
vim getaudio.sh
Press i to enter insert mode, paste this version, then press Esc and type :wq then Enter to save and quit:
#!/bin/bash
# getaudio.sh - download audio only, with the thumbnail as cover art
# Usage: ./getaudio.sh <url>
yt-dlp \
--extract-audio \
--audio-format mp3 \
--audio-quality 192K \
--convert-thumbnails jpg \
--embed-thumbnail \
--add-metadata \
-o "$HOME/Music/yt-dlp/%(title)s.%(ext)s" \
"$1"
What each part does:
#!/bin/bash(the shebang) tells macOS which program should run the file.--extract-audioand--audio-format mp3keep only the audio and convert it to MP3.--embed-thumbnail(with--convert-thumbnails jpg) puts the thumbnail inside the file as cover art.-osets where the file goes and how it is named, using the video’s title."$1"is the first argument, the link you pass in. The quotes matter because links often contain&and?, which the shell would otherwise misread.
Give it permission and run it
A new file is not executable. Add the execute permission, then run it with ./ and the link as the argument:
chmod +x getaudio.sh
./getaudio.sh "https://www.youtube.com/watch?v=VIDEO_ID"
ls -l getaudio.sh shows an x in the permissions column once it is executable. The finished MP3 appears in ~/Music/yt-dlp/.
What chmod +x does
chmod means “change mode,” and the mode is a file’s permission settings. +x adds the execute permission, which lets the system run the file as a program. Each file has three basic permissions: read (r), write (w), and execute (x). A new file has read and write but no execute, so ./getaudio.sh fails with “permission denied” until +x is added.
Compare the permissions before and after:
ls -l getaudio.sh
# before: -rw-r--r-- (no x anywhere)
# after: -rwxr-xr-x (x added)
The string reads left to right: the first character is the file type (- for a regular file), followed by three characters each for the owner, the group, and everyone else. A plain +x adds execute for all three. To add it for the owner only, use chmod u+x getaudio.sh (u means user, the file’s owner).
Running bash getaudio.sh works without +x because bash is the program being run and the file is just input to it. Running ./getaudio.sh executes the file directly, which needs +x and the #!/bin/bash line at the top to tell macOS which program should interpret it.
Setting permissions with numbers
chmod also accepts a three-digit number instead of letters. Each permission has a value: read is 4, write is 2, and execute is 1. Add the values together for each of the three groups (owner, group, everyone else):
| Number | Letters | Meaning |
|---|---|---|
7 |
rwx |
4 + 2 + 1: read, write, execute |
6 |
rw- |
4 + 2: read and write |
5 |
r-x |
4 + 1: read and execute |
4 |
r-- |
Read only |
0 |
--- |
No access |
So chmod 755 getaudio.sh gives the owner read, write, and execute (7), and everyone else read and execute (5 and 5). That produces -rwxr-xr-x, the same result as chmod +x on a typical new file. chmod 700 getaudio.sh makes it usable by the owner only, and chmod 644 getaudio.sh returns it to a normal non-executable file (-rw-r--r--).
Example 1, continued: control structures and constant bit rate
The script above works but assumes everything goes right. This version adds an if statement, a case statement, and a for loop, and encodes at a constant bit rate (CBR) chosen by the user.
#!/bin/bash
# getaudio.sh - download audio-only MP3s with embedded thumbnails
# Usage: ./getaudio.sh <low|medium|high> <url> [url...]
OUT_DIR="$HOME/Music/yt-dlp"
# IF: require a quality plus at least one link
if [ "$#" -lt 2 ]; then
echo "Usage: $0 <low|medium|high> <url> [url...]"
exit 1
fi
# FOR + IF: make sure the needed programs are installed
for tool in yt-dlp ffmpeg; do
if ! command -v "$tool" > /dev/null; then
echo "Missing $tool. Install it with: brew install $tool"
exit 1
fi
done
# CASE: translate the quality word into a constant bit rate
case "$1" in
low) BITRATE="128K" ;;
medium) BITRATE="192K" ;;
high) BITRATE="320K" ;;
*)
echo "Unknown quality '$1'. Use low, medium, or high."
exit 1
;;
esac
shift # remove the quality, so "$@" now holds only the links
mkdir -p "$OUT_DIR"
# FOR: loop over every link given
for url in "$@"; do
# CASE: pattern matching on the link to name the source site
case "$url" in
*youtube.com*|*youtu.be*) SITE="YouTube" ;;
*soundcloud.com*) SITE="SoundCloud" ;;
*) SITE="an unrecognized site" ;;
esac
echo "Downloading from $SITE at $BITRATE (constant bit rate): $url"
# IF: act on whether yt-dlp succeeded
if yt-dlp \
--extract-audio \
--audio-format mp3 \
--audio-quality "$BITRATE" \
--convert-thumbnails jpg \
--embed-thumbnail \
--add-metadata \
-o "$OUT_DIR/%(title)s.%(ext)s" \
"$url"; then
echo "Saved to $OUT_DIR"
else
echo "Failed: $url"
fi
done
Example runs:
./getaudio.sh high "https://www.youtube.com/watch?v=VIDEO_ID"
./getaudio.sh medium "URL_ONE" "URL_TWO"
How each control structure is used
| Structure | Where | Purpose |
|---|---|---|
if |
Argument check, tool check, download result | Stop early or choose a message based on a test |
case |
Quality word, source site | Pick one value from several named options |
for |
Tool list, link list | Repeat the same steps for each item |
Why this is constant bit rate. Passing a value with a K suffix to --audio-quality (such as 192K) asks ffmpeg for a fixed bitrate, so every second of audio uses the same amount of data. Passing a number from 0 to 9 instead selects variable bit rate (VBR), where the bitrate rises and falls with the complexity of the audio. The case statement above is what turns low, medium, and high into 128K, 192K, and 320K.
Reading the argument check: [ "$#" -lt 2 ]
The first if in the script tests whether enough arguments were given:
if [ "$#" -lt 2 ]; then
echo "Usage: $0 <low|medium|high> <url> [url...]"
exit 1
fi
$#is the special variable holding the number of arguments given to the script. Running./getaudio.sh high "URL"makes$#equal 2.-ltmeans “less than.” Inside[ ], numbers are compared with letter operators instead of symbols.[ ... ]is the test command. It succeeds or fails, andifreacts to the result. The spaces inside the brackets are required.- The quotes around
$#are not needed for a number, but quoting variables is a good habit.
So the line reads “if the number of arguments is less than 2, then…”. With fewer than two arguments the script prints the usage message and runs exit 1. An exit status of 1 signals failure to the shell, while exit 0 means success.
| Operator | Meaning |
|---|---|
-eq |
equal to |
-ne |
not equal to |
-lt |
less than |
-le |
less than or equal to |
-gt |
greater than |
-ge |
greater than or equal to |
Try it directly in your terminal. The variable $? holds the exit status of the last command, and 0 counts as true:
[ 1 -lt 2 ]; echo $? # prints 0 (true)
[ 3 -lt 2 ]; echo $? # prints 1 (false)
Quick reference: "$tool", case, and "$@"
"$tool":toolis a variable name chosen infor tool in yt-dlp ffmpeg. The loop sets it to each item in turn, so$toolisyt-dlpon the first pass andffmpegon the second.command -v "$tool"checks whether that program is installed, and the!in front makes theifrun only when it is missing.casesyntax:case "$1" incompares the first argument against the patterns that follow. Each pattern ends with), its commands end with;;,*)is the catch-all that goes last, andesac(casebackwards) closes the block. Patterns can use wildcards and|for “or,” as in*youtube.com*|*youtu.be*)."$@": all the arguments, kept as separate items. Aftershiftremoves the quality word,"$@"holds only the links, sofor url in "$@"runs once per link. The quotes keep each argument intact, even if it contains spaces.
Hands-on Example 2: running from anywhere, with input and output redirection
Goal: turn getaudio.sh into a command named getaudio that works from any folder, always saves its files and a log to ~/Music, and can read its list of links from a file.
Redirection operators
| Operator | Meaning | Example |
|---|---|---|
> |
Send output to a file, replacing its contents | ls > files.txt |
>> |
Append output to the end of a file | echo done >> log.txt |
2> |
Send error messages (stream 2) to a file | ls missing 2> errors.txt |
2>&1 |
Send errors to the same place as normal output | command >> log.txt 2>&1 |
< |
Read input from a file instead of the keyboard | getaudio high < urls.txt |
| |
Pipe one command’s output into another’s input | echo "URL" | getaudio low |
Update the script
This version keeps the if, case, and for structures from Example 1. It now writes yt-dlp’s detailed output to a log with >> and 2>&1, and reads links from standard input when none are given as arguments.
#!/bin/bash
# getaudio - download audio-only MP3s with embedded thumbnails
# Usage: getaudio <low|medium|high> <url> [url...]
# getaudio <low|medium|high> < urls.txt
OUT_DIR="$HOME/Music/yt-dlp" # change to "$HOME/Music" to save directly in Music
LOG="$OUT_DIR/getaudio.log"
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <low|medium|high> [url...]"
exit 1
fi
case "$1" in
low) BITRATE="128K" ;;
medium) BITRATE="192K" ;;
high) BITRATE="320K" ;;
*) echo "Unknown quality '$1'. Use low, medium, or high."; exit 1 ;;
esac
shift
mkdir -p "$OUT_DIR"
# A function groups steps so they can be reused
download() {
echo "$(date '+%Y-%m-%d %H:%M:%S') START $1" >> "$LOG"
yt-dlp \
--extract-audio --audio-format mp3 --audio-quality "$BITRATE" \
--convert-thumbnails jpg --embed-thumbnail --add-metadata \
-o "$OUT_DIR/%(title)s.%(ext)s" \
"$1" >> "$LOG" 2>&1
}
run_one() {
if download "$1"; then
echo "OK: $1"
else
echo "FAILED: $1 (see $LOG)"
fi
}
if [ "$#" -gt 0 ]; then
# Links were given as arguments
for url in "$@"; do
run_one "$url"
done
else
# No arguments: read links from standard input (a file or a pipe)
if [ -t 0 ]; then
echo "No links given. Pass links as arguments or use: getaudio $BITRATE < urls.txt"
exit 1
fi
while read -r url; do
[ -z "$url" ] && continue # skip blank lines
run_one "$url"
done
fi
The [ -t 0 ] test is true when standard input is the keyboard. It stops the script from sitting silently waiting for input when you forgot to give it links.
Make it run from anywhere
The shell finds commands by searching the folders listed in the PATH variable. Create a personal bin folder, put the script there under a short name, and add the folder to PATH:
mkdir -p ~/bin
cp ~/scripts/getaudio.sh ~/bin/getaudio
chmod +x ~/bin/getaudio
echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
Check that it worked:
which getaudio # prints /Users/<you>/bin/getaudio
Because the script uses $HOME/Music/yt-dlp as an absolute path, the files go to the same place no matter which folder you run it from.
Try it
Create a text file with one link per line:
cd /tmp # any folder works
vim urls.txt # paste one YouTube or SoundCloud link per line
getaudio medium "URL_ONE" # links as arguments
getaudio high < urls.txt # links read from a file (input redirection)
echo "URL_ONE" | getaudio low # links read from a pipe
Then look at the results:
ls ~/Music/yt-dlp # the MP3 files
cat ~/Music/yt-dlp/getaudio.log # the full yt-dlp output and any errors
tail -f ~/Music/yt-dlp/getaudio.log # watch the log live in a second terminal tab
The terminal shows only a short OK or FAILED line per link, while >> and 2>&1 collect everything else in the log, which makes failed downloads easy to diagnose.
What I got with it
This is the track I downloaded with getaudio: “evanora:unlimited - stay in” by Marjorie -W.C. Sinclair (credit birdwatcher), from SoundCloud. It came out as a 192K constant bit rate MP3 with the cover art embedded, which is the medium setting.