Files
mvanhorn__cli-printing-press/internal/generator/generator.go
T
Trevin Chow ffbde9923f fix(cli): stop selecting missing comm_health workflow template (#4665)
The communication archetype still listed workflows/comm_health.go.tmpl
after that file was never added to the embed, so every generate against
a communication spec warned and skipped it. Drop the dead selector and
keep selected workflow/insight templates locked to files that exist.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Trevin Chow <tmchow@users.noreply.github.com>
2026-09-10 12:32:59 +00:00

10099 lines
349 KiB
Go

package generator
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"go/format"
"maps"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"text/template"
"time"
"unicode"
"github.com/mvanhorn/cli-printing-press/v4/internal/browsersniff"
"github.com/mvanhorn/cli-printing-press/v4/internal/mcpdesc"
"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
"github.com/mvanhorn/cli-printing-press/v4/internal/piiplaceholders"
"github.com/mvanhorn/cli-printing-press/v4/internal/profiler"
"github.com/mvanhorn/cli-printing-press/v4/internal/shellargs"
"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
//go:embed templates
var templateFS embed.FS
// TemplateFS exposes the embedded template tree for callers outside the
// generator package (e.g. the patch subcommand that renders a subset of
// templates against already-published CLIs).
var TemplateFS = templateFS
// ReadmeSource represents a credited ecosystem tool for the README.
type ReadmeSource struct {
Name string
URL string
Language string
Stars int
}
// NovelFeature represents a transcendence feature for the README and SKILL.md.
type NovelFeature struct {
Name string
Command string
Description string
Rationale string
Example string // ready-to-run invocation
WhyItMatters string // one-sentence agent-facing rationale
Group string // theme name for grouped rendering
}
// QuickStartStep mirrors pipeline.QuickStartStep for template rendering.
type QuickStartStep struct {
Command string
Comment string
}
// Recipe mirrors pipeline.Recipe for README/SKILL template rendering.
type Recipe struct {
Title string
Command string
Explanation string
}
// TroubleshootTip mirrors pipeline.TroubleshootTip for template rendering.
type TroubleshootTip struct {
Symptom string
Fix string
}
// novelFeatureGroup is a template-facing bucket of novel features sharing
// a Group name. Produced by the groupNovelFeatures template helper so the
// README/SKILL templates don't have to do collection logic in-template.
type novelFeatureGroup struct {
Name string
Features []NovelFeature
}
// ReadmeNarrative mirrors pipeline.ReadmeNarrative for template rendering.
// Holds LLM-authored prose that makes generated docs feel like product
// documentation rather than scaffolding. All fields are optional.
type ReadmeNarrative struct {
DisplayName string
Headline string
ValueProp string
AuthNarrative string
QuickStart []QuickStartStep
Troubleshoots []TroubleshootTip
WhenToUse string
AntiTriggers []string
Recipes []Recipe
TriggerPhrases []string
}
// DomainContext holds structured domain knowledge for MCP-connected agents.
// Front-loaded at session start so agents understand the API without discovery.
type DomainContext struct {
APIName string `json:"api_name"`
Description string `json:"description"`
Archetype string `json:"archetype"`
Resources []ResourceSummary `json:"resources"`
QueryTips []string `json:"query_tips,omitempty"`
Playbook []PlaybookEntry `json:"playbook,omitempty"`
}
// ResourceSummary describes an API resource and its capabilities for agents.
type ResourceSummary struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Endpoints []string `json:"endpoints"`
Syncable bool `json:"syncable,omitempty"`
Searchable bool `json:"searchable,omitempty"`
Writable bool `json:"writable,omitempty"`
}
// PlaybookEntry is a domain-specific insight for agents.
type PlaybookEntry struct {
Topic string `json:"topic"`
Insight string `json:"insight"`
}
type Generator struct {
Spec *spec.APISpec
OutputDir string
VisionSet VisionTemplateSet
visionCommandNames map[string]string
FixtureSet *browsersniff.FixtureSet
TrafficAnalysis *browsersniff.TrafficAnalysis
Sources []ReadmeSource // Ecosystem tools to credit in README
DiscoveryPages []string // Pages visited during browser-sniff discovery
NovelFeatures []NovelFeature // Transcendence features for README/SKILL
Narrative *ReadmeNarrative // LLM-authored prose for README/SKILL; optional
// Partial regeneration must retain generated intent wiring because the
// narrative source used to lift recipe intents is no longer available.
PreserveMCPIntentRegistration bool
// PreserveMCPIntentFile keeps a legacy combined intents.go untouched when
// no explicit intents need regeneration.
PreserveMCPIntentFile bool
AsyncJobs map[string]AsyncJobInfo // Detected async-job endpoints, keyed by "<resource>/<endpoint>"
// ModulePath overrides the Go module import path emitted by templates that
// reference internal packages (`{{modulePath}}/internal/client`, etc.).
// Defaults to `<api>-pp-cli` when empty — matches the standalone-publish
// shape. Set explicitly when regenerating a CLI that lives under a
// different go.mod, e.g. library checkouts where the module path is the
// repo-prefixed full path. Read by mcp-sync from the existing go.mod.
ModulePath string
// Promoted-command plan, populated by Generate() before any rendering so
// SKILL/README templates can honor leaf promotion (and not emit phantom paths
// like `<cli> qr get-qrcode` for a resource the generator collapsed to `qr`).
PromotedCommands []PromotedCommand
PromotedResourceNames map[string]bool
PromotedEndpointNames map[string]string
profile *profiler.APIProfile
funcs template.FuncMap
templates map[string]*template.Template
htmlSyncStubComputed bool
htmlSyncStub bool
htmlSyncStubSelected bool
learnStorePromoted bool
mcpParamDescriptions *mcpdesc.ParamDescriptionCompactor
}
func New(s *spec.APISpec, outputDir string) *Generator {
normalizeGoogleServiceAccountAuth(s)
s.InferEndpointTemplateVarsFromBaseURLs()
s.EnrichPathParams()
s.PromoteGlobalPathTemplateVars()
normalizeSubstackGlobalWriterRoutes(s)
// Resolve the creator + contributors (the canonical attribution model),
// preserving persisted values across regens so a regen never flips the
// creator to whoever is running the generator. The legacy
// Owner/OwnerName/Printer/PrinterName fields are derived from the creator
// below (dual-write) so older skills and library tooling that still read
// them keep working during the transition window.
if s.Creator.IsZero() {
switch {
case s.Printer != "" || s.PrinterName != "":
// Printer names the human — bridge it into the creator in full.
s.Creator = spec.Person{Handle: s.Printer, Name: s.PrinterName}
case s.OwnerName != "" || s.Owner != "":
// An explicitly-set legacy owner drove the copyright header before
// the creator model existed; preserve that for backward-compat by
// seeding only the creator NAME. Handle is left empty so the owner
// slug never leaks into the printer byline (it is the vendor/module
// identity, not the human).
name := s.OwnerName
if name == "" {
name = s.Owner
}
s.Creator = spec.Person{Name: name}
default:
s.Creator = resolveCreatorForExisting(outputDir, s.Name)
}
}
if s.Contributors == nil {
s.Contributors = resolveContributorsForExisting(outputDir, s.Name)
}
// Strip attribution-unsafe characters once at the source so every render
// surface (copyright header, README byline, NOTICE) inherits clean values
// rather than each escaping independently.
s.Creator = s.Creator.Clean()
for i := range s.Contributors {
s.Contributors[i] = s.Contributors[i].Clean()
}
// Owner is the slug form (Go-module-adjacent, copyright-recoverable).
// Derive it from the creator handle when not explicitly set, then always
// sanitize before rendering generated files.
if s.Owner == "" {
s.Owner = s.Creator.Handle
}
s.Owner = sanitizeOwner(s.Owner)
// OwnerName / Printer / PrinterName are prose- or handle-shaped legacy
// fields, preserved verbatim. Empty creator-derived values are validated
// (soft) in Generate() before any file writes.
if s.OwnerName == "" {
s.OwnerName = s.Creator.Name
}
if s.Printer == "" {
s.Printer = s.Creator.Handle
}
if s.PrinterName == "" {
s.PrinterName = s.Creator.Name
}
g := &Generator{
Spec: s,
OutputDir: outputDir,
templates: make(map[string]*template.Template),
}
g.funcs = template.FuncMap{
"title": cases.Title(language.English).String,
"lower": strings.ToLower,
"upper": strings.ToUpper,
"trimSpace": strings.TrimSpace,
"join": strings.Join,
"camel": toCamel,
"cmdIdent": commandIdent,
"subResourceCmdIdent": subResourceCmdIdent,
"snake": naming.Snake,
"pascal": toPascal,
"goType": goType,
"goStructType": goStructType,
"goTypeForParam": goTypeForParam,
"goTypeForParamRequired": goTypeForParamRequired,
"goTypeForBodyParam": goTypeForBodyParam,
"goStoreType": goStoreType,
"cobraFlagFunc": cobraFlagFunc,
"cobraFlagFuncForParam": cobraFlagFuncForParam,
"cobraFlagFuncForParamRequired": cobraFlagFuncForParamRequired,
"cobraFlagFuncForBodyParam": cobraFlagFuncForBodyParam,
"mcpBindingFunc": mcpBindingFunc,
"recipeParamTypeString": func(t RecipeIntentParamType) string { return string(t) },
"defaultVal": defaultVal,
"defaultValForParam": defaultValForParam,
"defaultValForParamRequired": defaultValForParamRequired,
"defaultValForBodyParam": defaultValForBodyParam,
"hasDefault": paramHasDefault,
"isConstDefault": paramIsConstDefault,
"zeroVal": zeroVal,
"zeroValForParam": zeroValForParam,
"zeroValForParamRequired": zeroValForParamRequired,
"zeroValForBodyParam": zeroValForBodyParam,
"paramIsHeader": paramIsHeader,
"paramPresenceExpr": paramPresenceExpr,
"readParamPresenceExpr": readParamPresenceExpr,
"endpointHasHeaderParams": endpointHasHeaderParams,
"positionalArgs": positionalArgs,
"configTag": configTag,
"camelToJSON": camelToJSON,
"columnNames": columnNames,
"columnPlaceholders": columnPlaceholders,
"updateSet": updateSet,
"envVarField": envVarField,
"envVarPlaceholder": naming.EnvVarPlaceholder,
"envVarIsBuiltinField": envVarIsBuiltinField,
"envVarBuiltinFieldName": envVarBuiltinFieldName,
"resolveEnvVarField": resolveEnvVarField,
"pathKindEnvSuffix": pathKindEnvSuffix,
"authPlacement": authPlacement,
"authParameterName": authParameterName,
"isBrowserCookieAuth": isBrowserCookieAuth,
"authCommandShort": authCommandShort,
"authHarvestedEnvHint": authHarvestedEnvHint,
"oauth2AccessTokenAuth": oauth2AccessTokenAuth,
"oauth2DirectBearerEnvFallback": oauth2DirectBearerEnvFallback,
"oauth2AuthSource": oauth2AuthSource,
"basicAuthEnvVars": basicAuthEnvVars,
"basicAuthAppendsColonForSingleToken": basicAuthAppendsColonForSingleToken,
"clientCredentialsEnvVars": clientCredentialsEnvVars,
"deviceCodeEnvVars": deviceCodeEnvVars,
"clientCredentialsScope": clientCredentialsScope,
"clientCredentialsScopeUsesClientID": clientCredentialsScopeUsesClientID,
"clientCredentialsTenantEnvVar": clientCredentialsTenantEnvVar,
"clientCredentialsTokenURLHasTenant": clientCredentialsTokenURLHasTenant,
"hasNonCookieAuth": hasNonCookieAuth,
"authAgentEnvVars": authAgentEnvVars,
"skillAuthNarrative": skillAuthNarrative,
"hasAuthEnvVarKind": hasAuthEnvVarKind,
"isRequestAuthEnvVar": isRequestAuthEnvVar,
"requiredRequestAuthEnvVars": requiredRequestAuthEnvVars,
"optionalRequestAuthEnvVars": optionalRequestAuthEnvVars,
"requiredRequestAuthEnvVarCount": requiredRequestAuthEnvVarCount,
"requestAuthEnvVarCount": requestAuthEnvVarCount,
"hasRequestAuthEnvVarField": hasRequestAuthEnvVarField,
"authSetTokenAvailable": authSetTokenAvailable,
"authSetCredentialsAvailable": authSetCredentialsAvailable,
"authCredentialPersistenceCommand": authCredentialPersistenceCommand,
"authCredentialConsolidationAction": authCredentialConsolidationAction,
"authPlaceholderCredentialSetup": authPlaceholderCredentialSetup,
"authErrorCheckHint": authErrorCheckHint,
"authSetupHint": authSetupHint,
"authBrowserLoginAvailable": authBrowserLoginAvailable,
"authEnvPlaceholder": authEnvPlaceholder,
"authEnvPlaceholderByName": authEnvPlaceholderByName,
"authEnvHintComment": authEnvHintComment,
"effectiveTier": effectiveTier,
"effectiveSubTier": effectiveSubTier,
"add": func(a, b int) int { return a + b },
"chomp": func(s string) string { return strings.TrimRight(s, "\r\n") },
"staleAfterExpr": staleAfterExpr,
"oneline": naming.OneLine,
"composeMCPDesc": composeMCPDesc,
"composeMCPSubDesc": composeMCPSubDesc,
"mcpParamDesc": g.mcpParamDescription,
"hasDefaultSyncResources": hasDefaultSyncResources,
"syncHintInvocation": g.syncHintInvocation,
"syncHintIsBare": g.syncHintIsBare,
"flagName": flagName,
"paramIdent": paramIdent,
"paramWireName": paramWireName,
"isArrayQueryParam": isArrayQueryParam,
"isDeepObjectQueryParam": isDeepObjectQueryParam,
"queryParamStyle": queryParamStyle,
"queryParamExplodes": queryParamExplodes,
"hasArrayQueryParams": hasArrayQueryParams,
"hasDeepObjectQueryParams": hasDeepObjectQueryParams,
"hasIndexedOrArrayQueryParams": hasIndexedOrArrayQueryParams,
"typeFieldIdent": typeFieldIdent,
"typeFieldJSONTagComment": typeFieldJSONTagComment,
"safeTypeName": safeTypeName,
"hasNonScalarType": func(types map[string]spec.TypeDef) bool {
for _, td := range types {
for _, f := range td.Fields {
if f.Type == "object" || f.Type == "array" {
return true
}
}
}
return false
},
"exampleLine": g.exampleLine,
"promotedExampleLine": g.promotedExampleLine,
"endpointHappyArgs": endpointHappyArgs,
"commandExampleArgs": commandExampleArgs,
"currentYear": func() string { return strconv.Itoa(time.Now().Year()) },
"copyrightHolder": func() string {
return copyrightHolderString(g.Spec.Creator, g.Spec.OwnerName, g.Spec.Owner)
},
"endsSentence": endsSentence,
"modulePath": func() string {
if g.ModulePath != "" {
return g.ModulePath
}
return naming.CLI(s.Name)
},
"cliName": naming.CLI,
"mcpName": naming.MCP,
"goDirectiveVersion": resolveCurrentGoDirectiveVersion,
"goToolchainVersion": resolveCurrentGoToolchainVersion,
"graphqlQueryField": graphqlQueryField,
"graphqlFieldSelection": func(typeName string, types map[string]spec.TypeDef) []string {
return graphqlFieldSelection(typeName, types)
},
"compactFieldMapLiteral": compactFieldMapLiteral,
"isGraphQL": isGraphQLSpec,
"localReadIsList": localReadIsList,
"localReadSupported": localReadSupported,
"dataSourceStrategy": spec.EffectiveDataSourceStrategy,
"networkFallbackReason": networkFallbackReason,
"exportableResources": exportableResources,
"resourceReadPathEntries": resourceReadPathEntries,
"resourceDetailPathEntries": resourceDetailPathEntries,
"resourceWritePathEntries": resourceWritePathEntries,
"backtick": func() string { return "`" },
"kebab": toKebab,
"humanName": naming.HumanName,
"envPrefix": naming.EnvPrefix,
"mcpToolName": naming.SnakeIdentifier,
"lookupEndpoint": func(api *spec.APISpec, ref string) templateEndpoint {
e, _ := lookupEndpointForTemplate(api, ref)
return e
},
"intentIsReadOnly": intentIsReadOnly,
"intentIsDestructive": intentIsDestructive,
"composeIntentToolDescription": composeIntentToolDescription,
"intentParamsForEmit": intentParamsForEmit,
"intentParamDefaultGo": intentParamDefaultGo,
"effectiveEndpointPath": effectiveEndpointPath,
"effectiveSubEndpointPath": effectiveSubEndpointPath,
"enumLiteral": enumLiteral,
"enumDescriptionHint": enumDescriptionHint,
"jsonStringParam": isJSONStringParam,
"jsonEnumSuggestion": jsonEnumSuggestion,
"bodyMap": bodyMap,
"bodyMapForEndpoint": bodyMapForEndpoint,
"bodyMapForEndpointVars": bodyMapForEndpointVars,
"assignJSONBodyMap": assignJSONBodyMap,
"declareJSONBodyMap": declareJSONBodyMap,
"bodyVarDecls": bodyVarDecls,
"bodyFlagRegs": bodyFlagRegs,
"bodyRequiredChecks": bodyRequiredChecks,
"bodyExceedsFlagDepth": bodyExceedsFlagDepth,
"bodyHasStringBackedBool": bodyHasStringBackedBool,
"multipartBodyMaps": multipartBodyMaps,
"endpointUsesMultipart": endpointUsesMultipart,
"endpointUsesRawRequest": endpointUsesRawRequest,
"endpointUsesCSVArray": endpointUsesCSVArray,
"endpointHasQueryFlags": endpointHasQueryFlags,
"endpointHasRequestParams": endpointHasRequestParams,
"endpointHasRequiredInput": endpointHasRequiredInput,
"endpointSkipsErrorPathProbe": endpointSkipsErrorPathProbe,
"endpointIsReadCommand": func(endpoint spec.Endpoint, opName string) bool {
return endpointIsReadCommandShared(endpoint, opName, sharedGETRPCPaths(g.Spec.Resources))
},
"hasMultipartRequest": hasMultipartRequest,
"hasRawRequest": hasRawRequest,
"formBodyMaps": formBodyMaps,
"endpointUsesForm": endpointUsesForm,
"hasFormRequest": hasFormRequest,
"hasBodyJSONFallback": hasBodyJSONFallback,
"hasMCPNestedBodyPath": hasMCPNestedBodyPath,
"hasMCPJSONOrScalarBody": hasMCPJSONOrScalarBody,
"hasMCPParamDefault": hasMCPParamDefault,
"publicFlagName": publicFlagName,
"publicFlagAliases": publicFlagAliases,
"flagChangedExpr": flagChangedExpr,
"flagRequiredUnsatisfiedExpr": flagRequiredUnsatisfiedExpr,
"graphqlListParams": graphqlListParams,
"graphqlLatestParams": graphqlLatestParams,
"graphqlVariableType": graphqlVariableType,
"hasGraphQLParam": hasGraphQLParam,
"mcpInputName": mcpInputName,
"mcpToolInputParams": mcpToolInputParams,
"mcpParamBindings": mcpParamBindings,
"mcpEndpointPageable": mcpEndpointPageable,
"mcpPageConfig": mcpPageConfig,
"mcpGlobalTemplateInputParams": mcpGlobalTemplateInputParams,
"mcpGlobalTemplateBindings": mcpGlobalTemplateBindings,
// endpointNeedsClientLimit reports whether a list endpoint needs
// client-side truncation. True when the endpoint has a `limit`-named
// param AND no Pagination block — the spec author asked for a
// limit flag, but didn't declare a server-side paginator. Many
// APIs (Firebase, file-backed JSON dumps, RSS feeds) accept a
// `?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,
"endpointClientSideFilters": endpointClientSideFilters,
"globalScopeParams": globalScopeParams,
"responsePathCases": responsePathCases,
"syncParamDefaultCases": syncParamDefaultCases,
"syncRequiredQueryCases": syncRequiredQueryCases,
"syncHiddenHistoryCases": syncHiddenHistoryCases,
"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 and
// is not a colliding auth/credential name, 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)
},
"globalScopeEnvName": func(param spec.Param) string {
return globalScopeEnvName(s.Name, param)
},
"globalScopeFallbackValue": globalScopeFallbackValue,
"paramHasEnvDefault": paramHasEnvDefault,
// 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 == "" })
},
"hasSubstackPublicationIDTemplateResolver": hasSubstackPublicationIDTemplateResolver,
"substackPublicationIDTemplatePath": substackPublicationIDTemplatePath,
"safeName": safeSQLName,
"resourceIDFieldOverrideEntries": resourceIDFieldOverrideEntries,
"resourceIDBaseOverrideEntries": resourceIDBaseOverrideEntries,
"resourceParentKeyColumnEntries": resourceParentKeyColumnEntries,
"criticalResourceEntries": criticalResourceEntries,
"isBackfillColumn": isStoreBackfillColumn,
"hasBackfillColumns": hasStoreBackfillColumns,
"backfillDecl": storeBackfillDecl,
"safeNameSuffix": func(name, suffix string) string {
return safeSQLName(name + suffix)
},
"sqlString": sqlStringLiteral,
"hasDomainUpsert": func(name string) bool {
return domainUpsertMethodName(name) != "UpsertBatch"
},
// emitsDomainTable is the single source of truth for "this table gets
// a writable per-resource table and Upsert<X>." Table creation,
// typed-Upsert generation, the UpsertBatch dispatch switch, and the
// populated-table tests must all gate on the same predicate; otherwise
// dead tables (created but never written to) leak in for resources whose
// names hit the framework-cobra rename. JSONOnlyFallback intentionally
// keeps a writable per-resource table while dropping extracted columns.
"emitsDomainTable": emitsDomainTable,
"reconcileTypedTableEntries": reconcileTypedTableEntries,
"pathContainsParam": func(path, name string) bool {
return strings.Contains(path, "{"+name+"}")
},
"positionalIndex": positionalIndex,
"safeJoin": func(fields []string, sep string) string {
safe := make([]string, len(fields))
for i, f := range fields {
safe[i] = safeSQLName(f)
}
return strings.Join(safe, sep)
},
"goLiteral": func(v any) string {
// A nil default — common when the spec declares a field with no
// default value — must render as the Go keyword `nil`, not `<nil>`
// (which is what `fmt.Sprintf("%v", nil)` produces and which the
// Go compiler rejects). Without this branch, search.go and other
// generated files emit invalid syntax for spec fields with missing
// defaults.
if v == nil {
return "nil"
}
switch val := v.(type) {
case string:
return fmt.Sprintf("%q", val)
case int:
return strconv.Itoa(val)
case float64:
if val == float64(int(val)) {
return strconv.Itoa(int(val))
}
return fmt.Sprintf("%g", val)
case bool:
if val {
return "true"
}
return "false"
case []string:
parts := make([]string, len(val))
for i, s := range val {
parts[i] = fmt.Sprintf("%q", s)
}
return "[]any{" + strings.Join(parts, ", ") + "}"
case []any:
parts := make([]string, len(val))
for i, item := range val {
parts[i] = fmt.Sprintf("%q", fmt.Sprint(item))
}
return "[]any{" + strings.Join(parts, ", ") + "}"
case map[string]any:
return "map[string]any{}"
default:
return fmt.Sprintf("%v", v)
}
},
"firstResource": func(resources map[string]spec.Resource) string {
var names []string
for name := range resources {
names = append(names, name)
}
sort.Strings(names)
if len(names) > 0 {
return names[0]
}
return "resource"
},
// goRawSafe makes a string safe to embed inside a Go raw-string literal
// (backtick-delimited). Go raw strings cannot contain backticks —
// there's no escape — so the compiler rejects the file outright.
// Narrative fields are LLM-authored and routinely contain backticks
// (e.g. "the `--agent` flag"), so stripping is mandatory before
// rendering into Short/Long. Replaces ` with ' to preserve intent.
"goRawSafe": func(s string) string {
return strings.ReplaceAll(s, "`", "'")
},
// goString escapes a value so it is safe to interpolate inside a
// generated double-quoted Go string literal. Resource keys and
// other spec-derived tokens can carry ", \, or newlines.
"goString": goStringLiteralContent,
// truncate clips a string to max runes with an ellipsis. Used to
// enforce the root --help Long size budget: LLM-authored headlines
// and novel-feature descriptions have no inherent length ceiling,
// and agents running <cli> --help shouldn't be punished for one
// verbose absorb output. Counts runes (not bytes) so multi-byte
// characters don't produce mid-codepoint truncation.
"truncate": func(max int, s string) string {
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max <= 1 {
return string(runes[:max])
}
return string(runes[:max-1]) + "…"
},
// truncateWords keeps root Short within budget without ending on a
// partial word. A single long token still hard-clips to honor the cap.
"truncateWords": func(max int, s string) string {
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max <= 1 {
return string(runes[:max])
}
cut := runes[:max-1]
boundary := -1
for i, r := range slices.Backward(cut) {
if unicode.IsSpace(r) {
boundary = i
break
}
}
if boundary > 0 {
cut = cut[:boundary]
}
return strings.TrimRightFunc(string(cut), unicode.IsSpace) + "…"
},
"yamlDoubleQuoted": yamlDoubleQuoted,
// groupNovelFeatures clusters features by their Group field, preserving
// first-seen order of group names. Features with empty Group land in a
// trailing "More" bucket so nothing gets dropped. Returns nil when no
// feature carries a Group value — callers should then render flat.
//
// Group matching is canonicalized (lowercase + whitespace collapsed)
// because the absorb LLM will not produce exact-match strings — given
// five features in "Local state that compounds" it will usually emit
// at least one "Local State That Compounds" or "local state that
// compounds" by drift. Without canonicalization these silently render
// as separate groups and a reader skimming the README sees the
// grouping as broken. We canonicalize for bucketing but render the
// first-seen display form so the LLM's casing choice wins — it's
// usually the more legible one.
"groupNovelFeatures": func(features []NovelFeature) []novelFeatureGroup {
canonGroup := func(s string) string {
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}
anyGrouped := false
for _, f := range features {
if canonGroup(f.Group) != "" {
anyGrouped = true
break
}
}
if !anyGrouped {
return nil
}
order := []string{} // canonical keys in first-seen order
displayName := map[string]string{} // canonical → first-seen display form
byGroup := map[string][]NovelFeature{}
for _, f := range features {
display := f.Group
key := canonGroup(display)
if key == "" {
key = "more"
display = "More"
}
if _, seen := byGroup[key]; !seen {
order = append(order, key)
displayName[key] = display
}
byGroup[key] = append(byGroup[key], f)
}
out := make([]novelFeatureGroup, 0, len(order))
for _, key := range order {
out = append(out, novelFeatureGroup{Name: displayName[key], Features: byGroup[key]})
}
return out
},
"firstCommandExample": firstCommandExample,
}
return g
}
func normalizeGoogleServiceAccountAuth(s *spec.APISpec) {
if s == nil || s.Auth.Subtype != spec.AuthSubtypeGoogleServiceAccount {
return
}
s.Auth.AuthorizationURL = ""
s.Auth.EnvVars = []string{"GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_OAUTH_ACCESS_TOKEN"}
s.Auth.EnvVarSpecs = spec.NewORCaseEnvVarSpecs(s.Auth.EnvVars)
}
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 hasSubstackPublicationIDTemplateResolver(s *spec.APISpec) bool {
if s == nil || !strings.EqualFold(s.Name, "substack") || !s.IsEndpointTemplateVar("publication_id") {
return false
}
return specWalkEndpoints(s, func(_ string, endpoint spec.Endpoint) bool {
return substackPublicationIDTemplatePath(s, endpoint.Path)
})
}
func substackPublicationIDTemplatePath(s *spec.APISpec, path string) bool {
return s != nil && strings.EqualFold(s.Name, "substack") && strings.Contains(path, "{publication_id}")
}
func normalizeSubstackGlobalWriterRoutes(s *spec.APISpec) {
if s == nil || !strings.EqualFold(s.Name, "substack") || !s.IsEndpointTemplateVar("publication_id") {
return
}
for resourceName, resource := range s.Resources {
for endpointName, endpoint := range resource.Endpoints {
if normalized, ok := substackGlobalWriterPath(resourceName, endpointName, endpoint.Path); ok {
endpoint.Path = normalized
resource.Endpoints[endpointName] = endpoint
}
}
for subName, sub := range resource.SubResources {
for endpointName, endpoint := range sub.Endpoints {
if normalized, ok := substackGlobalWriterPath(subName, endpointName, endpoint.Path); ok {
endpoint.Path = normalized
sub.Endpoints[endpointName] = endpoint
}
}
resource.SubResources[subName] = sub
}
s.Resources[resourceName] = resource
}
}
func substackGlobalWriterPath(resourceName, endpointName, path string) (string, bool) {
resourceName = strings.ToLower(strings.TrimSpace(resourceName))
endpointName = strings.ToLower(strings.TrimSpace(endpointName))
path = strings.TrimSpace(path)
if resourceName == "images" && substackImageUploadPath(path) {
return "https://substack.com/api/v1/image", path != "https://substack.com/api/v1/image"
}
if resourceName != "drafts" {
return "", false
}
if !substackDraftsPath(path) {
return "", false
}
if endpointName == "" {
return "", false
}
trimmed := strings.TrimPrefix(path, "https://{publication}.substack.com/api/v1")
trimmed = strings.TrimPrefix(trimmed, "https://substack.com/api/v1")
if !strings.HasPrefix(trimmed, "/drafts") {
trimmed = "/drafts"
}
normalized := "https://substack.com/api/v1" + ensurePublicationIDQuery(trimmed)
return normalized, normalized != path
}
func substackImageUploadPath(path string) bool {
if path == "/image" || strings.HasPrefix(path, "/image?") {
path = "https://substack.com/api/v1" + path
}
for _, prefix := range []string{
"https://substack.com/api/v1/image",
"https://{publication}.substack.com/api/v1/image",
} {
if path == prefix || strings.HasPrefix(path, prefix+"?") {
return true
}
}
return false
}
func substackDraftsPath(path string) bool {
if strings.HasPrefix(path, "/drafts") {
return true
}
return strings.HasPrefix(path, "https://{publication}.substack.com/api/v1/drafts") || strings.HasPrefix(path, "https://substack.com/api/v1/drafts")
}
func ensurePublicationIDQuery(path string) string {
if strings.Contains(path, "publication_id=") {
return path
}
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
return path + sep + "publication_id={publication_id}"
}
func specWalkEndpoints(s *spec.APISpec, visit func(resourceName string, endpoint spec.Endpoint) bool) bool {
if s == nil {
return false
}
for resourceName, resource := range s.Resources {
if resourceWalkEndpoints(resourceName, resource, visit) {
return true
}
}
return false
}
func resourceWalkEndpoints(resourceName string, resource spec.Resource, visit func(resourceName string, endpoint spec.Endpoint) bool) bool {
for _, endpoint := range resource.Endpoints {
if visit(resourceName, endpoint) {
return true
}
}
for subResourceName, subResource := range resource.SubResources {
name := resourceName + "." + subResourceName
if resourceWalkEndpoints(name, subResource, visit) {
return true
}
}
return false
}
// HelperFlags controls which helper functions are emitted in helpers.go.
type HelperFlags struct {
HasDelete bool // spec has DELETE endpoints → emit classifyDeleteError
HasPathParams bool // spec has path parameters → emit replacePathParam
HasMultiPositional bool // spec has endpoints with 2+ positional params → emit usageErr
HasDataLayer bool // CLI has a local store (sync/search) → emit provenance helpers
HasStorePath bool // CLI has any local store, including a learn-only store
HasSyncHelpers bool // generated sync implementation calls sync-only helpers
HasClientLimit bool // at least one endpoint needs client-side limit truncation → emit truncateJSONArray
HasClientFilters bool // at least one docs-derived endpoint needs client-side response filtering
HasEmbeddedPaged bool // at least one GET endpoint has detected embedded paged sub-resources → emit fetchEmbeddedPagedSubresource
HasResponseUnwrap bool // at least one generated command can call extractResponseData
HasMutationEndpoints bool // emitted commands can detect partial failures → emit partial-failure support + --allow-partial-failure flag
HasPartialFailureErr bool // emitted command_endpoint.go command can call partialFailureErr
HasRequiredRoles bool // spec has per-endpoint requires_role gates → emit persona helpers
HasCreateCommands bool // spec has POST/PUT/PATCH write endpoints → emit create retry helpers
HasRawRequest bool // spec has non-JSON request bodies → emit raw file/stdin reader
}
// computeHelperFlags scans the spec's resources to determine which helpers are needed.
func computeHelperFlags(s *spec.APISpec) HelperFlags {
var flags HelperFlags
for _, r := range s.Resources {
var scan func(spec.Resource)
scan = func(resource spec.Resource) {
for name, e := range resource.Endpoints {
if strings.EqualFold(e.Method, "DELETE") {
flags.HasDelete = true
}
if endpointIsWriteCommand(e, name) && isMutationMethod(e.Method) {
flags.HasMutationEndpoints = true
}
if e.UsesRawRequestBody() {
flags.HasRawRequest = true
}
if strings.TrimSpace(e.RequiresRole) != "" {
flags.HasRequiredRoles = true
}
if endpointNeedsClientLimit(e) {
flags.HasClientLimit = true
}
if endpointIsCreateCommand(e, name) {
flags.HasCreateCommands = true
}
if len(endpointClientSideFilters(s, e)) > 0 {
flags.HasClientFilters = true
}
if len(e.EmbeddedPagedSubresources) > 0 {
flags.HasEmbeddedPaged = true
}
if strings.Contains(e.Path, "{") || e.Walker != nil {
flags.HasPathParams = true
}
positionalCount := 0
for _, p := range e.Params {
if p.Positional || p.PathParam {
flags.HasPathParams = true
}
if p.Positional {
positionalCount++
}
}
if positionalCount >= 2 {
flags.HasMultiPositional = true
}
}
for _, sub := range resource.SubResources {
scan(sub)
}
}
scan(r)
}
return flags
}
func applyPartialFailureFlags(flags *HelperFlags, apiSpec *spec.APISpec, promotedCommands []PromotedCommand, promotedEndpointNames map[string]string, hasStore bool) {
flags.HasMutationEndpoints, flags.HasPartialFailureErr = partialFailureEmissionFlags(apiSpec, promotedCommands, promotedEndpointNames, hasStore)
}
func partialFailureEmissionFlags(apiSpec *spec.APISpec, promotedCommands []PromotedCommand, promotedEndpointNames map[string]string, hasStore bool) (bool, bool) {
hasSupport := false
hasTypedErr := false
var scan func(spec.Resource, string)
scan = func(originalResource spec.Resource, promotedEndpointName string) {
resource := withoutOptionsEndpoints(originalResource)
for endpointName, endpoint := range resource.Endpoints {
if promotedEndpointName == endpointName {
continue
}
if endpointIsWriteCommand(endpoint, endpointName) && isMutationMethod(endpoint.Method) {
hasSupport = true
hasTypedErr = true
}
}
for _, originalSubResource := range resource.SubResources {
scan(originalSubResource, "")
}
}
for resourceName, originalResource := range apiSpec.Resources {
scan(originalResource, promotedEndpointNames[resourceName])
}
for _, command := range promotedCommands {
if promotedCommandCanDetectPartialFailure(command, hasStore) {
// command_promoted.go.tmpl detects partial failures for store
// write-back only; it never calls partialFailureErr.
hasSupport = true
}
}
return hasSupport, hasTypedErr
}
func promotedCommandCanDetectPartialFailure(command PromotedCommand, hasStore bool) bool {
if !hasStore || command.Endpoint.UsesBinaryResponse() || command.Endpoint.UsesTextResponse() || endpointIsReadCommand(command.Endpoint, command.EndpointName) {
return false
}
method := strings.ToUpper(strings.TrimSpace(command.Endpoint.Method))
return method == "POST" || method == "PUT" || method == "PATCH"
}
// isMutationMethod reports whether method is a mutating HTTP verb. Combined
// with endpointIsWriteCommand it matches the $isMutationOutput gate in
// command_endpoint.go.tmpl (write-classified non-GET/HEAD endpoints). The
// helpers must stay in sync with that gate — otherwise a DELETE-only CLI
// would reference undefined detectPartialFailure / partialFailureReport
// symbols. Detection itself is a no-op for DELETE bodies in practice; the
// cost is one dead function on truly-DELETE-only CLIs.
func isMutationMethod(method string) bool {
if method == "" {
return false
}
return !strings.EqualFold(method, "GET") && !strings.EqualFold(method, "HEAD")
}
// helpersTemplateData wraps APISpec with flags controlling conditional helper emission.
type helpersTemplateData struct {
*spec.APISpec
HelperFlags
HasAuthCommand bool
}
// doctorTemplateData wraps APISpec with flags for store-aware credential
// resolution and generated cache-health checks.
type doctorTemplateData struct {
*spec.APISpec
HasStore bool
HasCacheReport bool
HasAuthCommand bool
}
type credentialField struct {
GoField string
Tag string
}
type pathsTemplateData struct {
*spec.APISpec
PathKindEnvSuffixes []string
}
// authTemplateData wraps APISpec with traffic-analysis generation hints that
// control optional auth subcommands.
type authTemplateData struct {
*spec.APISpec
HasGraphQLPersistedQueries bool
}
// clientTemplateData wraps APISpec with optional runtime data hooks used by
// the generated HTTP client.
type clientTemplateData struct {
*spec.APISpec
IsGraphQL bool
HasGraphQLPersistedQueries bool
HasMultipartRequest bool
HasFormRequest bool
HasRawRequest bool
UseChromeImpersonation bool
// Populated by Generator.shouldEmitAuth() so this template gate stays in
// sync with auth.go emission, root.go registration, and scoreAuth.
HasAuthCommand bool
}
// configTemplateData wraps APISpec with a precomputed auth-surface flag so
// config.go.tmpl can gate token-management fields and helpers on the same
// predicate the auth-command emission and root.go registration use.
type configTemplateData struct {
*spec.APISpec
HasAuthCommand bool
CredentialFields []credentialField
UsesLegacyEnvVarCredentials bool
}
// endpointTemplateData is the data passed to command_endpoint.go.tmpl for both
// top-level resource endpoints and sub-resource endpoints. EffectivePath is
// either the relative endpoint path or a full URL when the endpoint declares
// one directly or inherits a per-resource/per-endpoint BaseURL override.
type endpointTemplateData struct {
ResourceName string
EffectivePath string
EffectiveTier string
FuncPrefix string
CommandPath string
EndpointName string
Endpoint spec.Endpoint
Resource spec.Resource
HasStore bool
IsAsync bool
Async AsyncJobInfo
PageSize int
// IsReadOnly drives mcp:read-only on the emitted command so hosts can
// skip the per-call prompt. GET RPCs without a read signal stay false
// so a mutation is never treated as unattended-safe.
IsReadOnly bool
*spec.APISpec
}
// readmeTemplateData wraps APISpec with additional fields for README rendering.
type readmeTemplateData struct {
*spec.APISpec
Sources []ReadmeSource
DiscoveryPages []string
NovelFeatures []NovelFeature
Narrative *ReadmeNarrative
ProseName string
CompactDescription string
SkillDescription string
HasDataLayer bool
HasSync bool
HasAsyncJobs bool
HasWriteCommands bool
HasCreateCommands bool
HasDelete bool
HasAuth bool
// HasAuthCommand mirrors Generator.shouldEmitAuth() so doc templates can
// gate credential-file prose on the same predicate that controls auth
// command emission. Distinct from HasAuth: an empty auth type still emits
// the auth surface, and docs must follow the emitted surface, not the
// spec's declared type.
HasAuthCommand bool
HasPartialFailureErr bool
HasAutoRefresh bool
SelectExample string
SyncResourcesExample string
FreshnessCommands []string
TrafficAnalysis *trafficAnalysisTemplateData
// PromotedResourceNames maps a resource name to true when the generator
// collapsed that single-endpoint resource into a leaf command. Templates
// (notably skill.md.tmpl's Command Reference) use this to emit `<cli>
// <resource>` instead of `<cli> <resource> <endpoint>` — the operation-id
// path doesn't exist as a registered cobra Use: declaration for promoted
// resources, so emitting it produces SKILL.md content that the
// unknown-command verifier rejects.
PromotedResourceNames map[string]bool
// PromotedEndpointNames maps a resource name to the single endpoint name
// that was promoted (e.g. "qr" → "get-qrcode"). Currently informational —
// templates that need to surface the underlying operation-id can read it.
PromotedEndpointNames map[string]string
// WhichIndex is the curated which command index: novel hero features
// first, then promoted endpoint commands, deduped by Command.
WhichIndex []whichIndexEntry
}
type generatorTemplateData struct {
*spec.APISpec
CompactDescription string
TrafficAnalysis *trafficAnalysisTemplateData
}
type rootTestTemplateData struct {
*spec.APISpec
ExpectedCommandPaths []string
HasAuthCommand bool
}
type trafficAnalysisTemplateData struct {
TargetURL string
EntryCount int
APIEntryCount int
Reachability string
Protocols []string
AuthCandidates []string
Protections []string
GenerationHints []string
Warnings []string
CandidateCommands []string
}
func (g *Generator) readmeData() *readmeTemplateData {
// The "sniffed" spec_source is the legacy provenance name for browser-captured
// specs (produced by the browser-sniff command). Kept for compatibility; a
// migration to "browser-sniffed" is deferred — see docs/plans/2026-04-18-002.
if g.Spec.WebsiteURL == "" && g.Spec.SpecSource == "sniffed" && g.Spec.BaseURL != "" {
if u, err := url.Parse(g.Spec.BaseURL); err == nil && u.Host != "" {
g.Spec.WebsiteURL = u.Scheme + "://" + u.Host
}
}
var syncable []profiler.SyncableResource
var dependent []profiler.DependentResource
if g.profile != nil {
syncable = g.profile.SyncableResources
dependent = g.profile.DependentSyncResources
}
helperFlags := computeHelperFlags(g.Spec)
applyPartialFailureFlags(&helperFlags, g.Spec, g.PromotedCommands, g.PromotedEndpointNames, g.hasDataLayer())
return &readmeTemplateData{
APISpec: g.Spec,
Sources: g.Sources,
DiscoveryPages: g.DiscoveryPages,
NovelFeatures: g.NovelFeatures,
Narrative: g.Narrative,
ProseName: g.proseName(),
CompactDescription: g.compactDescription(),
SkillDescription: g.skillDescription(),
HasDataLayer: g.hasDataLayer(),
HasSync: g.hasGeneratedSyncImplementation(),
HasAsyncJobs: len(g.AsyncJobs) > 0,
HasWriteCommands: hasWriteCommands(g.Spec.Resources),
HasCreateCommands: hasCreateCommands(g.Spec.Resources),
HasDelete: helperFlags.HasDelete,
HasAuth: hasAuth(g.Spec.Auth),
HasAuthCommand: g.shouldEmitAuth(),
HasPartialFailureErr: helperFlags.HasPartialFailureErr,
HasAutoRefresh: g.hasAutoRefresh(),
SelectExample: selectExampleForCommand(g.Spec),
SyncResourcesExample: syncResourcesExample(syncable, dependent),
FreshnessCommands: g.freshnessCommandPaths(),
TrafficAnalysis: g.trafficAnalysisData(),
PromotedResourceNames: g.PromotedResourceNames,
PromotedEndpointNames: g.PromotedEndpointNames,
WhichIndex: g.whichIndexEntries(),
}
}
func (g *Generator) hasDataLayer() bool {
// Explicit store-only plans remain supported for custom population paths;
// zero-syncable learn stores must not advertise generic local-data surfaces.
return g != nil && g.VisionSet.Store && (!g.learnStorePromoted || g.hasGeneratedSyncImplementation() || hasSyncCommandResources(g.profile))
}
func (g *Generator) hasWorkflowSurface() bool {
return g != nil && (g.hasDataLayer() || g.htmlSyncStubSelected)
}
func (g *Generator) dataSurfaceVisionSet() VisionTemplateSet {
set := g.VisionSet
set.Store = g.hasDataLayer()
return set
}
func (g *Generator) compactDescription() string {
if g.Narrative != nil {
if desc := naming.AuthoredDescription(g.Narrative.Headline); desc != "" {
return desc
}
}
if g.Spec != nil {
if desc := naming.AuthoredDescription(g.Spec.CLIDescription); desc != "" {
return desc
}
if desc := naming.CompactDescription(g.Spec.Description); desc != "" {
return desc
}
}
return fmt.Sprintf("Printing Press CLI for %s.", g.proseName())
}
// ManifestDescription keeps durable manifest prose intact; compact command
// surfaces can apply their own truncation later.
func (g *Generator) ManifestDescription() string {
candidates := []string{}
if g.Narrative != nil {
candidates = append(candidates, g.Narrative.Headline)
}
if g.Spec != nil {
candidates = append(candidates, g.Spec.CLIDescription)
}
for _, candidate := range candidates {
if desc := naming.ManifestDescription(candidate); desc != "" {
if !naming.HasLiteralEllipsisSuffix(desc) {
return desc
}
}
}
if g.Spec != nil {
if desc := naming.CompactDescription(g.Spec.Description); desc != "" {
return desc
}
}
return fmt.Sprintf("Printing Press CLI for %s.", g.proseName())
}
// ManifestDisplayName returns the best generated brand name for durable CLI
// metadata.
func (g *Generator) ManifestDisplayName() string {
if g.Narrative != nil {
if displayName := strings.TrimSpace(g.Narrative.DisplayName); displayName != "" {
return displayName
}
}
if g.Spec != nil {
return strings.TrimSpace(g.Spec.EffectiveDisplayName())
}
return ""
}
func (g *Generator) skillDescription() string {
switch {
case g.Narrative != nil && strings.TrimSpace(g.Narrative.Headline) != "":
return naming.AuthoredDescription(g.Narrative.Headline)
case g.Spec != nil && strings.TrimSpace(g.Spec.CLIDescription) != "":
return naming.AuthoredDescription(g.Spec.CLIDescription)
case g.Spec != nil && strings.TrimSpace(g.Spec.Description) != "":
return fmt.Sprintf("Printing Press CLI for %s. %s", g.proseName(), naming.CompactDescription(g.Spec.Description))
default:
return fmt.Sprintf("Printing Press CLI for %s.", g.proseName())
}
}
// freshnessCommandPaths returns the command paths surfaced in README.md and
// SKILL.md freshness sections. Keep this in lockstep with
// auto_refresh.go.tmpl's readCommandResources map so the docs describe the
// exact command paths that can trigger auto-refresh at runtime.
func (g *Generator) freshnessCommandPaths() []string {
if !g.Spec.Cache.Enabled || g.shouldEmitHTMLSyncStub() || g.profile == nil {
return nil
}
seen := map[string]struct{}{}
var paths []string
add := func(path string) {
if _, ok := seen[path]; ok {
return
}
seen[path] = struct{}{}
paths = append(paths, path)
}
cliName := naming.CLI(g.Spec.Name)
for _, resource := range g.profile.SyncableResources {
prefix := cliName + " " + resource.Name
add(prefix)
for _, subcommand := range []string{"list", "get", "search"} {
add(prefix + " " + subcommand)
}
}
for _, command := range g.Spec.Cache.Commands {
add(cliName + " " + command.Name)
}
sort.Strings(paths)
return paths
}
func (g *Generator) proseName() string {
if g.Narrative != nil && strings.TrimSpace(g.Narrative.DisplayName) != "" {
return strings.TrimSpace(g.Narrative.DisplayName)
}
return naming.HumanName(g.Spec.Name)
}
func hasAuth(auth spec.AuthConfig) bool {
return strings.TrimSpace(auth.Type) != "" && auth.Type != "none"
}
func authPlacement(auth spec.AuthConfig) string {
if strings.TrimSpace(auth.In) == "query" {
return "query"
}
return "header"
}
func authParameterName(auth spec.AuthConfig) string {
if strings.TrimSpace(auth.Header) != "" {
return auth.Header
}
if authPlacement(auth) == "query" {
return "api_key"
}
return "Authorization"
}
func isBrowserCookieAuth(auth spec.AuthConfig) bool {
return strings.TrimSpace(auth.Type) == "cookie" &&
strings.EqualFold(strings.TrimSpace(auth.In), "cookie") &&
(strings.TrimSpace(auth.Header) == "" || strings.EqualFold(strings.TrimSpace(auth.Header), "Cookie"))
}
func authCommandShort(api *spec.APISpec) string {
displayName := "this API"
if api != nil && strings.TrimSpace(api.EffectiveDisplayName()) != "" {
displayName = api.EffectiveDisplayName()
}
if api != nil && api.Auth.Optional {
return "Manage optional authentication for " + displayName
}
return "Manage authentication for " + displayName
}
func authHarvestedEnvHint(auth spec.AuthConfig) string {
switch {
case auth.Type == "cookie" || auth.Type == "composed":
return "populated automatically by auth login --chrome"
case auth.Subtype == spec.AuthSubtypeGoogleServiceAccount:
return "set GOOGLE_APPLICATION_CREDENTIALS or GOOGLE_OAUTH_ACCESS_TOKEN"
case auth.EffectiveOAuth2Grant() == spec.OAuth2GrantClientCredentials && auth.TokenURL != "":
return "populated automatically by auth login --client-id/--client-secret"
case auth.EffectiveOAuth2Grant() == spec.OAuth2GrantDeviceCode && auth.DeviceAuthorizationURL != "" && auth.TokenURL != "":
return "populated automatically by auth login --device-code"
case auth.AuthorizationURL != "":
return "populated automatically by auth login"
default:
return "set with auth set-token"
}
}
// oauth2DirectBearerEnvFallback reports whether the generated AuthHeader
// should honor a directly-held bearer from the canonical env var when no
// minted AccessToken exists. Only authorization-code-style flows qualify:
// under client_credentials and device_code the configured env vars are flow
// inputs that mint tokens, never wire-ready bearers.
func oauth2DirectBearerEnvFallback(auth spec.AuthConfig) bool {
if !oauth2AccessTokenAuth(auth) {
return false
}
switch auth.EffectiveOAuth2Grant() {
case spec.OAuth2GrantClientCredentials, spec.OAuth2GrantDeviceCode:
return false
}
v := auth.CanonicalEnvVar()
if v == nil {
return false
}
// Only a required request credential qualifies. An optional per-call
// token is a stale-able extra: a value persisted to config long ago
// must not be sent on the wire when the refreshed-AccessToken flow is
// the real credential path.
if !v.Required {
return false
}
return v.Kind == "" || v.Kind == spec.AuthEnvVarKindPerCall
}
func oauth2AccessTokenAuth(auth spec.AuthConfig) bool {
if auth.Type == "oauth2" || auth.Type == spec.AuthTypeOAuth2Refresh {
return true
}
if auth.Type != "bearer_token" {
return false
}
if auth.AuthorizationURL != "" && auth.TokenURL != "" && auth.EffectiveOAuth2Grant() == spec.OAuth2GrantAuthorizationCode {
return true
}
switch auth.EffectiveOAuth2Grant() {
case spec.OAuth2GrantClientCredentials, spec.OAuth2GrantDeviceCode:
return true
default:
return false
}
}
func oauth2AuthSource(auth spec.AuthConfig) string {
if auth.Type == spec.AuthTypeOAuth2Refresh {
return spec.AuthTypeOAuth2Refresh
}
return "oauth2"
}
func authFormatIsBasic(auth spec.AuthConfig) bool {
return strings.Contains(strings.ToLower(auth.Format), "basic ")
}
func basicAuthEnvVars(auth spec.AuthConfig) []spec.AuthEnvVar {
if !authFormatIsBasic(auth) {
return nil
}
var envVars []spec.AuthEnvVar
if len(auth.EnvVarSpecs) > 0 {
for _, envVar := range auth.EnvVarSpecs {
if envVar.IsRequestCredential() {
envVars = append(envVars, envVar)
}
}
} else {
for _, name := range auth.EnvVars {
envVars = append(envVars, spec.AuthEnvVar{
Name: name,
Kind: spec.AuthEnvVarKindPerCall,
Required: true,
})
}
}
if len(envVars) == 0 {
return nil
}
if len(envVars) == 1 {
return envVars
}
return envVars[:2]
}
// hasNonCookieAuth reports whether the CLI has at least one env-var
// based credential (bearer token, API key, OAuth client credentials,
// etc.). Cookie-only CLIs (instacart, airbnb, ebay, pagliacci,
// table-reservation-goat) skip the agentcookie.toml manifest emit
// and the bus-detection block in the generated config loader.
func hasNonCookieAuth(auth spec.AuthConfig) bool {
return auth.HasNonCookieAuth()
}
func clientCredentialsEnvVars(auth spec.AuthConfig) []spec.AuthEnvVar {
if auth.EffectiveOAuth2Grant() != spec.OAuth2GrantClientCredentials {
return nil
}
if len(auth.EnvVarSpecs) > 0 {
var candidates []spec.AuthEnvVar
tenantEnvVar := clientCredentialsTenantEnvVar(auth)
for _, envVar := range auth.EnvVarSpecs {
name := strings.TrimSpace(envVar.Name)
if name == "" || name == tenantEnvVar || envVar.EffectiveKind() == spec.AuthEnvVarKindHarvested {
continue
}
candidates = append(candidates, envVar)
}
clientID, clientSecret := clientCredentialsNamedPair(candidates)
if clientID.Name != "" && clientSecret.Name != "" {
return []spec.AuthEnvVar{clientID, clientSecret}
}
if len(candidates) >= 2 {
return candidates[:2]
}
return nil
}
var envVars []spec.AuthEnvVar
tenantEnvVar := clientCredentialsTenantEnvVar(auth)
for _, name := range auth.EnvVars {
if strings.TrimSpace(name) == "" || name == tenantEnvVar {
continue
}
envVars = append(envVars, spec.AuthEnvVar{
Name: name,
Kind: spec.AuthEnvVarKindPerCall,
Required: true,
Sensitive: !isClientIDAuthEnvVar(name),
})
}
if len(envVars) < 2 {
return nil
}
clientID, clientSecret := clientCredentialsNamedPair(envVars)
if clientID.Name != "" && clientSecret.Name != "" {
return []spec.AuthEnvVar{clientID, clientSecret}
}
return envVars[:2]
}
func deviceCodeEnvVars(auth spec.AuthConfig) []spec.AuthEnvVar {
if auth.EffectiveOAuth2Grant() != spec.OAuth2GrantDeviceCode {
return nil
}
if len(auth.EnvVarSpecs) > 0 {
var candidates []spec.AuthEnvVar
for _, envVar := range auth.EnvVarSpecs {
if strings.TrimSpace(envVar.Name) == "" || envVar.EffectiveKind() == spec.AuthEnvVarKindHarvested {
continue
}
candidates = append(candidates, envVar)
}
clientID, _ := clientCredentialsNamedPair(candidates)
if clientID.Name != "" {
return []spec.AuthEnvVar{clientID}
}
if len(candidates) > 0 {
return candidates[:1]
}
return nil
}
for _, name := range auth.EnvVars {
if strings.TrimSpace(name) == "" {
continue
}
return []spec.AuthEnvVar{{
Name: name,
Kind: spec.AuthEnvVarKindAuthFlowInput,
Required: strings.TrimSpace(auth.DefaultClientID) == "",
Sensitive: false,
}}
}
return nil
}
func clientCredentialsNamedPair(envVars []spec.AuthEnvVar) (spec.AuthEnvVar, spec.AuthEnvVar) {
var clientID spec.AuthEnvVar
var clientSecret spec.AuthEnvVar
for _, envVar := range envVars {
placeholder := naming.EnvVarPlaceholder(envVar.Name)
switch {
case clientID.Name == "" && (placeholder == "client_id" || strings.HasSuffix(placeholder, "_client_id")):
clientID = envVar
case clientSecret.Name == "" && (placeholder == "client_secret" || strings.HasSuffix(placeholder, "_client_secret")):
clientSecret = envVar
}
}
return clientID, clientSecret
}
func clientCredentialsTenantEnvVar(auth spec.AuthConfig) string {
if auth.EffectiveOAuth2Grant() != spec.OAuth2GrantClientCredentials {
return ""
}
for _, envVar := range auth.EnvVarSpecs {
if envVar.EffectiveKind() == spec.AuthEnvVarKindAuthFlowInput && isTenantAuthEnvVar(envVar.Name) {
return envVar.Name
}
}
if len(auth.EnvVarSpecs) == 0 || spec.AllAuthEnvVarSpecsInferred(auth.EnvVarSpecs) {
for _, name := range auth.EnvVars {
if isTenantAuthEnvVar(name) {
return name
}
}
}
return ""
}
func clientCredentialsTokenURLHasTenant(auth spec.AuthConfig) bool {
return clientCredentialsTenantEnvVar(auth) != "" && isMicrosoftEntraTokenURL(auth.TokenURL) && strings.Contains(strings.ToLower(auth.TokenURL), "/common/")
}
func clientCredentialsScope(auth spec.AuthConfig) string {
if auth.EffectiveOAuth2Grant() != spec.OAuth2GrantClientCredentials {
return ""
}
if len(auth.Scopes) > 0 {
return strings.Join(auth.Scopes, " ")
}
if isMicrosoftEntraTokenURL(auth.TokenURL) {
return "api://{client_id}/.default"
}
return ""
}
func clientCredentialsScopeUsesClientID(scope string) bool {
return strings.Contains(scope, "{client_id}")
}
func isMicrosoftEntraTokenURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
return strings.EqualFold(u.Host, "login.microsoftonline.com")
}
func isTenantAuthEnvVar(name string) bool {
placeholder := naming.EnvVarPlaceholder(name)
return placeholder == "tenant_id" || strings.HasSuffix(placeholder, "_tenant_id")
}
func isClientIDAuthEnvVar(name string) bool {
placeholder := naming.EnvVarPlaceholder(name)
return placeholder == "client_id" || strings.HasSuffix(placeholder, "_client_id")
}
var skillAuthEnvVarToken = regexp.MustCompile(`\b[A-Z][A-Z0-9]*_[A-Z0-9_]+\b`)
// Independently authored prose can name secrets the compiled binary
// ignores; drop it so Auth Setup lists only credentials the CLI reads.
func skillAuthNarrative(auth spec.AuthConfig, narrative string) string {
narrative = strings.TrimSpace(narrative)
if narrative == "" {
return ""
}
resolved := resolvedSkillAuthEnvVarNames(auth)
for _, name := range skillAuthEnvVarToken.FindAllString(narrative, -1) {
if _, ok := resolved[name]; !ok {
return ""
}
}
return narrative
}
func resolvedSkillAuthEnvVarNames(auth spec.AuthConfig) map[string]struct{} {
names := make(map[string]struct{})
add := func(name string) {
if name == "" {
return
}
names[name] = struct{}{}
}
for _, envVar := range authAgentEnvVars(auth) {
add(envVar.Name)
}
for _, envVar := range requestAuthEnvVars(auth) {
add(envVar.Name)
}
return names
}
func authAgentEnvVars(auth spec.AuthConfig) []spec.AuthEnvVar {
var envVars []spec.AuthEnvVar
seen := map[string]struct{}{}
add := func(envVar spec.AuthEnvVar) {
if strings.TrimSpace(envVar.Name) == "" || envVar.Kind == spec.AuthEnvVarKindHarvested {
return
}
if _, ok := seen[envVar.Name]; ok {
return
}
seen[envVar.Name] = struct{}{}
envVars = append(envVars, envVar)
}
if len(auth.EnvVarSpecs) > 0 {
for _, envVar := range auth.EnvVarSpecs {
add(envVar)
}
} else {
for _, name := range auth.EnvVars {
add(spec.AuthEnvVar{
Name: name,
Kind: spec.AuthEnvVarKindPerCall,
Required: true,
Sensitive: true,
})
}
}
for _, header := range auth.AdditionalHeaders {
add(header.EnvVar)
}
return envVars
}
func credentialFields(auth spec.AuthConfig) []credentialField {
var fields []credentialField
usedTags := make(map[string]struct{})
add := func(name string) {
if envVarIsBuiltinField(name) {
return
}
tag := naming.EnvVarPlaceholder(name)
if _, exists := usedTags[tag]; exists {
tag = strings.ToLower(name)
for suffix := 2; ; suffix++ {
if _, exists := usedTags[tag]; !exists {
break
}
tag = fmt.Sprintf("%s_%d", strings.ToLower(name), suffix)
}
}
usedTags[tag] = struct{}{}
fields = append(fields, credentialField{
GoField: envVarField(name),
Tag: tag,
})
}
if len(auth.EnvVarSpecs) > 0 {
for _, envVar := range auth.EnvVarSpecs {
add(envVar.Name)
}
} else {
for _, name := range auth.EnvVars {
add(name)
}
}
for _, header := range auth.AdditionalHeaders {
add(header.EnvVar.Name)
}
return fields
}
func usesLegacyEnvVarCredentials(auth spec.AuthConfig) bool {
return len(auth.EnvVarSpecs) == 0 && len(auth.EnvVars) > 0
}
func pathKindEnvSuffix(index int) string {
suffixes := naming.PathKindEnvSuffixes()
if index < 0 || index >= len(suffixes) {
return ""
}
return suffixes[index]
}
func hasAuthEnvVarKind(envVarSpecs []spec.AuthEnvVar, kind string) bool {
for _, envVar := range envVarSpecs {
if string(envVar.Kind) == kind {
return true
}
}
return false
}
func isRequestAuthEnvVar(envVar spec.AuthEnvVar) bool {
return envVar.IsRequestCredential()
}
func authEnvHintComment(envVar spec.AuthEnvVar) string {
// OneLineNormalize collapses newlines and neutralizes " and \ so the value
// is safe to embed verbatim in the generated Go double-quoted string literal
// that prints the hint; without it a description containing a quote or
// backslash produces non-compiling output. OneLineNormalize (not OneLine)
// avoids the length cap so the full description survives as a hint.
return naming.OneLineNormalize(strings.TrimSpace(envVar.Description))
}
func authEnvPlaceholder(envVar spec.AuthEnvVar) string {
return authEnvPlaceholderByName(envVar.Name)
}
func authEnvPlaceholderByName(envVarName string) string {
placeholder := naming.EnvVarPlaceholder(envVarName)
switch {
case placeholder == "domain" || strings.HasSuffix(placeholder, "_domain") ||
placeholder == "host" || strings.HasSuffix(placeholder, "_host"):
envNameUpper := strings.ToUpper(strings.TrimSpace(envVarName))
return "acme." + authEnvDomainVendor(envNameUpper, placeholder) + ".com"
case placeholder == "region" || strings.HasSuffix(placeholder, "_region"):
return "us-east-1"
case placeholder == "username" || strings.HasSuffix(placeholder, "_username"):
return "your-username"
case placeholder == "password" || strings.HasSuffix(placeholder, "_password"):
return "your-password"
case placeholder == "user_agent" || strings.HasSuffix(placeholder, "_user_agent"):
return "you@example.com (Your Tool Name)"
case placeholder == "cookies" || strings.HasSuffix(placeholder, "_cookies"):
return "<paste your session cookies>"
default:
return "your-token-here"
}
}
func requestAuthEnvVars(auth spec.AuthConfig) []spec.AuthEnvVar {
auth.NormalizeEnvVarSpecs("")
out := make([]spec.AuthEnvVar, 0, len(auth.EnvVarSpecs))
for _, envVar := range auth.EnvVarSpecs {
if envVar.IsRequestCredential() {
out = append(out, envVar)
}
}
return out
}
func requiredRequestAuthEnvVars(auth spec.AuthConfig) []spec.AuthEnvVar {
envVars := requestAuthEnvVars(auth)
out := make([]spec.AuthEnvVar, 0, len(envVars))
for _, envVar := range envVars {
if envVar.Required {
out = append(out, envVar)
}
}
return out
}
func optionalRequestAuthEnvVars(auth spec.AuthConfig) []spec.AuthEnvVar {
envVars := requestAuthEnvVars(auth)
out := make([]spec.AuthEnvVar, 0, len(envVars))
for _, envVar := range envVars {
if !envVar.Required {
out = append(out, envVar)
}
}
return out
}
func requiredRequestAuthEnvVarCount(auth spec.AuthConfig) int {
return len(requiredRequestAuthEnvVars(auth))
}
func requestAuthEnvVarCount(auth spec.AuthConfig) int {
return len(requestAuthEnvVars(auth))
}
func hasRequestAuthEnvVarField(auth spec.AuthConfig, field string) bool {
for _, envVar := range requestAuthEnvVars(auth) {
if resolveEnvVarField(envVar.Name) == field {
return true
}
}
return false
}
func authSetTokenAvailable(auth spec.AuthConfig) bool {
return authSetTokenAvailableForRequiredCount(auth, requiredRequestAuthEnvVarCount(auth))
}
func authSetCredentialsAvailable(auth spec.AuthConfig) bool {
return len(basicAuthEnvVars(auth)) == 2
}
func authSetTokenStdinCommand(cliName string) string {
return fmt.Sprintf(`echo "$TOKEN" | %s-pp-cli auth set-token`, cliName)
}
func authCredentialPersistenceCommand(auth spec.AuthConfig, cliName string) string {
if authSetTokenAvailable(auth) {
return authSetTokenStdinCommand(cliName)
}
envVars := basicAuthEnvVars(auth)
if len(envVars) == 2 {
return fmt.Sprintf("%s-pp-cli auth set-credentials %s %s", cliName, authEnvPlaceholder(envVars[0]), authEnvPlaceholder(envVars[1]))
}
return ""
}
func authCredentialConsolidationAction(auth spec.AuthConfig) string {
switch {
case authSetTokenAvailable(auth):
return "auth set-token"
case authSetCredentialsAvailable(auth):
return "auth set-credentials"
case auth.EffectiveOAuth2Grant() == spec.OAuth2GrantClientCredentials && auth.TokenURL != "":
return "auth set-token"
case auth.EffectiveOAuth2Grant() == spec.OAuth2GrantDeviceCode && auth.DeviceAuthorizationURL != "" && auth.TokenURL != "":
return "auth set-token"
case authBrowserLoginAvailable(auth):
return "auth login"
case auth.Type == "oauth2":
return "auth login"
case auth.Type == "cookie" || auth.Type == "composed" || auth.Type == "session_handshake":
return "auth login"
case auth.Subtype == spec.AuthSubtypeGoogleServiceAccount:
return "auth service-account"
default:
return ""
}
}
func authPlaceholderCredentialSetup(auth spec.AuthConfig, cliName string) string {
switch {
case auth.EffectiveOAuth2Grant() == spec.OAuth2GrantClientCredentials && auth.TokenURL != "":
return fmt.Sprintf("%s-pp-cli auth login or %s", cliName, authSetTokenStdinCommand(cliName))
case auth.EffectiveOAuth2Grant() == spec.OAuth2GrantDeviceCode && auth.DeviceAuthorizationURL != "" && auth.TokenURL != "":
return fmt.Sprintf("%s-pp-cli auth login --device-code or %s", cliName, authSetTokenStdinCommand(cliName))
case auth.Type == "oauth2" || authBrowserLoginAvailable(auth):
return fmt.Sprintf("%s-pp-cli auth login", cliName)
case auth.Type == "cookie" || auth.Type == "composed" || auth.Type == "session_handshake":
return fmt.Sprintf("%s-pp-cli auth login", cliName)
}
persistenceCommand := authCredentialPersistenceCommand(auth, cliName)
if persistenceCommand == "" {
if hint := authSetupHint(auth, cliName); hint != "" {
return hint
}
return fmt.Sprintf("%s-pp-cli auth setup", cliName)
}
exports := authCredentialExports(auth)
if exports == "" {
return persistenceCommand
}
return exports + " or " + persistenceCommand
}
func authCredentialExports(auth spec.AuthConfig) string {
envVars := requiredRequestAuthEnvVars(auth)
if len(envVars) == 0 && auth.IsAuthEnvVarORCase() {
envVars = requestAuthEnvVars(auth)
}
if len(envVars) == 0 {
return ""
}
exports := make([]string, 0, len(envVars))
for _, envVar := range envVars {
exports = append(exports, fmt.Sprintf("%s=<%s>", envVar.Name, authEnvPlaceholderByName(envVar.Name)))
}
return "export " + strings.Join(exports, " ")
}
func authSetTokenAvailableForRequiredCount(auth spec.AuthConfig, requiredCount int) bool {
if strings.Contains(strings.ToLower(auth.Format), "basic ") {
return requiredCount == 1 && requestAuthEnvVarCount(auth) == 1
}
switch auth.Type {
case "api_key", "bearer_token":
return requestAuthEnvVarCount(auth) > 0 && requiredCount <= 1
default:
return false
}
}
func basicAuthAppendsColonForSingleToken(auth spec.AuthConfig) bool {
envVars := basicAuthEnvVars(auth)
if len(envVars) != 1 {
return false
}
payload := strings.TrimSpace(auth.Format)
if scheme, rest, ok := strings.Cut(payload, " "); ok && strings.EqualFold(strings.TrimSpace(scheme), "Basic") {
payload = strings.TrimSpace(rest)
}
return !strings.Contains(payload, ":")
}
func authErrorCheckHint(auth spec.AuthConfig) string {
switch auth.Type {
case "bearer_token", "oauth2", "oauth2_refresh":
return "check your token."
case "api_key":
if strings.Contains(strings.ToLower(auth.Format), "basic ") {
return "check your Basic credentials."
}
return "check your API key."
default:
return "check your API credentials."
}
}
func authSetupHint(auth spec.AuthConfig, cliName string) string {
if auth.Subtype == spec.AuthSubtypeGoogleServiceAccount {
return fmt.Sprintf("Run '%s-pp-cli auth service-account --key <service-account.json>' or set GOOGLE_OAUTH_ACCESS_TOKEN.", cliName)
}
switch auth.Type {
case "", "none":
return ""
case "cookie", "composed":
return fmt.Sprintf("Run '%s-pp-cli auth login --chrome' to refresh browser-session credentials.", cliName)
case "oauth2":
return fmt.Sprintf("Run '%s-pp-cli auth login' to re-authenticate.", cliName)
}
if authBrowserLoginAvailable(auth) {
return fmt.Sprintf("Run '%s-pp-cli auth login' to re-authenticate.", cliName)
}
envVars := requiredRequestAuthEnvVars(auth)
if len(envVars) == 0 && auth.IsAuthEnvVarORCase() {
envVars = requestAuthEnvVars(auth)
exports := make([]string, 0, len(envVars))
for _, envVar := range envVars {
exports = append(exports, fmt.Sprintf(`export %s="%s"`, envVar.Name, authEnvPlaceholder(envVar)))
}
if len(exports) > 0 {
return "Set one of: " + strings.Join(exports, " or ")
}
}
if len(envVars) == 0 {
return fmt.Sprintf("Run '%s-pp-cli auth setup' for credential setup steps.", cliName)
}
exports := make([]string, 0, len(envVars))
for _, envVar := range envVars {
exports = append(exports, fmt.Sprintf(`%s="%s"`, envVar.Name, authEnvPlaceholder(envVar)))
}
if len(envVars) == 1 {
switch auth.Type {
case "bearer_token", "oauth2_refresh":
if authSetTokenAvailableForRequiredCount(auth, len(envVars)) {
return fmt.Sprintf("Set it with: %s or export %s", authSetTokenStdinCommand(cliName), exports[0])
}
return fmt.Sprintf("Set it with: export %s", exports[0])
case "api_key":
if strings.Contains(strings.ToLower(auth.Format), "basic ") {
return fmt.Sprintf("Set the Basic credential with: export %s", exports[0])
}
return fmt.Sprintf("Set your API key with: export %s", exports[0])
default:
return fmt.Sprintf("Set credentials with: export %s", exports[0])
}
}
if strings.Contains(strings.ToLower(auth.Format), "basic ") {
return "Set Basic credentials with: export " + strings.Join(exports, " ")
}
return "Set credentials with: export " + strings.Join(exports, " ")
}
func authBrowserLoginAvailable(auth spec.AuthConfig) bool {
return strings.TrimSpace(auth.AuthorizationURL) != "" &&
auth.EffectiveOAuth2Grant() == spec.OAuth2GrantAuthorizationCode
}
func authEnvDomainVendor(envNameUpper, placeholder string) string {
vendor := strings.TrimSuffix(strings.TrimSuffix(envNameUpper, "_DOMAIN"), "_HOST")
if vendor == envNameUpper {
vendor = strings.TrimSuffix(strings.TrimSuffix(placeholder, "_domain"), "_host")
}
vendor = strings.Trim(vendor, "_")
if vendor == "" {
return "example"
}
return strings.ToLower(strings.ReplaceAll(vendor, "_", "-"))
}
func effectiveTier(api *spec.APISpec, resource spec.Resource, endpoint spec.Endpoint) string {
if api == nil {
return ""
}
return api.EffectiveTier(resource, endpoint)
}
func effectiveSubTier(api *spec.APISpec, parent spec.Resource, subResource spec.Resource, endpoint spec.Endpoint) string {
if api == nil {
return ""
}
effectiveResource := subResource
if effectiveResource.Tier == "" {
effectiveResource.Tier = parent.Tier
}
return api.EffectiveTier(effectiveResource, endpoint)
}
func hasWriteCommands(resources map[string]spec.Resource) bool {
shared := sharedGETRPCPaths(resources)
for _, resource := range resources {
if resourceHasWriteCommandShared(resource, shared) {
return true
}
}
return false
}
func hasCreateCommands(resources map[string]spec.Resource) bool {
for _, resource := range resources {
if resourceHasCreateCommand(resource) {
return true
}
}
return false
}
func resourceHasWriteCommandShared(resource spec.Resource, sharedGETPaths map[string]bool) bool {
for name, endpoint := range resource.Endpoints {
if endpointIsWriteCommandShared(endpoint, name, sharedGETPaths) {
return true
}
}
for _, sub := range resource.SubResources {
if resourceHasWriteCommandShared(sub, sharedGETPaths) {
return true
}
}
return false
}
func resourceHasCreateCommand(resource spec.Resource) bool {
for name, endpoint := range resource.Endpoints {
if endpointIsCreateCommand(endpoint, name) {
return true
}
}
for _, sub := range resource.SubResources {
if resourceHasCreateCommand(sub) {
return true
}
}
return false
}
// methodIsWrite is the verb-only fallback. Prefer endpointIsWriteCommand
// when an Endpoint is in hand.
func methodIsWrite(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case "POST", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
// readOperationIDPrefixes signal a read regardless of HTTP verb. Matched
// against the leading camelCase token of the operation id, case-insensitive.
// Whole-token (not substring) matching avoids false reads on names like
// "getter" or "listenerStart" while still catching "getUser", "listOrders".
var readOperationIDPrefixes = map[string]bool{
"get": true,
"list": true,
"search": true,
"find": true,
"query": true,
"count": true,
"describe": true,
"fetch": true,
}
// Conservative action tokens for GET-based endpoints. A leading token
// here opts a GET into the write path even on an ordinary REST path.
// RPC-over-GET without a read signal fails closed independently of
// these tokens.
var mutationOperationIDPrefixes = map[string]bool{
"activate": true,
"add": true,
"approve": true,
"archive": true,
"cancel": true,
"create": true,
"deactivate": true,
"delete": true,
"deploy": true,
"disable": true,
"enable": true,
"execute": true,
"insert": true,
"pause": true,
"publish": true,
"put": true,
"reject": true,
"remove": true,
"restart": true,
"restore": true,
"resume": true,
"revoke": true,
"rotate": true,
"run": true,
"save": true,
"send": true,
"set": true,
"start": true,
"stop": true,
"sync": true,
"trigger": true,
"unpublish": true,
"update": true,
"upsert": true,
}
// writeOperationIDFragments name mutations. When a read-shaped leading token
// is followed by one of these (e.g. getOrCreate, fetchAndUpdate), the
// classifier flips back to write — the leading verb was misleading.
var writeOperationIDFragments = map[string]bool{
"create": true,
"update": true,
"delete": true,
"remove": true,
"add": true,
"insert": true,
"set": true,
"upsert": true,
"save": true,
}
// readBodyParamNames are filter-shape body field names. A POST whose body
// params are entirely drawn from this set is acting as a query, not a
// mutation; mixing in any unknown name flips the endpoint back to write.
var readBodyParamNames = map[string]bool{
"query": true,
"querytext": true,
"q": true,
"filter": true,
"filters": true,
"limit": true,
"offset": true,
"from": true,
"size": true,
"cursor": true,
"page": true,
"pagesize": true,
"sort": true,
"sortby": true,
"orderby": true,
"maxrecords": true,
"max_records": true,
}
// endpointIsWriteCommand returns true when the endpoint mutates external
// state. Read signals are checked in cost order: read-only annotation,
// explicit mutation signal, verb, name token, body shape. The read-only
// annotation remains the strongest override so an explicitly safe endpoint
// cannot be made destructive by a broad fallback. Unknown GET RPCs fail
// closed (write) unless a read token, mutation:false, or mcp:read-only
// supplies a positive read signal. Conventional REST GETs stay readable.
//
// opName is the map key from Resource.Endpoints (the operation id).
func endpointIsWriteCommand(endpoint spec.Endpoint, opName string) bool {
return endpointIsWriteCommandShared(endpoint, opName, nil)
}
func endpointIsWriteCommandShared(endpoint spec.Endpoint, opName string, sharedGETPaths map[string]bool) bool {
if v, ok := endpoint.Meta["mcp:read-only"]; ok && strings.EqualFold(strings.TrimSpace(v), "true") {
return false
}
if value, set := endpoint.MutationOverride(); set {
return value
}
tokens := camelCaseTokens(strings.TrimSpace(opName))
leading := ""
if len(tokens) > 0 {
leading = strings.ToLower(tokens[0])
}
if !methodIsWrite(endpoint.Method) {
if !strings.EqualFold(strings.TrimSpace(endpoint.Method), "GET") {
return false
}
if mutationOperationIDPrefixes[leading] {
return true
}
if readOperationIDPrefixes[leading] {
return leadingTokenHasWriteFragment(tokens)
}
return getLooksLikeRPC(endpoint, sharedGETPaths)
}
if readOperationIDPrefixes[leading] {
return leadingTokenHasWriteFragment(tokens)
}
return !bodyIsAllFilterShape(endpoint.Body)
}
func leadingTokenHasWriteFragment(tokens []string) bool {
for _, tok := range tokens[1:] {
if writeOperationIDFragments[strings.ToLower(tok)] {
return true
}
}
return false
}
func getLooksLikeRPC(endpoint spec.Endpoint, sharedGETPaths map[string]bool) bool {
if endpointLooksLikeRPC(endpoint) {
return true
}
return sharedGETPaths[canonicalRPCPath(endpoint.Path)]
}
func canonicalRPCPath(raw string) string {
path := strings.TrimSpace(raw)
if i := strings.Index(path, "?"); i >= 0 {
path = path[:i]
}
return path
}
var rpcQuerySelectorNames = map[string]bool{
"method": true,
"action": true,
"op": true,
"operation": true,
"cmd": true,
"command": true,
}
func endpointLooksLikeRPC(endpoint spec.Endpoint) bool {
raw := strings.TrimSpace(endpoint.Path)
base := strings.ToLower(canonicalRPCPath(raw))
if strings.HasSuffix(base, ".cgi") {
return true
}
seg := base
if i := strings.LastIndex(seg, "/"); i >= 0 {
seg = seg[i+1:]
}
switch seg {
case "rpc", "jsonrpc", "xmlrpc", "json-rpc":
return true
}
if i := strings.Index(raw, "?"); i >= 0 && queryHasRPCSelector(raw[i+1:]) {
return true
}
return slices.ContainsFunc(endpoint.Params, paramLooksLikeRPCSelector)
}
func queryHasRPCSelector(rawQuery string) bool {
values, err := url.ParseQuery(rawQuery)
if err != nil {
lower := strings.ToLower(rawQuery)
for name := range rpcQuerySelectorNames {
if strings.Contains(lower, name+"=") {
return true
}
}
return false
}
for name := range values {
if rpcQuerySelectorNames[strings.ToLower(name)] {
return true
}
}
return false
}
func paramLooksLikeRPCSelector(p spec.Param) bool {
in := strings.ToLower(strings.TrimSpace(p.In))
if p.PathParam || in == "path" || in == "header" {
return false
}
name := strings.ToLower(strings.TrimSpace(p.Name))
if alias := strings.ToLower(strings.TrimSpace(p.URLName)); alias != "" {
name = alias
}
return rpcQuerySelectorNames[name]
}
func sharedGETRPCPaths(resources map[string]spec.Resource) map[string]bool {
counts := map[string]int{}
var walk func(spec.Resource)
walk = func(resource spec.Resource) {
for _, endpoint := range resource.Endpoints {
if !strings.EqualFold(strings.TrimSpace(endpoint.Method), "GET") {
continue
}
if path := canonicalRPCPath(endpoint.Path); path != "" {
counts[path]++
}
}
for _, sub := range resource.SubResources {
walk(sub)
}
}
for _, resource := range resources {
walk(resource)
}
shared := map[string]bool{}
for path, n := range counts {
if n > 1 {
shared[path] = true
}
}
return shared
}
func endpointIsCreateCommand(endpoint spec.Endpoint, opName string) bool {
if !endpointIsWriteCommand(endpoint, opName) {
return false
}
switch strings.ToUpper(strings.TrimSpace(endpoint.Method)) {
case "POST", "PUT", "PATCH":
return true
default:
return false
}
}
// endpointIsReadCommand is the inverse of endpointIsWriteCommand.
// Templates use this through the IsReadOnly field on their data
// structs to decide whether to emit Annotations["mcp:read-only"].
// Centralizing the negation keeps "read-only command = not a write
// command" declared in one place; a future definition shift (e.g. a
// new annotation that forces read-only regardless of verb) only needs
// one update site.
func endpointIsReadCommand(endpoint spec.Endpoint, opName string) bool {
return !endpointIsWriteCommand(endpoint, opName)
}
func endpointIsReadCommandShared(endpoint spec.Endpoint, opName string, sharedGETPaths map[string]bool) bool {
return !endpointIsWriteCommandShared(endpoint, opName, sharedGETPaths)
}
// camelCaseTokens splits "getOrCreate" → ["get", "Or", "Create"] and
// "searchAll" → ["search", "All"]. Digits stay attached to the preceding
// token, while punctuation separators create a new token.
func camelCaseTokens(s string) []string {
if s == "" {
return nil
}
var tokens []string
var cur []rune
for _, r := range s {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
if len(cur) > 0 {
tokens = append(tokens, string(cur))
cur = nil
}
continue
}
if unicode.IsUpper(r) && len(cur) > 0 {
tokens = append(tokens, string(cur))
cur = []rune{r}
continue
}
cur = append(cur, r)
}
if len(cur) > 0 {
tokens = append(tokens, string(cur))
}
return tokens
}
// bodyIsAllFilterShape reports whether every body param's name is in
// readBodyParamNames. Returns false for empty bodies so a POST with no body
// (the fail-closed default) stays classified as a write.
func bodyIsAllFilterShape(body []spec.Param) bool {
if len(body) == 0 {
return false
}
for _, p := range body {
if !readBodyParamNames[strings.ToLower(strings.TrimSpace(p.Name))] {
return false
}
}
return true
}
func (g *Generator) templateData() *generatorTemplateData {
return &generatorTemplateData{
APISpec: g.Spec,
CompactDescription: g.compactDescription(),
TrafficAnalysis: g.trafficAnalysisData(),
}
}
func (g *Generator) trafficAnalysisData() *trafficAnalysisTemplateData {
if g.TrafficAnalysis == nil {
return nil
}
analysis := g.TrafficAnalysis
data := &trafficAnalysisTemplateData{
TargetURL: safeDisplayURL(analysis.Summary.TargetURL),
EntryCount: analysis.Summary.EntryCount,
APIEntryCount: analysis.Summary.APIEntryCount,
}
if analysis.Reachability != nil {
data.Reachability = fmt.Sprintf("%s (%.0f%% confidence)", analysis.Reachability.Mode, analysis.Reachability.Confidence*100)
}
for _, protocol := range analysis.Protocols {
data.Protocols = appendLimited(data.Protocols, fmt.Sprintf("%s (%.0f%% confidence)", protocol.Label, protocol.Confidence*100), 8)
}
for _, candidate := range analysis.Auth.Candidates {
parts := []string{candidate.Type}
if len(candidate.HeaderNames) > 0 {
parts = append(parts, "headers: "+strings.Join(candidate.HeaderNames, ", "))
}
if len(candidate.QueryNames) > 0 {
parts = append(parts, "query: "+strings.Join(candidate.QueryNames, ", "))
}
if len(candidate.CookieNames) > 0 {
parts = append(parts, "cookies: "+strings.Join(candidate.CookieNames, ", "))
}
data.AuthCandidates = appendLimited(data.AuthCandidates, strings.Join(parts, " — "), 8)
}
for _, protection := range analysis.Protections {
data.Protections = appendLimited(data.Protections, fmt.Sprintf("%s (%.0f%% confidence)", protection.Label, protection.Confidence*100), 8)
}
for _, hint := range analysis.GenerationHints {
data.GenerationHints = appendLimited(data.GenerationHints, hint, 10)
}
for _, warning := range analysis.Warnings {
data.Warnings = appendLimited(data.Warnings, warning.Type+": "+warning.Message, 10)
}
for _, command := range analysis.CandidateCommands {
label := command.Name
if command.Rationale != "" {
label += " — " + command.Rationale
}
data.CandidateCommands = appendLimited(data.CandidateCommands, label, 8)
}
return data
}
func (g *Generator) hasTrafficAnalysisHint(hint string) bool {
if g == nil || g.TrafficAnalysis == nil {
return false
}
return slices.Contains(g.TrafficAnalysis.GenerationHints, hint)
}
func (g *Generator) shouldUseChromeImpersonation() bool {
if g == nil || g.TrafficAnalysis == nil || g.TrafficAnalysis.Reachability == nil || g.TrafficAnalysis.Reachability.ImpersonationSafe == nil {
return true
}
return *g.TrafficAnalysis.Reachability.ImpersonationSafe
}
func appendLimited(values []string, value string, limit int) []string {
value = strings.TrimSpace(value)
if value == "" || len(values) >= limit {
return values
}
return append(values, value)
}
func safeDisplayURL(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parsed, err := url.Parse(value)
if err != nil {
return ""
}
parsed.User = nil
parsed.RawQuery = ""
parsed.ForceQuery = false
parsed.Fragment = ""
return parsed.String()
}
// resourceHasMutation reports whether the resource has any mutating endpoint
// directly on itself. This uses the same classifier as command generation so
// explicit and conservative GET mutation signals are reflected in agent
// context. Each sub-resource is surfaced as its own taxonomy entry and
// evaluated independently, so this deliberately does NOT recurse into
// r.SubResources — a read-only parent must not inherit a child's writability,
// and vice versa.
func resourceHasMutationShared(r spec.Resource, sharedGETPaths map[string]bool) bool {
for name, endpoint := range r.Endpoints {
if endpointIsWriteCommandShared(endpoint, name, sharedGETPaths) {
return true
}
}
return false
}
// buildDomainContext constructs structured domain knowledge for MCP agents
// from the spec and profiler output. This is front-loaded context that prevents
// agents from wasting tokens discovering what the API is about.
func (g *Generator) buildDomainContext() DomainContext {
ctx := DomainContext{
APIName: g.Spec.Name,
Description: g.compactDescription(),
Archetype: string(profiler.ArchetypeGeneric),
}
if g.profile != nil {
ctx.Archetype = string(g.profile.Domain.Archetype)
// Build resource summaries with syncable/searchable annotations
syncSet := make(map[string]bool)
for _, sr := range g.profile.SyncableResources {
syncSet[sr.Name] = true
}
sharedGETPaths := sharedGETRPCPaths(g.Spec.Resources)
// addResourceSummaries emits one ResourceSummary for the resource named
// `name` (dotted path for sub-resources, e.g. "projects.issues") and
// then recurses into its sub-resources. syncable/searchable are looked
// up by the qualified name: the profiler keys those maps by bare
// top-level name, so a dotted name is never a key and correctly resolves
// to false (omit-over-guess) for sub-entries.
var addResourceSummaries func(name string, r spec.Resource)
addResourceSummaries = func(name string, r spec.Resource) {
rs := ResourceSummary{
Name: name,
Description: naming.OneLine(r.Description),
Syncable: syncSet[name],
Searchable: len(g.profile.SearchableFields[name]) > 0,
Writable: resourceHasMutationShared(r, sharedGETPaths),
}
for eName := range r.Endpoints {
rs.Endpoints = append(rs.Endpoints, eName)
}
sort.Strings(rs.Endpoints)
ctx.Resources = append(ctx.Resources, rs)
subNames := make([]string, 0, len(r.SubResources))
for subName := range r.SubResources {
subNames = append(subNames, subName)
}
sort.Strings(subNames)
for _, subName := range subNames {
addResourceSummaries(name+"."+subName, r.SubResources[subName])
}
}
for rName, r := range g.Spec.Resources {
addResourceSummaries(rName, r)
}
sort.Slice(ctx.Resources, func(i, j int) bool {
return ctx.Resources[i].Name < ctx.Resources[j].Name
})
// Add query tips based on pagination profile
if g.profile.Pagination.CursorParam != "" {
ctx.QueryTips = append(ctx.QueryTips,
fmt.Sprintf("Pagination uses cursor-based paging. Pass %s parameter for subsequent pages.", g.profile.Pagination.CursorParam))
}
if g.profile.Pagination.PageSizeParam != "" {
ctx.QueryTips = append(ctx.QueryTips,
fmt.Sprintf("Control page size with the %s parameter (default %d).", g.profile.Pagination.PageSizeParam, g.profile.Pagination.DefaultPageSize))
}
if g.profile.Pagination.SinceParam != "" {
ctx.QueryTips = append(ctx.QueryTips,
fmt.Sprintf("Use %s for incremental fetches (filter by modification time).", g.profile.Pagination.SinceParam))
}
}
// Add playbook entries from novel features
for _, nf := range g.NovelFeatures {
ctx.Playbook = append(ctx.Playbook, PlaybookEntry{
Topic: nf.Name,
Insight: nf.Rationale,
})
}
// Add data layer tips when store is available
if g.hasDataLayer() {
ctx.QueryTips = append(ctx.QueryTips,
"Use the sql tool for ad-hoc analysis on synced data. Run sync first to populate the local database.",
"Use the search tool for full-text search across all synced resources. Faster than iterating list endpoints.",
"Prefer sql/search over repeated API calls when the data is already synced.")
}
// Add archetype-specific playbook entries — domain opinions that agents
// can't discover from the API spec alone (PostHog Rule 4: skills are human knowledge)
if g.profile != nil {
ctx.Playbook = append(ctx.Playbook, archetypePlaybook(g.profile.Domain.Archetype)...)
}
return ctx
}
// archetypePlaybook returns domain-specific insights based on API archetype.
// These are opinionated tips that prevent common agent mistakes.
func archetypePlaybook(arch profiler.DomainArchetype) []PlaybookEntry {
switch arch {
case profiler.ArchetypeProjectMgmt:
return []PlaybookEntry{
{Topic: "Finding stale work", Insight: "Use the stale command or sql query to find items not updated recently. More reliable than scanning list results manually."},
{Topic: "Load analysis", Insight: "When analyzing team workload, filter by assignee and status. Raw counts without status filtering are misleading."},
{Topic: "Bulk operations", Insight: "For bulk status changes, prefer update endpoints over delete+create. Most PM APIs track history on updates."},
}
case profiler.ArchetypeCommunication:
return []PlaybookEntry{
{Topic: "Message search", Insight: "Use the search tool on synced data rather than paginating through message history. Message APIs often have aggressive rate limits."},
{Topic: "Channel health", Insight: "When analyzing channel activity, use sql aggregation on synced messages. Don't iterate individual messages via API."},
}
case profiler.ArchetypePayments:
return []PlaybookEntry{
{Topic: "Financial data", Insight: "Always use read-only operations for financial queries. Never use create/update tools for payment data without explicit user confirmation."},
{Topic: "Reconciliation", Insight: "For reconciliation tasks, sync first then use sql for cross-referencing. API pagination over financial records is slow and rate-limited."},
}
case profiler.ArchetypeCRM:
return []PlaybookEntry{
{Topic: "Contact lookup", Insight: "Use search for finding contacts by name/email. List endpoints return unsorted results and require pagination for large datasets."},
{Topic: "Activity tracking", Insight: "When checking deal activity, sync first and query locally. CRM APIs often throttle activity-log endpoints heavily."},
}
case profiler.ArchetypeDeveloperPlatform:
return []PlaybookEntry{
{Topic: "Resource discovery", Insight: "Use list commands to discover available resources before attempting operations. Developer platform APIs often have nested resource hierarchies."},
}
default:
return nil
}
}
func (g *Generator) prepareOutput() error {
dirs := []string{
filepath.Join("cmd", naming.CLI(g.Spec.Name)),
filepath.Join("internal", "cli"),
filepath.Join("internal", "cache"),
filepath.Join("internal", "client"),
filepath.Join("internal", "cliutil"),
filepath.Join("internal", "cliutil", "testenv"),
filepath.Join("internal", "config"),
filepath.Join("internal", "mcp", "bound"),
filepath.Join("internal", "mcp", "cobratree"),
filepath.Join("internal", "platform"),
filepath.Join("internal", "platform", "testdata"),
filepath.Join("internal", "types"),
}
// Reserve the learn-package directory tree only when the spec opts
// in. Keeping these gated avoids creating empty dirs in CLIs that
// don't ship the self-learning loop, which would otherwise show up
// as untracked dirs in published-library trees.
if g.Spec.Learn.Enabled {
dirs = append(dirs,
filepath.Join("internal", "learn"),
filepath.Join("internal", "learn", "entities"),
filepath.Join("internal", "learn", "lookups"),
filepath.Join("internal", "learn", "patterns"),
)
}
if g.Spec.Streaming.Enabled() {
dirs = append(dirs, filepath.Join("internal", "wsclient"))
}
for _, d := range dirs {
if err := os.MkdirAll(filepath.Join(g.OutputDir, d), 0755); err != nil {
return fmt.Errorf("creating dir %s: %w", d, err)
}
}
// Early profiling: compute VisionSet before endpoint rendering so
// templates can check HasStore for data source resolution.
if g.VisionSet.IsZero() {
g.profile = profiler.Profile(g.Spec)
g.resetHTMLSyncStubCache()
plan := g.profile.ToVisionaryPlan(g.Spec.Name)
g.VisionSet = SelectVisionTemplates(plan)
}
if g.profile == nil {
g.profile = profiler.Profile(g.Spec)
g.resetHTMLSyncStubCache()
}
storeSelectedBeforeConstrain := g.VisionSet.Store
g.htmlSyncStubSelected = g.VisionSet.Sync && g.shouldEmitHTMLSyncStub()
g.VisionSet = constrainVisionTemplates(g.Spec, g.VisionSet, g.profile, os.Stderr)
if g.Spec.Learn.Enabled && !storeSelectedBeforeConstrain && g.VisionSet.Store {
g.learnStorePromoted = true
}
if g.Spec.Learn.Enabled && !g.VisionSet.Store {
// Defensive: constrainVisionTemplates already promotes Store for
// learn-enabled specs, so this branch is unreachable through the
// normal path. Promote rather than error so callers that force-set
// VisionSet around constrain keep working (soft-validation posture).
g.VisionSet.Store = true
fmt.Fprint(os.Stderr, learnStorePromotionInfo)
}
g.resolveVisionCommandNames()
if g.renameActiveFrameworkResourceCollisions() {
g.profile = profiler.Profile(g.Spec)
g.resetHTMLSyncStubCache()
}
promoteSyncPathContextVars(g.Spec, g.profile)
if err := g.validateFreshnessCommandCoverage(); err != nil {
return err
}
// Detect async-job endpoints once per generation. Results flow into
// per-endpoint template data (for conditional --wait emission) and into
// the root template (for the jobs command registration).
if g.AsyncJobs == nil {
g.AsyncJobs = DetectAsyncJobs(g.Spec)
}
// Suffix any param whose Go identifier or cobra flag name would collide
// with another param on the same endpoint or with a generator-introduced
// reserved name (pagination's flagAll, async's flagWait*). Must run after
// AsyncJobs detection so async endpoints reserve the wait identifiers.
if err := g.dedupeFlagIdentifiers(); err != nil {
return err
}
// Assert the FINAL per-endpoint MCP input-name set (post-dedup) is legal
// and collision-free before anything renders — an illegal or duplicate
// tool property key must fail generation, never ship (#165).
if err := g.validateMCPInputNames(); err != nil {
return err
}
g.dedupeTypeFieldIdentifiers()
return nil
}
func (g *Generator) renderSingleFiles() error {
singleFiles := map[string]string{
"main.go.tmpl": filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
"version.go.tmpl": filepath.Join("internal", "cli", "version.go"),
"helpers.go.tmpl": filepath.Join("internal", "cli", "helpers.go"),
"root_test.go.tmpl": filepath.Join("internal", "cli", "root_test.go"),
"doctor.go.tmpl": filepath.Join("internal", "cli", "doctor.go"),
"agent_context.go.tmpl": filepath.Join("internal", "cli", "agent_context.go"),
"profile.go.tmpl": filepath.Join("internal", "cli", "profile.go"),
"deliver.go.tmpl": filepath.Join("internal", "cli", "deliver.go"),
"feedback.go.tmpl": filepath.Join("internal", "cli", "feedback.go"),
"which.go.tmpl": filepath.Join("internal", "cli", "which.go"),
"which_test.go.tmpl": filepath.Join("internal", "cli", "which_test.go"),
"config.go.tmpl": filepath.Join("internal", "config", "config.go"),
"cache.go.tmpl": filepath.Join("internal", "cache", "cache.go"),
"platform_profile.go.tmpl": filepath.Join("internal", "platform", "profile.go"),
"platform_perms_unix.go.tmpl": filepath.Join("internal", "platform", "perms_unix.go"),
"platform_perms_windows.go.tmpl": filepath.Join("internal", "platform", "perms_windows.go"),
"platform_gate.go.tmpl": filepath.Join("internal", "platform", "gate.go"),
"platform_metadata.go.tmpl": filepath.Join("internal", "platform", "metadata.go"),
"platform_migration.go.tmpl": filepath.Join("internal", "platform", "migration.go"),
"platform_ratelimit.go.tmpl": filepath.Join("internal", "platform", "ratelimit.go"),
"platform_receipt.go.tmpl": filepath.Join("internal", "platform", "receipt.go"),
"platform_doctor.go.tmpl": filepath.Join("internal", "platform", "doctor.go"),
"platform_cli.go.tmpl": filepath.Join("internal", "cli", "platform_client.go"),
"platform_cli_test.go.tmpl": filepath.Join("internal", "cli", "platform_cli_test.go"),
"platform_window.go.tmpl": filepath.Join("internal", "cli", "platform_window.go"),
"platform_window_test.go.tmpl": filepath.Join("internal", "cli", "platform_window_test.go"),
"platform_conformance_test.go.tmpl": filepath.Join("internal", "platform", "conformance_test.go"),
"platform_receipt_status_golden.json.tmpl": filepath.Join("internal", "platform", "testdata", "receipt-status-golden.json"),
"client.go.tmpl": filepath.Join("internal", "client", "client.go"),
"client_test.go.tmpl": filepath.Join("internal", "client", "client_test.go"),
"client_platform_budget_test.go.tmpl": filepath.Join("internal", "client", "platform_budget_test.go"),
"client_platform_rate_limit_test.go.tmpl": filepath.Join("internal", "client", "platform_rate_limit_test.go"),
"client_verify_short_circuit_test.go.tmpl": filepath.Join("internal", "client", "client_verify_short_circuit_test.go"),
"cliutil_fanout.go.tmpl": filepath.Join("internal", "cliutil", "fanout.go"),
"cliutil_text.go.tmpl": filepath.Join("internal", "cliutil", "text.go"),
"cliutil_probe.go.tmpl": filepath.Join("internal", "cliutil", "probe.go"),
"cliutil_ratelimit.go.tmpl": filepath.Join("internal", "cliutil", "ratelimit.go"),
"cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
"cliutil_paths.go.tmpl": filepath.Join("internal", "cliutil", "paths.go"),
"cliutil_paths_test.go.tmpl": filepath.Join("internal", "cliutil", "paths_test.go"),
"cliutil_filelock.go.tmpl": filepath.Join("internal", "cliutil", "filelock.go"),
"cliutil_filelock_unix.go.tmpl": filepath.Join("internal", "cliutil", "filelock_unix.go"),
"cliutil_filelock_windows.go.tmpl": filepath.Join("internal", "cliutil", "filelock_windows.go"),
"cliutil_filelock_test.go.tmpl": filepath.Join("internal", "cliutil", "filelock_test.go"),
"cliutil_testenv.go.tmpl": filepath.Join("internal", "cliutil", "testenv", "testenv.go"),
"cliutil_testenv_sandbox_unix.go.tmpl": filepath.Join("internal", "cliutil", "testenv", "sandbox_unix.go"),
"cliutil_testenv_sandbox_windows.go.tmpl": filepath.Join("internal", "cliutil", "testenv", "sandbox_windows.go"),
"cliutil_extractnumber.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber.go"),
"cliutil_extractnumber_test.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber_test.go"),
"cliutil_jwtshape.go.tmpl": filepath.Join("internal", "cliutil", "jwtshape.go"),
"cliutil_jwtshape_test.go.tmpl": filepath.Join("internal", "cliutil", "jwtshape_test.go"),
"cliutil_duration.go.tmpl": filepath.Join("internal", "cliutil", "duration.go"),
"cliutil_duration_test.go.tmpl": filepath.Join("internal", "cliutil", "duration_test.go"),
"cliutil_odata_date.go.tmpl": filepath.Join("internal", "cliutil", "odata_date.go"),
"cliutil_odata_date_test.go.tmpl": filepath.Join("internal", "cliutil", "odata_date_test.go"),
"cliutil_test.go.tmpl": filepath.Join("internal", "cliutil", "cliutil_test.go"),
"mcp_bound.go.tmpl": filepath.Join("internal", "mcp", "bound", "bound.go"),
"mcp_bound_test.go.tmpl": filepath.Join("internal", "mcp", "bound", "bound_test.go"),
"types.go.tmpl": filepath.Join("internal", "types", "types.go"),
"golangci.yml.tmpl": ".golangci.yml",
"readme.md.tmpl": "README.md",
"agents.md.tmpl": "AGENTS.md",
"claude.md.tmpl": "CLAUDE.md",
"skill.md.tmpl": "SKILL.md",
"LICENSE.tmpl": "LICENSE",
"NOTICE.tmpl": "NOTICE",
}
maps.Copy(singleFiles, cobratreeWalkerTemplateFiles())
for tmplName, outPath := range singleFiles {
if tmplName == "types.go.tmpl" && g.shouldPreserveExistingTypesFile(outPath) {
continue
}
var data any
switch tmplName {
case "readme.md.tmpl", "agents.md.tmpl", "claude.md.tmpl", "skill.md.tmpl", "which.go.tmpl", "which_test.go.tmpl":
data = g.readmeData()
case "helpers.go.tmpl":
hFlags := computeHelperFlags(g.Spec)
applyPartialFailureFlags(&hFlags, g.Spec, g.PromotedCommands, g.PromotedEndpointNames, g.hasDataLayer())
hFlags.HasDataLayer = g.hasDataLayer()
hFlags.HasStorePath = g.VisionSet.Store
hFlags.HasSyncHelpers = g.hasGeneratedSyncImplementation()
hFlags.HasResponseUnwrap = g.hasDataLayer() && promotedCommandsCanUnwrapResponse(g.PromotedCommands, g.Spec.Types)
data = &helpersTemplateData{
APISpec: g.Spec,
HelperFlags: hFlags,
HasAuthCommand: g.shouldEmitAuth(),
}
case "root_test.go.tmpl":
data = &rootTestTemplateData{
APISpec: g.Spec,
ExpectedCommandPaths: expectedCommandPaths(buildCommandSurface(g.Spec, g.PromotedCommands)),
HasAuthCommand: g.shouldEmitAuth(),
}
case "doctor.go.tmpl":
data = &doctorTemplateData{
APISpec: g.Spec,
HasStore: g.hasDataLayer(),
HasCacheReport: g.hasGeneratedSyncImplementation(),
HasAuthCommand: g.shouldEmitAuth(),
}
case "cliutil_paths.go.tmpl":
data = &pathsTemplateData{
APISpec: g.Spec,
PathKindEnvSuffixes: naming.PathKindEnvSuffixes(),
}
case "client.go.tmpl":
data = &clientTemplateData{
APISpec: g.Spec,
IsGraphQL: isGraphQLSpec(g.Spec),
HasGraphQLPersistedQueries: g.hasTrafficAnalysisHint("graphql_persisted_query"),
HasMultipartRequest: hasMultipartRequest(g.Spec),
HasFormRequest: hasFormRequest(g.Spec),
HasRawRequest: hasRawRequest(g.Spec),
UseChromeImpersonation: g.shouldUseChromeImpersonation(),
HasAuthCommand: g.shouldEmitAuth(),
}
case "config.go.tmpl":
data = &configTemplateData{
APISpec: g.Spec,
HasAuthCommand: g.shouldEmitAuth(),
CredentialFields: credentialFields(g.Spec.Auth),
UsesLegacyEnvVarCredentials: usesLegacyEnvVarCredentials(g.Spec.Auth),
}
case "agent_context.go.tmpl":
data = g.templateData()
default:
data = g.Spec
}
if err := g.renderTemplate(tmplName, outPath, data); err != nil {
return fmt.Errorf("rendering %s: %w", tmplName, err)
}
}
return nil
}
func (g *Generator) shouldPreserveExistingTypesFile(outPath string) bool {
if g == nil || g.Spec == nil || g.Spec.SpecSource != "sniffed" {
return false
}
data, err := os.ReadFile(filepath.Join(g.OutputDir, outPath))
if err != nil {
return false
}
return generatedTypesFileHasDeclarations(string(data))
}
func generatedTypesFileHasDeclarations(content string) bool {
for line := range strings.SplitSeq(content, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "type ") {
return true
}
}
return false
}
func (g *Generator) renderOptionalSupportFiles() error {
if g.shouldEmitAuth() {
authData := &configTemplateData{
APISpec: g.Spec,
HasAuthCommand: true,
CredentialFields: credentialFields(g.Spec.Auth),
UsesLegacyEnvVarCredentials: usesLegacyEnvVarCredentials(g.Spec.Auth),
}
if err := g.renderTemplate("cliutil_credentials.go.tmpl", filepath.Join("internal", "cliutil", "credentials.go"), authData); err != nil {
return fmt.Errorf("rendering cliutil credentials: %w", err)
}
if err := g.renderTemplate("cliutil_credentials_test.go.tmpl", filepath.Join("internal", "cliutil", "credentials_test.go"), authData); err != nil {
return fmt.Errorf("rendering cliutil credentials test: %w", err)
}
// Behavioral test proving the read-time guard is wired into
// LoadCredentials (A4): owner-locked = hit, group/world-readable = soft
// miss (the not-found sentinel, no credential surfaced), over-permissive
// is not an error. In-package (package cliutil) so it drives
// credentialsPath() + VerifyCredsPerms directly.
if err := g.renderTemplate("cliutil_credentials_perms_test.go.tmpl", filepath.Join("internal", "cliutil", "credentials_perms_test.go"), authData); err != nil {
return fmt.Errorf("rendering cliutil credentials perms test: %w", err)
}
// Read-time credentials permission check (S1): a persisted token file
// written 0600 can later drift to loose perms, so guard it at load time.
// Emitted alongside credentials because it only matters when a token is
// persisted. Pure evaluator is build-tag-free (unit-testable anywhere);
// the POSIX and Windows guards are platform-gated.
if err := g.renderTemplate("cliutil_creds_perms_eval.go.tmpl", filepath.Join("internal", "cliutil", "creds_perms_eval.go"), authData); err != nil {
return fmt.Errorf("rendering cliutil creds perms evaluator: %w", err)
}
if err := g.renderTemplate("cliutil_creds_perms_eval_test.go.tmpl", filepath.Join("internal", "cliutil", "creds_perms_eval_test.go"), authData); err != nil {
return fmt.Errorf("rendering cliutil creds perms evaluator test: %w", err)
}
if err := g.renderTemplate("cliutil_creds_perms_unix.go.tmpl", filepath.Join("internal", "cliutil", "creds_perms_unix.go"), authData); err != nil {
return fmt.Errorf("rendering cliutil creds perms unix: %w", err)
}
if err := g.renderTemplate("cliutil_creds_perms_windows.go.tmpl", filepath.Join("internal", "cliutil", "creds_perms_windows.go"), authData); err != nil {
return fmt.Errorf("rendering cliutil creds perms windows: %w", err)
}
// Behavioral test proving the read-time guard is wired into config.Load:
// owner-locked = hit, group/world-readable = silent miss, env re-seed
// after refusal, symlink-to-loose-target refused, dangling = miss, and
// the parse-error path never leaks the token (S3).
if err := g.renderTemplate("config_perms_test.go.tmpl", filepath.Join("internal", "config", "config_perms_test.go"), authData); err != nil {
return fmt.Errorf("rendering config perms test: %w", err)
}
}
if g.hasOAuthTokenExchange() {
if err := g.renderTemplate("cliutil_oauth_token.go.tmpl", filepath.Join("internal", "cliutil", "oauth_token.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil oauth token client: %w", err)
}
if err := g.renderTemplate("cliutil_oauth_token_test.go.tmpl", filepath.Join("internal", "cliutil", "oauth_token_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil oauth token client test: %w", err)
}
}
if g.Spec.HasHTMLExtraction() {
if err := g.renderTemplate("html_extract.go.tmpl", filepath.Join("internal", "cli", "html_extract.go"), g.Spec); err != nil {
return fmt.Errorf("rendering HTML extraction helper: %w", err)
}
}
if g.hasGeneratedSyncImplementation() {
if err := g.renderTemplate("sync_hint.go.tmpl", filepath.Join("internal", "cli", "sync_hint.go"), g.Spec); err != nil {
return fmt.Errorf("rendering sync hint helper: %w", err)
}
if err := g.renderTemplate("sync_hint_test.go.tmpl", filepath.Join("internal", "cli", "sync_hint_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering sync hint test: %w", err)
}
}
// Emit the cliutil freshness helper only when the spec opts into cache
// or share and the CLI has a local store. Without a store there is
// nothing to check freshness against; without cache or share opt-in
// there is no caller that consumes the Decision.
if g.VisionSet.Store && ((g.Spec.Cache.Enabled && g.hasGeneratedSyncImplementation()) || g.Spec.Share.Enabled) {
if err := g.renderTemplate("cliutil_freshness.go.tmpl", filepath.Join("internal", "cliutil", "freshness.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil freshness: %w", err)
}
if err := g.renderTemplate("cliutil_freshness_test.go.tmpl", filepath.Join("internal", "cliutil", "freshness_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil freshness test: %w", err)
}
}
if hasCSVArrayRequest(g.Spec) {
if err := g.renderTemplate("cliutil_csv.go.tmpl", filepath.Join("internal", "cliutil", "csv.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil csv: %w", err)
}
}
if hasCSVResponse(g.Spec) {
if err := g.renderTemplate("cliutil_csv_parse.go.tmpl", filepath.Join("internal", "cliutil", "csv_parse.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil csv parse: %w", err)
}
}
if g.Spec.HasXMLResponse() {
if err := g.renderTemplate("cliutil_xml_parse.go.tmpl", filepath.Join("internal", "cliutil", "xml_parse.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil xml parse: %w", err)
}
}
// Emit the cliutil proxypath helper only for proxy-envelope clients —
// the BuildPath function is the only caller of net/url.Values in the
// cliutil package, and there's no point shipping it (and its tests)
// into CLIs that don't speak the proxy-envelope protocol.
if g.Spec.ClientPattern == "proxy-envelope" {
if err := g.renderTemplate("cliutil_proxypath.go.tmpl", filepath.Join("internal", "cliutil", "proxypath.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil proxypath: %w", err)
}
if err := g.renderTemplate("cliutil_proxypath_test.go.tmpl", filepath.Join("internal", "cliutil", "proxypath_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cliutil proxypath test: %w", err)
}
}
// Emit the auto-refresh wrapper only when cache is explicitly enabled
// and the CLI has both a store and a sync path to call. Without sync
// there is nothing to refresh with; without cache.enabled there is no
// read-path hook that would call autoRefreshIfStale.
if g.hasAutoRefresh() {
autoRefreshData := struct {
*spec.APISpec
SyncableResources []profiler.SyncableResource
DependentSyncResources []profiler.DependentResource
Pagination profiler.PaginationProfile
}{
APISpec: g.Spec,
SyncableResources: g.profile.SyncableResources,
DependentSyncResources: g.profile.DependentSyncResources,
Pagination: g.profile.Pagination,
}
if err := g.renderTemplate("auto_refresh.go.tmpl", filepath.Join("internal", "cli", "auto_refresh.go"), autoRefreshData); err != nil {
return fmt.Errorf("rendering auto_refresh: %w", err)
}
if err := g.renderTemplate("auto_refresh_test.go.tmpl", filepath.Join("internal", "cli", "auto_refresh_test.go"), autoRefreshData); err != nil {
return fmt.Errorf("rendering auto_refresh_test: %w", err)
}
}
// Emit the git-backed share package only when explicitly enabled and
// the CLI has a local store. Share requires a SnapshotTables allowlist;
// spec.Validate has already rejected a missing allowlist with a clear
// error before we reach this point.
if g.VisionSet.Store && g.Spec.Share.Enabled {
if err := os.MkdirAll(filepath.Join(g.OutputDir, "internal", "share"), 0o755); err != nil {
return fmt.Errorf("creating share dir: %w", err)
}
if err := g.renderTemplate("share.go.tmpl", filepath.Join("internal", "share", "share.go"), g.Spec); err != nil {
return fmt.Errorf("rendering share: %w", err)
}
if err := g.renderTemplate("share_test.go.tmpl", filepath.Join("internal", "share", "share_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering share test: %w", err)
}
if err := g.renderTemplate("share_commands.go.tmpl", filepath.Join("internal", "cli", "share_commands.go"), g.Spec); err != nil {
return fmt.Errorf("rendering share commands: %w", err)
}
}
// Emit the self-learning loop (entities + normalize + match +
// recall + teach + teach_log + preseed + lookups + patterns) only
// when the spec opts in via Learn.Enabled. The schema migrations
// for the supporting tables are emitted from store.go.tmpl under
// the same gate; see internal/generator/templates/learn/doc.go.tmpl
// for the package-level design and the schema-adaptation note.
if g.Spec.Learn.Enabled {
if err := g.renderLearnFiles(); err != nil {
return err
}
// learnings.go ports prediction-goat's canonical Apply engine
// (Apply/Recall/UpsertLearning/ListLearnings/ForgetLearnings +
// LearnedHit/Applier types + NormalizeQuery/MarshalLearnings
// helpers) into the generator's internal/store/ output. The
// engine is a method on *store.Store, so it lives next to
// store.go rather than under internal/learn/.
//
// The internal/store directory is created by renderStoreFiles()
// later in Generate(), but the learn-emission block runs first.
// Ensure the directory exists before writing into it. Idempotent
// with the subsequent MkdirAll in renderStoreFiles.
if err := os.MkdirAll(filepath.Join(g.OutputDir, "internal", "store"), 0o755); err != nil {
return fmt.Errorf("creating store dir for learn engine: %w", err)
}
if err := g.renderTemplate("learnings.go.tmpl", filepath.Join("internal", "store", "learnings.go"), g.Spec); err != nil {
return fmt.Errorf("rendering store learnings engine: %w", err)
}
if err := g.renderTemplate("learnings_test.go.tmpl", filepath.Join("internal", "store", "learnings_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering store learnings engine test: %w", err)
}
// store/playbooks.go ports the playbook-row CRUD (UpsertPlaybook,
// AppendPlaybookNotes, GetPlaybookByFamily, ListPlaybooks) into
// the generator's internal/store/ output. Lives next to
// learnings.go and shares the LearningSourceTaught constant + the
// learning_playbooks table created by store.go.tmpl. Gated under
// Learn.Enabled for the same reason as learnings.go.
if err := g.renderTemplate("store_playbooks.go.tmpl", filepath.Join("internal", "store", "playbooks.go"), g.Spec); err != nil {
return fmt.Errorf("rendering store playbooks: %w", err)
}
if err := g.renderTemplate("store_playbooks_test.go.tmpl", filepath.Join("internal", "store", "playbooks_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering store playbooks test: %w", err)
}
// store/candidates.go ships the quarantined learn_candidates
// lifecycle (DeriveCandidate signature upsert plus the
// confirm/reject/expire/purge state machine) beside
// learnings.go and playbooks.go. The backing table is created
// by store.go.tmpl at schema v9 under the same Learn gate;
// candidates never touch the verified learning tables until an
// explicit confirm materializes them through the playbook
// machinery.
if err := g.renderTemplate("store_candidates.go.tmpl", filepath.Join("internal", "store", "candidates.go"), g.Spec); err != nil {
return fmt.Errorf("rendering store candidates lifecycle: %w", err)
}
if err := g.renderTemplate("store_candidates_test.go.tmpl", filepath.Join("internal", "store", "candidates_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering store candidates lifecycle test: %w", err)
}
// store/events.go ships the learn_events measurement layer:
// best-effort telemetry inserts (callers swallow errors to
// teach.log), retention pruning, the forget cascade, the
// surface-detection seam, and the stats aggregation `learnings
// stats` reads. The backing table is created by store.go.tmpl
// at schema v9 under the same Learn gate.
if err := g.renderTemplate("store_events.go.tmpl", filepath.Join("internal", "store", "events.go"), g.Spec); err != nil {
return fmt.Errorf("rendering store events measurement layer: %w", err)
}
if err := g.renderTemplate("store_events_test.go.tmpl", filepath.Join("internal", "store", "events_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering store events measurement layer test: %w", err)
}
// teach.go and teach_test.go are emitted into internal/cli/
// (not the learn package) because they wire cobra commands;
// the learn package itself stays cobra-free per the boundary
// established in U3-U5.
if err := g.renderTemplate("teach.go.tmpl", filepath.Join("internal", "cli", "teach.go"), g.Spec); err != nil {
return fmt.Errorf("rendering teach commands: %w", err)
}
if err := g.renderTemplate("teach_test.go.tmpl", filepath.Join("internal", "cli", "teach_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering teach commands test: %w", err)
}
// teach_playbook.go ships the standalone playbook write surface:
// `teach-playbook` (query-family-keyed playbook + notes), `playbook list`
// (sentinel-filtered inspection), and `playbook amend` (atomic
// AppendPlaybookNotes self-correction). Lives in internal/cli/ alongside
// teach.go for the same package-boundary reason — cobra wiring stays
// out of the learn package. Registration on the root command is
// owned by root.go.tmpl (a later unit wires it).
if err := g.renderTemplate("teach_playbook.go.tmpl", filepath.Join("internal", "cli", "teach_playbook.go"), g.Spec); err != nil {
return fmt.Errorf("rendering teach-playbook commands: %w", err)
}
if err := g.renderTemplate("teach_playbook_test.go.tmpl", filepath.Join("internal", "cli", "teach_playbook_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering teach-playbook commands test: %w", err)
}
// learnings_candidates.go ships the candidate control surface
// (`learnings candidates|confirm|reject|purge`) plus the
// teach-promotion helper teach.go calls after a successful
// teach. The commands register on the existing learnings group
// inside teach.go's newLearningsCmd.
if err := g.renderTemplate("learnings_candidates.go.tmpl", filepath.Join("internal", "cli", "learnings_candidates.go"), g.Spec); err != nil {
return fmt.Errorf("rendering learnings candidates commands: %w", err)
}
if err := g.renderTemplate("learnings_candidates_test.go.tmpl", filepath.Join("internal", "cli", "learnings_candidates_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering learnings candidates commands test: %w", err)
}
// learnings_stats.go ships the measurement readout (`learnings
// stats`): the four headline metrics aggregated from the local
// learn_events table, plus the opportunistic retention prune.
// Registers on the learnings group inside teach.go's
// newLearningsCmd.
if err := g.renderTemplate("learnings_stats.go.tmpl", filepath.Join("internal", "cli", "learnings_stats.go"), g.Spec); err != nil {
return fmt.Errorf("rendering learnings stats command: %w", err)
}
if err := g.renderTemplate("learnings_stats_test.go.tmpl", filepath.Join("internal", "cli", "learnings_stats_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering learnings stats command test: %w", err)
}
// internal/cli/playbooks/ ships the embed.FS scaffold for hand-
// authored playbook content (JSON + notes files). U9 emits the
// scaffold only — the auto-install path that walks this FS lives
// in playbook_init.go (a later unit). MANIFEST.md keeps the
// //go:embed *.json *.md directive matching at least one file
// when no authored content has shipped yet, so the package
// compiles cleanly on a fresh print.
if err := os.MkdirAll(filepath.Join(g.OutputDir, "internal", "cli", "playbooks"), 0o755); err != nil {
return fmt.Errorf("creating cli/playbooks dir for embed.FS scaffold: %w", err)
}
if err := g.renderTemplate("playbooks_embed.go.tmpl", filepath.Join("internal", "cli", "playbooks", "embed.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cli/playbooks embed.go: %w", err)
}
if err := g.renderTemplate("playbooks_manifest.md.tmpl", filepath.Join("internal", "cli", "playbooks", "MANIFEST.md"), g.Spec); err != nil {
return fmt.Errorf("rendering cli/playbooks MANIFEST.md: %w", err)
}
// playbook_init.go is the embed-FS auto-install path. At first
// DB open after schema migration it walks playbooks.FS, parses
// each <base>.json + <base>_notes.md pair, derives the query
// family via learn.QueryFamily(learn.Normalize(example)), and
// upserts a row per family. A sentinel row tracks SeedVersion;
// re-seed fires on binary upgrades that bump the constant.
// `playbook amend` writes are preserved across upgrades via an
// anchored regex check on the `[amend YYYY-...]` marker.
// Root.go.tmpl wires runPlaybookInitOnce in a later unit.
if err := g.renderTemplate("playbook_init.go.tmpl", filepath.Join("internal", "cli", "playbook_init.go"), g.Spec); err != nil {
return fmt.Errorf("rendering playbook init: %w", err)
}
if err := g.renderTemplate("playbook_init_test.go.tmpl", filepath.Join("internal", "cli", "playbook_init_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering playbook init test: %w", err)
}
// learn_init.go translates spec.Learn (ticker patterns, stopwords,
// entity-lookup seeds) into a runtime *entities.Config and seeds
// the entity_lookups table at first start. Owns newLearnConfig()
// (which teach.go's command constructors call) and initLearn(),
// which root.go invokes from PersistentPreRunE under the same
// Learn.Enabled gate.
if err := g.renderTemplate("learn_init.go.tmpl", filepath.Join("internal", "cli", "learn_init.go"), learnInitTemplateData{
APISpec: g.Spec,
ResourceIdentityFields: learnResourceIdentityFieldEntries(g.Spec, g.profile),
}); err != nil {
return fmt.Errorf("rendering learn init: %w", err)
}
if err := g.renderTemplate("learn_init_test.go.tmpl", filepath.Join("internal", "cli", "learn_init_test.go"), g.Spec); err != nil {
return fmt.Errorf("rendering learn init test: %w", err)
}
}
if g.FixtureSet != nil {
if err := g.renderTemplate("captured_test.go.tmpl", filepath.Join("internal", "client", "client_captured_test.go"), g.FixtureSet); err != nil {
return fmt.Errorf("rendering captured fixture tests: %w", err)
}
}
// Persistent cookie jar — emitted only when the spec declares cookies
// (cookie or composed auth). Non-cookie CLIs (api_key, bearer, oauth2,
// session_handshake, Auth0-SPA) don't need the helper and get the same
// byte-identical client.go they always did. The session_handshake path
// uses sess.CookieJar() and bypasses this file entirely.
if g.Spec.Auth.HasCookies() && g.Spec.Auth.Type != "session_handshake" {
if err := g.renderTemplate("cookiejar.go.tmpl", filepath.Join("internal", "client", "cookiejar.go"), g.Spec); err != nil {
return fmt.Errorf("rendering cookie jar: %w", err)
}
}
// For GraphQL specs, emit additional client files (GraphQL transport + query constants)
if isGraphQLSpec(g.Spec) {
if err := g.renderTemplate("graphql_client.go.tmpl", filepath.Join("internal", "client", "graphql.go"), g.Spec); err != nil {
return fmt.Errorf("rendering graphql client: %w", err)
}
if err := g.renderTemplate("graphql_queries.go.tmpl", filepath.Join("internal", "client", "queries.go"), g.Spec); err != nil {
return fmt.Errorf("rendering graphql queries: %w", err)
}
}
// Specs that opt into cost-based throttling (Throttling.Enabled) get
// the ThrottleState primitives — bucket projection, cost-extension
// parser, retry helper. Gated by spec opt-in so existing GraphQL
// CLIs (Linear) and REST CLIs regenerate byte-identically. Lives
// under internal/client/ alongside graphql.go because the parser is
// graphql-shaped today; if a REST API ever exposes cost-bucket data
// in response headers this file would extend to read those, but the
// surface (ThrottleState, WaitForBudget, HandleThrottleError) is
// transport-agnostic.
if g.Spec.HasCostThrottling() {
if err := g.renderTemplate("throttle.go.tmpl", filepath.Join("internal", "client", "throttle.go"), g.Spec); err != nil {
return fmt.Errorf("rendering throttle helper: %w", err)
}
}
// Specs that declare per-tenant URL placeholders (e.g. Shopify's
// "{shop}" / "{version}") get a buildURL helper that resolves the
// {var} markers against env-backed Config.TemplateVars at request
// time. Specs without EndpointTemplateVars skip the file so existing
// CLIs regenerate byte-for-byte.
if len(g.Spec.EndpointTemplateVars) > 0 {
if err := g.renderTemplate("url.go.tmpl", filepath.Join("internal", "client", "url.go"), g.Spec); err != nil {
return fmt.Errorf("rendering url helper: %w", err)
}
}
if g.Spec.Streaming.Enabled() {
if err := g.renderTemplate("wsclient.go.tmpl", filepath.Join("internal", "wsclient", "client.go"), g.Spec); err != nil {
return fmt.Errorf("rendering websocket client helper: %w", err)
}
if err := g.renderTemplate("live_ws.go.tmpl", filepath.Join("internal", "cli", "live_ws.go"), g.Spec); err != nil {
return fmt.Errorf("rendering websocket live command: %w", err)
}
}
return nil
}
// renderLearnFiles emits the internal/learn package and its three
// sub-packages (entities, lookups, patterns) when the spec opts into
// the self-learning loop. The matching v3 schema migrations are
// emitted from internal/generator/templates/store.go.tmpl under the
// same Learn.Enabled gate.
//
// The teach_log.go.tmpl file uses the CLI name as the state-directory
// suffix; every other learn file is data-driven from the spec's
// LearnConfig values, which the per-CLI startup wires via NewConfig
// and SeedFromConfig at first run.
func (g *Generator) renderLearnFiles() error {
if err := validateLearnTickerPlaybookReachability(g.Spec.Learn, g.OutputDir); err != nil {
return err
}
learnData := struct {
*spec.APISpec
HasSync bool
}{
APISpec: g.Spec,
HasSync: g.hasGeneratedSyncImplementation(),
}
learnFiles := map[string]string{
"learn_entities/config.go.tmpl": filepath.Join("internal", "learn", "entities", "config.go"),
"learn_entities/config_test.go.tmpl": filepath.Join("internal", "learn", "entities", "config_test.go"),
"learn_entities/extract.go.tmpl": filepath.Join("internal", "learn", "entities", "extract.go"),
"learn_entities/extract_test.go.tmpl": filepath.Join("internal", "learn", "entities", "extract_test.go"),
"learn/doc.go.tmpl": filepath.Join("internal", "learn", "doc.go"),
"learn/normalize.go.tmpl": filepath.Join("internal", "learn", "normalize.go"),
"learn/normalize_test.go.tmpl": filepath.Join("internal", "learn", "normalize_test.go"),
"learn/match.go.tmpl": filepath.Join("internal", "learn", "match.go"),
"learn/match_test.go.tmpl": filepath.Join("internal", "learn", "match_test.go"),
"learn/recall.go.tmpl": filepath.Join("internal", "learn", "recall.go"),
"learn/recall_test.go.tmpl": filepath.Join("internal", "learn", "recall_test.go"),
"learn/recall_canonical_test.go.tmpl": filepath.Join("internal", "learn", "recall_canonical_test.go"),
"learn/teach.go.tmpl": filepath.Join("internal", "learn", "teach.go"),
"learn/teach_test.go.tmpl": filepath.Join("internal", "learn", "teach_test.go"),
"learn/teach_log.go.tmpl": filepath.Join("internal", "learn", "teach_log.go"),
"learn/teach_log_test.go.tmpl": filepath.Join("internal", "learn", "teach_log_test.go"),
"learn/journal.go.tmpl": filepath.Join("internal", "learn", "journal.go"),
"learn/journal_test.go.tmpl": filepath.Join("internal", "learn", "journal_test.go"),
"learn/derive.go.tmpl": filepath.Join("internal", "learn", "derive.go"),
"learn/derive_test.go.tmpl": filepath.Join("internal", "learn", "derive_test.go"),
"learn/synthesize.go.tmpl": filepath.Join("internal", "learn", "synthesize.go"),
"learn/synthesize_test.go.tmpl": filepath.Join("internal", "learn", "synthesize_test.go"),
"learn/protocol.go.tmpl": filepath.Join("internal", "learn", "protocol.go"),
"learn/preseed.go.tmpl": filepath.Join("internal", "learn", "preseed.go"),
"learn/preseed_test.go.tmpl": filepath.Join("internal", "learn", "preseed_test.go"),
"learn/playbooks.go.tmpl": filepath.Join("internal", "learn", "playbooks.go"),
"learn/playbooks_test.go.tmpl": filepath.Join("internal", "learn", "playbooks_test.go"),
"learn/promote.go.tmpl": filepath.Join("internal", "learn", "promote.go"),
"learn/promote_test.go.tmpl": filepath.Join("internal", "learn", "promote_test.go"),
"learn_lookups/store.go.tmpl": filepath.Join("internal", "learn", "lookups", "store.go"),
"learn_lookups/store_test.go.tmpl": filepath.Join("internal", "learn", "lookups", "store_test.go"),
"learn_lookups/seeds.go.tmpl": filepath.Join("internal", "learn", "lookups", "seeds.go"),
"learn_lookups/seeds_test.go.tmpl": filepath.Join("internal", "learn", "lookups", "seeds_test.go"),
"learn_patterns/doc.go.tmpl": filepath.Join("internal", "learn", "patterns", "doc.go"),
"learn_patterns/store.go.tmpl": filepath.Join("internal", "learn", "patterns", "store.go"),
"learn_patterns/store_test.go.tmpl": filepath.Join("internal", "learn", "patterns", "store_test.go"),
"learn_patterns/extract.go.tmpl": filepath.Join("internal", "learn", "patterns", "extract.go"),
"learn_patterns/extract_test.go.tmpl": filepath.Join("internal", "learn", "patterns", "extract_test.go"),
"learn_patterns/apply.go.tmpl": filepath.Join("internal", "learn", "patterns", "apply.go"),
"learn_patterns/apply_test.go.tmpl": filepath.Join("internal", "learn", "patterns", "apply_test.go"),
}
for tmplName, outPath := range learnFiles {
if err := g.renderTemplate(tmplName, outPath, learnData); err != nil {
return fmt.Errorf("rendering %s: %w", tmplName, err)
}
}
return nil
}
func (g *Generator) Generate() error {
g.Spec.DropCollidingEndpointTemplateEnvOverrides()
applyLargeMCPSurfaceDefault(g.Spec, os.Stderr)
// Fresh prints default the self-learning loop on (opt out with
// learn.disabled: true). Deliberately absent from GenerateMCPSurface:
// mcp-sync regenerates the MCP surface of published CLIs whose trees
// may lack the learn package, and the default must never flip there.
g.Spec.ApplyLearnLoopDefault(os.Stderr)
if g.Spec.OwnerName == "" {
// OwnerName flows into Hermes `author:` and other prose
// surfaces. We don't hard-fail on an empty value because the
// generator package is reused by many callers (tests, mcp-sync,
// regen-merge) where setting it is awkward. Instead, fall back
// to the slug-shaped Owner so emission is non-empty, and warn
// loudly so a real-print operator catches the misconfiguration.
// The library-wide sweep tool overrides this via its own per-CLI
// authorship mapping, so this fallback only ever lands on fresh
// prints by users who haven't set `git config user.name`.
fmt.Fprintf(os.Stderr,
"WARNING: spec.OwnerName is empty; falling back to slug-shaped Owner (%q) for `author:` field. "+
"Set `git config user.name` (display name, e.g. \"Trevin Chow\") to populate this correctly.\n",
g.Spec.Owner,
)
g.Spec.OwnerName = g.Spec.Owner
}
if g.Spec.Printer == "" {
// Publish enforces this so self-owned CLIs can still use matching owner/printer slugs.
fmt.Fprintf(os.Stderr,
"WARNING: spec.Printer is empty; README printer attribution will be omitted. "+
"Set `git config github.user` (your GitHub @handle) to populate this correctly before publishing.\n",
)
}
// Auth.VerifyPath drives the doctor's credentials probe. Derive it before
// HealthCheckPath so HealthCheckPath's fallback chain can pick up the
// derived value (mirroring how an operator-set auth.verify_path already
// flows through). Without this, specs that ship a me-shaped endpoint but
// no explicit auth.verify_path generate a doctor that reports credentials
// as merely "present (not verified)" instead of probing the API.
if g.Spec.Auth.VerifyPath == "" {
g.Spec.Auth.VerifyPath = deriveAuthVerifyPath(g.Spec)
}
if g.Spec.HealthCheckPath == "" {
g.Spec.HealthCheckPath = deriveHealthCheckPath(g.Spec)
}
if err := g.prepareOutput(); err != nil {
return err
}
// Lifted ahead of any rendering: SKILL/README emission needs promoted-resource
// awareness so it doesn't emit operation-id-shaped paths (`qr get-qrcode`) for
// resources the generator collapsed to a leaf (`qr`). buildPromotedCommandPlan
// is pure over g.Spec, so running it here is identical to running it where it
// used to live, except the maps are now visible to readmeData() / template
// rendering.
g.PromotedCommands, g.PromotedResourceNames, g.PromotedEndpointNames = buildPromotedCommandPlan(g.Spec)
if err := validateCommandSurface(buildCommandSurface(g.Spec, g.PromotedCommands), g.activeFrameworkCobraUseNames()); err != nil {
return err
}
if err := g.validatePromotedExamples(); err != nil {
return err
}
if err := g.renderSingleFiles(); err != nil {
return err
}
if err := g.renderOptionalSupportFiles(); err != nil {
return err
}
if err := g.renderResourceCommands(g.PromotedResourceNames, g.PromotedEndpointNames); err != nil {
return err
}
if err := g.renderAuthFiles(); err != nil {
return err
}
if err := g.renderMCPEntrypoint(); err != nil {
return err
}
return g.renderVisionAndRootFiles(g.PromotedCommands, g.PromotedResourceNames)
}
func (g *Generator) renameActiveFrameworkResourceCollisions() bool {
active := g.activeFrameworkCobraUseNames()
type resourceRename struct {
from string
to string
use string
resource spec.Resource
}
var renames []resourceRename
for name, resource := range g.Spec.Resources {
kebab := spec.NormalizeCobraCommandName(name)
if _, ok := active[kebab]; !ok {
continue
}
if g.Spec.ParseTimeReservedCobraUseName(kebab) {
continue
}
renames = append(renames, resourceRename{
from: name,
to: g.Spec.UniqueFrameworkCollisionResourceName(kebab),
use: kebab,
resource: resource,
})
}
for _, rename := range renames {
delete(g.Spec.Resources, rename.from)
g.Spec.Resources[rename.to] = rename.resource
fmt.Fprintf(os.Stderr, "warning: resource %q would shadow active framework cobra command %q; renamed to %q\n", rename.from, rename.use, rename.to)
}
return len(renames) > 0
}
func (g *Generator) activeFrameworkCobraUseNames() map[string]struct{} {
names := map[string]struct{}{
"agent-context": {},
"completion": {},
"doctor": {},
"feedback": {},
"help": {},
"profile": {},
"version": {},
"which": {},
}
if g.shouldEmitAuth() {
names["auth"] = struct{}{}
if g.emitsTopLevelOAuthLogin() {
names["login"] = struct{}{}
}
}
if g.Spec.BearerRefresh.Enabled() {
names["refresh-bearer"] = struct{}{}
}
if len(g.AsyncJobs) > 0 {
names["jobs"] = struct{}{}
}
if g.VisionSet.Export {
names["export"] = struct{}{}
}
if g.VisionSet.Import {
names["import"] = struct{}{}
}
if g.VisionSet.Search {
names["search"] = struct{}{}
}
if g.VisionSet.Sync {
names["sync"] = struct{}{}
}
if g.Spec.Streaming.Enabled() {
names["live"] = struct{}{}
}
if g.VisionSet.Tail {
names["tail"] = struct{}{}
}
if g.VisionSet.Analytics {
names["analytics"] = struct{}{}
}
if g.hasDataLayer() {
names["workflow"] = struct{}{}
}
if g.Spec.Share.Enabled {
names["share"] = struct{}{}
}
if g.Spec.Learn.Enabled {
// Same root verbs root.go.tmpl registers under Learn.Enabled.
names["learnings"] = struct{}{}
names["playbook"] = struct{}{}
names["recall"] = struct{}{}
names["teach"] = struct{}{}
names["teach-lookup"] = struct{}{}
names["teach-pattern"] = struct{}{}
names["teach-playbook"] = struct{}{}
}
if len(g.PromotedCommands) > 0 {
names["api"] = struct{}{}
}
for _, tmpl := range g.VisionSet.Workflows {
if name := g.resolvedVisionCommandName(tmpl); name != "" {
names[name] = struct{}{}
}
}
for _, tmpl := range g.VisionSet.Insights {
if name := g.resolvedVisionCommandName(tmpl); name != "" {
names[name] = struct{}{}
}
}
return names
}
func (g *Generator) resolveVisionCommandNames() {
g.visionCommandNames = make(map[string]string)
novelNames := g.novelFeatureRootCommandNames()
reserved := maps.Clone(novelNames)
for name := range g.Spec.Resources {
reserved[spec.NormalizeCobraCommandName(name)] = struct{}{}
}
for _, tmpl := range append(slices.Clone(g.VisionSet.Workflows), g.VisionSet.Insights...) {
bare := frameworkUseNameForTemplate(tmpl)
if bare == "" {
continue
}
name := bare
if _, collides := novelNames[bare]; collides {
name = g.uniqueFrameworkCommandName(bare, reserved)
fmt.Fprintf(os.Stderr, "warning: pattern-pack command %q would shadow novel command %q; renamed to %q\n", bare, bare, name)
}
g.visionCommandNames[bare] = name
reserved[name] = struct{}{}
}
}
func (g *Generator) resolvedVisionCommandName(tmpl string) string {
bare := frameworkUseNameForTemplate(tmpl)
if name, ok := g.visionCommandNames[bare]; ok {
return name
}
return bare
}
func (g *Generator) uniqueFrameworkCommandName(original string, reserved map[string]struct{}) string {
candidate := g.Spec.UniqueFrameworkCollisionResourceName(original)
if _, exists := reserved[candidate]; !exists {
return candidate
}
base := candidate
for suffix := 2; ; suffix++ {
candidate = fmt.Sprintf("%s-%d", base, suffix)
if _, exists := reserved[candidate]; !exists {
return candidate
}
}
}
func (g *Generator) novelFeatureRootCommandNames() map[string]struct{} {
names := make(map[string]struct{})
for _, feature := range g.NovelFeatures {
parts := novelFeatureCommandParts(feature.Command)
if len(parts) > 0 {
names[parts[0]] = struct{}{}
}
}
return names
}
func frameworkUseNameForTemplate(tmpl string) string {
ctor := commandConstructorForTemplate(tmpl)
if ctor == "" {
return ""
}
return strings.ReplaceAll(naming.Snake(ctor), "_", "-")
}
// cobratreeWalkerTemplateFiles maps each cobratree MCP-walker template to its
// output path under internal/mcp/cobratree. The walker is API-agnostic, so the
// file set is identical for every printed CLI (HTTP and device); single-source
// it here rather than repeating the manifest in each generator's file map.
func cobratreeWalkerTemplateFiles() map[string]string {
files := make(map[string]string)
for _, name := range []string{
"walker.go", "classify.go", "typemap.go", "shellout.go",
"shellout_test.go", "cli_path.go", "names.go",
} {
files["cobratree/"+name+".tmpl"] = filepath.Join("internal", "mcp", "cobratree", name)
}
return files
}
// GenerateMCPSurface rewrites the generated MCP entrypoint, tools package,
// cobratree helpers, AND the generator-reserved cliutil package without
// touching the printed CLI's command files. The cliutil package is
// included because the MCP template references helpers (SanitizeErrorBody,
// LooksLikeAuthError) that older library CLIs lack — leaving cliutil
// stale produces "undefined: cliutil.SanitizeErrorBody" build errors on
// regenerated tools.go. Per AGENTS.md, internal/cliutil is generator-
// reserved (agents must not hand-edit it), so unconditional regen here
// is intentionally asymmetric vs the marker-checked tools.go/handlers.go
// paths in mcp-sync. Spec-conditional cliutil files (freshness,
// autoRefresh, oauth token client) stay in renderOptionalSupportFiles so they don't get
// emitted when the spec opts out.
func (g *Generator) GenerateMCPSurface() error {
applyLargeMCPSurfaceDefault(g.Spec, os.Stderr)
if err := g.prepareOutput(); err != nil {
return err
}
g.PromotedCommands, g.PromotedResourceNames, g.PromotedEndpointNames = buildPromotedCommandPlan(g.Spec)
if err := validateCommandSurface(buildCommandSurface(g.Spec, g.PromotedCommands), g.activeFrameworkCobraUseNames()); err != nil {
return err
}
mcpFiles := map[string]string{
// cliutil files. Deliberately asymmetric with the marker-checked
// tools.go / handlers.go / root.go paths elsewhere in mcp-sync:
// those files can carry hand-edits and require explicit
// confirmation before overwrite, but cliutil is generator-
// reserved per AGENTS.md and unconditional regen is the
// expected contract. Without this, library CLIs whose cliutil
// predates a helper the new MCP template uses (SanitizeErrorBody,
// LooksLikeAuthError) fail to build after migration. See the
// GenerateMCPSurface doc comment for the full rationale.
"cliutil_fanout.go.tmpl": filepath.Join("internal", "cliutil", "fanout.go"),
"cliutil_text.go.tmpl": filepath.Join("internal", "cliutil", "text.go"),
"cliutil_probe.go.tmpl": filepath.Join("internal", "cliutil", "probe.go"),
"cliutil_ratelimit.go.tmpl": filepath.Join("internal", "cliutil", "ratelimit.go"),
"cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
"cliutil_paths.go.tmpl": filepath.Join("internal", "cliutil", "paths.go"),
"cliutil_paths_test.go.tmpl": filepath.Join("internal", "cliutil", "paths_test.go"),
"cliutil_filelock.go.tmpl": filepath.Join("internal", "cliutil", "filelock.go"),
"cliutil_filelock_unix.go.tmpl": filepath.Join("internal", "cliutil", "filelock_unix.go"),
"cliutil_filelock_windows.go.tmpl": filepath.Join("internal", "cliutil", "filelock_windows.go"),
"cliutil_filelock_test.go.tmpl": filepath.Join("internal", "cliutil", "filelock_test.go"),
"cliutil_testenv.go.tmpl": filepath.Join("internal", "cliutil", "testenv", "testenv.go"),
"cliutil_testenv_sandbox_unix.go.tmpl": filepath.Join("internal", "cliutil", "testenv", "sandbox_unix.go"),
"cliutil_testenv_sandbox_windows.go.tmpl": filepath.Join("internal", "cliutil", "testenv", "sandbox_windows.go"),
"cliutil_extractnumber.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber.go"),
"cliutil_extractnumber_test.go.tmpl": filepath.Join("internal", "cliutil", "extractnumber_test.go"),
"cliutil_jwtshape.go.tmpl": filepath.Join("internal", "cliutil", "jwtshape.go"),
"cliutil_jwtshape_test.go.tmpl": filepath.Join("internal", "cliutil", "jwtshape_test.go"),
"mcp_bound.go.tmpl": filepath.Join("internal", "mcp", "bound", "bound.go"),
"mcp_bound_test.go.tmpl": filepath.Join("internal", "mcp", "bound", "bound_test.go"),
}
maps.Copy(mcpFiles, cobratreeWalkerTemplateFiles())
for tmplName, outPath := range mcpFiles {
if err := g.renderTemplate(tmplName, outPath, g.Spec); err != nil {
return fmt.Errorf("rendering %s: %w", tmplName, err)
}
}
if err := g.renderMCPEntrypoint(); err != nil {
return err
}
return g.renderMCPToolFiles(g.schemaWithDependentParents())
}
func buildPromotedCommandPlan(apiSpec *spec.APISpec) ([]PromotedCommand, map[string]bool, map[string]string) {
// Compute promoted commands early — needed to determine Hidden flag on parent commands
promotedCommands := buildPromotedCommands(apiSpec)
// Build set of resource names that have promoted commands. Promoted commands
// replace the resource parent entirely — the promoted command wires sibling
// endpoints and sub-resources directly. Generating the unused parent would
// create a dead constructor (e.g., newBookingsCmd never called).
promotedResourceNames := make(map[string]bool)
// Map resource name → promoted endpoint name. The promoted command's RunE
// inlines this endpoint's logic, so the standalone file is dead code.
promotedEndpointNames := make(map[string]string)
for _, pc := range promotedCommands {
promotedResourceNames[pc.ResourceName] = true
promotedEndpointNames[pc.ResourceName] = pc.EndpointName
}
return promotedCommands, promotedResourceNames, promotedEndpointNames
}
func promotedCommandsCanUnwrapResponse(commands []PromotedCommand, types map[string]spec.TypeDef) bool {
for _, cmd := range commands {
if !cmd.Endpoint.UsesBinaryResponse() && !cmd.Endpoint.UsesTextResponse() && endpointHasStatusDataEnvelope(cmd.Endpoint, types) {
return true
}
}
return false
}
// endpointHasStatusDataEnvelope reports whether the endpoint's response type
// looks like a {status/success, data} envelope, gating extractResponseData
// emission. The runtime helper keys on the "status" field; "success" is
// accepted here too so success-keyed envelopes still emit the helper. A type
// with data+success but no status passes detection and the helper no-ops on it
// at runtime — harmless over-emission, and emit/call stay aligned either way.
func endpointHasStatusDataEnvelope(endpoint spec.Endpoint, types map[string]spec.TypeDef) bool {
item := strings.TrimSpace(endpoint.Response.Item)
if item == "" {
return false
}
typedef, ok := types[item]
if !ok {
return false
}
hasData := false
hasStatusOrSuccess := false
for _, field := range typedef.Fields {
switch strings.ToLower(strings.TrimSpace(field.Name)) {
case "data":
hasData = true
case "status", "success":
hasStatusOrSuccess = true
}
if hasData && hasStatusOrSuccess {
return true
}
}
return false
}
func parentCommandShort(resourceName, parentName string, resource spec.Resource, apiDescriptionShort string) string {
short := naming.OneLine(resource.Description)
if !naming.IsThinCommandShort(short) {
return short
}
if resourceName == "" {
return short
}
target := humanCommandSegment(resourceName)
if parentName != "" {
parent := humanCommandSegment(parentName)
if isVerbLikeParentSegment(resourceName) {
return "Run " + target + " operations for " + parent
}
if strings.EqualFold(resourceName, "pdf") {
return "Manage PDF files for " + parent
}
if actions := parentCommandActions(resource.Endpoints); actions != "" {
return actions + " " + target + " for " + parent
}
return "Manage " + target + " for " + parent
}
if apiDescriptionShort != "" {
return apiDescriptionShort
}
if actions := parentCommandActions(resource.Endpoints); actions != "" {
if computed := actions + " " + target; !naming.IsThinCommandShort(computed) {
return computed
}
}
return "Manage " + target + " command groups"
}
func parentCommandInfoDescriptionShort(description string) string {
description = naming.OneLineNormalize(description)
if description == "" {
return ""
}
for idx, r := range description {
if r != '.' && r != '!' && r != '?' {
continue
}
if r == '.' && parentCommandShortPeriodIsInternal(description, idx) {
continue
}
sentence := parentCommandShortCompact(description[:idx+len(string(r))])
if !naming.IsThinCommandShort(sentence) {
return sentence
}
return ""
}
short := parentCommandShortCompact(description)
if !naming.IsThinCommandShort(short) {
return short
}
return ""
}
func parentCommandShortCompact(description string) string {
description = naming.OneLineNormalize(description)
runes := []rune(description)
if len(runes) <= 120 {
return description
}
cut := string(runes[:120])
if idx := strings.LastIndex(cut, " "); idx > 60 {
return strings.TrimRight(cut[:idx], " ,;:")
}
return strings.TrimRight(cut, " ,;:")
}
func parentCommandShortPeriodIsInternal(description string, idx int) bool {
if idx <= 0 || idx >= len(description)-1 {
return false
}
prev, next := description[idx-1], description[idx+1]
if (isASCIILetter(prev) || isASCIIDigit(prev)) && (isASCIILetter(next) || isASCIIDigit(next)) {
return true
}
tokenStart := strings.LastIndex(description[:idx], " ") + 1
return strings.Contains(description[tokenStart:idx], ".")
}
func isASCIILetter(b byte) bool {
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
}
func isASCIIDigit(b byte) bool {
return b >= '0' && b <= '9'
}
func parentCommandActions(endpoints map[string]spec.Endpoint) string {
if len(endpoints) == 0 {
return ""
}
seen := make(map[string]bool, len(endpoints))
for name, endpoint := range endpoints {
if action := endpointActionVerb(name, endpoint.Method); action != "" {
seen[action] = true
}
}
actionOrder := []string{"list", "get", "search", "find", "query", "count", "describe", "fetch", "run", "trigger", "execute", "generate", "batch", "process", "enable", "disable", "create", "add", "update", "edit", "delete", "remove", "upload", "download", "send", "submit", "verify"}
var actions []string
for _, action := range actionOrder {
if seen[action] {
actions = append(actions, action)
}
}
return joinCommandActions(actions)
}
func endpointActionVerb(name, method string) string {
head := strings.ToLower(name)
if i := strings.IndexAny(head, "-_"); i > 0 {
head = head[:i]
}
switch head {
case "list", "get", "search", "find", "query", "count", "describe", "fetch", "run", "trigger", "execute", "generate", "batch", "process", "enable", "disable", "create", "add", "update", "edit", "delete", "remove", "upload", "download", "send", "submit", "verify":
return head
}
switch strings.ToUpper(method) {
case "GET":
return "get"
case "POST":
return "create"
case "PUT", "PATCH":
return "update"
case "DELETE":
return "delete"
default:
return ""
}
}
func joinCommandActions(actions []string) string {
switch len(actions) {
case 0:
return ""
case 1:
return titleFirst(actions[0])
case 2:
return titleFirst(actions[0]) + " and " + actions[1]
default:
return titleFirst(strings.Join(actions[:len(actions)-1], ", ")) + ", and " + actions[len(actions)-1]
}
}
func titleFirst(s string) string {
if s == "" {
return ""
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func isVerbLikeParentSegment(segment string) bool {
switch strings.ToLower(segment) {
case "approve", "archive", "cancel", "close", "delete", "download", "edit", "freeze", "publish", "reject", "restore", "send", "submit", "unarchive", "unfreeze", "upload", "verify":
return true
default:
return false
}
}
func humanCommandSegment(segment string) string {
words := strings.Fields(strings.ReplaceAll(strings.ReplaceAll(segment, "-", " "), "_", " "))
for i, word := range words {
switch strings.ToLower(word) {
case "api", "crm", "id", "mcp", "pdf":
words[i] = strings.ToUpper(word)
default:
words[i] = strings.ToLower(word)
}
}
return strings.Join(words, " ")
}
// withoutOptionsEndpoints returns a copy of the resource whose Endpoints
// map has OPTIONS-method entries removed, leaving the caller's original
// map untouched. The parent command template iterates Endpoints to
// register cobra subcommands (cmd.AddCommand(newXxxYyyCmd())), so if we
// only skip OPTIONS at the per-endpoint render site the parent ends up
// referencing functions in files we deliberately didn't emit. Filtering
// at the resource level keeps every downstream consumer of Endpoints
// (parent template, sub-resource parent template, novel-children pass)
// consistent with the per-endpoint skip.
func withoutOptionsEndpoints(r spec.Resource) spec.Resource {
if len(r.Endpoints) == 0 {
return r
}
hasOptions := false
for _, ep := range r.Endpoints {
if strings.EqualFold(ep.Method, "OPTIONS") {
hasOptions = true
break
}
}
if !hasOptions {
return r
}
filtered := make(map[string]spec.Endpoint, len(r.Endpoints))
for k, ep := range r.Endpoints {
if strings.EqualFold(ep.Method, "OPTIONS") {
continue
}
filtered[k] = ep
}
out := r
out.Endpoints = filtered
return out
}
func (g *Generator) renderResourceCommands(promotedResourceNames map[string]bool, promotedEndpointNames map[string]string) error {
var apiDescriptionShort string
if len(g.Spec.Resources) == 1 {
apiDescriptionShort = parentCommandInfoDescriptionShort(g.Spec.Description)
}
novelChildrenByParent := g.novelFeatureChildrenByParent()
// Generate per-resource parent files + per-endpoint command files
// This produces more files (one per endpoint) which improves Breadth scoring
for name, originalResource := range g.Spec.Resources {
// Filter OPTIONS-method endpoints out of the resource view used to
// render parent commands and per-endpoint files for this resource.
// Both downstream sites (parent template iterating Endpoints to
// register cobra subcommands; per-endpoint loop emitting one file
// per endpoint) must agree on which endpoints exist, otherwise the
// parent calls newXxxOptionsXxxCmd() functions whose files we
// deliberately didn't emit. Filtering once here keeps them in sync.
resource := withoutOptionsEndpoints(originalResource)
// Skip parent file for promoted resources — the promoted command replaces it.
// Sub-resource parents and endpoint files are still needed (wired by the promoted command).
resourceCommand := toKebab(name)
if !promotedResourceNames[name] {
// Parent file: wires subcommands together
parentData := struct {
ResourceName string
FuncPrefix string
CommandPath string
CommandUse string
Short string
Resource spec.Resource
NovelChildren []novelFeatureChildRender
APIResource bool
*spec.APISpec
}{
ResourceName: name,
FuncPrefix: name,
CommandPath: resourceCommand,
Short: parentCommandShort(name, "", resource, apiDescriptionShort),
Resource: resource,
NovelChildren: novelChildrenByParent[resourceCommand],
APIResource: true,
APISpec: g.Spec,
}
parentPath := filepath.Join("internal", "cli", safeResourceFileStem(name)+".go")
if err := g.renderTemplate("command_parent.go.tmpl", parentPath, parentData); err != nil {
return fmt.Errorf("rendering parent command %s: %w", name, err)
}
}
// Per-endpoint files. OPTIONS endpoints have already been filtered
// out of `resource.Endpoints` by withoutOptionsEndpoints at the
// top of the loop, so the only skip here is the promoted-endpoint
// inlining case.
for eName, endpoint := range resource.Endpoints {
// Skip the promoted endpoint — its logic is inlined in the promoted command's RunE.
if promotedEndpointNames[name] == eName {
continue
}
asyncInfo, isAsync := g.AsyncJobs[name+"/"+eName]
epData := endpointTemplateData{
ResourceName: name,
EffectivePath: effectiveEndpointPath(resource, endpoint),
EffectiveTier: g.Spec.EffectiveTier(resource, endpoint),
FuncPrefix: name,
CommandPath: resourceCommand,
EndpointName: eName,
Endpoint: endpoint,
Resource: resource,
HasStore: g.hasDataLayer(),
IsAsync: isAsync,
Async: asyncInfo,
PageSize: g.paginationPageSizeForEndpoint(endpoint),
IsReadOnly: endpointIsReadCommandShared(endpoint, eName, sharedGETRPCPaths(g.Spec.Resources)),
APISpec: g.Spec,
}
epPath := filepath.Join("internal", "cli", safeResourceFileStem(name+"_"+eName)+".go")
if err := g.renderTemplate("command_endpoint.go.tmpl", epPath, epData); err != nil {
return fmt.Errorf("rendering endpoint %s/%s: %w", name, eName, err)
}
}
// Sub-resource parent + endpoint files
collidingLeaves := collectionItemCollisionLeaves(resource)
for subName, originalSubResource := range resource.SubResources {
// Same OPTIONS filter as the top-level resource, applied to
// the sub-resource view for the same consistency reason.
subResource := withoutOptionsEndpoints(originalSubResource)
naming := subResourceCommandNamesFor(name, subName, collidingLeaves[subName])
commandUse := ""
if collidingLeaves[subName] {
commandUse = naming.commandUse
}
subParentData := struct {
ResourceName string
FuncPrefix string
CommandPath string
CommandUse string
Short string
Resource spec.Resource
NovelChildren []novelFeatureChildRender
APIResource bool
*spec.APISpec
}{
ResourceName: subName,
FuncPrefix: naming.funcPrefix,
CommandPath: naming.commandPath,
CommandUse: commandUse,
Short: parentCommandShort(subName, name, subResource, ""),
Resource: subResource,
NovelChildren: novelChildrenByParent[naming.commandPath],
APIResource: false,
APISpec: g.Spec,
}
subParentPath := filepath.Join("internal", "cli", naming.fileStem+".go")
if err := g.renderTemplate("command_parent.go.tmpl", subParentPath, subParentData); err != nil {
return fmt.Errorf("rendering sub-parent %s/%s: %w", name, subName, err)
}
// OPTIONS endpoints have already been filtered out of
// subResource.Endpoints by withoutOptionsEndpoints above.
for eName, endpoint := range subResource.Endpoints {
subKey := subName + "/" + eName
asyncInfo, isAsync := g.AsyncJobs[subKey]
effectiveResource := subResource
if effectiveResource.Tier == "" {
effectiveResource.Tier = resource.Tier
}
epData := endpointTemplateData{
ResourceName: subName,
EffectivePath: effectiveSubEndpointPath(resource, subResource, endpoint),
EffectiveTier: g.Spec.EffectiveTier(effectiveResource, endpoint),
FuncPrefix: naming.funcPrefix,
CommandPath: naming.commandPath,
EndpointName: eName,
Endpoint: endpoint,
Resource: effectiveResource,
HasStore: g.hasDataLayer(),
IsAsync: isAsync,
Async: asyncInfo,
PageSize: g.paginationPageSizeForEndpoint(endpoint),
IsReadOnly: endpointIsReadCommandShared(endpoint, eName, sharedGETRPCPaths(g.Spec.Resources)),
APISpec: g.Spec,
}
epPath := filepath.Join("internal", "cli", safeResourceFileStem(naming.rawStem+"_"+eName)+".go")
if err := g.renderTemplate("command_endpoint.go.tmpl", epPath, epData); err != nil {
return fmt.Errorf("rendering sub-endpoint %s/%s/%s: %w", name, subName, eName, err)
}
}
}
}
return nil
}
func (g *Generator) renderAuthFiles() error {
// Skip auth.go entirely when the spec declares no auth surface. See
// shouldEmitAuth for the predicate; the matching root.go template gate
// (HasAuthCommand) and the scorecard's no-auth exemption (scoreAuth)
// must stay in sync with this same condition.
if !g.shouldEmitAuth() {
return nil
}
// Render auth command. Template selection priority:
// 1. OAuth2 client_credentials (server-to-server, no user redirect)
// 2. OAuth2 device_code (agent/CLI-friendly user auth, no localhost redirect)
// 3. OAuth2 authorization_code (3-legged, AuthorizationURL non-empty)
// 4. Browser-cookie / composed / persisted-query
// 5. Simple token-management (catch-all)
authPath := filepath.Join("internal", "cli", "auth.go")
authTmpl := "auth_simple.go.tmpl"
switch {
case g.Spec.Auth.EffectiveOAuth2Grant() == spec.OAuth2GrantClientCredentials && g.Spec.Auth.TokenURL != "":
authTmpl = "auth_client_credentials.go.tmpl"
case g.Spec.Auth.EffectiveOAuth2Grant() == spec.OAuth2GrantDeviceCode && g.Spec.Auth.DeviceAuthorizationURL != "" && g.Spec.Auth.TokenURL != "":
authTmpl = "auth_device_code.go.tmpl"
case g.Spec.Auth.AuthorizationURL != "":
authTmpl = "auth.go.tmpl"
case g.Spec.Auth.Type == "cookie" || g.Spec.Auth.Type == "composed" || g.hasTrafficAnalysisHint("graphql_persisted_query") || g.Spec.Auth.Subtype == spec.AuthSubtypeAuth0SPAInMemory:
// Browser-aware auth template for browser-cookie auth, a
// persisted-query registry, or an Auth0-SPA-in-memory bearer token
// (CDP runtime extraction). Query refresh flows need temporary
// browser capture support, not a resident browser transport.
authTmpl = "auth_browser.go.tmpl"
}
authData := &authTemplateData{
APISpec: g.Spec,
HasGraphQLPersistedQueries: g.hasTrafficAnalysisHint("graphql_persisted_query"),
}
if err := g.renderTemplate(authTmpl, authPath, authData); err != nil {
return fmt.Errorf("rendering auth: %w", err)
}
if g.Spec.Auth.EffectiveOAuth2Grant() == spec.OAuth2GrantDeviceCode {
oauthPath := filepath.Join("internal", "oauth", "device.go")
if err := os.MkdirAll(filepath.Join(g.OutputDir, "internal", "oauth"), 0o755); err != nil {
return fmt.Errorf("creating OAuth helper directory: %w", err)
}
if err := g.renderTemplate("oauth_device.go.tmpl", oauthPath, g.Spec); err != nil {
return fmt.Errorf("rendering OAuth device-code helper: %w", err)
}
}
// For session_handshake auth, emit the session manager helper alongside
// the client. This was previously hand-patched in every CLI that used a
// crumb/CSRF-token pattern (yahoo-finance and any future reverse-engineered
// API with anti-CSRF on JSON endpoints). See retro issue #174 WU-2.
if g.Spec.Auth.Type == "session_handshake" {
sessionPath := filepath.Join("internal", "client", "session.go")
if err := g.renderTemplate("session_handshake.go.tmpl", sessionPath, g.Spec); err != nil {
return fmt.Errorf("rendering session manager: %w", err)
}
}
return nil
}
// shouldEmitAuth reports whether the generator should emit internal/cli/auth.go
// for this spec. Auth UI is emitted when the spec describes a real auth
// surface: a non-none auth.type, an AuthorizationURL (OAuth), or a
// graphql_persisted_query traffic-analysis hint (browser-aware refresh).
//
// Public-data specs (auth.type: "none", no OAuth, no GraphQL persisted-query)
// previously shipped a dead `auth set-token / status / logout` subcommand.
// Now they ship without it, root.go skips the registration via
// HasAuthCommand, and scoreAuth exempts them from the "no auth subcommand"
// deduction. All three call sites must agree -- they call this method.
func (g *Generator) shouldEmitAuth() bool {
typ := strings.ToLower(strings.TrimSpace(g.Spec.Auth.Type))
return (typ != "" && typ != "none") ||
g.Spec.Auth.AuthorizationURL != "" ||
g.hasTrafficAnalysisHint("graphql_persisted_query")
}
// client.go emits mintClientCredentials — and a compile-time reference to
// OAuthTokenHTTPClient — for every client_credentials grant, including
// specs with an empty TokenURL. TokenURL and AuthorizationURL cover the
// other call sites (refresh, auth-code exchange, device poll).
func (g *Generator) hasOAuthTokenExchange() bool {
if g == nil || g.Spec == nil {
return false
}
auth := g.Spec.Auth
return strings.TrimSpace(auth.TokenURL) != "" ||
strings.TrimSpace(auth.AuthorizationURL) != "" ||
auth.EffectiveOAuth2Grant() == spec.OAuth2GrantClientCredentials
}
func (g *Generator) emitsTopLevelOAuthLogin() bool {
return g.Spec.Auth.AuthorizationURL != "" &&
(g.Spec.Auth.EffectiveOAuth2Grant() != spec.OAuth2GrantClientCredentials || g.Spec.Auth.TokenURL == "")
}
func (g *Generator) renderMCPEntrypoint() error {
// MCP server: generate cmd/{name}-pp-mcp/ entry point and internal/mcp/ package
if g.VisionSet.MCP || true { // Always generate MCP for now
mcpDirs := []string{
filepath.Join("cmd", naming.MCP(g.Spec.Name)),
filepath.Join("internal", "mcp"),
filepath.Join("internal", "mcp", "bound"),
filepath.Join("internal", "cliutil", "testenv"),
}
for _, d := range mcpDirs {
if err := os.MkdirAll(filepath.Join(g.OutputDir, d), 0755); err != nil {
return fmt.Errorf("creating MCP dir %s: %w", d, err)
}
}
if err := g.renderTemplate("main_mcp.go.tmpl", filepath.Join("cmd", naming.MCP(g.Spec.Name), "main.go"), g.Spec); err != nil {
return fmt.Errorf("rendering MCP main: %w", err)
}
if g.Spec.HasMCPTransport("http") {
httpTestPath := filepath.Join("cmd", naming.MCP(g.Spec.Name), "http_auth_test.go")
if err := g.renderTemplate("main_mcp_http_test.go.tmpl", httpTestPath, g.Spec); err != nil {
return fmt.Errorf("rendering MCP HTTP auth tests: %w", err)
}
}
}
return nil
}
func (g *Generator) renderVisionAndRootFiles(promotedCommands []PromotedCommand, promotedResourceNames map[string]bool) error {
schema := g.schemaWithDependentParents()
if err := g.renderStoreFiles(schema); err != nil {
return err
}
visionData := g.visionRenderData(schema)
if err := g.renderVisionCommands(visionData); err != nil {
return err
}
workflowConstructors, err := g.renderWorkflowFiles(visionData)
if err != nil {
return err
}
insightConstructors := g.renderInsightFiles(visionData)
if err := g.renderMCPToolFiles(schema); err != nil {
return err
}
if err := g.renderPromotedCommandFiles(promotedCommands); err != nil {
return err
}
novelCommandStubs, err := g.renderNovelFeatureStubs()
if err != nil {
return err
}
return g.renderRootProjectFiles(promotedCommands, promotedResourceNames, workflowConstructors, insightConstructors, novelCommandStubs)
}
// schemaWithDependentParents adds a parent_id column + index to dependent
// resource tables so sync can record which parent each row belongs to.
// JSON-only fallback tables keep only id/data/synced_at; parent context for
// those rows remains available in the generic resources table. For
// walker-emitted dependents whose DependentResource.KeyField is non-empty,
// parent_id stores the value of that field (not strictly a parent's primary
// key); the column name is retained for backwards compatibility with existing
// CLIs. The naming caveat is internal — the column is not part of any
// user-visible API.
func (g *Generator) schemaWithDependentParents() []TableDef {
schema := BuildSchema(g.Spec)
// BuildSchema always appends the framework sync_state definition after API
// domain tables. Dependent enrichment and reserved-name routing apply only
// to the domain portion, including an API resource also named sync_state.
domainSchema := schema[:len(schema)-1]
// Add parent_id column to tables for dependent (parent-child) sync resources
if g.profile != nil {
depSet := make(map[string]bool)
for _, dep := range g.profile.DependentSyncResources {
depSet[dep.Name] = true
}
for i, table := range domainSchema {
if depSet[table.Name] {
if table.JSONOnlyFallback {
continue
}
if schema[i].ParentKeyColumn == "" {
schema[i].ParentKeyColumn = "parent_id"
}
hasParentID := false
for _, col := range table.Columns {
if col.Name == "parent_id" {
hasParentID = true
break
}
}
if !hasParentID {
if len(table.Columns)+1 > maxStoreDomainTableColumns {
schema[i].JSONOnlyFallback = true
schema[i].OriginalColumnCount = len(table.Columns) + 1
schema[i].Columns = append([]ColumnDef(nil), baseTableColumns...)
schema[i].Indexes = nil
schema[i].FTS5 = false
schema[i].FTS5Fields = nil
schema[i].FTS5Triggers = false
continue
}
schema[i].Columns = append(schema[i].Columns, ColumnDef{
Name: "parent_id",
Type: "TEXT",
})
schema[i].Indexes = append(schema[i].Indexes, IndexDef{
Name: "idx_" + table.Name + "_parent_id",
TableName: table.Name,
Columns: "parent_id",
})
}
}
}
}
// Reapply after dependent enrichment so parent_id cannot restore a reserved
// domain table routed through generic storage by BuildSchema.
routeReservedStoreTablesToGenericOnly(domainSchema, reservedStoreObjectNames(g.Spec))
var dependent []profiler.DependentResource
if g.profile != nil {
dependent = g.profile.DependentSyncResources
}
attachBareIDColumns(schema, dependent)
return schema
}
// attachBareIDColumns adds a queryable generated bare_id to parent-keyed
// typed tables. resourceStorageID writes id+NUL+parent as the primary key;
// SQL that compares id to a bare API id therefore misses. The column
// projects the same value BareResourceID returns so store queries can
// filter without matching the hidden parent suffix.
func attachBareIDColumns(tables []TableDef, dependent []profiler.DependentResource) {
parentKeyed := make(map[string]struct{})
for _, entry := range resourceParentKeyColumnEntries(tables, dependent) {
parentKeyed[entry.Name] = struct{}{}
}
for i := range tables {
table := &tables[i]
// JSON-only fallback keeps id/data/synced_at only; parent context
// for those rows lives in the generic resources table, so the
// parent-keyed bare_id projection must not come back after a
// wide-cap reset.
if table.JSONOnlyFallback || !emitsDomainTable(*table) || !isParentKeyedTypedTable(*table, parentKeyed) {
continue
}
if hasNamedColumn(*table, storeBareIDColumn) {
continue
}
table.Columns = append(table.Columns, ColumnDef{
Name: storeBareIDColumn,
Type: storeBareIDType,
Generated: true,
})
table.Indexes = append(table.Indexes, IndexDef{
Name: "idx_" + table.Name + "_" + storeBareIDColumn,
TableName: table.Name,
Columns: storeBareIDColumn,
})
}
}
func isParentKeyedTypedTable(table TableDef, parentKeyed map[string]struct{}) bool {
if table.ParentKeyColumn != "" {
return true
}
if _, ok := parentKeyed[table.Resource]; ok {
return true
}
_, ok := parentKeyed[table.Name]
return ok
}
func hasNamedColumn(table TableDef, name string) bool {
for _, col := range table.Columns {
if col.Name == name {
return true
}
}
return false
}
func (g *Generator) renderStoreFiles(schema []TableDef) error {
// Create store directory if needed
if g.VisionSet.Store {
for _, table := range schema {
if table.JSONOnlyFallback {
fmt.Fprintf(os.Stderr, "warning: store-fallback: %s (%d cols) -> JSON-only\n", table.Name, table.OriginalColumnCount)
}
}
if err := os.MkdirAll(filepath.Join(g.OutputDir, "internal", "store"), 0755); err != nil {
return fmt.Errorf("creating store dir: %w", err)
}
storeData := struct {
*spec.APISpec
SyncableResources []profiler.SyncableResource
DependentSyncResources []profiler.DependentResource
SearchableFields map[string][]string
Tables []TableDef
ChildScopeColumnSources []profiler.ChildScopeSource
MembershipScopedParents []profiler.MembershipScopedParent
}{
APISpec: g.Spec,
SyncableResources: g.profile.SyncableResources,
DependentSyncResources: g.profile.DependentSyncResources,
SearchableFields: g.profile.SearchableFields,
Tables: schema,
ChildScopeColumnSources: g.profile.ChildScopeColumnSources(),
MembershipScopedParents: g.profile.MembershipScopedParents(),
}
if err := g.renderTemplate("store.go.tmpl", filepath.Join("internal", "store", "store.go"), storeData); err != nil {
return fmt.Errorf("rendering store: %w", err)
}
if err := g.renderTemplate("store_extras.go.tmpl", filepath.Join("internal", "store", "extras.go"), storeData); err != nil {
return fmt.Errorf("rendering store extras: %w", err)
}
if err := g.renderTemplate("store_schema_version_test.go.tmpl", filepath.Join("internal", "store", "schema_version_test.go"), storeData); err != nil {
return fmt.Errorf("rendering store schema version test: %w", err)
}
if err := g.renderTemplate("store_upsert_batch_test.go.tmpl", filepath.Join("internal", "store", "upsert_batch_test.go"), storeData); err != nil {
return fmt.Errorf("rendering store upsert batch test: %w", err)
}
}
return nil
}
type visionRenderData struct {
*spec.APISpec
VisionSet VisionTemplateSet
CommandNames map[string]string
CommandConstructor string
HasSync bool
SyncableResources []profiler.SyncableResource
DependentSyncResources []profiler.DependentResource
SyncResourcesExample string
TenantScopedParents []profiler.TenantScopedParent
MembershipScopedParents []profiler.MembershipScopedParent
PaginationSupportedResources []string
PaginationDefaultResources []paginationDefaultEntry
SpecTimestampFields []string
SearchableFields map[string][]string
Tables []TableDef
Pagination profiler.PaginationProfile
SearchEndpointPath string
SearchQueryParam string
SearchEndpointMethod string
SearchBodyFields []profiler.SearchBodyField
SearchResponsePaths []string
SearchExampleType string
GraphQLFieldPaths map[string]string
AgentMoneyWorkflow AgentMoneyWorkflow
HTMLSyncStub bool
HTMLPageModeResources []criticalResourceEntry
}
type resourceIDFieldOverrideEntry struct {
Name string
Value string
}
type learnInitTemplateData struct {
*spec.APISpec
ResourceIdentityFields []learnIdentityFieldEntry
}
type learnIdentityFieldEntry struct {
ResourceType string
Fields []string
}
func learnCommonIdentityFields() []string {
return []string{
"name", "title", "display_name", "full_name", "short_name", "label",
"slug", "key", "code", "id", "address",
}
}
func learnResourceIdentityFieldEntries(api *spec.APISpec, profile *profiler.APIProfile) []learnIdentityFieldEntry {
idByType := map[string]string{}
add := func(name, idField string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
if idField != "" {
idByType[name] = idField
return
}
if _, ok := idByType[name]; !ok {
idByType[name] = ""
}
}
if api != nil {
var walk func(map[string]spec.Resource)
walk = func(resources map[string]spec.Resource) {
for name, res := range resources {
add(name, firstEndpointIDField(res))
if len(res.SubResources) > 0 {
walk(res.SubResources)
}
}
}
walk(api.Resources)
}
if profile != nil {
for _, r := range profile.SyncableResources {
add(r.Name, r.IDField)
}
for _, r := range profile.DependentSyncResources {
add(r.Name, r.IDField)
}
}
names := make([]string, 0, len(idByType))
for name := range idByType {
names = append(names, name)
}
sort.Strings(names)
common := learnCommonIdentityFields()
entries := make([]learnIdentityFieldEntry, 0, len(names))
for _, name := range names {
entries = append(entries, learnIdentityFieldEntry{
ResourceType: name,
Fields: identityFieldsForResource(idByType[name], common),
})
}
return entries
}
func identityFieldsForResource(idField string, common []string) []string {
out := make([]string, 0, len(common)+1)
seen := map[string]struct{}{}
add := func(f string) {
f = strings.TrimSpace(f)
if f == "" {
return
}
if _, ok := seen[f]; ok {
return
}
seen[f] = struct{}{}
out = append(out, f)
}
for _, part := range splitResourceIDFieldOverride(idField) {
add(part)
}
for _, f := range common {
add(f)
}
return out
}
func splitResourceIDFieldOverride(idField string) []string {
idField = strings.TrimSpace(idField)
if idField == "" {
return nil
}
if !strings.Contains(idField, "+") {
return []string{idField}
}
raw := strings.Split(idField, "+")
parts := make([]string, 0, len(raw))
for _, part := range raw {
part = strings.TrimSpace(part)
if part == "" {
return []string{idField}
}
parts = append(parts, part)
}
if len(parts) < 2 {
return []string{idField}
}
return parts
}
func firstEndpointIDField(r spec.Resource) string {
names := make([]string, 0, len(r.Endpoints))
for name := range r.Endpoints {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if id := strings.TrimSpace(r.Endpoints[name].IDField); id != "" {
return id
}
}
return ""
}
type resourceParentKeyColumnEntry struct {
Name string
Values []string
}
type criticalResourceEntry struct {
Name string
}
type paginationDefaultEntry struct {
Key string
Name string
CursorParam string
CursorType string
NextCursorPath string
LimitParam string
Limit int
}
func promoteSyncPathContextVars(api *spec.APISpec, profile *profiler.APIProfile) {
if api == nil || profile == nil {
return
}
seen := make(map[string]struct{}, len(api.SyncPathContextVars)+len(api.EndpointTemplateVars))
for _, name := range api.SyncPathContextVars {
if trimmed := strings.TrimSpace(name); trimmed != "" {
seen[trimmed] = struct{}{}
}
}
for _, name := range api.EndpointTemplateVars {
if trimmed := strings.TrimSpace(name); trimmed != "" {
seen[trimmed] = struct{}{}
}
}
for _, resource := range profile.SyncableResources {
if !resource.SkipDefaultSync {
continue
}
for _, name := range pathTemplateVarNames(resource.Path) {
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
api.SyncPathContextVars = append(api.SyncPathContextVars, name)
}
}
slices.Sort(api.SyncPathContextVars)
}
func pathTemplateVarNames(path string) []string {
var names []string
seen := map[string]struct{}{}
for i := 0; i < len(path); i++ {
if path[i] != '{' {
continue
}
j := strings.IndexByte(path[i:], '}')
if j < 0 {
break
}
name := strings.TrimSpace(path[i+1 : i+j])
if name != "" {
if _, ok := seen[name]; !ok {
seen[name] = struct{}{}
names = append(names, name)
}
}
i += j
}
return names
}
func paginationDefaultEntries(syncable []profiler.SyncableResource, dependent []profiler.DependentResource) []paginationDefaultEntry {
defaults := map[string]paginationDefaultEntry{}
add := func(key, name, cursorParam, cursorType, nextCursorPath, limitParam string, limit int, supportsPagination bool) {
if !supportsPagination || key == "" || name == "" {
return
}
if _, exists := defaults[key]; exists {
return
}
defaults[key] = paginationDefaultEntry{
Key: key,
Name: name,
CursorParam: cursorParam,
CursorType: cursorType,
NextCursorPath: nextCursorPath,
LimitParam: limitParam,
Limit: limit,
}
}
for _, resource := range syncable {
add(resource.Name, resource.Name, resource.PaginationCursorParam, resource.PaginationCursorType, resource.PaginationNextCursorPath, resource.PaginationLimitParam, resource.PaginationPageSize, resource.SupportsPagination)
}
for _, resource := range dependent {
add(resource.ParentResource+"/"+resource.Name, resource.Name, resource.PaginationCursorParam, resource.PaginationCursorType, resource.PaginationNextCursorPath, resource.PaginationLimitParam, resource.PaginationPageSize, resource.SupportsPagination)
}
keys := make([]string, 0, len(defaults))
for key := range defaults {
keys = append(keys, key)
}
sort.Strings(keys)
entries := make([]paginationDefaultEntry, len(keys))
for i, key := range keys {
entries[i] = defaults[key]
}
return entries
}
func resourceIDFieldOverrideEntries(syncable []profiler.SyncableResource, dependent []profiler.DependentResource) []resourceIDFieldOverrideEntry {
overrides := map[string]string{}
for _, resource := range syncable {
if resource.IDField != "" {
overrides[resource.Name] = resource.IDField
}
}
for _, resource := range dependent {
if resource.IDField != "" {
overrides[resource.Name] = resource.IDField
}
}
names := make([]string, 0, len(overrides))
for name := range overrides {
names = append(names, name)
}
sort.Strings(names)
entries := make([]resourceIDFieldOverrideEntry, len(names))
for i, name := range names {
entries[i] = resourceIDFieldOverrideEntry{Name: name, Value: overrides[name]}
}
return entries
}
func resourceParentKeyColumnEntries(tables []TableDef, dependent []profiler.DependentResource) []resourceParentKeyColumnEntry {
columns := make(map[string][]string)
add := func(resource, column string, first bool) {
if resource == "" || column == "" {
return
}
values := columns[resource]
if slices.Contains(values, column) {
return
}
if first {
columns[resource] = append([]string{column}, values...)
return
}
columns[resource] = append(values, column)
}
for _, table := range tables {
if table.ParentKeyColumn != "" {
add(table.Resource, table.ParentKeyColumn, false)
}
}
for _, resource := range dependent {
add(resource.Name, "parent_id", true)
}
names := make([]string, 0, len(columns))
for name := range columns {
names = append(names, name)
}
sort.Strings(names)
entries := make([]resourceParentKeyColumnEntry, 0, len(names))
for _, name := range names {
entries = append(entries, resourceParentKeyColumnEntry{
Name: name,
Values: columns[name],
})
}
return entries
}
func resourceIDBaseOverrideEntries(dependent []profiler.DependentResource) []resourceIDFieldOverrideEntry {
overrides := make(map[string]string)
for _, resource := range dependent {
if leaf := dependentResourcePathLeaf(resource.Path); leaf != "" {
overrides[resource.Name] = leaf
}
}
names := make([]string, 0, len(overrides))
for name := range overrides {
names = append(names, name)
}
sort.Strings(names)
entries := make([]resourceIDFieldOverrideEntry, len(names))
for i, name := range names {
entries[i] = resourceIDFieldOverrideEntry{Name: name, Value: overrides[name]}
}
return entries
}
func dependentResourcePathLeaf(path string) string {
if before, _, ok := strings.Cut(path, "?"); ok {
path = before
}
segments := strings.Split(strings.Trim(path, "/"), "/")
for _, segment := range slices.Backward(segments) {
segment = strings.TrimSpace(segment)
if segment == "" || (strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}")) {
continue
}
return spec.ToSnakeCase(segment)
}
return ""
}
func htmlPageModeResourceEntries(api *spec.APISpec, syncable []profiler.SyncableResource, dependent []profiler.DependentResource) []criticalResourceEntry {
resources := map[string]bool{}
if api != nil {
var collect func(resourceMap map[string]spec.Resource)
collect = func(resourceMap map[string]spec.Resource) {
for resourceName, resource := range resourceMap {
name := spec.ToSnakeCase(resourceName)
for _, endpoint := range resource.Endpoints {
if endpoint.UsesHTMLResponse() && endpoint.HTMLExtract.EffectiveMode() == spec.HTMLExtractModePage {
resources[name] = true
break
}
}
collect(resource.SubResources)
}
}
collect(api.Resources)
}
for _, resource := range syncable {
if syncableResourceUsesHTMLPageMode(resource) {
resources[resource.Name] = true
}
}
for _, resource := range dependent {
if dependentResourceUsesHTMLPageMode(resource) {
resources[resource.Name] = true
}
}
names := make([]string, 0, len(resources))
for name := range resources {
names = append(names, name)
}
sort.Strings(names)
entries := make([]criticalResourceEntry, len(names))
for i, name := range names {
entries[i] = criticalResourceEntry{Name: name}
}
return entries
}
func criticalResourceEntries(syncable []profiler.SyncableResource, dependent []profiler.DependentResource) []criticalResourceEntry {
critical := map[string]bool{}
for _, resource := range syncable {
if resource.Critical {
critical[resource.Name] = true
}
}
for _, resource := range dependent {
if resource.Critical {
critical[resource.Name] = true
}
}
names := make([]string, 0, len(critical))
for name := range critical {
names = append(names, name)
}
sort.Strings(names)
entries := make([]criticalResourceEntry, len(names))
for i, name := range names {
entries[i] = criticalResourceEntry{Name: name}
}
return entries
}
func paginationSupportedResources(syncable []profiler.SyncableResource, dependent []profiler.DependentResource) []string {
supported := map[string]bool{}
for _, resource := range syncable {
if resource.SupportsPagination {
supported[resource.Name] = true
}
}
for _, resource := range dependent {
if resource.SupportsPagination {
supported[resource.Name] = true
}
}
names := make([]string, 0, len(supported))
for name := range supported {
names = append(names, name)
}
sort.Strings(names)
return names
}
func hasDefaultSyncResources(syncable []profiler.SyncableResource) bool {
return len(defaultSyncResourceNames(syncable)) > 0
}
func defaultSyncResourceNames(syncable []profiler.SyncableResource) []string {
names := make([]string, 0, len(syncable))
for _, resource := range syncable {
if resource.SkipDefaultSync {
continue
}
if name := strings.TrimSpace(resource.Name); name != "" {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
func syncableHintResourceNames(syncable []profiler.SyncableResource) []string {
names := make([]string, 0, len(syncable))
for _, resource := range syncable {
if isVestigialSyncResource(resource) {
continue
}
if name := strings.TrimSpace(resource.Name); name != "" {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
// Quote, backslash, or newline in spec-derived tokens break generated "..." literals.
func goStringLiteralContent(s string) string {
quoted := strconv.Quote(s)
return quoted[1 : len(quoted)-1]
}
// syncHintInvocation is the working sync command for generated hints and
// help. Bare `sync` when defaultSyncResources is populated; `sync --resources
// a,b` when resources exist but none are default; empty when no hint should
// name sync.
func syncHintInvocation(cliName string, syncable []profiler.SyncableResource) string {
cliName = strings.TrimSpace(cliName)
if cliName == "" {
return ""
}
bin := cliName + "-pp-cli"
if len(defaultSyncResourceNames(syncable)) > 0 {
return shellargs.Join([]string{bin, "sync"})
}
if names := syncableHintResourceNames(syncable); len(names) > 0 {
return shellargs.Join([]string{bin, "sync", "--resources", strings.Join(names, ",")})
}
return ""
}
func (g *Generator) syncHintInvocation() string {
if g == nil || g.Spec == nil || !g.hasGeneratedSyncImplementation() {
return ""
}
var syncable []profiler.SyncableResource
if g.profile != nil {
syncable = g.profile.SyncableResources
}
return syncHintInvocation(g.Spec.Name, syncable)
}
func (g *Generator) syncHintIsBare() bool {
inv := g.syncHintInvocation()
return strings.HasSuffix(inv, " sync")
}
func specDateTimeFieldNames(api *spec.APISpec) []string {
if api == nil {
return nil
}
fields := map[string]struct{}{}
addName := func(name, format string) {
if strings.EqualFold(format, "date-time") && strings.TrimSpace(name) != "" {
fields[name] = struct{}{}
}
}
var walkParams func(params []spec.Param)
walkParams = func(params []spec.Param) {
for _, p := range params {
addName(p.Name, p.Format)
if len(p.Fields) > 0 {
walkParams(p.Fields)
}
}
}
var walkResource func(resource spec.Resource)
walkResource = func(resource spec.Resource) {
for _, endpoint := range resource.Endpoints {
walkParams(endpoint.Params)
walkParams(endpoint.Body)
}
for _, sub := range resource.SubResources {
walkResource(sub)
}
}
for _, typeDef := range api.Types {
for _, f := range typeDef.Fields {
addName(f.Name, f.Format)
}
}
for _, resource := range api.Resources {
walkResource(resource)
}
names := make([]string, 0, len(fields))
for name := range fields {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (g *Generator) visionRenderData(schema []TableDef) visionRenderData {
gqlFieldPaths := map[string]string{}
for rName, r := range g.Spec.Resources {
if ep, ok := r.Endpoints["list"]; ok && ep.ResponsePath != "" {
gqlFieldPaths[rName] = graphqlQueryField(ep.ResponsePath)
}
}
return visionRenderData{
APISpec: g.Spec,
VisionSet: g.VisionSet,
CommandNames: maps.Clone(g.visionCommandNames),
HasSync: g.hasGeneratedSyncImplementation(),
SyncableResources: withEffectiveSyncableRequestPaths(g.Spec, g.profile.SyncableResources),
DependentSyncResources: withEffectiveDependentRequestPaths(g.Spec, g.profile.DependentSyncResources),
SyncResourcesExample: syncResourcesExample(g.profile.SyncableResources, g.profile.DependentSyncResources),
TenantScopedParents: g.profile.TenantScopedParents(),
MembershipScopedParents: g.profile.MembershipScopedParents(),
PaginationSupportedResources: paginationSupportedResources(g.profile.SyncableResources, g.profile.DependentSyncResources),
PaginationDefaultResources: paginationDefaultEntries(g.profile.SyncableResources, g.profile.DependentSyncResources),
SpecTimestampFields: specDateTimeFieldNames(g.Spec),
SearchableFields: g.profile.SearchableFields,
Tables: schema,
Pagination: g.profile.Pagination,
SearchEndpointPath: effectiveRequestPath(g.Spec, "", g.profile.SearchEndpointPath, g.profile.SearchEndpointMethod),
SearchQueryParam: g.profile.SearchQueryParam,
SearchEndpointMethod: g.profile.SearchEndpointMethod,
SearchBodyFields: g.profile.SearchBodyFields,
SearchResponsePaths: searchResponsePaths(g.Spec, g.profile.SearchEndpointPath, g.profile.SearchEndpointMethod),
SearchExampleType: searchExampleType(g.Spec),
GraphQLFieldPaths: gqlFieldPaths,
AgentMoneyWorkflow: detectAgentMoneyWorkflow(g.Spec, g.PromotedEndpointNames),
HTMLSyncStub: g.htmlSyncStubSelected,
HTMLPageModeResources: htmlPageModeResourceEntries(g.Spec, g.profile.SyncableResources, g.profile.DependentSyncResources),
}
}
func searchResponsePaths(apiSpec *spec.APISpec, searchEndpointPath, searchEndpointMethod string) []string {
searchEndpointPath = strings.TrimSpace(searchEndpointPath)
if apiSpec == nil || searchEndpointPath == "" {
return nil
}
searchEndpointMethod = strings.ToUpper(strings.TrimSpace(searchEndpointMethod))
seen := map[string]bool{}
for _, resource := range apiSpec.Resources {
for _, endpoint := range resource.Endpoints {
if endpoint.Path != searchEndpointPath {
continue
}
if searchEndpointMethod != "" && strings.ToUpper(endpoint.Method) != searchEndpointMethod {
continue
}
path := strings.TrimSpace(endpoint.ResponsePath)
if path != "" {
seen[path] = true
}
}
}
paths := slices.Sorted(maps.Keys(seen))
return paths
}
func searchExampleType(apiSpec *spec.APISpec) string {
if apiSpec == nil {
return ""
}
names := slices.Sorted(maps.Keys(apiSpec.Resources))
if len(names) == 0 {
return ""
}
return names[0]
}
const htmlSyncStubThreshold = 0.7
func (g *Generator) shouldEmitHTMLSyncStub() bool {
if g == nil || isGraphQLSpec(g.Spec) {
return false
}
if g.htmlSyncStubComputed {
return g.htmlSyncStub
}
total, html := g.countHTMLPageModeSyncResources()
if total > 0 && html != total {
g.htmlSyncStubComputed = true
g.htmlSyncStub = false
return g.htmlSyncStub
}
if total == 0 {
total, html = countHTMLPageModeEndpoints(g.Spec)
}
if total == 0 {
g.htmlSyncStubComputed = true
g.htmlSyncStub = false
return g.htmlSyncStub
}
g.htmlSyncStubComputed = true
g.htmlSyncStub = float64(html)/float64(total) >= htmlSyncStubThreshold
return g.htmlSyncStub
}
func (g *Generator) hasGeneratedSyncImplementation() bool {
return g != nil && g.VisionSet.Sync && !g.shouldEmitHTMLSyncStub()
}
func (g *Generator) hasAutoRefresh() bool {
return g != nil && g.VisionSet.Store && g.hasGeneratedSyncImplementation() && g.Spec.Cache.Enabled
}
func (g *Generator) resetHTMLSyncStubCache() {
if g == nil {
return
}
g.htmlSyncStubComputed = false
g.htmlSyncStub = false
}
func (g *Generator) countHTMLPageModeSyncResources() (total int, html int) {
if g == nil || g.profile == nil {
return 0, 0
}
for _, resource := range g.profile.SyncableResources {
total++
if syncableResourceUsesHTMLPageMode(resource) {
html++
}
}
for _, resource := range g.profile.DependentSyncResources {
total++
if dependentResourceUsesHTMLPageMode(resource) {
html++
}
}
return total, html
}
func syncableResourceUsesHTMLPageMode(resource profiler.SyncableResource) bool {
return resource.UsesHTMLResponse && resource.HTMLExtract.EffectiveMode() == spec.HTMLExtractModePage
}
func dependentResourceUsesHTMLPageMode(resource profiler.DependentResource) bool {
return resource.UsesHTMLResponse && resource.HTMLExtract.EffectiveMode() == spec.HTMLExtractModePage
}
func countHTMLPageModeEndpoints(api *spec.APISpec) (total int, html int) {
if api == nil {
return 0, 0
}
for _, resource := range api.Resources {
resourceTotal, resourceHTML := countResourceHTMLPageModeEndpoints(resource)
total += resourceTotal
html += resourceHTML
}
return total, html
}
func countResourceHTMLPageModeEndpoints(resource spec.Resource) (total int, html int) {
for _, endpoint := range resource.Endpoints {
total++
if endpoint.UsesHTMLResponse() && endpoint.HTMLExtract.EffectiveMode() == spec.HTMLExtractModePage {
html++
}
}
for _, sub := range resource.SubResources {
subTotal, subHTML := countResourceHTMLPageModeEndpoints(sub)
total += subTotal
html += subHTML
}
return total, html
}
func (g *Generator) renderVisionCommands(visionData visionRenderData) error {
if g.VisionSet.Export || g.VisionSet.Import || g.VisionSet.Tail {
if err := g.renderTemplate("resource_paths.go.tmpl", filepath.Join("internal", "cli", "resource_paths.go"), visionData); err != nil {
return fmt.Errorf("rendering resource paths: %w", err)
}
}
// Render vision CLI commands
visionCmds := map[string]string{
"export.go.tmpl": filepath.Join("internal", "cli", "export.go"),
"import.go.tmpl": filepath.Join("internal", "cli", "import.go"),
"search.go.tmpl": filepath.Join("internal", "cli", "search.go"),
"sync.go.tmpl": filepath.Join("internal", "cli", "sync.go"),
"tail.go.tmpl": filepath.Join("internal", "cli", "tail.go"),
"analytics.go.tmpl": filepath.Join("internal", "cli", "analytics.go"),
}
gqlSpec := isGraphQLSpec(g.Spec)
for _, tmplName := range g.VisionSet.TemplateNames() {
if tmplName == "store.go.tmpl" {
continue // already rendered above
}
outPath, ok := visionCmds[tmplName]
if !ok {
continue
}
// For GraphQL specs, use the GraphQL sync template instead of the REST one
actualTmpl := tmplName
if tmplName == "sync.go.tmpl" && gqlSpec {
actualTmpl = "graphql_sync.go.tmpl"
} else if tmplName == "sync.go.tmpl" && visionData.HTMLSyncStub {
actualTmpl = "sync_stub.go.tmpl"
} else if tmplName == "import.go.tmpl" && gqlSpec {
// GraphQL APIs have no generic REST create endpoint, so the REST
// import template's POST /<resource> per record always 400s. Emit
// a GraphQL-aware import that errors clearly instead.
actualTmpl = "graphql_import.go.tmpl"
}
var tmplData any = g.Spec
if tmplName == "sync.go.tmpl" || tmplName == "search.go.tmpl" || tmplName == "export.go.tmpl" || tmplName == "import.go.tmpl" || tmplName == "tail.go.tmpl" || tmplName == "analytics.go.tmpl" {
tmplData = visionData
}
if err := g.renderTemplate(actualTmpl, outPath, tmplData); err != nil {
return fmt.Errorf("rendering vision %s: %w", tmplName, err)
}
if tmplName == "sync.go.tmpl" && actualTmpl == "sync.go.tmpl" {
if err := g.renderTemplate("sync_numeric_id_test.go.tmpl", filepath.Join("internal", "cli", "sync_numeric_id_test.go"), tmplData); err != nil {
return fmt.Errorf("rendering sync numeric ID test: %w", err)
}
}
}
return nil
}
func (g *Generator) renderWorkflowFiles(visionData visionRenderData) ([]string, error) {
// Render data source resolution template when store is enabled
if g.hasDataLayer() {
if err := g.renderTemplate("data_source.go.tmpl", filepath.Join("internal", "cli", "data_source.go"), visionData); err != nil {
return nil, fmt.Errorf("rendering data_source: %w", err)
}
}
// HTML page-mode CLIs keep a workflow stub that explains how to add a
// site-specific sync command even though they have no generic sync surface.
if g.hasWorkflowSurface() {
workflowData := struct {
*spec.APISpec
SyncableResources []profiler.SyncableResource
SearchableFields map[string][]string
Pagination profiler.PaginationProfile
AgentMoneyWorkflow AgentMoneyWorkflow
SyncEnabled bool
}{
APISpec: g.Spec,
SyncableResources: g.profile.SyncableResources,
SearchableFields: g.profile.SearchableFields,
Pagination: g.profile.Pagination,
AgentMoneyWorkflow: visionData.AgentMoneyWorkflow,
SyncEnabled: g.hasGeneratedSyncImplementation(),
}
if err := g.renderTemplate("channel_workflow.go.tmpl", filepath.Join("internal", "cli", "channel_workflow.go"), workflowData); err != nil {
return nil, fmt.Errorf("rendering workflow: %w", err)
}
}
var renderedWorkflowConstructors []string
// Render domain-specific workflow templates
for _, tmpl := range g.VisionSet.Workflows {
outName := strings.TrimSuffix(filepath.Base(tmpl), ".tmpl")
outPath := filepath.Join("internal", "cli", outName)
templateData := visionData
templateData.CommandConstructor = g.resolvedVisionCommandConstructor(tmpl)
if err := g.renderTemplate(tmpl, outPath, templateData); err != nil {
fmt.Fprintf(os.Stderr, "warning: skipping workflow template %s: %v\n", tmpl, err)
continue
}
if templateData.CommandConstructor != "" {
renderedWorkflowConstructors = append(renderedWorkflowConstructors, templateData.CommandConstructor)
}
}
return renderedWorkflowConstructors, nil
}
type AgentMoneyWorkflow struct {
Payment *AgentMoneyCommand
Request *AgentMoneyCommand
Transfer *AgentMoneyCommand
}
func (w AgentMoneyWorkflow) Enabled() bool {
return w.Payment != nil || w.Request != nil || w.Transfer != nil
}
func (w AgentMoneyWorkflow) complete() bool {
return w.Payment != nil && w.Request != nil && w.Transfer != nil
}
type AgentMoneyCommand struct {
CommandPath []string
HasAccountIDPosition bool
AmountFlag string
AmountInteger bool
RecipientIDFlag string
PaymentMethodFlag string
IdempotencyKeyFlag string
SourceAccountIDFlag string
DestinationAccountIDFlag string
NoteFlag string
ExternalMemoFlag string
PurposeFlag string
}
type agentMoneyKind string
const (
agentMoneyKindNone agentMoneyKind = ""
agentMoneyKindPayment agentMoneyKind = "payment"
agentMoneyKindRequest agentMoneyKind = "request"
agentMoneyKindTransfer agentMoneyKind = "transfer"
)
func detectAgentMoneyWorkflow(api *spec.APISpec, promotedEndpointNames map[string]string) AgentMoneyWorkflow {
var workflow AgentMoneyWorkflow
if api == nil {
return workflow
}
for _, resourceName := range sortedResourceNames(api.Resources) {
resource := api.Resources[resourceName]
for _, endpointName := range sortedEndpointNames(resource.Endpoints) {
endpoint := resource.Endpoints[endpointName]
class, cmd := agentMoneyCommandForEndpoint(endpoint, []string{toKebab(resourceName), toKebab(endpointName)}, promotedEndpointNames[resourceName] == endpointName)
assignAgentMoneyCommand(&workflow, class, cmd)
if workflow.complete() {
return workflow
}
}
collidingLeaves := collectionItemCollisionLeaves(resource)
for _, subName := range sortedResourceNames(resource.SubResources) {
sub := resource.SubResources[subName]
naming := subResourceCommandNamesFor(resourceName, subName, collidingLeaves[subName])
for _, endpointName := range sortedEndpointNames(sub.Endpoints) {
endpoint := sub.Endpoints[endpointName]
class, cmd := agentMoneyCommandForEndpoint(endpoint, append(strings.Fields(naming.commandPath), toKebab(endpointName)), false)
assignAgentMoneyCommand(&workflow, class, cmd)
if workflow.complete() {
return workflow
}
}
}
}
return workflow
}
func assignAgentMoneyCommand(workflow *AgentMoneyWorkflow, class agentMoneyKind, cmd *AgentMoneyCommand) {
if workflow == nil || cmd == nil {
return
}
switch class {
case agentMoneyKindTransfer:
if workflow.Transfer == nil {
workflow.Transfer = cmd
}
case agentMoneyKindRequest:
if workflow.Request == nil {
workflow.Request = cmd
}
case agentMoneyKindPayment:
if workflow.Payment == nil {
workflow.Payment = cmd
}
}
}
func classifyAgentMoneyEndpoint(endpoint spec.Endpoint, body map[string]spec.Param) agentMoneyKind {
if !strings.EqualFold(endpoint.Method, "POST") {
return agentMoneyKindNone
}
has := func(name string) bool {
_, ok := body[strings.ToLower(name)]
return ok
}
if has("amount") && has("sourceAccountId") && has("destinationAccountId") && has("idempotencyKey") {
return agentMoneyKindTransfer
}
if has("amount") && has("recipientId") && has("paymentMethod") && has("idempotencyKey") {
if strings.Contains(strings.ToLower(endpoint.Path), "request") {
return agentMoneyKindRequest
}
return agentMoneyKindPayment
}
return agentMoneyKindNone
}
func agentMoneyCommandForEndpoint(endpoint spec.Endpoint, path []string, promoted bool) (agentMoneyKind, *AgentMoneyCommand) {
body := paramsByLowerName(endpoint.Body)
class := classifyAgentMoneyEndpoint(endpoint, body)
if class == agentMoneyKindNone {
return class, nil
}
if !agentMoneyEndpointHasSupportedPositionals(endpoint) || !agentMoneyEndpointHasSupportedRequiredBody(endpoint, class) {
return class, nil
}
if promoted && len(path) >= 1 {
path = path[:1]
} else if endpoint.Alias != "" && len(path) > 0 {
path[len(path)-1] = endpoint.Alias
}
cmd := &AgentMoneyCommand{CommandPath: path}
for _, param := range endpoint.Params {
if param.Positional && strings.EqualFold(param.Name, "accountId") {
cmd.HasAccountIDPosition = true
break
}
}
if p, ok := body["amount"]; ok {
cmd.AmountFlag = flagName(paramIdent(p))
cmd.AmountInteger = primitiveKind(p.Type) == "int"
}
if p, ok := body["recipientid"]; ok {
cmd.RecipientIDFlag = flagName(paramIdent(p))
}
if p, ok := body["paymentmethod"]; ok {
cmd.PaymentMethodFlag = flagName(paramIdent(p))
}
if p, ok := body["idempotencykey"]; ok {
cmd.IdempotencyKeyFlag = flagName(paramIdent(p))
}
if p, ok := body["sourceaccountid"]; ok {
cmd.SourceAccountIDFlag = flagName(paramIdent(p))
}
if p, ok := body["destinationaccountid"]; ok {
cmd.DestinationAccountIDFlag = flagName(paramIdent(p))
}
if p, ok := body["note"]; ok {
cmd.NoteFlag = flagName(paramIdent(p))
}
if p, ok := body["externalmemo"]; ok {
cmd.ExternalMemoFlag = flagName(paramIdent(p))
}
if p, ok := body["purpose"]; ok {
cmd.PurposeFlag = flagName(paramIdent(p))
}
return class, cmd
}
func agentMoneyEndpointHasSupportedPositionals(endpoint spec.Endpoint) bool {
for _, param := range endpoint.Params {
if !param.Positional || !param.Required {
continue
}
if !strings.EqualFold(param.Name, "accountId") {
return false
}
}
return true
}
func agentMoneyEndpointHasSupportedRequiredBody(endpoint spec.Endpoint, class agentMoneyKind) bool {
required := map[string]struct{}{}
switch class {
case agentMoneyKindTransfer:
for _, name := range []string{"amount", "sourceaccountid", "destinationaccountid", "idempotencykey"} {
required[name] = struct{}{}
}
case agentMoneyKindRequest, agentMoneyKindPayment:
for _, name := range []string{"amount", "recipientid", "paymentmethod", "idempotencykey"} {
required[name] = struct{}{}
}
default:
return false
}
for _, param := range endpoint.Body {
if !param.Required {
continue
}
if _, ok := required[strings.ToLower(param.Name)]; !ok {
return false
}
}
return true
}
func paramsByLowerName(params []spec.Param) map[string]spec.Param {
out := make(map[string]spec.Param, len(params))
for _, param := range params {
out[strings.ToLower(param.Name)] = param
}
return out
}
func sortedResourceNames(resources map[string]spec.Resource) []string {
names := make([]string, 0, len(resources))
for name := range resources {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (g *Generator) renderInsightFiles(visionData visionRenderData) []string {
var renderedInsightConstructors []string
// Render insight templates
for _, tmpl := range g.VisionSet.Insights {
outName := strings.TrimSuffix(filepath.Base(tmpl), ".tmpl")
outPath := filepath.Join("internal", "cli", outName)
templateData := visionData
templateData.CommandConstructor = g.resolvedVisionCommandConstructor(tmpl)
if err := g.renderTemplate(tmpl, outPath, templateData); err != nil {
fmt.Fprintf(os.Stderr, "warning: skipping insight template %s: %v\n", tmpl, err)
continue
}
if templateData.CommandConstructor != "" {
renderedInsightConstructors = append(renderedInsightConstructors, templateData.CommandConstructor)
}
}
return renderedInsightConstructors
}
func (g *Generator) renderMCPToolFiles(schema []TableDef) error {
// Render MCP tools registration (needs VisionSet + store data + tool counts for annotations)
if g.VisionSet.MCP {
mcpTotal, mcpPublic := g.Spec.CountMCPTools()
domainCtx := g.buildDomainContext()
reservedIntentNames := reservedMCPToolNames(g.Spec, g.VisionSet, g.NovelFeatures)
recipeIntents := buildRecipeIntents(g.Spec.Name, g.Narrative, reservedIntentNames)
mcpData := struct {
*spec.APISpec
SyncableResources []profiler.SyncableResource
SearchableFields map[string][]string
Tables []TableDef
VisionSet VisionTemplateSet
MCPTotalCount int
MCPPublicCount int
NovelFeatures []NovelFeature
DomainContext DomainContext
RecipeIntents []RecipeIntent
HasMCPIntents bool
PreserveMCPIntentRegistration bool
PreserveMCPIntentFile bool
}{
APISpec: g.Spec,
SyncableResources: g.profile.SyncableResources,
SearchableFields: g.profile.SearchableFields,
Tables: schema,
VisionSet: g.dataSurfaceVisionSet(),
MCPTotalCount: mcpTotal,
MCPPublicCount: mcpPublic,
NovelFeatures: g.NovelFeatures,
DomainContext: domainCtx,
RecipeIntents: recipeIntents,
HasMCPIntents: len(g.Spec.MCP.Intents) > 0 || len(recipeIntents) > 0 || g.PreserveMCPIntentRegistration,
PreserveMCPIntentRegistration: g.PreserveMCPIntentRegistration,
PreserveMCPIntentFile: g.PreserveMCPIntentFile,
}
if err := g.renderTemplate("mcp_platform_gate.go.tmpl", filepath.Join("internal", "mcp", "platform_gate.go"), mcpData); err != nil {
return fmt.Errorf("rendering MCP tenant gate: %w", err)
}
if err := g.renderTemplate("mcp_platform_gate_test.go.tmpl", filepath.Join("internal", "mcp", "platform_gate_test.go"), mcpData); err != nil {
return fmt.Errorf("rendering MCP tenant-gate tests: %w", err)
}
if err := g.renderTemplate("mcp_tools.go.tmpl", filepath.Join("internal", "mcp", "tools.go"), mcpData); err != nil {
return fmt.Errorf("rendering MCP tools: %w", err)
}
if g.hasDataLayer() {
if err := g.renderTemplate("mcp_tools_test.go.tmpl", filepath.Join("internal", "mcp", "tools_test.go"), mcpData); err != nil {
return fmt.Errorf("rendering MCP tools tests: %w", err)
}
}
if mcpData.HasMCPIntents && !g.PreserveMCPIntentFile {
if err := g.renderTemplate("mcp_intents.go.tmpl", filepath.Join("internal", "mcp", "intents.go"), mcpData); err != nil {
return fmt.Errorf("rendering MCP intents: %w", err)
}
}
if len(recipeIntents) > 0 {
if err := g.renderTemplate("mcp_recipe_intents_test.go.tmpl", filepath.Join("internal", "mcp", "recipe_intents_test.go"), mcpData); err != nil {
return fmt.Errorf("rendering MCP recipe intent tests: %w", err)
}
}
if g.Spec.MCP.IsCodeOrchestration() {
if err := g.renderTemplate("mcp_code_orch.go.tmpl", filepath.Join("internal", "mcp", "code_orch.go"), mcpData); err != nil {
return fmt.Errorf("rendering MCP code-orchestration: %w", err)
}
// Durable regression test for the write-body contracts
// (double-marshal object path + bare-array body path).
// Gated on code-orchestration so intents-only CLIs, which
// have no codeOrchWriteBody/codeOrchArrayBody, still compile.
if err := g.renderTemplate("mcp_code_orch_writebody_test.go.tmpl", filepath.Join("internal", "mcp", "code_orch_writebody_test.go"), mcpData); err != nil {
return fmt.Errorf("rendering MCP code-orchestration write-body test: %w", err)
}
}
}
return nil
}
func (g *Generator) renderPromotedCommandFiles(promotedCommands []PromotedCommand) error {
// Generate api discovery command when promoted commands exist (lets users browse the raw generated surface)
if len(promotedCommands) > 0 {
if err := g.renderTemplate("api_discovery.go.tmpl", filepath.Join("internal", "cli", "api_discovery.go"), g.Spec); err != nil {
return fmt.Errorf("rendering api discovery: %w", err)
}
}
novelChildrenByParent := g.novelFeatureChildrenByParent()
// Generate promoted top-level commands (user-friendly aliases for nested API commands)
// promotedCommands was computed earlier so promoted resources can replace their raw parents.
for _, pc := range promotedCommands {
// Look up the full resource to pass sibling endpoints/sub-resources.
resource := g.Spec.Resources[pc.ResourceName]
promotedData := struct {
PromotedName string
ResourceName string
EndpointName string
EffectivePath string
Endpoint spec.Endpoint
EffectiveTier string
HasStore bool
HasResponseUnwrap bool
PageSize int
Resource spec.Resource
FuncPrefix string
IsReadOnly bool
NovelChildren []novelFeatureChildRender
*spec.APISpec
}{
PromotedName: pc.PromotedName,
ResourceName: pc.ResourceName,
EndpointName: pc.EndpointName,
EffectivePath: effectiveEndpointPath(resource, pc.Endpoint),
Endpoint: pc.Endpoint,
EffectiveTier: g.Spec.EffectiveTier(resource, pc.Endpoint),
HasStore: g.hasDataLayer(),
// Per-command: emit the extractResponseData call only on commands
// whose own response is envelope-shaped, so a non-envelope command
// in a mixed-envelope spec is never unwrapped. This is a subset of
// the CLI-level helper-emission gate (helpers.go emits the helper
// when ANY promoted command qualifies), so call ⊆ emit — no call to
// an unemitted helper.
HasResponseUnwrap: g.hasDataLayer() && !pc.Endpoint.UsesBinaryResponse() && !pc.Endpoint.UsesTextResponse() && endpointHasStatusDataEnvelope(pc.Endpoint, g.Spec.Types),
PageSize: g.paginationPageSizeForEndpoint(pc.Endpoint),
Resource: resource,
FuncPrefix: pc.ResourceName,
IsReadOnly: endpointIsReadCommandShared(pc.Endpoint, pc.EndpointName, sharedGETRPCPaths(g.Spec.Resources)),
NovelChildren: novelChildrenByParent[toKebab(pc.PromotedName)],
APISpec: g.Spec,
}
promotedPath := filepath.Join("internal", "cli", safeResourceFileStem("promoted_"+pc.PromotedName)+".go")
if err := g.renderTemplate("command_promoted.go.tmpl", promotedPath, promotedData); err != nil {
return fmt.Errorf("rendering promoted command %s: %w", pc.PromotedName, err)
}
}
return nil
}
func (g *Generator) paginationPageSizeForEndpoint(endpoint spec.Endpoint) int {
// Only an endpoint-declared default is safe as short-page evidence. Zero
// tells the generated helper that the server's page size is unknown.
if endpoint.Pagination == nil || endpoint.Pagination.LimitParam == "" {
return 0
}
limitParam, ok := endpointParamByName(endpoint, endpoint.Pagination.LimitParam)
if !ok {
return 0
}
pageSize := 0
if defaultPageSize, ok := positiveIntValue(limitParam.Default); ok {
pageSize = defaultPageSize
}
if maxPageSize, ok := paramMaxInt(limitParam); ok && pageSize > 0 && maxPageSize < pageSize {
pageSize = maxPageSize
}
return pageSize
}
func endpointParamByName(endpoint spec.Endpoint, name string) (spec.Param, bool) {
for _, param := range endpoint.Params {
if param.Name == name || param.URLName == name {
return param, true
}
}
return spec.Param{}, false
}
func positiveIntValue(value any) (int, bool) {
switch v := value.(type) {
case int:
if v > 0 {
return v, true
}
case int64:
if v > 0 && int64(int(v)) == v {
return int(v), true
}
case float64:
asInt := int(v)
if v > 0 && float64(asInt) == v {
return asInt, true
}
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil && n > 0 {
return n, true
}
}
return 0, false
}
func paramMaxInt(param spec.Param) (int, bool) {
var max int
hasMax := false
if param.Maximum != nil && *param.Maximum > 0 {
max = int(*param.Maximum)
hasMax = true
}
if param.ExclusiveMaximum != nil && *param.ExclusiveMaximum > 0 {
exclusiveMax := int(*param.ExclusiveMaximum)
if float64(exclusiveMax) == *param.ExclusiveMaximum {
exclusiveMax--
}
if exclusiveMax > 0 && (!hasMax || exclusiveMax < max) {
max = exclusiveMax
hasMax = true
}
}
return max, hasMax
}
func (g *Generator) renderRootProjectFiles(promotedCommands []PromotedCommand, promotedResourceNames map[string]bool, renderedWorkflowConstructors, renderedInsightConstructors []string, novelCommandStubs []novelFeatureCommandRender) error {
// Root --help Long surfaces ALL verified-built novel features — the
// whole point of this change is to stop making agents do discovery
// for novel capabilities. A count cap (earlier draft used 3) neuters
// the thesis for CLIs with genuinely many novel features, which are
// the CLIs that benefit most from the absorb work in the first place.
//
// Size is bounded two ways:
// 1. per-line truncation via the template's truncate helper (200 runes)
// 2. a soft cap on total feature lines rendered (MaxHighlightLines);
// overflow becomes a "…and N more — see README" breadcrumb so a
// verbose absorb output doesn't blow up --help
const maxHighlightLines = 15 // ~3000-char description ceiling in the worst case
shownNovel := g.NovelFeatures
overflow := 0
if len(shownNovel) > maxHighlightLines {
overflow = len(shownNovel) - maxHighlightLines
shownNovel = shownNovel[:maxHighlightLines]
}
// HasAuthCommand mirrors shouldEmitAuth. The template uses it to gate
// rootCmd.AddCommand(newAuthCmd) so the root binary does not reference an
// undefined symbol when auth.go was skipped.
hasAuthCommand := g.shouldEmitAuth()
helperFlags := computeHelperFlags(g.Spec)
applyPartialFailureFlags(&helperFlags, g.Spec, promotedCommands, g.PromotedEndpointNames, g.hasDataLayer())
rootData := struct {
*spec.APISpec
VisionSet VisionTemplateSet
VisionCmdNames map[string]bool
WorkflowConstructors []string
InsightConstructors []string
NovelCommandStubs []novelFeatureCommandRender
NovelFrameworkChildren map[string][]novelFeatureChildRender
PromotedCommands []PromotedCommand
PromotedResourceNames map[string]bool
Narrative *ReadmeNarrative
TopNovelFeatures []NovelFeature
NovelOverflowCount int
HasAsyncJobs bool
AsyncJobCount int
HasAuthCommand bool
HasCreateCommands bool
HasDelete bool
HasMutationEndpoints bool
HasAutoRefresh bool
HasStorePath bool
SelectExample string
HasWorkflow bool
CompactDescription string
}{
APISpec: g.Spec,
VisionSet: g.dataSurfaceVisionSet(),
VisionCmdNames: g.VisionSet.CmdNames(),
WorkflowConstructors: renderedWorkflowConstructors,
InsightConstructors: renderedInsightConstructors,
NovelCommandStubs: novelCommandStubs,
NovelFrameworkChildren: g.novelFeatureFrameworkChildren(),
PromotedCommands: promotedCommands,
PromotedResourceNames: promotedResourceNames,
Narrative: g.Narrative,
TopNovelFeatures: shownNovel,
NovelOverflowCount: overflow,
HasAsyncJobs: len(g.AsyncJobs) > 0,
AsyncJobCount: len(g.AsyncJobs),
HasAuthCommand: hasAuthCommand,
HasCreateCommands: hasCreateCommands(g.Spec.Resources),
HasDelete: helperFlags.HasDelete,
HasMutationEndpoints: helperFlags.HasMutationEndpoints,
HasAutoRefresh: g.hasAutoRefresh(),
HasStorePath: g.VisionSet.Store,
SelectExample: selectExampleForCommand(g.Spec),
HasWorkflow: g.hasWorkflowSurface(),
CompactDescription: g.compactDescription(),
}
if err := g.renderTemplate("root.go.tmpl", filepath.Join("internal", "cli", "root.go"), rootData); err != nil {
return fmt.Errorf("rendering root: %w", err)
}
if len(g.AsyncJobs) > 0 {
jobsData := struct {
*spec.APISpec
AsyncJobs map[string]AsyncJobInfo
}{
APISpec: g.Spec,
AsyncJobs: g.AsyncJobs,
}
if err := g.renderTemplate("jobs.go.tmpl", filepath.Join("internal", "cli", "jobs.go"), jobsData); err != nil {
return fmt.Errorf("rendering jobs: %w", err)
}
}
if err := g.renderTemplate("go.mod.tmpl", "go.mod", rootData); err != nil {
return fmt.Errorf("rendering go.mod: %w", err)
}
if err := g.renderTemplate("makefile.tmpl", "Makefile", rootData); err != nil {
return fmt.Errorf("rendering Makefile: %w", err)
}
if err := g.renderTemplate("goreleaser.yaml.tmpl", ".goreleaser.yaml", rootData); err != nil {
return fmt.Errorf("rendering goreleaser: %w", err)
}
if err := g.renderTemplate("gitignore.tmpl", ".gitignore", rootData); err != nil {
return fmt.Errorf("rendering gitignore: %w", err)
}
return nil
}
func (g *Generator) validateFreshnessCommandCoverage() error {
if !g.Spec.Cache.Enabled || len(g.Spec.Cache.Commands) == 0 {
return nil
}
syncable := make(map[string]struct{}, len(g.profile.SyncableResources))
for _, resource := range g.profile.SyncableResources {
syncable[resource.Name] = struct{}{}
}
for _, command := range g.Spec.Cache.Commands {
if _, collides := generatedFreshnessCommandNames(command.Name, syncable); collides {
return fmt.Errorf("cache.commands[%s]: command path is already covered by generated resource freshness", command.Name)
}
for _, resource := range command.Resources {
if _, ok := syncable[resource]; !ok {
return fmt.Errorf("cache.commands[%s]: resource %q is not syncable and cannot be auto-refreshed", command.Name, resource)
}
}
}
return nil
}
func generatedFreshnessCommandNames(name string, syncable map[string]struct{}) (string, bool) {
parts := strings.Fields(name)
if len(parts) == 0 {
return "", false
}
if _, ok := syncable[parts[0]]; !ok {
return "", false
}
if len(parts) == 1 {
return parts[0], true
}
if len(parts) == 2 {
switch parts[1] {
case "list", "get", "search":
return strings.Join(parts, " "), true
}
}
return "", false
}
func commandConstructorForTemplate(tmpl string) string {
switch filepath.Base(tmpl) {
case "pm_stale.go.tmpl":
return "Stale"
case "pm_orphans.go.tmpl":
return "Orphans"
case "pm_load.go.tmpl":
return "Load"
case "health_score.go.tmpl":
return "Health"
case "similar.go.tmpl":
return "Similar"
default:
return ""
}
}
func (g *Generator) resolvedVisionCommandConstructor(tmpl string) string {
constructor := commandConstructorForTemplate(tmpl)
if constructor == "" {
return ""
}
bare := frameworkUseNameForTemplate(tmpl)
resolved := g.resolvedVisionCommandName(tmpl)
if resolved == "" || resolved == bare {
return constructor
}
return commandIdent(resolved)
}
func (g *Generator) renderTemplate(tmplName, outPath string, data any) error {
tmpl, err := g.template(tmplName)
if err != nil {
return err
}
fullPath := filepath.Join(g.OutputDir, outPath)
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return fmt.Errorf("executing template %s: %w", tmplName, err)
}
if err := validateRenderedArtifact(outPath, buf.String()); err != nil {
return err
}
return os.WriteFile(fullPath, normalizeRendered(buf.Bytes(), tmplName, outPath), 0o644)
}
// normalizeRendered prepares template-rendered bytes for disk: trims trailing
// whitespace and re-appends a single newline, then runs go/format.Source on
// `.go` outputs so printed CLIs ship formatting-clean. Template authors
// hand-align struct fields inconsistently, and without this pass every fresh
// print surfaces hundreds of phantom diffs on the first `gofmt -w`. Falls
// through with a stderr warning rather than fail-hard so a malformed template
// surfaces as a compile error downstream instead of an opaque emit failure.
func normalizeRendered(raw []byte, tmplName, outPath string) []byte {
rendered := bytes.TrimRight(raw, " \t\r\n")
rendered = append(rendered, '\n')
if tmplName == "command_endpoint.go.tmpl" {
rendered = pruneUnusedClientImport(rendered)
}
if filepath.Ext(outPath) != ".go" {
return rendered
}
formatted, err := format.Source(rendered)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: gofmt failed for %s: %v\n", outPath, err)
return rendered
}
return formatted
}
// pruneUnusedClientImport drops the `<module>/internal/client` import line
// from a rendered command_endpoint.go file when the body never references
// the `client` package as a qualifier. The endpoint template emits this
// import for the GraphQL list/get path, but other branches (notably
// POST /graphql for GraphQL specs whose author wrote method: POST) reach
// the rendered file without producing a `client.X` reference, leaving an
// unused import that breaks `go build`.
func pruneUnusedClientImport(src []byte) []byte {
// Find the import block.
importStart := bytes.Index(src, []byte("\nimport (\n"))
if importStart < 0 {
return src
}
importBlockStart := importStart + len("\nimport (\n")
importEnd := bytes.Index(src[importBlockStart:], []byte("\n)"))
if importEnd < 0 {
return src
}
importEnd += importBlockStart
// Body is everything after the closing `)` of the import block.
body := src[importEnd:]
// A `client.X` reference is the only way the import is used inside the
// rendered body.
if bytes.Contains(body, []byte("client.")) {
return src
}
// Locate the line `"<...>/internal/client"` inside the import block and
// remove it (including its trailing newline). The literal suffix is
// stable across all module paths because it's emitted as
// `"{{modulePath}}/internal/client"` from the template.
importBlock := src[importBlockStart:importEnd]
suffix := []byte(`/internal/client"`)
rel := bytes.Index(importBlock, suffix)
if rel < 0 {
return src
}
abs := importBlockStart + rel
// Walk back to the start of the line.
lineStart := abs
for lineStart > 0 && src[lineStart-1] != '\n' {
lineStart--
}
// Walk forward to the end of the line, including the trailing newline.
lineEnd := abs
for lineEnd < len(src) && src[lineEnd] != '\n' {
lineEnd++
}
if lineEnd < len(src) {
lineEnd++
}
out := make([]byte, 0, len(src)-(lineEnd-lineStart))
out = append(out, src[:lineStart]...)
out = append(out, src[lineEnd:]...)
return out
}
func validateRenderedArtifact(outPath, content string) error {
switch filepath.Base(outPath) {
case "README.md", "SKILL.md":
default:
return nil
}
for _, marker := range []string{"<cli>-pp-cli", "~/.<cli>-pp-cli", "<CLI>_", "{{.Name}}"} {
if strings.Contains(content, marker) {
return fmt.Errorf("%s contains unsubstituted placeholder %q", outPath, marker)
}
}
if err := scanForControlBytes(outPath, content); err != nil {
return err
}
return nil
}
// scanForControlBytes rejects any rendered markdown that contains ASCII
// control bytes outside the small set legitimately used in text:
// 0x09 (tab), 0x0A (LF), 0x0D (CR). Everything else in 0x00-0x1F is
// rejected with the file path, byte offset, and a hint about the most
// likely cause.
//
// Why: research.json values flow through Go's encoding/json which honors
// JSON escape sequences like "\b" (0x08 backspace), "\f" (0x0C form
// feed), etc. When an agent author writes a regex literal as
// `"command": "...\bGo\b..."` (intending the literal characters
// `\` `b` `G` `o` `\` `b`) the JSON parser yields a string containing
// real backspace bytes, and the template engine writes those bytes
// straight into SKILL.md / README.md. The result renders as nothing in
// most viewers — silent corruption.
//
// The fix runs at render time (not at JSON parse time) so it catches
// every future class of escape mistake, not just the regex case that
// surfaced it. A targeted check on `narrative.recipes[].command`
// alone would only catch this one path.
//
// Surfaced by hackernews retro #350 finding F2.
func scanForControlBytes(outPath, content string) error {
for i := 0; i < len(content); i++ {
b := content[i]
// Tab (0x09), LF (0x0A), CR (0x0D) are allowed in markdown.
// Everything else in 0x00-0x1F is forbidden.
if b > 0x1F || b == 0x09 || b == 0x0A || b == 0x0D {
continue
}
hint := "likely cause: a JSON-parsed field (e.g. narrative.recipes[].command) contained \"\\b\", \"\\f\", or another JSON escape that became a control byte. Double-escape backslashes in regex literals: \"\\\\b\" not \"\\b\"."
return fmt.Errorf("%s contains forbidden control byte 0x%02X at offset %d. %s", outPath, b, i, hint)
}
return nil
}
func (g *Generator) template(tmplName string) (*template.Template, error) {
if tmpl, ok := g.templates[tmplName]; ok {
return tmpl, nil
}
content, err := templateFS.ReadFile(path.Join("templates", tmplName))
if err != nil {
return nil, fmt.Errorf("reading template %s: %w", tmplName, err)
}
tmpl, err := template.New(tmplName).Funcs(g.funcs).Parse(string(content))
if err != nil {
return nil, fmt.Errorf("parsing template %s: %w", tmplName, err)
}
g.templates[tmplName] = tmpl
return tmpl, nil
}
// Template helper functions.
//
// These run inside Go templates over parsed APISpec data, so their inputs
// have already been ASCII-folded by the openapi/graphql parsers (which
// route raw spec strings through naming.ASCIIFold at sanitizeTypeName,
// toCamelCase, etc). Treat any new caller that feeds raw spec strings
// directly into these helpers as a bug — fold first, then shape.
func toCamel(s string) string {
return naming.CamelIdentifier(s)
}
func commandIdent(parts ...string) string {
joined := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimLeft(part, "$")
if part != "" {
joined = append(joined, part)
}
}
return toCamel(strings.Join(joined, "-"))
}
func toPascal(s string) string {
parts := strings.FieldsFunc(s, func(r rune) bool {
return r == '_' || r == '-' || !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
for i, part := range parts {
if part == "" {
continue
}
lower := strings.ToLower(part)
parts[i] = strings.ToUpper(lower[:1]) + lower[1:]
}
result := strings.Join(parts, "")
if len(result) > 0 && !unicode.IsLetter(rune(result[0])) {
result = "V" + result
}
return result
}
func domainUpsertMethodName(tableName string) string {
return "Upsert" + toPascal(tableName)
}
func emitsDomainTable(t TableDef) bool {
return (len(t.Columns) > 3 || t.JSONOnlyFallback) && t.Name != "sync_state" && domainUpsertMethodName(t.Name) != "UpsertBatch"
}
type reconcileTypedTableEntry struct {
Resource string
Table string
}
func reconcileTypedTableEntries(tables []TableDef) []reconcileTypedTableEntry {
byResource := make(map[string]string)
ambiguous := make(map[string]struct{})
for _, table := range tables {
if !emitsDomainTable(table) {
continue
}
resource := strings.ToLower(table.Resource)
if _, skip := ambiguous[resource]; skip {
continue
}
if existing, ok := byResource[resource]; ok && existing != table.Name {
delete(byResource, resource)
ambiguous[resource] = struct{}{}
continue
}
byResource[resource] = table.Name
}
resources := make([]string, 0, len(byResource))
for resource := range byResource {
resources = append(resources, resource)
}
sort.Strings(resources)
entries := make([]reconcileTypedTableEntry, 0, len(resources))
for _, resource := range resources {
entries = append(entries, reconcileTypedTableEntry{Resource: resource, Table: byResource[resource]})
}
return entries
}
// isIDParam returns true if the parameter name suggests it's an identifier
// that should be typed as string regardless of the spec's declared type.
// IDs like steamid (17-digit number) overflow int64, and zero-value confusion
// makes IntVar unsuitable for identifiers.
func isIDParam(name string) bool {
lower := strings.ToLower(name)
return strings.HasSuffix(lower, "id") || strings.HasSuffix(lower, "ids") ||
strings.HasSuffix(lower, "_id") || strings.HasSuffix(lower, "_ids") ||
lower == "steamid" || lower == "steamids"
}
// isCursorParam returns true if the parameter name suggests it's a pagination
// cursor, page, offset, or timestamp that should be typed as string regardless
// of the spec's declared numeric type. Spec'd as `number` or `integer`, these
// values cross into scientific-notation territory (~10^6 and above) when Go's
// default float formatter renders them, which upstream APIs reject as invalid
// integer cursors. String typing handles opaque tokens, numeric strings, and
// integer literals uniformly.
func isCursorParam(name string) bool {
lower := strings.ToLower(name)
switch lower {
case "cursor", "min_cursor", "max_cursor", "next_cursor",
"page", "page_token", "next_page_token",
"page[cursor]", "min_time", "max_time", "offset":
return true
}
return false
}
// isFlagLimitParam returns true when a parameter is the canonical
// pagination/truncation `--limit` flag, which is always count-shaped
// (integer) regardless of whether the spec declares it as `number`.
// LLM-derived specs in `--docs` mode have produced `limit: number`,
// which previously emitted `Float64Var`/`float64 flagLimit` and broke
// `truncateJSONArray(data, flagLimit)` at compile time because the
// helper expects an `int`. Keyed on the same case-insensitive match
// `endpointNeedsClientLimit` uses, so the override fires exactly where
// the truncate caller lives.
func isFlagLimitParam(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), "limit")
}
func primitiveKind(t string) string {
switch strings.ToLower(strings.TrimSpace(t)) {
case "string":
return "string"
case "integer", "int":
return "int"
case "boolean", "bool":
return "bool"
case "number", "float":
return "float"
case "object":
return "object"
case "array":
return "array"
default:
return "string"
}
}
func goType(t string) string {
switch primitiveKind(t) {
case "string":
return "string"
case "int":
return "int"
case "bool":
return "bool"
case "float":
return "float64"
default:
return "string"
}
}
// goStructType returns the Go type for a struct field definition.
// Unlike goType (used for CLI flags which are always primitives),
// this maps object/array types to json.RawMessage for type fidelity.
func goStructType(t string) string {
if ref, ok := strings.CutPrefix(t, "ref:"); ok {
return safeTypeName(ref)
}
if ref, ok := strings.CutPrefix(t, "[]ref:"); ok {
return "[]" + safeTypeName(ref)
}
switch primitiveKind(t) {
case "object", "array":
return "json.RawMessage"
default:
return goType(t)
}
}
func typeFieldJSONTagComment(f spec.TypeField) string {
for _, r := range f.Name {
if r > unicode.MaxASCII {
return f.Name
}
}
return ""
}
func goStoreType(sqlType string) string {
upper := strings.ToUpper(sqlType)
switch {
case strings.HasPrefix(upper, "INTEGER"):
return "int"
case strings.HasPrefix(upper, "REAL"):
return "float64"
case strings.HasPrefix(upper, "JSON"):
return "json.RawMessage"
case strings.HasPrefix(upper, "DATETIME"):
return "string"
default:
return "string"
}
}
func camelToJSON(s string) string {
parts := strings.Split(strings.ToLower(s), "_")
if len(parts) == 0 {
return s
}
for i := 1; i < len(parts); i++ {
if parts[i] == "" {
continue
}
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
}
return strings.Join(parts, "")
}
func writableStoreColumns(cols []ColumnDef) []ColumnDef {
out := make([]ColumnDef, 0, len(cols))
for _, col := range cols {
if col.Generated {
continue
}
out = append(out, col)
}
return out
}
func columnNames(cols []ColumnDef) string {
cols = writableStoreColumns(cols)
names := make([]string, 0, len(cols))
for _, col := range cols {
names = append(names, safeSQLName(col.Name))
}
return strings.Join(names, ", ")
}
func columnPlaceholders(cols []ColumnDef) string {
cols = writableStoreColumns(cols)
if len(cols) == 0 {
return ""
}
placeholders := make([]string, len(cols))
for i := range cols {
placeholders[i] = "?"
}
return strings.Join(placeholders, ", ")
}
func updateSet(cols []ColumnDef) string {
var updates []string
for _, col := range writableStoreColumns(cols) {
if col.PrimaryKey {
continue
}
safe := safeSQLName(col.Name)
updates = append(updates, fmt.Sprintf("%s = excluded.%s", safe, safe))
}
return strings.Join(updates, ", ")
}
func isStoreBackfillColumn(col ColumnDef) bool {
switch col.Name {
case "id", "data", "synced_at":
return false
default:
return !col.PrimaryKey
}
}
func hasStoreBackfillColumns(table TableDef) bool {
return slices.ContainsFunc(table.Columns, isStoreBackfillColumn)
}
func storeBackfillDecl(col ColumnDef) string {
if strings.TrimSpace(col.Type) == "" {
return "TEXT"
}
return col.Type
}
func cobraFlagFunc(t string) string {
switch primitiveKind(t) {
case "string":
return "StringVar"
case "int":
return "IntVar"
case "bool":
return "BoolVar"
case "float":
return "Float64Var"
default:
return "StringVar"
}
}
// mcpBindingFunc returns the mcplib.With* function name used in MCP tool
// input-schema registration so each field binds to its native JSON type and
// passes through the generic makeAPIHandler intact. Funnels through
// primitiveKind so OpenAPI-parsed shapes ("int", "float", "bool") and
// internal-spec literals ("integer", "number", "boolean") produce the same
// binding. Array/object params bind natively (WithArray/WithObject): the
// handler's body path stores the parsed value verbatim and JSON-marshals it,
// so a native []any/map serializes as a real array/object instead of a quoted
// JSON string. The body_json polymorphic (oneOf/anyOf) fallback is hardcoded
// as WithString in the template and is NOT routed through this function.
func mcpBindingFunc(t string) string {
switch primitiveKind(t) {
case "int", "float":
return "WithNumber"
case "bool":
return "WithBoolean"
case "array":
return "WithArray"
case "object":
return "WithObject"
default:
return "WithString"
}
}
// goTypeForParam returns the Go type for a parameter, overriding bool→string
// for required bools without defaults so omitted can be distinguished from an
// explicit false, int→string for ID-like parameters to avoid overflow and zero-value confusion,
// numeric→string for pagination cursors so they survive scientific-notation
// rendering of large Unix timestamps and millisecond cursors, and
// float→int for the canonical `--limit` flag whose semantics are always
// a count.
func goTypeForParam(name, t string) string {
return goTypeForParamRequired(name, t, false, false)
}
func goTypeForParamRequired(name, t string, required bool, hasDefault bool) string {
kind := primitiveKind(t)
if required && !hasDefault && kind == "bool" {
return "string"
}
if isIDParam(name) && kind == "int" {
return "string"
}
if isCursorParam(name) && (kind == "int" || kind == "float") {
return "string"
}
if isFlagLimitParam(name) && kind == "float" {
return "int"
}
return goType(t)
}
// Body fields preserve their declared scalar type in JSON. The identifier,
// cursor, and limit overrides are for URL-facing flags; applying them to a
// JSON body changes the payload type. Required booleans still use a string
// backing value so omitted and explicit false remain distinguishable.
func goTypeForBodyParam(p spec.Param) string {
if isStringBackedBoolParam(p) {
return "string"
}
return goType(p.Type)
}
// cobraFlagFuncForParam returns the cobra flag function, overriding BoolVar→StringVar
// for required bools without defaults, IntVar→StringVar for ID-like parameters,
// Float64Var/IntVar→StringVar for pagination cursors,
// and Float64Var→IntVar for the canonical `--limit` flag.
func cobraFlagFuncForParam(name, t string) string {
return cobraFlagFuncForParamRequired(name, t, false, false)
}
func cobraFlagFuncForParamRequired(name, t string, required bool, hasDefault bool) string {
kind := primitiveKind(t)
if required && !hasDefault && kind == "bool" {
return "StringVar"
}
if isIDParam(name) && kind == "int" {
return "StringVar"
}
if isCursorParam(name) && (kind == "int" || kind == "float") {
return "StringVar"
}
if isFlagLimitParam(name) && kind == "float" {
return "IntVar"
}
return cobraFlagFunc(t)
}
func cobraFlagFuncForBodyParam(p spec.Param) string {
if isStringBackedBoolParam(p) {
return "StringVar"
}
return cobraFlagFunc(p.Type)
}
// defaultValForParam returns the default value for a flag parameter,
// overriding bool→string for required bools without defaults, int→string for
// ID-like parameters, numeric→string for pagination cursors so the StringVar default matches the StringVar field type,
// and float→int for the canonical `--limit` flag so the IntVar default
// matches its coerced int type.
func defaultValForParam(p spec.Param) string {
return defaultValForParamRequired(p, false, false)
}
func defaultValForParamRequired(p spec.Param, required bool, hasDefault bool) string {
kind := primitiveKind(p.Type)
if required && !hasDefault && kind == "bool" {
return `""`
}
if isIDParam(p.Name) && kind == "int" {
if p.Default != nil {
return fmt.Sprintf("%q", fmt.Sprintf("%v", p.Default))
}
return `""`
}
if isCursorParam(p.Name) && (kind == "int" || kind == "float") {
if p.Default != nil {
return fmt.Sprintf("%q", fmt.Sprintf("%v", p.Default))
}
return `""`
}
if isFlagLimitParam(p.Name) && kind == "float" {
coerced := p
coerced.Type = "integer"
return defaultVal(coerced)
}
return defaultVal(p)
}
func defaultValForBodyParam(p spec.Param) string {
if isStringBackedBoolParam(p) {
return `""`
}
return defaultVal(p)
}
func zeroValForParam(name, t string) string {
return zeroValForParamRequired(name, t, false, false)
}
func zeroValForParamRequired(name, t string, required bool, hasDefault bool) string {
kind := primitiveKind(t)
if required && !hasDefault && kind == "bool" {
return `""`
}
if isIDParam(name) && kind == "int" {
return `""`
}
if isCursorParam(name) && (kind == "int" || kind == "float") {
return `""`
}
return zeroVal(t)
}
func zeroValForBodyParam(p spec.Param) string {
if isStringBackedBoolParam(p) {
return `""`
}
return zeroVal(p.Type)
}
func paramHasDefault(p spec.Param) bool {
return p.Default != nil
}
func paramHasEnvDefault(p spec.Param) bool {
return p.GlobalScope && primitiveKind(p.Type) == "string"
}
func globalScopeFallbackValue(p spec.Param) string {
if p.Default == nil {
return ""
}
return fmt.Sprintf("%v", p.Default)
}
func globalScopeEnvName(apiName string, p spec.Param) string {
name := p.PublicInputName()
if name == "" {
name = p.Name
}
placeholder := strings.ToUpper(strings.ReplaceAll(naming.FlagName(name), "-", "_"))
if placeholder == "" {
placeholder = "SCOPE"
}
return naming.EnvPrefix(apiName) + "_" + placeholder
}
// Unwrap follows the profiled sync request so a sibling GET collection
// cannot steal the envelope. Live URLs are ignored so rewritten hosts
// still unwrap.
type responsePathCase struct {
Key string
ResponsePath string
}
type syncRequestRef struct {
Name string
Path string
Method string
}
type resourcePathEntry struct {
Name string
Path string
ResponsePath string
Pagination *spec.Pagination
PageSize int
}
func resourceReadPathEntries(data visionRenderData) []resourcePathEntry {
entries := map[string]resourcePathEntry{}
pathCounts := map[string]int{}
rawPaths := map[string]string{}
for name, resource := range data.Resources {
endpoint, ok := resourceEndpointForMethod(resource, "GET")
if !ok {
continue
}
entries[name] = resourcePathEntry{
Name: name,
Path: effectiveEndpointPath(resource, endpoint),
ResponsePath: endpoint.ResponsePath,
Pagination: endpoint.Pagination,
PageSize: resourcePathPageSize(data, endpoint),
}
rawPaths[name] = endpoint.Path
pathCounts[endpoint.Path]++
}
for name := range entries {
if pathCounts[rawPaths[name]] > 1 {
delete(entries, name)
}
}
return sortedResourcePathEntries(entries)
}
func resourcePathPageSize(data visionRenderData, endpoint spec.Endpoint) int {
pageSize := data.Pagination.DefaultPageSize
if pageSize <= 0 {
pageSize = 100
}
if endpoint.Pagination == nil || endpoint.Pagination.LimitParam == "" {
return pageSize
}
limitParam, ok := endpointParamByName(endpoint, endpoint.Pagination.LimitParam)
if !ok {
return pageSize
}
if defaultPageSize, ok := positiveIntValue(limitParam.Default); ok {
pageSize = defaultPageSize
}
if maxPageSize, ok := paramMaxInt(limitParam); ok && maxPageSize < pageSize {
pageSize = maxPageSize
}
return pageSize
}
func resourceWritePathEntries(data visionRenderData) []resourcePathEntry {
entries := map[string]resourcePathEntry{}
for name, resource := range data.Resources {
endpoint, ok := resourceEndpointForMethod(resource, "POST")
if !ok {
continue
}
entries[name] = resourcePathEntry{Name: name, Path: effectiveEndpointPath(resource, endpoint)}
}
return sortedResourcePathEntries(entries)
}
func resourceDetailPathEntries(data visionRenderData) []resourcePathEntry {
entries := map[string]resourcePathEntry{}
for name, resource := range data.Resources {
endpointNames := make([]string, 0, len(resource.Endpoints))
for endpointName := range resource.Endpoints {
endpointNames = append(endpointNames, endpointName)
}
sort.SliceStable(endpointNames, func(i, j int) bool {
return resourceDetailEndpointRank(endpointNames[i]) < resourceDetailEndpointRank(endpointNames[j])
})
for _, endpointName := range endpointNames {
endpoint := resource.Endpoints[endpointName]
if !strings.EqualFold(endpoint.Method, "GET") || endpoint.Response.Type == "array" || endpoint.Pagination != nil || strings.Count(endpoint.Path, "{") != 1 || strings.Count(endpoint.Path, "}") != 1 {
continue
}
entries[name] = resourcePathEntry{Name: name, Path: effectiveEndpointPath(resource, endpoint)}
break
}
}
return sortedResourcePathEntries(entries)
}
func resourceDetailEndpointRank(name string) string {
preferred := "1"
switch strings.ToLower(name) {
case "get", "read", "retrieve", "show", "detail":
preferred = "0"
}
return preferred + "\x00" + name
}
func sortedResourcePathEntries(entries map[string]resourcePathEntry) []resourcePathEntry {
names := make([]string, 0, len(entries))
for name := range entries {
names = append(names, name)
}
sort.Strings(names)
out := make([]resourcePathEntry, 0, len(names))
for _, name := range names {
out = append(out, entries[name])
}
return out
}
// syncParamDefaultCase is one deduplicated entry for the sync template's
// syncResourceParamDefaults switch. Flat and dependent resource sets can carry
// the same resource name, and a repeated case in a Go switch does not compile.
type syncParamDefaultCase struct {
Resource string
Defaults []profiler.SyncQueryParamDefault
}
// syncParamDefaultCases returns the resources whose list endpoint declares a
// query-param default, flat set first so a name present in both wins the same
// way it does everywhere else sync resolves a resource.
func syncParamDefaultCases(syncable []profiler.SyncableResource, dependents []profiler.DependentResource) []syncParamDefaultCase {
seen := map[string]struct{}{}
var out []syncParamDefaultCase
add := func(name string, defaults []profiler.SyncQueryParamDefault) {
if len(defaults) == 0 {
return
}
if _, dup := seen[name]; dup {
return
}
seen[name] = struct{}{}
out = append(out, syncParamDefaultCase{Resource: name, Defaults: defaults})
}
for _, resource := range syncable {
add(resource.Name, resource.QueryParamDefaults)
}
for _, dependent := range dependents {
add(dependent.Name, dependent.QueryParamDefaults)
}
return out
}
type syncRequiredQueryCase struct {
Resource string
Params []string
}
func syncRequiredQueryCases(syncable []profiler.SyncableResource, dependents []profiler.DependentResource) []syncRequiredQueryCase {
seen := map[string]struct{}{}
var out []syncRequiredQueryCase
add := func(name string, params []string) {
if len(params) == 0 {
return
}
if _, dup := seen[name]; dup {
return
}
seen[name] = struct{}{}
out = append(out, syncRequiredQueryCase{Resource: name, Params: params})
}
for _, resource := range syncable {
add(resource.Name, resource.RequiredQueryParams)
}
for _, dependent := range dependents {
add(dependent.Name, dependent.RequiredQueryParams)
}
return out
}
type syncHiddenHistoryCase struct {
Resource string
Defaults []profiler.SyncQueryParamDefault
}
func syncHiddenHistoryCases(syncable []profiler.SyncableResource, dependents []profiler.DependentResource) []syncHiddenHistoryCase {
seen := map[string]struct{}{}
var out []syncHiddenHistoryCase
add := func(name string, defaults []profiler.SyncQueryParamDefault) {
if len(defaults) == 0 {
return
}
if _, dup := seen[name]; dup {
return
}
seen[name] = struct{}{}
out = append(out, syncHiddenHistoryCase{Resource: name, Defaults: defaults})
}
for _, resource := range syncable {
add(resource.Name, resource.HiddenHistoryDefaults)
}
for _, dependent := range dependents {
add(dependent.Name, dependent.HiddenHistoryDefaults)
}
return out
}
// Profiled Path+Method wins so POST list unwraps its own envelope
// instead of a sibling GET collection. Ranking is the no-profile
// fallback. Live URLs are not the key so rewritten hosts still unwrap.
func responsePathCases(resources map[string]spec.Resource, syncable []profiler.SyncableResource, dependents []profiler.DependentResource) []responsePathCase {
refs := syncRequestRefs(syncable, dependents)
resourceNames := make([]string, 0, len(resources))
for name := range resources {
resourceNames = append(resourceNames, name)
}
sort.Strings(resourceNames)
var out []responsePathCase
for _, resourceName := range resourceNames {
resource := resources[resourceName]
responsePath := resourceSyncResponsePath(resource, resourceName, refs)
if responsePath == "" {
continue
}
out = append(out, responsePathCase{Key: resourceName, ResponsePath: responsePath})
}
return out
}
func resourceSyncResponsePath(resource spec.Resource, resourceName string, refs []syncRequestRef) string {
if endpoint, ok := matchSyncRequestEndpoint(resource, resourceName, refs); ok {
return strings.TrimSpace(endpoint.ResponsePath)
}
if !hasSyncRequest(resourceName, refs) {
if endpoint, ok := resourceEndpointForMethod(resource, "GET"); ok {
if path := strings.TrimSpace(endpoint.ResponsePath); path != "" {
return path
}
}
}
for _, endpointName := range rankedEndpointNames(resource) {
path := strings.TrimSpace(resource.Endpoints[endpointName].ResponsePath)
if path != "" {
return path
}
}
return ""
}
func syncRequestRefs(syncable []profiler.SyncableResource, dependents []profiler.DependentResource) []syncRequestRef {
out := make([]syncRequestRef, 0, len(syncable)+len(dependents))
for _, resource := range syncable {
out = append(out, syncRequestRef{Name: resource.Name, Path: resource.Path, Method: resource.Method})
}
for _, resource := range dependents {
out = append(out, syncRequestRef{Name: resource.Name, Path: resource.Path, Method: resource.Method})
}
return out
}
func hasSyncRequest(resourceName string, refs []syncRequestRef) bool {
for _, ref := range refs {
if syncRequestNameEqual(ref.Name, resourceName) {
return true
}
}
return false
}
func matchSyncRequestEndpoint(resource spec.Resource, resourceName string, refs []syncRequestRef) (spec.Endpoint, bool) {
for _, ref := range refs {
if !syncRequestNameEqual(ref.Name, resourceName) {
continue
}
if endpoint, ok := endpointMatchingSyncRequest(resource, ref.Path, ref.Method); ok {
return endpoint, true
}
}
return spec.Endpoint{}, false
}
func syncRequestNameEqual(left, right string) bool {
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right))
}
func endpointMatchingSyncRequest(resource spec.Resource, path, method string) (spec.Endpoint, bool) {
path = strings.TrimSpace(path)
method = strings.ToUpper(strings.TrimSpace(method))
if path == "" || method == "" {
return spec.Endpoint{}, false
}
for _, name := range rankedEndpointNames(resource) {
endpoint := resource.Endpoints[name]
if !strings.EqualFold(strings.TrimSpace(endpoint.Method), method) {
continue
}
if endpointMatchesSyncPath(resource, endpoint, path) {
return endpoint, true
}
}
return spec.Endpoint{}, false
}
func rankedEndpointNames(resource spec.Resource) []string {
names := make([]string, 0, len(resource.Endpoints))
for name := range resource.Endpoints {
names = append(names, name)
}
sort.SliceStable(names, func(i, j int) bool {
leftName, rightName := names[i], names[j]
leftRank := responsePathEndpointRank(leftName, resource.Endpoints[leftName])
rightRank := responsePathEndpointRank(rightName, resource.Endpoints[rightName])
if leftRank != rightRank {
return leftRank < rightRank
}
return leftName < rightName
})
return names
}
func endpointMatchesSyncPath(resource spec.Resource, endpoint spec.Endpoint, path string) bool {
if strings.TrimSpace(endpoint.Path) == path {
return true
}
return effectiveEndpointPath(resource, endpoint) == path
}
// Lower wins. Syncable still outranks non-syncable; within a band,
// collection-shaped endpoints beat create/update so alphabetical order
// cannot pick create's envelope, and a list* object sibling cannot beat
// the collection sync actually calls.
func responsePathEndpointRank(name string, endpoint spec.Endpoint) int {
rank := 0
if !endpoint.Syncable {
rank += 1000
}
rank += responsePathListShapeRank(name, endpoint)
return rank
}
func responsePathListShapeRank(name string, endpoint spec.Endpoint) int {
lower := strings.ToLower(strings.TrimSpace(name))
switch lower {
case "create", "add", "insert", "update", "patch", "replace", "delete", "remove", "destroy":
return 50
}
if !responsePathLooksLikeCollection(name, endpoint) {
return 10
}
switch lower {
case "list", "all", "index":
return 0
case "search", "query", "browse", "find":
return 2
}
return 3
}
func responsePathLooksLikeCollection(name string, endpoint spec.Endpoint) bool {
method := strings.ToUpper(strings.TrimSpace(endpoint.Method))
if method == "POST" {
return endpoint.Pagination != nil
}
if method != "GET" && method != "HEAD" {
return false
}
if strings.EqualFold(strings.TrimSpace(name), "list") || endpoint.Syncable {
return true
}
return strings.EqualFold(endpoint.Response.Type, "array") || endpoint.Pagination != nil
}
func globalScopeParams(resources map[string]spec.Resource) []spec.Param {
resourceNames := make([]string, 0, len(resources))
for name := range resources {
resourceNames = append(resourceNames, name)
}
sort.Strings(resourceNames)
seen := map[string]struct{}{}
var out []spec.Param
for _, resourceName := range resourceNames {
resource := resources[resourceName]
endpointNames := make([]string, 0, len(resource.Endpoints))
for endpointName := range resource.Endpoints {
endpointNames = append(endpointNames, endpointName)
}
sort.Strings(endpointNames)
for _, endpointName := range endpointNames {
endpoint := resource.Endpoints[endpointName]
for _, param := range endpoint.Params {
if !paramHasEnvDefault(param) {
continue
}
key := param.WireName()
if key == "" {
key = param.Name
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, param)
}
}
}
return out
}
// paramIsConstDefault holds for single-value-enum params whose default
// equals the only enum value. Templates emit MarkHidden for these so
// --help does not list a flag whose only valid value is the default,
// while the flag stays registered so the wire-side default still flows.
// This catches the single-URL routing-selector shape, where an API
// selects an operation via a fixed query param.
func paramIsConstDefault(p spec.Param) bool {
if len(p.Enum) != 1 || p.Default == nil {
return false
}
// Float defaults round-trip ambiguously under any single string format:
// fmt.Sprintf("%v", float64(2.0)) and strconv.FormatFloat with -1
// precision both yield "2", which would miss a heterogeneous spec
// declaring `enum: ["2.0"]` alongside `default: 2.0`. Parse the enum
// element as a float and compare numerically so "2", "2.0", and "2.00"
// are all equivalent to float64(2.0).
switch v := p.Default.(type) {
case float64:
if enumF, err := strconv.ParseFloat(p.Enum[0], 64); err == nil {
return enumF == v
}
return false
case float32:
if enumF, err := strconv.ParseFloat(p.Enum[0], 32); err == nil {
return float32(enumF) == v
}
return false
default:
return fmt.Sprintf("%v", p.Default) == p.Enum[0]
}
}
type jsonFlagSuggestion struct {
FlagName string
Values []string
}
type mcpParamBinding struct {
PublicName string
WireName string
Location string
BodyPath []string
Format string
QueryArray bool
QueryStyle string
QueryExplode bool
DeepObjectQuery bool
RequestContentType string
Default string
}
func flagChangedExpr(p spec.Param) string {
names := append([]string{publicFlagName(p)}, publicFlagAliases(p)...)
parts := make([]string, 0, len(names))
for _, name := range names {
parts = append(parts, fmt.Sprintf("cmd.Flags().Changed(%q)", name))
}
if len(parts) == 1 {
return parts[0]
}
return "(" + strings.Join(parts, " || ") + ")"
}
// flagRequiredUnsatisfiedExpr is the generated required-flag guard condition.
// Changed alone is not enough: a PreRunE or other resolver can write the
// bound value without flipping cobra's Changed bit.
func flagRequiredUnsatisfiedExpr(p spec.Param) string {
zero := zeroValForParamRequired(p.Name, p.Type, p.Required, paramHasDefault(p))
return fmt.Sprintf("!%s && flag%s == %s && !flags.dryRun",
flagChangedExpr(p), toCamel(paramIdent(p)), zero)
}
func mcpParamBindings(endpoint spec.Endpoint, pathTemplate string) []mcpParamBinding {
bindings := make([]mcpParamBinding, 0, len(endpoint.Params)+len(endpoint.Body))
requestContentType := ""
if endpointUsesMultipart(endpoint) || endpointUsesForm(endpoint) {
requestContentType = endpoint.RequestContentType
}
for _, p := range endpoint.Params {
if isMCPPaginationCursorParam(endpoint, p) {
continue
}
declaredLoc := strings.ToLower(strings.TrimSpace(p.In))
loc := declaredLoc
if loc == "" {
loc = "query"
}
if p.PathParam || (strings.Contains(pathTemplate, "{"+p.Name+"}") && (declaredLoc == "" || declaredLoc == "path")) {
loc = "path"
}
wireName := p.WireName()
if loc == "path" || loc == "header" {
wireName = p.Name
}
binding := mcpParamBinding{
PublicName: p.PublicInputName(),
WireName: wireName,
Location: loc,
RequestContentType: requestContentType,
}
// Carry the spec default onto the MCP binding for query params so an
// omitted arg sends the same value the cobra flag would (#2679). Format
// must match the cobra default rendering for CLI/MCP wire parity; keep in
// sync with that path (and cf. pipeline.stringifyParamDefault).
if loc == "query" || loc == "header" {
// deepObject wins over the array marker: an array-typed
// style=deepObject param routes through the indexed-key emitter
// only, never the repeated/joined array path (deliberate).
if loc == "query" && isDeepObjectQueryParam(p) {
binding.DeepObjectQuery = true
} else if isArrayQueryParam(p) {
binding.QueryArray = true
binding.QueryStyle = queryParamStyle(p)
binding.QueryExplode = queryParamExplodes(p)
}
if def, ok := mcpParamDefaultValue(p); ok {
binding.Default = def
}
}
bindings = append(bindings, binding)
}
if endpoint.BodyJSONFallback {
// Single opaque body-json input; the handler parses it as JSON and
// sends it verbatim, mirroring the CLI's --body-json fallback for
// oneOf/anyOf request bodies. Body is empty by parser invariant.
bindings = append(bindings, mcpParamBinding{
PublicName: "body_json",
WireName: "body_json",
Location: "body_json",
})
return bindings
}
appendMCPBodyBindings(&bindings, endpoint, requestContentType)
return bindings
}
func mcpToolInputParams(endpoint spec.Endpoint) []spec.Param {
params := make([]spec.Param, 0, len(endpoint.Params)+len(endpoint.Body))
for _, p := range endpoint.Params {
if isMCPPaginationCursorParam(endpoint, p) {
continue
}
params = append(params, p)
}
if endpoint.BodyJSONFallback {
return params
}
params = append(params, mcpBodyInputParams(endpoint)...)
return params
}
func mcpEndpointPageable(endpoint spec.Endpoint) bool {
if !strings.EqualFold(strings.TrimSpace(endpoint.Method), "GET") {
return false
}
if endpoint.Pagination == nil {
return false
}
if strings.TrimSpace(endpoint.Pagination.CursorParam) == "" {
return false
}
return !endpoint.UsesBinaryResponse() && !endpoint.UsesTextResponse()
}
func mcpPageConfig(endpoint spec.Endpoint) string {
if !mcpEndpointPageable(endpoint) {
return "mcpPageConfig{}"
}
nextCursorPath := endpoint.Pagination.NextCursorPath
paginationType := strings.ToLower(strings.TrimSpace(endpoint.Pagination.Type))
if strings.TrimSpace(nextCursorPath) == "" && paginationType != "offset" && paginationType != "page" {
nextCursorPath = endpoint.Pagination.CursorParam
}
return fmt.Sprintf("mcpPageConfig{CursorParam: %q, NextCursorPath: %q}",
endpoint.Pagination.CursorParam,
nextCursorPath,
)
}
func isMCPPaginationCursorParam(endpoint spec.Endpoint, p spec.Param) bool {
if !mcpEndpointPageable(endpoint) {
return false
}
if p.PathParam || p.Positional || paramIsHeader(p) || !isQueryParamLocation(p) {
return false
}
return p.Name == endpoint.Pagination.CursorParam || p.WireName() == endpoint.Pagination.CursorParam
}
func isQueryParamLocation(p spec.Param) bool {
loc := strings.TrimSpace(p.In)
return loc == "" || strings.EqualFold(loc, "query")
}
func mcpGlobalTemplateInputParams(endpoint spec.Endpoint, pathTemplate string, vars []string) []spec.Param {
if len(vars) == 0 {
return nil
}
known := map[string]struct{}{}
for _, param := range mcpToolInputParams(endpoint) {
known[param.PublicInputName()] = struct{}{}
}
params := make([]spec.Param, 0, len(vars))
for _, name := range vars {
if !spec.PathContainsPlaceholder(pathTemplate, name) {
continue
}
if _, exists := known[name]; exists {
continue
}
params = append(params, spec.Param{
Name: name,
Type: "string",
Description: fmt.Sprintf("Path template value for {%s}; overrides env/config for this MCP call", name),
})
known[name] = struct{}{}
}
return params
}
func mcpGlobalTemplateBindings(endpoint spec.Endpoint, pathTemplate string, vars []string) []mcpParamBinding {
inputs := mcpGlobalTemplateInputParams(endpoint, pathTemplate, vars)
bindings := make([]mcpParamBinding, 0, len(inputs))
for _, param := range inputs {
bindings = append(bindings, mcpParamBinding{
PublicName: param.PublicInputName(),
WireName: param.Name,
Location: "template",
})
}
return bindings
}
func mcpBodyInputParams(endpoint spec.Endpoint) []spec.Param {
if bodyUsesFlatEmission(endpoint) {
return append([]spec.Param(nil), endpoint.Body...)
}
body := flattenCollidingBodyFields(endpoint.Body)
params := make([]spec.Param, 0, len(body))
collectMCPBodyInputParams(&params, body, 0, "", true)
return params
}
func collectMCPBodyInputParams(params *[]spec.Param, body []spec.Param, depth int, flagPrefix string, ancestorsRequired bool) {
for _, p := range body {
if p.Type == "object" && len(p.Fields) > 0 {
if depth+1 >= maxBodyFlagDepth {
continue
}
collectMCPBodyInputParams(params, p.Fields, depth+1, joinFlag(flagPrefix, publicFlagName(p)), ancestorsRequired && p.Required)
continue
}
p.Required = p.Required && ancestorsRequired
if flagPrefix != "" {
p.FlagName = joinFlag(flagPrefix, publicFlagName(p))
p.Aliases = nil
}
*params = append(*params, p)
}
}
func appendMCPBodyBindings(bindings *[]mcpParamBinding, endpoint spec.Endpoint, requestContentType string) {
if bodyUsesFlatEmission(endpoint) {
for _, p := range endpoint.Body {
*bindings = append(*bindings, mcpParamBinding{
PublicName: p.PublicInputName(),
WireName: p.BodyWireName(),
Location: "body",
Format: multipartBindingFormat(endpoint, p),
RequestContentType: requestContentType,
})
}
return
}
collectMCPBodyBindings(bindings, flattenCollidingBodyFields(endpoint.Body), 0, "", nil, requestContentType)
}
func collectMCPBodyBindings(bindings *[]mcpParamBinding, body []spec.Param, depth int, flagPrefix string, bodyPath []string, requestContentType string) {
for _, p := range body {
if p.Type == "object" && len(p.Fields) > 0 {
if depth+1 >= maxBodyFlagDepth {
continue
}
nextPath := append(slices.Clone(bodyPath), p.BodyWireName())
collectMCPBodyBindings(bindings, p.Fields, depth+1, joinFlag(flagPrefix, publicFlagName(p)), nextPath, requestContentType)
continue
}
publicName := p.PublicInputName()
if flagPrefix != "" {
publicName = joinFlag(flagPrefix, publicFlagName(p))
}
binding := mcpParamBinding{
PublicName: publicName,
WireName: p.BodyWireName(),
Location: "body",
RequestContentType: requestContentType,
}
if isJSONOrScalarParam(p) {
binding.Format = "json_or_scalar"
}
if len(bodyPath) > 0 {
binding.BodyPath = append(append([]string(nil), bodyPath...), p.BodyWireName())
}
*bindings = append(*bindings, binding)
}
}
func hasMCPJSONOrScalarBody(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, endpointHasJSONOrScalarBody)
}
func endpointHasJSONOrScalarBody(endpoint spec.Endpoint) bool {
if endpoint.BodyJSONFallback {
return false
}
var walk func([]spec.Param, int) bool
walk = func(params []spec.Param, depth int) bool {
if depth >= maxBodyFlagDepth {
return false
}
for _, p := range params {
if isJSONOrScalarParam(p) {
return true
}
if walk(p.Fields, depth+1) {
return true
}
}
return false
}
return walk(endpoint.Body, 0)
}
func endpointHasMCPNestedBodyPath(endpoint spec.Endpoint) bool {
if endpoint.BodyJSONFallback || bodyUsesFlatEmission(endpoint) {
return false
}
return bodyHasReachableNestedLeaf(flattenCollidingBodyFields(endpoint.Body), 0)
}
func bodyHasReachableNestedLeaf(body []spec.Param, depth int) bool {
for _, p := range body {
if p.Type != "object" || len(p.Fields) == 0 {
continue
}
if depth+1 >= maxBodyFlagDepth {
continue
}
for _, field := range p.Fields {
if field.Type == "object" && len(field.Fields) > 0 {
if bodyHasReachableNestedLeaf([]spec.Param{field}, depth+1) {
return true
}
continue
}
return true
}
}
return false
}
func hasMCPNestedBodyPath(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, endpointHasMCPNestedBodyPath)
}
// mcpParamDefaultValue returns the wire-effective stringified default for a
// param and whether it is effective. A default that stringifies to "" is NOT
// effective: the non-paginated CLI template's zero-value gate (`if flag != ""`)
// skips an empty-string default on the wire, so for CLI/MCP parity the MCP
// binding must skip it too. (Paginated CLI branches emit query params without
// that gate, but the client layer — paginatedGet and client.Get — strips empty
// values before the wire, so parity holds there as well.) Counting/emitting an
// empty default would also leave a dead Default field and fallback block,
// breaking the "default-less CLIs stay byte-identical" goal. The check is on the
// stringified value, so a numeric/bool zero (%v -> "0"/"false") is a real
// default and is kept.
func mcpParamDefaultValue(p spec.Param) (string, bool) {
if p.Default == nil {
return "", false
}
// An array/object param now binds natively (WithArray/WithObject) and its
// live values are JSON-encoded; serialize a composite default the same way
// so an omitted-arg default isn't injected as Go's "%v" rendering
// ("[a b c]" / "map[...]") where the wire expects JSON.
switch primitiveKind(p.Type) {
case "array", "object":
if b, err := json.Marshal(p.Default); err == nil {
s := string(b)
return s, s != "" && s != "null"
}
}
v := fmt.Sprintf("%v", p.Default)
return v, v != ""
}
func endpointHasMCPParamDefault(endpoint spec.Endpoint, pathTemplate string) bool {
for _, p := range endpoint.Params {
if strings.Contains(pathTemplate, "{"+p.Name+"}") {
continue
}
if _, ok := mcpParamDefaultValue(p); ok {
return true
}
}
return false
}
func hasMCPParamDefault(apiSpec *spec.APISpec) bool {
if apiSpec == nil {
return false
}
var walk func(parent spec.Resource, resources map[string]spec.Resource, subResource bool) bool
walk = func(parent spec.Resource, resources map[string]spec.Resource, subResource bool) bool {
for _, resource := range resources {
for _, endpoint := range resource.Endpoints {
pathTemplate := effectiveEndpointPath(resource, endpoint)
if subResource {
pathTemplate = effectiveSubEndpointPath(parent, resource, endpoint)
}
if endpointHasMCPParamDefault(endpoint, pathTemplate) {
return true
}
}
if walk(resource, resource.SubResources, true) {
return true
}
}
return false
}
return walk(spec.Resource{}, apiSpec.Resources, false)
}
func multipartBindingFormat(endpoint spec.Endpoint, p spec.Param) string {
if !endpointUsesMultipart(endpoint) {
return ""
}
return p.Format
}
func mcpInputName(p spec.Param) string {
return p.PublicInputName()
}
// endpointNeedsClientLimit reports whether a list endpoint needs
// client-side response truncation. True when:
// - method is GET (only read endpoints need truncation)
// - the endpoint has a non-positional `limit` param (the user-facing
// --limit flag exists)
// - no Pagination block is declared (the spec author hasn't told us
// the API actually paginates)
//
// When all three conditions hold, the generator emits a
// truncateJSONArray call after the API response returns so --limit N
// is honored even when the API ignores ?limit=N. APIs like Firebase
// and various file-backed JSON endpoints accept the query param
// without applying it server-side; the truncation is harmless when
// the API DID return only N items already (idempotent).
//
// Surfaced by hackernews retro #350 finding F6.
func endpointNeedsClientLimit(endpoint spec.Endpoint) bool {
if !strings.EqualFold(strings.TrimSpace(endpoint.Method), "GET") {
return false
}
if endpoint.Pagination != nil {
return false
}
for _, p := range endpoint.Params {
if p.Positional || p.PathParam {
continue
}
if strings.EqualFold(strings.TrimSpace(p.Name), "limit") {
return true
}
}
return false
}
type clientSideFilter struct {
Param spec.Param
Field string
}
// endpointClientSideFilters reports best-effort response filters for
// docs-derived batch GET endpoints. Docs-derived specs sometimes expose
// a documented query flag that the API accepts but ignores; when the flag name
// matches a scalar response item field (including simple plural-to-singular
// forms like symbols -> symbol), generated commands locally narrow the returned
// JSON so the public flag stays truthful.
func endpointClientSideFilters(apiSpec *spec.APISpec, endpoint spec.Endpoint) []clientSideFilter {
if apiSpec == nil || strings.TrimSpace(apiSpec.SpecSource) != "docs" {
return nil
}
if !strings.EqualFold(strings.TrimSpace(endpoint.Method), "GET") || endpoint.Pagination != nil {
return nil
}
if !strings.EqualFold(strings.TrimSpace(endpoint.Response.Type), "array") {
return nil
}
if !endpointLooksLikeClientFilteredBatch(endpoint) {
return nil
}
if endpoint.Response.Item == "" {
return nil
}
itemType, ok := apiSpec.Types[endpoint.Response.Item]
if !ok || len(itemType.Fields) == 0 {
return nil
}
fieldsByKey := map[string]string{}
for _, field := range itemType.Fields {
if strings.TrimSpace(field.Name) == "" {
continue
}
fieldsByKey[normalizeClientSideFilterKey(field.Name)] = field.Name
}
var filters []clientSideFilter
seenFields := map[string]struct{}{}
for _, param := range endpoint.Params {
if !clientSideFilterParamEligible(param) {
continue
}
for _, candidate := range clientSideFilterFieldCandidates(param) {
field, ok := fieldsByKey[normalizeClientSideFilterKey(candidate)]
if !ok {
continue
}
if _, seen := seenFields[field]; seen {
continue
}
filters = append(filters, clientSideFilter{Param: param, Field: field})
seenFields[field] = struct{}{}
break
}
}
return filters
}
func endpointLooksLikeClientFilteredBatch(endpoint spec.Endpoint) bool {
for _, value := range []string{endpoint.Path, endpoint.Description} {
parts := strings.FieldsFunc(strings.ToLower(value), func(r rune) bool {
return (r < 'a' || r > 'z') && (r < '0' || r > '9')
})
if slices.Contains(parts, "batch") {
return true
}
}
return false
}
func clientSideFilterParamEligible(param spec.Param) bool {
if param.Positional || param.PathParam {
return false
}
if param.Purpose == spec.ParamPurposeFieldSelector {
return false
}
switch strings.ToLower(strings.TrimSpace(param.Type)) {
case "", "string", "string_csv_array":
default:
return false
}
name := normalizeClientSideFilterKey(param.WireName())
switch name {
case "", "limit", "page", "pagesize", "perpage", "offset", "cursor", "after", "before", "next", "sort", "order", "orderby", "fields", "field", "select", "include", "expand", "q", "query", "search":
return false
default:
return true
}
}
func clientSideFilterFieldCandidates(param spec.Param) []string {
names := []string{param.WireName(), param.Name}
var out []string
seen := map[string]struct{}{}
for _, name := range names {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
for _, candidate := range []string{trimmed, singularClientSideFilterName(trimmed)} {
key := normalizeClientSideFilterKey(candidate)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, candidate)
}
}
return out
}
func singularClientSideFilterName(name string) string {
if strings.HasSuffix(name, "ies") && len(name) > len("ies") {
return strings.TrimSuffix(name, "ies") + "y"
}
if strings.HasSuffix(name, "ses") {
if len(name) > len("ses") {
return strings.TrimSuffix(name, "es")
}
return name
}
if strings.HasSuffix(name, "s") && len(name) > 1 {
return strings.TrimSuffix(name, "s")
}
return name
}
func normalizeClientSideFilterKey(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(name)) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
return b.String()
}
// maxBodyFlagDepth caps how many levels of nested-object recursion the
// body-flag emitters expand into per-field Cobra flags. A Param at
// depth 0 is a top-level body field; its object children are at depth 1
// and recurse with depth+1. When the next depth would meet or exceed
// the cap, the object's subtree is skipped uniformly across renderBodyMap,
// renderBodyVarDecls, renderBodyFlagRegs, and renderBodyRequiredChecks.
// The user reaches truncated fields via the existing `--stdin` flag on
// POST/PUT/PATCH commands, which reads the full JSON body from stdin.
//
// Default 3 covers typical CRUD schemas (resource.object.field) without
// the recursive explosion seen on enterprise/ERP specs that self-reference
// through 6+ layers and produce 40k-line command files the Go compiler
// OOMs on.
const maxBodyFlagDepth = 3
// bodyMap renders the per-flag body-building block shared by the
// POST/PUT/PATCH branches in command_endpoint.go.tmpl and the body
// branch in command_promoted.go.tmpl. The four sites generated the
// same Go code at different indentation levels; this consolidates them
// and parameterizes the indent. The output is the body of the
// `bodyMap := map[string]any{}` block — callers emit the surrounding
// declaration (including a resource-root wrap when the schema names one)
// themselves.
//
// When a body Param has Type "object" with non-empty Fields, the block
// recurses: each leaf field becomes its own flag (parent-prefixed in
// the generated identifier so `start.dateTime` and `end.dateTime` do
// not collide), and the parent's wire-side key receives a built-up
// map[string]any rather than a single JSON-string flag. Recursion stops
// at maxBodyFlagDepth; deeper subtrees are only reachable via `--stdin`.
func bodyMap(body []spec.Param, indent string) string {
return bodyMapForVar(body, indent, "body")
}
func bodyMapForVar(body []spec.Param, indent, mapVar string) string {
var b strings.Builder
renderBodyMap(&b, flattenCollidingBodyFields(body), 0, indent, mapVar, "", "")
return b.String()
}
// bodyMapForEndpoint dispatches between the typed-flag body-map renderer
// and the --body-json fallback renderer. Templates call this in place of
// bodyMap so the BodyJSONFallback decision lives in one place.
func bodyMapForEndpoint(endpoint spec.Endpoint, indent string) string {
return bodyMapForEndpointVars(endpoint, indent, "body", "body")
}
func bodyMapForEndpointVars(endpoint spec.Endpoint, indent, mapVar, bodyVar string) string {
if endpoint.BodyJSONFallback {
return bodyJSONFallbackMap(endpoint, indent, bodyVar)
}
if wrapper, ok := bodyResourceWrap(endpoint); ok {
// Fill the inner resource fields into mapVar. The template wraps
// mapVar under the schema's single object key at assignment
// (`body = map[string]any{"issue": bodyMap}`) so we must not
// also nest that key here.
var b strings.Builder
renderBodyMap(&b, wrapper.Fields, 1, indent, mapVar, toCamel(paramIdent(wrapper)), publicFlagName(wrapper))
return b.String()
}
return bodyMapForVar(endpoint.Body, indent, mapVar)
}
// bodyResourceWrap reports the request-body schema's resource-root
// wrapper when the body is a single object property whose value is an
// object (Rails/ActiveModel `{"issue":{...}}`). Flat bodies
// (`{"user_id": N}`) and multi-key objects stay unwrapped.
func bodyResourceWrap(endpoint spec.Endpoint) (spec.Param, bool) {
if endpoint.BodyJSONFallback || endpoint.BodyIsArray || bodyUsesFlatEmission(endpoint) {
return spec.Param{}, false
}
body := flattenCollidingBodyFields(endpoint.Body)
if len(body) != 1 {
return spec.Param{}, false
}
p := body[0]
if p.Type != "object" || len(p.Fields) == 0 {
return spec.Param{}, false
}
return p, true
}
func bodyResourceWrapKey(endpoint spec.Endpoint) string {
if p, ok := bodyResourceWrap(endpoint); ok {
return p.BodyWireName()
}
return ""
}
func assignJSONBodyMap(endpoint spec.Endpoint, mapVar, bodyVar string) string {
if key := bodyResourceWrapKey(endpoint); key != "" {
return fmt.Sprintf("%s = map[string]any{%q: %s}", bodyVar, key, mapVar)
}
return fmt.Sprintf("%s = %s", bodyVar, mapVar)
}
func declareJSONBodyMap(endpoint spec.Endpoint, mapVar, bodyVar string) string {
if key := bodyResourceWrapKey(endpoint); key != "" {
return fmt.Sprintf("var %s any = map[string]any{%q: %s}", bodyVar, key, mapVar)
}
return fmt.Sprintf("var %s any = %s", bodyVar, mapVar)
}
// bodyJSONFallbackMap renders the body-population block used when an
// endpoint's request body schema is a oneOf/anyOf (or otherwise opaque)
// and we expose a single `--body-json` string flag. The caller has
// already initialized the body value; this block conditionally overwrites it
// with a parsed JSON value when the user passed a value.
//
// The fallback defaults to JSON objects because top-level discriminated
// unions in real-world specs (Cloudflare DNS records, Stripe PaymentMethod,
// Notion blocks, Linear filters) are object-shaped; array-root bodies are
// accepted only when the parser marked the endpoint BodyIsArray.
func bodyJSONFallbackMap(endpoint spec.Endpoint, indent, bodyVar string) string {
var b strings.Builder
fmt.Fprintf(&b, "%sif flagBodyJSON != \"\" {\n", indent)
fmt.Fprintf(&b, "%s\tvar parsedBodyJSON any\n", indent)
fmt.Fprintf(&b, "%s\tif err := json.Unmarshal([]byte(flagBodyJSON), &parsedBodyJSON); err != nil {\n", indent)
fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --body-json: %%w\", err)\n", indent)
fmt.Fprintf(&b, "%s\t}\n", indent)
if endpoint.BodyIsArray {
fmt.Fprintf(&b, "%s\tasArray, ok := parsedBodyJSON.([]any)\n", indent)
fmt.Fprintf(&b, "%s\tif !ok {\n", indent)
fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"--body-json must be a JSON array, got JSON %%T\", parsedBodyJSON)\n", indent)
fmt.Fprintf(&b, "%s\t}\n", indent)
fmt.Fprintf(&b, "%s\t%s = asArray\n", indent, bodyVar)
fmt.Fprintf(&b, "%s}\n", indent)
return b.String()
}
fmt.Fprintf(&b, "%s\tasMap, ok := parsedBodyJSON.(map[string]any)\n", indent)
fmt.Fprintf(&b, "%s\tif !ok {\n", indent)
fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"--body-json must be a JSON object, got JSON %%T\", parsedBodyJSON)\n", indent)
fmt.Fprintf(&b, "%s\t}\n", indent)
fmt.Fprintf(&b, "%s\t%s = asMap\n", indent, bodyVar)
fmt.Fprintf(&b, "%s}\n", indent)
return b.String()
}
func renderBodyMap(b *strings.Builder, body []spec.Param, depth int, indent, mapVar, identPrefix, flagPrefix string) {
for _, p := range body {
id := paramIdent(p)
ident := identPrefix + toCamel(id)
flag := joinFlag(flagPrefix, publicFlagName(p))
if p.Type == "object" && len(p.Fields) > 0 {
if depth+1 >= maxBodyFlagDepth {
continue
}
nestedMap := "nested" + ident
fmt.Fprintf(b, "%s{\n", indent)
fmt.Fprintf(b, "%s\t%s := map[string]any{}\n", indent, nestedMap)
renderBodyMap(b, p.Fields, depth+1, indent+"\t", nestedMap, ident, flag)
fmt.Fprintf(b, "%s\tif len(%s) > 0 {\n", indent, nestedMap)
fmt.Fprintf(b, "%s\t\t%s[%q] = %s\n", indent, mapVar, p.BodyWireName(), nestedMap)
fmt.Fprintf(b, "%s\t}\n", indent)
fmt.Fprintf(b, "%s}\n", indent)
continue
}
if isStringCSVArrayParam(p) {
fmt.Fprintf(b, "%sif cmd.Flags().Changed(%q) {\n", indent, flag)
if strings.EqualFold(strings.TrimSpace(p.ItemType), "object") {
fmt.Fprintf(b, "%s\t%s[%q] = %s\n", indent, mapVar, p.BodyWireName(), csvArrayValueExpr(p, "body"+ident))
} else {
fmt.Fprintf(b, "%s\tparsed%s, parseErr := %s\n", indent, ident, csvArrayValueExpr(p, "body"+ident))
fmt.Fprintf(b, "%s\tif parseErr != nil {\n", indent)
fmt.Fprintf(b, "%s\t\treturn fmt.Errorf(\"parsing --%s list: %%w\", parseErr)\n", indent, flag)
fmt.Fprintf(b, "%s\t}\n", indent)
fmt.Fprintf(b, "%s\t%s[%q] = parsed%s\n", indent, mapVar, p.BodyWireName(), ident)
}
fmt.Fprintf(b, "%s}\n", indent)
continue
}
if isJSONOrScalarParam(p) {
fmt.Fprintf(b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(b, "%s\tif looksLikeJSONComposite(body%s) {\n", indent, ident)
fmt.Fprintf(b, "%s\t\tvar parsed%s any\n", indent, ident)
fmt.Fprintf(b, "%s\t\tif err := json.Unmarshal([]byte(body%s), &parsed%s); err != nil {\n", indent, ident, ident)
fmt.Fprintf(b, "%s\t\t\treturn fmt.Errorf(\"parsing --%s JSON: %%w\", err)\n", indent, flag)
fmt.Fprintf(b, "%s\t\t}\n", indent)
fmt.Fprintf(b, "%s\t\t%s[%q] = parsed%s\n", indent, mapVar, p.BodyWireName(), ident)
fmt.Fprintf(b, "%s\t} else {\n", indent)
fmt.Fprintf(b, "%s\t\t%s[%q] = body%s\n", indent, mapVar, p.BodyWireName(), ident)
fmt.Fprintf(b, "%s\t}\n", indent)
fmt.Fprintf(b, "%s}\n", indent)
continue
}
isComplex := p.Type == "object" || p.Type == "array"
if isComplex || isJSONStringParam(p) {
// object/array: store the parsed value (so the API receives
// real JSON) after checking its top-level shape.
// jsonStringParam: parse and store the decoded value too. These
// params are string-typed only because the spec author described
// the *flag input* as JSON ("as JSON", "JSON object of ...");
// JSON-body APIs expect the real object/array on the wire, and
// sending the raw flag bytes double-encodes the field (live-hit
// on two printed CLIs: Bird CRM returned 422 on contact create,
// Title Toolbox's backend expects a criteria object). Only
// params that explicitly declare an encoded-string wire format
// (isEncodedJSONStringParam) keep the user's exact bytes.
rhs := "parsed" + ident
if isEncodedJSONStringParam(p) {
rhs = "body" + ident
}
fmt.Fprintf(b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(b, "%s\tvar parsed%s any\n", indent, ident)
fmt.Fprintf(b, "%s\tif err := json.Unmarshal([]byte(body%s), &parsed%s); err != nil {\n", indent, ident, ident)
fmt.Fprintf(b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: %%w\", err)\n", indent, flag)
fmt.Fprintf(b, "%s\t}\n", indent)
if isComplex {
shape, valueVar, valueType := "object", "asMap", "map[string]any"
if p.Type == "array" {
shape, valueVar, valueType = "array", "asArray", "[]any"
}
fmt.Fprintf(b, "%s\t%s, ok := parsed%s.(%s)\n", indent, valueVar, ident, valueType)
fmt.Fprintf(b, "%s\tif !ok {\n", indent)
fmt.Fprintf(b, "%s\t\treturn fmt.Errorf(\"--%s must be a JSON %s, got JSON %%T\", parsed%s)\n", indent, flag, shape, ident)
fmt.Fprintf(b, "%s\t}\n", indent)
rhs = valueVar
}
fmt.Fprintf(b, "%s\t%s[%q] = %s\n", indent, mapVar, p.BodyWireName(), rhs)
fmt.Fprintf(b, "%s}\n", indent)
continue
}
if (p.Type == "boolean" || p.Type == "bool") && (!p.Required || p.Default != nil) {
// Booleans gate on cmd.Flags().Changed instead of a zero-guard.
// The zero-guard (body != false) drops user-set false values,
// letting the server's default (often true) silently invert
// intent. Unconditionally emitting flips the bug: PATCH bodies
// would carry "field: false" for every untouched flag and
// overwrite server state. Changed distinguishes "user set
// false" from "user did not touch the flag" and is correct
// for POST, PUT, and PATCH. Internal YAML specs use "boolean";
// the OpenAPI parser normalizes to "bool".
fmt.Fprintf(b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(b, "%s\t%s[%q] = body%s\n", indent, mapVar, p.BodyWireName(), ident)
fmt.Fprintf(b, "%s}\n", indent)
continue
}
fmt.Fprintf(b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
if isStringBackedBoolParam(p) {
fmt.Fprintf(b, "%s\tparsed%s, err := strconv.ParseBool(body%s)\n", indent, ident, ident)
fmt.Fprintf(b, "%s\tif err != nil {\n", indent)
fmt.Fprintf(b, "%s\t\treturn fmt.Errorf(\"parsing --%s as bool: %%w\", err)\n", indent, flag)
fmt.Fprintf(b, "%s\t}\n", indent)
fmt.Fprintf(b, "%s\t%s[%q] = parsed%s\n", indent, mapVar, p.BodyWireName(), ident)
} else {
fmt.Fprintf(b, "%s\t%s[%q] = body%s\n", indent, mapVar, p.BodyWireName(), ident)
}
fmt.Fprintf(b, "%s}\n", indent)
}
}
func bodyLeafPresenceExpr(p spec.Param, ident, flag string) string {
changed := fmt.Sprintf("cmd.Flags().Changed(%q)", flag)
if flag == publicFlagName(p) {
changed = flagChangedExpr(p)
}
if (p.Type == "boolean" || p.Type == "bool") && (!p.Required || p.Default != nil) {
return changed
}
return fmt.Sprintf("(%s || body%s != %s)", changed, ident, zeroValForBodyParam(p))
}
func bodyHasStringBackedBool(endpoint spec.Endpoint) bool {
if endpoint.BodyJSONFallback || bodyUsesFlatEmission(endpoint) {
return false
}
var walk func([]spec.Param, int) bool
walk = func(params []spec.Param, depth int) bool {
for _, p := range params {
if p.Type == "object" && len(p.Fields) > 0 {
if depth+1 >= maxBodyFlagDepth {
continue
}
if walk(p.Fields, depth+1) {
return true
}
continue
}
if isStringBackedBoolParam(p) {
return true
}
}
return false
}
return walk(flattenCollidingBodyFields(endpoint.Body), 0)
}
func isStringBackedBoolParam(p spec.Param) bool {
return p.Required && p.Default == nil && primitiveKind(p.Type) == "bool"
}
// bodyVarDecls renders Go var declarations for body construction. For
// JSON-body endpoints, nested-object params (Type "object" with non-empty
// Fields) recurse: each leaf field becomes its own declaration with a
// parent-prefixed identifier. For multipart and form-encoded endpoints,
// the body path stays flat (one var per top-level param) because
// multipartBodyMaps and formBodyMaps continue to send object-typed
// parents as JSON-string fields. Output starts with "\n\tvar ..."
// matching the one-tab indent of the original `{{- range .Endpoint.Body}}`
// template loop.
//
// When endpoint.BodyJSONFallback is set (oneOf/anyOf body schema), a single
// `flagBodyJSON` string is declared instead of per-field flags.
func bodyVarDecls(endpoint spec.Endpoint) string {
var b strings.Builder
if endpoint.BodyJSONFallback {
b.WriteString("\n\tvar flagBodyJSON string")
return b.String()
}
if bodyUsesFlatEmission(endpoint) {
for _, p := range endpoint.Body {
fmt.Fprintf(&b, "\n\tvar body%s %s", toCamel(paramIdent(p)), goTypeForBodyParam(p))
}
return b.String()
}
renderBodyVarDecls(&b, flattenCollidingBodyFields(endpoint.Body), 0, "")
return b.String()
}
// bodyUsesFlatEmission reports whether the endpoint serializes its body
// via a non-JSON-map path (multipart/form-data or
// application/x-www-form-urlencoded). Those paths keep one var/flag per
// top-level body param so multipartBodyMaps / formBodyMaps still find
// the parent variable to read from.
func bodyUsesFlatEmission(endpoint spec.Endpoint) bool {
return endpointUsesMultipart(endpoint) || endpointUsesForm(endpoint)
}
func renderBodyVarDecls(b *strings.Builder, body []spec.Param, depth int, identPrefix string) {
for _, p := range body {
ident := identPrefix + toCamel(paramIdent(p))
if p.Type == "object" && len(p.Fields) > 0 {
if depth+1 >= maxBodyFlagDepth {
continue
}
renderBodyVarDecls(b, p.Fields, depth+1, ident)
continue
}
fmt.Fprintf(b, "\n\tvar body%s %s", ident, goTypeForBodyParam(p))
}
}
// bodyFlagRegs renders cobra flag registrations for body construction.
// Multipart endpoints keep flat (one flag per top-level body param) so
// the JSON-string fallback that multipartBodyMaps emits stays addressable.
// For non-multipart endpoints, nested-object params recurse with
// parent-prefixed flag names so two parents that share a field name do
// not collide. Aliases are emitted only at the top level.
func bodyFlagRegs(endpoint spec.Endpoint) string {
var b strings.Builder
if endpoint.BodyJSONFallback {
bodyShape := "object"
if endpoint.BodyIsArray {
bodyShape = "array"
}
fmt.Fprintf(&b, "\n\tcmd.Flags().StringVar(&flagBodyJSON, \"body-json\", \"\", \"Provide the full request body as a JSON %s string (this endpoint accepts a polymorphic schema: oneOf/anyOf)\")", bodyShape)
return b.String()
}
if bodyUsesFlatEmission(endpoint) {
for _, p := range endpoint.Body {
renderFlatBodyFlagReg(&b, p, "", "", true)
}
return b.String()
}
renderBodyFlagRegs(&b, flattenCollidingBodyFields(endpoint.Body), 0, "", "", true)
return b.String()
}
func renderBodyFlagRegs(b *strings.Builder, body []spec.Param, depth int, identPrefix, flagPrefix string, topLevel bool) {
for _, p := range body {
if p.Type == "object" && len(p.Fields) > 0 {
if depth+1 >= maxBodyFlagDepth {
continue
}
ident := identPrefix + toCamel(paramIdent(p))
flag := joinFlag(flagPrefix, publicFlagName(p))
renderBodyFlagRegs(b, p.Fields, depth+1, ident, flag, false)
continue
}
renderFlatBodyFlagReg(b, p, identPrefix, flagPrefix, topLevel)
}
}
// renderFlatBodyFlagReg emits cobra flag registrations for a single body
// param. Const-default detection via paramIsConstDefault is intentionally
// not wired through here yet: the primary use case is the query-param
// routing-selector shape, not body fields. If a future API surfaces a
// const-default body field, extend the emission here with a MarkHidden
// line gated by paramIsConstDefault, mirroring the command_endpoint and
// command_promoted templates.
func renderFlatBodyFlagReg(b *strings.Builder, p spec.Param, identPrefix, flagPrefix string, topLevel bool) {
ident := identPrefix + toCamel(paramIdent(p))
flag := joinFlag(flagPrefix, publicFlagName(p))
desc := naming.OneLine(p.Description)
fmt.Fprintf(b, "\n\tcmd.Flags().%s(&body%s, \"%s\", %s, \"%s\")",
cobraFlagFuncForBodyParam(p), ident, flag, defaultValForBodyParam(p), desc)
if topLevel {
for _, alias := range publicFlagAliases(p) {
fmt.Fprintf(b, "\n\tcmd.Flags().%s(&body%s, \"%s\", %s, \"%s\")",
cobraFlagFuncForBodyParam(p), ident, alias, defaultValForBodyParam(p), desc)
fmt.Fprintf(b, "\n\t_ = cmd.Flags().MarkHidden(\"%s\")", alias)
}
}
}
// bodyRequiredChecks renders required-flag validation for body params.
// indent is the indent prefix applied to each emitted `if` line so the
// helper can serve both command_endpoint.go.tmpl (4-tab indent inside
// `if !stdinBody`, 3-tab indent for multipart) and command_promoted.go.tmpl
// (3-tab indent at RunE-body level). Multipart endpoints keep flat
// behavior. For non-multipart, top-level params use flagChangedExpr
// (lifts aliases); nested fields use a single Changed() check on the
// parent-prefixed flag because aliases are not propagated to children.
// Required fields below an optional object are checked only when any flag
// in that object was supplied, matching JSON Schema's conditional presence
// semantics for nested required lists.
func bodyRequiredChecks(endpoint spec.Endpoint, indent string) string {
var b strings.Builder
if endpoint.BodyJSONFallback {
if endpoint.BodyRequired {
fmt.Fprintf(&b, "\n%sif !cmd.Flags().Changed(\"body-json\") && flagBodyJSON == \"\" && !flags.dryRun {", indent)
fmt.Fprintf(&b, "\n%s\treturn fmt.Errorf(\"required flag \\\"%%s\\\" not set\", \"body-json\")", indent)
fmt.Fprintf(&b, "\n%s}", indent)
}
return b.String()
}
if bodyUsesFlatEmission(endpoint) {
for _, p := range endpoint.Body {
renderFlatBodyRequiredCheck(&b, p, indent, "", "", true)
}
return b.String()
}
renderBodyRequiredChecks(&b, flattenCollidingBodyFields(endpoint.Body), 0, indent, "", "", true)
return b.String()
}
func renderBodyRequiredChecks(b *strings.Builder, body []spec.Param, depth int, indent, flagPrefix, identPrefix string, topLevel bool) {
for _, p := range body {
if p.Type == "object" && len(p.Fields) > 0 {
if depth+1 >= maxBodyFlagDepth {
continue
}
flag := joinFlag(flagPrefix, publicFlagName(p))
ident := identPrefix + toCamel(paramIdent(p))
if p.Required {
renderBodyRequiredChecks(b, p.Fields, depth+1, indent, flag, ident, false)
continue
}
var nested strings.Builder
renderBodyRequiredChecks(&nested, p.Fields, depth+1, indent+"\t", flag, ident, false)
if nested.Len() == 0 {
continue
}
changedExpr := bodyFieldsChangedExpr(p.Fields, depth+1, flag, ident)
if changedExpr == "" {
continue
}
fmt.Fprintf(b, "\n%sif %s {", indent, changedExpr)
b.WriteString(nested.String())
fmt.Fprintf(b, "\n%s}", indent)
continue
}
renderFlatBodyRequiredCheck(b, p, indent, flagPrefix, identPrefix, topLevel)
}
}
func bodyFieldsChangedExpr(body []spec.Param, depth int, flagPrefix, identPrefix string) string {
var expressions []string
for _, p := range body {
flag := joinFlag(flagPrefix, publicFlagName(p))
ident := identPrefix + toCamel(paramIdent(p))
if p.Type == "object" && len(p.Fields) > 0 {
if depth+1 >= maxBodyFlagDepth {
continue
}
if nested := bodyFieldsChangedExpr(p.Fields, depth+1, flag, ident); nested != "" {
expressions = append(expressions, nested)
}
continue
}
expressions = append(expressions, bodyLeafPresenceExpr(p, ident, flag))
}
return strings.Join(expressions, " || ")
}
// bodyExceedsFlagDepth reports whether emitting per-field body flags for
// the endpoint would have truncated any nested-object subtree under
// maxBodyFlagDepth. Multipart/form endpoints stay flat and never
// truncate; BodyJSONFallback endpoints route through a single
// --body-json flag and never reach the per-field path.
//
// The walk uses flattenCollidingBodyFields because that is what the
// emitters render. Collision-flattening clears `Fields` on an object
// whose dot-flattened subtree would clash with a sibling identifier,
// turning it into a JSON-string leaf the user passes as a single flag.
// Walking the raw body would falsely report truncation in that case
// and rewrite the --stdin help text even when every field is exposed.
func bodyExceedsFlagDepth(endpoint spec.Endpoint) bool {
if endpoint.BodyJSONFallback || bodyUsesFlatEmission(endpoint) {
return false
}
return walkBodyExceedsDepth(flattenCollidingBodyFields(endpoint.Body), 0)
}
// walkBodyExceedsDepth returns true as soon as any nested-object subtree
// at depth >= maxBodyFlagDepth-1 is found. The walk is bounded by the
// same depth check the emitters use, so a Param graph that
// intentionally self-references (cyclic spec) does not loop here.
func walkBodyExceedsDepth(body []spec.Param, depth int) bool {
for _, p := range body {
if p.Type != "object" || len(p.Fields) == 0 {
continue
}
if depth+1 >= maxBodyFlagDepth {
return true
}
if walkBodyExceedsDepth(p.Fields, depth+1) {
return true
}
}
return false
}
func renderFlatBodyRequiredCheck(b *strings.Builder, p spec.Param, indent, flagPrefix, identPrefix string, topLevel bool) {
if !p.Required || p.Default != nil {
return
}
flag := joinFlag(flagPrefix, publicFlagName(p))
ident := identPrefix + toCamel(paramIdent(p))
var changedExpr string
if topLevel {
changedExpr = flagChangedExpr(p)
} else {
changedExpr = fmt.Sprintf("cmd.Flags().Changed(%q)", flag)
}
fmt.Fprintf(b, "\n%sif !%s && body%s == %s && !flags.dryRun {", indent, changedExpr, ident, zeroValForBodyParam(p))
fmt.Fprintf(b, "\n%s\treturn fmt.Errorf(\"required flag \\\"%%s\\\" not set\", \"%s\")", indent, flag)
fmt.Fprintf(b, "\n%s}", indent)
}
func joinFlag(prefix, name string) string {
return naming.JoinFlag(prefix, name)
}
func multipartBodyMaps(body []spec.Param, indent string) string {
var b strings.Builder
for _, p := range body {
id := paramIdent(p)
ident := toCamel(id)
flag := publicFlagName(p)
if isComplexBodyField(p) || isJSONStringParam(p) {
fmt.Fprintf(&b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(&b, "%s\tif !json.Valid([]byte(body%s)) {\n", indent, ident)
fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: invalid JSON\")\n", indent, flag)
fmt.Fprintf(&b, "%s\t}\n", indent)
fmt.Fprintf(&b, "%s\tfields[%q] = body%s\n", indent, p.BodyWireName(), ident)
fmt.Fprintf(&b, "%s}\n", indent)
continue
}
if isBinaryParam(p) {
fmt.Fprintf(&b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(&b, "%s\tfileFields[%q] = body%s\n", indent, p.BodyWireName(), ident)
fmt.Fprintf(&b, "%s}\n", indent)
continue
}
if p.Type == "string" {
fmt.Fprintf(&b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(&b, "%s\tfields[%q] = body%s\n", indent, p.BodyWireName(), ident)
fmt.Fprintf(&b, "%s}\n", indent)
continue
}
fmt.Fprintf(&b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(&b, "%s\tfields[%q] = fmt.Sprintf(\"%%v\", body%s)\n", indent, p.BodyWireName(), ident)
fmt.Fprintf(&b, "%s}\n", indent)
}
return b.String()
}
func paramIsHeader(p spec.Param) bool {
return strings.EqualFold(strings.TrimSpace(p.In), "header")
}
func paramPresenceExpr(p spec.Param) string {
if primitiveKind(p.Type) == "int" && (p.Required || paramHasDefault(p)) {
return "true"
}
return fmt.Sprintf("(%s || flag%s != %s)", flagChangedExpr(p), toCamel(paramIdent(p)), zeroValForParamRequired(p.Name, p.Type, p.Required, paramHasDefault(p)))
}
func readParamPresenceExpr(p spec.Param) string {
if primitiveKind(p.Type) == "int" && (p.Required || paramHasDefault(p)) {
return "true"
}
return fmt.Sprintf("flag%s != %s", toCamel(paramIdent(p)), zeroValForParamRequired(p.Name, p.Type, p.Required, paramHasDefault(p)))
}
func endpointHasHeaderParams(endpoint spec.Endpoint) bool {
return slices.ContainsFunc(endpoint.Params, paramIsHeader)
}
// endpointHasQueryFlags reports whether the endpoint declares any non-positional,
// non-path parameters — i.e., flags that should be encoded as URL query string.
// True for any HTTP method. Used by the non-GET handler template to decide
// whether to build a params map and route through the *WithParams client
// variant, so query-shaped params on POST/PUT/DELETE/PATCH reach the URL
// instead of being silently dropped into the JSON body or omitted.
func endpointHasQueryFlags(endpoint spec.Endpoint) bool {
for _, p := range endpoint.Params {
if !p.Positional && !p.PathParam && !paramIsHeader(p) {
return true
}
}
return false
}
// endpointHasRequiredInput reports whether a bare invocation of the generated
// command (no flags, no args) would fail a required-input check before
// reaching the request: a required non-positional flag or a required body
// field. It gates the empty-invocation help short-circuit so read commands
// with only optional filters still execute on a bare call instead of printing
// help. Both halves mirror exactly when the template emits a required check:
// the flag half uses template.IsTrue to match the template's `(not .Default)`
// gate (so a required flag carrying a non-empty default — which the template
// lets satisfy itself — does not trigger the guard, just as it emits no
// required-flag error), and the body half reuses bodyRequiredChecks, gated on
// the body-bearing verbs the command template actually emits the body check
// for (POST/PUT/PATCH/DELETE) so a GET that happens to declare a required body
// param does not falsely trip the guard.
func endpointHasRequiredInput(endpoint spec.Endpoint) bool {
for _, p := range endpoint.Params {
if paramHasEnvDefault(p) {
continue
}
if p.Required && !p.Positional {
if truth, _ := template.IsTrue(p.Default); !truth {
return true
}
}
}
switch strings.ToUpper(endpoint.Method) {
case "POST", "PUT", "PATCH", "DELETE":
return strings.TrimSpace(bodyRequiredChecks(endpoint, "")) != ""
}
return false
}
// endpointSkipsErrorPathProbe reports whether live dogfood's synthesized
// "__printing_press_invalid__" argument is not a meaningful invalid input for
// this read command. Free-form string lookups and searches commonly return
// HTTP 200 plus empty results, so the generator emits pp:no-error-path-probe
// only when the command has exactly one required positional request parameter
// and no locally validated required input surface.
func endpointSkipsErrorPathProbe(endpoint spec.Endpoint) bool {
switch strings.ToUpper(strings.TrimSpace(endpoint.Method)) {
case "GET", "HEAD":
default:
return false
}
if endpointHasRequiredInput(endpoint) {
return false
}
var requestPositionals []spec.Param
for _, p := range orderedPositionalParams(endpoint) {
if p.PathParam || strings.Contains(endpoint.Path, "{"+p.Name+"}") {
return false
}
requestPositionals = append(requestPositionals, p)
}
if len(requestPositionals) != 1 {
return false
}
p := requestPositionals[0]
return p.Required && freeTextStringParam(p)
}
func freeTextStringParam(p spec.Param) bool {
if p.Type != "" && !strings.EqualFold(strings.TrimSpace(p.Type), "string") {
return false
}
if len(p.Enum) > 0 {
return false
}
if strings.TrimSpace(p.Format) != "" {
return false
}
if isJSONStringParam(p) {
return false
}
return true
}
// endpointHasRequestParams reports whether the endpoint passes any values in
// the client request's params map: query flags plus positional values not
// consumed by the URL path.
func endpointHasRequestParams(endpoint spec.Endpoint) bool {
for _, p := range endpoint.Params {
if !p.PathParam && !paramIsHeader(p) {
return true
}
}
return false
}
func endpointUsesMultipart(endpoint spec.Endpoint) bool {
return strings.EqualFold(strings.TrimSpace(endpoint.RequestContentType), "multipart/form-data")
}
func hasMultipartRequest(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, endpointUsesMultipart)
}
func endpointUsesForm(endpoint spec.Endpoint) bool {
return strings.EqualFold(strings.TrimSpace(endpoint.RequestContentType), "application/x-www-form-urlencoded")
}
func hasFormRequest(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, endpointUsesForm)
}
func endpointUsesCSVArray(endpoint spec.Endpoint) bool {
if endpointUsesMultipart(endpoint) || endpointUsesForm(endpoint) {
return false
}
var walk func([]spec.Param, int) bool
walk = func(params []spec.Param, depth int) bool {
if depth >= maxBodyFlagDepth {
return false
}
for _, p := range params {
if isStringCSVArrayParam(p) {
return true
}
if walk(p.Fields, depth+1) {
return true
}
}
return false
}
return walk(endpoint.Body, 0)
}
func hasCSVArrayRequest(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, endpointUsesCSVArray)
}
func endpointUsesCSVResponse(endpoint spec.Endpoint) bool {
return endpoint.UsesCSVResponse()
}
func hasCSVResponse(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, endpointUsesCSVResponse)
}
func endpointUsesBodyJSONFallback(endpoint spec.Endpoint) bool {
return endpoint.BodyJSONFallback
}
func hasBodyJSONFallback(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, endpointUsesBodyJSONFallback)
}
func endpointUsesRawRequest(endpoint spec.Endpoint) bool {
return endpoint.UsesRawRequestBody()
}
func hasRawRequest(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, endpointUsesRawRequest)
}
func anyEndpointMatches(apiSpec *spec.APISpec, predicate func(spec.Endpoint) bool) bool {
if apiSpec == nil {
return false
}
var walk func(resources map[string]spec.Resource) bool
walk = func(resources map[string]spec.Resource) bool {
for _, resource := range resources {
for _, endpoint := range resource.Endpoints {
if predicate(endpoint) {
return true
}
}
if walk(resource.SubResources) {
return true
}
}
return false
}
return walk(apiSpec.Resources)
}
// findEndpointMatch returns the first endpoint for which predicate is true,
// walking resources and sub-resources depth-first. Resource and endpoint
// names are iterated in sorted order so callers that bake the returned
// endpoint's path into generated output stay deterministic across runs.
func findEndpointMatch(apiSpec *spec.APISpec, predicate func(spec.Endpoint) bool) (spec.Endpoint, bool) {
if apiSpec == nil {
return spec.Endpoint{}, false
}
var walk func(resources map[string]spec.Resource) (spec.Endpoint, bool)
walk = func(resources map[string]spec.Resource) (spec.Endpoint, bool) {
for _, rName := range sortedKeys(resources) {
resource := resources[rName]
for _, eName := range sortedKeys(resource.Endpoints) {
endpoint := resource.Endpoints[eName]
if predicate(endpoint) {
return endpoint, true
}
}
if e, ok := walk(resource.SubResources); ok {
return e, ok
}
}
return spec.Endpoint{}, false
}
return walk(apiSpec.Resources)
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// formBodyMaps renders per-flag form-field assignments for endpoints that send
// application/x-www-form-urlencoded request bodies. Object/array/JSON-string
// fields are validated as JSON then sent as a single string field (matching
// the JSON-shaped struct_data convention used by reverse-engineered APIs);
// scalar fields are formatted with %v.
func formBodyMaps(body []spec.Param, indent string) string {
var b strings.Builder
for _, p := range body {
id := paramIdent(p)
ident := toCamel(id)
flag := publicFlagName(p)
if isComplexBodyField(p) || isJSONStringParam(p) {
fmt.Fprintf(&b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(&b, "%s\tif !json.Valid([]byte(body%s)) {\n", indent, ident)
fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: invalid JSON\")\n", indent, flag)
fmt.Fprintf(&b, "%s\t}\n", indent)
fmt.Fprintf(&b, "%s\tfields.Set(%q, body%s)\n", indent, p.BodyWireName(), ident)
fmt.Fprintf(&b, "%s}\n", indent)
continue
}
if p.Type == "string" {
fmt.Fprintf(&b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(&b, "%s\tfields.Set(%q, body%s)\n", indent, p.BodyWireName(), ident)
fmt.Fprintf(&b, "%s}\n", indent)
continue
}
fmt.Fprintf(&b, "%sif %s {\n", indent, bodyLeafPresenceExpr(p, ident, flag))
fmt.Fprintf(&b, "%s\tfields.Set(%q, fmt.Sprintf(\"%%v\", body%s))\n", indent, p.BodyWireName(), ident)
fmt.Fprintf(&b, "%s}\n", indent)
}
return b.String()
}
func isBinaryParam(p spec.Param) bool {
return strings.EqualFold(strings.TrimSpace(p.Format), "binary")
}
func isComplexBodyField(p spec.Param) bool {
return p.Type == "object" || p.Type == "array"
}
func isJSONStringParam(p spec.Param) bool {
if p.Type != "" && !strings.EqualFold(strings.TrimSpace(p.Type), "string") {
return false
}
format := strings.ToLower(strings.TrimSpace(p.Format))
switch format {
case "json", "application/json",
"json-string", "json_string", "jsonstring", "json-encoded", "json_encoded":
return true
}
description := strings.TrimSpace(p.Description)
if strings.HasPrefix(description, "{") || strings.HasPrefix(description, "[") {
return true
}
lowerDescription := strings.ToLower(description)
jsonDescriptionMarkers := []string{
"as json",
"json:",
"json object",
"json array",
"json value",
"valid json",
"json-encoded",
"json encoded",
"json-formatted",
"json formatted",
"serialized json",
"stringified json",
}
for _, marker := range jsonDescriptionMarkers {
if strings.Contains(lowerDescription, marker) {
return true
}
}
return false
}
// isEncodedJSONStringParam reports whether a JSON-string param explicitly
// declares that the wire field carries a JSON-*encoded string* (double-encoded),
// so the generated command must send the user's raw flag bytes instead of the
// decoded value. This is the exception, not the default: description phrases
// like "as JSON" or "JSON object of ..." describe the flag's input format and
// map to the decoded value; only wording that names an encoded/serialized
// string wire type ("JSON-encoded string", "serialized JSON string",
// "stringified JSON") or an explicit encoded-string format keeps raw bytes.
func isEncodedJSONStringParam(p spec.Param) bool {
if !isJSONStringParam(p) {
return false
}
switch strings.ToLower(strings.TrimSpace(p.Format)) {
case "json-string", "json_string", "jsonstring", "json-encoded", "json_encoded":
return true
}
lowerDescription := strings.ToLower(strings.TrimSpace(p.Description))
encodedMarkers := []string{
"json-encoded string",
"json encoded string",
"serialized json string",
"stringified json",
}
for _, marker := range encodedMarkers {
if strings.Contains(lowerDescription, marker) {
return true
}
}
return false
}
func jsonEnumSuggestion(p spec.Param, params []spec.Param) *jsonFlagSuggestion {
for _, other := range params {
if other.Name == p.Name || other.Positional || other.Type != "string" || len(other.Enum) == 0 {
continue
}
if !isRelatedJSONPresetParam(p, other) {
continue
}
return &jsonFlagSuggestion{
FlagName: publicFlagName(other),
Values: other.Enum,
}
}
return nil
}
func isRelatedJSONPresetParam(jsonParam, enumParam spec.Param) bool {
jsonText := strings.ToLower(jsonParam.Name + " " + jsonParam.Description)
enumText := strings.ToLower(enumParam.Name + " " + enumParam.Description)
if !strings.Contains(enumText, "preset") {
return false
}
return hasTemporalMarker(jsonText) && hasTemporalMarker(enumText)
}
func hasTemporalMarker(s string) bool {
for _, marker := range []string{"time", "date", "range", "window"} {
if strings.Contains(s, marker) {
return true
}
}
return false
}
func enumLiteral(values []string) string {
// Render a string slice as a Go []string literal for template embedding.
// Example: ["asc","desc"] -> `"asc", "desc"`. Returns empty string when
// the slice is empty so callers can {{if}}-gate the block.
values = trimmedEnumValues(values)
if len(values) == 0 {
return ""
}
parts := make([]string, len(values))
for i, v := range values {
parts[i] = fmt.Sprintf("%q", v)
}
return strings.Join(parts, ", ")
}
func enumDescriptionHint(values []string) string {
// Appends " (one of: a, b, c)" to a flag description when the param
// has enum constraints. Returns empty string when the slice is empty.
values = trimmedEnumValues(values)
if len(values) == 0 {
return ""
}
return " (one of: " + strings.Join(values, ", ") + ")"
}
func trimmedEnumValues(values []string) []string {
if len(values) == 0 {
return nil
}
trimmed := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
trimmed = append(trimmed, value)
}
return trimmed
}
func defaultVal(p spec.Param) string {
if p.Default != nil {
if defaultShouldUseZero(p) || stringDefaultOutsideEnum(p) {
return zeroVal(p.Type)
}
// Coerce the default value to match the declared param type
switch primitiveKind(p.Type) {
case "string":
// Trim before emitting so a whitespace-padded default that was
// kept by stringDefaultOutsideEnum (which compares trimmed values)
// still matches the trimmed enum validator built by enumLiteral —
// otherwise the generated CLI rejects its own default. Trimming a
// non-enum string default is harmless (surrounding whitespace in a
// spec default is noise, like the other sloppy defaults this path
// normalizes).
return fmt.Sprintf("%q", strings.TrimSpace(fmt.Sprintf("%v", p.Default)))
case "bool":
switch v := p.Default.(type) {
case bool:
return fmt.Sprintf("%t", v)
case string:
if v == "true" || v == "false" {
return v
}
}
return "false"
case "int":
switch v := p.Default.(type) {
case float64:
return fmt.Sprintf("%d", int(v))
case int:
return fmt.Sprintf("%d", v)
}
return "0"
case "float":
switch v := p.Default.(type) {
case float64:
return fmt.Sprintf("%f", v)
case int:
return fmt.Sprintf("%f", float64(v))
}
return "0.0"
case "object", "array":
data, err := json.Marshal(p.Default)
if err != nil {
return `""`
}
return fmt.Sprintf("%q", string(data))
}
}
return zeroVal(p.Type)
}
func defaultShouldUseZero(p spec.Param) bool {
if v, ok := p.Default.(string); ok && v == "" {
return true
}
switch primitiveKind(p.Type) {
case "object", "array":
data, err := json.Marshal(p.Default)
if err != nil {
return true
}
switch string(data) {
case `""`, "[]", "{}", "null":
return true
}
}
return false
}
func stringDefaultOutsideEnum(p spec.Param) bool {
if primitiveKind(p.Type) != "string" || len(p.Enum) == 0 {
return false
}
defaultText := strings.TrimSpace(fmt.Sprintf("%v", p.Default))
for _, enumValue := range p.Enum {
if defaultText == strings.TrimSpace(enumValue) {
return false
}
}
return true
}
func zeroVal(t string) string {
switch primitiveKind(t) {
case "string":
return `""`
case "int":
return "0"
case "bool":
return "false"
case "float":
return "0.0"
default:
return `""`
}
}
func isStringCSVArrayParam(p spec.Param) bool {
if strings.EqualFold(strings.TrimSpace(p.Type), "string_csv_array") {
return true
}
return primitiveKind(p.Type) == "array" && strings.EqualFold(strings.TrimSpace(p.ItemType), "string") && len(p.Fields) == 0
}
func isArrayQueryParam(p spec.Param) bool {
return !p.Positional && !p.PathParam && !paramIsHeader(p) && primitiveKind(p.Type) == "array"
}
func queryParamStyle(p spec.Param) string {
if style := strings.TrimSpace(p.QueryStyle); style != "" {
return style
}
return "form"
}
func queryParamExplodes(p spec.Param) bool {
return p.QueryExplode == nil || *p.QueryExplode
}
func hasArrayQueryParams(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, func(endpoint spec.Endpoint) bool {
return slices.ContainsFunc(endpoint.Params, isArrayQueryParam)
})
}
// isDeepObjectQueryParam reports whether p is a query parameter declared with
// OpenAPI style=deepObject — an array- or object-typed param whose wire form
// is indexed bracket keys (name[i][field]=v / name[key]=v), never a repeated
// or delimiter-joined value list. The parser stores serialization.Style
// verbatim, so this compares against the literal (case-insensitively, to
// tolerate hand-authored specs).
func isDeepObjectQueryParam(p spec.Param) bool {
if p.Positional || p.PathParam {
return false
}
kind := primitiveKind(p.Type)
return (kind == "array" || kind == "object") && strings.EqualFold(strings.TrimSpace(p.QueryStyle), "deepObject")
}
func hasDeepObjectQueryParams(apiSpec *spec.APISpec) bool {
return anyEndpointMatches(apiSpec, func(endpoint spec.Endpoint) bool {
return slices.ContainsFunc(endpoint.Params, isDeepObjectQueryParam)
})
}
// hasIndexedOrArrayQueryParams gates the generated-code surfaces shared by
// the repeated/joined array emission and the indexed deepObject emission:
// the net/url imports and codeOrchSplitQuery's path-returning form.
func hasIndexedOrArrayQueryParams(apiSpec *spec.APISpec) bool {
return hasArrayQueryParams(apiSpec) || hasDeepObjectQueryParams(apiSpec)
}
func csvArrayValueExpr(p spec.Param, inputExpr string) string {
switch strings.ToLower(strings.TrimSpace(p.ItemType)) {
case "object":
return fmt.Sprintf("cliutil.CSVTemplateObjects(%s, %s)", inputExpr, csvItemTemplateLiteral(p.ItemTemplate))
default:
return fmt.Sprintf("cliutil.ParseStringList(%s)", inputExpr)
}
}
func isJSONOrScalarParam(p spec.Param) bool {
return strings.EqualFold(strings.TrimSpace(p.Format), "json_or_scalar")
}
func csvItemTemplateLiteral(v any) string {
switch val := v.(type) {
case nil:
return "nil"
case string:
return fmt.Sprintf("%q", val)
case bool:
if val {
return "true"
}
return "false"
case int:
return strconv.Itoa(val)
case int64:
return strconv.FormatInt(val, 10)
case float64:
if val == float64(int(val)) {
return strconv.Itoa(int(val))
}
return fmt.Sprintf("%g", val)
case []any:
parts := make([]string, 0, len(val))
for _, item := range val {
parts = append(parts, csvItemTemplateLiteral(item))
}
return "[]any{" + strings.Join(parts, ", ") + "}"
case map[string]any:
keys := make([]string, 0, len(val))
for key := range val {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%q: %s", key, csvItemTemplateLiteral(val[key])))
}
return "map[string]any{" + strings.Join(parts, ", ") + "}"
case map[any]any:
converted := make(map[string]any, len(val))
for key, item := range val {
converted[fmt.Sprint(key)] = item
}
return csvItemTemplateLiteral(converted)
default:
return fmt.Sprintf("%q", fmt.Sprint(val))
}
}
// orderedPositionalParams returns an endpoint's positional params with the
// path-param positionals sorted into URL-path order (left-to-right by where
// each {placeholder} appears in Endpoint.Path). The OpenAPI parser appends path
// params in parameters-array order, which some specs declare deepest-first
// (Cloudflare lists project_name before account_id for
// /accounts/{account_id}/pages/projects/{project_name}). Emitting them in that
// order made commands accept <project_name> <account_id> — the reverse of the
// URL and of the conventional parent-first ordering — so args passed in natural
// path order routed each value into the wrong slot and 404'd. Only path-param
// positionals are reordered, and only among the slots they already hold; any
// non-path positional keeps its declared place.
func orderedPositionalParams(e spec.Endpoint) []spec.Param {
positionals := make([]spec.Param, 0, len(e.Params))
for _, p := range e.Params {
if p.Positional {
positionals = append(positionals, p)
}
}
pathRank := func(p spec.Param) int { return strings.Index(e.Path, "{"+p.Name+"}") }
pathParams := make([]spec.Param, 0, len(positionals))
for _, p := range positionals {
if pathRank(p) >= 0 {
pathParams = append(pathParams, p)
}
}
sort.SliceStable(pathParams, func(i, j int) bool {
return pathRank(pathParams[i]) < pathRank(pathParams[j])
})
j := 0
for i, p := range positionals {
if pathRank(p) >= 0 {
positionals[i] = pathParams[j]
j++
}
}
return positionals
}
func positionalArgs(e spec.Endpoint) string {
var args []string
for _, p := range orderedPositionalParams(e) {
args = append(args, "<"+p.Name+">")
}
if len(args) > 0 {
return " " + strings.Join(args, " ")
}
return ""
}
// positionalIndex returns the args[] slot a positional param fills at runtime.
// Cobra populates args from CLI positionals in the order they appear in the
// command's Use string, which orderedPositionalParams fixes to URL-path order.
// The slot therefore tracks that path order rather than the raw Endpoint.Params
// order, which interleaves query/header params and may declare path params in
// reverse path order. Returns -1 if name is not a positional param.
func positionalIndex(e spec.Endpoint, name string) int {
for i, p := range orderedPositionalParams(e) {
if p.Name == name {
return i
}
}
return -1
}
func configTag(format string) string {
switch format {
case "toml":
return "toml"
case "yaml":
return "yaml"
default:
return "json"
}
}
func envVarField(envVar string) string {
// STYTCH_PROJECT_ID -> ProjectID
parts := strings.Split(strings.ToLower(envVar), "_")
var result strings.Builder
for _, p := range parts {
if len(p) > 0 {
result.WriteString(strings.ToUpper(p[:1]) + p[1:])
}
}
return result.String()
}
// ReservedCLIResourceNames lives in internal/spec/spec.go (spec.ReservedCLIResourceNames)
// so the parser can consult it without importing this package and creating a cycle.
// goosTokens are the GOOS values Go's filename-based build constraints recognize.
// A file named *_<token>.go gets an implicit build tag and is silently excluded
// when the host OS doesn't match. Source of truth: `go tool dist list`.
var goosTokens = map[string]struct{}{
"aix": {},
"android": {},
"darwin": {},
"dragonfly": {},
"freebsd": {},
"hurd": {},
"illumos": {},
"ios": {},
"js": {},
"linux": {},
"nacl": {},
"netbsd": {},
"openbsd": {},
"plan9": {},
"solaris": {},
"wasip1": {},
"windows": {},
"zos": {},
}
// goarchTokens are the GOARCH values Go's filename-based build constraints recognize.
var goarchTokens = map[string]struct{}{
"386": {},
"amd64": {},
"arm": {},
"arm64": {},
"loong64": {},
"mips": {},
"mips64": {},
"mips64le": {},
"mipsle": {},
"ppc64": {},
"ppc64le": {},
"riscv": {},
"riscv64": {},
"s390x": {},
"sparc64": {},
"wasm": {},
}
// safeResourceFileStem returns a basename (without .go) safe to write under
// internal/cli/, suffixing "_cmd" if the bare stem matches Go's filename-based
// build-constraint pattern (*_<GOOS>.go, *_<GOARCH>.go, *_<GOOS>_<GOARCH>.go)
// or Go's test-file pattern (*_test.go). Without this rename a file like
// scheduling_windows.go would get an implicit Windows-only build tag and be
// silently excluded on macOS/Linux builds, and a file like webhook_test.go
// would be treated as a test file and excluded from the normal package build.
//
// The reserved-name collision is handled separately at spec-parse time
// (see ReservedCLIResourceNames) because the function-name collision needs a
// hard error rather than a silent rename — `new<Name>Cmd` would clash with
// the reserved template's identically-named cobra builder.
//
// The suffix "_cmd" is never itself a GOOS, GOARCH, or "test" token, so a
// single application is sufficient.
//
// Examples:
//
// safeResourceFileStem("scheduling_windows") -> "scheduling_windows_cmd"
// safeResourceFileStem("foo_linux_amd64") -> "foo_linux_amd64_cmd"
// safeResourceFileStem("webhook_test") -> "webhook_test_cmd"
// safeResourceFileStem("scheduling_window_days") -> "scheduling_window_days" (no change)
// safeResourceFileStem("feedback") -> "feedback" (no change; rejected at parse)
func safeResourceFileStem(stem string) string {
parts := strings.Split(stem, "_")
if len(parts) >= 2 {
last := parts[len(parts)-1]
if _, isOS := goosTokens[last]; isOS {
return stem + "_cmd"
}
if _, isArch := goarchTokens[last]; isArch {
return stem + "_cmd"
}
if last == "test" {
// Go treats *_test.go as a test file and excludes it from
// the normal package build. Suffix "_cmd" keeps the file in
// the normal build.
return stem + "_cmd"
}
}
if len(parts) >= 3 {
// Match the *_GOOS_GOARCH.go pattern (e.g., foo_linux_amd64.go).
penultimate := parts[len(parts)-2]
last := parts[len(parts)-1]
_, osOK := goosTokens[penultimate]
_, archOK := goarchTokens[last]
if osOK && archOK {
return stem + "_cmd"
}
}
return stem
}
// builtinConfigTags lists the JSON/TOML tags of hardcoded Config struct fields
// in config.go.tmpl. When an env var's placeholder matches one of these, the
// env var should populate the existing field instead of creating a duplicate.
var builtinConfigTags = map[string]string{
"access_token": "AccessToken",
"refresh_token": "RefreshToken",
"client_id": "ClientID",
"client_secret": "ClientSecret",
"base_url": "BaseURL",
"auth_header": "AuthHeaderVal",
}
// envVarIsBuiltinField returns true if the env var's placeholder tag would
// collide with a hardcoded Config struct field tag.
func envVarIsBuiltinField(envVar string) bool {
placeholder := naming.EnvVarPlaceholder(envVar)
_, ok := builtinConfigTags[placeholder]
return ok
}
// envVarBuiltinFieldName returns the Go field name of the hardcoded Config
// struct field that matches this env var's placeholder, or empty string if none.
func envVarBuiltinFieldName(envVar string) string {
placeholder := naming.EnvVarPlaceholder(envVar)
return builtinConfigTags[placeholder]
}
// resolveEnvVarField returns the correct Go field name for an env var,
// accounting for builtin field collisions. If the env var's placeholder
// matches a hardcoded field, returns that field name; otherwise returns
// the computed field name from envVarField.
func resolveEnvVarField(envVar string) string {
if name := envVarBuiltinFieldName(envVar); name != "" {
return name
}
return envVarField(envVar)
}
// composeMCPDesc is the template helper that wraps mcpdesc.Compose so
// the mcp_tools.go.tmpl template can build a full description from
// the parsed endpoint plus auth context. The composer in
// internal/mcpdesc shapes the action sentence + Required/Optional
// parameter lines + Returns clause; this wrapper just packages the
// arguments into the Input struct.
func composeMCPDesc(api *spec.APISpec, resource spec.Resource, endpoint spec.Endpoint, publicCount, totalCount int) string {
authType, noAuth := api.EffectiveEndpointAuth(resource, endpoint)
return mcpdesc.Compose(mcpdesc.Input{
Endpoint: endpoint,
NoAuth: noAuth,
AuthType: authType,
PublicCount: publicCount,
TotalCount: totalCount,
})
}
func composeMCPSubDesc(api *spec.APISpec, parent spec.Resource, subResource spec.Resource, endpoint spec.Endpoint, publicCount, totalCount int) string {
authType, noAuth := api.EffectiveSubEndpointAuth(parent, subResource, endpoint)
return mcpdesc.Compose(mcpdesc.Input{
Endpoint: endpoint,
NoAuth: noAuth,
AuthType: authType,
PublicCount: publicCount,
TotalCount: totalCount,
})
}
func (g *Generator) mcpParamDescription(p spec.Param) string {
if g.mcpParamDescriptions == nil {
g.mcpParamDescriptions = mcpdesc.NewParamDescriptionCompactor(g.Spec)
}
return naming.OneLine(g.mcpParamDescriptions.Description(p))
}
func exampleValue(p spec.Param) string {
if value, ok := syntheticExampleValue(p.Name); ok {
return value
}
if p.Example != nil {
if s := stringifyDefault(p.Example); shellSafeSchemaExampleValue(s) {
return s
}
}
// Enum-constrained params: the API rejects anything outside the set,
// so prefer the first declared value over name-shape heuristics.
// This wins over name-based branches because a hypothetical
// `status_id: [a,b,c]` enum still requires `a`, not a UUID.
for _, v := range p.Enum {
if strings.TrimSpace(v) != "" {
return v
}
}
if s, ok := schemaDefaultExampleValue(p); ok {
return s
}
if value, ok := descriptionExampleValue(p.Description); ok {
return value
}
nameLower := strings.ToLower(p.Name)
// camelCase `*Id` carries an exclusion fence so bool/numeric params
// ending in "id" (e.g. paid, valid) get their own branches. The fence
// is expressed as "not numeric/boolean" rather than "is string" so
// alternative string-shaped types (e.g., `uuid`, `guid`) still match.
isNumericOrBool := p.Type == "boolean" || p.Type == "bool" ||
p.Type == "integer" || p.Type == "int" ||
p.Type == "number" || p.Type == "float"
if nameLower == "id" ||
strings.HasSuffix(nameLower, "_id") ||
(strings.HasSuffix(nameLower, "id") && len(nameLower) > 2 && !isNumericOrBool) {
return piiplaceholders.SyntheticUUID
}
if strings.Contains(nameLower, "email") {
return "user@example.com"
}
if strings.Contains(nameLower, "url") || strings.Contains(nameLower, "link") {
return "https://example.com/resource"
}
if strings.Contains(nameLower, "name") || strings.Contains(nameLower, "title") {
return "example-resource"
}
// Reuse isNumericOrBool (defined above): a numeric- or boolean-typed
// param must not pick up an RFC3339/date example from a "time"/"date"
// substring in its name (epoch cursors like start_time, oldest), while
// Format == date/date-time stays authoritative for genuinely temporal
// string params.
if p.Format == "date" || (!isNumericOrBool && strings.Contains(nameLower, "date")) {
return "2026-01-15"
}
if p.Format == "date-time" || (!isNumericOrBool && strings.Contains(nameLower, "time")) {
return "2026-01-15T09:00:00Z"
}
if strings.Contains(nameLower, "token") || strings.Contains(nameLower, "key") {
return "your-token-here"
}
if strings.Contains(nameLower, "limit") || strings.Contains(nameLower, "count") || strings.Contains(nameLower, "size") {
if p.Type == "integer" || p.Type == "int" {
return "50"
}
}
if p.Type == "boolean" || p.Type == "bool" {
return "true"
}
if p.Type == "integer" || p.Type == "int" || p.Type == "number" || p.Type == "float" {
return "42"
}
return "example-value"
}
func descriptionExampleValue(description string) (string, bool) {
lower := strings.ToLower(description)
for _, marker := range []string{"e.g.", "eg.", "for example"} {
idx := descriptionExampleMarkerIndex(lower, marker)
if idx < 0 {
continue
}
rest := strings.TrimSpace(description[idx+len(marker):])
rest = strings.TrimLeft(rest, ": \t")
if value := firstShellSafeDescriptionToken(rest); value != "" {
return value, true
}
}
return "", false
}
func descriptionExampleMarkerIndex(lower, marker string) int {
searchFrom := 0
for {
idx := strings.Index(lower[searchFrom:], marker)
if idx < 0 {
return -1
}
idx += searchFrom
if idx == 0 {
return idx
}
prev := rune(lower[idx-1])
if !unicode.IsLetter(prev) && !unicode.IsDigit(prev) {
return idx
}
searchFrom = idx + len(marker)
}
}
func schemaDefaultExampleValue(p spec.Param) (string, bool) {
if p.DispatchParamSet && !p.DispatchParam {
return "", false
}
if p.Default == nil {
return "", false
}
if s, ok := defaultSliceExampleValue(p.Default); ok && shellSafeSchemaExampleValue(s) {
return s, true
}
s := stringifyDefault(p.Default)
if shellSafeSchemaExampleValue(s) {
return s, true
}
return "", false
}
func defaultSliceExampleValue(v any) (string, bool) {
switch t := v.(type) {
case []string:
if len(t) == 0 {
return "", false
}
return stringifyDefault(t[0]), true
case []any:
if len(t) == 0 {
return "", false
}
return stringifyDefault(t[0]), true
default:
return "", false
}
}
func firstShellSafeDescriptionToken(s string) string {
for _, delimiter := range []string{",", ";", ".", "\n", "\r", " or ", " and "} {
if idx := strings.Index(s, delimiter); idx >= 0 {
s = s[:idx]
}
}
s = strings.Trim(s, " \t`'\"()[]{}")
if s == "" || strings.ContainsAny(s, " \t") {
return ""
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == ':' || r == '/' || r == '.' {
continue
}
return ""
}
return s
}
func shellSafeSchemaExampleValue(s string) bool {
s = strings.TrimSpace(s)
if s == "" || strings.ContainsAny(s, " \t\n\r") {
return false
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == ':' || r == '/' || r == '.' {
continue
}
return false
}
return true
}
func exampleNeedsTODO(line string) bool {
return strings.Contains(line, "example-value")
}
func runnableExampleLine(line string) string {
if exampleNeedsTODO(line) {
return ""
}
return line
}
func kebabCommandParts(commandPath string) []string {
fields := strings.Fields(commandPath)
for i, field := range fields {
fields[i] = toKebab(field)
}
return fields
}
func (g *Generator) exampleLine(commandPath, endpointName string, endpoint spec.Endpoint) string {
if strings.TrimSpace(endpoint.Example) != "" {
return runnableExampleLine(endpoint.Example)
}
// Spec resource keys are snake_case; Cobra registers kebab Use: paths.
commandParts := append(kebabCommandParts(commandPath), toKebab(endpointName))
if line, ok := g.narrativeExampleLine(commandParts, endpoint); ok {
return runnableExampleLine(line)
}
if endpoint.Alias != "" {
aliasParts := append(kebabCommandParts(commandPath), endpoint.Alias)
if line, ok := g.narrativeExampleLine(aliasParts, endpoint); ok {
return runnableExampleLine(line)
}
}
if !requiredInputsAreDerivable(endpoint) {
return ""
}
var parts []string
parts = append(parts, naming.CLI(g.Spec.Name))
parts = append(parts, commandParts...)
parts = append(parts, commandExampleArgParts(endpoint)...)
return runnableExampleLine(" " + strings.Join(parts, " "))
}
func (g *Generator) promotedExampleLine(promotedName, endpointName string, endpoint spec.Endpoint) string {
if strings.TrimSpace(endpoint.Example) != "" {
line, err := g.resolvePromotedExample(promotedName, endpointName, endpoint)
if err == nil {
return runnableExampleLine(line)
}
}
return g.synthesizedRunnablePromotedExample(toKebab(promotedName), endpoint)
}
func (g *Generator) narrativeExampleLine(commandParts []string, endpoint spec.Endpoint) (string, bool) {
if g == nil || g.Narrative == nil {
return "", false
}
for _, command := range g.narrativeExampleCommands() {
segments, err := shellargs.SplitChain(command)
if err != nil {
continue
}
for _, segment := range segments {
if segment.AfterPipe {
continue
}
if narrativeCommandMatches(g.Spec.Name, segment.Text, commandParts, endpoint) {
return " " + strings.TrimSpace(segment.Text), true
}
}
}
return "", false
}
func (g *Generator) narrativeExampleCommands() []string {
if g == nil || g.Narrative == nil {
return nil
}
var commands []string
for _, step := range g.Narrative.QuickStart {
if command := strings.TrimSpace(step.Command); command != "" {
commands = append(commands, command)
}
}
for _, recipe := range g.Narrative.Recipes {
if command := strings.TrimSpace(recipe.Command); command != "" {
commands = append(commands, command)
}
}
return commands
}
func narrativeCommandMatches(apiName, command string, commandParts []string, endpoint spec.Endpoint) bool {
tokens, err := shellargs.Split(command)
if err != nil || len(tokens) < 1+len(commandParts) {
return false
}
if tokens[0] != naming.CLI(apiName) {
return false
}
args := tokens[1:]
commandIndex, ok := narrativeCommandStart(args)
if !ok || len(args[commandIndex:]) < len(commandParts) {
return false
}
for i, part := range commandParts {
if args[commandIndex+i] != part {
return false
}
}
return narrativeTailMatches(args[commandIndex+len(commandParts):], endpoint)
}
func narrativeTailMatches(args []string, endpoint spec.Endpoint) bool {
flags := narrativeEndpointFlags()
for _, p := range endpoint.Params {
if p.Positional {
continue
}
flags[publicFlagName(p)] = !isBoolParam(p)
}
for _, p := range endpoint.Body {
flags[publicFlagName(p)] = !isBoolParam(p)
}
positionals := 0
expectedPositionals := endpointPositionalCount(endpoint)
for i := 0; i < len(args); {
if strings.HasPrefix(args[i], "--") {
next, ok := skipNarrativeFlag(args, i, flags)
if !ok {
return false
}
i = next
continue
}
if positionals >= expectedPositionals {
return false
}
positionals++
i++
}
return positionals == expectedPositionals
}
func endpointPositionalCount(endpoint spec.Endpoint) int {
count := 0
for _, p := range endpoint.Params {
if p.Positional {
count++
}
}
return count
}
func narrativeEndpointFlags() map[string]bool {
flags := maps.Clone(narrativeGlobalFlags)
flags["all"] = false
flags["body-json"] = true
flags["stdin"] = false
flags["wait"] = false
flags["wait-interval"] = true
flags["wait-timeout"] = true
return flags
}
func skipNarrativeFlag(args []string, index int, flags map[string]bool) (int, bool) {
arg := args[index]
if arg == "--" {
return 0, false
}
name, _, hasInlineValue := strings.Cut(strings.TrimPrefix(arg, "--"), "=")
requiresValue, ok := flags[name]
if !ok {
return 0, false
}
next := index + 1
if requiresValue && !hasInlineValue {
if next >= len(args) {
return 0, false
}
next++
}
return next, true
}
func isBoolParam(p spec.Param) bool {
switch p.Type {
case "boolean", "bool":
return true
default:
return false
}
}
func narrativeCommandStart(args []string) (int, bool) {
for i := 0; i < len(args); {
arg := args[i]
if arg == "--" {
return 0, false
}
if !strings.HasPrefix(arg, "--") {
return i, true
}
next, ok := skipNarrativeFlag(args, i, narrativeGlobalFlags)
if !ok {
return 0, false
}
i = next
}
return 0, false
}
var narrativeGlobalFlags = map[string]bool{
"agent": false,
"allow-partial-failure": false,
"compact": false,
"config": true,
"csv": false,
"data-source": true,
"deliver": true,
"dry-run": false,
"human-friendly": false,
"idempotent": false,
"ignore-missing": false,
"json": false,
"max-age": true,
"no-cache": false,
"no-color": false,
"no-input": false,
"plain": false,
"profile": true,
"quiet": false,
"rate-limit": true,
"select": true,
"throttle-mode": true,
"timeout": true,
"yes": false,
}
func flagName(name string) string {
return naming.FlagName(name)
}
func safeTypeName(name string) string {
name = strings.TrimLeft(name, "$")
name = strings.NewReplacer(".", "_", "/", "_", "\\", "_", "-", "_", " ", "_").Replace(name)
var b strings.Builder
for _, r := range name {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
b.WriteRune(r)
}
}
result := b.String()
if len(result) > 0 && !unicode.IsLetter(rune(result[0])) {
result = "T" + result
}
if isGoKeyword(result) {
result = "T" + result
}
return result
}
// goKeywords is the set of reserved words from the Go language spec
// (https://go.dev/ref/spec#Keywords). Type names that match these refuse to
// parse as `type X struct { ... }`. Predeclared identifiers (bool, int,
// string, error, etc.) shadow rather than fail and are intentionally
// excluded; OpenAPI specs that use them as type names compile, just with
// shadowed builtins inside that file.
var goKeywords = map[string]bool{
"break": true, "case": true, "chan": true, "const": true, "continue": true,
"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
"func": true, "go": true, "goto": true, "if": true, "import": true,
"interface": true, "map": true, "package": true, "range": true, "return": true,
"select": true, "struct": true, "switch": true, "type": true, "var": true,
}
// isGoKeyword reports whether s is a reserved word in the Go language spec.
func isGoKeyword(s string) bool {
return goKeywords[s]
}
// cacheDurationDefault is the global stale-after fallback used when the spec
// declares no stale_after, or declares one that does not parse.
const cacheDurationDefault = "6 * time.Hour"
// staleAfterExpr renders the cache stale-after duration as a Go expression for
// direct initialization. A spec literal that parses to a non-negative duration
// becomes e.g. "168 * time.Hour"; an empty, unparseable, or negative value
// falls back to the 6h default (a negative stale-after would make the cache
// permanently stale). Emitting the value directly avoids a dead "staleAfter :=
// 6 * time.Hour" initializer that is always overwritten by a ParseDuration call
// which cannot fail on a constant literal.
func staleAfterExpr(lit string) string {
if lit == "" {
return cacheDurationDefault
}
d, err := time.ParseDuration(lit)
if err != nil || d < 0 {
return cacheDurationDefault
}
return goDurationExpr(d)
}
// goDurationExpr renders a time.Duration as a readable Go expression, preferring
// the largest whole unit (hours, then minutes, then seconds) and falling back
// to a nanosecond-typed literal for sub-second or non-round values.
func goDurationExpr(d time.Duration) string {
switch {
case d%time.Hour == 0:
return fmt.Sprintf("%d * time.Hour", d/time.Hour)
case d%time.Minute == 0:
return fmt.Sprintf("%d * time.Minute", d/time.Minute)
case d%time.Second == 0:
return fmt.Sprintf("%d * time.Second", d/time.Second)
default:
return fmt.Sprintf("time.Duration(%d)", int64(d))
}
}
// toKebab converts PascalCase, camelCase, or mixed names to kebab-case.
// It also strips a leading "I" if it looks like an interface prefix (e.g., ISteamUser → steam-user).
func toKebab(s string) string {
return spec.NormalizeCobraCommandName(s)
}
// PromotedCommand represents a top-level user-friendly command that wraps a nested API endpoint.
type PromotedCommand struct {
PromotedName string
ResourceName string
Endpoint spec.Endpoint
EndpointName string
}
// builtinCommands lists command names that must not be used for promoted commands
// because they collide with the CLI's own built-in commands.
var builtinCommands = map[string]bool{
"version": true,
"help": true,
"doctor": true,
"auth": true,
"sync": true,
"search": true,
"export": true,
"import": true,
"completion": true,
"refresh-bearer": true,
"workflow": true,
"tail": true,
"analytics": true,
}
// buildPromotedCommands scans spec resources and returns safe top-level shortcuts.
// Single-endpoint resources are promoted. GraphQL resources with a canonical
// get/list pair also promote the get endpoint so users keep a friendly
// `<cli> resource <id>` read path while list and mutation siblings remain
// available as subcommands under the promoted command.
func buildPromotedCommands(s *spec.APISpec) []PromotedCommand {
var promoted []PromotedCommand
usedNames := make(map[string]bool)
graphQL := isGraphQLSpec(s)
resourceNames := make([]string, 0, len(s.Resources))
for name := range s.Resources {
resourceNames = append(resourceNames, name)
}
sort.Strings(resourceNames)
for _, name := range resourceNames {
resource := s.Resources[name]
var bestName string
var bestEndpoint spec.Endpoint
found := false
if len(resource.Endpoints) > 1 {
if graphQL {
if ep, ok := resource.Endpoints["get"]; ok {
if _, hasList := resource.Endpoints["list"]; !hasList {
continue
}
bestName = "get"
bestEndpoint = ep
found = true
}
}
if !found {
continue
}
} else {
// Single-endpoint resources promote the only endpoint regardless of method.
// Without this, POST-only auth resources like `login`/`logout`/`register`
// render as `<cli> login login --email ...`.
for _, eName := range sortedEndpointNames(resource.Endpoints) {
ep := resource.Endpoints[eName]
bestName = eName
bestEndpoint = ep
found = true
}
}
if !found {
continue
}
// A body that recurses past maxBodyFlagDepth must NOT be promoted: the
// promoted template emits no --stdin fallback, so the truncated subtree
// would silently drop fields. Skipping promotion keeps the canonical
// command (which has --stdin) as the reachable surface.
if bodyExceedsFlagDepth(bestEndpoint) {
continue
}
promotedName := toKebab(name)
if builtinCommands[promotedName] {
continue
}
if usedNames[promotedName] {
continue
}
usedNames[promotedName] = true
promoted = append(promoted, PromotedCommand{
PromotedName: promotedName,
ResourceName: name,
Endpoint: bestEndpoint,
EndpointName: bestName,
})
}
return promoted
}
func sortedEndpointNames(endpoints map[string]spec.Endpoint) []string {
names := make([]string, 0, len(endpoints))
for name := range endpoints {
names = append(names, name)
}
sort.Strings(names)
return names
}
// isGraphQLSpec returns true if the spec was produced by a GraphQL SDL parser.
// Detection heuristic: all list endpoints have path "/graphql".
func isGraphQLSpec(s *spec.APISpec) bool {
if s == nil {
return false
}
hasListEndpoint := false
for _, r := range s.Resources {
for eName, ep := range r.Endpoints {
if eName == "list" {
hasListEndpoint = true
if ep.Path != "/graphql" {
return false
}
}
}
}
return hasListEndpoint
}
func networkFallbackReason(s *spec.APISpec) string {
if s == nil {
return "api_unreachable"
}
if s.IsSynthetic() {
return "synthetic_anchor_fallback"
}
u, err := url.Parse(strings.TrimSpace(s.BaseURL))
// In Printing Press specs, .local base URLs are synthetic placeholders.
// Real mDNS/private hosts should use a non-.local alias to avoid being
// classified as synthetic fallback surfaces.
if err == nil && strings.HasSuffix(strings.ToLower(u.Hostname()), ".local") {
return "synthetic_anchor_fallback"
}
return "api_unreachable"
}
func localReadIsList(supportsAllPagination bool, apiSpec *spec.APISpec, endpointName string, endpoint spec.Endpoint) bool {
if supportsAllPagination {
return true
}
if endpointHasPathScope(endpoint) {
return false
}
if localReadLooksLikeCollection(endpointName, endpoint) {
return true
}
return networkFallbackReason(apiSpec) == "synthetic_anchor_fallback" && strings.EqualFold(endpoint.Response.Type, "array")
}
func localReadSupported(endpoint spec.Endpoint) bool {
if endpoint.UsesTextResponse() {
return false
}
segments := strings.Split(strings.Trim(strings.TrimSpace(endpoint.Path), "/"), "/")
for i, segment := range segments {
if strings.Contains(segment, "{") {
return i == len(segments)-1
}
}
return true
}
func localReadLooksLikeCollection(endpointName string, endpoint spec.Endpoint) bool {
nameLower := strings.ToLower(strings.TrimSpace(endpointName))
if nameLower == "list" {
return true
}
if !localReadNameContainsAny(nameLower, []string{"search", "query", "browse", "find"}) {
return false
}
return strings.EqualFold(endpoint.Response.Type, "array")
}
func localReadNameContainsAny(s string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(s, needle) {
return true
}
}
return false
}
func endpointHasPathScope(endpoint spec.Endpoint) bool {
// Parsed specs and hand-authored fixtures may disagree between the path
// string and normalized Param flags; either signal means local List would
// over-return rows across parents.
if strings.Contains(endpoint.Path, "{") {
return true
}
for _, p := range endpoint.Params {
if p.PathParam {
return true
}
}
return false
}
// graphqlQueryField extracts the GraphQL query field name from a ResponsePath.
// For example, "data.issues.nodes" returns "issues", "data.issue" returns "issue".
// For SyncableResource.Path which is always "/graphql", return the resource name.
func graphqlQueryField(responsePath string) string {
responsePath = strings.TrimPrefix(responsePath, "/graphql")
if responsePath == "" || responsePath == "/graphql" {
return ""
}
parts := strings.Split(responsePath, ".")
// Strip "data" prefix
if len(parts) > 0 && parts[0] == "data" {
parts = parts[1:]
}
// Strip "nodes" suffix
if len(parts) > 0 && parts[len(parts)-1] == "nodes" {
parts = parts[:len(parts)-1]
}
if len(parts) > 0 {
return parts[0]
}
return responsePath
}
// graphqlFieldSelection returns the list of field names for a GraphQL query
// selection set, derived from the type definition in the spec.
func graphqlFieldSelection(typeName string, types map[string]spec.TypeDef) []string {
td, ok := types[typeName]
if !ok {
return []string{"id"}
}
var fields []string
for _, f := range td.Fields {
name := strings.TrimSpace(f.Name)
if name == "" {
continue
}
if selection := strings.TrimSpace(f.Selection); selection != "" {
fields = append(fields, name+" "+selection)
continue
}
fields = append(fields, name)
}
if len(fields) == 0 {
return []string{"id"}
}
return fields
}
// graphqlListParams returns the GraphQL list arguments this generator knows how
// to render into the query document and command variables map.
func graphqlListParams(endpoint spec.Endpoint) []spec.Param {
params := make([]spec.Param, 0, len(endpoint.Params))
for _, p := range endpoint.Params {
if p.Positional || p.PathParam {
continue
}
switch p.Name {
case "first", "after", "query":
default:
continue
}
params = append(params, p)
}
return params
}
func graphqlLatestParams(endpoint spec.Endpoint) []spec.Param {
params := make([]spec.Param, 0, len(endpoint.Params))
for _, p := range endpoint.Params {
if p.Positional || p.PathParam {
continue
}
if p.Name != "last" {
continue
}
params = append(params, p)
}
return params
}
func hasGraphQLParam(endpoint spec.Endpoint, name string) bool {
for _, p := range endpoint.Params {
if p.Name == name && !p.Positional && !p.PathParam {
return true
}
}
return false
}
func graphqlVariableType(p spec.Param) string {
var typ string
switch primitiveKind(p.Type) {
case "int":
typ = "Int"
case "float":
typ = "Float"
case "bool":
typ = "Boolean"
case "array":
typ = "[String!]"
default:
typ = "String"
}
if p.Required || strings.EqualFold(p.Name, "first") || strings.EqualFold(p.Name, "last") {
typ += "!"
}
return typ
}
type templateEndpoint struct {
spec.Endpoint
EffectivePath string
EffectiveTier string
}
// lookupEndpointForTemplate resolves a dotted "resource.endpoint" (or
// "resource.sub_resource.endpoint") reference from the spec's resource map.
// Templates use it when rendering intent handler dispatch tables so each
// step's HTTP method and effective path are known at generate time.
func lookupEndpointForTemplate(api *spec.APISpec, ref string) (templateEndpoint, bool) {
if api == nil {
return templateEndpoint{}, false
}
parts := strings.Split(ref, ".")
switch len(parts) {
case 2:
r, ok := api.Resources[parts[0]]
if !ok {
return templateEndpoint{}, false
}
e, ok := r.Endpoints[parts[1]]
if !ok {
return templateEndpoint{}, false
}
return templateEndpoint{
Endpoint: e,
EffectivePath: effectiveEndpointPath(r, e),
EffectiveTier: api.EffectiveTier(r, e),
}, true
case 3:
r, ok := api.Resources[parts[0]]
if !ok {
return templateEndpoint{}, false
}
sub, ok := r.SubResources[parts[1]]
if !ok {
return templateEndpoint{}, false
}
e, ok := sub.Endpoints[parts[2]]
if !ok {
return templateEndpoint{}, false
}
effectiveSub := sub
if effectiveSub.Tier == "" {
effectiveSub.Tier = r.Tier
}
return templateEndpoint{
Endpoint: e,
EffectivePath: effectiveSubEndpointPath(r, sub, e),
EffectiveTier: api.EffectiveTier(effectiveSub, e),
}, true
default:
return templateEndpoint{}, false
}
}
func effectiveEndpointPath(resource spec.Resource, endpoint spec.Endpoint) string {
return endpointPathWithBase(effectiveEndpointBaseURL(resource, endpoint), endpoint.Path)
}
func effectiveSubEndpointPath(parent spec.Resource, sub spec.Resource, endpoint spec.Endpoint) string {
return endpointPathWithBase(effectiveSubEndpointBaseURL(parent, sub, endpoint), endpoint.Path)
}
func effectiveEndpointBaseURL(resource spec.Resource, endpoint spec.Endpoint) string {
baseURL := endpoint.BaseURL
if baseURL == "" {
baseURL = resource.BaseURL
}
return strings.TrimRight(baseURL, "/")
}
func effectiveSubEndpointBaseURL(parent spec.Resource, sub spec.Resource, endpoint spec.Endpoint) string {
baseURL := endpoint.BaseURL
if baseURL == "" {
baseURL = sub.BaseURL
}
if baseURL == "" {
baseURL = parent.BaseURL
}
return strings.TrimRight(baseURL, "/")
}
func endpointPathWithBase(baseURL, path string) string {
baseURL = strings.TrimRight(baseURL, "/")
if baseURL == "" || strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "http://") {
return path
}
return baseURL + path
}