[object Object]

← back to Cli Printing Press

feat(cli): --deliver routes command output to file or webhook

f6e74931b899c2188ca6e8b9833717f0aa158d04 · 2026-04-17 23:26:03 -0400 · Matt Van Horn

Every printed CLI gains a persistent --deliver flag that routes command
output to one of three sinks: stdout (default), file:<path>, or
webhook:<url>. File sinks write atomically via tmp+rename; webhook
sinks POST the body as application/json (or application/x-ndjson when
--compact is set).

Unknown schemes produce a structured refusal naming the supported set
so agents do not silently misroute. Webhook failures surface the URL
and HTTP status on stderr and return non-zero.

Mapped from HeyGen's "fewer steps between agent output and a finished
artifact" framing (2026-04-13).

Files touched

Diff

commit f6e74931b899c2188ca6e8b9833717f0aa158d04
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Fri Apr 17 23:26:03 2026 -0400

    feat(cli): --deliver routes command output to file or webhook
    
    Every printed CLI gains a persistent --deliver flag that routes command
    output to one of three sinks: stdout (default), file:<path>, or
    webhook:<url>. File sinks write atomically via tmp+rename; webhook
    sinks POST the body as application/json (or application/x-ndjson when
    --compact is set).
    
    Unknown schemes produce a structured refusal naming the supported set
    so agents do not silently misroute. Webhook failures surface the URL
    and HTTP status on stderr and return non-zero.
    
    Mapped from HeyGen's "fewer steps between agent output and a finished
    artifact" framing (2026-04-13).
