Search
Search documentation
Inputs
Updated Oct 21, 2026
Most field configuration comes from zod itself — descriptions via
.describe(), defaults via .default(), choices via z.enum([...]),
variadic via z.array(...), optionality via .optional(). For commander
behavior that has no zod equivalent (short flags, env vars, conflicts,
hidden, a custom value parser, …) wrap the field with f.argument(config)
or f.option(config).
Default routing
Bare zod fields in .input(z.object({...})) are treated as --options. To
make a field positional, wrap with f.argument():
.input(
z.object({
name: f.argument().string(), // <name> positional
verbose: z.boolean(), // --verbose option (default routing)
}),
)You can also use f.option() explicitly to attach per-field option config —
even though bare zod fields are already options.
Flag names
A field's flag is its 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 zod key (headerRows). Set name to
choose the flag yourself:
.input(
z.object({
headerRows: z.coerce.number(), // --header-rows <value>
toPath: f.option({ name: "to" }).string(), // --to <value>
dryRun: f.option({ name: "dryRun" }).schema(z.boolean().default(false)), // --dryRun
}),
)A key starting with no gives a plain --no-… flag, not a commander
negation: noCache: z.boolean() is true when --no-cache is passed.
The wrappers
f.argument(config?) and f.option(config?) open a per-field builder. Each
exposes the common zod constructors plus .schema(any) for arbitrary zod:
| Method | Returns |
|---|---|
.string() | z.ZodString tagged with the config |
.number() | z.ZodNumber tagged |
.boolean() | z.ZodBoolean tagged |
.enum([...]) | z.ZodEnum tagged |
.array(inner) | z.ZodArray tagged |
.schema(custom) | wraps any zod schema (e.g. z.number().int().min(1)) |
The returned schema is the same zod type — fireargs writes its metadata
into Zod 4's global registry rather than wrapping the schema, so z.infer
flows through cleanly:
const input = z.object({
port: f.option({ env: "PORT" }).schema(z.number()),
})
type Input = z.infer<typeof input> // { port: number }OptionConfig
f.option(config) accepts:
| Field | 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 | Input key(s) of options that conflict with this one |
implies | { inputKey: value } set when this option is given |
hidden | Hide from --help |
preset | Preset value used when the flag appears without an argument |
helpGroup | Group heading under which this option is listed in help |
defaultDescription | Display string for the default value in help |
parse | Turns the CLI string into the field's value — see below |
ArgumentConfig
Commander's Argument exposes far fewer slots than Option — no short flag,
no env, no conflicts/implies, no hidden, no helpGroup. What remains:
| Field | What it does |
|---|---|
name | Name shown in help and usage, e.g. <file> |
defaultDescription | Display string for the default value in help |
parse | Turns the CLI string into the field's value — see below |
Parsing CLI values
Commander hands every value over as a string. fireargs converts it before
zod sees it, so the schema describes the real value and --json callers
send that value directly:
- Numbers coerce automatically. A field whose schema is
z.number()(or an array of them) turns--rows 10into10, and rejects--rows abcwith a commander error.--json '{"rows":"10"}'is still rejected — the JSON value must be a number. parsehandles any other syntax. It receives the raw string from argv or theenvvariable and returns the field's value, which zod then validates. It never runs on--jsoninput or on defaults.
.input(
z.object({
// --missing-values a,b → ["a", "b"]
missingValues: f
.option({ parse: v => v.split(",") })
.schema(z.array(z.string()).optional()),
// --header-rows false → false, --header-rows 1,2 → [1, 2]
headerRows: f
.option({ parse: v => (v === "false" ? false : v.split(",").map(Number)) })
.schema(z.union([z.literal(false), z.array(z.number())]).optional()),
// --props '{"a":1}' → { a: 1 }
props: f.option({ parse: v => JSON.parse(v) }).schema(z.record(z.string(), z.number())),
}),
)A field with parse always takes exactly one value (--key <value>), even
when its schema is an array or a boolean: parse owns the whole string.
TypeScript rejects a .schema(...) that cannot accept what parse returns,
and an error thrown by parse is reported by commander as an invalid
argument.
Reach for parse rather than .transform() when the comma list or JSON
string is only shell syntax. A transform belongs to the schema, so --json
callers would have to send the same string, and --llms describes the
field by its pre-transform type.
Examples
.input(
z.object({
// positional, required: <name>
name: f.argument().string().describe("user's name"),
// positional, optional: [greeting]; keep required positionals first
greeting: f.argument().schema(z.string().optional()),
// option with short alias and env fallback
port: f.option({ short: "p", env: "PORT" }).schema(z.number().default(8080)),
// boolean with explicit conflicts
rgb: f.option({ conflicts: "cmyk" }).boolean(),
cmyk: z.boolean(),
// hidden internal option
secret: f.option({ hidden: true }).string(),
// variadic with choices
formats: f.option().array(z.enum(["json", "yaml", "toml"])),
}),
)What if I forget the wrapper?
Plain zod fields work — they default to --options with mandatory zod-level
validation. The wrappers only add commander-specific config that zod
can't express. Don't sprinkle them everywhere; reach for them when you
genuinely need a short flag, env binding, or one of the other slots above.
Created with ❤ and Livemark