A shell is a program that reads commands and runs other programs.
When you type this:
lsthe shell starts the ls program.
When you type this:
echo hellothe shell starts the echo program and passes hello as an argument.
A small shell has this shape:
print prompt
read one line
split line into arguments
run the command
repeatThe Simplest Shell Loop
A shell is usually a loop.
const std = @import("std");
pub fn main() !void {
const stdin = std.io.getStdIn().reader();
const stdout = std.io.getStdOut().writer();
var buffer: [1024]u8 = undefined;
while (true) {
try stdout.writeAll("zsh> ");
const line_or_null = try stdin.readUntilDelimiterOrEof(&buffer, '\n');
const line = line_or_null orelse break;
const clean_line = std.mem.trim(u8, line, " \t\r\n");
if (clean_line.len == 0) {
continue;
}
if (std.mem.eql(u8, clean_line, "exit")) {
break;
}
try stdout.print("command: {s}\n", .{clean_line});
}
}This does not run commands yet. It only reads them.
Still, it already has the basic shell structure:
prompt
read
parse
handle
repeatSplitting a Command Line
A command like this:
echo hello zighas three words:
echo
hello
zigThe first word is the program name. The rest are arguments.
For a first shell, split on spaces and tabs.
fn splitLine(
allocator: std.mem.Allocator,
line: []const u8,
) ![][]const u8 {
var args = std.ArrayList([]const u8).init(allocator);
errdefer args.deinit();
var it = std.mem.tokenizeAny(u8, line, " \t");
while (it.next()) |part| {
try args.append(part);
}
return try args.toOwnedSlice();
}This parser is intentionally simple. It does not support quotes, escapes, variables, pipes, or redirection.
So this works:
echo hello zigBut this does not work correctly yet:
echo "hello zig"A real shell grammar is much harder. Start with words.
Running a Program
Zig can start a child process with std.process.Child.
fn runCommand(allocator: std.mem.Allocator, args: []const []const u8) !void {
if (args.len == 0) {
return;
}
var child = std.process.Child.init(args, allocator);
const result = try child.spawnAndWait();
switch (result) {
.Exited => |code| {
if (code != 0) {
std.debug.print("exit code: {}\n", .{code});
}
},
else => {
std.debug.print("process ended: {}\n", .{result});
},
}
}The argument list is passed directly to the child process.
For:
echo hello zigthe list is:
args[0] = echo
args[1] = hello
args[2] = zigThe operating system runs echo with those arguments.
A Working Tiny Shell
Now combine the pieces.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const stdin = std.io.getStdIn().reader();
const stdout = std.io.getStdOut().writer();
var buffer: [1024]u8 = undefined;
while (true) {
try stdout.writeAll("zsh> ");
const line_or_null = try stdin.readUntilDelimiterOrEof(&buffer, '\n');
const line = line_or_null orelse break;
const clean_line = std.mem.trim(u8, line, " \t\r\n");
if (clean_line.len == 0) {
continue;
}
if (std.mem.eql(u8, clean_line, "exit")) {
break;
}
const args = try splitLine(allocator, clean_line);
defer allocator.free(args);
try runCommand(allocator, args);
}
}
fn splitLine(
allocator: std.mem.Allocator,
line: []const u8,
) ![][]const u8 {
var args = std.ArrayList([]const u8).init(allocator);
errdefer args.deinit();
var it = std.mem.tokenizeAny(u8, line, " \t");
while (it.next()) |part| {
try args.append(part);
}
return try args.toOwnedSlice();
}
fn runCommand(allocator: std.mem.Allocator, args: []const []const u8) !void {
if (args.len == 0) {
return;
}
var child = std.process.Child.init(args, allocator);
const result = try child.spawnAndWait();
switch (result) {
.Exited => |code| {
if (code != 0) {
std.debug.print("exit code: {}\n", .{code});
}
},
else => {
std.debug.print("process ended: {}\n", .{result});
},
}
}Build it:
zig build-exe shell.zigRun it:
./shellTry:
echo helloTry:
zig versionTry:
exitBuilt-In Commands
Some commands must be handled by the shell itself.
For example, exit cannot be an ordinary child process if it is supposed to stop the shell.
Another important built-in is cd.
If you run cd as a child process, only the child changes directory. Then the child exits. The shell stays in the same directory.
So cd must change the shell process itself.
fn handleBuiltin(args: []const []const u8) !bool {
if (args.len == 0) {
return true;
}
if (std.mem.eql(u8, args[0], "exit")) {
return false;
}
if (std.mem.eql(u8, args[0], "cd")) {
if (args.len < 2) {
std.debug.print("cd: missing path\n", .{});
return true;
}
std.posix.chdir(args[1]) catch |err| {
std.debug.print("cd: {}\n", .{err});
};
return true;
}
return true;
}This function returns false when the shell should stop.
Adding Built-Ins to the Loop
Use the built-in handler before running a child process.
const keep_going = try handleBuiltin(args);
if (!keep_going) {
break;
}
if (isBuiltin(args[0])) {
continue;
}
try runCommand(allocator, args);A cleaner design separates detection from execution.
fn isBuiltin(name: []const u8) bool {
return std.mem.eql(u8, name, "exit") or
std.mem.eql(u8, name, "cd");
}Now exit and cd are handled by the shell. Everything else is launched as a child process.
Environment and PATH
When you type:
lsyou usually do not type the full path:
/bin/lsThe shell searches directories listed in the PATH environment variable.
A typical PATH looks like this:
/usr/local/bin:/usr/bin:/binThe shell tries:
/usr/local/bin/ls
/usr/bin/ls
/bin/lsuntil it finds an executable.
std.process.Child can use the operating system’s normal process lookup behavior depending on how it is configured and the platform. A complete shell should understand PATH, absolute paths, relative paths, and platform differences.
For a beginner shell, let std.process.Child handle the basic case and focus on the command loop.
Standard Input and Output
By default, a child process usually inherits the shell’s stdin, stdout, and stderr.
That is why this works naturally:
echo helloThe child writes to the same terminal as the shell.
Later, for redirection, the shell must change where the child reads or writes.
Example shell syntax:
echo hello > out.txtThis means:
run echo
send stdout to out.txtThat requires opening out.txt, then giving the child process that file as stdout.
Pipes
A pipe connects the output of one process to the input of another.
cat file.txt | grep errorConceptually:
cat stdout -> pipe -> grep stdinThis is one of the core shell features.
A tiny first shell does not need pipes. But the mental model is important: the shell creates processes and wires their file descriptors together.
Quoting Is Hard
This command looks simple:
echo "hello world"But the shell must keep hello world as one argument.
Without quote handling, a simple space splitter produces:
echo
"hello
world"That is wrong.
A real shell must handle:
quotes
backslashes
environment variables
command substitution
glob patterns
comments
operators like |, >, <, &&, ||
This is why a real shell has a parser, not just a tokenizer.
For now, keep the grammar small.
Error Handling
A shell should not crash because one command fails.
If a child process exits with code 1, the shell should continue.
If the command does not exist, print an error and continue.
runCommand(allocator, args) catch |err| {
std.debug.print("{s}: {}\n", .{ args[0], err });
};This keeps the shell alive.
The shell is the supervisor. Commands may fail. The shell keeps reading.
Resource Cleanup
A shell repeatedly allocates and starts processes. Cleanup matters.
In the tiny shell:
const args = try splitLine(allocator, clean_line);
defer allocator.free(args);The argument slice is freed after each command.
For child processes, spawnAndWait starts the process and waits for it to finish. A more advanced shell that starts background jobs must track child processes carefully.
What This Shell Cannot Do Yet
This small shell can:
read a command
split it into words
run a program
handle exit
possibly handle cd
It cannot properly handle:
quoted strings
pipes
redirection
background jobs
signals
history
tab completion
environment variable expansion
wildcards
job control
That is fine. The goal is to understand the base mechanism.
Mental Model
A shell is a command loop around process creation.
It reads a line from the terminal, parses it into a command, handles built-ins itself, and asks the operating system to run external programs.
The hard part of a real shell is not only starting processes. The hard part is parsing, redirection, pipes, signals, job control, and preserving the behavior users expect.