<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Morten Larsen</title>
    <description>The latest articles on DEV Community by Morten Larsen (@baunegaard).</description>
    <link>https://dev.to/baunegaard</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4134507%2F8415c440-75a6-451e-a5f1-cd3607c01ffe.jpg</url>
      <title>DEV Community: Morten Larsen</title>
      <link>https://dev.to/baunegaard</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/baunegaard"/>
    <language>en</language>
    <item>
      <title>Why Claude Code keeps writing shell commands that fail on your Mac</title>
      <dc:creator>Morten Larsen</dc:creator>
      <pubDate>Mon, 21 Sep 2026 20:14:48 +0000</pubDate>
      <link>https://dev.to/baunegaard/why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac-3bea</link>
      <guid>https://dev.to/baunegaard/why-claude-code-keeps-writing-shell-commands-that-fail-on-your-mac-3bea</guid>
      <description>&lt;p&gt;Claude wrote this, mid-task, while refactoring something unrelated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s/old/new/'&lt;/span&gt; config.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's correct. It's the form you'll find in half the StackOverflow answers, blog posts and Dockerfiles out there. It exited 0. And it left a file called &lt;code&gt;config.yml-e&lt;/code&gt; sitting in my repo, because on macOS that command means something else entirely.&lt;/p&gt;

&lt;p&gt;That's the good case, the one that leaves evidence. The louder failures look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;sed: 1: "config.yml": command c expects \ followed by text
date: illegal option -- d
stat: illegal option -- c
sed: illegal option -- -
(eval):1: no matches found: nope*.txt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've used Claude Code on a Mac for more than a week, you've seen some of these. Claude writes a command, it fails, Claude apologises and writes a different one, and you lose thirty seconds and a bit of trust. It happens often enough to feel like the model being sloppy.&lt;/p&gt;

&lt;p&gt;It isn't. There are two concrete, fixable reasons, and neither one is visible from inside a session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 1: the Bash tool is not bash
&lt;/h2&gt;

&lt;p&gt;Claude Code's Bash tool runs your &lt;strong&gt;login shell&lt;/strong&gt;. On every Mac since Catalina, that's zsh.&lt;/p&gt;

&lt;p&gt;So the tool named Bash, whose description begins "Executes a bash command," is handing your commands to zsh. Everything the model knows about bash — &lt;code&gt;mapfile&lt;/code&gt;, &lt;code&gt;${x^^}&lt;/code&gt;, word splitting, how an unmatched glob behaves — is subtly wrong. That last one is a good example of how quiet this gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; nope&lt;span class="k"&gt;*&lt;/span&gt;.txt &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FOLLOW-UP RAN"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In bash, &lt;code&gt;rm -f&lt;/code&gt; shrugs at the missing file and the follow-up runs. In zsh, &lt;code&gt;nomatch&lt;/code&gt; is on by default, so the unmatched glob is a &lt;em&gt;shell&lt;/em&gt; error, &lt;code&gt;rm&lt;/code&gt; never runs, and the chain returns non-zero. Same line, opposite outcome, and the error you get back is prefixed &lt;code&gt;(eval):1:&lt;/code&gt; — which tells you it went through &lt;code&gt;eval&lt;/code&gt;, but never mentions zsh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reason 2: the userland is BSD
&lt;/h2&gt;

&lt;p&gt;macOS ships BSD versions of the standard tools. Claude — like nearly every shell example ever written — assumes GNU.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What Claude writes&lt;/th&gt;
&lt;th&gt;What macOS does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sed -i -e 's/a/b/' file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;creates &lt;code&gt;file-e&lt;/code&gt;, edits the original, &lt;strong&gt;exits 0&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sed -i 's/a/b/' file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sed: 1: "file": command c expects \ followed by text&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;date -d yesterday +%F&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;date: illegal option -- d&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stat -c %s file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stat: illegal option -- c&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sed -E 's/\s+/_/'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;doesn't match — BSD &lt;code&gt;sed&lt;/code&gt; has no &lt;code&gt;\s&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;sed -i&lt;/code&gt; is the one worth internalising. BSD &lt;code&gt;sed -i&lt;/code&gt; reads the &lt;em&gt;next argument&lt;/em&gt; as a backup suffix, always. Write &lt;code&gt;-i -e 's/a/b/'&lt;/code&gt; and the suffix is &lt;code&gt;-e&lt;/code&gt;: the script is read from the following argument, the file is edited in place, exits 0, and a copy named &lt;code&gt;file-e&lt;/code&gt; appears beside it. That is literally where the &lt;code&gt;-e&lt;/code&gt; in the filename comes from. Write plain &lt;code&gt;-i 's/a/b/'&lt;/code&gt; and the suffix is &lt;code&gt;s/a/b/&lt;/code&gt;, so the filename becomes the script and &lt;code&gt;sed&lt;/code&gt; fails on its first letter. One form fails loudly, the other succeeds and litters. An agent that checks the exit code sees success on the second and moves on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't fix this in your shell profile
&lt;/h2&gt;

