Dev.to AI 🤖 Ai 👁 0 📖 8 min read

Your Coding Agent's sed -i Works on Linux and Breaks on macOS. BSD vs GNU sed, Explained

Book: RAG Pocket Guide The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series My project: Hermes IDE | GitHub — an IDE

Your Coding Agent's sed -i Works on Linux and Breaks on macOS. BSD vs GNU sed, Explained

You ask your coding agent to rename a function across a few files. It reaches for the shell, because a one-line sed is cheaper than opening every file. On a Linux box or a CI runner, the edit lands. On your Mac, the agent gets this back, or something shaped like it:

sed: 1: "Rakefile": invalid command code R

That exact line comes from a 2011 write-up of sed -i 's/require/include/' Rakefile on Mac OS X. Fifteen years later it is still what a Linux-style sed -i does on macOS. The agent then retries, guesses, or quietly switches strategy. The worse case is the one with no error at all, where it leaves a stray file in your repo and moves on.

There are two seds here, and they disagree about one flag.

Two seds, one flag that was never standard

sed -i is not POSIX. The current standard, POSIX.1-2024 (Issue 8), lists only -E and -n as options next to -e and -f. In-place editing is an extension, and the two big families added it separately, around the same time, with different grammar.

GNU sed added it in release 3.95: "New option -i, to support in-place editing a la Perl," per the project's NEWS file. That release was announced in October 2002 as an alpha on the way to GNU sed 4.0. Like Perl's -i, the backup suffix is optional and must be glued to the flag: -i alone means no backup, -i.bak means keep one.

FreeBSD got its own -i five months earlier, in a commit dated 7 May 2002. The message says it was meant as an alternative to Perl, with one deliberate difference: "a backup extension *MUST* be specified." A 2008 Mercurial mailing-list post quotes the reason from that commit: it isn't recommended to have options which optionally take a parameter. So in BSD sed, -i always consumes the next argument as the extension, even when that argument is your script.

Two branches of one old tree splitting apart, each holding a different shape of the same tool
Same ancestor, same flag name, two grammars for its argument.

macOS ships the FreeBSD lineage. Apple's own sed man page in text_cmds gives the synopsis as [-I extension] [-i extension] and lists -i among the "non-standard FreeBSD extensions". The FreeBSD man page says the same. There is a later wrinkle too: in a 2007 commit, FreeBSD changed -i to treat each file independently and moved the old single-stream behavior to a new -I.

The standards body looked at this once. Austin Group bug 530, "Support in-place editing in sed," was closed as rejected in 2012. The note says lowercase -i "is not a candidate for standardization" because implementations differ and the standard discourages optional option-arguments.

The same command on both seds

Take the shape agents write most, sed -i 's/old/new/' file.

GNU sed reads -i with no attached suffix, so no backup. The script is s/old/new/, the file is file, and the edit happens.

BSD sed reads -i and takes the next argument, s/old/new/, as the backup extension. Now there is no script yet, so the first remaining argument, the filename, becomes the script. sed tries to parse Rakefile as a sed command, sees R, and fails with invalid command code R. Change the filename and the error changes with it: a name that starts with a real command letter, like config.yml, fails with a different complaint.

The quieter failure is sed -i -e 's/x/y/' x.txt. GNU sed is happy: no suffix, then an explicit script. BSD sed takes -e as the extension, finds the script after it, edits the file, and writes a backup named with -e on the end. A 2020 comparison on FreeBSD and a Mac shows the directory afterwards:

x.txt       x.txt-e

No error, exit status fine, and now x.txt-e is sitting next to the real file, waiting for a careless git add .. Someone hit exactly this with a Java file in a 2015 report to bug-sed: the backup came out as TInfo.java-e. It was closed as not a bug: the backup came from the Mac's sed, not GNU sed.

The trap runs the other way too. The macOS fix, sed -i '' -e 's/old/new/' file, trips GNU sed, which reads '' as an input file. The same 2020 post shows GNU's reply, and the edit still lands, with a non-zero exit:

sed: can't read : No such file or directory

Drop the -e and GNU takes '' as an empty script, tries to open s/old/new/ as a file, and the edit you wanted never happens.

And GNU has its own version of the stray-file bug. Its manual warns that because -i takes an optional argument, it should not be followed by other short options: sed -iE ... means --in-place=E and creates FILEE as a backup.

Portable forms that work on both

