Skip to main content

core.cli — declarative CLI framework

core.cli is Verum's first-class toolkit for building command-line tools. It treats the CLI as a typed surface — every flag, argument, and subcommand is a declarative spec the runtime resolves into a parsed value with structured diagnostics — and it leaves Rust-style ad-hoc args.next() parsing in the compiler-test corner where it belongs.

One-line entry-point. mount core.cli.*; imports everything a script needs: types, builder, runtime, error model, help renderer. No second crate, no procedural-macro dance.

1. The mental model

A CLI program is a tree of commands. Each command carries:

  • Flags — long (--verbose) and / or short (-v) options that may take values (--output FILE).
  • Positional arguments — required or optional, with an optional arity (single, repeated, all-remaining).
  • Subcommands — child commands the parser recurses into.
  • A handler — a Verum function that receives the parsed argument record and a context, and returns an ExitCode.

Everything else (help text, completions, man pages, JSON-schema description, dry-run mode) is derived from this spec by the runtime.

2. Declarative API — @command derive (Phase 1)

The terse, recommended form. Annotate a record type with @command(...) and let the compiler generate the spec:

mount core.cli.*;

@command(
name: "wave",
about: "Greet a value, the Verum way.",
version: "0.1.0",
)
type Args is {
/// Who to greet.
@arg(positional, required) name: Text,

/// Repeat the greeting N times.
@flag(short: 'n', long: "count", default: 1)
count: Int { self > 0 },

/// Suppress trailing newline.
@flag(long: "no-newline")
no_newline: Bool,
};

fn main(args: Args) -> ExitCode {
for _ in 0 .. args.count {
if args.no_newline {
print(&f"hello, {args.name}");
} else {
print(&f"hello, {args.name}\n");
}
}
ExitCode.Success
}

The @command macro inspects the record's fields, classifies each (flag / positional / subcommand) by its @flag / @arg / @subcommand annotation, and emits an App<Args> builder chain that drives core.cli.runtime.

3. Builder API — App.new (Phase 0)

When you need full control — programmatic spec generation, dynamic subcommand registration, custom completion logic — drop down to the builder. This is what @command expands to:

let app = App.new("wave")
.about("Greet a value, the Verum way.")
.version("0.1.0")
.arg(ArgSpec.required("name").help("Who to greet."))
.flag(FlagSpec.new("count")
.short('n')
.takes_value()
.default(1)
.help("Repeat the greeting N times."))
.flag(FlagSpec.new("no-newline")
.help("Suppress trailing newline."))
.build();

App.new(...).build() returns an App<Args> that can be invoked several ways:

// Standard: parse argv from `env`, dispatch to `main`, exit.
ExitCode.exit(app.run(env.argv()));

// Test mode: parse a manufactured argv vector, return parsed
// args without dispatching. Used in `@test` and golden-test
// harnesses.
let parsed = app.parse(List.from(["wave", "Maxim", "-n", "3"]));

// JSON-schema export: every spec serialises to a stable schema
// for editors / shells / completion engines.
let schema = app.to_json_schema();

4. The error model

Parsing failures surface as a ParseError. Diagnostics are empathetic — they include did-you-mean suggestions, the offending argv slice, and (where applicable) the source RfC reference for the rule that fired:

error[CLI-EARG]: missing required argument 'name'
┌─ wave

1 │ wave
│ ^^^^ expected `name` here

did you mean: `wave Maxim`?
see: --help

ParseDiagnostic exposes the same content as a structured value when JSON output is requested:

match app.parse_with_errors(env.argv()) {
Result.Ok(args) => main(args),
Result.Err(diag) => {
if env.is_json_mode() {
print(&diag.to_json());
} else {
diag.render_pretty();
}
ExitCode.Usage
}
}

5. Layered modules

When you want to import only what you need, the framework exposes each component as a sub-module of core.cli:

ModulePurpose
core.cli.specCommandSpec, ArgSpec, FlagSpec, Group, Arity
core.cli.typesFromArg, ValueEnum, ArgKind protocols
core.cli.errorParseError, ParseDiagnostic
core.cli.parsercombinator-based argv parser (Parser<A>)
core.cli.helpadaptive help renderer (uses core.term.style)
core.cli.builderfluent App.new(…) chain
core.cli.runtimeApp<E> runtime + dispatcher
core.cli.derive@command derive macro support
core.cli.completionshell-completion script generation (bash, zsh, fish, powershell)
core.cli.manpageman(1)-format renderer
core.cli.configXDG-style config-file resolution
core.cli.frontmatterYAML / TOML frontmatter parsing for verum-script style
core.cli.permissions--allow=… / --deny=… capability resolution
core.cli.replinteractive REPL host
core.cli.pluginplugin discovery (drop-in verum-foo binaries)
core.cli.json_schemaJSON-schema export of any App spec
core.cli.refinementrefinement-typed arg validators (Int { self > 0 }, etc.)
core.cli.testing@test-mode harness for golden CLI tests

6. Exit-code discipline

Verum CLIs follow the BSD sysexits.h family. ExitCode carries the canonical roster:

VariantCodeWhen
Success0clean termination
Usage64bad invocation (missing arg, unknown flag)
DataError65input data malformed
NoInput66input file not found / unreadable
Unavailable69service unavailable (network, daemon)
Software70internal-software bug (panic surfaced)
OsError71OS-level call failed
IoError74I/O error
Cancelled130SIGINT — Ctrl-C
CapabilityDenied143permission policy denied (--allow=…)

Custom exit codes can be lifted via ExitCode.from_raw(rc: Int) where the argument is in [0, 255].

7. Permissions integration

core.cli.permissions implements the --allow=<scope>[=<target>] flag family. It reads the same policy spec the Verum runtime honours (see Script mode permissions), so your CLI's surface is identical to a script's:

$ wave --allow=net=tcp:api.example.com:443 --count 3 Maxim

A capability denial exits 143 and the diagnostic names the denied scope.

8. Testing

core.cli.testing provides a @test-mode harness:

@test
fn parses_count_flag() {
let app = build_app();
let parsed = app.parse(List.from([
"wave", "Maxim", "--count", "3"
])).unwrap();
assert_eq(parsed.count, 3);
assert_eq(parsed.name, Text.from("Maxim"));
}

Golden CLI tests — diff a CLI invocation's stdout / stderr / exit code against a checked-in reference — are supported via core.cli.testing.GoldenSession.

9. Plugin discovery

Drop-in plugins follow the verum-<name> binary convention: verum foo resolves to a verum-foo binary on PATH and forwards arguments. This is how verum bench, verum playbook, and the Aletheia CLI integrate without modifying the main verum binary.

core.cli.plugin.discover() enumerates installed plugins and their declared subcommand surface (via the JSON-schema export above).

10. See also