Skip to content

Repository files navigation

zig-cli

A type-safe, compile-time validated CLI library for Zig 0.16+. Define your CLI with structs, get full type safety and zero runtime overhead.

No string-based lookups. No runtime parsing. Just pure, type-safe Zig.

// Define options as a structconstMyOptions=struct {
name: []constu8,
port: u16=8080,
};
// Type-safe actionfnrun(ctx: *cli.Context(MyOptions)) !void {
constname=ctx.get(.name); // Compile-time validated!constport=ctx.get(.port);
}

Inspired by modern CLI frameworks, built for Zig's strengths.

Features

CLI Framework (Type-Safe)

  • Compile-Time Validation: All field access validated at compile time
  • Struct-Based Options: Define CLI options as structs - auto-generate everything
  • Zero Runtime Overhead: All type checking happens at compile time
  • IDE Autocomplete: Full IntelliSense/LSP support for field names
  • Command Routing: Support for nested subcommands with aliases
  • Auto-Generated Help: Beautiful help text from struct definitions
  • Type Safety: Enums, optionals, nested structs all supported
  • Middleware System: Type-safe pre/post command hooks

Interactive Prompts

  • State Machine: Clean 5-state state machine (initial -> active <-> error -> submit/cancel)
  • Event-driven: Fine-grained event system for prompt interactions
  • Terminal Detection: Automatic Unicode/ASCII and color support detection
  • Multiple Prompt Types:
    • Text input with validation and placeholders
    • Confirmation prompts
    • Select (single choice)
    • MultiSelect (multiple choices)
    • Password input with masking
    • Number input with range validation (integer/float)
    • Path selection with Tab autocomplete
    • Group prompts for multi-step workflows
    • Spinner for loading/activity indicators
    • Progress bars with multiple styles
    • Messages (intro, outro, note, log, cancel)
    • Box/panel rendering for organized output

Terminal Features

  • ANSI Colors: Full color support with automatic detection
  • Style Chaining: Composable styling API (.red().bold().underline())
  • Raw Mode: Cross-platform terminal raw mode handling
  • Cursor Control: Hide/show, save/restore cursor position
  • Unicode Support: Graceful fallback to ASCII when needed
  • Keyboard Input: Full keyboard event handling (arrows, enter, backspace, etc.)
  • Dimension Detection: Automatic terminal width/height detection
  • Box Rendering: Multiple box styles (single, double, rounded, ASCII)
  • Table Rendering: Column alignment, auto-width, multiple border styles

Configuration

  • Multiple Formats: TOML, JSONC (JSON with Comments), JSON5
  • Auto-discovery: Automatically find config files in standard locations
  • Type-safe Access: Typed getters for strings, integers, floats, booleans
  • Nested Values: Support for tables/objects and arrays
  • Flexible Syntax: Comments, trailing commas, unquoted keys (format-dependent)

Installation

Add zig-cli to your build.zig:

constzig_cli=b.dependency("zig-cli", .{
.target=target,
.optimize=optimize,
});
exe.root_module.addImport("zig-cli", zig_cli.module("zig-cli"));

Quick Start

Basic CLI Application

conststd=@import("std");
constcli=@import("zig-cli");
// 1. Define options as a struct - that's it!constGreetOptions=struct {
name: []constu8="World", // With default valueenthusiastic: bool=false, // Boolean flag
};
// 2. Type-safe action functionfngreet(ctx: *cli.Context(GreetOptions)) !void {
constio=std.Options.debug_io;
varbuf: [4096]u8=undefined;
varfile_writer=std.Io.File.stdout().writerStreaming(io, &buf);
conststdout=&file_writer.interface;
// Compile-time validated field access - no strings!constname=ctx.get(.name);
constpunct: []constu8=if (ctx.get(.enthusiastic)) "!"else".";
trystdout.print("Hello, {s}{s}\n", .{ name, punct });
trystdout.flush();
}
pubfnmain(init: std.process.Init) !void {
constallocator=init.gpa;
// 3. Create command - options auto-generated!varcmd=trycli.Command(GreetOptions).init(allocator, "greet", "Greet someone");
defercmd.deinit();
_=cmd.setAction(greet);
// 4. Parse args and executevarargs_list=std.ArrayList([]constu8){};
deferargs_list.deinit(allocator);
varargs_iter=std.process.Args.Iterator.init(init.minimal.args);
_=args_iter.skip(); // skip program namewhile (args_iter.next()) |arg| {
tryargs_list.append(allocator, arg);
}
varparser=cli.Parser.init(allocator);
tryparser.parse(cmd.getCommand(), args_list.items);
}

