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).
Summary
Add a
--json(synonym--output-format json) flag to the non-interactivenanocoder runpath 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-167is the headless orchestrator. It already produces every signal we'd want to emit (final assistantcleanedContent, native + XML tool calls, per-turntoolResults, exit code viaPlainConversationOutcome), but discards all of it after picking an exit code in theswitch (outcome.kind)block atsource/plain/shell.ts:113-130. The final assistant text is currently streamed straight to stdout viawrite(token)insource/plain/conversation.ts:131-140and is not retained.source/plain/conversation.ts:50-55definesPlainConversationOutcomeas{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-67already 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-196already parses--provider,--model,--context-max,--mode,--trust-directory,--plain,--no-plain, validates flag combinations (e.g.--plain+--vscodeis rejected atsource/cli.tsx:201-204), and filters known flags out of the prompt construction (the skip list atsource/cli.tsx:157-180). A new--jsonflag should slot into both loops.write_to_file/string_replace/ similar tools ran (theirToolResultobjects flow throughprocessToolUseinsource/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'argumentsforpathfields, or instrumentToolResult).source/hooks/useNonInteractiveMode.ts:1-186,source/app/App.tsx:408-410) is a separaterunpath that does not currently funnel throughrunPlainShell. JSON-mode parity there is an explicit follow-up (see Out of scope).Proposed approach
source/cli.tsx):--jsonand the alias--output-format json(accept--output-format=<value>and--output-format <value>to match the existing--mode=precedent atsource/cli.tsx:120-138). Add--jsonto the prompt-filter skip list atsource/cli.tsx:157-180.--jsonoutside ofrun, mirroring the--plainguard atsource/cli.tsx:197-200. Reject--jsontogether with--acpand--vscodeso each protocol owns stdout exclusively.outputFormat: 'text' | 'json'option intorunPlainShellatsource/cli.tsx:317-323.--helpblock atsource/cli.tsx:55-90.source/plain/shell.ts):RunPlainShellOptionswithoutputFormat: 'text' | 'json'.PlainConversationOutcome(or introduce a parallelPlainRunResult) insource/plain/conversation.tsto carry:finalText,reasoning,toolCalls: Array<{name, arguments, result, error?}>,filesChanged: string[], plus the existingkind/message/toolNamesfields.runPlainConversationloop, accumulate eachtoolCalland itstoolResultinto 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 fromtoolCall.function.arguments.path/file_path. Treat tool errors astoolCalls[i].error.runPlainShell, whenoutputFormat === 'json': skip token streaming and the[plain] tool: …status lines (or keep them on stderr only), build aRunJsonReportobject, andprocess.stdout.write(JSON.stringify(report, null, 2) + '\n')exactly once at the end, just beforeshutdown(code). Exit code mapping (source/plain/shell.ts:34-40) is unchanged: 0 / 1 / 2.shutdownhappy-path line (source/plain/shell.ts:159-162) only whenoutputFormat === 'text', so JSON consumers don't see a straydoneon stdout.source/plain/writer.ts): no behavior change required; the stdout/stderr split already supports a JSON-only stdout document. Optionally add awriteJson(payload: unknown)helper that serializes withJSON.stringifyplus a trailing newline so all JSON writes go through one place.source/plain/conversation.spec.tsto assert the new result fields are populated for a tool-using scenario.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 withkind: 'tool-approval-required'andtoolNames, exit 2; error path emitskind: 'error'and exits 1. Confirm that with--jsonset,process.stdout.writereceives exactly one JSON document and zero plain-text tokens.--jsonoutsiderunis rejected and--json --vscodeis rejected.--helptext and add a short example to README's non-interactive section showingnanocoder --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, nodone).kind(success|tool-approval-required|error),exitCode(0/1/2),finalText(string, possibly empty),reasoning(string | null),toolCalls(array, possibly empty), andfilesChanged(array of strings).toolCalls[i]entry includesname,arguments(object),result(string, possibly null), anderror(string | null when applicable).--jsonand--output-format jsonare accepted as both spaced (--json run …) and fused (--json=…not required;--output-format=jsonmust work) forms.--jsonwithoutrun, or combined with--acp/--vscode, exits with a clear error message and code 1, mirroring the existing--plainvalidation insource/cli.tsx:197-204.[plain] tool: …, error messages,done) continues to go to stderr regardless of mode.NO_COLOR=1does not affect JSON output; JSON is always uncolored.nanocoder --plain run "..."behavior is unchanged (all current tests still pass).Out of scope
runpath (i.e.nanocoder run "..."without--plain). That's a separate change that would thread JSON throughsource/hooks/useNonInteractiveMode.tsandsource/app/App.tsx; worth a follow-up issue.{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.--json-schemaflag for validatingtoolCalls[i].arguments. The reported shape is JSON Schema–compatible by convention only.source/acp/acp-server.ts).