← back to Cli Printing Press
fix(cli): consume x-tenant-env-var so per-tenant sync paths ship populated (#1368)
51b758c713f3c4252697d38d8355a9f3bb4b81f2 · 2026-05-13 22:15:37 -0700 · Trevin Chow
* fix(cli): consume x-tenant-env-var so per-tenant sync paths ship populated
ServiceTitan-shape APIs (every path is /tenant/{tenant}/<resource>) ship a
broken sync surface because the generator drops paths with non-{id}
placeholders into parameterized-only territory: defaultSyncResources()
returns an empty slice, syncResourcePath()'s map is empty, sync silently
no-ops, and every offline transcendence command depending on synced data
fails. Each ServiceTitan module printed today required hand-patching both
stubs (#1305 cross-CLI tally: 4 confirmed hits + ~22 sibling specs sharing
the shape).
Wire the info.x-tenant-env-var OpenAPI extension into the existing
EndpointTemplateVars + buildURL mechanism rather than adding a parallel
tenant-only codepath:
- Parser registers "tenant" as an EndpointTemplateVar and records the
declared env-var name (e.g. ST_TENANT_ID) in a new
APISpec.EndpointTemplateEnvOverrides map so downstream emitters use the
spec-declared name instead of the conventional <APINAME>_<PLACEHOLDER>.
- Profiler treats paths whose only {placeholder}s are EndpointTemplateVars
as standalone-listable, so tenant-scoped paths surface as flat
SyncableResources. Mixed paths like /tenant/{tenant}/channels/{channel_id}
stay parameterized.
- config.go.tmpl, url.go.tmpl, readme.md.tmpl route env-var name
resolution through a new endpointTemplateEnvName template helper that
consults the override map.
- sync.go.tmpl filters EndpointTemplateVars placeholders out of the
unresolved-key warning so {tenant} paths don't get skipped as
"requires parent context". Non-template-var specs render byte-identical
output so the golden harness stays clean.
- CLIManifest plumbs EndpointTemplateEnvOverrides through publish and
refresh so MCPB user_config / env names use the override; the new
endpointTemplateEnvVar(m, ...) signature delegates the default-name
branch to spec.DefaultEndpointTemplateEnvName (single source of truth
for the <APINAME>_<PLACEHOLDER> rule, which the prior local copy
silently disagreed with on hyphenated API names like servicetitan-crm).
The new extension is documented under docs/SPEC-EXTENSIONS.md.
Closes #1305
Refs #1332
* fix(cli): trim env-var values before the empty-string guard in template-var load
Whitespace-only env vars (e.g. SHOPIFY_SHOP=" ") passed the v != "" guard
and got stored in TemplateVars verbatim. buildURL then substituted the
empty segment after the runtime TrimSpace, producing malformed paths like
/tenant//customers. Move TrimSpace to wrap os.Getenv so the guard rejects
the whitespace-only case before the assignment, matching the
buildURL-side "empty is unset" invariant from
TestBuildURLEmptyVarValueIsTreatedAsUnset.
Also drop ticket numbers from new test docstrings per AGENTS.md's "no
dates, incidents, or ticket numbers in code comments" rule. The intent
of the pin is clear from the function name; the PR description carries
the issue link.
Files touched
M docs/SPEC-EXTENSIONS.mdM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/config.go.tmplM internal/generator/templates/readme.md.tmplM internal/generator/templates/sync.go.tmplM internal/generator/templates/url.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/climanifest.goM internal/pipeline/mcpb_manifest.goM internal/pipeline/publish.goM internal/profiler/profiler.goM internal/profiler/profiler_test.goM internal/spec/spec.go
Diff
commit 51b758c713f3c4252697d38d8355a9f3bb4b81f2
Author: Trevin Chow <trevin@trevinchow.com>
Date: Wed May 13 22:15:37 2026 -0700
fix(cli): consume x-tenant-env-var so per-tenant sync paths ship populated (#1368)
* fix(cli): consume x-tenant-env-var so per-tenant sync paths ship populated
ServiceTitan-shape APIs (every path is /tenant/{tenant}/<resource>) ship a
broken sync surface because the generator drops paths with non-{id}
placeholders into parameterized-only territory: defaultSyncResources()
returns an empty slice, syncResourcePath()'s map is empty, sync silently
no-ops, and every offline transcendence command depending on synced data
fails. Each ServiceTitan module printed today required hand-patching both
stubs (#1305 cross-CLI tally: 4 confirmed hits + ~22 sibling specs sharing
the shape).
Wire the info.x-tenant-env-var OpenAPI extension into the existing
EndpointTemplateVars + buildURL mechanism rather than adding a parallel
tenant-only codepath:
- Parser registers "tenant" as an EndpointTemplateVar and records the
declared env-var name (e.g. ST_TENANT_ID) in a new
APISpec.EndpointTemplateEnvOverrides map so downstream emitters use the
spec-declared name instead of the conventional <APINAME>_<PLACEHOLDER>.
- Profiler treats paths whose only {placeholder}s are EndpointTemplateVars
as standalone-listable, so tenant-scoped paths surface as flat
SyncableResources. Mixed paths like /tenant/{tenant}/channels/{channel_id}
stay parameterized.
- config.go.tmpl, url.go.tmpl, readme.md.tmpl route env-var name
resolution through a new endpointTemplateEnvName template helper that
consults the override map.
- sync.go.tmpl filters EndpointTemplateVars placeholders out of the
unresolved-key warning so {tenant} paths don't get skipped as
"requires parent context". Non-template-var specs render byte-identical
output so the golden harness stays clean.
- CLIManifest plumbs EndpointTemplateEnvOverrides through publish and
refresh so MCPB user_config / env names use the override; the new
endpointTemplateEnvVar(m, ...) signature delegates the default-name
branch to spec.DefaultEndpointTemplateEnvName (single source of truth
for the <APINAME>_<PLACEHOLDER> rule, which the prior local copy
silently disagreed with on hyphenated API names like servicetitan-crm).
The new extension is documented under docs/SPEC-EXTENSIONS.md.
Closes #1305
Refs #1332
* fix(cli): trim env-var values before the empty-string guard in template-var load
Whitespace-only env vars (e.g. SHOPIFY_SHOP=" ") passed the v != "" guard
and got stored in TemplateVars verbatim. buildURL then substituted the
empty segment after the runtime TrimSpace, producing malformed paths like
/tenant//customers. Move TrimSpace to wrap os.Getenv so the guard rejects
the whitespace-only case before the assignment, matching the
buildURL-side "empty is unset" invariant from
TestBuildURLEmptyVarValueIsTreatedAsUnset.
Also drop ticket numbers from new test docstrings per AGENTS.md's "no
dates, incidents, or ticket numbers in code comments" rule. The intent
of the pin is clear from the function name; the PR description carries
the issue link.
---
docs/SPEC-EXTENSIONS.md | 45 ++++++++++++
internal/generator/generator.go | 13 +++-
internal/generator/generator_test.go | 79 ++++++++++++++++++++
internal/generator/templates/config.go.tmpl | 2 +-
internal/generator/templates/readme.md.tmpl | 4 +-
internal/generator/templates/sync.go.tmpl | 29 ++++++++
internal/generator/templates/url.go.tmpl | 2 +-
internal/openapi/parser.go | 68 ++++++++++++++----
internal/openapi/parser_test.go | 78 ++++++++++++++++++++
internal/pipeline/climanifest.go | 39 +++++-----
internal/pipeline/mcpb_manifest.go | 14 ++--
internal/pipeline/publish.go | 1 +
internal/profiler/profiler.go | 42 ++++++++++-
internal/profiler/profiler_test.go | 79 ++++++++++++++++++++
internal/spec/spec.go | 108 ++++++++++++++++++++--------
15 files changed, 528 insertions(+), 75 deletions(-)
diff --git a/docs/SPEC-EXTENSIONS.md b/docs/SPEC-EXTENSIONS.md
index 8b348e09..5631ff7b 100644
--- a/docs/SPEC-EXTENSIONS.md
+++ b/docs/SPEC-EXTENSIONS.md
@@ -219,6 +219,51 @@ x-mcp:
endpoint_tools: hidden
```
+### `x-tenant-env-var`
+
+Declares the env-var name that resolves the implicit `{tenant}` path
+placeholder for multi-tenant SaaS APIs whose every path is
+`/tenant/{tenant}/<resource>`. Without this annotation, the generator
+classifies tenant-templated paths as parent-context-dependent and emits an
+empty `defaultSyncResources` / `syncResourcePath` map; sync silently no-ops
+and every downstream offline command ships broken.
+
+Parsed fields: `APISpec.EndpointTemplateVars` (`tenant` added) and
+`APISpec.EndpointTemplateEnvOverrides["tenant"]` (env-var name).
+
+Rules:
+- Optional. Specs without `x-tenant-env-var` keep single-tenant behavior;
+ no `{tenant}`-aware emission, no spurious env reads.
+- Declared under `info` only (path-positional templates are spec-wide).
+- Value must be a non-empty string after `TrimSpace`. Whitespace-only
+ values are treated as absent.
+- The placeholder name is `tenant`. Specs that use a different
+ placeholder (`{workspace}`, `{org}`) should set
+ `EndpointTemplateVars` + `EndpointTemplateEnvOverrides` directly in
+ internal YAML until this extension generalizes.
+
+Effect on generated output (when set):
+- The profiler treats `/.../{tenant}/...` paths as standalone-listable, so
+ the resource becomes a flat `SyncableResource` rather than a
+ `DependentSyncResource`.
+- The emitted `config.go` reads the override env-var name (e.g.
+ `ST_TENANT_ID`) into `Config.TemplateVars["tenant"]` at `Load()` time.
+- The emitted `url.go` `buildURL` substitutes `{tenant}` from
+ `Config.TemplateVars` at request time and names the override env var in
+ the actionable error when the value is missing.
+- The emitted `sync.go` filters `{tenant}` out of the unresolved-key
+ warning so per-tenant paths don't get skipped as "requires parent
+ context".
+
+Example:
+
+```yaml
+info:
+ title: ServiceTitan CRM
+ version: 1.0.0
+ x-tenant-env-var: ST_TENANT_ID
+```
+
## Security Scheme Extensions
Security scheme extensions are read from
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index c21e4575..68d2e38e 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -336,8 +336,17 @@ func New(s *spec.APISpec, outputDir string) *Generator {
// `?limit=N` query param without honoring it; truncating client-
// side means the user-facing --limit flag works regardless.
// Surfaced by hackernews retro #350 finding F6.
- "endpointNeedsClientLimit": endpointNeedsClientLimit,
- "envName": naming.EnvPrefix,
+ "endpointNeedsClientLimit": endpointNeedsClientLimit,
+ "envName": naming.EnvPrefix,
+ // endpointTemplateEnvName resolves the env-var name for a
+ // {placeholder} in EndpointTemplateVars. Returns the spec-declared
+ // override (e.g. ST_TENANT_ID for {tenant}) when one exists, else
+ // the conventional <APINAME>_<UPPER_PLACEHOLDER>. Bound to the
+ // generator's current spec; callers in templates pass just the
+ // placeholder name.
+ "endpointTemplateEnvName": func(placeholder string) string {
+ return s.EndpointTemplateEnvName(placeholder)
+ },
"safeName": safeSQLName,
"resourceIDFieldOverrideEntries": resourceIDFieldOverrideEntries,
"criticalResourceEntries": criticalResourceEntries,
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 9f17e63e..5d8e3a54 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -9661,3 +9661,82 @@ func TestStoreSkipsDeadTablesForResourcesWithoutTypedUpsert(t *testing.T) {
assert.Contains(t, store, "upsertGenericResourceTx(tx, resourceType, id",
"UpsertBatch must still call upsertGenericResourceTx so renamed resources land in `resources`")
}
+
+// TestGenerateEndpointTemplateEnvOverridesWireThrough: when the spec
+// declares an explicit env-var name for a template placeholder (e.g.
+// ST_TENANT_ID for {tenant}), every emitted artifact that touches env-var
+// resolution must use the override instead of the default
+// <APINAME>_<PLACEHOLDER> convention. Sync also needs to surface the
+// template-resolvable path without skipping it as "requires parent context".
+func TestGenerateEndpointTemplateEnvOverridesWireThrough(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "servicetitan-crm",
+ Description: "ServiceTitan CRM (test fixture)",
+ Version: "1.0",
+ BaseURL: "https://api.servicetitan.io",
+ EndpointTemplateVars: []string{"tenant"},
+ EndpointTemplateEnvOverrides: map[string]string{
+ "tenant": "ST_TENANT_ID",
+ },
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "Authorization",
+ EnvVars: []string{"ST_API_TOKEN"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/servicetitan-crm-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "customers": {
+ Description: "Customers",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/tenant/{tenant}/customers",
+ Pagination: &spec.Pagination{CursorParam: "pageToken", LimitParam: "pageSize"},
+ Response: spec.ResponseDef{Type: "array", Item: "Customer"},
+ },
+ },
+ },
+ },
+ Types: map[string]spec.TypeDef{
+ "Customer": {Fields: []spec.TypeField{{Name: "id", Type: "string"}}},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ urlGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "client", "url.go"))
+ require.NoError(t, err)
+ assert.Regexp(t, `"tenant":\s+"ST_TENANT_ID"`, string(urlGo),
+ "templateVarEnvNames must use the spec-declared override, not the SERVICETITAN_CRM_TENANT default")
+ assert.NotContains(t, string(urlGo), "SERVICETITAN_CRM_TENANT",
+ "the default <APINAME>_<PLACEHOLDER> name must not leak when an override is declared")
+
+ configGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ assert.Contains(t, string(configGo), `os.Getenv("ST_TENANT_ID")`,
+ "config Load() must read the override env var name")
+ assert.NotContains(t, string(configGo), `os.Getenv("SERVICETITAN_CRM_TENANT")`,
+ "the default env var name must not appear when an override exists")
+
+ syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ syncSrc := string(syncGo)
+ assert.Contains(t, syncSrc, `"customers": "/tenant/{tenant}/customers"`,
+ "defaultSyncResources/syncResourcePath must include the tenant-scoped path with the placeholder preserved")
+ assert.Contains(t, syncSrc, `endpointTemplateVarSet = map[string]bool`,
+ "sync must declare the template-var set so the unresolved-key check ignores {tenant}")
+ assert.Contains(t, syncSrc, `slices.DeleteFunc`,
+ "sync must filter template-var placeholders out of the missing-keys list before warning")
+
+ readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+ require.NoError(t, err)
+ assert.Contains(t, string(readme), "ST_TENANT_ID",
+ "README must surface the override env var name in the runtime endpoint instructions")
+}
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index aa7e7cd4..46a00d08 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -199,7 +199,7 @@ func Load(configPath string) (*Config, error) {
}
verifyMode := os.Getenv("PRINTING_PRESS_VERIFY") == "1"
{{- range .EndpointTemplateVars}}
- if v := os.Getenv("{{envName $.Name}}_{{upper .}}"); v != "" {
+ if v := strings.TrimSpace(os.Getenv("{{endpointTemplateEnvName .}}")); v != "" {
cfg.TemplateVars["{{.}}"] = v
} else if verifyMode {
cfg.TemplateVars["{{.}}"] = "{{.}}_placeholder"
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index fdfbf688..c21c5e10 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -113,7 +113,7 @@ Set the endpoint variables for the tenant, workspace, or API version you want th
```bash
{{- range .EndpointTemplateVars}}
-export {{envName $.Name}}_{{upper .}}="{{if eq . "shop"}}<your-store>.myshopify.com{{else if eq . "api_version"}}{{$.Version}}{{else}}<{{.}}>{{end}}"
+export {{endpointTemplateEnvName .}}="{{if eq . "shop"}}<your-store>.myshopify.com{{else if eq . "api_version"}}{{$.Version}}{{else}}<{{.}}>{{end}}"
{{- end}}
```
{{- end}}
@@ -333,7 +333,7 @@ This CLI resolves endpoint placeholders at runtime, so one installed binary can
Endpoint environment variables:
{{- range .EndpointTemplateVars}}
-- `{{envName $.Name}}_{{upper .}}` resolves `{ {{- . -}} }`
+- `{{endpointTemplateEnvName .}}` resolves `{ {{- . -}} }`
{{- end}}
Base URL: `{{.BaseURL}}`
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index ce917d5b..5b07104a 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -9,6 +9,9 @@ import (
"net/url"
"os"
"regexp"
+{{- if .EndpointTemplateVars}}
+ "slices"
+{{- end}}
"strconv"
"strings"
"sync"
@@ -31,6 +34,19 @@ import (
// sync still completes for resources that DO have resolvable paths.
var unresolvedPathKeyRE = regexp.MustCompile(`\{[a-zA-Z_][a-zA-Z0-9_]*\}`)
+{{- if .EndpointTemplateVars}}
+
+// endpointTemplateVarSet enumerates the {placeholder}s the printed CLI's
+// buildURL helper will substitute from env-backed Config.TemplateVars. The
+// unresolved-key sync check filters these out so per-tenant paths like
+// /tenant/{tenant}/<resource> don't trip the "requires parent context" skip.
+var endpointTemplateVarSet = map[string]bool{
+{{- range .EndpointTemplateVars}}
+ "{{.}}": true,
+{{- end}}
+}
+{{- end}}
+
// syncResult holds the outcome of syncing a single resource.
type syncResult struct {
Resource string
@@ -371,7 +387,20 @@ func syncResource(c interface {
// sync cannot fill. Emit a sync_warning describing the missing keys and
// continue — sync exits 0 if any resource succeeded, so this keeps
// hierarchical-API CLIs functional for the resources they CAN sync flat.
+{{- if .EndpointTemplateVars}}
+ //
+ // EndpointTemplateVars placeholders (e.g. {tenant} in per-tenant SaaS
+ // APIs) are pre-filtered out: buildURL substitutes them from
+ // env-backed Config.TemplateVars at request time, so they aren't
+ // missing context — they're already resolvable.
+ missingKeys := unresolvedPathKeyRE.FindAllString(path, -1)
+ missingKeys = slices.DeleteFunc(missingKeys, func(token string) bool {
+ return endpointTemplateVarSet[token[1:len(token)-1]]
+ })
+ if len(missingKeys) > 0 {
+{{- else}}
if missingKeys := unresolvedPathKeyRE.FindAllString(path, -1); len(missingKeys) > 0 {
+{{- end}}
if !humanFriendly {
payload := struct {
Event string `json:"event"`
diff --git a/internal/generator/templates/url.go.tmpl b/internal/generator/templates/url.go.tmpl
index 991967a6..f5d92167 100644
--- a/internal/generator/templates/url.go.tmpl
+++ b/internal/generator/templates/url.go.tmpl
@@ -25,7 +25,7 @@ var templateVarPattern = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}`)
// back to a generic "template variable {x} unresolved" message.
var templateVarEnvNames = map[string]string{
{{- range .EndpointTemplateVars}}
- "{{.}}": "{{envName $.Name}}_{{upper .}}",
+ "{{.}}": "{{endpointTemplateEnvName .}}",
{{- end}}
}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 69597dc2..b89f9da5 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -51,8 +51,21 @@ const (
extensionProxyRoutes = "x-proxy-routes"
extensionOrigin = "x-origin"
extensionProviderName = "x-providerName"
+ // extensionTenantEnvVar declares the env-var name that resolves the
+ // {tenant} path-positional template in multi-tenant SaaS APIs (every
+ // path is /tenant/{tenant}/...). When set, the parser registers
+ // "tenant" as an EndpointTemplateVar with this env var as the override,
+ // so the profiler treats /tenant/{tenant}/<resource> paths as standalone
+ // sync resources rather than parent-context-dependent.
+ extensionTenantEnvVar = "x-tenant-env-var"
)
+// tenantPlaceholderName is the canonical placeholder that x-tenant-env-var
+// maps to. Kept narrow on purpose — when this generalizes beyond ServiceTitan
+// (Atlassian {workspace}, GitHub {org}), promote to a list-shaped extension
+// rather than overloading this constant.
+const tenantPlaceholderName = "tenant"
+
// SetMaxResources overrides the default resource limit. When not called,
// the parser uses a default of 500 which accommodates all known APIs.
func SetMaxResources(n int) {
@@ -386,20 +399,24 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
return nil, err
}
+ templateVars, templateEnvOverrides := parseEndpointTemplateExtensions(doc)
+
result := &spec.APISpec{
- Name: name,
- DisplayName: displayName,
- DisplayNameDerivedFromTitle: displayNameDerivedFromTitle,
- Description: description,
- Version: version,
- BaseURL: baseURL,
- BaseURLIsPlaceholder: baseURLIsPlaceholder,
- BasePath: basePath,
- WebsiteURL: websiteURL,
- ProxyRoutes: proxyRoutes,
- Auth: auth,
- TierRouting: tierRouting,
- MCP: mcpConfig,
+ Name: name,
+ DisplayName: displayName,
+ DisplayNameDerivedFromTitle: displayNameDerivedFromTitle,
+ Description: description,
+ Version: version,
+ BaseURL: baseURL,
+ BaseURLIsPlaceholder: baseURLIsPlaceholder,
+ BasePath: basePath,
+ WebsiteURL: websiteURL,
+ ProxyRoutes: proxyRoutes,
+ Auth: auth,
+ TierRouting: tierRouting,
+ MCP: mcpConfig,
+ EndpointTemplateVars: templateVars,
+ EndpointTemplateEnvOverrides: templateEnvOverrides,
Config: spec.ConfigSpec{
Format: "toml",
Path: fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
@@ -438,6 +455,31 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
return result, nil
}
+// parseEndpointTemplateExtensions collects spec-declared endpoint template
+// placeholders and their env-var name overrides. Returns nil/nil for specs
+// that declare neither so existing generated outputs are byte-identical.
+//
+// Today only x-tenant-env-var lands here — it maps the implicit {tenant}
+// placeholder to an explicit env var (e.g. ST_TENANT_ID). When more
+// placeholders join (Atlassian {workspace}, GitHub {org}), prefer adding a
+// generic x-path-template-env-vars map-shaped extension over piling on
+// per-scope extensions.
+func parseEndpointTemplateExtensions(doc *openapi3.T) ([]string, map[string]string) {
+ raw, ok := lookupOpenAPIInfoExtension(doc, extensionTenantEnvVar)
+ if !ok {
+ return nil, nil
+ }
+ envName, ok := raw.(string)
+ if !ok {
+ return nil, nil
+ }
+ envName = strings.TrimSpace(envName)
+ if envName == "" {
+ return nil, nil
+ }
+ return []string{tenantPlaceholderName}, map[string]string{tenantPlaceholderName: envName}
+}
+
// parseTypedExtension bridges kin-openapi's untyped any-tree to a typed
// config struct via a JSON marshal/unmarshal roundtrip; callers rely on
// T's json tags for field mapping. Reach for it when the extension's
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 30324c9d..d0e3a554 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -5527,3 +5527,81 @@ paths:
assert.Equal(t, "https://api.real.com", parsed.BaseURL)
})
}
+
+// TestParseTenantEnvVarExtension: when info.x-tenant-env-var is set, the
+// parser registers "tenant" as an EndpointTemplateVar with the declared
+// env var as the override so the profiler can include
+// /tenant/{tenant}/<resource> paths in flat sync and downstream emitters
+// resolve the placeholder against the real env name.
+func TestParseTenantEnvVarExtension(t *testing.T) {
+ t.Parallel()
+
+ t.Run("info.x-tenant-env-var registers tenant template var + override", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: ServiceTitan CRM
+ version: 1.0.0
+ x-tenant-env-var: ST_TENANT_ID
+servers:
+ - url: https://api.servicetitan.io
+paths:
+ /tenant/{tenant}/customers:
+ get:
+ summary: List customers
+ parameters:
+ - name: tenant
+ in: path
+ required: true
+ schema: {type: string}
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Equal(t, []string{"tenant"}, parsed.EndpointTemplateVars)
+ assert.Equal(t, map[string]string{"tenant": "ST_TENANT_ID"}, parsed.EndpointTemplateEnvOverrides)
+ assert.Equal(t, "ST_TENANT_ID", parsed.EndpointTemplateEnvName("tenant"))
+ })
+
+ t.Run("absent extension leaves both fields empty", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Single Tenant API
+ version: 1.0.0
+servers:
+ - url: https://api.example.com
+paths:
+ /items:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Empty(t, parsed.EndpointTemplateVars)
+ assert.Empty(t, parsed.EndpointTemplateEnvOverrides)
+ })
+
+ t.Run("blank extension value is treated as absent", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Bad Annotation
+ version: 1.0.0
+ x-tenant-env-var: " "
+servers:
+ - url: https://api.example.com
+paths:
+ /items:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Empty(t, parsed.EndpointTemplateVars, "whitespace-only extension must not register a template var")
+ assert.Empty(t, parsed.EndpointTemplateEnvOverrides)
+ })
+}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 00473c5d..a2a613fe 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -58,24 +58,25 @@ type CLIManifest struct {
// Printer is the original printer's GitHub handle, preserved across regens.
Printer string `json:"printer,omitempty"`
// PrinterName is the optional display name rendered beside the printer handle.
- PrinterName string `json:"printer_name,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"`
- AuthEnvVarSpecs []spec.AuthEnvVar `json:"auth_env_var_specs,omitempty"`
- EndpointTemplateVars []string `json:"endpoint_template_vars,omitempty"`
+ PrinterName string `json:"printer_name,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"`
+ AuthEnvVarSpecs []spec.AuthEnvVar `json:"auth_env_var_specs,omitempty"`
+ EndpointTemplateVars []string `json:"endpoint_template_vars,omitempty"`
+ EndpointTemplateEnvOverrides map[string]string `json:"endpoint_template_env_overrides,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.
@@ -304,6 +305,7 @@ func orderedCLIManifestKeys(raw map[string]json.RawMessage) []string {
"auth_env_vars",
"auth_env_var_specs",
"endpoint_template_vars",
+ "endpoint_template_env_overrides",
"auth_key_url",
"auth_title",
"auth_description",
@@ -407,6 +409,7 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
m.AuthEnvVarSpecs = envVarSpecs
}
m.EndpointTemplateVars = parsed.EndpointTemplateVars
+ m.EndpointTemplateEnvOverrides = parsed.EndpointTemplateEnvOverrides
m.AuthKeyURL = parsed.Auth.KeyURL
m.AuthTitle = parsed.Auth.Title
m.AuthDescription = parsed.Auth.Description
diff --git a/internal/pipeline/mcpb_manifest.go b/internal/pipeline/mcpb_manifest.go
index 4544b262..338fa5e4 100644
--- a/internal/pipeline/mcpb_manifest.go
+++ b/internal/pipeline/mcpb_manifest.go
@@ -8,7 +8,6 @@ import (
"path/filepath"
"strings"
- "github.com/mvanhorn/cli-printing-press/v4/internal/naming"
"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
"github.com/mvanhorn/cli-printing-press/v4/internal/version"
)
@@ -308,7 +307,7 @@ func buildMCPBEnv(m CLIManifest) map[string]string {
env[envVar.Name] = "${user_config." + userConfigKey(envVar.Name) + "}"
}
for _, templateVar := range m.EndpointTemplateVars {
- name := endpointTemplateEnvVar(m.APIName, templateVar)
+ name := endpointTemplateEnvVar(m, templateVar)
env[name] = "${user_config." + userConfigKey(name) + "}"
}
return env
@@ -340,7 +339,7 @@ func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
}
}
for _, templateVar := range m.EndpointTemplateVars {
- name := endpointTemplateEnvVar(m.APIName, templateVar)
+ name := endpointTemplateEnvVar(m, templateVar)
vars[userConfigKey(name)] = MCPBVar{
Type: mcpbVarTypeString,
Title: name,
@@ -382,8 +381,13 @@ func mcpbUserConfigAuthEnvVars(m CLIManifest) []spec.AuthEnvVar {
return filtered
}
-func endpointTemplateEnvVar(apiName, templateVar string) string {
- return strings.ToUpper(naming.Snake(apiName) + "_" + naming.Snake(templateVar))
+func endpointTemplateEnvVar(m CLIManifest, templateVar string) string {
+ if override, ok := m.EndpointTemplateEnvOverrides[templateVar]; ok {
+ if trimmed := strings.TrimSpace(override); trimmed != "" {
+ return trimmed
+ }
+ }
+ return spec.DefaultEndpointTemplateEnvName(m.APIName, templateVar)
}
// userConfigKey lowercases the env var so manifest user_config keys match
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index c9baf52c..17e1a5ef 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -247,6 +247,7 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
m.AuthEnvVars = existing.AuthEnvVars
m.AuthEnvVarSpecs = existing.AuthEnvVarSpecs
m.EndpointTemplateVars = existing.EndpointTemplateVars
+ m.EndpointTemplateEnvOverrides = existing.EndpointTemplateEnvOverrides
m.AuthKeyURL = existing.AuthKeyURL
m.AuthTitle = existing.AuthTitle
m.AuthDescription = existing.AuthDescription
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index a4aee16c..a2702d21 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -3,6 +3,7 @@ package profiler
import (
"fmt"
"os"
+ "regexp"
"slices"
"sort"
"strings"
@@ -338,7 +339,16 @@ func Profile(s *spec.APISpec) *APIProfile {
listCapableGETs++
listResources[resourceName] = struct{}{}
- standaloneList := !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint)
+ // pathParamsAllTemplateVars treats paths whose only
+ // {placeholder}s are spec-declared EndpointTemplateVars
+ // (e.g. /tenant/{tenant}/<resource> when "tenant" is the
+ // tenant-scoping path-positional template) as standalone.
+ // buildURL substitutes those from env-backed
+ // Config.TemplateVars at request time, so they don't need
+ // parent-context iteration like /channels/{channelId}/messages
+ // does.
+ resolvable := pathParamsAllTemplateVars(endpoint.Path, s)
+ standaloneList := (!strings.Contains(endpoint.Path, "{") || resolvable) && !hasRequiredScopeParams(endpoint)
if endpoint.Pagination != nil {
p.ListEndpoints++
@@ -360,7 +370,7 @@ func Profile(s *spec.APISpec) *APIProfile {
meta.Path = expandedPath
syncable[expandedName] = meta
}
- } else if strings.Contains(endpoint.Path, "{") {
+ } else if strings.Contains(endpoint.Path, "{") && !resolvable {
// Parameterized paginated paths can't sync standalone — track
// them for dependent-resource detection below. Carry the
// endpoint's metadata so x-resource-id and x-critical
@@ -381,7 +391,7 @@ func Profile(s *spec.APISpec) *APIProfile {
} else if standaloneList {
addSyncCandidate(resourceName, metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex))
}
- } else if method == "GET" && !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint) && looksLikeCollectionEndpoint(endpointNameLower) {
+ } else if method == "GET" && (!strings.Contains(endpoint.Path, "{") || pathParamsAllTemplateVars(endpoint.Path, s)) && !hasRequiredScopeParams(endpoint) && looksLikeCollectionEndpoint(endpointNameLower) {
// Catch-all for simple GET collection endpoints that isListEndpoint
// didn't recognise (e.g., response is an untyped object with no
// wrapper field defined in the spec's types map).
@@ -700,6 +710,32 @@ var (
}
)
+// pathTemplatePlaceholderRE matches {placeholder} tokens in a path. Identifier
+// shape mirrors templateVarPattern in the emitted url.go.tmpl so client-side
+// resolution sees the same set of names this helper accepts.
+var pathTemplatePlaceholderRE = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}`)
+
+// pathParamsAllTemplateVars reports whether every {placeholder} in path is
+// declared in s.EndpointTemplateVars — i.e. fully resolvable via the printed
+// CLI's runtime buildURL substitution without parent-context iteration. Paths
+// with no {placeholder}s return false; the standaloneList gate handles those
+// separately.
+func pathParamsAllTemplateVars(path string, s *spec.APISpec) bool {
+ if s == nil || len(s.EndpointTemplateVars) == 0 || !strings.Contains(path, "{") {
+ return false
+ }
+ matches := pathTemplatePlaceholderRE.FindAllStringSubmatch(path, -1)
+ if len(matches) == 0 {
+ return false
+ }
+ for _, m := range matches {
+ if !s.IsEndpointTemplateVar(m[1]) {
+ return false
+ }
+ }
+ return true
+}
+
// hasRequiredScopeParams flags "scoped list" endpoints (e.g., GetFriendList
// requires steamid) that can't be synced without runtime context.
func hasRequiredScopeParams(endpoint spec.Endpoint) bool {
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index 4a77663a..8cf41aad 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -1815,3 +1815,82 @@ func TestProfilePagination_SkipsPathAndPositionalParams(t *testing.T) {
assert.Equal(t, "after", profile.Pagination.CursorParam, "path-param 'page' must not be treated as a cursor")
assert.Equal(t, "limit", profile.Pagination.PageSizeParam)
}
+
+// TestProfileTemplateVarPathBecomesFlatSyncable: paths whose only
+// {placeholder} is an EndpointTemplateVar (e.g. /tenant/{tenant}/<resource>
+// when the spec declares x-tenant-env-var) are runtime-resolvable through
+// buildURL — they should become flat SyncableResources rather than landing
+// in DependentSyncResources (which would require iterating a non-existent
+// parent table).
+func TestProfileTemplateVarPathBecomesFlatSyncable(t *testing.T) {
+ s := &spec.APISpec{
+ Name: "servicetitan",
+ EndpointTemplateVars: []string{"tenant"},
+ EndpointTemplateEnvOverrides: map[string]string{
+ "tenant": "ST_TENANT_ID",
+ },
+ Resources: map[string]spec.Resource{
+ "customers": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/tenant/{tenant}/customers",
+ Pagination: &spec.Pagination{CursorParam: "pageToken", LimitParam: "pageSize"},
+ Response: spec.ResponseDef{Type: "array"},
+ },
+ },
+ },
+ },
+ }
+
+ profile := Profile(s)
+ require.Len(t, profile.SyncableResources, 1, "tenant-scoped resource must surface as a flat SyncableResource")
+ assert.Equal(t, "customers", profile.SyncableResources[0].Name)
+ assert.Equal(t, "/tenant/{tenant}/customers", profile.SyncableResources[0].Path,
+ "path must preserve the {tenant} placeholder for buildURL to substitute")
+ assert.Empty(t, profile.DependentSyncResources, "tenant placeholder is not a parent context — must not become a DependentResource")
+}
+
+// TestProfileMixedPlaceholdersNotPromoted guards against over-eager
+// promotion: paths mixing a template-var placeholder with a real parent-
+// context placeholder must NOT be promoted to flat sync. The dependent-
+// resource matching is governed elsewhere; here we only pin the negative
+// (no false promotion) since that's what regression on this change would
+// look like.
+func TestProfileMixedPlaceholdersNotPromoted(t *testing.T) {
+ s := &spec.APISpec{
+ Name: "servicetitan",
+ EndpointTemplateVars: []string{"tenant"},
+ Resources: map[string]spec.Resource{
+ "channels": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/tenant/{tenant}/channels",
+ Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+ Response: spec.ResponseDef{Type: "array"},
+ },
+ },
+ },
+ "messages": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/tenant/{tenant}/channels/{channel_id}/messages",
+ Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+ Response: spec.ResponseDef{Type: "array"},
+ },
+ },
+ },
+ },
+ }
+
+ profile := Profile(s)
+ flatNames := make([]string, 0, len(profile.SyncableResources))
+ for _, r := range profile.SyncableResources {
+ flatNames = append(flatNames, r.Name)
+ }
+ assert.Contains(t, flatNames, "channels", "tenant-only path is flat")
+ assert.NotContains(t, flatNames, "messages",
+ "a path containing {channel_id} alongside the template var must not flatten into SyncableResources")
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 264d140d..7829be6d 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -7,6 +7,7 @@ import (
"net/url"
"os"
"regexp"
+ "slices"
"strings"
"unicode"
@@ -109,36 +110,47 @@ type APISpec struct {
// model couldn't represent that without hardcoding "/graphql" in the
// generated client.
GraphQLEndpointPath string `yaml:"graphql_endpoint_path,omitempty" json:"graphql_endpoint_path,omitempty"`
- // EndpointTemplateVars lists placeholder names embedded in BaseURL or
- // GraphQLEndpointPath as {var} (e.g., ["shop", "version"]). The
- // generator emits per-variable env-var lookups in the printed CLI's
- // config so users can resolve them at runtime. PR-1 carries this field
- // as plumbing only; PR-2 wires the runtime substitution.
- EndpointTemplateVars []string `yaml:"endpoint_template_vars,omitempty" json:"endpoint_template_vars,omitempty"`
- Owner string `yaml:"owner,omitempty" json:"owner,omitempty"` // GitHub owner for import paths and Homebrew tap
- OwnerName string `yaml:"owner_name,omitempty" json:"owner_name,omitempty"` // Display name (e.g. "Trevin Chow") for prose surfaces — Hermes author:, README byline. Distinct from Owner (slug) which drives module paths and copyright headers.
- Printer string `yaml:"printer,omitempty" json:"printer,omitempty"` // GitHub @handle of the human who ran the press for this CLI. Drives the per-CLI README byline link and the registry-side attribution. Distinct from Owner (the API-spec owner / wrapper-author identity).
- PrinterName string `yaml:"printer_name,omitempty" json:"printer_name,omitempty"` // Display name of the printer (e.g. "Matt Van Horn") for prose surfaces — README byline parenthetical. Resolution path mirrors OwnerName: raw git config user.name, no slug fallback, no "USER" sentinel.
- Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // "rest" (default) or "synthetic" — synthetic CLIs aggregate multiple sources beyond the spec; dogfood's path-validity check is relaxed accordingly
- SpecSource string `yaml:"spec_source,omitempty" json:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
- ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
- HTTPTransport string `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-http, browser-chrome, or browser-chrome-h3
- HealthCheckPath string `yaml:"health_check_path,omitempty" json:"health_check_path,omitempty"`
- ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
- BearerRefresh BearerRefreshConfig `yaml:"bearer_refresh,omitempty" json:"bearer_refresh,omitzero"` // live-source metadata for rotating public client bearer tokens
- WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
- Category string `yaml:"category,omitempty" json:"category,omitempty"` // catalog category (e.g., productivity, developer-tools) — used for library install path
- Auth AuthConfig `yaml:"auth" json:"auth"`
- TierRouting TierRoutingConfig `yaml:"tier_routing,omitempty" json:"tier_routing,omitzero"`
- RequiredHeaders []RequiredHeader `yaml:"required_headers,omitempty" json:"required_headers,omitempty"`
- Config ConfigSpec `yaml:"config" json:"config"`
- Resources map[string]Resource `yaml:"resources" json:"resources"`
- Types map[string]TypeDef `yaml:"types" json:"types"`
- ExtraCommands []ExtraCommand `yaml:"extra_commands,omitempty" json:"extra_commands,omitempty"` // hand-written cobra commands declared so SKILL.md can document them; spec-only metadata, no code generated
- Cache CacheConfig `yaml:"cache,omitempty" json:"cache"` // cache freshness + auto-refresh config; when enabled, generated read commands auto-refresh stale local data before serving
- Share ShareConfig `yaml:"share,omitempty" json:"share"` // git-backed snapshot sharing config; when enabled, emits a `share` subcommand that publishes/subscribes to a git repo
- MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp"` // MCP server generation config; when unset, the emitted MCP binary is stdio-only (today's default). Opting into http adds a --transport/--addr flag surface so the same binary can serve cloud-hosted agents.
- Throttling ThrottlingConfig `yaml:"throttling,omitempty" json:"throttling"` // cost-based throttling config; when Enabled with a recognized Shape, the generator emits a ThrottleState (generic harness) plus a per-Shape parser that reads the API's cost bucket. Only the "shopify" Shape ships in v1.
+ // EndpointTemplateVars lists placeholder names embedded in BaseURL,
+ // GraphQLEndpointPath, or per-tenant request paths as {var}
+ // (e.g., ["shop", "version"], or ["tenant"] for per-tenant SaaS APIs
+ // where the tenant ID is a path-positional segment). The generator
+ // emits per-variable env-var lookups in the printed CLI's config so
+ // users can resolve them at runtime, and the profiler treats paths
+ // whose only {placeholder}s are template vars as standalone-listable
+ // sync resources (rather than parent-context-dependent).
+ EndpointTemplateVars []string `yaml:"endpoint_template_vars,omitempty" json:"endpoint_template_vars,omitempty"`
+ // EndpointTemplateEnvOverrides maps a placeholder in EndpointTemplateVars
+ // to an explicit env-var name, overriding the default
+ // <APINAME>_<UPPER_PLACEHOLDER> resolution. Used for per-tenant or
+ // per-workspace path-positional templates whose env var doesn't follow
+ // the API-name convention (e.g. {tenant} resolved from ST_TENANT_ID
+ // across every ServiceTitan module). Populated from the OpenAPI
+ // `info.x-tenant-env-var` extension or set directly in internal YAML.
+ EndpointTemplateEnvOverrides map[string]string `yaml:"endpoint_template_env_overrides,omitempty" json:"endpoint_template_env_overrides,omitempty"`
+ Owner string `yaml:"owner,omitempty" json:"owner,omitempty"` // GitHub owner for import paths and Homebrew tap
+ OwnerName string `yaml:"owner_name,omitempty" json:"owner_name,omitempty"` // Display name (e.g. "Trevin Chow") for prose surfaces — Hermes author:, README byline. Distinct from Owner (slug) which drives module paths and copyright headers.
+ Printer string `yaml:"printer,omitempty" json:"printer,omitempty"` // GitHub @handle of the human who ran the press for this CLI. Drives the per-CLI README byline link and the registry-side attribution. Distinct from Owner (the API-spec owner / wrapper-author identity).
+ PrinterName string `yaml:"printer_name,omitempty" json:"printer_name,omitempty"` // Display name of the printer (e.g. "Matt Van Horn") for prose surfaces — README byline parenthetical. Resolution path mirrors OwnerName: raw git config user.name, no slug fallback, no "USER" sentinel.
+ Kind string `yaml:"kind,omitempty" json:"kind,omitempty"` // "rest" (default) or "synthetic" — synthetic CLIs aggregate multiple sources beyond the spec; dogfood's path-validity check is relaxed accordingly
+ SpecSource string `yaml:"spec_source,omitempty" json:"spec_source,omitempty"` // official, community, sniffed, docs — affects generated client defaults
+ ClientPattern string `yaml:"client_pattern,omitempty" json:"client_pattern,omitempty"` // rest (default), proxy-envelope — affects generated HTTP client
+ HTTPTransport string `yaml:"http_transport,omitempty" json:"http_transport,omitempty"` // standard (default for official APIs), browser-http, browser-chrome, or browser-chrome-h3
+ HealthCheckPath string `yaml:"health_check_path,omitempty" json:"health_check_path,omitempty"`
+ ProxyRoutes map[string]string `yaml:"proxy_routes,omitempty" json:"proxy_routes,omitempty"` // path prefix → service name for proxy-envelope routing
+ BearerRefresh BearerRefreshConfig `yaml:"bearer_refresh,omitempty" json:"bearer_refresh,omitzero"` // live-source metadata for rotating public client bearer tokens
+ WebsiteURL string `yaml:"website_url,omitempty" json:"website_url,omitempty"` // product/company website (not the API base URL)
+ Category string `yaml:"category,omitempty" json:"category,omitempty"` // catalog category (e.g., productivity, developer-tools) — used for library install path
+ Auth AuthConfig `yaml:"auth" json:"auth"`
+ TierRouting TierRoutingConfig `yaml:"tier_routing,omitempty" json:"tier_routing,omitzero"`
+ RequiredHeaders []RequiredHeader `yaml:"required_headers,omitempty" json:"required_headers,omitempty"`
+ Config ConfigSpec `yaml:"config" json:"config"`
+ Resources map[string]Resource `yaml:"resources" json:"resources"`
+ Types map[string]TypeDef `yaml:"types" json:"types"`
+ ExtraCommands []ExtraCommand `yaml:"extra_commands,omitempty" json:"extra_commands,omitempty"` // hand-written cobra commands declared so SKILL.md can document them; spec-only metadata, no code generated
+ Cache CacheConfig `yaml:"cache,omitempty" json:"cache"` // cache freshness + auto-refresh config; when enabled, generated read commands auto-refresh stale local data before serving
+ Share ShareConfig `yaml:"share,omitempty" json:"share"` // git-backed snapshot sharing config; when enabled, emits a `share` subcommand that publishes/subscribes to a git repo
+ MCP MCPConfig `yaml:"mcp,omitempty" json:"mcp"` // MCP server generation config; when unset, the emitted MCP binary is stdio-only (today's default). Opting into http adds a --transport/--addr flag surface so the same binary can serve cloud-hosted agents.
+ Throttling ThrottlingConfig `yaml:"throttling,omitempty" json:"throttling"` // cost-based throttling config; when Enabled with a recognized Shape, the generator emits a ThrottleState (generic harness) plus a per-Shape parser that reads the API's cost bucket. Only the "shopify" Shape ships in v1.
}
type TierRoutingConfig struct {
@@ -159,6 +171,42 @@ func (s *APISpec) HasTierRouting() bool {
return s.TierRouting.DefaultTier != "" || len(s.TierRouting.Tiers) > 0
}
+// EndpointTemplateEnvName returns the env-var name that resolves the given
+// {placeholder} in EndpointTemplateVars. Overrides win; the default is the
+// existing <APINAME>_<UPPER_PLACEHOLDER> convention so unannotated specs
+// (the common case) regenerate byte-for-byte.
+func (s *APISpec) EndpointTemplateEnvName(placeholder string) string {
+ if s != nil {
+ if override, ok := s.EndpointTemplateEnvOverrides[placeholder]; ok {
+ if trimmed := strings.TrimSpace(override); trimmed != "" {
+ return trimmed
+ }
+ }
+ }
+ apiName := ""
+ if s != nil {
+ apiName = s.Name
+ }
+ return DefaultEndpointTemplateEnvName(apiName, placeholder)
+}
+
+// DefaultEndpointTemplateEnvName builds the conventional env-var name for a
+// template placeholder when no override applies. Exported so the pipeline
+// manifest emitter can reuse the same rule without importing the generator.
+func DefaultEndpointTemplateEnvName(apiName, placeholder string) string {
+ return strings.ToUpper(strings.ReplaceAll(naming.Snake(apiName), "-", "_") + "_" + strings.ReplaceAll(naming.Snake(placeholder), "-", "_"))
+}
+
+// IsEndpointTemplateVar reports whether the given placeholder name appears
+// in EndpointTemplateVars. Used by the profiler to decide whether a path's
+// {placeholder}s are fully resolvable at request time.
+func (s *APISpec) IsEndpointTemplateVar(placeholder string) bool {
+ if s == nil {
+ return false
+ }
+ return slices.Contains(s.EndpointTemplateVars, placeholder)
+}
+
func (s *APISpec) EffectiveTier(resource Resource, endpoint Endpoint) string {
name, _, ok := s.EffectiveTierConfig(resource, endpoint)
if !ok {
← d697eb5a fix(cli): skip framework-auto-generated operationIds when de
·
back to Cli Printing Press
·
fix(cli): wire composed apiKey + bearer sibling headers thro 2437cab7 →