Posts in category “Essays”

Setting `CLAUDE_CONFIG_DIR` in `.env`? It silently loses to your shell.

Trying to isolate a child process by setting an env var in .env, only to find the value inherited from your shell wins instead. dotenv by default does not overwrite existing process.env values.

This bit me in HelloEDi. I wanted to spawn claude -p with CLAUDE_CONFIG_DIR pointing at an isolated dir so the user's global ~/.claude/CLAUDE.md (which carries a personal persona) wouldn't leak into the assistant. .env had:

CLAUDE_CONFIG_DIR=/home/davidw/.helloedi/claude-home

But my shell already exports CLAUDE_CONFIG_DIR=/home/davidw/.claudeh for my own Claude Code setup. dotenv loaded .env, saw the var already set, did nothing. The server's process.env.CLAUDE_CONFIG_DIR was still the shell's value. The spawned claude happily read the persona from there. The assistant introduced itself with the wrong name.

The fix is to never put the canonical var name in .env when your dev environment is likely to export it. Use a project-internal name and translate on spawn:

# .env
EDI_CLAUDE_CONFIG_DIR=/home/davidw/.helloedi/claude-home
// claudeRunner.js
const dir = process.env.EDI_CLAUDE_CONFIG_DIR ?? defaultDir;
spawn('claude', args, {
  env: { ...process.env, CLAUDE_CONFIG_DIR: dir },
});

Now the shell's CLAUDE_CONFIG_DIR stays in process.env (harmless to the parent), .env's EDI_* value is read without collision, and the child gets the right CLAUDE_CONFIG_DIR because we set it explicitly in the spawn env.

dotenv does have { override: true }, but flipping it globally affects every variable in .env — including ones you actually want the shell to win on. The renamed-variable approach is scoped and self-documenting.

The shape of this trap is general: whenever a name in .env overlaps with one your shell may already export, the child sees whichever value won at parent startup, not the one you wrote. Pick names you control, or be explicit at spawn time.

Your .bashrc bails out before agents see PATH — fix it with BASH_ENV

The first non-comment line in most .bashrc files is this:

[ -z "$PS1" ] && return

It means: if this shell isn't interactive, do nothing. Innocent-looking, but it silently breaks any agent that shells out via bash -c '...' — the child shell gets no PATH additions, no cargo env, no API keys you carefully exported in .bashrc.custom. Your terminal works fine, your CLI agents don't, and the failure mode is some bare command name "not found" that has nothing to do with the actual problem.

The fix is to split the file by purpose (env vs interactive), not by file (.bashrc vs .bashrc.custom), and use BASH_ENV as the loader for non-interactive shells.

Split env from interactive

Pull every export, every PATH= mutation, every . ~/.cargo/env and . ~/.bashrc.secret out of .bashrc.custom and put them in a new file. Call it ~/.bashrc.env. It must be idempotent and produce no output (no echo, no which).

# ~/.bashrc.env — env-only, safe for both interactive and non-interactive shells.
export FNM_DIR="$HOME/.local/share/fnm"
export PNPM_HOME="$HOME/.local/share/pnpm"

case ":$PATH:" in
  *":$FNM_DIR:"*) ;;
  *) PATH="$FNM_DIR:$PATH" ;;
esac
# ... more PATH cases ...

export PATH

[ -f "$HOME/.cargo/env" ]      && . "$HOME/.cargo/env"
[ -f "$HOME/.bashrc.secret" ]  && . "$HOME/.bashrc.secret"

# This is the lever — child non-interactive bashes will source this file.
export BASH_ENV="$HOME/.bashrc.env"
…more

`--data-raw "$VAR"` silently drops multipart body on Windows Git Bash

When posting multipart form data via curl in a bash script, it's tempting to build the body in a variable and pass it with --data-raw "$DATA". On Linux this often works fine. On Windows Git Bash, it silently breaks — the server receives the request but fields like body come through empty.

Two things go wrong at once. First, Windows-style paths (C:\Users\...) passed to bash utilities like tail and file are misread — the backslashes get misinterpreted and the file read returns nothing. Fix that with cygpath:

filename=$(cygpath -u "$1" 2>/dev/null || echo "$1")

Second, even with the right path, storing the body in a shell variable and passing it via --data-raw is unreliable. Shell variable expansion, CRLF handling, and platform-specific curl behavior all interact badly. The body gets mangled before it reaches the wire.

The fix is to bypass variables entirely: write the multipart payload to a temp file and send it with --data-binary @file. Since the post body is already in a file, tail it directly into the temp file instead of slurping it into a variable:

TMPBODY=$(mktemp)
TMPDATA=$(mktemp)

tail -n +2 "$filename" > "$TMPBODY"

{
  printf "%s\r\n" "--${BOUNDARY}"
  printf "Content-Disposition: form-data; name=\"body\"\r\n\r\n"
  cat "$TMPBODY"
  printf "\r\n%s\r\n" "--${BOUNDARY}--"
} > "$TMPDATA"

curl ... --data-binary "@$TMPDATA"

rm -f "$TMPBODY" "$TMPDATA"

--data-binary @file sends the exact bytes on disk — no shell expansion, no line ending conversion. Works the same on Linux, macOS, and Windows Git Bash.

Ctrl+M is the newline shortcut in Codex CLI's TUI

If you're using Codex CLI and want to insert a newline in the prompt box without submitting, Shift+Enter won't work. Ctrl+J works with old versions but not recent ones, now, you have to use Ctrl+M instead.

Ctrl+J is the ASCII control character for line feed (\n). Many terminal TUI libraries interpret it as "insert newline" rather than "submit," which is exactly what Codex CLI's input component does. Unfortunately, in the recent version they changed to Ctrl+M instead.

Shift+Enter looks like the right key (it works in browser-based chat UIs), but whether it inserts a newline or submits depends entirely on how the TUI library handles raw key events — and Codex CLI's library doesn't treat it as a newline.

So: Ctrl+J Ctrl+M to insert a newline, Enter to submit.

autossh + SSH Keepalive: The -M 0 Trick

autossh has its own connection monitoring that opens an extra port for heartbeats. But this is redundant — SSH already has native keepalive via ServerAliveInterval. The extra port can even cause problems if it's not available on the remote side.

The modern approach: disable autossh's built-in monitoring with -M 0 and let SSH's native heartbeats handle it. When SSH detects a dead connection (after ServerAliveInterval * ServerAliveCountMax seconds of no response), autossh automatically reconnects.

Put everything in ~/.ssh/config:

Host gateway
    HostName your-gateway-ip
    User ec2-user
    ServerAliveInterval 30
    ServerAliveCountMax 3
    LocalForward 8080 internal-soap-ec2:8080

Then the command reduces to:

autossh -M 0 -Nf gateway

-M 0 is the only autossh-specific flag you need. Everything else — host, user, keepalive, even the tunnel — lives in SSH config where it belongs. ServerAliveInterval 30 sends a heartbeat every 30 seconds through the SSH port itself (no extra ports needed), and ServerAliveCountMax 3 means 90 seconds of silence triggers a disconnect detection.