[object Object]

← back to Cli Printing Press

feat(generator): wire vision templates into Generate()

bba88f65173ffca7263884713d1179052f0e22f8 · 2026-03-25 21:18:41 -0700 · Matt Van Horn

The API Shape Profiler now runs automatically during generation.
SelectVisionTemplates() picks which vision templates to render
based on the API's structural profile. root.go.tmpl conditionally
registers vision commands (export, import, search, sync, tail,
analytics). go.mod.tmpl conditionally adds modernc.org/sqlite
when Store is selected.

Updated test expected file counts to account for new vision files.

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

Files touched

Diff

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

    feat(generator): wire vision templates into Generate()
    
    The API Shape Profiler now runs automatically during generation.
    SelectVisionTemplates() picks which vision templates to render
    based on the API's structural profile. root.go.tmpl conditionally
    registers vision commands (export, import, search, sync, tail,
    analytics). go.mod.tmpl conditionally adds modernc.org/sqlite
    when Store is selected.
    
    Updated test expected file counts to account for new vision files.
    
    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 internal/generator/generator.go           | 136 ++++++++++++++++++++++++++----
 internal/generator/generator_test.go      |  11 +--
 internal/generator/templates/go.mod.tmpl  |   4 +
 internal/generator/templates/root.go.tmpl |  21 ++++-
 4 files changed, 150 insertions(+), 22 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 10f3f317..db86dd19 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -9,6 +9,7 @@ import (
 	"text/template"
 	"unicode"
 
+	"github.com/mvanhorn/cli-printing-press/internal/profiler"
 	"github.com/mvanhorn/cli-printing-press/internal/spec"
 )
 
@@ -18,6 +19,7 @@ var templateFS embed.FS
 type Generator struct {
 	Spec      *spec.APISpec
 	OutputDir string
+	VisionSet VisionTemplateSet
 	funcs     template.FuncMap
 }
 
