← back to Cli Printing Press
Fix catalog display_name precedence (#675)
3da34f16de0953f5fe2583f814108c98cfc6fc55 · 2026-05-07 02:48:48 -0700 · Trevin Chow
Files touched
M internal/cli/generate_test.goM internal/cli/root.goM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM internal/pipeline/publish.goM internal/pipeline/publish_test.goM internal/spec/spec.go
Diff
commit 3da34f16de0953f5fe2583f814108c98cfc6fc55
Author: Trevin Chow <trevin@trevinchow.com>
Date: Thu May 7 02:48:48 2026 -0700
Fix catalog display_name precedence (#675)
---
internal/cli/generate_test.go | 32 +++++++++++++++++++++++++++++++-
internal/cli/root.go | 4 ++++
internal/openapi/parser.go | 23 +++++++++++++----------
internal/openapi/parser_test.go | 3 +++
internal/pipeline/climanifest.go | 16 +++++++++-------
internal/pipeline/climanifest_test.go | 30 ++++++++++++++++++++++++++++++
internal/pipeline/publish.go | 19 ++++++++++++-------
internal/pipeline/publish_test.go | 22 ++++++++++++++++++++++
internal/spec/spec.go | 4 ++++
9 files changed, 128 insertions(+), 25 deletions(-)
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index a45d1ee9..9098be7f 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -771,7 +771,8 @@ func TestEnrichSpecFromCatalogCopiesGenerationMetadata(t *testing.T) {
apiSpec := &spec.APISpec{Name: "test-api"}
enrichSpecFromCatalogEntry(apiSpec, &catalog.Entry{
- OwnerName: "Trevin Chow",
+ DisplayName: "Test.API",
+ OwnerName: "Trevin Chow",
MCP: spec.MCPConfig{
Transport: []string{"stdio", "http"},
Orchestration: "code",
@@ -779,8 +780,37 @@ func TestEnrichSpecFromCatalogCopiesGenerationMetadata(t *testing.T) {
},
})
+ assert.Equal(t, "Test.API", apiSpec.DisplayName)
assert.Equal(t, "Trevin Chow", apiSpec.OwnerName)
assert.Equal(t, []string{"stdio", "http"}, apiSpec.MCP.Transport)
assert.Equal(t, "code", apiSpec.MCP.Orchestration)
assert.Equal(t, "hidden", apiSpec.MCP.EndpointTools)
}
+
+func TestEnrichSpecFromCatalogReplacesTitleDerivedDisplayName(t *testing.T) {
+ apiSpec := &spec.APISpec{
+ Name: "trigger-dev",
+ DisplayName: "Trigger Dev",
+ DisplayNameDerivedFromTitle: true,
+ }
+
+ enrichSpecFromCatalogEntry(apiSpec, &catalog.Entry{
+ DisplayName: "Trigger.dev",
+ })
+
+ assert.Equal(t, "Trigger.dev", apiSpec.DisplayName)
+ assert.False(t, apiSpec.DisplayNameDerivedFromTitle)
+}
+
+func TestEnrichSpecFromCatalogKeepsExplicitDisplayName(t *testing.T) {
+ apiSpec := &spec.APISpec{
+ Name: "trigger-dev",
+ DisplayName: "Spec.dev",
+ }
+
+ enrichSpecFromCatalogEntry(apiSpec, &catalog.Entry{
+ DisplayName: "Trigger.dev",
+ })
+
+ assert.Equal(t, "Spec.dev", apiSpec.DisplayName)
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index ce4f6b8a..55eff172 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -1125,6 +1125,10 @@ func enrichSpecFromCatalogEntry(apiSpec *spec.APISpec, entry *catalog.Entry) {
if entry.OwnerName != "" && apiSpec.OwnerName == "" {
apiSpec.OwnerName = entry.OwnerName
}
+ if entry.DisplayName != "" && (apiSpec.DisplayName == "" || apiSpec.DisplayNameDerivedFromTitle) {
+ apiSpec.DisplayName = entry.DisplayName
+ apiSpec.DisplayNameDerivedFromTitle = false
+ }
if entry.HTTPTransport != "" && apiSpec.HTTPTransport == "" {
apiSpec.HTTPTransport = entry.HTTPTransport
}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index fb2fdfca..7f070103 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -205,6 +205,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
// don't get flattened by EffectiveDisplayName's HumanName(slug)
// fallback (the slug is ASCII-folded for filesystem safety).
var displayName string
+ displayNameDerivedFromTitle := false
if raw, ok := lookupOpenAPIInfoExtension(doc, extensionDisplayName); ok {
if s, ok := raw.(string); ok {
displayName = strings.TrimSpace(s)
@@ -213,6 +214,7 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
if displayName == "" && doc.Info != nil {
if derived := cleanSpecNameUnicode(doc.Info.Title); derived != "" && derived != "api" {
displayName = naming.HumanName(derived)
+ displayNameDerivedFromTitle = true
}
}
@@ -279,16 +281,17 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
}
result := &spec.APISpec{
- Name: name,
- DisplayName: displayName,
- Description: description,
- Version: version,
- BaseURL: baseURL,
- BasePath: basePath,
- WebsiteURL: websiteURL,
- ProxyRoutes: proxyRoutes,
- Auth: auth,
- TierRouting: tierRouting,
+ Name: name,
+ DisplayName: displayName,
+ DisplayNameDerivedFromTitle: displayNameDerivedFromTitle,
+ Description: description,
+ Version: version,
+ BaseURL: baseURL,
+ BasePath: basePath,
+ WebsiteURL: websiteURL,
+ ProxyRoutes: proxyRoutes,
+ Auth: auth,
+ TierRouting: tierRouting,
Config: spec.ConfigSpec{
Format: "toml",
Path: fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index a9e47fa7..51cc29e2 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -531,6 +531,7 @@ paths:
parsed, err := Parse(spec)
require.NoError(t, err)
assert.Equal(t, "Cal.com", parsed.DisplayName)
+ assert.False(t, parsed.DisplayNameDerivedFromTitle)
}
func TestParseTrimsWhitespaceFromXDisplayName(t *testing.T) {
@@ -553,6 +554,7 @@ paths:
parsed, err := Parse(spec)
require.NoError(t, err)
assert.Equal(t, "Brand Name", parsed.DisplayName)
+ assert.False(t, parsed.DisplayNameDerivedFromTitle)
}
// TestParseDerivesDisplayNameFromTitle locks the dual contract when no
@@ -593,6 +595,7 @@ paths:
require.NoError(t, err)
assert.Equal(t, tc.wantSlug, parsed.Name)
assert.Equal(t, tc.wantDisplay, parsed.DisplayName)
+ assert.True(t, parsed.DisplayNameDerivedFromTitle)
})
}
}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 6f0e00ba..8b507307 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -280,9 +280,12 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
m.AuthDescription = parsed.Auth.Description
m.AuthOptional = parsed.Auth.Optional
// DisplayName precedence: explicit spec field > catalog-set existing
- // value > slug-derived fallback. Unconditional fallback would clobber a
- // curated catalog value when the spec is silent.
- if parsed.DisplayName != "" {
+ // value > spec/title-derived fallback > slug-derived fallback.
+ // OpenAPI info.title is useful as a fallback, but it is not explicit
+ // enough to clobber a curated catalog value.
+ if parsed.DisplayName != "" && !parsed.DisplayNameDerivedFromTitle {
+ m.DisplayName = parsed.DisplayName
+ } else if m.DisplayName == "" && parsed.DisplayName != "" {
m.DisplayName = parsed.DisplayName
} else if m.DisplayName == "" {
m.DisplayName = parsed.EffectiveDisplayName()
@@ -455,14 +458,13 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
}
}
- // Look up catalog entry for category/description enrichment.
+ // Look up catalog entry for category/description/display-name enrichment.
if entry, err := catalogpkg.LookupFS(catalog.FS, p.APIName); err == nil {
m.CatalogEntry = entry.Name
m.Category = entry.Category
m.Description = entry.Description
- // Catalog's display_name wins over spec/title-case fallback when both
- // are present. Spec authors and catalog curators sometimes both set
- // it; the catalog is the curated cross-CLI source of truth.
+ // Catalog's display_name wins over spec/title fallback, while explicit
+ // spec display_name / x-display-name still wins in populateMCPMetadata.
if entry.DisplayName != "" {
m.DisplayName = entry.DisplayName
}
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index c3e3eb20..0dfa5202 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -433,6 +433,25 @@ func TestWriteManifestForGenerateWithLocalSpec(t *testing.T) {
assert.Equal(t, "/tmp/my-spec.yaml", got.SpecPath)
}
+func TestWriteManifestForGenerateKeepsCatalogDisplayNameOverTitleFallback(t *testing.T) {
+ dir := t.TempDir()
+
+ err := WriteManifestForGenerate(GenerateManifestParams{
+ APIName: "producthunt",
+ OutputDir: dir,
+ Spec: &spec.APISpec{
+ Name: "producthunt",
+ DisplayName: "Producthunt",
+ DisplayNameDerivedFromTitle: true,
+ Auth: spec.AuthConfig{Type: "none"},
+ },
+ })
+ require.NoError(t, err)
+
+ got := readPublishedManifest(t, dir)
+ assert.Equal(t, "Product Hunt", got.DisplayName)
+}
+
func TestWriteManifestForGenerateWithDocsURL(t *testing.T) {
dir := t.TempDir()
@@ -1266,6 +1285,17 @@ func TestPopulateMCPMetadataDisplayNamePrecedence(t *testing.T) {
assert.Equal(t, "Catalog Name", m.DisplayName)
})
+ t.Run("existing catalog value preserved over title-derived fallback", func(t *testing.T) {
+ m := CLIManifest{DisplayName: "Catalog Name"}
+ populateMCPMetadata(&m, &spec.APISpec{
+ Name: "catalog-name",
+ DisplayName: "Catalog Name API",
+ DisplayNameDerivedFromTitle: true,
+ Auth: spec.AuthConfig{Type: "none"},
+ })
+ assert.Equal(t, "Catalog Name", m.DisplayName)
+ })
+
t.Run("title-case fallback fires only when both spec and existing are empty", func(t *testing.T) {
var m CLIManifest
populateMCPMetadata(&m, &spec.APISpec{
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index d30c04ab..30f0c6ef 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -222,6 +222,18 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
}
}
+ // Catalog metadata must be present before parsing refreshes display_name:
+ // explicit spec display_name wins, but OpenAPI info.title-derived fallback
+ // should not clobber curated catalog display_name.
+ if entry, err := catalogpkg.LookupFS(catalog.FS, state.APIName); err == nil {
+ m.CatalogEntry = entry.Name
+ m.Category = entry.Category
+ m.Description = entry.Description
+ if entry.DisplayName != "" {
+ m.DisplayName = entry.DisplayName
+ }
+ }
+
// Detect spec format and compute checksum from the spec file archived
// alongside the CLI. generate writes spec.json for JSON inputs and
// spec.yaml for YAML inputs; --docs / --plan runs leave no archive and
@@ -261,13 +273,6 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
}
}
- // Look up catalog entry by API name; empty string if not found.
- if entry, err := catalogpkg.LookupFS(catalog.FS, state.APIName); err == nil {
- m.CatalogEntry = entry.Name
- m.Category = entry.Category
- m.Description = entry.Description
- }
-
// Load novel features from research.json if available, populating the
// manifest's NovelFeatures field so publish-validate's transcendence
// check passes without manual patching.
diff --git a/internal/pipeline/publish_test.go b/internal/pipeline/publish_test.go
index 07e88e6c..7632b653 100644
--- a/internal/pipeline/publish_test.go
+++ b/internal/pipeline/publish_test.go
@@ -175,6 +175,28 @@ func TestWriteCLIManifestForPublish_NovelFeaturesFromSkillFlowResearch(t *testin
assert.Equal(t, "today", m.NovelFeatures[1].Command)
}
+func TestWriteCLIManifestForPublishKeepsCatalogDisplayNameOverTitleFallback(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ state := NewStateWithRun("producthunt", filepath.Join(tmp, "working", "producthunt-pp-cli"), "20260507-display-name", "test-scope")
+ require.NoError(t, os.MkdirAll(state.WorkingDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(state.WorkingDir, "spec.yaml"), []byte(`
+openapi: "3.0.0"
+info:
+ title: Producthunt API
+ version: "1.0"
+paths: {}
+`), 0o644))
+
+ require.NoError(t, writeCLIManifestForPublish(state, state.WorkingDir))
+
+ m := readPublishedManifest(t, state.WorkingDir)
+ assert.Equal(t, "Product Hunt", m.DisplayName)
+}
+
// TestWriteCLIManifestForPublish_NovelFeaturesFromPrintFlowResearch covers the
// printing-press print flow: research.json lives at <RunRoot>/pipeline/research.json
// alongside phase artifacts. The fallback path keeps print-flow CLIs working.
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 1304ada7..bd966de0 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -71,6 +71,10 @@ type APISpec struct {
// Name as a fallback. The generate command also fills this from a
// matching catalog entry's display_name when available.
DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"`
+ // DisplayNameDerivedFromTitle marks OpenAPI parser fallbacks from
+ // info.title. Catalog enrichment may replace that fallback, but must not
+ // replace explicit display_name / x-display-name values.
+ DisplayNameDerivedFromTitle bool `yaml:"-" json:"-"`
// Description describes the API itself ("REST API for ordering pizza").
// It flows into generated docs and SKILL.md but is intentionally NOT used
// as the printed CLI's --help text; that's CLIDescription's job.
← a880a7f7 Annotate PM workflows as read-only (#674)
·
back to Cli Printing Press
·
Clarify polish source-of-truth guidance (#676) 5701f3e7 →