I wanted a small launcher for Claude Code that read its settings from a YAML file — env vars, flags, working directory, that kind of thing. Something I could version-control and tweak without remembering a 200-character invocation.
My first attempt printed the command line. My second attempt was better, and along the way I finally understood what exec actually does at the OS level. This post is about that second attempt.
The naive version: print and eval
The first version of my launcher was a shell-command builder. It read YAML and printed a string:
# claude_launcher.py (v1)
import yaml
from pathlib import Path
from shlex import quote
config = yaml.safe_load(open(Path(__file__).parent / "claude_launcher.yaml"))
parts = []
for k, v in config.get("env", {}).items():
parts.append(f"{k}={quote(str(v))}")
parts.append(quote(config["command"]))
parts.extend(quote(a) for a in config.get("args", []))
print(" ".join(parts))PythonRunning it gave me:
CLAUDE_CODE_NO_FLICKER=1 CLAUDE_CODE_DISABLE_MOUSE=1 claude --continue --chrome
Great, but it only prints. To actually launch claude I had to wrap it:
eval "$(python3.12 claude_launcher.py)"
Fine, but ugly. Can’t I just do $(python3.12 claude_launcher.py) without eval?
No. And the reason is subtle.
Why $() doesn’t work here
When the shell processes $(cmd), it runs cmd, captures the output, splits it on whitespace, and uses the words as arguments to a command. But env var assignments like FOO=bar in front of a command are a piece of shell syntax — the shell has to see them as it’s parsing the original line, not after substitution.
So $(python3.12 claude_launcher.py) tries to execute CLAUDE_CODE_NO_FLICKER=1 as a literal command name. Which, obviously, isn’t a command.
eval is the only tool that re-parses its argument as shell source. That’s why only eval works on the printed output.
Same story for backticks — they’re just older syntax for $().
The cleaner version: os.execvp
The fix is to stop printing a command and just run it. But naively that gives you a grandchild process:
subprocess.run(["claude", "--continue", "--chrome"])
Your process tree becomes:
zsh → python → claude
Python is sitting there doing nothing except waiting for claude to exit. It’s pure overhead — a whole Python interpreter parked in memory for the duration of your Claude Code session.
os.execvp avoids that:
# claude_launcher.py (v2)
import os, yaml
from pathlib import Path
config = yaml.safe_load(open(Path(__file__).parent / "claude_launcher.yaml"))
for k, v in config.get("env", {}).items():
os.environ[str(k)] = str(v)
argv = [config["command"]] + [str(a) for a in config.get("args", [])]
os.execvp(config["command"], argv)PythonNow python3.12 claude_launcher.py drops you straight into Claude Code. No eval, no wrapper script, no orphan Python interpreter.
What execvp actually does
This is the part that clicked for me.
When I read “replaces the current process,” I always thought it meant something like “kills Python, starts claude.” It doesn’t. It’s weirder and cooler than that.
At the OS level, two things are distinct:
- A process — an entry in the kernel’s process table. Has a PID, a parent, open file descriptors, a controlling terminal, env vars.
- A program — the machine code and memory running inside that process.
execvp swaps the program without touching the process. Specifically:
- The kernel wipes Python’s code and heap out of the process.
- The kernel loads claude’s executable into the same process slot.
- The process keeps its PID, parent, open files, env vars, cwd.
- Execution jumps to claude’s
main().
No new process is created. No exit code is sent to the parent. The shell (zsh) doesn’t notice anything at all — from its perspective, its child is still alive, just… different now.
It’s like a body swap. Same process lifetime, two different programs running inside it over that lifetime.
Process tree comparison
With subprocess.run:
zsh (pid 1000)
└── python (pid 1042)
└── claude (pid 1043)
Three processes. Python is the middle man, idle for the whole session.
With os.execvp:
zsh (pid 1000)
└── claude (pid 1042) ← this was python a microsecond ago
Two processes. Claude inherited Python’s PID.
When claude exits
With the exec approach, here’s what happens when you quit claude:
- Claude calls
exit(0). - Kernel removes the process (pid 1042) from the process table.
- Zsh gets a
SIGCHLDand reaps its child. echo $?in zsh shows claude’s exit code, not Python’s — Python never got to exit.
One process. One lifetime. Two programs.
Why this matters
For a launcher, execvp is almost always what you want:
- Signals work correctly. Ctrl+C goes from your terminal to claude directly. With
subprocess.run, signals hit Python first and Python has to decide what to do with them. - No memory overhead. Python is gone — no interpreter parked waiting.
- Exit codes propagate naturally. Zsh sees the real exit code.
- The process tree stays flat. Easier to reason about with
ps.
The only reason to not exec is if you actually need Python to do something after the child runs — cleanup, logging, retry logic. For a pure “set up env and launch” script, exec is the right primitive.
The config file
Just for completeness, here’s the YAML this launcher reads:
env:
CLAUDE_CODE_NO_FLICKER: 1
CLAUDE_CODE_DISABLE_MOUSE: 1
command: claude
args:
- --dangerously-skip-permissions
- --continue
- --chromeYAMLNow I can edit this file when I want to change anything, and python3.12 claude_launcher.py is all I type.
The one-liner summary
exec doesn’t start a new process. It swaps the program inside an existing one. That’s why your Python script can “become” another command without leaving any trace of itself behind.
Leave a Reply