There is no flag spelling that means "in place, no backup" to both seds. The Linux kernel ran into the same wall. A July 2026 patch to scripts/config says FreeBSD and macOS disallow a bare -i, while GNU and the other BSDs accept a bare -i but disallow a zero-length extension. The patch's answer was to keep a backup with a .swp extension and delete it on exit.

That gives you the first portable form. Glue a suffix to the flag, then remove the backup:

sed -i.bak 's/old/new/' config.yml && rm config.yml.bak

Both seds accept -i.bak as one argument. The && keeps the backup if the edit failed.

The second form skips sed. Perl's -i has the GNU grammar, an optional suffix glued to the flag, and it's the same Perl on Linux and macOS (perlrun):

perl -pi -e 's/old/new/' config.yml

Two caveats. The pattern is a Perl regex, not a sed BRE, so \( means a literal parenthesis and + needs no backslash. And Perl being on the machine isn't a promise: Apple's Catalina notes said future macOS versions "won't include scripting language runtimes by default," as quoted by TidBITS. Check with command -v perl before you write it into a script.

The third form branches on which sed you have. GNU sed documents --version; BSD sed has no such option and rejects it:

if sed --version >/dev/null 2>&1; then
  sed -i 's/old/new/' config.yml      # GNU
else
  sed -i '' 's/old/new/' config.yml   # BSD, macOS
fi

Or make the Mac match Linux. Homebrew's gnu-sed formula (4.10 today) installs GNU sed as gsed, and its caveat gives a gnubin directory you can put first on your PATH to get it as sed. That fixes your shell. It doesn't fix a teammate's laptop, or a script that runs under a clean environment.

Tell the agent which sed it has

An agent usually doesn't check sed --version before it edits. It writes the form it has seen most, and most shell you'll find in READMEs, Dockerfiles and CI configs runs on Linux. So say it in the instruction file the agent loads every session.

AGENTS.md is the shared format Codex, Gemini CLI, Aider, goose, opencode and others read. Claude Code reads CLAUDE.md, and its memory docs say it can also read a repository's AGENTS.md in place of CLAUDE.md. A few lines are enough:

## Shell
- Dev machines are macOS: `sed` is BSD sed, not GNU.
- No bare `sed -i`. Use
  `sed -i.bak 's/a/b/' f && rm f.bak`
  or `perl -pi -e 's/a/b/' f`.
- `sed -E` is fine on both.

The last line is true and worth keeping: -E for extended regexes works on both families and is now in POSIX Issue 8.

Be honest with yourself about what this buys. The same Claude Code docs say instruction files are treated "as context, not enforced configuration," and point to a PreToolUse hook if you need to block an action outright. The note makes the bad form rarer. It doesn't make it impossible, so you still want to see what the shell did.

Where Hermes IDE fits

I build Hermes IDE, so weigh this with that in mind. Hermes runs each agent's own CLI in a real terminal on your machine. On a Mac, that means the agent gets BSD sed, the same as in any other terminal. Hermes doesn't swap it out, and it never types into a terminal on its own.

What it changes is how fast you notice. Every agent turn is saved, so you can diff or restore a single turn, with undo. The Review Desk (⌘G) shows changes by turn or by file and lists untracked files per project, so a config.yml-e from a sed -i -e shows up in the list before you land the task.

Hermes Review Desk in By turn view: two turns listed, turn 1's diff of src/db.ts, the changed-files list and a drafted commit message
The Review Desk (⌘G) in By turn view for a Claude Code task: two turns, turn 1's diff of src/db.ts, the changed files and a commit message drafted from the turns.

Two more pieces help here. Memory and context pins save facts for a project that reach every later session's agent, so "this machine has BSD sed" can live there as well as in AGENTS.md. And when a task runs on its own branch and worktree, a stray backup file lands in that task's worktree, not in your main checkout. Hermes is free to download, source-available under BSL 1.1, and runs on macOS, Windows and Linux. Download it or read the code on GitHub.

If this was useful

What the agent needed was one local fact it couldn't guess, written where it reads it every session. Getting the right local facts in front of a model at the right moment is most of what the RAG Pocket Guide is about, from chunking to reranking. It's one of 8 short books in the Pocket Guides for Developers series, covering agents, RAG, prompts, observability, events, databases and system design.

RAG Pocket Guide: Retrieval, Chunking, and Reranking Patterns for Production

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.