From 2625acae2e606ab0d66c625b47107eaa3a4c8411 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Sun, 25 Oct 2026 23:24:06 +0000 Subject: [PATCH] feat!: print one-line JSON by default; add --pretty The CLI printed every result with 2-space indentation. Agents read stdout through a pipe and pay for every byte; the indentation is a large share of nested observations. - Default output is single-line JSON when stdout is not a terminal. - On a terminal the output stays indented, so humans see no change. - `--pretty` (global, anywhere in argv) and `MM_PRETTY=1|true` force indented output; `MM_PRETTY=0|false` forces one line on a terminal. - String results are unchanged. BREAKING CHANGE: JSON results printed to a pipe or file are now one line instead of 2-space indented. Pass `--pretty` or set `MM_PRETTY=1` to get the old output. The JSON content is unchanged. --- README.md | 2 + SKILL.md | 2 + src/cli/mm.test.ts | 220 +++++++++++++++++++++++++++++++++++++++++++++ src/cli/mm.ts | 84 ++++++++++++++++- 4 files changed, 305 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a8c622e..d96360b 100644 --- a/README.md +++ b/README.md @@ -565,10 +565,12 @@ The `mm` CLI provides a unified interface for agents and developers. All command | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--project ` | Target a specific project directory (absolute or relative). Overrides `MM_PROJECT` and git-based discovery. | | `--observe ` | Observation to return after the call: `none`, `diff` or `full`. Sent as `observe` in every tool, `/launch` and `/cleanup` request body. Default: `diff` for mutating commands and `run-steps`, `none` otherwise. | +| `--pretty` | Indent JSON results with 2 spaces. Default: one line per result when stdout is not a terminal (agents, pipes, files), indented on a terminal. Consumed anywhere in the command line, like `--observe`. | | Environment Variable | Description | | -------------------- | -------------------------------------------------------------------------------------------------------- | | `MM_PROJECT` | Default project directory when `--project` is not provided. Falls back to the current git worktree root. | +| `MM_PRETTY` | `1`/`true`: indent JSON results; `0`/`false`: one line, also on a terminal. `--pretty` takes precedence. | ### Project Targeting diff --git a/SKILL.md b/SKILL.md index a5a7659..81d9f39 100644 --- a/SKILL.md +++ b/SKILL.md @@ -6,6 +6,8 @@ If you are running outside the target project directory, use `--project ` All commands also accept the global `--observe none|diff|full` flag to choose the observation returned after the call (see Observation Behavior). The CLI consumes `--observe ` anywhere in the command line, so to type the literal text `--observe` use `run-steps` or the HTTP API. +Results are printed as one line of JSON when stdout is not a terminal (your case). Parse them as JSON; do not match on indentation. Add `--pretty` (or set `MM_PRETTY=1`) only when a human needs to read the output. `--pretty` is also consumed anywhere in the command line, like `--observe`. + ## Core Loop ``` diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index ca2a0c2..c6a36d9 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -13,8 +13,12 @@ import type { MockInstance } from 'vitest'; import { applyObserveLevel, extractObserveFlag, + extractPrettyFlag, extractProjectFlag, + formatJsonOutput, + resolvePretty, setObserveLevel, + setPrettyOutput, resolveTargetFromArgs, resolveWithinFromArgs, getPositionalTarget, @@ -97,6 +101,8 @@ const mockResolveRuntime = vi.fn(); // eslint-disable-next-line vitest/require-top-level-describe beforeEach(() => { vi.clearAllMocks(); + // A developer's own MM_PRETTY must not change the output format in tests. + vi.stubEnv('MM_PRETTY', undefined); mockSearch.mockResolvedValue({ config: { daemon: './daemon.ts', runtime: 'tsx' }, filepath: '/mock/worktree/mm-client-cli.config.ts', @@ -119,6 +125,8 @@ afterEach(() => { stderrSpy.mockRestore(); stdoutSpy.mockRestore(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); + setPrettyOutput(false); }); describe('extractProjectFlag', () => { @@ -206,6 +214,81 @@ describe('extractObserveFlag', () => { }); }); +describe('extractPrettyFlag', () => { + it('returns args unchanged when no --pretty flag', () => { + expect(extractPrettyFlag(['click', 'e1'])).toStrictEqual({ + args: ['click', 'e1'], + pretty: false, + }); + }); + + it('extracts --pretty from anywhere in argv', () => { + expect(extractPrettyFlag(['--pretty', 'describe-screen'])).toStrictEqual({ + args: ['describe-screen'], + pretty: true, + }); + expect(extractPrettyFlag(['click', 'e1', '--pretty'])).toStrictEqual({ + args: ['click', 'e1'], + pretty: true, + }); + }); + + it('removes every --pretty', () => { + expect( + extractPrettyFlag(['--pretty', 'click', '--pretty', 'e1']), + ).toStrictEqual({ args: ['click', 'e1'], pretty: true }); + }); +}); + +describe('resolvePretty', () => { + it('is compact when stdout is not a terminal and nothing is set', () => { + expect(resolvePretty(false, undefined, false)).toBe(false); + expect(resolvePretty(false, undefined, undefined)).toBe(false); + expect(resolvePretty(false, '', false)).toBe(false); + }); + + it('is pretty on a terminal by default', () => { + expect(resolvePretty(false, undefined, true)).toBe(true); + }); + + it('--pretty wins over MM_PRETTY and the terminal check', () => { + expect(resolvePretty(true, '0', false)).toBe(true); + expect(resolvePretty(true, undefined, false)).toBe(true); + }); + + it.each(['1', 'true', 'TRUE', ' true '])( + 'turns pretty on without a terminal for MM_PRETTY=%j', + (value) => { + expect(resolvePretty(false, value, false)).toBe(true); + }, + ); + + it.each(['0', 'false', 'False'])( + 'turns pretty off on a terminal for MM_PRETTY=%j', + (value) => { + expect(resolvePretty(false, value, true)).toBe(false); + }, + ); + + it('ignores other MM_PRETTY values', () => { + expect(resolvePretty(false, 'yes', false)).toBe(false); + expect(resolvePretty(false, 'yes', true)).toBe(true); + }); +}); + +describe('formatJsonOutput', () => { + const value = { a: 1, nested: { b: [1, 2] } }; + + it('prints one line by default', () => { + expect(formatJsonOutput(value)).toBe('{"a":1,"nested":{"b":[1,2]}}'); + }); + + it('indents with 2 spaces when pretty output is on', () => { + setPrettyOutput(true); + expect(formatJsonOutput(value)).toBe(JSON.stringify(value, null, 2)); + }); +}); + describe('applyObserveLevel', () => { afterEach(() => { setObserveLevel(undefined); @@ -861,6 +944,8 @@ describe('printHelp', () => { expect(output).toContain('Usage:'); expect(output).toContain('mm launch'); expect(output).toContain('--observe '); + expect(output).toContain('--pretty'); + expect(output).toContain('MM_PRETTY'); }); }); @@ -1267,6 +1352,35 @@ describe('sendRequest', () => { expect(stdoutSpy).toHaveBeenCalledWith('simple string\n'); }); + it('prints object results as one line of JSON by default', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + result: { clicked: true }, + observations: { nodes: [{ ref: 'e1' }] }, + }), + } as Response); + + await sendRequest(3000, 'POST', '/tool/click', { a11yRef: 'e1' }); + + expect(stdoutSpy).toHaveBeenCalledWith( + '{"clicked":true,"observations":{"nodes":[{"ref":"e1"}]}}\n', + ); + }); + + it('prints indented JSON when pretty output is on', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, result: { clicked: true } }), + } as Response); + setPrettyOutput(true); + + await sendRequest(3000, 'POST', '/tool/click', { a11yRef: 'e1' }); + + expect(stdoutSpy).toHaveBeenCalledWith('{\n "clicked": true\n}\n'); + }); + it('exits on error response', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, @@ -3092,6 +3206,112 @@ describe('main', () => { ); }); + describe('output format', () => { + const originalIsTTY = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY', + ); + + const setStdoutIsTTY = (isTTY: boolean): void => { + Object.defineProperty(process.stdout, 'isTTY', { + value: isTTY, + configurable: true, + writable: true, + }); + }; + + beforeEach(() => { + setStdoutIsTTY(false); + }); + + afterEach(() => { + if (originalIsTTY === undefined) { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } else { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTY); + } + setObserveLevel(undefined); + }); + + const runGetState = async (argv: string[]): Promise => { + const { readDaemonState, isDaemonAlive, isDaemonVersionMatch } = + await import('../server/daemon-state.js'); + vi.mocked(readDaemonState).mockResolvedValueOnce({ + port: 3000, + pid: 123, + nonce: 'abc', + startedAt: '2024-01-01', + version: '1.0.0', + subPorts: {}, + }); + vi.mocked(isDaemonAlive).mockResolvedValueOnce(true); + vi.mocked(isDaemonVersionMatch).mockReturnValueOnce(true); + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, result: { count: 2 } }), + } as Response); + + const origArgv = process.argv; + process.argv = ['node', 'mm', ...argv]; + try { + await main(); + } finally { + process.argv = origArgv; + } + }; + + it('prints one line of JSON when stdout is not a terminal', async () => { + await runGetState(['get-state']); + + expect(stdoutSpy).toHaveBeenCalledWith('{"count":2}\n'); + }); + + it('consumes --pretty and prints indented JSON', async () => { + await runGetState(['--pretty', 'get-state']); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:3000/tool/get_state', + expect.anything(), + ); + expect(stdoutSpy).toHaveBeenCalledWith('{\n "count": 2\n}\n'); + }); + + it('consumes --pretty together with --observe', async () => { + await runGetState(['--observe', 'none', 'get-state', '--pretty']); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:3000/tool/get_state', + expect.objectContaining({ body: '{"observe":"none"}' }), + ); + expect(stdoutSpy).toHaveBeenCalledWith('{\n "count": 2\n}\n'); + }); + + it('prints indented JSON when MM_PRETTY=1', async () => { + vi.stubEnv('MM_PRETTY', '1'); + + await runGetState(['get-state']); + + expect(stdoutSpy).toHaveBeenCalledWith('{\n "count": 2\n}\n'); + }); + + it('prints indented JSON on a terminal', async () => { + setStdoutIsTTY(true); + + await runGetState(['get-state']); + + expect(stdoutSpy).toHaveBeenCalledWith('{\n "count": 2\n}\n'); + }); + + it('prints one line on a terminal when MM_PRETTY=0', async () => { + setStdoutIsTTY(true); + vi.stubEnv('MM_PRETTY', '0'); + + await runGetState(['get-state']); + + expect(stdoutSpy).toHaveBeenCalledWith('{"count":2}\n'); + }); + }); + it('sends the stdin recovery phrase with a self-onboarding launch', async () => { const { readDaemonState, isDaemonAlive, isDaemonVersionMatch } = await import('../server/daemon-state.js'); diff --git a/src/cli/mm.ts b/src/cli/mm.ts index 7ccea37..8b60472 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -156,6 +156,74 @@ export function applyObserveLevel( return { observe: globalObserveLevel, ...(body as Record) }; } +/** + * Extracts and consumes the global `--pretty` flag (every occurrence) from + * argv. + * + * @param argv - Raw CLI arguments (after the node/script entries). + * @returns The remaining arguments and whether `--pretty` was given. + */ +export function extractPrettyFlag(argv: string[]): { + args: string[]; + pretty: boolean; +} { + const args = argv.filter((arg) => arg !== '--pretty'); + return { args, pretty: args.length !== argv.length }; +} + +/** + * Decides whether JSON output is indented. Agents read stdout through a pipe + * and pay for every byte, so the default there is one line per result. + * Precedence: `--pretty`, then `MM_PRETTY` (`1`/`true` = pretty, `0`/`false` + * = compact), then pretty only when stdout is a terminal. + * + * @param flag - Whether `--pretty` was given. + * @param env - The `MM_PRETTY` value. + * @param isTTY - Whether stdout is a terminal. + * @returns True to indent JSON output. + */ +export function resolvePretty( + flag: boolean, + env: string | undefined, + isTTY: boolean | undefined, +): boolean { + if (flag) { + return true; + } + const value = env?.trim().toLowerCase(); + if (value === '1' || value === 'true') { + return true; + } + if (value === '0' || value === 'false') { + return false; + } + return isTTY === true; +} + +let prettyOutput = false; + +/** + * Sets whether {@link formatJsonOutput} indents JSON. + * + * @param pretty - True to indent with 2 spaces. + */ +export function setPrettyOutput(pretty: boolean): void { + prettyOutput = pretty; +} + +/** + * Serializes a CLI result for stdout: one line by default, indented with 2 + * spaces when pretty output is on (see {@link resolvePretty}). + * + * @param output - The value to print. + * @returns The JSON text, without a trailing newline. + */ +export function formatJsonOutput(output: unknown): string { + return prettyOutput + ? JSON.stringify(output, null, 2) + : JSON.stringify(output); +} + /** * Resolves the target project root directory using the following precedence: * 1. `--project ` CLI flag @@ -220,8 +288,13 @@ export async function main(): Promise { const { args: projectlessArgs, projectPath } = extractProjectFlag( process.argv.slice(2), ); - const { args: remainingArgs, observe } = extractObserveFlag(projectlessArgs); + const { args: observelessArgs, observe } = + extractObserveFlag(projectlessArgs); setObserveLevel(observe); + const { args: remainingArgs, pretty } = extractPrettyFlag(observelessArgs); + setPrettyOutput( + resolvePretty(pretty, process.env.MM_PRETTY, process.stdout.isTTY), + ); if ( remainingArgs.length === 0 || @@ -1179,7 +1252,7 @@ export async function sendRequest( if (typeof output === 'string') { process.stdout.write(`${output}\n`); } else { - process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + process.stdout.write(`${formatJsonOutput(output)}\n`); } return; } catch (error) { @@ -1786,7 +1859,7 @@ export function parseLaunchArgs(args: string[]): Record { export function printHelp(): void { process.stdout.write(`mm — MetaMask CLI -Usage: mm [--project ] [--observe ] [options] +Usage: mm [--project ] [--observe ] [--pretty] [options] Global Options: --project Target a specific project directory (absolute or relative). @@ -1794,10 +1867,15 @@ Global Options: --observe Observation returned after the call: none | diff | full. Default: diff for mutating commands and run-steps, none for reads, discovery and cdp. + --pretty Indent JSON output. Default: one line per result when + stdout is not a terminal (agents, pipes), indented on a + terminal. Environment Variables: MM_PROJECT Default project directory when --project is not provided. Falls back to the current git worktree root. + MM_PRETTY 1/true: indent JSON output; 0/false: one line, also on a + terminal. --pretty takes precedence. Lifecycle: mm launch [--context e2e|prod] [--state default|onboarding|custom] [--self-onboarding] [--extension-path ] [--force] [--platform browser|ios|android] [--device-id ]