[object Object]

← back to Cli Printing Press

fix(generator): 6 dogfooding fixes for production-quality CLI output

399c9678ec6e5bd7082dd15a9e87de232928d051 · 2026-03-23 17:36:04 -0700 · Matt Van Horn

- Handle relative base URLs (warn instead of crash)
- Clean spec names (strip swagger/openapi/version noise words)
- Skip complex object params as CLI flags (warn and skip)
- Map tag descriptions to resources with plural/singular matching
- Humanize CamelCase descriptions (insert spaces)
- Truncate descriptions at word boundaries (not mid-word)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files touched

Diff

commit 399c9678ec6e5bd7082dd15a9e87de232928d051
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Mon Mar 23 17:36:04 2026 -0700

    fix(generator): 6 dogfooding fixes for production-quality CLI output
    
    - Handle relative base URLs (warn instead of crash)
    - Clean spec names (strip swagger/openapi/version noise words)
    - Skip complex object params as CLI flags (warn and skip)
    - Map tag descriptions to resources with plural/singular matching
    - Humanize CamelCase descriptions (insert spaces)
    - Truncate descriptions at word boundaries (not mid-word)
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 internal/generator/generator.go |   7 +-
 internal/openapi/parser.go      | 205 ++++++++++++++++++++++++++++++++++++++--
 internal/openapi/parser_test.go |  40 +++++++-
 3 files changed, 240 insertions(+), 12 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 4ce60df8..b964f7d6 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -261,7 +261,12 @@ func oneline(s string) string {
 	}
 	s = strings.TrimSpace(s)
 	if len(s) > 120 {
-		s = s[:117] + "..."
+		cut := s[:117]
+		if idx := strings.LastIndex(cut, " "); idx > 60 {
+			s = cut[:idx] + "..."
+		} else {
+			s = cut + "..."
+		}
 	}
 	return s
 }
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 8f5a1bb9..4029d035 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -32,7 +32,7 @@ func Parse(data []byte) (*spec.APISpec, error) {
 	description := ""
 	version := ""
 	if doc.Info != nil {
-		if v := toKebabCase(doc.Info.Title); v != "" {
+		if v := cleanSpecName(doc.Info.Title); v != "" {
 			name = v
 		}
 		description = strings.TrimSpace(doc.Info.Description)
@@ -42,6 +42,12 @@ func Parse(data []byte) (*spec.APISpec, error) {
 	baseURL := ""
 	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)
+			}
+		}
 	}
 
 	result := &spec.APISpec{
@@ -249,10 +255,15 @@ 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)
+			}
+
 			endpoint := spec.Endpoint{
 				Method:      strings.ToUpper(method),
 				Path:        path,
-				Description: firstNonEmpty(strings.TrimSpace(op.Summary), strings.TrimSpace(op.Description)),
+				Description: description,
 				Params:      mapParameters(pathItem, op),
 				Body:        mapRequestBody(op.RequestBody, method, path),
 			}
@@ -274,12 +285,10 @@ func mapTagDescriptions(tags openapi3.Tags) map[string]string {
 		if tag == nil {
 			continue
 		}
-		name := toSnakeCase(tag.Name)
-		if name == "" {
-			continue
-		}
 		if desc := strings.TrimSpace(tag.Description); desc != "" {
-			out[name] = desc
+			for _, key := range tagDescriptionKeys(tag.Name) {
+				out[key] = desc
+			}
 		}
 	}
 	return out
@@ -290,13 +299,52 @@ func resourceDescription(op *openapi3.Operation, tagDescriptions map[string]stri
 		return ""
 	}
 	for _, tag := range op.Tags {
-		if desc := tagDescriptions[toSnakeCase(tag)]; desc != "" {
-			return desc
+		for _, key := range tagDescriptionKeys(tag) {
+			if desc := tagDescriptions[key]; desc != "" {
+				return desc
+			}
 		}
 	}
 	return ""
 }
 
+func tagDescriptionKeys(name string) []string {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return nil
+	}
+
+	seen := map[string]struct{}{}
+	keys := make([]string, 0, 6)
+
+	add := func(key string) {
+		key = strings.TrimSpace(key)
+		if key == "" {
+			return
+		}
+		if _, ok := seen[key]; ok {
+			return
+		}
+		seen[key] = struct{}{}
+		keys = append(keys, key)
+	}
+
+	bases := []string{
+		toSnakeCase(name),
+		strings.ToLower(name),
+	}
+	for _, base := range bases {
+		add(base)
+		if strings.HasSuffix(base, "s") && len(base) > 1 {
+			add(strings.TrimSuffix(base, "s"))
+		} else {
+			add(base + "s")
+		}
+	}
+
+	return keys
+}
+
 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, "-", "_")