&lt;p&gt;This is the part that cost me an afternoon, and it's the reason a five-minute fix turns into a project.&lt;/p&gt;

&lt;p&gt;The obvious move is to &lt;code&gt;brew install coreutils gnu-sed&lt;/code&gt; and put the GNU binaries first on &lt;code&gt;PATH&lt;/code&gt; in &lt;code&gt;~/.zprofile&lt;/code&gt; or &lt;code&gt;~/.bashrc&lt;/code&gt;. The Bash tool will pick them up. So will everything else in your terminal, and that's the problem: &lt;strong&gt;a profile can't give only Claude Code the GNU tools.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Your profile reaches the Bash tool one way only: through the terminal that launched &lt;code&gt;claude&lt;/code&gt;, which ran the profile and passed its PATH on. An export there changes &lt;code&gt;sed&lt;/code&gt; for every script, Makefile and muscle-memory command in that terminal, not just for the agent. Launch Claude Code from the desktop app or an IDE and there's no terminal in the chain at all.&lt;/p&gt;

&lt;p&gt;The natural next step is to scope it with &lt;code&gt;if [ -n "$CLAUDECODE" ]&lt;/code&gt;. That doesn't work either, and this is the non-obvious bit. Claude Code captures a &lt;em&gt;shell snapshot&lt;/em&gt; when a session starts and replays it before every Bash call. It does run your profile to build that snapshot, with &lt;code&gt;CLAUDECODE=1&lt;/code&gt; set, so the guard fires. But the &lt;code&gt;export PATH&lt;/code&gt; line in the snapshot is written from Claude Code's &lt;strong&gt;own inherited environment&lt;/strong&gt;, not from the shell it just captured, so whatever that run added is dropped. The captured login shell is asked for its own PATH only on Windows. And in your terminal, the one place the export would stick, &lt;code&gt;CLAUDECODE&lt;/code&gt; isn't set, so it never runs.&lt;/p&gt;

&lt;p&gt;I verified this rather than assuming it. I put a probe in both &lt;code&gt;~/.bashrc&lt;/code&gt; and &lt;code&gt;~/.zshrc&lt;/code&gt; that logged &lt;code&gt;CLAUDECODE&lt;/code&gt;, prepended one directory unconditionally and another only when &lt;code&gt;CLAUDECODE&lt;/code&gt; was set. On Claude Code 2.1.282, in a bash session and a zsh session, the capture shell sourced the file with &lt;code&gt;CLAUDECODE=1&lt;/code&gt;, and neither directory reached the Bash tool.&lt;/p&gt;

&lt;p&gt;So your profile isn't being ignored. It runs. But only the PATH you launched &lt;code&gt;claude&lt;/code&gt; with reaches the tool, never what the profile adds during the capture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works
&lt;/h2&gt;

&lt;p&gt;Two pieces of documented-but-obscure surface, one for each problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  The shell: &lt;code&gt;CLAUDE_CODE_SHELL&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;First, get a bash worth pointing at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This step is easy to skip, and skipping it quietly undoes the rest. macOS does ship a &lt;code&gt;/bin/bash&lt;/code&gt;, but it's 3.2, from 2007 — Apple froze it when bash moved to GPLv3 and has never shipped a newer one. It has no &lt;code&gt;mapfile&lt;/code&gt;, no &lt;code&gt;${x^^}&lt;/code&gt;, no associative arrays. Switching the Bash tool to "bash" without installing one just moves you from a modern zsh to an 18-year-old bash, which is worse.&lt;/p&gt;

&lt;p&gt;Then, &lt;code&gt;CLAUDE_CODE_SHELL&lt;/code&gt; in the &lt;code&gt;env&lt;/code&gt; block of &lt;code&gt;settings.json&lt;/code&gt;. It's read &lt;strong&gt;before the shell is chosen&lt;/strong&gt;, which is exactly why it works where a profile can't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CLAUDE_CODE_SHELL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SHELL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bare &lt;code&gt;bash&lt;/code&gt; there means the first bash on PATH — Homebrew's 5.x now that you've installed it, not Apple's 3.2. Set &lt;code&gt;SHELL&lt;/code&gt; alongside it, or the model's own environment summary keeps telling it the shell is zsh.&lt;/p&gt;

