← 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
M cmd/printing-press/main.goM internal/cli/dogfood.goA internal/cli/exitcodes.goA internal/cli/exitcodes_pipeline_test.goA internal/cli/exitcodes_test.goM internal/cli/root.goM internal/cli/scorecard.goM internal/cli/vision.go
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 →