That's it! Run with: myapp greet --name Alice --enthusiastic

Benefits:

  • Options auto-generated from struct fields
  • Compile-time validation - typos caught by compiler
  • Full IDE autocomplete support
  • No string-based lookups
  • Zero runtime overhead

Interactive Prompts

conststd=@import("std");
constprompt=@import("zig-cli").prompt;
pubfnmain() !void {
vargpa=std.heap.GeneralPurposeAllocator(.{}){};
defer_=gpa.deinit();
constallocator=gpa.allocator();
// Text promptvartext_prompt=prompt.TextPrompt.init(allocator, "What is your name?");
defertext_prompt.deinit();
constname=trytext_prompt.prompt();
deferallocator.free(name);
// Confirm promptvarconfirm_prompt=prompt.ConfirmPrompt.init(allocator, "Continue?");
deferconfirm_prompt.deinit();
constconfirmed=tryconfirm_prompt.prompt();
_=confirmed;
// Select promptconstchoices= [_]prompt.SelectPrompt.Choice{
.{ .label="Option 1", .value="opt1" },
.{ .label="Option 2", .value="opt2" },
};
varselect_prompt=prompt.SelectPrompt.init(allocator, "Choose:", &choices);
deferselect_prompt.deinit();
constselected=tryselect_prompt.prompt();
deferallocator.free(selected);
}

API Reference

CLI Framework

Type-Safe Commands (Recommended)

Define your command options as a struct and get compile-time validation:

constGreetOptions=struct {
name: []constu8, // Required stringage: ?u16=null, // Optional integertimes: u8=1, // With default valueverbose: bool=false, // Boolean flagformat: enum { text, json } =.text, // Enum support
};
fngreetAction(ctx: *cli.Context(GreetOptions)) !void {
// Compile-time validated field access - no string lookups!constname=ctx.get(.name); // Returns []const u8constage=ctx.get(.age); // Returns ?u16consttimes=ctx.get(.times); // Returns u8// Or parse entire struct at onceconstopts=tryctx.parse();
_=age;
_=times;
std.debug.print("Hello, {s}!\n", .{opts.name});
}
pubfnmain(init: std.process.Init) !void {
constallocator=init.gpa;
// Auto-generates CLI options from struct fields!varcmd=trycli.Command(GreetOptions).init(allocator, "greet", "Greet a user");
defercmd.deinit();
_=cmd.setAction(greetAction);
varargs_list=std.ArrayList([]constu8){};
deferargs_list.deinit(allocator);
varargs_iter=std.process.Args.Iterator.init(init.minimal.args);
_=args_iter.skip();
while (args_iter.next()) |arg| {
tryargs_list.append(allocator, arg);
}
varparser=cli.Parser.init(allocator);
tryparser.parse(cmd.getCommand(), args_list.items);
}

Benefits:

  • Compile-time validation - field names validated at compile time
  • IDE autocomplete - full IntelliSense support
  • Type safety - no runtime string parsing or optionals
  • Auto-generation - options automatically created from struct
  • Zero overhead - comptime code generates efficient runtime

Low-Level Command API

For more control, use BaseCommand directly:

// Create a base commandconstcmd=trycli.BaseCommand.init(allocator, "myapp", "Description");
// Add options manuallyconstoption=cli.Option.init("name", "long-name", "Description", .string)
.withShort('n') // Short flag (-n)
.withRequired(true) // Make it required
.withDefault("value"); // Set default value_=trycmd.addOption(option);

Option types:

  • .string - String value
  • .int - Integer value
  • .float - Float value
  • .bool - Boolean flag

Adding Arguments

constarg=cli.Argument.init("name", "Description", .string)
.withRequired(true) // Required argument
.withVariadic(false); // Accept multiple values_=trycmd.addArgument(arg);

Creating Subcommands

constsubcmd=trycli.BaseCommand.init(allocator, "subcmd", "Subcommand description");
// Add aliases for the command_=trysubcmd.addAlias("sub");
_=trysubcmd.addAlias("s");
constopt=cli.Option.init("opt", "option", "Option description", .string);
_=trysubcmd.addOption(opt);
_=subcmd.setAction(myAction);
_=tryapp.addCommand(subcmd);

Now you can call the subcommand with: myapp subcmd, myapp sub, or myapp s

Middleware

Add pre/post command hooks to your CLI:

varchain=cli.Middleware.MiddlewareChain.init(allocator);
deferchain.deinit();
// Add built-in middlewaretrychain.use(cli.Middleware.Middleware.init("logging", cli.Middleware.loggingMiddleware));
trychain.use(cli.Middleware.Middleware.init("timing", cli.Middleware.timingMiddleware));
trychain.use(cli.Middleware.Middleware.init("validation", cli.Middleware.validationMiddleware));
// Custom middlewarefnauthMiddleware(ctx: _cli.Middleware.MiddlewareContext) !bool {
constis_authenticated=checkAuth();
if (!is_authenticated) {
tryctx.set("error", "Unauthorized");
returnfalse; // Stop chain
}
tryctx.set("user", "john@example.com");
returntrue; // Continue
}
// Add with priority (lower runs first)trychain.use(cli.Middleware.Middleware.init("auth", authMiddleware).withOrder(-10));
// Execute middleware chain before commandvarmiddleware_ctx=cli.Middleware.MiddlewareContext.init(allocator, parse_context, command);
defermiddleware_ctx.deinit();
if (trychain.execute(&middleware_ctx)) {
// All middleware passed, execute commandtrycommand.executeAction(parse_context);
}

Built-in middleware:

  • loggingMiddleware - Logs command execution
  • timingMiddleware - Records start time
  • validationMiddleware - Validates required options
  • environmentCheckMiddleware - Checks environment variables

Command Actions (Low-Level)

fnmyAction(ctx: _cli.BaseCommand.ParseContext) !void {
// Get option valueconstvalue=ctx.getOption("name") orelse"default";
// Check if option was providedif (ctx.hasOption("verbose")) {
// Do something
}
// Get positional argumentconstarg=ctx.getArgument(0) orelsereturnerror.MissingArgument;
// Get argument countconstcount=ctx.getArgumentCount();
_=value;
_=arg;
_=count;
}

Runtime vs Typed API Comparison

// Runtime API (string-based, via BaseCommand)constvalue=ctx.getOption("name"); // Returns ?[]const u8if (value) |v| {
constage_str=ctx.getOption("age") orelse"0";
constage=trystd.fmt.parseInt(u16, age_str, 10);
_=v;
_=age;
}
// Typed API (compile-time validated, via cli.Command(T))constname=ctx.get(.name); // Returns []const u8 directlyconstage=ctx.get(.age); // Returns u16, already parsed// ^^^^^ Compile-time validated enum field!

See examples/typed.zig for a complete working example.

Type-Safe Config

Load config files with compile-time schema validation:

constAppConfig=struct {
database: struct {
host: []constu8,
port: u16,
max_connections: u32=100,
},
log_level: enum { debug, info, warn, @"error" } =.info,
debug: bool=false,
};
// Load with full type checkingvarconfig=trycli.config.load(AppConfig, allocator, "config.toml");
deferconfig.deinit();
// Direct field access - no optionals, no string parsing!std.debug.print("DB: {s}:{d}\n", .{
config.value.database.host,
config.value.database.port,
});
std.debug.print("Log Level: {s}\n", .{@tagName(config.value.log_level)});
// Auto-discovery also worksvardiscovered=trycli.config.discover(AppConfig, allocator, "myapp");
deferdiscovered.deinit();

Supported types:

  • Primitives: bool, i8-i64, u8-u64, f32, f64
  • Strings: []const u8
  • Enums: Any Zig enum
  • Optionals: ?T for optional fields
  • Nested structs: Arbitrary depth
  • Arrays: Fixed-size arrays

Prompts

Text Prompt

vartext=prompt.TextPrompt.init(allocator, "Enter value:");
defertext.deinit();
_=text.withPlaceholder("placeholder text");
_=text.withDefault("default value");
_=text.withValidation(myValidator);
constvalue=trytext.prompt();
deferallocator.free(value);

