Skip to content

JSON Output

Rune aims to make it easy to build CLIs that treat both humans and agents as first-class users, balancing DX (Developer Experience) with AX (Agent Experience). As part of this foundation for AX, Rune provides a built-in mechanism for outputting command results in machine-readable JSON format.

Set json: true in defineCommand() to enable JSON mode for a command. In JSON mode, the return value of the run() function becomes the command’s structured output:

import { defineCommand } from "@rune-cli/rune";
export default defineCommand({
description: "List all projects",
json: true,
run() {
const projects = [
{ id: 1, name: "alpha" },
{ id: 2, name: "beta" },
];
return { projects };
},
});

Without json, run() is typed as returning void. When json: true is set, run() can return a value, and that return type is preserved by helpers such as runCommand().output.document. The return value must be serializable by JSON.stringify(). If a non-serializable value such as BigInt is returned, Rune treats it as an error.

Commands with json: true also receive options.json in run(). The value reflects the effective JSON mode for the current invocation: it is true when the user passed --json or when Rune auto-enabled JSON mode under an AI agent, and false otherwise. To check whether the user explicitly passed the flag, inspect rawArgs.

When a user runs the command with the --json flag, the return value of run() is printed to stdout as a single-line JSON document (no indentation):

Terminal window
$ your-cli projects list --json
{"projects":[{"id":1,"name":"alpha"},{"id":2,"name":"beta"}]}

When the --json flag is passed, output.log() calls are automatically suppressed. output.error() continues to write to stderr. On success, stdout contains exactly one JSON document, so it can be consumed directly by tools like jq or other programs. On failure, stdout stays empty unless the command wrote to it directly, and Rune writes the JSON error payload to stderr.

Without the --json flag, output.log() works as normal and the return value of run() is not printed. This allows a single command to serve both human-readable and agent-friendly output:

import { defineCommand } from "@rune-cli/rune";
export default defineCommand({
description: "List all projects",
json: true,
run({ output }) {
const projects = [
{ id: 1, name: "alpha" },
{ id: 2, name: "beta" },
];
// Only displayed without --json
for (const p of projects) {
output.log(`${p.id}: ${p.name}`);
}
// Output as JSON with --json
return { projects };
},
});

Only output written through the framework’s output API is suppressed in JSON mode. Output written directly via console.log() or process.stdout.write() is not suppressed and will corrupt the JSON payload. Always use output.log() and output.error() for command output.

For commands declared with json: true, Rune automatically enables JSON mode when it detects that the CLI is being invoked by an AI agent (e.g. Claude Code, Cursor, Codex), even without an explicit --json flag. This lets a single command serve humans with rich text output and agents with structured JSON, without requiring agents to discover and pass --json themselves.

Set RUNE_DISABLE_AUTO_JSON=1 (or true) to suppress this auto-activation. With it set, JSON mode is enabled only when --json is explicitly passed, just as if the CLI were run by a human.

Terminal window
RUNE_DISABLE_AUTO_JSON=1 your-cli projects list

This is primarily intended for AI agents that are themselves developing a Rune-based CLI: without this escape hatch, every invocation under the agent returns JSON, hiding the human-facing output.log() rendering that the agent is trying to verify. Setting the variable only affects Rune’s JSON mode auto-activation; it does not change other agent-aware behavior elsewhere in the toolchain.

The variable has no effect inside the runCommand() test harness, which already disables agent detection by default for deterministic tests.

Rune’s output helpers are not just a style preference:

  • output.log() is the normal way to write human-readable stdout from a command.
  • output.error() writes to stderr and is not suppressed by --json.
  • runCommand() can capture output written through these helpers in tests.
  • For commands with json: true, Rune suppresses output.log() when --json is passed so stdout contains only the JSON payload.

If you write directly with console.log() or process.stdout.write(), Rune cannot suppress that output in JSON mode.

If run() does not return an explicit value (i.e. returns undefined), the JSON output will be null.

When a command fails in JSON mode, error information is output to stderr as a JSON object. This applies not only to failures within run(), but also to argument parsing errors such as missing required arguments:

Terminal window
$ your-cli projects list --json
{"error":{"kind":"config/not-found","message":"Config file was not found","hint":"Create rune.config.ts"}}

The error payload includes the following fields:

  • kind: the error category
  • message: the error message
  • hint: a hint for resolution (when specified via CommandError)
  • details: additional structured data (only when serializable)

Use jsonl: true for commands whose stdout contract is a stream of JSON records, also known as JSON Lines or NDJSON. Unlike json: true, this is not activated by a flag: the command always emits JSON Lines.

import { defineCommand } from "@rune-cli/rune";
export default defineCommand({
description: "Stream events",
jsonl: true,
async *run() {
yield { id: "a", status: "ready" };
yield { id: "b", status: "done" };
},
});

Each yielded record is serialized as one compact JSON line:

Terminal window
$ your-cli events
{"id":"a","status":"ready"}
{"id":"b","status":"done"}

In JSON Lines mode, output.log() is always suppressed and output.error() still writes to stderr. jsonl: true cannot be combined with json: true, and Rune does not add a --jsonl flag. If the downstream pipe closes early, Rune treats the broken pipe as a normal early stop instead of printing an error. If a CLI needs both a human-readable view and a JSON Lines stream, prefer separate commands so each command has one predictable stdout contract.

If a JSON Lines command fails after emitting records, already-written stdout records remain valid. Rune reports the final error as a compact JSON error object on stderr; stderr may also contain human-readable output.error() diagnostics, so only stdout is guaranteed to be JSON Lines.

For how to test commands with JSON mode, see the Testing guide.