---
 internal/generator/generator.go              |   1 +
 internal/generator/generator_test.go         |   7 +-
 internal/generator/templates/deliver.go.tmpl | 113 +++++++++++++++++++++++++++
 internal/generator/templates/root.go.tmpl    |  27 +++++++
 internal/generator/templates/skill.md.tmpl   |  12 +++
 5 files changed, 157 insertions(+), 3 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index fcb1c1c1..43930667 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -660,6 +660,7 @@ func (g *Generator) Generate() error {
 		"doctor.go.tmpl":         filepath.Join("internal", "cli", "doctor.go"),
 		"agent_context.go.tmpl":  filepath.Join("internal", "cli", "agent_context.go"),
 		"profile.go.tmpl":        filepath.Join("internal", "cli", "profile.go"),
+		"deliver.go.tmpl":        filepath.Join("internal", "cli", "deliver.go"),
 		"config.go.tmpl":         filepath.Join("internal", "config", "config.go"),
 		"cache.go.tmpl":          filepath.Join("internal", "cache", "cache.go"),
 		"client.go.tmpl":         filepath.Join("internal", "client", "client.go"),
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index bdbaed21..e6d9c938 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -27,9 +27,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		// +3 for cliutil package: fanout.go, text.go, cliutil_test.go
 		// +1 for internal/cli/agent_context.go (Cloudflare-style runtime introspection)
 		// +1 for internal/cli/profile.go (HeyGen-style named-profile system)
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 37},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 41},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 41},
+		// +1 for internal/cli/deliver.go (HeyGen-style --deliver output routing)
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 38},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 42},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 42},
 	}
 
 	for _, tt := range tests {
diff --git a/internal/generator/templates/deliver.go.tmpl b/internal/generator/templates/deliver.go.tmpl
new file mode 100644
index 00000000..6f2c4fd9
--- /dev/null
+++ b/internal/generator/templates/deliver.go.tmpl
@@ -0,0 +1,113 @@
+// 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 (
+	"bytes"
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+)
+
+// DeliverSink describes where command output should be routed when
+// --deliver is set. Parsed from the sink specifier "scheme:target".
+type DeliverSink struct {
+	Scheme string
+	Target string
+}
+
+// ParseDeliverSink parses a --deliver value. Supported schemes:
+//   stdout          -> default, no redirection
+//   file:<path>     -> write output atomically to <path>
+//   webhook:<url>   -> POST output body to <url>
+//
+// Returns an error for unknown schemes with a message naming the
+// supported set, so agents see a structured refusal rather than a
+// silent misroute.
+func ParseDeliverSink(spec string) (DeliverSink, error) {
+	if spec == "" || spec == "stdout" {
+		return DeliverSink{Scheme: "stdout"}, nil
+	}
+	idx := strings.Index(spec, ":")
+	if idx == -1 {
+		return DeliverSink{}, fmt.Errorf("unknown --deliver sink %q: expected scheme:target (supported: stdout, file:<path>, webhook:<url>)", spec)
+	}
+	scheme := spec[:idx]
+	target := spec[idx+1:]
+	switch scheme {
+	case "file":
+		if target == "" {
+			return DeliverSink{}, fmt.Errorf("--deliver file:<path> requires a path")
+		}
+	case "webhook":
+		if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
+			return DeliverSink{}, fmt.Errorf("--deliver webhook:<url> requires an http:// or https:// URL, got %q", target)
+		}
+	default:
+		return DeliverSink{}, fmt.Errorf("unknown --deliver scheme %q (supported: stdout, file, webhook)", scheme)
+	}
+	return DeliverSink{Scheme: scheme, Target: target}, nil
+}
+
+// Deliver routes a captured output buffer to the configured sink. stdout
+// is a no-op because the buffer has already been streamed to stdout via
+// the MultiWriter set up in root.go.
+func Deliver(sink DeliverSink, body []byte, compact bool) error {
+	switch sink.Scheme {
+	case "", "stdout":
+		return nil
+	case "file":
+		return deliverFile(sink.Target, body)
+	case "webhook":
+		return deliverWebhook(sink.Target, body, compact)
+	default:
+		return fmt.Errorf("unsupported deliver sink %q", sink.Scheme)
+	}
+}
+
+func deliverFile(path string, body []byte) error {
+	// Atomic write: tmp + rename. Protects agents from seeing a partial
+	// file if the process is interrupted mid-write.
+	dir := filepath.Dir(path)
+	if dir != "" && dir != "." {
+		if err := os.MkdirAll(dir, 0o755); err != nil {
+			return fmt.Errorf("creating deliver dir: %w", err)
+		}
+	}
+	tmp := path + ".tmp"
+	if err := os.WriteFile(tmp, body, 0o600); err != nil {
+		return fmt.Errorf("writing deliver tmp: %w", err)
+	}
+	if err := os.Rename(tmp, path); err != nil {
+		return fmt.Errorf("replacing deliver file: %w", err)
+	}
+	return nil
+}
+
+func deliverWebhook(url string, body []byte, compact bool) error {
+	contentType := "application/json"
+	if compact {
+		contentType = "application/x-ndjson"
+	}
+	req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+	if err != nil {
+		return fmt.Errorf("building webhook request: %w", err)
+	}
+	req.Header.Set("Content-Type", contentType)
+	req.Header.Set("User-Agent", "{{.Name}}-pp-cli/deliver")
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(req)
+	if err != nil {
+		return fmt.Errorf("posting to webhook: %w", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode >= 400 {
+		return fmt.Errorf("webhook returned %s", resp.Status)
+	}
+	return nil
+}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 6dbe0fb6..b128c3a5 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -4,8 +4,11 @@
 package cli
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
+	"io"
+	"os"
 	"strings"
 	"text/tabwriter"
 	"time"
@@ -31,9 +34,15 @@ type rootFlags struct {
 	selectFields string
 	configPath   string
 	profileName  string
+	deliverSpec  string
 	timeout      time.Duration
 	rateLimit    float64
 	dataSource   string
+
+	// deliverBuf captures command output when --deliver is set to a
+	// non-stdout sink. Flushed to the sink after Execute returns.
+	deliverBuf  *bytes.Buffer
+	deliverSink DeliverSink
 }
 
 // Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.
@@ -102,6 +111,7 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 	rootCmd.PersistentFlags().BoolVar(&flags.agent, "agent", false, "Set all agent-friendly defaults (--json --compact --no-input --no-color --yes)")
 	rootCmd.PersistentFlags().StringVar(&flags.dataSource, "data-source", "auto", "Data source for read commands: auto (live with local fallback), live (API only), local (synced data only)")
 	rootCmd.PersistentFlags().StringVar(&flags.profileName, "profile", "", "Apply values from a saved profile (see '{{.Name}}-pp-cli profile list')")
+	rootCmd.PersistentFlags().StringVar(&flags.deliverSpec, "deliver", "", "Route output to a sink: stdout (default), file:<path>, webhook:<url>")
 {{- if eq .SpecSource "sniffed"}}
 	rootCmd.PersistentFlags().Float64Var(&flags.rateLimit, "rate-limit", 2, "Max requests per second (0 to disable, default 2 for sniffed APIs)")
 {{- else}}
@@ -109,6 +119,17 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 {{- end}}
 
 	rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
+		if flags.deliverSpec != "" {
+			sink, err := ParseDeliverSink(flags.deliverSpec)
+			if err != nil {
+				return err
+			}
+			flags.deliverSink = sink
+			if sink.Scheme != "stdout" && sink.Scheme != "" {
+				flags.deliverBuf = &bytes.Buffer{}
+				cmd.SetOut(io.MultiWriter(os.Stdout, flags.deliverBuf))
+			}
+		}
 		if flags.profileName != "" {
 			profile, err := GetProfile(flags.profileName)
 			if err != nil {
@@ -209,6 +230,12 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 			}
 		}
 	}
+	if err == nil && flags.deliverBuf != nil {
+		if derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {
+			fmt.Fprintf(os.Stderr, "warning: deliver to %s:%s failed: %v\n", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)
+			return derr
+		}
+	}
 	return err
 }
 
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index ccf56bc6..e11f7e9e 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -146,6 +146,18 @@ Add `--agent` to any command. Expands to: `--json --compact --no-input --no-colo
 - **Cacheable** — GET responses cached for 5 minutes, bypass with `--no-cache`
 - **Non-interactive** — never prompts, every input is a flag
 
+## Output Delivery
+
+Every command accepts `--deliver <sink>`. The output goes to the named sink in addition to (or instead of) stdout, so agents can route command results without hand-piping. Three sinks are supported:
+
+| Sink | Effect |
+|------|--------|
+| `stdout` | Default; write to stdout only |
+| `file:<path>` | Atomically write output to `<path>` (tmp + rename) |
+| `webhook:<url>` | POST the output body to the URL (`application/json` or `application/x-ndjson` when `--compact`) |
+
+Unknown schemes are refused with a structured error naming the supported set. Webhook failures return non-zero and log the URL + HTTP status on stderr.
+
 ## Named Profiles
 
 A profile is a saved set of flag values, reused across invocations. Use it when a scheduled agent calls the same command every run with the same configuration - HeyGen's "Beacon" pattern.

← 7140c3e7 feat(cli): named-profile system for repeatable agent context  ·  back to Cli Printing Press  ·  feat(cli): feedback subcommand for agent-in-band friction re c6223b41 →