Skip to content

Move banners, spinners, and run progress to stderr (output contract, Phase 0) - #1557

Open
hlenke wants to merge 4 commits into
mainfrom
hlenke/machine-readable-output-audit
Open

hlenke wants to merge 4 commits into
mainfrom
hlenke/machine-readable-output-audit

Conversation

@hlenke

@hlenke hlenke commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

What this does, in plain words

A terminal has two output pipes: stdout (the "data" pipe) and stderr (the "chatter" pipe). A person watching a terminal sees both, interleaved, so moving text between them changes nothing for humans. But scripts, CI jobs, and AI agents usually read only stdout and try to parse it — and today the CLI prints banners, spinner text, and live run progress into stdout, right next to the JSON we tell them to parse.

This PR moves all of that chatter to stderr, so stdout now carries only the command's result. The one-line version: checkly whoami --output json | jq . used to fail; now it always parses.

What changed

  • Banners and warnings → stderr. The engine-compatibility warning, the stale-skill banner, and every other longWarning/shortWarning (deploy, import, pw-test) now write to stderr. They were the worst offenders: they printed to stdout ahead of the JSON document on nearly every command.
  • Spinners → stderr. CommandStyle explicitly asked oclif to draw spinners on stdout ({ stdout: true }); oclif's default is stderr, so the fix is dropping that option. The blank separator line after a finished spinner follows it to stderr. sync-playwright's direct spinner call got the same one-line fix.
  • Reporter progress → stderr. For test/trigger/pw-test: the "Running N checks…" announcement, the live summary redraws, and streamed check logs now go to stderr (the redraw/erase codes now key off stderr.isTTY, the stream the summary actually renders on). Results stay on stdout: check results and failure details, the final tally, session IDs and links, report-file paths.
  • The contract is now written down — in docs/output-contract.md (streams, the JSON envelopes, and an exit-code table, including the known exit-2 overload documented as such), an "Output Streams" section in AGENTS.md, and the same section in the published skill, so agents know stdout is safely pipeable.
  • eslint now ignores .context/ (agent scratch space), same as the existing .claude/ ignore — without it pnpm lint fails in any workspace carrying scratch files.

Why it's safe for humans

Both streams render in the same terminal, interleaved in real time, so the interactive experience is what it always was: the same banners, the same spinner animation, the same live summary at the bottom of checkly test. The only people who notice a difference are the ones piping — and for them stdout gets cleaner: checkly test > log.txt now captures a result document instead of mixing in progress noise.

Evidence

Before/after, captured by packing the CLI at main and with this change, running both against the same demo project (stale skill installed, one passing + one deliberately failing check). Full screenshot set for every scenario (banners, spinners, reporter progress — TTY and piped) is in the linked workspace; the decisive captures:

checkly whoami --output json | jq .

BEFORE  jq: parse error: Invalid numeric literal at line 1, column 2   (exit 5)
        # stdout began with: "⚠ Checkly skill is out of date …"
AFTER   { "user": { "id": "…", "name": "…" }, … }                      (exit 0, clean parse)
        # the banner now goes to stderr

checkly validate 1>stdout 2>stderr

BEFORE  stdout: "Parsing your project... ✅\n\nValidating project resources... ✅\n\n✔ Your project is valid."
AFTER   stdout: "✔ Your project is valid."
        stderr: the spinner lines

checkly test 1>stdout 2>stderr (one passing, one failing check)

BEFORE  stdout (6 KB): spinner lines + "Running 2 checks…" + live summary redraw blocks + failure details + final tally, all mixed; stderr empty.
AFTER   stdout (5.2 KB): session reference, the failing check's details, "1 failed, 1 passed, 2 total", the session link — nothing else.
        stderr (0.8 KB): spinner lines, "Running 2 checks in eu-central-1.", the live summary progression.

Both runs exit 1 (the failing check), as before.

