Command-Line Programs, Without the Ceremony

Command-Line Programs, Without the Ceremony

January 29, 2026·
Luma Core Team

Command-line tools are often the first programs people write - and the ones they keep rewriting. They start small, grow organically, and over time accumulate flags, positional arguments, prompts, and little bits of glue code that are rarely fun to maintain.

In many languages, building a CLI means choosing between two extremes. On one side, you have minimal access to raw arguments and standard input, which forces you to reinvent parsing and validation yourself. On the other, you have large frameworks that introduce their own abstractions, configuration files, and mental overhead before you can even read a flag.

Luma’s new cli type was designed to avoid both.

One handle, one mental model

At the center of Luma’s CLI story is a single idea: there should be one obvious place where all command-line interaction happens.

c: cli = cli()

That’s it. No global variables, no static calls, no separate modules for flags, input, and output. A cli value represents the program’s interface to the command line: flags, arguments, prompts, and even captured output.

Letting types do the work

A recurring theme in Luma is that types are not decoration - they are instructions. That idea carries naturally into command-line parsing.

Instead of declaring flags and then describing how to parse them, you simply say what you want:

name: str = c.flag("name", "world")
count: int = c.flag("count", 10)
verbose: bool = c.flag("verbose", false)

There’s no separate schema. No annotations. No parsing logic to maintain. The type you assign tells Luma how to interpret the flag. If the user passes something invalid, the program fails early and clearly.

This approach scales without becoming noisy. Optional flags are optional because their type says so:

config: ?str = c.flag("config")

If the flag isn’t provided, you get nil. If it is provided but invalid, that’s an error - exactly as you’d expect.

Repeated flags without special cases

One surprisingly tricky problem in many CLI libraries is handling repeated flags. You often end up with special APIs or custom parsing logic just to support patterns like --tag=a --tag=b.

In Luma, this falls out naturally from the type system.

tags: [str] = c.flag("tag")

If the flag appears once, you get a list with one value. If it appears many times, the values accumulate. If it never appears, you get an empty list. There are no special rules to learn - just a consistent interpretation of “this flag produces many values”.

The result is code that stays simple even as the CLI grows.

Positional arguments that stay out of the way

Flags tend to get the most attention, but positional arguments are just as important. Luma keeps them deliberately simple.

files: [str] = c.args()

You get exactly what the user typed, in order, with flags already removed. If you only care about a specific position, you can ask for it directly:

input: ?str = c.arg(0)

There’s no ambiguity, no indexing errors to guard against, and no need to manually slice arrays.

Talking to users, not terminals

Many command-line programs eventually need to ask the user something. In some ecosystems, this means dropping down to raw input handling, string parsing, and validation loops.

In Luma, prompting is a first-class capability of the CLI handle.

age: int = c.prompt("Enter your age: ")

The prompt knows the type you expect, retries automatically on invalid input, and gives you a value that’s already parsed.

When you need more control, you can add it - explicitly, and only then.

port: int = c.prompt("Port: ", {
    default: 8080,
    validate: p -> p >= 1024 && p <= 65535
})

Defaults, validation, and timeouts are all opt-in. The common case stays one line. The rare case remains possible without inventing new APIs.

Capturing output without rewriting code

Testing and scripting often require capturing output instead of printing it directly. In many languages, this leads to refactoring functions just so they return strings instead of printing them.

Luma takes a different approach. Output capture lives alongside everything else in the CLI handle.

c.record()
generate_report()
result: str = c.stop()

The code that generates output doesn’t need to know whether it’s being printed, tested, or redirected. That decision stays at the edges, where it belongs.

A calmer way to build tools

What all of this adds up to is not a “CLI framework”, but a consistent way of thinking about command-line programs.

There’s no separate DSL for flags.
No hidden global state.
No split between “simple” and “advanced” APIs.

Instead, you describe what you want - a string, a number, a list, an optional value — and Luma handles the mechanics.

Compared to traditional approaches, this removes a surprising amount of friction. You spend less time configuring parsers and more time writing the actual behavior of your program. The code stays readable even as the interface grows, and the mental model remains the same from the first script to the hundredth.

A solid foundation

The addition of the cli type completes an important piece of Luma’s standard toolbox. Alongside file handling and typed input, it gives developers everything they need to build real, usable command-line applications without pulling in external libraries or inventing patterns on the fly.

It’s not flashy. It doesn’t try to be clever. And that’s exactly the point.

The best CLI tools are the ones that disappear behind their intent - and now, in Luma, the same can be said for building them.

Last updated on