Lua CLI Library Design
Opinionated CLI parsing for normalize scripts.
Goals
- Minimal boilerplate for common patterns
- Auto-generated help text
- Subcommand support (like @todo add/done/rm)
- Type coercion (string → number/boolean)
- Required vs optional args
- Validation with clear errors
API Sketch
Declarative Style
Arrays for ordering, keys for clarity:
local cli = require("cli")
cli.run {
name = "todo",
description = "TODO list manager",
commands = {
{ name = "list", description = "List items", default = true,
run = function(args) print("Listing...") end },
{ name = "add", description = "Add a new item", args = { "text..." },
run = function(args) print("Adding: " .. args.text) end },
{ name = "done", description = "Mark item as done", args = { "query" },
run = function(args) print("Done: " .. args.query) end },
},
}Simple Script (No Subcommands)
cli.run {
name = "greet",
description = "Greet someone",
args = { "name" },
options = {
{ name = "loud", short = "l", description = "Shout it" },
{ name = "times", short = "n", description = "Repeat count", default = 1 },
},
run = function(args)
local msg = "Hello, " .. args.name
if args.loud then msg = msg:upper() .. "!" end
for i = 1, args.times do print(msg) end
end,
}Subcommands
Nested commands (e.g., normalize @git remote add):
cli.run {
name = "git",
description = "Git helper",
commands = {
{ name = "status", description = "Show status",
run = function(args) ... end },
{ name = "remote", description = "Manage remotes",
commands = {
{ name = "list", description = "List remotes", default = true,
run = function(args) ... end },
{ name = "add", description = "Add a remote",
args = { "name", "url" },
run = function(args)
print("Adding " .. args.name .. " -> " .. args.url)
end },
{ name = "remove", description = "Remove a remote",
args = { "name" },
run = function(args) ... end },
}},
},
}Usage:
normalize @git remote add origin https://...
normalize @git remote list
normalize @git remote # runs 'list' (default)Handlers
Handlers live with their command definition - everything in one place:
commands = {
{ name = "add", description = "Add an item",
args = { "text..." },
options = {
{ name = "priority", short = "p", description = "Set priority" },
},
run = function(args)
-- args.text = collected positional args
-- args.priority = option value or nil
add_item(args.text, args.priority)
end },
}For complex handlers, define function above and reference:
local function handle_add(args)
-- ... lots of logic ...
end
local function handle_remove(args)
-- ... lots of logic ...
end
cli.run {
name = "todo",
commands = {
{ name = "add", description = "Add item", args = { "text..." }, run = handle_add },
{ name = "rm", description = "Remove item", args = { "query" }, run = handle_remove },
},
}Syntax Reference
-- Positional args: array of strings (order matters)
args = { "file" } -- required
args = { "file?" } -- optional
args = { "files..." } -- rest (collects remaining)
args = { "src", "dst?" } -- src required, dst optional
-- Options: array of tables (order matters for help text)
options = {
{ name = "verbose", short = "v", description = "Verbose output" },
{ name = "output", short = "o", description = "Output file", default = "out.txt" },
{ name = "count", short = "n", description = "Repeat count", type = "number" },
}
-- Commands: array of tables (order matters for help text)
commands = {
{ name = "list", description = "List items", default = true, run = fn },
{ name = "add", description = "Add item", args = {...}, options = {...}, run = fn },
{ name = "remote", description = "Manage remotes", commands = {...} }, -- nested
}
-- A command can have both run + commands (run is fallback when no subcommand)
-- Prefer `default = true` on a subcommand if you want it visible in help
{ name = "remote", run = fallback_fn, commands = {...} }Generated Help
$ normalize @todo --help
todo - TODO list manager
Usage: normalize @todo <command> [options]
Commands:
add <text> Add a new item
done <query> Mark item as done
list List items (default)
Options:
-h, --help Show this help
--version Show version
$ normalize @todo add --help
todo add - Add a new item
Usage: normalize @todo add <text>
Arguments:
text Item text (can be multiple words)
Options:
-h, --help Show this helpType Coercion
app:option("count", {
short = "n",
type = "number", -- auto-converts, errors if not a number
default = 10,
})
app:flag("dry-run", {
type = "boolean", -- default for flags
})
app:option("tags", {
type = "list", -- comma-separated → table
})Validation
app:option("level", {
type = "number",
validate = function(v)
if v < 1 or v > 5 then
return nil, "must be between 1 and 5"
end
return v
end,
})
app:arg("file", {
validate = function(v)
if not file_exists(v) then
return nil, "file not found: " .. v
end
return v
end,
})Error Handling
$ normalize @myapp --count abc
Error: --count: expected number, got 'abc'
$ normalize @myapp --level 10
Error: --level: must be between 1 and 5
$ normalize @myapp
Error: missing required argument: file
Run 'normalize @myapp --help' for usage.Implementation Notes
Where to Put It
Option A: Builtin Lua module (like ts, view, edit)
- Pro: Always available, no require path issues
- Con: More code in normalize binary
Option B: Bundled .lua file loaded via require
- Pro: Pure Lua, easy to modify
- Con: Need to handle module loading
Recommendation: Option A - builtin module. CLI parsing is fundamental enough to warrant builtin status.
Template Integration
New template: normalize script new foo --template cli
local cli = require("cli")
local app = cli.app {
name = "{name}",
description = "Description of {name}",
}
app:command("list", {
description = "List items",
default = true,
run = function(args)
print("TODO: implement list")
end,
})
app:command("add", {
description = "Add an item",
run = function(args)
print("TODO: implement add: " .. args.text)
end,
})
:arg("text", { description = "Item text", rest = true })
app:run()Implemented Features
All open questions have been resolved and implemented:
Global Options Inheritance
App-level options are inherited by all subcommands:
cli.run {
name = "app",
options = {
{ name = "verbose", short = "v", description = "Verbose output" },
},
commands = {
{ name = "build", run = function(args)
if args.verbose then print("Building...") end
end },
},
}Usage: normalize @app --verbose build or normalize @app build --verbose
Command Aliases
Commands can have multiple names:
{ name = "remove", aliases = {"rm", "delete"}, ... }Type Coercion
Options can specify type for automatic conversion:
options = {
{ name = "count", type = "number" }, -- string → number
{ name = "port", type = "integer" }, -- string → integer (must be whole)
{ name = "force", type = "boolean" }, -- "true"/"1" → true, "false"/"0" → false
}Environment Variable Fallbacks
Options can specify an environment variable to use as default:
{ name = "port", type = "integer", env = "PORT" }Required Options
Options can be marked as required (only enforced in strict mode):
{ name = "output", short = "o", required = true }Mutually Exclusive Options
Options can conflict with each other (only enforced in strict mode):
options = {
{ name = "json", conflicts_with = "text" },
{ name = "text", conflicts_with = "json" },
}Config Flags
These are opt-in behaviors, disabled by default:
cli.run {
name = "app",
bundling = true, -- enable -abc → -a -b -c
negatable = true, -- enable --no-* for all flags
strict = true, -- enable validation (required args/options, conflicts)
...
}Short Option Bundling (bundling = true)
When enabled, combined short flags expand:
-abc→-a -b -c-vvv→-v -v -v
Only works for flags (options without values).
Negatable Flags (negatable = true)
When enabled globally, all flags can be negated with --no- prefix:
--verbose→ setsverbose = true--no-verbose→ setsverbose = false
Individual options can also opt-in/out:
{ name = "color", negatable = true } -- allows --no-color even without global flag
{ name = "force", negatable = false } -- prevents --no-force even with global flagStrict Mode (strict = true)
Enables validation errors for:
- Missing required positional arguments
- Missing required options
- Mutually exclusive option conflicts
Without strict mode, these issues are silently ignored (useful for lenient parsing).