← 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
M internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/helpers.go.tmplM internal/generator/templates/skill.md.tmplM skills/printing-press/SKILL.md
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 →