@@ -456,6 +504,10 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
 	body := make([]spec.Param, 0, len(names))
 	for _, name := range names {
 		schema := schemaRefValue(properties[name])
+		if isComplexBodyFieldSchema(schema) {
+			warnf("skipping body field %q: complex type not supported as CLI flag", name)
+			continue
+		}
 		param := spec.Param{
 			Name:        name,
 			Type:        mapSchemaType(schema),
@@ -764,6 +816,10 @@ func isObjectSchema(schema *openapi3.Schema) bool {
 	return len(schema.Properties) > 0 || len(schema.AllOf) > 0
 }
 
+func isComplexBodyFieldSchema(schema *openapi3.Schema) bool {
+	return isObjectSchema(schema) || isArraySchema(schema)
+}
+
 func schemaTypeName(schemaRef *openapi3.SchemaRef, fallback string) string {
 	if schemaRef == nil {
 		return toTypeName(fallback)
@@ -1140,6 +1196,137 @@ func toKebabCase(input string) string {
 	return strings.Trim(b.String(), "-")
 }
 
+func cleanSpecName(title string) string {
+	title = strings.ToLower(strings.TrimSpace(title))
+	if title == "" {
+		return "api"
+	}
+
+	title = strings.ReplaceAll(title, "open api", " ")
+
+	var normalized strings.Builder
+	lastSpace := true
+	for _, r := range title {
+		if unicode.IsLetter(r) || unicode.IsDigit(r) {
+			normalized.WriteRune(r)
+			lastSpace = false
+			continue
+		}
+		if !lastSpace {
+			normalized.WriteByte(' ')
+			lastSpace = true
+		}
+	}
+
+	tokens := strings.Fields(normalized.String())
+	if len(tokens) == 0 {
+		return "api"
+	}
+
+	noiseWords := map[string]struct{}{
+		"swagger":       {},
+		"openapi":       {},
+		"rest":          {},
+		"api":           {},
+		"spec":          {},
+		"specification": {},
+		"preview":       {},
+		"http":          {},
+	}
+
+	filtered := make([]string, 0, len(tokens))
+	for _, token := range tokens {
+		if _, ok := noiseWords[token]; ok {
+			continue
+		}
+		if isVersionToken(token) {
+			continue
+		}
+		filtered = append(filtered, token)
+	}
+
+	name := toKebabCase(strings.Join(filtered, " "))
+	if name == "" {
+		return "api"
+	}
+	return name
+}
+
+func isVersionToken(token string) bool {
+	token = strings.TrimSpace(strings.ToLower(token))
+	if token == "" {
+		return false
+	}
+
+	if strings.HasPrefix(token, "v") {
+		token = strings.TrimPrefix(token, "v")
+		if token == "" {
+			return false
+		}
+	}
+
+	hasDigit := false
+	for _, r := range token {
+		if unicode.IsDigit(r) {
+			hasDigit = true
+			continue
+		}
+		if r == '.' {
+			continue
+		}
+		return false
+	}
+
+	return hasDigit
+}
+
+func shouldHumanizeDescription(description string) bool {
+	description = strings.TrimSpace(description)
+	if description == "" || strings.ContainsAny(description, " \t\r\n") {
+		return false
+	}
+	for i, r := range description {
+		if i > 0 && unicode.IsUpper(r) {
+			return true
+		}
+	}
+	return false
+}
+
+func humanizeDescription(description string) string {
+	description = strings.TrimSpace(description)
+	if description == "" {
+		return ""
+	}
+
+	var b strings.Builder
+	var prev rune
+	for i, r := range description {
+		if i > 0 && unicode.IsUpper(r) && unicode.IsLower(prev) {
+			b.WriteByte(' ')
+		}
+		b.WriteRune(r)
+		prev = r
+	}
+
+	words := strings.Fields(b.String())
+	if len(words) == 1 {
+		word := strings.ToLower(words[0])
+		if strings.HasSuffix(word, "apps") && len(word) > len("apps") {
+			words = []string{word[:len(word)-len("apps")], "apps"}
+		}
+	}
+
+	if len(words) == 0 {
+		return ""
+	}
+
+	sentence := strings.ToLower(strings.Join(words, " "))
+	runes := []rune(sentence)
+	runes[0] = unicode.ToUpper(runes[0])
+	return string(runes)
+}
+
 func toTypeName(input string) string {
 	input = strings.TrimSpace(input)
 	if input == "" {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index eff3512f..2dae754d 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -20,7 +20,7 @@ func TestParsePetstore(t *testing.T) {
 	parsed, err := Parse(data)
 	require.NoError(t, err)
 
-	assert.Equal(t, "swagger-petstore-openapi-30", parsed.Name)
+	assert.Equal(t, "petstore", parsed.Name)
 	assert.Equal(t, "/api/v3", parsed.BaseURL)
 	assert.NotEmpty(t, parsed.Resources)
 
@@ -46,7 +46,7 @@ func TestParseStytchOpenAPI(t *testing.T) {
 	parsed, err := Parse(data)
 	require.NoError(t, err)
 
-	assert.Equal(t, "stytch-api", parsed.Name)
+	assert.Equal(t, "stytch", parsed.Name)
 	assert.NotEmpty(t, parsed.BaseURL)
 	assert.NotEmpty(t, parsed.Resources)
 	assert.NotEmpty(t, parsed.Types)
@@ -245,3 +245,39 @@ func TestOperationIDToName(t *testing.T) {
 		})
 	}
 }
+
+func TestCleanSpecName(t *testing.T) {
+	tests := []struct {
+		title string
+		want  string
+	}{
+		{title: "Swagger Petstore - OpenAPI 3.0", want: "petstore"},
+		{title: "Discord HTTP API (Preview)", want: "discord"},
+		{title: "Stytch API", want: "stytch"},
+		{title: "GitHub REST API", want: "github"},
+		{title: "", want: "api"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.title, func(t *testing.T) {
+			assert.Equal(t, tt.want, cleanSpecName(tt.title))
+		})
+	}
+}
+
+func TestHumanizeDescription(t *testing.T) {
+	tests := []struct {
+		input string
+		want  string
+	}{
+		{input: "Connectedapps", want: "Connected apps"},
+		{input: "DeleteBiometricRegistration", want: "Delete biometric registration"},
+		{input: "Already normal text", want: "Already normal text"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			assert.Equal(t, tt.want, humanizeDescription(tt.input))
+		})
+	}
+}

← c8942bc3 docs: add Steinberger parity plan  ·  back to Cli Printing Press  ·  docs: add dogfood quality plan 21a7c6aa →