Skip to content

GitHub Copilot CLI for Bash

Lesson 6 of 9Intermediate11 min readGitHub Copilot & AI Engineering · Copilot CLIVerified: bash 5.2 on Ubuntu 24.04; GitHub Copilot CLI permission model, September 2026

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.

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.

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.

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.

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 bash
set -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.

Three cases where the generated version and the correct version differ in ways worth seeing.

Commonly generated:

Terminal window
for f in $(ls *.log); do
gzip "$f"
done

Breaks on any filename containing a space or a newline, because $(ls) output is word-split.

Correct:

Terminal window
for f in *.log; do
[ -e "$f" ] || continue
gzip "$f"
done

A 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]"; done
got: [a]
got: [file.log]
got: [b.log]
$ for f in *.log; do echo "got: [$f]"; done
got: [a file.log]
got: [b.log]

The first loop turned one file into two iterations, neither of which names a real file.

Commonly generated:

Terminal window
rm -rf "$BUILD_DIR"/*

With BUILD_DIR unset or empty, this is rm -rf /*.

Correct:

Terminal window
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 set

The command does not run. Without the :?, it would have.

Commonly generated:

Terminal window
find . -name "*.tmp" | xargs rm

Breaks on filenames with spaces, and xargs runs rm with no arguments if find returns nothing — which on some systems removes something unexpected.

Correct:

Terminal window
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.

The four questions from the DevOps lesson, applied to shell:

  1. 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.

  2. What does it touch? A specific path is bounded. A glob, a find root, or --all is not.

  3. Is it reversible? Almost nothing in shell is. rm has no reflog.

  4. 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.

The lowest-risk and genuinely valuable use.

Terminal window
{/* A pipeline nobody wants to read */}
ps aux | awk '$3 > 50 {print $2, $11}' | head -20

Explain this pipeline stage by stage. What does $3 refer 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.

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.

Terminal window
shellcheck script.sh

Worth 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.

Terminal window
bash -n script.sh

Run 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.

Terminal window
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.

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.

A generated command assumes an environment, and the assumption is usually GNU coreutils on Linux.

The differences that bite most often:

OperationGNU (Linux)BSD (macOS)
In-place sedsed -i 's/a/b/' fsed -i '' 's/a/b/' f
date arithmeticdate -d '3 days ago'date -v-3d
readlinkreadlink -f pathNot available; use realpath or a loop
stat formatstat -c %s fstat -f %z f
grep -PAvailableNot 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.

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.

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 bash
cd /some/path
./build.sh
cp 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 bash
set -euo pipefail
cd "${BUILD_ROOT:?BUILD_ROOT must be set}"
./build.sh
cp 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.

Terminal window
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

A 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.

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.

  • 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

Use a disposable directory with throwaway files.

  1. Ask for a command to find and delete files older than 30 days. Predict: does it offer the listing version first?

  2. Read the command to the end. Predict: where is the destructive part?

  3. Ask what happens if the directory variable is empty. Predict: does the command guard against it?

  4. Ask for a script version. Predict: does it include set -euo pipefail?

  5. Create a file with a space in its name and run a generated for f in $(ls) loop against it. Predict: what happens?

  6. Ask it to explain a pipeline you did not write, stage by stage. Check one stage against the manual page.

  7. Add a shell convention to .github/copilot-instructions.md and regenerate. Predict: does the script improve?

AI-assisted engineering learning pathEleven lessons on getting value from Copilot and agents without giving up review discipline.