&lt;h3&gt;
  
  
  The tools: &lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Same shape — install first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;coreutils findutils gawk gnu-sed gnu-tar gnu-which &lt;span class="nb"&gt;grep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each of those formulas ships a &lt;code&gt;libexec/gnubin&lt;/code&gt; directory containing the GNU builds under their &lt;strong&gt;plain names&lt;/strong&gt; — &lt;code&gt;sed&lt;/code&gt;, not &lt;code&gt;gsed&lt;/code&gt;. That directory is the thing you put on PATH; nothing is symlinked or copied, so a later &lt;code&gt;brew install&lt;/code&gt; or &lt;code&gt;brew uninstall&lt;/code&gt; takes effect at the next session with no step in between.&lt;/p&gt;

&lt;p&gt;Getting it onto the &lt;em&gt;agent's&lt;/em&gt; PATH is where &lt;code&gt;CLAUDE_ENV_FILE&lt;/code&gt; comes in. A &lt;code&gt;SessionStart&lt;/code&gt; hook may append shell statements to the file that variable names, and Claude Code sources that file &lt;strong&gt;after&lt;/strong&gt; the snapshot, before every Bash call. So a PATH prepend written there wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/homebrew/opt/coreutils/libexec/gnubin:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook writes one such line covering the gnubin directory of every formula that is actually installed.&lt;/p&gt;

&lt;p&gt;That path is the Apple Silicon one. On an Intel Mac, or with an x86_64 Homebrew under Rosetta, the prefix is &lt;code&gt;/usr/local&lt;/code&gt;, and a PATH entry pointing at a directory that isn't there fails quietly — &lt;code&gt;sed&lt;/code&gt; stays BSD and nothing says so. The hook detects the prefix instead of hardcoding it, by probing the filesystem rather than asking &lt;code&gt;brew&lt;/code&gt;: a hook launched from the desktop app or an IDE doesn't necessarily have &lt;code&gt;brew&lt;/code&gt; on PATH either.&lt;/p&gt;

&lt;p&gt;If you'd rather do both halves in one go:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;bash coreutils findutils gawk gnu-sed gnu-tar gnu-which &lt;span class="nb"&gt;grep&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Measured on Claude Code 2.1.266, macOS 26.6, in a session started with no inherited PATH, from a directory with and without the config:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Without&lt;/th&gt;
&lt;th&gt;With&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;zsh 5.9&lt;/td&gt;
&lt;td&gt;bash 5.3.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sed --version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sed: illegal option -- -&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GNU sed 4.10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;date --version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;date: illegal option -- -&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GNU coreutils 9.11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;awk --version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;awk version 20200816&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GNU Awk 5.4.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;date -d yesterday +%F&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;error&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-09-08&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your own terminal is untouched. &lt;code&gt;sed&lt;/code&gt; still resolves to &lt;code&gt;/usr/bin/sed&lt;/code&gt; in your shell, and &lt;code&gt;gsed&lt;/code&gt; and &lt;code&gt;gdate&lt;/code&gt; still work the way you're used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaged
&lt;/h2&gt;

&lt;p&gt;I put both halves in a repo, because I was tired of pasting them into every project:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/Baune8D/claude-code-macos" rel="noopener noreferrer"&gt;https://github.com/Baune8D/claude-code-macos&lt;/a&gt;&lt;/strong&gt; (MIT)&lt;/p&gt;

&lt;p&gt;As a plugin, once, for every repo you open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/plugin marketplace add Baune8D/claude-code-macos
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;claude-code-macos@claude-code-macos
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then add that &lt;code&gt;env&lt;/code&gt; block to &lt;code&gt;~/.claude/settings.json&lt;/code&gt; by hand. A plugin manifest has no &lt;code&gt;env&lt;/code&gt; block, and &lt;code&gt;CLAUDE_CODE_SHELL&lt;/code&gt; is read before the shell is chosen so no hook can write it either — which means a plugin install alone gets you the GNU tools while leaving you on zsh. There's a hook that tells you when that's the state you're in, rather than letting the session run half-fixed.&lt;/p&gt;

&lt;p&gt;Or copy &lt;code&gt;.claude/settings.json&lt;/code&gt; and &lt;code&gt;.claude/hooks/&lt;/code&gt; into a repo, which is the whole fix in one step and travels to everyone who clones it.&lt;/p&gt;

