← back to Cli Printing Press
feat(cli): feedback subcommand for agent-in-band friction reports
c6223b41f2f77b0defa9ac5e4a590045cb9ead03 · 2026-04-17 23:35:52 -0400 · Matt Van Horn
Every printed CLI ships 'feedback <text>' and 'feedback list'. Entries
are appended as JSON lines to ~/.<cli>-pp-cli/feedback.jsonl. When
<CLI>_FEEDBACK_ENDPOINT is set and either --send is passed or
<CLI>_FEEDBACK_AUTO_SEND=true, the entry is also POSTed upstream.
Local-only is the default so the feature is safe without configuration.
Upstream POST failures surface on stderr but do not fail the command -
the primary goal was recording the feedback, and the local write
already succeeded.
agent-context JSON surfaces feedback_endpoint_configured so
introspecting agents know whether the upstream channel exists.
Mapped from HeyGen's "send it our way - from you or your agent"
framing (2026-04-13).
Files touched
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/agent_context.go.tmplA internal/generator/templates/feedback.go.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/skill.md.tmpl
Diff
commit c6223b41f2f77b0defa9ac5e4a590045cb9ead03
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Fri Apr 17 23:35:52 2026 -0400
feat(cli): feedback subcommand for agent-in-band friction reports
Every printed CLI ships 'feedback <text>' and 'feedback list'. Entries
are appended as JSON lines to ~/.<cli>-pp-cli/feedback.jsonl. When
<CLI>_FEEDBACK_ENDPOINT is set and either --send is passed or
<CLI>_FEEDBACK_AUTO_SEND=true, the entry is also POSTed upstream.
Local-only is the default so the feature is safe without configuration.
Upstream POST failures surface on stderr but do not fail the command -
the primary goal was recording the feedback, and the local write
already succeeded.
agent-context JSON surfaces feedback_endpoint_configured so
introspecting agents know whether the upstream channel exists.
Mapped from HeyGen's "send it our way - from you or your agent"
framing (2026-04-13).
---
internal/generator/generator.go | 1 +
internal/generator/generator_test.go | 7 +-
internal/generator/templates/agent_context.go.tmpl | 16 +-
internal/generator/templates/feedback.go.tmpl | 222 +++++++++++++++++++++
internal/generator/templates/root.go.tmpl | 1 +
internal/generator/templates/skill.md.tmpl | 14 ++
6 files changed, 251 insertions(+), 10 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 43930667..357f69c6 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -661,6 +661,7 @@ func (g *Generator) Generate() error {
"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"),
+ "feedback.go.tmpl": filepath.Join("internal", "cli", "feedback.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 e6d9c938..0011dd4b 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -28,9 +28,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
// +1 for internal/cli/agent_context.go (Cloudflare-style runtime introspection)
// +1 for internal/cli/profile.go (HeyGen-style named-profile system)
// +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},
+ // +1 for internal/cli/feedback.go (HeyGen-style in-band agent feedback channel)
+ {name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 39},
+ {name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 43},
+ {name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 43},
}
for _, tt := range tests {
diff --git a/internal/generator/templates/agent_context.go.tmpl b/internal/generator/templates/agent_context.go.tmpl
index 97f691b3..3f16dee0 100644
--- a/internal/generator/templates/agent_context.go.tmpl
+++ b/internal/generator/templates/agent_context.go.tmpl
@@ -22,11 +22,12 @@ const agentContextSchemaVersion = "1"
// (2026-04-13 Wrangler post): agents can introspect the live CLI without
// parsing --help or reading source.
type agentContext struct {
- SchemaVersion string `json:"schema_version"`
- CLI agentContextCLI `json:"cli"`
- Auth agentContextAuth `json:"auth"`
- Commands []agentContextCommand `json:"commands"`
- AvailableProfiles []string `json:"available_profiles"`
+ SchemaVersion string `json:"schema_version"`
+ CLI agentContextCLI `json:"cli"`
+ Auth agentContextAuth `json:"auth"`
+ Commands []agentContextCommand `json:"commands"`
+ AvailableProfiles []string `json:"available_profiles"`
+ FeedbackEndpointConfigured bool `json:"feedback_endpoint_configured"`
}
type agentContextCLI struct {
@@ -101,8 +102,9 @@ func buildAgentContext(rootCmd *cobra.Command) agentContext {
Mode: authMode,
EnvVars: envVars,
},
- Commands: collectAgentCommands(rootCmd),
- AvailableProfiles: profiles,
+ Commands: collectAgentCommands(rootCmd),
+ AvailableProfiles: profiles,
+ FeedbackEndpointConfigured: FeedbackEndpointConfigured(),
}
}
diff --git a/internal/generator/templates/feedback.go.tmpl b/internal/generator/templates/feedback.go.tmpl
new file mode 100644
index 00000000..63457513
--- /dev/null
+++ b/internal/generator/templates/feedback.go.tmpl
@@ -0,0 +1,222 @@
+// 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"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+)
+
+// FeedbackEntry is one line in the local feedback ledger. Every run of
+// the feedback command appends one entry; upstream POST is a separate,
+// optional step.
+type FeedbackEntry struct {
+ Text string `json:"text"`
+ CLI string `json:"cli"`
+ Version string `json:"version"`
+ AgentID string `json:"agent_id,omitempty"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+const feedbackMaxTextLen = 4096
+
+func feedbackFilePath() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home dir: %w", err)
+ }
+ dir := filepath.Join(home, ".{{.Name}}-pp-cli")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", fmt.Errorf("creating state dir: %w", err)
+ }
+ return filepath.Join(dir, "feedback.jsonl"), nil
+}
+
+// FeedbackEndpointConfigured reports whether an upstream feedback URL
+// is available. Surfaced via agent-context so introspecting agents know
+// whether their feedback will ship upstream.
+func FeedbackEndpointConfigured() bool {
+ return os.Getenv("{{envName .Name}}_FEEDBACK_ENDPOINT") != ""
+}
+
+func feedbackEndpoint() string {
+ return os.Getenv("{{envName .Name}}_FEEDBACK_ENDPOINT")
+}
+
+func feedbackAutoSend() bool {
+ v := strings.ToLower(strings.TrimSpace(os.Getenv("{{envName .Name}}_FEEDBACK_AUTO_SEND")))
+ return v == "1" || v == "true" || v == "yes"
+}
+
+func appendFeedback(entry FeedbackEntry) error {
+ p, err := feedbackFilePath()
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening feedback ledger: %w", err)
+ }
+ defer f.Close()
+ return json.NewEncoder(f).Encode(entry)
+}
+
+func postFeedback(url string, entry FeedbackEntry) error {
+ body, err := json.Marshal(entry)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("building feedback request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", "{{.Name}}-pp-cli/feedback")
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("posting feedback: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ return fmt.Errorf("feedback endpoint returned %s", resp.Status)
+ }
+ return nil
+}
+
+func newFeedbackCmd(flags *rootFlags) *cobra.Command {
+ var useStdin bool
+ var send bool
+ cmd := &cobra.Command{
+ Use: "feedback [text]",
+ Short: "Record feedback about this CLI (local by default; upstream opt-in)",
+ Long: `Feedback is captured locally first at ~/.{{.Name}}-pp-cli/feedback.jsonl.
+When ` + "`{{envName .Name}}_FEEDBACK_ENDPOINT`" + ` is set and either --send is
+passed or ` + "`{{envName .Name}}_FEEDBACK_AUTO_SEND=true`" + `, the entry is
+POSTed as JSON after the local write.
+
+Write what surprised you or tripped you up, not a bug report. The
+loop is: agent notices friction -> one invocation -> captured -> the
+maintainer sees it.`,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ var text string
+ if useStdin {
+ data, err := io.ReadAll(cmd.InOrStdin())
+ if err != nil {
+ return fmt.Errorf("reading stdin: %w", err)
+ }
+ text = strings.TrimSpace(string(data))
+ } else if len(args) > 0 {
+ text = strings.Join(args, " ")
+ }
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return fmt.Errorf("feedback text is empty (pass arguments or --stdin)")
+ }
+ truncated := false
+ if len(text) > feedbackMaxTextLen {
+ text = text[:feedbackMaxTextLen]
+ truncated = true
+ }
+
+ entry := FeedbackEntry{
+ Text: text,
+ CLI: "{{.Name}}-pp-cli",
+ Version: version,
+ AgentID: os.Getenv("AGENT_ID"),
+ Timestamp: time.Now().UTC(),
+ }
+ if err := appendFeedback(entry); err != nil {
+ return err
+ }
+
+ upstreamResult := map[string]any{"sent": false}
+ if endpoint := feedbackEndpoint(); endpoint != "" && (send || feedbackAutoSend()) {
+ if err := postFeedback(endpoint, entry); err != nil {
+ fmt.Fprintf(cmd.ErrOrStderr(), "warning: feedback upstream POST failed: %v\n", err)
+ upstreamResult["sent"] = false
+ upstreamResult["error"] = err.Error()
+ } else {
+ upstreamResult["sent"] = true
+ upstreamResult["endpoint"] = endpoint
+ }
+ }
+
+ if flags.asJSON {
+ return flags.printJSON(cmd, map[string]any{
+ "recorded": true,
+ "truncated": truncated,
+ "upstream": upstreamResult,
+ "entry": entry,
+ })
+ }
+ fmt.Fprintf(cmd.OutOrStdout(), "feedback recorded locally (%d chars%s)\n", len(text), func() string {
+ if truncated {
+ return ", truncated"
+ }
+ return ""
+ }())
+ if sent, _ := upstreamResult["sent"].(bool); sent {
+ fmt.Fprintf(cmd.OutOrStdout(), "upstream POST: %v\n", upstreamResult["endpoint"])
+ }
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&useStdin, "stdin", false, "Read feedback body from stdin rather than arguments")
+ cmd.Flags().BoolVar(&send, "send", false, "POST to the configured feedback endpoint in addition to local write")
+
+ cmd.AddCommand(newFeedbackListCmd(flags))
+ return cmd
+}
+
+func newFeedbackListCmd(flags *rootFlags) *cobra.Command {
+ var limit int
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List recent feedback entries",
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ p, err := feedbackFilePath()
+ if err != nil {
+ return err
+ }
+ data, err := os.ReadFile(p)
+ if err != nil {
+ if os.IsNotExist(err) {
+ if flags.asJSON {
+ return flags.printJSON(cmd, []FeedbackEntry{})
+ }
+ return nil
+ }
+ return err
+ }
+ var entries []FeedbackEntry
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var e FeedbackEntry
+ if err := json.Unmarshal([]byte(line), &e); err != nil {
+ continue
+ }
+ entries = append(entries, e)
+ }
+ if limit > 0 && limit < len(entries) {
+ entries = entries[len(entries)-limit:]
+ }
+ return flags.printJSON(cmd, entries)
+ },
+ }
+ cmd.Flags().IntVar(&limit, "limit", 20, "Maximum number of recent entries to return")
+ return cmd
+}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index b128c3a5..3c76f766 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -181,6 +181,7 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
rootCmd.AddCommand(newAuthCmd(&flags))
rootCmd.AddCommand(newAgentContextCmd(rootCmd))
rootCmd.AddCommand(newProfileCmd(&flags))
+ rootCmd.AddCommand(newFeedbackCmd(&flags))
{{- if .HasAsyncJobs}}
rootCmd.AddCommand(newJobsCmd(&flags))
{{- end}}
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index e11f7e9e..298612d4 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -146,6 +146,20 @@ 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
+## Agent Feedback
+
+When you (or the agent) notice something off about this CLI, record it:
+
+```
+<cli>-pp-cli feedback "the --since flag is inclusive but docs say exclusive"
+<cli>-pp-cli feedback --stdin < notes.txt
+<cli>-pp-cli feedback list --json --limit 10
+```
+
+Entries are stored locally at `~/.<cli>-pp-cli/feedback.jsonl`. They are never POSTed unless `<CLI>_FEEDBACK_ENDPOINT` is set AND either `--send` is passed or `<CLI>_FEEDBACK_AUTO_SEND=true`. Default behavior is local-only.
+
+Write what *surprised* you, not a bug report. Short, specific, one line: that is the part that compounds.
+
## 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:
← f6e74931 feat(cli): --deliver routes command output to file or webhoo
·
back to Cli Printing Press
·
feat(cli): agent_workflow_readiness scorecard dimension 3ae025f8 →