11package cli
22
33import (
4+ "bytes"
45 "encoding/json"
56 "fmt"
67 "io"
@@ -11,6 +12,7 @@ import (
1112
1213 "github.com/spf13/cobra"
1314
15+ "github.com/flashcatcloud/flashduty-cli/internal/output"
1416 "github.com/flashcatcloud/flashduty-cli/internal/timeutil"
1517)
1618
@@ -320,16 +322,177 @@ func bindURLTagged(body map[string]any, rv reflect.Value) {
320322
321323// printGenericResult renders a generated command's typed response. In
322324// machine-readable mode (TOON/JSON) it marshals the whole value — which is what
323- // the agent reads. In human (table) mode it derives an aligned table by
324- // reflection (renderGenericTable), since generated commands carry no hand-written
325- // column set; anything that isn't a list or object falls back to indented JSON.
325+ // the agent reads. A list-shaped response (a top-level array of objects, or an
326+ // items/docs/list page envelope whose other fields are scalar pagination
327+ // metadata) that overflows compactListOutputLimit is first bounded to the
328+ // leading rows that fit via boundProjectedList, with the reduction announced on
329+ // stderr; a payload that fits, and any detail-shaped single object, prints
330+ // untouched. In human (table) mode it derives an aligned table by reflection
331+ // (renderGenericTable), since generated commands carry no hand-written column
332+ // set; anything that isn't a list or object falls back to indented JSON.
326333func printGenericResult (ctx * RunContext , data any ) error {
327334 if ctx .Structured () {
328- return ctx . Printer . Print ( data , nil )
335+ return printBoundedGenericResult ( ctx , data )
329336 }
330337 return renderGenericTable (ctx , data )
331338}
332339
340+ // printBoundedGenericResult is printGenericResult's structured-mode half. The
341+ // under-cap fast path is the pre-bound behavior verbatim — same printer call,
342+ // byte-identical output. Only an over-cap list payload detours through the
343+ // bounding machinery, and only there does the output change (fewer rows; a
344+ // rebuilt envelope, so key order is no longer the struct's field order).
345+ func printBoundedGenericResult (ctx * RunContext , data any ) error {
346+ encoded , err := marshalStructured (data )
347+ if err != nil || len (encoded )+ 1 < compactListOutputLimit {
348+ // Fits the budget (or cannot be measured, in which case the printer
349+ // surfaces the same marshal error): emit untouched.
350+ return ctx .Printer .Print (data , nil )
351+ }
352+
353+ generic , err := genericStructured (data )
354+ if err != nil {
355+ return ctx .Printer .Print (data , nil )
356+ }
357+
358+ switch value := generic .(type ) {
359+ case []any :
360+ rows , ok := objectRows (value )
361+ if ! ok {
362+ return ctx .Printer .Print (data , nil )
363+ }
364+ bounded , note , err := boundProjectedList (rows , compactListOutputLimit )
365+ if err != nil {
366+ return err
367+ }
368+ noteProjectionBound (ctx .Cmd .ErrOrStderr (), note )
369+ return ctx .Printer .Print (bounded , nil )
370+ case map [string ]any :
371+ key , ok := listEnvelopeKey (value )
372+ if ! ok {
373+ // Detail-shaped single object: never bounded, never errored — a
374+ // shortened id or status would pass for a real value.
375+ return ctx .Printer .Print (data , nil )
376+ }
377+ rows , ok := objectRows (value [key ].([]any ))
378+ if ! ok {
379+ return ctx .Printer .Print (data , nil )
380+ }
381+ // boundProjectedList sizes the rows standalone, but printed inside the
382+ // envelope they share the budget with the pagination siblings (and, in
383+ // indented JSON, sit one indent level deeper). Fit against the full
384+ // limit, then re-fit with the observed envelope overhead subtracted
385+ // until the whole payload is under it.
386+ budget := compactListOutputLimit
387+ for {
388+ bounded , note , err := boundProjectedList (rows , budget )
389+ if err != nil {
390+ return err
391+ }
392+ value [key ] = bounded
393+ out , err := marshalStructured (value )
394+ if err != nil {
395+ return err
396+ }
397+ if len (out )+ 1 < compactListOutputLimit {
398+ noteProjectionBound (ctx .Cmd .ErrOrStderr (), note )
399+ return ctx .Printer .Print (value , nil )
400+ }
401+ budget -= len (out ) + 2 - compactListOutputLimit
402+ }
403+ default :
404+ return ctx .Printer .Print (data , nil )
405+ }
406+ }
407+
408+ // genericStructured decodes data through its JSON encoding into plain
409+ // maps/slices/scalars, so the list-bounding machinery can walk rows of any SDK
410+ // response type. Numbers decode as json.Number first (UseNumber) and are then
411+ // narrowed by narrowNumbers: decoding straight to float64 would round integer
412+ // IDs above 2^53 (channel_id, team_id, …) in the bounded output. Unset SDK
413+ // timestamps go in as null (NullUnsetInstants), matching what the printer
414+ // would have emitted for the unbounded payload.
415+ func genericStructured (data any ) (any , error ) {
416+ raw , err := json .Marshal (output .NullUnsetInstants (data ))
417+ if err != nil {
418+ return nil , err
419+ }
420+ dec := json .NewDecoder (bytes .NewReader (raw ))
421+ dec .UseNumber ()
422+ var generic any
423+ if err := dec .Decode (& generic ); err != nil {
424+ return nil , err
425+ }
426+ return narrowNumbers (generic ), nil
427+ }
428+
429+ // narrowNumbers rewrites every json.Number in a decoded generic value to its
430+ // int64 form when the literal is an integer (exact for IDs beyond 2^53), else
431+ // float64 — both encoders (JSON and TOON) render those natively.
432+ func narrowNumbers (value any ) any {
433+ switch v := value .(type ) {
434+ case json.Number :
435+ if i , err := v .Int64 (); err == nil {
436+ return i
437+ }
438+ if f , err := v .Float64 (); err == nil {
439+ return f
440+ }
441+ return v .String ()
442+ case map [string ]any :
443+ for key , item := range v {
444+ v [key ] = narrowNumbers (item )
445+ }
446+ return v
447+ case []any :
448+ for i , item := range v {
449+ v [i ] = narrowNumbers (item )
450+ }
451+ return v
452+ default :
453+ return value
454+ }
455+ }
456+
457+ // listEnvelopeKey reports whether value is a paginated list envelope — exactly
458+ // one array field named items/docs/list with only scalar siblings (total,
459+ // has_next_page, search_after_ctx, …) — and returns the row array's key. It
460+ // mirrors cligen's listEnvelope (internal/cmd/cligen), which classifies the
461+ // same shape when generating these commands.
462+ func listEnvelopeKey (value map [string ]any ) (string , bool ) {
463+ key := ""
464+ for name , field := range value {
465+ _ , isArray := field .([]any )
466+ if isArray && (name == "items" || name == "docs" || name == "list" ) {
467+ if key != "" {
468+ return "" , false // two candidate row arrays: not a flat list envelope
469+ }
470+ key = name
471+ continue
472+ }
473+ switch field .(type ) {
474+ case map [string ]any , []any :
475+ return "" , false // non-scalar sibling: a richer response, not a flat list
476+ }
477+ }
478+ return key , key != ""
479+ }
480+
481+ // objectRows converts a decoded JSON array to rows for boundProjectedList. ok
482+ // is false when any element is not an object: an array of scalars has no row
483+ // fields to bound and prints unbounded instead.
484+ func objectRows (items []any ) ([]map [string ]any , bool ) {
485+ rows := make ([]map [string ]any , len (items ))
486+ for i , item := range items {
487+ row , ok := item .(map [string ]any )
488+ if ! ok {
489+ return nil , false
490+ }
491+ rows [i ] = row
492+ }
493+ return rows , true
494+ }
495+
333496// genParseTimeFlag parses a relative-or-absolute time flag into unix seconds,
334497// mirroring the curated incident-list --since/--until handling: a Go duration
335498// ("7d", "24h") is "now minus duration", "+7d" is the future, "now" is now, and
0 commit comments