Skip to content

Commit a876215

Browse files
committed
feat: paginate list commands with --limit and truncation hints
Addresses F7 from AGENT_NATIVE_AUDIT.md. Adds --limit (default 50; 0=unbounded) and --offset to every list-style command on top of the F1 --json flag layout: vers status, vers alias (no arg), vers commit list, vers repo list, vers repo tag list, vers tag list, vers env list Output shapes: - JSON, not truncated: bare items array (backwards-compat). - JSON, truncated: envelope with items, total, limit, offset, truncated, next_offset, and hint. - Text/quiet: data on stdout, single-line truncation hint on stderr. env list special-cases the historical map shape ({KEY: VALUE, ...}) when not truncated and switches to an ordered envelope ({items: [{key, value}]}) when truncated, since Go map iteration is unstable. Pagination is currently client-side because the SDK does not yet expose typed Limit/Offset query params. Inline TODO comments at every call site mark where to plumb the flags through once the SDK supports them.
1 parent 076a080 commit a876215

6 files changed

Lines changed: 226 additions & 58 deletions

File tree

cmd/alias.go

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,40 @@ package cmd
22

33
import (
44
"fmt"
5+
"sort"
56

7+
pres "github.com/hdresearch/vers-cli/internal/presenters"
68
"github.com/hdresearch/vers-cli/internal/utils"
79
"github.com/spf13/cobra"
810
)
911

