← back to Cli Printing Press
fix(cli): substitute server-URL variables from env vars at runtime (#1448)
e36050fb97d818fec71258fbb73d65533ac423fb · 2026-05-15 10:12:39 -0700 · Trevin Chow
* fix(cli): substitute server-URL variables from env vars at runtime
Multi-tenant SaaS specs that declare `servers[0].url` with a `{var}`
placeholder backed by a Variables block (e.g. Freshservice's
`https://{domain}/api/v2`) used to be flattened at parse time:
resolveServerURL substituted each variable's `default:` value into
BaseURL, so the generated CLI hardcoded the spec author's example
hostname and DNS-failed on every request against any other tenant.
The accompanying env-var read was emitted, stored on Config, and
never substituted back into BaseURL.
The parser now preserves `{var}` markers when servers[0] declares
Variables, captures each variable's `default:` value into a new
EndpointTemplateVarDefaults map, and registers the placeholder as
an EndpointTemplateVar. config.Load gains a runtime path that reads
the conventional `<APINAME>_<UPPER_PLACEHOLDER>` env var, normalizes
browser-pasted values (strips scheme + trailing slash,
case-insensitive), and falls back to the spec-declared default so
doctor still probes a real-shaped URL when nothing is exported.
Defaults flow through the manifest, publish round-trip, and MCPB
user_config. Spec-defaulted vars are emitted as Required: false in
the MCPB user_config so Claude Desktop installs don't block on a
required field that is pre-filled with a vendor placeholder.
Closes #1295
* docs(cli): clarify second-pass purpose in resolveServerURLTemplate
The previous comment described in-place default substitution for
identifier-style variables, but those are caught by the first pass and
preserved for runtime substitution. The second pass only fires for
variable names that don't match templateVarPattern (hyphenated or
digit-leading names) so their default gets baked before the strip pass
deletes the placeholder.
Refs #1295
Files touched
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/config.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM internal/pipeline/mcpb_manifest.goM internal/pipeline/publish.goM internal/spec/spec.go
Diff
commit e36050fb97d818fec71258fbb73d65533ac423fb
Author: Trevin Chow <trevin@trevinchow.com>
Date: Fri May 15 10:12:39 2026 -0700
fix(cli): substitute server-URL variables from env vars at runtime (#1448)
* fix(cli): substitute server-URL variables from env vars at runtime
Multi-tenant SaaS specs that declare `servers[0].url` with a `{var}`
placeholder backed by a Variables block (e.g. Freshservice's
`https://{domain}/api/v2`) used to be flattened at parse time:
resolveServerURL substituted each variable's `default:` value into
BaseURL, so the generated CLI hardcoded the spec author's example
hostname and DNS-failed on every request against any other tenant.
The accompanying env-var read was emitted, stored on Config, and
never substituted back into BaseURL.
The parser now preserves `{var}` markers when servers[0] declares
Variables, captures each variable's `default:` value into a new
EndpointTemplateVarDefaults map, and registers the placeholder as
an EndpointTemplateVar. config.Load gains a runtime path that reads
the conventional `<APINAME>_<UPPER_PLACEHOLDER>` env var, normalizes
browser-pasted values (strips scheme + trailing slash,
case-insensitive), and falls back to the spec-declared default so
doctor still probes a real-shaped URL when nothing is exported.
Defaults flow through the manifest, publish round-trip, and MCPB
user_config. Spec-defaulted vars are emitted as Required: false in
the MCPB user_config so Claude Desktop installs don't block on a
required field that is pre-filled with a vendor placeholder.
Closes #1295
* docs(cli): clarify second-pass purpose in resolveServerURLTemplate
The previous comment described in-place default substitution for
identifier-style variables, but those are caught by the first pass and
preserved for runtime substitution. The second pass only fires for
variable names that don't match templateVarPattern (hyphenated or
digit-leading names) so their default gets baked before the strip pass
deletes the placeholder.
Refs #1295
---
internal/generator/generator.go | 27 ++++
internal/generator/generator_test.go | 184 ++++++++++++++++++++++
internal/generator/templates/config.go.tmpl | 36 +++++
internal/openapi/parser.go | 154 ++++++++++++++++++-
internal/openapi/parser_test.go | 228 ++++++++++++++++++++++++++++
internal/pipeline/climanifest.go | 7 +
internal/pipeline/climanifest_test.go | 28 +++-
internal/pipeline/mcpb_manifest.go | 17 ++-
internal/pipeline/publish.go | 1 +
internal/spec/spec.go | 69 ++++++---
10 files changed, 714 insertions(+), 37 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index c6e7c2c2..fa1e29de 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -348,6 +348,24 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"endpointTemplateEnvName": func(placeholder string) string {
return s.EndpointTemplateEnvName(placeholder)
},
+ // endpointTemplateDefault returns the spec-declared default value for
+ // a placeholder (e.g. server-URL variables' `default:` value), or ""
+ // when none. Templates branch on the empty case to skip the runtime
+ // fallback path and preserve byte-compat with placeholders that have
+ // no spec-level default (path-positional templates like {tenant}).
+ "endpointTemplateDefault": func(placeholder string) string {
+ return s.EndpointTemplateDefault(placeholder)
+ },
+ // Predicates the config.Load template branches on. Splitting on
+ // has-default vs has-undefaulted preserves byte-compat for CLIs whose
+ // placeholders are all path-positional (no defaults): without the
+ // split, every prior CLI would regenerate just to emit unused helpers.
+ "endpointTemplateVarsHasDefault": func(vars []string) bool {
+ return endpointTemplateVarsAny(vars, s, func(v string) bool { return v != "" })
+ },
+ "endpointTemplateVarsHasUndefaulted": func(vars []string) bool {
+ return endpointTemplateVarsAny(vars, s, func(v string) bool { return v == "" })
+ },
"safeName": safeSQLName,
"resourceIDFieldOverrideEntries": resourceIDFieldOverrideEntries,
"criticalResourceEntries": criticalResourceEntries,
@@ -532,6 +550,15 @@ func New(s *spec.APISpec, outputDir string) *Generator {
return g
}
+func endpointTemplateVarsAny(vars []string, s *spec.APISpec, predicate func(string) bool) bool {
+ for _, name := range vars {
+ if predicate(s.EndpointTemplateDefault(name)) {
+ return true
+ }
+ }
+ return false
+}
+
func buildWhichFallbackEntries(resources map[string]spec.Resource) []NovelFeature {
var entries []NovelFeature
var resNames []string
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 7cd32483..0807cda8 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -8739,6 +8739,190 @@ func TestLoadTemplateVarsRealEnvWinsOverPlaceholder(t *testing.T) {
runGoCommand(t, outputDir, "test", "./internal/config", "-run", "TestLoadTemplateVars")
}
+// freshserviceTemplateVarsTestSpec returns a Freshservice-shape APISpec
+// used by EndpointTemplateVars-with-defaults tests. The `{domain}`
+// placeholder carries a spec-declared default so config.Load can fall
+// back to a real URL when the user's env var is unset.
+func freshserviceTemplateVarsTestSpec() *spec.APISpec {
+ return &spec.APISpec{
+ Name: "freshservice",
+ Description: "Freshservice (test fixture)",
+ Version: "v2",
+ BaseURL: "https://{domain}/api/v2",
+ EndpointTemplateVars: []string{"domain"},
+ EndpointTemplateVarDefaults: map[string]string{"domain": "yourcompany.freshservice.com"},
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "X-Api-Key",
+ EnvVars: []string{"FRESHSERVICE_API_KEY"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/freshservice-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "tickets": {
+ Description: "Tickets",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/tickets",
+ Description: "List tickets",
+ ResponsePath: "tickets",
+ Response: spec.ResponseDef{Type: "array", Item: "Ticket"},
+ },
+ },
+ },
+ },
+ Types: map[string]spec.TypeDef{
+ "Ticket": {Fields: []spec.TypeField{
+ {Name: "id", Type: "string"},
+ {Name: "subject", Type: "string"},
+ }},
+ },
+ }
+}
+
+// TestGenerateEndpointTemplateVarsDefaultFallback covers the multi-tenant
+// base-URL substitution path. When servers[0].url declares a `{var}`
+// placeholder with a `default:` value, config.Load must:
+// - substitute the env-var value (normalized) into Config.TemplateVars
+// when the env var is set, and
+// - fall back to the spec-declared default when the env var is unset,
+//
+// so doctor and verify probe a real-shaped URL instead of one with a
+// literal {domain} in it. The normalizer strips scheme + trailing slash
+// because users routinely paste browser-bar URLs into shell exports.
+func TestGenerateEndpointTemplateVarsDefaultFallback(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := freshserviceTemplateVarsTestSpec()
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ configGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ configGoStr := string(configGo)
+ assert.Contains(t, configGoStr, `cfg.TemplateVars["domain"] = "yourcompany.freshservice.com"`,
+ "config.Load must bake the spec default as the env-var-unset fallback")
+ assert.Contains(t, configGoStr, "normalizeEndpointTemplateValue",
+ "config.go must emit the normalizer helper when a server-URL default is present")
+ assert.NotContains(t, configGoStr, `BaseURL: "https://yourcompany.freshservice.com/api/v2"`,
+ "BaseURL must keep the {domain} placeholder, not bake the default into the constant")
+ assert.Contains(t, configGoStr, `BaseURL: "https://{domain}/api/v2"`,
+ "BaseURL constant must carry the placeholder for runtime substitution")
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+
+ behaviorTest := `package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func clearFreshserviceEnv(t *testing.T) {
+ t.Helper()
+ for _, k := range []string{"FRESHSERVICE_DOMAIN", "FRESHSERVICE_BASE_URL", "FRESHSERVICE_CONFIG", "PRINTING_PRESS_VERIFY"} {
+ t.Setenv(k, "")
+ _ = os.Unsetenv(k)
+ }
+}
+
+func TestLoadDomainDefaultFallback(t *testing.T) {
+ clearFreshserviceEnv(t)
+ cfgPath := filepath.Join(t.TempDir(), "config.toml")
+ cfg, err := Load(cfgPath)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if got := cfg.TemplateVars["domain"]; got != "yourcompany.freshservice.com" {
+ t.Errorf("unset env var should fall back to spec default; got %q", got)
+ }
+}
+
+func TestLoadDomainEnvVarOverridesDefault(t *testing.T) {
+ clearFreshserviceEnv(t)
+ t.Setenv("FRESHSERVICE_DOMAIN", "acme.freshservice.com")
+ cfgPath := filepath.Join(t.TempDir(), "config.toml")
+ cfg, err := Load(cfgPath)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if got := cfg.TemplateVars["domain"]; got != "acme.freshservice.com" {
+ t.Errorf("env var should beat default; got %q", got)
+ }
+}
+
+func TestLoadDomainNormalizesPastedURL(t *testing.T) {
+ cases := []struct {
+ input string
+ want string
+ }{
+ {"acme.freshservice.com", "acme.freshservice.com"},
+ {"https://acme.freshservice.com", "acme.freshservice.com"},
+ {"https://acme.freshservice.com/", "acme.freshservice.com"},
+ {"http://acme.freshservice.com//", "acme.freshservice.com"},
+ {" acme.freshservice.com ", "acme.freshservice.com"},
+ {"HTTPS://acme.freshservice.com", "acme.freshservice.com"},
+ {"Https://acme.freshservice.com/", "acme.freshservice.com"},
+ {"HTTP://acme.freshservice.com", "acme.freshservice.com"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.input, func(t *testing.T) {
+ clearFreshserviceEnv(t)
+ t.Setenv("FRESHSERVICE_DOMAIN", tc.input)
+ cfgPath := filepath.Join(t.TempDir(), "config.toml")
+ cfg, err := Load(cfgPath)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if got := cfg.TemplateVars["domain"]; got != tc.want {
+ t.Errorf("Load(%q): TemplateVars[\"domain\"] = %q, want %q", tc.input, got, tc.want)
+ }
+ })
+ }
+}
+`
+ testPath := filepath.Join(outputDir, "internal", "config", "domain_fallback_test.go")
+ require.NoError(t, os.WriteFile(testPath, []byte(behaviorTest), 0o644))
+ runGoCommand(t, outputDir, "test", "./internal/config", "-run", "TestLoadDomain")
+}
+
+// TestGenerateEndpointTemplateDefaultIsEscaped guards against a malicious
+// or hand-edited OpenAPI spec whose server-variable default contains Go
+// string-literal syntax (a stray double quote, backslash, or newline).
+// Without proper %q escaping the generator emits invalid Go and the
+// printed CLI fails to compile — or worse, the default becomes a vector
+// for injecting Go statements into config.Load() that run on every CLI
+// invocation.
+func TestGenerateEndpointTemplateDefaultIsEscaped(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := freshserviceTemplateVarsTestSpec()
+ apiSpec.EndpointTemplateVarDefaults = map[string]string{
+ "domain": `evil"; os.Exit(1); //`,
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ configGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "config", "config.go"))
+ require.NoError(t, err)
+ got := string(configGo)
+ assert.Contains(t, got, `cfg.TemplateVars["domain"] = "evil\"; os.Exit(1); //"`,
+ "%q escaping must quote internal double quotes so the default can never close the Go string literal")
+ assert.NotContains(t, got, `cfg.TemplateVars["domain"] = "evil"; os.Exit(1); //`,
+ "unescaped emission would close the string and execute os.Exit on every Load")
+
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+}
+
// TestGenerateNoEndpointTemplateVarsByteCompat guards the byte-compat
// promise: a spec that doesn't declare EndpointTemplateVars must regenerate
// without url.go and with the original c.BaseURL+path concat in client.do().
diff --git a/internal/generator/templates/config.go.tmpl b/internal/generator/templates/config.go.tmpl
index 5d0b6a48..d92f4dec 100644
--- a/internal/generator/templates/config.go.tmpl
+++ b/internal/generator/templates/config.go.tmpl
@@ -207,21 +207,57 @@ func Load(configPath string) (*Config, error) {
// the printing-press verifier in every mock subprocess), an unset
// template var falls through to "<name>_placeholder" so dry-run legs
// reach Cobra instead of hitting buildURL's actionable error first.
+ // Placeholders with a spec-declared default (server-URL variables) skip
+ // the verify branch; the default is a real value that lets verify probe
+ // a real-shaped URL.
if cfg.TemplateVars == nil {
cfg.TemplateVars = map[string]string{}
}
+{{- if endpointTemplateVarsHasUndefaulted .EndpointTemplateVars}}
verifyMode := os.Getenv("PRINTING_PRESS_VERIFY") == "1"
+{{- end}}
{{- range .EndpointTemplateVars}}
+{{- if endpointTemplateDefault .}}
+ if v := strings.TrimSpace(os.Getenv("{{endpointTemplateEnvName .}}")); v != "" {
+ cfg.TemplateVars["{{.}}"] = normalizeEndpointTemplateValue(v)
+ } else {
+ cfg.TemplateVars["{{.}}"] = {{printf "%q" (endpointTemplateDefault .)}}
+ }
+{{- else}}
if v := strings.TrimSpace(os.Getenv("{{endpointTemplateEnvName .}}")); v != "" {
cfg.TemplateVars["{{.}}"] = v
} else if verifyMode {
cfg.TemplateVars["{{.}}"] = "{{.}}_placeholder"
}
{{- end}}
+{{- end}}
{{- end}}
return cfg, nil
}
+{{- if endpointTemplateVarsHasDefault .EndpointTemplateVars}}
+
+// normalizeEndpointTemplateValue cleans up a server-URL template value the
+// user has likely pasted from a browser address bar; drops the scheme prefix
+// and trailing slash so a placeholder like {domain} resolves to a bare host
+// whether the user typed `acme.example.com`, `https://acme.example.com`, or
+// `https://acme.example.com/`. Applied only to placeholders carrying a
+// spec-declared default (server-URL variables); path-positional placeholders
+// like {tenant} pass through unchanged because their accepted shapes are
+// API-specific.
+func normalizeEndpointTemplateValue(v string) string {
+ // Strip scheme prefix case-insensitively: users paste `HTTPS://` from
+ // some browsers' address bars, and Go's TrimPrefix is exact-match.
+ // Callers TrimSpace the env-var value before passing it in.
+ if len(v) >= 8 && strings.EqualFold(v[:8], "https://") {
+ v = v[8:]
+ } else if len(v) >= 7 && strings.EqualFold(v[:7], "http://") {
+ v = v[7:]
+ }
+ return strings.TrimRight(v, "/")
+}
+{{- end}}
+
func (c *Config) AuthHeader() string {
{{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
{{- $isAuthEnvVarORCase := .Auth.IsAuthEnvVarORCase}}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 8b3ec59f..bd6a7ad0 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -360,8 +360,15 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
baseURL := ""
basePath := ""
+ // serverTemplatePlaceholders / serverTemplateDefaults carry the placeholder
+ // names and their spec-declared defaults when the top-level server URL is
+ // a multi-tenant template (e.g. `https://{domain}/api/v2`). Empty for
+ // static specs so per-tenant runtime substitution stays opt-in and the
+ // existing byte-compat goldens for single-host APIs don't churn.
+ var serverTemplatePlaceholders []string
+ var serverTemplateDefaults map[string]string
if len(doc.Servers) > 0 && doc.Servers[0] != nil {
- baseURL, basePath = resolveServerURL(doc.Servers[0])
+ baseURL, basePath, serverTemplatePlaceholders, serverTemplateDefaults = resolveServerURLTemplate(doc.Servers[0])
}
if baseURL == "" && basePath == "" {
// No top-level servers — walk per-operation `servers:` blocks. Specs
@@ -400,6 +407,12 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
}
templateVars, templateEnvOverrides := parseEndpointTemplateExtensions(doc)
+ // Merge server-URL template placeholders into the endpoint-template-var
+ // bucket. The downstream config.Load template walks EndpointTemplateVars
+ // to emit per-variable env-var lookups; without this merge a spec like
+ // `https://{domain}/api/v2` would lose `{domain}` between the parser and
+ // the generator and the CLI would DNS-fail on every call.
+ templateVars, templateDefaults := mergeServerTemplatePlaceholders(templateVars, serverTemplatePlaceholders, serverTemplateDefaults)
result := &spec.APISpec{
Name: name,
@@ -417,6 +430,7 @@ func parseWithLocation(data []byte, lenient bool, location *url.URL) (*spec.APIS
MCP: mcpConfig,
EndpointTemplateVars: templateVars,
EndpointTemplateEnvOverrides: templateEnvOverrides,
+ EndpointTemplateVarDefaults: templateDefaults,
Config: spec.ConfigSpec{
Format: "toml",
Path: fmt.Sprintf("~/.config/%s-pp-cli/config.toml", name),
@@ -2098,6 +2112,137 @@ func operationAllowsAnonymous(op *openapi3.Operation, doc *openapi3.T) bool {
return false
}
+// resolveServerURLTemplate is the top-level entry point used for
+// `doc.Servers[0]`. It preserves `{var}` placeholders for variables that the
+// spec declares an explicit Variables entry for, so the generator can emit a
+// runtime substitution path (env var > spec default) in config.Load() rather
+// than baking the default into BaseURL at generate time. Returns the same
+// (baseURL, basePath) pair as resolveServerURL plus the placeholder names
+// that survived substitution and a map of their declared defaults. Variables
+// without an explicit Variables entry (e.g. dangling `{foo}` markers in a
+// hand-written spec) fall through to the legacy strip-unresolved behavior so
+// stale specs don't suddenly require an env var that doesn't exist.
+func resolveServerURLTemplate(server *openapi3.Server) (baseURL, basePath string, placeholders []string, defaults map[string]string) {
+ if server == nil {
+ return "", "", nil, nil
+ }
+ serverURL := strings.TrimRight(strings.TrimSpace(server.URL), "/")
+ if strings.Contains(serverURL, "{") && server.Variables != nil {
+ // First pass: identify which `{var}` markers are backed by an explicit
+ // variable definition. These get preserved for runtime substitution.
+ // Order placeholders by left-to-right appearance in the URL so the
+ // generated EndpointTemplateVars slice has a stable, intuitive shape;
+ // Go map iteration would otherwise produce nondeterministic ordering.
+ matches := templateVarPattern.FindAllStringSubmatch(serverURL, -1)
+ seen := map[string]bool{}
+ for _, m := range matches {
+ name := m[1]
+ if seen[name] {
+ continue
+ }
+ variable, ok := server.Variables[name]
+ if !ok || variable == nil {
+ continue
+ }
+ seen[name] = true
+ placeholders = append(placeholders, name)
+ if defaults == nil {
+ defaults = map[string]string{}
+ }
+ defaults[name] = variable.Default
+ }
+ // Second pass: bake in the default for any Variables entry the first
+ // pass did NOT register for runtime substitution. The first pass
+ // only registers names that match templateVarPattern's identifier
+ // regex (`[a-zA-Z_][a-zA-Z0-9_]*`); a variable with a hyphenated or
+ // digit-leading name (`{server-id}`, `{2nd-host}` — OpenAPI 3.0
+ // places no character restriction on variable names) falls through
+ // here so its default is substituted in place. Without this, the
+ // strip pass below would delete the placeholder entirely and the
+ // resulting URL would DNS-fail with no actionable hint.
+ for varName, variable := range server.Variables {
+ if _, runtime := defaults[varName]; runtime {
+ continue
+ }
+ if variable != nil && variable.Default != "" {
+ serverURL = strings.ReplaceAll(serverURL, "{"+varName+"}", variable.Default)
+ }
+ }
+ }
+ // Strip any remaining unresolved placeholders that don't have a runtime
+ // substitution path (matches the legacy resolveServerURL behavior).
+ // Runtime placeholders are left in place so the printed CLI's buildURL
+ // can substitute them per request.
+ serverURL = templateVarPattern.ReplaceAllStringFunc(serverURL, func(match string) string {
+ name := match[1 : len(match)-1]
+ if _, runtime := defaults[name]; runtime {
+ return match
+ }
+ return ""
+ })
+ serverURL = normalizeURLSlashes(serverURL)
+ serverURL = strings.TrimRight(serverURL, "/")
+ if serverURL == "" {
+ return "", "", placeholders, defaults
+ }
+ lowerURL := strings.ToLower(serverURL)
+ if strings.HasPrefix(lowerURL, "http://") || strings.HasPrefix(lowerURL, "https://") {
+ return serverURL, "", placeholders, defaults
+ }
+ return "", serverURL, placeholders, defaults
+}
+
+// templateVarPattern mirrors the regex used by the generated `buildURL` helper
+// so the parser and the runtime substitute the same set of placeholder names.
+var templateVarPattern = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}`)
+
+func normalizeURLSlashes(s string) string {
+ s = strings.ReplaceAll(s, "//", "/")
+ s = strings.Replace(s, "http:/", "http://", 1)
+ s = strings.Replace(s, "https:/", "https://", 1)
+ return s
+}
+
+// mergeServerTemplatePlaceholders folds the placeholders the parser pulled
+// off `doc.Servers[0].Variables` into the existing EndpointTemplateVars list
+// (today populated only by x-tenant-env-var). Order: extension-declared
+// placeholders first, then server-URL placeholders in spec order, deduped.
+func mergeServerTemplatePlaceholders(existing []string, serverPlaceholders []string, serverDefaults map[string]string) ([]string, map[string]string) {
+ if len(serverPlaceholders) == 0 && len(serverDefaults) == 0 {
+ return existing, nil
+ }
+ seen := make(map[string]bool, len(existing)+len(serverPlaceholders))
+ out := make([]string, 0, len(existing)+len(serverPlaceholders))
+ for _, name := range existing {
+ if seen[name] {
+ continue
+ }
+ seen[name] = true
+ out = append(out, name)
+ }
+ for _, name := range serverPlaceholders {
+ if seen[name] {
+ continue
+ }
+ seen[name] = true
+ out = append(out, name)
+ }
+ var defaults map[string]string
+ if len(serverDefaults) > 0 {
+ defaults = make(map[string]string, len(serverDefaults))
+ for name, val := range serverDefaults {
+ if val == "" {
+ continue
+ }
+ defaults[name] = val
+ }
+ if len(defaults) == 0 {
+ defaults = nil
+ }
+ }
+ return out, defaults
+}
+
// resolveServerURL applies template-variable substitution and protocol
// normalization to an OpenAPI Server, returning either an absolute http(s)
// base URL or a relative base path. Empty strings indicate the server entry
@@ -2124,10 +2269,7 @@ func resolveServerURL(server *openapi3.Server) (baseURL, basePath string) {
}
serverURL = serverURL[:start] + serverURL[end+1:]
}
- serverURL = strings.ReplaceAll(serverURL, "//", "/")
- // Restore protocol double-slash if normalization collapsed it.
- serverURL = strings.Replace(serverURL, "http:/", "http://", 1)
- serverURL = strings.Replace(serverURL, "https:/", "https://", 1)
+ serverURL = normalizeURLSlashes(serverURL)
serverURL = strings.TrimRight(serverURL, "/")
if serverURL == "" {
return "", ""
@@ -2136,7 +2278,7 @@ func resolveServerURL(server *openapi3.Server) (baseURL, basePath string) {
if strings.HasPrefix(lowerURL, "http://") || strings.HasPrefix(lowerURL, "https://") {
return serverURL, ""
}
- // Relative URL — caller will need to surface as basePath.
+ // Relative URL; caller will need to surface as basePath.
return "", serverURL
}
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index d0ead1c3..082d4200 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -5828,3 +5828,231 @@ paths:
assert.Empty(t, parsed.EndpointTemplateEnvOverrides)
})
}
+
+// TestParseServerURLVariablesAsTemplateVars covers the multi-tenant SaaS
+// case: when servers[0].url declares a `{var}` placeholder backed by a
+// Variables block, the parser must preserve the placeholder in BaseURL,
+// register the variable as an EndpointTemplateVar, and capture its
+// `default:` value so the generator can fall back at runtime when the
+// user's env var is unset. Without this, the generator bakes the default
+// into BaseURL at generate time and the printed CLI DNS-fails on every
+// call against any tenant other than the spec author's example.
+func TestParseServerURLVariablesAsTemplateVars(t *testing.T) {
+ t.Parallel()
+
+ t.Run("single placeholder with default registers template var", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Freshservice
+ version: 1.0.0
+servers:
+ - url: "https://{domain}/api/v2"
+ variables:
+ domain:
+ default: "yourcompany.freshservice.com"
+paths:
+ /tickets:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Equal(t, "https://{domain}/api/v2", parsed.BaseURL,
+ "placeholder must survive to BaseURL so the runtime can substitute env-var values")
+ assert.Equal(t, []string{"domain"}, parsed.EndpointTemplateVars)
+ assert.Equal(t, "yourcompany.freshservice.com", parsed.EndpointTemplateVarDefaults["domain"],
+ "variable default must be captured for runtime fallback")
+ assert.Equal(t, "FRESHSERVICE_DOMAIN", parsed.EndpointTemplateEnvName("domain"),
+ "env var follows the conventional <APINAME>_<UPPER_PLACEHOLDER> rule")
+ })
+
+ t.Run("static server URL leaves new fields empty", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Static API
+ version: 1.0.0
+servers:
+ - url: https://api.example.com/v1
+paths:
+ /items:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Equal(t, "https://api.example.com/v1", parsed.BaseURL)
+ assert.Empty(t, parsed.EndpointTemplateVars)
+ assert.Empty(t, parsed.EndpointTemplateVarDefaults)
+ })
+
+ t.Run("variable without explicit Variables entry strips legacy placeholder", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Dangling Placeholder API
+ version: 1.0.0
+servers:
+ - url: "https://{foo}.example.com"
+paths:
+ /items:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Equal(t, "https://.example.com", parsed.BaseURL,
+ "dangling placeholders without Variables entries strip away (legacy behavior)")
+ assert.Empty(t, parsed.EndpointTemplateVars)
+ assert.Empty(t, parsed.EndpointTemplateVarDefaults)
+ })
+
+ t.Run("multiple placeholders preserve order and defaults", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Two Var API
+ version: 1.0.0
+servers:
+ - url: "https://{tenant}.example.com/api/{version}"
+ variables:
+ tenant:
+ default: "demo"
+ version:
+ default: "v1"
+paths:
+ /items:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Equal(t, "https://{tenant}.example.com/api/{version}", parsed.BaseURL)
+ assert.Equal(t, []string{"tenant", "version"}, parsed.EndpointTemplateVars,
+ "placeholders must be ordered by left-to-right appearance in the URL")
+ assert.Equal(t, "demo", parsed.EndpointTemplateVarDefaults["tenant"])
+ assert.Equal(t, "v1", parsed.EndpointTemplateVarDefaults["version"])
+ })
+
+ t.Run("placeholder with empty default registers var but no default entry", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Empty Default API
+ version: 1.0.0
+servers:
+ - url: "https://{host}/api"
+ variables:
+ host:
+ default: ""
+paths:
+ /items:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Equal(t, "https://{host}/api", parsed.BaseURL,
+ "placeholder still survives so the env var is required at runtime")
+ assert.Equal(t, []string{"host"}, parsed.EndpointTemplateVars)
+ assert.Empty(t, parsed.EndpointTemplateVarDefaults,
+ "empty defaults must not pollute the defaults map — env var becomes the only fallback")
+ })
+
+ t.Run("x-tenant-env-var override coexists with server-URL placeholder", func(t *testing.T) {
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Combo API
+ version: 1.0.0
+ x-tenant-env-var: COMBO_TENANT_ID
+servers:
+ - url: "https://{domain}/api/v2"
+ variables:
+ domain:
+ default: "demo.example.com"
+paths:
+ /tenant/{tenant}/items:
+ get:
+ 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", "domain"}, parsed.EndpointTemplateVars,
+ "extension-declared placeholders come first, server-URL placeholders follow")
+ assert.Equal(t, "COMBO_TENANT_ID", parsed.EndpointTemplateEnvName("tenant"))
+ assert.Equal(t, "COMBO_DOMAIN", parsed.EndpointTemplateEnvName("domain"))
+ assert.Equal(t, "demo.example.com", parsed.EndpointTemplateVarDefaults["domain"])
+ assert.Empty(t, parsed.EndpointTemplateVarDefaults["tenant"],
+ "path-positional templates from x-tenant-env-var have no spec-level default")
+ })
+
+ t.Run("dangling placeholder after runtime placeholder still gets stripped", func(t *testing.T) {
+ // `{api_version}` has no Variables entry — it must strip away rather
+ // than survive into BaseURL. The earlier `{domain}` is a runtime
+ // placeholder; the strip loop must walk past it (cursor advance),
+ // not terminate, or any later dangling marker leaks into every URL.
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Mixed Placeholder API
+ version: 1.0.0
+servers:
+ - url: "https://{domain}/api/{api_version}"
+ variables:
+ domain:
+ default: "demo.example.com"
+paths:
+ /items:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Equal(t, "https://{domain}/api", parsed.BaseURL,
+ "`{domain}` preserved for runtime; `{api_version}` stripped because it has no Variables entry (trailing slash trimmed)")
+ assert.Equal(t, []string{"domain"}, parsed.EndpointTemplateVars)
+ assert.NotContains(t, parsed.BaseURL, "{api_version}",
+ "dangling placeholders after a runtime placeholder must not survive into BaseURL")
+ })
+
+ t.Run("default with shell-sensitive characters is captured verbatim", func(t *testing.T) {
+ // Defaults flow through to the generated config.go as Go string
+ // literals; the generator must escape them so a default containing
+ // `"`, `\`, or a newline cannot break the printed CLI's compile.
+ // Parser-side it stays verbatim — escape is the emit-time concern.
+ data := []byte(`
+openapi: 3.0.3
+info:
+ title: Quoted Default API
+ version: 1.0.0
+servers:
+ - url: "https://{host}/api"
+ variables:
+ host:
+ default: "a\"b\\c"
+paths:
+ /items:
+ get:
+ responses:
+ "200": {description: ok}
+`)
+ parsed, err := Parse(data)
+ require.NoError(t, err)
+ assert.Equal(t, `a"b\c`, parsed.EndpointTemplateVarDefaults["host"],
+ "default captured verbatim; generator must use %q to escape at emit time")
+ })
+}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 1c4feb6c..ce70ca61 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -83,6 +83,12 @@ type CLIManifest struct {
AuthAdditionalHeaders []spec.AdditionalAuthHeader `json:"auth_additional_headers,omitempty"`
EndpointTemplateVars []string `json:"endpoint_template_vars,omitempty"`
EndpointTemplateEnvOverrides map[string]string `json:"endpoint_template_env_overrides,omitempty"`
+ // EndpointTemplateVarDefaults mirrors APISpec.EndpointTemplateVarDefaults
+ // so a regenerating run, the MCPB manifest's user_config default fill,
+ // and the public-library republish path all see the same fallback values
+ // the parser captured from the spec. Empty for path-positional templates
+ // (x-tenant-env-var style) since those have no spec-level default.
+ EndpointTemplateVarDefaults map[string]string `json:"endpoint_template_var_defaults,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.
@@ -417,6 +423,7 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
m.AuthAdditionalHeaders = parsed.Auth.AdditionalHeaders
m.EndpointTemplateVars = parsed.EndpointTemplateVars
m.EndpointTemplateEnvOverrides = parsed.EndpointTemplateEnvOverrides
+ m.EndpointTemplateVarDefaults = parsed.EndpointTemplateVarDefaults
m.AuthKeyURL = parsed.Auth.KeyURL
m.AuthTitle = parsed.Auth.Title
m.AuthDescription = parsed.Auth.Description
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index f75094eb..18d00e76 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -822,18 +822,42 @@ func TestWriteMCPBManifest(t *testing.T) {
shop, ok := got.UserConfig["shopify_shop"]
require.True(t, ok)
assert.Equal(t, "SHOPIFY_SHOP", shop.Title)
- assert.True(t, shop.Required)
+ assert.True(t, shop.Required, "{shop} has no spec-level default; user must supply it")
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.False(t, apiVersion.Required, "spec-defaulted vars are optional in MCPB user_config; presenting Required+Default together is contradictory and causes strict MCPB hosts to block install with the default pre-filled")
assert.Equal(t, "2026-04", apiVersion.Default)
assert.Contains(t, apiVersion.Description, "{api_version}")
})
+ t.Run("endpoint template var with spec-declared default is optional in user_config", func(t *testing.T) {
+ dir := t.TempDir()
+ writeManifest(t, dir, CLIManifest{
+ APIName: "freshservice",
+ DisplayName: "Freshservice",
+ MCPBinary: "freshservice-pp-mcp",
+ MCPReady: "full",
+ AuthType: "api_key",
+ AuthEnvVars: []string{"FRESHSERVICE_API_KEY"},
+ EndpointTemplateVars: []string{"domain"},
+ EndpointTemplateVarDefaults: map[string]string{"domain": "yourcompany.freshservice.com"},
+ })
+
+ require.NoError(t, WriteMCPBManifest(dir))
+ got := readMCPBManifest(t, dir)
+
+ domain, ok := got.UserConfig["freshservice_domain"]
+ require.True(t, ok)
+ assert.Equal(t, "yourcompany.freshservice.com", domain.Default,
+ "spec-declared default flows through to MCPB user_config")
+ assert.False(t, domain.Required,
+ "Required: false avoids the install-blocking contradiction when MCPB hosts honor `required` strictly")
+ })
+
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 c4e359ca..ed8a4d7b 100644
--- a/internal/pipeline/mcpb_manifest.go
+++ b/internal/pipeline/mcpb_manifest.go
@@ -318,8 +318,13 @@ func buildMCPBEnv(m CLIManifest) map[string]string {
// 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.
+// required. Endpoint template vars are required only when the spec offers
+// no fallback default: path-positional placeholders (Shopify {shop},
+// ServiceTitan {tenant}) have no spec-level default and must be supplied,
+// but a server-URL variable carrying a `default:` value resolves at runtime
+// without user input, so marking it Required = true alongside a Default
+// presents Claude Desktop with a contradictory user_config (required field
+// pre-filled with a vendor-placeholder value the user is unlikely to want).
func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
authEnvVarSpecs := mcpbUserConfigAuthEnvVars(m)
if len(authEnvVarSpecs) == 0 && len(m.EndpointTemplateVars) == 0 {
@@ -340,12 +345,13 @@ func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
}
for _, templateVar := range m.EndpointTemplateVars {
name := endpointTemplateEnvVar(m, templateVar)
+ defaultValue := endpointTemplateDefault(m, templateVar)
vars[userConfigKey(name)] = MCPBVar{
Type: mcpbVarTypeString,
Title: name,
Description: endpointTemplateVarDescription(templateVar, name),
- Required: true,
- Default: endpointTemplateDefault(m, templateVar),
+ Required: defaultValue == "",
+ Default: defaultValue,
}
}
return vars
@@ -438,6 +444,9 @@ func authUserConfigText(m CLIManifest, envVar spec.AuthEnvVar, required bool, si
}
func endpointTemplateDefault(m CLIManifest, templateVar string) string {
+ if v := m.EndpointTemplateVarDefaults[templateVar]; v != "" {
+ return v
+ }
if strings.EqualFold(templateVar, "api_version") {
return m.APIVersion
}
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 17e1a5ef..fee7801f 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -248,6 +248,7 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
m.AuthEnvVarSpecs = existing.AuthEnvVarSpecs
m.EndpointTemplateVars = existing.EndpointTemplateVars
m.EndpointTemplateEnvOverrides = existing.EndpointTemplateEnvOverrides
+ m.EndpointTemplateVarDefaults = existing.EndpointTemplateVarDefaults
m.AuthKeyURL = existing.AuthKeyURL
m.AuthTitle = existing.AuthTitle
m.AuthDescription = existing.AuthDescription
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 074225cd..283d7162 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -126,31 +126,40 @@ type APISpec struct {
// 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.
+ EndpointTemplateEnvOverrides map[string]string `yaml:"endpoint_template_env_overrides,omitempty" json:"endpoint_template_env_overrides,omitempty"`
+ // EndpointTemplateVarDefaults maps a placeholder in EndpointTemplateVars
+ // to a spec-declared default value. Populated for server-URL variables
+ // (OpenAPI `servers[0].url.variables.<name>.default`) so the generator
+ // can emit a runtime fallback in config.Load() — when the user's env
+ // var is unset, the default substitutes into BaseURL and doctor still
+ // has a real URL to probe. Path-positional templates (x-tenant-env-var
+ // style) leave this empty; there is no spec-level default for a
+ // tenant ID.
+ EndpointTemplateVarDefaults map[string]string `yaml:"endpoint_template_var_defaults,omitempty" json:"endpoint_template_var_defaults,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 {
@@ -197,6 +206,16 @@ func DefaultEndpointTemplateEnvName(apiName, placeholder string) string {
return strings.ToUpper(strings.ReplaceAll(naming.Snake(apiName), "-", "_") + "_" + strings.ReplaceAll(naming.Snake(placeholder), "-", "_"))
}
+// EndpointTemplateDefault returns the spec-declared default value for the
+// given placeholder, or "" when none is registered. Empty for path-positional
+// templates that have no spec-level fallback.
+func (s *APISpec) EndpointTemplateDefault(placeholder string) string {
+ if s == nil {
+ return ""
+ }
+ return s.EndpointTemplateVarDefaults[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.
← c57345a1 fix(cli): align publish-skill contract tests with api-slug s
·
back to Cli Printing Press
·
fix(cli): propagate PRINTING_PRESS_VERIFY=1 to browser-sessi dbfc6118 →