[object Object]

← back to Cli Printing Press

feat(templates): add structured confirmation envelope for mutating commands

d91566f9c785c5359965affb70eb640853fca3dd · 2026-03-27 14:40:54 -0700 · Trevin Chow

Generated CLIs now wrap POST/PUT/PATCH/DELETE responses in a confirmation
envelope when JSON output is active (--json flag or piped stdout). The
envelope provides action, resource, path, status code, and success fields
so agents can confirm what mutation occurred without parsing API-specific
response shapes.

Key behaviors:
- --select and --compact filter the inner data before wrapping
- --quiet suppresses envelope output entirely
- --dry-run produces envelope with dry_run:true, status:0, success:false
- GET/HEAD commands are unchanged
- Non-JSON output paths are unchanged

Also removes dead command.go.tmpl (superseded by command_endpoint.go.tmpl)
and makes client methods return HTTP status codes for mutating operations.

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

Files touched

Diff

commit d91566f9c785c5359965affb70eb640853fca3dd
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri Mar 27 14:40:54 2026 -0700

    feat(templates): add structured confirmation envelope for mutating commands
    
    Generated CLIs now wrap POST/PUT/PATCH/DELETE responses in a confirmation
    envelope when JSON output is active (--json flag or piped stdout). The
    envelope provides action, resource, path, status code, and success fields
    so agents can confirm what mutation occurred without parsing API-specific
    response shapes.
    
    Key behaviors:
    - --select and --compact filter the inner data before wrapping
    - --quiet suppresses envelope output entirely
    - --dry-run produces envelope with dry_run:true, status:0, success:false
    - GET/HEAD commands are unchanged
    - Non-JSON output paths are unchanged
    
    Also removes dead command.go.tmpl (superseded by command_endpoint.go.tmpl)
    and makes client methods return HTTP status codes for mutating operations.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator_test.go               |  49 +++++
 internal/generator/templates/client.go.tmpl        |  32 ++--
 internal/generator/templates/command.go.tmpl       | 198 ---------------------
 .../generator/templates/command_endpoint.go.tmpl   |  46 ++++-
 internal/generator/templates/import.go.tmpl        |   2 +-
 internal/generator/templates/mcp_tools.go.tmpl     |   8 +-
 6 files changed, 112 insertions(+), 223 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 152d81ef..2b965183 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -287,3 +287,52 @@ func TestGeneratedOutput_READMEHasQuickStart(t *testing.T) {
 	assert.Contains(t, content, "Output Formats")
 	assert.Contains(t, content, "Agent Usage")
 }
+
+func TestGeneratedOutput_MutatingCommandsHaveEnvelope(t *testing.T) {
+	t.Parallel()
+
+	outputDir := generatePetstore(t)
+
+	// POST command should have confirmation envelope
+	addGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "pet_add.go"))
+	require.NoError(t, err)
+	content := string(addGo)
+	assert.Contains(t, content, `envelope := map[string]any{`)
+	assert.Contains(t, content, `"action":`)
+	assert.Contains(t, content, `"resource":`)
+	assert.Contains(t, content, `"status":   statusCode`)
+	assert.Contains(t, content, `"success":  statusCode >= 200 && statusCode < 300`)
+	// Envelope fires on both --json and auto-JSON (piped/non-TTY)
+	assert.Contains(t, content, `flags.asJSON || !isTerminal(cmd.OutOrStdout())`)
+
+	// --quiet is respected before envelope output
+	assert.Contains(t, content, "if flags.quiet {")
+
+	// --select and --compact are applied to inner data before wrapping in envelope
+	assert.Contains(t, content, "filtered := data")
+	assert.Contains(t, content, "compactFields(filtered)")
+	assert.Contains(t, content, "filterFields(filtered, flags.selectFields)")
+	assert.Contains(t, content, `json.Unmarshal(filtered, &parsed)`)
+
+	// Envelope bypasses printOutputWithFlags to avoid double-filtering
+	assert.Contains(t, content, `printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)`)
+
+	// Dry-run is flagged honestly in the envelope
+	assert.Contains(t, content, `flags.dryRun`)
+	assert.Contains(t, content, `envelope["dry_run"] = true`)
+	assert.Contains(t, content, `envelope["status"] = 0`)
+	assert.Contains(t, content, `envelope["success"] = false`)
+}
+
+func TestGeneratedOutput_GetCommandsLackEnvelope(t *testing.T) {
+	t.Parallel()
+
+	outputDir := generatePetstore(t)
+
+	// GET command should NOT have confirmation envelope
+	getGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "pet_get-by-id.go"))
+	require.NoError(t, err)
+	content := string(getGo)
+	assert.NotContains(t, content, "envelope")
+	assert.NotContains(t, content, "statusCode")
+}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index daf6cea5..c2e1a022 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -60,7 +60,7 @@ func (c *Client) Get(path string, params map[string]string) (json.RawMessage, er
 			return cached, nil
 		}
 	}
