From 633d63bdd9ed2df332927268e4fd66a8515b301d Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Wed, 4 Nov 2026 12:15:06 +0100 Subject: [PATCH 1/2] chore: keep stdout to a command's output with forbidigo A command already writes its output to cmd.OutOrStdout() and everything else to stderr. Nothing enforced it, so a stray fmt.Println would mix log text into what a pipe reads. Tests stay exempt: their child processes stand in for programs whose stdout is their output. Co-Authored-By: Claude Opus 5.5 --- .golangci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index 4ecd93c9..19cf480e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -96,6 +96,11 @@ linters: linters: - forbidigo text: user agent of the front end + # A test's child process stands in for a program whose stdout is its output. + - path: _test\.go + linters: + - forbidigo + text: stdout is a command's output - path: internal/tfstate/apiproxy/ linters: - forbidigo @@ -142,6 +147,14 @@ linters: - pattern: ^time\.Sleep$ pkg: ^time$ msg: a test waits on synctest's fake clock; run it inside synctest.Test and call synctest.Sleep + # stdout carries only what a command outputs, so that a pipe reads nothing else. Taking it + # from the command lets a test capture it. + - pattern: ^fmt\.Print(f|ln)?$ + pkg: ^fmt$ + msg: &stdout-only stdout is a command's output; write that to cmd.OutOrStdout(), and anything for a person through slog or cmd.ErrOrStderr() + - pattern: ^os\.Stdout$ + pkg: ^os$ + msg: *stdout-only gocritic: disabled-checks: From ffc6d0612ef8a38d542ddbdd77dfc5bf9bad8721 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Wed, 4 Nov 2026 12:15:06 +0100 Subject: [PATCH 2/2] docs: say that only the exit status marks a cut ndjson listing json output stays unterminated when a listing fails, so it does not parse. ndjson has no end to leave out, and a pipe into jq hides the exit status. Tell agents to use pipefail. Co-Authored-By: Claude Opus 5.5 --- cmd/internal/output.go | 3 ++- skills/meshstack-cli/SKILL.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/internal/output.go b/cmd/internal/output.go index 45bb242d..f27e099d 100644 --- a/cmd/internal/output.go +++ b/cmd/internal/output.go @@ -56,7 +56,8 @@ func (f *OutputFlag) Type() string { } // WriteList leaves a listing that fails part way through unterminated, so that json output which -// stopped early does not parse as a complete array. +// stopped early does not parse as a complete array. ndjson has no end to leave out, so there only +// the exit status tells a cut listing from a complete one. func (format OutputFormat) WriteList(w io.Writer, items iter.Seq2[jsontext.Value, error]) error { list, err := format.listing() if err != nil { diff --git a/skills/meshstack-cli/SKILL.md b/skills/meshstack-cli/SKILL.md index ace9c96d..cd5e2702 100644 --- a/skills/meshstack-cli/SKILL.md +++ b/skills/meshstack-cli/SKILL.md @@ -119,6 +119,8 @@ workspace identifier as meshPanel shows it (`metadata.name`, for example Commands for people (`auth status`, `profile list`) write Markdown. The others write one JSON array by default. For anything you consume yourself, use `-o ndjson`: one JSON object per line, so `jq` filters stream. A listing stops at 100 items unless you pass `--limit ` or `--limit unlimited`. +A listing that fails part way leaves the lines it got and exits non-zero, which a pipe hides: run +with `set -o pipefail`. ```sh meshstack bb list --limit unlimited -o ndjson | jq -r '...'