← 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
M internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/templates/deliver.go.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/skill.md.tmpl
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 →