-	result, err := c.do("GET", path, params, nil)
+	result, _, err := c.do("GET", path, params, nil)
 	if err == nil && !c.NoCache && c.cacheDir != "" {
 		c.writeCache(path, params, result)
 	}
@@ -95,30 +95,30 @@ func (c *Client) writeCache(path string, params map[string]string, data json.Raw
 	os.WriteFile(cacheFile, []byte(data), 0o644)
 }
 
-func (c *Client) Post(path string, body any) (json.RawMessage, error) {
+func (c *Client) Post(path string, body any) (json.RawMessage, int, error) {
 	return c.do("POST", path, nil, body)
 }
 
-func (c *Client) Delete(path string) (json.RawMessage, error) {
+func (c *Client) Delete(path string) (json.RawMessage, int, error) {
 	return c.do("DELETE", path, nil, nil)
 }
 
-func (c *Client) Put(path string, body any) (json.RawMessage, error) {
+func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
 	return c.do("PUT", path, nil, body)
 }
 
-func (c *Client) Patch(path string, body any) (json.RawMessage, error) {
+func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
 	return c.do("PATCH", path, nil, body)
 }
 
-func (c *Client) do(method, path string, params map[string]string, body any) (json.RawMessage, error) {
+func (c *Client) do(method, path string, params map[string]string, body any) (json.RawMessage, int, error) {
 	url := c.BaseURL + path
 
 	var bodyBytes []byte
 	if body != nil {
 		b, err := json.Marshal(body)
 		if err != nil {
-			return nil, fmt.Errorf("marshaling body: %w", err)
+			return nil, 0, fmt.Errorf("marshaling body: %w", err)
 		}
 		bodyBytes = b
 	}
@@ -139,12 +139,12 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 
 		req, err := http.NewRequest(method, url, bodyReader)
 		if err != nil {
-			return nil, fmt.Errorf("creating request: %w", err)
+			return nil, 0, fmt.Errorf("creating request: %w", err)
 		}
 
 		authHeader, err := c.authHeader()
 		if err != nil {
-			return nil, err
+			return nil, 0, err
 		}
 		if authHeader != "" {
 {{- if and .Auth .Auth.In (eq .Auth.In "query")}}
@@ -180,12 +180,12 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 		respBody, err := io.ReadAll(resp.Body)
 		resp.Body.Close()
 		if err != nil {
-			return nil, fmt.Errorf("reading response: %w", err)
+			return nil, 0, fmt.Errorf("reading response: %w", err)
 		}
 
 		// Success
 		if resp.StatusCode < 400 {
-			return json.RawMessage(respBody), nil
+			return json.RawMessage(respBody), resp.StatusCode, nil
 		}
 
 		apiErr := &APIError{
@@ -214,13 +214,13 @@ func (c *Client) do(method, path string, params map[string]string, body any) (js
 		}
 
 		// Client error or retries exhausted - return the error
-		return nil, apiErr
+		return nil, resp.StatusCode, apiErr
 	}
 
-	return nil, lastErr
+	return nil, 0, lastErr
 }
 
-func (c *Client) dryRun(method, url string, params map[string]string, body []byte) (json.RawMessage, error) {
+func (c *Client) dryRun(method, url string, params map[string]string, body []byte) (json.RawMessage, int, error) {
 	fmt.Fprintf(os.Stderr, "%s %s\n", method, url)
 	if params != nil {
 		for k, v := range params {
@@ -231,7 +231,7 @@ func (c *Client) dryRun(method, url string, params map[string]string, body []byt
 	}
 	authHeader, err := c.authHeader()
 	if err != nil {
-		return nil, err
+		return nil, 0, err
 	}
 	if authHeader != "" {
 		// Mask token for safety
@@ -251,7 +251,7 @@ func (c *Client) dryRun(method, url string, params map[string]string, body []byt
 		}
 	}
 	fmt.Fprintf(os.Stderr, "\n(dry run - no request sent)\n")
-	return json.RawMessage(`{"dry_run": true}`), nil
+	return json.RawMessage(`{"dry_run": true}`), 0, nil
 }
 
 func (c *Client) authHeader() (string, error) {
diff --git a/internal/generator/templates/command.go.tmpl b/internal/generator/templates/command.go.tmpl
deleted file mode 100644
index 21c2b544..00000000
--- a/internal/generator/templates/command.go.tmpl
+++ /dev/null
@@ -1,198 +0,0 @@
-// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
-// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
-
-package cli
-
-import (
-	"encoding/json"
-	"fmt"
-	"io"
-	"os"
-	"strings"
-
-	"github.com/spf13/cobra"
-)
-
-var _ = strings.ReplaceAll // ensure import
-var _ = fmt.Sprintf        // ensure import
-var _ = io.ReadAll         // ensure import
-var _ = os.Stdin           // ensure import
-var _ json.RawMessage      // ensure import
-
-func new{{camel .FuncPrefix}}Cmd(flags *rootFlags) *cobra.Command {
-	cmd := &cobra.Command{
-		Use:   "{{.ResourceName}}",
-		Short: "{{oneline .Resource.Description}}",
-	}
-{{range $eName, $endpoint := .Resource.Endpoints}}
-	cmd.AddCommand(new{{camel $.FuncPrefix}}{{camel $eName}}Cmd(flags))
-{{- end}}
-{{- range $subName, $sub := .Resource.SubResources}}
-	cmd.AddCommand(new{{camel $.FuncPrefix}}{{camel $subName}}Cmd(flags))
-{{- end}}
-	return cmd
-}
-{{range $eName, $endpoint := .Resource.Endpoints}}
-func new{{camel $.FuncPrefix}}{{camel $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}}
-{{- if $endpoint.Pagination}}
-	var flagAll bool
-{{- end}}
-{{- if or (eq $endpoint.Method "POST") (eq $endpoint.Method "PUT") (eq $endpoint.Method "PATCH")}}
-	var stdinBody bool
-{{- end}}
-
-	cmd := &cobra.Command{
-		Use:   "{{$eName}}{{positionalArgs $endpoint}}",
-{{- if $endpoint.Alias}}
-		Aliases: []string{"{{$endpoint.Alias}}"},
-{{- end}}
-		Short: "{{oneline $endpoint.Description}}",
-		Example: "{{exampleLine $.CommandPath $eName $endpoint}}",
-		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 or (eq $endpoint.Method "GET") (eq $endpoint.Method "HEAD")}}
-{{- if $endpoint.Pagination}}
-			data, err := paginatedGet(c, path, map[string]string{
-{{- range $endpoint.Params}}
-{{- if not .Positional}}
-				"{{.Name}}": fmt.Sprintf("%v", flag{{camel .Name}}),
-{{- end}}
-{{- end}}
-			}, flagAll, "{{$endpoint.Pagination.CursorParam}}", "{{$endpoint.Pagination.NextCursorPath}}", "{{$endpoint.Pagination.HasMoreField}}")
-{{- else}}
-			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)
-{{- end}}
-{{- else if eq $endpoint.Method "POST"}}
-			var body map[string]any
-			if stdinBody {
-				stdinData, err := io.ReadAll(os.Stdin)
-				if err != nil {
-					return fmt.Errorf("reading stdin: %w", err)
-				}
-				var jsonBody map[string]any
-				if err := json.Unmarshal(stdinData, &jsonBody); err != nil {
-					return fmt.Errorf("parsing stdin JSON: %w", err)
-				}
-				body = jsonBody
-			} else {
-				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"}}
-			var body map[string]any
-			if stdinBody {
-				stdinData, err := io.ReadAll(os.Stdin)
-				if err != nil {
-					return fmt.Errorf("reading stdin: %w", err)
-				}
-				var jsonBody map[string]any
-				if err := json.Unmarshal(stdinData, &jsonBody); err != nil {
-					return fmt.Errorf("parsing stdin JSON: %w", err)
-				}
-				body = jsonBody
-			} else {
-				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)
-{{- else if eq $endpoint.Method "PATCH"}}
-			var body map[string]any
-			if stdinBody {
-				stdinData, err := io.ReadAll(os.Stdin)
-				if err != nil {
-					return fmt.Errorf("reading stdin: %w", err)
-				}
-				var jsonBody map[string]any
-				if err := json.Unmarshal(stdinData, &jsonBody); err != nil {
-					return fmt.Errorf("parsing stdin JSON: %w", err)
-				}
-				body = jsonBody
-			} else {
-				body = map[string]any{}
-{{- range $endpoint.Body}}
-				if body{{camel .Name}} != {{zeroVal .Type}} {
-					body["{{.Name}}"] = body{{camel .Name}}
-				}
-{{- end}}
-			}
-			data, err := c.Patch(path, body)
-{{- end}}
-{{- if eq $endpoint.Method "DELETE"}}
-			if err != nil {
-				return classifyDeleteError(err)
-			}
-{{- else}}
-			if err != nil {
-				return classifyAPIError(err)
-			}
-{{- end}}
-
-			return printOutput(cmd.OutOrStdout(), data, flags.asJSON)
-		},
-	}
-
-{{- range $endpoint.Params}}
-{{- if not .Positional}}
-	cmd.Flags().{{cobraFlagFunc .Type}}(&flag{{camel .Name}}, "{{flagName .Name}}", {{defaultVal .}}, "{{oneline .Description}}")
-{{- if .Required}}
-	_ = cmd.MarkFlagRequired("{{flagName .Name}}")
-{{- end}}
-{{- end}}
-{{- end}}
-{{- range $endpoint.Body}}
-	cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel .Name}}, "{{flagName .Name}}", {{defaultVal .}}, "{{oneline .Description}}")
-{{- if .Required}}
-	_ = cmd.MarkFlagRequired("{{flagName .Name}}")
-{{- end}}
-{{- end}}
-{{- if $endpoint.Pagination}}
-	cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
-{{- end}}
-{{- if or (eq $endpoint.Method "POST") (eq $endpoint.Method "PUT") (eq $endpoint.Method "PATCH")}}
-	cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
-{{- end}}
-
-	return cmd
-}
-{{end}}
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index dd75e17f..59f198c1 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -98,9 +98,9 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				}
 {{- end}}
 			}
