Tried my hand at creating a tiny CLI
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-07 09:13:34 -05:00
src Finalize CLI builder validation and option aliases 2026-09-07 09:12:56 -05:00
tests Finalize CLI builder validation and option aliases 2026-09-07 09:12:56 -05:00
.gitignore Initial CLI attempt lol 2026-09-05 12:06:14 -05:00
Cargo.lock Finalize CLI builder validation and option aliases 2026-09-07 09:13:34 -05:00
Cargo.toml Finalize CLI builder validation and option aliases 2026-09-07 09:13:34 -05:00
README.md Finalize CLI builder validation and option aliases 2026-09-07 09:12:56 -05:00

walts-cli

A tiny Rust CLI library for building command-driven applications without proc macros or a large framework.

Quick start

use walts_cli::{Cli, Request};

fn build(request: &Request) -> Result<(), String> {
    let path: String = request.arg(0)?;
    let output: String = request.get("output")?;
    let verbose: bool = request.get("verbose")?;

    println!("building {path} -> {output}");

    if verbose {
        println!("verbose mode enabled");
    }

    Ok(())
}

fn main() -> Result<(), String> {
    let cli = Cli::builder()
        .version("0.1.0")
        .command("build", build, |cmd| {
            cmd.alias("b")
                .command_help("Compile a source file")
                .arg("path")
                .required()
                .option("-o", "--output")
                .default("out.bin")
                .flag("-v", "--verbose")
        })
        .build()?;

    cli.run()
}

Cli::builder() creates a CLI definition. Calling .build() validates the definition and returns a finalized Cli that can be parsed or run.

Commands

Commands are registered on CliBuilder.

let cli = Cli::builder()
    .command("build", build, |cmd| {
        cmd.command_help("Compile a source file")
    })
    .command("run", run, |cmd| {
        cmd.command_help("Run a source file")
    })
    .build()?;

Each command receives a parsed Request.

Namespaced commands

Command names may use : to create lightweight namespaces.

let cli = Cli::builder()
    .command("remote:add", remote_add, |cmd| {
        cmd.command_help("Add a remote")
    })
    .command("remote:list", remote_list, |cmd| {
        cmd.command_help("List remotes")
    })
    .build()?;

Users still invoke the full command name:

app remote:add
app remote:list

Namespaced commands are grouped under their common root in generated help output.

Positional arguments

Declare positional arguments with .arg().

.command("build", build, |cmd| {
    cmd.arg("path")
        .required()
})

Read them by index:

let path: String = request.arg(0)?;

Arguments are parsed through FromStr, so they can be requested as any compatible type.

Options

Declare value-taking options with .option().

.command("build", build, |cmd| {
    cmd.option("-o", "--output")
})

Read an option by its long-name key without the leading dashes:

let output: String = request.get("output")?;

Flags

Declare boolean flags with .flag().

.command("build", build, |cmd| {
    cmd.flag("-v", "--verbose")
})

Read them as booleans:

let verbose: bool = request.get("verbose")?;

Additional short forms

Options and flags may define additional short forms with .short().

.command("build", build, |cmd| {
    cmd.flag("-v", "--verbose")
        .short("-V")
})

All of these resolve to the same flag:

app build -v
app build -V
app build --verbose

Additional short forms are also shown in command help:

-v, -V, --verbose

Required values

Arguments and options can be marked required.

cmd.arg("path")
    .required()
    .option("-o", "--output")
    .required()

Missing required values return an error.

Default values

Arguments and options may define defaults.

cmd.option("-o", "--output")
    .default("out.bin")

Then:

let output: String = request.get("output")?;

returns out.bin when the user does not provide --output.

Flexible argument ordering

Options and flags can appear before, between, or after positional arguments.

app build src/main.rs -v
app build -v src/main.rs
app build -o out.bin src/main.rs
app build src/main.rs -o out.bin

Equals syntax

Long options support --option=value.

app build --output=out.bin src/main.rs

This is equivalent to:

app build --output out.bin src/main.rs

Flags do not accept values.

app build --verbose=true

returns an error.

Repeated options

Repeated options are preserved.

app build -I src -I include

Read them with get_list():

let includes: Vec<String> = request.get_list("include")?;

A single option also works with get_list():

app build -I src

returns:

vec!["src"]

Short and long forms may be mixed:

app build -I src --include include

Repeated equals-form options are also supported:

app build --include=src --include=include

Command aliases

Commands may define one or more aliases.

let cli = Cli::builder()
    .command("build", build, |cmd| {
        cmd.alias("b")
            .alias("compile")
    })
    .build()?;

All of these resolve to the same command:

app build
app b
app compile

The resulting Request stores the canonical command name.

Aliases are also shown in generated help output.

Help text

Commands may define a description with .command_help().

.command("build", build, |cmd| {
    cmd.command_help("Compile a source file")
})

Arguments and options may define their own help text with .help().

.command("build", build, |cmd| {
    cmd.arg("path")
        .help("Source file to compile")
        .option("-o", "--output")
        .help("Set output path")
})

A finalized Cli can render help as a string:

let help = cli.help_text(None)?;

Specific command help can be rendered by name:

let help = cli.help_text(Some("build"))?;

External command dependencies

A CLI may declare programs that must exist in PATH.

let cli = Cli::builder()
    .requires("jj")
    .requires("tea")
    .build()?;

Required programs are checked when the CLI runs.

If a dependency cannot be found, the CLI returns an error such as:

required command not found: jj

Dependency lookup supports normal PATH lookup and Windows PATHEXT handling.

Running external commands

This is ever-evolving, but it started out because git sucks and I didn't want obtuse aliases, so right now it has a small helper for invoking external processes.

use walts_cli::run;

run("jj", &["st"])?;

The helper:

  • runs the requested executable with arguments
  • inherits stdout and stderr
  • returns Ok(()) on success
  • returns a readable error if the process cannot start
  • returns a readable error when the child exits unsuccessfully

Example:

jj exited with exit status: 1

Parsing manually

Cli::parse_from() allows deterministic parsing without reading the real process arguments.

This is useful for tests.

let cli = Cli::builder()
    .command("build", build, |cmd| {
        cmd.arg("path")
            .required()
            .flag("-v", "--verbose")
    })
    .build()?;

let outcome = cli.parse_from([
    "walts-cli-test",
    "build",
    "src/main.rs",
    "-v",
])?;

parse_from() expects the first item to be the program name, followed by the command and its arguments.

Parse outcomes

Parsing returns a ParseOutcome.

pub enum ParseOutcome {
    Request(Request, Handler),
    Help,
    Version,
}

A normal command produces a Request and its handler.

Help and version requests are handled without invoking a command handler.

Build validation

CLI definitions are validated when .build() is called.

Validation includes:

  • duplicate command names
  • duplicate command aliases
  • command and alias collisions
  • invalid command names
  • duplicate positional argument names
  • invalid positional argument ordering
  • malformed short and long options
  • duplicate option names and short forms
  • invalid option and flag combinations

A successfully built Cli is therefore a finalized CLI definition ready for parsing or execution.

Errors

I'm lazy so currently we use simple Result<_, String> errors instead of a diagnostic framework.

Examples include:

unknown command: wat
unknown option: --wat
missing required argument: path
missing required option: --output
expected value after --output
option does not take a value: --verbose
required command not found: jj

Eventually I'll make them better.

Philosophy

I keep it intentionally small.

It provides the pieces needed for straightforward command-driven applications without introducing proc macros, derive-based command definitions, or a large parser framework.

The library currently focuses on:

  • commands
  • namespaced commands
  • positional arguments
  • options
  • flags
  • required values
  • defaults
  • command aliases
  • additional short option forms
  • flexible option ordering
  • --option=value
  • repeated options
  • generated and grouped help
  • CLI definition validation
  • external command dependency checks
  • external process execution