← back to Cli Printing Press
feat(cli): add curated Shopify wrapper spec (#476)
40cefcb5cf8f48a9a7b286f4a1486f8c7680bc5e · 2026-05-01 23:25:36 -0500 · Cathryn Lavery
Files touched
M .gitignoreA catalog/specs/shopify-2026-04-wrapper.yamlM internal/generator/cursor_param_test.goM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/readme.md.tmplM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM internal/pipeline/mcpb_manifest.goM internal/spec/spec.goM testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
Diff
commit 40cefcb5cf8f48a9a7b286f4a1486f8c7680bc5e
Author: Cathryn Lavery <50469282+cathrynlavery@users.noreply.github.com>
Date: Fri May 1 23:25:36 2026 -0500
feat(cli): add curated Shopify wrapper spec (#476)
---
.gitignore | 3 +
catalog/specs/shopify-2026-04-wrapper.yaml | 345 +++++++++++++++++++++
internal/generator/cursor_param_test.go | 24 ++
internal/generator/generator.go | 59 +++-
internal/generator/generator_test.go | 22 +-
.../generator/templates/command_endpoint.go.tmpl | 47 +++
internal/generator/templates/helpers.go.tmpl | 32 ++
internal/generator/templates/readme.md.tmpl | 41 ++-
internal/pipeline/climanifest.go | 34 +-
internal/pipeline/climanifest_test.go | 34 ++
internal/pipeline/mcpb_manifest.go | 50 ++-
internal/spec/spec.go | 5 +
.../printing-press-golden/README.md | 2 +-
13 files changed, 655 insertions(+), 43 deletions(-)
diff --git a/.gitignore b/.gitignore
index fed694aa..5792f3d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
.DS_Store
+.env
+.env.*
+catalog/specs/*.graphql
.cache/
.gotmp/
.claude/worktrees/
diff --git a/catalog/specs/shopify-2026-04-wrapper.yaml b/catalog/specs/shopify-2026-04-wrapper.yaml
new file mode 100644
index 00000000..dadff134
--- /dev/null
+++ b/catalog/specs/shopify-2026-04-wrapper.yaml
@@ -0,0 +1,345 @@
+name: shopify
+display_name: Shopify
+description: Ecommerce orders, products, customers, inventory, fulfillment orders, and bulk operations via the Shopify Admin GraphQL API.
+cli_description: Operate a Shopify store from the terminal with local sync, analytics, and bulk exports.
+version: "2026-04"
+spec_source: official
+category: commerce
+website_url: "https://shopify.dev/docs/api/admin-graphql"
+base_url: "https://{shop}"
+graphql_endpoint_path: "/admin/api/{api_version}/graphql.json"
+endpoint_template_vars:
+ - shop
+ - api_version
+
+auth:
+ type: api_key
+ header: X-Shopify-Access-Token
+ in: header
+ env_vars:
+ - SHOPIFY_ACCESS_TOKEN
+ key_url: "https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens"
+
+config:
+ format: toml
+ path: "~/.config/shopify-pp-cli/config.toml"
+
+cache:
+ enabled: true
+ stale_after: "30m"
+ refresh_timeout: "45s"
+ env_opt_out: SHOPIFY_NO_AUTO_REFRESH
+ resources:
+ orders: "10m"
+ products: "1h"
+ inventory-items: "15m"
+ customers: "1h"
+ fulfillment-orders: "10m"
+
+throttling:
+ enabled: true
+ shape: shopify
+
+resources:
+ orders:
+ description: "Shopify orders with money totals, financial state, and fulfillment state."
+ endpoints:
+ list:
+ method: GET
+ path: "/graphql"
+ description: "List orders from the Shopify Admin GraphQL API."
+ params:
+ - name: first
+ type: integer
+ default: 100
+ description: "Page size for Shopify cursor pagination."
+ - name: after
+ type: string
+ description: "Cursor for the next page."
+ response:
+ type: array
+ item: ShopifyOrder
+ response_path: "data.orders.nodes"
+ pagination:
+ type: cursor
+ limit_param: first
+ cursor_param: after
+ next_cursor_path: "data.orders.pageInfo.endCursor"
+ has_more_field: "data.orders.pageInfo.hasNextPage"
+ get:
+ method: GET
+ path: "/graphql"
+ description: "Get one Shopify order by GraphQL ID."
+ params:
+ - name: id
+ type: string
+ required: true
+ positional: true
+ description: "Shopify GraphQL order ID."
+ response:
+ type: object
+ item: ShopifyOrder
+ response_path: "data.order"
+
+ products:
+ description: "Shopify products with product status, catalog metadata, and a compact variant inventory projection."
+ endpoints:
+ list:
+ method: GET
+ path: "/graphql"
+ description: "List products from the Shopify Admin GraphQL API."
+ params:
+ - name: first
+ type: integer
+ default: 100
+ description: "Page size for Shopify cursor pagination."
+ - name: after
+ type: string
+ description: "Cursor for the next page."
+ response:
+ type: array
+ item: ShopifyProduct
+ response_path: "data.products.nodes"
+ pagination:
+ type: cursor
+ limit_param: first
+ cursor_param: after
+ next_cursor_path: "data.products.pageInfo.endCursor"
+ has_more_field: "data.products.pageInfo.hasNextPage"
+ get:
+ method: GET
+ path: "/graphql"
+ description: "Get one Shopify product by GraphQL ID."
+ params:
+ - name: id
+ type: string
+ required: true
+ positional: true
+ description: "Shopify GraphQL product ID."
+ response:
+ type: object
+ item: ShopifyProduct
+ response_path: "data.product"
+
+ inventory-items:
+ description: "Shopify inventory items with tracked status and available quantities by location."
+ endpoints:
+ list:
+ method: GET
+ path: "/graphql"
+ description: "List inventory items from the Shopify Admin GraphQL API."
+ params:
+ - name: first
+ type: integer
+ default: 100
+ description: "Page size for Shopify cursor pagination."
+ - name: after
+ type: string
+ description: "Cursor for the next page."
+ response:
+ type: array
+ item: ShopifyInventoryItem
+ response_path: "data.inventoryItems.nodes"
+ pagination:
+ type: cursor
+ limit_param: first
+ cursor_param: after
+ next_cursor_path: "data.inventoryItems.pageInfo.endCursor"
+ has_more_field: "data.inventoryItems.pageInfo.hasNextPage"
+ get:
+ method: GET
+ path: "/graphql"
+ description: "Get one Shopify inventory item by GraphQL ID."
+ params:
+ - name: id
+ type: string
+ required: true
+ positional: true
+ description: "Shopify GraphQL inventory item ID."
+ response:
+ type: object
+ item: ShopifyInventoryItem
+ response_path: "data.inventoryItem"
+
+ customers:
+ description: "Shopify customers with lifetime order count, lifetime spend, and contact fields."
+ endpoints:
+ list:
+ method: GET
+ path: "/graphql"
+ description: "List customers from the Shopify Admin GraphQL API."
+ params:
+ - name: first
+ type: integer
+ default: 100
+ description: "Page size for Shopify cursor pagination."
+ - name: after
+ type: string
+ description: "Cursor for the next page."
+ response:
+ type: array
+ item: ShopifyCustomer
+ response_path: "data.customers.nodes"
+ pagination:
+ type: cursor
+ limit_param: first
+ cursor_param: after
+ next_cursor_path: "data.customers.pageInfo.endCursor"
+ has_more_field: "data.customers.pageInfo.hasNextPage"
+ get:
+ method: GET
+ path: "/graphql"
+ description: "Get one Shopify customer by GraphQL ID."
+ params:
+ - name: id
+ type: string
+ required: true
+ positional: true
+ description: "Shopify GraphQL customer ID."
+ response:
+ type: object
+ item: ShopifyCustomer
+ response_path: "data.customer"
+
+ fulfillment-orders:
+ description: "Shopify fulfillment orders for lag, routing, and fulfillment-state analysis."
+ endpoints:
+ list:
+ method: GET
+ path: "/graphql"
+ description: "List fulfillment orders from the Shopify Admin GraphQL API."
+ params:
+ - name: first
+ type: integer
+ default: 100
+ description: "Page size for Shopify cursor pagination."
+ - name: after
+ type: string
+ description: "Cursor for the next page."
+ response:
+ type: array
+ item: ShopifyFulfillmentOrder
+ response_path: "data.fulfillmentOrders.nodes"
+ pagination:
+ type: cursor
+ limit_param: first
+ cursor_param: after
+ next_cursor_path: "data.fulfillmentOrders.pageInfo.endCursor"
+ has_more_field: "data.fulfillmentOrders.pageInfo.hasNextPage"
+ get:
+ method: GET
+ path: "/graphql"
+ description: "Get one Shopify fulfillment order by GraphQL ID."
+ params:
+ - name: id
+ type: string
+ required: true
+ positional: true
+ description: "Shopify GraphQL fulfillment order ID."
+ response:
+ type: object
+ item: ShopifyFulfillmentOrder
+ response_path: "data.fulfillmentOrder"
+
+types:
+ ShopifyOrder:
+ fields:
+ - name: id
+ type: string
+ - name: name
+ type: string
+ - name: createdAt
+ type: string
+ - name: processedAt
+ type: string
+ - name: displayFinancialStatus
+ type: string
+ - name: displayFulfillmentStatus
+ type: string
+ - name: currencyCode
+ type: string
+ - name: totalPriceSet
+ type: object
+ selection: "{ shopMoney { amount currencyCode } }"
+ - name: totalRefundedSet
+ type: object
+ selection: "{ shopMoney { amount currencyCode } }"
+
+ ShopifyProduct:
+ fields:
+ - name: id
+ type: string
+ - name: title
+ type: string
+ - name: handle
+ type: string
+ - name: vendor
+ type: string
+ - name: productType
+ type: string
+ - name: status
+ type: string
+ - name: updatedAt
+ type: string
+ - name: variants
+ type: object
+ selection: "(first: 50) { nodes { id sku price inventoryItem { id } } }"
+
+ ShopifyInventoryItem:
+ fields:
+ - name: id
+ type: string
+ - name: sku
+ type: string
+ - name: tracked
+ type: boolean
+ - name: updatedAt
+ type: string
+ - name: inventoryLevels
+ type: object
+ selection: "(first: 50) { nodes { id location { id name } quantities(names: [\"available\"]) { name quantity updatedAt } updatedAt } }"
+
+ ShopifyCustomer:
+ fields:
+ - name: id
+ type: string
+ - name: email
+ type: string
+ - name: firstName
+ type: string
+ - name: lastName
+ type: string
+ - name: numberOfOrders
+ type: integer
+ - name: amountSpent
+ type: object
+ selection: "{ amount currencyCode }"
+ - name: createdAt
+ type: string
+ - name: updatedAt
+ type: string
+
+ ShopifyFulfillmentOrder:
+ fields:
+ - name: id
+ type: string
+ - name: status
+ type: string
+ - name: requestStatus
+ type: string
+ - name: assignedLocation
+ type: object
+ selection: "{ name location { id name } }"
+ - name: fulfillAt
+ type: string
+ - name: createdAt
+ type: string
+ - name: updatedAt
+ type: string
+ - name: order
+ type: object
+ selection: "{ id name }"
+
+extra_commands:
+ - name: bulk-operations
+ description: "Run, poll, and inspect Shopify Admin GraphQL bulk operations."
diff --git a/internal/generator/cursor_param_test.go b/internal/generator/cursor_param_test.go
index 14023490..7f8b2711 100644
--- a/internal/generator/cursor_param_test.go
+++ b/internal/generator/cursor_param_test.go
@@ -88,6 +88,10 @@ func TestGoTypeForParamCursorOverride(t *testing.T) {
{"page_size int stays int", "page_size", "int", "int"},
{"threshold float stays float", "threshold", "float", "float64"},
{"user_id int becomes string via isIDParam", "user_id", "int", "string"},
+ {"integer alias maps to int", "first", "integer", "int"},
+ {"boolean alias maps to bool", "published", "boolean", "bool"},
+ {"number alias maps to float64", "cost", "number", "float64"},
+ {"integer cursor alias still becomes string", "page", "integer", "string"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -97,6 +101,26 @@ func TestGoTypeForParamCursorOverride(t *testing.T) {
}
}
+func TestCobraFlagFuncAcceptsSpecScalarAliases(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "IntVar", cobraFlagFunc("integer"))
+ assert.Equal(t, "BoolVar", cobraFlagFunc("boolean"))
+ assert.Equal(t, "Float64Var", cobraFlagFunc("number"))
+ assert.Equal(t, "StringVar", cobraFlagFuncForParam("cursor", "integer"))
+}
+
+func TestDefaultAndZeroValuesAcceptSpecScalarAliases(t *testing.T) {
+ t.Parallel()
+
+ assert.Equal(t, "25", defaultVal(spec.Param{Name: "first", Type: "integer", Default: 25}))
+ assert.Equal(t, "true", defaultVal(spec.Param{Name: "published", Type: "boolean", Default: true}))
+ assert.Equal(t, "1.500000", defaultVal(spec.Param{Name: "cost", Type: "number", Default: 1.5}))
+ assert.Equal(t, "0", zeroVal("integer"))
+ assert.Equal(t, "false", zeroVal("boolean"))
+ assert.Equal(t, "0.0", zeroVal("number"))
+}
+
func TestDefaultValForParamCursorOverride(t *testing.T) {
t.Parallel()
tests := []struct {
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 85621e81..4832dcd5 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -188,10 +188,11 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"defaultValForParam": defaultValForParam,
"zeroVal": zeroVal,
"zeroValForParam": func(name, t string) string {
- if isIDParam(name) && t == "int" {
+ kind := primitiveKind(t)
+ if isIDParam(name) && kind == "int" {
return `""`
}
- if isCursorParam(name) && (t == "int" || t == "float") {
+ if isCursorParam(name) && (kind == "int" || kind == "float") {
return `""`
}
return zeroVal(t)
@@ -2280,8 +2281,27 @@ func isCursorParam(name string) bool {
return false
}
+func primitiveKind(t string) string {
+ switch strings.ToLower(strings.TrimSpace(t)) {
+ case "string":
+ return "string"
+ case "integer", "int":
+ return "int"
+ case "boolean", "bool":
+ return "bool"
+ case "number", "float":
+ return "float"
+ case "object":
+ return "object"
+ case "array":
+ return "array"
+ default:
+ return "string"
+ }
+}
+
func goType(t string) string {
- switch t {
+ switch primitiveKind(t) {
case "string":
return "string"
case "int":
@@ -2299,7 +2319,7 @@ func goType(t string) string {
// Unlike goType (used for CLI flags which are always primitives),
// this maps object/array types to json.RawMessage for type fidelity.
func goStructType(t string) string {
- switch t {
+ switch primitiveKind(t) {
case "object", "array":
return "json.RawMessage"
default:
@@ -2389,7 +2409,7 @@ func storeBackfillDecl(col ColumnDef) string {
}
func cobraFlagFunc(t string) string {
- switch t {
+ switch primitiveKind(t) {
case "string":
return "StringVar"
case "int":
@@ -2408,10 +2428,11 @@ func cobraFlagFunc(t string) string {
// numeric→string for pagination cursors so they survive scientific-notation
// rendering of large Unix timestamps and millisecond cursors.
func goTypeForParam(name, t string) string {
- if isIDParam(name) && t == "int" {
+ kind := primitiveKind(t)
+ if isIDParam(name) && kind == "int" {
return "string"
}
- if isCursorParam(name) && (t == "int" || t == "float") {
+ if isCursorParam(name) && (kind == "int" || kind == "float") {
return "string"
}
return goType(t)
@@ -2420,10 +2441,11 @@ func goTypeForParam(name, t string) string {
// cobraFlagFuncForParam returns the cobra flag function, overriding IntVar→StringVar
// for ID-like parameters and Float64Var/IntVar→StringVar for pagination cursors.
func cobraFlagFuncForParam(name, t string) string {
- if isIDParam(name) && t == "int" {
+ kind := primitiveKind(t)
+ if isIDParam(name) && kind == "int" {
return "StringVar"
}
- if isCursorParam(name) && (t == "int" || t == "float") {
+ if isCursorParam(name) && (kind == "int" || kind == "float") {
return "StringVar"
}
return cobraFlagFunc(t)
@@ -2433,13 +2455,14 @@ func cobraFlagFuncForParam(name, t string) string {
// overriding int→string for ID-like parameters and numeric→string for
// pagination cursors so the StringVar default matches the StringVar field type.
func defaultValForParam(p spec.Param) string {
- if isIDParam(p.Name) && p.Type == "int" {
+ kind := primitiveKind(p.Type)
+ if isIDParam(p.Name) && kind == "int" {
if p.Default != nil {
return fmt.Sprintf("%q", fmt.Sprintf("%v", p.Default))
}
return `""`
}
- if isCursorParam(p.Name) && (p.Type == "int" || p.Type == "float") {
+ if isCursorParam(p.Name) && (kind == "int" || kind == "float") {
if p.Default != nil {
return fmt.Sprintf("%q", fmt.Sprintf("%v", p.Default))
}
@@ -2601,7 +2624,7 @@ func hasTemporalMarker(s string) bool {
func defaultVal(p spec.Param) string {
if p.Default != nil {
// Coerce the default value to match the declared param type
- switch p.Type {
+ switch primitiveKind(p.Type) {
case "string":
return fmt.Sprintf("%q", fmt.Sprintf("%v", p.Default))
case "bool":
@@ -2642,7 +2665,7 @@ func defaultVal(p spec.Param) string {
}
func zeroVal(t string) string {
- switch t {
+ switch primitiveKind(t) {
case "string":
return `""`
case "int":
@@ -3152,7 +3175,15 @@ func graphqlFieldSelection(typeName string, types map[string]spec.TypeDef) []str
}
var fields []string
for _, f := range td.Fields {
- fields = append(fields, f.Name)
+ name := strings.TrimSpace(f.Name)
+ if name == "" {
+ continue
+ }
+ if selection := strings.TrimSpace(f.Selection); selection != "" {
+ fields = append(fields, name+" "+selection)
+ continue
+ }
+ fields = append(fields, name)
}
if len(fields) == 0 {
return []string{"id"}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 4e99d85d..fd2b6fbe 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1288,7 +1288,7 @@ func TestGenerateWithOwnerField(t *testing.T) {
assert.Contains(t, string(mainGo), "testowner")
readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
require.NoError(t, err)
- assert.Contains(t, string(readme), "go install github.com/mvanhorn/printing-press-library/library/other/owned-pp-cli/cmd/owned-pp-cli@latest")
+ assert.Contains(t, string(readme), "go install github.com/mvanhorn/printing-press-library/library/other/owned/cmd/owned-pp-cli@latest")
}
func TestGenerateWithEmptyOwner(t *testing.T) {
@@ -4539,6 +4539,26 @@ func TestGenerateGraphQLCompiles(t *testing.T) {
runGoCommand(t, outputDir, "build", "./...")
}
+func TestGraphQLFieldSelectionSupportsNestedSelections(t *testing.T) {
+ t.Parallel()
+
+ got := graphqlFieldSelection("Order", map[string]spec.TypeDef{
+ "Order": {
+ Fields: []spec.TypeField{
+ {Name: "id", Type: "string"},
+ {Name: "totalPriceSet", Type: "object", Selection: "{ shopMoney { amount currencyCode } }"},
+ {Name: "customer", Type: "object", Selection: "{ id email }"},
+ },
+ },
+ })
+
+ assert.Equal(t, []string{
+ "id",
+ "totalPriceSet { shopMoney { amount currencyCode } }",
+ "customer { id email }",
+ }, got)
+}
+
// TestGenerateGraphQLEndpointPathRendersTemplatedURL guards PR-1's contract:
// when a GraphQL spec sets BaseURL to a templated host and GraphQLEndpointPath
// to a templated path (the Shopify shape), the generated client must build
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index cab0d4b8..cd0c5192 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -1,5 +1,6 @@
// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+{{- $isGraphQLEndpoint := and (isGraphQL .APISpec) (eq .Endpoint.Path "/graphql") (or (eq .EndpointName "list") (eq .EndpointName "get"))}}
package cli
@@ -16,7 +17,10 @@ import (
{{- if .IsAsync}}
"time"
{{- end}}
+{{ if $isGraphQLEndpoint}}
+ "{{modulePath}}/internal/client"
+{{- end}}
"github.com/spf13/cobra"
)
@@ -131,6 +135,9 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
path = replacePathParam(path, "{{.Name}}", fmt.Sprintf("%v", flag{{camel (paramIdent .)}}))
{{- end}}
{{- end}}
+{{- if $isGraphQLEndpoint}}
+ _ = path
+{{- end}}
{{- if .Endpoint.UsesHTMLResponse}}
htmlRequestParams := map[string]string{}
@@ -155,7 +162,47 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- if or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD")}}
+{{- if and $isGraphQLEndpoint (eq .EndpointName "list")}}
+ variables := map[string]any{
+ "first": flagFirst,
+ }
+ if flagAfter != "" {
+ variables["after"] = flagAfter
+ }
{{- if .Endpoint.Pagination}}
+ var data json.RawMessage
+ if flagAll && !flags.dryRun {
+ var items []json.RawMessage
+ items, err = c.PaginatedQuery(client.{{pascal .ResourceName}}ListQuery, variables, "{{graphqlQueryField .Endpoint.ResponsePath}}", flagFirst)
+ if err == nil {
+ data, err = json.Marshal(items)
+ }
+ } else {
+ data, err = c.Query(client.{{pascal .ResourceName}}ListQuery, variables)
+ if err == nil && !flags.dryRun {
+ data, err = extractGraphQLConnection(data, "{{graphqlQueryField .Endpoint.ResponsePath}}")
+ }
+ }
+{{- else}}
+ data, err := c.Query(client.{{pascal .ResourceName}}ListQuery, variables)
+ if err == nil && !flags.dryRun {
+ data, err = extractGraphQLConnection(data, "{{graphqlQueryField .Endpoint.ResponsePath}}")
+ }
+{{- end}}
+{{- if .HasStore}}
+ prov := attachFreshness(DataProvenance{Source: "live"}, flags)
+{{- end}}
+{{- else if and $isGraphQLEndpoint (eq .EndpointName "get")}}
+ data, err := c.Query(client.{{pascal .ResourceName}}GetQuery, map[string]any{
+ "id": args[0],
+ })
+ if err == nil && !flags.dryRun {
+ data, err = extractGraphQLObject(data, "{{graphqlQueryField .Endpoint.ResponsePath}}")
+ }
+{{- if .HasStore}}
+ prov := attachFreshness(DataProvenance{Source: "live"}, flags)
+{{- end}}
+{{- else if .Endpoint.Pagination}}
{{- if .HasStore}}
data, prov, err := resolvePaginatedRead(cmd.Context(), c, flags, "{{lower .ResourceName}}", path, map[string]string{
{{- range $i, $p := .Endpoint.Params}}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 68decd3e..903a6efc 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -233,6 +233,38 @@ func isSyncAccessWarning(err error) (*accessWarning, bool) {
func isSyncAccessWarning(err error) (*accessWarning, bool) { return nil, false }
{{- end}}
+{{- if isGraphQL .APISpec}}
+func extractGraphQLConnection(data json.RawMessage, field string) (json.RawMessage, error) {
+ var root map[string]json.RawMessage
+ if err := json.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("parsing graphql data: %w", err)
+ }
+ connRaw, ok := root[field]
+ if !ok {
+ return nil, fmt.Errorf("graphql response missing %q", field)
+ }
+ var conn struct {
+ Nodes []json.RawMessage `json:"nodes"`
+ }
+ if err := json.Unmarshal(connRaw, &conn); err != nil {
+ return nil, fmt.Errorf("parsing graphql connection %q: %w", field, err)
+ }
+ return json.Marshal(conn.Nodes)
+}
+
+func extractGraphQLObject(data json.RawMessage, field string) (json.RawMessage, error) {
+ var root map[string]json.RawMessage
+ if err := json.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("parsing graphql data: %w", err)
+ }
+ obj, ok := root[field]
+ if !ok {
+ return nil, fmt.Errorf("graphql response missing %q", field)
+ }
+ return obj, nil
+}
+{{- end}}
+
// classifyAPIError maps API errors to structured exit codes with actionable hints.
func classifyAPIError(err error) error {
msg := err.Error()
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index abcdda4b..deb97863 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -20,7 +20,7 @@ Learn more at [{{.ProseName}}]({{.WebsiteURL}}).
### Go
```
-go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}-pp-cli/cmd/{{.Name}}-pp-cli@latest
+go install github.com/mvanhorn/printing-press-library/library/{{if .Category}}{{.Category}}{{else}}other{{end}}/{{.Name}}/cmd/{{.Name}}-pp-cli@latest
```
### Binary
@@ -54,6 +54,20 @@ See [Install](#install) above.
### 2. Set Up Credentials
+{{- if .GraphQLEndpointPath}}
+This CLI talks to the {{.ProseName}} GraphQL API at `{{.BaseURL}}{{.GraphQLEndpointPath}}`.
+{{- end}}
+{{- if .EndpointTemplateVars}}
+
+Set the endpoint variables for the tenant, workspace, or API version you want this CLI to use:
+
+```bash
+{{- range .EndpointTemplateVars}}
+export {{envName $.Name}}_{{upper .}}="{{if eq . "shop"}}<your-store>.myshopify.com{{else if eq . "api_version"}}{{$.Version}}{{else}}<{{.}}>{{end}}"
+{{- end}}
+```
+{{- end}}
+
Get your API key from your API provider's developer portal. The key typically looks like a long alphanumeric string.
{{- if .Auth.EnvVars}}
@@ -252,6 +266,23 @@ Covered command paths:
JSON outputs that use the generated provenance envelope include freshness metadata at `meta.freshness`. This metadata describes the freshness decision for the covered command path; it does not claim full historical backfill or API-specific enrichment.
{{- end}}
+{{- if .EndpointTemplateVars}}
+
+## Runtime Endpoint
+
+This CLI resolves endpoint placeholders at runtime, so one installed binary can target different tenants or API versions without regeneration.
+
+Endpoint environment variables:
+{{- range .EndpointTemplateVars}}
+- `{{envName $.Name}}_{{upper .}}` resolves `{ {{- . -}} }`
+{{- end}}
+
+Base URL: `{{.BaseURL}}`
+{{- if .GraphQLEndpointPath}}
+
+GraphQL path: `{{.GraphQLEndpointPath}}`
+{{- end}}
+{{- end}}
## Use as MCP Server
@@ -261,7 +292,7 @@ This CLI ships a companion MCP server for use with Claude Desktop, Cursor, and o
{{- if and (eq .Auth.Type "api_key") .Auth.EnvVars}}
```bash
-claude mcp add {{.Name}} {{.Name}}-pp-mcp -e {{index .Auth.EnvVars 0}}=<your-key>
+claude mcp add {{.Name}} {{.Name}}-pp-mcp{{range .EndpointTemplateVars}} -e {{envName $.Name}}_{{upper .}}={{if eq . "shop"}}<your-store>.myshopify.com{{else if eq . "api_version"}}{{$.Version}}{{else}}<{{.}}>{{end}}{{end}} -e {{index .Auth.EnvVars 0}}=<your-key>
```
{{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
@@ -309,6 +340,9 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_
"{{.Name}}": {
"command": "{{.Name}}-pp-mcp"{{- if and .Auth.EnvVars (ne .Auth.Type "none") (ne .Auth.Type "cookie") (ne .Auth.Type "composed")}},
"env": {
+{{- range .EndpointTemplateVars}}
+ "{{envName $.Name}}_{{upper .}}": "{{if eq . "shop"}}<your-store>.myshopify.com{{else if eq . "api_version"}}{{$.Version}}{{else}}<{{.}}>{{end}}",
+{{- end}}
"{{index .Auth.EnvVars 0}}": "<your-key>"
}{{- end}}
}
@@ -330,6 +364,9 @@ Config file: `{{.Config.Path}}`
{{- if .Auth.EnvVars}}
Environment variables:
+{{- range .EndpointTemplateVars}}
+- `{{envName $.Name}}_{{upper .}}`
+{{- end}}
{{- range .Auth.EnvVars}}
- `{{.}}`
{{- end}}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 23ec7ec2..349422a2 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -46,22 +46,23 @@ type CLIManifest struct {
// Owner is the attribution recorded in generated copyright headers
// (for example "hiten-shah"). Persisted here so subsequent regens
// preserve attribution regardless of who's running the generator.
- Owner string `json:"owner,omitempty"`
- SpecURL string `json:"spec_url,omitempty"`
- SpecPath string `json:"spec_path,omitempty"`
- SpecFormat string `json:"spec_format,omitempty"`
- SpecChecksum string `json:"spec_checksum,omitempty"`
- RunID string `json:"run_id,omitempty"`
- CatalogEntry string `json:"catalog_entry,omitempty"`
- Category string `json:"category,omitempty"`
- Description string `json:"description,omitempty"`
- MCPBinary string `json:"mcp_binary,omitempty"`
- MCPToolCount int `json:"mcp_tool_count,omitempty"`
- MCPPublicToolCount int `json:"mcp_public_tool_count,omitempty"`
- MCPReady string `json:"mcp_ready,omitempty"`
- APIVersion string `json:"api_version,omitempty"` // from the spec's info.version — provenance only, not the CLI version
- AuthType string `json:"auth_type,omitempty"`
- AuthEnvVars []string `json:"auth_env_vars,omitempty"`
+ Owner string `json:"owner,omitempty"`
+ SpecURL string `json:"spec_url,omitempty"`
+ SpecPath string `json:"spec_path,omitempty"`
+ SpecFormat string `json:"spec_format,omitempty"`
+ SpecChecksum string `json:"spec_checksum,omitempty"`
+ RunID string `json:"run_id,omitempty"`
+ CatalogEntry string `json:"catalog_entry,omitempty"`
+ Category string `json:"category,omitempty"`
+ Description string `json:"description,omitempty"`
+ MCPBinary string `json:"mcp_binary,omitempty"`
+ MCPToolCount int `json:"mcp_tool_count,omitempty"`
+ MCPPublicToolCount int `json:"mcp_public_tool_count,omitempty"`
+ MCPReady string `json:"mcp_ready,omitempty"`
+ APIVersion string `json:"api_version,omitempty"` // from the spec's info.version — provenance only, not the CLI version
+ AuthType string `json:"auth_type,omitempty"`
+ AuthEnvVars []string `json:"auth_env_vars,omitempty"`
+ EndpointTemplateVars []string `json:"endpoint_template_vars,omitempty"`
// AuthKeyURL is the page where users register for an API key. Used by
// downstream emitters (MCPB manifest user_config descriptions, doctor
// hints) to point users at the right credential source.
@@ -247,6 +248,7 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
m.MCPReady = computeMCPReady(parsed.Auth.Type)
m.AuthType = parsed.Auth.Type
m.AuthEnvVars = parsed.Auth.EnvVars
+ m.EndpointTemplateVars = parsed.EndpointTemplateVars
m.AuthKeyURL = parsed.Auth.KeyURL
m.AuthOptional = parsed.Auth.Optional
// Always refresh from parsed; an empty-check here would let a
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index b305ae83..62e0ae48 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -641,6 +641,40 @@ func TestWriteMCPBManifest(t *testing.T) {
assert.Contains(t, key.Description, "https://dashboard.stripe.com/apikeys")
})
+ t.Run("endpoint template vars emit required user_config fields", func(t *testing.T) {
+ dir := t.TempDir()
+ writeManifest(t, dir, CLIManifest{
+ APIName: "shopify",
+ DisplayName: "Shopify",
+ MCPBinary: "shopify-pp-mcp",
+ MCPReady: "full",
+ APIVersion: "2026-04",
+ AuthType: "api_key",
+ AuthEnvVars: []string{"SHOPIFY_ACCESS_TOKEN"},
+ EndpointTemplateVars: []string{"shop", "api_version"},
+ })
+
+ require.NoError(t, WriteMCPBManifest(dir))
+ got := readMCPBManifest(t, dir)
+
+ assert.Equal(t, "${user_config.shopify_shop}", got.Server.MCPConfig.Env["SHOPIFY_SHOP"])
+ assert.Equal(t, "${user_config.shopify_api_version}", got.Server.MCPConfig.Env["SHOPIFY_API_VERSION"])
+
+ shop, ok := got.UserConfig["shopify_shop"]
+ require.True(t, ok)
+ assert.Equal(t, "SHOPIFY_SHOP", shop.Title)
+ assert.True(t, shop.Required)
+ assert.False(t, shop.Sensitive)
+ assert.Contains(t, shop.Description, "{shop}")
+
+ apiVersion, ok := got.UserConfig["shopify_api_version"]
+ require.True(t, ok)
+ assert.Equal(t, "SHOPIFY_API_VERSION", apiVersion.Title)
+ assert.True(t, apiVersion.Required)
+ assert.Equal(t, "2026-04", apiVersion.Default)
+ assert.Contains(t, apiVersion.Description, "{api_version}")
+ })
+
t.Run("composed auth emits optional user_config fields", func(t *testing.T) {
dir := t.TempDir()
writeManifest(t, dir, CLIManifest{
diff --git a/internal/pipeline/mcpb_manifest.go b/internal/pipeline/mcpb_manifest.go
index bf28c412..d28ccccc 100644
--- a/internal/pipeline/mcpb_manifest.go
+++ b/internal/pipeline/mcpb_manifest.go
@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
+ "github.com/mvanhorn/cli-printing-press/v3/internal/naming"
"github.com/mvanhorn/cli-printing-press/v3/internal/version"
)
@@ -278,23 +279,29 @@ func loadExistingMCPBManifest(dir string) *existingMCPBManifest {
// the value at runtime from what the user typed (or whatever the keychain
// has cached). Empty list returns nil so the manifest stays compact.
func buildMCPBEnv(m CLIManifest) map[string]string {
- if len(m.AuthEnvVars) == 0 {
+ if len(m.AuthEnvVars) == 0 && len(m.EndpointTemplateVars) == 0 {
return nil
}
- env := make(map[string]string, len(m.AuthEnvVars))
+ env := make(map[string]string, len(m.AuthEnvVars)+len(m.EndpointTemplateVars))
for _, name := range m.AuthEnvVars {
env[name] = "${user_config." + userConfigKey(name) + "}"
}
+ for _, templateVar := range m.EndpointTemplateVars {
+ name := endpointTemplateEnvVar(m.APIName, templateVar)
+ env[name] = "${user_config." + userConfigKey(name) + "}"
+ }
return env
}
-// buildMCPBUserConfig translates each declared auth env var into a
-// user_config entry. Required-ness depends on auth type: composed/cookie
-// flows mean some tools work unauthenticated, so we keep the field optional
-// and let the user skip it; api_key/bearer_token mean the API needs the
-// credential to do anything useful, so we mark required.
+// buildMCPBUserConfig translates each declared auth env var and endpoint
+// template var into a user_config entry. Required-ness for auth depends on
+// auth type: composed/cookie flows mean some tools work unauthenticated, so
+// we keep the field optional and let the user skip it; api_key/bearer_token
+// mean the API needs the credential to do anything useful, so we mark
+// required. Endpoint template vars are always required because unresolved
+// placeholders make every request URL invalid.
func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
- if len(m.AuthEnvVars) == 0 {
+ if len(m.AuthEnvVars) == 0 && len(m.EndpointTemplateVars) == 0 {
return nil
}
// AuthOptional overrides the auth-type heuristic. api_key would otherwise
@@ -303,7 +310,7 @@ func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
// every other tool works without the key. The spec author's `optional:
// true` is the explicit signal.
required := authRequiresCredential(m.AuthType) && !m.AuthOptional
- vars := make(map[string]MCPBVar, len(m.AuthEnvVars))
+ vars := make(map[string]MCPBVar, len(m.AuthEnvVars)+len(m.EndpointTemplateVars))
for _, name := range m.AuthEnvVars {
vars[userConfigKey(name)] = MCPBVar{
Type: mcpbVarTypeString,
@@ -313,15 +320,40 @@ func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
Required: required,
}
}
+ for _, templateVar := range m.EndpointTemplateVars {
+ name := endpointTemplateEnvVar(m.APIName, templateVar)
+ vars[userConfigKey(name)] = MCPBVar{
+ Type: mcpbVarTypeString,
+ Title: name,
+ Description: endpointTemplateVarDescription(templateVar, name),
+ Required: true,
+ Default: endpointTemplateDefault(m, templateVar),
+ }
+ }
return vars
}
+func endpointTemplateEnvVar(apiName, templateVar string) string {
+ return strings.ToUpper(naming.Snake(apiName) + "_" + naming.Snake(templateVar))
+}
+
// userConfigKey lowercases the env var so manifest user_config keys match
// the `${user_config.foo_bar}` substitution syntax in mcp_config.env.
func userConfigKey(envVar string) string {
return strings.ToLower(envVar)
}
+func endpointTemplateVarDescription(templateVar, envVar string) string {
+ return fmt.Sprintf("Sets %s for the endpoint template variable {%s}.", envVar, templateVar)
+}
+
+func endpointTemplateDefault(m CLIManifest, templateVar string) string {
+ if strings.EqualFold(templateVar, "api_version") {
+ return m.APIVersion
+ }
+ return ""
+}
+
// envVarDescription is the help text under each user_config field. The
// registration URL (when we have one) is what makes the difference between
// "fill this in" and "I don't know where to get this value."
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 2afb9b6c..253203d5 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -656,6 +656,11 @@ type TypeDef struct {
type TypeField struct {
Name string `yaml:"name" json:"name"`
Type string `yaml:"type" json:"type"`
+ // Selection is an optional GraphQL sub-selection rendered when this field
+ // is used in a generated GraphQL query. It lets wrapper specs keep the Go
+ // field simple (for example, totalPriceSet as json.RawMessage) while still
+ // issuing valid nested GraphQL selections.
+ Selection string `yaml:"selection,omitempty" json:"selection,omitempty"`
}
func Parse(path string) (*APISpec, error) {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
index cf82d878..48ef7c8d 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
@@ -7,7 +7,7 @@ Purpose-built fixture for golden generation coverage.
### Go
```
-go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden-pp-cli/cmd/printing-press-golden-pp-cli@latest
+go install github.com/mvanhorn/printing-press-library/library/other/printing-press-golden/cmd/printing-press-golden-pp-cli@latest
```
### Binary
← 691ee25a chore(main): release 3.3.0 (#469)
·
back to Cli Printing Press
·
fix(cli): scorer accuracy — insight prefix, MCP dir selectio 9f265510 →