Module 7 — Script Arguments and User Input

Updated 3 September 2026

Module 7 — Script Arguments and User Input. A script you can only edit is a note; a script that takes arguments is a tool. How deploy.sh staging --dry-run actually works.

🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)

Before you start. You need Modules 1–6. Load-bearing: quoting (Module 2 — the "$@" story is a quoting story), exit codes and the usage-error convention (Module 3), >&2 (Module 4), case and [ ] (Module 5), while and arithmetic (Module 6 — getopts is a while-case machine). Module 5's route.sh already used $1 on credit; this module pays that debt. Tools: nothing new.

Part A — Positional parameters: what the caller typed

A1. $1, $2, … $0, and $#

When someone runs ./greet.sh staging blue, bash takes the words after the script's name and files them into numbered variables before line one executes: $1 is staging, $2 is blue — the positional parameters. Three relatives complete the set: $0 holds the name the script was invoked by (./greet.sh here — useful in usage messages); $# holds the count of arguments (2 here); and positions past nine need braces — ${10} — because $10 reads as $1 followed by a literal 0 (Module 2's greedy-name rule biting in a new place).

These are variables with the usual rules — which means the usual reflex: "$1" in quotes, everywhere — an argument can contain spaces (a filename, a message) and unquoted it word-splits exactly like any other expansion. And an argument never supplied expands to nothing, silently (Module 2 A1's silence — now arriving from the outside world, where you control it even less).

Real-world analogy — the job ticket's numbered fields. A print-shop job ticket has numbered blanks: field 1 — paper size, field 2 — copies. The customer fills the blanks; the machine operator (your script) reads them by number and never asks who wrote them. The shop's name printed on the ticket header is $0; the "fields filled: N" tally is $#.

Where the analogy stops working. A paper form shows its empty fields — you can see blank where "copies" should be. A script cannot see absence: $2 unset and $2 never-provided look identical (empty), and the machine runs anyway with a blank where a value belonged. That is why Part A2's counting guard exists — the tally $# is the only witness that fields were left unfilled.

🧪 Exercise 7.1 — the numbered blanks

The second run is incomplete on purpose.

bash
cd ~/bash-course
nano greet.sh     # four lines:
bash
#!/bin/bash
echo "script name: $0"
echo "first arg:   $1"
echo "second arg:  $2"
echo "arg count:   $#"
bash
chmod +x greet.sh
./greet.sh staging blue
./greet.sh              # ← no arguments at all
Expected result — click to reveal (second run runs on empty)
plain text
script name: ./greet.sh
first arg:   staging
second arg:  blue
arg count:   2
script name: ./greet.sh
first arg:   
second arg:  
arg count:   0

What to read out of it: run 1 shows the filing system working — words to numbers, in order. Run 2 is the quiet hazard: no error, no complaint, just empty expansions where values belonged — the script cheerfully "greeted" nobody. The one honest witness is the last line: $# said 0. Every defensive pattern in this module grows from reading that witness before doing any work.

A2. The counting guard: fail fast, fail helpfully

Module 3 established the convention (usage error → message on stderr, exit 2); Module 5 built guards; here they fuse into the first line of virtually every production script:

bash
[ "$#" -ge 1 ] || { echo "usage: $0 command [targets...]" >&2; exit 2; }

Read it with your accumulated toolkit: $# counted, [ -ge ] compared numerically (Module 5 B2), || fired on failure (Module 3), the message named the script via $0 and left on channel 2 (Module 4), and exit 2 told the caller which kind of failure. The { …; } braces group the two commands so || owns both — a small new piece of syntax doing exactly what it looks like. A script guarded this way fails in a millisecond with instructions, instead of failing halfway through real work with a mystery.

Interview questions — Part A

🎯 "How to calculate the number of passed arguments?" — asked verbatim at Edureka; PlacementPreparation asks "How do you pass arguments to a shell script?" and "What is a positional parameter in shell scripting?"

The direct answer: $# expands to the count of positional parameters. Arguments are passed as words after the script name (./script.sh a b c$1 $2 $3, $# = 3) and read by number inside.

Going deeper: the count's real job is validation — [ "$#" -eq 2 ] || { usage; exit 2; } at the top of the script — because absent arguments expand silently to empty and the script otherwise runs on blanks. Note $# counts arguments, not characters or words within them: one quoted "two words" argument is 1.

The details that separate candidates: knowing ${10} needs braces and why ($10 parses as $1 + 0); that set -- new args can rewrite the positional parameters mid-script (rare, but explains what shift belongs to); and the reflex that every $1-style read is quoted, since arguments arrive from callers who owe you no promises about spaces.

🎯 "How to get script name inside a script?" — asked verbatim at Edureka; PlacementPreparation asks "What is $0 in shell scripting?"; Zero To Mastery covers it under "What are positional parameters in Bash?"

The direct answer: $0 — it holds whatever name the script was invoked by, and its canonical use is self-documenting usage messages: echo "usage: $0 source dest" >&2.

Going deeper: "whatever name it was invoked by" is the precision that matters — ./deploy.sh, /opt/tools/deploy.sh, and bash deploy.sh all yield different $0 values. For just the filename, strip the directory part with basename "$0" or the pure-bash ${0##*/} (Module 9's parameter expansion, previewed).

The details that separate candidates: knowing $0 is not a positional parameter (it survives shift, and $# doesn't count it); and the operational angle — tools that behave differently depending on the name they were invoked by (busybox is one binary with hundreds of names) work by reading $0, which is a lovely systems-thinking flourish in an interview.

Part B — All the arguments at once

B1. "$@" vs "$*" — one of bash's great interview questions, settled by probe

Two spellings expand to "all the arguments," and inside double quotes they differ in the way that matters most in bash: "$@" becomes one word per argument, boundaries preserved; "$*" becomes a single word, everything glued with spaces. When a script passes its arguments onward — to a loop, to another command, to ssh — "$@" forwards them intact; "$*" welds them into one lump. The rule is short: forwarding arguments → "$@", always; building one display string → "$*", occasionally. Unquoted, both collapse into the same splitting free-for-all as any unquoted expansion — so the real answer to "$@ vs $*?" is "quoted "$@", and the other three spellings are traps."

Real-world analogy — forwarding the parcels vs shrink-wrapping them. Your script received parcels (arguments). "$@" forwards them as they came — three parcels in, three parcels out, each intact. "$*" shrink-wraps everything onto one pallet: convenient to label ("shipment: a, b, c" — a log line), useless to deliver, because the receiver gets one indivisible lump.

Where the analogy stops working. Shrink-wrap is visible. The weld in "$*" is invisible in most output — echo prints both versions identically (Module 2 D1's blindness) — so the bug hides until something downstream needs the boundaries. The probe (printf '[%s]\n') is how you see the difference; make it your reflex whenever "how many arguments is this, really?" arises.

🧪 Exercise 7.2 — the probe settles it
bash
cd ~/bash-course
nano args.sh     # five lines:
bash
#!/bin/bash
echo "as \"\$@\":"
printf '[%s]\n' "$@"
echo "as \"\$*\":"
printf '[%s]\n' "$*"
bash
chmod +x args.sh
./args.sh one "two three"
Expected result — click to reveal
plain text
as "$@":
[one]
[two three]
as "$*":
[one two three]

What to read out of it: two arguments went in (the second containing a space, protected by the caller's quotes). "$@" produced two brackets, the space-containing argument intact — a faithful forward. "$*" produced one bracket — the weld, with the original boundary between one and two three erased forever. Nothing downstream can ever recover that boundary. Now the rule writes itself, and you have seen why.

B2. shift: consume the first, promote the rest

shift discards $1 and slides everything left: old $2 becomes $1, $# drops by one. It is the tool for arguments with roles: in ./svc.sh restart web-1 web-2, the first word is a command and the rest are targets — so read the command, shift, and what remains in "$@" is exactly the target list, ready for a Module 6 loop. (shift 2 slides by two; shifting when $# is 0 fails with a verdict — guard first.)

🧪 Exercise 7.3 — command, then targets

The second run fails on purpose (the guard doing its job).

bash
cd ~/bash-course
nano svc.sh     # six lines:
bash
#!/bin/bash
[ "$#" -ge 1 ] || { echo "usage: $0 command [targets...]" >&2; exit 2; }
cmd="$1"
shift
echo "command: $cmd"
printf 'target: [%s]\n' "$@"
bash
chmod +x svc.sh
./svc.sh restart web-1 web-2
./svc.sh ; echo "verdict: $?"
Expected result — click to reveal (contains a deliberate failure)
plain text
command: restart
target: [web-1]
target: [web-2]
usage: ./svc.sh command [targets...]
verdict: 2

What to read out of it: after shift, "$@" held only the targets — the command had been consumed, and the probe shows two clean brackets. The empty invocation hit A2's guard: usage on stderr, verdict 2, zero work attempted — a script that fails like a professional. This consume-then-forward shape is the skeleton of every subcommand-style tool you have ever used (git commit, docker run — command first, then its own arguments).

Interview questions — Part B

🎯 "What is the difference between $* and $@?" — asked verbatim at Edureka; Zero To Mastery covers it under "What are positional parameters in Bash?"

The direct answer: quoted, "$@" expands to one word per argument with boundaries preserved; "$*" expands to a single word with all arguments joined by spaces (the first character of IFS, precisely). Unquoted, both split and glob like any expansion — avoid both unquoted.

Going deeper: demonstrate with the probe — printf '[%s]\n' "$@" vs "$*" on arguments including a spacey one — and state the use-cases: "$@" for forwarding (wrappers, loops, exec real-tool "$@"), and the star form for log lines — echo "called with: $*". Edureka's own published answer says the at-sign form treats each quoted argument as separate — correct in effect, but the mechanism-level answer (word boundaries preserved vs joined) is what holds up under follow-ups.

The details that separate candidates: the IFS detail on "$*" (its glue character is configurable — IFS=, makes "$*" comma-join, a genuinely useful trick for building CSV log fields); and knowing for x in "$@" is so common it has a shorthand — plain for x; do …; done iterates over the arguments by default.

Part C — getopts: options done properly

C1. The while-case machine

Positional arguments carry what to act on; options-n, -e staging — carry how to act, and parsing them by position collapses the moment a caller reorders them. The builtin getopts exists for exactly this, and it is a machine made entirely of parts you own: a while loop (each spin parses one option), a case (route by which option arrived), and three variables getopts maintains — the current option letter (in your chosen variable), OPTARG (the value attached to a value-taking option), and OPTIND (how far into the argument list parsing has advanced).

The spec string is the machine's configuration: ":e:nh" reads as — leading : = silent mode, "I will handle errors myself" (recommended: it routes malformed input to your case branches : and \? instead of printing bash's own message); e: = "-e takes a value"; n, h = flags without values. After the loop, shift $((OPTIND-1)) discards everything parsed, leaving pure positional arguments in "$@" — options first, positionals after, both machines fed.

bash
#!/bin/bash
dry=0
env=""
while getopts ":e:nh" opt; do
    case "$opt" in
        e)  env="$OPTARG" ;;
        n)  dry=1 ;;
        h)  echo "usage: $0 -e environment [-n]"; exit 0 ;;
        :)  echo "option -$OPTARG needs a value" >&2; exit 2 ;;
        \?) echo "unknown option: -$OPTARG" >&2; exit 2 ;;
    esac
done
shift $((OPTIND-1))
echo "env=$env dry=$dry leftover=$*"
Real-world analogy — airport check-in. Options are the check-in counter: one passenger (option) processed per loop spin; some travel alone (-n, a flag), some carry a bag that must be tagged with them (-e staging — OPTARG is the bag). The counter keeps a running "next in line" pointer (OPTIND). When the queue's options are exhausted, everyone left in the hall is a positional argument — regular passengers who skip the counter entirely, handled after shift $((OPTIND-1)) closes it.

Where the analogy stops working. Real counters accept latecomers. getopts stops at the first word that is not an option./opts.sh release-42 -n parses zero options, because release-42 ends the queue and -n is stranded behind it as a positional. Options-before-positionals is not a style preference; it is how the machine works, and your usage line should show that order.

🧪 Exercise 7.4 — feed the machine, then abuse it

Two runs fail on purpose — through your error branches, not bash's.

bash
cd ~/bash-course
nano opts.sh      # the script from above, exactly
chmod +x opts.sh
./opts.sh -e staging -n release-42
./opts.sh -x ; echo "verdict: $?"
./opts.sh -e ; echo "verdict: $?"
./opts.sh -h
Expected result — click to reveal (contains deliberate failures)
plain text
env=staging dry=1 leftover=release-42
unknown option: -x
verdict: 2
option -e needs a value
verdict: 2
usage: ./opts.sh -e environment [-n]

