Back to blog
Engineering16 min read

Prompting for Shell Scripts and CLI One-Liners

Shell script prompts that build in a dry run: quoting every variable, listing before deleting, and never piping an unreviewed script into a shell. Tested GNU vs macOS (BSD) differences included.

NH
Nafiul Hasan
Founder, Prompt Architects

TL;DR: The fastest way to get a broken or destructive shell script or one-liner from an AI is to ask for the final command directly. Ask instead for a dry run first, every variable quoted and shown substituted, no unrequested sudo, and no piping an unreviewed script into a shell. Specify GNU/Linux or macOS too, because sed, find, date, and stat all take different flags on each. Below: the exact preamble to paste before every shell request, verified GNU-vs-macOS differences, and thirteen copy-paste prompts for the tasks people actually script.

A model that writes shell for you is not being careless when it hands you something dangerous. It's being exactly as careful as you asked it to be, which for most people's default phrasing is "make it work," not "make it safe." rm -rf "$dir"/* and rm -rf /* differ by one variable being empty, and nothing about either line looks wrong until you know what $dir was supposed to hold. Reading the command before you run it isn't the disclaimer at the bottom of this post. It's the actual craft this post teaches, and it's the same reason a careful engineer reads a diff before merging it rather than trusting that the tests will catch everything.

Why does an AI-written one-liner need a "read before you run" rule?

Because the failure modes are specific, common, and don't announce themselves. Four show up constantly in generated shell.

An unquoted variable can expand to empty. rm -rf $dir/* with dir unset or blank becomes rm -rf /* after word-splitting, with no error and no warning, just every argument the shell can see. A find … -exec rm with no path guard is the second: a relative path typed from the wrong directory, or a pattern that matches more than intended, deletes files nobody meant to touch. A redirect that truncates the file it's also reading is the third, and it's subtle enough that it deserves its own worked example below. The fourth is simpler to name and just as costly: an unrequested sudo, or a bare curl | bash that runs a script nobody on your side ever opened.

What does an unquoted variable actually do, and does the shell matter?

It's worth seeing word-splitting happen once, because "quote your variables" is easy to nod along to and easy to forget under a deadline. Here's the same variable, once unquoted and once quoted, under bash:

name="report final.txt"
touch -- "$name"

set -- $name          # unquoted: splits on whitespace
echo "arg count: $#"  # 2
for a in "$@"; do echo "  arg: [$a]"; done
# arg: [report]
# arg: [final.txt]

set -- "$name"        # quoted: stays one value
echo "arg count: $#"  # 1
for a in "$@"; do echo "  arg: [$a]"; done
# arg: [report final.txt]

Both blocks were run directly, not inferred. The unquoted expansion hands the shell two words instead of one filename, which is exactly how a loop that works fine on every test file with no spaces in its name quietly breaks, or deletes the wrong thing, the first time it meets a real one that has one.

The shell you're in changes this, which is why the preamble below asks the model to name it before writing anything. Bash and POSIX sh word-split an unquoted expansion on whitespace by default. Zsh, tested the same way, does not: the identical unquoted $name stayed one value under zsh in this testing. That's not an argument for skipping the quotes in zsh either, since a script written for one shell often ends up copy-pasted into a #!/bin/bash file or a CI runner that uses a different one. It's the reason "which shell" is the first question in the safety preamble, not a formality.

What's the one prompt block that prevents most of this?

A short preamble, pasted once at the top of a chat and reused for every shell request after it. Paste this before you ask for anything that touches files, processes, or permissions:

Before giving me a command that changes anything:
1. Tell me which shell (bash, zsh, POSIX sh) and OS (Linux/GNU or macOS/BSD)
   you're writing for, and flag anything that behaves differently on the other one.
2. Show me the command with every variable already substituted in, or wrapped
   in echo, BEFORE the version that actually runs.
3. Quote every variable expansion ("$var", never $var) and tell me what
   happens if it's empty or contains a space.
4. Do not add sudo unless I explicitly asked for it.
5. If the command deletes, overwrites, moves, or truncates anything, give me
   a listing-only or --dry-run version first and wait for me to confirm
   before giving me the version that changes anything.
6. Never suggest piping a downloaded script directly into a shell. Show the
   download-then-inspect-then-run form instead.

Every prompt further down this page assumes that block is already sitting above it in the same chat. It's worth pasting even for a task that looks trivial, because the whole point is that you can't tell which task is trivial from the prompt alone. "Clean up the temp files" is a completely reasonable request that a careless implementation turns into something considerably larger.

Why does sort file.txt > file.txt erase the file?

Because shell redirection sets up the output file before the command reads anything, and this is worth seeing once rather than taking on faith:

printf "banana\napple\ncherry\n" > list.txt
sort list.txt > list.txt
cat list.txt   # empty. tested on macOS, Sep 2026; the same thing happens on GNU/Linux

> truncates list.txt to zero bytes the moment the shell opens it for writing, and that happens before sort gets a chance to read a single line. sort never sees content that's already gone by the time it starts. The fix is sort's own -o flag, which reads the entire input before it opens the output:

sort list.txt -o list.txt   # correct: reads fully, then writes

The same trap catches grep, awk, and anything else piped through a plain > back into one of its own input files. Ask a model for a rewrite-in-place task and, per the preamble above, it should reach for the tool's own in-place flag, or a temp-file-then-mv, and never a bare redirect back onto the source it just read.

Does the same shell command work the same way on macOS and Linux?

Not always, and the gap trips up scripts that were only ever tested on one platform. Every row below was run directly against /usr/bin/sed, /usr/bin/find, /bin/date, and /usr/bin/stat on a current Mac, not assumed from memory:

Tested directly against each command's own usage banner, macOS (Darwin), September 2026. A script written for one errors, or silently misbehaves, on the other
FeatureGNU / LinuxmacOS (BSD)
Edit a file in placesed -i 's/a/b/' filesed -i '' 's/a/b/' file. The empty '' is required, not optional
Custom find output formatfind . -printf '%f\n'Not supported at all. No -printf primary exists
Long-form flags on find--maxdepth, --help both workSingle-dash only: -maxdepth. --maxdepth errors
Relative date arithmeticdate -d "1 day ago"date -v-1d
File size for a scriptstat -c%s filestat -f%z file

One piece of common advice is actually out of date. readlink -f is often called GNU-only, but on a current Mac, /usr/bin/readlink's own usage line lists -f as a supported option, and it resolved a real path correctly in testing. Verify against your own machine's readlink -f before assuming either way; this is exactly the kind of claim that goes stale between macOS releases, and repeating outdated folklore in a generated script is no better than the model inventing something new.

Two patterns worked identically on GNU and macOS in this testing, which is a reason to prefer them by default rather than only reaching for them once a script breaks: find -print0 piped into xargs -0, for filenames that might contain spaces or newlines, and rm -- to stop a filename that starts with a dash from being read as a flag. find's own -delete action is also portable between the two, tested directly, so a find … -print you've already reviewed can become find … -delete with the same path and filter, no rewrite needed.

Copy-paste prompts for the shell tasks people actually script

Paste the preamble above once, then one of these. Each is built so the model's first answer is the safe listing, not the change.

1. Rename a batch of files by extension

Write a loop that renames every *.jpeg file in this directory to *.jpg. Show
me the dry-run version first, echoing the mv command instead of running it,
using -- before each filename and quoting every expansion so a filename with
a space doesn't break the loop.
for f in *.jpeg; do
  [ -e "$f" ] || continue
  echo mv -- "$f" "${f%.jpeg}.jpg"
done

2. Delete old files, but only after you've seen the list

I want to delete files under ./tmp older than 30 days. First give me a find
command that only lists them, with a depth limit so it can't walk into
directories I didn't mean to include. Do not give me the delete version in
the same answer. I'll ask for it separately once I've reviewed the list.
find ./tmp -maxdepth 2 -type f -mtime +30 -print

Only once that list looks right does the real command follow, scoped to the exact same path and depth:

find ./tmp -maxdepth 2 -type f -mtime +30 -delete

3. Find-and-replace text across many files

Write a command that replaces every occurrence of OLD_STRING with NEW_STRING
in every *.conf file under ./config. Ask me first whether I'm on macOS or
Linux, and give me the correct sed -i form for that OS. Don't give me a
version that silently creates or omits a backup file without telling me
which it did.
# GNU/Linux — creates no backup
find ./config -name "*.conf" -print0 | xargs -0 sed -i 's/OLD_STRING/NEW_STRING/g'

# macOS (BSD) — the empty '' argument means no backup file
find ./config -name "*.conf" -print0 | xargs -0 sed -i '' 's/OLD_STRING/NEW_STRING/g'

4. Fix permissions in bulk, dry run first

Some files under ./uploads have the wrong permissions. Give me a find command
that only lists files that aren't 644, so I can see how many that is, before
you give me the chmod version.
find ./uploads -type f ! -perm 644 -print

5. Archive logs and verify before you touch the originals

Archive every *.log file under ./logs into logs-archive.tar.gz. After
creating it, list the archive's contents back out so I can confirm what's
inside before anything gets deleted. Don't delete the originals in the same
command that creates the archive.
find ./logs -name "*.log" -print0 | tar --null -T - -czf logs-archive.tar.gz
tar -tzf logs-archive.tar.gz   # verify contents before deleting anything

6. Loop over a list of values safely

I have a text file, one item per line, some with spaces in them. Write a
loop that runs a command once per line without word-splitting or glob
expansion breaking on the spaces.
while IFS= read -r line; do
  echo "processing: $line"
done < items.txt

The IFS= and -r are both load-bearing, not decoration: IFS= stops leading and trailing whitespace on each line from being trimmed, and -r stops a backslash in the line from being treated as an escape. Drop either and a loop that works on your test file quietly mangles the first real input that doesn't match it.

7. Stop a runaway process by name, without guessing

A process matching "worker" is stuck. Give me a command that lists matching
processes with their PIDs and full command lines first, so I can confirm
which one before killing anything. Don't give me a kill command that matches
by pattern directly.
pgrep -fl worker

Only the exact PID confirmed from that list goes into the next step:

kill 48213

That's a specific PID copied from the listing above, never a pattern match handed straight to kill. pkill worker looks like the shorter version of the same idea, and it's exactly the shortcut this whole post argues against: it matches and signals every process whose command line contains "worker," which on a busy machine can be more processes than you meant.

8. Review a cron job before it goes in

I want to add a cron job. First show me my current crontab so I can see
what's already there, then give me the new line to add. Don't have me
overwrite the whole file blind.
crontab -l

9. Replace curl | bash with a version you can actually read

Give me the safe way to run this vendor's install script: download it to a
local file, show me how to open and read it, and only then run the local
copy. Do not give me a curl-piped-into-bash one-liner.
curl -fsSL https://example.com/install.sh -o install.sh
less install.sh          # read it before anything executes
bash install.sh

10. Clean up merged git branches, list before delete

List the local branches already merged into main, excluding main itself.
Don't give me a command that deletes them yet. I want to check the list
first.
git branch --merged main | grep -v '^\*\|main$'

11. See what's actually using disk space, before deleting anything

Show me disk usage by top-level directory under ./data, sorted largest
first, so I can decide what's worth investigating. This is a read-only
request; don't suggest a cleanup command in the same answer.
du -sh ./data/*/ | sort -rh

