[object Object]

← back to Cli Printing Press

fix(cli): support nested --select paths + suppress provenance on non-TTY stdout (#229)

ac4d6aa22ff7ef42128d005c4bc39c605ff05733 · 2026-04-20 21:12:16 -0700 · Trevin Chow

* fix(cli): support nested --select paths + suppress provenance on non-TTY stdout

Two bugs reported by agents consuming printed CLIs:

1. `--select` with dotted paths (e.g. `events.shortName`) silently returned
   `{}`. Only flat keys were matched, so nested paths never hit anything.
   Now `filterFields` descends into objects and traverses arrays
   element-wise, matching case-insensitively and kebab-case like before.

2. `--agent` consumers that merged stderr into stdout saw the
   "N results (live)" provenance line polluting JSON output. The line is
   redundant — the `meta.source` field already carries the same info in
   the response envelope. `printProvenance` now short-circuits when
   stdout is not a TTY, so piped and redirected output stays clean.

Also updates the SKILL.md template to document the new nested-path
capability and adds a conditional "Response envelope" subsection for
data-layer CLIs. The research-agent narrative rule now requires at least
one recipe pairing `--agent` with `--select` so future SKILL.md files
teach the pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): promote single-endpoint resources regardless of HTTP method

buildPromotedCommands only considered GET endpoints, so POST-only auth
resources rendered as doubly-nested commands. Pagliacci's login for
example shipped as `pagliacci-pizza-pp-cli login login --email …` —
ugly UX no agent or human would type. Same for logout/register/
password-forgot/password-reset across any CLI where a resource has a
single non-GET endpoint.

Fix: when a resource has exactly one endpoint, promote it. Multi-endpoint
resources still require a GET to promote (picking an arbitrary mutation
as the 'default' command would be surprising).

Companion PR in printing-press-library applies the flattening to the
shipped pagliacci CLI without regen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ac4d6aa22ff7ef42128d005c4bc39c605ff05733
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon Apr 20 21:12:16 2026 -0700

    fix(cli): support nested --select paths + suppress provenance on non-TTY stdout (#229)
    
    * fix(cli): support nested --select paths + suppress provenance on non-TTY stdout
    
    Two bugs reported by agents consuming printed CLIs:
    
    1. `--select` with dotted paths (e.g. `events.shortName`) silently returned
       `{}`. Only flat keys were matched, so nested paths never hit anything.
       Now `filterFields` descends into objects and traverses arrays
       element-wise, matching case-insensitively and kebab-case like before.
    
    2. `--agent` consumers that merged stderr into stdout saw the
       "N results (live)" provenance line polluting JSON output. The line is
       redundant — the `meta.source` field already carries the same info in
       the response envelope. `printProvenance` now short-circuits when
       stdout is not a TTY, so piped and redirected output stays clean.
    
    Also updates the SKILL.md template to document the new nested-path
    capability and adds a conditional "Response envelope" subsection for
    data-layer CLIs. The research-agent narrative rule now requires at least
    one recipe pairing `--agent` with `--select` so future SKILL.md files
    teach the pattern.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): promote single-endpoint resources regardless of HTTP method
    
    buildPromotedCommands only considered GET endpoints, so POST-only auth
    resources rendered as doubly-nested commands. Pagliacci's login for
    example shipped as `pagliacci-pizza-pp-cli login login --email …` —
    ugly UX no agent or human would type. Same for logout/register/
    password-forgot/password-reset across any CLI where a resource has a
    single non-GET endpoint.
    
    Fix: when a resource has exactly one endpoint, promote it. Multi-endpoint
    resources still require a GET to promote (picking an arbitrary mutation
    as the 'default' command would be surprising).
    
    Companion PR in printing-press-library applies the flattening to the
    shipped pagliacci CLI without regen.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go              |  48 ++++++++----
 internal/generator/generator_test.go         |  50 ++++++++++++-
 internal/generator/templates/helpers.go.tmpl | 105 +++++++++++++++++----------
 internal/generator/templates/skill.md.tmpl   |  22 +++++-
 skills/printing-press/SKILL.md               |   2 +-
 5 files changed, 170 insertions(+), 57 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 888d2986..72dd1d6d 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -495,6 +495,7 @@ type readmeTemplateData struct {
 	DiscoveryPages []string
 	NovelFeatures  []NovelFeature
 	Narrative      *ReadmeNarrative
+	HasDataLayer   bool
 }
 
 func (g *Generator) readmeData() *readmeTemplateData {
@@ -512,6 +513,7 @@ func (g *Generator) readmeData() *readmeTemplateData {
 		DiscoveryPages: g.DiscoveryPages,
 		NovelFeatures:  g.NovelFeatures,
 		Narrative:      g.Narrative,
+		HasDataLayer:   g.VisionSet.Store,
 	}
 }
 
