Use this skill when running shell commands, writing shell scripts, or working with file redirection. Covers zsh compatibility (noclobber, extended globbing, special characters), shell detection, and safe patterns that work across bash and zsh. Activate whenever commands fail with "file exists", "no matches found", "event not found", or other shell-specific errors, and whenever writing commands that use redirection, globbing, or special characters.
How this skill is triggered — by the user, by Claude, or both
Slash command
/cstrahan-claude-plugins:shellThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
When Claude Code runs commands via the Bash tool, it's actually running whatever the user's `$SHELL` is — often zsh with the user's full configuration loaded. This means shell options like `noclobber`, `extended_glob`, and others are active and can cause commands that work fine in vanilla bash to fail in surprising ways.
When Claude Code runs commands via the Bash tool, it's actually running whatever the user's $SHELL is — often zsh with the user's full configuration loaded. This means shell options like noclobber, extended_glob, and others are active and can cause commands that work fine in vanilla bash to fail in surprising ways.
echo "$SHELL" # User's login shell (e.g., /bin/zsh)
echo "$ZSH_VERSION" # Set only in zsh
echo "$BASH_VERSION" # Set only in bash
In practice, assume zsh on macOS — it's been the default since Catalina (10.15). Write commands that work under both bash and zsh.
Many zsh configurations set noclobber (set -o noclobber or setopt noclobber), which changes how > and >> behave.
With noclobber enabled:
| What you write | What happens | Error |
|---|---|---|
echo "data" > existing_file.txt | Fails | file exists |
echo "data" >> missing_file.txt | Fails (zsh only) | no such file or directory |
The > operator refuses to overwrite existing files, and in zsh (not bash), the >> operator refuses to create new files.
>| for overwriteThe >| operator forces the redirect regardless of noclobber. It works identically in both bash and zsh:
echo "data" >| file.txt # Create or overwrite — always works
Use >| whenever the intent is "this file should contain exactly this content." There is no downside — it behaves identically to > when noclobber is off, and does the right thing when it's on.
>>|For appending, zsh's >> fails if the target file doesn't exist (with noclobber). The force variant >>| handles both cases:
echo "data" >>| file.txt # Append, creating file if needed (zsh)
However, >>| is zsh-only syntax — it causes a syntax error in bash. If you need a portable append-or-create:
# Portable: works in both bash and zsh
touch file.txt && echo "data" >> file.txt
Or if you know you're in zsh:
echo "data" >>| file.txt
>! — it's not portableZsh also supports >! as a force-overwrite operator, but in bash, >! is parsed as > followed by ! (history expansion). This creates a file literally named ! and silently does the wrong thing. Always use >| instead.
| Intent | Safe syntax | Notes |
|---|---|---|
| Overwrite/create | `> | ` |
| Append to existing file | >> | Works everywhere when file exists |
| Append, create if missing | `>> | ` |
When creating temp files (e.g., for GIT_SEQUENCE_EDITOR), mktemp creates the file before you write to it — so > will fail with noclobber:
# WRONG — fails with noclobber because mktemp already created the file
TMPFILE=$(mktemp)
cat > "$TMPFILE" << 'EOF'
content
EOF
# RIGHT — use >| to force overwrite
TMPFILE=$(mktemp)
cat >| "$TMPFILE" << 'EOF'
content
EOF
# ALSO RIGHT — remove first, then write
TMPFILE=$(mktemp)
rm -f "$TMPFILE"
cat > "$TMPFILE" << 'EOF'
content
EOF
A heredoc redirects a block of inline text into a command's stdin until a delimiter line is reached. The single most important fact: whether the delimiter is quoted determines whether the body is interpreted or literal. Bash and zsh agree on every detail of this behavior — the gotchas below are not shell-portability issues, they are language behavior any heredoc-using script has to get right.
| Form | Body interpretation |
|---|---|
<<EOF (delimiter unquoted) | Like a double-quoted string: $var expands, `cmd` and $(cmd) substitute, \ escapes $, `, \, and a trailing newline (line continuation). Any of <<EOF, <<"EOF" minus the quotes, or << EOF are all "unquoted". |
<<'EOF', <<"EOF", <<\EOF | Body is fully literal. NO expansion. NO command substitution. NO arithmetic. NO backslash escapes — \$, \`, \\ are kept verbatim, not consumed. Even \<newline> does NOT join lines. |
Any quoting on the delimiter — single, double, or a leading backslash —
puts you in literal mode. The choice between 'EOF', "EOF", and
\EOF is purely stylistic; they're equivalent.
<<'EOF'When the delimiter is quoted, the body is literal. That means
backslashes are kept as-is — they do not quote the next character
the way they would in a double-quoted string or an unquoted heredoc.
Adding \$, \`, \\ to "be safe" lands literal backslashes in
your output:
# WRONG — produces "literal: \$USER" with the backslash
cat <<'EOF'
literal: \$USER
EOF
# RIGHT — single-quoted delimiter already prevents expansion;
# write the dollar sign as-is:
cat <<'EOF'
literal: $USER
EOF
This is the most common heredoc bug for someone who's used to escaping inside double-quoted strings. The rule of thumb: pick one mode, and write the body in that mode's idiom.
<<EOF) and escape what you don't want
expanded (\$, \`).<<'EOF') and write everything
as-is. No backslashes for $, `, \.Verified identical between bash and zsh:
| Body content | <<EOF (unquoted) | <<'EOF' / <<"EOF" / <<\EOF |
|---|---|---|
$USER | expands to value of $USER | literal $USER |
`echo hi` | expands to hi | literal `echo hi` |
\$USER | escape consumed → $USER (literal dollar) | literal \$USER (backslash kept!) |
\`hi\` | escape consumed → `hi` | literal \`hi\` |
\\hi | escape consumed → \hi | literal \\hi |
text\ then newline | line continuation — joins to next line | NOT joined; trailing \ kept |
<<-EOF)The dash variant <<- strips leading tabs (not spaces) from each body
line and from the line containing the delimiter, so heredocs in
indented code don't have to break the indent. Both shells implement
this the same way; quoting rules above still apply.
if true; then
cat <<-'EOF'
this line is preceded by a tab
so is this one
EOF
fi
Combine with >| (see above) so the heredoc-write doesn't trip
zsh's noclobber when the target file already exists:
cat >| "$TMPFILE" <<'EOF'
literal body — no expansion, no escapes
EOF
When the same file needs both expanded values (paths to other temp
files, the user's home dir, a session ID) and large blocks of literal
text (a script body, a config file, source code), you can avoid
escaping every $ and ` in a single unquoted heredoc by
building the file in two or more passes:
>| — writes the parts that need
$VAR / $(cmd) expansion. Bake those values into the output as
literals.>> — append blocks that
should be fully literal (no expansion, no backslash-escape
bookkeeping).TMPFILE=$(mktemp)
COUNTER_FILE=$(mktemp)
# Pass 1: expanded. $COUNTER_FILE is burned in as the literal path.
cat >| "$TMPFILE" << OUTER
#!/bin/bash
COUNTER="$COUNTER_FILE"
OUTER
# Pass 2: literal. $COUNTER and $1 stay as bash-runtime references,
# no escaping needed.
cat >> "$TMPFILE" << 'OUTER'
n=$(cat "$COUNTER")
n=$((n + 1))
echo "$n" >| "$COUNTER"
case "$n" in
1) cp /tmp/msgs/first "$1" ;;
2) cp /tmp/msgs/second "$1" ;;
esac
OUTER
chmod +x "$TMPFILE"
The split makes intent obvious: the first pass shows exactly which
values are baked in at script-generation time; the second pass is
plain runtime shell code, written without any heredoc-specific
backslash bookkeeping. It's much easier to read (and audit) than a
single unquoted heredoc with \$ peppered everywhere.
The same recipe scales beyond two passes — alternate >| once and
>> for every subsequent block to assemble files of any structure.
Zsh treats several characters as functional operators that bash treats as literal. These cause "no matches found" errors or silent misbehavior when they appear unquoted in arguments.
| Character | Context | Example that breaks |
|---|---|---|
? | Single-char glob (bash AND zsh) | echo https://example.com?param=value → "no matches found" |
= (word-initial) | Zsh filename expansion — =cmd becomes /path/to/cmd | echo =true outputs /bin/true |
^ | Zsh extended glob negation, or history substitution at line start | git rebase -i abc1234^ → "no matches found" |
~ | Zsh extended glob exclusion (also home dir expansion) | *.c~main.c means "all .c except main.c" |
# | Zsh extended glob repetition (with extended_glob) | Part of a regex-like pattern matching |
() | Zsh glob qualifiers (unquoted only) | cp download (1).pdf ~/ → error |
<> | Zsh numeric range glob | file<1-10> matches file1 through file10 |
! | History expansion (bash and zsh, inside double quotes too) | echo "Hello!" → "event not found" |
The ? character is a standard glob that matches any single character — it's active in both bash and zsh. It's a common pitfall with URLs containing query strings:
# WRONG — ? is a glob character, shell tries to match files
curl https://example.com/api?page=1&limit=10
# RIGHT — quote the URL
curl 'https://example.com/api?page=1&limit=10'
If a string contains any non-alphanumeric character beyond /, _, -, or ., wrap it in single quotes:
# WRONG
git rebase -i abc1234^
echo Hello!
# RIGHT
git rebase -i 'abc1234^'
echo 'Hello!'
Single quotes prevent ALL interpretation — no variable expansion, no globbing, no history expansion. They are the only truly safe quoting mechanism for literal strings in zsh.
Use double quotes for the variable parts and single quotes for the literal parts, concatenated:
# Variable $BASE with a literal ^
git rebase -i "${BASE}"'^'
# Or escape the specific character
git rebase -i "${BASE}\^"
Git caret notation:
# These all work in zsh:
git rebase -i 'abc1234^' # Single quotes
git rebase -i "abc1234^" # Double quotes (^ is safe in double quotes)
git rebase -i abc1234\^ # Escaped
git rebase -i 'abc1234^{commit}' # Extended ref syntax
Filenames with parentheses:
# WRONG — unquoted parentheses trigger glob qualification
cp download (1).pdf ~/Documents/ # zsh error: no matches found: (1).pdf
# RIGHT — any quoting works (double or single)
cp "download (1).pdf" ~/Documents/
cp 'download (1).pdf' ~/Documents/
Exclamation points in commit messages:
# WRONG — history expansion inside double quotes
git commit -m "fix: handle edge case!"
# RIGHT — single quotes, or escape
git commit -m 'fix: handle edge case!'
git commit -m "fix: handle edge case\!"
Beyond special characters and options, bash and zsh have fundamental behavioral differences that can cause scripts to silently produce wrong results.
This is the most significant difference between the two shells.
VAR="hello world"
# Bash: $VAR becomes two arguments → looks for files "hello" and "world"
ls $VAR # bash: ls hello world
# Zsh: $VAR stays one argument → looks for file "hello world"
ls $VAR # zsh: ls "hello world"
This means scripts that rely on unquoted variables to pass multiple arguments (a common bash pattern) will break in zsh. If you need word splitting in zsh, use $=VAR. But the better approach is to always use arrays for multi-value data, which works in both shells.
${arr[0]} is the first element.${arr[1]} is the first element. ${arr[0]} returns empty.arr=(a b c)
# Bash:
echo "${arr[0]}" # "a"
# Zsh:
echo "${arr[1]}" # "a"
echo "${arr[0]}" # "" (empty!)
Any logic that calculates array offsets will be off-by-one between the two shells.
*.xyz stays as *.xyz).no matches found.# Bash: passes literal "*.nonexistent" to echo
echo *.nonexistent # bash: *.nonexistent
# Zsh: aborts the command entirely
echo *.nonexistent # zsh: error: no matches found: *.nonexistent
This is particularly dangerous for commands that use glob-like syntax for non-file purposes (e.g., pip install package[extra]). In zsh, quote these to prevent glob interpretation: pip install 'package[extra]'.
When writing scripts (as opposed to inline commands), always include a shebang to ensure the correct interpreter:
#!/usr/bin/env bash
When zsh encounters a script starting with #!/bin/bash or #!/usr/bin/env bash, it spawns a bash subprocess, bypassing all zsh-specific behavior. This is the simplest way to guarantee bash semantics for a script file.
This is why the GIT_SEQUENCE_EDITOR temp scripts in the git-interactive-rebase skill use #!/bin/bash — the outer shell may be zsh, but the inner script runs in bash where cat > "$1" works without noclobber issues.
| Behavior | Bash | Zsh |
|---|---|---|
| Word splitting on unquoted vars | Automatic | None (use $=VAR to force) |
| Array start index | 0 | 1 |
| Unmatched glob | Passes literal string | Fatal error: "no matches found" |
| Empty unquoted variable | Removed from arg list | Removed from arg list |
read -n 1 (single char) | Works | Use read -k 1 instead |
npx claudepluginhub cstrahan/claude-plugins --plugin cstrahan-claude-pluginsGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.