-			data, err := c.Post(path, body)
+			data, statusCode, err := c.Post(path, body)
 {{- else if eq .Endpoint.Method "DELETE"}}
-			data, err := c.Delete(path)
+			data, statusCode, err := c.Delete(path)
 {{- else if eq .Endpoint.Method "PUT"}}
 			var body map[string]any
 			if stdinBody {
@@ -121,7 +121,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				}
 {{- end}}
 			}
-			data, err := c.Put(path, body)
+			data, statusCode, err := c.Put(path, body)
 {{- else if eq .Endpoint.Method "PATCH"}}
 			var body map[string]any
 			if stdinBody {
@@ -142,7 +142,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				}
 {{- end}}
 			}
-			data, err := c.Patch(path, body)
+			data, statusCode, err := c.Patch(path, body)
 {{- end}}
 {{- if eq .Endpoint.Method "DELETE"}}
 			if err != nil {
@@ -154,6 +154,44 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			}
 {{- end}}
 
+{{- if not (or (eq .Endpoint.Method "GET") (eq .Endpoint.Method "HEAD"))}}
+			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
+				if flags.quiet {
+					return nil
+				}
+				// Apply --compact and --select to the API response before wrapping
+				filtered := data
+				if flags.compact {
+					filtered = compactFields(filtered)
+				}
+				if flags.selectFields != "" {
+					filtered = filterFields(filtered, flags.selectFields)
+				}
+				envelope := map[string]any{
+					"action":   "{{lower .Endpoint.Method}}",
+					"resource": "{{.ResourceName}}",
+					"path":     path,
+					"status":   statusCode,
+					"success":  statusCode >= 200 && statusCode < 300,
+				}
+				if flags.dryRun {
+					envelope["dry_run"] = true
+					envelope["status"] = 0
+					envelope["success"] = false
+				}
+				if len(filtered) > 0 {
+					var parsed any
+					if err := json.Unmarshal(filtered, &parsed); err == nil {
+						envelope["data"] = parsed
+					}
+				}
+				envelopeJSON, err := json.Marshal(envelope)
+				if err != nil {
+					return err
+				}
+				return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+			}
+{{- end}}
 			return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
 		},
 	}