12+
var (
13+
aliasLimit int
14+
aliasOffset int
15+
)
16+
1017
var aliasCmd = &cobra.Command{
1118
Use: "alias [name]",
1219
Short: "Show VM ID for an alias, or list all aliases",
1320
Long: `Look up the VM ID for a given alias, or list all aliases if no argument is provided.
1421
1522
Examples:
1623
vers alias myvm # Show VM ID for alias 'myvm'
17-
vers alias # List all aliases`,
24+
vers alias # List all aliases
25+
26+
Pagination (when listing all):
27+
--limit N Cap results at N (default 50). Use 0 for unbounded.
28+
--offset N Skip the first N aliases (alphabetically by name).`,
1829
Args: cobra.MaximumNArgs(1),
1930
RunE: func(cmd *cobra.Command, args []string) error {
2031
if len(args) == 0 {
21-
return listAliases()
32+
return listAliases(cmd)
2233
}
2334
return showAlias(args[0])
2435
},
2536
}
2637

27-
func listAliases() error {
38+
func listAliases(cmd *cobra.Command) error {
2839
aliases, err := utils.LoadAliases()
2940
if err != nil {
3041
return fmt.Errorf("failed to load aliases: %w", err)
@@ -35,9 +46,20 @@ func listAliases() error {
3546
return nil
3647
}
3748

38-
for alias, vmID := range aliases {
39-
fmt.Printf("%s -> %s\n", alias, vmID)
49+
// Sort for stable pagination.
50+
names := make([]string, 0, len(aliases))
51+
for name := range aliases {
52+
names = append(names, name)
53+
}
54+
sort.Strings(names)
55+
56+
// TODO: aliases are stored locally; if remote alias listing ever moves
57+
// server-side, plumb aliasLimit/aliasOffset through to the request.
58+
start, end, info := pres.ApplyPaging(len(names), aliasLimit, aliasOffset)
59+
for _, name := range names[start:end] {
60+
fmt.Printf("%s -> %s\n", name, aliases[name])
4061
}
62+
pres.PrintTruncationHint(cmd.ErrOrStderr(), info)
4163
return nil
4264
}
4365

@@ -58,4 +80,6 @@ func showAlias(name string) error {
5880

5981
func init() {
6082
rootCmd.AddCommand(aliasCmd)
83+
aliasCmd.Flags().IntVar(&aliasLimit, "limit", 50, "Maximum number of aliases to return (0 = unbounded)")
84+
aliasCmd.Flags().IntVar(&aliasOffset, "offset", 0, "Number of aliases to skip (for paging)")
6185
}

cmd/commit.go

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ Use --json for machine-readable output.`,
8282
var (
8383
commitListPublic bool
8484
commitListQuiet bool
85-
commitListJSON bool
85+
commitListJSON bool
8686
commitListFormat string
87+
commitListLimit int
88+
commitListOffset int
8789
)
8890

8991
var commitListCmd = &cobra.Command{
@@ -94,7 +96,14 @@ var commitListCmd = &cobra.Command{
9496
Use -q/--quiet to output just commit IDs (one per line), useful for scripting:
9597
vers commit delete $(vers commit list -q) # delete all commits
9698
97-
Use --json for machine-readable output.`,
99+
Use --json for machine-readable output.
100+
101+
Pagination:
102+
--limit N Cap results at N (default 50). Use 0 for unbounded.
103+
--offset N Skip the first N results (use with --limit to page).
104+
105+
When the result is truncated, a hint with --offset for the next page is
106+
printed to stderr (text mode) or included in the JSON envelope.`,
98107
Args: cobra.NoArgs,
99108
RunE: func(cmd *cobra.Command, args []string) error {
100109
apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium)
@@ -111,17 +120,29 @@ Use --json for machine-readable output.`,
111120
if err != nil {
112121
return err
113122
}
123+
124+
// Apply client-side pagination over res.Commits.
125+
// TODO: when the SDK exposes server-side limit/offset on the commit
126+
// list endpoint, plumb commitListLimit/commitListOffset through to
127+
// the API and use the server-reported total instead of len(items).
128+
start, end, info := pres.ApplyPaging(len(res.Commits), commitListLimit, commitListOffset)
129+
paged := res.Commits[start:end]
130+
114131
switch format {
115132
case pres.FormatQuiet:
116-
ids := make([]string, len(res.Commits))
117-
for i, c := range res.Commits {
133+
ids := make([]string, len(paged))
134+
for i, c := range paged {
118135
ids[i] = c.CommitID
119136
}
120137
pres.PrintQuiet(ids)
138+
pres.PrintTruncationHint(cmd.ErrOrStderr(), info)
121139
case pres.FormatJSON:
122-
pres.PrintJSON(res.Commits)
140+
pres.PrintListJSON(paged, info)
123141
default:
124-
pres.RenderCommitsList(application, res)
142+
pagedView := res
143+
pagedView.Commits = paged
144+
pres.RenderCommitsList(application, pagedView)
145+
pres.PrintTruncationHint(cmd.ErrOrStderr(), info)
125146
}
126147
return nil
127148
},
@@ -249,6 +270,8 @@ func init() {
249270
commitListCmd.Flags().BoolVar(&commitListJSON, "json", false, "Output as JSON")
250271
commitListCmd.Flags().StringVar(&commitListFormat, "format", "", "Output format (json) [deprecated: use --json]")
251272
_ = commitListCmd.Flags().MarkDeprecated("format", "use --json instead")
273+
commitListCmd.Flags().IntVar(&commitListLimit, "limit", 50, "Maximum number of commits to return (0 = unbounded)")
274+
commitListCmd.Flags().IntVar(&commitListOffset, "offset", 0, "Number of commits to skip (for paging)")
252275
commitCmd.AddCommand(commitListCmd)
253276
commitCmd.AddCommand(commitDeleteCmd)
254277

cmd/env.go

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import (
1212
"github.com/spf13/cobra"
1313
)
1414

15-
var envJSON bool
16-
var envFormat string
15+
var (
16+
envJSON bool
17+
envFormat string
18+
envLimit int
19+
envOffset int
20+
)
1721

1822
// envCmd represents the env command
1923
var envCmd = &cobra.Command{
@@ -33,7 +37,16 @@ var envListCmd = &cobra.Command{
3337
Short: "List all environment variables",
3438
Long: `List all environment variables configured for your account.
3539
36-
These variables will be injected into newly created VMs at boot time.`,
40+
These variables will be injected into newly created VMs at boot time.
41+
42+
Pagination:
43+
--limit N Cap results at N (default 50). Use 0 for unbounded.
44+
--offset N Skip the first N results (alphabetically by key).
45+
46+
Note: when truncated, JSON output switches from the historical map shape
47+
({KEY: VALUE, ...}) to an ordered envelope ({items: [{key, value}, ...]})
48+
so paging by --offset is stable. The map shape is preserved when not
49+
truncated for backwards compatibility.`,
3750
Aliases: []string{"ls"},
3851
RunE: func(cmd *cobra.Command, args []string) error {
3952
apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium)
@@ -48,25 +61,52 @@ These variables will be injected into newly created VMs at boot time.`,
4861
if err != nil {
4962
return err
5063
}
64+
65+
// Sort keys for consistent output and stable pagination.
66+
keys := make([]string, 0, len(vars))
67+
for k := range vars {
68+
keys = append(keys, k)
69+
}
70+
sort.Strings(keys)
71+
72+
// TODO: env list returns the entire map from the API; once a
73+
// server-side limit/offset is exposed, plumb envLimit/envOffset
74+
// through instead of trimming client-side here.
75+
start, end, info := pres.ApplyPaging(len(keys), envLimit, envOffset)
76+
pagedKeys := keys[start:end]
77+
78+
// Build a sorted, paged map for emission. Use a slice of {key,value}
79+
// pairs so JSON output preserves order when truncated.
80+
type kv struct {
81+
Key string `json:"key"`
82+
Value string `json:"value"`
83+
}
84+
pagedPairs := make([]kv, len(pagedKeys))
85+
pagedMap := make(map[string]string, len(pagedKeys))
86+
for i, k := range pagedKeys {
87+
pagedPairs[i] = kv{Key: k, Value: vars[k]}
88+
pagedMap[k] = vars[k]
89+
}
90+
5191
switch format {
5292
case pres.FormatJSON:
53-
pres.PrintJSON(vars)
93+
// Preserve historical shape (object keyed by name) when not
94+
// truncated; switch to ordered envelope when paginated so the
95+
// agent gets a stable next_offset.
96+
if info.Truncated {
97+
pres.PrintListJSON(pagedPairs, info)
98+
} else {
99+
pres.PrintJSON(pagedMap)
100+
}
54101
default:
55-
if len(vars) == 0 {
102+
if len(keys) == 0 {
56103
fmt.Println("No environment variables configured.")
57104
return nil
58105
}
59106

60-
// Sort keys for consistent output
61-
keys := make([]string, 0, len(vars))
62-
for k := range vars {
63-
keys = append(keys, k)
64-
}
65-
sort.Strings(keys)
66-
67107
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
68108
fmt.Fprintln(w, "KEY\tVALUE")
69-
for _, key := range keys {
109+
for _, key := range pagedKeys {
70110
value := vars[key]
71111
// Truncate long values for display
72112
if len(value) > 50 {
@@ -75,6 +115,7 @@ These variables will be injected into newly created VMs at boot time.`,
75115
fmt.Fprintf(w, "%s\t%s\n", key, value)
76116
}
77117
w.Flush()
118+
pres.PrintTruncationHint(cmd.ErrOrStderr(), info)
78119
}
79120
return nil
80121
},
@@ -195,4 +236,6 @@ func init() {
195236
envListCmd.Flags().BoolVar(&envJSON, "json", false, "Output as JSON")
196237
envListCmd.Flags().StringVar(&envFormat, "format", "", "Output format (json) [deprecated: use --json]")
197238
_ = envListCmd.Flags().MarkDeprecated("format", "use --json instead")
239+
envListCmd.Flags().IntVar(&envLimit, "limit", 50, "Maximum number of environment variables to return (0 = unbounded)")
240+
envListCmd.Flags().IntVar(&envOffset, "offset", 0, "Number of variables to skip (for paging)")
198241
}

