zig-utils/zig-cli
A modern, feature-rich CLI library for Zig.
A type-safe, compile-time validated CLI library for Zig. 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 struct
const MyOptions = struct {
name: []const u8,
port: u16 = 8080,
};
// Type-safe action
fn run(ctx: *cli.Context(MyOptions)) !void {
const name = ctx.get(.name); // Compile-time validated!
const port = ctx.get(.port);
}
Inspired by modern CLI frameworks, built for Zig's strengths.
.red().bold().underline())Add zig-cli to your build.zig:
const zig_cli = b.dependency("zig-cli", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zig-cli", zig_cli.module("zig-cli"));
const std = @import("std");
const cli = @import("zig-cli");
// 1. Define options as a struct - that's it!
const GreetOptions = struct {
name: []const u8 = "World", // With default value
enthusiastic: bool = false, // Boolean flag
};
// 2. Type-safe action function
fn greet(ctx: *cli.Context(GreetOptions)) !void {
const stdout = std.io.getStdOut().writer();
// Compile-time validated field access - no strings!
const name = ctx.get(.name);
const punct: []const u8 = if (ctx.get(.enthusiastic)) "!" else ".";
try stdout.print("Hello, {s}{s}\n", .{name, punct});
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// 3. Create command - options auto-generated!
var cmd = try cli.command(GreetOptions).init(allocator, "greet", "Greet someone");
defer cmd.deinit();
_ = cmd.setAction(greet);
// 4. Parse and execute
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
try cli.Parser.init(allocator).parse(cmd.getCommand(), args[1..]);
}
That's it! Run with: myapp greet --name Alice --enthusiastic
Benefits:
const std = @import("std");
const prompt = @import("zig-cli").prompt;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Text prompt
var text_prompt = prompt.TextPrompt.init(allocator, "What is your name?");
defer text_prompt.deinit();
const name = try text_prompt.prompt();
defer allocator.free(name);
// Confirm prompt
var confirm_prompt = prompt.ConfirmPrompt.init(allocator, "Continue?");
defer confirm_prompt.deinit();
const confirmed = try confirm_prompt.prompt();
// Select prompt
const choices = [_]prompt.SelectPrompt.Choice{
.{ .label = "Option 1", .value = "opt1" },
.{ .label = "Option 2", .value = "opt2" },
};
var select_prompt = prompt.SelectPrompt.init(allocator, "Choose:", &choices);
defer select_prompt.deinit();
const selected = try select_prompt.prompt();
defer allocator.free(selected);
}
var app = try cli.CLI.init(allocator, "app-name", "1.0.0", "Description");
defer app.deinit();
const option = cli.Option.init("name", "long-name", "Description", .string)
.withShort('n') // Short flag (-n)
.withRequired(true) // Make it required
.withDefault("value"); // Set default value
_ = try app.option(option);
Option types:
.string - String value.int - Integer value.float - Float value.bool - Boolean flagconst arg = cli.Argument.init("name", "Description", .string)
.withRequired(true) // Required argument
.withVariadic(false); // Accept multiple values
_ = try app.argument(arg);
const subcmd = try cli.Command.init(allocator, "subcmd", "Subcommand description");
// Add aliases for the command
_ = try subcmd.addAlias("sub");
_ = try subcmd.addAlias("s");
const opt = cli.Option.init("opt", "option", "Option description", .string);
_ = try subcmd.addOption(opt);
_ = subcmd.setAction(myAction);
_ = try app.command(subcmd);
Now you can call the subcommand with: myapp subcmd, myapp sub, or myapp s
Add pre/post command hooks to your CLI:
var chain = cli.Middleware.MiddlewareChain.init(allocator);
defer chain.deinit();
// Add built-in middleware
try chain.use(cli.Middleware.Middleware.init("logging", cli.Middleware.loggingMiddleware));
try chain.use(cli.Middleware.Middleware.init("timing", cli.Middleware.timingMiddleware));
try chain.use(cli.Middleware.Middleware.init("validation", cli.Middleware.validationMiddleware));
// Custom middleware
fn authMiddleware(ctx: *cli.Middleware.MiddlewareContext) !bool {
const is_authenticated = checkAuth();
if (!is_authenticated) {
try ctx.set("error", "Unauthorized");
return false; // Stop chain
}
try ctx.set("user", "[email protected]");
return true; // Continue
}
// Add with priority (lower runs first)
try chain.use(cli.Middleware.Middleware.init("auth", authMiddleware).withOrder(-10));
// Execute middleware chain before command
var middleware_ctx = cli.Middleware.MiddlewareContext.init(allocator, parse_context, command);
defer middleware_ctx.deinit();
if (try chain.execute(&middleware_ctx)) {
// All middleware passed, execute command
try command.executeAction(parse_context);
}
Built-in middleware:
loggingMiddleware - Logs command executiontimingMiddleware - Records start timevalidationMiddleware - Validates required optionsenvironmentCheckMiddleware - Checks environment variablesfn myAction(ctx: *cli.Command.ParseContext) !void {
// Get option value
const value = ctx.getOption("name") orelse "default";
// Check if option was provided
if (ctx.hasOption("verbose")) {
// Do something
}
// Get positional argument
const arg = ctx.getArgument(0) orelse return error.MissingArgument;
// Get argument count
const count = ctx.getArgumentCount();
}
zig-cli provides a fully typed API layer that leverages Zig's comptime features for maximum type safety and zero runtime overhead.
Define your command options as a struct and get compile-time validation:
const GreetOptions = struct {
name: []const u8, // Required string
age: ?u16 = null, // Optional integer
times: u8 = 1, // With default value
verbose: bool = false, // Boolean flag
format: enum { text, json } = .text, // Enum support
};
fn greetAction(ctx: *cli.TypedContext(GreetOptions)) !void {
// Compile-time validated field access - no string lookups!
const name = ctx.get(.name); // Returns []const u8
const age = ctx.get(.age); // Returns ?u16
const times = ctx.get(.times); // Returns u8
// Or parse entire struct at once
const opts = try ctx.parse();
std.debug.print("Hello, {s}!\n", .{opts.name});
}
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Auto-generates CLI options from struct fields!
var cmd = try cli.TypedCommand(GreetOptions).init(
allocator,
"greet",
"Greet a user",
);
defer cmd.deinit();
_ = cmd.setAction(greetAction);
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
// Get underlying command for parsing
try cli.Parser.init(allocator).parse(cmd.getCommand(), args[1..]);
}
Benefits:
Load config files with compile-time schema validation:
const AppConfig = struct {
database: struct {
host: []const u8,
port: u16,
max_connections: u32 = 100,
},
log_level: enum { debug, info, warn, @"error" } = .info,
debug: bool = false,
};
// Load with full type checking
var config = try cli.config.loadTyped(AppConfig, allocator, "config.toml");
defer config.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 works
var discovered = try cli.config.discoverTyped(AppConfig, allocator, "myapp");
defer discovered.deinit();
Supported types:
bool, i8-i64, u8-u64, f32, f64[]const u8?T for optional fieldsCreate middleware with typed context instead of string HashMap:
const AuthData = struct {
user_id: []const u8 = "",
username: []const u8 = "",
role: enum { admin, user, guest } = .guest,
authenticated: bool = false,
};
fn authMiddleware(ctx: *cli.TypedMiddleware(AuthData)) !bool {
// Type-safe field access with compile-time validation
ctx.set(.user_id, "12345");
ctx.set(.username, "john_doe");
ctx.set(.role, .admin); // Enum - compile-time checked!
ctx.set(.authenticated, true);
// Access with type safety
if (ctx.get(.role) == .admin) {
std.debug.print("Admin access granted\n", .{});
}
return true;
}
// Use in middleware chain
var chain = cli.TypedMiddlewareChain(AuthData).init(allocator);
defer chain.deinit();
try chain.useTyped(authMiddleware, "auth");
// Runtime API (string-based)
const value = ctx.getOption("name"); // Returns ?[]const u8
if (value) |v| {
const age_str = ctx.getOption("age") orelse "0";
const age = try std.fmt.parseInt(u16, age_str, 10);
}
// Typed API (compile-time validated)
const name = ctx.get(.name); // Returns []const u8 directly
const age = ctx.get(.age); // Returns u16, already parsed
// ^^^^^ Compile-time validated enum field!
See examples/typed.zig for a complete working example.
var text = prompt.TextPrompt.init(allocator, "Enter value:");
defer text.deinit();
_ = text.withPlaceholder("placeholder text");
_ = text.withDefault("default value");
_ = text.withValidation(myValidator);
const value = try text.prompt();
defer allocator.free(value);
Custom validator:
fn myValidator(value: []const u8) ?[]const u8 {
if (value.len < 3) {
return "Value must be at least 3 characters";
}
return null; // Valid
}
var confirm = prompt.ConfirmPrompt.init(allocator, "Continue?");
defer confirm.deinit();
_ = confirm.withDefault(true);
const result = try confirm.prompt(); // Returns bool
const choices = [_]prompt.SelectPrompt.Choice{
.{ .label = "TypeScript", .value = "ts", .description = "JavaScript with types" },
.{ .label = "Zig", .value = "zig", .description = "Systems programming" },
};
var select = prompt.SelectPrompt.init(allocator, "Choose a language:", &choices);
defer select.deinit();
const selected = try select.prompt();
defer allocator.free(selected);
const choices = [_]prompt.SelectPrompt.Choice{
.{ .label = "Option 1", .value = "opt1" },
.{ .label = "Option 2", .value = "opt2" },
};
var multi = try prompt.MultiSelectPrompt.init(allocator, "Select options:", &choices);
defer multi.deinit();
const selected = try multi.prompt(); // Returns [][]const u8
defer {
for (selected) |item| allocator.free(item);
allocator.free(selected);
}
var password = prompt.PasswordPrompt.init(allocator, "Enter password:");
defer password.deinit();
_ = password.withMaskChar('*');
_ = password.withValidation(validatePassword);
const pwd = try password.prompt();
defer allocator.free(pwd);
var spinner = prompt.SpinnerPrompt.init(allocator, "Loading data...");
try spinner.start();
// Do some work
std.time.sleep(2 * std.time.ns_per_s);
try spinner.stop("Data loaded successfully!");
// Intro/Outro for CLI flows
try prompt.intro(allocator, "My CLI Application");
// ... your application logic ...
try prompt.outro(allocator, "All done! Thanks for using our CLI.");
// Notes and logs
try prompt.note(allocator, "Important", "This is additional information");
try prompt.log(allocator, .info, "Starting process...");
try prompt.log(allocator, .success, "Process completed!");
try prompt.log(allocator, .warning, "This is a warning");
try prompt.log(allocator, .error_level, "An error occurred");
// Cancel message
try prompt.cancel(allocator, "Operation was canceled");
// Simple box
try prompt.box(allocator, "Title", "This is the content");
// Custom box with styling
var box = prompt.Box.init(allocator);
box = box.withStyle(.rounded); // .single, .double, .rounded, .ascii
box = box.withPadding(2);
try box.render("My Box",
\\Line 1 of content
\\Line 2 of content
\\Line 3 of content
);
var num_prompt = prompt.NumberPrompt.init(allocator, "Enter port:", .integer);
defer num_prompt.deinit();
_ = num_prompt.withRange(1, 65535); // Set min/max
_ = num_prompt.withDefault(8080);
const port = try num_prompt.prompt(); // Returns f64
const port_int = @as(u16, @intFromFloat(port));
Number types:
.integer - Integer values.float - Floating-point valuesvar path_prompt = prompt.PathPrompt.init(allocator, "Select file:", .file);
defer path_prompt.deinit();
_ = path_prompt.withMustExist(true); // Must exist
_ = path_prompt.withDefault("./config.toml");
const path = try path_prompt.prompt();
defer allocator.free(path);
// Press Tab to autocomplete based on filesystem
Path types:
.file - File selection.directory - Directory selection.any - File or directoryconst prompts = [_]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" },
},
}},
};
var group = prompt.GroupPrompt.init(allocator, &prompts);
defer group.deinit();
try group.run();
// Access results by key
const name = group.getText("name");
const age = group.getNumber("age");
const agreed = group.getBool("agree");
const lang = group.getText("lang");
var progress = prompt.ProgressBar.init(allocator, 100, "Processing files");
defer progress.deinit();
try progress.start();
for (0..100) |i| {
// Do some work
std.time.sleep(50 * std.time.ns_per_ms);
try progress.update(i + 1);
}
try progress.finish();
Progress bar styles:
.bar - Classic progress bar (█████░░░░░).blocks - Block characters (▓▓▓▓▓░░░░░).dots - Dots (⣿⣿⣿⣿⣿⡀⡀⡀⡀⡀).ascii - ASCII fallback ([====------])const columns = [_]prompt.Table.Column{
.{ .header = "Name", .alignment = .left },
.{ .header = "Age", .alignment = .right },
.{ .header = "Status", .alignment = .center },
};
var table = prompt.Table.init(allocator, &columns);
defer table.deinit();
table = table.withStyle(.rounded); // .simple, .rounded, .double, .minimal
try table.addRow(&[_][]const u8{ "Alice", "30", "Active" });
try table.addRow(&[_][]const u8{ "Bob", "25", "Inactive" });
try table.addRow(&[_][]const u8{ "Charlie", "35", "Active" });
try table.render();
// Create styled text with chainable API
const styled = try prompt.style(allocator, "Error occurred")
.red()
.bold()
.underline()
.render();
defer allocator.free(styled);
try prompt.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.
zig-cli supports type-safe configuration loading from TOML, JSONC (JSON with Comments), and JSON5 files.
// 1. Define your config schema as a struct
const AppConfig = struct {
database: struct {
host: []const u8,
port: u16,
},
log_level: enum { debug, info, warn, @"error" } = .info,
debug: bool = false,
};
// 2. Load with full type checking
var config = try cli.config.load(AppConfig, allocator, "config.toml");
defer config.deinit();
// 3. Direct field access - type-safe!
std.debug.print("DB: {s}:{d}\n", .{
config.value.database.host,
config.value.database.port,
});
// Load from string
var config2 = try cli.config.loadFromString(AppConfig, allocator, toml_content, .toml);
defer config2.deinit();
// Auto-discover config file
var config3 = try cli.config.discover(AppConfig, allocator, "myapp");
defer config3.deinit();
// Searches for: myapp.toml, myapp.json5, myapp.jsonc
// In: ., ./.config, ~/.config/myapp
// Get typed values
if (config.getString("name")) |name| {
std.debug.print("Name: {s}\n", .{name});
}
if (config.getInt("port")) |port| {
std.debug.print("Port: {d}\n", .{port});
}
if (config.getBool("debug")) |debug| {
std.debug.print("Debug: {}\n", .{debug});
}
if (config.getFloat("timeout")) |timeout| {
std.debug.print("Timeout: {d}s\n", .{timeout});
}
// Get raw value for complex types
if (config.get("database")) |db_value| {
// Handle nested tables, arrays, etc.
}
TOML:
# config.toml
name = "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 keys
name: 'myapp', // single quotes
port: 8080,
permissions: 0x755, // hex numbers
ratio: .5, // leading decimal
maxValue: Infinity, // special values
}
const ansi = @import("zig-cli").prompt.Ansi;
const colored = try ansi.colorize(allocator, "text", .green);
defer allocator.free(colored);
// Convenience functions
const bold = try ansi.bold(allocator, "text");
const red = try ansi.red(allocator, "error");
const green = try ansi.green(allocator, "success");
const symbols = 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]});
Check out the examples/ directory for complete examples:
basic.zig - Basic CLI with options and subcommandsprompts.zig - All prompt types with validationadvanced.zig - Complex CLI with multiple commands and argumentsshowcase.zig - Comprehensive feature demonstration including all new promptsconfig.zig - Configuration file examples (TOML, JSONC, JSON5)typed.zig - Type-safe API examples (compile-time validated) (NEW!)Example config files are in examples/configs/:
example.toml - TOML format exampleexample.jsonc - JSONC format exampleexample.json5 - JSON5 format exampleRun examples with your own Zig project by importing zig-cli.
CLI
├── Command (root)
│ ├── Options (parsed from --flags)
│ ├── Arguments (positional)
│ └── Subcommands (nested)
└── Parser (validation pipeline)
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
zig-cli is inspired by the TypeScript library clapp, bringing similar developer experience to Zig:
| Feature | clapp | zig-cli |
|---|---|---|
| Builder Pattern | ✅ | ✅ |
| Subcommands | ✅ | ✅ |
| Command Aliases | ✅ | ✅ |
| Interactive Prompts | ✅ | ✅ |
| State Machine | ✅ | ✅ |
| Type Validation | ✅ | ✅ |
| ANSI Colors | ✅ | ✅ |
| Style Chaining | ✅ | ✅ |
| Spinner/Loading | ✅ | ✅ |
| Progress Bars | ✅ | ✅ |
| Box Rendering | ✅ | ✅ |
| Table Rendering | ✅ | ✅ |
| Message Prompts | ✅ | ✅ |
| Number Prompts | ✅ | ✅ |
| Path Prompts | ✅ | ✅ |
| Group Prompts | ✅ | ✅ |
| Terminal Detection | ✅ | ✅ |
| Dimension Detection | ✅ | ✅ |
| Config Files (TOML/JSONC/JSON5) | ✅ | ✅ |
| Middleware System | ✅ | ✅ |
| Language | TypeScript | Zig |
| Binary Size | ~50MB (with Node.js) | ~500KB |
| Startup Time | ~50-100ms | <1ms |
zig build test
MIT
Contributions are welcome! Please feel free to submit a Pull Request.
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 (NEW!)
TypedCommand with auto-generated options from structs
TypedConfig with schema validation
TypedMiddleware with compile-time field checking
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