← back to Cli Printing Press
feat(cli,skills): three retro WUs from postman-explore — POST-as-query detection, novel-command --select, scorecard YAML (#424)
8ceea6ad65a0b27784f0134e20cb55827e21b645 · 2026-04-30 01:53:52 -0700 · Trevin Chow
* docs: plan three retro WUs from postman-explore session
Plans implementation of three Printing Press defects surfaced in
retro #423: scorecard YAML support (U1), novel-command --select
plumbing (U2), and POST-as-query detection (U3). Acceptance examples
carry verbatim from retro AE1-AE4.
* fix(cli): scorecard accepts OpenAPI YAML specs
loadOpenAPISpec previously read the file, ran an internal-YAML detector,
then fell through to json.Unmarshal — rejecting OpenAPI YAML files even
though OpenAPI YAML is the more common format on apis-guru and swagger.io.
Required users to convert YAML→JSON before scoring.
Adds a content-sniffing branch: trim leading whitespace, check the first
byte. `{` routes to the existing JSON path (preserving its error message);
anything else routes to a new `yaml.Unmarshal` branch with a YAML-specific
error message. Keeps the two branches separate so a malformed JSON spec
reports as 'parsing spec JSON' and a malformed YAML spec reports as
'parsing OpenAPI YAML spec'.
Six new test cases under TestLoadOpenAPISpec_OpenAPIYAML cover the YAML
branch happy path, JSON/YAML equivalence, format-specific error messages,
the internal-YAML short-circuit (still works), and leading-whitespace
detection.
From retro #423 (WU-3 / F4).
* feat(cli,skills): novel commands honor --select and --compact
The audit in retro #423 confirmed hand-written novel commands across the
library silently drop --select and --compact: recipe-goat (14 files),
dub (16), espn (1), yahoo-finance (2), postman-explore (was 8 before
session-time fix). Agents kept hitting the trap because the SKILL never
named the right helper.
Two parts:
1. Generator emits printJSONFiltered into every CLI's
internal/cli/helpers.go alongside the existing filterFields and
compactFields. The helper marshals the typed value, applies
filterFields when --select is set or compactFields when --compact is
set, then prints with two-space indent.
2. SKILL Phase 3 Agent Build Checklist gains principle 11 documenting
the helper as the required path for novel commands' JSON output.
Principle 2 also gets a one-line cross-reference. The header bumped
from '9 principles' (already stale — 10 are present) to '11 principles'.
A regression test in internal/generator/print_json_filtered_test.go
builds a fixture CLI and asserts the helper is emitted with the right
signature, the --select branch, and the --compact branch.
Existing endpoint-mirror commands stay on printOutputWithFlags
(unchanged); only hand-written novel commands need the new helper.
Backfilling existing library CLIs is deferred to /printing-press-polish
on next invocation per CLI.
From retro #423 (WU-2 / F3).
* fix(cli): generator-aware POST-as-query detection
Two coordinated changes resolve retro #423 F1 + F2.
F1 — methodIsWrite was a pure HTTP-verb classifier: POST/PUT/PATCH/DELETE
all returned true. APIs that use POST for queries (Postman /search-all,
GraphQL POST /graphql, RPC-style search APIs) got the wrong README
boilerplate (Retryable / Confirmable / Piped-input bullets) because
hasWriteCommands flipped true.
F2 — command_promoted.go.tmpl hardcoded c.Get(path, params) for every
promoted endpoint. Promoting a POST-only endpoint produced a runtime
HTTP 400 from the wrong-verb mismatch (postman-explore search-all
shipped Hidden:true as a per-CLI workaround).
Fix:
- New endpointIsWriteCommand(endpoint, name) bool in generator.go
combines four signals to classify writes correctly:
1. mcp:read-only annotation (highest precedence)
2. HTTP verb (GET/HEAD/OPTIONS always read)
3. operationId prefix (get/list/search/find/query/count/describe/fetch)
4. body shape (filter-only params signal a query, not a mutation)
- resourceHasWriteCommand routes through endpointIsWriteCommand. The
old methodIsWrite stays as a thin verb-only fallback for callers that
don't have an Endpoint in hand.
- command_promoted.go.tmpl gains a verb branch in the no-store fallback:
GET keeps c.Get(path, params) byte-identically; DELETE emits c.Delete;
POST/PUT/PATCH emit c.{Method}(path, params) so the proxy receives the
correct verb. Body-flag plumbing for promoted shortcuts is tracked as
a follow-up; users with rich body shapes use the typed
'<resource> <endpoint>' command.
- HasStore + non-GET still routes through resolveRead (which is GET-only
internally) — the resolver doesn't have a POST-aware variant yet, and
building one is a separate concern. The template carries a comment
documenting this scope explicitly.
Tests:
- TestEndpointIsWriteCommand covers 19 classification cases including
GraphQL queries, Postman /search-all, fetch/find/count/describe-style
reads, genuine POST creates/PUT updates/PATCHes/DELETEs, the
fail-closed default, mcp:read-only short-circuit, case-insensitive
prefix matching, and mixed filter+write body shapes.
- TestHasWriteCommands_PostAsQueryFlipsHasWriteFalse and
TestHasWriteCommands_GenuineMutationsStayTrue are the integration
guards confirming the classifier propagates through to the
HasWriteCommands template signal.
- TestPromotedCommand_PostEndpointEmitsPost builds a fixture CLI with
a single POST endpoint and asserts the generated promoted command
contains c.Post(. TestPromotedCommand_GetEndpointStillEmitsGet is
the negative guard ensuring GET endpoints don't suddenly emit POST.
Smoke against postman-explore: regenerated README now emits 'Read-only
by default' instead of the Retryable/Confirmable/Piped-input bullets.
From retro #423 (WU-1 / F1 + F2).
* refactor(cli,skills): simplify retro-WU helpers per /simplify review
Six surface-level cleanups, no behavioral change:
- printJSONFiltered now composes printOutputWithFlags instead of
re-implementing the --select / --compact branching. Novel commands
pick up --csv and --quiet for free, and the helper stays aligned with
the endpoint-mirror path automatically as printOutputWithFlags evolves.
Signature simplifies from interface{ OutOrStdout() io.Writer } to
io.Writer, matching the helper it now wraps.
- endpointIsWriteCommand factored into a body-shape sub-helper
(bodyIsAllFilterShape) and trailing if/return collapsed to a single
return. Doc comments tightened to describe behavior, not narrate the
task that produced them.
- Two near-duplicate promoted-command tests (POST and GET) merged into
one table-driven TestPromotedCommandVerbBranching with a glob-based
promoted-file lookup. The candidate-path search loop is gone.
- print_json_filtered_test asserts the helper delegates to
printOutputWithFlags rather than asserting on filterFields/compactFields
literals — drift-resistant when printOutputWithFlags evolves.
- SKILL principle 2 trimmed to a one-line cross-reference; principle 11
is the single source of truth for the helper rule.
- Comments throughout the new code stripped of retro/incident references
and 'previously this was broken' narration per AGENTS.md hygiene rules.
All tests still green, golangci-lint reports 0 issues.
* refactor(cli,skills): apply ce-code-review safe fixes
Fixes from ce-code-review on PR #424:
- HEAD/OPTIONS verb fallback in command_promoted.go.tmpl so unknown HTTP
verbs route to c.Get instead of emitting an undefined c.Head/c.Options.
- Empty / whitespace-only spec files now return an explicit "empty" error
from loadOpenAPISpec instead of falling through to a confusing parse
failure.
- endpointIsWriteCommand switched from strings.HasPrefix to whole-word
camelCase token matching with a write-verb override on subsequent
tokens, so getOrCreate/fetchAndUpdate stay writes and "getter" no
longer false-positives as a read.
- UTF-8 BOM stripping at the top of loadOpenAPISpec so editors that emit
one don't break content sniffing or json.Unmarshal.
- methodIsWrite doc comment trimmed; SKILL principles 2 and 11 folded
into a single principle (10 total).
Test coverage: getOrCreate/fetchAndUpdate/listAndDelete write-override
cases, "getter" whole-word non-match, HEAD-falls-back-to-Get template
branch, empty-file / whitespace-only / BOM-prefixed-JSON loader paths.
2436 tests across 28 packages pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A docs/plans/2026-04-30-001-feat-postman-retro-wus-plan.mdA internal/generator/endpoint_is_write_test.goM internal/generator/generator.goA internal/generator/print_json_filtered_test.goM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/helpers.go.tmplM internal/pipeline/scorecard.goM internal/pipeline/scorecard_tier2_test.goM skills/printing-press/SKILL.md
Diff
commit 8ceea6ad65a0b27784f0134e20cb55827e21b645
Author: Trevin Chow <trevin@trevinchow.com>
Date: Thu Apr 30 01:53:52 2026 -0700
feat(cli,skills): three retro WUs from postman-explore — POST-as-query detection, novel-command --select, scorecard YAML (#424)
* docs: plan three retro WUs from postman-explore session
Plans implementation of three Printing Press defects surfaced in
retro #423: scorecard YAML support (U1), novel-command --select
plumbing (U2), and POST-as-query detection (U3). Acceptance examples
carry verbatim from retro AE1-AE4.
* fix(cli): scorecard accepts OpenAPI YAML specs
loadOpenAPISpec previously read the file, ran an internal-YAML detector,
then fell through to json.Unmarshal — rejecting OpenAPI YAML files even
though OpenAPI YAML is the more common format on apis-guru and swagger.io.
Required users to convert YAML→JSON before scoring.
Adds a content-sniffing branch: trim leading whitespace, check the first
byte. `{` routes to the existing JSON path (preserving its error message);
anything else routes to a new `yaml.Unmarshal` branch with a YAML-specific
error message. Keeps the two branches separate so a malformed JSON spec
reports as 'parsing spec JSON' and a malformed YAML spec reports as
'parsing OpenAPI YAML spec'.
Six new test cases under TestLoadOpenAPISpec_OpenAPIYAML cover the YAML
branch happy path, JSON/YAML equivalence, format-specific error messages,
the internal-YAML short-circuit (still works), and leading-whitespace
detection.
From retro #423 (WU-3 / F4).
* feat(cli,skills): novel commands honor --select and --compact
The audit in retro #423 confirmed hand-written novel commands across the
library silently drop --select and --compact: recipe-goat (14 files),
dub (16), espn (1), yahoo-finance (2), postman-explore (was 8 before
session-time fix). Agents kept hitting the trap because the SKILL never
named the right helper.
Two parts:
1. Generator emits printJSONFiltered into every CLI's
internal/cli/helpers.go alongside the existing filterFields and
compactFields. The helper marshals the typed value, applies
filterFields when --select is set or compactFields when --compact is
set, then prints with two-space indent.
2. SKILL Phase 3 Agent Build Checklist gains principle 11 documenting
the helper as the required path for novel commands' JSON output.
Principle 2 also gets a one-line cross-reference. The header bumped
from '9 principles' (already stale — 10 are present) to '11 principles'.
A regression test in internal/generator/print_json_filtered_test.go
builds a fixture CLI and asserts the helper is emitted with the right
signature, the --select branch, and the --compact branch.
Existing endpoint-mirror commands stay on printOutputWithFlags
(unchanged); only hand-written novel commands need the new helper.
Backfilling existing library CLIs is deferred to /printing-press-polish
on next invocation per CLI.
From retro #423 (WU-2 / F3).
* fix(cli): generator-aware POST-as-query detection
Two coordinated changes resolve retro #423 F1 + F2.
F1 — methodIsWrite was a pure HTTP-verb classifier: POST/PUT/PATCH/DELETE
all returned true. APIs that use POST for queries (Postman /search-all,
GraphQL POST /graphql, RPC-style search APIs) got the wrong README
boilerplate (Retryable / Confirmable / Piped-input bullets) because
hasWriteCommands flipped true.
F2 — command_promoted.go.tmpl hardcoded c.Get(path, params) for every
promoted endpoint. Promoting a POST-only endpoint produced a runtime
HTTP 400 from the wrong-verb mismatch (postman-explore search-all
shipped Hidden:true as a per-CLI workaround).
Fix:
- New endpointIsWriteCommand(endpoint, name) bool in generator.go
combines four signals to classify writes correctly:
1. mcp:read-only annotation (highest precedence)
2. HTTP verb (GET/HEAD/OPTIONS always read)
3. operationId prefix (get/list/search/find/query/count/describe/fetch)
4. body shape (filter-only params signal a query, not a mutation)
- resourceHasWriteCommand routes through endpointIsWriteCommand. The
old methodIsWrite stays as a thin verb-only fallback for callers that
don't have an Endpoint in hand.
- command_promoted.go.tmpl gains a verb branch in the no-store fallback:
GET keeps c.Get(path, params) byte-identically; DELETE emits c.Delete;
POST/PUT/PATCH emit c.{Method}(path, params) so the proxy receives the
correct verb. Body-flag plumbing for promoted shortcuts is tracked as
a follow-up; users with rich body shapes use the typed
'<resource> <endpoint>' command.
- HasStore + non-GET still routes through resolveRead (which is GET-only
internally) — the resolver doesn't have a POST-aware variant yet, and
building one is a separate concern. The template carries a comment
documenting this scope explicitly.
Tests:
- TestEndpointIsWriteCommand covers 19 classification cases including
GraphQL queries, Postman /search-all, fetch/find/count/describe-style
reads, genuine POST creates/PUT updates/PATCHes/DELETEs, the
fail-closed default, mcp:read-only short-circuit, case-insensitive
prefix matching, and mixed filter+write body shapes.
- TestHasWriteCommands_PostAsQueryFlipsHasWriteFalse and
TestHasWriteCommands_GenuineMutationsStayTrue are the integration
guards confirming the classifier propagates through to the
HasWriteCommands template signal.
- TestPromotedCommand_PostEndpointEmitsPost builds a fixture CLI with
a single POST endpoint and asserts the generated promoted command
contains c.Post(. TestPromotedCommand_GetEndpointStillEmitsGet is
the negative guard ensuring GET endpoints don't suddenly emit POST.
Smoke against postman-explore: regenerated README now emits 'Read-only
by default' instead of the Retryable/Confirmable/Piped-input bullets.
From retro #423 (WU-1 / F1 + F2).
* refactor(cli,skills): simplify retro-WU helpers per /simplify review
Six surface-level cleanups, no behavioral change:
- printJSONFiltered now composes printOutputWithFlags instead of
re-implementing the --select / --compact branching. Novel commands
pick up --csv and --quiet for free, and the helper stays aligned with
the endpoint-mirror path automatically as printOutputWithFlags evolves.
Signature simplifies from interface{ OutOrStdout() io.Writer } to
io.Writer, matching the helper it now wraps.
- endpointIsWriteCommand factored into a body-shape sub-helper
(bodyIsAllFilterShape) and trailing if/return collapsed to a single
return. Doc comments tightened to describe behavior, not narrate the
task that produced them.
- Two near-duplicate promoted-command tests (POST and GET) merged into
one table-driven TestPromotedCommandVerbBranching with a glob-based
promoted-file lookup. The candidate-path search loop is gone.
- print_json_filtered_test asserts the helper delegates to
printOutputWithFlags rather than asserting on filterFields/compactFields
literals — drift-resistant when printOutputWithFlags evolves.
- SKILL principle 2 trimmed to a one-line cross-reference; principle 11
is the single source of truth for the helper rule.
- Comments throughout the new code stripped of retro/incident references
and 'previously this was broken' narration per AGENTS.md hygiene rules.
All tests still green, golangci-lint reports 0 issues.
* refactor(cli,skills): apply ce-code-review safe fixes
Fixes from ce-code-review on PR #424:
- HEAD/OPTIONS verb fallback in command_promoted.go.tmpl so unknown HTTP
verbs route to c.Get instead of emitting an undefined c.Head/c.Options.
- Empty / whitespace-only spec files now return an explicit "empty" error
from loadOpenAPISpec instead of falling through to a confusing parse
failure.
- endpointIsWriteCommand switched from strings.HasPrefix to whole-word
camelCase token matching with a write-verb override on subsequent
tokens, so getOrCreate/fetchAndUpdate stay writes and "getter" no
longer false-positives as a read.
- UTF-8 BOM stripping at the top of loadOpenAPISpec so editors that emit
one don't break content sniffing or json.Unmarshal.
- methodIsWrite doc comment trimmed; SKILL principles 2 and 11 folded
into a single principle (10 total).
Test coverage: getOrCreate/fetchAndUpdate/listAndDelete write-override
cases, "getter" whole-word non-match, HEAD-falls-back-to-Get template
branch, empty-file / whitespace-only / BOM-prefixed-JSON loader paths.
2436 tests across 28 packages pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
.../2026-04-30-001-feat-postman-retro-wus-plan.md | 316 ++++++++++++++++++
internal/generator/endpoint_is_write_test.go | 371 +++++++++++++++++++++
internal/generator/generator.go | 120 ++++++-
internal/generator/print_json_filtered_test.go | 33 ++
.../generator/templates/command_promoted.go.tmpl | 15 +
internal/generator/templates/helpers.go.tmpl | 12 +
internal/pipeline/scorecard.go | 25 +-
internal/pipeline/scorecard_tier2_test.go | 174 ++++++++++
skills/printing-press/SKILL.md | 4 +-
9 files changed, 1064 insertions(+), 6 deletions(-)
diff --git a/docs/plans/2026-04-30-001-feat-postman-retro-wus-plan.md b/docs/plans/2026-04-30-001-feat-postman-retro-wus-plan.md
new file mode 100644
index 00000000..197d0a50
--- /dev/null
+++ b/docs/plans/2026-04-30-001-feat-postman-retro-wus-plan.md
@@ -0,0 +1,316 @@
+---
+title: "feat: Three retro work units from postman-explore — POST-as-query detection, novel-command --select, scorecard YAML"
+type: feat
+status: active
+date: 2026-04-30
+origin: https://github.com/mvanhorn/cli-printing-press/issues/423
+---
+
+# feat: Three retro work units from postman-explore — POST-as-query detection, novel-command --select, scorecard YAML
+
+## Summary
+
+Implement three of the eight work units filed in retro issue #423 from the postman-explore generation session — the small/medium complexity wins with broad cross-CLI impact. Land them in dependency-light order (scorecard YAML → novel-command `--select` → POST-as-query detection) so each PR is reviewable on its own and the higher-blast-radius change comes last when the easier wins have already validated the testing posture.
+
+---
+
+## Problem Frame
+
+Three independent Printing Press defects surfaced during the postman-explore v3 regeneration. Each one was confirmed via cross-API audit to affect more than just postman-explore:
+
+- **POST-as-query is misclassified as a write across the generator.** Every API with a `POST /search`-style endpoint, every GraphQL CLI, and every proxy-envelope CLI gets the wrong README boilerplate, and any promoted POST endpoint additionally fails at runtime because the promoted-command template hardcodes `c.Get(...)`.
+- **Hand-written novel commands silently drop `--select` and `--compact`.** Confirmed broken in recipe-goat (14 files), dub (16), espn (1), yahoo-finance (2), and postman-explore (was 8 before the session-time fix). Agents keep falling into this trap because the SKILL never tells them how to plumb the filters through.
+- **`scorecard --spec foo.yaml` rejects OpenAPI YAML.** Forces every user with a YAML spec to convert to JSON before scoring. Most OpenAPI specs in the wild are YAML.
+
+The retro classifies F1/F2 (POST-as-query) and F3 (novel-command `--select`) as P1 because the audit named three or more concrete APIs that would benefit. F4 (scorecard YAML) is P2.
+
+---
+
+## Requirements
+
+- **R1.** `printing-press scorecard --spec foo.yaml` works for OpenAPI 3.x YAML files without conversion. (Origin: retro F4 / WU-3.)
+- **R2.** Hand-written novel commands honor `--select` and `--compact` for JSON output via a generator-emitted helper. The Phase 3 SKILL build instructions require novel commands to use the helper. (Origin: retro F3 / WU-2.)
+- **R3.** `methodIsWrite` (or its replacement) returns `false` for POST endpoints that are semantically queries — search, GraphQL, RPC-style — based on operation-id prefix, request-body shape, or annotations. (Origin: retro F1 / WU-1.)
+- **R4.** Promoted endpoints emit the correct HTTP verb in the generated command. POST-only endpoints that get promoted run successfully against the live endpoint. (Origin: retro F2 / WU-1.)
+
+**Origin actors:** the agent running `/printing-press` for any future API; the agent running `/printing-press-polish` against any existing CLI; users who pass an OpenAPI YAML spec to `scorecard` directly.
+
+**Origin acceptance examples** (carried verbatim from retro):
+- AE1 (covers R1): `scorecard --dir <cli> --spec foo.yaml` against an OpenAPI 3.x YAML spec returns scores byte-equivalent to running it against the same spec converted to JSON. Existing JSON specs and internal-YAML specs continue to work byte-identically.
+- AE2 (covers R2): a fresh CLI with a hand-written novel command running `<cli> <novel> --json --select foo,bar` returns only `foo` and `bar` fields. Endpoint-mirror commands continue to apply filters byte-identically. The same call via MCP `tools/call` honors the `select` argument.
+- AE3 (covers R3): a spec with `POST /search-all` (operation id `searchAll`) flips `HasWriteCommands` to false; the generated README emits "Read-only by default". A spec with `POST /graphql` (operation id `query*`) similarly classifies as read. A spec with `POST /users` (operation id `createUser`) keeps `HasWriteCommands` true.
+- AE4 (covers R4): a spec with `POST /search-all` promoted emits `c.Post(path, body)` and runs successfully against the live endpoint. A spec with `GET /resources` promoted continues emitting `c.Get(path, params)` byte-identically.
+
+---
+
+## Scope Boundaries
+
+- Does not refactor the existing 33+ library CLIs to use the new `printJSONFiltered` helper. The SKILL update + generator emit fix the next regeneration; existing CLIs catch up via `/printing-press-polish` and individual runs (out of scope here).
+- Does not change MCP `destructiveHint` annotations on POST endpoints. The retro flags this as a separate concern; revisit if the README/HasWriteCommands fix surfaces cross-impact.
+- Does not address the four other retro work units (WU-4 narrative-command verification, WU-5 browser-sniff direct-probe fallback, WU-6 proxy-envelope subclass fixes, WU-7 store-migration concurrency, WU-8 traffic-analysis version doc). Those land separately.
+- Does not change verify or dogfood spec loading. WU-3 fixes only `loadOpenAPISpec` in `internal/pipeline/scorecard.go`.
+
+### Deferred to Follow-Up Work
+
+- Backfilling existing library CLIs with `printJSONFiltered` calls in their hand-written novel commands: tracked by the polish skill on next invocation per CLI.
+- A separate sweep that flips `MCP destructiveHint` for POST-as-query endpoints once the new write-detection signals are in place.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/generator/generator.go` — current `methodIsWrite` (line search: `func methodIsWrite(method string) bool`) and the two callers `hasWriteCommands` / `resourceHasWriteCommand` that propagate it. Endpoint struct in `internal/spec/spec.go` carries `Method`, `Path`, `Description`, `Body []Param`, `Meta map[string]string`. The map key in `Resource.Endpoints` is the operation id (used elsewhere as the canonical name).
+- `internal/generator/templates/command_promoted.go.tmpl` — currently emits `data, err := c.Get(path, params)` unconditionally for the no-store branch. The `HasStore` branch already calls `resolveRead`/`resolvePaginatedRead`. Verb branching needs to slot in alongside both.
+- `internal/generator/templates/helpers.go.tmpl` — the existing emission point for `filterFields` (line 451), `compactFields` (line 601), `wrapWithProvenance` (line 1242), and `printOutputWithFlags`. Add `printJSONFiltered` here so it's emitted into every CLI's `internal/cli/helpers.go` alongside the helpers it composes.
+- `internal/pipeline/scorecard.go:loadOpenAPISpec` — current shape: read file → `isInternalYAMLSpec` check → fall through to `json.Unmarshal`. The pipeline package already imports YAML elsewhere (`fullrun.go`, `dogfood.go`, `workflow_manifest.go`).
+- `skills/printing-press/SKILL.md` — Phase 3 "Agent Build Checklist" section is where the new principle for `--select` plumbing belongs. Existing principles 1-10 cover non-interactive, structured output, progressive help, etc. Add a new principle (or extend principle 2 "Structured output") that requires `printJSONFiltered`.
+
+### Institutional Learnings
+
+- AGENTS.md "Code & Comment Hygiene" guidance: prefer mechanical fixes over instructional fixes. Both WU-1 (generator) and WU-2 (generator + skill) fit this — the skill instruction alone wouldn't reach existing CLIs, but the generator emit + skill instruction together do for regeneration.
+- AGENTS.md "Updating dependent verifiers in the same change": F1's fix touches `HasWriteCommands` which influences README emission. The README template branching (`{{- if .HasWriteCommands}}{{else}}Read-only by default{{end}}`) is already in place, so no template change required — only the detector logic. Confirmed during retro audit.
+
+### External References
+
+None needed — all three changes are internal to the printing-press repo with no external API contracts to preserve.
+
+---
+
+## Key Technical Decisions
+
+- **U1 (scorecard YAML) lands first.** Smallest surface, isolated to one function in the pipeline package, no cross-template implications. Establishes the test pattern for the next two units. Lowest risk for the build/test infrastructure.
+- **U2 (novel-command `--select`) lands second.** Self-contained: one new helper in `helpers.go.tmpl` plus one new principle in SKILL.md. No dependency on U1, but lands second because U3 may want to reference the build-checklist style established by U2's SKILL update.
+- **U3 (POST-as-query detection) lands last.** Highest blast radius — touches `methodIsWrite` (called from `hasWriteCommands`), the `command_promoted.go.tmpl` template (used by every CLI with a promoted endpoint), and indirectly the README/SKILL emission. Lands last so the simpler fixes have validated the test approach and any cross-template regression caught by golden tests is investigated in isolation.
+- **Don't change `methodIsWrite`'s signature.** Replace `methodIsWrite(method string) bool` with a new `endpointIsWriteCommand(endpoint spec.Endpoint, name string) bool` that takes the operation id (the map key). Keep `methodIsWrite` around as a thin wrapper for any non-endpoint callers (e.g., raw method-string contexts) so we don't ripple through unrelated code.
+- **Use the operation id as the primary semantic signal.** It's the most reliable cross-API signal — every spec parser and every endpoint mirror uses it. Body shape (request body present but only filter-shaped) is a weaker secondary signal. Annotations (`mcp:read-only` is the existing flag; check whether endpoints can carry it) are a tertiary signal.
+- **YAML loader: use `yaml.Unmarshal` from the same package the rest of the pipeline uses.** Don't add a new dependency. The `gopkg.in/yaml.v3` (or equivalent) module is already in `go.mod` per `fullrun.go`/`dogfood.go`.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should `printJSONFiltered` go in `helpers.go.tmpl` or a new `cliutil_*.go.tmpl`?** Resolved: `helpers.go.tmpl`. Same package as the helpers it composes (`filterFields`, `compactFields`); no need for a new emitted file when an existing one already owns the JSON-output helper surface. Existing `cliutil/` templates (`cliutil_fanout.go.tmpl`, `cliutil_text.go.tmpl`, etc.) hold cross-cutting utilities for novel-feature client code; the JSON output helpers are CLI-formatting concerns and belong with the rest of `helpers.go`.
+- **Should `methodIsWrite` change its signature or be replaced?** Resolved: introduce `endpointIsWriteCommand(endpoint, name)` as a new function; have `resourceHasWriteCommand` call the new function. Keep `methodIsWrite` exported as a thin shim so any cross-package callers continue to work. The semantic upgrade lives in the new function; the old one becomes a fallback for callers that only have a verb string.
+- **Should the YAML loader consolidate JSON parsing through `yaml.Unmarshal`?** Resolved: keep the YAML and JSON branches separate. YAML is a JSON superset and `yaml.Unmarshal` would accept JSON input, but separate branches preserve clear, format-specific error messages (a JSON syntax error reports as "parsing spec JSON" with the JSON parser's error; a YAML syntax error reports as "parsing OpenAPI YAML spec" with the YAML parser's error). The shrinkage from consolidation isn't worth the diagnostic ambiguity.
+
+### Deferred to Implementation
+
+- **Exact list of operation-id prefixes that signal "read".** Plan baseline: `get*, list*, search*, find*, query*, count*, describe*, fetch*`. The implementer should verify this against the catalog's existing specs (grep `operationId` patterns) and add anything that's clearly read-shaped before locking the list. The list is encoded as a slice of prefixes for testability.
+- **Whether to short-circuit `endpointIsWriteCommand` on `mcp:read-only` annotation.** Plan baseline: yes, check `endpoint.Meta["mcp:read-only"] == "true"` first. Confirm during implementation whether `Endpoint.Meta` is the right map (vs. a sibling annotation field) by reading current usages.
+- **Body-shape signal for "POST-as-query".** Plan baseline: when `endpoint.Body` contains only filter-style params (e.g., `query`, `filter`, `limit`, `offset`, `from`, `size`, `cursor`, `page`), classify as read. Implementer can refine the keyword list once they see how `Body []Param` is populated for the postman-explore search-all and any GraphQL CLI in the catalog.
+
+---
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```
+ Spec parser
+ │
+ ▼
+ Endpoint{ Method, Body, Meta, ... }
+ │
+ │ (called per endpoint, with operation id)
+ ▼
+ ┌────────────────────────────────────────────────────────────┐
+ │ endpointIsWriteCommand(endpoint, name) │
+ │ ──────────────────────────────────────────────────────── │
+ │ 1. mcp:read-only annotation set? → read (false) │
+ │ 2. Method ∈ {GET, HEAD, OPTIONS}? → read (false) │
+ │ 3. operationId prefix in {get,list,search,find,query,...} │
+ │ → read (false) │
+ │ 4. Body shape: only filter-style params? → read (false) │
+ │ 5. otherwise → write (true) │
+ └────────────────────────────────────────────────────────────┘
+ │
+ ▼
+ hasWriteCommands → README template
+ │
+ ▼
+ command_promoted.go.tmpl: branch on .Endpoint.Method
+ ┌─────────────────────────┬────────────────────────────┐
+ │ Method == "GET" │ Method != "GET" │
+ │ data, err := c.Get( │ data, _, err := c.Post( │
+ │ path, params) │ path, body) │
+ └─────────────────────────┴────────────────────────────┘
+```
+
+---
+
+## Implementation Units
+
+- U1. **Scorecard accepts OpenAPI YAML specs**
+
+**Goal:** `printing-press scorecard --spec foo.yaml` succeeds for OpenAPI 3.x YAML files without manual conversion. Existing JSON specs and internal-YAML specs continue to work byte-identically.
+
+**Requirements:** R1 (AE1).
+
+**Dependencies:** None.
+
+**Files:**
+- Modify: `internal/pipeline/scorecard.go` (the `loadOpenAPISpec` function only)
+- Test: `internal/pipeline/scorecard_tier2_test.go` (extend the existing test surface)
+
+**Approach:**
+- Add a `yaml.Unmarshal` fallback to `loadOpenAPISpec`: after the internal-YAML check fails and before the JSON fallback, attempt YAML decoding. The YAML branch produces the same `map[string]any` shape that the JSON branch consumes, so downstream code (paths/security extraction) doesn't change.
+- Match the existing import style for YAML in `internal/pipeline/` (whichever yaml package is already used in `fullrun.go`/`dogfood.go`).
+- Preserve the existing internal-YAML detection (`isInternalYAMLSpec`) — internal YAML and OpenAPI YAML are distinguishable by top-level keys. Internal YAML has `kind:` / `resources:` / `auth:` top-levels; OpenAPI has `openapi:` / `paths:` / `components:`.
+- Decision: keep YAML and JSON branches separate (don't consolidate via `yaml.Unmarshal` for both) to preserve clear error messages when a JSON spec has a syntax error vs. a YAML spec has one.
+
+**Patterns to follow:**
+- `internal/pipeline/fullrun.go` and `internal/pipeline/dogfood.go` for the existing YAML import in the package.
+- The `isInternalYAMLSpec` detection pattern already in `loadOpenAPISpec` for short-circuiting on top-level keys.
+
+**Test scenarios:**
+- Covers AE1. Happy path: feeding an OpenAPI 3.x YAML spec to `loadOpenAPISpec` returns a populated `*openAPISpecInfo` with the same `Paths` and `SecuritySchemes` as feeding the same spec converted to JSON.
+- Happy path: an internal-YAML spec (with `kind: yaml-internal` or whatever the current sentinel is) continues to short-circuit through the internal branch — verified by reading the round-tripped APISpec.
+- Happy path: an OpenAPI 3.x JSON spec continues to load via the existing JSON branch — verified by an unchanged paths/security output.
+- Error path: a YAML file with malformed top-level (e.g., a tab-indented `paths:`) returns a clear "parsing OpenAPI YAML spec" error message that includes the file path.
+- Error path: a JSON file with a syntax error returns the existing "parsing spec JSON" message — no regression in diagnostic clarity.
+- Edge case: an empty file (zero bytes) returns a non-nil error referencing the empty input. Confirm behavior matches whatever the existing JSON-fallback does with empty input.
+
+**Verification:**
+- `printing-press scorecard --dir <fixture-cli> --spec testdata/openapi-yaml-spec.yaml` runs to completion and produces the same total score as running it with the JSON form of the same spec.
+- All existing scorecard tests pass without changes.
+
+---
+
+- U2. **Hand-written novel commands honor `--select` and `--compact`**
+
+**Goal:** Generator emits a `printJSONFiltered` helper into every CLI's `internal/cli/helpers.go`, and the SKILL Phase 3 build checklist requires novel commands to use it. After this lands, the next regeneration of any CLI with novel features produces commands that respect `--select` and `--compact` on JSON output.
+
+**Requirements:** R2 (AE2).
+
+**Dependencies:** U1 (none functionally — but landing U1 first establishes the test pattern).
+
+**Files:**
+- Modify: `internal/generator/templates/helpers.go.tmpl` (add `printJSONFiltered` near the existing `filterFields`/`compactFields` functions — line vicinity 451-700)
+- Modify: `skills/printing-press/SKILL.md` (Phase 3 "Agent Build Checklist" section — add a new principle or extend principle 2 "Structured output")
+- Test: `internal/generator/helpers_template_test.go` (add or extend a golden-style test that builds a CLI from a fixture spec and asserts the emitted helpers.go contains `printJSONFiltered` with the right signature)
+
+**Approach:**
+- Add `printJSONFiltered(cmd interface{ OutOrStdout() io.Writer }, v any, flags *rootFlags) error` to `helpers.go.tmpl`. Marshal `v`, apply `filterFields(raw, flags.selectFields)` if `flags.selectFields != ""`, else apply `compactFields(raw)` if `flags.compact`, then encode with two-space indent.
+- The `cmd` parameter takes the minimal interface required (just `OutOrStdout()`) so the helper stays testable without importing cobra in test files.
+- Update `SKILL.md` Phase 3 "Agent Build Checklist" with a new principle (call it principle 11 or a sub-bullet under principle 2): "Novel commands MUST use `printJSONFiltered(cmd, v, flags)` for JSON output. Direct `flags.printJSON(cmd, v)` calls drop `--select` and `--compact`. Verify with `<cli> <novel-cmd> --json --select <field> | jq 'keys'` returning only the requested fields."
+- Don't refactor the existing 33+ library CLIs in this PR. Their next regeneration picks up the helper; their next polish run picks up the SKILL guidance for novel-feature additions.
+
+**Patterns to follow:**
+- The existing `filterFields` / `compactFields` / `wrapWithProvenance` style in `helpers.go.tmpl` — same indentation, same comment density, same parameter ordering convention.
+- Existing build-checklist principles 1-10 in `SKILL.md` for the SKILL.md addition's prose style.
+
+**Test scenarios:**
+- Covers AE2. Happy path: build a CLI from a small fixture spec that has a novel-feature command emitted via the new helper. Run `<cli> <novel> --json --select foo,bar` against the binary; assert the output JSON has exactly the keys `foo` and `bar` and nothing else.
+- Happy path: same fixture with `--json --compact` returns the trimmed field set per `compactFields`'s known blocklist.
+- Happy path: same fixture with `--json` (no select, no compact) returns the full field set.
+- Edge case: `--json --select foo,nonexistent_field` returns a JSON object containing `foo` and omits `nonexistent_field` (matches existing `filterFields` semantics).
+- Edge case: empty result set with `--json --select foo` returns `[]` (matches the empty-array path the helper takes).
+- Integration: emit the helper into a CLI fixture and grep the generated `internal/cli/helpers.go` for the function signature to assert the template produced it. This proves the generator emit, not just the SKILL doc.
+- Negative: existing endpoint-mirror commands' JSON output is unchanged (they use `printOutputWithFlags`, not `printJSONFiltered`). Confirmed by running an existing fixture's endpoint mirror with `--json --select` before and after the change and asserting byte-equivalence.
+
+**Verification:**
+- `go test ./internal/generator/...` passes including any new test that builds a fixture CLI and asserts `printJSONFiltered` is emitted.
+- A regenerated postman-explore CLI (run as a manual smoke test, not committed) shows `canonical stripe --json --select name,publisherHandle,forkCount` returning only those three fields.
+- `skills/printing-press/SKILL.md` diff renders cleanly and the new principle is in the Agent Build Checklist with the same numbering style as 1-10.
+
+---
+
+- U3. **Generator-aware POST-as-query detection**
+
+**Goal:** Stop the generator from treating POST endpoints as writes when they're queries (search, GraphQL, RPC). Fix the promoted-command template to emit the correct HTTP verb. Both changes are needed together to fully resolve postman-explore-style breakage; landing only one is a half-fix.
+
+**Requirements:** R3 (AE3), R4 (AE4).
+
+**Dependencies:** U2 (no functional dep; ordering is for review/test posture only).
+
+**Files:**
+- Modify: `internal/generator/generator.go` (introduce `endpointIsWriteCommand(endpoint spec.Endpoint, name string) bool` and route `resourceHasWriteCommand` through it; keep `methodIsWrite(method string) bool` as a thin shim for verb-only callers)
+- Modify: `internal/generator/templates/command_promoted.go.tmpl` (add a verb branch: `{{- if eq .Endpoint.Method "GET" }}c.Get(...){{else}}c.Post(...){{end}}`; gracefully handle the `Pagination` / `HasStore` matrix that already exists)
+- Test: `internal/generator/generator_test.go` (existing — add cases for `endpointIsWriteCommand` against contrived Endpoint values)
+- Test: `internal/generator/generator_promoted_test.go` (or wherever promoted-command emission is tested — add a fixture with a POST-promoted endpoint and assert the emitted code calls `c.Post`)
+
+**Approach:**
+- New function `endpointIsWriteCommand(endpoint spec.Endpoint, name string) bool` checks signals in this order:
+ 1. If `endpoint.Meta["mcp:read-only"] == "true"`, return false. (Verify `Meta` is the right field by reading current usages.)
+ 2. If `methodIsWrite(endpoint.Method)` is false (i.e., GET/HEAD/OPTIONS), return false.
+ 3. If `name` (the operation id, which is the map key) starts with any of `get`, `list`, `search`, `find`, `query`, `count`, `describe`, `fetch`, return false. (Match case-insensitively to match Postman's `searchAll` and any `Search*` etc.)
+ 4. If `endpoint.Body` is non-empty but every param has a name in `{query, queryText, filter, limit, offset, from, size, cursor, page, pageSize, sort, sortBy}`, return false. (Filter-shaped body = query semantics.)
+ 5. Otherwise, return true (genuine write).
+- Refactor `resourceHasWriteCommand` to iterate `for name, endpoint := range resource.Endpoints` and call `endpointIsWriteCommand(endpoint, name)`. The function in `generator.go` is the only caller of the existing `methodIsWrite` for write detection; keep `methodIsWrite` exported for any out-of-package callers.
+- Update `command_promoted.go.tmpl` to add a verb branch in the no-store fallback (line ~bottom of the no-pagination, no-store path):
+ ```
+ {{- if eq .Endpoint.Method "GET" }}
+ data, err := c.Get(path, params)
+ {{- else }}
+ data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, body)
+ {{- end }}
+ ```
+ The `body` value comes from the same Body marshalling that the underlying typed POST command uses (read the equivalent typed-command template for the pattern).
+- The `HasStore` and `Pagination` branches in the same template should also gain verb awareness if they hit POST endpoints. Audit during implementation whether `resolveRead` / `resolvePaginatedRead` already handle non-GET, or if they need a sibling. This is the most uncertain part of the implementation; budget time for it.
+
+**Execution note:** Add the test cases first. The semantic-signal logic in `endpointIsWriteCommand` is the most novel part; building tests against `Endpoint` fixtures up front locks the intended behavior before refactoring the dispatcher.
+
+**Patterns to follow:**
+- `internal/generator/generator.go` existing `hasWriteCommands` / `resourceHasWriteCommand` for the iteration shape.
+- The existing `{{- if .HasStore}}{{else}}{{end}}` branching style in `command_promoted.go.tmpl` for the verb branch.
+- `internal/generator/generator_test.go` for the test fixture setup pattern (build a `spec.Endpoint` literal, assert on classification).
+- The typed POST endpoint template (likely `command.go.tmpl` or similar) for the `c.Post(path, body)` call shape and how `body` is constructed from `endpoint.Body` params.
+
+**Test scenarios:**
+- Covers AE3. Happy path: `POST /search-all` with operation id `searchAll`, body `[{queryText, size, from}]` classifies as read. `HasWriteCommands` for a resource containing only this endpoint returns false. README emits "Read-only by default" for a fixture spec with only this endpoint.
+- Covers AE3. Happy path: `POST /graphql` with operation id `query` or `queryRoot` and body `[{query, variables}]` classifies as read.
+- Covers AE3. Happy path: `POST /users` with operation id `createUser` and body `[{name, email, role}]` (no filter-shaped names) classifies as write.
+- Edge case: GET endpoint with operation id `deleteUser` (nonsensical but possible from poor specs) classifies as read because verb is GET. Document the behavior — verb wins over name when verb is read-shaped.
+- Edge case: POST endpoint with no body and no operation id signal classifies as write (fail-closed).
+- Edge case: endpoint with `Meta["mcp:read-only"] = "true"` classifies as read regardless of verb or name.
+- Covers AE4. Integration: a fixture spec with `POST /search-all` promoted and emitted by the generator produces a Cobra command whose RunE calls `c.Post("/search-all", body)` — verified by greping the generated command file. The emitted code compiles (`go build`).
+- Covers AE4. Integration: a fixture spec with `GET /resources` promoted continues to emit `c.Get("/resources", params)` byte-identically. Confirms no regression in the GET path.
+- Negative: existing fixtures used by golden-style tests in `internal/generator/` that include POST endpoints (CRUD APIs) continue to classify them as writes — confirmed by re-running the existing test suite without changes to its expectations.
+
+**Verification:**
+- `go test ./internal/generator/...` passes including new endpoint-classification tests.
+- Manually regenerate postman-explore (in a scratch dir) and confirm: README shows "Read-only by default", and the promoted `search-all` command (now unhidden, since it works) calls `c.Post` and returns real data against the live endpoint.
+- Existing CRUD-shape fixtures (the golden-test specs) still classify their POST/PUT/DELETE endpoints as writes; their generated READMEs still emit Retryable bullets.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph (U3 only):** `endpointIsWriteCommand` feeds `hasWriteCommands` which feeds `HasWriteCommands` in template data which gates the README's Retryable section AND drives any other consumer of `HasWriteCommands` (audit during implementation — at minimum the README template, possibly SKILL prose). The promoted-command template change has no upstream effects but is gated on `.Endpoint.Method` which is already part of the template data.
+- **Error propagation:** No changes to error handling. `loadOpenAPISpec` (U1) returns the same error type from the YAML branch as the JSON branch.
+- **State lifecycle risks:** None — all three changes are pure code generation / classification. No runtime state involved.
+- **API surface parity:** U2's `printJSONFiltered` is a new exported helper in every regenerated CLI's `internal/cli/helpers.go`. It composes existing exports; no API surface is removed.
+- **Integration coverage:** U3's verb-branch fix needs an end-to-end smoke against a real POST-promoted endpoint (postman-explore is the natural fixture; postman is no-auth so the smoke is cheap). U2's helper needs a regenerate-and-run smoke against a fixture novel command. U1's YAML support is unit-testable in isolation.
+- **Unchanged invariants:** Existing internal-YAML specs continue to load via the same code path. Existing OpenAPI JSON specs continue to load via the same code path. The `methodIsWrite` function continues to exist for any cross-package callers. The `flags.printJSON` helper is not removed; existing endpoint-mirror commands continue to use `printOutputWithFlags`.
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| U3's verb-branch fix in `command_promoted.go.tmpl` interacts with the existing `HasStore`/`Pagination` matrix in non-obvious ways. The store branches use `resolveRead` / `resolvePaginatedRead` which may not have POST equivalents. | Audit the template and the resolver helpers during U3 implementation. Budget time to either add POST equivalents or scope the fix to the no-store branch and explicitly defer the HasStore+POST combination as a follow-up. |
+| The operation-id prefix list in U3 misses prefixes used by some specs in the catalog, causing legitimate write commands to be misclassified as reads. | Test against the catalog's existing specs (grep for `operationId:` in `catalog/specs/*.yaml`) before locking the prefix list. Lean toward fail-closed: when in doubt, classify as write. |
+| Golden tests in `internal/generator/` lock in specific generated output for fixture specs. U3's emit changes (verb branch in promoted template) may break golden tests that include promoted POST endpoints. | Run `scripts/golden.sh verify` before and after each unit. If a fixture's emitted code legitimately changes for the better, run `scripts/golden.sh update` and review the diff per AGENTS.md guidance. If a fixture breaks because of an unintended side effect, fix the code. |
+| YAML import collision: pipeline package may import a different YAML package than the spec parser. | Check `go.mod` and `go list -m all` before the U1 implementation; use whichever YAML package the pipeline already imports rather than introducing a new dependency. |
+
+---
+
+## Documentation / Operational Notes
+
+- **CHANGELOG / commit messages:** Each unit lands as a separate commit (or PR) with the conventional-commit prefix `fix(cli):` for U1 and U3, `feat(cli,skills):` for U2. The retro issue #423 is referenced in the body. Per `AGENTS.md`, a `fix:` triggers a patch bump and a `feat:` triggers a minor bump in the next release.
+- **Polish skill follow-up:** After U2 lands, the next `/printing-press-polish` invocation per CLI in the library should detect existing `flags.printJSON` calls in hand-written novel commands and offer to swap them. That's tracked separately and not part of this plan; the deferred-work section already names it.
+- **Docs/PIPELINE.md and docs/SKILLS.md:** Neither needs edits for this plan. The phase contract isn't changing; the SKILL update is a single principle addition.
+
+---
+
+## Sources & References
+
+- **Origin retro issue:** https://github.com/mvanhorn/cli-printing-press/issues/423
+- **Origin retro doc (full findings):** https://files.catbox.moe/84v2w8.md (also at `manuscripts/postman-explore/20260429-230407/proofs/20260430-004209-retro-postman-explore-pp-cli.md`)
+- **Postman-explore PR:** https://github.com/mvanhorn/printing-press-library/pull/159 (where the `--select` fix shipped as a printed-CLI workaround; this plan generalizes the fix to the generator)
+- **Related code:** `internal/generator/generator.go:methodIsWrite`, `internal/generator/templates/command_promoted.go.tmpl`, `internal/generator/templates/helpers.go.tmpl`, `internal/pipeline/scorecard.go:loadOpenAPISpec`, `skills/printing-press/SKILL.md` Phase 3 Agent Build Checklist.
diff --git a/internal/generator/endpoint_is_write_test.go b/internal/generator/endpoint_is_write_test.go
new file mode 100644
index 00000000..818d3798
--- /dev/null
+++ b/internal/generator/endpoint_is_write_test.go
@@ -0,0 +1,371 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestEndpointIsWriteCommand covers the four classification signals: HTTP
+// verb, operationId prefix, body shape, and the mcp:read-only annotation.
+// POST endpoints used as queries (search, GraphQL, RPC-style) must classify
+// as reads; genuine mutations must classify as writes regardless of body
+// shape coincidence.
+func TestEndpointIsWriteCommand(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ opName string
+ endpoint spec.Endpoint
+ want bool
+ }{
+ {
+ name: "GET endpoint is read",
+ opName: "listUsers",
+ endpoint: spec.Endpoint{Method: "GET", Path: "/users"},
+ want: false,
+ },
+ {
+ name: "HEAD endpoint is read",
+ opName: "headStatus",
+ endpoint: spec.Endpoint{Method: "HEAD", Path: "/status"},
+ want: false,
+ },
+ {
+ name: "POST search endpoint is read (operationId prefix searchAll)",
+ opName: "searchAll",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/search-all",
+ Body: []spec.Param{
+ {Name: "queryText", Type: "string"},
+ {Name: "size", Type: "integer"},
+ {Name: "from", Type: "integer"},
+ },
+ },
+ want: false,
+ },
+ {
+ name: "POST GraphQL is read (operationId prefix query)",
+ opName: "query",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/graphql",
+ Body: []spec.Param{
+ {Name: "query", Type: "string"},
+ {Name: "variables", Type: "object"},
+ },
+ },
+ want: false,
+ },
+ {
+ name: "POST list-style is read (operationId prefix list)",
+ opName: "listFilteredItems",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/items/list",
+ Body: []spec.Param{
+ {Name: "filter", Type: "object"},
+ },
+ },
+ want: false,
+ },
+ {
+ name: "POST find-style is read",
+ opName: "findCustomers",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/customers/find"},
+ want: false,
+ },
+ {
+ name: "POST count-style is read",
+ opName: "countOrders",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/orders/count"},
+ want: false,
+ },
+ {
+ name: "POST fetch-style is read",
+ opName: "fetchEvents",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/events/fetch"},
+ want: false,
+ },
+ {
+ name: "POST describe-style is read",
+ opName: "describeWorkspace",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/workspaces/describe"},
+ want: false,
+ },
+ {
+ name: "POST create endpoint stays write",
+ opName: "createUser",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/users",
+ Body: []spec.Param{
+ {Name: "name", Type: "string"},
+ {Name: "email", Type: "string"},
+ {Name: "role", Type: "string"},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "POST add endpoint stays write",
+ opName: "addItemToCart",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/cart/items"},
+ want: true,
+ },
+ {
+ name: "PUT update endpoint stays write",
+ opName: "updateUser",
+ endpoint: spec.Endpoint{Method: "PUT", Path: "/users/{id}"},
+ want: true,
+ },
+ {
+ name: "PATCH partial-update endpoint stays write",
+ opName: "patchOrder",
+ endpoint: spec.Endpoint{Method: "PATCH", Path: "/orders/{id}"},
+ want: true,
+ },
+ {
+ name: "DELETE endpoint stays write",
+ opName: "deleteUser",
+ endpoint: spec.Endpoint{Method: "DELETE", Path: "/users/{id}"},
+ want: true,
+ },
+ {
+ name: "POST endpoint with no body and no semantic signal is write (fail-closed)",
+ opName: "doSomething",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/something"},
+ want: true,
+ },
+ {
+ name: "POST endpoint with mcp:read-only annotation is read regardless of name",
+ opName: "doMutation",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/widgets",
+ Meta: map[string]string{"mcp:read-only": "true"},
+ },
+ want: false,
+ },
+ {
+ name: "operationId prefix matching is case-insensitive",
+ opName: "SearchCollections",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/search/collections"},
+ want: false,
+ },
+ {
+ name: "POST with only filter-shape body params is read",
+ opName: "doQuery",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/widgets/query",
+ Body: []spec.Param{
+ {Name: "filter", Type: "object"},
+ {Name: "limit", Type: "integer"},
+ {Name: "offset", Type: "integer"},
+ {Name: "sort", Type: "string"},
+ },
+ },
+ want: false,
+ },
+ {
+ name: "POST with mixed filter and write-shape body params stays write",
+ opName: "doStuff",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/widgets",
+ Body: []spec.Param{
+ {Name: "filter", Type: "object"},
+ {Name: "name", Type: "string"}, // write-shape
+ },
+ },
+ want: true,
+ },
+ {
+ name: "POST getOrCreate flips back to write (read-shaped leading token, mutation in tail)",
+ opName: "getOrCreateUser",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/users"},
+ want: true,
+ },
+ {
+ name: "POST fetchAndUpdate flips back to write (mutation token in tail)",
+ opName: "fetchAndUpdateProfile",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/profile"},
+ want: true,
+ },
+ {
+ name: "POST listAndDelete flips back to write",
+ opName: "listAndDeleteOrphans",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/orphans"},
+ want: true,
+ },
+ {
+ name: "leading-token match is whole-word, not prefix substring (getter is not get)",
+ opName: "getter",
+ endpoint: spec.Endpoint{Method: "POST", Path: "/getter"},
+ want: true, // single-token "getter" — not the literal "get" verb, fail-closed
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := endpointIsWriteCommand(tc.endpoint, tc.opName)
+ assert.Equal(t, tc.want, got, "endpointIsWriteCommand(%q) returned wrong classification", tc.opName)
+ })
+ }
+}
+
+// TestHasWriteCommands_PostAsQueryFlipsHasWriteFalse verifies the
+// classifier propagates through resourceHasWriteCommand and hasWriteCommands
+// so a resource containing only a POST search endpoint flips HasWriteCommands
+// to false — that signal drives the README's read-only branching.
+func TestHasWriteCommands_PostAsQueryFlipsHasWriteFalse(t *testing.T) {
+ t.Parallel()
+
+ resources := map[string]spec.Resource{
+ "search": {
+ Description: "Search the public network",
+ Endpoints: map[string]spec.Endpoint{
+ "searchAll": {
+ Method: "POST",
+ Path: "/search-all",
+ Body: []spec.Param{
+ {Name: "queryText", Type: "string"},
+ },
+ },
+ },
+ },
+ }
+
+ assert.False(t, hasWriteCommands(resources),
+ "a resource with only POST search endpoints should classify as read-only")
+}
+
+// TestPromotedCommandVerbBranching covers the integration path: the
+// rendered promoted command emits the same HTTP verb the spec declared,
+// so a POST-only endpoint hits c.Post and a GET-only endpoint stays on
+// c.Get/resolveRead.
+func TestPromotedCommandVerbBranching(t *testing.T) {
+ cases := []struct {
+ name string
+ apiName string
+ resourceName string
+ endpointName string
+ endpoint spec.Endpoint
+ mustContain []string
+ mustNotHave []string
+ }{
+ {
+ name: "POST endpoint emits c.Post",
+ apiName: "post-promoted",
+ resourceName: "queries",
+ endpointName: "searchAll",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/search-all",
+ Description: "Search collections by free text",
+ Body: []spec.Param{{Name: "queryText", Type: "string"}},
+ },
+ mustContain: []string{"c.Post("},
+ mustNotHave: []string{"c.Get(path, params)"},
+ },
+ {
+ name: "GET endpoint keeps c.Get / resolveRead",
+ apiName: "get-promoted",
+ resourceName: "items",
+ endpointName: "listItems",
+ endpoint: spec.Endpoint{
+ Method: "GET",
+ Path: "/items",
+ Description: "List items",
+ },
+ mustNotHave: []string{"c.Post(", "c.Put(", "c.Patch("},
+ },
+ {
+ // HEAD / OPTIONS aren't supported by the generated client.
+ // Falling back to c.Get keeps generation compileable; the only
+ // alternative would be emitting an undefined method like c.Head.
+ name: "HEAD endpoint falls back to c.Get",
+ apiName: "head-promoted",
+ resourceName: "probes",
+ endpointName: "headStatus",
+ endpoint: spec.Endpoint{
+ Method: "HEAD",
+ Path: "/status",
+ Description: "Probe status",
+ },
+ mustContain: []string{"c.Get(path, params)"},
+ mustNotHave: []string{"c.Head(", "c.Options("},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec(tc.apiName)
+ apiSpec.Resources = map[string]spec.Resource{
+ tc.resourceName: {
+ Description: tc.resourceName,
+ Endpoints: map[string]spec.Endpoint{tc.endpointName: tc.endpoint},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), tc.apiName+"-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ src := readPromotedCommandFile(t, outputDir)
+ for _, want := range tc.mustContain {
+ require.Contains(t, src, want)
+ }
+ for _, banned := range tc.mustNotHave {
+ require.NotContains(t, src, banned)
+ }
+ })
+ }
+}
+
+// readPromotedCommandFile finds the single promoted_*.go file the generator
+// emits for a fixture spec with one resource. Naming varies (resource name
+// vs. kebabed endpoint name vs. camelCase), so the lookup glob-matches.
+func readPromotedCommandFile(t *testing.T, outputDir string) string {
+ t.Helper()
+ matches, err := filepath.Glob(filepath.Join(outputDir, "internal", "cli", "promoted_*.go"))
+ require.NoError(t, err)
+ require.Len(t, matches, 1, "expected exactly one promoted command file in internal/cli/")
+ content, err := os.ReadFile(matches[0])
+ require.NoError(t, err)
+ return string(content)
+}
+
+// TestHasWriteCommands_GenuineMutationsStayTrue is the negative guard: a
+// POST createUser endpoint with a write-shape body must still classify as a
+// write so the README keeps emitting the mutation-aware Agent Usage bullets.
+func TestHasWriteCommands_GenuineMutationsStayTrue(t *testing.T) {
+ t.Parallel()
+
+ resources := map[string]spec.Resource{
+ "users": {
+ Description: "User accounts",
+ Endpoints: map[string]spec.Endpoint{
+ "createUser": {
+ Method: "POST",
+ Path: "/users",
+ Body: []spec.Param{
+ {Name: "name", Type: "string"},
+ {Name: "email", Type: "string"},
+ },
+ },
+ },
+ },
+ }
+
+ assert.True(t, hasWriteCommands(resources),
+ "a POST endpoint with a write-shape operationId and body should classify as write")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 4f273083..243fbb82 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -760,8 +760,8 @@ func hasWriteCommands(resources map[string]spec.Resource) bool {
}
func resourceHasWriteCommand(resource spec.Resource) bool {
- for _, endpoint := range resource.Endpoints {
- if methodIsWrite(endpoint.Method) {
+ for name, endpoint := range resource.Endpoints {
+ if endpointIsWriteCommand(endpoint, name) {
return true
}
}
@@ -773,6 +773,8 @@ func resourceHasWriteCommand(resource spec.Resource) bool {
return false
}
+// methodIsWrite is the verb-only fallback. Prefer endpointIsWriteCommand
+// when an Endpoint is in hand.
func methodIsWrite(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case "POST", "PUT", "PATCH", "DELETE":
@@ -782,6 +784,120 @@ func methodIsWrite(method string) bool {
}
}
+// readOperationIDPrefixes signal a read regardless of HTTP verb. Matched
+// against the leading camelCase token of the operation id, case-insensitive.
+// Whole-token (not substring) matching avoids false reads on names like
+// "getter" or "listenerStart" while still catching "getUser", "listOrders".
+var readOperationIDPrefixes = map[string]bool{
+ "get": true,
+ "list": true,
+ "search": true,
+ "find": true,
+ "query": true,
+ "count": true,
+ "describe": true,
+ "fetch": true,
+}
+
+// writeOperationIDFragments name mutations. When a read-shaped leading token
+// is followed by one of these (e.g. getOrCreate, fetchAndUpdate), the
+// classifier flips back to write — the leading verb was misleading.
+var writeOperationIDFragments = map[string]bool{
+ "create": true,
+ "update": true,
+ "delete": true,
+ "remove": true,
+ "add": true,
+ "insert": true,
+ "set": true,
+ "upsert": true,
+ "save": true,
+}
+
+// readBodyParamNames are filter-shape body field names. A POST whose body
+// params are entirely drawn from this set is acting as a query, not a
+// mutation; mixing in any unknown name flips the endpoint back to write.
+var readBodyParamNames = map[string]bool{
+ "query": true,
+ "querytext": true,
+ "q": true,
+ "filter": true,
+ "filters": true,
+ "limit": true,
+ "offset": true,
+ "from": true,
+ "size": true,
+ "cursor": true,
+ "page": true,
+ "pagesize": true,
+ "sort": true,
+ "sortby": true,
+ "orderby": true,
+}
+
+// endpointIsWriteCommand returns true when the endpoint mutates external
+// state. Read signals are checked in cost order: annotation, verb, name
+// token, body shape. Fail-closed when none fire so unknown shapes stay
+// classified as writes.
+//
+// opName is the map key from Resource.Endpoints (the operation id).
+func endpointIsWriteCommand(endpoint spec.Endpoint, opName string) bool {
+ if v, ok := endpoint.Meta["mcp:read-only"]; ok && strings.EqualFold(strings.TrimSpace(v), "true") {
+ return false
+ }
+ if !methodIsWrite(endpoint.Method) {
+ return false
+ }
+ tokens := camelCaseTokens(strings.TrimSpace(opName))
+ if len(tokens) > 0 && readOperationIDPrefixes[strings.ToLower(tokens[0])] {
+ for _, tok := range tokens[1:] {
+ if writeOperationIDFragments[strings.ToLower(tok)] {
+ return true
+ }
+ }
+ return false
+ }
+ return !bodyIsAllFilterShape(endpoint.Body)
+}
+
+// camelCaseTokens splits "getOrCreate" → ["get", "Or", "Create"] and
+// "searchAll" → ["search", "All"]. Non-letter runes (digits, separators)
+// stay attached to the preceding token.
+func camelCaseTokens(s string) []string {
+ if s == "" {
+ return nil
+ }
+ var tokens []string
+ var cur []rune
+ for _, r := range s {
+ if unicode.IsUpper(r) && len(cur) > 0 {
+ tokens = append(tokens, string(cur))
+ cur = []rune{r}
+ continue
+ }
+ cur = append(cur, r)
+ }
+ if len(cur) > 0 {
+ tokens = append(tokens, string(cur))
+ }
+ return tokens
+}
+
+// bodyIsAllFilterShape reports whether every body param's name is in
+// readBodyParamNames. Returns false for empty bodies so a POST with no body
+// (the fail-closed default) stays classified as a write.
+func bodyIsAllFilterShape(body []spec.Param) bool {
+ if len(body) == 0 {
+ return false
+ }
+ for _, p := range body {
+ if !readBodyParamNames[strings.ToLower(strings.TrimSpace(p.Name))] {
+ return false
+ }
+ }
+ return true
+}
+
func (g *Generator) templateData() *generatorTemplateData {
return &generatorTemplateData{
APISpec: g.Spec,
diff --git a/internal/generator/print_json_filtered_test.go b/internal/generator/print_json_filtered_test.go
new file mode 100644
index 00000000..7c223faa
--- /dev/null
+++ b/internal/generator/print_json_filtered_test.go
@@ -0,0 +1,33 @@
+package generator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestPrintJSONFiltered_EmittedIntoHelpers verifies the generator emits the
+// printJSONFiltered helper into every CLI's internal/cli/helpers.go. The
+// helper composes printOutputWithFlags so novel commands honor --select,
+// --compact, --csv, and --quiet identically to endpoint-mirror commands;
+// asserting it delegates rather than re-implements keeps the two paths
+// aligned when printOutputWithFlags evolves.
+func TestPrintJSONFiltered_EmittedIntoHelpers(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("filtered-print")
+ outputDir := filepath.Join(t.TempDir(), "filtered-print-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ helpersPath := filepath.Join(outputDir, "internal", "cli", "helpers.go")
+ content, err := os.ReadFile(helpersPath)
+ require.NoError(t, err)
+ src := string(content)
+
+ require.Contains(t, src, "func printJSONFiltered(",
+ "helpers.go must export printJSONFiltered for novel commands")
+ require.Contains(t, src, "printOutputWithFlags(w, json.RawMessage(raw), flags)",
+ "printJSONFiltered must delegate to printOutputWithFlags so flag handling stays unified")
+}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index d139e444..8f14db68 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -138,8 +138,23 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- end}}
{{- end}}
{{- if .HasStore}}
+ // resolveRead is GET-only internally, so HasStore + non-GET should
+ // be marked Hidden in the printed CLI and reached via the typed
+ // `<resource> <endpoint>` form instead.
data, prov, err := resolveRead(c, flags, "{{lower .ResourceName}}", {{if .Endpoint.Pagination}}true{{else}}false{{end}}, path, params, nil)
+{{- else if eq (upper .Endpoint.Method) "GET"}}
+ data, err := c.Get(path, params)
+{{- else if eq (upper .Endpoint.Method) "DELETE"}}
+ data, _, err := c.Delete(path)
+{{- else if or (eq (upper .Endpoint.Method) "POST") (eq (upper .Endpoint.Method) "PUT") (eq (upper .Endpoint.Method) "PATCH")}}
+ // params (built from query/path Params) is sent as the request body.
+ // Rich body shapes should use the typed `<resource> <endpoint>`
+ // command, which declares each body field as a flag.
+ data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, params)
{{- else}}
+ // HEAD/OPTIONS and unknown verbs fall back to GET so generation
+ // stays compileable. Spec authors who genuinely need these verbs
+ // should add a typed client method and a dedicated template branch.
data, err := c.Get(path, params)
{{- end}}
{{- end}}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index d9577bb2..02b91c31 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -445,6 +445,18 @@ func paginatedGet(c interface {
return json.RawMessage(result), nil
}
+// printJSONFiltered marshals a Go-typed value through the same output
+// pipeline endpoint-mirror commands use. Hand-written novel commands that
+// build a typed slice/struct call this so --select, --compact, --csv, and
+// --quiet all behave the same way as on generator-emitted commands.
+func printJSONFiltered(w io.Writer, v any, flags *rootFlags) error {
+ raw, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ return printOutputWithFlags(w, json.RawMessage(raw), flags)
+}
+
// filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.
// Supports dotted paths like "events.shortName" to descend into nested structures.
// Arrays are traversed element-wise: "events.shortName" keeps shortName on each event.
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 2d623659..cc1667f8 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -1,6 +1,7 @@
package pipeline
import (
+ "bytes"
"encoding/json"
"fmt"
"os"
@@ -11,6 +12,7 @@ import (
"strings"
apispec "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+ "gopkg.in/yaml.v3"
)
// infraCoreFiles are CLI infrastructure files excluded from workflow/insight scoring.
@@ -1515,9 +1517,28 @@ func loadOpenAPISpec(specPath string) (*openAPISpecInfo, error) {
return internalSpecToOpenAPISpecInfo(internal), nil
}
+ // Strip a UTF-8 BOM if present so editors that emit one (Windows
+ // Notepad, some VS Code locales) don't fail content sniffing or
+ // json.Unmarshal, which both reject leading BOM bytes.
+ data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
+
+ // Distinguish OpenAPI JSON from OpenAPI YAML by content sniffing the
+ // first non-whitespace byte. JSON objects start with `{`; YAML mappings
+ // start with letters/quotes. yaml.Unmarshal would accept either format,
+ // but separate branches preserve format-specific error messages.
var raw map[string]any
- if err := json.Unmarshal(data, &raw); err != nil {
- return nil, fmt.Errorf("parsing spec JSON: %w", err)
+ trimmed := bytes.TrimLeft(data, " \t\r\n")
+ if len(trimmed) == 0 {
+ return nil, fmt.Errorf("spec file %s is empty", specPath)
+ }
+ if trimmed[0] == '{' {
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return nil, fmt.Errorf("parsing spec JSON: %w", err)
+ }
+ } else {
+ if err := yaml.Unmarshal(data, &raw); err != nil {
+ return nil, fmt.Errorf("parsing OpenAPI YAML spec: %w", err)
+ }
}
info := &openAPISpecInfo{
diff --git a/internal/pipeline/scorecard_tier2_test.go b/internal/pipeline/scorecard_tier2_test.go
index 194a5186..297d2192 100644
--- a/internal/pipeline/scorecard_tier2_test.go
+++ b/internal/pipeline/scorecard_tier2_test.go
@@ -1533,6 +1533,180 @@ func TestLoadOpenAPISpec_Swagger20SecurityDefinitions(t *testing.T) {
})
}
+// TestLoadOpenAPISpec_OpenAPIYAML covers the YAML branch that lets
+// scorecard --spec foo.yaml work without converting to JSON first.
+// The function prefers JSON when the spec begins with `{` and falls
+// back to yaml.Unmarshal otherwise so the JSON branch's error message
+// stays diagnostic for malformed JSON inputs.
+func TestLoadOpenAPISpec_OpenAPIYAML(t *testing.T) {
+ t.Run("OpenAPI YAML produces same paths and security as JSON form", func(t *testing.T) {
+ dir := t.TempDir()
+ yamlPath := filepath.Join(dir, "openapi.yaml")
+ writeScorecardFixture(t, dir, "openapi.yaml", `openapi: "3.0.3"
+info:
+ title: Test API
+ version: "1.0.0"
+paths:
+ /users:
+ get:
+ operationId: listUsers
+ responses:
+ "200":
+ description: ok
+ /widgets:
+ post:
+ operationId: createWidget
+ responses:
+ "201":
+ description: created
+components:
+ securitySchemes:
+ bearerAuth:
+ type: http
+ scheme: bearer
+`)
+
+ info, err := loadOpenAPISpec(yamlPath)
+ assert.NoError(t, err)
+ assert.Equal(t, []string{"/users", "/widgets"}, info.Paths)
+ scheme := info.SecuritySchemes["bearerAuth"]
+ assert.Equal(t, "http", scheme.Type)
+ assert.Equal(t, "bearer", scheme.Scheme)
+ })
+
+ t.Run("equivalent JSON and YAML specs produce equivalent info", func(t *testing.T) {
+ dir := t.TempDir()
+ jsonPath := filepath.Join(dir, "spec.json")
+ yamlPath := filepath.Join(dir, "spec.yaml")
+
+ writeScorecardFixture(t, dir, "spec.json", `{
+ "openapi": "3.0.3",
+ "paths": {
+ "/items": { "get": { "responses": { "200": { "description": "ok" } } } }
+ },
+ "components": {
+ "securitySchemes": {
+ "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" }
+ }
+ }
+}`)
+ writeScorecardFixture(t, dir, "spec.yaml", `openapi: "3.0.3"
+paths:
+ /items:
+ get:
+ responses:
+ "200":
+ description: ok
+components:
+ securitySchemes:
+ apiKey:
+ type: apiKey
+ in: header
+ name: X-API-Key
+`)
+
+ jsonInfo, err := loadOpenAPISpec(jsonPath)
+ assert.NoError(t, err)
+ yamlInfo, err := loadOpenAPISpec(yamlPath)
+ assert.NoError(t, err)
+
+ assert.Equal(t, jsonInfo.Paths, yamlInfo.Paths)
+ assert.Equal(t, jsonInfo.SecuritySchemes, yamlInfo.SecuritySchemes)
+ })
+
+ t.Run("malformed YAML returns OpenAPI YAML error", func(t *testing.T) {
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "broken.yaml")
+ // Mixing tabs and spaces is a classic YAML structural failure.
+ writeScorecardFixture(t, dir, "broken.yaml", "openapi: \"3.0.3\"\npaths:\n\t/users:\n get: {}\n")
+
+ _, err := loadOpenAPISpec(specPath)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "parsing OpenAPI YAML spec")
+ })
+
+ t.Run("malformed JSON keeps the JSON-specific error message", func(t *testing.T) {
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "broken.json")
+ // Trailing comma is invalid JSON; whitespace-leading `{` routes to JSON branch.
+ writeScorecardFixture(t, dir, "broken.json", `{ "paths": { "/x": {} }, }`)
+
+ _, err := loadOpenAPISpec(specPath)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "parsing spec JSON")
+ assert.NotContains(t, err.Error(), "parsing OpenAPI YAML spec")
+ })
+
+ t.Run("internal YAML spec still routes through internal branch", func(t *testing.T) {
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "internal.yaml")
+ writeScorecardFixture(t, dir, "internal.yaml", `name: example
+display_name: Example API
+description: Test fixture for internal-YAML routing
+base_url: https://api.example.com
+auth:
+ type: bearer_token
+ env_vars:
+ - EXAMPLE_TOKEN
+resources:
+ users:
+ description: User accounts
+ endpoints:
+ list:
+ method: GET
+ path: /users
+`)
+
+ info, err := loadOpenAPISpec(specPath)
+ assert.NoError(t, err)
+ assert.NotNil(t, info, "internal-YAML branch should produce a non-nil info")
+ })
+
+ t.Run("leading whitespace before { still detects JSON", func(t *testing.T) {
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "indented.json")
+ writeScorecardFixture(t, dir, "indented.json", "\n\n {\"paths\": {\"/x\": {}}}")
+
+ info, err := loadOpenAPISpec(specPath)
+ assert.NoError(t, err)
+ assert.Equal(t, []string{"/x"}, info.Paths)
+ })
+
+ t.Run("empty file returns explicit error", func(t *testing.T) {
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "empty.yaml")
+ writeScorecardFixture(t, dir, "empty.yaml", "")
+
+ _, err := loadOpenAPISpec(specPath)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "empty")
+ })
+
+ t.Run("whitespace-only file returns explicit error", func(t *testing.T) {
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "blank.yaml")
+ writeScorecardFixture(t, dir, "blank.yaml", " \n\n\t \n")
+
+ _, err := loadOpenAPISpec(specPath)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "empty")
+ })
+
+ t.Run("UTF-8 BOM-prefixed JSON still detects JSON branch", func(t *testing.T) {
+ dir := t.TempDir()
+ specPath := filepath.Join(dir, "bom.json")
+ // Editors on Windows occasionally emit a UTF-8 BOM at the head of
+ // JSON files. Without the BOM strip it would route to the YAML
+ // branch and the JSON-specific error message would be lost.
+ bom := string([]byte{0xEF, 0xBB, 0xBF})
+ writeScorecardFixture(t, dir, "bom.json", bom+`{"paths": {"/y": {}}}`)
+
+ info, err := loadOpenAPISpec(specPath)
+ assert.NoError(t, err)
+ assert.Equal(t, []string{"/y"}, info.Paths)
+ })
+}
+
func writeScorecardFixture(t *testing.T, root, relPath, content string) {
t.Helper()
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index c7a0fd74..b4e997ea 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1738,10 +1738,10 @@ Priority 3 (polish):
### Agent Build Checklist (per command)
-After building each command in Priority 1 and Priority 2, verify these 9 principles are met. These map 1:1 to what Phase 4.9's agent readiness reviewer will check - apply them now so the review becomes a confirmation, not a catch-all.
+After building each command in Priority 1 and Priority 2, verify these 10 principles are met. These map 1:1 to what Phase 4.9's agent readiness reviewer will check - apply them now so the review becomes a confirmation, not a catch-all.
1. **Non-interactive**: No TTY prompts, no `bufio.Scanner(os.Stdin)`, works in CI without a terminal
-2. **Structured output**: `--json` produces valid JSON, `--select` filters fields correctly
+2. **Structured output**: `--json` produces valid JSON, `--select` filters fields correctly. Hand-written novel commands that build a Go-typed slice/struct and emit JSON MUST call `printJSONFiltered(cmd.OutOrStdout(), v, flags)` instead of `flags.printJSON(cmd, v)`. The helper marshals the value and routes the bytes through `printOutputWithFlags`, picking up `--select`, `--compact`, `--csv`, and `--quiet` for free; direct `flags.printJSON` drops every one of those flags silently. Endpoint-mirror commands already use `printOutputWithFlags`; this rule covers hand-written novel commands. Verify with `<cli> <novel> --json --select <field> | jq 'keys'` returning only the requested fields.
3. **Progressive help**: `--help` shows realistic examples with domain-specific values (not "abc123"). **Use `Example: strings.Trim(\`...\`, "\n")` (preserves leading 2-space indent) NOT `strings.TrimSpace(\`...\`)` (strips it).** TrimSpace makes the first example line unindented; dogfood's example-detection parser is tolerant of this in current versions, but the indented form renders correctly across every Cobra version and is the convention used by every generated command.
4. **Actionable errors**: Error messages name the specific flag/arg that's wrong and the correct usage
5. **Safe retries**: Mutation commands support `--dry-run`, idempotent where possible
← 337840c8 fix(skills): sharpen retro cardinal rule to name over-fitted
·
back to Cli Printing Press
·
fix(ci): disable golangci-lint remote schema verify (#432) b6c70893 →