Shell is the domain where AI assistance is most immediately useful and least forgiving.
Useful because the syntax is famously unmemorable — the find flags, the awk expression, the exact
jq filter — and a model produces them instantly. Unforgiving because there is no undo, no dry run by
default, and a one-character difference between a command that lists files and one that deletes them.
Where it helps most
Section titled “Where it helps most”Recalling syntax. The find predicate, the awk field separator, the jq path expression, the
tar flag combination nobody has ever memorised. You know what you want; the syntax is the friction,
and this is the case where an agent saves real time with essentially no risk.
Explaining somebody else’s pipeline. A four-stage pipeline in a script nobody has touched in three years is a reading problem with a checkable answer.
Debugging quoting. Shell quoting produces failures that are data-dependent and hard to reason about — a script that works until somebody uses a filename with an apostrophe in it. An agent that can run the command and read the error iterates faster than you guessing.
Composing something one-off. A task you will do once and never again, where writing a careful script is not worth it and getting it right still matters — a bulk rename, a log extraction, a one-time data fix.
The specific failures in generated shell
Section titled “The specific failures in generated shell”Six, and they recur.
Unquoted variables. rm $FILE breaks on a filename with a space and does something different from
what was intended. rm "$FILE" does not. A generated command will often quote correctly and cannot be
relied on to.
Glob expansion with an empty variable. rm -rf "$DIR"/* with DIR unset expands to /*. This is
the classic, and it is why ${DIR:?} — which fails if the variable is unset — exists.
Silent pipeline failure. In a pipeline, the exit status is the last command’s. An early failure
goes unnoticed unless set -o pipefail is on. Generated snippets rarely include it.
Word splitting in command substitution. for f in $(ls) breaks on any filename containing a space.
The correct form is a find with -print0 and read -d '', or a glob — and it is more verbose, which
is why generated code often uses the simpler wrong version.
Destructive flags mid-command. -delete, -exec rm {} \;, --force, --purge. Read the whole
command.
Assumptions about the environment. GNU versus BSD flag differences, a tool that is not installed, a shell that is not bash. Generated commands assume the common case.
Asking well
Section titled “Asking well”Say what you want, not the command you half-remember.
Find every file over 100MB modified in the last week, excluding
.git, and show me their sizes sorted largest first. Do not delete anything.
That last sentence is worth including habitually. It is not enforcement — the tool approval is — and it shapes the output toward listing rather than acting.
Ask for the safe version first.
Show me the version that lists what would be affected before the version that acts on it.
Most destructive operations have a listing form: find without -delete, rm -i, git clean -nd,
rsync --dry-run. Getting both and running the first is the pattern.
Ask what happens in the edge cases.
What does this do if the variable is empty? If a filename contains a space? If the directory does not exist?
Three questions, and they cover most of the failure list above. The answers are checkable by reading the command.
Scripts versus one-liners
Section titled “Scripts versus one-liners”Different standards, and conflating them causes problems.
A one-liner you run once, read once, and discard. The bar is “does it do what I want right now”, and reading it carefully is the whole review.
A script is committed, run repeatedly, and run by other people — possibly in CI, possibly with more privilege than you had. It needs the things a one-liner does not:
#!/usr/bin/env bashset -euo pipefail-e exits on error. -u fails on an unset variable — which turns the empty-glob catastrophe
into an error. -o pipefail makes a pipeline fail when any stage fails.
Generated scripts frequently omit this line. Asking for it explicitly, or putting it in repository instructions, is worth doing:
- All shell scripts start with `set -euo pipefail`.- Quote all variable expansions.- Use `${VAR:?}` for variables whose emptiness would be dangerous.Worked examples
Section titled “Worked examples”Three cases where the generated version and the correct version differ in ways worth seeing.
Iterating over files
Section titled “Iterating over files”Commonly generated:
for f in $(ls *.log); do gzip "$f"doneBreaks on any filename containing a space or a newline, because $(ls) output is word-split.
Correct:
for f in *.log; do [ -e "$f" ] || continue gzip "$f"doneA glob does not word-split. The [ -e "$f" ] || continue handles the case where nothing matches — in
which case bash leaves the literal *.log in the variable, and gzip '*.log' fails confusingly.
Verified in a directory containing a file.log and b.log:
$ for f in $(ls *.log); do echo "got: [$f]"; donegot: [a]got: [file.log]got: [b.log]
$ for f in *.log; do echo "got: [$f]"; donegot: [a file.log]got: [b.log]The first loop turned one file into two iterations, neither of which names a real file.
Deleting by pattern
Section titled “Deleting by pattern”Commonly generated:
rm -rf "$BUILD_DIR"/*With BUILD_DIR unset or empty, this is rm -rf /*.
Correct:
rm -rf "${BUILD_DIR:?BUILD_DIR must be set}"/*${VAR:?message} exits with the message if the variable is unset or empty. One piece of syntax,
and it converts a catastrophe into an error.
Verified with an empty variable:
$ BUILD_DIR=""; rm -rf "${BUILD_DIR:?BUILD_DIR must be set}"/*bash: BUILD_DIR: BUILD_DIR must be setThe command does not run. Without the :?, it would have.
Processing command output safely
Section titled “Processing command output safely”Commonly generated:
find . -name "*.tmp" | xargs rmBreaks on filenames with spaces, and xargs runs rm with no arguments if find returns nothing —
which on some systems removes something unexpected.
Correct:
find . -name "*.tmp" -print0 | xargs -0 -r rm-print0 and -0 use null separators, and -r stops xargs running the command when input is empty.
The pattern across all three: the correct version is more verbose, which is exactly why the shorter wrong version gets generated. Asking “what happens with a space in the filename” surfaces the difference every time.
Reviewing a generated command
Section titled “Reviewing a generated command”The four questions from the DevOps lesson, applied to shell:
-
What does it do? Read left to right, and read to the end. If there is a flag you do not recognise, ask before running.
-
What does it touch? A specific path is bounded. A glob, a
findroot, or--allis not. -
Is it reversible? Almost nothing in shell is.
rmhas no reflog. -
What if a variable is empty? The question that catches the worst outcome.
For anything with rm, --force, > redirecting over a file, or sudo, add a fifth: run the
listing version first.
Explaining existing shell
Section titled “Explaining existing shell”The lowest-risk and genuinely valuable use.
{/* A pipeline nobody wants to read */}ps aux | awk '$3 > 50 {print $2, $11}' | head -20Explain this pipeline stage by stage. What does
$3refer to, and what happens if no process matches?
Reading comprehension with a checkable answer. The stage-by-stage request matters — a summary of what a pipeline does is less useful than an explanation of each stage, because the bug is usually in one stage.
The same applies to a script you have inherited. Asking “what does this do, and what would break if I changed X” is a good use of an agent that can also read the files the script references.
Testing generated shell
Section titled “Testing generated shell”Shell has no compiler, which removes the cheapest verification available in every other language. Three substitutes.
ShellCheck. A static analyser for shell that catches most of the failure list on this page —
unquoted variables, word splitting, useless cat, missing set -e. It is fast, deterministic, and the
right first check on any generated script.
shellcheck script.shWorth putting in CI for any repository with scripts, and worth asking the agent to run:
Write the script, then run ShellCheck on it and fix what it reports.
bash -n parses without executing, catching syntax errors. Weaker than ShellCheck and available
everywhere.
bash -n script.shRun it against a disposable copy. For anything touching files, copy the directory and run there. A one-command precaution that converts a destructive mistake into a wasted minute.
cp -r target/ /tmp/target-test/ && cd /tmp/target-test/The general point: a generated shell script deserves a static check in a way generated Python does not, because the language provides so little safety of its own. ShellCheck fills a gap that the language leaves open, and running it is faster than reading carefully.
What not to delegate
Section titled “What not to delegate”Anything with sudo. Elevated privilege turns a mistake into a larger one, and there is rarely a
reason for an agent to need it. Where a task genuinely requires root, running it yourself is the right
call.
Anything that writes outside the working directory. > into a system path, a cp to /etc, a
symlink somewhere unexpected.
Anything modifying your shell configuration. Changes to .bashrc, .profile or PATH affect every
future session, including ones where you are not thinking about them.
Anything that touches credentials. Reading ~/.aws/credentials, writing to a credential helper,
exporting a token. See Git credentials.
curl | bash. Fetching and executing in one step, from any source. The command that most often
appears in installation instructions and is worth never running from a generated suggestion — download,
read, then run.
Portability
Section titled “Portability”A generated command assumes an environment, and the assumption is usually GNU coreutils on Linux.
The differences that bite most often:
| Operation | GNU (Linux) | BSD (macOS) |
|---|---|---|
| In-place sed | sed -i 's/a/b/' f | sed -i '' 's/a/b/' f |
date arithmetic | date -d '3 days ago' | date -v-3d |
readlink | readlink -f path | Not available; use realpath or a loop |
stat format | stat -c %s f | stat -f %z f |
grep -P | Available | Not available |
A script generated on the assumption of one and run on the other fails in ways that read as bugs in the script rather than as portability problems.
Two practical responses:
Say which platform. “This runs on macOS” or “this runs in an Alpine container in CI” changes the output, and it is one clause.
Prefer POSIX where a script will travel. Asking for POSIX-compatible shell rather than bash-specific
syntax produces something that works in sh, which matters for anything running in a minimal container
image where bash may not be installed at all.
The CI case is the one that catches people: a script that works on a developer’s macOS laptop and fails
in an Alpine-based CI container, because sed -i '' is BSD syntax and the container has GNU sed — or
because the container has sh rather than bash. See CI for where these
scripts end up running.
Common mistakes
Section titled “Common mistakes”Approving a command after reading the first half. The destructive flag is usually at the end.
Running a generated command with sudo without reading it. Elevated privilege turns a mistake into
a bigger one.
Trusting quoting. Generated commands quote correctly most of the time.
Omitting set -euo pipefail from scripts. The single most valuable line in a shell script.
Not asking what happens when a variable is empty. The question that catches the worst case.
Using a one-liner standard for a script. A committed script is run by other people, later, with different data.
Assuming the environment. GNU and BSD flags differ, and a script that works on a laptop can fail in a container for reasons that read as bugs.
Not running ShellCheck. It catches most of the failures on this page in under a second, which is faster than reading carefully.
Letting it run destructive commands without a listing pass. Almost every destructive operation has a listing form.
Error handling in generated scripts
Section titled “Error handling in generated scripts”The area where generated shell is weakest, because correct error handling is verbose and the common case works without it.
What is usually generated:
#!/usr/bin/env bashcd /some/path./build.shcp output/* /destination/Three commands, none of which is checked. If cd fails, the rest runs in the wrong directory. If
build.sh fails, the copy proceeds with stale output.
What it should be:
#!/usr/bin/env bashset -euo pipefail
cd "${BUILD_ROOT:?BUILD_ROOT must be set}"./build.shcp output/* "${DESTINATION:?DESTINATION must be set}/"set -e alone fixes most of it: any failing command stops the script. The variable guards handle the
empty-path case.
Two further patterns worth asking for explicitly:
Cleanup on exit.
tmp=$(mktemp -d)trap 'rm -rf "$tmp"' EXITA trap on EXIT runs whether the script succeeds, fails or is interrupted. Generated scripts create
temporary directories and rarely clean them up.
Meaningful failure messages. set -e exits silently. For anything anyone else will run, a message
saying which step failed is the difference between a five-minute debug and a thirty-minute one.
The instruction that produces better scripts:
Include
set -euo pipefail, guard variables whose emptiness would be dangerous, clean up temporary files with a trap, and print a clear message on failure.
Mental model
Section titled “Mental model”Shell has no undo and no type system. A generated command is a proposal in a language where a one-character difference changes everything, which makes reading it the entire review — and the reading has to reach the end.
What you learned
Section titled “What you learned”- Generated shell is most useful for syntax recall and explanation, both low risk
- The characteristic accident is a command that starts safely and ends destructively
- The six recurring failures: unquoted variables, empty-variable globs, silent pipeline failure, word splitting, mid-command destructive flags, environment assumptions
- Ask for the listing version before the acting version
- Ask what happens with an empty variable, a space in a filename, a missing directory
- Scripts need
set -euo pipefail; one-liners are reviewed by reading them ${VAR:?}fails on an unset variable, which is what prevents the empty-glob case- Explaining a pipeline stage by stage is more useful than summarising it
Exercise
Section titled “Exercise”Use a disposable directory with throwaway files.
-
Ask for a command to find and delete files older than 30 days. Predict: does it offer the listing version first?
-
Read the command to the end. Predict: where is the destructive part?
-
Ask what happens if the directory variable is empty. Predict: does the command guard against it?
-
Ask for a script version. Predict: does it include
set -euo pipefail? -
Create a file with a space in its name and run a generated
for f in $(ls)loop against it. Predict: what happens? -
Ask it to explain a pipeline you did not write, stage by stage. Check one stage against the manual page.
-
Add a shell convention to
.github/copilot-instructions.mdand regenerate. Predict: does the script improve?