[object Object]

← back to Cli Printing Press

fix(cli): dedup colliding flag identifiers in generated commands (#283)

72a84a88d671174c814b09fb4f5032cc26f4b867 · 2026-04-25 10:13:32 -0700 · Trevin Chow

* fix(cli): dedup colliding flag identifiers in generated commands

When a spec lists params whose names camelize to the same Go
identifier — Twilio's StartTime, StartTime>, StartTime< all
collapsing to "StartTime" — or has a literal "all" param on a
paginated endpoint colliding with pagination's reserved flagAll,
the generator emitted multiple `var flagX` declarations in one
function and the resulting CLI refused to compile.

A pre-render dedup pass now walks each endpoint's params and
populates a new IdentName field on collisions, suffixing _2/_3
until the camel identifier and kebab flag name are both unique
relative to other params on the same endpoint and to reserved
generator names (flagAll for paginated endpoints; flagWait*
for async). Templates derive Go identifiers and CLI flag names
through paramIdent (IdentName when set, else Name). Wire-side
serialization — URL params, JSON keys, path substitution —
keeps reading Name, so request bodies and query strings still
hit the API with the parameter names it expects.

Reported in #275 (F-2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): align sub-resource async key in flag dedup pass

Code review on #283 surfaced that the dedup pass keyed
sub-resource async lookups on resName.subName/epName, while
the rest of the generator (see generator.go:1195) keys them
on subName/epName without the parent prefix. The mismatch
hid behind the fact that DetectAsyncJobs does not currently
walk sub-resources, so the lookup was a no-op either way,
but any future async coverage of sub-resources would have
silently skipped the wait identifier protection.

Drop the parent-resource prefix and add a comment pointing
at the existing convention. Also add a regression test for
the top-level async case (a user param named "wait" on an
async-detected endpoint), which had no coverage before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 72a84a88d671174c814b09fb4f5032cc26f4b867
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 25 10:13:32 2026 -0700

    fix(cli): dedup colliding flag identifiers in generated commands (#283)
    
    * fix(cli): dedup colliding flag identifiers in generated commands
    
    When a spec lists params whose names camelize to the same Go
    identifier — Twilio's StartTime, StartTime>, StartTime< all
    collapsing to "StartTime" — or has a literal "all" param on a
    paginated endpoint colliding with pagination's reserved flagAll,
    the generator emitted multiple `var flagX` declarations in one
    function and the resulting CLI refused to compile.
    
    A pre-render dedup pass now walks each endpoint's params and
    populates a new IdentName field on collisions, suffixing _2/_3
    until the camel identifier and kebab flag name are both unique
    relative to other params on the same endpoint and to reserved
    generator names (flagAll for paginated endpoints; flagWait*
    for async). Templates derive Go identifiers and CLI flag names
    through paramIdent (IdentName when set, else Name). Wire-side
    serialization — URL params, JSON keys, path substitution —
    keeps reading Name, so request bodies and query strings still
    hit the API with the parameter names it expects.
    
    Reported in #275 (F-2).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): align sub-resource async key in flag dedup pass
    
    Code review on #283 surfaced that the dedup pass keyed
    sub-resource async lookups on resName.subName/epName, while
    the rest of the generator (see generator.go:1195) keys them
    on subName/epName without the parent prefix. The mismatch
    hid behind the fact that DetectAsyncJobs does not currently
    walk sub-resources, so the lookup was a no-op either way,
    but any future async coverage of sub-resources would have
    silently skipped the wait identifier protection.
    
    Drop the parent-resource prefix and add a comment pointing
    at the existing convention. Also add a regression test for
    the top-level async case (a user param named "wait" on an
    async-detected endpoint), which had no coverage before.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/generator/flag_collision.go               | 135 +++++++++++++
 internal/generator/flag_collision_test.go          | 222 +++++++++++++++++++++
 internal/generator/generator.go                    |   7 +
 .../generator/templates/command_endpoint.go.tmpl   | 130 ++++++------
 .../generator/templates/command_promoted.go.tmpl   |  48 ++---
 internal/spec/spec.go                              |   9 +
 6 files changed, 462 insertions(+), 89 deletions(-)

diff --git a/internal/generator/flag_collision.go b/internal/generator/flag_collision.go
new file mode 100644
index 00000000..806dd966
--- /dev/null
+++ b/internal/generator/flag_collision.go
@@ -0,0 +1,135 @@
+package generator
+
+import (
+	"fmt"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+// dedupeFlagIdentifiers ensures that no two non-positional params on a single
+// endpoint share a Go identifier (flag<Camel>) or cobra flag name after
+// camelization or kebab-casing, and that no param collides with a reserved
+// generator-introduced identifier (pagination's flagAll; async's flagWait,
+// flagWaitTimeout, flagWaitInterval).
+//
+// Conflicting params have Name suffixed with _2, _3, ... until the identifier
+// and flag name are both unique. Without this, specs that use date-range
+// filter conventions (e.g., Twilio's StartTime, StartTime>, StartTime< all
+// camelize to "StartTime") or expose a literal "all" parameter on a paginated
+// endpoint (e.g., GitHub notifications colliding with pagination's --all)
+// produce duplicate `var flagX` declarations and refuse to compile.
+func (g *Generator) dedupeFlagIdentifiers() {
+	if g.Spec == nil {
+		return
+	}
+	for resName, res := range g.Spec.Resources {
+		for epName, ep := range res.Endpoints {
+			idents, flags := reservedFlagNamesForEndpoint(resName, epName, ep, g.AsyncJobs)
+			ep.Params = uniquifyParamNames(ep.Params, idents, flags)
+			res.Endpoints[epName] = ep
+		}
+		for subName, sub := range res.SubResources {
+			// Sub-resource async lookups elsewhere in the generator (see
+			// generator.go:1195) key on subName/epName without the parent
+			// resource prefix; mirror that here so any future async
+			// detection on sub-resources protects the wait identifiers
+			// correctly. DetectAsyncJobs does not currently walk
+			// sub-resources, so this lookup is a no-op today.
+			for epName, ep := range sub.Endpoints {
+				idents, flags := reservedFlagNamesForEndpoint(subName, epName, ep, g.AsyncJobs)
+				ep.Params = uniquifyParamNames(ep.Params, idents, flags)
+				sub.Endpoints[epName] = ep
+			}
+			res.SubResources[subName] = sub
+		}
+		g.Spec.Resources[resName] = res
+	}
+}
+
+// reservedFlagNamesForEndpoint returns identifiers and flag names that the
+// command templates emit themselves and that user params therefore must not
+// shadow.
+func reservedFlagNamesForEndpoint(resKey, epName string, ep spec.Endpoint, asyncJobs map[string]AsyncJobInfo) (idents, flags map[string]struct{}) {
+	idents = map[string]struct{}{}
+	flags = map[string]struct{}{}
+	if ep.Pagination != nil {
+		idents["flagAll"] = struct{}{}
+		flags["all"] = struct{}{}
+	}
+	if _, isAsync := asyncJobs[resKey+"/"+epName]; isAsync {
+		idents["flagWait"] = struct{}{}
+		idents["flagWaitTimeout"] = struct{}{}
+		idents["flagWaitInterval"] = struct{}{}
+		flags["wait"] = struct{}{}
+		flags["wait-timeout"] = struct{}{}
+		flags["wait-interval"] = struct{}{}
+	}
+	return idents, flags
+}
+
+// uniquifyParamNames returns params with IdentName populated whenever a
+// param's Go identifier or cobra flag name would otherwise collide with
+// another param earlier in the list or with a reserved generator name. The
+// first occurrence of each colliding pattern keeps IdentName empty (templates
+// fall back to Name); subsequent ones get IdentName set to Name with _2, _3,
+// ... appended. Wire-side serialization always reads from Name and is never
+// mutated. Positional params are not flagged and pass through.
+func uniquifyParamNames(params []spec.Param, reservedIdents, reservedFlags map[string]struct{}) []spec.Param {
+	if len(params) == 0 {
+		return params
+	}
+	usedIdents := map[string]struct{}{}
+	usedFlags := map[string]struct{}{}
+	for k := range reservedIdents {
+		usedIdents[k] = struct{}{}
+	}
+	for k := range reservedFlags {
+		usedFlags[k] = struct{}{}
+	}
+
+	out := make([]spec.Param, len(params))
+	for i, p := range params {
+		if p.Positional {
+			out[i] = p
+			continue
+		}
+		ident := "flag" + toCamel(p.Name)
+		flag := flagName(p.Name)
+		if _, identTaken := usedIdents[ident]; !identTaken {
+			if _, flagTaken := usedFlags[flag]; !flagTaken {
+				usedIdents[ident] = struct{}{}
+				usedFlags[flag] = struct{}{}
+				out[i] = p
+				continue
+			}
+		}
+		for n := 2; ; n++ {
+			candidate := fmt.Sprintf("%s_%d", p.Name, n)
+			ident = "flag" + toCamel(candidate)
+			flag = flagName(candidate)
+			_, identTaken := usedIdents[ident]
+			_, flagTaken := usedFlags[flag]
+			if !identTaken && !flagTaken {
+				p.IdentName = candidate
+				usedIdents[ident] = struct{}{}
+				usedFlags[flag] = struct{}{}
+				out[i] = p
+				break
+			}
+		}
+	}
+	return out
+}
+
+// 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
+// resulting string must never be used for wire-side serialization;
+// callers writing URL params, JSON keys, or path substitutions read
+// Name directly.
+func paramIdent(p spec.Param) string {
+	if p.IdentName != "" {
+		return p.IdentName
+	}
+	return p.Name
+}
diff --git a/internal/generator/flag_collision_test.go b/internal/generator/flag_collision_test.go
new file mode 100644
index 00000000..1010d6ac
--- /dev/null
+++ b/internal/generator/flag_collision_test.go
@@ -0,0 +1,222 @@
+package generator
+
+import (
+	"go/ast"
+	"go/parser"
+	"go/token"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestGenerateDeduplicatesCamelCollidingParams covers Case A from issue #275 F-2.
+// Twilio's spec lists StartTime, StartTime>, and StartTime< as distinct query
+// params for date-range filtering. toCamel strips '>' and '<' as non-alphanumeric,
+// so all three would yield Go identifier "StartTime" and the template would emit
+// three `var flagStartTime` declarations in one function — illegal redeclaration.
+func TestGenerateDeduplicatesCamelCollidingParams(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("collide-camel")
+	// Two endpoints so `list` renders to its own file rather than being
+	// consolidated by the single-endpoint promotion path.
+	apiSpec.Resources["calls"] = spec.Resource{
+		Description: "Calls",
+		Endpoints: map[string]spec.Endpoint{
+			"list": {
+				Method:      "GET",
+				Path:        "/calls",
+				Description: "List calls within a date range",
+				Params: []spec.Param{
+					{Name: "StartTime", Type: "string", Description: "Exact timestamp"},
+					{Name: "StartTime>", Type: "string", Description: "After timestamp"},
+					{Name: "StartTime<", Type: "string", Description: "Before timestamp"},
+				},
+			},
+			"get": {
+				Method:      "GET",
+				Path:        "/calls/{id}",
+				Description: "Get one call",
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "collide-camel-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	flagVars, flagBindings := parseFlagDeclarations(t,
+		filepath.Join(outputDir, "internal", "cli", "calls_list.go"))
+
+	assertNoDuplicates(t, flagVars,
+		"each param must produce a distinct Go identifier")
+	assertNoDuplicates(t, flagBindings,
+		"each param must register a distinct cobra flag name")
+	require.Len(t, flagVars, 3,
+		"all three params must still be represented after dedup")
+}
+
+// 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
+// `var flagAll` once for the user-defined `all` param and again for pagination's
+// "fetch all pages" flag — illegal redeclaration.
+func TestGenerateRenamesParamCollidingWithPaginationAll(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("collide-all")
+	apiSpec.Resources["notifications"] = spec.Resource{
+		Description: "Notifications",
+		Endpoints: map[string]spec.Endpoint{
+			"list": {
+				Method:      "GET",
+				Path:        "/notifications",
+				Description: "List notifications",
+				Params: []spec.Param{
+					{Name: "all", Type: "bool", Description: "Include read notifications"},
+				},
+				Pagination: &spec.Pagination{
+					Type:           "page_token",
+					LimitParam:     "per_page",
+					CursorParam:    "page",
+					NextCursorPath: "next",
+					HasMoreField:   "has_more",
+				},
+			},
+			"get": {
+				Method:      "GET",
+				Path:        "/notifications/{id}",
+				Description: "Get one notification",
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "collide-all-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	flagVars, flagBindings := parseFlagDeclarations(t,
+		filepath.Join(outputDir, "internal", "cli", "notifications_list.go"))
+
+	assertNoDuplicates(t, flagVars,
+		"pagination's reserved flagAll must not collide with a user param named 'all'")
+	assertNoDuplicates(t, flagBindings,
+		"--all from pagination must not collide with --all from a user param")
+	assert.Contains(t, flagVars, "flagAll",
+		"pagination's flagAll keeps the canonical name")
+}
+
+// TestGenerateRenamesParamCollidingWithAsyncWait covers the async-reserved-name
+// path. Async-job endpoints emit `var flagWait`, `var flagWaitTimeout`, and
+// `var flagWaitInterval` from the IsAsync branch in command_endpoint.go.tmpl;
+// a user param literally named `wait` (or `wait_timeout`, `wait_interval`)
+// would otherwise produce a duplicate `var flagWait` in the same function.
+//
+// Async detection requires a job-id-shaped response field plus a sibling status
+// endpoint, so the spec mirrors that contract.
+func TestGenerateRenamesParamCollidingWithAsyncWait(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("collide-async")
+	apiSpec.Types = map[string]spec.TypeDef{
+		"JobResp": {Fields: []spec.TypeField{
+			{Name: "job_id", Type: "string"},
+			{Name: "status", Type: "string"},
+		}},
+	}
+	apiSpec.Resources["videos"] = spec.Resource{
+		Description: "Videos",
+		Endpoints: map[string]spec.Endpoint{
+			"create": {
+				Method:      "POST",
+				Path:        "/videos",
+				Description: "Create a video render job",
+				Response:    spec.ResponseDef{Type: "object", Item: "JobResp"},
+				Params: []spec.Param{
+					{Name: "wait", Type: "string", Description: "Watermark text on the rendered video"},
+				},
+			},
+			"get": {
+				Method:      "GET",
+				Path:        "/videos/{id}",
+				Description: "Get one video",
+				Response:    spec.ResponseDef{Type: "object", Item: "JobResp"},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "collide-async-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	flagVars, flagBindings := parseFlagDeclarations(t,
+		filepath.Join(outputDir, "internal", "cli", "videos_create.go"))
+
+	assertNoDuplicates(t, flagVars,
+		"async's reserved flagWait must not collide with a user param named 'wait'")
+	assertNoDuplicates(t, flagBindings,
+		"--wait from async must not collide with --wait from a user param")
+	assert.Contains(t, flagVars, "flagWait",
+		"async's flagWait keeps the canonical name")
+}
+
+// parseFlagDeclarations returns the names of all `var flagXxx` declarations and
+// the literal flag names passed to cobra's *Var registrations.
+func parseFlagDeclarations(t *testing.T, path string) (vars, bindings []string) {
+	t.Helper()
+	src, err := os.ReadFile(path)
+	require.NoError(t, err, "read generated file")
+
+	fset := token.NewFileSet()
+	file, err := parser.ParseFile(fset, path, src, 0)
+	require.NoError(t, err, "generated file must parse as Go")
+
+	ast.Inspect(file, func(n ast.Node) bool {
+		switch decl := n.(type) {
+		case *ast.GenDecl:
+			if decl.Tok != token.VAR {
+				return true
+			}
+			for _, sp := range decl.Specs {
+				vs, ok := sp.(*ast.ValueSpec)
+				if !ok {
+					continue
+				}
+				for _, name := range vs.Names {
+					if strings.HasPrefix(name.Name, "flag") {
+						vars = append(vars, name.Name)
+					}
+				}
+			}
+		case *ast.CallExpr:
+			// cobra registrations: cmd.Flags().StringVar(&flagX, "name", ...)
+			sel, ok := decl.Fun.(*ast.SelectorExpr)
+			if !ok || !strings.HasSuffix(sel.Sel.Name, "Var") {
+				return true
+			}
+			if len(decl.Args) < 2 {
+				return true
+			}
+			lit, ok := decl.Args[1].(*ast.BasicLit)
+			if !ok || lit.Kind != token.STRING {
+				return true
+			}
+			bindings = append(bindings, strings.Trim(lit.Value, `"`))
+		}
+		return true
+	})
+	return vars, bindings
+}
+
+func assertNoDuplicates(t *testing.T, names []string, msg string) {
+	t.Helper()
+	seen := map[string]int{}
+	for _, n := range names {
+		seen[n]++
+	}
+	for n, count := range seen {
+		assert.Equal(t, 1, count, "%s: %q appears %d times", msg, n, count)
+	}
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 456000fa..25645401 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -198,6 +198,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"mcpDescription":         mcpDescription,
 		"mcpDescriptionRich":     mcpDescriptionRich,
 		"flagName":               flagName,
+		"paramIdent":             paramIdent,
 		"safeTypeName":           safeTypeName,
 		"hasNonScalarType": func(types map[string]spec.TypeDef) bool {
 			for _, td := range types {
@@ -947,6 +948,12 @@ func (g *Generator) Generate() error {
 		g.AsyncJobs = DetectAsyncJobs(g.Spec)
 	}
 
+	// Suffix any param whose Go identifier or cobra flag name would collide
+	// 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()
+
 	// Generate single files
 	singleFiles := map[string]string{
 		"main.go.tmpl":           filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 642d8ab4..3af167a4 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -23,11 +23,11 @@ import (
 func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra.Command {
 {{- range .Endpoint.Params}}
 {{- if not .Positional}}
-	var flag{{camel .Name}} {{goTypeForParam .Name .Type}}
+	var flag{{camel (paramIdent .)}} {{goTypeForParam .Name .Type}}
 {{- end}}
 {{- end}}
 {{- range .Endpoint.Body}}
-	var body{{camel .Name}} {{goType .Type}}
+	var body{{camel (paramIdent .)}} {{goType .Type}}
 {{- end}}
 {{- if .Endpoint.Pagination}}
 	var flagAll bool
@@ -56,24 +56,24 @@ 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 .Name}}") && !flags.dryRun {
-				return fmt.Errorf("required flag \"%s\" not set", "{{flagName .Name}}")
+			if !cmd.Flags().Changed("{{flagName (paramIdent .)}}") && !flags.dryRun {
+				return fmt.Errorf("required flag \"%s\" not set", "{{flagName (paramIdent .)}}")
 			}
 {{- end}}
 {{- end}}
 {{- range .Endpoint.Params}}
 {{- if and .Enum (not .Positional) (eq .Type "string")}}
-			if cmd.Flags().Changed("{{flagName .Name}}") {
-				allowed{{camel .Name}} := []string{ {{enumLiteral .Enum}} }
-				valid{{camel .Name}} := false
-				for _, v := range allowed{{camel .Name}} {
-					if flag{{camel .Name}} == v {
-						valid{{camel .Name}} = true
+			if cmd.Flags().Changed("{{flagName (paramIdent .)}}") {
+				allowed{{camel (paramIdent .)}} := []string{ {{enumLiteral .Enum}} }
+				valid{{camel (paramIdent .)}} := false
+				for _, v := range allowed{{camel (paramIdent .)}} {
+					if flag{{camel (paramIdent .)}} == v {
+						valid{{camel (paramIdent .)}} = true
 						break
 					}
 				}
-				if !valid{{camel .Name}} {
-					fmt.Fprintf(os.Stderr, "warning: --%s %q not in allowed set %v\n", "{{flagName .Name}}", flag{{camel .Name}}, allowed{{camel .Name}})
+				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 .)}})
 				}
 			}
 {{- end}}
@@ -81,17 +81,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 $param.Name}}") {
-				var parsed{{camel $param.Name}} any
-				if err := json.Unmarshal([]byte(flag{{camel $param.Name}}), &parsed{{camel $param.Name}}); err != nil {
+			if cmd.Flags().Changed("{{flagName (paramIdent $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 $param.Name}} == v {
-							return fmt.Errorf("--{{flagName $param.Name}} must be valid JSON. Did you mean --{{.FlagName}} %s?", flag{{camel $param.Name}})
+						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)}})
 						}
 					}
 {{- end}}
-					return fmt.Errorf("--{{flagName $param.Name}} must be valid JSON: %w", err)
+					return fmt.Errorf("--{{flagName (paramIdent $param)}} must be valid JSON: %w", err)
 				}
 			}
 {{- end}}
@@ -100,8 +100,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 .Name}}") && !flags.dryRun {
-					return fmt.Errorf("required flag \"%s\" not set", "{{flagName .Name}}")
+				if !cmd.Flags().Changed("{{flagName (paramIdent .)}}") && !flags.dryRun {
+					return fmt.Errorf("required flag \"%s\" not set", "{{flagName (paramIdent .)}}")
 				}
 {{- end}}
 {{- end}}
@@ -127,7 +127,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- end}}
 {{- range .Endpoint.Params}}
 {{- if and .PathParam (not .Positional)}}
-			path = replacePathParam(path, "{{.Name}}", fmt.Sprintf("%v", flag{{camel .Name}}))
+			path = replacePathParam(path, "{{.Name}}", fmt.Sprintf("%v", flag{{camel (paramIdent .)}}))
 {{- end}}
 {{- end}}
 
@@ -138,8 +138,8 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			htmlRequestParams["{{.Name}}"] = args[{{$i}}]
 {{- end}}
 {{- if and (not .Positional) (not .PathParam)}}
-			if flag{{camel .Name}} != {{zeroValForParam .Name .Type}} {
-				htmlRequestParams["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel .Name}})
+			if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+				htmlRequestParams["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
 			}
 {{- end}}
 {{- end}}
@@ -162,7 +162,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				"{{.Name}}": args[{{$i}}],
 {{- end}}
 {{- if and (not .Positional) (not .PathParam)}}
-				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
+				"{{.Name}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
 {{- end}}
 {{- end}}
 			}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
@@ -173,7 +173,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				"{{.Name}}": args[{{$i}}],
 {{- end}}
 {{- if and (not .Positional) (not .PathParam)}}
-				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
+				"{{.Name}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
 {{- end}}
 {{- end}}
 			}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
@@ -185,8 +185,8 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			params["{{.Name}}"] = args[{{$i}}]
 {{- end}}
 {{- if and (not .Positional) (not .PathParam)}}
-			if flag{{camel .Name}} != {{zeroValForParam .Name .Type}} {
-				params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel .Name}})
+			if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+				params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
 			}
 {{- end}}
 {{- end}}
@@ -216,24 +216,24 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				body = map[string]any{}
 {{- range .Endpoint.Body}}
 {{- if or (eq .Type "object") (eq .Type "array")}}
-				if body{{camel .Name}} != "" {
-					var parsed{{camel .Name}} any
-					if err := json.Unmarshal([]byte(body{{camel .Name}}), &parsed{{camel .Name}}); err != nil {
-						return fmt.Errorf("parsing --{{flagName .Name}} JSON: %w", err)
+				if body{{camel (paramIdent .)}} != "" {
+					var parsed{{camel (paramIdent .)}} any
+					if err := json.Unmarshal([]byte(body{{camel (paramIdent .)}}), &parsed{{camel (paramIdent .)}}); err != nil {
+						return fmt.Errorf("parsing --{{flagName (paramIdent .)}} JSON: %w", err)
 					}
-					body["{{.Name}}"] = parsed{{camel .Name}}
+					body["{{.Name}}"] = parsed{{camel (paramIdent .)}}
 				}
 {{- else if jsonStringParam .}}
-				if body{{camel .Name}} != "" {
-					var parsed{{camel .Name}} any
-					if err := json.Unmarshal([]byte(body{{camel .Name}}), &parsed{{camel .Name}}); err != nil {
-						return fmt.Errorf("parsing --{{flagName .Name}} JSON: %w", err)
+				if body{{camel (paramIdent .)}} != "" {
+					var parsed{{camel (paramIdent .)}} any
+					if err := json.Unmarshal([]byte(body{{camel (paramIdent .)}}), &parsed{{camel (paramIdent .)}}); err != nil {
+						return fmt.Errorf("parsing --{{flagName (paramIdent .)}} JSON: %w", err)
 					}
-					body["{{.Name}}"] = body{{camel .Name}}
+					body["{{.Name}}"] = body{{camel (paramIdent .)}}
 				}
 {{- else}}
-				if body{{camel .Name}} != {{zeroVal .Type}} {
-					body["{{.Name}}"] = body{{camel .Name}}
+				if body{{camel (paramIdent .)}} != {{zeroVal .Type}} {
+					body["{{.Name}}"] = body{{camel (paramIdent .)}}
 				}
 {{- end}}
 {{- end}}
@@ -265,24 +265,24 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				body = map[string]any{}
 {{- range .Endpoint.Body}}
 {{- if or (eq .Type "object") (eq .Type "array")}}
-				if body{{camel .Name}} != "" {
-					var parsed{{camel .Name}} any
-					if err := json.Unmarshal([]byte(body{{camel .Name}}), &parsed{{camel .Name}}); err != nil {
-						return fmt.Errorf("parsing --{{flagName .Name}} JSON: %w", err)
+				if body{{camel (paramIdent .)}} != "" {
+					var parsed{{camel (paramIdent .)}} any
+					if err := json.Unmarshal([]byte(body{{camel (paramIdent .)}}), &parsed{{camel (paramIdent .)}}); err != nil {
+						return fmt.Errorf("parsing --{{flagName (paramIdent .)}} JSON: %w", err)
 					}
-					body["{{.Name}}"] = parsed{{camel .Name}}
+					body["{{.Name}}"] = parsed{{camel (paramIdent .)}}
 				}
 {{- else if jsonStringParam .}}
-				if body{{camel .Name}} != "" {
-					var parsed{{camel .Name}} any
-					if err := json.Unmarshal([]byte(body{{camel .Name}}), &parsed{{camel .Name}}); err != nil {
-						return fmt.Errorf("parsing --{{flagName .Name}} JSON: %w", err)
+				if body{{camel (paramIdent .)}} != "" {
+					var parsed{{camel (paramIdent .)}} any
+					if err := json.Unmarshal([]byte(body{{camel (paramIdent .)}}), &parsed{{camel (paramIdent .)}}); err != nil {
+						return fmt.Errorf("parsing --{{flagName (paramIdent .)}} JSON: %w", err)
 					}
-					body["{{.Name}}"] = body{{camel .Name}}
+					body["{{.Name}}"] = body{{camel (paramIdent .)}}
 				}
 {{- else}}
-				if body{{camel .Name}} != {{zeroVal .Type}} {
-					body["{{.Name}}"] = body{{camel .Name}}
+				if body{{camel (paramIdent .)}} != {{zeroVal .Type}} {
+					body["{{.Name}}"] = body{{camel (paramIdent .)}}
 				}
 {{- end}}
 {{- end}}
@@ -308,24 +308,24 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				body = map[string]any{}
 {{- range .Endpoint.Body}}
 {{- if or (eq .Type "object") (eq .Type "array")}}
-				if body{{camel .Name}} != "" {
-					var parsed{{camel .Name}} any
-					if err := json.Unmarshal([]byte(body{{camel .Name}}), &parsed{{camel .Name}}); err != nil {
-						return fmt.Errorf("parsing --{{flagName .Name}} JSON: %w", err)
+				if body{{camel (paramIdent .)}} != "" {
+					var parsed{{camel (paramIdent .)}} any
+					if err := json.Unmarshal([]byte(body{{camel (paramIdent .)}}), &parsed{{camel (paramIdent .)}}); err != nil {
+						return fmt.Errorf("parsing --{{flagName (paramIdent .)}} JSON: %w", err)
 					}
-					body["{{.Name}}"] = parsed{{camel .Name}}
+					body["{{.Name}}"] = parsed{{camel (paramIdent .)}}
 				}
 {{- else if jsonStringParam .}}
-				if body{{camel .Name}} != "" {
-					var parsed{{camel .Name}} any
-					if err := json.Unmarshal([]byte(body{{camel .Name}}), &parsed{{camel .Name}}); err != nil {
-						return fmt.Errorf("parsing --{{flagName .Name}} JSON: %w", err)
+				if body{{camel (paramIdent .)}} != "" {
+					var parsed{{camel (paramIdent .)}} any
+					if err := json.Unmarshal([]byte(body{{camel (paramIdent .)}}), &parsed{{camel (paramIdent .)}}); err != nil {
+						return fmt.Errorf("parsing --{{flagName (paramIdent .)}} JSON: %w", err)
 					}
-					body["{{.Name}}"] = body{{camel .Name}}
+					body["{{.Name}}"] = body{{camel (paramIdent .)}}
 				}
 {{- else}}
-				if body{{camel .Name}} != {{zeroVal .Type}} {
-					body["{{.Name}}"] = body{{camel .Name}}
+				if body{{camel (paramIdent .)}} != {{zeroVal .Type}} {
+					body["{{.Name}}"] = body{{camel (paramIdent .)}}
 				}
 {{- end}}
 {{- end}}
@@ -512,11 +512,11 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 
 {{- range .Endpoint.Params}}
 {{- if not .Positional}}
-	cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel .Name}}, "{{flagName .Name}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
+	cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
 {{- end}}
 {{- end}}
 {{- range .Endpoint.Body}}
-	cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel .Name}}, "{{flagName .Name}}", {{defaultVal .}}, "{{oneline .Description}}")
+	cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultVal .}}, "{{oneline .Description}}")
 {{- 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 a392f767..60c56691 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -14,7 +14,7 @@ import (
 func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- range .Endpoint.Params}}
 {{- if not .Positional}}
-	var flag{{camel .Name}} {{goTypeForParam .Name .Type}}
+	var flag{{camel (paramIdent .)}} {{goTypeForParam .Name .Type}}
 {{- end}}
 {{- end}}
 {{- if .Endpoint.Pagination}}
@@ -29,24 +29,24 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 {{- range .Endpoint.Params}}
 {{- if and .Required (not .Positional) (not .Default)}}
-			if !cmd.Flags().Changed("{{flagName .Name}}") && !flags.dryRun {
-				return fmt.Errorf("required flag \"%s\" not set", "{{flagName .Name}}")
+			if !cmd.Flags().Changed("{{flagName (paramIdent .)}}") && !flags.dryRun {
+				return fmt.Errorf("required flag \"%s\" not set", "{{flagName (paramIdent .)}}")
 			}
 {{- end}}
 {{- end}}
 {{- range .Endpoint.Params}}
 {{- if and .Enum (not .Positional) (eq .Type "string")}}
-			if cmd.Flags().Changed("{{flagName .Name}}") {
-				allowed{{camel .Name}} := []string{ {{enumLiteral .Enum}} }
-				valid{{camel .Name}} := false
-				for _, v := range allowed{{camel .Name}} {
-					if flag{{camel .Name}} == v {
-						valid{{camel .Name}} = true
+			if cmd.Flags().Changed("{{flagName (paramIdent .)}}") {
+				allowed{{camel (paramIdent .)}} := []string{ {{enumLiteral .Enum}} }
+				valid{{camel (paramIdent .)}} := false
+				for _, v := range allowed{{camel (paramIdent .)}} {
+					if flag{{camel (paramIdent .)}} == v {
+						valid{{camel (paramIdent .)}} = true
 						break
 					}
 				}
-				if !valid{{camel .Name}} {
-					fmt.Fprintf(os.Stderr, "warning: --%s %q not in allowed set %v\n", "{{flagName .Name}}", flag{{camel .Name}}, allowed{{camel .Name}})
+				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 .)}})
 				}
 			}
 {{- end}}
@@ -54,17 +54,17 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- range .Endpoint.Params}}
 {{- if and (not .Positional) (jsonStringParam .)}}
 {{- $param := .}}
-			if cmd.Flags().Changed("{{flagName $param.Name}}") {
-				var parsed{{camel $param.Name}} any
-				if err := json.Unmarshal([]byte(flag{{camel $param.Name}}), &parsed{{camel $param.Name}}); err != nil {
+			if cmd.Flags().Changed("{{flagName (paramIdent $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 $param.Name}} == v {
-							return fmt.Errorf("--{{flagName $param.Name}} must be valid JSON. Did you mean --{{.FlagName}} %s?", flag{{camel $param.Name}})
+						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)}})
 						}
 					}
 {{- end}}
-					return fmt.Errorf("--{{flagName $param.Name}} must be valid JSON: %w", err)
+					return fmt.Errorf("--{{flagName (paramIdent $param)}} must be valid JSON: %w", err)
 				}
 			}
 {{- end}}
@@ -93,8 +93,8 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 			htmlRequestParams["{{.Name}}"] = args[{{$i}}]
 {{- end}}
 {{- if not .Positional}}
-			if flag{{camel .Name}} != {{zeroValForParam .Name .Type}} {
-				htmlRequestParams["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel .Name}})
+			if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+				htmlRequestParams["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
 			}
 {{- end}}
 {{- end}}
@@ -108,7 +108,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 				"{{.Name}}": args[{{$i}}],
 {{- end}}
 {{- if not .Positional}}
-				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
+				"{{.Name}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
 {{- end}}
 {{- end}}
 			}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
@@ -119,7 +119,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 				"{{.Name}}": args[{{$i}}],
 {{- end}}
 {{- if not .Positional}}
-				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
+				"{{.Name}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
 {{- end}}
 {{- end}}
 			}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
@@ -131,8 +131,8 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 			params["{{.Name}}"] = args[{{$i}}]
 {{- end}}
 {{- if not .Positional}}
-			if flag{{camel .Name}} != {{zeroValForParam .Name .Type}} {
-				params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel .Name}})
+			if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+				params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
 			}
 {{- end}}
 {{- end}}
@@ -218,7 +218,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 
 {{- range .Endpoint.Params}}
 {{- if not .Positional}}
-	cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel .Name}}, "{{flagName .Name}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
+	cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
 {{- end}}
 {{- end}}
 {{- if .Endpoint.Pagination}}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 58a48f65..bd977e63 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -394,6 +394,15 @@ type Param struct {
 	Fields      []Param  `yaml:"fields" json:"fields"`                     // for nested objects
 	Enum        []string `yaml:"enum,omitempty" json:"enum,omitempty"`     // enum constraints for the parameter
 	Format      string   `yaml:"format,omitempty" json:"format,omitempty"` // OpenAPI format hints (date-time, email, uri, etc.)
+	// IdentName, when set, overrides Name for Go identifier and CLI flag
+	// derivation (camel/flagName). Name remains the wire-side parameter name
+	// used in URLs, JSON keys, and path substitution. Populated by the
+	// generator's flag-collision dedup pass when two params on the same
+	// endpoint would otherwise produce identical Go identifiers or CLI flag
+	// names — for example Twilio's StartTime/StartTime>/StartTime< all
+	// collapsing to "StartTime" through camelization. Most params leave this
+	// empty and template helpers fall back to Name.
+	IdentName string `yaml:"-" json:"-"`
 }
 
 type ResponseDef struct {

← 6bbae936 fix(cli): honor explicit --output flag in generate (#281)  ·  back to Cli Printing Press  ·  fix(cli): prepend T to type names that match Go reserved wor 1a95a78a →