&lt;p&gt;It's silent when it succeeds. It speaks once, at session start, when it can't deliver: Homebrew missing, a formula not installed, the Bash tool not running bash, or the bash that won still being Apple's 3.2. The agent gets the fact ("This session's &lt;code&gt;sed&lt;/code&gt; is the macOS build, not GNU."), you get the fix (&lt;code&gt;brew install gnu-sed&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  One deliberate omission: &lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;find&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Claude Code ships its own. Both are installed into the session as &lt;strong&gt;shell functions&lt;/strong&gt; that re-exec the &lt;code&gt;claude&lt;/code&gt; binary as &lt;a href="https://github.com/Genivia/ugrep" rel="noopener noreferrer"&gt;ugrep&lt;/a&gt; and &lt;a href="https://github.com/tavianator/bfs" rel="noopener noreferrer"&gt;bfs&lt;/a&gt; — and a function beats a PATH lookup, so those two names stay Claude Code's however you arrange PATH.&lt;/p&gt;

&lt;p&gt;An earlier version of my hook removed those functions. I took it back out after measuring: across a couple dozen common idioms (&lt;code&gt;-rn&lt;/code&gt;, &lt;code&gt;--include&lt;/code&gt;, &lt;code&gt;-oP&lt;/code&gt;, &lt;code&gt;\b&lt;/code&gt;, &lt;code&gt;-A&lt;/code&gt;, &lt;code&gt;-printf&lt;/code&gt;, &lt;code&gt;-regex&lt;/code&gt;, &lt;code&gt;-exec {} +&lt;/code&gt;) the engines never disagreed on syntax. Every difference was in ugrep's &lt;em&gt;defaults&lt;/em&gt; — a bare &lt;code&gt;grep -r&lt;/code&gt; honours &lt;code&gt;.gitignore&lt;/code&gt;, skips binaries, and omits the &lt;code&gt;./&lt;/code&gt; prefix. For an agent searching a repository, not walking &lt;code&gt;node_modules&lt;/code&gt; is simply the better default.&lt;/p&gt;

&lt;p&gt;The GNU &lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;findutils&lt;/code&gt; formulas are still worth installing, because scripts, an explicit &lt;code&gt;command grep&lt;/code&gt;, and the wrapper's own fall-through for &lt;code&gt;-z&lt;/code&gt;/&lt;code&gt;--null&lt;/code&gt; all resolve through PATH.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upstream
&lt;/h2&gt;

&lt;p&gt;This should ideally not need a workaround, so both halves are filed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/91498" rel="noopener noreferrer"&gt;anthropics/claude-code#91498&lt;/a&gt; — the Bash tool runs the login shell (not my issue; I found it afterwards)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/95705" rel="noopener noreferrer"&gt;anthropics/claude-code#95705&lt;/a&gt; — the userland is BSD and nothing says so&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Until one of those lands, this is what I run. If you've been quietly assuming Claude is just bad at shell on your machine — it's the environment, and it takes about two minutes to fix.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>macos</category>
      <category>claudecode</category>
      <category>claude</category>
    </item>
    <item>
      <title>Turn your ytt data values schema into a documentation site</title>
      <dc:creator>Morten Larsen</dc:creator>
      <pubDate>Mon, 21 Sep 2026 19:59:08 +0000</pubDate>
      <link>https://dev.to/baunegaard/turn-your-ytt-data-values-schema-into-a-documentation-site-3gho</link>
      <guid>https://dev.to/baunegaard/turn-your-ytt-data-values-schema-into-a-documentation-site-3gho</guid>
      <description>&lt;p&gt;If you template Kubernetes manifests with &lt;a href="https://carvel.dev/ytt/" rel="noopener noreferrer"&gt;ytt&lt;/a&gt;, you probably have a data values schema. It starts small: a handful of keys with a &lt;code&gt;#@schema/desc&lt;/code&gt; here and a &lt;code&gt;#@schema/validation&lt;/code&gt; there. Then it grows. Ours, the &lt;code&gt;deployment.yaml&lt;/code&gt; schema for every microservice at Dinero, ended up with a few hundred nested maps. At that size nobody opens the schema file to find out what a key does. They ask a colleague, or copy a value from another service and hope.&lt;/p&gt;

