← 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
M internal/generator/generator_test.goM internal/generator/templates/client.go.tmplD internal/generator/templates/command.go.tmplM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/import.go.tmplM internal/generator/templates/mcp_tools.go.tmpl
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 →