What to read out of it: the happy path parsed both options and delivered the positional leftover after the shift. The two abuse runs are the design working: -x routed to \? with the offending letter in OPTARG; a value-less -e routed to : — both messages yours, both on stderr, both exiting 2 (the Module 3 convention, mechanized). Compare that with hand-rolled parsing, where -x would have been silently ignored. The -h run shows the other convention: asked-for help is success — stdout, exit 0.

Counter-intuitive: getopts does not do long options. --dry-run is not parseable by it — GNU-style long options come from a different, external tool (getopt, no s — a separate program with its own pitfalls) or from a hand-rolled while/case over "$@". Production bash mostly either sticks to short options (-n) and lets getopts do the work, or hand-rolls a case "$1" loop for a small fixed set of long options. Knowing that --dry-run support is a decision with a cost — not a getopts feature you forgot — is precisely the kind of boundary interviewers probe.

Interview questions — Part C

🎯 "How do you parse command-line options in a shell script?" — asked constantly in DevOps screens; no canonical published wording circulates (tutorial pages like GeeksforGeeks and KodeKloud cover the mechanics without an interview framing), so treat the topic as the question

The direct answer: while getopts ":e:nh" opt; do case "$opt" in … esac; done; shift $((OPTIND-1)) — spec string declares the options (: after a letter = takes a value; leading : = silent mode), OPTARG carries values, and the shift leaves positionals in "$@".

