ADR-072 — CLI output format: human default, NDJSON via --json; exit-code IntEnum¶
Status · Accepted Date · 2026-05-03 TA anchor ·/components/cli Related RFC · RFC-020 (closes here); related ADR-045 (prompt versioning is independent of package SemVer — same pattern applied here for the output schema)
Context¶
The CLI (ADR-069) serves two distinct consumers:
- Humans running commands interactively or reading output in a terminal. They want readable, aligned text. They do not want to parse JSON.
- Agents and scripts consuming output programmatically. They want structured, parseable output. They do not want to parse human-readable text with regex.
These two requirements are in tension. The output format must be a first-class decision, not an afterthought. Similarly, exit codes must be stable and documented so script callers can branch on them without parsing stderr text.
Decision¶
Output format¶
Human-readable text is the default. JSON mode is opt-in via --json.
JSON output uses newline-delimited JSON (NDJSON) — one JSON object per line — rather than a single JSON document. The final line of NDJSON output is always a summary event (kind result for success, kind error for failure).
Both modes go through a shared OutputWriter Protocol in chemigram/cli/output.py. Commands never call print() or sys.stdout directly. Lint-enforceable; audit-tested.
Output schema versioning¶
The NDJSON event schema is versioned independently of package SemVer — the same pattern as prompt versioning per ADR-045. Locked at 1.0 for v1.3.0; surfaced via chemigram status. Future additions (new optional fields, new event types) bump the minor; breaking changes (removed field, changed field type, removed event type) bump the major.
Exit codes¶
class ExitCode(IntEnum):
SUCCESS = 0
INTERNAL_ERROR = 1 # unhandled exception (bug)
INVALID_INPUT = 2 # bad args, schema validation failure
NOT_FOUND = 3 # image_id, primitive name, snapshot, mask, etc.
STATE_ERROR = 4 # workspace in inconsistent state
VERSIONING_ERROR = 5 # snapshot-graph integrity issues
DARKTABLE_ERROR = 6 # render subprocess failure
MASKING_ERROR = 7 # masking provider failure
SYNTHESIZER_ERROR = 8 # XMP synthesis failure
PERMISSION_ERROR = 9 # filesystem permission
NOT_IMPLEMENTED = 10 # tool stub or feature gate
The mapping chemigram.cli.error_mapping.error_code_to_exit(ErrorCode) → ExitCode is total (every ErrorCode from chemigram.mcp.errors produces a non-zero ExitCode); the match statement is mypy-exhaustive, with a runtime audit-style integration test as a safety net.
The set is closed; new exit codes go through an ADR amendment. Adding a new ErrorCode upstream requires extending this enum in lockstep.
Diagnostic command¶
chemigram status is a diagnostic that always exits 0 and reports missing components in fields and warnings. Erroring out on missing darktable would defeat the discoverability use case (you can't ask "is darktable installed?" if the answer is to fail when it isn't). Scripts needing a hard check for darktable can branch on payload["darktable_cli_path"] is None in the JSON output.
Rationale¶
Why not JSON by default¶
The primary interactive use case is a human at a terminal. A chemigram command invoked directly should be readable without piping to jq. Industry convention (grep, git, curl, darktable-cli) defaults to human output and provides machine output via flags.
Why NDJSON instead of a single JSON document¶
Single JSON document:
- Requires buffering the entire output before the consumer can parse
- Cannot be streamed
- For long operations, the consumer gets nothing until completion
NDJSON:
- Each event is parseable as it arrives
- Compatible with streaming:
result.stdout.split('\n'),json.loads()per line - Standard in log processing (Logstash, Loki, etc.) and tool output (eslint
--format json, etc.) - The summary-line convention (last line is always a summary) gives non-streaming consumers a single parse target
Why an OutputWriter abstraction¶
Without it, each command module would contain conditionals (if json_mode: print(json.dumps(...)) else: typer.echo(...)). This duplicates the branching across every command and couples command logic to output format. The Protocol pattern isolates the format decision; commands write to ctx.obj["writer"].event(...) and never know which writer they're talking to.
Why a closed exit-code set¶
Exit codes are a contract. Agents shelling out to the CLI branch on the numeric value to distinguish "image missing" from "darktable failed" from "bad entry name" without parsing stderr text. A closed enum + mypy-exhaustive mapping prevents accidental drift between the engine's ErrorCode taxonomy and the CLI's exit codes.
Why versioned independently of package SemVer¶
chemigram may release patch versions without changing output schema (a typo fix in a help string isn't a schema change). Conversely, an output schema may need a major version bump even when the package SemVer doesn't (e.g., changing the shape of state_after is breaking for script consumers but doesn't necessarily break the CLI's behavior). Decoupling them — same pattern as prompt versioning per ADR-045 — gives both surfaces stable contracts.
Consequences¶
Positive:
- Human default is approachable for first-time users and manual invocation.
--jsongives agents and scripts a stable, parseable surface without regex.- NDJSON supports streaming consumption; no buffering required.
OutputWriterabstraction makes commands testable without format coupling.- Exit codes are stable; agents branch on category without parsing messages.
- Schema versioning lets the schema evolve without forcing a CLI major bump.
Negative:
- Schema maintenance. The event schema is a public API. Breaking changes require a version bump and changelog entry. Discipline must be enforced in review.
- NDJSON is less familiar than single JSON. Consumers expecting a single object need one extra parse step (
splitlines()→ last line). The summary-last-line convention is documented to mitigate. richdependency for colored human output. Typer uses Rich for colored output by default. Acceptable: Rich is already in the dependency tree via Typer (ADR-070).
Verification¶
- Per-verb integration tests assert both human and
--jsonoutput shapes for every verb. - Exit-code parity audit (
tests/integration/cli/test_error_code_parity.py) walks everyErrorCodeand confirms a unique non-zeroExitCodemapping. OUTPUT_SCHEMA_VERSION = "1.0"constant is exposed inchemigram status; locked-in regression test intests/unit/cli/test_output.py.
Alternatives considered¶
JSON by default, human output via --text¶
Rejected. Most invocations are interactive. Industry convention defaults to human output.
Single JSON document, no NDJSON¶
Rejected. Buffering the entire output before parsing is wrong for streamable verbs (log, vocab list); a streaming-first format is the better default.
Free-form exit codes (1 for any error)¶
Rejected. Forces consumers to parse stderr text to distinguish error categories — defeats the purpose of having structured errors at the engine level.