Canonical CliConsole seam — one CLI output object, plain --json, object-not-env determinism
Status: Accepted
Date: 2026-07-14
Deciders: Stijn Dejongh (operator), Claude (Opus 4.8)
Technical Story: Fixes #2632; shipped in mission #2620 / PR #2629. Deferred non-CLI scope tracked as #2634.
Context and Problem Statement
The Claude Code harness (and many developer shells / CI agents) exports FORCE_COLOR=3. Rich honours FORCE_COLOR above NO_COLOR/TERM=dumb and decides colour at render time, and every command module constructed its own module-level console = Console() that snapshots the ambient colour environment at import time. Two failures fell out of this (#2632, ~81 pre-existing red tests across ~15 files):
- (a)
--jsoncorruption.console.print_json(json.dumps(payload))syntax-highlights the JSON, so under forced colour the machine output carries ANSI escapes andjson.loads(result.output)raisesExpecting value: line 1 column 1. This is a real user-facing bug, not just a test artefact: any caller pipingspec-kitty … --json | jqunder a colour-forcing harness gets corrupt output. - (b) substring brittleness. Styled human output splits literal substrings (
"delivered 1"renders asdelivered \x1b[0m\x1b[1;32m1\x1b[0m), soassert "x" in result.outputfalse-REDs even though the behaviour is identical to CI's plain environment.
An existing fix existed but was scoped to exactly one directory (tests/specify_cli/cli/commands/agent/conftest.py), and it worked by enumerating and monkeypatching each module's console — the fragile whack-a-mole this ADR eliminates.
Decision Drivers
--jsonmust be plain by construction, independent of terminal, env, or harness — machine output is data, never a styled display.- Determinism must be a property of the object, not the environment. Mutating
os.environ["NO_COLOR"]in a fixture works but leaks into subprocesses and sibling tests — rejected by the operator. - No shim, no new debt. This lands inside a debt-reduction mission; the shared console is to be moved, not shadowed by a back-compat re-export.
- Single seam. One object every module shares, so colour policy (and future output policy) is changed in one place — not re-derived per module.
- Drop-in compatibility. The seam is passed to
Live(console=…),Progress(console=…), and functions typedconsole: Console, so it must be arich.console.Console, not a proxy.
Considered Options
- Harden the ~81 assertions to strip ANSI / parse structured output. Rejected: 81 spot-fixes, masks the product
--jsonbug, and the next styled-output test re-hits it. - Suite-level conftest that sets
NO_COLORinos.environ. Rejected: env mutation leaks into subprocesses and neighbouring tests, and does not fix the product bug for real--jsonconsumers. - Pin/downgrade
rich. Rejected: chases a moving dependency, doesn't encode the invariant, and doesn't addressFORCE_COLORsemantics (which are correct — the app was wrong to route machine output through a styled console). - A canonical
CliConsoleseam (chosen). One sharedConsolesubclass whose--jsonoutput is plain by construction and whose colour is toggled in place on the object for tests.
For the seam's home we considered src/kernel/cli/, but kernel is the zero-dependency import floor (its README forbids CLI/rich concerns and any spec-kitty-package imports). CLI output is a specify_cli concern — so the seam lives at src/specify_cli/cli/console.py.
Decision Outcome
Chosen option: a single canonical CliConsole seam, because it fixes both the product --json corruption and the test brittleness at one point, with no env mutation and no shim.
src/specify_cli/cli/console.pydefinesclass CliConsole(Console)and the shared singletonsconsole(stdout) anderr_console(stderr).emit_json(payload)/print_json(...)bypass Rich's highlighter and write plain JSON — so every existing--jsoncall site becomes safe simply by routing through the seam.set_plain(bool)toggles colourless rendering in place (no_color=True,_color_system=None). Because it subclassesConsole, it is a drop-in forLive/Progress/console: Console; and because every module imports the same singleton, oneset_plain(True)call in a single autouse fixture neutralises colour across the whole suite — noos.environinvolved.- The whole CLI layer (
cli/+cli/commands, ~77 ad-hocConsole()constructions) is moved onto the seam; the old definitions are deleted, and the 18from …cli.helpers import consoleimporters are repointed to…cli.console(no shim). A structural guard (tests/architectural/test_cli_console_single_seam.py) forbids rawConsole(...)anywhere undersrc/specify_cli/cli/except the seam module.
Consequences
Positive
--jsonoutput is plain andjq-safe regardless ofFORCE_COLOR/terminal — a real product-correctness fix, not just green tests.- Test determinism is achieved by configuring one object; no environment mutation, no per-module console monkeypatching (the fragile
agent/conftest.pyenumeration collapses to oneset_plaincall). - ~77 ad-hoc
Console()constructions eliminated; the arch guard keeps the seam singular by construction (debt reduction that holds).
Negative
- A large mechanical diff folded into an already-large PR.
- The seam covers the CLI layer only. ~14 non-CLI module-level consoles remain (
sync/,readiness/,retrospective/,core/,identity/,charter_runtime/,template/,upgrade/), and one of them —retrospective/cli.py:277_console.print_json— is an active--jsoncorruption vector with the same root cause. These are deferred to #2634, not because they are safe, but because migrating them cleanly requires resolving a layering question: those are lower-layer packages, and importingcli.consolefrom them would invert the dependency direction. The right fix is likely to relocate the seam to a home both layers can import (e.g. aspecify_cli-root console module) — a design decision that deserves its own change rather than a rushed rider here.
Neutral
- The few deliberately-special CLI consoles (
width=200/width=120/highlight=False/dynamiccolor_system) remain distinct instances, but of the seam class (CliConsole(...)), so they still get plain--jsonandset_plain.
Confirmation
tests/architectural/test_cli_console_single_seam.py(AST guard: no rawConsole(...)undercli/outside the seam) + a non-vacuity companion.- Fast unit tests for
CliConsole(emit_json/print_jsonplain underforce_terminal=True;set_plainin-place toggle;err_consoletargets stderr). - The #2632 files pass under
FORCE_COLOR=3, and a regression constructs a colour-forcedCliConsoleand asserts--jsonoutput carries no\x1bandjson.loadssucceeds.