@@ -1858,28 +1860,44 @@ func buildPromotedCommands(s *spec.APISpec) []PromotedCommand {
 	usedNames := make(map[string]bool)
 
 	for name, resource := range s.Resources {
-		// Find the primary GET endpoint: prefer GET without positional params, else first GET
+		// Find the primary endpoint for the resource.
+		//
+		// Multi-endpoint resources: prefer a GET without positional params (list
+		// endpoint), else first GET — a read is the safest default landing spot.
+		//
+		// Single-endpoint resources: promote the only endpoint regardless of method.
+		// Without this, POST-only auth resources like `login`/`logout`/`register`
+		// render as `<cli> login login --email …` — ugly double-naming that no
+		// agent or human wants to type.
 		var bestName string
 		var bestEndpoint spec.Endpoint
 		found := false
 
-		for eName, ep := range resource.Endpoints {
-			if ep.Method != "GET" {
-				continue
-			}
-			hasPositional := false
-			for _, p := range ep.Params {
-				if p.Positional {
-					hasPositional = true
-					break
-				}
-			}
-			if !found || !hasPositional {
+		if len(resource.Endpoints) == 1 {
+			for eName, ep := range resource.Endpoints {
 				bestName = eName
 				bestEndpoint = ep
 				found = true
-				if !hasPositional {
-					break // Ideal: GET without path params (list endpoint)
+			}
+		} else {
+			for eName, ep := range resource.Endpoints {
+				if ep.Method != "GET" {
+					continue
+				}
+				hasPositional := false
+				for _, p := range ep.Params {
+					if p.Positional {
+						hasPositional = true
+						break
+					}
+				}
+				if !found || !hasPositional {
+					bestName = eName
+					bestEndpoint = ep
+					found = true
+					if !hasPositional {
+						break // Ideal: GET without path params (list endpoint)
+					}
 				}
 			}
 		}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 0011dd4b..b2188b4a 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -31,7 +31,7 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		// +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},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 41},
 	}
 
 	for _, tt := range tests {
@@ -980,6 +980,54 @@ func TestBuildPromotedCommands(t *testing.T) {
 		assert.Equal(t, "list", promoted[0].EndpointName, "should prefer the list endpoint (no positional params)")
 	})
 
+	t.Run("single-endpoint POST resource IS promoted (e.g. login, logout, register)", func(t *testing.T) {
+		t.Parallel()
+		s := &spec.APISpec{
+			Name:    "test",
+			Version: "0.1.0",
+			BaseURL: "https://api.example.com",
+			Resources: map[string]spec.Resource{
+				"login": {
+					Endpoints: map[string]spec.Endpoint{
+						"login": {Method: "POST", Path: "/Login", Description: "Log in to account"},
+					},
+				},
+				"password-forgot": {
+					Endpoints: map[string]spec.Endpoint{
+						"forgot-password": {Method: "POST", Path: "/PasswordForgot", Description: "Request password reset email"},
+					},
+				},
+			},
+		}
+		promoted := buildPromotedCommands(s)
+		require.Len(t, promoted, 2, "both single-endpoint POST resources should promote — without this, UX becomes `<cli> login login --email …`")
+		names := map[string]bool{}
+		for _, p := range promoted {
+			names[p.PromotedName] = true
+		}
+		assert.True(t, names["login"], "POST-only login resource should promote")
+		assert.True(t, names["password-forgot"], "POST-only password-forgot resource should promote")
+	})
+
+	t.Run("multi-endpoint resource still requires GET for promotion (write-only resources stay nested)", func(t *testing.T) {
+		t.Parallel()
+		s := &spec.APISpec{
+			Name:    "test",
+			Version: "0.1.0",
+			BaseURL: "https://api.example.com",
+			Resources: map[string]spec.Resource{
+				"mutations": {
+					Endpoints: map[string]spec.Endpoint{
+						"create": {Method: "POST", Path: "/m", Description: "create"},
+						"delete": {Method: "DELETE", Path: "/m/{id}", Description: "delete"},
+					},
+				},
+			},
+		}
+		promoted := buildPromotedCommands(s)
+		assert.Empty(t, promoted, "multi-endpoint resources without a GET should not promote — picking one mutation as the 'default' is surprising")
+	})
+
 	t.Run("all built-in names are skipped", func(t *testing.T) {
 		t.Parallel()
 		resources := map[string]spec.Resource{}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 2bdf502a..f2edb53f 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -345,68 +345,89 @@ func paginatedGet(c interface {
 	return json.RawMessage(result), nil
 }
 
-// filterFields keeps only the specified comma-separated fields from JSON objects/arrays.
+// filterFields keeps only the specified fields (comma-separated) from JSON objects/arrays.
+// Supports dotted paths like "events.shortName" to descend into nested structures.
+// Arrays are traversed element-wise: "events.shortName" keeps shortName on each event.
 func filterFields(data json.RawMessage, fields string) json.RawMessage {
-	wanted := map[string]bool{}
+	var paths [][]string
 	for _, f := range strings.Split(fields, ",") {
 		f = strings.TrimSpace(f)
-		if f != "" {
-			// Normalize to lowercase for case-insensitive matching
-			wanted[strings.ToLower(f)] = true
+		if f == "" {
+			continue
+		}
+		parts := strings.Split(f, ".")
+		for i := range parts {
+			parts[i] = strings.ToLower(parts[i])
 		}
+		paths = append(paths, parts)
 	}
-	if len(wanted) == 0 {
+	if len(paths) == 0 {
 		return data
 	}
+	return filterFieldsRec(data, paths)
+}
 
-	// Try array of objects
-	var items []map[string]json.RawMessage
-	if json.Unmarshal(data, &items) == nil {
-		filtered := make([]map[string]json.RawMessage, len(items))
-		for i, item := range items {
-			m := map[string]json.RawMessage{}
-			for k, v := range item {
-				if fieldMatchesSelect(k, wanted) {
-					m[k] = v
-				}
-			}
-			filtered[i] = m
+// filterFieldsRec applies path filters to a JSON value. Each path is a list of
+// lowercase segments; arrays descend element-wise.
+func filterFieldsRec(data json.RawMessage, paths [][]string) json.RawMessage {
+	var arr []json.RawMessage
+	if err := json.Unmarshal(data, &arr); err == nil {
+		out := make([]json.RawMessage, len(arr))
+		for i, el := range arr {
+			out[i] = filterFieldsRec(el, paths)
 		}
-		out, _ := json.Marshal(filtered)
-		return out
+		result, _ := json.Marshal(out)
+		return result
 	}
 
-	// Try single object
 	var obj map[string]json.RawMessage
-	if json.Unmarshal(data, &obj) == nil {
-		m := map[string]json.RawMessage{}
+	if err := json.Unmarshal(data, &obj); err == nil {
+		keepWhole := map[string]bool{}
+		subPaths := map[string][][]string{}
+		for _, p := range paths {
+			if len(p) == 0 {
+				continue
+			}
+			head := p[0]
+			if len(p) == 1 {
+				keepWhole[head] = true
+			} else {
+				subPaths[head] = append(subPaths[head], p[1:])
+			}
+		}
+		filtered := map[string]json.RawMessage{}
 		for k, v := range obj {
-			if fieldMatchesSelect(k, wanted) {
-				m[k] = v
+			matched := matchSelectSegment(k, keepWhole, subPaths)
+			if matched == "" {
+				continue
+			}
+			if keepWhole[matched] {
+				filtered[k] = v
+				continue
+			}
+			if subs := subPaths[matched]; subs != nil {
+				filtered[k] = filterFieldsRec(v, subs)
 			}
 		}
-		out, _ := json.Marshal(m)
-		return out
+		result, _ := json.Marshal(filtered)
+		return result
 	}
 
 	return data
 }
 
-// fieldMatchesSelect checks if a field name matches any of the wanted select fields.
-// Matches case-insensitively and converts camelCase/PascalCase to kebab-case for comparison.
-// "OrderDate" matches "orderdate", "order-date", or "OrderDate".
-func fieldMatchesSelect(fieldName string, wanted map[string]bool) bool {
+// matchSelectSegment returns the matching lowercase segment, or "" if no match.
+// Supports direct case-insensitive match and camelCase→kebab-case conversion.
+func matchSelectSegment(fieldName string, keepWhole map[string]bool, subPaths map[string][][]string) string {
 	lower := strings.ToLower(fieldName)
-	// Direct case-insensitive match: "ID" matches "id"
-	if wanted[lower] {
-		return true
+	if keepWhole[lower] || subPaths[lower] != nil {
+		return lower
 	}
-	// Convert original casing to kebab-case: "OrderDate" → "order-date"
 	kebab := camelToKebab(fieldName)
-	if kebab != lower && wanted[kebab] {
-		return true
+	if kebab != lower && (keepWhole[kebab] || subPaths[kebab] != nil) {
+		return kebab
 	}
-	return false
+	return ""
 }
 
 // camelToKebab converts "orderDate" or "orderdate" to "order-date" by splitting on
@@ -1081,8 +1102,14 @@ type DataProvenance struct {
 	ResourceType string    `json:"resource_type,omitempty"` // which resource type was queried
 }
 
-// printProvenance writes a one-line provenance message to stderr.
+// printProvenance writes a one-line provenance message to stderr for TTY users.
+// Suppressed when stdout is piped or redirected — the JSON response envelope
+// already carries meta.source, so the stderr line is redundant and becomes
+// noise in agent flows that merge stderr into stdout.
 func printProvenance(cmd *cobra.Command, count int, prov DataProvenance) {
+	if !isTerminal(cmd.OutOrStdout()) {
+		return
+	}
 	if prov.Source == "live" {
 		fmt.Fprintf(cmd.ErrOrStderr(), "%d results (live)\n", count)
 		return
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index 4db2a123..fc2fb3ba 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -148,10 +148,30 @@ Run `{{.Name}}-pp-cli doctor` to verify setup.
 Add `--agent` to any command. Expands to: `--json --compact --no-input --no-color --yes`.
 
 - **Pipeable** — JSON on stdout, errors on stderr
-- **Filterable** — `--select id,name` returns a subset of fields
+- **Filterable** — `--select` keeps a subset of fields. Dotted paths descend into nested structures; arrays traverse element-wise. Critical for keeping context small on verbose APIs:
+
+  ```bash
+  {{.Name}}-pp-cli <command> --agent --select id,name,status
+  {{.Name}}-pp-cli <command> --agent --select items.id,items.owner.name
+  ```
 - **Previewable** — `--dry-run` shows the request without sending
 - **Cacheable** — GET responses cached for 5 minutes, bypass with `--no-cache`
 - **Non-interactive** — never prompts, every input is a flag
+{{- if .HasDataLayer}}
+
+### Response envelope
+
+Commands that read from the local store or the API wrap output in a provenance envelope:
+
+```json
+{
+  "meta": {"source": "live" | "local", "synced_at": "...", "reason": "..."},
+  "results": <data>
+}
+```
+
+Parse `.results` for data and `.meta.source` to know whether it's live or local. A human-readable `N results (live)` summary is printed to stderr only when stdout is a terminal — piped/agent consumers get pure JSON on stdout.
+{{- end}}
 
 ## Agent Feedback
 
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index 97e2ae5c..7ba9a5e5 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1141,7 +1141,7 @@ For each tool, fill in what you know from the research. Stars and command_count
 4. `quickstart` is a 3–6 step flow using REAL arguments (symbols, IDs, resource names an agent can actually pass). Each step's `comment` explains *why* it runs. This replaces the generic "resource list" first-command fallback.
 5. `troubleshoots` captures API-specific failure modes (rate-limit mitigation, cookie expiry, paginated quirks). Each `fix` must be actionable — a command or a concrete setting change.
 6. `when_to_use` is SKILL-only narrative. 2–4 sentences describing the kinds of agent tasks this CLI is the right choice for. Not rendered in README.
-7. `recipes` are 3–5 worked examples rendered in SKILL.md. Each has a title, a real command, and a one-line explanation. Prefer recipes that exercise novel features.
+7. `recipes` are 3–5 worked examples rendered in SKILL.md. Each has a title, a real command, and a one-line explanation. Prefer recipes that exercise novel features. **At least one recipe must pair `--agent` with `--select`** — using dotted paths (e.g. `--select events.shortName,events.competitions.competitors.team.displayName`) when the response is deeply nested. APIs like ESPN, HubSpot, and Linear return tens of KB per call; without a `--select` recipe, agents burn context parsing verbose payloads. Pick a command known to return a large or deeply nested response and show the narrowing pattern.
 8. `trigger_phrases` are natural-language phrases a user might say that should invoke this CLI's skill. Include 3–5 domain-specific phrases (e.g. for a finance CLI: "quote AAPL", "check my portfolio", "options for TSLA") and 2 generic phrases ("use <api-name>", "run <api-name>"). Domain verbs vary — don't just template "use X" variants.
 9. All `narrative` fields are optional. Omit fields you can't populate honestly rather than emit filler. The generator falls back to generic content gracefully.
 

← 84043f3a feat(cli): support extra_commands: in spec.yaml for hand-wri  ·  back to Cli Printing Press  ·  docs(cli): fix plugin install commands in README (#230) ab7254b1 →