[object Object]

← back to Cli Printing Press

fix(cli): derive Google Discovery resources from operationIds (#680)

39cbc18971a10aba41bbc56a68e1710a03bb063d · 2026-05-07 13:28:50 -0500 · Cathryn Lavery

* feat(cli): add Google Cloud Run generation

* fix(cli): prefer catalog specs for print

* chore(cli): satisfy current lint rules

* fix(cli): enrich generated specs by catalog URL

Files touched

Diff

commit 39cbc18971a10aba41bbc56a68e1710a03bb063d
Author: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com>
Date:   Thu May 7 13:28:50 2026 -0500

    fix(cli): derive Google Discovery resources from operationIds (#680)
    
    * feat(cli): add Google Cloud Run generation
    
    * fix(cli): prefer catalog specs for print
    
    * chore(cli): satisfy current lint rules
    
    * fix(cli): enrich generated specs by catalog URL
---
 catalog/google-cloud-run.yaml                    |  21 ++
 docs/SPEC-EXTENSIONS.md                          |  15 ++
 internal/cli/generate_test.go                    |  15 ++
 internal/cli/root.go                             |  49 +++-
 internal/openapi/parser.go                       | 309 ++++++++++++++++++-----
 internal/openapi/parser_test.go                  | 157 ++++++++++++
 internal/pipeline/climanifest.go                 |  21 +-
 internal/pipeline/climanifest_test.go            |  22 ++
 internal/pipeline/discover.go                    |  22 +-
 internal/pipeline/discover_test.go               |   7 +
 internal/pipeline/regenmerge/apply.go            |   4 +-
 testdata/golden/expected/catalog-list/stdout.txt |   1 +
 testdata/openapi/google-discovery-run.yaml       | 212 ++++++++++++++++
 13 files changed, 778 insertions(+), 77 deletions(-)

diff --git a/catalog/google-cloud-run.yaml b/catalog/google-cloud-run.yaml
new file mode 100644
index 00000000..65093178
--- /dev/null
+++ b/catalog/google-cloud-run.yaml
@@ -0,0 +1,21 @@
+name: google-cloud-run
+display_name: Google Cloud Run
+description: Cloud Run Admin API for deploying and managing serverless container services, revisions, jobs, and executions
+category: cloud
+spec_url: https://api.apis.guru/v2/specs/googleapis.com/run/v2/openapi.yaml
+spec_format: yaml
+openapi_version: "3.0"
+tier: community
+spec_source: community
+verified_date: "2026-05-06"
+homepage: https://cloud.google.com/run/docs/reference/rest
+notes: |
+  OpenAPI 3.0 spec is community-maintained on apis.guru, converted from Google's
+  Discovery document. Auth is OAuth2 with the
+  https://www.googleapis.com/auth/cloud-platform scope; the simplest token source
+  is `gcloud auth print-access-token`.
+
+  Spec quirk: paths use the Google convention `/v2/{name}` with the resource
+  hierarchy encoded in operationIds (e.g. run.projects.locations.services.revisions.delete)
+  rather than in the path. The OpenAPI parser derives resources from those
+  Google Discovery operationIds when path segments are generic.
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 597df884..a5a3c1e5 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -15,6 +15,8 @@ in the same change as any new `Extensions["x-*"]` lookup in that file.
 | `x-display-name` | `info` | `APISpec.DisplayName` | No |
 | `x-website` | `info` | `APISpec.WebsiteURL` | No |
 | `x-proxy-routes` | `info` | `APISpec.ProxyRoutes` | No |
+| `x-origin` | `info` | Google Discovery resource fallback | No |
+| `x-providerName` | `info` | Google Discovery resource fallback | No |
 | `x-tier-routing` | root or `info` | `APISpec.TierRouting` | No |
 | `x-auth-type` | `components.securitySchemes.<name>` | `APISpec.Auth.Type` | No |
 | `x-auth-format` | `components.securitySchemes.<name>` | `APISpec.Auth.Format` | No |
@@ -131,6 +133,19 @@ info:
     /v1/publish: publishing
 ```
 
+### `x-origin` / `x-providerName`
+
+Recognized on Google Discovery specs converted by apis.guru. These extensions do
+not populate an `APISpec` field; they gate the parser's operationId-based
+resource fallback for paths such as `/v2/{name}` and `/{resource}:getIamPolicy`.
+
+Rules:
+- Optional.
+- `x-providerName: googleapis.com` enables Google Discovery resource fallback.
+- `x-origin` enables the fallback when any entry has `format: google` or a
+  Discovery URL under `googleapis.com/$discovery`.
+- Ignored for non-Google specs.
+
 ### `x-tier-routing`
 
 Declares opt-in free/paid credential routing for APIs where some endpoints work
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 2c6ee633..72e6483d 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -787,6 +787,21 @@ func TestEnrichSpecFromCatalogCopiesGenerationMetadata(t *testing.T) {
 	assert.Equal(t, "hidden", apiSpec.MCP.EndpointTools)
 }
 
+func TestEnrichSpecFromCatalogMatchesSpecURLWhenSlugDiffers(t *testing.T) {
+	apiSpec := &spec.APISpec{
+		Name:                        "cloud-run-admin",
+		DisplayName:                 "Cloud Run Admin",
+		DisplayNameDerivedFromTitle: true,
+	}
+
+	enrichSpecFromCatalog(apiSpec, "https://api.apis.guru/v2/specs/googleapis.com/run/v2/openapi.yaml")
+
+	assert.Equal(t, "Google Cloud Run", apiSpec.DisplayName)
+	assert.False(t, apiSpec.DisplayNameDerivedFromTitle)
+	assert.Equal(t, "cloud", apiSpec.Category)
+	assert.Equal(t, "https://cloud.google.com/run/docs/reference/rest", apiSpec.WebsiteURL)
+}
+
 func TestEnrichSpecFromCatalogReplacesTitleDerivedDisplayName(t *testing.T) {
 	apiSpec := &spec.APISpec{
 		Name:                        "trigger-dev",
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 4e38d79b..5f463654 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -303,7 +303,7 @@ func newGenerateCmd() *cobra.Command {
 				return printDryRun(apiSpec, absOut, specFiles)
 			}
 
-			novelFeatures, polished, err := runGenerateProject(apiSpec, absOut, generateProjectOptions{validate: validate, polish: polish, researchDir: researchDir, trafficAnalysisPath: trafficAnalysisPath, specFiles: specFiles, rejectUnshippablePageContextTraffic: true})
+			novelFeatures, polished, err := runGenerateProject(apiSpec, absOut, generateProjectOptions{validate: validate, polish: polish, researchDir: researchDir, trafficAnalysisPath: trafficAnalysisPath, specFiles: specFiles, specURL: specURL, rejectUnshippablePageContextTraffic: true})
 			if err != nil {
 				return err
 			}
@@ -428,11 +428,12 @@ type generateProjectOptions struct {
 	researchDir                         string
 	trafficAnalysisPath                 string
 	specFiles                           []string
+	specURL                             string
 	rejectUnshippablePageContextTraffic bool
 }
 
 func runGenerateProject(apiSpec *spec.APISpec, absOut string, opts generateProjectOptions) ([]pipeline.NovelFeatureManifest, bool, error) {
-	enrichSpecFromCatalog(apiSpec)
+	enrichSpecFromCatalog(apiSpec, catalogSpecLookupRefs(opts.specFiles, opts.specURL)...)
 	gen := generator.New(apiSpec, absOut)
 	novelFeatures := loadResearchSources(gen, opts.researchDir)
 	trafficAnalysis, err := loadTrafficAnalysisForGenerate(opts.trafficAnalysisPath, opts.specFiles, apiSpec.SpecSource)
@@ -1093,19 +1094,53 @@ func translateNarrative(n *pipeline.ReadmeNarrative) *generator.ReadmeNarrative
 }
 
 // enrichSpecFromCatalog looks up the API in the embedded catalog and copies
-// ProxyRoutes into the spec if present. This allows catalog entries to declare
-// service routing for proxy-envelope APIs without requiring CLI flags.
-func enrichSpecFromCatalog(apiSpec *spec.APISpec) {
+// generation metadata into the spec if present.
+func enrichSpecFromCatalog(apiSpec *spec.APISpec, specRefs ...string) {
 	if apiSpec == nil || apiSpec.Name == "" {
 		return
 	}
-	entry, err := catalog.LookupFS(catalogfs.FS, apiSpec.Name)
-	if err != nil {
+	entry := lookupCatalogEntryForGenerateSpec(apiSpec.Name, specRefs)
+	if entry == nil {
 		return
 	}
 	enrichSpecFromCatalogEntry(apiSpec, entry)
 }
 
+func catalogSpecLookupRefs(specFiles []string, specURL string) []string {
+	refs := make([]string, 0, len(specFiles)+1)
+	if specURL != "" {
+		refs = append(refs, specURL)
+	}
+	refs = append(refs, specFiles...)
+	return refs
+}
+
+func lookupCatalogEntryForGenerateSpec(apiName string, specRefs []string) *catalog.Entry {
+	if entry, err := catalog.LookupFS(catalogfs.FS, apiName); err == nil {
+		return entry
+	}
+	specURLs := make(map[string]struct{}, len(specRefs))
+	for _, ref := range specRefs {
+		ref = strings.TrimSpace(ref)
+		if strings.HasPrefix(ref, "https://") || strings.HasPrefix(ref, "http://") {
+			specURLs[ref] = struct{}{}
+		}
+	}
+	if len(specURLs) == 0 {
+		return nil
+	}
+	entries, err := catalog.ParseFS(catalogfs.FS)
+	if err != nil {
+		return nil
+	}
+	for i := range entries {
+		if _, ok := specURLs[entries[i].SpecURL]; ok {
+			return &entries[i]
+		}
+	}
+	return nil
+}
+
 func enrichSpecFromCatalogEntry(apiSpec *spec.APISpec, entry *catalog.Entry) {
 	if apiSpec == nil || entry == nil {
 		return
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index ffabd4f0..934eb236 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -9,6 +9,7 @@ import (
 	"net/url"
 	"os"
 	"regexp"
+	"slices"
 	"sort"
 	"strconv"
 	"strings"
@@ -41,6 +42,8 @@ const (
 	extensionDisplayName      = "x-display-name"
 	extensionWebsite          = "x-website"
 	extensionProxyRoutes      = "x-proxy-routes"
+	extensionOrigin           = "x-origin"
+	extensionProviderName     = "x-providerName"
 )
 
 // SetMaxResources overrides the default resource limit. When not called,
@@ -1390,6 +1393,8 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 		return pathKeys[i] < pathKeys[j]
 	})
 	commonPrefix := detectCommonPrefix(pathKeys, basePath)
+	frameworkRenames := map[string]string{}
+	googleDiscovery := isGoogleDiscoverySpec(doc)
 
 	// Auto-calibrate endpoint limit unless the user explicitly set it.
 	// Pre-scans the spec to find the largest resource or sub-resource,
@@ -1400,13 +1405,16 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 		type resourceKey struct{ primary, sub string }
 		endpointCounts := map[resourceKey]int{}
 		for _, path := range pathKeys {
-			primaryName, subName := resourceAndSubFromPath(path, basePath, commonPrefix)
-			if primaryName == "" {
+			pathItem := doc.Paths.Value(path)
+			if pathItem == nil {
 				continue
 			}
-			pathItem := doc.Paths.Value(path)
-			if pathItem != nil {
-				endpointCounts[resourceKey{primaryName, subName}] += len(pathItem.Operations())
+			for _, op := range pathItem.Operations() {
+				primaryName, subName := resourceAndSubForOperation(path, basePath, commonPrefix, op, googleDiscovery)
+				if primaryName == "" {
+					continue
+				}
+				endpointCounts[resourceKey{primaryName, subName}]++
 			}
 		}
 		for _, count := range endpointCounts {
@@ -1437,53 +1445,6 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 		pathCritical := readPathItemCritical(pathItem, path)
 		pathTier := readTierExtension(pathItem.Extensions, fmt.Sprintf("path %q", path))
 
-		primaryName, subName := resourceAndSubFromPath(path, basePath, commonPrefix)
-		if primaryName == "" {
-			warnf("skipping path %q: could not derive resource name", path)
-			continue
-		}
-
-		// Framework cobra collision check — only for top-level resources
-		// (subName == ""). Sub-resources register under a parent prefix and
-		// don't shadow framework commands. If primaryName matches a reserved
-		// cobra Use literal, rename to <api-slug>-<resource> and warn so
-		// the operator sees it. Self-collision (the renamed name already
-		// exists in out.Resources) bumps a numeric suffix until unique.
-		if subName == "" {
-			if _, reserved := spec.ReservedCobraUseNames[primaryName]; reserved {
-				primaryName = renameForFrameworkCollision(out, primaryName, path)
-			}
-		}
-
-		resource, ok := out.Resources[primaryName]
-		if !ok {
-			if len(out.Resources) >= maxResources {
-				warnf("skipping path %q: resource limit (%d) reached", path, maxResources)
-				continue
-			}
-			resource = spec.Resource{
-				Description:  tagDescriptions[primaryName],
-				Endpoints:    map[string]spec.Endpoint{},
-				SubResources: map[string]spec.Resource{},
-			}
-		}
-
-		// Determine the target: direct resource endpoints or sub-resource endpoints
-		var targetEndpoints map[string]spec.Endpoint
-		targetResourceName := primaryName
-		if subName != "" {
-			if _, ok := resource.SubResources[subName]; !ok {
-				resource.SubResources[subName] = spec.Resource{
-					Description: tagDescriptions[subName],
-					Endpoints:   map[string]spec.Endpoint{},
-				}
-			}
-			targetEndpoints = resource.SubResources[subName].Endpoints
-			targetResourceName = subName
-		} else {
-			targetEndpoints = resource.Endpoints
-		}
-
 		methods := make([]string, 0, len(operations))
 		for method := range operations {
 			methods = append(methods, method)
@@ -1497,12 +1458,63 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 				continue
 			}
 
+			primaryName, subName := resourceAndSubForOperation(path, basePath, commonPrefix, op, googleDiscovery)
+			if primaryName == "" {
+				warnf("skipping %s %q: could not derive resource name", method, path)
+				continue
+			}
+			endpointResourceName := primaryName
+			if subName != "" {
+				endpointResourceName = subName
+			}
+
+			// Framework cobra collision check. Sub-resource names do not shadow
+			// framework commands, but every primary resource becomes a top-level
+			// Cobra command, even when the current endpoint lands under a
+			// sub-resource.
+			if renamed, ok := frameworkRenames[primaryName]; ok {
+				primaryName = renamed
+			} else if _, reserved := spec.ReservedCobraUseNames[primaryName]; reserved {
+				originalName := primaryName
+				primaryName = renameForFrameworkCollision(out, primaryName, path)
+				frameworkRenames[originalName] = primaryName
+			}
+
+			resource, ok := out.Resources[primaryName]
+			if !ok {
+				if len(out.Resources) >= maxResources {
+					warnf("skipping path %q: resource limit (%d) reached", path, maxResources)
+					continue
+				}
+				resource = spec.Resource{
+					Description:  tagDescriptions[primaryName],
+					Endpoints:    map[string]spec.Endpoint{},
+					SubResources: map[string]spec.Resource{},
+				}
+			}
+
+			// Determine the target: direct resource endpoints or sub-resource endpoints
+			var targetEndpoints map[string]spec.Endpoint
+			targetResourceName := primaryName
+			if subName != "" {
+				if _, ok := resource.SubResources[subName]; !ok {
+					resource.SubResources[subName] = spec.Resource{
+						Description: tagDescriptions[subName],
+						Endpoints:   map[string]spec.Endpoint{},
+					}
+				}
+				targetEndpoints = resource.SubResources[subName].Endpoints
+				targetResourceName = subName
+			} else {
+				targetEndpoints = resource.Endpoints
+			}
+
 			if len(targetEndpoints) >= maxEndpointsPerResource {
 				warnf("skipping %s %q: endpoint limit (%d) reached for resource %q.%s", method, path, maxEndpointsPerResource, primaryName, targetResourceName)
 				continue
 			}
 
-			endpointName := resolveEndpointName(method, path, op, targetEndpoints, targetResourceName, basePath, commonPrefix)
+			endpointName := resolveEndpointName(method, path, op, targetEndpoints, endpointResourceName, basePath, commonPrefix)
 			summary := strings.TrimSpace(op.Summary)
 			desc := strings.TrimSpace(op.Description)
 			description := selectDescription(summary, desc)
@@ -1559,20 +1571,20 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			endpoint.Critical = pathCritical
 
 			targetEndpoints[endpointName] = endpoint
-		}
 
-		// Update descriptions
-		if subName != "" {
-			sub := resource.SubResources[subName]
-			if sub.Description == "" {
-				sub.Description = humanizeResourceName(subName)
+			// Update descriptions
+			if subName != "" {
+				sub := resource.SubResources[subName]
+				if sub.Description == "" {
+					sub.Description = humanizeResourceName(subName)
+				}
+				resource.SubResources[subName] = sub
 			}
-			resource.SubResources[subName] = sub
-		}
-		if resource.Description == "" {
-			resource.Description = humanizeResourceName(primaryName)
+			if resource.Description == "" {
+				resource.Description = humanizeResourceName(primaryName)
+			}
+			out.Resources[primaryName] = resource
 		}
-		out.Resources[primaryName] = resource
 	}
 
 	assignEndpointAliases(out.Resources)
@@ -3049,6 +3061,26 @@ func resourceAndSubFromPath(path, basePath string, commonPrefix []string) (strin
 	return resourceAndSubFromSegments(segments)
 }
 
+func resourceAndSubForOperation(path, basePath string, commonPrefix []string, op *openapi3.Operation, googleDiscovery bool) (string, string) {
+	primary, sub := resourceAndSubFromPath(path, basePath, commonPrefix)
+	if primary != "" && !pathStartsWithCustomVerbParam(path, basePath, commonPrefix) {
+		return primary, sub
+	}
+	if googleDiscovery {
+		opPrimary, opSub := resourceAndSubFromGoogleOperationID(operationID(op), true)
+		if opPrimary != "" {
+			return opPrimary, opSub
+		}
+		if pathPrimary := resourceFromGooglePathAfterLeadingParams(path, basePath, commonPrefix); pathPrimary != "" {
+			return pathPrimary, ""
+		}
+	}
+	if opPrimary, opSub := resourceAndSubFromGoogleOperationID(operationID(op), false); opPrimary != "" {
+		return opPrimary, opSub
+	}
+	return primary, sub
+}
+
 func resourceAndSubFromSegments(segments []string) (string, string) {
 	if len(segments) == 0 {
 		return "", ""
@@ -3078,6 +3110,34 @@ func resourceAndSubFromSegments(segments []string) (string, string) {
 	return primary, sub
 }
 
+func pathStartsWithCustomVerbParam(path, basePath string, commonPrefix []string) bool {
+	segments := pathSegmentsAfterBase(path, basePath)
+	if len(commonPrefix) > 0 && hasSegmentPrefix(segments, commonPrefix) {
+		segments = segments[len(commonPrefix):]
+	}
+	return len(segments) > 0 && isPathParamCustomVerbSegment(segments[0])
+}
+
+func resourceFromGooglePathAfterLeadingParams(path, basePath string, commonPrefix []string) string {
+	segments := pathSegmentsAfterBase(path, basePath)
+	if len(commonPrefix) > 0 && hasSegmentPrefix(segments, commonPrefix) {
+		segments = segments[len(commonPrefix):]
+	}
+	if len(segments) == 0 || !isPathParamSegment(segments[0]) {
+		return ""
+	}
+	for _, segment := range segments {
+		if isPathParamSegment(segment) {
+			continue
+		}
+		if isPathParamCustomVerbSegment(segment) {
+			continue
+		}
+		return sanitizeResourceName(strings.ReplaceAll(toSnakeCase(segment), "_", "-"))
+	}
+	return ""
+}
+
 func detectCommonPrefix(paths []string, basePath string) []string {
 	segmentLists := make([][]string, 0, len(paths))
 	for _, path := range paths {
@@ -3262,8 +3322,7 @@ func isVersionSegment(segment string) bool {
 		return false
 	}
 	if segment[0] == 'v' && len(segment) >= 2 {
-		_, err := strconv.Atoi(segment[1:])
-		return err == nil
+		return versionSegmentPattern.MatchString(segment)
 	}
 	_, err := strconv.Atoi(segment)
 	return err == nil
@@ -3273,6 +3332,11 @@ func isPathParamSegment(segment string) bool {
 	return strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}")
 }
 
+func isPathParamCustomVerbSegment(segment string) bool {
+	close := strings.Index(segment, "}:")
+	return strings.HasPrefix(segment, "{") && close > 0 && close+2 < len(segment)
+}
+
 func baseURLPath(baseURL string) string {
 	if baseURL == "" {
 		return ""
@@ -3288,6 +3352,9 @@ func operationIDToName(operationID, resourceName string, commonPrefix []string)
 	if strings.TrimSpace(operationID) == "" {
 		return ""
 	}
+	if name := googleOperationIDEndpointName(operationID, resourceName); name != "" {
+		return name
+	}
 	original := toSnakeCase(operationID)
 	if original == "" {
 		return ""
@@ -3319,6 +3386,114 @@ func operationIDToName(operationID, resourceName string, commonPrefix []string)
 	return strings.ReplaceAll(name, "_", "-")
 }
 
+func googleOperationIDEndpointName(operationID, resourceName string) string {
+	chain, verb, ok := googleOperationIDResourceChain(operationID, true, true)
+	if !ok {
+		return ""
+	}
+	resource := sanitizeResourceName(strings.ReplaceAll(toSnakeCase(resourceName), "_", "-"))
+	if resource == "" {
+		return ""
+	}
+	if slices.Contains(chain, resource) {
+		return strings.ReplaceAll(toSnakeCase(verb), "_", "-")
+	}
+	return ""
+}
+
+func resourceAndSubFromGoogleOperationID(operationID string, allowUnscoped bool) (string, string) {
+	chain, _, ok := googleOperationIDResourceChain(operationID, allowUnscoped, false)
+	if !ok {
+		return "", ""
+	}
+	switch len(chain) {
+	case 0:
+		return "", ""
+	case 1:
+		return chain[0], ""
+	case 2:
+		return chain[0], chain[1]
+	default:
+		return chain[len(chain)-2], chain[len(chain)-1]
+	}
+}
+
+func googleOperationIDResourceChain(operationID string, allowUnscoped, fallbackWhenScopedEmpty bool) ([]string, string, bool) {
+	parts := strings.Split(strings.TrimSpace(operationID), ".")
+	if len(parts) < 3 {
+		return nil, "", false
+	}
+	start, ok := googleOperationIDScopeStart(parts)
+	if !ok && allowUnscoped {
+		start = 1
+		ok = true
+	}
+	if ok && allowUnscoped && fallbackWhenScopedEmpty && len(parts[start:]) < 2 {
+		start = 1
+	}
+	if !ok || len(parts[start:]) < 2 {
+		return nil, "", false
+	}
+
+	verb := parts[len(parts)-1]
+	rawChain := parts[start : len(parts)-1]
+	chain := make([]string, 0, len(rawChain))
+	for _, segment := range rawChain {
+		resource := sanitizeResourceName(strings.ReplaceAll(toSnakeCase(segment), "_", "-"))
+		if resource == "" {
+			return nil, "", false
+		}
+		chain = append(chain, resource)
+	}
+	return chain, verb, true
+}
+
+func googleOperationIDScopeStart(parts []string) (int, bool) {
+	for i := range len(parts) {
+		switch parts[i] {
+		case "projects", "organizations", "folders":
+			if i+1 < len(parts) && parts[i+1] == "locations" {
+				return i + 2, true
+			}
+		case "billingAccounts":
+			if i+1 < len(parts) && parts[i+1] == "locations" {
+				return i + 2, true
+			}
+			return i + 1, true
+		}
+	}
+	return 0, false
+}
+
+func isGoogleDiscoverySpec(doc *openapi3.T) bool {
+	if raw, ok := lookupOpenAPIInfoExtension(doc, extensionProviderName); ok {
+		if provider, ok := raw.(string); ok && strings.EqualFold(strings.TrimSpace(provider), "googleapis.com") {
+			return true
+		}
+	}
+	raw, ok := lookupOpenAPIInfoExtension(doc, extensionOrigin)
+	if !ok {
+		return false
+	}
+	origins, ok := raw.([]any)
+	if !ok {
+		return false
+	}
+	for _, origin := range origins {
+		entry, ok := origin.(map[string]any)
+		if !ok {
+			continue
+		}
+		if format, ok := entry["format"].(string); ok && strings.EqualFold(strings.TrimSpace(format), "google") {
+			return true
+		}
+		if originURL, ok := entry["url"].(string); ok && strings.Contains(originURL, "googleapis.com/$discovery") {
+			return true
+		}
+	}
+	return false
+}
+
 func operationIDResourceVariants(resourceName string) []string {
 	resource := toSnakeCase(strings.TrimSpace(resourceName))
 	if resource == "" {
@@ -3373,6 +3548,8 @@ func stripOperationIDVersionPrefix(name string) string {
 // These appear in specs like Cal.com: BookingsController_2024-08-13_getBooking → 2024_08_13
 var operationIDDatePattern = regexp.MustCompile(`_\d{4}_\d{2}_\d{2}_?`)
 
+var versionSegmentPattern = regexp.MustCompile(`^v[0-9]+((alpha|beta|p[0-9]+)[0-9]*)*$`)
+
 // stripOperationIDControllerAndDate removes controller class names and embedded
 // version dates from operationIDs. APIs auto-generated from NestJS, FastAPI, or
 // similar frameworks include these patterns (e.g., BookingsController_2024-08-13_getBooking).
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index e351ec82..654bbc15 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -722,6 +722,9 @@ func TestPathSegmentsStripsGenericAPIPrefix(t *testing.T) {
 		{"strips api then version", "/api/v2/pokemon", "", "pokemon"},
 		{"strips version then api then version", "/v2/api/v1/pokemon", "", "pokemon"},
 		{"strips api then numeric version", "/api/0/organizations", "", "organizations"},
+		{"strips beta version", "/v1beta2/{parent}/repositories", "", "{parent}"},
+		{"strips alpha version", "/v1alpha1/{parent}/services", "", "{parent}"},
+		{"strips p beta version", "/v1p1beta1/{parent}/sessions", "", "{parent}"},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -766,6 +769,8 @@ func TestOperationIDToName(t *testing.T) {
 		{operationID: "OrganizationsController_getOrg", resourceName: "organizations", want: "get-org"},
 		// No controller/version pattern — should be unchanged
 		{operationID: "getBookingByUid", resourceName: "bookings", want: "get-by-uid"},
+		{operationID: "run.projects.locations.services.revisions.delete", resourceName: "revisions", want: "delete"},
+		{operationID: "run.projects.locations.services.getIamPolicy", resourceName: "services", want: "get-iam-policy"},
 	}
 
 	for _, tt := range tests {
@@ -776,6 +781,158 @@ func TestOperationIDToName(t *testing.T) {
 	}
 }
 
+func TestResourceAndSubFromGoogleOperationID(t *testing.T) {
+	tests := []struct {
+		name         string
+		operationID  string
+		wantPrimary  string
+		wantSub      string
+		wantName     string
+		nameResource string
+	}{
+		{
+			name:         "project location resource",
+			operationID:  "run.projects.locations.services.create",
+			wantPrimary:  "services",
+			nameResource: "services",
+			wantName:     "create",
+		},
+		{
+			name:         "nested subresource keeps owning parent",
+			operationID:  "run.projects.locations.services.revisions.delete",
+			wantPrimary:  "services",
+			wantSub:      "revisions",
+			nameResource: "revisions",
+			wantName:     "delete",
+		},
+		{
+			name:         "deep chain uses immediate parent",
+			operationID:  "run.projects.locations.jobs.executions.tasks.list",
+			wantPrimary:  "executions",
+			wantSub:      "tasks",
+			nameResource: "tasks",
+			wantName:     "list",
+		},
+		{
+			name:         "organization location scope",
+			operationID:  "example.organizations.locations.widgets.get",
+			wantPrimary:  "widgets",
+			nameResource: "widgets",
+			wantName:     "get",
+		},
+		{
+			name:         "billing account scope",
+			operationID:  "example.billingAccounts.invoices.list",
+			wantPrimary:  "invoices",
+			nameResource: "invoices",
+			wantName:     "list",
+		},
+		{
+			name:        "non Google scope ignored",
+			operationID: "gmail.users.messages.list",
+		},
+		{
+			name:         "unscoped Google Discovery operation",
+			operationID:  "bigquery.tables.getIamPolicy",
+			wantPrimary:  "tables",
+			nameResource: "tables",
+			wantName:     "get-iam-policy",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotPrimary, gotSub := resourceAndSubFromGoogleOperationID(tt.operationID, false)
+			if tt.name == "unscoped Google Discovery operation" {
+				gotPrimary, gotSub = resourceAndSubFromGoogleOperationID(tt.operationID, true)
+			}
+			assert.Equal(t, tt.wantPrimary, gotPrimary)
+			assert.Equal(t, tt.wantSub, gotSub)
+			assert.Equal(t, tt.wantName, googleOperationIDEndpointName(tt.operationID, tt.nameResource))
+		})
+	}
+}
+
+func TestParseGoogleDiscoveryResourceFallback(t *testing.T) {
+	t.Parallel()
+
+	data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "google-discovery-run.yaml"))
+	require.NoError(t, err)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+
+	assert.NotContains(t, parsed.Resources, "name-run")
+	assert.NotContains(t, parsed.Resources, "name-wait")
+	assert.NotContains(t, parsed.Resources, "resource-get-iam-policy")
+	assert.NotContains(t, parsed.Resources, "resource-set-iam-policy")
+	assert.NotContains(t, parsed.Resources, "resource-test-iam-permissions")
+
+	services := parsed.Resources["services"]
+	require.Contains(t, services.Endpoints, "create")
+	require.Contains(t, services.Endpoints, "patch")
+	require.Contains(t, services.Endpoints, "list")
+	require.Contains(t, services.Endpoints, "get-iam-policy")
+	require.Contains(t, services.Endpoints, "set-iam-policy")
+	require.Contains(t, services.Endpoints, "test-iam-permissions")
+	require.Contains(t, services.SubResources, "revisions")
+	assert.Contains(t, services.SubResources["revisions"].Endpoints, "delete")
+	assert.Contains(t, services.SubResources["revisions"].Endpoints, "get")
+	assert.Contains(t, services.SubResources["revisions"].Endpoints, "list")
+
+	assert.NotContains(t, parsed.Resources, "jobs")
+	jobs := parsed.Resources["google-cloud-run-jobs"]
+	require.Contains(t, jobs.Endpoints, "create")
+	require.Contains(t, jobs.Endpoints, "list")
+	require.Contains(t, jobs.Endpoints, "run")
+	require.Contains(t, jobs.SubResources, "executions")
+	assert.Contains(t, jobs.SubResources["executions"].Endpoints, "list")
+
+	executions := parsed.Resources["executions"]
+	require.Contains(t, executions.SubResources, "tasks")
+	assert.Contains(t, executions.SubResources["tasks"].Endpoints, "list")
+
+	operations := parsed.Resources["operations"]
+	assert.Contains(t, operations.Endpoints, "list")
+	assert.Contains(t, operations.Endpoints, "wait")
+
+	locations := parsed.Resources["locations"]
+	assert.Contains(t, locations.Endpoints, "list")
+}
+
+func TestParseGoogleDiscoveryUnscopedOperationIDFallbackRequiresGoogleOrigin(t *testing.T) {
+	t.Parallel()
+
+	base := `openapi: 3.0.3
+info:
+  title: BigQuery API
+  version: v2
+%s
+paths:
+  /{resource}:getIamPolicy:
+    get:
+      operationId: bigquery.tables.getIamPolicy
+      parameters:
+        - name: resource
+          in: path
+          required: true
+          schema: {type: string}
+      responses:
+        "200": {description: ok}
+`
+
+	parsed, err := Parse(fmt.Appendf(nil, base, "  x-providerName: googleapis.com\n"))
+	require.NoError(t, err)
+	require.Contains(t, parsed.Resources, "tables")
+	assert.Contains(t, parsed.Resources["tables"].Endpoints, "get-iam-policy")
+	assert.NotContains(t, parsed.Resources, "resource-get-iam-policy")
+
+	parsed, err = Parse(fmt.Appendf(nil, base, ""))
+	require.NoError(t, err)
+	assert.NotContains(t, parsed.Resources, "tables")
+	assert.Contains(t, parsed.Resources, "resource-get-iam-policy")
+}
+
 func TestReclassifyPathParamModifiers(t *testing.T) {
 	tests := []struct {
 		name           string
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 56a0970c..8741bff0 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -459,7 +459,7 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 	}
 
 	// Look up catalog entry for category/description/display-name enrichment.
-	if entry, err := catalogpkg.LookupFS(catalog.FS, p.APIName); err == nil {
+	if entry := lookupCatalogEntryForGenerate(p.APIName, m.SpecURL); entry != nil {
 		m.CatalogEntry = entry.Name
 		m.Category = entry.Category
 		m.Description = entry.Description
@@ -491,6 +491,25 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 	return WriteMCPBManifestFromStruct(p.OutputDir, m)
 }
 
+func lookupCatalogEntryForGenerate(apiName, specURL string) *catalogpkg.Entry {
+	if entry, err := catalogpkg.LookupFS(catalog.FS, apiName); err == nil {
+		return entry
+	}
+	if specURL == "" {
+		return nil
+	}
+	entries, err := catalogpkg.ParseFS(catalog.FS)
+	if err != nil {
+		return nil
+	}
+	for i := range entries {
+		if entries[i].SpecURL == specURL {
+			return &entries[i]
+		}
+	}
+	return nil
+}
+
 // detectSpecFormat examines the raw spec bytes and returns a format
 // string: "openapi3", "graphql", or "internal".
 func detectSpecFormat(data []byte) string {
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index d062b738..dabe29a4 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -452,6 +452,28 @@ func TestWriteManifestForGenerateKeepsCatalogDisplayNameOverTitleFallback(t *tes
 	assert.Equal(t, "Product Hunt", got.DisplayName)
 }
 
+func TestWriteManifestForGenerateMatchesCatalogBySpecURLWhenSlugDiffers(t *testing.T) {
+	dir := t.TempDir()
+
+	err := WriteManifestForGenerate(GenerateManifestParams{
+		APIName:   "cloud-run-admin",
+		SpecURL:   "https://api.apis.guru/v2/specs/googleapis.com/run/v2/openapi.yaml",
+		OutputDir: dir,
+		Spec: &spec.APISpec{
+			Name:                        "cloud-run-admin",
+			DisplayName:                 "Cloud Run Admin",
+			DisplayNameDerivedFromTitle: true,
+			Auth:                        spec.AuthConfig{Type: "bearer_token"},
+		},
+	})
+	require.NoError(t, err)
+
+	got := readPublishedManifest(t, dir)
+	assert.Equal(t, "google-cloud-run", got.CatalogEntry)
+	assert.Equal(t, "Google Cloud Run", got.DisplayName)
+	assert.Equal(t, "cloud", got.Category)
+}
+
 func TestWriteManifestForGenerateWithDocsURL(t *testing.T) {
 	dir := t.TempDir()
 
diff --git a/internal/pipeline/discover.go b/internal/pipeline/discover.go
index 24955b44..66608494 100644
--- a/internal/pipeline/discover.go
+++ b/internal/pipeline/discover.go
@@ -3,6 +3,9 @@ package pipeline
 import (
 	"fmt"
 	"strings"
+
+	catalogfs "github.com/mvanhorn/cli-printing-press/v4/catalog"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
 )
 
 // KnownSpec holds metadata about a known API spec.
@@ -133,7 +136,14 @@ func ApisGuruPattern(provider, version string) string {
 func DiscoverSpec(apiName string) (string, string, error) {
 	normalized := strings.ToLower(strings.TrimSpace(apiName))
 
-	// Check known specs first
+	if entry, err := catalog.LookupFS(catalogfs.FS, normalized); err == nil {
+		if entry.SpecURL == "" {
+			return "", "", fmt.Errorf("catalog entry %q does not define spec_url - try providing a URL with --spec", apiName)
+		}
+		return entry.SpecURL, catalogSpecSource(entry), nil
+	}
+
+	// Check known specs for aliases that do not have catalog entries.
 	if spec, ok := KnownSpecs[normalized]; ok {
 		return spec.URL, "known-specs registry", nil
 	}
@@ -149,6 +159,16 @@ func DiscoverSpec(apiName string) (string, string, error) {
 	return "", "", fmt.Errorf("could not find OpenAPI spec for %q - try providing a URL with --spec", apiName)
 }
 
+func catalogSpecSource(entry *catalog.Entry) string {
+	if source := strings.TrimSpace(entry.SpecSource); source != "" {
+		return "catalog entry (" + source + ")"
+	}
+	if tier := strings.TrimSpace(entry.Tier); tier != "" {
+		return "catalog entry (" + tier + ")"
+	}
+	return "catalog entry"
+}
+
 // IsSandboxSafe returns true if the API is known to have a safe test/sandbox environment.
 func IsSandboxSafe(apiName string) bool {
 	normalized := strings.ToLower(strings.TrimSpace(apiName))
diff --git a/internal/pipeline/discover_test.go b/internal/pipeline/discover_test.go
index 6155f792..357af0e6 100644
--- a/internal/pipeline/discover_test.go
+++ b/internal/pipeline/discover_test.go
@@ -14,6 +14,13 @@ func TestDiscoverSpec_KnownAPI(t *testing.T) {
 	assert.Contains(t, url, "petstore")
 }
 
+func TestDiscoverSpec_CatalogEntryWins(t *testing.T) {
+	url, source, err := DiscoverSpec("google-cloud-run")
+	require.NoError(t, err)
+	assert.Equal(t, "https://api.apis.guru/v2/specs/googleapis.com/run/v2/openapi.yaml", url)
+	assert.Equal(t, "catalog entry (community)", source)
+}
+
 func TestDiscoverSpec_UnknownAPI(t *testing.T) {
 	// Unknown APIs still return a url via apis-guru fallback, so check that it works
 	url, source, err := DiscoverSpec("zzz-nonexistent-api-zzz")
diff --git a/internal/pipeline/regenmerge/apply.go b/internal/pipeline/regenmerge/apply.go
index 97fbcf25..210d724c 100644
--- a/internal/pipeline/regenmerge/apply.go
+++ b/internal/pipeline/regenmerge/apply.go
@@ -294,8 +294,8 @@ func injectAddCommands(hostPath string, calls []string) error {
 		// Insert before the last `return` statement, or at the end if no
 		// trailing return.
 		insertAt := len(fn.Body.List)
-		for i := len(fn.Body.List) - 1; i >= 0; i-- {
-			if _, isRet := fn.Body.List[i].(*dst.ReturnStmt); isRet {
+		for i, stmt := range slices.Backward(fn.Body.List) {
+			if _, isRet := stmt.(*dst.ReturnStmt); isRet {
 				insertAt = i
 				break
 			}
diff --git a/testdata/golden/expected/catalog-list/stdout.txt b/testdata/golden/expected/catalog-list/stdout.txt
index d7e798e2..0cf40b8c 100644
--- a/testdata/golden/expected/catalog-list/stdout.txt
+++ b/testdata/golden/expected/catalog-list/stdout.txt
@@ -3,6 +3,7 @@ auth:
 
 cloud:
   digitalocean         Cloud infrastructure and developer platform API
+  google-cloud-run     Cloud Run Admin API for deploying and managing serverless container services, revisions, jobs, and executions
 
 developer-tools:
   github               Software development platform API
diff --git a/testdata/openapi/google-discovery-run.yaml b/testdata/openapi/google-discovery-run.yaml
new file mode 100644
index 00000000..18024fb4
--- /dev/null
+++ b/testdata/openapi/google-discovery-run.yaml
@@ -0,0 +1,212 @@
+openapi: 3.0.3
+info:
+  title: Google Cloud Run API
+  version: v2
+  x-origin:
+    - format: google
+      url: https://run.googleapis.com/$discovery/rest?version=v2
+      version: v2
+  x-providerName: googleapis.com
+servers:
+  - url: https://run.googleapis.com/
+paths:
+  /v2/{name}:
+    delete:
+      operationId: run.projects.locations.services.revisions.delete
+      parameters:
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+    get:
+      operationId: run.projects.locations.services.revisions.get
+      parameters:
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+    patch:
+      operationId: run.projects.locations.services.patch
+      parameters:
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{name}/operations:
+    get:
+      operationId: run.projects.locations.operations.list
+      parameters:
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{name}/locations:
+    get:
+      operationId: run.projects.locations.list
+      parameters:
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{name}:run:
+    post:
+      operationId: run.projects.locations.jobs.run
+      parameters:
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{name}:wait:
+    post:
+      operationId: run.projects.locations.operations.wait
+      parameters:
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{parent}/executions:
+    get:
+      operationId: run.projects.locations.jobs.executions.list
+      parameters:
+        - name: parent
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{parent}/jobs:
+    get:
+      operationId: run.projects.locations.jobs.list
+      parameters:
+        - name: parent
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+    post:
+      operationId: run.projects.locations.jobs.create
+      parameters:
+        - name: parent
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{parent}/revisions:
+    get:
+      operationId: run.projects.locations.services.revisions.list
+      parameters:
+        - name: parent
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{parent}/services:
+    get:
+      operationId: run.projects.locations.services.list
+      parameters:
+        - name: parent
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+    post:
+      operationId: run.projects.locations.services.create
+      parameters:
+        - name: parent
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{parent}/tasks:
+    get:
+      operationId: run.projects.locations.jobs.executions.tasks.list
+      parameters:
+        - name: parent
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{resource}:getIamPolicy:
+    get:
+      operationId: run.projects.locations.services.getIamPolicy
+      parameters:
+        - name: resource
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{resource}:setIamPolicy:
+    post:
+      operationId: run.projects.locations.services.setIamPolicy
+      parameters:
+        - name: resource
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok
+  /v2/{resource}:testIamPermissions:
+    post:
+      operationId: run.projects.locations.services.testIamPermissions
+      parameters:
+        - name: resource
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: ok

← da078d0e chore(main): release 4.0.2 (#684)  ·  back to Cli Printing Press  ·  chore(main): release 4.0.3 (#686) 2c538be1 →