From ca3131b36ccf025f64b9e43ecae2698d37747de7 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:29:48 -0700 Subject: [PATCH] perf(cli): write compact JSON when output is not a terminal (#1413) `--json` / `--format json` always indented its streamed result. Agents and scripts read REA through pipes and redirects, where indentation carries no information: on real application results it added 16-45% model tokens (o200k proxy) and 25-73% bytes. The skill's CLI fallback uses `--json`. Indent only when stdout is a TTY, as `gh --json` does; piped or redirected JSON is compact. The JSON value, `jsonl`, `--filter-output`, `--full-output` and the token-window paths are unchanged. `jq .` restores indentation for files. Co-authored-by: Claude Opus 5.5 --- docs/cli.md | 5 +++- scripts/verify/javascript/output.mjs | 6 ++-- src/cli/jsonOutput.ts | 18 ++++++++++-- .../tools/historicalCaptureResources.test.ts | 28 +++++++++++-------- tests/boundary/cli/jsonOutput.test.ts | 15 ++++++++++ 5 files changed, 53 insertions(+), 19 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index ed5f810e9..2a9863c1e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -179,7 +179,10 @@ user permissions; consult the chosen guide for host requirements and effects. ## Output and exit status The default terminal format is TOON. Use `--json` when saving results for a -JSON consumer. Output selection and formatting do not change operation status. +JSON consumer. `--json` is indented on a terminal and compact when piped or +redirected, which keeps large results smaller for agents and scripts; pipe +through `jq .` for indented files. Output selection and formatting do not change +operation status. | Status | Meaning | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/scripts/verify/javascript/output.mjs b/scripts/verify/javascript/output.mjs index 13dfa916c..dbdfdc340 100644 --- a/scripts/verify/javascript/output.mjs +++ b/scripts/verify/javascript/output.mjs @@ -54,15 +54,15 @@ try { data: { ...evidence, normalized_result: { payload: [marker] } }, meta: metadata, }; - const skeleton = JSON.stringify(template, null, format === "json" ? 2 : 0); + // The destination is a redirected file, so both formats are compact. + const skeleton = JSON.stringify(template); const encodedMarker = JSON.stringify(marker); const markerIndex = skeleton.indexOf(encodedMarker); assert.ok(markerIndex >= 0); assert.equal(markerIndex, skeleton.lastIndexOf(encodedMarker)); const prefix = skeleton.slice(0, markerIndex); const suffix = `${skeleton.slice(markerIndex + encodedMarker.length)}\n`; - const indentation = prefix.slice(prefix.lastIndexOf("\n") + 1); - const separator = format === "json" ? `,\n${indentation}` : ","; + const separator = ","; const expectedHash = createHash("sha256"); expectedHash.update(prefix); for (let index = 0; index < count; index += 1) { diff --git a/src/cli/jsonOutput.ts b/src/cli/jsonOutput.ts index b42bd82bd..f7422a7bf 100644 --- a/src/cli/jsonOutput.ts +++ b/src/cli/jsonOutput.ts @@ -4,16 +4,27 @@ import { bufferedJsonParts, jsonParts } from "../domain/jsonSerialization.js"; type JsonOutputFormat = "json" | "jsonl"; -/** Write ordinary JSON without constructing a document-sized string. */ +/** + * Write ordinary JSON without constructing a document-sized string. + * + * `json` is indented for a terminal reader. Piped or redirected output, which + * agents and scripts consume, is compact: indentation adds no information and + * measured 16-45% more model tokens on real results. The value is identical. + */ export const writeJsonOutput = async ( value: unknown, destination: Writable, format: JsonOutputFormat = "json", ): Promise => { - for (const part of bufferedJsonParts(jsonOutputParts(value, format))) + for (const part of bufferedJsonParts( + jsonOutputParts(value, format, isTerminal(destination)), + )) await writePart(destination, part); }; +const isTerminal = (destination: Writable): boolean => + "isTTY" in destination && destination.isTTY === true; + const writePart = (destination: Writable, part: string): Promise => new Promise((resolve, reject) => { const onError = (cause: unknown): void => reject(cause); @@ -39,6 +50,7 @@ const writePart = (destination: Writable, part: string): Promise => export function* jsonOutputParts( value: unknown, format: JsonOutputFormat, + pretty = true, ): Generator { if (format === "jsonl" && Array.isArray(value)) { for (const item of value) { @@ -47,6 +59,6 @@ export function* jsonOutputParts( } return; } - yield* jsonParts(value, format === "json"); + yield* jsonParts(value, pretty && format === "json"); yield "\n"; } diff --git a/tests/acceptance/tools/historicalCaptureResources.test.ts b/tests/acceptance/tools/historicalCaptureResources.test.ts index 7aea0fcce..87e509e65 100644 --- a/tests/acceptance/tools/historicalCaptureResources.test.ts +++ b/tests/acceptance/tools/historicalCaptureResources.test.ts @@ -83,18 +83,22 @@ it("reports real HAR heap exhaustion through CLI and MCP and preserves server us }); expect(JSON.stringify(response)).not.toContain("caller-marked"); expect(JSON.stringify(response)).not.toContain("rea doctor"); - await expect( - execute(process.execPath, [ - cli, - "inspect-web-network-capture", - path, - "har", - "--json", - ]), - ).rejects.toMatchObject({ - code: 1, - stdout: expect.stringContaining('"code": "resource_constraint"'), - }); + const failed: unknown = await execute(process.execPath, [ + cli, + "inspect-web-network-capture", + path, + "har", + "--json", + ]).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(failed).toMatchObject({ code: 1 }); + const stdout = + typeof failed === "object" && failed !== null && "stdout" in failed + ? String(failed.stdout) + : ""; + expect(JSON.parse(stdout)).toMatchObject({ code: "resource_constraint" }); const small = join(root, "small.har"); await writeFile(small, capture(2)); await client.ping(); diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index 029407388..fd03f0e92 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -16,6 +16,7 @@ import { silentLogger } from "../../../src/logger.js"; const collect = async ( value: unknown, format: "json" | "jsonl" = "json", + terminal = true, ): Promise => { const chunks: Buffer[] = []; const destination = new Writable({ @@ -28,6 +29,7 @@ const collect = async ( callback(); }, }); + if (terminal) Object.defineProperty(destination, "isTTY", { value: true }); await writeJsonOutput(value, destination, format); return Buffer.concat(chunks).toString("utf8"); }; @@ -52,6 +54,16 @@ describe("incremental CLI JSON", () => { expect(await collect(value)).toBe(`${JSON.stringify(value, null, 2)}\n`); }); + it("indents JSON only for a terminal and writes piped JSON compactly", async () => { + const value = { nested: [{ value: null }], text: "雪𝟠" }; + expect(await collect(value, "json", true)).toBe( + `${JSON.stringify(value, null, 2)}\n`, + ); + expect(await collect(value, "json", false)).toBe( + `${JSON.stringify(value)}\n`, + ); + }); + it("writes compact JSONL records and preserves an empty record set", async () => { const value = [{ text: "first\nrecord" }, { text: "雪𝟠" }]; expect(await collect(value, "jsonl")).toBe( @@ -78,6 +90,7 @@ describe("incremental CLI JSON", () => { }, 1); }, }); + Object.defineProperty(destination, "isTTY", { value: true }); await writeJsonOutput({ leaf }, destination); expect(largestChunk).toBeLessThan(Buffer.byteLength(leaf)); const actual = Buffer.concat(chunks).toString("utf8"); @@ -212,6 +225,8 @@ describe("streamed CLI result surface", () => { expect(operation).toBe("inspect_managed_members"); return value; }); + // A terminal destination exercises the indented encoding past the limit. + Object.defineProperty(destination, "isTTY", { value: true }); const argv = ["inspect-managed-members", "fixture.dll", "--json"]; const output = createStreamedCliJsonOutput(argv, destination); if (output === undefined) throw new Error("Missing JSON output surface");