[object Object]

← back to Cli Printing Press

feat(cli): differentiate exit codes by failure type

538e65c4ab0053bed3ccea367012e424325ad3ca · 2026-03-27 13:45:56 -0700 · Trevin Chow

Agents can now branch on exit code without parsing error text:
- 1: user input error (missing/invalid flags)
- 2: spec/data error (file not found, parse failure)
- 3: generation/pipeline failure (build, quality gate)
- 4: unknown/unclassified error (fallback)

Adds ExitError type in internal/cli carrying a typed code, with
errors.As extraction in main.go. Wraps errors at source in all
command RunE functions. Includes guard tests that verify pipeline
error messages stay in sync with the string-matched classification.

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

Files touched

Diff

commit 538e65c4ab0053bed3ccea367012e424325ad3ca
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri Mar 27 13:45:56 2026 -0700

    feat(cli): differentiate exit codes by failure type
    
    Agents can now branch on exit code without parsing error text:
    - 1: user input error (missing/invalid flags)
    - 2: spec/data error (file not found, parse failure)
    - 3: generation/pipeline failure (build, quality gate)
    - 4: unknown/unclassified error (fallback)
    
    Adds ExitError type in internal/cli carrying a typed code, with
    errors.As extraction in main.go. Wraps errors at source in all
    command RunE functions. Includes guard tests that verify pipeline
    error messages stay in sync with the string-matched classification.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 cmd/printing-press/main.go              |   7 ++-
 internal/cli/dogfood.go                 |   2 +-
 internal/cli/exitcodes.go               |  19 ++++++
 internal/cli/exitcodes_pipeline_test.go | 101 ++++++++++++++++++++++++++++++++
 internal/cli/exitcodes_test.go          |  53 +++++++++++++++++
 internal/cli/root.go                    |  32 ++++++----
 internal/cli/scorecard.go               |   4 +-
 internal/cli/vision.go                  |   4 +-
 8 files changed, 204 insertions(+), 18 deletions(-)

diff --git a/cmd/printing-press/main.go b/cmd/printing-press/main.go
index 06d27333..b8f6268c 100644
--- a/cmd/printing-press/main.go
+++ b/cmd/printing-press/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"errors"
 	"fmt"
 	"os"
 
