A CLI tool is a command-line program.
You run it from a shell:
mytool input.txtor:
mytool --helpMany important programs are CLI tools:
git
curl
grep
ls
gcc
zig
python
docker
ffmpegCLI 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 outputFor example:
cat file.txt | grep error | sortEach 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
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.zigRun it:
./mainCommand-Line Arguments
Arguments are the words after the program name.
Example:
mytool input.txt output.txtArguments:
input.txt
output.txtRead 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 worldmay print:
arg 0 = ./main
arg 1 = hello
arg 2 = worldarg 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.txtProgram:
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
repeatThe 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 | mytoolor:
echo hello | mytoolRead 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 | linecountThis is the same streaming idea again.
Options and Flags
CLI tools often support flags:
mytool --help
mytool --version
mytool -v
mytool --output=result.txtA 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 Code | Meaning |
|---|---|
0 | success |
| non-zero | failure |
Example:
std.process.exit(1);Shell scripts depend on exit codes.
Example:
if mytool input.txt; then
echo success
else
echo failed
fiStandard 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 errorProgram:
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 lineComposability
Good CLI tools work well in pipelines.
Example:
cat app.log | mygrep ERROR | linecountPrograms 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.binFor 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 fileA 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
TMPDIRRead 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:
- parse arguments
- open input
- process data
- write output
- 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.