Search
Search documentation
JSON mode
Updated Oct 23, 2026
--json is a built-in flag on every fireargs command. It lets the same
program serve human users at the shell, scripts in a pipeline, and LLM
agents — all backed by one schema-validated handler.
Enabled by default. Disable per command with
json_option: FlagConfig::Disabled, or rename the flag and its help with
FlagConfig::Custom { long, description } — any long name works, e.g.
long: "as-json".into() for --as-json.
Three modes
greet world # CLI in, rendered output (if any)
greet world --json # CLI in, JSON out
greet --json '{"name":"world"}' # JSON in, JSON outThe flag takes an optional value (--json [<value>]):
- Absent — normal CLI parsing, the handler runs, and the validated output goes to the command's render if it has one; otherwise nothing is printed.
- Present without a value — CLI parsing for input, but the handler's return is serialized as JSON to stdout.
- Present with a JSON value — input comes from the JSON value; CLI positionals and options are skipped, so required positionals may be left off; output is JSON.
What happens on --json '<value>'
- Parses the value as JSON.
- Validates it against the input struct's JSON Schema, collecting every failing path.
- Deserializes it into the input struct with serde.
- Calls the handler with the input and
context.json == true. - Serializes the handler's return and validates it against the output type's JSON Schema.
- Writes it to stdout as JSON indented by two spaces, with a newline.
When --json is passed alone (no value), step 1 is skipped and input is
collected from the regular CLI parse instead. Steps 2–6 are identical.
JSON input bypasses clap, so env vars, a field's parse, number parsing,
conflicts and implies do not apply — serde's defaults and the schema's
validation, choices included, still do. Send each value in its real type:
["a","b"], not "a,b"; 3, not "3" — the schema rejects a string for
an integer, like zod does. Strings still carry the types JSON has no literal
for: a chrono date, a Uuid, a unit enum variant. Keys the struct does not
declare are ignored, as serde does by default; #[serde(deny_unknown_fields)]
rejects them.
Keeping stdout clean
The handler's Context tells it which mode it runs in. In JSON mode,
anything else written to stdout corrupts the result, so spinners, colors
and progress output should check context.json:
use fireargs::{self as f, Command, CommandConfig, Error};
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Deserialize, JsonSchema)]
struct SyncInput {
source: String,
}
fn create_sync() -> Command {
f::command(CommandConfig::default())
.input::<SyncInput>()
.output::<Vec<String>>()
.handler(|input, context| async move {
if !context.json {
eprintln!("Syncing {}…", input.source);
}
Ok::<_, Error>(vec![input.source])
})
}Errors
Without a hook, a value that is not JSON and input that fails the schema are reported like a commander parse error, written to stderr with exit code 1:
error: --json value is not valid JSON: expected ident at line 1 column 2
error: invalid --json input at 'items[0][1]': "x" is not of type "integer"The path reads like TypeScript's (a.b[0]); the message after the colon is
the JSON Schema validator's. An error the handler returns 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.
Set render_error in the command's or program's config to turn all of them
into output instead. The hook receives a &fireargs::Error: input failures
downcast to fireargs::InputError (InvalidJson or Invalid with the
issues), output failures to fireargs::OutputError, and a handler's error
is the one it returned:
use std::sync::Arc;
use fireargs::{self as f, Context, Error, InputError, Program, ProgramConfig, RenderError};
use serde_json::json;
fn create_cli(greet: f::Command) -> Program {
let render_error: RenderError = Arc::new(|error: &Error, context: &Context| {
let input = error.downcast_ref::<InputError>().is_some();
Some(match context.json {
true => json!({"error": error.to_string(), "input": input}),
false => json!(format!("error: {error}")),
})
});
f::program(ProgramConfig {
name: Some("myapp".into()),
render_error: Some(render_error),
..Default::default()
})
.router([("greet", greet.into())])
}- In JSON mode a returned value is written as JSON to stdout; otherwise a
returned JSON string is written to stderr. Return
Noneto write it yourself. - The run then exits with code 1; a hook cannot hand the error back.
- The nearest hook wins: the leaf's own, then each enclosing program's.
Clap's parse errors (unknown option, extra argument) happen before the run
starts: clap prints error: ... and its usage line to stderr and exits
with code 1. A missing required positional, a conflicts violation and a
CLI value that number parsing or a field's parse rejects are reported in
commander's wording, also with exit code 1. None of these reach
render_error. A value that parses but fails the schema — a bound, a
choice, a missing required option — does, like --json input.
Composes with subcommands
Every leaf in a program tree gets its own --json:
myapp greet --json '{"name":"world"}'
myapp api deploy --json '{"env":"prod"}'The program itself doesn't have a --json — programs dispatch to
subcommands; only leaves take input. See Programs.
Pairs with --llms
Together with LLMs mode, --json is the call-time
half of fireargs's agentic interface: an LLM reads the --llms manifest to
learn the schemas, then calls back through --json with a value matching
the documented inputSchema.
Created with ❤ and Livemark