burgee
Concepts

The output policy

One function decides where output is going and how much colour it may carry: roundel/policy's outputMode() and colorLevel(), and roundel/terminal's interactive(). The packages that draw ask them rather than reading the process themselves.

What it is

Two subpaths of roundel answer the questions every package that draws has to ask before it writes a byte:

  • roundel/policy: which mode this run is in (outputMode), and how much colour it may use (colorLevel).
  • roundel/terminal: is anybody there to type (interactive), and can the terminal draw a tick (unicode).

All four are pure functions of a runtime you pass in (env, isTTY, and for colour argv), never of process. The same input always gives the same answer, so a test passes a literal and a harness can ask the question a program asked.

Why it exists

A spinner, a prompt and the help text each have to decide whether the terminal is a terminal. When each package decides its own way, they disagree about the same run: one animates into a CI log while another has already gone plain. roundel/policy's own documentation cites that failure (clack #286) as the reason it exists: every package in the family that draws asks here instead of reading isTTY, NO_COLOR, FORCE_COLOR, CI, CLI_ACCESSIBLE or a --color flag itself.

The exceptions are named, not tolerated: flagstaff/ora's isInteractive, caique/clack's isCI, burgee/commander's colour check and paratext's hyperlink detection each reproduce their incumbent's own detection, because the incumbent's suite grades exactly that. Each is a row, with its reason, in the KNOWN table of inline-implementation-lock.test.ts.

The modes

outputMode(rt, { json }) returns one of five modes. The first row that matches wins:

OrderModeChosen when
1jsonthe caller passed { json: true }: the run was asked for --json
2accessibleCLI_ACCESSIBLE is set, terminal or not
3ttystdout is a terminal
4ciCI is set and stdout is not a terminal
5pipeanything else

An empty variable is not set: CI= and CLI_ACCESSIBLE= are the same as leaving them out, the convention NO_COLOR established. --json is passed in rather than read from argv, because whether a run asked for structured output is the argument parser's knowledge.

There is no agent mode. An agent reading a pipe is pipe, an agent that asked for --json is json, and whether an agent can be asked something is a separate question, interactive(), below.

Pinned by outputMode — R1, first match wins and an empty variable is not set in policy.test.ts.

The mode is not the colour

The mode decides redraws: whether a component animates in place (tty) or prints its static projection once per change. It never decides the colour level, which colorLevel(rt, { json }) answers from the user's instruction first and detection second, in this order:

  1. --json, NO_COLOR, or accessible with nothing asked → 0. Structured output carries no escapes. A non-empty NO_COLOR beats every instruction below, including FORCE_COLOR=3 and --color. CLI_ACCESSIBLE defaults to 0, because colour is noise to a screen reader, but an explicit ask still wins over it.
  2. An exact instruction → that level. FORCE_COLOR=0 (or false) is read before any flag, so FORCE_COLOR=0 --color=256 is off: an explicit "off" is never undone by a level flag. After that the --color flags in argv outrank a numeric FORCE_COLOR: --no-color, --no-colors, --color=false|never are 0; --color=256 is 2; --color=16m|full|truecolor is 3. A numeric FORCE_COLOR is that level, clamped to 3. Only flags before a -- terminator count, and an unrecognised value (--color=lots) is no instruction.
  3. Azure Pipelines (TF_BUILD and AGENT_NAME) → 1, the one runner read above the pipe check.
  4. A pipe nobody asked to colour → 0.
  5. TERM=dumb → the floor: 1 if something asked for colour (FORCE_COLOR=true, --color), else 0.
  6. Detection. With CI present, the CI vendor's level (GITHUB_ACTIONS, GITEA_ACTIONS, CIRCLECI are 3; TRAVIS, APPVEYOR, GITLAB_CI, BUILDKITE, DRONE and Codeship are 1; any other runner 0); otherwise COLORTERM=truecolor is 3, a TERM ending in -256 or -256color is 2, and a known TERM or any COLORTERM is 1. An ask for colour without a level (FORCE_COLOR=true, --color) never goes below 1.

The levels are chalk's: 0 none, 1 sixteen colours, 2 256 colours, 3 truecolor. With no instruction the order is supports-color's, which chalk's own level.js asserts, so the family and chalk agree about a bare pipe. Each rule is a describe block in policy.test.ts: R2 revised 2026-09-08: FORCE_COLOR is the user’s instruction, in any mode, the --color flags, when the caller hands over argv, an explicit “colour off” is never overridden into colour on, accessible mode is a pipe, not a terminal (R2) and the CI vendor table, once the run has asked for colour. Colour levels on roundel's site has the full table.

Is anybody there to type?

interactive(rt) is true when FORCE_TTY=1, and otherwise only with a terminal on stdin, no non-empty CI, and none of the agent variables AI_AGENT, CLAUDECODE, CURSOR_AGENT, CODEX_THREAD_ID, GEMINI_CLI (exported as AGENTS). An agent may have a terminal; what it does not have is a person. Pinned by the interactive block of terminal.test.ts, including names CLAUDECODE, which is the case that hung an agent.

Run it

colorLevel and outputMode read a runtime; this one describes the real process:

policy.mjs
import { colorLevel, outputMode } from 'roundel/policy';
import { interactive } from 'roundel/terminal';

const json = process.argv.includes('--json');
const rt = {
  env: process.env,
  argv: process.argv.slice(2),
  isTTY: { stdout: process.stdout.isTTY === true, stdin: process.stdin.isTTY === true },
};

console.log(`mode=${outputMode(rt, { json })} level=${colorLevel(rt, { json })} interactive=${interactive(rt)}`);

Piped, as every command below runs, with stdin closed:

node policy.mjs
mode=pipe level=0 interactive=false
CI=1 node policy.mjs
mode=ci level=0 interactive=false
CLI_ACCESSIBLE=1 node policy.mjs --color=256
mode=accessible level=2 interactive=false
NO_COLOR=1 FORCE_COLOR=3 node policy.mjs --color=256
mode=pipe level=0 interactive=false
FORCE_COLOR=0 node policy.mjs --color=256
mode=pipe level=0 interactive=false
CI=1 GITHUB_ACTIONS=true FORCE_COLOR=true node policy.mjs
mode=ci level=3 interactive=false
FORCE_COLOR=1 node policy.mjs --json
mode=json level=0 interactive=false
FORCE_TTY=1 node policy.mjs
mode=pipe level=0 interactive=true

How the family consults it

Only packages that compose may import roundel (the family has the rule), so the policy is read by the three packages that draw, and by nothing below them:

PackageAsksFor
flagstaffoutputModeonce per hoist(): tty animates, json writes NDJSON to stderr, every other mode prints the static projection
flagstaffunicodeflagstaff/ora's symbols, as is-unicode-supported decides them
caiqueinteractivecaique/decide: nobody there means refuse and name the flag, never wait
caiqueunicodecaique/inquirer's glyphs
burgeecolorLevelwhether help is coloured (help.ts's colorFor)

burgee's handler context is the one place the family answers "interactive" differently. ctx.interactive comes from burgee's own detectAgent (agent.ts): FORCE_TTY=1, or stdout is a terminal and no agent variable is set. It probes the same five variables as AGENTS, but it reads stdout rather than stdin and does not read CI. Pinned by agent.test.ts.

The leaves take a runtime slice of the same shape and decide their own narrower question from it: paratext whether a terminal escape is supported, for instance. They cannot import roundel, because a leaf depends on nothing in the family.

Where the rules live

On this page