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 cog, 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)​

Not shipped

The derive does not run yet — measured, not guessed. The library says so itself, and says it in a place you can read without leaving the shell:

grep -nE 'Status: Phase 1|macro pass lands' core/cli/derive.vr
# 7:// Status: Phase 1 — semantic-only, scaffolding for the meta1 macro pass
# 18:// Until the macro pass lands, hand-written code uses `cli.builder` to

Copying the block below and running it today gives error: No main function found in VBC module, because the entry point Verum recognises is fn main() or fn main(args: List<Text>) — a typed fn main(args: Args) -> ExitCode is part of the desugaring that has not landed.

Two smaller differences from the library while you read: the derive's own contract spells its arguments with = (@command(name = "tool", version = "1.0")), and the handler is a named function taking a &HandlerContext, not main. core/cli/derive_example.vr is the end-to-end shape the library itself maintains.

What works today is §1 above — App.new, the fluent builder, and the combinator parser. ExitCode.Ok and ExitCode.ok() are both real (core/cli/error.vr); everything else in this section describes the planned surface.

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.Ok
}

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:

The builder is AppBuilder, and it takes the name AND the one-line description together — there is no .about(…) step. A flag's value-or-switch nature is chosen by its CONSTRUCTOR (FlagSpec.value vs FlagSpec.switch), not by a .takes_value() builder, and every spec constructor takes its help text as the second argument.

mount core.cli.builder.{AppBuilder, FlagBuilder};
mount core.cli.spec.{ArgSpec, FlagSpec, CommandSpec};
mount core.cli.runtime.{App};

let spec: CommandSpec = AppBuilder.new("wave", "Greet a value, the Verum way.")
.version("0.1.0")
.arg(ArgSpec.required("name", "Who to greet."))
.flag(FlagBuilder.value("count", "Repeat the greeting N times.")
.short('n')
.default("1")
.build())
.flag(FlagSpec.switch("no-newline", "Suppress trailing newline."))
.build();

let app = App.from_spec(spec);

AppBuilder.build() returns a CommandSpec — the description of the command — and App.from_spec turns that into the runnable App. App is not generic: it is { spec, registry, style }, and handlers are attached with .register(name, handler).

The per-argument builders are ArgBuilder and FlagBuilder; each ends in .build() and yields the corresponding spec. Use the ArgSpec / FlagSpec constructors directly when you need no extra steps:

ArgSpec.required(name, help) ArgSpec.optional(name, help)
ArgSpec.variadic(name, help)
FlagSpec.switch(long, help) FlagSpec.value(long, help)
Does not run yet at Tier 0

CommandSpec.new initialises its settings field with Default.default(), and that call dispatches by NAME across the whole program rather than by the field's declared type — it lands on GitRevision.default and panics. So AppBuilder.new(…) cannot return in the interpreter today. The shapes above are read off the implement blocks and are what the API will be once the dispatch is fixed.

An App can be invoked several ways:

// Standard: parse argv from `env`, dispatch, and exit the process.
// `run_or_exit` returns `!` — it does not come back. Internally it
// reads `env.args()`, calls `try_run`, and passes `code.code()` to
// `env.exit`.
app.run_or_exit();

// Test mode: parse a manufactured argv vector and get the exit code
// back instead of exiting. Used in `@test` and golden-test harnesses.
let outcome: Result<ExitCode, ParseError> =
app.try_run(List.from(["wave", "Maxim", "-n", "3"]));

// JSON-schema export: a spec serialises to a stable schema for
// editors / shells / completion engines. It is a free function over
// the SPEC, not a method on the app —
// `core.cli.json_schema.{render, render_compact}`.
let schema = json_schema.render(&spec);

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:

// `try_run` is the entry that hands the error back; `run_or_exit`
// calls it with `env.args()` and exits for you. There is no
// `parse_with_errors`, no `env.argv()` and no `env.is_json_mode()`.
match app.try_run(env.args()) {
Result.Ok(code) => code,
Result.Err(err) => {
// `ParseError` is a RECORD carrying the structured hints:
// { kind, field: Maybe<Text>, value: Maybe<Text>,
// message: Text, diagnostics: List<ParseDiagnostic> }
eprint(&err.message);
for d in &err.diagnostics {
// `ParseDiagnostic` is a SUM — DidYouMean(Text) |
// ExpectedOneOf(List<Text>) | ValidRange(Text) |
// SeeHelp(Text) — and `to_text` renders one hint line.
// There is no `render_pretty` and no `to_json`.
eprint(&d.to_text());
}
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
Ok0success
GenericError1generic error
Usage2bad argv / parse error
DataErr65invalid input data
NoInput66input file unreadable
NoUser67addressee unknown
NoHost68host unknown
Unavailable69service unavailable
Software70internal software error
OsErr71OS-level error
CantCreate73cannot create output
IoErr74generic I/O failure
TempFail75temporary failure (try again)
NoPerm77permission denied
ConfigErr78configuration error
SigInt130interrupted (Ctrl-C)
SigTerm143terminated
Custom(Int)nany code the roster above does not name

ExitCode converts in one direction only: code(&self) -> Int gives the POSIX number (Ok → 0, Usage → 2, DataErr → 65, …). There is no from_raw; to exit with a number the enum does not name, call env.exit(n) directly.

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 models plugins rather than finding them: PluginManifest.new(name, version), and a PluginRegistry you install into, ask count of, and attach_to a CommandSpec. Enumerating what is actually on PATH is not part of it — there is no discover(), and the registry is populated by the caller.

10. See also​