&lt;p&gt;The frustrating part is that the answers were all in the schema. ytt's schema annotations carry a title, a description, a default, examples and validation rules for every value. We just had no way to read them that didn't involve scrolling through YAML.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/Baune8D/ytt-schema-docs" rel="noopener noreferrer"&gt;ytt-schema-docs&lt;/a&gt;, a small build step that renders a ytt data values schema as a static site. Here is the &lt;a href="https://baunegaard.net/ytt-schema-docs/" rel="noopener noreferrer"&gt;example site&lt;/a&gt;, generated from a deployment-style schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;p&gt;Every map in the schema becomes a table with one row per property: the name and title, the type as a badge, the default and constraints, and the description. Nested maps get their own table, and the property name links to it. Arrays of maps show up as &lt;code&gt;name[]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A few things fall out of the annotations you already write:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;#@schema/validation min=1, max=65535&lt;/code&gt; renders as Minimum and Maximum chips. &lt;code&gt;one_of=&lt;/code&gt; renders each allowed value as a chip. &lt;code&gt;min_len=&lt;/code&gt; and &lt;code&gt;max_len=&lt;/code&gt; work the same for strings and arrays.&lt;/li&gt;
&lt;li&gt;A property is marked &lt;strong&gt;Required&lt;/strong&gt; when its default fails its own validation. That is exactly the pattern the ytt docs recommend for required values: an empty string with &lt;code&gt;min_len=1&lt;/code&gt;, a &lt;code&gt;0&lt;/code&gt; with &lt;code&gt;min=1&lt;/code&gt;, or a &lt;code&gt;""&lt;/code&gt; against a &lt;code&gt;one_of&lt;/code&gt; list. A &lt;code&gt;#@schema/nullable&lt;/code&gt; property defaults to &lt;code&gt;null&lt;/code&gt;, which ytt does not validate, so it is never marked required.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;#@schema/examples&lt;/code&gt; renders as a labeled example under the type.&lt;/li&gt;
&lt;li&gt;HTML in descriptions is allowed, so &lt;code&gt;&amp;lt;strong&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;br/&amp;gt;&lt;/code&gt; in a &lt;code&gt;#@schema/desc&lt;/code&gt; do what you expect.&lt;/li&gt;
&lt;li&gt;Giving a property the title &lt;code&gt;__REMOVE_ME__&lt;/code&gt; hides it and everything nested under it, for internal settings that should not be in the public docs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Around the tables there is a sidebar listing every map, nested by depth, with a search box that filters on map and property names. The sidebar highlights the map you are reading as you scroll. The site follows your system's light or dark mode and has a toggle, and the tables stack into blocks on a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;The trick is that ytt can export its schema as OpenAPI v3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ytt &lt;span class="nt"&gt;--file&lt;/span&gt; schema.yaml &lt;span class="nt"&gt;--data-values-schema-inspect&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; openapi-v3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you a standard document with &lt;code&gt;properties&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, &lt;code&gt;minimum&lt;/code&gt;, &lt;code&gt;enum&lt;/code&gt; and so on, plus the ytt-specific &lt;code&gt;x-example-description&lt;/code&gt;. From there it is ordinary web tooling:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A tiny ytt overlay patches the &lt;code&gt;info&lt;/code&gt; block with the site title, description and version.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openapi-generator.tech/" rel="noopener noreferrer"&gt;openapi-generator&lt;/a&gt; renders the document with a custom template. The template is little more than a page shell that embeds each schema as JSON.&lt;/li&gt;
&lt;li&gt;A browser script dereferences the &lt;code&gt;$ref&lt;/code&gt;s, walks the maps depth first in schema order, and builds the tables and the sidebar with plain DOM APIs. Tailwind provides the styling.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because it is a build step and not a ytt extension, it works with any ytt version that has the OpenAPI export, and it does not care how your templates are organised. Point it at the schema file and run &lt;code&gt;npm run build&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it on your own schema
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Baune8D/ytt-schema-docs
&lt;span class="nb"&gt;cd &lt;/span&gt;ytt-schema-docs
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="c"&gt;# replace schema.yaml with yours, or change the path in package.json&lt;/span&gt;
npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The site lands in &lt;code&gt;dist&lt;/code&gt; as a single &lt;code&gt;index.html&lt;/code&gt; with a script and a stylesheet next to it. Host it anywhere static files go. We serve ours from a small Node container next to the deploy CLI that owns the schema, and CI regenerates the OpenAPI export before every image build so the docs can never drift from the templates.&lt;/p&gt;

&lt;p&gt;You need Node, &lt;code&gt;ytt&lt;/code&gt; on your &lt;code&gt;PATH&lt;/code&gt;, and a Java runtime for openapi-generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would like to hear
&lt;/h2&gt;

&lt;p&gt;If you use ytt schemas, I am curious what you would want rendered differently, and whether the required-value rule matches how you write validations. Issues and pull requests are welcome on &lt;a href="https://github.com/Baune8D/ytt-schema-docs" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>yaml</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
