Fireargs is in technical previewShare feedback

Search

Search documentation

JSON mode

Updated Oct 21, 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 pydantic-validated handler.

Enabled by default. Disable per command with json_option=False, or customize the flag and its help with json_option={"flags": ..., "description": ...} — any flag name works, e.g. {"flags": "-j, --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 out

The 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>'

  1. Checks the value is JSON.
  2. Validates it against the input model in pydantic's strict mode (model_validate_json(value, strict=True)).
  3. Calls the handler with the validated input and context.json is True.
  4. Validates the handler's return against the output type.
  5. Dumps it in pydantic's JSON mode, by alias, leaving out None optionals, and 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 3–5 are identical.

JSON input bypasses click, so env vars, choices checks, a field's parse and number parsing do not apply — the model's defaults and validation still do. Send each value in its real type: ["a","b"], not "a,b"; 3, not "3" — strict mode rejects a string for an int, like zod does. Strings still carry the types JSON has no literal for: an ISO 8601 datetime, a UUID, an Enum value. Keys the model does not declare are ignored, as pydantic does by default.

Keeping stdout clean

The handler's optional second parameter 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:

import click
from pydantic import BaseModel
from fireargs import Context, command


class SyncInput(BaseModel):
    source: str


@command()
def sync(input: SyncInput, context: Context) -> list[str]:
    if not context.json:
        click.echo(f"Syncing {input.source}…", err=True)
    return [input.source]

Errors

Without a hook, a value that is not JSON and input that fails the model are reported like a commander parse error, written to stderr with exit code 1:

error: --json value is not valid JSON: Expecting value: line 1 column 1 (char 0)
error: invalid --json input at 'items[0][1]': Input should be a valid integer

The path reads like TypeScript's (a.b[0]); the message after the colon is pydantic's. Output validation failures and handler exceptions propagate out of the click command: in standalone mode click prints the traceback and exits 1, and with standalone_mode=False or CliRunner the exception is raised or captured.

Set render_error in the command's or program's config to turn all of them into output instead — the hook receives the original json.JSONDecodeError or pydantic ValidationError for input failures, and the exception itself for output, handler and render failures:

from fireargs import Context, Program


def render_error(error: Exception, context: Context) -> object:
    return {"error": str(error)} if context.json else f"error: {error}"


cli = Program(name="myapp", render_error=render_error, router={"greet": greet})
  • In JSON mode a returned value is written as JSON to stdout; otherwise a returned string is written to stderr. Return None to write it yourself; raise to propagate as before.
  • The run then exits with code 1, through click's own exit.
  • It also catches render failures. The nearest hook wins: the leaf's own, then each enclosing program's. A hook may be async def.

Click's parse errors (unknown option, bad choice, extra argument) happen before the run starts: click prints its usage line and Error: ... to stderr and exits with code 2. 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, with exit code 1 — in TypeScript commander rejects them while parsing. None of these reach render_error.

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