Skip to content

[Feature] Add --json flag to run / --plain for machine-readable output #603

Description

@will-lamerton

Summary
Add a --json (synonym --output-format json) flag to the non-interactive nanocoder run path so callers can consume a single, well-formed JSON document on stdout containing the final assistant message, the tool calls made, the files changed, and the exit status. This matches Gemini CLI's and Pi's structured-output behavior and turns Nanocoder into a first-class building block for CI/scripting pipelines.

Context

  • source/plain/shell.ts:1-167 is the headless orchestrator. It already produces every signal we'd want to emit (final assistant cleanedContent, native + XML tool calls, per-turn toolResults, exit code via PlainConversationOutcome), but discards all of it after picking an exit code in the switch (outcome.kind) block at source/plain/shell.ts:113-130. The final assistant text is currently streamed straight to stdout via write(token) in source/plain/conversation.ts:131-140 and is not retained.
  • source/plain/conversation.ts:50-55 defines PlainConversationOutcome as {kind: 'success'} | {kind: 'tool-approval-required'; toolNames: string[]} | {kind: 'error'; message: string}. To support JSON we need a richer return shape (or a sibling collector) that carries the final text, the ordered tool-call log, and the per-tool file-mutation list.
  • source/plain/writer.ts:1-67 already splits stdout (tokens, blank lines) from stderr (boot banner, [plain] tool: …, errors). JSON output should follow the same convention: the document goes to stdout, all human-readable status stays on stderr so existing log scrapers keep working.
  • source/cli.tsx:97-196 already parses --provider, --model, --context-max, --mode, --trust-directory, --plain, --no-plain, validates flag combinations (e.g. --plain + --vscode is rejected at source/cli.tsx:201-204), and filters known flags out of the prompt construction (the skip list at source/cli.tsx:157-180). A new --json flag should slot into both loops.
  • File-change tracking is not currently surfaced. The tool layer already records which write_to_file / string_replace / similar tools ran (their ToolResult objects flow through processToolUse in source/message-handler), but the plain shell has no hook for collecting "files this run mutated". That sub-piece will need to be designed as part of this work (e.g. inspect the executed tool calls' arguments for path fields, or instrument ToolResult).
  • The Ink non-interactive path (source/hooks/useNonInteractiveMode.ts:1-186, source/app/App.tsx:408-410) is a separate run path that does not currently funnel through runPlainShell. JSON-mode parity there is an explicit follow-up (see Out of scope).

Proposed approach

  • CLI surface (source/cli.tsx):
    • Parse --json and the alias --output-format json (accept --output-format=<value> and --output-format <value> to match the existing --mode= precedent at source/cli.tsx:120-138). Add --json to the prompt-filter skip list at source/cli.tsx:157-180.
    • Reject --json outside of run, mirroring the --plain guard at source/cli.tsx:197-200. Reject --json together with --acp and --vscode so each protocol owns stdout exclusively.
    • Pass an outputFormat: 'text' | 'json' option into runPlainShell at source/cli.tsx:317-323.
    • Document the new flags in the --help block at source/cli.tsx:55-90.
  • Plain shell (source/plain/shell.ts):
    • Extend RunPlainShellOptions with outputFormat: 'text' | 'json'.
    • Extend PlainConversationOutcome (or introduce a parallel PlainRunResult) in source/plain/conversation.ts to carry: finalText, reasoning, toolCalls: Array<{name, arguments, result, error?}>, filesChanged: string[], plus the existing kind/message/toolNames fields.
    • In the runPlainConversation loop, accumulate each toolCall and its toolResult into a result array, and dedupe the set of file paths touched by mutating tools (use a small allowlist: write_to_file, create_file, string_replace, edit_file, plus any future file-mutating tools) — derived from toolCall.function.arguments.path / file_path. Treat tool errors as toolCalls[i].error.
    • In runPlainShell, when outputFormat === 'json': skip token streaming and the [plain] tool: … status lines (or keep them on stderr only), build a RunJsonReport object, and process.stdout.write(JSON.stringify(report, null, 2) + '\n') exactly once at the end, just before shutdown(code). Exit code mapping (source/plain/shell.ts:34-40) is unchanged: 0 / 1 / 2.
    • Keep the existing shutdown happy-path line (source/plain/shell.ts:159-162) only when outputFormat === 'text', so JSON consumers don't see a stray done on stdout.
  • Writer (source/plain/writer.ts): no behavior change required; the stdout/stderr split already supports a JSON-only stdout document. Optionally add a writeJson(payload: unknown) helper that serializes with JSON.stringify plus a trailing newline so all JSON writes go through one place.
  • Tests:
    • Extend source/plain/conversation.spec.ts to assert the new result fields are populated for a tool-using scenario.
    • Add a source/plain/shell.spec.ts (or extend an existing one) covering: success path emits one JSON document with the expected fields and exit 0; tool-approval-required path emits a JSON document with kind: 'tool-approval-required' and toolNames, exit 2; error path emits kind: 'error' and exits 1. Confirm that with --json set, process.stdout.write receives exactly one JSON document and zero plain-text tokens.
    • Add a CLI-level test (or update existing parser coverage) confirming --json outside run is rejected and --json --vscode is rejected.
  • Docs: update --help text and add a short example to README's non-interactive section showing nanocoder --plain --json run "summarize README.md" | jq .finalText.

Acceptance criteria

  • nanocoder --plain --json run "..." writes exactly one well-formed JSON object to stdout, terminated by a newline, and nothing else on stdout (no streaming tokens, no boot banner, no done).
  • The JSON object always contains: kind (success | tool-approval-required | error), exitCode (0/1/2), finalText (string, possibly empty), reasoning (string | null), toolCalls (array, possibly empty), and filesChanged (array of strings).
  • Each toolCalls[i] entry includes name, arguments (object), result (string, possibly null), and error (string | null when applicable).
  • --json and --output-format json are accepted as both spaced (--json run …) and fused (--json=… not required; --output-format=json must work) forms.
  • --json without run, or combined with --acp / --vscode, exits with a clear error message and code 1, mirroring the existing --plain validation in source/cli.tsx:197-204.
  • Exit codes remain 0 / 1 / 2 for success / error / tool-approval-required respectively, in both text and JSON modes.
  • All human-readable status (boot banner, [plain] tool: …, error messages, done) continues to go to stderr regardless of mode.
  • NO_COLOR=1 does not affect JSON output; JSON is always uncolored.
  • Existing text-mode nanocoder --plain run "..." behavior is unchanged (all current tests still pass).
  • New unit tests cover the three outcome kinds in JSON mode and the CLI flag-validation rules.

Out of scope

  • Structured JSON output for the Ink non-interactive run path (i.e. nanocoder run "..." without --plain). That's a separate change that would thread JSON through source/hooks/useNonInteractiveMode.ts and source/app/App.tsx; worth a follow-up issue.
  • Streaming JSONL events ({event:"token", data:"…"}\n{event:"tool_call", …}) on stdout. v1 emits a single final document only; streaming can be added later without breaking the contract.
  • A separate JSON-schema file or --json-schema flag for validating toolCalls[i].arguments. The reported shape is JSON Schema–compatible by convention only.
  • Changes to the ACP server's existing JSON-RPC framing (source/acp/acp-server.ts).

Metadata

Metadata

Assignees

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions