[object Object]

← back to Cli Printing Press

fix(cli): unwrap single-key API envelopes before agent provenance envelope (#1095)

ff4b65286008d0f2224befa5b1d0624afe5e1e6e · 2026-05-11 11:19:21 -0700 · Trevin Chow

When an API wraps its collection response in a single key like
{"results":[...]} or {"data":[...]}, the agent provenance envelope
double-nests the output to {"meta":..., "results":{"results":[...]}},
forcing agents to use API-specific jq paths instead of a stable
.results[].

Detect single-key wrapper objects whose value is a JSON array
(results, data, items, nodes, entries, records) and unwrap them
before wrapWithProvenance builds the envelope. Multi-key objects
(e.g. with cursor or next_page_token siblings) and bare arrays pass
through so pagination fields stay accessible and non-collection
responses aren't reshaped.

Closes #894

Files touched

Diff

commit ff4b65286008d0f2224befa5b1d0624afe5e1e6e
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 11:19:21 2026 -0700

    fix(cli): unwrap single-key API envelopes before agent provenance envelope (#1095)
    
    When an API wraps its collection response in a single key like
    {"results":[...]} or {"data":[...]}, the agent provenance envelope
    double-nests the output to {"meta":..., "results":{"results":[...]}},
    forcing agents to use API-specific jq paths instead of a stable
    .results[].
    
    Detect single-key wrapper objects whose value is a JSON array
    (results, data, items, nodes, entries, records) and unwrap them
    before wrapWithProvenance builds the envelope. Multi-key objects
    (e.g. with cursor or next_page_token siblings) and bare arrays pass
    through so pagination fields stay accessible and non-collection
    responses aren't reshaped.
    
    Closes #894
---
 internal/generator/generator_test.go               | 190 ++++++++++++++++++++-
 internal/generator/templates/helpers.go.tmpl       |  49 +++++-
 .../internal/cli/helpers.go                        |  47 ++++-
 .../expected/generate-golden-api/dogfood.json      |   2 +-
 .../printing-press-golden/internal/cli/helpers.go  |  47 ++++-
 5 files changed, 321 insertions(+), 14 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 8a66957a..7dec700a 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -1756,17 +1756,20 @@ func TestGenerateHTMLExtractionEmbeddedJSONMode(t *testing.T) {
 	assert.Equal(t, "Soup", recipesEnv.Results[1]["name"])
 
 	// Custom selector + empty json_path: returns the whole parsed JSON.
+	// The extracted shape `{"items":[...]}` is a single-key wrapper that
+	// wrapWithProvenance unwraps, so the envelope's `results` is the
+	// inner array — consistent .results[] shape across APIs.
 	cmd = exec.Command(binaryPath, "articles", "list", "--json")
 	cmd.Env = append(os.Environ(), "EMBEDDEDJSON_BASE_URL="+server.URL)
 	out, err = cmd.CombinedOutput()
 	require.NoError(t, err, string(out))
 	var articleEnv struct {
-		Results map[string]any `json:"results"`
+		Results []map[string]any `json:"results"`
 	}
 	require.NoError(t, json.Unmarshal(out, &articleEnv), string(out))
-	items, ok := articleEnv.Results["items"].([]any)
-	require.True(t, ok, "expected items array, got %T", articleEnv.Results["items"])
-	require.Len(t, items, 2)
+	require.Len(t, articleEnv.Results, 2)
+	assert.Equal(t, "a", articleEnv.Results[0]["slug"])
+	assert.Equal(t, "b", articleEnv.Results[1]["slug"])
 
 	// Missing script tag: extractor reports an actionable error rather
 	// than silently returning empty data.
@@ -3866,6 +3869,7 @@ func TestGeneratedHelpers_ConditionalDataLayerFunctions(t *testing.T) {
 	assert.NotContains(t, content, "DataProvenance")
 	assert.NotContains(t, content, "printProvenance")
 	assert.NotContains(t, content, "wrapWithProvenance")
+	assert.NotContains(t, content, "unwrapSingleKeyArray")
 	assert.NotContains(t, content, "defaultDBPath")
 
 	// Core helpers should still be present
@@ -3873,6 +3877,184 @@ func TestGeneratedHelpers_ConditionalDataLayerFunctions(t *testing.T) {
 	assert.Contains(t, content, "printOutputWithFlags")
 }
 
+// TestGeneratedHelpers_WrapWithProvenanceUnwrapsSingleKeyEnvelope guards
+// the runtime contract of wrapWithProvenance for single-key API envelopes.
+// Without the unwrap, --json output for APIs that wrap collections in
+// {"results":[...]}, {"data":[...]}, etc. ends up double-nested
+// ({"meta":..., "results":{"results":[...]}}), forcing agents into
+// API-specific jq paths instead of a stable .results[]. The behavior test
+// is injected into the generated CLI and run with `go test`, so a template
+// regression on the unwrap shape fails this test before any printed CLI
+// inherits the bad output shape.
+func TestGeneratedHelpers_WrapWithProvenanceUnwrapsSingleKeyEnvelope(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "envelopeunwrap",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth:    spec.AuthConfig{Type: "none"},
+		Config:  spec.ConfigSpec{Format: "toml", Path: "~/.config/envelopeunwrap-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/items",
+						Description: "List items",
+						Response:    spec.ResponseDef{Type: "array"},
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	gen.VisionSet = VisionTemplateSet{Store: true}
+	require.NoError(t, gen.Generate())
+
+	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	src := string(helpersGo)
+	assert.Contains(t, src, "func unwrapSingleKeyArray(",
+		"helpers.go must define the unwrap helper")
+	assert.Contains(t, src, "unwrapSingleKeyArray(data)",
+		"wrapWithProvenance must call the unwrap helper on valid JSON data")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	behaviorTest := `package cli
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestUnwrapSingleKeyArray_KnownWrapperKeysUnwrap(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"results", ` + "`{\"results\":[{\"id\":\"a\"}]}`" + `, ` + "`[{\"id\":\"a\"}]`" + `},
+		{"data", ` + "`{\"data\":[{\"id\":\"a\"}]}`" + `, ` + "`[{\"id\":\"a\"}]`" + `},
+		{"items", ` + "`{\"items\":[1,2,3]}`" + `, ` + "`[1,2,3]`" + `},
+		{"nodes", ` + "`{\"nodes\":[]}`" + `, ` + "`[]`" + `},
+		{"entries", ` + "`{\"entries\":[\"x\"]}`" + `, ` + "`[\"x\"]`" + `},
+		{"records", ` + "`{\"records\":[null]}`" + `, ` + "`[null]`" + `},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			got := unwrapSingleKeyArray(json.RawMessage(tc.in))
+			if string(got) != tc.want {
+				t.Fatalf("unwrapSingleKeyArray(%s) = %s, want %s", tc.in, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestUnwrapSingleKeyArray_PassThroughs(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+	}{
+		{"bare array", ` + "`[{\"id\":\"a\"}]`" + `},
+		{"multi-key object preserves cursor", ` + "`{\"results\":[],\"next_page_token\":\"abc\"}`" + `},
+		{"unknown wrapper key", ` + "`{\"payload\":[1,2]}`" + `},
+		{"single key but not array value", ` + "`{\"data\":{\"issues\":{\"nodes\":[]}}}`" + `},
+		{"single key but value is null", ` + "`{\"results\":null}`" + `},
+		{"single key but value is string", ` + "`{\"data\":\"hello\"}`" + `},
+		{"empty object", ` + "`{}`" + `},
+		{"non-object scalar", ` + "`42`" + `},
+		{"invalid json", ` + "`<xml/>`" + `},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			got := unwrapSingleKeyArray(json.RawMessage(tc.in))
+			if string(got) != tc.in {
+				t.Fatalf("unwrapSingleKeyArray(%s) = %s, want unchanged", tc.in, got)
+			}
+		})
+	}
+}
+
+func TestWrapWithProvenance_FlattensSingleKeyEnvelope(t *testing.T) {
+	prov := DataProvenance{Source: "live"}
+	wrapped, err := wrapWithProvenance(json.RawMessage(` + "`{\"results\":[{\"id\":\"a\"}]}`" + `), prov)
+	if err != nil {
+		t.Fatalf("wrapWithProvenance: %v", err)
+	}
+	var out struct {
+		Meta    map[string]any    ` + "`json:\"meta\"`" + `
+		Results []json.RawMessage ` + "`json:\"results\"`" + `
+	}
+	if err := json.Unmarshal(wrapped, &out); err != nil {
+		t.Fatalf("output is not flat .results[]: %v\noutput: %s", err, wrapped)
+	}
+	if len(out.Results) != 1 {
+		t.Fatalf("want 1 result, got %d (output: %s)", len(out.Results), wrapped)
+	}
+}
+
+func TestWrapWithProvenance_PreservesMultiKeyResponseForCursor(t *testing.T) {
+	prov := DataProvenance{Source: "live"}
+	wrapped, err := wrapWithProvenance(json.RawMessage(` + "`{\"results\":[{\"id\":\"a\"}],\"next_page_token\":\"xyz\"}`" + `), prov)
+	if err != nil {
+		t.Fatalf("wrapWithProvenance: %v", err)
+	}
+	var out struct {
+		Results map[string]json.RawMessage ` + "`json:\"results\"`" + `
+	}
+	if err := json.Unmarshal(wrapped, &out); err != nil {
+		t.Fatalf("results should still be the original object: %v\noutput: %s", err, wrapped)
+	}
+	if _, ok := out.Results["next_page_token"]; !ok {
+		t.Fatalf("cursor must remain accessible; output: %s", wrapped)
+	}
+}
+
+func TestWrapWithProvenance_BareArrayUnchanged(t *testing.T) {
+	prov := DataProvenance{Source: "live"}
+	wrapped, err := wrapWithProvenance(json.RawMessage(` + "`[{\"id\":\"a\"}]`" + `), prov)
+	if err != nil {
+		t.Fatalf("wrapWithProvenance: %v", err)
+	}
+	var out struct {
+		Results []json.RawMessage ` + "`json:\"results\"`" + `
+	}
+	if err := json.Unmarshal(wrapped, &out); err != nil {
+		t.Fatalf("bare array must round-trip into .results[]: %v\noutput: %s", err, wrapped)
+	}
+	if len(out.Results) != 1 {
+		t.Fatalf("want 1 result, got %d (output: %s)", len(out.Results), wrapped)
+	}
+}
+
+func TestWrapWithProvenance_NonJSONEmbeddedAsString(t *testing.T) {
+	prov := DataProvenance{Source: "live"}
+	wrapped, err := wrapWithProvenance(json.RawMessage(` + "`<rss><channel/></rss>`" + `), prov)
+	if err != nil {
+		t.Fatalf("wrapWithProvenance: %v", err)
+	}
+	var out struct {
+		Results string ` + "`json:\"results\"`" + `
+	}
+	if err := json.Unmarshal(wrapped, &out); err != nil {
+		t.Fatalf("non-JSON payload must embed as a string: %v\noutput: %s", err, wrapped)
+	}
+	if out.Results != ` + "`<rss><channel/></rss>`" + ` {
+		t.Fatalf("want raw payload preserved, got %q", out.Results)
+	}
+}
+`
+	testPath := filepath.Join(outputDir, "internal", "cli", "wrap_provenance_unwrap_test.go")
+	require.NoError(t, os.WriteFile(testPath, []byte(behaviorTest), 0o644))
+	runGoCommand(t, outputDir, "test", "./internal/cli", "-run", "TestUnwrapSingleKeyArray|TestWrapWithProvenance_")
+}
+
 // --- Unit 3: Top-Level Command Promotion Tests ---
 
 func TestToKebab(t *testing.T) {
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index a5c2fcba..629368db 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -5,6 +5,9 @@
 package cli
 
 import (
+{{- if .HasDataLayer}}
+	"bytes"
+{{- end}}
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -1422,12 +1425,50 @@ func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
 	fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
 }
 
+// unwrapSingleKeyArray flattens single-key collection envelopes
+// ({"results":[...]}, {"data":[...]}, etc.) so the agent envelope
+// emits a stable .results[] across APIs. Multi-key objects pass
+// through so cursor/pagination fields stay accessible; non-array
+// values pass through so non-collection responses aren't reshaped.
+//
+// The wrapper-key set is intentionally narrower than
+// extractPaginatedItems (which also walks domain-specific keys like
+// "messages", "members", "values" used by social/messaging APIs).
+// This helper only flattens canonical collection envelopes for
+// --json output; the pagination walker has a broader remit.
+func unwrapSingleKeyArray(data json.RawMessage) json.RawMessage {
+	leading := bytes.TrimLeft(data, " \t\r\n")
+	if len(leading) == 0 || leading[0] != '{' {
+		return data
+	}
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err != nil {
+		return data
+	}
+	if len(obj) != 1 {
+		return data
+	}
+	for key, val := range obj {
+		if key != "results" && key != "data" && key != "items" && key != "nodes" && key != "entries" && key != "records" {
+			return data
+		}
+		trimmed := bytes.TrimLeft(val, " \t\r\n")
+		if len(trimmed) == 0 || trimmed[0] != '[' {
+			return data
+		}
+		return val
+	}
+	return data
+}
+
 // wrapWithProvenance wraps response data in a provenance envelope:
 // {"results": ..., "meta": {...}}. When data is valid JSON, it embeds as
 // the parsed shape; when data is non-JSON (e.g., XML/RSS responses, plain
 // text), it embeds as a JSON string so json.Marshal doesn't choke on
 // "invalid character '<'" while still passing the raw payload through to
-// the consumer.
+// the consumer. Single-key array envelopes from the API (e.g.
+// {"results": [...]}, {"data": [...]}) are unwrapped first so the output
+// shape is the same regardless of the API's wrapper key.
 func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
 	meta := map[string]any{"source": prov.Source}
 	if prov.SyncedAt != nil {
@@ -1442,8 +1483,10 @@ func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMess
 	if prov.Freshness != nil {
 		meta["freshness"] = prov.Freshness
 	}
-	var results any = json.RawMessage(data)
-	if !json.Valid(data) {
+	var results any
+	if json.Valid(data) {
+		results = json.RawMessage(unwrapSingleKeyArray(data))
+	} else {
 		results = string(data)
 	}
 	envelope := map[string]any{
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 5d0bf0c8..3fb2cf3a 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -4,6 +4,7 @@
 package cli
 
 import (
+	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -1222,12 +1223,50 @@ func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
 	fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
 }
 
+// unwrapSingleKeyArray flattens single-key collection envelopes
+// ({"results":[...]}, {"data":[...]}, etc.) so the agent envelope
+// emits a stable .results[] across APIs. Multi-key objects pass
+// through so cursor/pagination fields stay accessible; non-array
+// values pass through so non-collection responses aren't reshaped.
+//
+// The wrapper-key set is intentionally narrower than
+// extractPaginatedItems (which also walks domain-specific keys like
+// "messages", "members", "values" used by social/messaging APIs).
+// This helper only flattens canonical collection envelopes for
+// --json output; the pagination walker has a broader remit.
+func unwrapSingleKeyArray(data json.RawMessage) json.RawMessage {
+	leading := bytes.TrimLeft(data, " \t\r\n")
+	if len(leading) == 0 || leading[0] != '{' {
+		return data
+	}
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err != nil {
+		return data
+	}
+	if len(obj) != 1 {
+		return data
+	}
+	for key, val := range obj {
+		if key != "results" && key != "data" && key != "items" && key != "nodes" && key != "entries" && key != "records" {
+			return data
+		}
+		trimmed := bytes.TrimLeft(val, " \t\r\n")
+		if len(trimmed) == 0 || trimmed[0] != '[' {
+			return data
+		}
+		return val
+	}
+	return data
+}
+
 // wrapWithProvenance wraps response data in a provenance envelope:
 // {"results": ..., "meta": {...}}. When data is valid JSON, it embeds as
 // the parsed shape; when data is non-JSON (e.g., XML/RSS responses, plain
 // text), it embeds as a JSON string so json.Marshal doesn't choke on
 // "invalid character '<'" while still passing the raw payload through to
-// the consumer.
+// the consumer. Single-key array envelopes from the API (e.g.
+// {"results": [...]}, {"data": [...]}) are unwrapped first so the output
+// shape is the same regardless of the API's wrapper key.
 func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
 	meta := map[string]any{"source": prov.Source}
 	if prov.SyncedAt != nil {
@@ -1242,8 +1281,10 @@ func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMess
 	if prov.Freshness != nil {
 		meta["freshness"] = prov.Freshness
 	}
-	var results any = json.RawMessage(data)
-	if !json.Valid(data) {
+	var results any
+	if json.Valid(data) {
+		results = json.RawMessage(unwrapSingleKeyArray(data))
+	} else {
 		results = string(data)
 	}
 	envelope := map[string]any{
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 9961935d..c7ad4ac4 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -16,7 +16,7 @@
   },
   "dead_functions": {
     "dead": 0,
-    "total": 53
+    "total": 54
   },
   "dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
   "example_check": {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index 8a2aa5b3..d2f780d6 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -4,6 +4,7 @@
 package cli
 
 import (
+	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -1225,12 +1226,50 @@ func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
 	fmt.Fprintf(cmd.ErrOrStderr(), "%s%d results (cached, synced %s)\n", prefix, count, age)
 }
 
+// unwrapSingleKeyArray flattens single-key collection envelopes
+// ({"results":[...]}, {"data":[...]}, etc.) so the agent envelope
+// emits a stable .results[] across APIs. Multi-key objects pass
+// through so cursor/pagination fields stay accessible; non-array
+// values pass through so non-collection responses aren't reshaped.
+//
+// The wrapper-key set is intentionally narrower than
+// extractPaginatedItems (which also walks domain-specific keys like
+// "messages", "members", "values" used by social/messaging APIs).
+// This helper only flattens canonical collection envelopes for
+// --json output; the pagination walker has a broader remit.
+func unwrapSingleKeyArray(data json.RawMessage) json.RawMessage {
+	leading := bytes.TrimLeft(data, " \t\r\n")
+	if len(leading) == 0 || leading[0] != '{' {
+		return data
+	}
+	var obj map[string]json.RawMessage
+	if err := json.Unmarshal(data, &obj); err != nil {
+		return data
+	}
+	if len(obj) != 1 {
+		return data
+	}
+	for key, val := range obj {
+		if key != "results" && key != "data" && key != "items" && key != "nodes" && key != "entries" && key != "records" {
+			return data
+		}
+		trimmed := bytes.TrimLeft(val, " \t\r\n")
+		if len(trimmed) == 0 || trimmed[0] != '[' {
+			return data
+		}
+		return val
+	}
+	return data
+}
+
 // wrapWithProvenance wraps response data in a provenance envelope:
 // {"results": ..., "meta": {...}}. When data is valid JSON, it embeds as
 // the parsed shape; when data is non-JSON (e.g., XML/RSS responses, plain
 // text), it embeds as a JSON string so json.Marshal doesn't choke on
 // "invalid character '<'" while still passing the raw payload through to
-// the consumer.
+// the consumer. Single-key array envelopes from the API (e.g.
+// {"results": [...]}, {"data": [...]}) are unwrapped first so the output
+// shape is the same regardless of the API's wrapper key.
 func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMessage, error) {
 	meta := map[string]any{"source": prov.Source}
 	if prov.SyncedAt != nil {
@@ -1245,8 +1284,10 @@ func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMess
 	if prov.Freshness != nil {
 		meta["freshness"] = prov.Freshness
 	}
-	var results any = json.RawMessage(data)
-	if !json.Valid(data) {
+	var results any
+	if json.Valid(data) {
+		results = json.RawMessage(unwrapSingleKeyArray(data))
+	} else {
 		results = string(data)
 	}
 	envelope := map[string]any{

← 17d19307 fix(cli): stage runstate manuscripts during lock promote (#1  ·  back to Cli Printing Press  ·  fix(cli): gofmt rendered .go output in generator emit phase 1ca94b91 →