Custom validator:

fnmyValidator(value: []constu8) ?[]constu8 {
if (value.len<3) {
return"Value must be at least 3 characters";
}
returnnull; // Valid
}

Confirm Prompt

varconfirm=prompt.ConfirmPrompt.init(allocator, "Continue?");
deferconfirm.deinit();
_=confirm.withDefault(true);
constresult=tryconfirm.prompt(); // Returns bool

Select Prompt

constchoices= [_]prompt.SelectPrompt.Choice{
.{ .label="TypeScript", .value="ts", .description="JavaScript with types" },
.{ .label="Zig", .value="zig", .description="Systems programming" },
};
varselect=prompt.SelectPrompt.init(allocator, "Choose a language:", &choices);
deferselect.deinit();
constselected=tryselect.prompt();
deferallocator.free(selected);

MultiSelect Prompt

constchoices= [_]prompt.MultiSelectPrompt.Choice{
.{ .label="Option 1", .value="opt1" },
.{ .label="Option 2", .value="opt2" },
};
varmulti=tryprompt.MultiSelectPrompt.init(allocator, "Select options:", &choices);
defermulti.deinit();
constselected=trymulti.prompt(); // Returns [][]const u8defer {
for (selected) |item|allocator.free(item);
allocator.free(selected);
}

Password Prompt

varpassword=prompt.PasswordPrompt.init(allocator, "Enter password:");
deferpassword.deinit();
_=password.withMaskChar('_');
_=password.withValidation(validatePassword);
constpwd=trypassword.prompt();
deferallocator.free(pwd);

Spinner Prompt

varspinner=prompt.SpinnerPrompt.init(allocator, "Loading data...");
tryspinner.start();
// Do some work_=std.c.nanosleep(&.{ .sec=2, .nsec=0 }, null);
tryspinner.stop("Data loaded successfully!");

Message Prompts

// Intro/Outro for CLI flowstryprompt.intro(allocator, "My CLI Application");
// ... your application logic ...tryprompt.outro(allocator, "All done! Thanks for using our CLI.");
// Notes and logstryprompt.note(allocator, "Important", "This is additional information");
tryprompt.log(allocator, .info, "Starting process...");
tryprompt.log(allocator, .success, "Process completed!");
tryprompt.log(allocator, .warning, "This is a warning");
tryprompt.log(allocator, .error_level, "An error occurred");
// Cancel messagetryprompt.cancel(allocator, "Operation was canceled");

Box Rendering

// Simple boxtryprompt.box(allocator, "Title", "This is the content");
// Custom box with stylingvarbox=prompt.Box.init(allocator);
box=box.withStyle(.rounded); // .single, .double, .rounded, .asciibox=box.withPadding(2);
trybox.render("My Box",
\\Line 1 of content\\Line 2 of content\\Line 3 of content
);

Number Prompt

varnum_prompt=prompt.NumberPrompt.init(allocator, "Enter port:", .integer);
defernum_prompt.deinit();
_=num_prompt.withRange(1, 65535); // Set min/max_=num_prompt.withDefault(8080);
constport=trynum_prompt.prompt(); // Returns f64constport_int=@as(u16, @intFromFloat(port));

Number types:

  • .integer - Integer values
  • .float - Floating-point values

Path Prompt

varpath_prompt=prompt.PathPrompt.init(allocator, "Select file:", .file);
deferpath_prompt.deinit();
_=path_prompt.withMustExist(true); // Must exist_=path_prompt.withDefault("./config.toml");
constpath=trypath_prompt.prompt();
deferallocator.free(path);
// Press Tab to autocomplete based on filesystem

Path types:

  • .file - File selection
  • .directory - Directory selection
  • .any - File or directory

Group Prompts

constprompts= [_]prompt.GroupPrompt.PromptDef{
.{ .text= .{ .key="name", .message="Your name?" } },
.{ .number= .{ .key="age", .message="Your age?", .number_type=.integer } },
.{ .confirm= .{ .key="agree", .message="Do you agree?" } },
.{ .select= .{
.key="lang",
.message="Choose language:",
.choices= &[_]prompt.SelectPrompt.Choice{
.{ .label="Zig", .value="zig" },
.{ .label="TypeScript", .value="ts" },
},
}},
};
vargroup=prompt.GroupPrompt.init(allocator, &prompts);
defergroup.deinit();
trygroup.run();
// Access results by keyconstname=group.getText("name");
constage=group.getNumber("age");
constagreed=group.getBool("agree");
constlang=group.getText("lang");

