4x8matrix/cli_builder

A CLI Builder for Luau

cli-parser

A command-line parser for Luau: global options, commands, subcommands, positional arguments, generated help you can restyle or replace, usage errors that read like a real tool's, and Tab completion in bash, zsh, fish and PowerShell that comes for free. Runtime-agnostic; the examples run under Lune, create_app and studio-themer run it under Zune.

pesde add 4x8matrix/cli_builder

Example

local process = require("@lune/process")
local cliBuilder = require("./luau_packages/cli_builder")

local cli = cliBuilder.cli
local command = cliBuilder.command
local option = cliBuilder.option
local argument = cliBuilder.argument

local app = cli
	.new()
	:setName("pixelcove")
	:setVersion("2.1.0")
	:setDescription("Manage game assets.")
	:addOption(option.new():setName("verbose"):addAlias("V"):setDescription("Log everything."):build())
	:addCommand(command
		.new()
		:setName("import")
		:addAlias("i")
		:setDescription("Import an asset.")
		:addArgument(argument.new():setName("source"):setRequired(true):build())
		:addOption(option.new():setName("type"):setType("string"):setChoices({ "sprite", "sound" }):setDefaultValue("sprite"):build())
		:setCallback(function(context)
			print(context.commandArguments.source, context.commandOptions.type, context.globalOptions.verbose)
		end)
		:build())

process.exit(app:run(process.args, process.env))
$ pixelcove import player.png --type sound -V
player.png sound true

$ pixelcove import
error: missing required argument <source>
  The path to the source asset file.

Usage:
  pixelcove import [options] <source>

Run 'pixelcove import --help' for more information.

Usage errors say what was missing or wrong, with the description and the possible values of the argument or option involved, and a did-you-mean for unknown commands and options.

run(args, environment?) returns an exit code: 0 after a callback, help or the version; 2 after a usage error, which is printed with a usage hint. Pass the process environment so options with setEnv can read it.

What the parser accepts

  • --name value, --name=value, -a value, -avalue; boolean aliases cluster (-Vc).
  • Options anywhere after the command they belong to, before or after positionals. Global options anywhere.
  • -- ends option parsing; everything after it is positional and also available as context.rest.
  • Booleans: --flag, --flag=false (true/false/yes/no/1/0), and --no-flag when the option is negatable.
  • Repeatable options collect an array; a non-repeatable option given twice is a usage error.
  • Choices, number conversion, defaults, environment fallbacks ([env: NAME] in help), required options.
  • Variadic last argument (<files...>), number arguments, argument choices and defaults.
  • Command aliases, hidden commands and options, a default command that takes the first token as its argument.
  • Unknown commands and options fail with a did-you-mean suggestion.
  • --help/-h anywhere, app help <command...>, app <command> help. --version/-v when a version is set and the app has not defined those itself.

Builders

Every builder takes a resource table (option.new({ name = "type", type = "string" })) or a chain of setters, and ends with :build().

option: setName, setDescription, setType("string" | "boolean" | "number") (boolean by default), setDefaultValue, setValueName (the <PATH> placeholder), addAlias (one character), setChoices, setRepeatable, setEnv, setNegatable, setRequired, setHidden.

argument: setName, setDescription, setType("string" | "number"), setRequired, setChoices, setVariadic, setDefaultValue. Required arguments come before optional ones; a variadic argument is last.

command: setName, setDescription, setCallback, addOption, addArgument, addSubCommand, addAlias, setHidden.

cli: setName, setDescription, setVersion, setLicense, setGitRepository, setBugReportUrl, setHomepage, setCopyright, setEpilog, addAuthor, addOption, addCommand, setCallback (runs when no command is given), setDefaultCommand, setBeforeRunHook, configure({ ... }), helpText(commandTree?), helpModel(commandTree?), completionScript(shell), complete(words, environment?), run(args, environment?).

Options and arguments also take setCompleter(fn) for live completion values (see Completions).

Definitions that could not be told apart are refused as the app is built: duplicate option names or aliases, duplicate command names or aliases, and the reserved --help, -h, help, completions and __complete.

