Fireargs is in technical previewShare feedback

Search

Search documentation

Commands

Updated Oct 23, 2026

f::command(config) opens a builder that ends in a Command. .input::<I>() picks the input struct, .output::<O>() the output type, and .handler(...) takes an async closure that receives the input and a Context and returns Result<O, E>. The steps come in that order — the builder's types enforce it — with the optional .parse(...) and .render(...) before the handler.

use std::process::ExitCode;

use fireargs::{self as f, CommandConfig, Error};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, JsonSchema)]
struct GreetInput {
    name: String,
}

#[derive(Serialize, JsonSchema)]
struct Greeting {
    greeting: String,
}

fn main() -> ExitCode {
    let config = CommandConfig {
        name: Some("greet".into()),
        ..Default::default()
    };
    let greet = f::command(config)
        .input::<GreetInput>()
        .output::<Greeting>()
        .handler(|input, _| async move {
            Ok::<_, Error>(Greeting { greeting: format!("hello {}", input.name) })
        });
    greet.run()
}

The command is named by name. Under a program the router key names it instead; a command run on its own has no other name, so set name for its --llms tool name and --version line.

Input and output

The input must be Deserialize + JsonSchema: its fields determine the positional arguments and --options (see Inputs). A command without input takes an empty struct (struct NoInput {}). The output can be any Serialize + JsonSchema type — a struct, a Vec<T>, a scalar, () (see Outputs). The handler's return value is serialized and validated against the output's JSON Schema on every run, so schemars' validation attributes on the output hold too.

The handler's error type is anything that converts into fireargs::Error (Box<dyn std::error::Error + Send + Sync>): a &str, a String, or any std::error::Error. Spell it at the Ok (Ok::<_, Error>(...)) when the closure never fails, so the compiler can infer it.

.render(...)

Optional. Turns the validated output into what a human sees at the shell. It runs only without --json; with it, the output is written as JSON instead. It receives the output and a RenderContext carrying input (the parsed input), json and command. A returned Some(text) is written to stdout with a trailing newline; return None to write the output yourself (spinners, colors, tables). Without a render, a run without --json prints nothing.

use fireargs::{self as f, Command, CommandConfig, Error};
use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
struct ListInput {
    #[schemars(extend("fireargs" = {"kind": "argument"}))]
    path: String,
}

fn create_list_names() -> Command {
    f::command(CommandConfig::default())
        .input::<ListInput>()
        .output::<Vec<String>>()
        .render(|output, context| {
            let lines: Vec<String> = output.iter().map(|name| format!("- {name}")).collect();
            Some(format!("{}:\n{}", context.input.path, lines.join("\n")))
        })
        .handler(|input, _| async move {
            Ok::<_, Error>(vec![format!("{}/a", input.path), format!("{}/b", input.path)])
        })
}

The handler

Receives the parsed, schema-validated input and a Context. Must return a value of the output type, or an error.

context.json is true when --json was given, with or without a value: a handler that drives spinners or progress output keeps quiet then, so stdout stays parseable. context.command is the space-separated route path of the running command, such as "admin reset", or the command's name when it runs on its own.

Handlers are always async. .run() blocks on the handler with a minimal executor, so fn main() -> ExitCode { cli.run() } needs no runtime. A handler that awaits runtime-bound futures (tokio's I/O or timers) runs under that runtime with .run_async().await:

use std::process::ExitCode;

use fireargs::{self as f, CommandConfig, Context, Error};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, JsonSchema)]
struct FetchInput {
    url: String,
}

#[derive(Serialize, JsonSchema)]
struct Fetched {
    url: String,
    mode: String,
}

#[tokio::main]
async fn main() -> ExitCode {
    let config = CommandConfig {
        name: Some("fetch".into()),
        description: Some("Fetch a URL".into()),
        ..Default::default()
    };
    let fetch = f::command(config)
        .input::<FetchInput>()
        .output::<Fetched>()
        .handler(|input, context: Context| async move {
            tokio::task::yield_now().await;
            let mode = if context.json { "json" } else { "text" };
            Ok::<_, Error>(Fetched { url: input.url, mode: mode.into() })
        });
    fetch.run_async().await
}

Input that fails the schema is reported like a commander parse error — one error: ... line per field on stderr, exit code 1. An error returned by the handler, and output that fails its schema, end the run: .run() prints error: <message> to stderr and exits 1, and .try_run_from(...) returns the error. A render_error hook takes all of them instead (see JSON mode).

What gets derived from the input struct

For each field of the input struct:

Rust constructBecomes
String / an integer / a float--key <value>, required unless defaulted; a number's CLI string is parsed
bool--key flag
{"kind": "argument"} metapositional [key], read in field order
#[serde(default)] / default = "f"shown as (default: …) in --help; serde fills it
a unit enumchoices: (choices: "a", "b") in --help, checked against the schema
Vec<T>--key <value>...: --key a b or --key a --key b; positional [key]...
Vec<SomeEnum>repeated, with choices on each value
Option<T>optional, with no default needed
a doc comment on the fieldshown in --help and surfaced as description in --llms JSON Schema

key is shown in kebab-case: with #[serde(rename_all = "camelCase")], header_rows becomes headerRows on the wire and --header-rows at the shell. See Flag names.

For per-field CLI config that serde and schemars don't model (positional, short flag, env var, conflicts, hidden, a preset value, …) add the fireargs meta to the field; a custom value parser is .parse(...) on the builder.

CommandConfig

The struct f::command(...) takes. Every field is optional; spread ..Default::default() for the rest.

FieldWhat it does
namethe command name when run on its own; a program's router key overrides it
descriptionhelp text, and the tool description in --llms
summarythe one-line text in the parent's command list and -h; falls back to description
aliasesextra names the parent program also routes to this command
usagereplaces the generated usage line
versionadds a -V, --version flag printing it
before_help, after_helptext printed before and after the generated help
json_option, llms_optiona FlagConfig to rename or disable --json/--llms. See LLMs mode
render_errorArc<dyn Fn(&Error, &Context) -> Option<Value>>. See JSON mode

The clap command is built when the command runs and stays internal, so clap settings beyond these are not reachable. Run a command with .run(), .run_async().await, or .try_run_from(argv, &mut out, &mut err).await, which takes the argv (binary name first) and the writers, for tests.

Why a builder

The input and output types are generic parameters of the builder, and the handler and render closures are typed from them, so the schemas and the handler cannot drift apart. The builder's steps are distinct types, so a command missing its input, output or handler does not compile.

Created with ❤ and Livemark