Progress Bar

varprogress=prompt.ProgressBar.init(allocator, 100, "Processing files");
deferprogress.deinit();
tryprogress.start();
for (0..100) |i| {
// Do some work_=std.c.nanosleep(&.{ .sec=0, .nsec=50_std.time.ns_per_ms }, null);
tryprogress.update(i+1);
}
tryprogress.finish();

Progress bar styles:

  • .bar - Classic progress bar
  • .blocks - Block characters
  • .dots - Dots
  • .ascii - ASCII fallback

Table Rendering

constcolumns= [_]prompt.Table.Column{
.{ .header="Name", .alignment=.left },
.{ .header="Age", .alignment=.right },
.{ .header="Status", .alignment=.center },
};
vartable=prompt.Table.init(allocator, &columns);
defertable.deinit();
table=table.withStyle(.rounded); // .simple, .rounded, .double, .minimaltrytable.addRow(&[_][]constu8{ "Alice", "30", "Active" });
trytable.addRow(&[_][]constu8{ "Bob", "25", "Inactive" });
trytable.addRow(&[_][]constu8{ "Charlie", "35", "Active" });
trytable.render();

Style Chaining

// Create styled text with chainable APIconststyled=tryprompt.style(allocator, "Error occurred")
.red()
.bold()
.underline()
.render();
deferallocator.free(styled);
tryprompt.Terminal.init().write(styled);
// Available colors: black, red, green, yellow, blue, magenta, cyan, white// Available styles: bold(), dim(), italic(), underline()// Available backgrounds: bgRed(), bgGreen(), bgBlue(), etc.

Configuration Files

zig-cli supports type-safe configuration loading from TOML, JSONC (JSON with Comments), and JSON5 files.

Loading Config

// 1. Define your config schema as a structconstAppConfig=struct {
database: struct {
host: []constu8,
port: u16,
},
log_level: enum { debug, info, warn, @"error" } =.info,
debug: bool=false,
};
// 2. Load with full type checkingvarconfig=trycli.config.load(AppConfig, allocator, "config.toml");
deferconfig.deinit();
// 3. Direct field access - type-safe!std.debug.print("DB: {s}:{d}\n", .{
config.value.database.host,
config.value.database.port,
});
// Load from stringvarconfig2=trycli.config.loadFromString(AppConfig, allocator, toml_content, .toml);
deferconfig2.deinit();
// Auto-discover config filevarconfig3=trycli.config.discover(AppConfig, allocator, "myapp");
deferconfig3.deinit();
// Searches for: myapp.toml, myapp.json5, myapp.jsonc// In: ., ./.config, ~/.config/myapp

Untyped Config Access

For simple cases, use the raw Config type:

varraw_config=cli.config.Config.init(allocator);
deferraw_config.deinit();
tryraw_config.loadFromFile("config.toml", .auto);
// Get typed valuesif (raw_config.getString("name")) |name| {
std.debug.print("Name: {s}\n", .{name});
}
if (raw_config.getInt("port")) |port| {
std.debug.print("Port: {d}\n", .{port});
}
if (raw_config.getBool("debug")) |debug| {
std.debug.print("Debug: {}\n", .{debug});
}

Supported Formats

TOML:

# config.tomlname = "myapp"port = 8080
[database]
host = "localhost"

JSONC (JSON with Comments):

{
// Comments are allowed"name": "myapp",
"port": 8080,
"database": {
"host": "localhost"
}, // trailing commas allowed
}

JSON5:

