A shell interpreter built from scratch in Python — no shell libraries, no subprocess module abstraction. Every process spawn, pipe, redirection, and readline hook is wired directly against the os module, following the CodeCrafters "Build Your Own Shell" challenge.
The goal was to actually understand what a shell does at the OS level: process creation, file descriptor plumbing, and terminal I/O.
- REPL with builtins (
cd,pwd,echo,type,exit,history) and external program execution viafork+execvp - Quote-aware parsing — single quotes, double quotes, and backslash escaping, each with different literal-vs-special-character rules, hand-written as a single-pass character scanner
- I/O redirection —
>,>>,1>,1>>,2>,2>>, implemented differently for builtins (swapsys.stdout) vs. external commands (rawos.open+os.dup2at the fd level), since only one of those is even running inside the Python process - N-stage pipelines (
cmd1 | cmd2 | cmd3 | ...) — general pipe-chaining usingos.pipe()+os.dup2(), with careful fd closing to avoid the classic "reader blocks forever" deadlock - Tab completion — a stateful callback matching GNU Readline's own completion protocol, covering builtins,
$PATHexecutables, and multi-match / longest-common-prefix behavior - Command history —
historybuiltin with count-limited output (history -n), plus file persistence (-r/-w/-a,$HISTFILEload-on-start / save-on-exit)
- OOP refactor early on. Started as a flat script with lambda-based dispatch; refactored into a
Shellclass once the command-handling logic outgrew a simpleif/elifchain. Builtins are stored as adict[str, Callable], making dispatch a single lookup instead of a growing conditional. - Redirection as a data-driven map, not branching logic.
REDIRECT_OPSmaps each operator (>,2>>, etc.) to a(fd_number, append_bool)tuple, so adding a new redirection operator is a one-line dictionary entry rather than a new code path. This came out of a deliberate refactor once the naive if/elif version got unreadable — see "Notable decisions" below. - Builtin vs. external redirection are genuinely different mechanisms, not just different code paths for the same idea:
- Builtins run as plain Python function calls in-process, so redirecting them means temporarily reassigning the Python-level
sys.stdout/sys.stderrobject. - External commands run as separate OS processes, so redirection has to happen at the fd level, before
execvpreplaces the process image —os.open()the target file,os.dup2()it onto fd 1/2, then exec, because the fd table (unlike everything else) survives bothfork()andexec().
- Builtins run as plain Python function calls in-process, so redirecting them means temporarily reassigning the Python-level
- Pipelines use a sliding-window fd handoff. Each iteration creates one new pipe, wires the previous iteration's read-end as this command's stdin, and (for all but the last command) wires this iteration's write-end as its stdout — generalizing cleanly to any pipeline length, not just two commands.
- Completion follows Readline's actual callback contract:
complete_command(text, state)is called repeatedly with increasingstateuntil it returnsNone, mirroring how Readline (and tools like it) lazily generate completion candidates instead of computing the full list upfront.
A few specific moments that are more interesting than the feature checklist:
-
The redirection refactor. The first working version of redirection was a wall of near-duplicate
if op == '>': ... elif op == '>>': ... elif op == '2>':blocks. Recognizing the duplication and collapsing it into theREDIRECT_OPSmapping + a single dispatch path was a deliberate mid-project refactor. -
Pipeline fd deadlocks. Every
os.pipe()call creates two fds that get duplicated further byfork(). If any stray copy of a pipe's write-end is left open anywhere (parent process included), the reader on the other end blocks forever waiting for input that will never come, because the kernel still thinks a writer might exist. Getting the close-discipline right — parent closes its copy of each pipe end the instant it's handed off to a child. -
A pipeline stage that's an external command is actually two processes, not one. The pipeline's per-stage child does the fd wiring (
dup2/close), then calls the shell's normalexecute_command, which — for external programs — forks again internally beforeexecvp. So a 3-command pipeline spins up 6 processes total, not 3. It still works correctly (fd tables survive both fork and exec). -
Two unrelated history systems coexist.
import readlinegives up/down-arrow history navigation "for free," entirely inside GNU Readline's own C-level internal list — no code was written for it. Separately, the shell maintains its ownself.historyPython list, used by thehistorybuiltin and file persistence. These two lists are not synchronized and can drift apart.
./your_program.shRequires Python 3.13. See app/main.py for the implementation.
Currently 43/76 stages (Base REPL, Navigation, Quoting, Redirection, Command Completion, Pipelines, History, History Persistence). Actively continuing into Filename Completion, Background Jobs, Programmable Completion, and Parameter Expansion.
Originally built in 2025; revised and extended in 2026.