cmd/repo.go

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@ var repoCreateCmd = &cobra.Command{
4545

4646
var (
4747
repoListQuiet bool
48-
repoListJSON bool
48+
repoListJSON bool
4949
repoListFormat string
50+
repoListLimit int
51+
repoListOffset int
5052
)
5153

5254
var repoListCmd = &cobra.Command{
@@ -55,7 +57,14 @@ var repoListCmd = &cobra.Command{
5557
Long: `List all repositories in your organization.
5658
5759
Use -q/--quiet to output just names (one per line), useful for scripting.
58-
Use --json for machine-readable output.`,
60+
Use --json for machine-readable output.
61+
62+
Pagination:
63+
--limit N Cap results at N (default 50). Use 0 for unbounded.
64+
--offset N Skip the first N results (use with --limit to page).
65+
66+
When the result is truncated, a hint with --offset for the next page is
67+
printed to stderr (text mode) or included in the JSON envelope.`,
5968
Args: cobra.NoArgs,
6069
RunE: func(cmd *cobra.Command, args []string) error {
6170
apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium)
@@ -70,17 +79,25 @@ Use --json for machine-readable output.`,
7079
if err != nil {
7180
return err
7281
}
82+
83+
// TODO: when the SDK exposes server-side limit/offset for repo list,
84+
// plumb repoListLimit/repoListOffset through instead of trimming here.
85+
start, end, info := pres.ApplyPaging(len(res.Repositories), repoListLimit, repoListOffset)
86+
paged := res.Repositories[start:end]
87+
7388
switch format {
7489
case pres.FormatQuiet:
75-
names := make([]string, len(res.Repositories))
76-
for i, r := range res.Repositories {
90+
names := make([]string, len(paged))
91+
for i, r := range paged {
7792
names[i] = r.Name
7893
}
7994
pres.PrintQuiet(names)
95+
pres.PrintTruncationHint(cmd.ErrOrStderr(), info)
8096
case pres.FormatJSON:
81-
pres.PrintJSON(res.Repositories)
97+
pres.PrintListJSON(paged, info)
8298
default:
83-
pres.RenderRepoList(application, pres.RepoListView{Repositories: res.Repositories})
99+
pres.RenderRepoList(application, pres.RepoListView{Repositories: paged})
100+
pres.PrintTruncationHint(cmd.ErrOrStderr(), info)
84101
}
85102
return nil
86103
},
@@ -270,16 +287,22 @@ var repoTagCreateCmd = &cobra.Command{
270287

271288
var (
272289
repoTagListQuiet bool
273-
repoTagListJSON bool
290+
repoTagListJSON bool
274291
repoTagListFormat string
292+
repoTagListLimit int
293+
repoTagListOffset int
275294
)
276295

277296
var repoTagListCmd = &cobra.Command{
278297
Use: "list <repo-name>",
279298
Short: "List tags in a repository",
280299
Long: `List all tags within a repository.
281300
282-
Use -q/--quiet for just tag names. Use --json for machine-readable output.`,
301+
Use -q/--quiet for just tag names. Use --json for machine-readable output.
302+
303+
Pagination:
304+
--limit N Cap results at N (default 50). Use 0 for unbounded.
305+
--offset N Skip the first N results (use with --limit to page).`,
283306
Args: cobra.ExactArgs(1),
284307
RunE: func(cmd *cobra.Command, args []string) error {
285308
apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium)
@@ -296,20 +319,28 @@ Use -q/--quiet for just tag names. Use --json for machine-readable output.`,
296319
if err != nil {
297320
return err
298321
}
322+
323+
// TODO: plumb limit/offset to the SDK once server-side pagination is
324+
// exposed; today we trim client-side after the full response.
325+
start, end, info := pres.ApplyPaging(len(res.Tags), repoTagListLimit, repoTagListOffset)
326+
paged := res.Tags[start:end]
327+
299328
switch format {
300329
case pres.FormatQuiet:
301-
names := make([]string, len(res.Tags))
302-
for i, t := range res.Tags {
330+
names := make([]string, len(paged))
331+
for i, t := range paged {
303332
names[i] = t.TagName
304333
}
305334
pres.PrintQuiet(names)
335+
pres.PrintTruncationHint(cmd.ErrOrStderr(), info)
306336
case pres.FormatJSON:
307-
pres.PrintJSON(res.Tags)
337+
pres.PrintListJSON(paged, info)
308338
default:
309339
pres.RenderRepoTagList(application, pres.RepoTagListView{
310340
Repository: res.Repository,
311-
Tags: res.Tags,
341+
Tags: paged,
312342
})
343+
pres.PrintTruncationHint(cmd.ErrOrStderr(), info)
313344
}
314345
return nil
315346
},
@@ -470,6 +501,8 @@ func init() {
470501
repoListCmd.Flags().BoolVar(&repoListJSON, "json", false, "Output as JSON")
471502
repoListCmd.Flags().StringVar(&repoListFormat, "format", "", "Output format (json) [deprecated: use --json]")
472503
_ = repoListCmd.Flags().MarkDeprecated("format", "use --json instead")
504+
repoListCmd.Flags().IntVar(&repoListLimit, "limit", 50, "Maximum number of repositories to return (0 = unbounded)")
505+
repoListCmd.Flags().IntVar(&repoListOffset, "offset", 0, "Number of repositories to skip (for paging)")
473506
repoCmd.AddCommand(repoListCmd)
474507

475508
// repo get
@@ -500,6 +533,8 @@ func init() {
500533
repoTagListCmd.Flags().BoolVar(&repoTagListJSON, "json", false, "Output as JSON")
501534
repoTagListCmd.Flags().StringVar(&repoTagListFormat, "format", "", "Output format (json) [deprecated: use --json]")
502535
_ = repoTagListCmd.Flags().MarkDeprecated("format", "use --json instead")
536+
repoTagListCmd.Flags().IntVar(&repoTagListLimit, "limit", 50, "Maximum number of tags to return (0 = unbounded)")
537+
repoTagListCmd.Flags().IntVar(&repoTagListOffset, "offset", 0, "Number of tags to skip (for paging)")
503538
repoTagCmd.AddCommand(repoTagListCmd)
504539

505540
repoTagGetCmd.Flags().BoolVar(&repoTagGetJSON, "json", false, "Output as JSON")

0 commit comments

Comments
 (0)