sort -h (or -rh for largest first) sorts human-readable sizes like 2.1G and 340K in the order they actually represent, not alphabetically, and it worked identically on both GNU and macOS in testing. Without it, du -sh output sorts as text, which puts 2.1G before 340K because 2 sorts before 3, a mistake that's easy to miss because the list still looks plausible at a glance.

The same word-splitting hits command substitution, not just plain variables, and it's worth flagging separately because it hides behind a command that looks like it's just listing files. files=$(ls *.txt) followed by an unquoted for f in $files split two filenames with spaces in them into four words in testing, identically to the earlier example. The standard fix is to stop parsing ls output at all: a bare glob (for f in *.txt) or find … -print0 piped into a loop that reads null-delimited entries both sidestep the problem instead of working around it after the fact.

12. Check for hardcoded secrets before you run a script someone else wrote

Before I run the script below, scan it for anything that looks like a
hardcoded API key, password, or token, and flag the exact line. Don't fix
or remove anything, just report what you find so I can decide myself.

SCRIPT:
<paste>

13. Empty a log file that another process is actively writing to

A service is still writing to app.log and I want to clear its contents
without breaking the running process or losing the file path it has open.
Don't give me a command that deletes and recreates the file; give me the
in-place truncation form, and tell me if the service will need a reload
signal afterward.
: > app.log