@@ -10,6 +11,10 @@ import (
 func main() {
 	if err := cli.Execute(); err != nil {
 		fmt.Fprintln(os.Stderr, err.Error())
-		os.Exit(1)
+		var exitErr *cli.ExitError
+		if errors.As(err, &exitErr) {
+			os.Exit(exitErr.Code)
+		}
+		os.Exit(cli.ExitUnknownError)
 	}
 }
diff --git a/internal/cli/dogfood.go b/internal/cli/dogfood.go
index 5f6ab6cf..7c953f96 100644
--- a/internal/cli/dogfood.go
+++ b/internal/cli/dogfood.go
@@ -28,7 +28,7 @@ func newDogfoodCmd() *cobra.Command {
 		RunE: func(cmd *cobra.Command, args []string) error {
 			report, err := pipeline.RunDogfood(dir, specPath)
 			if err != nil {
-				return fmt.Errorf("running dogfood: %w", err)
+				return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("running dogfood: %w", err)}
 			}
 
 			if asJSON {
diff --git a/internal/cli/exitcodes.go b/internal/cli/exitcodes.go
new file mode 100644
index 00000000..10501d21
--- /dev/null
+++ b/internal/cli/exitcodes.go
@@ -0,0 +1,19 @@
+package cli
+
+// Exit codes for structured error reporting.
+const (
+	ExitSuccess         = 0
+	ExitInputError      = 1
+	ExitSpecError       = 2
+	ExitGenerationError = 3
+	ExitUnknownError    = 4
+)
+
+// ExitError wraps an error with a specific exit code.
+type ExitError struct {
+	Code int
+	Err  error
+}
+
+func (e *ExitError) Error() string { return e.Err.Error() }
+func (e *ExitError) Unwrap() error { return e.Err }
diff --git a/internal/cli/exitcodes_pipeline_test.go b/internal/cli/exitcodes_pipeline_test.go
new file mode 100644
index 00000000..25a13e44
--- /dev/null
+++ b/internal/cli/exitcodes_pipeline_test.go
@@ -0,0 +1,101 @@
+package cli_test
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/cli"
+	"github.com/mvanhorn/cli-printing-press/internal/pipeline"
+)
+
+// TestPrintCmd_AlreadyExists_ExitCode verifies that the "already exists"
+// error from pipeline.Init still contains the substring matched by the
+// print command's exit-code classification (root.go). If someone edits the
+// pipeline error message and this test breaks, the exit-code switch must
+// be updated to match.
+func TestPrintCmd_AlreadyExists_ExitCode(t *testing.T) {
+	orig, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	tmp := t.TempDir()
+	if err := os.Chdir(tmp); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { os.Chdir(orig) })
+
+	// Create a fake state file so StateExists returns true.
+	apiName := "exitcode-test"
+	pipeDir := filepath.Join("docs", "plans", apiName+"-pipeline")
+	if err := os.MkdirAll(pipeDir, 0o755); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.WriteFile(filepath.Join(pipeDir, "state.json"), []byte("{}"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	_, initErr := pipeline.Init(apiName, pipeline.Options{})
+	if initErr == nil {
+		t.Fatal("expected error from pipeline.Init when state already exists")
+	}
+
+	if !strings.Contains(initErr.Error(), "already exists") {
+		t.Errorf("pipeline.Init collision error no longer contains %q; exit-code classification in root.go will misroute this error.\ngot: %s", "already exists", initErr.Error())
+	}
+
+	// Simulate what the print command does: classify and verify the code.
+	msg := initErr.Error()
+	var exitErr *cli.ExitError
+	switch {
+	case strings.Contains(msg, "already exists"):
+		exitErr = &cli.ExitError{Code: cli.ExitInputError, Err: initErr}
+	case strings.Contains(msg, "discovering spec"):
+		exitErr = &cli.ExitError{Code: cli.ExitSpecError, Err: initErr}
+	default:
+		exitErr = &cli.ExitError{Code: cli.ExitGenerationError, Err: initErr}
+	}
+
+	if exitErr.Code != cli.ExitInputError {
+		t.Errorf("expected ExitInputError (%d) for collision, got %d", cli.ExitInputError, exitErr.Code)
+	}
+
+	// Verify errors.As works through the wrapper.
+	var extracted *cli.ExitError
+	if !errors.As(exitErr, &extracted) {
+		t.Fatal("errors.As should extract ExitError")
+	}
+	if extracted.Code != cli.ExitInputError {
+		t.Errorf("extracted code = %d, want %d", extracted.Code, cli.ExitInputError)
+	}
+}
+
+// TestPipelineInitDiscoverSpec_ErrorSubstring verifies that a spec discovery
+// failure from pipeline.Init contains "discovering spec", which the print
+// command's exit-code classification depends on. This test uses a bogus API
+// name with no catalog entry and no network to trigger the failure.
+func TestPipelineInitDiscoverSpec_ErrorSubstring(t *testing.T) {
+	orig, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	tmp := t.TempDir()
+	if err := os.Chdir(tmp); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { os.Chdir(orig) })
+
+	// Use a name that won't match any known spec and will fail discovery.
+	_, initErr := pipeline.Init("zzz-nonexistent-api-exitcode-test", pipeline.Options{})
+	if initErr == nil {
+		t.Skip("pipeline.Init succeeded unexpectedly (network may have resolved the name)")
+	}
+
+	if !strings.Contains(initErr.Error(), "discovering spec") {
+		t.Errorf("pipeline.Init spec-discovery error no longer contains %q; exit-code classification in root.go will misroute this error.\ngot: %s", "discovering spec", initErr.Error())
+	}
+}
diff --git a/internal/cli/exitcodes_test.go b/internal/cli/exitcodes_test.go
new file mode 100644
index 00000000..aa026336
--- /dev/null
+++ b/internal/cli/exitcodes_test.go
@@ -0,0 +1,53 @@
+package cli
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+)
+
+func TestExitError_Error(t *testing.T) {
+	err := &ExitError{Code: ExitSpecError, Err: fmt.Errorf("spec not found")}
+	if err.Error() != "spec not found" {
+		t.Errorf("got %q, want %q", err.Error(), "spec not found")
+	}
+}
+
+func TestExitError_Unwrap(t *testing.T) {
+	inner := fmt.Errorf("inner error")
+	err := &ExitError{Code: ExitInputError, Err: fmt.Errorf("wrapping: %w", inner)}
+	if !errors.Is(err, inner) {
+		t.Error("errors.Is should find inner error through ExitError")
+	}
+}
+
+func TestExitError_As(t *testing.T) {
+	err := &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("build failed")}
+	wrapped := fmt.Errorf("command failed: %w", err)
+
+	var exitErr *ExitError
+	if !errors.As(wrapped, &exitErr) {
+		t.Fatal("errors.As should extract ExitError from wrapped error")
+	}
+	if exitErr.Code != ExitGenerationError {
+		t.Errorf("got code %d, want %d", exitErr.Code, ExitGenerationError)
+	}
+}
+
+func TestExitCodes_Values(t *testing.T) {
+	if ExitSuccess != 0 {
+		t.Errorf("ExitSuccess = %d, want 0", ExitSuccess)
+	}
+	if ExitInputError != 1 {
+		t.Errorf("ExitInputError = %d, want 1", ExitInputError)
+	}
+	if ExitSpecError != 2 {
+		t.Errorf("ExitSpecError = %d, want 2", ExitSpecError)
+	}
+	if ExitGenerationError != 3 {
+		t.Errorf("ExitGenerationError = %d, want 3", ExitGenerationError)
+	}
+	if ExitUnknownError != 4 {
+		t.Errorf("ExitUnknownError = %d, want 4", ExitUnknownError)
+	}
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 9d3d8637..49abdd70 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -97,16 +97,16 @@ func newGenerateCmd() *cobra.Command {
 					docSpec, err = docspec.GenerateFromDocs(docsURL, apiName)
 				}
 				if err != nil {
-					return fmt.Errorf("generating spec from docs: %w", err)
+					return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("generating spec from docs: %w", err)}
 				}
 				docYAML, err := yaml.Marshal(docSpec)
 				if err != nil {
-					return fmt.Errorf("marshaling doc spec: %w", err)
+					return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("marshaling doc spec: %w", err)}
 				}
 				// Re-parse through the standard path so validation is consistent
 				parsed, err := spec.ParseBytes(docYAML)
 				if err != nil {
-					return fmt.Errorf("parsing generated spec: %w", err)
+					return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("parsing generated spec: %w", err)}
 				}
 
 				if outputDir == "" {
@@ -129,11 +129,11 @@ func newGenerateCmd() *cobra.Command {
 
 				gen := generator.New(parsed, absOut)
 				if err := gen.Generate(); err != nil {
-					return fmt.Errorf("generating project: %w", err)
+					return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("generating project: %w", err)}
 				}
 				if validate {
 					if err := gen.Validate(); err != nil {
-						return fmt.Errorf("validating generated project: %w", err)
+						return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("validating generated project: %w", err)}
 					}
 				}
 
