Lab 2 — Shell Scripting: Build the bin/ Scripts
Time: ~3–4 hrs · Difficulty: Core · Builds on: Lab 1 (filesystem navigation)
Objective
You’ll learn shell scripting by building the first scripts of your milestone dotfiles repo: small programs that automate friction you actually hit. You’ll cover shebangs, the executable bit, arguments, conditionals, loops, pipes, and redirection — and you’ll write defensive scripts that fail loudly instead of silently. By the end you’ll have a ~/dev/dotfiles/bin directory on your PATH with at least four working scripts you can run by name from anywhere. Type every line yourself. A script you pasted is a script you can’t debug.
Setup
You need Lab 1 finished (Homebrew, Git, gh, and ~/dev). Create the project structure:
cd ~/dev
mkdir -p dotfiles/bin
cd dotfiles
Checkpoint: pwd prints /Users/<yourname>/dev/dotfiles and ls shows a bin directory.
If not: if ls shows no bin, the mkdir -p dotfiles/bin ran from the wrong place; cd ~/dev first, then rerun it. If pwd shows a different path, cd ~/dev/dotfiles.
Background
Recall first (from memory, before reading on): from Lab 1 — what does pwd print, and what is the difference between an absolute path like /Users/you/dev and a relative path like dev/sandbox? (You’ll lean on both constantly while testing scripts; if either is fuzzy, replay Lab 1 step 5 first.)
A shell script is a text file of commands the shell runs top to bottom — exactly the commands you’d type by hand, saved so you can replay them. The first line, the shebang (#!/usr/bin/env bash), tells the system which interpreter to run. The file must be marked executable (chmod +x) before you can run it by name. Scripts can read arguments ($1, $2, … and $@ for all of them), make decisions with if, and repeat with for/while. We standardize on bash for portability — it’s on every server you’ll meet later — even though your interactive shell is zsh.
Three things must line up for you to type a script’s name and have it run:
flowchart LR
A["File starts with a shebang #!"] --> B["File is marked executable (chmod +x)"]
B --> C["File's directory is on PATH"]
C --> D["You type the name; the shell runs it"]
Notice: miss any one of the three and you get an error — no shebang means “bad interpreter,” no chmod +x means “permission denied,” not on PATH means “command not found.” The error tells you which gate failed.
Steps
1. A first script and the executable bit
Create bin/hello with nano (you’ll get more nano in Lab 4; for now: type, then Ctrl-O Enter to save, Ctrl-X to exit):
nano bin/hello
Type exactly this:
#!/usr/bin/env bash
echo "Hello from your first script, $USER"
Save and exit. Now try to run it, then make it executable and run it again:
./bin/hello
chmod +x bin/hello
./bin/hello
The first attempt fails with permission denied; after chmod +x it works.
Checkpoint: the second ./bin/hello prints Hello from your first script, <yourname>.
If not: “permission denied” means the chmod +x didn’t take — rerun chmod +x bin/hello. “bad interpreter” means the shebang line has a typo or a blank line above it; it must be the very first line, exactly #!/usr/bin/env bash.
2. Put bin/ on your PATH
So you can type hello instead of ./bin/hello from anywhere, add your bin to PATH. Open your shell config:
nano ~/.zshrc
Add this line at the bottom (use your real path):
export PATH="$HOME/dev/dotfiles/bin:$PATH"
Save, exit, then reload and test:
source ~/.zshrc
cd ~
hello
Checkpoint: typing just hello from your home directory prints the greeting. echo $PATH shows your bin directory at the front.
If not: “command not found” means PATH didn’t pick up your bin — confirm you ran source ~/.zshrc (or opened a new terminal) and that the export PATH=... line uses your real path. Recall the PATH-resolution diagram in the README: the shell searches PATH directories in order and your bin must be among them.
The next three steps teach the lab’s real new skill — writing a defensive script (one that checks its arguments and fails loudly) — as a worked → faded → independent progression.
Stage 1 — Worked example (I do): Script 1, mkproject (arguments + conditionals)
Study and run this complete script. Every non-obvious line is explained below it; you are not inventing anything yet. Create ~/dev/dotfiles/bin/mkproject:
#!/usr/bin/env bash
set -euo pipefail
if [ $# -lt 1 ]; then
echo "usage: mkproject <name>" >&2
exit 1
fi
name="$1"
mkdir -p "$HOME/dev/$name"
cd "$HOME/dev/$name"
echo "# $name" > README.md
git init -q
echo "created project at $HOME/dev/$name"
set -euo pipefail makes the script exit on errors, on undefined variables, and on failures inside pipes — defensive by default. $# is the argument count; $1 is the first argument. >&2 sends the usage message to standard error. Make it executable:
chmod +x ~/dev/dotfiles/bin/mkproject
mkproject demo-app
ls ~/dev/demo-app
Checkpoint: ~/dev/demo-app exists with a README.md and a .git directory. Running mkproject with no argument prints the usage line and exits.
If not: if it ran but created nothing, check you made it executable and that set -euo pipefail is spelled exactly. If git init errored, confirm git --version works from Lab 1.
Stage 2 — Faded practice (we do): Script 2, todo (append + redirection + timestamps)
Now you fill in the mechanical parts. This is the same defensive pattern as mkproject, but the argument guard and the append line are left as TODOs. Use Script 1 as your reference. Create bin/todo:
#!/usr/bin/env bash
set -euo pipefail
log="$HOME/todo.md"
# TODO: if no arguments were given ($# is 0), print the existing log
# (cat "$log" 2>/dev/null) or "(no todos yet)", then exit 0.
if [ ____ ]; then
____
exit 0
fi
timestamp="$(date '+%Y-%m-%d %H:%M')"
# TODO: APPEND (not overwrite) a checkbox line to "$log".
# Format: "- [ ] <all the args> ($timestamp)". Recall: which operator appends?
echo "- [ ] $* ($timestamp)" ____ "$log"
echo "added: $*"
$* joins all arguments into one string, so todo buy milk logs the whole phrase. The two blanks are exactly the argument-guard and append-vs-overwrite ideas from Lab 1. Once filled in, make it executable and test:
chmod +x ~/dev/dotfiles/bin/todo
todo write lab 2 scripts
todo review pro git chapter 1
todo
For reference, the worked solution (only peek after attempting): the condition is [ $# -eq 0 ], the body is cat "$log" 2>/dev/null || echo "(no todos yet)", and the append operator is >>.
Checkpoint: todo with no args prints two timestamped lines; ~/todo.md exists and grows each time you add one.
If not: if your second todo overwrote the first instead of adding to it, you used > rather than >> — fix the append operator. If set -u complained, you left a blank unfilled.
Stage 3 — Independent (you do): Script 3, extract (a case statement)
No scaffolding now — only the goal and the pattern you’ve already practiced twice. Write bin/extract from scratch. It must:
- start with the shebang and
set -euo pipefail; - print
usage: extract <archive>to standard error and exit non-zero if given no argument (the same guard you wrote fortodo); - exit with a clear error if the argument is not an existing file;
- unpack the archive based on its extension. Use a
casestatement (cleaner than a chain ofifs) handling at least*.tar.gz/*.tgz(tar -xzf),*.zip(unzip), and a catch-all*that prints “don’t know how to handle” and exits non-zero; - print
extracted <file>on success.
If you get stuck on case syntax, the worked reference is in step 6 below — but try it yourself first; that struggle is where the learning is. Test with:
chmod +x ~/dev/dotfiles/bin/extract
cd ~/dev/sandbox
echo "hi" > sample.txt
zip sample.zip sample.txt
rm sample.txt
extract sample.zip
cat sample.txt
Checkpoint: sample.txt is recovered from the archive; extract on an unknown type prints a clear error and exits non-zero.
If not: if every file falls through to the catch-all, your case patterns are quoted wrong — patterns like *.zip) must be unquoted globs. Compare against the reference in step 6.
4. Script 3 — ccd (clone and cd, demonstrating a function caveat)
A real friction-killer: clone a repo and enter it. A plain script can’t change your shell’s directory (a script runs in a child process), so the idiomatic fix is a shell function in ~/.zshrc. This teaches an important boundary. Add to ~/.zshrc:
ccd() {
if [ $# -lt 1 ]; then
echo "usage: ccd <git-url>" >&2
return 1
fi
git clone "$1" && cd "$(basename "$1" .git)"
}
Reload and test on any public repo:
source ~/.zshrc
cd ~/dev/sandbox
ccd https://github.com/octocat/Hello-World.git
pwd
Checkpoint: after ccd, pwd shows you’re inside the freshly cloned Hello-World directory. You can explain why this had to be a function, not a bin/ script.
If not: if ccd “doesn’t change directory,” you wrote it as a bin/ script — a script runs in a child process and can’t move your shell. It must be a function in ~/.zshrc. If ccd isn’t found, you didn’t source ~/.zshrc.
6. Reference — the worked extract (compare against your Stage 3 attempt)
This is the worked solution for the extract you wrote in Stage 3. Compare it to yours and adopt whatever is cleaner; the full bin/extract should read:
#!/usr/bin/env bash
set -euo pipefail
if [ $# -lt 1 ]; then
echo "usage: extract <archive>" >&2
exit 1
fi
file="$1"
if [ ! -f "$file" ]; then
echo "extract: '$file' is not a file" >&2
exit 1
fi
case "$file" in
*.tar.gz|*.tgz) tar -xzf "$file" ;;
*.tar.bz2) tar -xjf "$file" ;;
*.tar) tar -xf "$file" ;;
*.zip) unzip "$file" ;;
*.gz) gunzip "$file" ;;
*) echo "extract: don't know how to handle '$file'" >&2; exit 1 ;;
esac
echo "extracted $file"
case matches the filename against patterns — much cleaner than a chain of ifs. You already tested extract in Stage 3; if your version differs, this reference shows the idiomatic shape.
7. Use a pipe and a loop together
Quick exercise to cement composition. Count how many of your scripts are executable:
ls -l ~/dev/dotfiles/bin | grep "rwx" | wc -l
And loop over them to print each one’s first line (its shebang):
for f in ~/dev/dotfiles/bin/*; do
echo "$f -> $(head -n1 "$f")"
done
Checkpoint: the pipe prints a count of executable scripts; the loop prints each script path with its shebang line.
If not: if the count is 0, none of your scripts are executable yet — rerun chmod +x on each. If the loop errors, you may have run it before any files exist in bin/.
Definition of Done
~/dev/dotfiles/binis on yourPATH(confirmed withecho $PATH).- At least four working tools exist:
mkproject,todo,extract(inbin/), andccd(a function in~/.zshrc). - Each
bin/script has a shebang, is executable, usesset -euo pipefail, and prints a usage line when called wrong.
Self-verify (should print OK):
command -v mkproject todo extract >/dev/null && type ccd >/dev/null && echo OK
Self-explain: in one sentence, why does typing todo by name work from any directory, while ./bin/hello only worked from inside ~/dev/dotfiles?
Stretch Goals
- Add
note— open or create a dated Markdown note (~/notes/$(date +%F).md) in your$EDITOR. - Add
serve— runpython3 -m http.serverin the current directory; accept an optional port argument. - Add
weather—curl wttr.in/<city>and print the result; default to a city if none is given. - Make every script print clean usage when given
-hor--help, using a shared pattern.
Troubleshooting
permission deniedrunning a script — you forgotchmod +x. Run it on the file.command not foundfor your own script —bin/isn’t onPATH, or you didn’tsource ~/.zshrc/ open a new terminal. Checkecho $PATH.bad interpreter/ script won’t run — your shebang line is wrong or has a typo; it must be the very first line, no blank line above it.ccd“doesn’t change my directory” as a script — correct: a script runs in a child process and can’t change the parent shell’s directory. It must be a function in~/.zshrc. This is a feature of how processes work, not a bug.set -ucomplains about an unset variable — you referenced a variable that might be empty. Quote it and provide a default, e.g."${VAR:-}", or check$#first.zip/unzipnot found — install withbrew install zip unzip(most Macs already have them).