[object Object]

← back to Cli Printing Press

feat(templates): Go templates for all generated CLI files

a78f09790928b24c0925fe9330cf0c7d3f88ad79 · 2026-03-23 09:06:35 -0700 · Matt Van Horn

Templates for: main.go, root.go, helpers.go, doctor.go, config.go,
client.go, command.go (per-resource), types.go, go.mod, goreleaser,
golangci, Makefile, README. Based on Steinberger patterns from
wacli/discrawl/sag source code analysis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit a78f09790928b24c0925fe9330cf0c7d3f88ad79
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Mon Mar 23 09:06:35 2026 -0700

    feat(templates): Go templates for all generated CLI files
    
    Templates for: main.go, root.go, helpers.go, doctor.go, config.go,
    client.go, command.go (per-resource), types.go, go.mod, goreleaser,
    golangci, Makefile, README. Based on Steinberger patterns from
    wacli/discrawl/sag source code analysis.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/templates/client.go.tmpl       | 101 ++++++++++++++++++++++++++
 internal/templates/command.go.tmpl      | 125 ++++++++++++++++++++++++++++++++
 internal/templates/config.go.tmpl       |  87 ++++++++++++++++++++++
 internal/templates/doctor.go.tmpl       |  69 ++++++++++++++++++
 internal/templates/go.mod.tmpl          |  10 +++
 internal/templates/golangci.yml.tmpl    |  12 +++
 internal/templates/goreleaser.yaml.tmpl |  27 +++++++
 internal/templates/helpers.go.tmpl      |  37 ++++++++++
 internal/templates/main.go.tmpl         |  15 ++++
 internal/templates/makefile.tmpl        |  16 ++++
 internal/templates/readme.md.tmpl       |  44 +++++++++++
 internal/templates/root.go.tmpl         | 108 +++++++++++++++++++++++++++
 internal/templates/types.go.tmpl        |   8 ++
 13 files changed, 659 insertions(+)

