Replacing Your Python Script With Another Program: os.execvp

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))
Python

Running 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)
Python

Now 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:

  1. The kernel wipes Python’s code and heap out of the process.
  2. The kernel loads claude’s executable into the same process slot.
  3. The process keeps its PID, parent, open files, env vars, cwd.
  4. 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:

  1. Claude calls exit(0).
  2. Kernel removes the process (pid 1042) from the process table.
  3. Zsh gets a SIGCHLD and reaps its child.
  4. 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
  - --chrome
YAML

Now 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.


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

🧭