Skip to content

Writing a Small Shell

A shell is a program that reads commands and runs other programs.

A shell is a program that reads commands and runs other programs.

When you type this:

ls

the shell starts the ls program.

When you type this:

echo hello

the 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
repeat

The 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
repeat

Splitting a Command Line

A command like this:

echo hello zig

has three words:

echo
hello
zig

The 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 zig

But 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 zig

the list is:

args[0] = echo
args[1] = hello
args[2] = zig

The 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.zig

Run it:

./shell

Try:

echo hello

Try:

zig version

Try:

exit

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

ls

you usually do not type the full path:

/bin/ls

The shell searches directories listed in the PATH environment variable.

A typical PATH looks like this:

/usr/local/bin:/usr/bin:/bin

The shell tries:

/usr/local/bin/ls
/usr/bin/ls
/bin/ls

until 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 hello

The 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.txt

This means:

run echo
send stdout to out.txt

That 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 error

Conceptually:

cat stdout -> pipe -> grep stdin

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