Configuration

app:configure({
	ansiColorEnabled = not process.env.NO_COLOR,   -- default true; also pass false when stdout is not a TTY
	output = print,                                -- receives help, the version, usage errors, completion data
	theme = { title = { "Cyan", "Bold" }, note = { "Dim" }, indent = 4, columnGap = 2 },
	help = { row = function(row, width, style) ... end },
	completions = true,                            -- false removes `completions` and the `__complete` hook
})

Customising the output

Three depths, from a colour tweak to a rewrite.

Theme. Roles paint parts of the text; each role is a list of formats (Bold, Dim, Italic, Underline, Strikethrough, Grey, Red, Green, Yellow, Blue, Magenta, Cyan, White). indent and columnGap set the row layout.

rolepaintsdefault
titleUsage: and section headingsYellow, Underline
namecommand, option and argument namesGreen, Bold
placeholder<PATH>
descriptionrow descriptions
note[default: x], [env: X], (required)Grey
linkURLs in the footerBold
creditauthors, license, copyrightGrey, Italic
errorthe error: prefixRed, Bold

Parts. Help is rendered by eight functions; replace any of them with configure({ help = { <part> = fn } }) and the others stay default. Every part receives a style and paints only through style.paint(role, text), so ansiColorEnabled = false strips custom parts too. Returning "" drops a part.

partsignaturerenders
banner(model, style)name version and the description
usage(model, style)the Usage: block
row(row, width, style)one aligned row; width is the label column
section(section, rows, style)a heading over the rendered rows
footer(model, style)links, epilog, credits, copyright
help(model, parts, style)joins parts.banner, parts.usage, parts.sections, parts.footer
error(errorModel, style)a usage error with its usage hint
version(model, style)the version line
app:configure({
	help = {
		row = function(row, width, style)
			return `  {style.paint("name", row.label)}{string.rep(" ", width - #row.label + 2)}{row.description}`
		end,
	},
})

Model. app:helpModel(commandTree?) returns the data every part is rendered from, for apps that render help themselves:

{
	kind = "global" | "command",
	name, version, description,
	path = { "import", "batch" },
	usage = { "pixelcove import [options] <source> [destination]", "pixelcove import <command>" },
	sections = { { id = "options", heading = "Options:", rows = { { kind, names, placeholder, label, description, notes } } } },
	labelWidth = 24,
	links = { { label, url } }, epilog, credits = { "Authors: ..." }, copyright,
}

The callback context

{
	commandTree = { Command },                      -- the resolved path, innermost last
	commandOptions = { [name] = value },            -- options of every command on the path
	commandArguments = { [name] = value },
	globalOptions = { [name] = value },
	rest = { string },                              -- tokens after `--`
}

Absent booleans are false; absent strings and numbers are nil unless a default or environment variable fills them; repeatable options and variadic arguments are arrays.

Completions

Every app gets a completions <shell> command and Tab completion for bash, zsh, fish and PowerShell without writing anything. Install once:

pixelcove completions fish > ~/.config/fish/completions/pixelcove.fish
pixelcove completions bash > ~/.local/share/bash-completion/completions/pixelcove
pixelcove completions zsh > ~/.zsh/completions/_pixelcove      # a directory on $fpath
pixelcove completions powershell >> $PROFILE

The scripts are thin: on every Tab the shell runs pixelcove __complete -- <words so far> and shows what the app answers, the way kubectl and gh do. So completion knows commands, aliases, options, --no- forms and choices as they are today, shows descriptions in fish and zsh, and can offer live values:

argument.new():setName("theme"):setCompleter(function(request)
	-- request.partial, request.words, request.commandPath, request.environment
	return { "mocha", { value = "nord", description = "cool blues" } }
end)

When neither a completer nor choices exist, the shell falls back to file names. app:complete(words) returns the same candidates as data, which is how the engine is tested. configure({ completions = false }) removes both commands.

Development

pesde install              frktest into luau_packages/
lune run .lune/test.luau   the suites under tests/

License

MIT