Tests

  • New stream-split coverage: command-style.spec.ts (spinner + warnings on stderr, JSON errors stay on stdout), abstract-list.spec.ts (progress vs results split, clear-codes keyed to stderr TTY, streamed logs on stderr), dot.spec.ts (announcement on stderr, dots on stdout).
  • Updated: login.spec.ts spinner expectations (no { stdout: true }; the spinner's blank separator no longer appears on stdout).
  • Updated after the first CI run: the six expect(stderr).toBe('') assertions in the deploy e2e specs. Those fixtures' checks have no locations, so construct-diagnostic warnings ("Check has no location", deprecated constructs) now land on stderr — exactly where the contract puts warnings. The assertions become a guard for the new contract: stderr may carry warnings, but never an error report (no ✖/×-led line after stripping ANSI).
  • Full local suite: 4329 passed (on the merged tree); lint and type-tests clean.

Notes / follow-ups

  • Draft PRs don't run pull_request workflows on GitHub, so CI was started by briefly marking the PR ready while pushing (it is a draft again; the runs and their results stay on the PR).
  • This is Phase 0 of the machine-readable-output audit: exit-0 no-checks bugs, the deploy -o boolean rename, env ls JSON, and the versioned envelope are deliberately not in this PR.

stdout now carries only command results: the engine and skill-freshness
banners, all spinners, warnings, and the reporters' live run progress
("Running N checks…", live summary redraws, streamed logs) move to
stderr. This makes today's JSON surfaces trustworthy — a piped
`checkly --output json | jq` now always parses, because nothing prints
to stdout around the JSON document. Interactive terminal output is
unchanged: both streams render in a terminal, so humans see the same
banners, spinner animations, and live summaries as before.

Details:

- CommandStyle: ux.action spinners render on oclif's default stderr
  stream (drop `{ stdout: true }`); the blank separator after a finished
  spinner follows it to stderr; longWarning/shortWarning go to stderr
  (covers the engine + skill-freshness banners in BaseCommand and the
  deploy/import/pw-test warnings).
- Reporters: printLnErr for "Running N checks" announcements, live
  summary redraws (clear codes now target the stream the summary
  renders on, keyed off stderr.isTTY), and streamed check logs. Check
  results, retry/failure details, final summaries, and session links
  stay on stdout.
- sync-playwright: same spinner stream fix.
- Document the output contract (streams, envelopes, exit-code table)
  in docs/output-contract.md, AGENTS.md, and the published skill.
- Tests: stream-split coverage in command-style, abstract-list, and
  dot specs; login spinner expectations updated.
@hlenke
hlenke marked this pull request as ready for review October 16, 2026 21:10
@hlenke hlenke closed this Oct 16, 2026
@hlenke hlenke reopened this Oct 16, 2026
Resolve the one conflict in AGENTS.md by keeping both additions: this
branch's "Output Streams" section and main's new "Construct
Conventions" section.
@hlenke
hlenke marked this pull request as draft October 16, 2026 21:16
@hlenke
hlenke marked this pull request as ready for review October 16, 2026 21:25
Construct-diagnostic warnings ("Check has no location", deprecated
constructs) now print to stderr, so a successful deploy no longer has
an empty stderr. Replace the six `expect(stderr).toBe('')` assertions
with a guard that fits the output contract: stderr may carry warnings,
but never an error report (no ✖/×-led line, stripped of ANSI codes).
@hlenke
hlenke marked this pull request as draft October 16, 2026 21:25
A final summary printed to stdout (onEnd, onCancel, onDetach) still set
_clearString whenever stderr was a TTY. With stdout piped and stderr on a
terminal, a Ctrl-C run then wrote those erase codes to the TTY against
lines that were never drawn there, wiping unrelated terminal content.

Erase codes now pair exclusively with live summaries on stderr, the
cancel redraw joins the live loop on stderr (the cancelled result summary
still arrives on stdout via onEnd), and the brief summary no longer
registers codes nothing ever clears. The command-style spec's ux.action
spies get mock implementations so no real spinner writes during tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hlenke
hlenke marked this pull request as ready for review October 17, 2026 00:46
@hlenke
hlenke requested a review from sorccu October 17, 2026 00:46

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant