[object Object]

← back to Cli Printing Press

fix(generator): dogfood to Steinberger quality across Petstore, Stytch, Discord

54e55a663e08f5e3a1c73bc90dc1a887d4343fc7 · 2026-03-23 18:17:11 -0700 · Matt Van Horn

- Handle relative server URLs (Petstore /api/v3) via BasePath field
- Use hyphens for command names, resource names, and flag names
- Smart description selection: prefer description over mangled summaries
- Auto-generate descriptions from endpoint/resource names when spec has none
- Add collapsed operationID variants for better prefix stripping
- Use camel instead of title in templates for hyphen-safe Go identifiers
- Auto-generate field descriptions when spec has none

All 3 test specs (Petstore, Stytch, Discord) pass 7 quality gates.

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

Files touched

Diff

commit 54e55a663e08f5e3a1c73bc90dc1a887d4343fc7
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Mon Mar 23 18:17:11 2026 -0700

    fix(generator): dogfood to Steinberger quality across Petstore, Stytch, Discord
    
    - Handle relative server URLs (Petstore /api/v3) via BasePath field
    - Use hyphens for command names, resource names, and flag names
    - Smart description selection: prefer description over mangled summaries
    - Auto-generate descriptions from endpoint/resource names when spec has none
    - Add collapsed operationID variants for better prefix stripping
    - Use camel instead of title in templates for hyphen-safe Go identifiers
    - Auto-generate field descriptions when spec has none
    
    All 3 test specs (Petstore, Stytch, Discord) pass 7 quality gates.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go              |   5 +
 internal/generator/templates/command.go.tmpl |  14 +--
 internal/generator/templates/config.go.tmpl  |   2 +
 internal/generator/templates/doctor.go.tmpl  |   2 +
 internal/generator/templates/root.go.tmpl    |   2 +-
 internal/openapi/parser.go                   | 153 ++++++++++++++++++++++++---
 internal/openapi/parser_test.go              |   3 +-
 internal/spec/spec.go                        |   3 +-
 8 files changed, 160 insertions(+), 24 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index b964f7d6..cd04ba36 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -39,6 +39,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"envVarPlaceholder": envVarPlaceholder,
 		"add":              func(a, b int) int { return a + b },
 		"oneline":          oneline,
+		"flagName":         flagName,
 	}
 	return g
 }
@@ -271,6 +272,10 @@ func oneline(s string) string {
 	return s
 }
 
+func flagName(name string) string {
+	return strings.ReplaceAll(name, "_", "-")
+}
+
 func envVarPlaceholder(envVar string) string {
 	// STYTCH_PROJECT_ID -> project_id (the placeholder in the format string)
 	parts := strings.Split(envVar, "_")
diff --git a/internal/generator/templates/command.go.tmpl b/internal/generator/templates/command.go.tmpl
index b4ef5270..62224be9 100644
--- a/internal/generator/templates/command.go.tmpl
+++ b/internal/generator/templates/command.go.tmpl
@@ -10,18 +10,18 @@ import (
 
 var _ = strings.ReplaceAll // ensure import
 
-func new{{title .ResourceName}}Cmd(flags *rootFlags) *cobra.Command {
+func new{{camel .ResourceName}}Cmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "{{.ResourceName}}",
 		Short: "{{oneline .Resource.Description}}",
 	}
 {{range $eName, $endpoint := .Resource.Endpoints}}
-	cmd.AddCommand(new{{title $.ResourceName}}{{title $eName}}Cmd(flags))
+	cmd.AddCommand(new{{camel $.ResourceName}}{{camel $eName}}Cmd(flags))
 {{- end}}
 	return cmd
 }
 {{range $eName, $endpoint := .Resource.Endpoints}}
-func new{{title $.ResourceName}}{{title $eName}}Cmd(flags *rootFlags) *cobra.Command {
+func new{{camel $.ResourceName}}{{camel $eName}}Cmd(flags *rootFlags) *cobra.Command {
 {{- range $endpoint.Params}}
 {{- if not .Positional}}
 	var flag{{camel .Name}} {{goType .Type}}
@@ -102,16 +102,16 @@ func new{{title $.ResourceName}}{{title $eName}}Cmd(flags *rootFlags) *cobra.Com
 
 {{- range $endpoint.Params}}
 {{- if not .Positional}}
-	cmd.Flags().{{cobraFlagFunc .Type}}(&flag{{camel .Name}}, "{{.Name}}", {{defaultVal .}}, "{{oneline .Description}}")
+	cmd.Flags().{{cobraFlagFunc .Type}}(&flag{{camel .Name}}, "{{flagName .Name}}", {{defaultVal .}}, "{{oneline .Description}}")
 {{- if .Required}}
-	_ = cmd.MarkFlagRequired("{{.Name}}")
+	_ = cmd.MarkFlagRequired("{{flagName .Name}}")
 {{- end}}
 {{- end}}
 {{- end}}
 {{- range $endpoint.Body}}
-	cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel .Name}}, "{{.Name}}", {{defaultVal .}}, "{{oneline .Description}}")
+	cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel .Name}}, "{{flagName .Name}}", {{defaultVal .}}, "{{oneline .Description}}")
 {{- if .Required}}
-	_ = cmd.MarkFlagRequired("{{.Name}}")
+	_ = cmd.MarkFlagRequired("{{flagName .Name}}")
 {{- end}}
 {{- end}}
 
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index e6b54c0e..16536626 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -23,7 +23,9 @@ type Config struct {
 
 func Load(configPath string) (*Config, error) {
 	cfg := &Config{
+{{- if .BaseURL}}
 		BaseURL: "{{.BaseURL}}",
+{{- end}}
 	}
 
 	// Resolve config path
diff --git a/internal/generator/templates/doctor.go.tmpl b/internal/generator/templates/doctor.go.tmpl
index 8c0e124c..8f1742b4 100644
--- a/internal/generator/templates/doctor.go.tmpl
+++ b/internal/generator/templates/doctor.go.tmpl
@@ -47,6 +47,8 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
 					resp.Body.Close()
 					report["api"] = fmt.Sprintf("reachable (HTTP %d)", resp.StatusCode)
 				}
+			} else if cfg != nil && cfg.BaseURL == "" {
+				report["api"] = "not configured (set base_url in config file)"
 			}
 
 			report["version"] = version
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index eb89bed0..25d633f8 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -40,7 +40,7 @@ func Execute() error {
 	rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 30*time.Second, "Request timeout")
 
 {{- range $name, $resource := .Resources}}
-	rootCmd.AddCommand(new{{title $name}}Cmd(&flags))
+	rootCmd.AddCommand(new{{camel $name}}Cmd(&flags))
 {{- end}}
 	rootCmd.AddCommand(newDoctorCmd(&flags))
 	rootCmd.AddCommand(newVersionCliCmd())
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 4029d035..4316b9c1 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -40,12 +40,17 @@ func Parse(data []byte) (*spec.APISpec, error) {
 	}
 
 	baseURL := ""
+	basePath := ""
 	if len(doc.Servers) > 0 && doc.Servers[0] != nil {
-		baseURL = strings.TrimRight(strings.TrimSpace(doc.Servers[0].URL), "/")
-		if baseURL != "" {
-			lowerBaseURL := strings.ToLower(baseURL)
-			if !strings.HasPrefix(lowerBaseURL, "http://") && !strings.HasPrefix(lowerBaseURL, "https://") {
-				warnf("server URL %q has no http scheme; generated CLI may require manual base_url config", baseURL)
+		serverURL := strings.TrimRight(strings.TrimSpace(doc.Servers[0].URL), "/")
+		if serverURL != "" {
+			lowerURL := strings.ToLower(serverURL)
+			if strings.HasPrefix(lowerURL, "http://") || strings.HasPrefix(lowerURL, "https://") {
+				baseURL = serverURL
+			} else {
+				// Relative URL - store the path portion to append when user configures a host
+				basePath = serverURL
+				warnf("server URL %q is relative; generated CLI will require base_url in config (e.g. https://example.com%s)", serverURL, serverURL)
 			}
 		}
 	}
@@ -55,6 +60,7 @@ func Parse(data []byte) (*spec.APISpec, error) {
 		Description: description,
 		Version:     version,
 		BaseURL:     baseURL,
+		BasePath:    basePath,
 		Auth:        mapAuth(doc, name),
 		Config: spec.ConfigSpec{
 			Format: "toml",
@@ -64,7 +70,11 @@ func Parse(data []byte) (*spec.APISpec, error) {
 		Types:     map[string]spec.TypeDef{},
 	}
 
-	mapResources(doc, result, baseURLPath(baseURL))
+	resourceBasePath := basePath
+	if baseURL != "" {
+		resourceBasePath = baseURLPath(baseURL)
+	}
+	mapResources(doc, result, resourceBasePath)
 	mapTypes(doc, result)
 
 	if err := result.Validate(); err != nil {
@@ -255,9 +265,12 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			}
 
 			endpointName := resolveEndpointName(method, path, op, resource.Endpoints, resourceName, basePath)
-			description := firstNonEmpty(strings.TrimSpace(op.Summary), strings.TrimSpace(op.Description))
-			if shouldHumanizeDescription(description) {
-				description = humanizeDescription(description)
+			summary := strings.TrimSpace(op.Summary)
+			desc := strings.TrimSpace(op.Description)
+			description := selectDescription(summary, desc)
+
+			if description == "" {
+				description = humanizeEndpointName(endpointName)
 			}
 
 			endpoint := spec.Endpoint{
@@ -275,6 +288,9 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			resource.Endpoints[endpointName] = endpoint
 		}
 
+		if resource.Description == "" {
+			resource.Description = humanizeResourceName(resourceName)
+		}
 		out.Resources[resourceName] = resource
 	}
 }
@@ -329,8 +345,11 @@ func tagDescriptionKeys(name string) []string {
 		keys = append(keys, key)
 	}
 
+	snake := toSnakeCase(name)
+	kebab := strings.ReplaceAll(snake, "_", "-")
 	bases := []string{
-		toSnakeCase(name),
+		snake,
+		kebab,
 		strings.ToLower(name),
 	}
 	for _, base := range bases {
@@ -347,7 +366,6 @@ func tagDescriptionKeys(name string) []string {
 
 func resolveEndpointName(method, path string, op *openapi3.Operation, existing map[string]spec.Endpoint, resourceName, basePath string) string {
 	name := operationIDToName(operationID(op), resourceName)
-	name = strings.ReplaceAll(name, "-", "_")
 	if name == "" {
 		name = defaultEndpointName(method, path)
 	}
@@ -418,12 +436,16 @@ func mapParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []spec.P
 		}
 
 		schema := schemaRefValue(parameter.Schema)
+		description := strings.TrimSpace(parameter.Description)
+		if description == "" {
+			description = humanizeFieldName(parameter.Name)
+		}
 		param := spec.Param{
 			Name:        parameter.Name,
 			Type:        mapSchemaType(schema),
 			Required:    parameter.Required,
 			Positional:  parameter.In == openapi3.ParameterInPath,
-			Description: strings.TrimSpace(parameter.Description),
+			Description: description,
 			Enum:        schemaEnum(schema),
 			Format:      schemaFormat(schema),
 		}
@@ -508,11 +530,15 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
 			warnf("skipping body field %q: complex type not supported as CLI flag", name)
 			continue
 		}
+		description := schemaDescription(schema)
+		if description == "" {
+			description = humanizeFieldName(name)
+		}
 		param := spec.Param{
 			Name:        name,
 			Type:        mapSchemaType(schema),
 			Required:    isRequired(required, name),
-			Description: schemaDescription(schema),
+			Description: description,
 			Enum:        schemaEnum(schema),
 			Format:      schemaFormat(schema),
 		}
@@ -899,7 +925,7 @@ func resourceNameFromPath(path, basePath string) string {
 	if isPathParamSegment(segments[0]) {
 		return ""
 	}
-	return sanitizeResourceName(toSnakeCase(segments[0]))
+	return sanitizeResourceName(strings.ReplaceAll(toSnakeCase(segments[0]), "_", "-"))
 }
 
 func endpointCollisionSuffix(path, resourceName, basePath string) string {
@@ -1045,6 +1071,12 @@ func operationIDResourceVariants(resourceName string) []string {
 	} else {
 		addVariant(resource + "s")
 	}
+	// Add collapsed variant (no underscores) for operationIDs like "connectedapps"
+	collapsed := strings.ReplaceAll(resource, "_", "")
+	addVariant(collapsed)
+	if strings.HasSuffix(collapsed, "s") && len(collapsed) > 1 {
+		addVariant(strings.TrimSuffix(collapsed, "s"))
+	}
 
 	return variants
 }
@@ -1293,6 +1325,24 @@ func shouldHumanizeDescription(description string) bool {
 	return false
 }
 
+func looksLikeMangledOperationID(s string) bool {
+	s = strings.TrimSpace(s)
+	if s == "" || strings.ContainsAny(s, " \t\r\n") {
+		return false
+	}
+	// Single word, no spaces - likely an operationID
+	// Check for CamelCase
+	if shouldHumanizeDescription(s) {
+		return true
+	}
+	// Check for lowercase concatenated words (e.g. "deleteexternalid")
+	// Heuristic: single token > 12 chars with no separators
+	if len(s) > 12 && !strings.ContainsAny(s, " _-\t") {
+		return true
+	}
+	return false
+}
+
 func humanizeDescription(description string) string {
 	description = strings.TrimSpace(description)
 	if description == "" {
@@ -1397,6 +1447,81 @@ func firstNonEmpty(values ...string) string {
 	return ""
 }
 
+func selectDescription(summary, description string) string {
+	summaryHasSpaces := summary != "" && strings.ContainsAny(summary, " \t")
+
+	// If summary is a real sentence (has spaces), prefer it
+	if summaryHasSpaces {
+		return summary
+	}
+
+	// Summary is a single word or empty - prefer the description if available
+	if description != "" {
+		return description
+	}
+
+	// No description - use summary, humanizing if it looks like a mangled operationID
+	if summary != "" {
+		if shouldHumanizeDescription(summary) {
+			return humanizeDescription(summary)
+		}
+		if looksLikeMangledOperationID(summary) {
+			return humanizeConcatenated(summary)
+		}
+		return summary
+	}
+
+	return ""
+}
+
+func humanizeEndpointName(name string) string {
+	words := strings.Split(strings.ReplaceAll(name, "_", "-"), "-")
+	if len(words) == 0 {
+		return ""
+	}
+	sentence := strings.Join(words, " ")
+	runes := []rune(sentence)
+	runes[0] = unicode.ToUpper(runes[0])
+	return string(runes)
+}
+
+func humanizeResourceName(name string) string {
+	words := strings.Split(strings.ReplaceAll(name, "_", "-"), "-")
+	if len(words) == 0 {
+		return ""
+	}
+	sentence := "Manage " + strings.Join(words, " ")
+	return sentence
+}
+
+func humanizeConcatenated(s string) string {
+	lower := strings.ToLower(s)
+	// Try to split on known verb prefixes
+	prefixes := []string{"delete", "create", "update", "get", "list", "search", "revoke", "exchange", "rotate", "authenticate", "migrate"}
+	for _, prefix := range prefixes {
+		if strings.HasPrefix(lower, prefix) && len(lower) > len(prefix) {
+			rest := lower[len(prefix):]
+			words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(toSnakeCase(rest), "_", " "), "-", " "))
+			if len(words) > 0 {
+				sentence := strings.Title(prefix) + " " + strings.Join(words, " ")
+				return sentence
+			}
+		}
+	}
+	return s
+}
+
+func humanizeFieldName(name string) string {
+	words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(toSnakeCase(name), "_", " "), "-", " "))
+	if len(words) == 0 {
+		return ""
+	}
+	sentence := strings.Join(words, " ")
+	runes := []rune(sentence)
+	runes[0] = unicode.ToUpper(runes[0])
+	return string(runes)
+}
+
 func warnf(format string, args ...any) {
 	fmt.Fprintf(os.Stderr, "warning: "+format+"\n", args...)
 }
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 2dae754d..870e9ff6 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -21,7 +21,8 @@ func TestParsePetstore(t *testing.T) {
 	require.NoError(t, err)
 
 	assert.Equal(t, "petstore", parsed.Name)
-	assert.Equal(t, "/api/v3", parsed.BaseURL)
+	assert.Equal(t, "", parsed.BaseURL)
+	assert.Equal(t, "/api/v3", parsed.BasePath)
 	assert.NotEmpty(t, parsed.Resources)
 
 	hasEndpoint := false
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index b8cfbb7d..686641db 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -12,6 +12,7 @@ type APISpec struct {
 	Description string              `yaml:"description"`
 	Version     string              `yaml:"version"`
 	BaseURL     string              `yaml:"base_url"`
+	BasePath    string              `yaml:"base_path,omitempty"`
 	Auth        AuthConfig          `yaml:"auth"`
 	Config      ConfigSpec          `yaml:"config"`
 	Resources   map[string]Resource `yaml:"resources"`
@@ -104,7 +105,7 @@ func (s *APISpec) Validate() error {
 	if s.Name == "" {
 		return fmt.Errorf("name is required")
 	}
-	if s.BaseURL == "" {
+	if s.BaseURL == "" && s.BasePath == "" {
 		return fmt.Errorf("base_url is required")
 	}
 	if len(s.Resources) == 0 {

← ceacb135 docs: final dogfood plan - fix press until Steinberger quali  ·  back to Cli Printing Press  ·  feat(generator): sub-resource grouping for nested API paths 3d07636b →