@@ -17,9 +17,14 @@ const (
1717// GenerateFence renders the factual fenced block for one command group: a
1818// section per leaf verb with its short description and a flag table (name,
1919// type, required, usage + enum), plus a body-only (--data) note when the
20- // command has nested JSON-only fields. Required-ness and enums are sourced from
21- // the authoritative "Request fields:" text in each command's Long; the flag
22- // list falls back to the dump's Flags when no such block exists (read-only
20+ // command has nested JSON-only fields, plus a one-line response-shape summary
21+ // (top-level object vs. bare array vs. `{items: [...]}` page wrapper, and the
22+ // field names at that level) when the command documents one. Required-ness
23+ // and enums are sourced from the authoritative "Request fields:" text in each
24+ // command's Long; the response shape is likewise sourced from that same
25+ // Long's "Response fields (...):" block (cligen's own ground truth — see
26+ // responseShapeLine), not re-derived or hand-curated. The flag list falls
27+ // back to the dump's Flags when no Request-fields block exists (read-only
2328// verbs). Output is deterministic.
2429func GenerateFence (d Dump , group string ) string {
2530 cmds := groupCommands (d , group )
@@ -84,6 +89,9 @@ func writeCommand(b *strings.Builder, c Command) {
8489 if len (fields .bodyOnly ) > 0 {
8590 fmt .Fprintf (b , "- body-only (`--data`): %s\n " , strings .Join (fields .bodyOnly , "; " ))
8691 }
92+ if shape := responseShapeLine (c .Long ); shape != "" {
93+ b .WriteString (shape )
94+ }
8795}
8896
8997// positionalsOf returns the placeholder tokens after the leaf verb in a Use
@@ -295,3 +303,119 @@ func cleanUsage(tail string) string {
295303 s = strings .TrimPrefix (s , "—" )
296304 return strings .TrimSpace (s )
297305}
306+
307+ // --- Long "Response fields:" parser -----------------------------------------
308+ //
309+ // cligen classifies every documented response into exactly one of three
310+ // envelope shapes and says so verbatim in the "Response fields (...):" header
311+ // it writes into Long — this parser only ever recognizes those three; it does
312+ // not infer a shape of its own. That header (and the field list under it) is
313+ // authoritative today but surfaces only via `--help`, which an agent that
314+ // reads just the card fence never invokes. responseShapeLine folds a
315+ // one-line summary of it into the fence so every generated verb — not only
316+ // the handful some earlier hand-written card happened to cover — tells the
317+ // agent up front whether `--json` is a bare array, a single object, or a
318+ // `{items: [...]}` page wrapper, and exactly which field names exist at that
319+ // level. Guessing a field name silently returns null instead of an error, so
320+ // this is the difference between an agent noticing its own mistake and not.
321+
322+ // responseHeaderRe matches the header line, capturing the parenthetical shape
323+ // description cligen wrote (verbatim, no leading indent — it starts a new
324+ // paragraph in Long).
325+ var responseHeaderRe = regexp .MustCompile (`^Response fields \((.*)\):$` )
326+
327+ // responseFieldRe matches one Response-fields bullet row at any indent depth,
328+ // e.g. " - account_id (integer) (required) — ..." or, one level deeper,
329+ // " - person_ids (array<integer>) ...". Capture groups: indent, name, type.
330+ var responseFieldRe = regexp .MustCompile (`^( *)- ([a-zA-Z0-9_]+) \(([^)]*)\)` )
331+
332+ // wrapperWireNames are the exact wire names cligen's own listEnvelope
333+ // (internal/cmd/cligen/main.go) treats as a paginated-list envelope field:
334+ // a sole array-typed sibling named items, docs, or list. Mirrored here as a
335+ // sanity check, not a duplicate classifier — see the guard in
336+ // responseShapeLine below.
337+ var wrapperWireNames = map [string ]bool {"items" : true , "docs" : true , "list" : true }
338+
339+ // respField is one parsed Response-fields bullet row.
340+ type respField struct { name , typ string }
341+
342+ // responseShapeLine renders the one-line response-shape summary for a
343+ // command's Long, or "" when Long documents no Response fields block (mutation
344+ // verbs with an empty body, and a few hand-written commands that predate
345+ // cligen). The three shapes cligen's header can name:
346+ //
347+ // - top-level object: the block's own fields are the response.
348+ // - top-level array: `--json` is a bare array of these row objects — pipe
349+ // `jq '.[]'`, never `.items[]`.
350+ // - `{items: [...]}` page wrapper: the block's sole top-level field is
351+ // `items`; the row fields are nested one level (2 spaces) deeper under it.
352+ //
353+ // Field names (with their documented type) are read from whichever indent
354+ // depth holds the actual row/object fields for the detected shape, so the
355+ // summary always names the fields an agent would pipe `jq` at — not the
356+ // wrapper key.
357+ func responseShapeLine (long string ) string {
358+ lines := strings .Split (long , "\n " )
359+ headerIdx , header := - 1 , ""
360+ for i , line := range lines {
361+ if m := responseHeaderRe .FindStringSubmatch (line ); m != nil {
362+ headerIdx , header = i , m [1 ]
363+ break
364+ }
365+ }
366+ if headerIdx < 0 {
367+ return ""
368+ }
369+
370+ wrapped := strings .Contains (header , "nested under items[]" )
371+ fieldIndent := " "
372+ if wrapped {
373+ fieldIndent = " " // one level under the sole top-level "items" row
374+ }
375+
376+ var fields []respField
377+ for _ , line := range lines [headerIdx + 1 :] {
378+ if strings .TrimSpace (line ) == "" {
379+ break
380+ }
381+ m := responseFieldRe .FindStringSubmatch (line )
382+ if m == nil || m [1 ] != fieldIndent {
383+ continue
384+ }
385+ fields = append (fields , respField {m [2 ], m [3 ]})
386+ }
387+ if len (fields ) == 0 {
388+ return ""
389+ }
390+
391+ // Safety net: this parser only ever recognizes the wrapped shape by the
392+ // literal substring "nested under items[]" in the header (see the doc
393+ // comment above the const block). If cligen's wording for that header
394+ // ever drifts without this parser being updated to match, `wrapped` goes
395+ // false here even though the response really is a page wrapper — and the
396+ // sole top-level field is then exactly one of cligen's own wrapper wire
397+ // names (items/docs/list, from listEnvelope in cligen/main.go), holding
398+ // an array. Asserting "single object" in that case would be confidently
399+ // WRONG about the one thing this whole feature exists to get right, so
400+ // refuse to guess: say nothing rather than assert a shape we can no
401+ // longer be sure of. A missing line is recoverable via `--help`; a
402+ // wrong one isn't.
403+ if ! wrapped && len (fields ) == 1 && wrapperWireNames [fields [0 ].name ] && strings .HasPrefix (fields [0 ].typ , "array" ) {
404+ return ""
405+ }
406+
407+ var shape string
408+ switch {
409+ case wrapped :
410+ shape = "`{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`)"
411+ case strings .Contains (header , "TOP-LEVEL array" ):
412+ shape = "TOP-LEVEL array — pipe `--json | jq '.[]'` (NOT `.items[]`)"
413+ default :
414+ shape = "single object (`data` unwrapped to the top level)"
415+ }
416+ names := make ([]string , len (fields ))
417+ for i , f := range fields {
418+ names [i ] = f .name + " (" + f .typ + ")"
419+ }
420+ return fmt .Sprintf ("- response: %s — fields: %s\n " , shape , strings .Join (names , "; " ))
421+ }
0 commit comments