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

Stop opening admin cmd just to mklink — git-bash can do real symlinks

If you've been alt-tabbing to an admin cmd window to run mklink every time you wanted a symlink on Windows, there's a much cleaner way nobody seems to mention. Two settings, set them once, forget about it.

The reason ln -s in git-bash silently turns into cp by default is two unrelated barriers stacked together:

  1. Windows non-admins can't create symlinks unless Developer Mode is on.
  2. MSYS (git-bash's runtime) doesn't even try to create native symlinks without the MSYS env var set.

Fix the first once with admin, fix the second in your shell profile.

# Run once in admin PowerShell, or use Settings → Privacy & Security → For developers → Developer Mode
Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" `
  -Name "AllowDevelopmentWithoutDevLicense" -Value 1 -Type DWord
# In ~/.bashrc
export MSYS=winsymlinks:nativestrict

nativestrict makes failures loud — ln -s errors out instead of silently copying when symlinks aren't supported. Avoid winsymlinks:native (the lenient sibling) — its silent fallback is exactly the trap you're trying to escape. Avoid winsymlinks:lnk entirely; .lnk shortcuts aren't symlinks anything else recognizes.

Open a new git-bash window and verify:

ln -s ~/.bashrc /tmp/test-link
ls -la /tmp/test-link

If the line starts with l, it's a real symlink that git, Linux tools, GNU stow, and your dotfiles install.sh all treat consistently across platforms. If it starts with -, either the env var didn't reach the new shell or Developer Mode didn't actually toggle.

Bonus: Developer Mode also unlocks Windows 11's native sudo command, so you can stop alt-tabbing to admin terminals for quick one-offs entirely.

WT keeps opening as Administrator? Check settings.json before the registry

Your Windows Terminal default tab is opening with admin privileges and you don't remember asking for it. The intuitive guess is the AppCompat Layers registry — that's where ticking "Run as administrator" on a shortcut's compatibility tab gets stored, and it persists across reinstalls. Worth checking, but it's the second place to look for WT.

The first place is settings.json:

{
    "commandline": "C:\\Program Files\\Git\\bin\\bash.exe",
    "elevate": true,
    "guid": "{17c3a2bf-...}",
    "name": "Git Bash"
}

WT 1.18+ added elevate as a per-profile flag. When it's true and that profile is also pointed to by defaultProfile, every new window silently opens elevated — no UAC prompt at the tab level because the elevation happened at WT launch. Delete the line, restart WT, done.

If you still want an admin tab on demand, add a second profile with a fresh GUID and a different name (e.g. "Git Bash (Admin)") that keeps elevate: true. You get a dropdown choice and a real UAC prompt when you actually need it, instead of unconditional elevation on every launch.

To rule out the registry side as well:

Get-ItemProperty -Path "HKCU:\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers"
Get-ItemProperty -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers"

Each property name is an exe path; a value containing RUNASADMIN means that program is forced to elevate every launch. To clear one, prefer the GUI — right-click the exe → Properties → Compatibility → uncheck "Run this program as administrator" — over editing the registry by hand.

Two mechanisms, same symptom, different layers. For Windows Terminal specifically, the per-profile setting wins; check it first.

VARCHAR2(4000 CHAR) Might Not Store 4000 Characters

VARCHAR2(4000) means 4000 bytes, not characters. Most people know this. What's less obvious: VARCHAR2(4000 CHAR) doesn't guarantee 4000 characters either.

Under the default MAX_STRING_SIZE=STANDARD, the hard column cap is 4000 bytes regardless of whether you declared BYTE or CHAR. In AL32UTF8, a Chinese character takes ~3 bytes, so VARCHAR2(4000 CHAR) on a column storing CJK text will fail once the actual byte count exceeds 4000 — around ~1333 characters in.

To actually store 4000 CJK characters in a single VARCHAR2, the instance needs MAX_STRING_SIZE=EXTENDED (12c+), which raises the limit to 32767 bytes. This is not the default — not even in 19c — and it's a one-way migration that requires running utl32k.sql in upgrade mode. Oracle keeps it off by default precisely because it changes data dictionary behavior and breaks compatibility.

Quick check for your instance:

SELECT value FROM v$parameter WHERE name = 'max_string_size';

STANDARD = 4000-byte ceiling. EXTENDED = 32767-byte ceiling.