Skip to content

feat(cli)!: report what a write returned instead of echoing its path - #248

Merged
ysyneu merged 2 commits into
feat/cli-generated-write-outputfrom
cli/generated-writes-return-their-response
Nov 10, 2026
Merged

ysyneu merged 2 commits into
feat/cli-generated-write-outputfrom
cli/generated-writes-return-their-response

Conversation

@ysyneu

@ysyneu ysyneu commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

What was wrong

118 generated commands printed a fixed acknowledgement assembled from their own HTTP verb and path and threw the API response away:

$ flashduty incident ack inc-1
OK: POST /incident/ack

That line restates what the caller just typed. It says nothing about the outcome of the write it is reporting, so a caller could not tell from it what had happened. In --json it was worse — a rendered English sentence under a message key, with no field to read:

{"message": "OK: POST /incident/ack"}

What these endpoints actually return

Checked against the OpenAPI spec in the linked go-flashduty module, not guessed. All 118 fall into two classes, and neither returns an object or an id:

Class Count What the spec declares Right output
JSON mutation 110 200 data is an empty object — EmptyResponse, EmptyObject, PlatformEmptyObject, MemberEmptyObject, CalendarEmptyObject, ScheduleEmptyObject, RuleEmptyResponse, RumErrorIngestionEmptyResponse, or an inline {type: object} with no properties say nothing was returned, and report the request id
CSV / file download 8 no application/json 200 body at all (application/octet-stream or text/csv) stream the bytes verbatim

The download set is insight/{incident,channel,responder,team}/export, rum/issue/export, status-page/subscriber/export, enrichment/mapping/data/download, and GET /safari/artifact/stream.

So there is nothing to report that the server stored — and inventing one would be a lie. What the server does report, and the CLI was discarding on every single write, is the request_id: per SuccessEnvelope it is present on every 2xx, mirrored in the Flashcat-Request-Id header, and the thing to quote when reporting a problem.

New output

$ flashduty incident ack inc-1
OK: the API returned no payload (request_id 01HK8XQE3Z7JM2NTFQ5YJ8P9R4).

$ flashduty incident ack inc-1 --json
{
  "request_id": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4"
}

$ flashduty incident ack inc-1 --output-format toon
request_id: 01HK8XQE3Z7JM2NTFQ5YJ8P9R4

When the server reports no id, the line and the record both leave it out rather than printing a hole:

OK: the API returned no payload.
{}

A download is unchanged — bytes reach stdout verbatim, so > file.csv still works:

$ flashduty rum issue-export --data '{"start_time":...,"end_time":...}'
issue_id,error_type
abc,Error

How

One rule in internal/cmd/cligen, keyed off the SDK method's own shape (no data return, reflected at generation time) — no per-command list, no flag. It routes every such command to one runtime helper, printRawOrNoPayload in internal/cli/gen_support.go.

That helper also absorbs the resp != nil && len(resp.Raw) > 0 check each call site used to repeat. The SDK fills Response.Raw exactly when a 2xx body is not envelope JSON, so that check is the runtime discriminator between a download and an empty envelope — and folding it in is what keeps the claim and the response in step: "no payload" is now asserted by the same code that establishes it, so no caller can print it while holding bytes. enrichment mapping-data-upload was missing the check and would have made exactly that claim.

Generated code net −472 lines (118 insertions, 590 deletions across 22 files).

Three curated commands carried the same canned line and now use the same helper (alert.go, insight.go, upload.go). A curated command shadows its generated twin at registration, so changing only the generator would have left the command users actually reach unchanged.

Breaking change for machine callers

--json / --toon on these commands now carry request_id instead of message. A script reading .message from a write will need updating. Table output and exit codes are otherwise unaffected, and downloads are untouched.

Verification

  • make (fmt, lint, test, build): lint 0 issues, go test -race ./... all packages pass.
  • make check-cards: skilldoc: cards OK, unchanged.
  • Regeneration is idempotent: go run ./internal/cmd/cligen and make gen-cards re-run produce no further drift.
  • Tests pin the default line by equality, not substring — callers read this through head, so a line pushed down by output added above it must fail.
  • Mutation-checked: reverting the generator to the canned line fails 12 tests across both packages. The tests are not vacuous.
  • internal/cmd/cligen/write_ack_test.go asserts the premise from the spec — that all 118 of these operations declare no payload — so an endpoint that later grows a real response fails the build instead of being dropped in silence.
  • e2e/ is compile-checked only (go vet -tags e2e ./e2e/). It was not run: it needs FLASHDUTY_E2E_APP_KEY and performs real writes. Everything above ran against local stub servers; nothing was written to a live account.

Not done, deliberately

  • No read-after-write. Fully answering "what is stored now" for the 110 mutations needs a per-endpoint read, which is not derivable from the spec and would double the request count on every write. Out of scope for a generator rule.
  • Agent command cards unchanged. They never quoted the old line, and the read-back guidance some of them carry (noise.md, template.md) is about verifying that content round-tripped byte for byte — which an empty envelope still cannot confirm. Removing it would claim something this change does not deliver.

Generated commands whose SDK method returns no typed data printed a
fixed acknowledgement assembled from their own HTTP verb and path, e.g.
"OK: POST /incident/ack", and discarded the response. That line restated
what the caller had just typed, so it said nothing about the outcome of
the write it was reporting, and in --json it arrived as a rendered
English sentence under a "message" key with no field to read.

All 118 such endpoints genuinely carry no payload: 110 mutations whose
200 envelope declares `data` as an empty object, and 8 CSV/file
downloads with no application/json 200 body at all, whose bytes already
stream through Response.Raw. So the fix is not to invent a payload but
to say there was none, and to hand back the request id the CLI had been
dropping on every write — the one fact of the call a caller cannot
derive from its own arguments, and what it has to quote when reporting
a write that went wrong.

    $ flashduty incident ack inc-1
    OK: the API returned no payload (request_id 01HK8XQE3Z7JM2NTFQ5YJ8P9R4).

    $ flashduty incident ack inc-1 --json
    {
      "request_id": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4"
    }

One rule in the generator derives this from the SDK method's own shape,
so it reaches every generated command with no per-command list and no
flag. Three curated commands carried the same canned line and now call
the same helper: a curated command shadows its generated twin at
registration, so changing only the generator would have left the
command users actually reach unchanged.

The helper, printRawOrNoPayload, also absorbs the Response.Raw check
each call site used to repeat, which is what keeps the two in step: the
claim "no payload" is now made by the same code that establishes it, so
a caller cannot print it while holding bytes. mapping-data upload was
missing that check and would have made exactly that claim.

Output shape, for callers parsing it: `--json` and `--toon` now carry
request_id instead of message, and carry an empty object when the server
reports no id. The Response.Raw branch is untouched, so a CSV or file
download still streams verbatim and `> file.csv` is unaffected.

Tests pin the default line by equality rather than substring, because
callers read this through `head` and a line pushed down by output added
above it has to fail. A spec-driven test asserts the premise that every
one of these operations declares no payload, so an endpoint that later
grows a real response fails the build instead of being dropped in
silence.
The helper these cover is printRawOrNoPayload/printNoPayload, so test names and file names reading WriteAck pointed at an identifier that no longer exists. Renames only, plus one comment reflowed; no assertion changed.
@ysyneu
ysyneu merged commit 2b277ff into feat/cli-generated-write-output Nov 10, 2026
12 checks passed
@ysyneu
ysyneu deleted the cli/generated-writes-return-their-response branch November 10, 2026 12:08
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