# Writing a CLI Tool

### Writing a CLI Tool

A CLI tool is a command-line program.

You run it from a shell:

```bash
mytool input.txt
```

or:

```bash
mytool --help
```

Many important programs are CLI tools:

```text
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:

```text
read input
process data
write output
```

For example:

```bash
cat file.txt | grep error | sort
```

Each program does one job.

Programs communicate through standard streams:

| Stream | File Descriptor | Purpose |
|---|---:|---|
| stdin | `0` | input |
| stdout | `1` | normal output |
| stderr | `2` | errors and diagnostics |

This design lets tools work together.

#### A Tiny CLI Program

```zig
const std = @import("std");

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

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

Build it:

```bash
zig build-exe main.zig
```

Run it:

```bash
./main
```

#### Command-Line Arguments

Arguments are the words after the program name.

Example:

```bash
mytool input.txt output.txt
```

Arguments:

```text
input.txt
output.txt
```

Read them with `std.process.argsAlloc`.

```zig
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:

```bash
./main hello world
```

may print:

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

`arg 0` is usually the executable path.

#### Validating Arguments

Never assume arguments exist.

Bad:

```zig
const filename = args[1];
```

If the user forgets the argument, the program crashes.

Better:

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

Then:

```zig
const filename = args[1];
```

is safe.

#### Writing a Small `cat`

Let’s build a tiny version of `cat`.

Usage:

```bash
mycat file.txt
```

Program:

```zig
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:

```text
read chunk
write chunk
repeat
```

The program never loads the whole file into memory.

#### Why Streaming Matters

This code:

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

reads the whole file into memory.

That is fine for small files.

Streaming is better for large files:

```zig
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:

```bash
cat file.txt | mytool
```

or:

```bash
echo hello | mytool
```

Read stdin like this:

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

Example echo tool:

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

```zig
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:

```bash
cat file.txt | linecount
```

This is the same streaming idea again.

#### Options and Flags

CLI tools often support flags:

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

A simple manual parser:

```zig
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:

```zig
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 Code | Meaning |
|---:|---|
| `0` | success |
| non-zero | failure |

Example:

```zig
std.process.exit(1);
```

Shell scripts depend on exit codes.

Example:

```bash
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:

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

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

Better:

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

```zig
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:

```bash
cat file.txt | mygrep error
```

Program:

```zig
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:

```text
read line
check condition
write matching line
```

#### Composability

Good CLI tools work well in pipelines.

Example:

```bash
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:

```bash
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:

```zig
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:

```text
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:

```text
EDITOR
HOME
PATH
TMPDIR
```

Read one:

```zig
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:

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

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

