← 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
M internal/generator/generator.goM internal/generator/templates/command.go.tmplM internal/generator/templates/config.go.tmplM internal/generator/templates/doctor.go.tmplM internal/generator/templates/root.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/spec/spec.go
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 →