[object Object]

← back to Cli Printing Press

feat(cli): add --auth-preference flag for catalog-driven scheme selection (#865)

34ca81497f6e46ee4fe5f92a30f6f3f15d498e27 · 2026-05-16 13:49:10 -0400 · Michael Schreiber

* feat(cli): add --auth-preference flag for catalog-driven scheme selection

When an OpenAPI spec advertises multiple security schemes — most commonly
OAuth2 (with full authorizationCode flow) plus HTTP Basic — the parser
defaulted to OAuth2. That selection is correct for hosted multi-tenant
integrations but unusable for personal-token CLIs (Atlassian Jira /
Confluence / Bitbucket, GitLab, and others all expose this exact pair).

Add a generation-time hint that lets a catalog entry — or a one-off
generate caller — pin a specific securityScheme name from the spec:

  printing-press generate --spec jira.json --auth-preference basicAuth

The hint is a soft preference: an unknown name silently falls back to
the existing default selector so a typo in catalog YAML degrades
gracefully rather than failing the whole generate. Matching is
case-insensitive against the spec's scheme name.

Plumbing:

* internal/openapi/parser.go: introduce ParseOptions, route the six
  Parse*/ParseFile* helpers through a single ParseWithOptions, thread
  AuthPreference into selectSecurityScheme.
* internal/cli/root.go: --auth-preference flag on `generate`.
* internal/catalog/catalog.go: optional auth_preference field on Entry.
* .github/workflows/validate-catalog.yml: forward auth_preference to
  generate so the CI matches what users get from the catalog.
* catalog/jira.yaml: new official entry, pinned to basicAuth.

Tests cover: preference selects the named scheme, case-insensitive
matching preserves the spec's casing in Auth.Scheme, and an unknown
preference falls through to the default OAuth2-wins behavior.

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

* fix(ci): use yq to read auth_preference in validate-catalog

grep+sed silently mangled quoted values, trailing comments, or
indented keys. Use `yq e '.auth_preference // ""'` so the workflow
forwards the same value the generator would parse.

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

* fix(cli): forward catalog auth_preference into OpenAPI parse for generate

* test(cli): migrate auth-preference test fixtures from bare example.com to api.example.com

The spec validator added in main (fix #984) rejects bare example.com as a
reserved RFC 2606 placeholder host; subdomains like api.example.com remain
allowed. Update the two auth-preference parser tests so they pass the merged
validator.

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

* fix(cli): flatten nested-prefix body fields that collide with sibling identifiers

Two body properties whose camelCased prefix-paths converge on the same
Go identifier — e.g. Atlassian ProjectComponent's top-level
`leadAccountId` alongside the sibling `lead` object's nested
`accountId` — emit two `var bodyLeadAccountId string` declarations
after #957's nested-object expansion, and the generated CLI fails to
compile with "redeclared in this block".

The existing parser-side seenCamelNames pass only dedupes among
top-level body params; it doesn't predict the identifiers that the
generator's nested-prefix recursion will produce.

Add flattenCollidingBodyFields, a generator-side pre-pass that walks
endpoint.Body using the same identifier rule as renderBodyMap
(`toCamel(paramIdent(p))` joined to the parent prefix). Where a leaf's
predicted identifier collides with another leaf elsewhere in the tree,
clear the offending parent's Fields so it falls through to the
JSON-string fallback. The user can still reach the nested data via
`--lead '{"accountId":"..."}'`.

Wired into all four body-emission template helpers (bodyMap,
bodyVarDecls, bodyFlagRegs, bodyRequiredChecks) so detection and
emission cannot drift. No effect on the common case: 17/17 golden
fixtures byte-identical.

Discovered while greening up the validate-catalog CI job for the Jira
catalog entry added in this PR — the Atlassian Jira spec was the first
real shape to trip the collision.

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

* fix(cli): preserve Bearer default over OAuth2 authorization-code in scheme selection

The OAuth2+AC early-return loop in selectSecurityScheme bypassed the
scoring system for any spec advertising both http/bearer and OAuth2
authorizationCode. Since schemePriorityBearer = 0 < schemePriorityOAuth2AuthCode
= 200, the scoring system intentionally picks Bearer as the simpler
scheme for CLI use; the early-return inverted that for every Bearer +
OAuth2+AC spec.

The loop also added nothing for the Jira goal: AuthPreference already
pins basicAuth when the catalog asks, and scoring already prefers
OAuth2+AC (200) over basicAuth (500) by default.

Drop the loop. Add a parser test that locks both halves of the contract:
default selection keeps Bearer; AuthPreference: "OAuth2" still lets
callers opt into the 3LO dance when they want it.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>

Files touched

Diff

commit 34ca81497f6e46ee4fe5f92a30f6f3f15d498e27
Author: Michael Schreiber <michael.a.schreiber@gmail.com>
Date:   Sat May 16 13:49:10 2026 -0400

    feat(cli): add --auth-preference flag for catalog-driven scheme selection (#865)
    
    * feat(cli): add --auth-preference flag for catalog-driven scheme selection
    
    When an OpenAPI spec advertises multiple security schemes — most commonly
    OAuth2 (with full authorizationCode flow) plus HTTP Basic — the parser
    defaulted to OAuth2. That selection is correct for hosted multi-tenant
    integrations but unusable for personal-token CLIs (Atlassian Jira /
    Confluence / Bitbucket, GitLab, and others all expose this exact pair).
    
    Add a generation-time hint that lets a catalog entry — or a one-off
    generate caller — pin a specific securityScheme name from the spec:
    
      printing-press generate --spec jira.json --auth-preference basicAuth
    
    The hint is a soft preference: an unknown name silently falls back to
    the existing default selector so a typo in catalog YAML degrades
    gracefully rather than failing the whole generate. Matching is
    case-insensitive against the spec's scheme name.
    
    Plumbing:
    
    * internal/openapi/parser.go: introduce ParseOptions, route the six
      Parse*/ParseFile* helpers through a single ParseWithOptions, thread
      AuthPreference into selectSecurityScheme.
    * internal/cli/root.go: --auth-preference flag on `generate`.
    * internal/catalog/catalog.go: optional auth_preference field on Entry.
    * .github/workflows/validate-catalog.yml: forward auth_preference to
      generate so the CI matches what users get from the catalog.
    * catalog/jira.yaml: new official entry, pinned to basicAuth.
    
    Tests cover: preference selects the named scheme, case-insensitive
    matching preserves the spec's casing in Auth.Scheme, and an unknown
    preference falls through to the default OAuth2-wins behavior.
    
    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
    
    * fix(ci): use yq to read auth_preference in validate-catalog
    
    grep+sed silently mangled quoted values, trailing comments, or
    indented keys. Use `yq e '.auth_preference // ""'` so the workflow
    forwards the same value the generator would parse.
    
    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
    
    * fix(cli): forward catalog auth_preference into OpenAPI parse for generate
    
    * test(cli): migrate auth-preference test fixtures from bare example.com to api.example.com
    
    The spec validator added in main (fix #984) rejects bare example.com as a
    reserved RFC 2606 placeholder host; subdomains like api.example.com remain
    allowed. Update the two auth-preference parser tests so they pass the merged
    validator.
    
    Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
    
    * fix(cli): flatten nested-prefix body fields that collide with sibling identifiers
    
    Two body properties whose camelCased prefix-paths converge on the same
    Go identifier — e.g. Atlassian ProjectComponent's top-level
    `leadAccountId` alongside the sibling `lead` object's nested
    `accountId` — emit two `var bodyLeadAccountId string` declarations
    after #957's nested-object expansion, and the generated CLI fails to
    compile with "redeclared in this block".
    
    The existing parser-side seenCamelNames pass only dedupes among
    top-level body params; it doesn't predict the identifiers that the
    generator's nested-prefix recursion will produce.
    
    Add flattenCollidingBodyFields, a generator-side pre-pass that walks
    endpoint.Body using the same identifier rule as renderBodyMap
    (`toCamel(paramIdent(p))` joined to the parent prefix). Where a leaf's
    predicted identifier collides with another leaf elsewhere in the tree,
    clear the offending parent's Fields so it falls through to the
    JSON-string fallback. The user can still reach the nested data via
    `--lead '{"accountId":"..."}'`.
    
    Wired into all four body-emission template helpers (bodyMap,
    bodyVarDecls, bodyFlagRegs, bodyRequiredChecks) so detection and
    emission cannot drift. No effect on the common case: 17/17 golden
    fixtures byte-identical.
    
    Discovered while greening up the validate-catalog CI job for the Jira
    catalog entry added in this PR — the Atlassian Jira spec was the first
    real shape to trip the collision.
    
    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
    
    * fix(cli): preserve Bearer default over OAuth2 authorization-code in scheme selection
    
    The OAuth2+AC early-return loop in selectSecurityScheme bypassed the
    scoring system for any spec advertising both http/bearer and OAuth2
    authorizationCode. Since schemePriorityBearer = 0 < schemePriorityOAuth2AuthCode
    = 200, the scoring system intentionally picks Bearer as the simpler
    scheme for CLI use; the early-return inverted that for every Bearer +
    OAuth2+AC spec.
    
    The loop also added nothing for the Jira goal: AuthPreference already
    pins basicAuth when the catalog asks, and scoring already prefers
    OAuth2+AC (200) over basicAuth (500) by default.
    
    Drop the loop. Add a parser test that locks both halves of the contract:
    default selection keeps Bearer; AuthPreference: "OAuth2" still lets
    callers opt into the 3LO dance when they want it.
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
    Co-authored-by: Trevin Chow <trevin@trevinchow.com>
---
 .github/workflows/validate-catalog.yml           |  13 +-
 catalog/jira.yaml                                |  21 +++
 internal/catalog/catalog.go                      |   7 +
 internal/catalog/catalog_test.go                 |  22 +++
 internal/cli/generate_test.go                    |  68 +++++++++
 internal/cli/public_param_audit.go               |   2 +-
 internal/cli/root.go                             |  39 +++--
 internal/generator/body_collision.go             |  88 +++++++++++
 internal/generator/body_collision_test.go        | 116 +++++++++++++++
 internal/generator/generator.go                  |   8 +-
 internal/openapi/parser.go                       |  85 +++++++----
 internal/openapi/parser_test.go                  | 178 +++++++++++++++++++++++
 testdata/golden/expected/catalog-list/stdout.txt |   1 +
 13 files changed, 604 insertions(+), 44 deletions(-)

diff --git a/.github/workflows/validate-catalog.yml b/.github/workflows/validate-catalog.yml
index 8373f39d..ce4007c4 100644
--- a/.github/workflows/validate-catalog.yml
+++ b/.github/workflows/validate-catalog.yml
@@ -98,12 +98,23 @@ jobs:
             NAME=$(grep '^name:' "$file" | head -1 | sed 's/name: *//')
             OUT_DIR="${RUNNER_TEMP:-/tmp}/${NAME}-cli"
 
+            # Forward auth_preference when the entry sets one so the generated
+            # CLI matches what `printing-press generate <name>` would produce.
+            # Use yq so quoted values, trailing comments, and indentation are
+            # handled correctly (grep+sed silently mangled those).
+            AUTH_PREFERENCE_ARG=()
+            AUTH_PREF=$(yq e '.auth_preference // ""' "$file")
+            if [ -n "$AUTH_PREF" ]; then
+              AUTH_PREFERENCE_ARG=(--auth-preference "$AUTH_PREF")
+            fi
+
             ./printing-press generate \
               --spec "$SPEC_FILE" \
               --spec-url "$SPEC_URL" \
               --name "$NAME" \
               --output "$OUT_DIR" \
-              --validate
+              --validate \
+              "${AUTH_PREFERENCE_ARG[@]}"
 
             echo "PASS: Generated CLI compiles"
             rm -rf "$OUT_DIR" "$SPEC_FILE"
diff --git a/catalog/jira.yaml b/catalog/jira.yaml
new file mode 100644
index 00000000..3203ba96
--- /dev/null
+++ b/catalog/jira.yaml
@@ -0,0 +1,21 @@
+name: jira
+display_name: Jira Cloud
+description: Issue tracking and project management API for Jira Cloud
+category: project-management
+spec_url: https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json
+spec_format: json
+openapi_version: "3.0"
+tier: official
+verified_date: "2026-05-09"
+auth_preference: basicAuth
+homepage: https://developer.atlassian.com/cloud/jira/platform/rest/v3/
+notes: |
+  HTTP Basic auth: email + API token (https://id.atlassian.com/manage-profile/security/api-tokens).
+  Site URL is per-tenant (https://<your-site>.atlassian.net); supply via JIRA_BASE_URL env var.
+  Spec is large (~420 paths); generator truncation applies.
+
+  Common JQL patterns (use with the issue-search command or --jql flag):
+    Issues assigned to you:   assignee = currentUser()
+    Open issues in a project: project = MYPROJ AND statusCategory != Done
+    Recently updated:         updated >= -7d ORDER BY updated DESC
+  Run `<slug>-pp-cli --help` or the `context` MCP tool for exact command names after generation.
diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go
index f7cfc8b5..f2e4d82d 100644
--- a/internal/catalog/catalog.go
+++ b/internal/catalog/catalog.go
@@ -178,6 +178,13 @@ type Entry struct {
 	// can use as implementation backing when no official spec exists. When this
 	// list is non-empty, spec_url and spec_format are optional.
 	WrapperLibraries []WrapperLibrary `yaml:"wrapper_libraries,omitempty"`
+	// AuthPreference is the securityScheme name from the upstream spec the
+	// generator should pick when multiple schemes are advertised. Without it
+	// the parser's default selection wins (which favors OAuth2 with a full
+	// authorization-code flow). Set this to a simpler scheme (e.g. "basicAuth")
+	// when the API supports both OAuth2 and HTTP Basic and the printed CLI is
+	// meant for personal API-token use rather than a 3LO web flow.
+	AuthPreference string `yaml:"auth_preference,omitempty"`
 }
 
 // IsWrapperOnly reports whether this entry represents an API reached through
diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go
index 227c2bfc..5ff4c121 100644
--- a/internal/catalog/catalog_test.go
+++ b/internal/catalog/catalog_test.go
@@ -53,6 +53,28 @@ notes: Example fixture.
 	assert.Equal(t, "Example fixture.", entry.Notes)
 }
 
+func TestParseEntryAuthPreference(t *testing.T) {
+	// auth_preference is optional; when set it carries the upstream
+	// securityScheme name verbatim through the parser → generator pipeline.
+	data := []byte(`
+name: jira-like
+display_name: Jira-like
+description: Multi-scheme spec
+category: project-management
+spec_url: https://example.com/openapi.yaml
+spec_format: json
+openapi_version: "3.0"
+tier: official
+verified_date: "2026-05-09"
+homepage: https://example.com
+auth_preference: basicAuth
+`)
+
+	entry, err := ParseEntry(data)
+	require.NoError(t, err)
+	assert.Equal(t, "basicAuth", entry.AuthPreference)
+}
+
 func TestValidateEntry(t *testing.T) {
 	base := Entry{
 		Name:        "test-api",
diff --git a/internal/cli/generate_test.go b/internal/cli/generate_test.go
index 2bc057a4..680e9bea 100644
--- a/internal/cli/generate_test.go
+++ b/internal/cli/generate_test.go
@@ -10,6 +10,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	catalogfs "github.com/mvanhorn/cli-printing-press/v4/catalog"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/catalog"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/catalogmeta"
 	"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline"
@@ -1815,6 +1816,73 @@ resources:
 		"the spec-derived directory must not be created when --output is explicit")
 }
 
+func TestOpenAPIAuthPreferenceForGenerateFromJiraCatalogEntry(t *testing.T) {
+	t.Parallel()
+
+	jira, err := catalog.LookupFS(catalogfs.FS, "jira")
+	require.NoError(t, err)
+	require.Equal(t, "basicAuth", jira.AuthPreference)
+
+	assert.Equal(t, "basicAuth", openAPIAuthPreferenceForGenerate("", "", []string{jira.SpecURL}, ""),
+		"catalog spec_url match should forward auth_preference without --auth-preference")
+	assert.Equal(t, "OAuth2", openAPIAuthPreferenceForGenerate("OAuth2", "", []string{jira.SpecURL}, ""),
+		"explicit --auth-preference must override catalog")
+
+	localSpec := filepath.Join(t.TempDir(), "swagger.json")
+	assert.Equal(t, "basicAuth", openAPIAuthPreferenceForGenerate("", "jira", []string{localSpec}, ""),
+		"catalog slug via --name should resolve auth_preference without https spec refs")
+}
+
+func TestOpenAPIAuthPreferenceForGenerateParsesJiraLikeSpecWithCatalogDefault(t *testing.T) {
+	t.Parallel()
+
+	// Mirrors real Jira-style specs: OAuth2 authorizationCode + HTTP Basic; default
+	// parser choice is OAuth2 unless AuthPreference pins basicAuth (see openapi parser tests).
+	specBytes := []byte(`openapi: "3.0.3"
+info:
+  title: Atlassian-like
+  version: "1.0"
+servers:
+  - url: https://example.atlassian.net
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      flows:
+        authorizationCode:
+          authorizationUrl: https://auth.example.com/authorize
+          tokenUrl: https://auth.example.com/token
+          scopes:
+            read: read access
+    basicAuth:
+      type: http
+      scheme: basic
+paths:
+  /v1/things:
+    get:
+      operationId: list things
+      security:
+        - basicAuth: []
+        - OAuth2: [read]
+      responses: {"200": {description: ok}}
+`)
+
+	jira, err := catalog.LookupFS(catalogfs.FS, "jira")
+	require.NoError(t, err)
+
+	pref := openAPIAuthPreferenceForGenerate("", "", []string{jira.SpecURL}, "")
+	require.Equal(t, "basicAuth", pref)
+
+	parsed, err := parseOpenAPISpec(filepath.Join(t.TempDir(), "spec.yaml"), specBytes, false, pref)
+	require.NoError(t, err)
+	assert.Equal(t, "basicAuth", parsed.Auth.Scheme)
+	assert.Equal(t, "api_key", parsed.Auth.Type)
+
+	defaultParsed, err := parseOpenAPISpec(filepath.Join(t.TempDir(), "spec2.yaml"), specBytes, false, "")
+	require.NoError(t, err)
+	assert.Equal(t, "OAuth2", defaultParsed.Auth.Scheme, "without catalog-driven preference, OAuth2 wins")
+}
+
 func TestEnrichSpecFromCatalogCopiesGenerationMetadata(t *testing.T) {
 	apiSpec := &spec.APISpec{Name: "test-api", BaseURL: spec.PlaceholderBaseURL, BaseURLIsPlaceholder: true}
 
diff --git a/internal/cli/public_param_audit.go b/internal/cli/public_param_audit.go
index 23db035f..8742020b 100644
--- a/internal/cli/public_param_audit.go
+++ b/internal/cli/public_param_audit.go
@@ -93,7 +93,7 @@ func parsePublicParamAuditSpec(specFiles []string, cliName string, lenient bool)
 
 		var apiSpec *spec.APISpec
 		if openapi.IsOpenAPI(data) {
-			apiSpec, err = parseOpenAPISpec(specFile, data, lenient)
+			apiSpec, err = parseOpenAPISpec(specFile, data, lenient, "")
 		} else if graphql.IsGraphQLSDL(data) {
 			apiSpec, err = graphql.ParseSDLBytes(specFile, data)
 		} else {
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 257c0e94..00c93ce4 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -100,6 +100,7 @@ func newGenerateCmd() *cobra.Command {
 	var specURL string
 	var planFile string
 	var trafficAnalysisPath string
+	var authPreference string
 
 	cmd := &cobra.Command{
 		Use:   "generate",
@@ -274,6 +275,8 @@ func newGenerateCmd() *cobra.Command {
 				openapi.SetMaxEndpointsPerResource(maxEndpointsPerResource)
 			}
 
+			openAPIParseAuthPref := openAPIAuthPreferenceForGenerate(authPreference, cliName, specFiles, specURL)
+
 			var specs []*spec.APISpec
 			var specRawBytes [][]byte // raw spec data for archiving
 			for _, specFile := range specFiles {
@@ -285,7 +288,7 @@ func newGenerateCmd() *cobra.Command {
 
 				var apiSpec *spec.APISpec
 				if openapi.IsOpenAPI(data) {
-					apiSpec, err = parseOpenAPISpec(specFile, data, lenient)
+					apiSpec, err = parseOpenAPISpec(specFile, data, lenient, openAPIParseAuthPref)
 				} else if graphql.IsGraphQLSDL(data) {
 					apiSpec, err = graphql.ParseSDLBytes(specFile, data)
 				} else {
@@ -441,6 +444,7 @@ func newGenerateCmd() *cobra.Command {
 	cmd.Flags().StringVar(&specURL, "spec-url", "", "Original spec URL for provenance (use when --spec is a local file downloaded from a URL)")
 	cmd.Flags().StringVar(&planFile, "plan", "", "Path to a markdown plan document for plan-driven generation (instead of --spec)")
 	cmd.Flags().StringVar(&trafficAnalysisPath, "traffic-analysis", "", "Path to browser-sniff traffic-analysis.json for advisory generation context")
+	cmd.Flags().StringVar(&authPreference, "auth-preference", "", "Preferred securityScheme name from the spec (overrides default selection and any catalog auth_preference; useful when a spec advertises multiple schemes such as OAuth2 + HTTP Basic and you want the simpler one). When omitted, a matching embedded catalog entry's auth_preference applies for OpenAPI parsing.")
 
 	return cmd
 }
@@ -703,17 +707,26 @@ func readSpec(specFile string, refresh bool, skipCache bool) ([]byte, error) {
 	return data, nil
 }
 
-func parseOpenAPISpec(specFile string, data []byte, lenient bool) (*spec.APISpec, error) {
-	if openapi.IsRemoteSpecSource(specFile) {
-		if lenient {
-			return openapi.ParseLenient(data)
-		}
-		return openapi.Parse(data)
+func parseOpenAPISpec(specFile string, data []byte, lenient bool, authPreference string) (*spec.APISpec, error) {
+	opts := openapi.ParseOptions{Lenient: lenient, AuthPreference: authPreference}
+	if !openapi.IsRemoteSpecSource(specFile) {
+		opts.Path = specFile
+	}
+	return openapi.ParseWithOptions(data, opts)
+}
+
+// openAPIAuthPreferenceForGenerate resolves AuthPreference for openapi.ParseWithOptions.
+// Explicit --auth-preference wins; otherwise a matching catalog entry's auth_preference
+// is used so catalog-driven generates pick the intended scheme before spec enrichment.
+func openAPIAuthPreferenceForGenerate(cliAuthPref, cliName string, specFiles []string, specURL string) string {
+	if s := strings.TrimSpace(cliAuthPref); s != "" {
+		return s
 	}
-	if lenient {
-		return openapi.ParseWithPathLenient(data, specFile)
+	entry := lookupCatalogEntryForGenerateSpec(strings.TrimSpace(cliName), catalogSpecLookupRefs(specFiles, specURL))
+	if entry == nil {
+		return ""
 	}
-	return openapi.ParseWithPath(data, specFile)
+	return strings.TrimSpace(entry.AuthPreference)
 }
 
 // archiveSpecBytes picks the bytes and filename for the spec snapshot that
@@ -1532,8 +1545,10 @@ func catalogSpecLookupRefs(specFiles []string, specURL string) []string {
 }
 
 func lookupCatalogEntryForGenerateSpec(apiName string, specRefs []string) *catalog.Entry {
-	if entry, err := catalog.LookupFS(catalogfs.FS, apiName); err == nil {
-		return entry
+	if name := strings.TrimSpace(apiName); name != "" {
+		if entry, err := catalog.LookupFS(catalogfs.FS, name); err == nil {
+			return entry
+		}
 	}
 	specURLs := make(map[string]struct{}, len(specRefs))
 	for _, ref := range specRefs {
diff --git a/internal/generator/body_collision.go b/internal/generator/body_collision.go
new file mode 100644
index 00000000..891bf9e4
--- /dev/null
+++ b/internal/generator/body_collision.go
@@ -0,0 +1,88 @@
+package generator
+
+import (
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+)
+
+// flattenCollidingBodyFields returns body with Fields cleared on any
+// object param whose nested expansion would produce a Go identifier
+// already claimed by another leaf in the same body tree. The colliding
+// parent then falls through to the JSON-string fallback in
+// renderBodyMap, so the user can still reach the field via the parent
+// flag as a JSON blob.
+//
+// Without this pass, two body properties whose camelCased prefix-paths
+// converge on the same identifier — e.g. top-level `leadAccountId`
+// alongside nested `lead.accountId` (Atlassian's ProjectComponent
+// exposes exactly this pair) — emit two `var bodyLeadAccountId ...`
+// declarations and the generated CLI fails to compile with
+// "redeclared in this block".
+//
+// The check uses the same identifier-prediction rule as renderBodyMap
+// and renderBodyVarDecls (`toCamel(paramIdent(p))` joined to the
+// parent prefix) so detection and emission cannot drift.
+func flattenCollidingBodyFields(body []spec.Param) []spec.Param {
+	counts := countBodyLeaves(body, "")
+	collision := false
+	for _, n := range counts {
+		if n > 1 {
+			collision = true
+			break
+		}
+	}
+	if !collision {
+		return body
+	}
+	return clearCollidingParents(body, "", counts)
+}
+
+func countBodyLeaves(params []spec.Param, prefix string) map[string]int {
+	counts := map[string]int{}
+	var walk func([]spec.Param, string)
+	walk = func(ps []spec.Param, pfx string) {
+		for _, p := range ps {
+			ident := pfx + toCamel(paramIdent(p))
+			if p.Type == "object" && len(p.Fields) > 0 {
+				walk(p.Fields, ident)
+				continue
+			}
+			counts[ident]++
+		}
+	}
+	walk(params, prefix)
+	return counts
+}
+
+func clearCollidingParents(params []spec.Param, prefix string, counts map[string]int) []spec.Param {
+	out := make([]spec.Param, len(params))
+	copy(out, params)
+	for i := range out {
+		p := &out[i]
+		if p.Type != "object" || len(p.Fields) == 0 {
+			continue
+		}
+		ident := prefix + toCamel(paramIdent(*p))
+		if subtreeHasCollidingLeaf(p.Fields, ident, counts) {
+			p.Fields = nil
+			continue
+		}
+		p.Fields = clearCollidingParents(p.Fields, ident, counts)
+	}
+	return out
+}
+
+func subtreeHasCollidingLeaf(params []spec.Param, prefix string, counts map[string]int) bool {
+	for _, p := range params {
+		ident := prefix + toCamel(paramIdent(p))
+		if p.Type == "object" && len(p.Fields) > 0 {
+			if subtreeHasCollidingLeaf(p.Fields, ident, counts) {
+				return true
+			}
+			continue
+		}
+		if counts[ident] > 1 {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/generator/body_collision_test.go b/internal/generator/body_collision_test.go
index 59ff0eed..404bf80d 100644
--- a/internal/generator/body_collision_test.go
+++ b/internal/generator/body_collision_test.go
@@ -433,6 +433,122 @@ components:
 		"the postalAddress.customer cycle-cut leaf must emit a unique identifier under its full path")
 }
 
+// TestFlattenCollidingBodyFields_NestedPrefixShape covers the Atlassian
+// ProjectComponent shape: a top-level scalar `leadAccountId` plus a
+// sibling `lead` object whose nested `accountId` would expand to the
+// same Go identifier `bodyLeadAccountId`. The parser-side seenCamelNames
+// dedup only checks top-level names, so the collision surfaces in the
+// generator. flattenCollidingBodyFields must clear the offending
+// parent's Fields so it falls through to the JSON-blob branch.
+func TestFlattenCollidingBodyFields_NestedPrefixShape(t *testing.T) {
+	t.Parallel()
+
+	body := []spec.Param{
+		{Name: "leadAccountId", Type: "string"},
+		{
+			Name: "lead",
+			Type: "object",
+			Fields: []spec.Param{
+				{Name: "accountId", Type: "string"},
+				{Name: "displayName", Type: "string"},
+			},
+		},
+	}
+
+	got := flattenCollidingBodyFields(body)
+
+	require.Len(t, got, 2)
+	assert.Equal(t, "leadAccountId", got[0].Name)
+	assert.Empty(t, got[0].Fields, "top-level scalar is untouched")
+	assert.Equal(t, "lead", got[1].Name)
+	assert.Empty(t, got[1].Fields,
+		"colliding parent must have Fields cleared so it falls through to JSON-blob")
+}
+
+// TestFlattenCollidingBodyFields_NoCollisionPassesThrough guards the
+// common case: when nested expansion is collision-free the helper must
+// not strip Fields. Two unrelated objects with non-colliding leaf names
+// (the canonical start/end DateTimeTimeZone example from #957) must
+// round-trip with Fields intact.
+func TestFlattenCollidingBodyFields_NoCollisionPassesThrough(t *testing.T) {
+	t.Parallel()
+
+	body := []spec.Param{
+		{
+			Name: "start",
+			Type: "object",
+			Fields: []spec.Param{
+				{Name: "dateTime", Type: "string"},
+				{Name: "timeZone", Type: "string"},
+			},
+		},
+		{
+			Name: "end",
+			Type: "object",
+			Fields: []spec.Param{
+				{Name: "dateTime", Type: "string"},
+				{Name: "timeZone", Type: "string"},
+			},
+		},
+	}
+
+	got := flattenCollidingBodyFields(body)
+
+	require.Len(t, got, 2)
+	for _, p := range got {
+		assert.Len(t, p.Fields, 2, "nested object %q keeps its 2 Fields", p.Name)
+	}
+}
+
+// TestGenerateProjectComponentShapeCompiles is the end-to-end regression
+// for the Atlassian Jira validate-catalog failure: a POST endpoint whose
+// body contains both `leadAccountId` (scalar) and `lead` (object with
+// nested `accountId`) must produce a generated CLI that compiles. Before
+// the flattenCollidingBodyFields pass, this shape emitted two
+// `var bodyLeadAccountId string` declarations and failed govulncheck's
+// load step with "redeclared in this block".
+func TestGenerateProjectComponentShapeCompiles(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("collide-nested")
+	apiSpec.Resources["components"] = spec.Resource{
+		Description: "Components",
+		Endpoints: map[string]spec.Endpoint{
+			"create": {
+				Method:      "POST",
+				Path:        "/components",
+				Description: "Create a component (Jira ProjectComponent shape)",
+				Body: []spec.Param{
+					{Name: "leadAccountId", Type: "string", Description: "Lead user account ID (top-level)"},
+					{
+						Name:        "lead",
+						Type:        "object",
+						Description: "Lead user details (nested object)",
+						Fields: []spec.Param{
+							{Name: "accountId", Type: "string", Description: "Account ID inside the lead object"},
+							{Name: "displayName", Type: "string", Description: "Display name"},
+						},
+					},
+				},
+			},
+			"get": {
+				Method:      "GET",
+				Path:        "/components/{id}",
+				Description: "Get one component",
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "collide-nested-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	bodyVars, _ := parseBodyDeclarations(t,
+		filepath.Join(outputDir, "internal", "cli", "components_create.go"))
+
+	assertNoDuplicates(t, bodyVars,
+		"nested-prefix collision must not produce duplicate `var body<X>` declarations")
+}
+
 // parseBodyDeclarations returns the names of all `var bodyXxx` declarations
 // and the literal cobra flag names registered. Cobra registrations may come
 // from either flag<X> or body<X> Go identifiers, so the flag-binding return
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index e86c6f65..7db4349d 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -3398,7 +3398,7 @@ func endpointNeedsClientLimit(endpoint spec.Endpoint) bool {
 // map[string]any rather than a single JSON-string flag.
 func bodyMap(body []spec.Param, indent string) string {
 	var b strings.Builder
-	renderBodyMap(&b, body, indent, "body", "", "")
+	renderBodyMap(&b, flattenCollidingBodyFields(body), indent, "body", "", "")
 	return b.String()
 }
 
@@ -3518,7 +3518,7 @@ func bodyVarDecls(endpoint spec.Endpoint) string {
 		}
 		return b.String()
 	}
-	renderBodyVarDecls(&b, endpoint.Body, "")
+	renderBodyVarDecls(&b, flattenCollidingBodyFields(endpoint.Body), "")
 	return b.String()
 }
 
@@ -3560,7 +3560,7 @@ func bodyFlagRegs(endpoint spec.Endpoint) string {
 		}
 		return b.String()
 	}
-	renderBodyFlagRegs(&b, endpoint.Body, "", "", true)
+	renderBodyFlagRegs(&b, flattenCollidingBodyFields(endpoint.Body), "", "", true)
 	return b.String()
 }
 
@@ -3622,7 +3622,7 @@ func bodyRequiredChecks(endpoint spec.Endpoint, indent string) string {
 		}
 		return b.String()
 	}
-	renderBodyRequiredChecks(&b, endpoint.Body, indent, "", true)
+	renderBodyRequiredChecks(&b, flattenCollidingBodyFields(endpoint.Body), indent, "", true)
 	return b.String()
 }
 
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index cbe55d86..5bacd948 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -163,62 +163,83 @@ func stripBrokenRefs(data []byte, errMsg string) []byte {
 
 // Parse parses an OpenAPI spec strictly. Use ParseLenient for specs with broken $refs.
 func Parse(data []byte) (*spec.APISpec, error) {
-	return parse(data, false)
+	return ParseWithOptions(data, ParseOptions{})
 }
 
 // ParseFile parses an OpenAPI spec from a file and resolves local external
 // refs relative to that file.
 func ParseFile(path string) (*spec.APISpec, error) {
-	return parseFile(path, false)
+	return parseFileWithOptions(path, ParseOptions{})
 }
 
 // ParseWithPath parses OpenAPI spec bytes and resolves local external refs
 // relative to the given file path.
 func ParseWithPath(data []byte, path string) (*spec.APISpec, error) {
-	return parseWithPath(data, path, false)
+	return ParseWithOptions(data, ParseOptions{Path: path})
 }
 
 // ParseLenient parses an OpenAPI spec, skipping validation errors from broken $refs.
 // It logs warnings to stderr for any issues found but continues parsing.
 func ParseLenient(data []byte) (*spec.APISpec, error) {
-	return parse(data, true)
+	return ParseWithOptions(data, ParseOptions{Lenient: true})
 }
 
 // ParseFileLenient parses an OpenAPI spec from a file and skips validation
 // errors from broken $refs after resolving local external refs relative to
 // that file.
 func ParseFileLenient(path string) (*spec.APISpec, error) {
-	return parseFile(path, true)
+	return parseFileWithOptions(path, ParseOptions{Lenient: true})
 }
 
 // ParseWithPathLenient parses OpenAPI spec bytes, resolving local external refs
 // relative to the given file path and skipping validation errors from broken
 // refs.
 func ParseWithPathLenient(data []byte, path string) (*spec.APISpec, error) {
-	return parseWithPath(data, path, true)
-}
-
-func parseFile(path string, lenient bool) (*spec.APISpec, error) {
-	data, err := os.ReadFile(path)
+	return ParseWithOptions(data, ParseOptions{Path: path, Lenient: true})
+}
+
+// ParseOptions carries optional hints that influence parsing without changing
+// the existing single-purpose Parse* signatures. New behavioral knobs (e.g.
+// auth-scheme preference) should be added here rather than as new positional
+// parameters across every entry point.
+type ParseOptions struct {
+	// Path resolves local external refs relative to a file location. Empty
+	// means the spec is treated as remote/in-memory only.
+	Path string
+	// Lenient skips validation errors from broken $refs.
+	Lenient bool
+	// AuthPreference names a security scheme from components.securitySchemes
+	// that should win over the parser's default selection priority. Used when
+	// a spec exposes multiple valid schemes (e.g. OAuth2 + basic) and the
+	// caller knows which one fits the printed CLI's intended auth model.
+	// Unknown names are ignored (default selection runs).
+	AuthPreference string
+}
+
+// ParseWithOptions is the canonical parser entry point; the older Parse* and
+// ParseFile* helpers delegate to it with default ParseOptions plus their own
+// path/lenient settings.
+func ParseWithOptions(data []byte, opts ParseOptions) (*spec.APISpec, error) {
+	if opts.Path == "" {
+		return parseWithLocation(data, opts.Lenient, nil, opts.AuthPreference)
+	}
+	location, err := fileLocation(opts.Path)
 	if err != nil {
-		return nil, fmt.Errorf("reading OpenAPI spec: %w", err)
+		return nil, err
 	}
-	return parseWithPath(data, path, lenient)
+	return parseWithLocation(data, opts.Lenient, location, opts.AuthPreference)
 }
 
-func parseWithPath(data []byte, path string, lenient bool) (*spec.APISpec, error) {
-	location, err := fileLocation(path)
+func parseFileWithOptions(path string, opts ParseOptions) (*spec.APISpec, error) {
+	data, err := os.ReadFile(path)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("reading OpenAPI spec: %w", err)
 	}
-	return parseWithLocation(data, lenient, location)
+	opts.Path = path
+	return ParseWithOptions(data, opts)
 }
 
-func parse(data []byte, lenient bool) (*spec.APISpec, error) {
-	return parseWithLocation(data, lenient, nil)
-}
-
-func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APISpec, error) {
+func parseWithLocation(data []byte, lenient bool, location *url.URL, authPreference string) (*spec.APISpec, error) {
 	var metadata specDataMetadata
 	if normalized, meta, err := normalizeSpecDataWithMetadata(data); err == nil {
 		data = normalized
@@ -388,7 +409,7 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
 		baseURLIsPlaceholder = true
 	}
 
-	auth := mapAuthWithDescriptionInference(doc, name, !metadata.explicitEmptySecuritySchemes)
+	auth := mapAuthWithDescriptionInference(doc, name, !metadata.explicitEmptySecuritySchemes, authPreference)
 	if auth.Type != "none" && allOperationsAllowAnonymous(doc) {
 		auth = spec.AuthConfig{Type: "none"}
 	}
@@ -549,12 +570,12 @@ func lookupOpenAPIInfoExtension(doc *openapi3.T, key string) (any, bool) {
 }
 
 func mapAuth(doc *openapi3.T, name string) spec.AuthConfig {
-	return mapAuthWithDescriptionInference(doc, name, true)
+	return mapAuthWithDescriptionInference(doc, name, true, "")
 }
 
-func mapAuthWithDescriptionInference(doc *openapi3.T, name string, allowDescriptionInference bool) spec.AuthConfig {
+func mapAuthWithDescriptionInference(doc *openapi3.T, name string, allowDescriptionInference bool, authPreference string) spec.AuthConfig {
 	auth := spec.AuthConfig{Type: "none"}
-	schemeName, scheme := selectSecurityScheme(doc)
+	schemeName, scheme := selectSecurityScheme(doc, authPreference)
 	if scheme == nil {
 		result := inferQueryParamAuth(doc, name, auth)
 		if result.Type == "none" {
@@ -1758,13 +1779,25 @@ func isNegated(text string, keywordIdx int) bool {
 	return false
 }
 
-func selectSecurityScheme(doc *openapi3.T) (string, *openapi3.SecurityScheme) {
+func selectSecurityScheme(doc *openapi3.T, authPreference string) (string, *openapi3.SecurityScheme) {
 	if doc == nil || doc.Components == nil || len(doc.Components.SecuritySchemes) == 0 {
 		return "", nil
 	}
 
 	candidates := candidateSecuritySchemeNames(doc)
 
+	if pref := strings.TrimSpace(authPreference); pref != "" {
+		for _, name := range candidates {
+			if !strings.EqualFold(name, pref) {
+				continue
+			}
+			scheme := securitySchemeValue(doc.Components.SecuritySchemes[name])
+			if scheme != nil {
+				return name, scheme
+			}
+		}
+	}
+
 	bestScore := math.MaxInt
 	var bestName string
 	var bestScheme *openapi3.SecurityScheme
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index c4d4a964..a6adacaf 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -989,6 +989,184 @@ paths:
 	assert.Equal(t, "https://example.com/auth", parsed.Auth.AuthorizationURL)
 }
 
+func TestParseAuthPreferenceSelectsNamedScheme(t *testing.T) {
+	t.Parallel()
+
+	// Many real-world specs (Atlassian Jira, Confluence, Bitbucket, GitLab)
+	// advertise both OAuth2 (with full authorizationCode flow) and HTTP Basic.
+	// The default selector picks OAuth2 — correct for hosted multi-tenant
+	// integrations but wrong for personal-token CLIs. AuthPreference lets the
+	// catalog (or a generate caller) pin the simpler scheme.
+	specBytes := []byte(`openapi: "3.0.3"
+info:
+  title: Atlassian-like
+  version: "1.0"
+servers:
+  - url: https://example.atlassian.net
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      flows:
+        authorizationCode:
+          authorizationUrl: https://auth.example.com/authorize
+          tokenUrl: https://auth.example.com/token
+          scopes:
+            read: read access
+    basicAuth:
+      type: http
+      scheme: basic
+paths:
+  /v1/things:
+    get:
+      operationId: list things
+      security:
+        - basicAuth: []
+        - OAuth2: [read]
+      responses: {"200": {description: ok}}
+`)
+
+	defaultParsed, err := Parse(specBytes)
+	require.NoError(t, err)
+	assert.Equal(t, "OAuth2", defaultParsed.Auth.Scheme, "without preference, OAuth2+AC wins by design")
+	assert.Equal(t, "bearer_token", defaultParsed.Auth.Type)
+
+	preferred, err := ParseWithOptions(specBytes, ParseOptions{AuthPreference: "basicAuth"})
+	require.NoError(t, err)
+	assert.Equal(t, "api_key", preferred.Auth.Type, "preference pins HTTP Basic")
+	assert.Equal(t, "basicAuth", preferred.Auth.Scheme)
+	assert.Equal(t, "Basic {username}:{password}", preferred.Auth.Format)
+}
+
+func TestParseAuthPreferenceCaseInsensitive(t *testing.T) {
+	t.Parallel()
+
+	specBytes := []byte(`openapi: "3.0.3"
+info:
+  title: Mixed
+  version: "1.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      flows:
+        authorizationCode:
+          authorizationUrl: https://api.example.com/authorize
+          tokenUrl: https://api.example.com/token
+          scopes:
+            read: read
+    BasicAuth:
+      type: http
+      scheme: basic
+paths:
+  /v1/things:
+    get:
+      operationId: list things
+      security:
+        - BasicAuth: []
+        - OAuth2: [read]
+      responses: {"200": {description: ok}}
+`)
+
+	preferred, err := ParseWithOptions(specBytes, ParseOptions{AuthPreference: "basicauth"})
+	require.NoError(t, err)
+	assert.Equal(t, "api_key", preferred.Auth.Type)
+	assert.Equal(t, "BasicAuth", preferred.Auth.Scheme, "match is case-insensitive but result preserves spec casing")
+}
+
+func TestParseAuthPreferenceUnknownNameFallsBackToDefault(t *testing.T) {
+	t.Parallel()
+
+	// An unknown preference name must not fail parse; it falls through to the
+	// default selector so a typo in catalog yaml degrades gracefully.
+	specBytes := []byte(`openapi: "3.0.3"
+info:
+  title: Mixed
+  version: "1.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    OAuth2:
+      type: oauth2
+      flows:
+        authorizationCode:
+          authorizationUrl: https://api.example.com/authorize
+          tokenUrl: https://api.example.com/token
+          scopes:
+            read: read
+    basicAuth:
+      type: http
+      scheme: basic
+paths:
+  /v1/things:
+    get:
+      operationId: list things
+      security:
+        - basicAuth: []
+        - OAuth2: [read]
+      responses: {"200": {description: ok}}
+`)
+
+	parsed, err := ParseWithOptions(specBytes, ParseOptions{AuthPreference: "doesNotExist"})
+	require.NoError(t, err)
+	assert.Equal(t, "OAuth2", parsed.Auth.Scheme, "unknown preference falls back to default selector")
+	assert.Equal(t, "bearer_token", parsed.Auth.Type)
+}
+
+func TestParseBearerPreservedOverOAuth2AuthCode(t *testing.T) {
+	t.Parallel()
+
+	// GitHub-style shape: an http/bearer scheme alongside a full OAuth2
+	// authorizationCode flow. The scoring system in schemePriorityScore
+	// pins schemePriorityBearer = 0 < schemePriorityOAuth2AuthCode = 200
+	// precisely because Bearer is the simplest scheme for a CLI to use.
+	// Default selection must keep Bearer; AuthPreference must still let
+	// callers opt into OAuth2 when they want the 3LO dance.
+	specBytes := []byte(`openapi: "3.0.3"
+info:
+  title: GitHub-like
+  version: "1.0"
+servers:
+  - url: https://api.example.com
+components:
+  securitySchemes:
+    BearerAuth:
+      type: http
+      scheme: bearer
+    OAuth2:
+      type: oauth2
+      flows:
+        authorizationCode:
+          authorizationUrl: https://auth.example.com/authorize
+          tokenUrl: https://auth.example.com/token
+          scopes:
+            read: read access
+paths:
+  /v1/things:
+    get:
+      operationId: list things
+      security:
+        - BearerAuth: []
+        - OAuth2: [read]
+      responses: {"200": {description: ok}}
+`)
+
+	defaultParsed, err := Parse(specBytes)
+	require.NoError(t, err)
+	assert.Equal(t, "BearerAuth", defaultParsed.Auth.Scheme, "default selection must keep Bearer over OAuth2+AC")
+	assert.Equal(t, "bearer_token", defaultParsed.Auth.Type)
+
+	preferred, err := ParseWithOptions(specBytes, ParseOptions{AuthPreference: "OAuth2"})
+	require.NoError(t, err)
+	assert.Equal(t, "OAuth2", preferred.Auth.Scheme, "AuthPreference must still let callers opt into OAuth2")
+	assert.Equal(t, "bearer_token", preferred.Auth.Type)
+	assert.Equal(t, "https://auth.example.com/authorize", preferred.Auth.AuthorizationURL)
+	assert.Equal(t, "https://auth.example.com/token", preferred.Auth.TokenURL)
+}
+
 func TestBearerSchemeNameCanSpecializeEnvVar(t *testing.T) {
 	t.Parallel()
 
diff --git a/testdata/golden/expected/catalog-list/stdout.txt b/testdata/golden/expected/catalog-list/stdout.txt
index 27baa4da..cff87db9 100644
--- a/testdata/golden/expected/catalog-list/stdout.txt
+++ b/testdata/golden/expected/catalog-list/stdout.txt
@@ -35,6 +35,7 @@ payments:
 
 project-management:
   asana                Work management and project tracking API
+  jira                 Issue tracking and project management API for Jira Cloud
 
 sales-and-crm:
   hubspot              CRM contacts API

← 62838d04 fix(cli): validate-narrative splits piped recipes and strips  ·  back to Cli Printing Press  ·  fix(cli): filter auth login --chrome cookies by spec auth.co 95417401 →