Going deeper: explain the two error branches silent mode unlocks (\? unknown option, : missing value — your messages, stderr, exit 2), the first-non-option stopping rule (options must precede positionals), and the boundary: getopts is short-options only; long options mean external getopt or a manual loop, each a trade-off you can name.

The details that separate candidates: OPTIND awareness — including resetting OPTIND=1 before reusing getopts inside a function (a real bug source); the help convention (-h → stdout, exit 0 — help that was asked for is not an error); and the honest recommendation gradient: getopts for short options, manual case for a few long ones, and "this script has outgrown bash" as a legitimate professional answer for complex CLIs.

Part D — Asking a human: read, prompts, and when not to

D1. Interactive input, done deliberately

Module 6 used read as a file-line machine; pointed at a terminal instead, it becomes a question. read -p "Deploy to which env? " target prints the prompt and waits; the human's line lands in target. Two option flags matter in operations: -s (silent — keystrokes not echoed; for passwords; follow with a bare echo to restore the line) and -r, which you already never omit. A default for the empty answer is one Module 9 preview away: target="${target:-staging}" — "if empty, use staging."

The deeper skill is knowing when not to ask. A prompt is a human-shaped dependency: under cron or CI there is no human, and read pointed at a non-terminal takes whatever stdin holds — or end-of-file, instantly, leaving your variable empty and your script marching on with a blank (A1's silence, again). Production rule: scripts take their inputs from arguments and environment variables; prompts are reserved for interactive safety confirmations — and even those need an escape hatch (a -y/--yes flag) so automation can consent without a keyboard.

Real-world analogy — the confirmation dialog. A prompt is a "Are you sure? [type the cluster name]" dialog: excellent guarding a destructive button a human is about to press, absurd inside a factory robot's routine — the robot either stalls at the dialog forever or, worse, headbutts OK with whatever is on its clipboard.

Where the analogy stops working. GUI dialogs know whether a human is present. read does not — it reads stdin, whoever owns it. The dedicated test exists: [ -t 0 ] asks "is my stdin a terminal?" and lets a script prompt humans while refusing (or defaulting) under automation — the professional version of the dialog, and one line of Module 5 machinery.

🧪 Exercise 7.5 — prompt a human, then simulate the robot
bash
cd ~/bash-course
read -p "Deploy to which env? " target
echo "chosen: $target"
read -p "Deploy to which env? " target <<< "staging"    # a here-string plays the robot
echo "robot chose: $target"
Expected result — click to reveal
plain text
Deploy to which env? production
chosen: production
robot chose: staging

What to read out of it: the first pair is interactive — type anything (the transcript shows production as a sample; yours is whatever you typed). The second pair is the important experiment: the here-string (Module 4) impersonated a human, and read took it without hesitation — note the prompt did not even display, because bash saw stdin was not a terminal. That is precisely how automation meets prompts: silently, instantly, with whatever stdin happens to carry. Now you have seen why prompts and cron do not mix — from both sides.

D2. How a script should take its inputs — the decision

Note: the diagram below is a Mermaid code block. Notion does not render it automatically — click the block and switch it from "Code" to "Preview" (or "Split") to see the flowchart.
Diagram source
flowchart TD
    A["A script needs<br>an input value"] --> B{"What kind<br>of value?"}
    B -->|"what to act on<br>(target, file, env)"| C["positional argument<br>$1, guarded by $#"]
    B -->|"how to act<br>(mode, flag, tuning)"| D["option via getopts<br>-n, -e value"]
    B -->|"site config / secret<br>(URL, token, tier)"| E["environment variable<br>validated at startup"]
    B -->|"human confirmation<br>of something destructive"| F{"stdin a terminal?<br>[ -t 0 ]"}
    F -->|"yes"| G["read -p, with<br>a --yes escape hatch"]
    F -->|"no"| H["require the flag:<br>refuse, exit 2"]

Interview questions — Part D

🎯 "Why is bash read command not waiting for input?" — asked verbatim in LinuxTeck's FAQ; its neighbors there: "Can I use read in a cron job?" and "How do I read multiple inputs in a single line bash?"

The direct answer: because stdin is not what you think it is — read waits only when stdin has nothing ready. Common cases: the script is under cron/CI (stdin empty or closed — read returns immediately with an empty variable and a non-zero verdict), stdin was consumed by an earlier command in a loop (a body's ssh or read ate the lines your while-read expected), or input was piped in and already exhausted.

Going deeper: the cron answer is "you shouldn't" — no human is attached, so convert the prompt to an argument, environment variable, or flag; the loop answer is the classic ssh -n / < /dev/null fix (give the greedy body its own empty stdin). Multiple inputs on one line: read -r first second rest — word-splits the line across variables, extras landing in the last.

The details that separate candidates: [ -t 0 ] as the terminal test that makes scripts behave correctly in both worlds; read's timeout option -t 5 for prompts that must not hang unattended runs forever; and knowing read's exit status at EOF is non-zero — which is exactly how while-read loops terminate (Module 6), the same fact wearing its other hat.

🎯 "How do I set a default value if the user just presses Enter?" — asked verbatim in LinuxTeck's FAQ

The direct answer: read into the variable, then apply a default with parameter expansion: read -p "Env [staging]: " env; env="${env:-staging}" — empty answer (or plain Enter) becomes staging.

Going deeper: ${var:-default} is the read-only form (expands to the default, leaves the variable untouched) and ${var:=default} also assigns — Module 9 owns the full family; this is its most-used member. Show the prompt convention too: displaying the default in brackets ([staging]) so the human knows what Enter means before pressing it.

The details that separate candidates: the distinction between "pressed Enter" (empty string) and "typed spaces" (not empty — :- still won't fire; trim first if that matters); and defaults as an interface contract: the same ${VAR:-default} pattern applied to environment variables is how production scripts document their tunables in one self-enforcing line.

Part E — Production

E1. 🏭 Production practices

These are the practices in production — how the things this module taught are actually done on real systems:

Every script opens with validation and a usage line. $# checked, required values verified non-empty, usage printed to stderr with $0, exit 2 — before any work. A script's first response to confusion is instructions, not effort.

Arguments are quoted like all expansions, and forwarded as "$@". Wrapper scripts end in exec real-tool "$@" — boundaries intact, no welding.

Inputs are routed by kind. Targets → positionals; behavior switches → getopts options; site config and secrets → environment variables (never positional — they leak into process listings and shell history); human confirmation → a prompt guarded by [ -t 0 ], with a --yes/-y escape hatch for automation.

Option parsing is silent-mode getopts with both error branches implemented. Unknown option and missing value each produce a named complaint on stderr and exit 2; -h produces help on stdout and exit 0.

Prompts never block automation. Anything that might run under cron/CI either takes its answers as flags or refuses cleanly when stdin is not a terminal; read -t bounds any prompt that survives.

Secrets are read with read -rs — no echo to the terminal, no value on the command line, and never written into logs or set -x traces (Module 15 returns to this).

E2. Production-practice table

SymptomWhat is really happeningWhat to runThe fix
Script ran "fine" but acted on nothing — empty target, blank filenameA never-supplied argument expanded to empty, silently; no $# guard existed./script.sh with no args and watch; printf '[%s]\n' "$1" shows []Counting guard at the top: [ "$#" -ge N ] || { usage >&2; exit 2; }
A wrapper mangles arguments containing spaces; downstream sees extra itemsForwarding used $@ unquoted or "$*" — split or welded boundariesThe probe inside the wrapper: printf '[%s]\n' "$@" vs what downstream receivedForward with "$@", exactly and only
Options after the filename are ignored: ./tool file -n runs without -ngetopts stopped at the first non-option word; -n became a positionalEcho $OPTIND after the loop; probe the leftover "$@"Document and enforce options-before-positionals; mention the order in the usage line
$10 prints the first argument with a zero glued onParsed as ${1}0 — positional names past 9 need bracesset -- a b c d e f g h i j; echo $10 vs ${10} in a test shell${10} — and if a script genuinely has ten positionals, redesign it (options or a manifest file)
Script hangs forever in CI at "Are you sure?"A read prompt with no human attached; CI's stdin never answersReproduce with stdin closed: ./script.sh < /dev/null[ -t 0 ] gate + --yes flag; read -t N as a backstop
getopts inside a function parses correctly once, then never againOPTIND kept its advanced value from the previous callEcho $OPTIND at function entry — it is not 1local OPTIND=1 (or reset it) at the top of any function using getopts

E3. 🎓 Capstone — four tickets from the queue

Work each ticket yourself before opening the answer. Everything needed was taught in Modules 1–7.
🎓 Ticket 1 — "Our wrapper script run-in-venv.sh python3 script.py --user 'Ana Marin' keeps failing downstream with 'unrecognized arguments: Marin'. The wrapper is one line: python3 \"$*\" — wait, no: $venv_python $*."

Diagnosis. B1's weld and split, both. $* unquoted re-splits every argument — 'Ana Marin', so carefully quoted by the caller, was dismembered at the wrapper's boundary; downstream received Marin as a stray. (The quoted "$*" variant would fail differently — everything as one argument.) Wrappers are exactly where argument fidelity matters most, and exactly where "$@" is non-negotiable.

Work the steps: probe both spellings with the caller's exact arguments (Exercise 7.2's experiment, with --user "Ana Marin"); count brackets.

Fix and prevention: exec "$venv_python" "$@" — quoted command, forwarded boundaries (and exec, Module 12, so the wrapper doesn't linger as a parent). Review rule for any wrapper: the only acceptable spelling of "pass everything through" is "$@".

🎓 Ticket 2 — "Cleanup tool: ./cleanup.sh <days> <dir>. Someone ran it with the arguments swapped — ./cleanup.sh /var/tmp 30 — and it tried to treat /var/tmp as an age and 30 as a directory. It found no directory named 30, so 'nothing bad happened,' but next time we may not be lucky."

Diagnosis. Positional arguments carry meaning by position only — the script trusted order and got a plausible-looking swap. Nothing validated that $1 was numeric or $2 was a directory, so the script marched into work with garbage roles (A2's missing guards, in their most dangerous costume: swapped rather than absent).

Work the steps: run with swapped arguments and trace: printf '[%s]\n' "$1" "$2", then watch which guard would have caught each.

Fix and prevention: validate types, not just count: [[ "$1" =~ ^[0-9]+$ ]] || { echo "days must be a number, got: $1" >&2; exit 2; } and [ -d "$2" ] || { echo "no such directory: $2" >&2; exit 2; } (Module 5's validators, promoted to gatekeepers). For genuinely confusable pairs, switch to options — -d 30 -p /var/tmp cannot be swapped silently. Destructive scripts deserve unswappable interfaces.

🎓 Ticket 3 — "A colleague added --verbose support to our getopts-based tool. Now ./tool --verbose target doesn't error — it just quietly does nothing verbose, and 'target' processing started treating --verbose as a filename once."

Diagnosis. C1's counter-intuitive boundary: getopts cannot see long options. --verbose is, to getopts, a - option named - followed by junk — or, depending on spec and silent mode, simply the first non-option word, ending the parse and leaving --verbose in the positionals, where the file-processing loop received it. The colleague added the flag to the usage text but the machine never learned it.

Work the steps: run with -x (getopts catches it — the \? branch fires) then with --verbose (no branch fires; probe the leftover "$@" and find --verbose sitting there as data).

Fix and prevention: pick a lane: (a) short option -v, one letter in the spec string, done; (b) a small manual while case "$1" in --verbose) …; shift;; --*) die;; *) break;; esac loop before getopts for a fixed set of long options; or (c) admit the CLI has outgrown bash. What is not on the menu: long options silently falling through to the data path — add a --* reject branch wherever positionals are consumed.

🎓 Ticket 4 — "Night deploys started failing at 02:00 with empty commit messages. The deploy script asks 'Release notes?' via read — it works every time we run it by hand."

Diagnosis. D1's human-shaped dependency meeting the robot: under the scheduler stdin is not a terminal; read returned instantly (empty variable, non-zero verdict nobody checked), and the deploy proceeded with a blank message — the by-hand runs never reproduced it because a human was always attached. Exercise 7.5's second experiment, in production.

Work the steps: reproduce the robot: ./deploy.sh < /dev/null — watch the prompt not even display and the blank sail through. Confirm with [ -t 0 ] && echo human || echo robot in both contexts.

Fix and prevention: notes become an input, not a prompt: -m "message" option (getopts) or RELEASE_NOTES environment variable, validated non-empty at the top. Keep the prompt only inside an if [ -t 0 ] branch for interactive use, and make the non-terminal path require the flag — refuse loudly (exit 2) rather than deploy blankly. Rule of thumb worth writing into the runbook: every prompt is a bug in any script that cron might ever meet.

E4. Documentation reference

TopicAuthoritative referenceWhat you'll find there
$@, $*, $#, $0, $$ and friendsBash manual — Special ParametersEvery special parameter's exact expansion rules, including the quoted "$@" behavior
shift and getoptsBash manual — Bourne Shell BuiltinsBoth builtins: shift's argument, getopts' spec strings, OPTARG/OPTIND semantics
read and its optionsBash manual — Bash Builtinsread's full option set: -r, -p, -s, -t timeouts, multi-variable reads
The [ -t 0 ] terminal testBash manual — Bash Conditional ExpressionsThe -t file descriptor test among the full conditional catalogue

E5. Self-assessment

Answer from memory, out loud or on paper. Every answer is in this module.

  1. When and how do $1, $2 get their values? What does an unsupplied $3 expand to, and what is the only witness?
  2. Why does $10 misbehave, and what is the correct spelling?
  3. What exactly does $0 hold — and why is "whatever name it was invoked by" the precise answer?
  4. Write the standard first-line guard for a script requiring two arguments, and name the module convention behind each of its five pieces.
  5. "$@" vs "$*": what does each expand to, which one forwards faithfully, and what is the one legitimate use of the other?
  6. What does shift do to $1, $2, and $#? Sketch the command-then-targets pattern that uses it.
  7. In the spec string ":e:nh" — what does each character mean, including the leading colon?
  8. Which two case branches does silent-mode getopts route errors to, and what belongs in each?
  9. Why must options come before positional arguments with getopts?
  10. What can't getopts parse at all, and what are the two honest alternatives?
  11. Why is read -p dangerous in a script cron might run, and what one-line test distinguishes human from robot stdin?
  12. How do you give a prompt a default value, and how do you display that default to the human?

E6. Sources

Interview questions in this module were captured verbatim from:

Edureka — Top 60 Shell Scripting Interview Questions and Answers — "How to calculate the number of passed arguments?", "How to get script name inside a script?", "What is the difference between $* and $@?" (page updated Dec 9, 2024)

PlacementPreparation — Top 50 Shell Scripting Interview Questions for Freshers — "How do you pass arguments to a shell script?", "What is $0 in shell scripting?", "What is a positional parameter in shell scripting?" (published Sept 23, 2024; last updated Feb 27, 2025)

Zero To Mastery — Bash Interview Prep: 31 Essential Questions and Answers — "What are positional parameters in Bash?" (published June 18, 2026)

LinuxTeck — Bash read Command to Get User Input: 6 Practical Examples — FAQ: "Why is bash read command not waiting for input?", "How do I set a default value if the user just presses Enter?", "Can I use read in a cron job?", "How do I read multiple inputs in a single line bash?" (published May 5, 2026; updated May 6, 2026)

A note on the corpus: positional parameters and $* vs $@ are heavily represented in published lists. getopts is not — it is asked about constantly in real DevOps screens, but circulating pages are tutorials (GeeksforGeeks, KodeKloud) rather than question lists with fixed wording; this module's getopts question is labeled accordingly rather than given an invented citation. No questions were invented. All expected-result outputs were produced by running the commands on Ubuntu 24.04 / bash 5.2.

Next: your scripts have real interfaces now — but their insides are still one long column of commands. Functions give scripts internal structure: named, reusable, testable pieces with their own arguments (the same $1 machinery, reused): Module 8 — Functions and Script Structure.
Spotted a mistake or want something added? Send me a note.