Skip to content

Writing a CLI Tool

A CLI tool is a command-line program.

A CLI tool is a command-line program.

You run it from a shell:

mytool input.txt

or:

mytool --help

Many important programs are CLI tools:

git
curl
grep
ls
gcc
zig
python
docker
ffmpeg

CLI tools are one of the best ways to learn systems programming because they combine:

file handling

argument parsing

process control

text processing

error handling

streaming I/O

terminal interaction

operating system APIs

A good CLI tool should be simple, predictable, scriptable, and composable.

The Unix Philosophy

Many command-line tools follow a simple idea:

read input
process data
write output

For example:

cat file.txt | grep error | sort

Each program does one job.

Programs communicate through standard streams:

StreamFile DescriptorPurpose
stdin0input
stdout1normal output
stderr2errors and diagnostics

This design lets tools work together.

A Tiny CLI Program

const std = @import("std");

pub fn main() !void {
    const stdout = std.io.getStdOut().writer();

    try stdout.writeAll("hello from zig\n");
}

Build it:

zig build-exe main.zig

Run it:

./main

Command-Line Arguments

Arguments are the words after the program name.

Example:

mytool input.txt output.txt

Arguments:

input.txt
output.txt

Read them with std.process.argsAlloc.

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    for (args, 0..) |arg, i| {
        std.debug.print("arg {} = {s}\n", .{ i, arg });
    }
}

Running:

./main hello world

may print:

arg 0 = ./main
arg 1 = hello
arg 2 = world

arg 0 is usually the executable path.

Validating Arguments

Never assume arguments exist.

Bad:

const filename = args[1];

If the user forgets the argument, the program crashes.

Better:

if (args.len < 2) {
    std.debug.print("usage: mytool <file>\n", .{});
    std.process.exit(1);
}

Then:

const filename = args[1];

is safe.

Writing a Small cat

Let’s build a tiny version of cat.

Usage:

mycat file.txt

Program:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len < 2) {
        std.debug.print("usage: mycat <file>\n", .{});
        std.process.exit(1);
    }

    const path = args[1];

    var file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    const stdout = std.io.getStdOut().writer();

    var buffer: [4096]u8 = undefined;

    while (true) {
        const n = try file.read(&buffer);

        if (n == 0) {
            break;
        }

        try stdout.writeAll(buffer[0..n]);
    }
}

This demonstrates an important systems-programming pattern:

read chunk
write chunk
repeat

The program never loads the whole file into memory.

Why Streaming Matters

This code:

const bytes = try file.readToEndAlloc(...);

reads the whole file into memory.

That is fine for small files.

Streaming is better for large files:

while (true) {
    const n = try file.read(&buffer);

    if (n == 0) break;

    try stdout.writeAll(buffer[0..n]);
}

Streaming uses fixed memory no matter how large the file becomes.

Many CLI tools are streaming tools.

Standard Input

Good CLI tools can read from stdin.

Example:

cat file.txt | mytool

or:

echo hello | mytool

Read stdin like this:

const stdin = std.io.getStdIn().reader();

Example echo tool:

const std = @import("std");

pub fn main() !void {
    const stdin = std.io.getStdIn().reader();
    const stdout = std.io.getStdOut().writer();

    var buffer: [4096]u8 = undefined;

    while (true) {
        const n = try stdin.read(&buffer);

        if (n == 0) {
            break;
        }

        try stdout.writeAll(buffer[0..n]);
    }
}

This copies stdin to stdout.

A Simple wc -l

Now build a tiny line counter.

const std = @import("std");

pub fn main() !void {
    const stdin = std.io.getStdIn().reader();

    var buffer: [4096]u8 = undefined;
    var lines: usize = 0;

    while (true) {
        const n = try stdin.read(&buffer);

        if (n == 0) {
            break;
        }

        for (buffer[0..n]) |b| {
            if (b == '\n') {
                lines += 1;
            }
        }
    }

    std.debug.print("{}\n", .{lines});
}

Usage:

cat file.txt | linecount

This is the same streaming idea again.

Options and Flags

CLI tools often support flags:

mytool --help
mytool --version
mytool -v
mytool --output=result.txt

A simple manual parser:

for (args[1..]) |arg| {
    if (std.mem.eql(u8, arg, "--help")) {
        printHelp();
        return;
    }

    if (std.mem.eql(u8, arg, "--version")) {
        printVersion();
        return;
    }
}

Small tools can parse manually.

Larger tools often use argument-parsing libraries or more structured parsing code.

Help Output

CLI tools should explain themselves.

Example:

fn printHelp() void {
    std.debug.print(
        \\usage:
        \\    mytool [options] <file>
        \\
        \\options:
        \\    --help       show help
        \\    --version    show version
        \\
    , .{});
}

A good --help message includes:

purpose

usage syntax

options

examples

defaults when important

Exit Codes

CLI tools should return meaningful exit codes.

Convention:

Exit CodeMeaning
0success
non-zerofailure

Example:

std.process.exit(1);

Shell scripts depend on exit codes.

Example:

if mytool input.txt; then
    echo success
else
    echo failed
fi

Standard Output vs Standard Error

Use stdout for normal results.

Use stderr for diagnostics.

Bad:

try stdout.writeAll("processing...\n");
try stdout.writeAll("final result\n");

If stdout is redirected, both messages go into the file.

Better:

const stderr = std.io.getStdErr().writer();

try stderr.writeAll("processing...\n");
try stdout.writeAll("final result\n");

Now progress messages stay visible in the terminal.

Reading Lines

Many CLI tools process line-oriented text.

const std = @import("std");

pub fn main() !void {
    const stdin = std.io.getStdIn().reader();

    var buffer: [1024]u8 = undefined;

    while (try stdin.readUntilDelimiterOrEof(&buffer, '\n')) |line| {
        std.debug.print("line: {s}\n", .{line});
    }
}

This reads one line at a time.

This pattern appears everywhere in Unix-style text tools.

Writing a Tiny Grep

Search for lines containing a word.

Usage:

cat file.txt | mygrep error

Program:

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const allocator = gpa.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len < 2) {
        std.debug.print("usage: mygrep <word>\n", .{});
        std.process.exit(1);
    }

    const needle = args[1];

    const stdin = std.io.getStdIn().reader();
    const stdout = std.io.getStdOut().writer();

    var buffer: [4096]u8 = undefined;

    while (try stdin.readUntilDelimiterOrEof(&buffer, '\n')) |line| {
        if (std.mem.indexOf(u8, line, needle) != null) {
            try stdout.print("{s}\n", .{line});
        }
    }
}

This is a real useful CLI pattern:

read line
check condition
write matching line

Composability

Good CLI tools work well in pipelines.

Example:

cat app.log | mygrep ERROR | linecount

Programs become more powerful when they can combine.

This is why CLI tools usually:

read stdin

write stdout

avoid interactive prompts unless necessary

keep output machine-readable when possible

avoid unnecessary formatting

Binary Data

Not all CLI tools process text.

Some process binary streams:

cat image.png | mytool > output.bin

For binary data:

avoid line-based APIs

use byte slices

avoid UTF-8 assumptions

stream bytes directly

Example binary copy loop:

while (true) {
    const n = try stdin.read(&buffer);

    if (n == 0) {
        break;
    }

    try stdout.writeAll(buffer[0..n]);
}

This works for both text and binary data.

Temporary Files

Some tools need temporary files.

Example workflow:

read input
write transformed output to temp file
replace original file

A common safe pattern:

write new data to temporary file

flush file

rename temporary file over original

This reduces the chance of leaving a corrupted partially-written file if the program crashes.

Environment Variables

CLI tools often use environment variables for configuration.

Example:

EDITOR
HOME
PATH
TMPDIR

Read one:

const home = try std.process.getEnvVarOwned(
    allocator,
    "HOME",
);
defer allocator.free(home);

Environment variables are useful for defaults and configuration, but explicit command-line arguments should usually override them.

Signals

CLI tools should expect interruption.

On Unix-like systems, Ctrl+C usually sends SIGINT.

A tool may terminate immediately, or it may clean up temporary files before exiting.

Long-running tools should think about interruption behavior.

Logging

Simple CLI tools often log to stderr.

Example:

const stderr = std.io.getStdErr().writer();

try stderr.print("processing {s}\n", .{filename});

Avoid mixing logs with stdout output unless stdout is only for humans.

Resource Cleanup

CLI tools often acquire resources:

files

directories

allocated memory

child processes

network connections

temporary files

Use defer immediately after acquisition.

var file = try std.fs.cwd().openFile(path, .{});
defer file.close();

This pattern keeps cleanup reliable.

A Good Beginner Design

When building a CLI tool, start simple:

  1. parse arguments
  2. open input
  3. process data
  4. write output
  5. return correct exit code

Then improve:

add help output

add streaming

add better errors

add tests

add limits

add structured parsing

add concurrency only if needed

Mental Model

A CLI tool is usually a stream processor around operating system resources.

It reads input from files or stdin, transforms data, writes output to stdout, and reports errors through stderr.

The strongest CLI tools are small, predictable, composable, and explicit about failure.