Problem
docs/json-contracts.md promises that JSON mode "captures command stdout and emits exactly
one success or error envelope on stdout". That guarantee only holds for writes that go through
sys.stdout. run_app() installs the capture with contextlib.redirect_stdout()
(lib/python/base_cli/_run.py:236-250), which rebinds the sys.stdout object and does
nothing to file descriptor 1. Anything that writes to fd 1 directly bypasses the capture and
lands on the real stdout, interleaved with the envelope:
subprocess.run(...) / Popen with inherited stdout — the normal case for infra tooling that
shells out to kubectl, terraform, aws, git, ssh;
- C extensions and native libraries writing to
stdout;
os.write(1, ...) and sys.__stdout__.
The result is stdout that is not parseable JSON, which is precisely what the strict-JSON
consumer guide (docs/strict-json-consumer.md) tells adopters they can rely on. The limitation
is not documented anywhere.
Verified evidence
Reviewed 2026-09-30 at a58ec109349fa3f3d03eae5b0de078b39ea361a2 (macOS, Python 3.14.6, Click 8.4.2).
A command that prints one structured line and then runs a subprocess, invoked with --json in a
real child process so fd-1 behaviour is faithful:
@app.command()
def main(ctx):
print('{"records": [{"host": "web-1", "status": "ok"}]}')
subprocess.run([sys.executable, "-c", "print('SUBPROCESS-DIRECT-TO-FD1')"], check=True)
return 0
Raw stdout of the CLI process:
SUBPROCESS-DIRECT-TO-FD1
{"schema_version":1,"schema":"base-cli.output","code":"ok",...,"details":{"exit_code":0,"stdout":"{\"records\": ...
json.loads(stdout) fails: Expecting value: line 1 column 1 (char 0). The subprocess line
appears before the envelope, so even a "parse the last line" workaround is fragile once a
subprocess emits multiple lines or no trailing newline.
Proposal
Pick one and document it as normative:
- Redirect the descriptor. In JSON mode,
dup2() a pipe or the spool file over fd 1 for the
duration of the invocation and restore it before writing the envelope. This captures
subprocesses and native writers, at the cost of real descriptor plumbing and a Windows path.
- Declare and detect the boundary. Keep
redirect_stdout, document that fd-1 writers are
outside the contract, and give commands a supported way to route child output (for example a
ctx.stdout the lifecycle owns, plus guidance to pass stdout= explicitly to subprocess).
Optionally detect a dirtied fd 1 and downgrade to a documented error envelope rather than
emitting invalid stdout.
Option 1 keeps the published guarantee true; option 2 changes the guarantee. Either is
defensible, but the current state promises option 1 and implements option 2 without saying so.
Acceptance criteria
docs/json-contracts.md and docs/strict-json-consumer.md state exactly what JSON mode does
and does not capture, with the subprocess case named explicitly.
- A regression test runs a real subprocess inside a
--json invocation and asserts the whole of
stdout parses as a single JSON document (option 1) or that the documented failure mode occurs
deterministically (option 2).
- Consumer guidance shows the supported way to run a child process under JSON mode.
Non-goals
- Do not silently truncate or discard child-process output.
- Do not make NDJSON mode capture stdout; it intentionally streams.
Problem
docs/json-contracts.mdpromises that JSON mode "captures command stdout and emits exactlyone success or error envelope on stdout". That guarantee only holds for writes that go through
sys.stdout.run_app()installs the capture withcontextlib.redirect_stdout()(
lib/python/base_cli/_run.py:236-250), which rebinds thesys.stdoutobject and doesnothing to file descriptor 1. Anything that writes to fd 1 directly bypasses the capture and
lands on the real stdout, interleaved with the envelope:
subprocess.run(...)/Popenwith inherited stdout — the normal case for infra tooling thatshells out to
kubectl,terraform,aws,git,ssh;stdout;os.write(1, ...)andsys.__stdout__.The result is stdout that is not parseable JSON, which is precisely what the strict-JSON
consumer guide (
docs/strict-json-consumer.md) tells adopters they can rely on. The limitationis not documented anywhere.
Verified evidence
Reviewed 2026-09-30 at
a58ec109349fa3f3d03eae5b0de078b39ea361a2(macOS, Python 3.14.6, Click 8.4.2).A command that prints one structured line and then runs a subprocess, invoked with
--jsonin areal child process so fd-1 behaviour is faithful:
Raw stdout of the CLI process:
json.loads(stdout)fails:Expecting value: line 1 column 1 (char 0). The subprocess lineappears before the envelope, so even a "parse the last line" workaround is fragile once a
subprocess emits multiple lines or no trailing newline.
Proposal
Pick one and document it as normative:
dup2()a pipe or the spool file over fd 1 for theduration of the invocation and restore it before writing the envelope. This captures
subprocesses and native writers, at the cost of real descriptor plumbing and a Windows path.
redirect_stdout, document that fd-1 writers areoutside the contract, and give commands a supported way to route child output (for example a
ctx.stdoutthe lifecycle owns, plus guidance to passstdout=explicitly tosubprocess).Optionally detect a dirtied fd 1 and downgrade to a documented error envelope rather than
emitting invalid stdout.
Option 1 keeps the published guarantee true; option 2 changes the guarantee. Either is
defensible, but the current state promises option 1 and implements option 2 without saying so.
Acceptance criteria
docs/json-contracts.mdanddocs/strict-json-consumer.mdstate exactly what JSON mode doesand does not capture, with the subprocess case named explicitly.
--jsoninvocation and asserts the whole ofstdout parses as a single JSON document (option 1) or that the documented failure mode occurs
deterministically (option 2).
Non-goals