Entry 003

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

Luis in a black t-shirt printed with “oh my zsh” in outlined, slanted lettering, a small DJI wireless microphone clipped to the collar, in front of a shoji screen

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, and for
  • 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:

  1. Open Settings, then Appearance.
  2. Under Input, click the prompt preview.
  3. In Edit prompt, choose Warp terminal prompt.
  4. 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-audio and --audio-format mp3 keep only the audio and convert it to MP3.
  • --embed-thumbnail (with --convert-thumbnails jpg) puts the thumbnail inside the file as cover art.
  • -o sets 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.
  • -lt means “less than.” Inside [ ], numbers are compared with letter operators instead of symbols.
  • [ ... ] is the test command. It succeeds or fails, and if reacts 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": tool is a variable name chosen in for tool in yt-dlp ffmpeg. The loop sets it to each item in turn, so $tool is yt-dlp on the first pass and ffmpeg on the second. command -v "$tool" checks whether that program is installed, and the ! in front makes the if run only when it is missing.
  • case syntax: case "$1" in compares the first argument against the patterns that follow. Each pattern ends with ), its commands end with ;;, *) is the catch-all that goes last, and esac (case backwards) closes the block. Patterns can use wildcards and | for “or,” as in *youtube.com*|*youtu.be*).
  • "$@": all the arguments, kept as separate items. After shift removes the quality word, "$@" holds only the links, so for 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.