@@ -169,14 +169,14 @@ func newGenerateCmd() *cobra.Command {
 			}
 
 			if len(specFiles) == 0 {
-				return fmt.Errorf("--spec is required")
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--spec is required")}
 			}
 
 			var specs []*spec.APISpec
 			for _, specFile := range specFiles {
 				data, err := readSpec(specFile, refresh, dryRun)
 				if err != nil {
-					return fmt.Errorf("reading spec %s: %w", specFile, err)
+					return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("reading spec %s: %w", specFile, err)}
 				}
 
 				var apiSpec *spec.APISpec
@@ -192,7 +192,7 @@ func newGenerateCmd() *cobra.Command {
 					apiSpec, err = spec.ParseBytes(data)
 				}
 				if err != nil {
-					return fmt.Errorf("parsing spec %s: %w", specFile, err)
+					return &ExitError{Code: ExitSpecError, Err: fmt.Errorf("parsing spec %s: %w", specFile, err)}
 				}
 
 				specs = append(specs, apiSpec)
@@ -203,7 +203,7 @@ func newGenerateCmd() *cobra.Command {
 				apiSpec = specs[0]
 			} else {
 				if cliName == "" {
-					return fmt.Errorf("--name is required when using multiple specs")
+					return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--name is required when using multiple specs")}
 				}
 				apiSpec = mergeSpecs(specs, cliName)
 			}