diff --git a/internal/generator/templates/import.go.tmpl b/internal/generator/templates/import.go.tmpl
index 3502589c..dbd87b00 100644
--- a/internal/generator/templates/import.go.tmpl
+++ b/internal/generator/templates/import.go.tmpl
@@ -74,7 +74,7 @@ but do not stop the import.`,
 					continue
 				}
 
-				_, err := c.Post(path, body)
+				_, _, err := c.Post(path, body)
 				if err != nil {
 					fmt.Fprintf(os.Stderr, "warning: failed to import record: %v\n", err)
 					failed++
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index b2cff316..1160b1c7 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -136,15 +136,15 @@ func makeAPIHandler(method, pathTemplate string, positionalParams []string) serv
 			data, err = c.Get(path, params)
 		case "POST":
 			body, _ := json.Marshal(req.Params.Arguments)
-			data, err = c.Post(path, body)
+			data, _, err = c.Post(path, body)
 		case "PUT":
 			body, _ := json.Marshal(req.Params.Arguments)
-			data, err = c.Put(path, body)
+			data, _, err = c.Put(path, body)
 		case "PATCH":
 			body, _ := json.Marshal(req.Params.Arguments)
-			data, err = c.Patch(path, body)
+			data, _, err = c.Patch(path, body)
 		case "DELETE":
-			data, err = c.Delete(path)
+			data, _, err = c.Delete(path)
 		default:
 			return mcplib.NewToolResultError("unsupported method: " + method), nil
 		}

← c3eacf94 feat(dogfood): add ExampleCheck to validate help example cor  ·  back to Cli Printing Press  ·  docs(readme): add install link, fix star count, remove self- dbd5b3a2 →