← 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
M internal/generator/generator_test.goM internal/generator/templates/helpers.go.tmplM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.goM testdata/golden/expected/generate-golden-api/dogfood.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
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 →