@@ -232,11 +232,11 @@ func newGenerateCmd() *cobra.Command {
 
 			gen := generator.New(apiSpec, absOut)
 			if err := gen.Generate(); err != nil {
-				return fmt.Errorf("generating project: %w", err)
+				return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("generating project: %w", err)}
 			}
 			if validate {
 				if err := gen.Validate(); err != nil {
-					return fmt.Errorf("validating generated project: %w", err)
+					return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("validating generated project: %w", err)}
 				}
 			}
 
@@ -435,7 +435,15 @@ func newPrintCmd() *cobra.Command {
 				Resume:    resume,
 			})
 			if err != nil {
-				return err
+				msg := err.Error()
+				switch {
+				case strings.Contains(msg, "already exists"):
+					return &ExitError{Code: ExitInputError, Err: err}
+				case strings.Contains(msg, "discovering spec"):
+					return &ExitError{Code: ExitSpecError, Err: err}
+				default:
+					return &ExitError{Code: ExitGenerationError, Err: err}
+				}
 			}
 
 			fmt.Fprintf(os.Stderr, "Pipeline created for %s\n", apiName)
diff --git a/internal/cli/scorecard.go b/internal/cli/scorecard.go
index 8c884b73..8779bbe2 100644
--- a/internal/cli/scorecard.go
+++ b/internal/cli/scorecard.go
@@ -24,7 +24,7 @@ func newScorecardCmd() *cobra.Command {
   printing-press scorecard --dir ./generated/stripe-cli --json`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if dir == "" {
-				return fmt.Errorf("--dir is required")
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--dir is required")}
 			}
 
 			// Use a temp pipeline dir for the scorecard output
@@ -36,7 +36,7 @@ func newScorecardCmd() *cobra.Command {
 
 			sc, err := pipeline.RunScorecard(dir, pipelineDir, specPath)
 			if err != nil {
-				return fmt.Errorf("running scorecard: %w", err)
+				return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("running scorecard: %w", err)}
 			}
 
 			if asJSON {
diff --git a/internal/cli/vision.go b/internal/cli/vision.go
index c51ca1e0..ad8eaddf 100644
--- a/internal/cli/vision.go
+++ b/internal/cli/vision.go
@@ -28,7 +28,7 @@ The vision command produces the structure; Phase 0 fills it with intelligence.`,
   printing-press vision --api stripe --output ./research`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if apiName == "" {
-				return fmt.Errorf("--api is required")
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("--api is required")}
 			}
 			if outputDir == "" {
 				outputDir = apiName + "-cli"
@@ -74,7 +74,7 @@ The vision command produces the structure; Phase 0 fills it with intelligence.`,
 			}
 
 			if err := vision.WriteReport(plan, absOut); err != nil {
-				return fmt.Errorf("writing visionary research: %w", err)
+				return &ExitError{Code: ExitGenerationError, Err: fmt.Errorf("writing visionary research: %w", err)}
 			}
 
 			fmt.Fprintf(os.Stderr, "Visionary research template written to %s/visionary-research.md\n", absOut)

← b494c3b8 feat(cli): add --dry-run flag to generate command  ·  back to Cli Printing Press  ·  docs: add testing guidance to AGENTS.md b86384b1 →