Skip to content

feat(cli): JSON output for switch and version - #10

Merged
leinardi merged 4 commits into
mainfrom
feat/switch-json
Sep 10, 2026
Merged

leinardi merged 4 commits into
mainfrom
feat/switch-json

Conversation

@leinardi

Copy link
Copy Markdown
Owner

Summary

Adds structured output to the two commands that lacked it, so a client — the GNOME Shell extension being built against
monmux — can read a switch's result instead of parsing prose, and writes the contract down.

  • monmux version --json{"version", "commit", "date"}, through the same JSON writer info --json uses. The prose
    output is unchanged when the flag is absent.
  • monmux switch --json — one document on stdout for every outcome, with the exit code unchanged. The pair
    outcome/writeStatus is fixed: sent/sent (exit 0), dry-run/none (exit 0), refused/none (exit 2),
    failed/unknown (exit 1). The exit code stays the authority for "the write status is unknown"; the document is the
    authority for telling a dry run from a real send, which the exit code cannot. A client validates the two against each
    other and treats any other combination as a protocol error, so a dry run can never be announced as a completed switch.
  • catalog list --json / catalog show --json — each entry of inputs[] gains label, the human name monmux itself
    prints (DisplayPort, HDMI 2, USB-C). info --json's enabledInputs stays a list of names: a client that wants
    labels joins on the catalog entry, where every enabled input is recorded by construction.
  • docs/json.md — the whole contract, field by field: what is stable and what is prose, what redaction does to a
    document, the three shapes the version fields take across a release, a make go-build and a bare go build, and the
    scope of the switch guarantee.

What is not weakened

No fail-closed invariant moves. The text path is untouched: a refusal still exits 2 and still ends with
No DDC write was performed. A --json run that failed returns a renderedError that message() renders as nothing and
exitCode() unwraps, so the report is not printed twice and the codes are exactly the ones the text path uses. --json
adds no way to name a VCP code or a value; the bytes still come from the compiled-in catalog. Redaction is unchanged:
command, display.handle and refusal.detected are masked unless --show-serial.

assumed reports what happened rather than what was asked for — it is true exactly when the policy assumed a model,
including a bypassed write whose tool then failed, which is the one outcome where an unverified value may have reached a
monitor.

Scope of the guarantee

One document for every run that reaches the switch handler, including the argument errors it raises itself. Errors cobra
raises before it — wrong argument count, unknown flag, bad flag value — stay prose on stderr with exit 1, as for every
command, because --json has not been parsed when they occur. A client reads "exit 1 with nothing parsable on stdout" as a
failure whose text is stderr, never as a refusal.

Pull request checklist

  • I am targeting the main branch
  • I have rebased this branch on top of the destination branch
  • I have executed make check locally before creating the commit and it has run successfully
  • I have performed a self-review of my own code
  • There are no WIP commits in this PR

Also run on every commit: make go-test, make go-vet-cross, make go-lint-cross (0 issues for both GOOS=linux and
GOOS=darwin).

Monitor writes

  • If this changes internal/catalog/models.yaml, the evidence is in the entry, and the Summary above says which model
    and which input it enables — n/a, the catalog is untouched
  • No hardware run in this pull request was performed by an AI agent — AGENTS.md

Verification was read-only throughout: monmux version --json, monmux switch dp --dry-run --json and a refusal
(monmux switch hdmi9 --json, exit 2). Nothing was written to a monitor. The new tests drive the fake backend only.

Type of changes

  • 🐛 Bug fix
  • ✨ New feature
  • 🖥️ Catalog (a model or an input recorded, enabled or corrected)
  • 🔧 Refactoring
  • 📜 Docs
  • 🧰 CI / tooling / infra
  • Other (describe in Summary)

Give clients a structured answer for the one command that had none: version --json prints {version, commit, date} through the shared JSON writer, and the prose output is unchanged when the flag is absent.

The report-specific writeJSON helper in info.go becomes the one JSON writer the package uses, so catalog list/show and version all emit with SetEscapeHTML(false) and the same indentation; the duplicate encodeJSON in catalog.go is gone.
Every run that reaches the switch handler now prints exactly one JSON document on stdout, whatever the outcome, and the exit code is unchanged. The pair (outcome, writeStatus) is fixed - sent/sent, dry-run/none, refused/none, failed/unknown - so a client can tell a dry run from a send, which the exit code alone cannot, and can validate the two against each other.

The text path is untouched: a refusal still exits 2 with the full template and its closing promise. A JSON run that failed returns a renderedError instead, which message() renders as nothing and exitCode() unwraps, so the report is not printed twice. Cobra-level errors stay prose on stderr with exit 1, because --json has not been parsed when they occur.

assumed reports what happened rather than what was asked for: performSwitch records that the policy actually assumed a model, so a bypassed write whose tool then failed still says so on the one outcome where an unverified value may have reached the monitor.

Refusal.explanation becomes Refusal.Explanation so the document can pair the reason code with the sentence a human would have seen, rather than keeping a second copy of the wording.
Each entry of inputs[] in catalog list --json and catalog show --json now has a label field, from catalog.Input.Label() - the human name monmux itself prints in success and refusal messages, so a client can show what monmux shows without inventing a spelling of its own.

info --json's enabledInputs stays a list of names: every enabled input is recorded by construction, so a client that needs labels joins on the catalog entry.
New docs/json.md covers every --json command field by field: the outcome/writeStatus pair and how it maps to the exit code, which fields are stable and which are prose, what redaction does to a document, the three shapes the version fields take across a release, a make go-build and a bare go build, and the scope of the switch guarantee - one document for every run that reaches the handler, with cobra-level errors staying prose on stderr, so a client never reads exit 1 with no parsable stdout as a refusal.

The flag table now lists switch and version among the --json commands, the README usage block shows it, troubleshooting says the reason code is refusal.reason, security.md counts switch --json among the outputs that redact, and both the AGENTS.md documentation table and the adversarial-review skill - routing table and contract-drift checklist alike - point at the new page.
@leinardi
leinardi merged commit 029694f into main Sep 10, 2026
12 checks passed
@leinardi
leinardi deleted the feat/switch-json branch September 10, 2026 21:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant