cligentic
← back to catalog
cligentic block

json-mode

Dual-rendering output for CLIs serving humans and agents. Auto-detects TTY vs piped, enforces stdout/stderr discipline, respects NO_COLOR. Never calls process.exit().

Install
json-mode demo
NPM dependencies
  • picocolors
Block dependencies
Size

110 LOC

The problem

CLIs that serve both humans and agents need to render the same data two different ways. Most CLIs do this badly - either they only support one mode (breaks agents), or they mix data and logs on the same stream (breaks JSON parsing), or they use process.exit() inside output helpers (hijacks the caller's control flow).

The json-mode block enforces four rules:

  1. stdout is data - only the answer goes here
  2. stderr is logs - notes, progress, errors, hints
  3. Mode detection is implicit - piped stdout auto-switches to JSON
  4. Never process.exit() - the caller decides exit codes

Quickstart

import { emit, note, reportError, detectMode } from "@/cli/agent/json-mode";
 
program
  .option("--json", "emit JSON for agents")
  .command("list")
  .action(async (opts) => {
    note("Fetching items...", opts);
 
    try {
      const items = await fetchItems();
      emit(items, opts, (data) => {
        for (const item of data) console.log(`- ${item.name}`);
      });
    } catch (err) {
      reportError(err, opts);
      process.exit(1);
    }
  });

Running myapp list in a terminal prints the bulleted list. Running myapp list --json or myapp list | jq . emits NDJSON to stdout. Running in CI auto-detects non-TTY and switches to JSON. Zero extra flags in the author's code.

The four functions

detectMode(opts)

Returns "human" or "json" based on this precedence:

  1. Explicit --json flag → always JSON
  2. NO_JSON=1 env var → force human
  3. process.stdout.isTTY === false (piped, redirected) → JSON
  4. Default → human
const mode = detectMode({ json: false });
// → "human" in a terminal, "json" in a pipe, "json" with --json

emit(value, opts, humanRender?)

The dispatcher. In JSON mode, writes JSON.stringify(value) to stdout. In human mode, calls humanRender(value) if provided, otherwise falls back to pretty-printed JSON.

emit({ count: 3, items: ["a", "b", "c"] }, opts, (data) => {
  console.log(`Got ${data.count} items:`);
  for (const item of data.items) console.log(`${item}`);
});

Arrays emit as NDJSON (one JSON object per line) so agents can stream-parse large result sets without waiting for the closing bracket.

note(message, opts)

Writes a human-guidance message to stderr. In JSON mode, notes are suppressed entirely to keep stderr clean for next-steps output.

note("Fetching from API...", opts);
note("Using profile: production", opts);

Use notes liberally for progress indicators. They become invisible in agent mode, so you don't have to guard them.

reportError(error, opts)

Renders an error without exiting. In JSON mode, emits a structured { ok: false, error, stack? } object to stdout so agents parse it as data. In human mode, writes a colored message to stderr.

try {
  await riskyOperation();
} catch (err) {
  const payload = reportError(err, opts);
  // payload is { ok: false, error, stack? }
  // Decide exit code yourself - this function never exits.
  process.exit(1);
}

Critical: reportError is the only safe way to render errors in a dual-mode CLI. Using console.error(err) mixes the error into stderr where next-step hints also live, breaking agent parsing.

Discipline

The whole block is 170 lines and enforces one thing: stream discipline.

stdout  →  data the agent needs to make decisions
stderr  →  guidance the agent or human can optionally follow

If you only remember one thing: never console.log() anything except the canonical answer. Everything else goes through note(), emitSuccess(), or reportError().

Pairs with next-steps

json-mode is the foundation. next-steps builds on top of it to emit post-command guidance ("here's what to run next") using the same mode detection and the same stderr stream discipline.

Install next-steps and it auto-pulls json-mode via registry dependencies.

bunx shadcn@latest add https://cligentic.railly.dev/r/next-steps.json
Source

The full file, 110 lines.

This is the exact TypeScript that lands in your project when you run the install command. Read it, copy it, edit it, own it. cligentic never touches it again.

src/cli/agent/json-mode.ts
// cligentic block: json-mode
//
// Dual-rendering output helpers for CLIs that serve both humans and agents.
//
// Design rules:
//   1. stdout is data. Only structured JSON in --json mode, formatted in human.
//   2. stderr is logs. Notes, progress, errors.
//   3. Mode detection is implicit. Piped stdout auto-switches to JSON.
//   4. Never call process.exit().
//   5. One call site: emit(value, opts, humanRender?).
//
// Usage:
//   import { detectMode, emit, note, reportError } from "./agent/json-mode";
//
//   program.option("--json", "emit JSON for agents");
//   program.command("list").action(async (opts) => {
//     const items = await fetchItems();
//     emit(items, opts, (data) => {
//       for (const item of data) console.log(`- ${item.name}`);
//     });
//   });

import pc from "picocolors";
import {
  type EmitOptions,
  type OutputMode,
  detectMode,
  shouldColor,
} from "../platform/detect";

// Re-export so consumers can import everything from json-mode
export { type EmitOptions, type OutputMode as Mode, detectMode, shouldColor };

/**
 * Emits a value to stdout. JSON in json mode, humanRender callback in human.
 * Arrays emit as NDJSON (one object per line) for agent stream-parsing.
 */
export function emit<T>(value: T, opts: EmitOptions = {}, humanRender?: (value: T) => void): void {
  const mode = detectMode(opts);

  if (mode === "json") {
    if (Array.isArray(value)) {
      for (const item of value) {
        process.stdout.write(`${JSON.stringify(item)}\n`);
      }
    } else {
      process.stdout.write(`${JSON.stringify(value)}\n`);
    }
    return;
  }

  if (humanRender) {
    humanRender(value);
    return;
  }

  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}

/**
 * Writes a note to stderr. Suppressed in json mode and quiet mode.
 */
export function note(message: string, opts: EmitOptions = {}): void {
  if (opts.json === true) return;
  if (!process.stdout.isTTY && opts.json !== false) return;
  if (opts.quiet) return;

  const colored = shouldColor() ? pc.dim(message) : message;
  process.stderr.write(`${colored}\n`);
}

/**
 * Writes a success message. JSON to stdout in json mode, colored to stderr in human.
 */
export function emitSuccess(message: string, opts: EmitOptions = {}): void {
  const mode = detectMode(opts);
  if (mode === "json") {
    process.stdout.write(`${JSON.stringify({ ok: true, message })}\n`);
    return;
  }
  const prefix = shouldColor() ? pc.green("✓") : "✓";
  process.stderr.write(`${prefix} ${message}\n`);
}

/**
 * Reports an error without exiting. JSON to stdout in json mode, colored
 * to stderr in human. Returns the structured payload for caller inspection.
 */
export function reportError(
  error: string | Error,
  opts: EmitOptions = {},
): { ok: false; error: string; stack?: string } {
  const message = error instanceof Error ? error.message : error;
  const stack = error instanceof Error ? error.stack : undefined;
  const payload = { ok: false as const, error: message, ...(stack ? { stack } : {}) };

  const mode = detectMode(opts);
  if (mode === "json") {
    process.stdout.write(`${JSON.stringify(payload)}\n`);
    return payload;
  }

  const prefix = shouldColor() ? pc.red("✗") : "✗";
  process.stderr.write(`${prefix} ${message}\n`);
  if (stack && process.env.DEBUG) {
    process.stderr.write(`${shouldColor() ? pc.dim(stack) : stack}\n`);
  }
  return payload;
}