← back to Cli Printing Press
feat(cli): add public parameter names (#648)
cf91eab3bbbd748107bd31618ef357c3a93c592e · 2026-05-06 22:45:26 -0700 · Trevin Chow
* docs(cli): plan public parameter names
* docs(cli): add dominos public param evidence
* docs(cli): apply public param plan review
* docs(cli): clarify public param skill handoff
* feat(cli): add public parameter names
* docs(cli): mark public param plan complete
* feat(cli): gate public param name reviews
Add a public-param audit command that inventories cryptic wire names and preserves evidence-backed agent decisions in a ledger. Keep generated examples and MCP surfaces aligned with authored public names so aliases stay available without leaking into the primary agent contract.
* fix(cli): dedupe MCP body input names
Honor generated IdentName values when deriving public MCP and manifest input names so query/body collisions use the same deduped names as Cobra flags.
Files touched
M AGENTS.mdM docs/PATTERNS.mdM docs/SKILLS.mdA docs/plans/2026-05-06-001-feat-agent-authored-public-param-names-plan.mdA internal/cli/public_param_audit.goA internal/cli/public_param_audit_test.goM internal/cli/root.goM internal/generator/body_collision_test.goM internal/generator/first_command_example.goM internal/generator/first_command_example_test.goM internal/generator/flag_collision.goM internal/generator/flag_collision_test.goM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/mcp_tools.go.tmplM internal/mcpdesc/compose.goM internal/mcpdesc/compose_test.goM internal/pipeline/merge.goM internal/pipeline/merge_test.goM internal/pipeline/overlay.goA internal/pipeline/public_param_audit.goA internal/pipeline/public_param_audit_test.goM internal/pipeline/toolsmanifest.goM internal/pipeline/toolsmanifest_test.goM internal/spec/spec.goM internal/spec/spec_test.goM skills/printing-press/SKILL.mdM skills/printing-press/references/browser-sniff-capture.mdM skills/printing-press/references/crowd-sniff.mdM skills/printing-press/references/spec-format.mdA testdata/golden/cases/generate-public-param-names/artifacts.txtA testdata/golden/cases/generate-public-param-names/command.txtM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.goA testdata/golden/expected/generate-public-param-names/exit.txtA testdata/golden/expected/generate-public-param-names/public-param-golden/README.mdA testdata/golden/expected/generate-public-param-names/public-param-golden/SKILL.mdA testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.goA testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_find.goA testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.goA testdata/golden/expected/generate-public-param-names/stderr.txtA testdata/golden/expected/generate-public-param-names/stdout.txtM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.goA testdata/golden/fixtures/public-param-names.yaml
Diff
commit cf91eab3bbbd748107bd31618ef357c3a93c592e
Author: Trevin Chow <trevin@trevinchow.com>
Date: Wed May 6 22:45:26 2026 -0700
feat(cli): add public parameter names (#648)
* docs(cli): plan public parameter names
* docs(cli): add dominos public param evidence
* docs(cli): apply public param plan review
* docs(cli): clarify public param skill handoff
* feat(cli): add public parameter names
* docs(cli): mark public param plan complete
* feat(cli): gate public param name reviews
Add a public-param audit command that inventories cryptic wire names and preserves evidence-backed agent decisions in a ledger. Keep generated examples and MCP surfaces aligned with authored public names so aliases stay available without leaking into the primary agent contract.
* fix(cli): dedupe MCP body input names
Honor generated IdentName values when deriving public MCP and manifest input names so query/body collisions use the same deduped names as Cobra flags.
---
AGENTS.md | 2 +-
docs/PATTERNS.md | 2 +-
docs/SKILLS.md | 7 +
...-feat-agent-authored-public-param-names-plan.md | 525 +++++++++++++++++++++
internal/cli/public_param_audit.go | 177 +++++++
internal/cli/public_param_audit_test.go | 166 +++++++
internal/cli/root.go | 1 +
internal/generator/body_collision_test.go | 10 +
internal/generator/first_command_example.go | 75 ++-
internal/generator/first_command_example_test.go | 62 ++-
internal/generator/flag_collision.go | 102 +++-
internal/generator/flag_collision_test.go | 81 ++++
internal/generator/generator.go | 90 ++--
internal/generator/generator_test.go | 91 +++-
.../generator/templates/command_endpoint.go.tmpl | 32 +-
.../generator/templates/command_promoted.go.tmpl | 34 +-
internal/generator/templates/mcp_tools.go.tmpl | 84 +++-
internal/mcpdesc/compose.go | 6 +-
internal/mcpdesc/compose_test.go | 18 +
internal/pipeline/merge.go | 40 +-
internal/pipeline/merge_test.go | 88 +++-
internal/pipeline/overlay.go | 8 +-
internal/pipeline/public_param_audit.go | 205 ++++++++
internal/pipeline/public_param_audit_test.go | 128 +++++
internal/pipeline/toolsmanifest.go | 32 +-
internal/pipeline/toolsmanifest_test.go | 92 ++++
internal/spec/spec.go | 140 ++++++
internal/spec/spec_test.go | 145 ++++++
skills/printing-press/SKILL.md | 70 +++
.../references/browser-sniff-capture.md | 1 +
skills/printing-press/references/crowd-sniff.md | 4 +-
skills/printing-press/references/spec-format.md | 46 +-
.../generate-public-param-names/artifacts.txt | 5 +
.../cases/generate-public-param-names/command.txt | 2 +
.../printing-press-rich-auth/internal/mcp/tools.go | 46 +-
.../printing-press-golden/internal/mcp/tools.go | 66 ++-
.../expected/generate-public-param-names/exit.txt | 1 +
.../public-param-golden/README.md | 188 ++++++++
.../public-param-golden/SKILL.md | 160 +++++++
.../internal/cli/stores_create.go | 124 +++++
.../internal/cli/stores_find.go | 70 +++
.../public-param-golden/internal/mcp/tools.go | 246 ++++++++++
.../generate-public-param-names/stderr.txt | 2 +
.../generate-public-param-names/stdout.txt | 0
.../tier-routing-golden/internal/mcp/tools.go | 50 +-
testdata/golden/fixtures/public-param-names.yaml | 51 ++
46 files changed, 3408 insertions(+), 167 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 18324b4b..a382c9d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -169,4 +169,4 @@ The "Code & Comment Hygiene" rules apply here too. Keep inline `AGENTS.md` rules
See [`docs/DOCS.md`](docs/DOCS.md) for the full doc-authoring rules.
## Patterns
-Cross-cutting design patterns are documented in [`docs/PATTERNS.md`](docs/PATTERNS.md). Notably **Deterministic Inventory + Agent-Marked Ledger** — the shape used by `printing-press tools-audit` for workflows that combine mechanical detection with per-item agent judgment.
+Cross-cutting design patterns are documented in [`docs/PATTERNS.md`](docs/PATTERNS.md). Notably **Deterministic Inventory + Agent-Marked Ledger** — the shape used by `printing-press tools-audit` and `printing-press public-param-audit` for workflows that combine mechanical detection with per-item agent judgment.
diff --git a/docs/PATTERNS.md b/docs/PATTERNS.md
index 88f40aa5..2640384a 100644
--- a/docs/PATTERNS.md
+++ b/docs/PATTERNS.md
@@ -6,7 +6,7 @@ Cross-cutting design patterns the printing press uses, with the rules for applyi
When a workflow has a checklist where detection is mechanical but each item needs per-item judgment, split the work between a binary-emitted inventory and an agent-maintained ledger. The binary owns "what's there"; the agent owns "what to do about each item." A persistent file holds both, so the work survives context flushes and the audit trail surfaces the agent's reasoning.
-The canonical example is `printing-press tools-audit` + `skills/printing-press-polish/references/tools-polish.md`. The binary parses every Cobra command and the runtime tools manifest, emits findings (empty Short, thin Short, missing read-only annotation, thin/empty MCP description). The agent walks each finding, fixes most, and marks the rest `accepted` with a one-sentence rationale plus pre-decision fields where the gate requires them. The ledger persists at `<cli-dir>/.printing-press-tools-polish.json` for 24 hours.
+The canonical example is `printing-press tools-audit` + `skills/printing-press-polish/references/tools-polish.md`. The binary parses every Cobra command and the runtime tools manifest, emits findings (empty Short, thin Short, missing read-only annotation, thin/empty MCP description). The agent walks each finding, fixes most, and marks the rest `accepted` with a one-sentence rationale plus pre-decision fields where the gate requires them. The ledger persists at `<cli-dir>/.printing-press-tools-polish.json` for 24 hours. `printing-press public-param-audit` uses the same shape for cryptic wire parameters: the binary finds decision-required params, while the agent either authors `flag_name` in the spec or records an evidence-backed skip.
Reach for this pattern when the work has the **detect mechanically + decide per-item + persist rationale** shape. The trigger isn't a numeric item count — a 15-item list with three accept decisions across two sessions benefits, while a 200-item batch update where every item has the same fix does not. Skip it when one pass is enough, when every item has the same fix, when detection itself requires judgment, or when a `TodoWrite` task list with rationale in the description carries the whole workflow.
diff --git a/docs/SKILLS.md b/docs/SKILLS.md
index 0f8cecc1..f77d5cfa 100644
--- a/docs/SKILLS.md
+++ b/docs/SKILLS.md
@@ -14,6 +14,13 @@ Decide responsibility explicitly:
- **SKILL.md / agent capability:** judgment calls, product tradeoffs, semantic honesty, whether prose overpromises, whether output is plausible, whether a feature is worth building, or workflows that require user/API context not available to the binary. Keep these as clear agent instructions and reviewer prompt contracts.
- **Both:** the machine should produce or verify the deterministic substrate, then SKILL.md should direct the agent to inspect the remaining semantic layer. Example pattern: dogfood syncs README/SKILL feature blocks from `novel_features_built`; the skill tells the agent to audit surrounding prose, recipes, trigger phrases, and examples for indirect claims.
+Public parameter names follow the "Both" pattern. The agent uses evidence to
+author semantic `flag_name` and compatibility `aliases`; the Printing Press CLI
+inventories suspicious wire names with `public-param-audit`, preserves
+evidence-backed skip decisions in a ledger, and propagates authored fields
+through generated CLI, docs, MCP, and manifest surfaces without inferring names
+itself.
+
For any SKILL.md update, search for the old concept across the skill file, not just the paragraph closest to the code change. Agentic review prompts often duplicate workflow assumptions from earlier phase instructions.
## Reference File Pattern
diff --git a/docs/plans/2026-05-06-001-feat-agent-authored-public-param-names-plan.md b/docs/plans/2026-05-06-001-feat-agent-authored-public-param-names-plan.md
new file mode 100644
index 00000000..cecaf221
--- /dev/null
+++ b/docs/plans/2026-05-06-001-feat-agent-authored-public-param-names-plan.md
@@ -0,0 +1,525 @@
+---
+title: "feat(cli): Agent-authored public names for API parameters"
+type: feat
+status: completed
+date: 2026-05-06
+related:
+ - cli-printing-press#642
+---
+
+# feat(cli): Agent-authored public names for API parameters
+
+## Overview
+
+Generated endpoint commands currently use one value, `spec.Param.Name`, for two different jobs: the upstream wire key and the public CLI/MCP/docs parameter name. That is fine for well-named API params like `limit`, but it produces poor agent-facing surfaces for APIs with cryptic wire keys such as `s` and `c`.
+
+Add an explicit public parameter name model so the Printing Press agent can choose semantic names during spec enrichment while the generator keeps requests wired to the original API keys. The machine owns validation and rendering consistency; the agent owns the semantic naming judgment.
+
+The feature is the handoff between those two halves, not just a new field. The Printing Press skill must teach an agent how to decide that a cryptic wire key like `s` means `address` for one endpoint, while the Printing Press CLI must make that agent-authored decision durable and carry it through every generated surface without guessing the meaning itself.
+
+---
+
+## Problem Frame
+
+Issue #642 is valid, but `aliases` alone is not enough if README/SKILL/help/MCP should prefer the friendlier name. A plain alias lets `--address` work, but leaves the canonical generated surface as `--s`, and typed MCP would still ask agents for `s`.
+
+The desired contract is:
+
+- `name`: upstream wire key, e.g. `s`
+- `flag_name`: preferred public CLI/MCP/docs name, e.g. `address`
+- `aliases`: optional compatibility spellings accepted by Cobra, e.g. `[s]`
+
+Choosing `address` is not deterministic string cleanup. It depends on endpoint context, parameter descriptions, browser-sniff interaction evidence, SDK/source evidence, and product judgment. A deterministic rule might choose `street-address` or `search` and be wrong. The Printing Press skill should therefore inspect cryptic params and author `flag_name` only when the evidence supports it.
+
+---
+
+## Requirements Trace
+
+- R1. Specs can express a public parameter name without changing the upstream wire key.
+- R2. Cobra flags, generated help, examples, README/SKILL surfaces, typed MCP schemas, and `tools-manifest.json` prefer the public name when present.
+- R3. Request construction still uses the original wire `Param.Name` for query/path/body keys.
+- R4. Optional aliases bind to the same Cobra backing variable as the public flag. Aliases are public CLI spellings; a raw wire key may be used as an alias only when it is already a valid public flag name.
+- R5. Validation rejects invalid public names and collisions across public names, aliases, body flags, endpoint params, and generator-reserved flags.
+- R6. The Printing Press skill guides the agent to author `flag_name` from evidence during spec enrichment; the machine does not auto-infer semantic names.
+- R7. Tests prove both public and alias flags route to the same wire key, and MCP/public manifests expose the public name while remapping back to the wire key.
+- R8. Verification covers the full skill-to-generator loop: a skill-style enriched spec or overlay containing agent-authored `flag_name` values produces friendly CLI flags, generated README/SKILL examples, typed MCP inputs, and manifest metadata while preserving wire keys.
+
+---
+
+## Scope Boundaries
+
+- This is a generator/spec/skill change, not a hand patch to a printed CLI.
+- Public naming applies first to flag-backed endpoint params and body fields. Positional placeholder naming can use the same model where practical, but CLI positional aliasing is out of scope.
+- No global dictionary of cryptic names. `s` must not automatically become `address`, because it may mean search, state, status, sort, start, or street depending on context.
+- No automatic rewrite of upstream OpenAPI files. For official specs, the agent should apply public names through the existing overlay/enrichment path or generated internal spec artifacts.
+- No public-library backfill in this plan. Existing printed CLIs benefit when regenerated or explicitly patched later.
+- No new OpenAPI `x-` extension is required for the first implementation. It can be added later if catalog-owned annotated OpenAPI specs need first-class support.
+
+### Deferred to Follow-Up Work
+
+- Library-wide audit/backfill of already-printed CLIs with cryptic flags.
+- Richer positional placeholder display and alias support beyond MCP remapping.
+- Optional OpenAPI extension support such as `x-pp-flag-name` / `x-pp-aliases` if sidecar overlays prove insufficient.
+
+---
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- `internal/spec/spec.go` defines `Param` and already has internal-only `IdentName`, proving the generator can separate wire identity from generated identifiers.
+- `internal/generator/flag_collision.go` dedupes Go identifiers and Cobra flag names while preserving wire-side serialization.
+- `internal/generator/templates/command_endpoint.go.tmpl` and `internal/generator/templates/command_promoted.go.tmpl` register endpoint/body flags and perform required/enum/JSON checks against one generated flag name today.
+- `internal/generator/generator.go` owns template helpers such as `flagName`, `paramIdent`, `exampleLine`, and JSON enum suggestions.
+- `internal/generator/templates/mcp_tools.go.tmpl` currently exposes typed MCP inputs using `.Name` and passes raw argument maps directly to the API handler.
+- `internal/pipeline/toolsmanifest.go` serializes manifest params using a single `Name` field today.
+- `internal/pipeline/overlay.go` and `internal/pipeline/merge.go` provide the existing spec enrichment path, but `ParamPatch` currently only supports defaults.
+- `skills/printing-press/SKILL.md` owns the agent workflow for printing CLIs from research/sniff/crowd-sniff inputs.
+- `skills/printing-press/references/spec-format.md` documents the internal YAML spec shape.
+- `docs/SKILLS.md` states the intended split: deterministic substrate belongs in Go/templates; `SKILL.md` should reserve agentic review for semantic judgment.
+
+### Institutional Learnings
+
+- `docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md` is the closest design pattern: widen a legacy scalar/list into a richer model, centralize precedence, and stop downstream surfaces from re-deriving semantics independently.
+- Prior generator plans around MCP parity and param handling show that deterministic generated surface changes need focused unit tests plus golden verification when output changes.
+
+### Affected Printed CLI Evidence
+
+Domino's is the canonical real-world affected CLI:
+
+- `~/Code/printing-press-library/library/food-and-dining/dominos/spec.yaml` keeps the store-locator wire params as `s` and `c`, with descriptions that clearly identify them as street address and city/state/zip.
+- `internal/cli/stores_find.go` was manually back-patched after generation to expose public `--address` / `--city` flags, keep hidden `--s` / `--c` compatibility aliases, and still send `params["s"]` / `params["c"]` upstream.
+- `README.md` and `SKILL.md` examples already teach users and agents to call `dominos-pp-cli stores find --address ... --city ...`.
+- `internal/mcp/tools.go` still exposes typed MCP inputs named `s` and `c`, and the tool description says `Required: s, c`. This proves a Cobra-only alias implementation is insufficient; the generator must propagate public names through typed MCP schemas and remap those public inputs back to wire keys.
+- No checked-in `tools-manifest.json` exists in that Domino's directory, but the same manifest drift would occur today because `internal/pipeline/toolsmanifest.go` writes params from the single raw `Name` field.
+
+### Skill / Generator Contract
+
+The intended loop is:
+
+1. The Printing Press skill gathers evidence during research, browser-sniff, crowd-sniff, SDK/source inspection, and endpoint workflow review.
+2. The agent uses that evidence to author semantic public names into the enrichment artifact: `flag_name` for the preferred public name, and `aliases` only for compatibility spellings.
+3. The Printing Press CLI validates that authored data and rejects invalid or colliding public surfaces. It must not infer semantic names from descriptions, examples, or global dictionaries.
+4. The generator renders the authored public names through CLI help, generated README/SKILL examples, typed MCP schemas, and `tools-manifest.json`.
+5. Runtime request construction continues to use the original wire key from `name`.
+
+The Domino's store-locator example shows the reasoning path the skill should teach. The agent does not choose `address` and `city` because `s` and `c` have universal meanings. It chooses them from endpoint-local evidence:
+
+- The endpoint path is `/power/store-locator`, and the endpoint description is "Find nearby Domino's stores by address", so the params are part of a location-search workflow.
+- The `s` parameter description is `Street address (e.g., "350 5th Ave")`, which disambiguates `s` from search, state, status, sort, or start.
+- The `c` parameter description is `City, state, zip (e.g., "New York NY 10118")`, which disambiguates `c` from category, country, count, or currency.
+- README/SKILL examples and the hand-patched CLI already express the user workflow as `stores find --address ... --city ...`, confirming that concise task-level names fit the surface better than literal names like `street-address` or `city-state-zip`.
+
+From that evidence, the target shape is:
+
+```yaml
+params:
+ - name: s
+ flag_name: address
+ aliases: [s]
+ description: Street address
+ - name: c
+ flag_name: city
+ aliases: [c]
+ description: City, state, zip
+```
+
+The agent-authored improvement is `flag_name: address` and `flag_name: city`. The `aliases` entries are compatibility only. If evidence is unclear, the skill must leave `flag_name` unset rather than letting the generator invent a friendly name.
+
+### External References
+
+- No external research needed. This is repo-local generator behavior and Printing Press workflow design.
+
+---
+
+## Key Technical Decisions
+
+- **Use `flag_name` as the preferred public field name.** `aliases` are secondary accepted spellings. This keeps the model explicit: `name` is wire, `flag_name` is public, `aliases` are compatibility.
+- **The agent chooses `flag_name`; the machine validates and propagates it.** The machine can surface cryptic params for review, but it must not infer semantic names automatically.
+- **Prefer public names everywhere agents discover the surface.** Help, command examples, typed MCP schemas, tools manifest params, README examples that include flags, and generated SKILL content should all use `flag_name` when present.
+- **Keep `IdentName` internal.** It remains a collision/dedup artifact and should not become the authored public-name mechanism.
+- **Reject authored public-name collisions instead of silently deduping them.** A user-authored `flag_name: address` must remain `--address`; if it collides, the spec/overlay should fail fast so the agent can choose a better name.
+- **MCP has one public input name, not alias inputs.** Cobra can accept aliases. Typed MCP should expose the preferred public name and remap it to the wire key internally.
+- **Tools manifest names become public names, with wire metadata added.** Manifest consumers should see agent-friendly names while retaining enough metadata to route correctly and debug wire behavior.
+
+---
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should `aliases` alone solve this?** No. Aliases alone leave the canonical agent-facing surfaces as cryptic wire names.
+- **Should the machine infer `flag_name` from descriptions?** No. It may detect cryptic candidates, but final naming is agent-authored semantic judgment.
+- **Should raw wire keys be aliases by default?** No. The agent should add `aliases: [s]` when a raw spelling is useful and already valid as a CLI flag. Raw API names that are not valid lowercase kebab-case flags need an explicit public compatibility spelling or no alias.
+- **Should this apply to body fields too?** Yes for the model and generator plumbing, because body fields are also Cobra flags and MCP inputs. The Printing Press skill can initially focus review on query params surfaced by issue #642.
+
+### Deferred to Implementation
+
+- Exact helper naming for public-name resolution and changed-flag checks.
+- Whether alias flags should be hidden in Cobra help by default. The implementation should evaluate pflag support and choose the least surprising behavior; public help must still prefer `flag_name`.
+- How much of the cryptic-param review should be a new report versus a Printing Press skill checklist using existing spec artifacts.
+
+---
+
+## 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.*
+
+```mermaid
+flowchart LR
+ A[Research / spec / sniff evidence] --> B[Printing Press agent review]
+ B -->|semantic judgment: authors flag_name| C[Internal spec or overlay]
+ B -->|compatibility judgment: authors aliases| C
+ C --> D[Printing Press CLI validation]
+ D --> E[Generator templates]
+ E --> F[Cobra flags and help]
+ E --> G[README and SKILL examples]
+ E --> H[Typed MCP schema]
+ E --> I[tools-manifest.json]
+ F -->|public or alias flag value| J[wire request map]
+ H -->|public MCP arg| J
+ J -->|Param.Name| K[Upstream API]
+```
+
+Example target spec shape:
+
+```yaml
+params:
+ - name: s
+ flag_name: address
+ aliases: [s]
+ description: Street address
+ - name: c
+ flag_name: city
+ aliases: [c]
+ description: City, state, zip
+```
+
+---
+
+## Implementation Units
+
+- U1. **Widen the parameter model and enrichment path**
+
+**Goal:** Add public parameter naming fields to the spec model and make overlays able to author them without mutating source OpenAPI documents.
+
+**Requirements:** R1, R4, R6
+
+**Dependencies:** None
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Modify: `internal/pipeline/overlay.go`
+- Modify: `internal/pipeline/merge.go`
+- Test: `internal/spec/spec_test.go`
+- Test: `internal/pipeline/merge_test.go`
+- Modify: `skills/printing-press/references/spec-format.md`
+
+**Approach:**
+- Add `FlagName` and `Aliases` fields to `spec.Param` with `flag_name` / `aliases` YAML and JSON keys.
+- Extend `pipeline.ParamPatch` with `FlagName *string`, `ClearFlagName bool`, and `Aliases *[]string` so enrichment overlays can set or clear public names without changing upstream spec artifacts. Tri-state semantics are explicit:
+ - omitted `flag_name` with no `clear_flag_name` leaves the existing public name unchanged
+ - `clear_flag_name: true` removes the existing public name
+ - `flag_name: ""` is invalid, even in overlays
+ - a non-empty `flag_name` sets the public name and must pass final public-name validation
+ - omitted `aliases` preserves existing aliases; explicit `aliases: []` clears them
+- Extend `EndpointOverlay` with a separate `body` patch list, or another collision-safe body-field patch shape, so body fields can receive `flag_name` and `aliases` through the same enrichment path as endpoint params.
+- Preserve `Name` as the only wire key. Do not repurpose `IdentName`; leave it internal and unmarshal-disabled.
+- Document the internal YAML fields with the explicit rule: the agent chooses `flag_name` only when source evidence makes the user-facing meaning clear.
+
+**Patterns to follow:**
+- `AuthEnvVar` rich-model widening in `internal/spec/spec.go`.
+- `internal/pipeline/merge.go` patch application style for defaults.
+- `skills/printing-press/references/spec-format.md` annotated field documentation style.
+
+**Test scenarios:**
+- Happy path: YAML with `name: s`, `flag_name: address`, `aliases: [s]` parses and preserves all fields.
+- Happy path: JSON with the same fields parses and preserves all fields.
+- Happy path: overlay patch sets `flag_name` and `aliases` for an existing param without changing `Name`.
+- Happy path: overlay patch sets and clears `flag_name` / `aliases` for an existing body field without changing its body key.
+- Edge case: overlay patch with omitted `flag_name` preserves the existing public name; `clear_flag_name: true` clears it; `flag_name: ""` is rejected.
+- Edge case: overlay patch for an unknown param leaves the spec unchanged.
+- Edge case: overlay patch with omitted `aliases` preserves existing aliases; explicit `aliases: []` clears them.
+
+**Verification:**
+- Parsed specs and overlays can carry public names through to generator input without changing wire names.
+
+---
+
+- U2. **Validate public names and alias collisions**
+
+**Goal:** Reject invalid or ambiguous public surfaces before code generation.
+
+**Requirements:** R4, R5
+
+**Dependencies:** U1
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Modify: `internal/generator/flag_collision.go`
+- Test: `internal/spec/spec_test.go`
+- Test: `internal/generator/flag_collision_test.go`
+
+**Approach:**
+- Validate `flag_name` and each alias as lowercase kebab-case flag names.
+- Put shape-only validation in the spec layer: empty values, invalid kebab-case, duplicate names within a single endpoint/body list, and alias equal to its own public name.
+- Put full command-context validation in the generator layer, where pagination, mutating method, body-field, and async-job context is available.
+- Reject aliases equal to another param/body public name, aliases equal to another alias, and public names/aliases that collide with body-field flags on the same command.
+- Reject public names/aliases that collide with generator-reserved flags on that command, including pagination `all`, mutating `stdin`, and async `wait`, `wait-timeout`, `wait-interval`.
+- Treat aliases as public CLI flag spellings, not guaranteed literal wire keys. Do not auto-normalize invalid raw names into aliases; the agent must author a valid compatibility spelling when compatibility is needed.
+- Keep the existing generated dedup path for un-authored collisions, but do not silently rename explicit public names or aliases.
+
+**Patterns to follow:**
+- `reservedFlagNamesForEndpoint` in `internal/generator/flag_collision.go`.
+- `validateFrameworkCobraCollisions` in `internal/spec/spec.go` for parse-time failure with actionable messages.
+
+**Test scenarios:**
+- Happy path: `flag_name: address`, `aliases: [s]` passes on an endpoint with no other `address` flag.
+- Error path: alias equals its public `flag_name`.
+- Error path: alias equals another param's wire-derived fallback flag.
+- Error path: two params declare the same `flag_name`.
+- Error path: one param's alias equals another param's `flag_name`.
+- Error path: `flag_name: all` is rejected on a paginated endpoint.
+- Error path: `flag_name: stdin` is rejected on a POST/PUT/PATCH endpoint.
+- Error path: uppercase, spaces, underscores, and empty public names are rejected.
+- Error path: a raw wire key such as `StartTime>` or `foo_bar` is rejected when copied literally into `aliases`; use an authored public CLI spelling such as `start-time-after` or `foo-bar` instead.
+- Edge case: a cryptic `name: s` with no `flag_name` continues to generate as today.
+
+**Verification:**
+- Invalid authored public names fail before Go generation or Cobra runtime registration.
+
+---
+
+- U3. **Render public names through Cobra commands and docs examples**
+
+**Goal:** Generated CLI flags, validation errors, and examples prefer `flag_name` while still accepting aliases that bind to the same variable.
+
+**Requirements:** R2, R3, R4
+
+**Dependencies:** U1, U2
+
+**Files:**
+- Modify: `internal/generator/generator.go`
+- Modify: `internal/generator/flag_collision.go`
+- Modify: `internal/generator/templates/command_endpoint.go.tmpl`
+- Modify: `internal/generator/templates/command_promoted.go.tmpl`
+- Test: `internal/generator/generator_test.go`
+- Test: `internal/generator/json_string_validation_test.go`
+- Test: `internal/generator/enum_validation_test.go`
+- Test: `internal/generator/flag_collision_test.go`
+
+**Approach:**
+- Add central helpers for public flag resolution, alias lists, and "any registered flag changed" checks.
+- Register the preferred public flag first, using the shared backing variable.
+- Register aliases against the same backing variable. Prefer hiding aliases from help when they preserve cryptic wire keys, matching the Domino's `stores find` back-patch where `--address` / `--city` are visible and `--s` / `--c` still work but are hidden.
+- Update required checks to accept any registered name, not just the preferred public flag.
+- Update enum warnings, JSON-string validation errors, and "Did you mean" suggestions to use public flag names.
+- Update request construction to keep serializing query/path/body keys with `Param.Name`.
+- Update `exampleLine` and any generated examples that include flags to choose public names.
+
+**Patterns to follow:**
+- Existing `paramIdent`, `flagName`, `defaultValForParam`, `cobraFlagFuncForParam`, and JSON enum suggestion helpers in `internal/generator/generator.go`.
+- Existing `bodyMap` invariant: variable names can change, but body/request keys remain `p.Name`.
+
+**Test scenarios:**
+- Happy path: generated command registers `--address` and `--s` against the same variable for `name: s`, `flag_name: address`, `aliases: [s]`.
+- Happy path: invoking the generated CLI with `--address "350 5th Ave"` sends query key `s=350 5th Ave`.
+- Happy path: invoking the generated CLI with `--s "350 5th Ave"` sends the same query key.
+- Happy path: a required param accepts the alias and does not fail with "required flag not set."
+- Happy path: enum warning for a public-named param mentions `--status`, not the cryptic wire key.
+- Error path: JSON-string validation mentions the public flag name and public "Did you mean" suggestion.
+- Edge case: params without `flag_name` and aliases generate byte-equivalent or behavior-equivalent code.
+- Edge case: body field with `flag_name` stores the value under the original body key.
+
+**Verification:**
+- Generated commands compile and prove public/alias flags route to the same wire key.
+
+---
+
+- U4. **Expose public names through MCP and tools manifest**
+
+**Goal:** Agent-native typed surfaces use public names while handlers remap back to wire keys.
+
+**Requirements:** R2, R3, R7
+
+**Dependencies:** U1, U2
+
+**Files:**
+- Modify: `internal/generator/templates/mcp_tools.go.tmpl`
+- Modify: `internal/pipeline/toolsmanifest.go`
+- Test: `internal/generator/generator_test.go`
+- Test: `internal/pipeline/toolsmanifest_test.go`
+- Test: `internal/pipeline/mcpsync/sync_test.go`
+
+**Approach:**
+- Typed MCP tool schemas should use `flag_name` when present.
+- Typed MCP tool schemas should include body fields as inputs for POST/PUT/PATCH endpoints, using body-field `flag_name` values when present.
+- Add a generated mapping from public input name to wire `Param.Name` in `makeAPIHandler`, including path/body/query location awareness.
+- For GET/HEAD query params, remap public input names into `params[wireName]`.
+- For POST/PUT/PATCH bodies, remap public body field names into wire body keys before marshaling.
+- For path params where public names are present, replace `{wireName}` placeholders from public input values.
+- Update `tools-manifest.json` params so `name` is the public name, with an added `wire_name` field when it differs from the public name and `aliases` when present.
+- Inventory every in-repo `tools-manifest.json` consumer before changing the manifest writer. Update required readers in the same change, or document an explicit compatibility strategy such as schema versioning, dual-field migration, or keeping `name` as the stable wire identity and adding `public_name`.
+- Keep MCP alias support out of scope: MCP exposes one schema name per input.
+- Domino's shows the failure mode to prevent: CLI/docs say `address` and `city`, but typed MCP currently requires `s` and `c`. The generated MCP tool for the same spec must instead require `address` and `city`, then send `s` and `c` to `/power/store-locator`.
+
+**Patterns to follow:**
+- Current `makeAPIHandler` path/query split in `internal/generator/templates/mcp_tools.go.tmpl`.
+- `internal/pipeline/toolsmanifest.go` as the single schema writer for manifest consumers.
+- `internal/pipeline/mcpsync/sync.go` because MCP sync refreshes `tools-manifest.json` and must preserve the same contract.
+
+**Test scenarios:**
+- Happy path: generated MCP tool for `name: s`, `flag_name: address` exposes input `address`, not `s`.
+- Happy path: MCP handler called with `{"address":"350 5th Ave"}` sends query key `s`.
+- Happy path: generated MCP tool for a body field with `flag_name` exposes the public input and marshals the original body key.
+- Happy path: tools manifest param has `name: "address"` and `wire_name: "s"`.
+- Happy path: alias metadata appears in the manifest when aliases are declared.
+- Happy path: known manifest consumers either read `wire_name` when they need the upstream key or continue to work under the chosen compatibility strategy.
+- Edge case: params without public names have no redundant `wire_name`.
+- Error path: handler ignores unknown public args the same way current handler treats unknown raw args, or returns the existing error shape if validation already rejects them.
+- Integration: `mcp-sync` regenerates a manifest with public param names after reading a spec containing `flag_name`.
+
+**Verification:**
+- Typed MCP and manifest consumers see agent-friendly names without losing wire-key correctness.
+
+---
+
+- U5. **Teach the Printing Press skill to author public names from evidence**
+
+**Goal:** Make the agent workflow the source of semantic public-name decisions during spec enrichment, with clear evidence rules, clear handoff into the Printing Press CLI, and no deterministic guessing by the generator.
+
+**Requirements:** R6
+
+**Dependencies:** U1, U3
+
+**Files:**
+- Modify: `skills/printing-press/SKILL.md`
+- Modify: `skills/printing-press/references/spec-format.md`
+- Modify: `skills/printing-press/references/browser-sniff-capture.md`
+- Modify: `skills/printing-press/references/crowd-sniff.md`
+- Modify: `docs/SKILLS.md`
+- Test: `internal/cli/verify_skill_bundled.py`
+- Test: `internal/cli/verify_skill_test.go`
+
+**Approach:**
+- Add a required spec-enrichment checkpoint: inspect cryptic params and add `flag_name` only when source evidence makes the meaning clear.
+- State the handoff explicitly: the skill authors `flag_name` / `aliases` in the internal spec or overlay; the Printing Press CLI validates and propagates those fields. The generator must not choose semantic names itself.
+- Clarify terminology in the skill: `flag_name` is the semantic improvement agents and users should see; `aliases` are accepted compatibility spellings and are not the main friendly name.
+- Define good evidence: explicit parameter descriptions, vendor docs, SDK argument names, browser-sniff form/input context, traffic-analysis labels, or endpoint-specific user workflow evidence.
+- Define bad evidence: one-letter name alone, generic "query parameter" descriptions, ambiguous sample values, or global assumptions such as "s always means search."
+- Encourage concise task-level names agents naturally use. Example: `Street address` on a store locator should usually become `address`, not `street-address`, because the command's task is finding by address.
+- Add a brief checklist to the browser-sniff and crowd-sniff references so captured form labels and SDK parameter names are preserved as evidence for public names.
+- Preserve evidence in the manuscript, overlay notes, or generated review report so a later maintainer can tell why a `flag_name` was authored.
+- Add the Domino's store-locator example as the canonical walkthrough: evidence says `s` means street address and `c` means city/state/zip; the skill authors `flag_name: address`, `flag_name: city`, and optional compatibility aliases `[s]` / `[c]`; the Printing Press CLI then emits public `address` / `city` surfaces while sending `s` / `c` upstream.
+- Keep deterministic tooling as a review aid only: if added later, it should report cryptic params needing review, not fill names automatically.
+
+**Patterns to follow:**
+- `docs/SKILLS.md` machine-vs-agent split.
+- Browser-sniff provenance reporting in `skills/printing-press/references/browser-sniff-capture.md`.
+- Crowd-sniff provenance report structure in `skills/printing-press/references/crowd-sniff.md`.
+
+**Test scenarios:**
+- Happy path: bundled skill verification accepts the new `flag_name` guidance and spec-format documentation.
+- Happy path: skill guidance includes the Domino's-style enrichment flow and distinguishes `flag_name` from compatibility `aliases`.
+- Happy path: a generated skill for a CLI with public param names references public flags through generated examples/help.
+- Edge case: guidance explicitly says to leave ambiguous cryptic params unchanged.
+- Error path: verify-skill catches stale examples if generated SKILL.md still references raw `--s` after `flag_name: address` is present.
+
+**Verification:**
+- The Printing Press skill tells agents how to create the field, when not to create it, where the authored data is handed to the Printing Press CLI, and how the generated surfaces should reflect the decision.
+
+---
+
+- U6. **Add representative fixtures and golden coverage**
+
+**Goal:** Lock the cross-surface contract so future generator changes cannot regress public-name behavior.
+
+**Requirements:** R1, R2, R3, R4, R5, R7, R8
+
+**Dependencies:** U1, U2, U3, U4, U5
+
+**Files:**
+- Modify: `testdata/golden/specs/` or equivalent golden fixture source
+- Modify: `testdata/golden/expected/`
+- Test: `internal/generator/generator_test.go`
+- Test: `internal/pipeline/toolsmanifest_test.go`
+- Test: `internal/spec/spec_test.go`
+- Test: `internal/pipeline/merge_test.go`
+- Test: `internal/generator/templates_test.go`
+
+**Approach:**
+- Add or extend a small deterministic fixture with a store-locator style endpoint:
+ - `name: s`, `flag_name: address`, `aliases: [s]`
+ - `name: c`, `flag_name: city`, `aliases: [c]`
+- Assert generated CLI command source, help examples, MCP source, and tools manifest all use `address`/`city` publicly.
+- Assert request-building code still uses wire keys `s`/`c`.
+- Add one end-to-end fixture or focused integration test that starts from a skill-style enriched spec or overlay and proves the agent-authored `flag_name` survives through CLI, generated README/SKILL examples, MCP, and manifest output.
+- Run golden verification because command output, generated source, MCP surface, and docs may change.
+
+**Patterns to follow:**
+- Existing `flag_collision_test.go` generated-source inspection.
+- Existing `json_string_validation_test.go` runtime command execution pattern.
+- Existing `toolsmanifest_test.go` deterministic manifest assertions.
+
+**Test scenarios:**
+- Happy path: golden expected CLI shows `--address` and `--city` as public flags.
+- Happy path: alias `--s` remains accepted where declared.
+- Happy path: README/SKILL generated examples that include endpoint flags use public names.
+- Happy path: `tools-manifest.json` lists public names and wire metadata.
+- Happy path: a skill-authored `flag_name` in an overlay is the source of the generated public surfaces, proving the skill-to-generator handoff.
+- Error path: a fixture with alias collisions fails validation with actionable text.
+- Regression: a spec without public names does not receive invented names.
+
+**Verification:**
+- Focused Go tests pass for spec, generator, and pipeline packages.
+- `scripts/golden.sh verify` passes after intentional fixture updates.
+
+---
+
+## System-Wide Impact
+
+- **Interaction graph:** The change crosses spec parsing, overlays, endpoint command templates, promoted command templates, MCP templates, tools manifest generation, generated README/SKILL output, and the Printing Press skill.
+- **Skill/generator handoff:** The Printing Press skill owns semantic public-name authorship; the Printing Press CLI owns validation, storage, and deterministic propagation. A working implementation must prove both halves together.
+- **Error propagation:** Invalid public names must fail early with actionable validation errors. Runtime request errors should continue through existing API error classification.
+- **State lifecycle risks:** No persistent runtime state changes. Generated artifacts and manifest schema change deterministically.
+- **API surface parity:** CLI and typed MCP must agree on public names. Cobra aliases are CLI-only compatibility; MCP exposes one preferred input name.
+- **Integration coverage:** Unit tests alone are insufficient because the risk is cross-surface drift. The plan requires generated CLI, MCP, manifest, and docs fixture coverage.
+- **Unchanged invariants:** `Param.Name` remains the upstream wire key. `IdentName` remains internal. Existing specs without `flag_name` or `aliases` continue to work.
+
+---
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Wrong public names are worse than terse names because they mislead every agent-facing surface | The skill authors names only from clear evidence; the machine never auto-infers names |
+| Public name and alias collision logic duplicates existing dedup behavior | Explicit authored names are validated and rejected; existing dedup remains for generated fallback names only |
+| MCP remapping accidentally sends public names upstream | Add focused tests for GET query, path replacement, and POST body remapping |
+| Manifest consumers break if `name` changes from wire to public | Inventory known consumers first, update required readers in the same change, and document the chosen schema/version compatibility strategy |
+| Hidden alias behavior may not be supported cleanly by pflag | Defer exact help-hide choice to implementation, but require public help to prefer `flag_name` |
+| Scope expands into a broad spec annotation system | Keep first implementation to `flag_name` and `aliases` on `Param`, plus overlay support |
+
+---
+
+## Documentation / Operational Notes
+
+- Update `skills/printing-press/references/spec-format.md` and `docs/SPEC-EXTENSIONS.md` only if the implementation adds any OpenAPI extension support. If no extension is added, avoid documenting one.
+- Update `docs/SKILLS.md` because this feature changes the boundary between deterministic generator guarantees and Printing Press agent judgment.
+- If the generated tools manifest schema changes, document the `wire_name` and `aliases` fields near `internal/pipeline/toolsmanifest.go` consumers or in the relevant artifact docs.
+- PR body should close issue #642 if the implementation fully covers the Cobra + docs + MCP public-name contract.
+
+---
+
+## Sources & References
+
+- Related issue: [cli-printing-press#642](https://github.com/mvanhorn/cli-printing-press/issues/642)
+- Spec model: `internal/spec/spec.go`
+- Generator collision handling: `internal/generator/flag_collision.go`
+- Endpoint command template: `internal/generator/templates/command_endpoint.go.tmpl`
+- Promoted command template: `internal/generator/templates/command_promoted.go.tmpl`
+- MCP template: `internal/generator/templates/mcp_tools.go.tmpl`
+- Tools manifest writer: `internal/pipeline/toolsmanifest.go`
+- Overlay model: `internal/pipeline/overlay.go`
+- Printing Press skill: `skills/printing-press/SKILL.md`
+- Skill authoring guidance: `docs/SKILLS.md`
+- Design pattern: `docs/solutions/design-patterns/auth-envvar-rich-model-2026-05-05.md`
diff --git a/internal/cli/public_param_audit.go b/internal/cli/public_param_audit.go
new file mode 100644
index 00000000..c809948f
--- /dev/null
+++ b/internal/cli/public_param_audit.go
@@ -0,0 +1,177 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/graphql"
+ "github.com/mvanhorn/cli-printing-press/v3/internal/openapi"
+ "github.com/mvanhorn/cli-printing-press/v3/internal/pipeline"
+ "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+ "github.com/spf13/cobra"
+)
+
+func newPublicParamAuditCmd() *cobra.Command {
+ var specFiles []string
+ var cliName string
+ var lenient bool
+ var ledgerPath string
+ var asJSON bool
+ var strict bool
+
+ cmd := &cobra.Command{
+ Use: "public-param-audit",
+ Short: "Inventory cryptic wire parameter names that need public flag names",
+ Long: `Parses one or more specs and inventories flag-backed parameters whose
+wire names are suspicious public CLI/MCP names, such as one-letter or
+punctuation-heavy names. The deterministic inventory is reconciled with an
+optional agent-edited ledger. Strict mode fails until every finding has either
+a real flag_name in the spec or an evidence-backed skip decision in the ledger.`,
+ Example: ` printing-press public-param-audit --spec ./spec.yaml
+ printing-press public-param-audit --spec ./spec.yaml --ledger ./public-param-audit.json --strict`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(specFiles) == 0 {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--spec is required")}
+ }
+
+ apiSpec, err := parsePublicParamAuditSpec(specFiles, cliName, lenient)
+ if err != nil {
+ return err
+ }
+
+ findings := pipeline.AuditPublicParamNames(apiSpec)
+ if ledgerPath != "" {
+ previous, err := readPublicParamLedger(ledgerPath)
+ if err != nil {
+ return &ExitError{Code: ExitInputError, Err: fmt.Errorf("reading public parameter ledger: %w", err)}
+ }
+ findings = pipeline.ReconcilePublicParamAuditFindings(findings, previous.Findings)
+ }
+ ledger := pipeline.NewPublicParamAuditLedger(findings)
+
+ if ledgerPath != "" {
+ if err := writePublicParamLedger(ledgerPath, ledger); err != nil {
+ return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("writing public parameter ledger: %w", err)}
+ }
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ if err := enc.Encode(ledger); err != nil {
+ return fmt.Errorf("encoding JSON: %w", err)
+ }
+ } else {
+ renderPublicParamAudit(cmd.OutOrStdout(), ledger)
+ }
+
+ if strict && ledger.Summary.Pending > 0 {
+ return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("public parameter audit has %d pending finding(s)", ledger.Summary.Pending)}
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().StringSliceVar(&specFiles, "spec", nil, "Path or URL to API spec (can be repeated)")
+ cmd.Flags().StringVar(&cliName, "name", "", "CLI name (required when using multiple specs)")
+ cmd.Flags().BoolVar(&lenient, "lenient", false, "Skip validation errors from broken $refs in OpenAPI specs")
+ cmd.Flags().StringVar(&ledgerPath, "ledger", "", "Path to an agent-edited public parameter audit ledger")
+ cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON instead of a human-readable summary")
+ cmd.Flags().BoolVar(&strict, "strict", false, "fail when unresolved findings remain")
+ return cmd
+}
+
+func parsePublicParamAuditSpec(specFiles []string, cliName string, lenient bool) (*spec.APISpec, error) {
+ var specs []*spec.APISpec
+ for _, specFile := range specFiles {
+ data, err := readSpec(specFile, false, true)
+ if err != nil {
+ return nil, &ExitError{Code: ExitSpecError, Err: fmt.Errorf("reading spec %s: %w", specFile, err)}
+ }
+
+ var apiSpec *spec.APISpec
+ if openapi.IsOpenAPI(data) {
+ if lenient {
+ apiSpec, err = openapi.ParseLenient(data)
+ } else {
+ apiSpec, err = openapi.Parse(data)
+ }
+ } else if graphql.IsGraphQLSDL(data) {
+ apiSpec, err = graphql.ParseSDLBytes(specFile, data)
+ } else {
+ apiSpec, err = spec.ParseBytes(data)
+ }
+ if err != nil {
+ return nil, &ExitError{Code: ExitSpecError, Err: fmt.Errorf("parsing spec %s: %w", specFile, err)}
+ }
+ specs = append(specs, apiSpec)
+ }
+
+ if len(specs) == 1 {
+ if cliName != "" {
+ specs[0].Name = cliName
+ }
+ return specs[0], nil
+ }
+ if cliName == "" {
+ return nil, &ExitError{Code: ExitInputError, Err: fmt.Errorf("--name is required when using multiple specs")}
+ }
+ return mergeSpecs(specs, cliName), nil
+}
+
+func readPublicParamLedger(path string) (pipeline.PublicParamAuditLedger, error) {
+ var ledger pipeline.PublicParamAuditLedger
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return ledger, nil
+ }
+ return ledger, err
+ }
+ if strings.TrimSpace(string(data)) == "" {
+ return ledger, nil
+ }
+ if err := json.Unmarshal(data, &ledger); err != nil {
+ return ledger, err
+ }
+ return ledger, nil
+}
+
+func writePublicParamLedger(path string, ledger pipeline.PublicParamAuditLedger) error {
+ data, err := json.MarshalIndent(ledger, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ return os.WriteFile(path, data, 0o644)
+}
+
+func renderPublicParamAudit(out interface{ Write([]byte) (int, error) }, ledger pipeline.PublicParamAuditLedger) {
+ fmt.Fprintf(out, "Public parameter audit: %d pending, %d resolved, %d accepted skip(s), %d total\n",
+ ledger.Summary.Pending,
+ ledger.Summary.Resolved,
+ ledger.Summary.Accepted,
+ ledger.Summary.Total,
+ )
+ for _, finding := range ledger.Findings {
+ status := "pending"
+ if finding.CurrentPublicName != "" {
+ status = "resolved"
+ } else if finding.HasAcceptedPublicParamSkip() {
+ status = "accepted-skip"
+ }
+ fmt.Fprintf(out, "- %s %s.%s %s %q", status, finding.Resource, finding.Endpoint, finding.Location, finding.WireName)
+ if finding.CurrentPublicName != "" {
+ fmt.Fprintf(out, " -> --%s", finding.CurrentPublicName)
+ }
+ if len(finding.Reasons) > 0 {
+ fmt.Fprintf(out, " (%s)", strings.Join(finding.Reasons, ", "))
+ }
+ if finding.Description != "" {
+ fmt.Fprintf(out, ": %s", finding.Description)
+ }
+ fmt.Fprintln(out)
+ }
+}
diff --git a/internal/cli/public_param_audit_test.go b/internal/cli/public_param_audit_test.go
new file mode 100644
index 00000000..edf9a7cf
--- /dev/null
+++ b/internal/cli/public_param_audit_test.go
@@ -0,0 +1,166 @@
+package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/pipeline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPublicParamAuditJSONInventoriesDecisionRequiredParams(t *testing.T) {
+ specPath := writePublicParamAuditSpec(t, `
+name: public-param-test
+description: Public param test
+version: "0.1.0"
+base_url: https://example.test
+auth:
+ type: none
+resources:
+ stores:
+ description: Store operations
+ endpoints:
+ find:
+ method: GET
+ path: /stores
+ description: Find nearby stores
+ params:
+ - name: s
+ type: string
+ required: true
+ description: Street address
+ - name: city
+ type: string
+ required: true
+ description: City name
+ response:
+ type: array
+ item: Store
+types:
+ Store:
+ fields:
+ - name: id
+ type: string
+`)
+
+ var out bytes.Buffer
+ cmd := newPublicParamAuditCmd()
+ cmd.SetOut(&out)
+ cmd.SetArgs([]string{"--spec", specPath, "--json"})
+ require.NoError(t, cmd.Execute())
+
+ var ledger pipeline.PublicParamAuditLedger
+ require.NoError(t, json.Unmarshal(out.Bytes(), &ledger))
+ assert.Equal(t, pipeline.PublicParamAuditSummary{Total: 1, Pending: 1}, ledger.Summary)
+ require.Len(t, ledger.Findings, 1)
+ assert.Equal(t, "stores.find.params.s", ledger.Findings[0].ID)
+ assert.Equal(t, "Street address", ledger.Findings[0].Description)
+}
+
+func TestPublicParamAuditStrictFailsOnlyForUnreviewedCandidates(t *testing.T) {
+ specPath := writePublicParamAuditSpec(t, `
+name: public-param-test
+description: Public param test
+version: "0.1.0"
+base_url: https://example.test
+auth:
+ type: none
+resources:
+ stores:
+ description: Store operations
+ endpoints:
+ find:
+ method: GET
+ path: /stores
+ description: Find nearby stores
+ params:
+ - name: s
+ type: string
+ required: true
+ description: Street address
+ response:
+ type: array
+ item: Store
+types:
+ Store:
+ fields:
+ - name: id
+ type: string
+`)
+
+ cmd := newPublicParamAuditCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--spec", specPath, "--strict"})
+ err := cmd.Execute()
+ require.Error(t, err)
+ var exitErr *ExitError
+ require.True(t, errors.As(err, &exitErr))
+ assert.Equal(t, ExitGenerationError, exitErr.Code)
+
+ ledgerPath := filepath.Join(t.TempDir(), "public-param-audit.json")
+ initial := pipeline.NewPublicParamAuditLedger([]pipeline.PublicParamAuditFinding{{
+ ID: "stores.find.params.s",
+ Decision: pipeline.PublicParamDecisionSkip,
+ SourceEvidence: "The API docs describe s as the public search syntax.",
+ SkipReason: "The one-letter token is documented as user-authored query syntax, not a hidden abbreviation.",
+ }})
+ data, marshalErr := json.Marshal(initial)
+ require.NoError(t, marshalErr)
+ require.NoError(t, os.WriteFile(ledgerPath, data, 0o644))
+
+ cmd = newPublicParamAuditCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--spec", specPath, "--ledger", ledgerPath, "--strict"})
+ require.NoError(t, cmd.Execute())
+}
+
+func TestPublicParamAuditStrictPassesWhenFlagNameIsInSpec(t *testing.T) {
+ specPath := writePublicParamAuditSpec(t, `
+name: public-param-test
+description: Public param test
+version: "0.1.0"
+base_url: https://example.test
+auth:
+ type: none
+resources:
+ stores:
+ description: Store operations
+ endpoints:
+ find:
+ method: GET
+ path: /stores
+ description: Find nearby stores
+ params:
+ - name: s
+ flag_name: street
+ aliases: [s]
+ type: string
+ required: true
+ description: Street address
+ response:
+ type: array
+ item: Store
+types:
+ Store:
+ fields:
+ - name: id
+ type: string
+`)
+
+ cmd := newPublicParamAuditCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetArgs([]string{"--spec", specPath, "--strict"})
+ require.NoError(t, cmd.Execute())
+}
+
+func writePublicParamAuditSpec(t *testing.T, contents string) string {
+ t.Helper()
+ path := filepath.Join(t.TempDir(), "spec.yaml")
+ require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
+ return path
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 8e634157..fbbeb8a5 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -64,6 +64,7 @@ func Execute() error {
rootCmd.AddCommand(newLockCmd())
rootCmd.AddCommand(newMCPAuditCmd())
rootCmd.AddCommand(newToolsAuditCmd())
+ rootCmd.AddCommand(newPublicParamAuditCmd())
rootCmd.AddCommand(newProbeReachabilityCmd())
rootCmd.AddCommand(newSchemaCmd())
rootCmd.AddCommand(newBundleCmd())
diff --git a/internal/generator/body_collision_test.go b/internal/generator/body_collision_test.go
index b9a498d9..44be2943 100644
--- a/internal/generator/body_collision_test.go
+++ b/internal/generator/body_collision_test.go
@@ -100,6 +100,16 @@ func TestGenerateRenamesBodyFieldCollidingWithQueryParam(t *testing.T) {
"--tags from a body field must not collide with --tags from a query param")
assert.Contains(t, flagBindings, "tags",
"the first registrant keeps the canonical flag name")
+ assert.Contains(t, flagBindings, "tags-2",
+ "the colliding body field gets the deduped public flag name")
+
+ mcpTools, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
+ require.NoError(t, err)
+ mcpSource := string(mcpTools)
+ assert.Contains(t, mcpSource, `mcplib.WithString("tags"`)
+ assert.Contains(t, mcpSource, `mcplib.WithString("tags-2"`)
+ assert.Contains(t, mcpSource, `PublicName: "tags", WireName: "tags", Location: "query"`)
+ assert.Contains(t, mcpSource, `PublicName: "tags-2", WireName: "tags", Location: "body"`)
}
// TestGenerateRenamesBodyFieldCollidingWithStdin guards against a body field
diff --git a/internal/generator/first_command_example.go b/internal/generator/first_command_example.go
index 397966e2..cc862bf0 100644
--- a/internal/generator/first_command_example.go
+++ b/internal/generator/first_command_example.go
@@ -10,8 +10,10 @@ import (
)
// firstCommandExample returns a runnable "resource [endpoint] <pos1> <pos2>..."
-// invocation for docs that need a concrete example. Read-only verbs (list,
-// get, search, query) are preferred to keep examples non-destructive.
+// invocation for docs that need a concrete example. Required public flags are
+// included so generated docs do not advertise commands that fail immediately.
+// Read-only verbs (list, get, search, query) are preferred to keep examples
+// non-destructive.
// Returns empty when the spec has no endpoints, so callers can skip the
// block rather than render nonsense.
//
@@ -38,14 +40,12 @@ func firstCommandExample(resources map[string]spec.Resource) string {
preferredVerbs := []string{"list", "get", "search", "query"}
pathFor := func(rName string, r spec.Resource, eName string, ep spec.Endpoint) string {
- base := rName
+ parts := []string{rName}
if !isPromotableSingleEndpoint(rName, r) {
- base = rName + " " + eName
+ parts = append(parts, eName)
}
- if args := positionalArgsForExample(ep); args != "" {
- return base + " " + args
- }
- return base
+ parts = append(parts, readmeExampleArgs(ep)...)
+ return strings.Join(parts, " ")
}
for _, rName := range resNames {
@@ -66,21 +66,62 @@ func firstCommandExample(resources map[string]spec.Resource) string {
return ""
}
-// positionalArgsForExample joins the endpoint's required positional
-// arguments into a single space-separated string suitable for splicing
-// into a docs example. Each value comes from skillExamplePositionalValue
-// (spec.Param.Default → canonicalargs → "mock-value"). Returns empty
-// when the endpoint declares no positional params, so the caller can
-// emit the bare command path.
-func positionalArgsForExample(ep spec.Endpoint) string {
+func commandExampleArgs(ep spec.Endpoint) string {
+ return strings.Join(commandExampleArgParts(ep), " ")
+}
+
+func commandExampleArgParts(ep spec.Endpoint) []string {
var parts []string
for _, p := range ep.Params {
if !p.Positional {
continue
}
- parts = append(parts, skillExamplePositionalValue(p))
+ val := exampleValue(p)
+ if val == "" {
+ val = "<" + p.Name + ">"
+ }
+ parts = append(parts, val)
+ }
+ return append(parts, requiredFlagExampleParts(ep)...)
+}
+
+func readmeExampleArgs(ep spec.Endpoint) []string {
+ var parts []string
+ for _, p := range ep.Params {
+ if p.Positional {
+ parts = append(parts, skillExamplePositionalValue(p))
+ }
+ }
+ return append(parts, requiredFlagExampleParts(ep)...)
+}
+
+func requiredFlagExampleParts(ep spec.Endpoint) []string {
+ var parts []string
+ for _, p := range ep.Params {
+ if p.Positional || !p.Required {
+ continue
+ }
+ val := exampleValue(p)
+ if val == "" {
+ val = "value"
+ }
+ parts = append(parts, "--"+publicFlagName(p), val)
+ }
+
+ switch strings.ToUpper(ep.Method) {
+ case "POST", "PUT", "PATCH":
+ for _, p := range ep.Body {
+ if p.Required && p.Type == "string" {
+ val := exampleValue(p)
+ if val == "" {
+ val = "value"
+ }
+ parts = append(parts, "--"+publicFlagName(p), val)
+ break
+ }
+ }
}
- return strings.Join(parts, " ")
+ return parts
}
// skillExamplePositionalValue resolves one positional param to the value
diff --git a/internal/generator/first_command_example_test.go b/internal/generator/first_command_example_test.go
index 2b9827f3..ee8f67ec 100644
--- a/internal/generator/first_command_example_test.go
+++ b/internal/generator/first_command_example_test.go
@@ -188,10 +188,9 @@ func TestFirstCommandExampleHonorsPromotion(t *testing.T) {
want: "articles list mock-vertical weeknight",
},
{
- // items has two endpoints — no promotion. No positional
- // params on the chosen endpoint, so the helper emits the
- // bare path; non-positional flag params don't appear.
- name: "non-positional params do not pollute the example",
+ // items has two endpoints — no promotion. Optional
+ // non-positional flag params don't appear.
+ name: "optional non-positional params do not pollute the example",
resources: map[string]spec.Resource{
"items": {
Endpoints: map[string]spec.Endpoint{
@@ -209,6 +208,61 @@ func TestFirstCommandExampleHonorsPromotion(t *testing.T) {
},
want: "items list",
},
+ {
+ name: "required non-positional params use public flag names",
+ resources: map[string]spec.Resource{
+ "stores": {
+ Endpoints: map[string]spec.Endpoint{
+ "find": {
+ Method: "GET",
+ Path: "/stores",
+ Params: []spec.Param{
+ {Name: "s", FlagName: "address", Required: true, Type: "string"},
+ {Name: "c", FlagName: "city", Required: true, Type: "string"},
+ },
+ },
+ "refresh": {Method: "POST", Path: "/stores/refresh"},
+ },
+ },
+ },
+ want: "stores find --address example-value --city example-value",
+ },
+ {
+ name: "required non-positional params without public names use derived flag names",
+ resources: map[string]spec.Resource{
+ "search": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/search",
+ Params: []spec.Param{
+ {Name: "search_query", Required: true, Type: "string"},
+ },
+ },
+ "refresh": {Method: "POST", Path: "/search/refresh"},
+ },
+ },
+ },
+ want: "search list --search-query example-value",
+ },
+ {
+ name: "required body field uses public flag name",
+ resources: map[string]spec.Resource{
+ "stores": {
+ Endpoints: map[string]spec.Endpoint{
+ "create": {
+ Method: "POST",
+ Path: "/stores",
+ Body: []spec.Param{
+ {Name: "store_code", FlagName: "store-code", Required: true, Type: "string"},
+ },
+ },
+ "refresh": {Method: "POST", Path: "/stores/refresh"},
+ },
+ },
+ },
+ want: "stores create --store-code example-value",
+ },
{
name: "promoted single-endpoint resource keeps positionals",
resources: map[string]spec.Resource{
diff --git a/internal/generator/flag_collision.go b/internal/generator/flag_collision.go
index 0d3d2603..ac748e5f 100644
--- a/internal/generator/flag_collision.go
+++ b/internal/generator/flag_collision.go
@@ -2,6 +2,7 @@ package generator
import (
"fmt"
+ "strings"
"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
)
@@ -22,13 +23,17 @@ import (
// and body fields that share names produce duplicate `var flagX` / `var
// bodyX` declarations and refuse to compile, or register the same cobra
// flag twice and fail at runtime.
-func (g *Generator) dedupeFlagIdentifiers() {
+func (g *Generator) dedupeFlagIdentifiers() error {
if g.Spec == nil {
- return
+ return nil
}
for resName, res := range g.Spec.Resources {
for epName, ep := range res.Endpoints {
- res.Endpoints[epName] = dedupeEndpointIdentifiers(resName, epName, ep, g.AsyncJobs)
+ deduped, err := dedupeEndpointIdentifiers(resName, epName, ep, g.AsyncJobs)
+ if err != nil {
+ return err
+ }
+ res.Endpoints[epName] = deduped
}
for subName, sub := range res.SubResources {
// Sub-resource async lookups elsewhere in the generator (see
@@ -38,12 +43,17 @@ func (g *Generator) dedupeFlagIdentifiers() {
// correctly. DetectAsyncJobs does not currently walk
// sub-resources, so this lookup is a no-op today.
for epName, ep := range sub.Endpoints {
- sub.Endpoints[epName] = dedupeEndpointIdentifiers(subName, epName, ep, g.AsyncJobs)
+ deduped, err := dedupeEndpointIdentifiers(subName, epName, ep, g.AsyncJobs)
+ if err != nil {
+ return err
+ }
+ sub.Endpoints[epName] = deduped
}
res.SubResources[subName] = sub
}
g.Spec.Resources[resName] = res
}
+ return nil
}
// dedupeEndpointIdentifiers runs the param-then-body uniquification for one
@@ -51,8 +61,11 @@ func (g *Generator) dedupeFlagIdentifiers() {
// fields and query/path params each emit `cmd.Flags().*Var(..., flagName, ...)`
// against the same cobra command, so collisions across the two lists must be
// detected together.
-func dedupeEndpointIdentifiers(resKey, epName string, ep spec.Endpoint, asyncJobs map[string]AsyncJobInfo) spec.Endpoint {
+func dedupeEndpointIdentifiers(resKey, epName string, ep spec.Endpoint, asyncJobs map[string]AsyncJobInfo) (spec.Endpoint, error) {
flagIdents, flagNames := reservedFlagNamesForEndpoint(resKey, epName, ep, asyncJobs)
+ if err := validateAuthoredPublicFlags(resKey, epName, ep, flagNames); err != nil {
+ return ep, err
+ }
// Pass 1: query/path params populate the flag<Camel> namespace.
ep.Params = uniquifyIdentifiers(ep.Params, "flag", flagIdents, flagNames)
@@ -65,12 +78,12 @@ func dedupeEndpointIdentifiers(resKey, epName string, ep spec.Endpoint, asyncJob
}
for _, p := range ep.Params {
if !p.Positional {
- bodyFlagNames[flagName(paramIdent(p))] = struct{}{}
+ bodyFlagNames[publicFlagName(p)] = struct{}{}
}
}
ep.Body = uniquifyIdentifiers(ep.Body, "body", nil, bodyFlagNames)
- return ep
+ return ep, nil
}
// reservedFlagNamesForEndpoint returns identifiers and cobra flag names that
@@ -139,7 +152,7 @@ func uniquifyIdentifiers(params []spec.Param, identPrefix string, reservedIdents
continue
}
ident := identPrefix + toCamel(p.Name)
- flag := flagName(p.Name)
+ flag := publicFlagName(p)
if _, identTaken := usedIdents[ident]; !identTaken {
if _, flagTaken := usedFlags[flag]; !flagTaken {
usedIdents[ident] = struct{}{}
@@ -166,6 +179,68 @@ func uniquifyIdentifiers(params []spec.Param, identPrefix string, reservedIdents
return out
}
+func validateAuthoredPublicFlags(resKey, epName string, ep spec.Endpoint, reservedFlags map[string]struct{}) error {
+ seen := map[string]publicFlagUse{}
+ for _, entry := range publicFlagEntries(ep.Params, "param") {
+ if err := validatePublicFlagEntry(resKey, epName, entry, reservedFlags, seen); err != nil {
+ return err
+ }
+ }
+ for _, entry := range publicFlagEntries(ep.Body, "body") {
+ if err := validatePublicFlagEntry(resKey, epName, entry, reservedFlags, seen); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+type publicFlagUse struct {
+ label string
+ explicit bool
+}
+
+type publicFlagEntry struct {
+ name string
+ label string
+ explicit bool
+}
+
+func publicFlagEntries(params []spec.Param, kind string) []publicFlagEntry {
+ entries := []publicFlagEntry{}
+ for _, p := range params {
+ public := publicFlagName(p)
+ nameExplicit := strings.TrimSpace(p.FlagName) != ""
+ entries = append(entries, publicFlagEntry{
+ name: public,
+ label: fmt.Sprintf("%s %q public name", kind, p.Name),
+ explicit: nameExplicit,
+ })
+ for _, alias := range p.Aliases {
+ entries = append(entries, publicFlagEntry{
+ name: alias,
+ label: fmt.Sprintf("%s %q alias", kind, p.Name),
+ explicit: true,
+ })
+ }
+ }
+ return entries
+}
+
+func validatePublicFlagEntry(resKey, epName string, entry publicFlagEntry, reservedFlags map[string]struct{}, seen map[string]publicFlagUse) error {
+ if entry.explicit {
+ if _, ok := reservedFlags[entry.name]; ok {
+ return fmt.Errorf("resource %q endpoint %q: %s %q collides with reserved flag --%s", resKey, epName, entry.label, entry.name, entry.name)
+ }
+ }
+ if previous, ok := seen[entry.name]; ok {
+ if entry.explicit || previous.explicit {
+ return fmt.Errorf("resource %q endpoint %q: %s %q collides with %s", resKey, epName, entry.label, entry.name, previous.label)
+ }
+ }
+ seen[entry.name] = publicFlagUse{label: entry.label, explicit: entry.explicit}
+ return nil
+}
+
// paramIdent returns the name a Param should use when deriving Go
// identifiers (via camel) or cobra flag names (via flagName). It is
// IdentName when populated by the dedup pass and Name otherwise. The
@@ -178,3 +253,14 @@ func paramIdent(p spec.Param) string {
}
return p.Name
}
+
+func publicFlagName(p spec.Param) string {
+ if p.FlagName != "" {
+ return p.FlagName
+ }
+ return flagName(paramIdent(p))
+}
+
+func publicFlagAliases(p spec.Param) []string {
+ return p.Aliases
+}
diff --git a/internal/generator/flag_collision_test.go b/internal/generator/flag_collision_test.go
index e8275773..ee977c4a 100644
--- a/internal/generator/flag_collision_test.go
+++ b/internal/generator/flag_collision_test.go
@@ -60,6 +60,87 @@ func TestGenerateDeduplicatesCamelCollidingParams(t *testing.T) {
"all three params must still be represented after dedup")
}
+func TestGenerateRejectsAuthoredPublicFlagCollisions(t *testing.T) {
+ cases := []struct {
+ name string
+ params []spec.Param
+ body []spec.Param
+ paging *spec.Pagination
+ method string
+ wantErr string
+ }{
+ {
+ name: "alias equals fallback public name",
+ params: []spec.Param{
+ {Name: "s", Type: "string", FlagName: "address", Aliases: []string{"city"}},
+ {Name: "city", Type: "string"},
+ },
+ method: "GET",
+ wantErr: `public name "city" collides with param "s" alias`,
+ },
+ {
+ name: "duplicate authored flag name",
+ params: []spec.Param{
+ {Name: "s", Type: "string", FlagName: "address"},
+ {Name: "street", Type: "string", FlagName: "address"},
+ },
+ method: "GET",
+ wantErr: `public name "address" collides with param "s" public name`,
+ },
+ {
+ name: "body collision",
+ params: []spec.Param{
+ {Name: "s", Type: "string", FlagName: "address"},
+ },
+ body: []spec.Param{
+ {Name: "address", Type: "string"},
+ },
+ method: "POST",
+ wantErr: `body "address" public name "address" collides with param "s" public name`,
+ },
+ {
+ name: "reserved pagination flag",
+ params: []spec.Param{
+ {Name: "includeAll", Type: "boolean", FlagName: "all"},
+ },
+ paging: &spec.Pagination{Type: "cursor"},
+ method: "GET",
+ wantErr: `collides with reserved flag --all`,
+ },
+ {
+ name: "reserved mutating stdin flag",
+ params: []spec.Param{
+ {Name: "source", Type: "string", FlagName: "stdin"},
+ },
+ method: "POST",
+ wantErr: `collides with reserved flag --stdin`,
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ apiSpec := minimalSpec("public-collisions")
+ apiSpec.Resources["stores"] = spec.Resource{
+ Description: "Stores",
+ Endpoints: map[string]spec.Endpoint{
+ "find": {
+ Method: tt.method,
+ Path: "/stores",
+ Description: "Find stores",
+ Params: tt.params,
+ Body: tt.body,
+ Pagination: tt.paging,
+ },
+ },
+ }
+
+ err := New(apiSpec, filepath.Join(t.TempDir(), "out")).Generate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
// TestGenerateRenamesParamCollidingWithPaginationAll covers Case B from issue #275 F-2.
// GitHub's spec has a `repos_notifications_activity-list-repo-for-authenticated-user`
// endpoint that takes an `all` param and is paginated. The endpoint template emits
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 9d2148b6..8ccb0932 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -246,8 +246,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
}
return false
},
- "exampleLine": g.exampleLine,
- "currentYear": func() string { return strconv.Itoa(time.Now().Year()) },
+ "exampleLine": g.exampleLine,
+ "commandExampleArgs": commandExampleArgs,
+ "currentYear": func() string { return strconv.Itoa(time.Now().Year()) },
"modulePath": func() string {
if g.ModulePath != "" {
return g.ModulePath
@@ -295,6 +296,11 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"jsonStringParam": isJSONStringParam,
"jsonEnumSuggestion": jsonEnumSuggestion,
"bodyMap": bodyMap,
+ "publicFlagName": publicFlagName,
+ "publicFlagAliases": publicFlagAliases,
+ "flagChangedExpr": flagChangedExpr,
+ "mcpInputName": mcpInputName,
+ "mcpParamBindings": mcpParamBindings,
// endpointNeedsClientLimit reports whether a list endpoint needs
// client-side truncation. True when the endpoint has a `limit`-named
// param AND no Pagination block — the spec author asked for a
@@ -1269,7 +1275,9 @@ func (g *Generator) prepareOutput() error {
// with another param on the same endpoint or with a generator-introduced
// reserved name (pagination's flagAll, async's flagWait*). Must run after
// AsyncJobs detection so async endpoints reserve the wait identifiers.
- g.dedupeFlagIdentifiers()
+ if err := g.dedupeFlagIdentifiers(); err != nil {
+ return err
+ }
return nil
}
@@ -2622,6 +2630,51 @@ type jsonFlagSuggestion struct {
Values []string
}
+type mcpParamBinding struct {
+ PublicName string
+ WireName string
+ Location string
+}
+
+func flagChangedExpr(p spec.Param) string {
+ names := append([]string{publicFlagName(p)}, publicFlagAliases(p)...)
+ parts := make([]string, 0, len(names))
+ for _, name := range names {
+ parts = append(parts, fmt.Sprintf("cmd.Flags().Changed(%q)", name))
+ }
+ if len(parts) == 1 {
+ return parts[0]
+ }
+ return "(" + strings.Join(parts, " || ") + ")"
+}
+
+func mcpParamBindings(endpoint spec.Endpoint, pathTemplate string) []mcpParamBinding {
+ bindings := make([]mcpParamBinding, 0, len(endpoint.Params)+len(endpoint.Body))
+ for _, p := range endpoint.Params {
+ loc := "query"
+ if strings.Contains(pathTemplate, "{"+p.Name+"}") {
+ loc = "path"
+ }
+ bindings = append(bindings, mcpParamBinding{
+ PublicName: p.PublicInputName(),
+ WireName: p.Name,
+ Location: loc,
+ })
+ }
+ for _, p := range endpoint.Body {
+ bindings = append(bindings, mcpParamBinding{
+ PublicName: p.PublicInputName(),
+ WireName: p.Name,
+ Location: "body",
+ })
+ }
+ return bindings
+}
+
+func mcpInputName(p spec.Param) string {
+ return p.PublicInputName()
+}
+
// endpointNeedsClientLimit reports whether a list endpoint needs
// client-side response truncation. True when:
// - method is GET (only read endpoints need truncation)
@@ -2668,7 +2721,7 @@ func bodyMap(body []spec.Param, indent string) string {
for _, p := range body {
id := paramIdent(p)
ident := toCamel(id)
- flag := flagName(id)
+ flag := publicFlagName(p)
isComplex := p.Type == "object" || p.Type == "array"
if isComplex || isJSONStringParam(p) {
// object/array: store the parsed value (so the API receives
@@ -2740,7 +2793,7 @@ func jsonEnumSuggestion(p spec.Param, params []spec.Param) *jsonFlagSuggestion {
continue
}
return &jsonFlagSuggestion{
- FlagName: flagName(other.Name),
+ FlagName: publicFlagName(other),
Values: other.Enum,
}
}
@@ -3077,32 +3130,7 @@ func (g *Generator) exampleLine(commandPath, endpointName string, endpoint spec.
parts = append(parts, naming.CLI(g.Spec.Name))
parts = append(parts, strings.Fields(commandPath)...)
parts = append(parts, endpointName)
-
- // Add positional arg placeholders with realistic values
- for _, p := range endpoint.Params {
- if p.Positional {
- val := exampleValue(p)
- if val == "" {
- val = "<" + p.Name + ">"
- }
- parts = append(parts, val)
- }
- }
-
- // Add a sample flag for POST/PUT/PATCH with realistic values
- switch endpoint.Method {
- case "POST", "PUT", "PATCH":
- for _, p := range endpoint.Body {
- if p.Required && p.Type == "string" {
- val := exampleValue(p)
- if val == "" {
- val = "value"
- }
- parts = append(parts, "--"+strings.ReplaceAll(p.Name, "_", "-"), val)
- break
- }
- }
- }
+ parts = append(parts, commandExampleArgParts(endpoint)...)
return " " + strings.Join(parts, " ")
}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 62584d13..4cff2fc0 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -5449,7 +5449,7 @@ func TestGenerateOperationRoutingPathParamDefault(t *testing.T) {
mcpGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
require.NoError(t, err)
assert.Regexp(t,
- regexp.MustCompile(`makeAPIHandler\("GET",\s*"/graphql/\{pathQueryId\}/Followers",\s*\[]string\{[^}]*"pathQueryId"`),
+ regexp.MustCompile(`makeAPIHandler\("GET",\s*"/graphql/\{pathQueryId\}/Followers",\s*\[]mcpParamBinding\{.*WireName: "pathQueryId".*\},\s*\[]string\{[^}]*"pathQueryId"`),
string(mcpGo),
"MCP handler must receive the routing path param so it can substitute the URL")
}
@@ -6442,11 +6442,11 @@ func TestGenerateMCPHandlerPreservesQueryPositionals(t *testing.T) {
// Call sites still pass both names — the upstream emit is unchanged;
// the fix lives entirely inside the handler body.
assert.Regexp(t,
- regexp.MustCompile(`makeAPIHandler\("GET",\s*"/search/movie",\s*\[]string\{[^}]*"query"`),
+ regexp.MustCompile(`makeAPIHandler\("GET",\s*"/search/movie",\s*\[]mcpParamBinding\{.*WireName: "query".*\},\s*\[]string\{[^}]*"query"`),
tools,
"search call site must still pass `query` in positionalParams (handler decides path vs query at runtime)")
assert.Regexp(t,
- regexp.MustCompile(`makeAPIHandler\("GET",\s*"/movie/\{movieId\}",\s*\[]string\{[^}]*"movieId"`),
+ regexp.MustCompile(`makeAPIHandler\("GET",\s*"/movie/\{movieId\}",\s*\[]mcpParamBinding\{.*WireName: "movieId".*\},\s*\[]string\{[^}]*"movieId"`),
tools,
"get-by-id call site must pass `movieId` in positionalParams")
@@ -6462,6 +6462,91 @@ func TestGenerateMCPHandlerPreservesQueryPositionals(t *testing.T) {
runGoCommand(t, outputDir, "build", "./...")
}
+func TestGeneratePublicParamNamesAcrossCLISurfaces(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("public-params")
+ delete(apiSpec.Resources, "items")
+ apiSpec.Resources["stores"] = spec.Resource{
+ Description: "Stores",
+ Endpoints: map[string]spec.Endpoint{
+ "find": {
+ Method: "GET",
+ Path: "/power/store-locator",
+ Description: "Find nearby stores by address",
+ Params: []spec.Param{
+ {Name: "s", FlagName: "address", Aliases: []string{"s"}, Type: "string", Required: true, Description: "Street address"},
+ {Name: "c", FlagName: "city", Aliases: []string{"c"}, Type: "string", Required: true, Description: "City, state, zip"},
+ },
+ },
+ "create": {
+ Method: "POST",
+ Path: "/stores",
+ Description: "Create a store",
+ Body: []spec.Param{
+ {Name: "store_code", FlagName: "store-code", Type: "string", Required: true, Description: "Store code"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ findSource := readGeneratedFile(t, outputDir, "internal", "cli", "stores_find.go")
+ assert.Contains(t, findSource, `public-params-pp-cli stores find --address example-value --city example-value`)
+ assert.Contains(t, findSource, `StringVar(&flagS, "address", "", "Street address")`)
+ assert.Contains(t, findSource, `StringVar(&flagS, "s", "", "Street address")`)
+ assert.Contains(t, findSource, `_ = cmd.Flags().MarkHidden("s")`)
+ assert.Contains(t, findSource, `if !(cmd.Flags().Changed("address") || cmd.Flags().Changed("s")) && !flags.dryRun`)
+ assert.Contains(t, findSource, `params["s"] = fmt.Sprintf("%v", flagS)`)
+ assert.NotContains(t, findSource, `required flag "s" not set`)
+
+ createSource := readGeneratedFile(t, outputDir, "internal", "cli", "stores_create.go")
+ assert.Contains(t, createSource, `public-params-pp-cli stores create --store-code example-value`)
+ assert.Contains(t, createSource, `StringVar(&bodyStoreCode, "store-code", "", "Store code")`)
+ assert.Contains(t, createSource, `body["store_code"] = bodyStoreCode`)
+
+ mcpSource := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
+ assert.Contains(t, mcpSource, `mcplib.WithString("address", mcplib.Required(), mcplib.Description("Street address"))`)
+ assert.Contains(t, mcpSource, `PublicName: "address", WireName: "s", Location: "query"`)
+ assert.Contains(t, mcpSource, `params[binding.WireName] = fmt.Sprintf("%v", v)`)
+ assert.Contains(t, mcpSource, `mcplib.WithString("store-code", mcplib.Required(), mcplib.Description("Store code"))`)
+ assert.Contains(t, mcpSource, `PublicName: "store-code", WireName: "store_code", Location: "body"`)
+ assert.Contains(t, mcpSource, `bodyArgs[binding.WireName] = v`)
+
+ readme := readGeneratedFile(t, outputDir, "README.md")
+ assert.Contains(t, readme, `public-params-pp-cli stores create --store-code example-value`)
+
+ skill := readGeneratedFile(t, outputDir, "SKILL.md")
+ assert.Contains(t, skill, `public-params-pp-cli stores create --store-code example-value`)
+}
+
+func TestGeneratePublicParamNamesInPromotedExamples(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("promoted-public-params")
+ apiSpec.Resources["checkout"] = spec.Resource{
+ Description: "Checkout",
+ Endpoints: map[string]spec.Endpoint{
+ "create": {
+ Method: "POST",
+ Path: "/checkout",
+ Description: "Create a checkout",
+ Body: []spec.Param{
+ {Name: "store_code", FlagName: "store-code", Type: "string", Required: true, Description: "Store code"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ promotedSource := readGeneratedFile(t, outputDir, "internal", "cli", "promoted_checkout.go")
+ assert.Contains(t, promotedSource, `Example: " promoted-public-params-pp-cli checkout --store-code example-value"`)
+}
+
// TestGenerateMCPCodeOrchKeywordsHasStopwordFilter proves the keyword
// extractor in the code-orchestration thin surface filters short tokens
// and a stopword set. Without this, two-letter substrings like "is"/"in"
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 61ec96f6..e7505acc 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -61,14 +61,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- range .Endpoint.Params}}
{{- if and .Required (not .Positional) (not .Default)}}
- if !cmd.Flags().Changed("{{flagName (paramIdent .)}}") && !flags.dryRun {
- return fmt.Errorf("required flag \"%s\" not set", "{{flagName (paramIdent .)}}")
+ if !{{flagChangedExpr .}} && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
}
{{- end}}
{{- end}}
{{- range .Endpoint.Params}}
{{- if and .Enum (not .Positional) (eq .Type "string")}}
- if cmd.Flags().Changed("{{flagName (paramIdent .)}}") {
+ if {{flagChangedExpr .}} {
allowed{{camel (paramIdent .)}} := []string{ {{enumLiteral .Enum}} }
valid{{camel (paramIdent .)}} := false
for _, v := range allowed{{camel (paramIdent .)}} {
@@ -78,7 +78,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
}
}
if !valid{{camel (paramIdent .)}} {
- fmt.Fprintf(os.Stderr, "warning: --%s %q not in allowed set %v\n", "{{flagName (paramIdent .)}}", flag{{camel (paramIdent .)}}, allowed{{camel (paramIdent .)}})
+ fmt.Fprintf(os.Stderr, "warning: --%s %q not in allowed set %v\n", "{{publicFlagName .}}", flag{{camel (paramIdent .)}}, allowed{{camel (paramIdent .)}})
}
}
{{- end}}
@@ -86,17 +86,17 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- range .Endpoint.Params}}
{{- if and (not .Positional) (jsonStringParam .)}}
{{- $param := .}}
- if cmd.Flags().Changed("{{flagName (paramIdent $param)}}") {
+ if {{flagChangedExpr $param}} {
var parsed{{camel (paramIdent $param)}} any
if err := json.Unmarshal([]byte(flag{{camel (paramIdent $param)}}), &parsed{{camel (paramIdent $param)}}); err != nil {
{{- with jsonEnumSuggestion $param $.Endpoint.Params}}
for _, v := range []string{ {{enumLiteral .Values}} } {
if flag{{camel (paramIdent $param)}} == v {
- return fmt.Errorf("--{{flagName (paramIdent $param)}} must be valid JSON. Did you mean --{{.FlagName}} %s?", flag{{camel (paramIdent $param)}})
+ return fmt.Errorf("--{{publicFlagName $param}} must be valid JSON. Did you mean --{{.FlagName}} %s?", flag{{camel (paramIdent $param)}})
}
}
{{- end}}
- return fmt.Errorf("--{{flagName (paramIdent $param)}} must be valid JSON: %w", err)
+ return fmt.Errorf("--{{publicFlagName $param}} must be valid JSON: %w", err)
}
}
{{- end}}
@@ -105,8 +105,8 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
if !stdinBody {
{{- range .Endpoint.Body}}
{{- if and .Required (not .Default)}}
- if !cmd.Flags().Changed("{{flagName (paramIdent .)}}") && !flags.dryRun {
- return fmt.Errorf("required flag \"%s\" not set", "{{flagName (paramIdent .)}}")
+ if !{{flagChangedExpr .}} && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
}
{{- end}}
{{- end}}
@@ -503,11 +503,21 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- range .Endpoint.Params}}
{{- if not .Positional}}
- cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
+{{- $param := .}}
+ cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel (paramIdent .)}}, "{{publicFlagName .}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
+{{- range publicFlagAliases $param}}
+ cmd.Flags().{{cobraFlagFuncForParam $param.Name $param.Type}}(&flag{{camel (paramIdent $param)}}, "{{.}}", {{defaultValForParam $param}}, "{{oneline $param.Description}}{{enumDescriptionHint $param.Enum}}")
+ _ = cmd.Flags().MarkHidden("{{.}}")
+{{- end}}
{{- end}}
{{- end}}
{{- range .Endpoint.Body}}
- cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultVal .}}, "{{oneline .Description}}")
+{{- $param := .}}
+ cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel (paramIdent .)}}, "{{publicFlagName .}}", {{defaultVal .}}, "{{oneline .Description}}")
+{{- range publicFlagAliases $param}}
+ cmd.Flags().{{cobraFlagFunc $param.Type}}(&body{{camel (paramIdent $param)}}, "{{.}}", {{defaultVal $param}}, "{{oneline $param.Description}}")
+ _ = cmd.Flags().MarkHidden("{{.}}")
+{{- end}}
{{- end}}
{{- if .Endpoint.Pagination}}
cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index d362fd3c..211c319b 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -28,26 +28,26 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
Use: "{{.PromotedName}}{{positionalArgs .Endpoint}}",
Short: "{{oneline .Endpoint.Description}}",
Long: "Shortcut for '{{.ResourceName}} {{.EndpointName}}'. {{oneline .Endpoint.Description}}",
- Example: " {{modulePath}} {{.PromotedName}}",
+ Example: " {{modulePath}} {{.PromotedName}}{{with commandExampleArgs .Endpoint}} {{.}}{{end}}",
Annotations: map[string]string{"pp:endpoint": {{printf "%q" (printf "%s.%s" .ResourceName .EndpointName)}}, "pp:method": {{printf "%q" (upper .Endpoint.Method)}}, "pp:path": {{printf "%q" .EffectivePath}}{{if .IsReadOnly}}, "mcp:read-only": "true"{{end}}},
RunE: func(cmd *cobra.Command, args []string) error {
{{- range .Endpoint.Params}}
{{- if and .Required (not .Positional) (not .Default)}}
- if !cmd.Flags().Changed("{{flagName (paramIdent .)}}") && !flags.dryRun {
- return fmt.Errorf("required flag \"%s\" not set", "{{flagName (paramIdent .)}}")
+ if !{{flagChangedExpr .}} && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
}
{{- end}}
{{- end}}
{{- range .Endpoint.Body}}
{{- if and .Required (not .Default)}}
- if !cmd.Flags().Changed("{{flagName (paramIdent .)}}") && !flags.dryRun {
- return fmt.Errorf("required flag \"%s\" not set", "{{flagName (paramIdent .)}}")
+ if !{{flagChangedExpr .}} && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
}
{{- end}}
{{- end}}
{{- range .Endpoint.Params}}
{{- if and .Enum (not .Positional) (eq .Type "string")}}
- if cmd.Flags().Changed("{{flagName (paramIdent .)}}") {
+ if {{flagChangedExpr .}} {
allowed{{camel (paramIdent .)}} := []string{ {{enumLiteral .Enum}} }
valid{{camel (paramIdent .)}} := false
for _, v := range allowed{{camel (paramIdent .)}} {
@@ -57,7 +57,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
}
}
if !valid{{camel (paramIdent .)}} {
- fmt.Fprintf(os.Stderr, "warning: --%s %q not in allowed set %v\n", "{{flagName (paramIdent .)}}", flag{{camel (paramIdent .)}}, allowed{{camel (paramIdent .)}})
+ fmt.Fprintf(os.Stderr, "warning: --%s %q not in allowed set %v\n", "{{publicFlagName .}}", flag{{camel (paramIdent .)}}, allowed{{camel (paramIdent .)}})
}
}
{{- end}}
@@ -65,17 +65,17 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- range .Endpoint.Params}}
{{- if and (not .Positional) (jsonStringParam .)}}
{{- $param := .}}
- if cmd.Flags().Changed("{{flagName (paramIdent $param)}}") {
+ if {{flagChangedExpr $param}} {
var parsed{{camel (paramIdent $param)}} any
if err := json.Unmarshal([]byte(flag{{camel (paramIdent $param)}}), &parsed{{camel (paramIdent $param)}}); err != nil {
{{- with jsonEnumSuggestion $param $.Endpoint.Params}}
for _, v := range []string{ {{enumLiteral .Values}} } {
if flag{{camel (paramIdent $param)}} == v {
- return fmt.Errorf("--{{flagName (paramIdent $param)}} must be valid JSON. Did you mean --{{.FlagName}} %s?", flag{{camel (paramIdent $param)}})
+ return fmt.Errorf("--{{publicFlagName $param}} must be valid JSON. Did you mean --{{.FlagName}} %s?", flag{{camel (paramIdent $param)}})
}
}
{{- end}}
- return fmt.Errorf("--{{flagName (paramIdent $param)}} must be valid JSON: %w", err)
+ return fmt.Errorf("--{{publicFlagName $param}} must be valid JSON: %w", err)
}
}
{{- end}}
@@ -273,11 +273,21 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- range .Endpoint.Params}}
{{- if not .Positional}}
- cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
+{{- $param := .}}
+ cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel (paramIdent .)}}, "{{publicFlagName .}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
+{{- range publicFlagAliases $param}}
+ cmd.Flags().{{cobraFlagFuncForParam $param.Name $param.Type}}(&flag{{camel (paramIdent $param)}}, "{{.}}", {{defaultValForParam $param}}, "{{oneline $param.Description}}{{enumDescriptionHint $param.Enum}}")
+ _ = cmd.Flags().MarkHidden("{{.}}")
+{{- end}}
{{- end}}
{{- end}}
{{- range .Endpoint.Body}}
- cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultVal .}}, "{{oneline .Description}}")
+{{- $param := .}}
+ cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel (paramIdent .)}}, "{{publicFlagName .}}", {{defaultVal .}}, "{{oneline .Description}}")
+{{- range publicFlagAliases $param}}
+ cmd.Flags().{{cobraFlagFunc $param.Type}}(&body{{camel (paramIdent $param)}}, "{{.}}", {{defaultVal $param}}, "{{oneline $param.Description}}")
+ _ = cmd.Flags().MarkHidden("{{.}}")
+{{- end}}
{{- end}}
{{- if .Endpoint.Pagination}}
cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index d6712317..6c6fd743 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -42,11 +42,20 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDescription({{printf "%q" (composeMCPDesc $.APISpec $resource $endpoint $.MCPPublicCount $.MCPTotalCount)}}),
{{- range $endpoint.Params}}
{{- if eq .Type "integer"}}
- mcplib.WithNumber({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+ mcplib.WithNumber({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
{{- else if eq .Type "boolean"}}
- mcplib.WithBoolean({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+ mcplib.WithBoolean({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
{{- else}}
- mcplib.WithString({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+ mcplib.WithString({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+{{- end}}
+{{- end}}
+{{- range $endpoint.Body}}
+{{- if eq .Type "integer"}}
+ mcplib.WithNumber({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+{{- else if eq .Type "boolean"}}
+ mcplib.WithBoolean({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+{{- else}}
+ mcplib.WithString({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
{{- end}}
{{- end}}
{{- if eq (upper $endpoint.Method) "GET"}}
@@ -64,9 +73,9 @@ func RegisterTools(s *server.MCPServer) {
{{- end}}
),
{{- if $.HasTierRouting}}
- makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
{{- else}}
- makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
{{- end}}
)
{{- end}}
@@ -77,11 +86,20 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDescription({{printf "%q" (composeMCPSubDesc $.APISpec $resource $subResource $endpoint $.MCPPublicCount $.MCPTotalCount)}}),
{{- range $endpoint.Params}}
{{- if eq .Type "integer"}}
- mcplib.WithNumber({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+ mcplib.WithNumber({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
{{- else if eq .Type "boolean"}}
- mcplib.WithBoolean({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+ mcplib.WithBoolean({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
{{- else}}
- mcplib.WithString({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+ mcplib.WithString({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+{{- end}}
+{{- end}}
+{{- range $endpoint.Body}}
+{{- if eq .Type "integer"}}
+ mcplib.WithNumber({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+{{- else if eq .Type "boolean"}}
+ mcplib.WithBoolean({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
+{{- else}}
+ mcplib.WithString({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
{{- end}}
{{- end}}
{{- if eq (upper $endpoint.Method) "GET"}}
@@ -99,9 +117,9 @@ func RegisterTools(s *server.MCPServer) {
{{- end}}
),
{{- if $.HasTierRouting}}
- makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
{{- else}}
- makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
{{- end}}
)
{{- end}}
@@ -157,11 +175,17 @@ func RegisterTools(s *server.MCPServer) {
cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
}
+type mcpParamBinding struct {
+ PublicName string
+ WireName string
+ Location string
+}
+
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
{{- if .HasTierRouting}}
-func makeAPIHandler(method, pathTemplate, tier string, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate, tier string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
{{- else}}
-func makeAPIHandler(method, pathTemplate string, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
{{- end}}
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
@@ -181,7 +205,27 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
// args that map to query params (e.g. `search <query>` -> ?query=);
// the placeholder check below disambiguates them at runtime.
path := pathTemplate
+ knownArgs := make(map[string]bool, len(bindings))
pathParams := make(map[string]bool, len(positionalParams))
+ params := make(map[string]string)
+ bodyArgs := make(map[string]any)
+ for _, binding := range bindings {
+ knownArgs[binding.PublicName] = true
+ v, ok := args[binding.PublicName]
+ if !ok {
+ continue
+ }
+ switch binding.Location {
+ case "path":
+ placeholder := "{" + binding.WireName + "}"
+ pathParams[binding.PublicName] = true
+ path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+ case "body":
+ bodyArgs[binding.WireName] = v
+ default:
+ params[binding.WireName] = fmt.Sprintf("%v", v)
+ }
+ }
for _, p := range positionalParams {
placeholder := "{" + p + "}"
if !strings.Contains(pathTemplate, placeholder) {
@@ -193,12 +237,16 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
}
}
- params := make(map[string]string)
for k, v := range args {
- if pathParams[k] {
+ if pathParams[k] || knownArgs[k] {
continue
}
- params[k] = fmt.Sprintf("%v", v)
+ switch method {
+ case "POST", "PUT", "PATCH":
+ bodyArgs[k] = v
+ default:
+ params[k] = fmt.Sprintf("%v", v)
+ }
}
var data json.RawMessage
@@ -206,13 +254,13 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
case "GET":
data, err = c.Get(path, params)
case "POST":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Post(path, body)
case "PUT":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Put(path, body)
case "PATCH":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Patch(path, body)
case "DELETE":
data, _, err = c.Delete(path)
diff --git a/internal/mcpdesc/compose.go b/internal/mcpdesc/compose.go
index df606f12..60b6db5f 100644
--- a/internal/mcpdesc/compose.go
+++ b/internal/mcpdesc/compose.go
@@ -245,13 +245,13 @@ func appendMethodMarker(desc, method string) string {
func formatParam(p spec.Param) string {
if p.Default == nil {
- return p.Name
+ return p.PublicInputName()
}
val, ok := formatDefault(p.Default)
if !ok {
- return p.Name
+ return p.PublicInputName()
}
- return p.Name + " (default: " + val + ")"
+ return p.PublicInputName() + " (default: " + val + ")"
}
// formatDefault returns the value and true when usable inline, or
diff --git a/internal/mcpdesc/compose_test.go b/internal/mcpdesc/compose_test.go
index c1899c0c..00a149a4 100644
--- a/internal/mcpdesc/compose_test.go
+++ b/internal/mcpdesc/compose_test.go
@@ -46,6 +46,24 @@ func TestCompose_ListGETArrayResponse(t *testing.T) {
assert.Equal(t, "List projects. Optional: status, limit, cursor. Returns array of Project.", got)
}
+func TestCompose_UsesPublicParamNames(t *testing.T) {
+ in := Input{
+ Endpoint: spec.Endpoint{
+ Method: "GET",
+ Path: "/power/store-locator",
+ Description: "Find nearby stores by address",
+ Params: []spec.Param{
+ {Name: "s", FlagName: "address", Required: true},
+ {Name: "c", FlagName: "city", Required: true},
+ },
+ Response: spec.ResponseDef{Type: "array", Item: "Store"},
+ },
+ AuthType: "none",
+ }
+ got := Compose(in)
+ assert.Equal(t, "Find nearby stores by address. Required: address, city. Returns array of Store.", got)
+}
+
func TestCompose_PatchUPDATEWithPathParams(t *testing.T) {
in := Input{
Endpoint: spec.Endpoint{
diff --git a/internal/pipeline/merge.go b/internal/pipeline/merge.go
index fc83ae16..f04ac96c 100644
--- a/internal/pipeline/merge.go
+++ b/internal/pipeline/merge.go
@@ -1,14 +1,16 @@
package pipeline
import (
+ "fmt"
+
"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
)
// MergeOverlay applies an overlay onto an APISpec, modifying it in place.
// Non-nil overlay fields override the original spec values.
-func MergeOverlay(s *spec.APISpec, overlay *SpecOverlay) {
+func MergeOverlay(s *spec.APISpec, overlay *SpecOverlay) error {
if overlay == nil || s == nil {
- return
+ return nil
}
for rName, rOverlay := range overlay.Resources {
@@ -30,7 +32,12 @@ func MergeOverlay(s *spec.APISpec, overlay *SpecOverlay) {
if eOverlay.Description != nil {
ep.Description = *eOverlay.Description
}
- applyParamPatches(&ep, eOverlay.Params)
+ if err := applyParamPatches(&ep.Params, eOverlay.Params); err != nil {
+ return fmt.Errorf("resource %q sub-resource %q endpoint %q params: %w", rName, subName, eName, err)
+ }
+ if err := applyParamPatches(&ep.Body, eOverlay.Body); err != nil {
+ return fmt.Errorf("resource %q sub-resource %q endpoint %q body: %w", rName, subName, eName, err)
+ }
sub.Endpoints[eName] = ep
resource.SubResources[subName] = sub
break
@@ -42,23 +49,42 @@ func MergeOverlay(s *spec.APISpec, overlay *SpecOverlay) {
if eOverlay.Description != nil {
endpoint.Description = *eOverlay.Description
}
- applyParamPatches(&endpoint, eOverlay.Params)
+ if err := applyParamPatches(&endpoint.Params, eOverlay.Params); err != nil {
+ return fmt.Errorf("resource %q endpoint %q params: %w", rName, eName, err)
+ }
+ if err := applyParamPatches(&endpoint.Body, eOverlay.Body); err != nil {
+ return fmt.Errorf("resource %q endpoint %q body: %w", rName, eName, err)
+ }
resource.Endpoints[eName] = endpoint
}
s.Resources[rName] = resource
}
+ return nil
}
-func applyParamPatches(endpoint *spec.Endpoint, patches []ParamPatch) {
+func applyParamPatches(params *[]spec.Param, patches []ParamPatch) error {
for _, patch := range patches {
- for i, param := range endpoint.Params {
+ if patch.FlagName != nil && *patch.FlagName == "" {
+ return fmt.Errorf("param %q: flag_name must not be empty; use clear_flag_name to remove it", patch.Name)
+ }
+ for i, param := range *params {
if param.Name == patch.Name {
if patch.Default != nil {
- endpoint.Params[i].Default = *patch.Default
+ (*params)[i].Default = *patch.Default
+ }
+ if patch.ClearFlagName {
+ (*params)[i].FlagName = ""
+ }
+ if patch.FlagName != nil {
+ (*params)[i].FlagName = *patch.FlagName
+ }
+ if patch.Aliases != nil {
+ (*params)[i].Aliases = append([]string(nil), (*patch.Aliases)...)
}
break
}
}
}
+ return nil
}
diff --git a/internal/pipeline/merge_test.go b/internal/pipeline/merge_test.go
index 368a1c33..db5a65e5 100644
--- a/internal/pipeline/merge_test.go
+++ b/internal/pipeline/merge_test.go
@@ -5,6 +5,7 @@ import (
"github.com/mvanhorn/cli-printing-press/v3/internal/spec"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestMergeOverlay(t *testing.T) {
@@ -46,7 +47,7 @@ func TestMergeOverlay(t *testing.T) {
},
}
- MergeOverlay(apiSpec, overlay)
+ require.NoError(t, MergeOverlay(apiSpec, overlay))
assert.Equal(t, "Manage email messages", apiSpec.Resources["messages"].Description)
assert.Equal(t, "me", apiSpec.Resources["messages"].Endpoints["list"].Params[0].Default)
@@ -54,7 +55,86 @@ func TestMergeOverlay(t *testing.T) {
}
func TestMergeOverlayNilSafe(t *testing.T) {
- MergeOverlay(nil, nil)
- MergeOverlay(&spec.APISpec{}, nil)
- MergeOverlay(nil, &SpecOverlay{})
+ require.NoError(t, MergeOverlay(nil, nil))
+ require.NoError(t, MergeOverlay(&spec.APISpec{}, nil))
+ require.NoError(t, MergeOverlay(nil, &SpecOverlay{}))
+}
+
+func TestMergeOverlayPublicParamNames(t *testing.T) {
+ apiSpec := &spec.APISpec{
+ Name: "test",
+ Resources: map[string]spec.Resource{
+ "stores": {
+ Endpoints: map[string]spec.Endpoint{
+ "find": {
+ Method: "GET",
+ Path: "/stores",
+ Params: []spec.Param{
+ {Name: "s", Type: "string", FlagName: "street-address", Aliases: []string{"street"}},
+ {Name: "c", Type: "string"},
+ },
+ Body: []spec.Param{
+ {Name: "delivery_window", Type: "string", FlagName: "window"},
+ },
+ },
+ },
+ },
+ },
+ }
+ address := "address"
+ cityAliases := []string{"c"}
+ bodyAliases := []string{}
+ overlay := &SpecOverlay{
+ Resources: map[string]ResourceOverlay{
+ "stores": {
+ Endpoints: map[string]EndpointOverlay{
+ "find": {
+ Params: []ParamPatch{
+ {Name: "s", FlagName: &address},
+ {Name: "c", Aliases: &cityAliases},
+ },
+ Body: []ParamPatch{
+ {Name: "delivery_window", ClearFlagName: true, Aliases: &bodyAliases},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ require.NoError(t, MergeOverlay(apiSpec, overlay))
+ endpoint := apiSpec.Resources["stores"].Endpoints["find"]
+ assert.Equal(t, "s", endpoint.Params[0].Name)
+ assert.Equal(t, "address", endpoint.Params[0].FlagName)
+ assert.Equal(t, []string{"street"}, endpoint.Params[0].Aliases)
+ assert.Equal(t, "c", endpoint.Params[1].Name)
+ assert.Equal(t, []string{"c"}, endpoint.Params[1].Aliases)
+ assert.Empty(t, endpoint.Body[0].FlagName)
+ assert.Empty(t, endpoint.Body[0].Aliases)
+}
+
+func TestMergeOverlayRejectsEmptyFlagName(t *testing.T) {
+ apiSpec := &spec.APISpec{
+ Resources: map[string]spec.Resource{
+ "stores": {
+ Endpoints: map[string]spec.Endpoint{
+ "find": {Params: []spec.Param{{Name: "s"}}},
+ },
+ },
+ },
+ }
+ empty := ""
+ overlay := &SpecOverlay{
+ Resources: map[string]ResourceOverlay{
+ "stores": {
+ Endpoints: map[string]EndpointOverlay{
+ "find": {Params: []ParamPatch{{Name: "s", FlagName: &empty}}},
+ },
+ },
+ },
+ }
+
+ err := MergeOverlay(apiSpec, overlay)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "flag_name must not be empty")
}
diff --git a/internal/pipeline/overlay.go b/internal/pipeline/overlay.go
index 77835b91..e97d02d5 100644
--- a/internal/pipeline/overlay.go
+++ b/internal/pipeline/overlay.go
@@ -16,10 +16,14 @@ type ResourceOverlay struct {
type EndpointOverlay struct {
Description *string `yaml:"description,omitempty"`
Params []ParamPatch `yaml:"params,omitempty"`
+ Body []ParamPatch `yaml:"body,omitempty"`
}
// ParamPatch modifies a single parameter.
type ParamPatch struct {
- Name string `yaml:"name"`
- Default *string `yaml:"default,omitempty"`
+ Name string `yaml:"name"`
+ Default *string `yaml:"default,omitempty"`
+ FlagName *string `yaml:"flag_name,omitempty"`
+ ClearFlagName bool `yaml:"clear_flag_name,omitempty"`
+ Aliases *[]string `yaml:"aliases,omitempty"`
}
diff --git a/internal/pipeline/public_param_audit.go b/internal/pipeline/public_param_audit.go
new file mode 100644
index 00000000..699f4229
--- /dev/null
+++ b/internal/pipeline/public_param_audit.go
@@ -0,0 +1,205 @@
+package pipeline
+
+import (
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+)
+
+const (
+ PublicParamAuditLedgerVersion = 1
+
+ PublicParamDecisionSkip = "skip"
+ PublicParamDecisionFlagName = "flag_name"
+)
+
+// PublicParamAuditLedger is the persisted agent-reviewed audit state.
+// The audit refreshes deterministic fields on every run while preserving
+// agent-owned decision fields for stable finding IDs.
+type PublicParamAuditLedger struct {
+ Version int `json:"version"`
+ Summary PublicParamAuditSummary `json:"summary"`
+ Findings []PublicParamAuditFinding `json:"findings"`
+}
+
+type PublicParamAuditSummary struct {
+ Total int `json:"total"`
+ Pending int `json:"pending"`
+ Resolved int `json:"resolved"`
+ Accepted int `json:"accepted"`
+}
+
+// PublicParamAuditFinding records one flag-backed spec parameter whose
+// wire name is suspiciously unlike a public command flag.
+type PublicParamAuditFinding struct {
+ ID string `json:"id"`
+ Resource string `json:"resource"`
+ Endpoint string `json:"endpoint"`
+ Location string `json:"location"`
+ WireName string `json:"wire_name"`
+ CurrentPublicName string `json:"current_public_name,omitempty"`
+ Aliases []string `json:"aliases,omitempty"`
+ Type string `json:"type,omitempty"`
+ Required bool `json:"required,omitempty"`
+ Description string `json:"description,omitempty"`
+ EndpointPath string `json:"endpoint_path,omitempty"`
+ EndpointDescription string `json:"endpoint_description,omitempty"`
+ Reasons []string `json:"reasons"`
+
+ Decision string `json:"decision,omitempty"`
+ ProposedFlagName string `json:"proposed_flag_name,omitempty"`
+ ProposedAliases []string `json:"proposed_aliases,omitempty"`
+ SourceEvidence string `json:"source_evidence,omitempty"`
+ SkipReason string `json:"skip_reason,omitempty"`
+ Note string `json:"note,omitempty"`
+}
+
+// AuditPublicParamNames inventories parameters where the wire name is a
+// poor public flag candidate and an agent may need to author flag_name.
+func AuditPublicParamNames(api *spec.APISpec) []PublicParamAuditFinding {
+ if api == nil {
+ return nil
+ }
+
+ var findings []PublicParamAuditFinding
+ resourceNames := sortedResourceKeys(api.Resources)
+ for _, resourceName := range resourceNames {
+ findings = append(findings, auditPublicParamResource(resourceName, api.Resources[resourceName])...)
+ }
+ return findings
+}
+
+func auditPublicParamResource(resourceName string, resource spec.Resource) []PublicParamAuditFinding {
+ var findings []PublicParamAuditFinding
+ endpointNames := sortedEndpointKeys(resource.Endpoints)
+ for _, endpointName := range endpointNames {
+ endpoint := resource.Endpoints[endpointName]
+ findings = append(findings, auditPublicParams(resourceName, endpointName, "params", endpoint, endpoint.Params)...)
+ findings = append(findings, auditPublicParams(resourceName, endpointName, "body", endpoint, endpoint.Body)...)
+ }
+ subResourceNames := sortedResourceKeys(resource.SubResources)
+ for _, subName := range subResourceNames {
+ findings = append(findings, auditPublicParamResource(resourceName+"."+subName, resource.SubResources[subName])...)
+ }
+ return findings
+}
+
+func auditPublicParams(resourceName, endpointName, location string, endpoint spec.Endpoint, params []spec.Param) []PublicParamAuditFinding {
+ var findings []PublicParamAuditFinding
+ for _, param := range params {
+ if param.Positional {
+ continue
+ }
+ reasons := publicParamAuditReasons(param)
+ if len(reasons) == 0 {
+ continue
+ }
+ findings = append(findings, PublicParamAuditFinding{
+ ID: publicParamAuditID(resourceName, endpointName, location, param.Name),
+ Resource: resourceName,
+ Endpoint: endpointName,
+ Location: location,
+ WireName: param.Name,
+ CurrentPublicName: param.FlagName,
+ Aliases: append([]string(nil), param.Aliases...),
+ Type: param.Type,
+ Required: param.Required,
+ Description: strings.TrimSpace(param.Description),
+ EndpointPath: endpoint.Path,
+ EndpointDescription: strings.TrimSpace(endpoint.Description),
+ Reasons: reasons,
+ })
+ }
+ return findings
+}
+
+func publicParamAuditReasons(param spec.Param) []string {
+ wire := strings.TrimSpace(param.Name)
+ if wire == "" {
+ return nil
+ }
+
+ var reasons []string
+ if utf8.RuneCountInString(wire) == 1 {
+ reasons = append(reasons, "one-letter-wire-name")
+ }
+ if containsOperatorLikePunctuation(wire) {
+ reasons = append(reasons, "operator-like-wire-name")
+ }
+ return reasons
+}
+
+func containsOperatorLikePunctuation(name string) bool {
+ for _, r := range name {
+ switch {
+ case r >= 'a' && r <= 'z':
+ continue
+ case r >= 'A' && r <= 'Z':
+ continue
+ case r >= '0' && r <= '9':
+ continue
+ case r == '_' || r == '-':
+ continue
+ default:
+ return true
+ }
+ }
+ return false
+}
+
+func publicParamAuditID(resourceName, endpointName, location, wireName string) string {
+ return fmt.Sprintf("%s.%s.%s.%s", resourceName, endpointName, location, wireName)
+}
+
+// ReconcilePublicParamAuditFindings preserves agent-owned ledger fields
+// for findings still present in the current deterministic inventory.
+func ReconcilePublicParamAuditFindings(current, previous []PublicParamAuditFinding) []PublicParamAuditFinding {
+ previousByID := make(map[string]PublicParamAuditFinding, len(previous))
+ for _, finding := range previous {
+ previousByID[finding.ID] = finding
+ }
+ out := make([]PublicParamAuditFinding, 0, len(current))
+ for _, finding := range current {
+ if previous, ok := previousByID[finding.ID]; ok {
+ finding.Decision = previous.Decision
+ finding.ProposedFlagName = previous.ProposedFlagName
+ finding.ProposedAliases = append([]string(nil), previous.ProposedAliases...)
+ finding.SourceEvidence = previous.SourceEvidence
+ finding.SkipReason = previous.SkipReason
+ finding.Note = previous.Note
+ }
+ out = append(out, finding)
+ }
+ return out
+}
+
+func SummarizePublicParamAudit(findings []PublicParamAuditFinding) PublicParamAuditSummary {
+ summary := PublicParamAuditSummary{Total: len(findings)}
+ for _, finding := range findings {
+ switch {
+ case finding.CurrentPublicName != "":
+ summary.Resolved++
+ case finding.HasAcceptedPublicParamSkip():
+ summary.Accepted++
+ default:
+ summary.Pending++
+ }
+ }
+ return summary
+}
+
+func (f PublicParamAuditFinding) HasAcceptedPublicParamSkip() bool {
+ return f.Decision == PublicParamDecisionSkip &&
+ strings.TrimSpace(f.SourceEvidence) != "" &&
+ strings.TrimSpace(f.SkipReason) != ""
+}
+
+func NewPublicParamAuditLedger(findings []PublicParamAuditFinding) PublicParamAuditLedger {
+ return PublicParamAuditLedger{
+ Version: PublicParamAuditLedgerVersion,
+ Summary: SummarizePublicParamAudit(findings),
+ Findings: findings,
+ }
+}
diff --git a/internal/pipeline/public_param_audit_test.go b/internal/pipeline/public_param_audit_test.go
new file mode 100644
index 00000000..b2537b90
--- /dev/null
+++ b/internal/pipeline/public_param_audit_test.go
@@ -0,0 +1,128 @@
+package pipeline
+
+import (
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v3/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAuditPublicParamNamesFindsDecisionRequiredCrypticParams(t *testing.T) {
+ api := &spec.APISpec{
+ Resources: map[string]spec.Resource{
+ "stores": {
+ Endpoints: map[string]spec.Endpoint{
+ "find": {
+ Path: "/stores",
+ Description: "Find nearby stores",
+ Params: []spec.Param{
+ {Name: "s", Type: "string", Required: true, Description: "Street address"},
+ {Name: "c", Type: "string", Required: true, Description: "City name"},
+ {Name: "q", Type: "string", Required: false},
+ {Name: "store_code", Type: "string", Required: true, Description: "Store code"},
+ {Name: "id", Type: "string", Required: true, Positional: true, Description: "Store ID"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ findings := AuditPublicParamNames(api)
+
+ require.Len(t, findings, 3)
+ city := requirePublicParamFinding(t, findings, "stores.find.params.c")
+ assert.Equal(t, "c", city.WireName)
+ assert.Equal(t, []string{"one-letter-wire-name"}, city.Reasons)
+ assert.Equal(t, "q", requirePublicParamFinding(t, findings, "stores.find.params.q").WireName)
+ assert.Equal(t, "s", requirePublicParamFinding(t, findings, "stores.find.params.s").WireName)
+}
+
+func TestAuditPublicParamNamesMarksExistingFlagNamesResolved(t *testing.T) {
+ api := &spec.APISpec{
+ Resources: map[string]spec.Resource{
+ "stores": {
+ Endpoints: map[string]spec.Endpoint{
+ "find": {
+ Params: []spec.Param{
+ {Name: "s", FlagName: "street", Aliases: []string{"s"}, Type: "string", Required: true, Description: "Street address"},
+ {Name: "c", Type: "string", Required: true, Description: "City name"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ ledger := NewPublicParamAuditLedger(AuditPublicParamNames(api))
+
+ assert.Equal(t, PublicParamAuditSummary{Total: 2, Pending: 1, Resolved: 1}, ledger.Summary)
+ street := requirePublicParamFinding(t, ledger.Findings, "stores.find.params.s")
+ assert.Equal(t, "street", street.CurrentPublicName)
+ assert.Equal(t, []string{"s"}, street.Aliases)
+}
+
+func TestPublicParamAuditSkipRequiresEvidence(t *testing.T) {
+ findings := []PublicParamAuditFinding{
+ {ID: "stores.find.params.s", WireName: "s", Decision: PublicParamDecisionSkip, SkipReason: "This is a public API field."},
+ {ID: "stores.find.params.c", WireName: "c", Decision: PublicParamDecisionSkip, SourceEvidence: "Docs say c is a literal vendor field.", SkipReason: "The vendor documents c as the public term."},
+ }
+
+ summary := SummarizePublicParamAudit(findings)
+
+ assert.Equal(t, 1, summary.Pending)
+ assert.Equal(t, 1, summary.Accepted)
+}
+
+func TestPublicParamAuditFlagNameDecisionDoesNotResolveUntilSpecChanges(t *testing.T) {
+ findings := []PublicParamAuditFinding{
+ {
+ ID: "stores.find.params.s",
+ WireName: "s",
+ Decision: PublicParamDecisionFlagName,
+ ProposedFlagName: "street",
+ SourceEvidence: "Docs call this street address.",
+ },
+ }
+
+ summary := SummarizePublicParamAudit(findings)
+
+ assert.Equal(t, 1, summary.Pending)
+ assert.Equal(t, 0, summary.Resolved)
+}
+
+func TestReconcilePublicParamAuditFindingsPreservesAgentDecisionFields(t *testing.T) {
+ current := []PublicParamAuditFinding{{ID: "stores.find.params.s", WireName: "s", Description: "Street address"}}
+ previous := []PublicParamAuditFinding{{
+ ID: "stores.find.params.s",
+ Decision: PublicParamDecisionFlagName,
+ ProposedFlagName: "street",
+ ProposedAliases: []string{"s"},
+ SourceEvidence: "Docs call this street address.",
+ SkipReason: "old skip",
+ Note: "agent reviewed",
+ }}
+
+ got := ReconcilePublicParamAuditFindings(current, previous)
+
+ require.Len(t, got, 1)
+ assert.Equal(t, "Street address", got[0].Description)
+ assert.Equal(t, PublicParamDecisionFlagName, got[0].Decision)
+ assert.Equal(t, "street", got[0].ProposedFlagName)
+ assert.Equal(t, []string{"s"}, got[0].ProposedAliases)
+ assert.Equal(t, "Docs call this street address.", got[0].SourceEvidence)
+ assert.Equal(t, "old skip", got[0].SkipReason)
+ assert.Equal(t, "agent reviewed", got[0].Note)
+}
+
+func requirePublicParamFinding(t *testing.T, findings []PublicParamAuditFinding, id string) PublicParamAuditFinding {
+ t.Helper()
+ for _, finding := range findings {
+ if finding.ID == id {
+ return finding
+ }
+ }
+ require.Failf(t, "missing finding", "finding %q not found in %#v", id, findings)
+ return PublicParamAuditFinding{}
+}
diff --git a/internal/pipeline/toolsmanifest.go b/internal/pipeline/toolsmanifest.go
index d55e4e42..6d3dee12 100644
--- a/internal/pipeline/toolsmanifest.go
+++ b/internal/pipeline/toolsmanifest.go
@@ -106,13 +106,16 @@ type ManifestTool struct {
}
// ManifestParam describes a tool parameter with an explicit location
-// (path, query, or body).
+// (path, query, or body). Name is the public CLI/MCP input name; WireName is
+// set only when the upstream API key differs from that public name.
type ManifestParam struct {
- Name string `json:"name"`
- Type string `json:"type"`
- Location string `json:"location"`
- Description string `json:"description,omitempty"`
- Required bool `json:"required,omitempty"`
+ Name string `json:"name"`
+ WireName string `json:"wire_name,omitempty"`
+ Type string `json:"type"`
+ Location string `json:"location"`
+ Description string `json:"description,omitempty"`
+ Required bool `json:"required,omitempty"`
+ Aliases []string `json:"aliases,omitempty"`
}
// ManifestHeader represents a header name/value pair used for both
@@ -338,23 +341,29 @@ func buildManifestTool(name, description string, ep spec.Endpoint, describeParam
if p.Positional || p.PathParam {
loc = "path"
}
+ name := p.PublicInputName()
tool.Params = append(tool.Params, ManifestParam{
- Name: p.Name,
+ Name: name,
+ WireName: manifestWireName(name, p.Name),
Type: normalizeParamType(p.Type),
Location: loc,
Description: describeParam(p),
Required: p.Required,
+ Aliases: append([]string(nil), p.Aliases...),
})
}
// Body params → body.
for _, p := range ep.Body {
+ name := p.PublicInputName()
tool.Params = append(tool.Params, ManifestParam{
- Name: p.Name,
+ Name: name,
+ WireName: manifestWireName(name, p.Name),
Type: normalizeParamType(p.Type),
Location: "body",
Description: describeParam(p),
Required: p.Required,
+ Aliases: append([]string(nil), p.Aliases...),
})
}
@@ -372,6 +381,13 @@ func buildManifestTool(name, description string, ep spec.Endpoint, describeParam
return tool
}
+func manifestWireName(publicName, wireName string) string {
+ if publicName == wireName {
+ return ""
+ }
+ return wireName
+}
+
func manifestEndpoints(records []manifestEndpointRecord) []spec.Endpoint {
endpoints := make([]spec.Endpoint, 0, len(records))
for _, record := range records {
diff --git a/internal/pipeline/toolsmanifest_test.go b/internal/pipeline/toolsmanifest_test.go
index 01b86022..5ef75214 100644
--- a/internal/pipeline/toolsmanifest_test.go
+++ b/internal/pipeline/toolsmanifest_test.go
@@ -235,6 +235,98 @@ func TestWriteToolsManifest_ParamLocationClassification(t *testing.T) {
assert.False(t, tool.Params[3].Required)
}
+func TestWriteToolsManifest_PublicParamNames(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "public-params",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "stores": {
+ Description: "Stores",
+ Endpoints: map[string]spec.Endpoint{
+ "find": {
+ Method: "GET",
+ Path: "/stores",
+ Description: "Find stores",
+ Params: []spec.Param{
+ {Name: "s", FlagName: "address", Aliases: []string{"s"}, Type: "string", Required: true, Description: "Street address"},
+ },
+ },
+ "create": {
+ Method: "POST",
+ Path: "/stores",
+ Description: "Create store",
+ Body: []spec.Param{
+ {Name: "store_code", FlagName: "store-code", Aliases: []string{"code"}, Type: "string", Required: true, Description: "Store code"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ require.NoError(t, WriteToolsManifest(dir, parsed))
+ got, err := ReadToolsManifest(dir)
+ require.NoError(t, err)
+
+ require.Len(t, got.Tools, 2)
+ var find, create ManifestTool
+ for _, tool := range got.Tools {
+ switch tool.Name {
+ case "stores_find":
+ find = tool
+ case "stores_create":
+ create = tool
+ }
+ }
+ require.Len(t, find.Params, 1)
+ assert.Equal(t, "address", find.Params[0].Name)
+ assert.Equal(t, "s", find.Params[0].WireName)
+ assert.Equal(t, []string{"s"}, find.Params[0].Aliases)
+
+ require.Len(t, create.Params, 1)
+ assert.Equal(t, "store-code", create.Params[0].Name)
+ assert.Equal(t, "store_code", create.Params[0].WireName)
+ assert.Equal(t, []string{"code"}, create.Params[0].Aliases)
+}
+
+func TestWriteToolsManifest_IdentNamePublicParamName(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "deduped-params",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "posts": {
+ Endpoints: map[string]spec.Endpoint{
+ "create": {
+ Method: "POST",
+ Path: "/posts",
+ Params: []spec.Param{
+ {Name: "id", Type: "string", Description: "Query ID"},
+ },
+ Body: []spec.Param{
+ {Name: "id", IdentName: "id_2", Type: "string", Description: "Body ID"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ require.NoError(t, WriteToolsManifest(dir, parsed))
+ got, err := ReadToolsManifest(dir)
+ require.NoError(t, err)
+
+ require.Len(t, got.Tools, 1)
+ require.Len(t, got.Tools[0].Params, 2)
+ assert.Equal(t, "id", got.Tools[0].Params[0].Name)
+ assert.Empty(t, got.Tools[0].Params[0].WireName)
+ assert.Equal(t, "id-2", got.Tools[0].Params[1].Name)
+ assert.Equal(t, "id", got.Tools[0].Params[1].WireName)
+}
+
// TestWriteToolsManifest_ReclassifiedPathParamKeepsPathLocation pins
// the path location for path params that reclassifyPathParamModifiers
// converted from positional args to flags (e.g., enum-typed path
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index afc09acb..1304ada7 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -8,6 +8,7 @@ import (
"os"
"regexp"
"strings"
+ "unicode"
"github.com/mvanhorn/cli-printing-press/v3/internal/naming"
"gopkg.in/yaml.v3"
@@ -953,6 +954,8 @@ func (h *HTMLExtract) EffectiveScriptSelector() string {
type Param struct {
Name string `yaml:"name" json:"name"`
+ FlagName string `yaml:"flag_name,omitempty" json:"flag_name,omitempty"`
+ Aliases []string `yaml:"aliases,omitempty" json:"aliases,omitempty"`
Type string `yaml:"type" json:"type"`
Required bool `yaml:"required" json:"required"`
Positional bool `yaml:"positional" json:"positional"`
@@ -971,6 +974,85 @@ type Param struct {
// collapsing to "StartTime" through camelization. Most params leave this
// empty and template helpers fall back to Name.
IdentName string `yaml:"-" json:"-"`
+ // FlagNameSet is true when the spec explicitly contained flag_name.
+ // It lets validation distinguish an omitted public name from invalid
+ // `flag_name: ""` while still allowing overlays to clear FlagName.
+ FlagNameSet bool `yaml:"-" json:"-"`
+}
+
+func (p Param) PublicInputName() string {
+ if p.FlagName != "" {
+ return p.FlagName
+ }
+ if p.IdentName != "" {
+ return publicInputNameFromIdent(p.IdentName)
+ }
+ return p.Name
+}
+
+func publicInputNameFromIdent(name string) string {
+ name = strings.TrimLeft(name, "$")
+ var b strings.Builder
+ runes := []rune(name)
+ for i, r := range runes {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
+ if b.Len() > 0 {
+ b.WriteByte('-')
+ }
+ continue
+ }
+ if i > 0 && unicode.IsUpper(r) {
+ prev := runes[i-1]
+ if unicode.IsLower(prev) || unicode.IsDigit(prev) {
+ b.WriteByte('-')
+ } else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
+ b.WriteByte('-')
+ }
+ }
+ b.WriteRune(unicode.ToLower(r))
+ }
+ result := b.String()
+ for strings.Contains(result, "--") {
+ result = strings.ReplaceAll(result, "--", "-")
+ }
+ return strings.Trim(result, "-")
+}
+
+func (p *Param) UnmarshalYAML(value *yaml.Node) error {
+ type paramAlias Param
+ var out paramAlias
+ if err := value.Decode(&out); err != nil {
+ return err
+ }
+ *p = Param(out)
+ p.FlagNameSet = yamlMappingHasKey(value, "flag_name")
+ return nil
+}
+
+func (p *Param) UnmarshalJSON(data []byte) error {
+ type paramAlias Param
+ var out paramAlias
+ if err := json.Unmarshal(data, &out); err != nil {
+ return err
+ }
+ *p = Param(out)
+ var raw map[string]json.RawMessage
+ if err := json.Unmarshal(data, &raw); err == nil {
+ _, p.FlagNameSet = raw["flag_name"]
+ }
+ return nil
+}
+
+func yamlMappingHasKey(value *yaml.Node, key string) bool {
+ if value == nil || value.Kind != yaml.MappingNode {
+ return false
+ }
+ for i := 0; i+1 < len(value.Content); i += 2 {
+ if value.Content[i].Value == key {
+ return true
+ }
+ }
+ return false
}
type ResponseDef struct {
@@ -1456,6 +1538,9 @@ func (s *APISpec) Validate() error {
if e.Path == "" {
return fmt.Errorf("resource %q endpoint %q: path is required", name, eName)
}
+ if err := validateEndpointPublicParamNames(e); err != nil {
+ return fmt.Errorf("resource %q endpoint %q: %w", name, eName, err)
+ }
if err := validateEndpointResponseFormat(e); err != nil {
return fmt.Errorf("resource %q endpoint %q: %w", name, eName, err)
}
@@ -1471,6 +1556,9 @@ func (s *APISpec) Validate() error {
if e.Path == "" {
return fmt.Errorf("resource %q sub-resource %q endpoint %q: path is required", name, subName, eName)
}
+ if err := validateEndpointPublicParamNames(e); err != nil {
+ return fmt.Errorf("resource %q sub-resource %q endpoint %q: %w", name, subName, eName, err)
+ }
if err := validateEndpointResponseFormat(e); err != nil {
return fmt.Errorf("resource %q sub-resource %q endpoint %q: %w", name, subName, eName, err)
}
@@ -1494,6 +1582,58 @@ func (s *APISpec) NormalizeAuthEnvVarSpecs() {
}
}
+var publicParamNameRe = regexp.MustCompile(`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`)
+
+func validateEndpointPublicParamNames(endpoint Endpoint) error {
+ if err := validatePublicParamNameList("params", endpoint.Params); err != nil {
+ return err
+ }
+ if err := validatePublicParamNameList("body", endpoint.Body); err != nil {
+ return err
+ }
+ return nil
+}
+
+func validatePublicParamNameList(context string, params []Param) error {
+ seen := map[string]string{}
+ for i, p := range params {
+ label := fmt.Sprintf("%s[%d] (%s)", context, i, p.Name)
+ if p.FlagNameSet && p.FlagName == "" {
+ return fmt.Errorf("%s: flag_name must not be empty", label)
+ }
+ if p.FlagName != "" {
+ if !publicParamNameRe.MatchString(p.FlagName) {
+ return fmt.Errorf("%s: flag_name %q must be lowercase kebab-case", label, p.FlagName)
+ }
+ if previous, ok := seen[p.FlagName]; ok {
+ return fmt.Errorf("%s: flag_name %q collides with %s", label, p.FlagName, previous)
+ }
+ seen[p.FlagName] = label + " flag_name"
+ }
+ publicName := p.FlagName
+ if publicName == "" && publicParamNameRe.MatchString(p.Name) {
+ publicName = p.Name
+ }
+ for ai, alias := range p.Aliases {
+ aliasLabel := fmt.Sprintf("%s aliases[%d]", label, ai)
+ if alias == "" {
+ return fmt.Errorf("%s: alias must not be empty", aliasLabel)
+ }
+ if !publicParamNameRe.MatchString(alias) {
+ return fmt.Errorf("%s: alias %q must be lowercase kebab-case", aliasLabel, alias)
+ }
+ if publicName != "" && alias == publicName {
+ return fmt.Errorf("%s: alias %q duplicates its public name", aliasLabel, alias)
+ }
+ if previous, ok := seen[alias]; ok {
+ return fmt.Errorf("%s: alias %q collides with %s", aliasLabel, alias, previous)
+ }
+ seen[alias] = aliasLabel
+ }
+ }
+ return nil
+}
+
func validateAuthEnvVarSpecs(context string, auth AuthConfig) error {
seen := map[string]struct{}{}
for i, envVar := range auth.EnvVarSpecs {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index d9bae558..97496d47 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -73,6 +73,151 @@ func TestParseStytch(t *testing.T) {
assert.Len(t, s.Types["Session"].Fields, 4)
}
+func TestParsePublicParamNames(t *testing.T) {
+ yamlSpec := []byte(`
+name: public-params
+base_url: https://api.example.com
+auth:
+ type: none
+resources:
+ stores:
+ endpoints:
+ find:
+ method: GET
+ path: /stores
+ params:
+ - name: s
+ flag_name: address
+ aliases: [s]
+ type: string
+ description: Street address
+`)
+ s, err := ParseBytes(yamlSpec)
+ require.NoError(t, err)
+ param := s.Resources["stores"].Endpoints["find"].Params[0]
+ assert.Equal(t, "s", param.Name)
+ assert.Equal(t, "address", param.FlagName)
+ assert.Equal(t, []string{"s"}, param.Aliases)
+
+ jsonSpec := []byte(`{
+ "name": "public-params-json",
+ "base_url": "https://api.example.com",
+ "auth": {"type": "none"},
+ "resources": {
+ "stores": {
+ "endpoints": {
+ "find": {
+ "method": "GET",
+ "path": "/stores",
+ "params": [
+ {"name": "c", "flag_name": "city", "aliases": ["c"], "type": "string"}
+ ]
+ }
+ }
+ }
+ }
+}`)
+ s, err = ParseBytes(jsonSpec)
+ require.NoError(t, err)
+ param = s.Resources["stores"].Endpoints["find"].Params[0]
+ assert.Equal(t, "c", param.Name)
+ assert.Equal(t, "city", param.FlagName)
+ assert.Equal(t, []string{"c"}, param.Aliases)
+}
+
+func TestParamPublicInputName(t *testing.T) {
+ assert.Equal(t, "address", Param{Name: "s", FlagName: "address"}.PublicInputName())
+ assert.Equal(t, "store_code", Param{Name: "store_code"}.PublicInputName())
+ assert.Equal(t, "id-2", Param{Name: "id", IdentName: "id_2"}.PublicInputName())
+ assert.Equal(t, "start-time-2", Param{Name: "StartTime>", IdentName: "StartTime>_2"}.PublicInputName())
+}
+
+func TestValidatePublicParamNames(t *testing.T) {
+ cases := []struct {
+ name string
+ param Param
+ wantErr string
+ }{
+ {
+ name: "uppercase flag name",
+ param: Param{Name: "s", FlagName: "Street", Type: "string"},
+ wantErr: `flag_name "Street" must be lowercase kebab-case`,
+ },
+ {
+ name: "underscore alias",
+ param: Param{Name: "s", FlagName: "address", Aliases: []string{"street_address"}, Type: "string"},
+ wantErr: `alias "street_address" must be lowercase kebab-case`,
+ },
+ {
+ name: "alias duplicates public name",
+ param: Param{Name: "s", FlagName: "address", Aliases: []string{"address"}, Type: "string"},
+ wantErr: `alias "address" duplicates its public name`,
+ },
+ }
+
+ for _, tt := range cases {
+ t.Run(tt.name, func(t *testing.T) {
+ s := APISpec{
+ Name: "public-params",
+ BaseURL: "https://api.example.com",
+ Auth: AuthConfig{Type: "none"},
+ Resources: map[string]Resource{
+ "stores": {
+ Endpoints: map[string]Endpoint{
+ "find": {Method: "GET", Path: "/stores", Params: []Param{tt.param}},
+ },
+ },
+ },
+ }
+ err := s.Validate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+}
+
+func TestParseRejectsExplicitEmptyFlagName(t *testing.T) {
+ yamlSpec := []byte(`
+name: public-params
+base_url: https://api.example.com
+auth:
+ type: none
+resources:
+ stores:
+ endpoints:
+ find:
+ method: GET
+ path: /stores
+ params:
+ - name: s
+ flag_name: ""
+ type: string
+`)
+ _, err := ParseBytes(yamlSpec)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "flag_name must not be empty")
+
+ jsonSpec := []byte(`{
+ "name": "public-params-json",
+ "base_url": "https://api.example.com",
+ "auth": {"type": "none"},
+ "resources": {
+ "stores": {
+ "endpoints": {
+ "find": {
+ "method": "GET",
+ "path": "/stores",
+ "params": [{"name": "s", "flag_name": "", "type": "string"}]
+ }
+ }
+ }
+ }
+}`)
+ _, err = ParseBytes(jsonSpec)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "flag_name must not be empty")
+}
+
func TestValidation(t *testing.T) {
tests := []struct {
name string
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index b1f90569..a3a4077b 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1545,6 +1545,76 @@ template produces correct auth from the start.
APIs, public data feeds), don't invent auth. The signal must come from research — not
from guessing. No research mention of auth = no enrichment.
+#### Public Parameter Name Enrichment
+
+Before generating, inspect endpoint params and body fields whose API names would
+make poor CLI or MCP inputs, especially one-letter keys, punctuation-heavy keys,
+or names that only make sense inside the upstream protocol. When clear evidence
+shows the user-facing meaning, author `flag_name` in the internal spec or overlay
+and add `aliases` only for compatibility spellings.
+
+This is an agent judgment step, not a generator inference step. The skill uses
+research, docs, SDK/source names, browser-sniff form labels, traffic-analysis
+request context, and endpoint workflow evidence to decide the semantic name.
+The Printing Press CLI validates and propagates that authored data through Cobra
+flags, generated examples, typed MCP schemas, and `tools-manifest.json`; it must
+not guess that a cryptic key always means the same thing across APIs.
+
+Run the deterministic inventory before generation when a spec may contain
+cryptic wire names:
+
+```bash
+printing-press public-param-audit --spec <spec-or-overlay-output> --ledger <runstate>/public-param-audit.json --strict
+```
+
+The command does not decide that every finding needs a public flag. It identifies
+parameters that need an agent decision. For each pending finding, either:
+
+- Author `flag_name` and compatibility `aliases` in the spec or overlay, then rerun
+ the audit so the finding becomes resolved from the spec itself.
+- Record `decision: "skip"` in the ledger with `source_evidence` and `skip_reason`
+ when source material shows no public rename is warranted.
+
+A ledger entry with `decision: "flag_name"` or `proposed_flag_name` is only a note
+to the agent; it is not complete until the spec or overlay actually contains the
+public name. Strict mode fails on unreviewed findings, not on evidence-backed
+skips. A one-letter wire name alone is enough to enter the inventory, but not
+enough to author a rename.
+
+Good evidence:
+- Explicit parameter descriptions, vendor docs, or SDK argument names.
+- Browser-sniff form/input labels and the interaction that produced the request.
+- Traffic-analysis context tying the parameter to a specific endpoint workflow.
+- Existing manuscript notes or reviewed examples that use the same task-level term.
+
+Bad evidence:
+- A one-letter name alone.
+- Generic descriptions such as "query parameter" or "string value".
+- Ambiguous sample values with no endpoint context.
+- Global assumptions such as "`s` means search" or "`c` means city".
+
+Prefer concise task-level names an agent would naturally use on that command. For
+a store-locator endpoint with `s` described as `Street address` and `c` described
+as `City, state, zip`, author:
+
+```yaml
+params:
+ - name: s
+ flag_name: address
+ aliases: [s]
+ description: Street address
+ - name: c
+ flag_name: city
+ aliases: [c]
+ description: City, state, zip
+```
+
+The upstream wire keys remain `s` and `c`; generated users and agents see
+`address` and `city`. If the evidence is unclear after research, leave
+`flag_name` unset, record the reviewed source material and evidence gap in the
+audit ledger, and preserve the gap in the manuscript rather than inventing a
+friendly name.
+
#### Free/Paid Tier Routing Enrichment
If Phase 1 finds that the headline commands should stay free but secondary
diff --git a/skills/printing-press/references/browser-sniff-capture.md b/skills/printing-press/references/browser-sniff-capture.md
index 8f3c3cee..ca8c7912 100644
--- a/skills/printing-press/references/browser-sniff-capture.md
+++ b/skills/printing-press/references/browser-sniff-capture.md
@@ -996,6 +996,7 @@ The report must contain these sections:
5. **Traffic Analysis** — Summarize `$DISCOVERY_DIR/traffic-analysis.json`:
- Protocols observed (labels + confidence, e.g., `rest_json`, `graphql`, `google_batchexecute`, `ssr_embedded_data`)
- Auth signals (candidate types, header/query/cookie names only -- never values)
+ - Parameter-name evidence from forms, input labels, placeholder text, SDK/source names, and request context. Preserve enough detail to justify any later `flag_name` enrichment.
- Protection signals (Cloudflare/CAPTCHA/login redirects/protected-web hints)
- Generation hints (e.g., `requires_browser_auth`, `requires_js_rendering`, `requires_protected_client`, `has_rpc_envelope`)
- Candidate commands worth considering
diff --git a/skills/printing-press/references/crowd-sniff.md b/skills/printing-press/references/crowd-sniff.md
index df5e7f53..371d629a 100644
--- a/skills/printing-press/references/crowd-sniff.md
+++ b/skills/printing-press/references/crowd-sniff.md
@@ -45,4 +45,6 @@ The report must contain these sections:
5. **Auth Patterns Detected** — Authentication patterns found in SDK code (API key headers, bearer tokens, OAuth flows). Include the header name or env variable convention when visible.
-6. **Coverage Summary** — Total endpoints found, breakdown by source tier, and any gaps compared to the Phase 1 research brief (e.g., "Brief mentions webhooks but no webhook endpoints found in community code").
+6. **Parameter Name Evidence** — SDK argument names, wrapper function names, examples, form labels, or source comments that clarify cryptic wire keys. Preserve endpoint-local evidence so spec enrichment can author `flag_name` without guessing.
+
+7. **Coverage Summary** — Total endpoints found, breakdown by source tier, and any gaps compared to the Phase 1 research brief (e.g., "Brief mentions webhooks but no webhook endpoints found in community code").
diff --git a/skills/printing-press/references/spec-format.md b/skills/printing-press/references/spec-format.md
index 13de2cf8..2ddd4f48 100644
--- a/skills/printing-press/references/spec-format.md
+++ b/skills/printing-press/references/spec-format.md
@@ -34,7 +34,9 @@ resources: # map[string]Resource (REQUIRED: at least one
path: "/users" # string (REQUIRED) API path; supports {param} placeholders
description: "List users" # string endpoint help text
params: # []Param query/path parameters
- - name: limit # string param name; flag is --limit unless positional=true
+ - name: limit # string upstream wire key; request serialization always uses this value
+ flag_name: page-size # string optional public CLI/MCP/docs name; agent-authored from evidence
+ aliases: [] # []string optional hidden compatibility flag spellings
type: int # string type: string | int | bool | float
required: false # bool whether Cobra marks the flag required
positional: false # bool true => consumes positional CLI arg and fills {name} in path
@@ -45,6 +47,7 @@ resources: # map[string]Resource (REQUIRED: at least one
format: "" # string optional format hint (date-time, email, uri, etc.)
body: # []Param request body fields (primarily for POST/PUT)
- name: email
+ # flag_name and aliases are optional here too; omit unless evidence supports them
type: string
required: true
positional: false
@@ -204,7 +207,43 @@ types:
type: string
```
-## 3. Validation Rules
+## 3. Public Parameter Names
+
+`name` is the upstream wire key. The generator uses it for query strings, path
+substitution, and JSON body keys. Do not change `name` just to make a prettier
+CLI.
+
+`flag_name` is the preferred public name shown in generated CLI flags, examples,
+typed MCP schemas, and `tools-manifest.json`. Add it only when source evidence
+makes the meaning clear. Valid values are lowercase kebab-case.
+
+`aliases` are accepted Cobra flag spellings for compatibility. They bind to the
+same backing variable as `flag_name` and are hidden from generated help. Do not
+copy raw wire keys into `aliases` unless they are already valid lowercase
+kebab-case public flags.
+
+Example:
+
+```yaml
+params:
+ - name: s
+ flag_name: address
+ aliases: [s]
+ type: string
+ required: true
+ description: Street address
+ - name: c
+ flag_name: city
+ aliases: [c]
+ type: string
+ required: true
+ description: City, state, zip
+```
+
+Here `--address` and `--city` are the generated public names, `--s` and `--c`
+remain compatibility aliases, and requests still send upstream keys `s` and `c`.
+
+## 4. Validation Rules
Validation in `spec.Validate()` enforces:
@@ -215,8 +254,9 @@ Validation in `spec.Validate()` enforces:
- every endpoint must have both `method` and `path`
- `resources.<name>.base_url` is allowed for resources that live on another host; sub-resources inherit the parent override unless they set their own `base_url`
- resource `base_url` overrides cannot be combined with `client_pattern: proxy-envelope`, because proxy-envelope clients POST every request to the root `base_url`
+- `flag_name` and `aliases` must be lowercase kebab-case, non-empty when present, and collision-free within their command surface
-## 4. Common Mistakes
+## 5. Common Mistakes
These commonly cause generation/build failures or incorrect CLI behavior:
diff --git a/testdata/golden/cases/generate-public-param-names/artifacts.txt b/testdata/golden/cases/generate-public-param-names/artifacts.txt
new file mode 100644
index 00000000..10b56c2c
--- /dev/null
+++ b/testdata/golden/cases/generate-public-param-names/artifacts.txt
@@ -0,0 +1,5 @@
+public-param-golden/internal/cli/stores_find.go
+public-param-golden/internal/cli/stores_create.go
+public-param-golden/internal/mcp/tools.go
+public-param-golden/README.md
+public-param-golden/SKILL.md
diff --git a/testdata/golden/cases/generate-public-param-names/command.txt b/testdata/golden/cases/generate-public-param-names/command.txt
new file mode 100644
index 00000000..d743692f
--- /dev/null
+++ b/testdata/golden/cases/generate-public-param-names/command.txt
@@ -0,0 +1,2 @@
+set -euo pipefail
+"$BINARY" generate --spec testdata/golden/fixtures/public-param-names.yaml --output "$CASE_ACTUAL_DIR/public-param-golden" --force --validate=false
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go
index 20034836..1b03aa82 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go
@@ -31,7 +31,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/items", []string{ }),
+ makeAPIHandler("GET", "/items", []mcpParamBinding{ }, []string{ }),
)
// SQL tool — ad-hoc analysis on synced data without API calls
s.AddTool(
@@ -60,8 +60,14 @@ func RegisterTools(s *server.MCPServer) {
cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
}
+type mcpParamBinding struct {
+ PublicName string
+ WireName string
+ Location string
+}
+
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
-func makeAPIHandler(method, pathTemplate string, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
@@ -77,7 +83,27 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
// args that map to query params (e.g. `search <query>` -> ?query=);
// the placeholder check below disambiguates them at runtime.
path := pathTemplate
+ knownArgs := make(map[string]bool, len(bindings))
pathParams := make(map[string]bool, len(positionalParams))
+ params := make(map[string]string)
+ bodyArgs := make(map[string]any)
+ for _, binding := range bindings {
+ knownArgs[binding.PublicName] = true
+ v, ok := args[binding.PublicName]
+ if !ok {
+ continue
+ }
+ switch binding.Location {
+ case "path":
+ placeholder := "{" + binding.WireName + "}"
+ pathParams[binding.PublicName] = true
+ path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+ case "body":
+ bodyArgs[binding.WireName] = v
+ default:
+ params[binding.WireName] = fmt.Sprintf("%v", v)
+ }
+ }
for _, p := range positionalParams {
placeholder := "{" + p + "}"
if !strings.Contains(pathTemplate, placeholder) {
@@ -89,12 +115,16 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
}
}
- params := make(map[string]string)
for k, v := range args {
- if pathParams[k] {
+ if pathParams[k] || knownArgs[k] {
continue
}
- params[k] = fmt.Sprintf("%v", v)
+ switch method {
+ case "POST", "PUT", "PATCH":
+ bodyArgs[k] = v
+ default:
+ params[k] = fmt.Sprintf("%v", v)
+ }
}
var data json.RawMessage
@@ -102,13 +132,13 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
case "GET":
data, err = c.Get(path, params)
case "POST":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Post(path, body)
case "PUT":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Put(path, body)
case "PATCH":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Patch(path, body)
case "DELETE":
data, _, err = c.Delete(path)
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
index 82c52533..41eed8dc 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
@@ -31,15 +31,18 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/currencies", []string{ }),
+ makeAPIHandler("GET", "/currencies", []mcpParamBinding{ }, []string{ }),
)
s.AddTool(
mcplib.NewTool("projects_create",
mcplib.WithDescription("Create project. Required: name, visibility. Optional: owner_email. Returns the new Project."),
+ mcplib.WithString("name", mcplib.Required(), mcplib.Description("Name")),
+ mcplib.WithString("owner_email", mcplib.Description("Owner email")),
+ mcplib.WithString("visibility", mcplib.Required(), mcplib.Description("Visibility")),
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("POST", "/projects", []string{ }),
+ makeAPIHandler("POST", "/projects", []mcpParamBinding{{PublicName: "name", WireName: "name", Location: "body"},{PublicName: "owner_email", WireName: "owner_email", Location: "body"},{PublicName: "visibility", WireName: "visibility", Location: "body"}, }, []string{ }),
)
s.AddTool(
mcplib.NewTool("projects_get",
@@ -49,7 +52,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/projects/{projectId}", []string{"projectId", }),
+ makeAPIHandler("GET", "/projects/{projectId}", []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"}, }, []string{"projectId", }),
)
s.AddTool(
mcplib.NewTool("projects_list",
@@ -61,7 +64,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/projects", []string{ }),
+ makeAPIHandler("GET", "/projects", []mcpParamBinding{{PublicName: "status", WireName: "status", Location: "query"},{PublicName: "limit", WireName: "limit", Location: "query"},{PublicName: "cursor", WireName: "cursor", Location: "query"}, }, []string{ }),
)
s.AddTool(
mcplib.NewTool("projects_tasks_list-project",
@@ -74,16 +77,19 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/projects/{projectId}/tasks", []string{"projectId", }),
+ makeAPIHandler("GET", "/projects/{projectId}/tasks", []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"},{PublicName: "priority", WireName: "priority", Location: "query"},{PublicName: "limit", WireName: "limit", Location: "query"},{PublicName: "cursor", WireName: "cursor", Location: "query"}, }, []string{"projectId", }),
)
s.AddTool(
mcplib.NewTool("projects_tasks_update-project",
mcplib.WithDescription("Update project task. Required: projectId, taskId. Optional: completed, priority, title. Partial update."),
mcplib.WithString("projectId", mcplib.Required(), mcplib.Description("Project id")),
mcplib.WithString("taskId", mcplib.Required(), mcplib.Description("Task id")),
+ mcplib.WithString("completed", mcplib.Description("Completed")),
+ mcplib.WithString("priority", mcplib.Description("Priority")),
+ mcplib.WithString("title", mcplib.Description("Title")),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("PATCH", "/projects/{projectId}/tasks/{taskId}", []string{"projectId","taskId", }),
+ makeAPIHandler("PATCH", "/projects/{projectId}/tasks/{taskId}", []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"},{PublicName: "taskId", WireName: "taskId", Location: "path"},{PublicName: "completed", WireName: "completed", Location: "body"},{PublicName: "priority", WireName: "priority", Location: "body"},{PublicName: "title", WireName: "title", Location: "body"}, }, []string{"projectId","taskId", }),
)
s.AddTool(
mcplib.NewTool("public_get-status",
@@ -92,7 +98,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/public/status", []string{ }),
+ makeAPIHandler("GET", "/public/status", []mcpParamBinding{ }, []string{ }),
)
s.AddTool(
mcplib.NewTool("reports_summary_get-report-year",
@@ -102,7 +108,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/reports/{year}/summary", []string{"year", }),
+ makeAPIHandler("GET", "/reports/{year}/summary", []mcpParamBinding{{PublicName: "year", WireName: "year", Location: "path"}, }, []string{"year", }),
)
// Search tool — faster than iterating list endpoints for finding specific items
s.AddTool(
@@ -142,8 +148,14 @@ func RegisterTools(s *server.MCPServer) {
cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
}
+type mcpParamBinding struct {
+ PublicName string
+ WireName string
+ Location string
+}
+
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
-func makeAPIHandler(method, pathTemplate string, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
@@ -159,7 +171,27 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
// args that map to query params (e.g. `search <query>` -> ?query=);
// the placeholder check below disambiguates them at runtime.
path := pathTemplate
+ knownArgs := make(map[string]bool, len(bindings))
pathParams := make(map[string]bool, len(positionalParams))
+ params := make(map[string]string)
+ bodyArgs := make(map[string]any)
+ for _, binding := range bindings {
+ knownArgs[binding.PublicName] = true
+ v, ok := args[binding.PublicName]
+ if !ok {
+ continue
+ }
+ switch binding.Location {
+ case "path":
+ placeholder := "{" + binding.WireName + "}"
+ pathParams[binding.PublicName] = true
+ path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+ case "body":
+ bodyArgs[binding.WireName] = v
+ default:
+ params[binding.WireName] = fmt.Sprintf("%v", v)
+ }
+ }
for _, p := range positionalParams {
placeholder := "{" + p + "}"
if !strings.Contains(pathTemplate, placeholder) {
@@ -171,12 +203,16 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
}
}
- params := make(map[string]string)
for k, v := range args {
- if pathParams[k] {
+ if pathParams[k] || knownArgs[k] {
continue
}
- params[k] = fmt.Sprintf("%v", v)
+ switch method {
+ case "POST", "PUT", "PATCH":
+ bodyArgs[k] = v
+ default:
+ params[k] = fmt.Sprintf("%v", v)
+ }
}
var data json.RawMessage
@@ -184,13 +220,13 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
case "GET":
data, err = c.Get(path, params)
case "POST":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Post(path, body)
case "PUT":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Put(path, body)
case "PATCH":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Patch(path, body)
case "DELETE":
data, _, err = c.Delete(path)
diff --git a/testdata/golden/expected/generate-public-param-names/exit.txt b/testdata/golden/expected/generate-public-param-names/exit.txt
new file mode 100644
index 00000000..573541ac
--- /dev/null
+++ b/testdata/golden/expected/generate-public-param-names/exit.txt
@@ -0,0 +1 @@
+0
diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/README.md b/testdata/golden/expected/generate-public-param-names/public-param-golden/README.md
new file mode 100644
index 00000000..9f9c521f
--- /dev/null
+++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/README.md
@@ -0,0 +1,188 @@
+# Public Param Golden CLI
+
+Public parameter name golden fixture
+
+## Install
+
+### Binary
+
+Download a pre-built binary for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/public-param-golden-current). On macOS, clear the Gatekeeper quarantine: `xattr -d com.apple.quarantine <binary>`. On Unix, mark it executable: `chmod +x <binary>`.
+
+### Go
+
+```
+go install github.com/mvanhorn/printing-press-library/library/other/public-param-golden/cmd/public-param-golden-pp-cli@latest
+```
+
+## Quick Start
+
+### 1. Install
+
+See [Install](#install) above.
+
+### 2. Verify Setup
+
+```bash
+public-param-golden-pp-cli doctor
+```
+
+This checks your configuration.
+
+### 3. Try Your First Command
+
+```bash
+public-param-golden-pp-cli stores create --store-code example-value
+```
+
+## Usage
+
+Run `public-param-golden-pp-cli --help` for the full command reference and flag list.
+
+## Commands
+
+### stores
+
+Store lookup operations
+
+- **`public-param-golden-pp-cli stores create`** - Create a store record
+- **`public-param-golden-pp-cli stores find`** - Find nearby stores by address
+
+
+## Output Formats
+
+```bash
+# Human-readable table (default in terminal, JSON when piped)
+public-param-golden-pp-cli stores create --store-code example-value
+
+# JSON for scripting and agents
+public-param-golden-pp-cli stores create --store-code example-value --json
+
+# Filter to specific fields
+public-param-golden-pp-cli stores create --store-code example-value --json --select id,name,status
+
+# Dry run — show the request without sending
+public-param-golden-pp-cli stores create --store-code example-value --dry-run
+
+# Agent mode — JSON + compact + no prompts in one flag
+public-param-golden-pp-cli stores create --store-code example-value --agent
+```
+
+## Agent Usage
+
+This CLI is designed for AI agent consumption:
+
+- **Non-interactive** - never prompts, every input is a flag
+- **Pipeable** - `--json` output to stdout, errors to stderr
+- **Filterable** - `--select id,name` returns only fields you need
+- **Previewable** - `--dry-run` shows the request without sending
+- **Explicit retries** - add `--idempotent` to create retries when a no-op success is acceptable
+- **Confirmable** - `--yes` for explicit confirmation of destructive actions
+- **Piped input** - write commands can accept structured input when their help lists `--stdin`
+- **Agent-safe by default** - no colors or formatting unless `--human-friendly` is set
+
+Exit codes: `0` success, `2` usage error, `3` not found, `5` API error, `7` rate limited, `10` config error.
+
+## Use with Claude Code
+
+Install the focused skill — it auto-installs the CLI on first invocation:
+
+```bash
+npx skills add mvanhorn/printing-press-library/cli-skills/pp-public-param-golden -g
+```
+
+Then invoke `/pp-public-param-golden <query>` in Claude Code. The skill is the most efficient path — Claude Code drives the CLI directly without an MCP server in the middle.
+
+<details>
+<summary>Use as an MCP server in Claude Code (advanced)</summary>
+
+If you'd rather register this CLI as an MCP server in Claude Code, install the MCP binary first:
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/other/public-param-golden/cmd/public-param-golden-pp-mcp@latest
+```
+
+Then register it:
+
+```bash
+claude mcp add public-param-golden public-param-golden-pp-mcp
+```
+
+</details>
+
+## Use with Claude Desktop
+
+This CLI ships an [MCPB](https://github.com/modelcontextprotocol/mcpb) bundle — Claude Desktop's standard format for one-click MCP extension installs (no JSON config required).
+
+To install:
+
+1. Download the `.mcpb` for your platform from the [latest release](https://github.com/mvanhorn/printing-press-library/releases/tag/public-param-golden-current).
+2. Double-click the `.mcpb` file. Claude Desktop opens and walks you through the install.
+
+Requires Claude Desktop 1.0.0 or later. Pre-built bundles ship for macOS Apple Silicon (`darwin-arm64`) and Windows (`amd64`, `arm64`); for other platforms, use the manual config below.
+
+<!-- pp-hermes-install-anchor -->
+## Install via Hermes
+
+From the Hermes CLI:
+
+```bash
+hermes skills install mvanhorn/printing-press-library/cli-skills/pp-public-param-golden --force
+```
+
+Inside a Hermes chat session:
+
+```bash
+/skills install mvanhorn/printing-press-library/cli-skills/pp-public-param-golden --force
+```
+
+## Install via OpenClaw
+
+Tell your OpenClaw agent (copy this):
+
+```
+Install the pp-public-param-golden skill from https://github.com/mvanhorn/printing-press-library/tree/main/cli-skills/pp-public-param-golden. The skill defines how its required CLI can be installed.
+```
+
+<details>
+<summary>Manual JSON config (advanced)</summary>
+
+If you can't use the MCPB bundle (older Claude Desktop, unsupported platform), install the MCP binary and configure it manually.
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/other/public-param-golden/cmd/public-param-golden-pp-mcp@latest
+```
+
+Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
+
+```json
+{
+ "mcpServers": {
+ "public-param-golden": {
+ "command": "public-param-golden-pp-mcp"
+ }
+ }
+}
+```
+
+</details>
+
+## Health Check
+
+```bash
+public-param-golden-pp-cli doctor
+```
+
+Verifies configuration and connectivity to the API.
+
+## Configuration
+
+Config file: ``
+
+## Troubleshooting
+**Not found errors (exit code 3)**
+- Check the resource ID is correct
+- Run the `list` command to see available items
+
+---
+
+Generated by [CLI Printing Press](https://github.com/mvanhorn/cli-printing-press)
diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/SKILL.md b/testdata/golden/expected/generate-public-param-names/public-param-golden/SKILL.md
new file mode 100644
index 00000000..aabdf89e
--- /dev/null
+++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/SKILL.md
@@ -0,0 +1,160 @@
+---
+name: pp-public-param-golden
+description: "Printing Press CLI for Public Param Golden. Public parameter name golden fixture"
+author: "printing-press-golden"
+license: "Apache-2.0"
+argument-hint: "<command> [args] | install cli|mcp"
+allowed-tools: "Read Bash"
+metadata:
+ openclaw:
+ requires:
+ bins:
+ - public-param-golden-pp-cli
+ install:
+ - kind: go
+ bins: [public-param-golden-pp-cli]
+ module: github.com/mvanhorn/printing-press-library/library/other/public-param-golden/cmd/public-param-golden-pp-cli
+---
+
+# Public Param Golden — Printing Press CLI
+
+## Prerequisites: Install the CLI
+
+This skill drives the `public-param-golden-pp-cli` binary. **You must verify the CLI is installed before invoking any command from this skill.** If it is missing, install it first:
+
+1. Install via the Printing Press installer:
+ ```bash
+ npx -y @mvanhorn/printing-press install public-param-golden --cli-only
+ ```
+2. Verify: `public-param-golden-pp-cli --version`
+3. Ensure `$GOPATH/bin` (or `$HOME/go/bin`) is on `$PATH`.
+
+If the `npx` install fails (no Node, offline, etc.), fall back to a direct Go install (requires Go 1.23+):
+
+```bash
+go install github.com/mvanhorn/printing-press-library/library/other/public-param-golden/cmd/public-param-golden-pp-cli@latest
+```
+
+If `--version` reports "command not found" after install, the install step did not put the binary on `$PATH`. Do not proceed with skill commands until verification succeeds.
+
+Public parameter name golden fixture
+
+## Command Reference
+
+**stores** — Store lookup operations
+
+- `public-param-golden-pp-cli stores create` — Create a store record
+- `public-param-golden-pp-cli stores find` — Find nearby stores by address
+
+
+### Finding the right command
+
+When you know what you want to do but not which command does it, ask the CLI directly:
+
+```bash
+public-param-golden-pp-cli which "<capability in your own words>"
+```
+
+`which` resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code `0` means at least one match; exit code `2` means no confident match — fall back to `--help` or use a narrower query.
+
+## Auth Setup
+
+No authentication required.
+
+Run `public-param-golden-pp-cli doctor` to verify setup.
+
+## Agent Mode
+
+Add `--agent` to any command. Expands to: `--json --compact --no-input --no-color --yes`.
+
+- **Pipeable** — JSON on stdout, errors on stderr
+- **Filterable** — `--select` keeps a subset of fields. Dotted paths descend into nested structures; arrays traverse element-wise. Critical for keeping context small on verbose APIs:
+
+ ```bash
+ public-param-golden-pp-cli stores create --store-code example-value --agent --select id,name,status
+ ```
+- **Previewable** — `--dry-run` shows the request without sending
+- **Non-interactive** — never prompts, every input is a flag
+- **Explicit retries** — use `--idempotent` only when an already-existing create should count as success
+
+## Agent Feedback
+
+When you (or the agent) notice something off about this CLI, record it:
+
+```
+public-param-golden-pp-cli feedback "the --since flag is inclusive but docs say exclusive"
+public-param-golden-pp-cli feedback --stdin < notes.txt
+public-param-golden-pp-cli feedback list --json --limit 10
+```
+
+Entries are stored locally at `~/.public-param-golden-pp-cli/feedback.jsonl`. They are never POSTed unless `PUBLIC_PARAM_GOLDEN_FEEDBACK_ENDPOINT` is set AND either `--send` is passed or `PUBLIC_PARAM_GOLDEN_FEEDBACK_AUTO_SEND=true`. Default behavior is local-only.
+
+Write what *surprised* you, not a bug report. Short, specific, one line: that is the part that compounds.
+
+## Output Delivery
+
+Every command accepts `--deliver <sink>`. The output goes to the named sink in addition to (or instead of) stdout, so agents can route command results without hand-piping. Three sinks are supported:
+
+| Sink | Effect |
+|------|--------|
+| `stdout` | Default; write to stdout only |
+| `file:<path>` | Atomically write output to `<path>` (tmp + rename) |
+| `webhook:<url>` | POST the output body to the URL (`application/json` or `application/x-ndjson` when `--compact`) |
+
+Unknown schemes are refused with a structured error naming the supported set. Webhook failures return non-zero and log the URL + HTTP status on stderr.
+
+## Named Profiles
+
+A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern.
+
+```
+public-param-golden-pp-cli profile save briefing --json
+public-param-golden-pp-cli --profile briefing stores create --store-code example-value
+public-param-golden-pp-cli profile list --json
+public-param-golden-pp-cli profile show briefing
+public-param-golden-pp-cli profile delete briefing --yes
+```
+
+Explicit flags always win over profile values; profile values win over defaults. `agent-context` lists all available profiles under `available_profiles` so introspecting agents discover them at runtime.
+
+## Exit Codes
+
+| Code | Meaning |
+|------|---------|
+| 0 | Success |
+| 2 | Usage error (wrong arguments) |
+| 3 | Resource not found |
+| 5 | API error (upstream issue) |
+| 7 | Rate limited (wait and retry) |
+| 10 | Config error |
+
+## Argument Parsing
+
+Parse `$ARGUMENTS`:
+
+1. **Empty, `help`, or `--help`** → show `public-param-golden-pp-cli --help` output
+2. **Starts with `install`** → ends with `mcp` → MCP installation; otherwise → see Prerequisites above
+3. **Anything else** → Direct Use (execute as CLI command with `--agent`)
+
+## MCP Server Installation
+
+1. Install the MCP server:
+ ```bash
+ go install github.com/mvanhorn/printing-press-library/library/other/public-param-golden/cmd/public-param-golden-pp-mcp@latest
+ ```
+2. Register with Claude Code:
+ ```bash
+ claude mcp add public-param-golden-pp-mcp -- public-param-golden-pp-mcp
+ ```
+3. Verify: `claude mcp list`
+
+## Direct Use
+
+1. Check if installed: `which public-param-golden-pp-cli`
+ If not found, offer to install (see Prerequisites at the top of this skill).
+2. Match the user query to the best command from the Unique Capabilities and Command Reference above.
+3. Execute with the `--agent` flag:
+ ```bash
+ public-param-golden-pp-cli <command> [subcommand] [args] --agent
+ ```
+4. If ambiguous, drill into subcommand help: `public-param-golden-pp-cli <command> --help`.
diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
new file mode 100644
index 00000000..0a0554ae
--- /dev/null
+++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_create.go
@@ -0,0 +1,124 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newStoresCreateCmd(flags *rootFlags) *cobra.Command {
+ var bodyStoreCode string
+ var stdinBody bool
+
+ cmd := &cobra.Command{
+ Use: "create",
+ Short: "Create a store record",
+ Example: " public-param-golden-pp-cli stores create --store-code example-value",
+ Annotations: map[string]string{"pp:endpoint": "stores.create", "pp:method": "POST", "pp:path": "/stores"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !stdinBody {
+ if !(cmd.Flags().Changed("store-code") || cmd.Flags().Changed("code")) && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "store-code")
+ }
+ }
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/stores"
+ var body map[string]any
+ if stdinBody {
+ stdinData, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ return fmt.Errorf("reading stdin: %w", err)
+ }
+ var jsonBody map[string]any
+ if err := json.Unmarshal(stdinData, &jsonBody); err != nil {
+ return fmt.Errorf("parsing stdin JSON: %w", err)
+ }
+ body = jsonBody
+ } else {
+ body = map[string]any{}
+ if bodyStoreCode != "" {
+ body["store_code"] = bodyStoreCode
+ }
+ }
+ data, statusCode, err := c.Post(path, body)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ // Check if response contains an array (directly or wrapped in "data")
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
+ } else {
+ return nil
+ }
+ } else {
+ var wrapped struct{ Data []map[string]any `json:"data"` }
+ if json.Unmarshal(data, &wrapped) == nil && len(wrapped.Data) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
+ } else {
+ return nil
+ }
+ }
+ }
+ }
+ if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+ if flags.quiet {
+ return nil
+ }
+ // Apply --compact and --select to the API response before wrapping.
+ // --select wins when both are set: explicit field choice trumps the
+ // generic high-gravity allow-list. Otherwise --compact still applies
+ // when --agent is on but the user did not name fields.
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ envelope := map[string]any{
+ "action": "post",
+ "resource": "stores",
+ "path": path,
+ "status": statusCode,
+ "success": statusCode >= 200 && statusCode < 300,
+ }
+ if flags.dryRun {
+ envelope["dry_run"] = true
+ envelope["status"] = 0
+ envelope["success"] = false
+ }
+ if len(filtered) > 0 {
+ var parsed any
+ if err := json.Unmarshal(filtered, &parsed); err == nil {
+ envelope["data"] = parsed
+ }
+ }
+ envelopeJSON, err := json.Marshal(envelope)
+ if err != nil {
+ return err
+ }
+ return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+ cmd.Flags().StringVar(&bodyStoreCode, "store-code", "", "Store code")
+ cmd.Flags().StringVar(&bodyStoreCode, "code", "", "Store code")
+ _ = cmd.Flags().MarkHidden("code")
+ cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
+
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_find.go b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_find.go
new file mode 100644
index 00000000..e2ddce1f
--- /dev/null
+++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/cli/stores_find.go
@@ -0,0 +1,70 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newStoresFindCmd(flags *rootFlags) *cobra.Command {
+ var flagS string
+ var flagC string
+
+ cmd := &cobra.Command{
+ Use: "find",
+ Short: "Find nearby stores by address",
+ Example: " public-param-golden-pp-cli stores find --address example-value --city example-value",
+ Annotations: map[string]string{"pp:endpoint": "stores.find", "pp:method": "GET", "pp:path": "/power/store-locator", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !(cmd.Flags().Changed("address") || cmd.Flags().Changed("s")) && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "address")
+ }
+ if !(cmd.Flags().Changed("city") || cmd.Flags().Changed("c")) && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "city")
+ }
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/power/store-locator"
+ params := map[string]string{}
+ if flagS != "" {
+ params["s"] = fmt.Sprintf("%v", flagS)
+ }
+ if flagC != "" {
+ params["c"] = fmt.Sprintf("%v", flagC)
+ }
+ data, err := c.Get(path, params)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ return err
+ }
+ if len(items) >= 25 {
+ fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items))
+ }
+ return nil
+ }
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+ cmd.Flags().StringVar(&flagS, "address", "", "Street address")
+ cmd.Flags().StringVar(&flagS, "s", "", "Street address")
+ _ = cmd.Flags().MarkHidden("s")
+ cmd.Flags().StringVar(&flagC, "city", "", "City, state, zip")
+ cmd.Flags().StringVar(&flagC, "c", "", "City, state, zip")
+ _ = cmd.Flags().MarkHidden("c")
+
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.go
new file mode 100644
index 00000000..72980e0a
--- /dev/null
+++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.go
@@ -0,0 +1,246 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package mcp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ mcplib "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "public-param-golden-pp-cli/internal/cli"
+ "public-param-golden-pp-cli/internal/client"
+ "public-param-golden-pp-cli/internal/config"
+ "public-param-golden-pp-cli/internal/mcp/cobratree"
+)
+
+// RegisterTools registers all API operations as MCP tools.
+func RegisterTools(s *server.MCPServer) {
+ s.AddTool(
+ mcplib.NewTool("stores_create",
+ mcplib.WithDescription("Create a store record. Required: store-code. Returns the new Store."),
+ mcplib.WithString("store-code", mcplib.Required(), mcplib.Description("Store code")),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("POST", "/stores", []mcpParamBinding{{PublicName: "store-code", WireName: "store_code", Location: "body"}, }, []string{ }),
+ )
+ s.AddTool(
+ mcplib.NewTool("stores_find",
+ mcplib.WithDescription("Find nearby stores by address. Required: address, city. Returns array of Store."),
+ mcplib.WithString("address", mcplib.Required(), mcplib.Description("Street address")),
+ mcplib.WithString("city", mcplib.Required(), mcplib.Description("City, state, zip")),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/power/store-locator", []mcpParamBinding{{PublicName: "address", WireName: "s", Location: "query"},{PublicName: "city", WireName: "c", Location: "query"}, }, []string{ }),
+ )
+
+ // Context tool — front-loaded domain knowledge for agents.
+ // Call this first to understand the API taxonomy, query patterns, and capabilities.
+ s.AddTool(
+ mcplib.NewTool("context",
+ mcplib.WithDescription("Get API domain context: resource taxonomy, auth requirements, query tips, and unique capabilities. Call this first."),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ ),
+ handleContext,
+ )
+
+ // Runtime Cobra-tree mirror — exposes every user-facing command that is
+ // not already covered by a typed endpoint or framework MCP tool.
+ cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
+}
+
+type mcpParamBinding struct {
+ PublicName string
+ WireName string
+ Location string
+}
+
+// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
+func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+ return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ c, err := newMCPClient()
+ if err != nil {
+ return mcplib.NewToolResultError(err.Error()), nil
+ }
+
+ // mcp-go v0.47+ made CallToolParams.Arguments an `any` to support
+ // non-map payloads; GetArguments() returns the map[string]any shape
+ // we rely on here (or an empty map when the payload is something else).
+ args := req.GetArguments()
+
+ // positionalParams mixes real URL path params with CLI positional
+ // args that map to query params (e.g. `search <query>` -> ?query=);
+ // the placeholder check below disambiguates them at runtime.
+ path := pathTemplate
+ knownArgs := make(map[string]bool, len(bindings))
+ pathParams := make(map[string]bool, len(positionalParams))
+ params := make(map[string]string)
+ bodyArgs := make(map[string]any)
+ for _, binding := range bindings {
+ knownArgs[binding.PublicName] = true
+ v, ok := args[binding.PublicName]
+ if !ok {
+ continue
+ }
+ switch binding.Location {
+ case "path":
+ placeholder := "{" + binding.WireName + "}"
+ pathParams[binding.PublicName] = true
+ path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+ case "body":
+ bodyArgs[binding.WireName] = v
+ default:
+ params[binding.WireName] = fmt.Sprintf("%v", v)
+ }
+ }
+ for _, p := range positionalParams {
+ placeholder := "{" + p + "}"
+ if !strings.Contains(pathTemplate, placeholder) {
+ continue
+ }
+ pathParams[p] = true
+ if v, ok := args[p]; ok {
+ path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+ }
+ }
+
+ for k, v := range args {
+ if pathParams[k] || knownArgs[k] {
+ continue
+ }
+ switch method {
+ case "POST", "PUT", "PATCH":
+ bodyArgs[k] = v
+ default:
+ params[k] = fmt.Sprintf("%v", v)
+ }
+ }
+
+ var data json.RawMessage
+ switch method {
+ case "GET":
+ data, err = c.Get(path, params)
+ case "POST":
+ body, _ := json.Marshal(bodyArgs)
+ data, _, err = c.Post(path, body)
+ case "PUT":
+ body, _ := json.Marshal(bodyArgs)
+ data, _, err = c.Put(path, body)
+ case "PATCH":
+ body, _ := json.Marshal(bodyArgs)
+ data, _, err = c.Patch(path, body)
+ case "DELETE":
+ data, _, err = c.Delete(path)
+ default:
+ return mcplib.NewToolResultError("unsupported method: " + method), nil
+ }
+
+ if err != nil {
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "HTTP 409"):
+ return mcplib.NewToolResultText("already exists (no-op)"), nil
+ case strings.Contains(msg, "HTTP 401"):
+ return mcplib.NewToolResultError("authentication failed: " + msg +
+ "\nhint: check your API credentials." +
+ "\n Run 'public-param-golden-pp-cli doctor' to check auth status."), nil
+ case strings.Contains(msg, "HTTP 403"):
+ return mcplib.NewToolResultError("permission denied: " + msg +
+ "\nhint: this API is configured without credentials; the service may be blocking the request by rate limit, geography, bot protection, or endpoint policy." +
+ "\n Run 'public-param-golden-pp-cli doctor' to check auth status."), nil
+ case strings.Contains(msg, "HTTP 404"):
+ if method == "DELETE" {
+ return mcplib.NewToolResultText("already deleted (no-op)"), nil
+ }
+ return mcplib.NewToolResultError("not found: " + msg), nil
+ case strings.Contains(msg, "HTTP 429"):
+ return mcplib.NewToolResultError("rate limited: " + msg), nil
+ default:
+ return mcplib.NewToolResultError(msg), nil
+ }
+ }
+
+ // For GET responses, wrap bare arrays with count metadata
+ if method == "GET" {
+ trimmed := strings.TrimSpace(string(data))
+ if len(trimmed) > 0 && trimmed[0] == '[' {
+ var items []json.RawMessage
+ if json.Unmarshal(data, &items) == nil {
+ wrapped := map[string]any{
+ "count": len(items),
+ "items": items,
+ }
+ out, _ := json.Marshal(wrapped)
+ return mcplib.NewToolResultText(string(out)), nil
+ }
+ }
+ }
+ return mcplib.NewToolResultText(string(data)), nil
+ }
+}
+
+func newMCPClient() (*client.Client, error) {
+ home, _ := os.UserHomeDir()
+ cfgPath := filepath.Join(home, ".config", "public-param-golden-pp-cli", "config.toml")
+ cfg, err := config.Load(cfgPath)
+ if err != nil {
+ return nil, fmt.Errorf("loading config: %w", err)
+ }
+ c := client.New(cfg, 30*time.Second, 0)
+ // Agents calling through MCP need fresh data every call. The on-disk
+ // response cache survives across MCP server invocations, so a
+ // DELETE/PATCH followed by a GET would otherwise return the
+ // pre-mutation snapshot for up to the cache TTL. The interactive CLI
+ // constructs its own client and is unaffected.
+ c.NoCache = true
+ return c, nil
+}
+
+func dbPath() string {
+ home, _ := os.UserHomeDir()
+ return filepath.Join(home, ".local", "share", "public-param-golden-pp-cli", "data.db")
+}
+// Note: MCP tools use their own dbPath() because they are in a separate package (main, not cli).
+// The CLI's defaultDBPath() in the cli package uses the same canonical path.
+
+func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+ ctx := map[string]any{
+ "api": "public-param-golden",
+ "description": "Public parameter name golden fixture",
+ "archetype": "generic",
+ "tool_count": 2,
+ // tool_surface tells agents which surface a capability lives on.
+ "tool_surface": "MCP exposes typed endpoint tools plus a runtime mirror of user-facing CLI commands. Endpoint tools keep typed schemas; command-mirror tools shell out to the companion public-param-golden-pp-cli binary.",
+ "resources": []map[string]any{
+ {
+ "name": "stores",
+ "description": "Store lookup operations",
+ "endpoints": []string{"create", "find", },
+ "searchable": true,
+ },
+ },
+ "query_tips": []string{
+ "Pagination uses cursor-based paging. Pass after parameter for subsequent pages.",
+ "Control page size with the limit parameter (default 100).",
+ },
+ }
+ data, _ := json.MarshalIndent(ctx, "", " ")
+ return mcplib.NewToolResultText(string(data)), nil
+}
+
+// RegisterNovelFeatureTools is kept as a compatibility no-op for older MCP
+// mains. New generated mains call RegisterTools only; RegisterTools now
+// includes the runtime Cobra-tree mirror.
+func RegisterNovelFeatureTools(s *server.MCPServer) {
+ _ = s
+}
diff --git a/testdata/golden/expected/generate-public-param-names/stderr.txt b/testdata/golden/expected/generate-public-param-names/stderr.txt
new file mode 100644
index 00000000..94e7c594
--- /dev/null
+++ b/testdata/golden/expected/generate-public-param-names/stderr.txt
@@ -0,0 +1,2 @@
+warning: could not derive run_id from --research-dir; phase5 dogfood acceptance will refuse to write without it
+Generated public-param-golden at <ARTIFACT_DIR>/generate-public-param-names/public-param-golden
diff --git a/testdata/golden/expected/generate-public-param-names/stdout.txt b/testdata/golden/expected/generate-public-param-names/stdout.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go
index 392d9e54..70382322 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go
@@ -31,7 +31,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/items/enterprise", "enterprise", []string{ }),
+ makeAPIHandler("GET", "/items/enterprise", "enterprise", []mcpParamBinding{ }, []string{ }),
)
s.AddTool(
mcplib.NewTool("items_list",
@@ -40,7 +40,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/items", "free", []string{ }),
+ makeAPIHandler("GET", "/items", "free", []mcpParamBinding{ }, []string{ }),
)
s.AddTool(
mcplib.NewTool("items_premium",
@@ -49,7 +49,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/items/premium", "paid", []string{ }),
+ makeAPIHandler("GET", "/items/premium", "paid", []mcpParamBinding{ }, []string{ }),
)
// SQL tool — ad-hoc analysis on synced data without API calls
s.AddTool(
@@ -78,8 +78,14 @@ func RegisterTools(s *server.MCPServer) {
cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
}
+type mcpParamBinding struct {
+ PublicName string
+ WireName string
+ Location string
+}
+
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
-func makeAPIHandler(method, pathTemplate, tier string, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate, tier string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
@@ -96,7 +102,27 @@ func makeAPIHandler(method, pathTemplate, tier string, positionalParams []string
// args that map to query params (e.g. `search <query>` -> ?query=);
// the placeholder check below disambiguates them at runtime.
path := pathTemplate
+ knownArgs := make(map[string]bool, len(bindings))
pathParams := make(map[string]bool, len(positionalParams))
+ params := make(map[string]string)
+ bodyArgs := make(map[string]any)
+ for _, binding := range bindings {
+ knownArgs[binding.PublicName] = true
+ v, ok := args[binding.PublicName]
+ if !ok {
+ continue
+ }
+ switch binding.Location {
+ case "path":
+ placeholder := "{" + binding.WireName + "}"
+ pathParams[binding.PublicName] = true
+ path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
+ case "body":
+ bodyArgs[binding.WireName] = v
+ default:
+ params[binding.WireName] = fmt.Sprintf("%v", v)
+ }
+ }
for _, p := range positionalParams {
placeholder := "{" + p + "}"
if !strings.Contains(pathTemplate, placeholder) {
@@ -108,12 +134,16 @@ func makeAPIHandler(method, pathTemplate, tier string, positionalParams []string
}
}
- params := make(map[string]string)
for k, v := range args {
- if pathParams[k] {
+ if pathParams[k] || knownArgs[k] {
continue
}
- params[k] = fmt.Sprintf("%v", v)
+ switch method {
+ case "POST", "PUT", "PATCH":
+ bodyArgs[k] = v
+ default:
+ params[k] = fmt.Sprintf("%v", v)
+ }
}
var data json.RawMessage
@@ -121,13 +151,13 @@ func makeAPIHandler(method, pathTemplate, tier string, positionalParams []string
case "GET":
data, err = c.Get(path, params)
case "POST":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Post(path, body)
case "PUT":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Put(path, body)
case "PATCH":
- body, _ := json.Marshal(args)
+ body, _ := json.Marshal(bodyArgs)
data, _, err = c.Patch(path, body)
case "DELETE":
data, _, err = c.Delete(path)
diff --git a/testdata/golden/fixtures/public-param-names.yaml b/testdata/golden/fixtures/public-param-names.yaml
new file mode 100644
index 00000000..50401745
--- /dev/null
+++ b/testdata/golden/fixtures/public-param-names.yaml
@@ -0,0 +1,51 @@
+name: public-param-golden
+description: Public parameter name golden fixture
+version: "0.1.0"
+base_url: https://api.public-param.example/v1
+auth:
+ type: none
+resources:
+ stores:
+ description: Store lookup operations
+ endpoints:
+ find:
+ method: GET
+ path: /power/store-locator
+ description: Find nearby stores by address
+ params:
+ - name: s
+ flag_name: address
+ aliases: [s]
+ type: string
+ required: true
+ description: Street address
+ - name: c
+ flag_name: city
+ aliases: [c]
+ type: string
+ required: true
+ description: City, state, zip
+ response:
+ type: array
+ item: Store
+ create:
+ method: POST
+ path: /stores
+ description: Create a store record
+ body:
+ - name: store_code
+ flag_name: store-code
+ aliases: [code]
+ type: string
+ required: true
+ description: Store code
+ response:
+ type: object
+ item: Store
+types:
+ Store:
+ fields:
+ - name: id
+ type: string
+ - name: name
+ type: string
← e6aa0326 fix(cli): omit version field from SKILL.md frontmatter (#656
·
back to Cli Printing Press
·
feat(cli): improve generated money workflows and artifact sa 6d9ab664 →