roundel
Guides

The output policy

outputMode() answers json, accessible, tty, ci or pipe from a runtime, first match wins — the one rule every package in the family asks before it redraws.

roundel/policy answers two questions about a process, from a description of it you pass in: which mode it is in, and how much colour it may use. This page is the first; the second is Colour levels.

outputMode(rt, { json })

modechosen whenwhat a well-behaved component does
jsonthe program passed { json: true }writes structured events, never escapes
accessibleCLI_ACCESSIBLE is set, terminal or notwrites the text form once per state, never redraws
ttystdout is a terminalanimates and repaints in place
ciCI is set and stdout is not a terminalwrites the text form once per state
pipeanything elsewrites the text form once per state

The first row that matches wins, so a CI job that attaches a terminal is tty, and --json beats everything. An empty variable is not set — CI= and CLI_ACCESSIBLE= are the same as leaving them out, the convention NO_COLOR established.

modes.mjs
import { outputMode } from 'roundel/policy';

const terminal = { isTTY: { stdout: true } };
const pipe = { isTTY: { stdout: false } };

console.log(outputMode({ ...terminal, env: {} }));
console.log(outputMode({ ...pipe, env: {} }));
console.log(outputMode({ ...pipe, env: { CI: 'true' } }));
console.log(outputMode({ ...terminal, env: { CI: 'true' } }));
console.log(outputMode({ ...terminal, env: { CLI_ACCESSIBLE: '1' } }));
console.log(outputMode({ ...terminal, env: { CLI_ACCESSIBLE: '1' } }, { json: true }));
console.log(outputMode({ ...pipe, env: { CI: '' } }));
node modes.mjs
tty
pipe
ci
tty
accessible
json
pipe

Why one rule

A spinner, a prompt and the help text each have to decide whether the terminal is a terminal. When each asks its own way, they disagree: one package animates into a CI log while another has already gone plain. flagstaff, caique and burgee all ask outputMode, so a program built from them makes the decision once.

--json is passed in rather than read from process.argv, because whether a run asked for structured output is the argument parser's knowledge, not the environment's.

A pure function

The policy reads only the runtime it is handed — env, isTTY.stdout and, for colour, argv — and never process. The same input always gives the same answer, so a test passes a literal and a harness driving your CLI can ask the question your program asked.

Is anybody there to type?

roundel/terminal answers the question a prompt has to ask before it waits: interactive(rt) is true only with a terminal on stdin, no CI, and no agent variable (AI_AGENT, CLAUDECODE, CURSOR_AGENT, CODEX_THREAD_ID, GEMINI_CLI). An agent may well have a terminal; what it does not have is a person. FORCE_TTY=1 is the one override.

interactive.mjs
import { interactive } from 'roundel/terminal';

const tty = { stdin: true };

console.log(interactive({ env: {}, isTTY: tty }));
console.log(interactive({ env: { CLAUDECODE: '1' }, isTTY: tty }));
console.log(interactive({ env: { CI: 'true' }, isTTY: tty }));
console.log(interactive({ env: {}, isTTY: { stdin: false } }));
console.log(interactive({ env: { CLAUDECODE: '1', FORCE_TTY: '1' }, isTTY: tty }));
node interactive.mjs
true
false
false
false
true

caique's prompts ask exactly this before they draw, so a prompt run by an agent is refused, naming the flag to pass, instead of waiting. The same subpath has unicode(rt), whether the terminal can be expected to draw non-ASCII glyphs, which flagstaff and caique read before they draw a tick.

The mode is not the colour

The mode decides redraws. It never decides the colour level: a pipe may be coloured when the user says so with FORCE_COLOR, and a terminal may be plain under NO_COLOR. The one place they touch is json, where the level is always 0. Colour levels has the rule.

On this page