This truncates the file to zero bytes in place. The service's existing file descriptor still points at the same file, so it keeps writing rather than writing into a file nobody else can see, which is what happens if you rm the file and a new one gets created under the same name later. Whether the service itself needs a reload signal to notice the truncation cleanly depends on that service, and a model that doesn't know your specific one should say so rather than guess.

Free Chrome Extension

Stop rewriting prompts. Start shipping.

Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.

Create An Account

What if the model reaches for sudo, or a path you never mentioned?

Stop and ask why before you run anything. An unrequested sudo means the model inferred you need elevated permissions from something in your phrasing, and that inference can be wrong in either direction: too cautious and the command simply fails, too permissive and a mistake elsewhere in the same line now has root's blast radius instead of your own. A path outside what you described is the same kind of signal. Either your prompt was ambiguous about scope, or the model filled a gap with a guess. Both are cheaper to resolve as a follow-up question in the chat than as a surprise in a terminal, which is the same discipline behind red-teaming a prompt before you trust its output and behind treating a prompt hygiene checklist as a real gate rather than a formality.

The pattern underneath every prompt on this page is the same one whether you're generating a shell one-liner, a regex, or a commit message from a diff: show the reviewable version before the version that acts, and never let "it looks right" substitute for having actually read it. A model that writes a shell command doesn't run it. You do, in a shell that will do exactly what the text says, including the part where a blank variable and a wildcard turn a five-word request into a much longer cleanup job than the one you meant to start.

Frequently asked questions

Free Chrome Extension

Stop rewriting prompts. Start shipping.

Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.

Create An Account