diff --git a/internal/templates/client.go.tmpl b/internal/templates/client.go.tmpl
new file mode 100644
index 00000000..5a2391f7
--- /dev/null
+++ b/internal/templates/client.go.tmpl
@@ -0,0 +1,101 @@
+package client
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+)
+
+type Client struct {
+	BaseURL    string
+	AuthHeader string
+	HTTPClient *http.Client
+}
+
+func New(baseURL, authHeader string, timeout time.Duration) *Client {
+	return &Client{
+		BaseURL:    strings.TrimRight(baseURL, "/"),
+		AuthHeader: authHeader,
+		HTTPClient: &http.Client{Timeout: timeout},
+	}
+}
+
+func (c *Client) Get(path string, params map[string]string) (json.RawMessage, error) {
+	return c.do("GET", path, params, nil)
+}
+
+func (c *Client) Post(path string, body any) (json.RawMessage, error) {
+	return c.do("POST", path, nil, body)
+}
+
+func (c *Client) Delete(path string) (json.RawMessage, error) {
+	return c.do("DELETE", path, nil, nil)
+}
+
+func (c *Client) Put(path string, body any) (json.RawMessage, error) {
+	return c.do("PUT", path, nil, body)
+}
+
+func (c *Client) do(method, path string, params map[string]string, body any) (json.RawMessage, error) {
+	url := c.BaseURL + path
+
+	var bodyReader io.Reader
+	if body != nil {
+		b, err := json.Marshal(body)
+		if err != nil {
+			return nil, fmt.Errorf("marshaling body: %w", err)
+		}
+		bodyReader = strings.NewReader(string(b))
+	}
+
+	req, err := http.NewRequest(method, url, bodyReader)
+	if err != nil {
+		return nil, fmt.Errorf("creating request: %w", err)
+	}
+
+	if c.AuthHeader != "" {
+		req.Header.Set("Authorization", c.AuthHeader)
+	}
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	req.Header.Set("User-Agent", "{{.Name}}-cli/{{.Version}}")
+
+	if params != nil {
+		q := req.URL.Query()
+		for k, v := range params {
+			if v != "" {
+				q.Set(k, v)
+			}
+		}
+		req.URL.RawQuery = q.Encode()
+	}
+
+	resp, err := c.HTTPClient.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("%s %s: %w", method, path, err)
+	}
+	defer resp.Body.Close()
+
+	respBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("reading response: %w", err)
+	}
+
+	if resp.StatusCode >= 400 {
+		return nil, fmt.Errorf("%s %s returned HTTP %d: %s", method, path, resp.StatusCode, truncateBody(respBody))
+	}
+
+	return json.RawMessage(respBody), nil
+}
+
+func truncateBody(b []byte) string {
+	s := string(b)
+	if len(s) > 200 {
+		return s[:200] + "..."
+	}
+	return s
+}
diff --git a/internal/templates/command.go.tmpl b/internal/templates/command.go.tmpl
new file mode 100644
index 00000000..892be853
--- /dev/null
+++ b/internal/templates/command.go.tmpl
@@ -0,0 +1,125 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/spf13/cobra"
+)
+
+func new{{title .ResourceName}}Cmd(flags *rootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "{{.ResourceName}}",
+		Short: "{{.Resource.Description}}",
+	}
+{{range $eName, $endpoint := .Resource.Endpoints}}
+	cmd.AddCommand(new{{title $.ResourceName}}{{title $eName}}Cmd(flags))
+{{- end}}
+	return cmd
+}
+{{range $eName, $endpoint := .Resource.Endpoints}}
+func new{{title $.ResourceName}}{{title $eName}}Cmd(flags *rootFlags) *cobra.Command {
+{{- range $endpoint.Params}}
+{{- if not .Positional}}
+	var flag{{camel .Name}} {{goType .Type}}
+{{- end}}
+{{- end}}
+{{- range $endpoint.Body}}
+	var body{{camel .Name}} {{goType .Type}}
+{{- end}}
+
+	cmd := &cobra.Command{
+		Use:   "{{$eName}}{{positionalArgs $endpoint}}",
+		Short: "{{$endpoint.Description}}",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			c, err := flags.newClient()
+			if err != nil {
+				return err
+			}
+
+			path := "{{$endpoint.Path}}"
+{{- range $i, $p := $endpoint.Params}}
+{{- if .Positional}}
+			if len(args) < {{add $i 1}} {
+				return usageErr(fmt.Errorf("{{.Name}} is required"))
+			}
+			path = replacePathParam(path, "{{.Name}}", args[{{$i}}])
+{{- end}}
+{{- end}}
+
+{{- if eq $endpoint.Method "GET"}}
+			params := map[string]string{}
+{{- range $endpoint.Params}}
+{{- if not .Positional}}
+			if flag{{camel .Name}} != {{zeroVal .Type}} {
+				params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel .Name}})
+			}
+{{- end}}
+{{- end}}
+			data, err := c.Get(path, params)
+{{- else if eq $endpoint.Method "POST"}}
+			body := map[string]any{}
+{{- range $endpoint.Body}}
+			if body{{camel .Name}} != {{zeroVal .Type}} {
+				body["{{.Name}}"] = body{{camel .Name}}
+			}
+{{- end}}
+			data, err := c.Post(path, body)
+{{- else if eq $endpoint.Method "DELETE"}}
+			data, err := c.Delete(path)
+{{- else if eq $endpoint.Method "PUT"}}
+			body := map[string]any{}
+{{- range $endpoint.Body}}
+			if body{{camel .Name}} != {{zeroVal .Type}} {
+				body["{{.Name}}"] = body{{camel .Name}}
+			}
+{{- end}}
+			data, err := c.Put(path, body)
+{{- end}}
+			if err != nil {
+				return apiErr(err)
+			}
+
+			if flags.asJSON {
+				return flags.printJSON(cmd, json.RawMessage(data))
+			}
+
+			fmt.Fprintln(cmd.OutOrStdout(), string(data))
+			return nil
+		},
+	}
+
+{{- range $endpoint.Params}}
+{{- if not .Positional}}
+	cmd.Flags().{{cobraFlagFunc .Type}}(&flag{{camel .Name}}, "{{.Name}}", {{defaultVal .}}, "{{.Description}}")
+{{- if .Required}}
+	_ = cmd.MarkFlagRequired("{{.Name}}")
+{{- end}}
+{{- end}}
+{{- end}}
+{{- range $endpoint.Body}}
+	cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel .Name}}, "{{.Name}}", {{defaultVal .}}, "{{.Description}}")
+{{- if .Required}}
+	_ = cmd.MarkFlagRequired("{{.Name}}")
+{{- end}}
+{{- end}}
+
+	return cmd
+}
+{{end}}
+func replacePathParam(path, name, value string) string {
+	return fmt.Sprintf("%s", path[:0]) + // prevent unused import
+		fmt.Sprintf("%s", replaceAll(path, "{"+name+"}", value))
+}
+
+var replaceAll = func(s, old, new string) string {
+	import_strings := "strings"
+	_ = import_strings
+	result := s
+	for i := 0; i < len(result); i++ {
+		if i+len(old) <= len(result) && result[i:i+len(old)] == old {
+			result = result[:i] + new + result[i+len(old):]
+		}
+	}
+	return result
+}
diff --git a/internal/templates/config.go.tmpl b/internal/templates/config.go.tmpl
new file mode 100644
index 00000000..e7166fb1
--- /dev/null
+++ b/internal/templates/config.go.tmpl
@@ -0,0 +1,87 @@
+package config
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+{{- if eq .Config.Format "toml"}}
+
+	"github.com/pelletier/go-toml/v2"
+{{- end}}
+)
+
+type Config struct {
+	BaseURL    string `{{configTag .Config.Format}} :"base_url"`
+	AuthHeader string `{{configTag .Config.Format}} :"auth_header"`
+	AuthSource string `{{configTag .Config.Format}} :"-"`
+	Path       string `{{configTag .Config.Format}} :"-"`
+{{- range .Auth.EnvVars}}
+	{{envVarField .}} string `{{configTag $.Config.Format}} :"{{snake .}}"`
+{{- end}}
+}
+
+func Load(configPath string) (*Config, error) {
+	cfg := &Config{
+		BaseURL: "{{.BaseURL}}",
+	}
+
+	// Resolve config path
+	path := configPath
+	if path == "" {
+		path = os.Getenv("{{upper .Name}}_CONFIG")
+	}
+	if path == "" {
+		home, _ := os.UserHomeDir()
+		path = filepath.Join(home, ".config", "{{.Name}}-cli", "config.{{.Config.Format}}")
+	}
+	cfg.Path = path
+
+	// Try to load config file
+	data, err := os.ReadFile(path)
+	if err == nil {
+{{- if eq .Config.Format "toml"}}
+		if err := toml.Unmarshal(data, cfg); err != nil {
+			return nil, fmt.Errorf("parsing config %s: %w", path, err)
+		}
+{{- end}}
+	}
+
+	// Env var overrides
+{{- range .Auth.EnvVars}}
+	if v := os.Getenv("{{.}}"); v != "" {
+		cfg.{{envVarField .}} = v
+		cfg.AuthSource = "env:{{.}}"
+	}
+{{- end}}
+
+	return cfg, nil
+}
+
+func (c *Config) AuthHeader() string {
+	if c.AuthHeader != "" {
+		return c.AuthHeader
+	}
+{{- if eq .Auth.Type "api_key"}}
+	format := "{{.Auth.Format}}"
+{{- range .Auth.EnvVars}}
+	format = strings.ReplaceAll(format, "{ {{- envVarPlaceholder . -}} }", c.{{envVarField .}})
+{{- end}}
+	if strings.Contains(format, "{") {
+		return "" // not all vars resolved
+	}
+	return format
+{{- else if eq .Auth.Type "bearer_token"}}
+	{{- if gt (len .Auth.EnvVars) 0}}
+	if c.{{envVarField (index .Auth.EnvVars 0)}} != "" {
+		return "Bearer " + c.{{envVarField (index .Auth.EnvVars 0)}}
+	}
+	{{- end}}
+	return ""
+{{- else}}
+	return ""
+{{- end}}
+}
+
+// Ensure strings import is used
+var _ = strings.ReplaceAll
diff --git a/internal/templates/doctor.go.tmpl b/internal/templates/doctor.go.tmpl
new file mode 100644
index 00000000..b52b5853
--- /dev/null
+++ b/internal/templates/doctor.go.tmpl
@@ -0,0 +1,69 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/USER/{{.Name}}-cli/internal/config"
+	"github.com/spf13/cobra"
+)
+
+func newDoctorCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{
+		Use:   "doctor",
+		Short: "Check CLI health",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			report := map[string]any{}
+
+			// Check config
+			cfg, err := config.Load(flags.configPath)
+			if err != nil {
+				report["config"] = fmt.Sprintf("error: %s", err)
+			} else {
+				report["config"] = "ok"
+				report["config_path"] = cfg.Path
+				report["base_url"] = cfg.BaseURL
+			}
+
+			// Check auth
+			if cfg != nil {
+				header := cfg.AuthHeader()
+				if header == "" {
+					report["auth"] = "not configured"
+				} else {
+					report["auth"] = "configured"
+					report["auth_source"] = cfg.AuthSource
+				}
+			}
+
+			// Check API connectivity
+			if cfg != nil && cfg.BaseURL != "" {
+				httpClient := &http.Client{Timeout: 5 * time.Second}
+				resp, err := httpClient.Get(cfg.BaseURL)
+				if err != nil {
+					report["api"] = fmt.Sprintf("unreachable: %s", err)
+				} else {
+					resp.Body.Close()
+					report["api"] = fmt.Sprintf("reachable (HTTP %d)", resp.StatusCode)
+				}
+			}
+
+			report["version"] = version
+
+			if flags.asJSON {
+				return flags.printJSON(cmd, report)
+			}
+
+			// Human-readable output
+			w := cmd.OutOrStdout()
+			for _, key := range []string{"config", "config_path", "base_url", "auth", "auth_source", "api", "version"} {
+				if v, ok := report[key]; ok {
+					fmt.Fprintf(w, "%s=%v\n", key, v)
+				}
+			}
+			return nil
+		},
+	}
+}
diff --git a/internal/templates/go.mod.tmpl b/internal/templates/go.mod.tmpl
new file mode 100644
index 00000000..16f02592
--- /dev/null
+++ b/internal/templates/go.mod.tmpl
@@ -0,0 +1,10 @@
+module github.com/USER/{{.Name}}-cli
+
+go 1.23
+
+require (
+	github.com/spf13/cobra v1.9.1
+{{- if eq .Config.Format "toml"}}
+	github.com/pelletier/go-toml/v2 v2.2.4
+{{- end}}
+)
diff --git a/internal/templates/golangci.yml.tmpl b/internal/templates/golangci.yml.tmpl
new file mode 100644
index 00000000..5397226d
--- /dev/null
+++ b/internal/templates/golangci.yml.tmpl
@@ -0,0 +1,12 @@
+linters:
+  enable:
+    - errorlint
+    - govet
+    - ineffassign
+    - staticcheck
+    - unused
+
+formatters:
+  enable:
+    - gofmt
+    - goimports
diff --git a/internal/templates/goreleaser.yaml.tmpl b/internal/templates/goreleaser.yaml.tmpl
new file mode 100644
index 00000000..e463f0aa
--- /dev/null
+++ b/internal/templates/goreleaser.yaml.tmpl
@@ -0,0 +1,27 @@
+version: 2
+project_name: {{.Name}}-cli
+changelog:
+  disable: true
+builds:
+  - id: {{.Name}}-cli
+    main: ./cmd/{{.Name}}-cli
+    binary: {{.Name}}-cli
+    env:
+      - CGO_ENABLED=0
+    ldflags:
+      - -s -w -X github.com/USER/{{.Name}}-cli/internal/cli.version={{"{{"}} .Version {{"}}"}}
+    targets:
+      - darwin_amd64
+      - darwin_arm64
+      - linux_amd64
+      - linux_arm64
+      - windows_amd64
+      - windows_arm64
+archives:
+  - formats: [tar.gz]
+    name_template: "{{"{{"}} .ProjectName {{"}}"}}_{{"{{"}} .Version {{"}}"}}_{{"{{"}} .Os {{"}}"}}_{{"{{"}} .Arch {{"}}"}}"
+    format_overrides:
+      - goos: windows
+        formats: [zip]
+checksum:
+  name_template: checksums.txt
diff --git a/internal/templates/helpers.go.tmpl b/internal/templates/helpers.go.tmpl
new file mode 100644
index 00000000..3c0ed8b6
--- /dev/null
+++ b/internal/templates/helpers.go.tmpl
@@ -0,0 +1,37 @@
+package cli
+
+import "errors"
+
+var As = errors.As
+
+type cliError struct {
+	code int
+	err  error
+}
+
+func (e *cliError) Error() string { return e.err.Error() }
+func (e *cliError) Unwrap() error { return e.err }
+
+func usageErr(err error) error  { return &cliError{code: 2, err: err} }
+func configErr(err error) error { return &cliError{code: 3, err: err} }
+func authErr(err error) error   { return &cliError{code: 4, err: err} }
+func apiErr(err error) error    { return &cliError{code: 5, err: err} }
+
+func truncate(s string, max int) string {
+	if len(s) <= max {
+		return s
+	}
+	if max <= 3 {
+		return s[:max]
+	}
+	return s[:max-3] + "..."
+}
+
+func firstNonEmpty(items ...string) string {
+	for _, s := range items {
+		if s != "" {
+			return s
+		}
+	}
+	return ""
+}
diff --git a/internal/templates/main.go.tmpl b/internal/templates/main.go.tmpl
new file mode 100644
index 00000000..b765938e
--- /dev/null
+++ b/internal/templates/main.go.tmpl
@@ -0,0 +1,15 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/USER/{{.Name}}-cli/internal/cli"
+)
+
+func main() {
+	if err := cli.Execute(); err != nil {
+		fmt.Fprintln(os.Stderr, err.Error())
+		os.Exit(cli.ExitCode(err))
+	}
+}
diff --git a/internal/templates/makefile.tmpl b/internal/templates/makefile.tmpl
new file mode 100644
index 00000000..489c5de1
--- /dev/null
+++ b/internal/templates/makefile.tmpl
@@ -0,0 +1,16 @@
+.PHONY: build test lint install clean
+
+build:
+	go build -o bin/{{.Name}}-cli ./cmd/{{.Name}}-cli
+
+test:
+	go test ./...
+
+lint:
+	golangci-lint run
+
+install:
+	go install ./cmd/{{.Name}}-cli
+
+clean:
+	rm -rf bin/
diff --git a/internal/templates/readme.md.tmpl b/internal/templates/readme.md.tmpl
new file mode 100644
index 00000000..8094f59b
--- /dev/null
+++ b/internal/templates/readme.md.tmpl
@@ -0,0 +1,44 @@
+# {{.Name}}-cli
+
+{{.Description}}
+
+## Install
+
+```
+go install github.com/USER/{{.Name}}-cli/cmd/{{.Name}}-cli@latest
+```
+
+## Usage
+
+```
+{{.Name}}-cli --help
+{{.Name}}-cli --version
+{{.Name}}-cli doctor
+```
+
+### Commands
+{{range $name, $resource := .Resources}}
+#### {{$name}}
+
+{{$resource.Description}}
+{{range $eName, $endpoint := $resource.Endpoints}}
+- `{{$.Name}}-cli {{$name}} {{$eName}}` - {{$endpoint.Description}}
+{{- end}}
+{{end}}
+
+### Output Formats
+
+- Default: human-readable table
+- `--json`: JSON output
+- `--plain`: tab-separated text for piping
+
+### Configuration
+
+Config file: `{{.Config.Path}}`
+
+Environment variables:
+{{- range .Auth.EnvVars}}
+- `{{.}}`
+{{- end}}
+
+## Generated by CLI Printing Press
diff --git a/internal/templates/root.go.tmpl b/internal/templates/root.go.tmpl
new file mode 100644
index 00000000..83f41562
--- /dev/null
+++ b/internal/templates/root.go.tmpl
@@ -0,0 +1,108 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"text/tabwriter"
+	"time"
+
+	"github.com/USER/{{.Name}}-cli/internal/client"
+	"github.com/USER/{{.Name}}-cli/internal/config"
+	"github.com/spf13/cobra"
+)
+
+var version = "{{.Version}}"
+
+type rootFlags struct {
+	asJSON     bool
+	plain      bool
+	quiet      bool
+	configPath string
+	timeout    time.Duration
+}
+
+func Execute() error {
+	var flags rootFlags
+
+	rootCmd := &cobra.Command{
+		Use:           "{{.Name}}-cli",
+		Short:         "{{.Description}}",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+		Version:       version,
+	}
+	rootCmd.SetVersionTemplate("{{.Name}}-cli {{"{{"}} .Version {{"}}"}}\n")
+
+	rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "Output as JSON")
+	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")
+	rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 30*time.Second, "Request timeout")
+
+{{- range $name, $resource := .Resources}}
+	rootCmd.AddCommand(new{{title $name}}Cmd(&flags))
+{{- end}}
+	rootCmd.AddCommand(newDoctorCmd(&flags))
+	rootCmd.AddCommand(newVersionCliCmd())
+
+	return rootCmd.Execute()
+}
+
+func ExitCode(err error) int {
+	var codeErr *cliError
+	if As(err, &codeErr) {
+		return codeErr.code
+	}
+	return 1
+}
+
+func (f *rootFlags) newClient() (*client.Client, error) {
+	cfg, err := config.Load(f.configPath)
+	if err != nil {
+		return nil, configErr(err)
+	}
+	return client.New(cfg.BaseURL, cfg.AuthHeader(), f.timeout), nil
+}
+
+func (f *rootFlags) printJSON(w *cobra.Command, v any) error {
+	enc := json.NewEncoder(w.OutOrStdout())
+	enc.SetIndent("", "  ")
+	return enc.Encode(v)
+}
+
+func (f *rootFlags) printTable(w *cobra.Command, headers []string, rows [][]string) error {
+	if f.asJSON {
+		return fmt.Errorf("use printJSON for JSON output")
+	}
+	tw := tabwriter.NewWriter(w.OutOrStdout(), 2, 4, 2, ' ', 0)
+	header := ""
+	for i, h := range headers {
+		if i > 0 {
+			header += "\t"
+		}
+		header += h
+	}
+	fmt.Fprintln(tw, header)
+	for _, row := range rows {
+		line := ""
+		for i, cell := range row {
+			if i > 0 {
+				line += "\t"
+			}
+			line += cell
+		}
+		fmt.Fprintln(tw, line)
+	}
+	return tw.Flush()
+}
+
+func newVersionCliCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "version",
+		Short: "Print version",
+		Run: func(cmd *cobra.Command, args []string) {
+			fmt.Printf("{{.Name}}-cli %s\n", version)
+		},
+	}
+}
diff --git a/internal/templates/types.go.tmpl b/internal/templates/types.go.tmpl
new file mode 100644
index 00000000..545f38c2
--- /dev/null
+++ b/internal/templates/types.go.tmpl
@@ -0,0 +1,8 @@
+package types
+{{range $name, $typeDef := .Types}}
+type {{$name}} struct {
+{{- range $typeDef.Fields}}
+	{{title .Name}} {{goType .Type}} `json:"{{.Name}}"`
+{{- end}}
+}
+{{end}}

← b4ab56ba feat(spec): YAML spec parser with validation and Stytch test  ·  back to Cli Printing Press  ·  feat(validate): quality gates + Clerk/Loops test specs + int 4b510ed7 →