[object Object]

← back to Cli Printing Press

feat(profiler): add API Shape Intelligence Engine

f499c4b57c38df3b0dfc552e99e8353d13142af0 · 2026-03-25 21:12:29 -0700 · Matt Van Horn

Analyze OpenAPI spec structure to predict power-user features.
Detects 11 signals: pagination, search gaps, webhooks, CRUD patterns,
lifecycle states, foreign keys, chronological resources, file ops.
Produces APIProfile with ToVisionaryPlan() for template selection.

Tests: Petstore (simple), Discord (complex), Minimal - all pass.

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

Files touched

Diff

commit f499c4b57c38df3b0dfc552e99e8353d13142af0
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Wed Mar 25 21:12:29 2026 -0700

    feat(profiler): add API Shape Intelligence Engine
    
    Analyze OpenAPI spec structure to predict power-user features.
    Detects 11 signals: pagination, search gaps, webhooks, CRUD patterns,
    lifecycle states, foreign keys, chronological resources, file ops.
    Produces APIProfile with ToVisionaryPlan() for template selection.
    
    Tests: Petstore (simple), Discord (complex), Minimal - all pass.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 internal/profiler/profiler.go      | 499 +++++++++++++++++++++++++++++++++++++
 internal/profiler/profiler_test.go | 368 +++++++++++++++++++++++++++
 2 files changed, 867 insertions(+)

diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
new file mode 100644
index 00000000..263b44f6
--- /dev/null
+++ b/internal/profiler/profiler.go
@@ -0,0 +1,499 @@
+package profiler
+
+import (
+	"sort"
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/mvanhorn/cli-printing-press/internal/vision"
+)
+
+// APIProfile describes the shape of an API and what power-user features it warrants.
+type APIProfile struct {
+	HighVolume       bool
+	NeedsSearch      bool
+	HasRealtime      bool
+	OfflineValuable  bool
+	ComplexResources bool
+	HasLifecycles    bool
+	HasDependencies  bool
+	HasChronological bool
+	HasFileOps       bool
+	CRUDResources    int
+	ListEndpoints    int
+	TotalEndpoints   int
+	ReadRatio        float64
+
+	SyncableResources []string
+	SearchableFields  map[string][]string
+}
+
+func Profile(s *spec.APISpec) *APIProfile {
+	if s == nil {
+		return &APIProfile{
+			SearchableFields: make(map[string][]string),
+		}
+	}
+
+	p := &APIProfile{
+		SearchableFields: make(map[string][]string),
+	}
+
+	resourceNames := collectResourceNames(s.Resources)
+	syncable := make(map[string]struct{})
+	searchable := make(map[string]map[string]struct{})
+	listResources := make(map[string]struct{})
+
+	var getEndpoints int
+	var listCapableGETs int
+	var hasSearchEndpoint bool
+
+	var walk func(name string, r spec.Resource)
+	walk = func(name string, r spec.Resource) {
+		resourceName := strings.ToLower(name)
+		resourceHasGet := false
+		resourceHasPost := false
+		resourceHasMutating := false
+
+		if containsAny(resourceName, []string{"webhook", "event", "callback", "notification"}) {
+			p.HasRealtime = true
+		}
+		if containsAny(resourceName, []string{"audit", "log", "event", "history", "activity"}) {
+			p.HasChronological = true
+		}
+
+		for endpointName, endpoint := range r.Endpoints {
+			p.TotalEndpoints++
+
+			method := strings.ToUpper(endpoint.Method)
+			switch method {
+			case "GET":
+				getEndpoints++
+				resourceHasGet = true
+			case "POST":
+				resourceHasPost = true
+			case "PUT", "PATCH", "DELETE":
+				resourceHasMutating = true
+			}
+
+			endpointNameLower := strings.ToLower(endpointName)
+			pathLower := strings.ToLower(endpoint.Path)
+
+			if containsAny(endpointNameLower, []string{"search"}) || containsAny(pathLower, []string{"search"}) {
+				hasSearchEndpoint = true
+			}
+			if containsAny(pathLower, []string{"webhook", "event", "callback", "notification"}) {
+				p.HasRealtime = true
+			}
+			if containsAny(pathLower, []string{"audit", "log", "event", "history", "activity"}) || hasChronologicalParams(endpoint.Params) {
+				p.HasChronological = true
+			}
+
+			if isListEndpoint(endpointName, endpoint) {
+				listCapableGETs++
+				listResources[resourceName] = struct{}{}
+				if endpoint.Pagination != nil {
+					p.ListEndpoints++
+					syncable[resourceName] = struct{}{}
+				}
+			}
+
+			if len(endpoint.Body) > 10 {
+				p.ComplexResources = true
+			}
+			if hasLifecycleField(endpoint.Body) || hasLifecycleField(endpoint.Params) {
+				p.HasLifecycles = true
+			}
+			if hasFileBody(endpoint.Body) {
+				p.HasFileOps = true
+			}
+			if !p.HasDependencies && hasDependency(endpoint.Body, resourceNames) {
+				p.HasDependencies = true
+			}
+
+			for _, field := range collectStringFields(endpoint.Body) {
+				if searchable[resourceName] == nil {
+					searchable[resourceName] = make(map[string]struct{})
+				}
+				searchable[resourceName][field] = struct{}{}
+			}
+		}
+
+		if resourceHasGet && resourceHasPost && resourceHasMutating {
+			p.CRUDResources++
+		}
+
+		for subName, sub := range r.SubResources {
+			walk(subName, sub)
+		}
+	}
+
+	for name, resource := range s.Resources {
+		walk(name, resource)
+	}
+
+	if p.TotalEndpoints > 0 {
+		p.ReadRatio = float64(getEndpoints) / float64(p.TotalEndpoints)
+		p.OfflineValuable = p.ReadRatio > 0.6
+	}
+	if listCapableGETs > 0 {
+		p.HighVolume = float64(p.ListEndpoints)/float64(listCapableGETs) > 0.5
+	}
+	p.NeedsSearch = len(listResources) >= 3 && !hasSearchEndpoint
+
+	p.SyncableResources = sortedKeys(syncable)
+	for resource, fields := range searchable {
+		p.SearchableFields[resource] = sortedKeys(fields)
+	}
+
+	return p
+}
+
+func (p *APIProfile) ToVisionaryPlan(apiName string) *vision.VisionaryPlan {
+	if p == nil {
+		p = &APIProfile{}
+	}
+
+	plan := &vision.VisionaryPlan{
+		APIName: apiName,
+		Identity: vision.APIIdentity{
+			CoreEntities: p.SyncableResources,
+			DataProfile: vision.DataProfile{
+				Volume:     lowHigh(p.HighVolume),
+				SearchNeed: lowHigh(p.NeedsSearch),
+				Realtime:   p.HasRealtime,
+			},
+		},
+	}
+
+	plan.Architecture = append(plan.Architecture,
+		vision.ArchitectureDecision{
+			Area:               "persistence",
+			NeedLevel:          lowHigh(p.HighVolume || p.OfflineValuable),
+			Decision:           "local store",
+			Rationale:          "Read-heavy or high-volume APIs benefit from local persistence for repeat access and offline workflows.",
+			ImplementationHint: "Use SQLite-backed storage and cache frequently accessed resources.",
+		},
+		vision.ArchitectureDecision{
+			Area:               "search",
+			NeedLevel:          lowHigh(p.NeedsSearch),
+			Decision:           "full-text indexing",
+			Rationale:          "Multi-resource list-heavy APIs need a fast local search surface when no dedicated endpoint exists.",
+			ImplementationHint: "Index string fields in FTS5 tables keyed by resource type.",
+		},
+		vision.ArchitectureDecision{
+			Area:               "realtime",
+			NeedLevel:          lowHigh(p.HasRealtime),
+			Decision:           "streaming event tail",
+			Rationale:          "Webhook and event-heavy APIs warrant live inspection workflows.",
+			ImplementationHint: "Offer tail-style commands that poll or stream event resources.",
+		},
+	)
+
+	for _, featureName := range p.RecommendedFeatures() {
+		feature := featureIdeaFor(featureName, p)
+		feature.TotalScore = feature.ComputeScore()
+		plan.Features = append(plan.Features, feature)
+	}
+
+	return plan
+}
+
+func (p *APIProfile) RecommendedFeatures() []string {
+	if p == nil {
+		return []string{"export", "import"}
+	}
+
+	var features []string
+	if p.HighVolume {
+		features = append(features, "sync")
+	}
+	if p.NeedsSearch {
+		features = append(features, "search")
+	}
+	if p.HighVolume || p.NeedsSearch || p.HasDependencies {
+		features = append(features, "store")
+	}
+
+	features = append(features, "export", "import")
+
+	if p.HasRealtime || p.HasChronological {
+		features = append(features, "tail")
+	}
+	if p.HighVolume || p.HasChronological {
+		features = append(features, "analytics")
+	}
+
+	return features
+}
+
+func featureIdeaFor(name string, p *APIProfile) vision.FeatureIdea {
+	switch name {
+	case "sync":
+		return scoredFeature(
+			"sync",
+			"Continuously mirror paginated resources into a local cache for fast bulk access.",
+			[]string{"sync.go.tmpl"},
+			2, 3, 2, 1, 2, 3, 2, 1,
+		)
+	case "search":
+		return scoredFeature(
+			"search",
+			"Search across locally indexed records when the upstream API lacks a dedicated search endpoint.",
+			[]string{"search.go.tmpl"},
+			2, 3, 2, 1, 2, 3, 2, 1,
+		)
+	case "store":
+		return scoredFeature(
+			"store",
+			"Persist fetched records locally to support repeat access, joins, and offline work.",
+			[]string{"store.go.tmpl"},
+			2, 2, 3, 1, 2, 2, 2, 1,
+		)
+	case "export":
+		return scoredFeature(
+			"export",
+			"Export API records into shell-friendly formats for scripting and archival.",
+			[]string{"export.go.tmpl"},
+			1, 2, 3, 1, 2, 1, 3, 1,
+		)
+	case "import":
+		return scoredFeature(
+			"import",
+			"Import records from files or stdin to support bootstrap and migration workflows.",
+			[]string{"import.go.tmpl"},
+			1, 2, 3, 1, 2, 1, 3, 1,
+		)
+	case "tail":
+		return scoredFeature(
+			"tail",
+			"Tail event-like resources to inspect API activity as it happens.",
+			[]string{"tail.go.tmpl"},
+			2, 3, 2, 1, 1, dataFit(p.HasRealtime || p.HasChronological), 2, 1,
+		)
+	case "analytics":
+		return scoredFeature(
+			"analytics",
+			"Run local analytics over synced records to summarize high-volume or historical activity.",
+			[]string{"analytics.go.tmpl"},
+			2, 2, 2, 1, 2, dataFit(p.HighVolume || p.HasChronological), 2, 1,
+		)
+	default:
+		return vision.FeatureIdea{Name: name}
+	}
+}
+
+func scoredFeature(name, description string, templates []string, evidence, impact, feasibility, uniqueness, composability, fit, maintainability, moat int) vision.FeatureIdea {
+	return vision.FeatureIdea{
+		Name:                      name,
+		Description:               description,
+		EvidenceStrength:          evidence,
+		UserImpact:                impact,
+		ImplementationFeasibility: feasibility,
+		Uniqueness:                uniqueness,
+		Composability:             composability,
+		DataProfileFit:            fit,
+		Maintainability:           maintainability,
+		CompetitiveMoat:           moat,
+		TemplateNames:             templates,
+	}
+}
+
+func lowHigh(v bool) string {
+	if v {
+		return "high"
+	}
+	return "low"
+}
+
+func dataFit(v bool) int {
+	if v {
+		return 3
+	}
+	return 1
+}
+
+func isListEndpoint(name string, endpoint spec.Endpoint) bool {
+	if strings.ToUpper(endpoint.Method) != "GET" {
+		return false
+	}
+	if endpoint.Pagination != nil {
+		return true
+	}
+	if endpoint.Response.Type == "array" {
+		return true
+	}
+
+	name = strings.ToLower(name)
+	return containsAny(name, []string{"list", "all"})
+}
+
+func hasLifecycleField(params []spec.Param) bool {
+	for _, param := range params {
+		if isLifecycleParam(param) {
+			return true
+		}
+		if hasLifecycleField(param.Fields) {
+			return true
+		}
+	}
+	return false
+}
+
+func isLifecycleParam(param spec.Param) bool {
+	name := strings.ToLower(param.Name)
+	return (name == "status" || name == "state") && len(param.Enum) >= 3
+}
+
+func hasFileBody(params []spec.Param) bool {
+	for _, param := range params {
+		if strings.EqualFold(param.Type, "file") || strings.EqualFold(param.Format, "binary") {
+			return true
+		}
+		if hasFileBody(param.Fields) {
+			return true
+		}
+	}
+	return false
+}
+
+func hasDependency(params []spec.Param, resourceNames map[string]struct{}) bool {
+	for _, param := range params {
+		name := strings.ToLower(param.Name)
+		if strings.HasSuffix(name, "_id") && strings.EqualFold(param.Type, "string") {
+			prefix := strings.TrimSuffix(name, "_id")
+			if matchesResource(prefix, resourceNames) {
+				return true
+			}
+		}
+		if hasDependency(param.Fields, resourceNames) {
+			return true
+		}
+	}
+	return false
+}
+
+func matchesResource(name string, resourceNames map[string]struct{}) bool {
+	for _, variant := range nameVariants(name) {
+		if _, ok := resourceNames[variant]; ok {
+			return true
+		}
+	}
+	return false
+}
+
+func collectResourceNames(resources map[string]spec.Resource) map[string]struct{} {
+	names := make(map[string]struct{})
+
+	var walk func(name string, r spec.Resource)
+	walk = func(name string, r spec.Resource) {
+		for _, variant := range nameVariants(name) {
+			names[variant] = struct{}{}
+		}
+		for subName, sub := range r.SubResources {
+			walk(subName, sub)
+		}
+	}
+
+	for name, resource := range resources {
+		walk(name, resource)
+	}
+
+	return names
+}
+
+func nameVariants(name string) []string {
+	normalized := normalizeName(name)
+	if normalized == "" {
+		return nil
+	}
+
+	seen := map[string]struct{}{normalized: {}}
+	var variants []string
+	variants = append(variants, normalized)
+
+	if strings.HasSuffix(normalized, "ies") {
+		addVariant(normalized[:len(normalized)-3]+"y", seen, &variants)
+	}
+	if strings.HasSuffix(normalized, "es") {
+		addVariant(strings.TrimSuffix(normalized, "es"), seen, &variants)
+	}
+	if strings.HasSuffix(normalized, "s") {
+		addVariant(strings.TrimSuffix(normalized, "s"), seen, &variants)
+	}
+
+	return variants
+}
+
+func addVariant(variant string, seen map[string]struct{}, variants *[]string) {
+	if variant == "" {
+		return
+	}
+	if _, ok := seen[variant]; ok {
+		return
+	}
+	seen[variant] = struct{}{}
+	*variants = append(*variants, variant)
+}
+
+func normalizeName(name string) string {
+	replacer := strings.NewReplacer("-", "_", " ", "_")
+	return strings.Trim(replacer.Replace(strings.ToLower(name)), "_")
+}
+
+func collectStringFields(params []spec.Param) []string {
+	fields := make(map[string]struct{})
+	var walk func(items []spec.Param)
+	walk = func(items []spec.Param) {
+		for _, param := range items {
+			if strings.EqualFold(param.Type, "string") {
+				fields[param.Name] = struct{}{}
+			}
+			if len(param.Fields) > 0 {
+				walk(param.Fields)
+			}
+		}
+	}
+	walk(params)
+	return sortedKeys(fields)
+}
+
+func hasChronologicalParams(params []spec.Param) bool {
+	for _, param := range params {
+		name := strings.ToLower(param.Name)
+		desc := strings.ToLower(param.Description)
+
+		if name == "since" || name == "until" || name == "before" || name == "after" {
+			return true
+		}
+		if strings.Contains(name, "timestamp") || strings.Contains(name, "created_at") || strings.Contains(name, "updated_at") {
+			return true
+		}
+		if (strings.Contains(name, "sort") || strings.Contains(name, "order")) &&
+			(strings.Contains(desc, "time") || strings.Contains(desc, "date") || strings.Contains(desc, "timestamp") || strings.Contains(desc, "created")) {
+			return true
+		}
+		if hasChronologicalParams(param.Fields) {
+			return true
+		}
+	}
+	return false
+}
+
+func containsAny(s string, needles []string) bool {
+	for _, needle := range needles {
+		if strings.Contains(s, needle) {
+			return true
+		}
+	}
+	return false
+}
+
+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
+}
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
new file mode 100644
index 00000000..680b9756
--- /dev/null
+++ b/internal/profiler/profiler_test.go
@@ -0,0 +1,368 @@
+package profiler
+
+import (
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestProfilePetstore(t *testing.T) {
+	profile := Profile(petstoreSpec())
+
+	assert.False(t, profile.HighVolume)
+	assert.False(t, profile.NeedsSearch)
+	assert.False(t, profile.HasRealtime)
+	assert.Equal(t, []string{"export", "import"}, profile.RecommendedFeatures())
+}
+
+func TestProfileDiscord(t *testing.T) {
+	profile := Profile(discordSpec())
+
+	assert.True(t, profile.HighVolume)
+	assert.True(t, profile.NeedsSearch)
+	assert.True(t, profile.HasRealtime)
+	assert.True(t, profile.HasChronological)
+	assert.True(t, profile.HasDependencies)
+	assert.ElementsMatch(t, []string{"sync", "search", "store", "export", "import", "tail", "analytics"}, profile.RecommendedFeatures())
+	assert.Contains(t, profile.SyncableResources, "messages")
+	assert.Contains(t, profile.SearchableFields["messages"], "content")
+}
+
+func TestProfileMinimal(t *testing.T) {
+	profile := Profile(minimalSpec())
+
+	assert.False(t, profile.HighVolume)
+	assert.False(t, profile.NeedsSearch)
+	assert.False(t, profile.HasRealtime)
+	assert.False(t, profile.HasChronological)
+	assert.False(t, profile.HasDependencies)
+	assert.Zero(t, profile.CRUDResources)
+	assert.Equal(t, []string{"export", "import"}, profile.RecommendedFeatures())
+}
+
+func TestToVisionaryPlan(t *testing.T) {
+	profile := Profile(discordSpec())
+	plan := profile.ToVisionaryPlan("discord")
+
+	require.NotNil(t, plan)
+	assert.Equal(t, "discord", plan.APIName)
+	assert.Equal(t, "high", plan.Identity.DataProfile.Volume)
+	assert.Equal(t, "high", plan.Identity.DataProfile.SearchNeed)
+	assert.True(t, plan.Identity.DataProfile.Realtime)
+
+	areas := make(map[string]string)
+	for _, decision := range plan.Architecture {
+		areas[decision.Area] = decision.NeedLevel
+	}
+	assert.Equal(t, "high", areas["persistence"])
+	assert.Equal(t, "high", areas["search"])
+	assert.Equal(t, "high", areas["realtime"])
+
+	featureTemplates := make(map[string][]string)
+	for _, feature := range plan.Features {
+		featureTemplates[feature.Name] = feature.TemplateNames
+		assert.GreaterOrEqual(t, feature.TotalScore, 8)
+	}
+	assert.Equal(t, []string{"sync.go.tmpl"}, featureTemplates["sync"])
+	assert.Equal(t, []string{"search.go.tmpl"}, featureTemplates["search"])
+	assert.Equal(t, []string{"store.go.tmpl"}, featureTemplates["store"])
+	assert.Equal(t, []string{"tail.go.tmpl"}, featureTemplates["tail"])
+	assert.Equal(t, []string{"analytics.go.tmpl"}, featureTemplates["analytics"])
+}
+
+func petstoreSpec() *spec.APISpec {
+	return &spec.APISpec{
+		Name: "petstore",
+		Resources: map[string]spec.Resource{
+			"pets": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/pets",
+						Response: spec.ResponseDef{Type: "array"},
+					},
+					"get": {
+						Method:   "GET",
+						Path:     "/pets/{petId}",
+						Response: spec.ResponseDef{Type: "object"},
+					},
+					"create": {
+						Method: "POST",
+						Path:   "/pets",
+						Body: []spec.Param{
+							{Name: "name", Type: "string"},
+							{Name: "status", Type: "string", Enum: []string{"available", "pending", "sold"}},
+						},
+					},
+					"update": {
+						Method: "PUT",
+						Path:   "/pets/{petId}",
+						Body: []spec.Param{
+							{Name: "name", Type: "string"},
+						},
+					},
+					"delete": {
+						Method: "DELETE",
+						Path:   "/pets/{petId}",
+					},
+					"findByStatus": {
+						Method:   "GET",
+						Path:     "/pets/findByStatus",
+						Response: spec.ResponseDef{Type: "array"},
+						Params: []spec.Param{
+							{Name: "status", Type: "string"},
+						},
+					},
+				},
+			},
+			"store": {
+				Endpoints: map[string]spec.Endpoint{
+					"inventory": {
+						Method:   "GET",
+						Path:     "/store/inventory",
+						Response: spec.ResponseDef{Type: "object"},
+					},
+					"order": {
+						Method: "POST",
+						Path:   "/store/order",
+						Body: []spec.Param{
+							{Name: "pet_id", Type: "integer"},
+						},
+					},
+				},
+			},
+			"user": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/users",
+						Response: spec.ResponseDef{Type: "array"},
+					},
+					"get": {
+						Method:   "GET",
+						Path:     "/users/{username}",
+						Response: spec.ResponseDef{Type: "object"},
+					},
+					"create": {
+						Method: "POST",
+						Path:   "/users",
+						Body: []spec.Param{
+							{Name: "username", Type: "string"},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func minimalSpec() *spec.APISpec {
+	return &spec.APISpec{
+		Name: "minimal",
+		Resources: map[string]spec.Resource{
+			"widgets": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/widgets",
+						Response: spec.ResponseDef{Type: "array"},
+					},
+					"get": {
+						Method:   "GET",
+						Path:     "/widgets/{widgetId}",
+						Response: spec.ResponseDef{Type: "object"},
+					},
+				},
+			},
+		},
+	}
+}
+
+func discordSpec() *spec.APISpec {
+	paginatedList := func(path string) spec.Endpoint {
+		return spec.Endpoint{
+			Method:     "GET",
+			Path:       path,
+			Response:   spec.ResponseDef{Type: "array"},
+			Pagination: &spec.Pagination{Type: "cursor", LimitParam: "limit", CursorParam: "before"},
+		}
+	}
+
+	return &spec.APISpec{
+		Name: "discord",
+		Resources: map[string]spec.Resource{
+			"guilds": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/guilds"),
+					"get": {
+						Method:   "GET",
+						Path:     "/guilds/{guild_id}",
+						Response: spec.ResponseDef{Type: "object"},
+					},
+					"create": {
+						Method: "POST",
+						Path:   "/guilds",
+						Body: []spec.Param{
+							{Name: "name", Type: "string"},
+							{Name: "region", Type: "string"},
+							{Name: "status", Type: "string", Enum: []string{"active", "archived", "deleted"}},
+						},
+					},
+					"update": {
+						Method: "PATCH",
+						Path:   "/guilds/{guild_id}",
+						Body: []spec.Param{
+							{Name: "name", Type: "string"},
+							{Name: "state", Type: "string", Enum: []string{"draft", "active", "paused"}},
+						},
+					},
+					"delete": {
+						Method: "DELETE",
+						Path:   "/guilds/{guild_id}",
+					},
+				},
+			},
+			"channels": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/channels"),
+					"create": {
+						Method: "POST",
+						Path:   "/channels",
+						Body: []spec.Param{
+							{Name: "guild_id", Type: "string"},
+							{Name: "name", Type: "string"},
+							{Name: "topic", Type: "string"},
+						},
+					},
+				},
+			},
+			"messages": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/channels/{channel_id}/messages"),
+					"create": {
+						Method: "POST",
+						Path:   "/channels/{channel_id}/messages",
+						Body: []spec.Param{
+							{Name: "channel_id", Type: "string"},
+							{Name: "author_id", Type: "string"},
+							{Name: "content", Type: "string"},
+							{Name: "title", Type: "string"},
+							{Name: "summary", Type: "string"},
+							{Name: "content_type", Type: "string"},
+							{Name: "visibility", Type: "string"},
+							{Name: "status", Type: "string", Enum: []string{"draft", "queued", "sent"}},
+							{Name: "thread_id", Type: "string"},
+							{Name: "reply_to_id", Type: "string"},
+							{Name: "embed_title", Type: "string"},
+							{Name: "embed_description", Type: "string"},
+						},
+					},
+					"upload": {
+						Method: "POST",
+						Path:   "/channels/{channel_id}/attachments",
+						Body: []spec.Param{
+							{Name: "channel_id", Type: "string"},
+							{Name: "file", Type: "file", Format: "binary"},
+						},
+					},
+				},
+			},
+			"members": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/guilds/{guild_id}/members"),
+					"create": {
+						Method: "POST",
+						Path:   "/guilds/{guild_id}/members",
+						Body: []spec.Param{
+							{Name: "guild_id", Type: "string"},
+							{Name: "user_id", Type: "string"},
+							{Name: "nick", Type: "string"},
+						},
+					},
+				},
+			},
+			"roles": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/guilds/{guild_id}/roles"),
+					"create": {
+						Method: "POST",
+						Path:   "/guilds/{guild_id}/roles",
+						Body: []spec.Param{
+							{Name: "guild_id", Type: "string"},
+							{Name: "name", Type: "string"},
+						},
+					},
+				},
+			},
+			"threads": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/channels/{channel_id}/threads"),
+					"create": {
+						Method: "POST",
+						Path:   "/channels/{channel_id}/threads",
+						Body: []spec.Param{
+							{Name: "channel_id", Type: "string"},
+							{Name: "name", Type: "string"},
+						},
+					},
+				},
+			},
+			"reactions": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/channels/{channel_id}/messages/{message_id}/reactions"),
+					"create": {
+						Method: "POST",
+						Path:   "/channels/{channel_id}/messages/{message_id}/reactions",
+						Body: []spec.Param{
+							{Name: "channel_id", Type: "string"},
+							{Name: "message_id", Type: "string"},
+							{Name: "emoji", Type: "string"},
+						},
+					},
+				},
+			},
+			"webhooks": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/webhooks"),
+					"create": {
+						Method: "POST",
+						Path:   "/webhooks",
+						Body: []spec.Param{
+							{Name: "channel_id", Type: "string"},
+							{Name: "callback_url", Type: "string"},
+						},
+					},
+				},
+			},
+			"audit-logs": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:     "GET",
+						Path:       "/guilds/{guild_id}/audit-logs",
+						Response:   spec.ResponseDef{Type: "array"},
+						Pagination: &spec.Pagination{Type: "cursor", LimitParam: "limit", CursorParam: "before"},
+						Params: []spec.Param{
+							{Name: "before", Type: "string", Description: "Return entries before this timestamp"},
+							{Name: "sort", Type: "string", Description: "Sort by created timestamp descending"},
+						},
+					},
+				},
+			},
+			"notifications": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": paginatedList("/users/{user_id}/notifications"),
+					"create": {
+						Method: "POST",
+						Path:   "/users/{user_id}/notifications",
+						Body: []spec.Param{
+							{Name: "user_id", Type: "string"},
+							{Name: "message", Type: "string"},
+						},
+					},
+				},
+			},
+		},
+	}
+}

← 5c6840d5 fix(scorecard): replace presence checks with quality-based s  ·  back to Cli Printing Press  ·  feat(generator): wire vision templates into Generate() bba88f65 →