{// Unquoted keysname: 'myapp',// single quotesport: 8080,permissions: 0x755,// hex numbersratio: .5,// leading decimalmaxValue: Infinity,// special values}

Terminal & ANSI

Colors

constansi=@import("zig-cli").prompt.Ansi;
constcolored=tryansi.colorize(allocator, "text", .green);
deferallocator.free(colored);
// Convenience functionsconstbold=tryansi.bold(allocator, "text");
constred=tryansi.red(allocator, "error");
constgreen=tryansi.green(allocator, "success");

Symbols

constsymbols=ansi.Symbols.forTerminal(supports_unicode);
std.debug.print("{s} Success!\n", .{symbols.checkmark});
std.debug.print("{s} Error!\n", .{symbols.cross});
std.debug.print("{s} Loading...\n", .{symbols.spinner[0]});

Examples

Check out the examples/ directory for complete examples:

  • simple.zig - Minimal typed CLI example
  • basic.zig - Basic CLI with options and subcommands
  • typed.zig - Type-safe API examples (compile-time validated)
  • advanced.zig - Complex CLI with multiple commands and arguments
  • prompts.zig - All prompt types with validation
  • showcase.zig - Comprehensive feature demonstration
  • config.zig - Configuration file examples (TOML, JSONC, JSON5)

Example config files are in examples/configs/:

  • example.toml - TOML format example
  • example.jsonc - JSONC format example
  • example.json5 - JSON5 format example

Build and run examples:

zig build examples # Build all examples
zig build run-simple # Run a specific example
zig build run-showcase # Run the showcase

Architecture

CLI Framework

Command(T) (typed, compile-time validated)
├── BaseCommand (underlying command)
│ ├── Options (parsed from --flags)
│ ├── Arguments (positional)
│ └── Subcommands (nested)
├── Context(T) (typed parse context)
└── Parser (validation pipeline)

Prompt System

PromptCore (state machine)
├── Terminal I/O
│ ├── Raw mode handling
│ ├── Keyboard input
│ └── ANSI output
├── State: initial -> active <-> error -> submit/cancel
└── Events: value, cursor, key, submit, cancel

Design Principles

  1. Type Safety: Leverage Zig's type system for compile-time safety
  2. Memory Ownership: Clear allocation/deallocation patterns
  3. Error Handling: Explicit error handling with Zig's error unions
  4. Cross-platform: Works on macOS, Linux, and Windows
  5. Zero Dependencies: Only uses Zig standard library
  6. Composable: Mix and match CLI and prompt features

Comparison with clapp

zig-cli is inspired by the TypeScript library clapp, bringing similar developer experience to Zig:

Featureclappzig-cli
Builder Patternyesyes
Subcommandsyesyes
Command Aliasesyesyes
Interactive Promptsyesyes
State Machineyesyes
Type Validationyesyes
ANSI Colorsyesyes
Style Chainingyesyes
Spinner/Loadingyesyes
Progress Barsyesyes
Box Renderingyesyes
Table Renderingyesyes
Message Promptsyesyes
Number Promptsyesyes
Path Promptsyesyes
Group Promptsyesyes
Terminal Detectionyesyes
Dimension Detectionyesyes
Config Files (TOML/JSONC/JSON5)yesyes
Middleware Systemyesyes
LanguageTypeScriptZig
Binary Size~50MB (with Node.js)~500KB
Startup Time~50-100ms<1ms

Testing

zig build test

Building

zig build # Build the library
zig build test# Run all tests
zig build examples # Build all examples

Community

For help, discussion about best practices, or any other conversation that would benefit from being searchable:

Discussions on GitHub

For casual chit-chat with others using this package:

Join the zig-utils Discord Server

License

MIT

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Roadmap

Completed Features

  • Spinner/loading indicators
  • Box/panel rendering
  • Message prompts (intro, outro, note, log, cancel)
  • Terminal dimension detection
  • Command aliases
  • Config file support (TOML, JSONC, JSON5)
  • Auto-discovery of config files
  • Progress bars with multiple styles
  • Table rendering with column alignment
  • Style chaining (.red().bold().underline())
  • Group prompts with result access
  • Number prompt with range validation
  • Path prompt with autocomplete
  • Middleware system for commands
  • Type-safe API with compile-time validation
    • TypedCommand with auto-generated options from structs
    • TypedConfig with schema validation
    • TypedMiddleware with compile-time field checking

Future Enhancements

  • Tree rendering for hierarchical data
  • Date/time prompts
  • Shell completion generation (bash, zsh, fish)
  • Better Windows terminal support
  • Task prompts with status indicators
  • Streaming output prompts
  • Vim keybindings for prompts
  • Multi-column layout support

About

A modern, feature-rich CLI library for Zig.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages