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:
| Key | What it does |
|---|---|
name | Long flag without dashes, e.g. "to" → --to |
short | Short flag letter, e.g. "v" adds -v alongside --verbose |
env | Environment variable to fall back to when the flag is absent |
conflicts | Key or keys of options that conflict with this one |
implies | {"key": value} set when this option is given |
hidden | Hide from --help |
preset | Value used when the flag appears without an argument |
helpGroup | Heading under which this option is listed in help, e.g. "Logging" |
defaultDescription | Display 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":
| Key | What it does |
|---|---|
name | Name shown in help and usage, e.g. [file] |
defaultDescription | Display 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
Vecof them) turns--rows 10into10, and rejects--rows abcwitherror: 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--jsoninput 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