Show HN: Zli – A Batteries-Included CLI Framework for Zig

5 days ago 1

A blazing-fast, zero-cost CLI framework for Zig. The last one you will ever use.

Build modular, ergonomic, and high-performance CLIs with ease. All batteries included.

Zig Version  MIT Built by xcaeser Version

🧱 Each command is modular and self-contained. inspired by Cobra (Go) and clap (Rust).

See docs.md for full usage, examples, and internals.

  • Modular commands & subcommands
  • Fast flag parsing (--flag, --flag=value, shorthand -abc)
  • Type-safe support for bool, int, string
  • Named positional arguments with required, optional, variadic
  • Auto help/version/deprecation handling
  • Pretty help output with aligned flags & args
  • Cobra-like usage hints, context-aware
zig fetch --save=zli https://github.com/xcaeser/zli/archive/v3.5.0.tar.gz

Add to your build.zig:

const zli_dep = b.dependency("zli", .{ .target = target }); exe.root_module.addImport("zli", zli_dep.module("zli"));
your-app/ ├── build.zig ├── src/ │ ├── main.zig │ └── cli/ │ ├── root.zig │ ├── run.zig │ └── version.zig
  • Each command is in its own file
  • You explicitly register subcommands
  • root.zig is the entry point
// src/main.zig const std = @import("std"); const cli = @import("cli/root.zig"); pub fn main() !void { const allocator = std.heap.smp_allocator; var root = try cli.build(allocator); defer root.deinit(); try root.execute(.{}); // Or pass data with: try root.execute(.{ .data = &my_data }); }
// src/cli/root.zig const std = @import("std"); const zli = @import("zli"); const run = @import("run.zig"); const version = @import("version.zig"); pub fn build(allocator: std.mem.Allocator) !*zli.Command { const root = try zli.Command.init(allocator, .{ .name = "blitz", .description = "Your dev toolkit CLI", }, showHelp); try root.addCommands(&.{ try run.register(allocator), try version.register(allocator), }); return root; } fn showHelp(ctx: zli.CommandContext) !void { try ctx.command.printHelp(true); }
// src/cli/run.zig const std = @import("std"); const zli = @import("zli"); const now_flag = zli.Flag{ .name = "now", .shortcut = "n", .description = "Run immediately", .type = .Bool, .default_value = .{ .Bool = false }, }; pub fn register(allocator: std.mem.Allocator) !*zli.Command { const cmd = try zli.Command.init(allocator, .{ .name = "run", .description = "Run your workflow", }, run); try cmd.addFlag(now_flag); try cmd.addPositionalArg(.{ .name = "script", .description = "Script to execute", .required = true, }); try cmd.addPositionalArg(.{ .name = "env", .description = "Environment name", .required = false, }); return cmd; } fn run(ctx: zli.CommandContext) !void { const now = ctx.flag("now", bool); // type-safe flag access const script = ctx.getArg("script") orelse { try ctx.command.stderr.print("Missing script arg\n", .{}); return; }; const env = ctx.getArg("env") orelse "default"; std.debug.print("Running {s} in {s} (now = {})\n", .{ script, env, now }); }
// src/cli/version.zig const std = @import("std"); const zli = @import("zli"); pub fn register(allocator: std.mem.Allocator) !*zli.Command { return zli.Command.init(allocator, .{ .name = "version", .shortcut = "v", .description = "Show CLI version", }, show); } fn show(ctx: zli.CommandContext) !void { std.debug.print("{}\n", .{ctx.root.options.version}); }
  • Commands & subcommands
  • Flags & shorthands
  • Type-safe flag values
  • Help/version auto handling
  • Deprecation notices
  • Positional args (required, optional, variadic)
  • Pretty-aligned help for flags & args
  • Named access: ctx.getArg("name")
  • Clean usage output like Cobra
  • Command aliases
  • Persistent flags

MIT. See LICENSE. Contributions welcome.

Read Entire Article