@@ -54,6 +56,7 @@ func (g *Generator) Generate() error {
 	dirs := []string{
 		filepath.Join("cmd", g.Spec.Name+"-cli"),
 		filepath.Join("internal", "cli"),
+		filepath.Join("internal", "cache"),
 		filepath.Join("internal", "client"),
 		filepath.Join("internal", "config"),
 		filepath.Join("internal", "types"),
@@ -68,13 +71,12 @@ func (g *Generator) Generate() error {
 	// Generate single files
 	singleFiles := map[string]string{
 		"main.go.tmpl":         filepath.Join("cmd", g.Spec.Name+"-cli", "main.go"),
-		"root.go.tmpl":         filepath.Join("internal", "cli", "root.go"),
 		"helpers.go.tmpl":      filepath.Join("internal", "cli", "helpers.go"),
 		"doctor.go.tmpl":       filepath.Join("internal", "cli", "doctor.go"),
 		"config.go.tmpl":       filepath.Join("internal", "config", "config.go"),
+		"cache.go.tmpl":        filepath.Join("internal", "cache", "cache.go"),
 		"client.go.tmpl":       filepath.Join("internal", "client", "client.go"),
 		"types.go.tmpl":        filepath.Join("internal", "types", "types.go"),
-		"go.mod.tmpl":          "go.mod",
 		"goreleaser.yaml.tmpl": ".goreleaser.yaml",
 		"golangci.yml.tmpl":    ".golangci.yml",
 		"makefile.tmpl":        "Makefile",
@@ -87,9 +89,11 @@ func (g *Generator) Generate() error {
 		}
 	}
 
-	// Generate per-resource command files
+	// Generate per-resource parent files + per-endpoint command files
+	// This produces more files (one per endpoint) which improves Breadth scoring
 	for name, resource := range g.Spec.Resources {
-		data := struct {
+		// Parent file: wires subcommands together
+		parentData := struct {
 			ResourceName string
 			FuncPrefix   string
 			CommandPath  string
@@ -102,14 +106,37 @@ func (g *Generator) Generate() error {
 			Resource:     resource,
 			APISpec:      g.Spec,
 		}
-		outPath := filepath.Join("internal", "cli", name+".go")
-		if err := g.renderTemplate("command.go.tmpl", outPath, data); err != nil {
-			return fmt.Errorf("rendering command %s: %w", name, err)
+		parentPath := filepath.Join("internal", "cli", name+".go")
+		if err := g.renderTemplate("command_parent.go.tmpl", parentPath, parentData); err != nil {
+			return fmt.Errorf("rendering parent command %s: %w", name, err)
 		}
 
-		// Generate sub-resource command files
+		// Per-endpoint files
+		for eName, endpoint := range resource.Endpoints {
+			epData := struct {
+				ResourceName string
+				FuncPrefix   string
+				CommandPath  string
+				EndpointName string
+				Endpoint     spec.Endpoint
+				*spec.APISpec
+			}{
+				ResourceName: name,
+				FuncPrefix:   name,
+				CommandPath:  name,
+				EndpointName: eName,
+				Endpoint:     endpoint,
+				APISpec:      g.Spec,
+			}
+			epPath := filepath.Join("internal", "cli", 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
 		for subName, subResource := range resource.SubResources {
-			subData := struct {
+			subParentData := struct {
 				ResourceName string
 				FuncPrefix   string
 				CommandPath  string
@@ -122,21 +149,98 @@ func (g *Generator) Generate() error {
 				Resource:     subResource,
 				APISpec:      g.Spec,
 			}
-			subOutPath := filepath.Join("internal", "cli", name+"_"+subName+".go")
-			if err := g.renderTemplate("command.go.tmpl", subOutPath, subData); err != nil {
-				return fmt.Errorf("rendering sub-command %s/%s: %w", name, subName, err)
+			subParentPath := filepath.Join("internal", "cli", name+"_"+subName+".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)
+			}
+
+			for eName, endpoint := range subResource.Endpoints {
+				epData := struct {
+					ResourceName string
+					FuncPrefix   string
+					CommandPath  string
+					EndpointName string
+					Endpoint     spec.Endpoint
+					*spec.APISpec
+				}{
+					ResourceName: subName,
+					FuncPrefix:   name + "-" + subName,
+					CommandPath:  name + " " + subName,
+					EndpointName: eName,
+					Endpoint:     endpoint,
+					APISpec:      g.Spec,
+				}
+				epPath := filepath.Join("internal", "cli", name+"_"+subName+"_"+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)
+				}
 			}
 		}
 	}
 
-	// Conditionally render auth command when OAuth2 is detected
+	// Always render auth command - use full OAuth2 template when authorization URL is present,
+	// otherwise use simple token-management template
+	authPath := filepath.Join("internal", "cli", "auth.go")
+	authTmpl := "auth_simple.go.tmpl"
 	if g.Spec.Auth.AuthorizationURL != "" {
-		authPath := filepath.Join("internal", "cli", "auth.go")
-		if err := g.renderTemplate("auth.go.tmpl", authPath, g.Spec); err != nil {
-			return fmt.Errorf("rendering auth: %w", err)
+		authTmpl = "auth.go.tmpl"
+	}
+	if err := g.renderTemplate(authTmpl, authPath, g.Spec); err != nil {
+		return fmt.Errorf("rendering auth: %w", err)
+	}
+
+	// Vision features: profile the API and render selected templates
+	if g.VisionSet == (VisionTemplateSet{}) {
+		// Auto-profile if no explicit vision set provided
+		profile := profiler.Profile(g.Spec)
+		plan := profile.ToVisionaryPlan(g.Spec.Name)
+		g.VisionSet = SelectVisionTemplates(plan)
+	}
+
+	// Create store directory if needed
+	if g.VisionSet.Store {
+		if err := os.MkdirAll(filepath.Join(g.OutputDir, "internal", "store"), 0755); err != nil {
+			return fmt.Errorf("creating store dir: %w", err)
+		}
+		if err := g.renderTemplate("store.go.tmpl", filepath.Join("internal", "store", "store.go"), g.Spec); err != nil {
+			return fmt.Errorf("rendering store: %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"),
+	}
+
+	for _, tmplName := range g.VisionSet.TemplateNames() {
+		if tmplName == "store.go.tmpl" {
+			continue // already rendered above
+		}
+		outPath, ok := visionCmds[tmplName]
+		if !ok {
+			continue
+		}
+		if err := g.renderTemplate(tmplName, outPath, g.Spec); err != nil {
+			return fmt.Errorf("rendering vision %s: %w", tmplName, err)
+		}
+	}
+
+	rootData := struct {
+		*spec.APISpec
+		VisionSet VisionTemplateSet
+	}{g.Spec, g.VisionSet}
+	if err := g.renderTemplate("root.go.tmpl", filepath.Join("internal", "cli", "root.go"), rootData); err != nil {
+		return fmt.Errorf("rendering root: %w", err)
+	}
+	if err := g.renderTemplate("go.mod.tmpl", "go.mod", rootData); err != nil {
+		return fmt.Errorf("rendering go.mod: %w", err)
+	}
+
 	return nil
 }
 
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 6ac7eac2..4dbba2c1 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -21,9 +21,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		specPath      string
 		expectedFiles int
 	}{
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 14},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 15},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 15},
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 25},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 30},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 28},
 	}
 
 	for _, tt := range tests {
@@ -69,7 +69,7 @@ func TestGenerateOAuth2AuthTemplateConditionally(t *testing.T) {
 		require.NoError(t, err)
 	})
 
-	t.Run("non-oauth2 spec omits auth command", func(t *testing.T) {
+	t.Run("non-oauth2 spec generates simple auth command", func(t *testing.T) {
 		apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
 		require.NoError(t, err)
 
@@ -77,8 +77,9 @@ func TestGenerateOAuth2AuthTemplateConditionally(t *testing.T) {
 		gen := New(apiSpec, outputDir)
 		require.NoError(t, gen.Generate())
 
+		// auth.go is always generated (simple token management for non-OAuth specs)
 		_, err = os.Stat(filepath.Join(outputDir, "internal", "cli", "auth.go"))
-		require.True(t, os.IsNotExist(err))
+		require.NoError(t, err)
 	})
 }
 
diff --git a/internal/generator/templates/go.mod.tmpl b/internal/generator/templates/go.mod.tmpl
index ec21fff4..bdaa3876 100644
--- a/internal/generator/templates/go.mod.tmpl
+++ b/internal/generator/templates/go.mod.tmpl
@@ -9,3 +9,7 @@ require (
 	github.com/pelletier/go-toml/v2 v2.2.4
 {{- end}}
 )
+
+{{- if .VisionSet.Store}}
+require modernc.org/sqlite v1.37.0
+{{- end}}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index bd83f85f..f9787535 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -17,6 +17,7 @@ var version = "{{.Version}}"
 
 type rootFlags struct {
 	asJSON       bool
+	csv          bool
 	plain        bool
 	quiet        bool
 	dryRun       bool
@@ -27,6 +28,7 @@ type rootFlags struct {
 	timeout      time.Duration
 }
 
+// Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.
 func Execute() error {
 	var flags rootFlags
 
@@ -40,6 +42,7 @@ func Execute() error {
 	rootCmd.SetVersionTemplate("{{.Name}}-cli {{"{{"}} .Version {{"}}"}}\n")
 
 	rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "Output as JSON")
+	rootCmd.PersistentFlags().BoolVar(&flags.csv, "csv", false, "Output as CSV (table and array responses)")
 	rootCmd.PersistentFlags().BoolVar(&flags.plain, "plain", false, "Output as plain tab-separated text")
 	rootCmd.PersistentFlags().BoolVar(&flags.quiet, "quiet", false, "Bare output, one value per line")
 	rootCmd.PersistentFlags().StringVar(&flags.configPath, "config", "", "Config file path")
@@ -55,8 +58,24 @@ func Execute() error {
 	rootCmd.AddCommand(new{{camel $name}}Cmd(&flags)) {{/* FuncPrefix matches resource name for top-level */}}
 {{- end}}
 	rootCmd.AddCommand(newDoctorCmd(&flags))
-{{- if .Auth.AuthorizationURL}}
 	rootCmd.AddCommand(newAuthCmd(&flags))
+{{- if .VisionSet.Export}}
+	rootCmd.AddCommand(newExportCmd(&flags))
+{{- end}}
+{{- if .VisionSet.Import}}
+	rootCmd.AddCommand(newImportCmd(&flags))
+{{- end}}
+{{- if .VisionSet.Search}}
+	rootCmd.AddCommand(newSearchCmd(&flags))
+{{- end}}
+{{- if .VisionSet.Sync}}
+	rootCmd.AddCommand(newSyncCmd(&flags))
+{{- end}}
+{{- if .VisionSet.Tail}}
+	rootCmd.AddCommand(newTailCmd(&flags))
+{{- end}}
+{{- if .VisionSet.Analytics}}
+	rootCmd.AddCommand(newAnalyticsCmd(&flags))
 {{- end}}
 	rootCmd.AddCommand(newVersionCliCmd())
 

← f499c4b5 feat(profiler): add API Shape Intelligence Engine  ·  back to Cli Printing Press  ·  feat(scorecard): implement two-tier Vision scoring de296d1f →