Fireargs is in technical previewShare feedback

Search

Search documentation

Inputs

Updated Oct 23, 2026

Most field configuration comes from serde and schemars — descriptions from doc comments, defaults from #[serde(default)], constraints such as #[schemars(range(min = 1))] or #[schemars(regex(pattern = ...))], choices from a unit enum, repeated values from Vec<T>, optionality from Option<T>. For CLI behavior that has no serde equivalent (positionals, short flags, env vars, conflicts, hidden, …) add the fireargs meta to the field with #[schemars(extend("fireargs" = {...}))].

Default routing

Plain fields of the input struct are --options. To make a field positional, give it "kind": "argument":

use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
struct GreetInput {
    #[schemars(extend("fireargs" = {"kind": "argument"}))]
    name: String, // [name] positional
    #[serde(default)]
    verbose: bool, // --verbose option (default routing)
}

"kind": "option" is the default, written out only to attach more option config.

Flag names

A field's flag is its input key in kebab-case: headerRows becomes --header-rows, and a positional sourcePath is shown as [source-path]. The handler, --json input and the --llms manifest keep the key (headerRows). Set name to choose the flag yourself:

use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct CopyInput {
    #[serde(default)]
    header_rows: u32, // --header-rows <value>
    #[serde(default)]
    #[schemars(extend("fireargs" = {"kind": "option", "name": "to"}))]
    to_path: String, // --to <value>
    #[serde(default)]
    #[schemars(extend("fireargs" = {"kind": "option", "name": "dryRun"}))]
    dry_run: bool, // --dryRun
}

A key starting with no gives a plain --no-… flag, not a negation: a noCache: bool is true when --no-cache is passed.

Wire keys shared with TypeScript

The key is the field's serde name, so a Rust field header_rows is header_rows on the wire; its flag is --header-rows either way. Put #[serde(rename_all = "camelCase")] on the input struct to keep the keys of a TypeScript twin (headerRows): --json input and output and the --llms schemas then use the camelCase key:

use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct ReadInput {
    #[serde(default)]
    header_rows: u32, // --header-rows, "headerRows" on the wire
}

#[serde(rename = "...")] on one field sets its key alone. Meta that names other fields (conflicts, implies) and .parse(...) use these keys.

The meta

#[schemars(extend("fireargs" = {...}))] writes a JSON object under the fireargs key of the field's JSON Schema, where fireargs reads it. Its keys are camelCase, the same as the TypeScript ArgumentConfig / OptionConfig. It sits beside schemars' own attributes:

use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
struct ListInput {
    /// Max rows
    #[serde(default = "twenty")]
    #[schemars(range(min = 1), extend("fireargs" = {"kind": "option", "short": "l"}))]
    limit: u32,
}

fn twenty() -> u32 {
    20
}

A meta with an unknown key or a wrongly typed value panics when the command is built, naming the field, rather than turning back into a plain option. The meta is stripped from the --llms schemas.

Every schemars attribute applies, since input is validated against the JSON Schema it produces: range, length, regex, email, url, contains, inner(...) for items, and extend with any other JSON Schema keyword.

Option meta

With "kind": "option" (or no kind), the meta takes:

KeyWhat it does
nameLong flag without dashes, e.g. "to" → --to
shortShort flag letter, e.g. "v" adds -v alongside --verbose
envEnvironment variable to fall back to when the flag is absent
conflictsKey or keys of options that conflict with this one
implies{"key": value} set when this option is given
hiddenHide from --help
presetValue used when the flag appears without an argument
helpGroupHeading under which this option is listed in help, e.g. "Logging"
defaultDescriptionDisplay string for the default value in help

The env var is listed in --help as [env: NAME=], and its value goes through number parsing and .parse(...) like a CLI value.

Argument meta

A positional takes fewer keys — no short flag, env var, conflicts/implies, hidden, preset or help group. Beyond "kind": "argument":

KeyWhat it does
nameName shown in help and usage, e.g. [file]
defaultDescriptionDisplay string for the default value in help

A positional's default is shown next to its description under Arguments, as (default: …).

Parsing CLI values

Clap hands every value over as a string. fireargs converts it before the schema sees it, so the struct describes the real value and --json callers send that value directly:

  • Numbers parse automatically. A field typed as an integer or a float (or a Vec of them) turns --rows 10 into 10, and rejects --rows abc with error: option '--rows <value>' argument 'abc' is invalid. Not a number. --json '{"rows":"10"}' is still rejected — the JSON value must be a number.
  • .parse(key, ...) handles any other syntax. Registered on the builder by input key, since a closure cannot live in the schema, it receives the raw string from argv or the env var and returns the field's JSON value, which the schema then validates. It never runs on --json input or on defaults.
use std::collections::BTreeMap;

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

#[derive(Deserialize, Serialize, JsonSchema)]
#[serde(untagged)]
enum HeaderRows {
    Flag(bool),
    Rows(Vec<u32>),
}

#[derive(Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
struct ReadInput {
    missing_values: Option<Vec<String>>,
    header_rows: Option<HeaderRows>,
    #[serde(default)]
    props: BTreeMap<String, i64>,
}

fn parse_header_rows(text: &str) -> Result<Value, String> {
    if text == "false" {
        return Ok(json!(false));
    }
    let rows: Result<Vec<u32>, _> = text.split(',').map(str::parse).collect();
    rows.map(|rows| json!(rows)).map_err(|error| error.to_string())
}

fn create_read() -> Command {
    f::command(CommandConfig::default())
        .input::<ReadInput>()
        .output::<ReadInput>()
        // --missing-values a,b → ["a", "b"]
        .parse("missingValues", |text| Ok(json!(text.split(',').collect::<Vec<_>>())))
        // --header-rows false → false, --header-rows 1,2 → [1, 2]
        .parse("headerRows", parse_header_rows)
        // --props '{"a":1}' → {"a": 1}
        .parse("props", |text| serde_json::from_str(text).map_err(|e| e.to_string()))
        .handler(|input, _| async move { Ok::<_, Error>(input) })
}

A field with parse always takes exactly one value (--key <value>), even when its type is a Vec or a bool: parse owns the whole string. An Err(message) from parse is reported as an invalid argument, with the message, and exits 1 without reaching render_error:

error: option '--props <value>' argument 'nope' is invalid. expected ident at line 1 column 2

.parse(...) naming a key the input does not have panics when the command is built.

Reach for parse rather than a custom Deserialize or deserialize_with when the comma list or JSON string is only shell syntax. A deserializer belongs to the type, so --json callers would go through it too, and --llms cannot tell them which string syntax it expects.

Examples

use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum Format {
    Json,
    Yaml,
    Toml,
}

#[derive(Deserialize, JsonSchema)]
struct ServeInput {
    /// user's name
    // positional, required: [name]
    #[schemars(extend("fireargs" = {"kind": "argument"}))]
    name: String,
    // positional, optional; keep required positionals first
    #[schemars(extend("fireargs" = {"kind": "argument"}))]
    greeting: Option<String>,
    // option with short flag and env fallback
    #[serde(default = "default_port")]
    #[schemars(extend("fireargs" = {"kind": "option", "short": "p", "env": "PORT"}))]
    port: u16,
    // booleans with explicit conflicts
    #[serde(default)]
    #[schemars(extend("fireargs" = {"kind": "option", "conflicts": "cmyk"}))]
    rgb: bool,
    #[serde(default)]
    cmyk: bool,
    // hidden internal option
    #[schemars(extend("fireargs" = {"kind": "option", "hidden": true}))]
    secret: Option<String>,
    // with choices: --formats json yaml, or --formats json --formats yaml
    #[serde(default)]
    formats: Vec<Format>,
    // preset: --level alone is "info", --level debug is "debug"
    #[schemars(extend("fireargs" = {"kind": "option", "preset": "info"}))]
    level: Option<String>,
    /// Retry this many times
    #[serde(default = "default_retries")]
    #[schemars(range(max = 10))]
    retries: u32,
}

fn default_port() -> u16 {
    8080
}

fn default_retries() -> u32 {
    3
}

What if I forget the meta?

Plain fields work — they default to --options with the schema's validation. The meta only adds CLI-specific config that serde and schemars can't express. Don't sprinkle it everywhere; reach for it when you genuinely need a positional, a short flag, an env binding, or one of the other keys above.

Created with ❤ and Livemark