[object Object]

← back to Cli Printing Press

feat(scaffold): initial project structure with CLI skeleton

454739bd04333318247c1fb4c4d57cf47237b072 · 2026-03-23 09:01:56 -0700 · Matt Van Horn

Go module, Cobra CLI with generate/version commands, spec parser
structs, and generator stub. Compiles and runs.

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

Files touched

Diff

commit 454739bd04333318247c1fb4c4d57cf47237b072
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Mon Mar 23 09:01:56 2026 -0700

    feat(scaffold): initial project structure with CLI skeleton
    
    Go module, Cobra CLI with generate/version commands, spec parser
    structs, and generator stub. Compiles and runs.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 cmd/printing-press/main.go      |  15 +++++
 go.mod                          |  13 +++++
 go.sum                          |  13 +++++
 internal/cli/root.go            |  81 +++++++++++++++++++++++++++
 internal/generator/generator.go |  19 +++++++
 internal/spec/spec.go           | 120 ++++++++++++++++++++++++++++++++++++++++
 6 files changed, 261 insertions(+)

diff --git a/cmd/printing-press/main.go b/cmd/printing-press/main.go
new file mode 100644
index 00000000..06d27333
--- /dev/null
+++ b/cmd/printing-press/main.go
@@ -0,0 +1,15 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/mvanhorn/cli-printing-press/internal/cli"
+)
+
+func main() {
+	if err := cli.Execute(); err != nil {
+		fmt.Fprintln(os.Stderr, err.Error())
+		os.Exit(1)
+	}
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 00000000..d46c2082
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,13 @@
+module github.com/mvanhorn/cli-printing-press
+
+go 1.26.1
+
+require (
+	github.com/spf13/cobra v1.10.2
+	gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/spf13/pflag v1.0.9 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 00000000..47edb24d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,13 @@
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/cli/root.go b/internal/cli/root.go
new file mode 100644
index 00000000..0b7c80e0
--- /dev/null
+++ b/internal/cli/root.go
@@ -0,0 +1,81 @@
+package cli
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/mvanhorn/cli-printing-press/internal/generator"
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/spf13/cobra"
+)
+
+var version = "0.1.0"
+
+func Execute() error {
+	rootCmd := &cobra.Command{
+		Use:           "printing-press",
+		Short:         "Describe your API. Get a production CLI.",
+		SilenceUsage:  true,
+		SilenceErrors: true,
+		Version:       version,
+	}
+	rootCmd.SetVersionTemplate("printing-press {{.Version}}\n")
+
+	rootCmd.AddCommand(newGenerateCmd())
+	rootCmd.AddCommand(newVersionCmd())
+
+	return rootCmd.Execute()
+}
+
+func newGenerateCmd() *cobra.Command {
+	var specFile string
+	var outputDir string
+
+	cmd := &cobra.Command{
+		Use:   "generate",
+		Short: "Generate a Go CLI project from an API spec",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if specFile == "" {
+				return fmt.Errorf("--spec is required")
+			}
+
+			apiSpec, err := spec.Parse(specFile)
+			if err != nil {
+				return fmt.Errorf("parsing spec: %w", err)
+			}
+
+			if outputDir == "" {
+				outputDir = apiSpec.Name + "-cli"
+			}
+
+			absOut, err := filepath.Abs(outputDir)
+			if err != nil {
+				return fmt.Errorf("resolving output path: %w", err)
+			}
+
+			gen := generator.New(apiSpec, absOut)
+			if err := gen.Generate(); err != nil {
+				return fmt.Errorf("generating project: %w", err)
+			}
+
+			fmt.Fprintf(os.Stderr, "Generated %s-cli at %s\n", apiSpec.Name, absOut)
+			return nil
+		},
+	}
+
+	cmd.Flags().StringVar(&specFile, "spec", "", "Path to API spec YAML file (required)")
+	cmd.Flags().StringVar(&outputDir, "output", "", "Output directory (default: <name>-cli)")
+
+	return cmd
+}
+
+func newVersionCmd() *cobra.Command {
+	return &cobra.Command{
+		Use:   "version",
+		Short: "Print version",
+		Run: func(cmd *cobra.Command, args []string) {
+			fmt.Printf("printing-press %s\n", version)
+		},
+	}
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
new file mode 100644
index 00000000..75aa5e7a
--- /dev/null
+++ b/internal/generator/generator.go
@@ -0,0 +1,19 @@
+package generator
+
+import (
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+type Generator struct {
+	Spec      *spec.APISpec
+	OutputDir string
+}
+
+func New(s *spec.APISpec, outputDir string) *Generator {
+	return &Generator{Spec: s, OutputDir: outputDir}
+}
+
+func (g *Generator) Generate() error {
+	// TODO: implement in Task 3+4
+	return nil
+}
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
new file mode 100644
index 00000000..1d0f3188
--- /dev/null
+++ b/internal/spec/spec.go
@@ -0,0 +1,120 @@
+package spec
+
+import (
+	"fmt"
+	"os"
+
+	"gopkg.in/yaml.v3"
+)
+
+type APISpec struct {
+	Name        string              `yaml:"name"`
+	Description string              `yaml:"description"`
+	Version     string              `yaml:"version"`
+	BaseURL     string              `yaml:"base_url"`
+	Auth        AuthConfig          `yaml:"auth"`
+	Config      ConfigSpec          `yaml:"config"`
+	Resources   map[string]Resource `yaml:"resources"`
+	Types       map[string]TypeDef  `yaml:"types"`
+}
+
+type AuthConfig struct {
+	Type    string   `yaml:"type"` // api_key, oauth2, bearer_token, none
+	Header  string   `yaml:"header"`
+	Format  string   `yaml:"format"`
+	EnvVars []string `yaml:"env_vars"`
+}
+
+type ConfigSpec struct {
+	Format string `yaml:"format"` // toml, yaml
+	Path   string `yaml:"path"`
+}
+
+type Resource struct {
+	Description string               `yaml:"description"`
+	Endpoints   map[string]Endpoint  `yaml:"endpoints"`
+}
+
+type Endpoint struct {
+	Method      string      `yaml:"method"`
+	Path        string      `yaml:"path"`
+	Description string      `yaml:"description"`
+	Params      []Param     `yaml:"params"`
+	Body        []Param     `yaml:"body"`
+	Response    ResponseDef `yaml:"response"`
+	Pagination  *Pagination `yaml:"pagination"`
+}
+
+type Param struct {
+	Name        string  `yaml:"name"`
+	Type        string  `yaml:"type"`
+	Required    bool    `yaml:"required"`
+	Positional  bool    `yaml:"positional"`
+	Default     any     `yaml:"default"`
+	Description string  `yaml:"description"`
+	Fields      []Param `yaml:"fields"` // for nested objects
+}
+
+type ResponseDef struct {
+	Type string `yaml:"type"` // object, array
+	Item string `yaml:"item"` // type name
+}
+
+type Pagination struct {
+	Type         string `yaml:"type"`          // cursor, offset, page_token
+	CursorField  string `yaml:"cursor_field"`
+	HasMoreField string `yaml:"has_more_field"`
+}
+
+type TypeDef struct {
+	Fields []TypeField `yaml:"fields"`
+}
+
+type TypeField struct {
+	Name string `yaml:"name"`
+	Type string `yaml:"type"`
+}
+
+func Parse(path string) (*APISpec, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("reading file: %w", err)
+	}
+
+	var s APISpec
+	if err := yaml.Unmarshal(data, &s); err != nil {
+		return nil, fmt.Errorf("parsing yaml: %w", err)
+	}
+
+	if err := s.Validate(); err != nil {
+		return nil, fmt.Errorf("validation: %w", err)
+	}
+
+	return &s, nil
+}
+
+func (s *APISpec) Validate() error {
+	if s.Name == "" {
+		return fmt.Errorf("name is required")
+	}
+	if s.BaseURL == "" {
+		return fmt.Errorf("base_url is required")
+	}
+	if len(s.Resources) == 0 {
+		return fmt.Errorf("at least one resource is required")
+	}
+	for name, r := range s.Resources {
+		if len(r.Endpoints) == 0 {
+			return fmt.Errorf("resource %q has no endpoints", name)
+		}
+		for eName, e := range r.Endpoints {
+			if e.Method == "" {
+				return fmt.Errorf("resource %q endpoint %q: method is required", name, eName)
+			}
+			if e.Path == "" {
+				return fmt.Errorf("resource %q endpoint %q: path is required", name, eName)
+			}
+		}
+	}
+	return nil
+}

(oldest)  ·  back to Cli Printing Press  ·  feat(spec): YAML spec parser with validation and Stytch test b4ab56ba →