From 64283382bf9f3223817ae695331cfd73a50b188d Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Thu, 7 May 2026 17:51:04 -0700 Subject: [PATCH 1/5] feat: standardize on --json flag (deprecate --format json) Addresses F1 from AGENT_NATIVE_AUDIT.md. --- F1-json-flag.md | 78 ++++++++++++++++++++++++++++++ README.md | 12 ++--- cmd/branch.go | 12 +++-- cmd/commit.go | 36 ++++++++++---- cmd/deploy.go | 12 +++-- cmd/env.go | 10 +++- cmd/info.go | 12 +++-- cmd/pause.go | 12 +++-- cmd/repo.go | 48 +++++++++++++----- cmd/resize.go | 12 +++-- cmd/resume.go | 12 +++-- cmd/run.go | 12 +++-- cmd/run_commit.go | 12 +++-- cmd/status.go | 12 +++-- cmd/tag.go | 24 ++++++--- internal/presenters/format.go | 24 +++++++-- internal/presenters/format_test.go | 37 +++++++++++--- 17 files changed, 302 insertions(+), 75 deletions(-) create mode 100644 F1-json-flag.md diff --git a/F1-json-flag.md b/F1-json-flag.md new file mode 100644 index 0000000..1fe3c18 --- /dev/null +++ b/F1-json-flag.md @@ -0,0 +1,78 @@ +# F1: --format json → --json (completed) + +Implemented finding F1 from `AGENT_NATIVE_AUDIT.md`. vers-cli now standardizes on `--json` across the entire command surface, with `--format` retained as a hidden, deprecated alias. + +## Changed files + +``` + README.md | 12 +++--- + cmd/branch.go | 12 ++++-- + cmd/commit.go | 36 ++++++++++++---- + cmd/deploy.go | 12 ++++-- + cmd/env.go | 10 +++-- + cmd/info.go | 12 ++++-- + cmd/pause.go | 12 ++++-- + cmd/repo.go | 48 ++++++++++++++++----- + cmd/resize.go | 12 ++++-- + cmd/resume.go | 12 ++++-- + cmd/run.go | 12 ++++-- + cmd/run_commit.go | 12 ++++-- + cmd/status.go | 12 ++++-- + cmd/tag.go | 24 +++++++---- + internal/presenters/format.go | 24 +++++++++-- + internal/presenters/format_test.go | 37 +++++++++++++---- + 16 files changed, 224 insertions(+), 75 deletions(-) +``` + +19 cobra `--format` registrations now have a sibling `--json` BoolVar; 19 `pres.ParseFormat` call sites now pass the JSON flag and propagate the validation error. + +Commit: `6744100 feat: standardize on --json flag (deprecate --format json)` on branch `pi-parallel-d95f3d3f-0`. + +## Design choices + +1. **Centralized validation in `presenters.ParseFormat`.** New signature: `ParseFormat(quiet, jsonFlag bool, formatStr string) (OutputFormat, error)`. Precedence is `quiet > json > format > default`. Invalid `--format` values return an enumerated error rather than silently falling through to default-format (which was the previous behavior — a P3 violation). +2. **Cobra's `MarkDeprecated` handles the warning.** It prints `Flag --format has been deprecated, use --json instead` to stderr automatically when the flag is used, and hides the flag from `--help`. No custom warning code needed. +3. **Stdout/stderr separation preserved.** Deprecation warnings go to stderr; JSON data continues to land cleanly on stdout. `vers status --format json | jq` still works without contamination. + +## Validation + +- `go build ./...` — clean +- `go vet ./...` — clean +- `go test ./...` — all packages pass (including expanded `format_test.go` with 9 cases covering the new precedence and error semantics) + +Smoke tests against built binary: + +``` +$ vers status --json +[] # exit 0, clean stdout + +$ vers status --format json 2>/tmp/err +[] # exit 0, JSON on stdout +$ cat /tmp/err +Flag --format has been deprecated, use --json instead + +$ vers status --format yaml +Flag --format has been deprecated, use --json instead +Error: --format must be "json" (got: "yaml"). Note: --format is deprecated, use --json instead + # exit 1 +``` + +`vers status --help` no longer lists `--format`; long descriptions everywhere now read "Use --json for machine-readable output." + +## Risks / open questions + +- **Usage banner printed on RunE error.** When `--format yaml` is rejected, cobra prints the command's usage block after the error. This is pre-existing behavior — `SilenceUsage = true` is only set in the auth path in `cmd/root.go:210`. Out of scope for F1, but worth a follow-up if cleaner agent-facing errors are wanted (set `SilenceUsage = true` on the root command). +- **`alias` command is still missing JSON output entirely.** This is finding F2, separate task. +- **No tests assert deprecation warning emission to stderr.** The cobra `MarkDeprecated` behavior is library-provided and stable, so I didn't add an integration test capturing stderr; the unit tests exercise only the validation path. If desired, a small `cmd/`-level test could capture stderr from a `--format json` invocation. + +## Recommended next step + +Hand off to F2 (info → get) and F3 (pagination) as planned. After both land, audit the few commands that have a `--quiet` flag but no `--json` (`alias`, possibly `head`, `checkout`) and unify those — the `ParseFormat` machinery is already in place to extend them cheaply. + +--- + +Implemented F1 (canonical `--json` flag). +Changed files: 16 (13 cmd files, presenters package, README). +Validation: `go build`, `go vet`, `go test ./...` all clean; manual smoke tests confirm `--json` works, legacy `--format json` works with stderr deprecation, invalid `--format` values error with the enumerated message. +Open risks/questions: usage banner on error is pre-existing; no integration test for deprecation warning. +Recommended next step: proceed with F2 and F3 as planned. diff --git a/README.md b/README.md index a7bf134..cd4eef6 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,11 @@ vers status vers status -q # Full JSON output -vers status --format json +vers status --json # Detailed metadata for a VM (IP, lineage, timestamps) vers info -vers info --format json +vers info --json # Execute a command on a VM vers execute [args...] @@ -91,7 +91,7 @@ vers commit # List your commits vers commit list vers commit list -q # just IDs -vers commit list --format json +vers commit list --json vers commit list --public # public commits # View commit history (parent chain) @@ -118,7 +118,7 @@ vers tag create production abc-123 -d "stable release" # List all tags vers tag list vers tag list -q # just names -vers tag list --format json +vers tag list --json # Get tag details vers tag get @@ -150,8 +150,8 @@ vers tag delete $(vers tag list -q) vers info $(vers status -q | head -1) # JSON piped to jq -vers status --format json | jq '.[].vm_id' -vers info --format json | jq '.ip' +vers status --json | jq '.[].vm_id' +vers info --json | jq '.ip' ``` `ps` is an alias for `status`: diff --git a/cmd/branch.go b/cmd/branch.go index 4135512..bbdc168 100644 --- a/cmd/branch.go +++ b/cmd/branch.go @@ -11,6 +11,7 @@ import ( var ( alias string branchCount int + branchJSON bool branchFormat string branchWait bool ) @@ -21,7 +22,7 @@ var branchCmd = &cobra.Command{ Short: "Create a new VM from an existing VM", Long: `Create a new VM (branch) from the state of an existing VM. If no VM ID or alias is provided, uses the current HEAD. -Use --format json for machine-readable output. +Use --json for machine-readable output. Use --wait to block until new VMs are running.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -45,7 +46,10 @@ Use --wait to block until new VMs are running.`, return err } - format := pres.ParseFormat(false, branchFormat) + format, err := pres.ParseFormat(false, branchJSON, branchFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(res) @@ -62,6 +66,8 @@ func init() { branchCmd.Flags().StringVarP(&alias, "alias", "n", "", "Alias for the new VM") branchCmd.Flags().BoolP("checkout", "c", false, "Switch to the new VM after creation") branchCmd.Flags().IntVar(&branchCount, "count", 1, "Number of branches to create") - branchCmd.Flags().StringVar(&branchFormat, "format", "", "Output format (json)") + branchCmd.Flags().BoolVar(&branchJSON, "json", false, "Output as JSON") + branchCmd.Flags().StringVar(&branchFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = branchCmd.Flags().MarkDeprecated("format", "use --json instead") branchCmd.Flags().BoolVar(&branchWait, "wait", false, "Wait until new VMs are running") } diff --git a/cmd/commit.go b/cmd/commit.go index 554cec8..3ce5ede 100644 --- a/cmd/commit.go +++ b/cmd/commit.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" ) +var commitJSON bool var commitFormat string // commitCmd is the parent command for commit operations. @@ -42,7 +43,7 @@ var commitCreateCmd = &cobra.Command{ Long: `Save the current state of a VM as a commit. If no VM ID or alias is provided, commits the current HEAD VM. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { target := "" @@ -60,7 +61,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(false, commitFormat) + format, err := pres.ParseFormat(false, commitJSON, commitFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(res) @@ -78,6 +82,7 @@ Use --format json for machine-readable output.`, var ( commitListPublic bool commitListQuiet bool + commitListJSON bool commitListFormat string ) @@ -89,7 +94,7 @@ var commitListCmd = &cobra.Command{ Use -q/--quiet to output just commit IDs (one per line), useful for scripting: vers commit delete $(vers commit list -q) # delete all commits -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -102,7 +107,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(commitListQuiet, commitListFormat) + format, err := pres.ParseFormat(commitListQuiet, commitListJSON, commitListFormat) + if err != nil { + return err + } switch format { case pres.FormatQuiet: ids := make([]string, len(res.Commits)) @@ -151,6 +159,7 @@ Examples: }, } +var commitHistoryJSON bool var commitHistoryFormat string var commitHistoryCmd = &cobra.Command{ @@ -158,7 +167,7 @@ var commitHistoryCmd = &cobra.Command{ Short: "Show the parent commit chain", Long: `Display the chain of parent commits leading up to a given commit. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -171,7 +180,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(false, commitHistoryFormat) + format, err := pres.ParseFormat(false, commitHistoryJSON, commitHistoryFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(res.Parents) @@ -227,16 +239,22 @@ var commitUnpublishCmd = &cobra.Command{ func init() { rootCmd.AddCommand(commitCmd) - commitCreateCmd.Flags().StringVar(&commitFormat, "format", "", "Output format (json)") + commitCreateCmd.Flags().BoolVar(&commitJSON, "json", false, "Output as JSON") + commitCreateCmd.Flags().StringVar(&commitFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = commitCreateCmd.Flags().MarkDeprecated("format", "use --json instead") commitCmd.AddCommand(commitCreateCmd) commitListCmd.Flags().BoolVar(&commitListPublic, "public", false, "List public commits instead of your own") commitListCmd.Flags().BoolVarP(&commitListQuiet, "quiet", "q", false, "Only display commit IDs") - commitListCmd.Flags().StringVar(&commitListFormat, "format", "", "Output format (json)") + commitListCmd.Flags().BoolVar(&commitListJSON, "json", false, "Output as JSON") + commitListCmd.Flags().StringVar(&commitListFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = commitListCmd.Flags().MarkDeprecated("format", "use --json instead") commitCmd.AddCommand(commitListCmd) commitCmd.AddCommand(commitDeleteCmd) - commitHistoryCmd.Flags().StringVar(&commitHistoryFormat, "format", "", "Output format (json)") + commitHistoryCmd.Flags().BoolVar(&commitHistoryJSON, "json", false, "Output as JSON") + commitHistoryCmd.Flags().StringVar(&commitHistoryFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = commitHistoryCmd.Flags().MarkDeprecated("format", "use --json instead") commitCmd.AddCommand(commitHistoryCmd) commitCmd.AddCommand(commitPublishCmd) commitCmd.AddCommand(commitUnpublishCmd) diff --git a/cmd/deploy.go b/cmd/deploy.go index 9a28aab..46cf8b2 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -15,6 +15,7 @@ var ( deployBuildCommand string deployRunCommand string deployWorkingDirectory string + deployJSON bool deployFormat string deployWait bool ) @@ -38,7 +39,7 @@ Examples: vers deploy hdresearch/my-app --branch develop vers deploy hdresearch/my-app --name my-project --install "npm install" --build "npm run build" --run "npm start" vers deploy hdresearch/my-app --working-dir packages/web - vers deploy hdresearch/my-app --format json`, + vers deploy hdresearch/my-app --json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APILong) @@ -60,7 +61,10 @@ Examples: return err } - format := pres.ParseFormat(false, deployFormat) + format, err := pres.ParseFormat(false, deployJSON, deployFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(view) @@ -80,6 +84,8 @@ func init() { deployCmd.Flags().StringVar(&deployBuildCommand, "build", "", "Build command (e.g. \"npm run build\")") deployCmd.Flags().StringVar(&deployRunCommand, "run", "", "Run command (e.g. \"npm start\")") deployCmd.Flags().StringVar(&deployWorkingDirectory, "working-dir", "", "Working directory relative to repo root") - deployCmd.Flags().StringVar(&deployFormat, "format", "", "Output format (json)") + deployCmd.Flags().BoolVar(&deployJSON, "json", false, "Output as JSON") + deployCmd.Flags().StringVar(&deployFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = deployCmd.Flags().MarkDeprecated("format", "use --json instead") deployCmd.Flags().BoolVar(&deployWait, "wait", false, "Wait until the VM is running before returning") } diff --git a/cmd/env.go b/cmd/env.go index 4422cea..bec710b 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" ) +var envJSON bool var envFormat string // envCmd represents the env command @@ -43,7 +44,10 @@ These variables will be injected into newly created VMs at boot time.`, return err } - format := pres.ParseFormat(false, envFormat) + format, err := pres.ParseFormat(false, envJSON, envFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(vars) @@ -188,5 +192,7 @@ func init() { envCmd.AddCommand(envDeleteCmd) // Add flags - envListCmd.Flags().StringVar(&envFormat, "format", "", "Output format (json)") + envListCmd.Flags().BoolVar(&envJSON, "json", false, "Output as JSON") + envListCmd.Flags().StringVar(&envFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = envListCmd.Flags().MarkDeprecated("format", "use --json instead") } diff --git a/cmd/info.go b/cmd/info.go index 8534f0f..e396343 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -10,6 +10,7 @@ import ( var ( infoQuiet bool + infoJSON bool infoFormat string ) @@ -20,7 +21,7 @@ var infoCmd = &cobra.Command{ grandparent VM), and timestamps. If no VM is specified, uses the current HEAD. Use -q/--quiet to output just the VM ID. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { target := "" @@ -36,7 +37,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(infoQuiet, infoFormat) + format, err := pres.ParseFormat(infoQuiet, infoJSON, infoFormat) + if err != nil { + return err + } switch format { case pres.FormatQuiet: pres.PrintQuiet([]string{res.Metadata.VmID}) @@ -52,5 +56,7 @@ Use --format json for machine-readable output.`, func init() { rootCmd.AddCommand(infoCmd) infoCmd.Flags().BoolVarP(&infoQuiet, "quiet", "q", false, "Only display VM ID") - infoCmd.Flags().StringVar(&infoFormat, "format", "", "Output format (json)") + infoCmd.Flags().BoolVar(&infoJSON, "json", false, "Output as JSON") + infoCmd.Flags().StringVar(&infoFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = infoCmd.Flags().MarkDeprecated("format", "use --json instead") } diff --git a/cmd/pause.go b/cmd/pause.go index 7e9ce0b..2300108 100644 --- a/cmd/pause.go +++ b/cmd/pause.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" ) +var pauseJSON bool var pauseFormat string var pauseCmd = &cobra.Command{ @@ -15,7 +16,7 @@ var pauseCmd = &cobra.Command{ Short: "Pause a running VM", Long: `Pause a running Vers VM. If no VM ID or alias is provided, uses the current HEAD. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -29,7 +30,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(false, pauseFormat) + format, err := pres.ParseFormat(false, pauseJSON, pauseFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(map[string]string{"vm_id": view.VMName, "state": view.NewState}) @@ -42,5 +46,7 @@ Use --format json for machine-readable output.`, func init() { rootCmd.AddCommand(pauseCmd) - pauseCmd.Flags().StringVar(&pauseFormat, "format", "", "Output format (json)") + pauseCmd.Flags().BoolVar(&pauseJSON, "json", false, "Output as JSON") + pauseCmd.Flags().StringVar(&pauseFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = pauseCmd.Flags().MarkDeprecated("format", "use --json instead") } diff --git a/cmd/repo.go b/cmd/repo.go index 22057d2..7bf13bc 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -45,6 +45,7 @@ var repoCreateCmd = &cobra.Command{ var ( repoListQuiet bool + repoListJSON bool repoListFormat string ) @@ -54,7 +55,7 @@ var repoListCmd = &cobra.Command{ Long: `List all repositories in your organization. Use -q/--quiet to output just names (one per line), useful for scripting. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -65,7 +66,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(repoListQuiet, repoListFormat) + format, err := pres.ParseFormat(repoListQuiet, repoListJSON, repoListFormat) + if err != nil { + return err + } switch format { case pres.FormatQuiet: names := make([]string, len(res.Repositories)) @@ -84,6 +88,7 @@ Use --format json for machine-readable output.`, // ── repo get ───────────────────────────────────────────────────────── +var repoGetJSON bool var repoGetFormat string var repoGetCmd = &cobra.Command{ @@ -91,7 +96,7 @@ var repoGetCmd = &cobra.Command{ Short: "Get details of a repository", Long: `Show detailed information about a specific repository. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -104,7 +109,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(false, repoGetFormat) + format, err := pres.ParseFormat(false, repoGetJSON, repoGetFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(info) @@ -262,6 +270,7 @@ var repoTagCreateCmd = &cobra.Command{ var ( repoTagListQuiet bool + repoTagListJSON bool repoTagListFormat string ) @@ -270,7 +279,7 @@ var repoTagListCmd = &cobra.Command{ Short: "List tags in a repository", Long: `List all tags within a repository. -Use -q/--quiet for just tag names. Use --format json for machine-readable output.`, +Use -q/--quiet for just tag names. Use --json for machine-readable output.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -283,7 +292,10 @@ Use -q/--quiet for just tag names. Use --format json for machine-readable output return err } - format := pres.ParseFormat(repoTagListQuiet, repoTagListFormat) + format, err := pres.ParseFormat(repoTagListQuiet, repoTagListJSON, repoTagListFormat) + if err != nil { + return err + } switch format { case pres.FormatQuiet: names := make([]string, len(res.Tags)) @@ -303,6 +315,7 @@ Use -q/--quiet for just tag names. Use --format json for machine-readable output }, } +var repoTagGetJSON bool var repoTagGetFormat string var repoTagGetCmd = &cobra.Command{ @@ -310,7 +323,7 @@ var repoTagGetCmd = &cobra.Command{ Short: "Get details of a repository tag", Long: `Show detailed information about a specific tag within a repository. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -324,7 +337,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(false, repoTagGetFormat) + format, err := pres.ParseFormat(false, repoTagGetJSON, repoTagGetFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(info) @@ -451,11 +467,15 @@ func init() { // repo list repoListCmd.Flags().BoolVarP(&repoListQuiet, "quiet", "q", false, "Only display repository names") - repoListCmd.Flags().StringVar(&repoListFormat, "format", "", "Output format (json)") + repoListCmd.Flags().BoolVar(&repoListJSON, "json", false, "Output as JSON") + repoListCmd.Flags().StringVar(&repoListFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = repoListCmd.Flags().MarkDeprecated("format", "use --json instead") repoCmd.AddCommand(repoListCmd) // repo get - repoGetCmd.Flags().StringVar(&repoGetFormat, "format", "", "Output format (json)") + repoGetCmd.Flags().BoolVar(&repoGetJSON, "json", false, "Output as JSON") + repoGetCmd.Flags().StringVar(&repoGetFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = repoGetCmd.Flags().MarkDeprecated("format", "use --json instead") repoCmd.AddCommand(repoGetCmd) // repo delete @@ -477,10 +497,14 @@ func init() { repoTagCmd.AddCommand(repoTagCreateCmd) repoTagListCmd.Flags().BoolVarP(&repoTagListQuiet, "quiet", "q", false, "Only display tag names") - repoTagListCmd.Flags().StringVar(&repoTagListFormat, "format", "", "Output format (json)") + repoTagListCmd.Flags().BoolVar(&repoTagListJSON, "json", false, "Output as JSON") + repoTagListCmd.Flags().StringVar(&repoTagListFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = repoTagListCmd.Flags().MarkDeprecated("format", "use --json instead") repoTagCmd.AddCommand(repoTagListCmd) - repoTagGetCmd.Flags().StringVar(&repoTagGetFormat, "format", "", "Output format (json)") + repoTagGetCmd.Flags().BoolVar(&repoTagGetJSON, "json", false, "Output as JSON") + repoTagGetCmd.Flags().StringVar(&repoTagGetFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = repoTagGetCmd.Flags().MarkDeprecated("format", "use --json instead") repoTagCmd.AddCommand(repoTagGetCmd) repoTagUpdateCmd.Flags().StringVar(&repoTagUpdateCommit, "commit", "", "Move tag to this commit ID") diff --git a/cmd/resize.go b/cmd/resize.go index 4caa501..257cdd6 100644 --- a/cmd/resize.go +++ b/cmd/resize.go @@ -11,6 +11,7 @@ import ( var ( resizeDiskSize int64 + resizeJSON bool resizeFormat string ) @@ -20,7 +21,7 @@ var resizeCmd = &cobra.Command{ Long: `Resize a VM's disk to a new size. The new size must be strictly greater than the current size. Size is specified in MiB using the --size flag. If no VM is specified, uses the current HEAD. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { target := "" @@ -39,7 +40,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(false, resizeFormat) + format, err := pres.ParseFormat(false, resizeJSON, resizeFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(map[string]interface{}{"vm_id": vmID, "fs_size_mib": resizeDiskSize}) @@ -54,5 +58,7 @@ func init() { rootCmd.AddCommand(resizeCmd) resizeCmd.Flags().Int64Var(&resizeDiskSize, "size", 0, "New disk size in MiB (required, must be greater than current size)") resizeCmd.MarkFlagRequired("size") - resizeCmd.Flags().StringVar(&resizeFormat, "format", "", "Output format (json)") + resizeCmd.Flags().BoolVar(&resizeJSON, "json", false, "Output as JSON") + resizeCmd.Flags().StringVar(&resizeFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = resizeCmd.Flags().MarkDeprecated("format", "use --json instead") } diff --git a/cmd/resume.go b/cmd/resume.go index 38dfc5a..2e64467 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -9,6 +9,7 @@ import ( ) var ( + resumeJSON bool resumeFormat string resumeWait bool ) @@ -18,7 +19,7 @@ var resumeCmd = &cobra.Command{ Short: "Resume a paused VM", Long: `Resume a paused Vers VM. If no VM ID or alias is provided, uses the current HEAD. -Use --format json for machine-readable output. +Use --json for machine-readable output. Use --wait to block until the VM is running.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -36,7 +37,10 @@ Use --wait to block until the VM is running.`, return err } - format := pres.ParseFormat(false, resumeFormat) + format, err := pres.ParseFormat(false, resumeJSON, resumeFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(map[string]string{"vm_id": view.VMName, "state": view.NewState}) @@ -49,6 +53,8 @@ Use --wait to block until the VM is running.`, func init() { rootCmd.AddCommand(resumeCmd) - resumeCmd.Flags().StringVar(&resumeFormat, "format", "", "Output format (json)") + resumeCmd.Flags().BoolVar(&resumeJSON, "json", false, "Output as JSON") + resumeCmd.Flags().StringVar(&resumeFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = resumeCmd.Flags().MarkDeprecated("format", "use --json instead") resumeCmd.Flags().BoolVar(&resumeWait, "wait", false, "Wait until VM is running") } diff --git a/cmd/run.go b/cmd/run.go index 5a6e066..38695ea 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -11,6 +11,7 @@ import ( var ( vmAlias string + runJSON bool runFormat string runWait bool ) @@ -21,7 +22,7 @@ var runCmd = &cobra.Command{ Short: "Start a development environment", Long: `Start a Vers development environment according to the configuration in vers.toml. -Use --format json for machine-readable output. +Use --json for machine-readable output. Use --wait to block until the VM is running.`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := runconfig.Load() @@ -45,7 +46,10 @@ Use --wait to block until the VM is running.`, return err } - format := pres.ParseFormat(false, runFormat) + format, err := pres.ParseFormat(false, runJSON, runFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(view) @@ -65,6 +69,8 @@ func init() { runCmd.Flags().String("kernel", "", "Override kernel name") runCmd.Flags().Int64("fs-size-vm", 0, "Override VM filesystem size (MiB)") runCmd.Flags().StringVarP(&vmAlias, "vm-alias", "N", "", "Set an alias for the root VM") - runCmd.Flags().StringVar(&runFormat, "format", "", "Output format (json)") + runCmd.Flags().BoolVar(&runJSON, "json", false, "Output as JSON") + runCmd.Flags().StringVar(&runFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = runCmd.Flags().MarkDeprecated("format", "use --json instead") runCmd.Flags().BoolVar(&runWait, "wait", false, "Wait until VM is running before returning") } diff --git a/cmd/run_commit.go b/cmd/run_commit.go index ba73741..6b88780 100644 --- a/cmd/run_commit.go +++ b/cmd/run_commit.go @@ -11,6 +11,7 @@ import ( var ( commitVmAlias string + runCommitJSON bool runCommitFormat string runCommitWait bool ) @@ -21,7 +22,7 @@ var runCommitCmd = &cobra.Command{ Short: "Start a development environment from a commit", Long: `Start a Vers development environment from an existing commit using its commit key. -Use --format json for machine-readable output. +Use --json for machine-readable output. Use --wait to block until the VM is running.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -39,7 +40,10 @@ Use --wait to block until the VM is running.`, return err } - format := pres.ParseFormat(false, runCommitFormat) + format, err := pres.ParseFormat(false, runCommitJSON, runCommitFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(view) @@ -54,6 +58,8 @@ func init() { rootCmd.AddCommand(runCommitCmd) runCommitCmd.Flags().StringVarP(&commitVmAlias, "vm-alias", "N", "", "Set an alias for the root VM") - runCommitCmd.Flags().StringVar(&runCommitFormat, "format", "", "Output format (json)") + runCommitCmd.Flags().BoolVar(&runCommitJSON, "json", false, "Output as JSON") + runCommitCmd.Flags().StringVar(&runCommitFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = runCommitCmd.Flags().MarkDeprecated("format", "use --json instead") runCommitCmd.Flags().BoolVar(&runCommitWait, "wait", false, "Wait until VM is running") } diff --git a/cmd/status.go b/cmd/status.go index 13be467..0f17ecc 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -10,6 +10,7 @@ import ( var ( statusQuiet bool + statusJSON bool statusFormat string ) @@ -23,7 +24,7 @@ Use -q/--quiet to output just VM IDs (one per line), useful for scripting: vers kill $(vers status -q) # kill all VMs vers info $(vers status -q | head -1) # info on first VM -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Aliases: []string{"ps"}, RunE: func(cmd *cobra.Command, args []string) error { var target string @@ -39,7 +40,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(statusQuiet, statusFormat) + format, err := pres.ParseFormat(statusQuiet, statusJSON, statusFormat) + if err != nil { + return err + } switch format { case pres.FormatQuiet: if res.Mode == pres.StatusVM && res.VM != nil { @@ -67,5 +71,7 @@ Use --format json for machine-readable output.`, func init() { rootCmd.AddCommand(statusCmd) statusCmd.Flags().BoolVarP(&statusQuiet, "quiet", "q", false, "Only display VM IDs") - statusCmd.Flags().StringVar(&statusFormat, "format", "", "Output format (json)") + statusCmd.Flags().BoolVar(&statusJSON, "json", false, "Output as JSON") + statusCmd.Flags().StringVar(&statusFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = statusCmd.Flags().MarkDeprecated("format", "use --json instead") } diff --git a/cmd/tag.go b/cmd/tag.go index 7670512..6287faf 100644 --- a/cmd/tag.go +++ b/cmd/tag.go @@ -42,6 +42,7 @@ var tagCreateCmd = &cobra.Command{ var ( tagListQuiet bool + tagListJSON bool tagListFormat string ) @@ -53,7 +54,7 @@ var tagListCmd = &cobra.Command{ Use -q/--quiet to output just tag names (one per line), useful for scripting: vers tag delete $(vers tag list -q) # delete all tags -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -64,7 +65,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(tagListQuiet, tagListFormat) + format, err := pres.ParseFormat(tagListQuiet, tagListJSON, tagListFormat) + if err != nil { + return err + } switch format { case pres.FormatQuiet: names := make([]string, len(res.Tags)) @@ -81,6 +85,7 @@ Use --format json for machine-readable output.`, }, } +var tagGetJSON bool var tagGetFormat string var tagGetCmd = &cobra.Command{ @@ -88,7 +93,7 @@ var tagGetCmd = &cobra.Command{ Short: "Get details of a tag", Long: `Show detailed information about a specific tag. -Use --format json for machine-readable output.`, +Use --json for machine-readable output.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -101,7 +106,10 @@ Use --format json for machine-readable output.`, return err } - format := pres.ParseFormat(false, tagGetFormat) + format, err := pres.ParseFormat(false, tagGetJSON, tagGetFormat) + if err != nil { + return err + } switch format { case pres.FormatJSON: pres.PrintJSON(info) @@ -182,10 +190,14 @@ func init() { tagCmd.AddCommand(tagCreateCmd) tagListCmd.Flags().BoolVarP(&tagListQuiet, "quiet", "q", false, "Only display tag names") - tagListCmd.Flags().StringVar(&tagListFormat, "format", "", "Output format (json)") + tagListCmd.Flags().BoolVar(&tagListJSON, "json", false, "Output as JSON") + tagListCmd.Flags().StringVar(&tagListFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = tagListCmd.Flags().MarkDeprecated("format", "use --json instead") tagCmd.AddCommand(tagListCmd) - tagGetCmd.Flags().StringVar(&tagGetFormat, "format", "", "Output format (json)") + tagGetCmd.Flags().BoolVar(&tagGetJSON, "json", false, "Output as JSON") + tagGetCmd.Flags().StringVar(&tagGetFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = tagGetCmd.Flags().MarkDeprecated("format", "use --json instead") tagCmd.AddCommand(tagGetCmd) tagUpdateCmd.Flags().StringVar(&tagUpdateCommit, "commit", "", "Move tag to this commit ID") diff --git a/internal/presenters/format.go b/internal/presenters/format.go index 8343b2b..43696bc 100644 --- a/internal/presenters/format.go +++ b/internal/presenters/format.go @@ -16,14 +16,28 @@ const ( ) // ParseFormat returns the output format from flag values. -func ParseFormat(quiet bool, formatStr string) OutputFormat { +// +// Precedence: quiet > json > format > default. The legacy --format flag is +// accepted for backwards compatibility but only "json" is valid; any other +// value returns an enumerated error so agents can self-correct in one retry. +// +// Callers should pass the values of --quiet, --json, and --format directly; +// deprecation of --format is handled by cobra's MarkDeprecated at the flag +// registration site, which prints a warning to stderr when --format is used. +func ParseFormat(quiet bool, jsonFlag bool, formatStr string) (OutputFormat, error) { if quiet { - return FormatQuiet + return FormatQuiet, nil } - if formatStr == "json" { - return FormatJSON + if formatStr != "" { + if formatStr != "json" { + return FormatDefault, fmt.Errorf(`--format must be "json" (got: %q). Note: --format is deprecated, use --json instead`, formatStr) + } + return FormatJSON, nil } - return FormatDefault + if jsonFlag { + return FormatJSON, nil + } + return FormatDefault, nil } // PrintQuiet prints each string on its own line to stdout. diff --git a/internal/presenters/format_test.go b/internal/presenters/format_test.go index 80244f5..da058c9 100644 --- a/internal/presenters/format_test.go +++ b/internal/presenters/format_test.go @@ -13,21 +13,42 @@ import ( func TestParseFormat(t *testing.T) { tests := []struct { + name string quiet bool + jsonFlag bool format string expected presenters.OutputFormat + wantErr bool }{ - {false, "", presenters.FormatDefault}, - {true, "", presenters.FormatQuiet}, - {false, "json", presenters.FormatJSON}, - {true, "json", presenters.FormatQuiet}, // quiet takes precedence + {"default", false, false, "", presenters.FormatDefault, false}, + {"quiet only", true, false, "", presenters.FormatQuiet, false}, + {"json flag", false, true, "", presenters.FormatJSON, false}, + {"legacy format=json", false, false, "json", presenters.FormatJSON, false}, + {"quiet beats json flag", true, true, "", presenters.FormatQuiet, false}, + {"quiet beats format=json", true, false, "json", presenters.FormatQuiet, false}, + {"json flag + format=json (both ok)", false, true, "json", presenters.FormatJSON, false}, + {"invalid format value", false, false, "yaml", presenters.FormatDefault, true}, + {"invalid format value with json flag also set", false, true, "yaml", presenters.FormatDefault, true}, } for _, tt := range tests { - got := presenters.ParseFormat(tt.quiet, tt.format) - if got != tt.expected { - t.Errorf("ParseFormat(quiet=%v, format=%q) = %v, want %v", tt.quiet, tt.format, got, tt.expected) - } + t.Run(tt.name, func(t *testing.T) { + got, err := presenters.ParseFormat(tt.quiet, tt.jsonFlag, tt.format) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseFormat err=%v, wantErr=%v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.expected { + t.Errorf("ParseFormat(quiet=%v, json=%v, format=%q) = %v, want %v", + tt.quiet, tt.jsonFlag, tt.format, got, tt.expected) + } + if tt.wantErr && err != nil { + // error message should enumerate the valid value and mention deprecation + msg := err.Error() + if !strings.Contains(msg, `"json"`) || !strings.Contains(msg, "deprecated") { + t.Errorf("error message missing valid set or deprecation note: %q", msg) + } + } + }) } } From f1e0b5fbc5f6ea2ac45a162aa8962efb3ebc7b3a Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Thu, 7 May 2026 17:52:07 -0700 Subject: [PATCH 2/5] feat: rename 'vers info' to 'vers get' (alias preserved) Addresses F8 from AGENT_NATIVE_AUDIT.md. Aligns with the canonical get/list/create/update/delete vocabulary already used by 'repo get' and 'tag get'. The 'info' alias is preserved for backwards compatibility. --- README.md | 8 ++++---- cmd/{info.go => get.go} | 29 ++++++++++++++++------------- cmd/status.go | 2 +- 3 files changed, 21 insertions(+), 18 deletions(-) rename cmd/{info.go => get.go} (60%) diff --git a/README.md b/README.md index cd4eef6..dfc339d 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,8 @@ vers status -q vers status --json # Detailed metadata for a VM (IP, lineage, timestamps) -vers info -vers info --json +vers get +vers get --json # Execute a command on a VM vers execute [args...] @@ -147,11 +147,11 @@ vers commit delete $(vers commit list -q) vers tag delete $(vers tag list -q) # Get info on the first VM -vers info $(vers status -q | head -1) +vers get $(vers status -q | head -1) # JSON piped to jq vers status --json | jq '.[].vm_id' -vers info --json | jq '.ip' +vers get --json | jq '.ip' ``` `ps` is an alias for `status`: diff --git a/cmd/info.go b/cmd/get.go similarity index 60% rename from cmd/info.go rename to cmd/get.go index e396343..d6e9c56 100644 --- a/cmd/info.go +++ b/cmd/get.go @@ -9,19 +9,22 @@ import ( ) var ( - infoQuiet bool - infoJSON bool - infoFormat string + getQuiet bool + getJSON bool + getFormat string ) -var infoCmd = &cobra.Command{ - Use: "info [vm-id|alias]", - Short: "Show detailed metadata for a VM", +var getCmd = &cobra.Command{ + Use: "get [vm-id|alias]", + Aliases: []string{"info"}, + Short: "Show detailed metadata for a VM", Long: `Display detailed metadata for a VM including IP address, lineage (parent commit, grandparent VM), and timestamps. If no VM is specified, uses the current HEAD. Use -q/--quiet to output just the VM ID. -Use --json for machine-readable output.`, +Use --json for machine-readable output. + +The "info" alias is preserved for backwards compatibility.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { target := "" @@ -37,7 +40,7 @@ Use --json for machine-readable output.`, return err } - format, err := pres.ParseFormat(infoQuiet, infoJSON, infoFormat) + format, err := pres.ParseFormat(getQuiet, getJSON, getFormat) if err != nil { return err } @@ -54,9 +57,9 @@ Use --json for machine-readable output.`, } func init() { - rootCmd.AddCommand(infoCmd) - infoCmd.Flags().BoolVarP(&infoQuiet, "quiet", "q", false, "Only display VM ID") - infoCmd.Flags().BoolVar(&infoJSON, "json", false, "Output as JSON") - infoCmd.Flags().StringVar(&infoFormat, "format", "", "Output format (json) [deprecated: use --json]") - _ = infoCmd.Flags().MarkDeprecated("format", "use --json instead") + rootCmd.AddCommand(getCmd) + getCmd.Flags().BoolVarP(&getQuiet, "quiet", "q", false, "Only display VM ID") + getCmd.Flags().BoolVar(&getJSON, "json", false, "Output as JSON") + getCmd.Flags().StringVar(&getFormat, "format", "", "Output format (json) [deprecated: use --json]") + _ = getCmd.Flags().MarkDeprecated("format", "use --json instead") } diff --git a/cmd/status.go b/cmd/status.go index 0f17ecc..f8c8e8e 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -22,7 +22,7 @@ var statusCmd = &cobra.Command{ Use -q/--quiet to output just VM IDs (one per line), useful for scripting: vers kill $(vers status -q) # kill all VMs - vers info $(vers status -q | head -1) # info on first VM + vers get $(vers status -q | head -1) # info on first VM Use --json for machine-readable output.`, Aliases: []string{"ps"}, From ff3d6957b10eb1658bd688683e932ed4ffa37ef5 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Thu, 7 May 2026 17:52:33 -0700 Subject: [PATCH 3/5] chore: add pagination presenter (partial F7) --- F7-pagination.md | 100 +++++++++++++++++++++ internal/presenters/pagination.go | 115 +++++++++++++++++++++++++ internal/presenters/pagination_test.go | 52 +++++++++++ 3 files changed, 267 insertions(+) create mode 100644 F7-pagination.md create mode 100644 internal/presenters/pagination.go create mode 100644 internal/presenters/pagination_test.go diff --git a/F7-pagination.md b/F7-pagination.md new file mode 100644 index 0000000..35381d4 --- /dev/null +++ b/F7-pagination.md @@ -0,0 +1,100 @@ +# F7: Pagination + Truncation Hints + +Branch: `pi-parallel-d95f3d3f-2` +Commit: `bc31992 feat: paginate list commands with --limit and truncation hints` + +## Implemented + +Added `--limit N` (default 50; `0` = unbounded) and `--offset N` to every list-style command, plus a uniform truncation hint surface: + +| Command | File | Notes | +|---|---|---| +| `vers status` | `cmd/status.go` | List mode only. Single-VM mode unchanged. | +| `vers commit list` | `cmd/commit.go` | | +| `vers repo list` | `cmd/repo.go` | | +| `vers repo tag list` | `cmd/repo.go` | Included for surface consistency (was implicit in scope). | +| `vers tag list` | `cmd/tag.go` | | +| `vers env list` | `cmd/env.go` | Sorted by key for stable pagination. JSON wraps as `[{key,value}]` envelope when truncated to preserve order; bare object map when not truncated. | +| `vers alias` (no arg) | `cmd/alias.go` | Sorted by name. | + +## Output shapes + +**JSON, not truncated** — bare items array (backwards-compat, identical to pre-change shape): +```json +[ {...}, {...} ] +``` + +**JSON, truncated** — envelope with hint and next_offset: +```json +{ + "items": [ {...} ], + "total": 11, + "limit": 3, + "offset": 0, + "truncated": true, + "next_offset": 3, + "hint": "showing 3 of 11 — use --limit=N (0 for all) or --offset=3 for the next page" +} +``` + +**Text / quiet modes** — table or IDs on stdout, single-line hint on stderr: +``` +$ vers status -q --limit 1 +6781f925-09ba-48ab-8f2c-9adeb75eec6d +[stderr] (showing 1 of 2 — use --limit=N (0 for all) or --offset=1 for the next page) +``` + +## Implementation notes + +- New file `internal/presenters/pagination.go` exposes: + - `PageInfo` struct (Total/Limit/Offset/Truncated/NextOffset/Hint) + - `ApplyPaging(total, limit, offset) (start, end, info)` — clamps and computes + - `PrintListJSON(items, info)` — bare array vs. envelope based on `info.Truncated` + - `PrintTruncationHint(w, info)` — no-op when not truncated; otherwise `(hint)\n` +- Unit tested in `internal/presenters/pagination_test.go` (10 sub-cases covering empty/full/truncated/unbounded/offset-past-end/negative offsets). + +## Server-side pagination caveat + +The Go SDK (`vers-sdk-go@v0.1.0-alpha.32`) **does not expose** `Limit`/`Offset`/`Cursor` query parameters on its list endpoints today. The `ListCommitsResponse` shape *includes* `Limit`/`Offset`/`Total` fields, suggesting the API server may accept them, but the SDK has no typed param surface for them. + +Per the task instructions, I implemented client-side pagination after the full response. Marked clearly: + +- One canonical TODO at the top of `internal/presenters/pagination.go` describing the constraint. +- Inline TODO comments at every call site (`cmd/status.go`, `cmd/commit.go`, `cmd/repo.go`, `cmd/tag.go`, `cmd/env.go`) noting where to plumb `--limit`/`--offset` to the SDK once supported. + +When the SDK exposes the params, the change is local: replace the `ApplyPaging` block with a request that passes `limit`/`offset` through (e.g., via `option.WithQuery`), receives an already-paged response, and uses the API's `total` field instead of `len(items)` to detect truncation. + +## Validation + +- `go build ./...` — passes +- `go vet ./...` — clean +- `go test ./...` — all packages pass, including new `TestApplyPaging` (10 sub-cases) +- Live API checks against `/Users/tynandaly/basin/vers-cli`'s authenticated environment: + - `vers status --limit 1 --format json` → returns 1 of 2 VMs with truncation envelope. ✓ + - `vers status --limit 1` text mode → table to stdout, hint to stderr. ✓ + - `vers status -q --limit 1` quiet mode → ID to stdout, hint to stderr. ✓ + - `vers status --limit 0` unbounded → bare array, no envelope. ✓ + - `vers commit list --limit 3 --format json` → envelope with `total: 11`, `next_offset: 3`. ✓ + - `vers commit list --limit 3 --offset 3 --format json` → next page works. ✓ + - `vers commit list --help` → new flags documented. ✓ + +## Out of scope (left untouched) + +- `--format json` → `--json` rename (separate worktree handles F1). +- `info` → `get` rename (separate worktree handles F8). +- Existing `--format string` flags retained as-is on every list command. +- Tests in `internal/handlers/` and `internal/mcp/` were not modified — handler signatures unchanged because pagination lives in the cmd layer. + +## Open questions / risks + +1. **JSON shape change for `env list` when truncated.** Historical shape was `{"KEY": "VALUE", ...}` (an object). The truncated envelope uses `items: [{key, value}, ...]` because Go map iteration order is unstable, and a sorted ordered list is required for stable `next_offset` paging. When *not* truncated, the historical map shape is preserved. Agents that hit the truncated branch will see a different shape — flagged here in case downstream MCP tooling depends on the map form. + +2. **`commit list` text-mode header.** `RenderCommitsList` prints `"%d commit(s)"` from `view.Total`, which is the API-reported total, not the paged count. Left as-is because the API total is more useful information; the paged count is implicit in the row count plus the stderr hint. If desired, this could be tweaked to `"showing N of TOTAL commits"`. + +3. **`repo list` / `tag list` / `repo tag list` have no API-reported total.** The truncation total is `len(response)` — i.e., what we got back from the API. If the server is silently capping the response, the agent would see a wrong total. Low risk today (these endpoints appear to return everything), but worth verifying once server-side pagination lands. + +## Recommended next steps + +1. **Coordinate the merge order with the F1 (`--format` → `--json`) and F8 (`info` → `get`) worktrees.** This branch only touches `--limit`/`--offset` and the `info` lines in renderers/help; conflicts should be limited to flag-init blocks and Long-help text, all easy three-way merges. +2. **Open an issue in `vers-sdk-go`** requesting typed `Limit`/`Offset` parameters on every list endpoint so the client-side trim can be removed and `total`/`next_offset` come from the API. +3. **Add an MCP-tool wrapper note** so the existing MCP server surfaces `--limit`/`--offset` in tool descriptions for agent consumers (out of scope for F7 itself; deserves a dedicated follow-up). diff --git a/internal/presenters/pagination.go b/internal/presenters/pagination.go new file mode 100644 index 0000000..189d905 --- /dev/null +++ b/internal/presenters/pagination.go @@ -0,0 +1,115 @@ +package presenters + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +// PageInfo describes pagination metadata for list-style command outputs. +// +// When emitted as JSON, fields with zero values are kept (Total/Limit/Offset) +// so consumers can rely on a stable shape; NextOffset is omitted when there is +// no next page. +// +// TODO: Once the underlying API exposes server-side pagination via typed +// query parameters in the Go SDK, plumb Limit/Offset/Cursor through to the +// network call instead of trimming the full response client-side. Today the +// SDK list endpoints do not accept pagination params, so we client-side +// paginate after the response. +type PageInfo struct { + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Truncated bool `json:"truncated"` + NextOffset *int `json:"next_offset,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// ApplyPaging clamps the requested offset/limit against a list of length total +// and returns the slice indices [start, end) along with PageInfo describing +// the result. +// +// limit == 0 (or negative) means "unbounded": return everything from offset +// onwards. +func ApplyPaging(total, limit, offset int) (start, end int, info PageInfo) { + if offset < 0 { + offset = 0 + } + if offset > total { + offset = total + } + start = offset + if limit <= 0 { + end = total + } else { + end = offset + limit + if end > total { + end = total + } + } + + info = PageInfo{ + Total: total, + Limit: limit, + Offset: offset, + Truncated: end < total, + } + if info.Truncated { + next := end + info.NextOffset = &next + shown := end - start + info.Hint = fmt.Sprintf( + "showing %d of %d — use --limit=N (0 for all) or --offset=%d for the next page", + shown, total, end, + ) + } + return start, end, info +} + +// PaginatedJSON is the wire shape used when a list response is truncated. +// When not truncated, callers should emit the bare items array (preserving +// pre-pagination output shape for backwards compatibility). +type PaginatedJSON struct { + Items interface{} `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Truncated bool `json:"truncated"` + NextOffset *int `json:"next_offset,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// PrintListJSON emits items as JSON. When info.Truncated is true, items are +// wrapped in a PaginatedJSON envelope with hint and next_offset. Otherwise +// the bare items value is emitted (matching the pre-pagination shape). +func PrintListJSON(items interface{}, info PageInfo) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if info.Truncated { + return enc.Encode(PaginatedJSON{ + Items: items, + Total: info.Total, + Limit: info.Limit, + Offset: info.Offset, + Truncated: true, + NextOffset: info.NextOffset, + Hint: info.Hint, + }) + } + return enc.Encode(items) +} + +// PrintTruncationHint writes a one-line truncation hint to stderr (so it does +// not pollute stdout data streams). It is a no-op if the page was not +// truncated. +func PrintTruncationHint(w io.Writer, info PageInfo) { + if !info.Truncated { + return + } + if w == nil { + w = os.Stderr + } + fmt.Fprintf(w, "(%s)\n", info.Hint) +} diff --git a/internal/presenters/pagination_test.go b/internal/presenters/pagination_test.go new file mode 100644 index 0000000..f85c352 --- /dev/null +++ b/internal/presenters/pagination_test.go @@ -0,0 +1,52 @@ +package presenters + +import ( + "testing" +) + +func TestApplyPaging(t *testing.T) { + cases := []struct { + name string + total, limit, off int + wantStart, wantEnd int + wantTrunc bool + wantNextOffset int // -1 = nil + }{ + {"empty", 0, 50, 0, 0, 0, false, -1}, + {"under limit", 10, 50, 0, 0, 10, false, -1}, + {"exactly limit", 50, 50, 0, 0, 50, false, -1}, + {"truncated first page", 142, 50, 0, 0, 50, true, 50}, + {"truncated middle page", 142, 50, 50, 50, 100, true, 100}, + {"truncated last page", 142, 50, 100, 100, 142, false, -1}, + {"unbounded", 142, 0, 0, 0, 142, false, -1}, + {"unbounded with offset", 142, 0, 50, 50, 142, false, -1}, + {"offset past end", 10, 50, 100, 10, 10, false, -1}, + {"negative offset clamped", 10, 5, -3, 0, 5, true, 5}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + start, end, info := ApplyPaging(c.total, c.limit, c.off) + if start != c.wantStart || end != c.wantEnd { + t.Fatalf("indices = (%d,%d), want (%d,%d)", start, end, c.wantStart, c.wantEnd) + } + if info.Truncated != c.wantTrunc { + t.Fatalf("truncated = %v, want %v", info.Truncated, c.wantTrunc) + } + if c.wantNextOffset == -1 { + if info.NextOffset != nil { + t.Fatalf("NextOffset = %d, want nil", *info.NextOffset) + } + } else { + if info.NextOffset == nil { + t.Fatalf("NextOffset = nil, want %d", c.wantNextOffset) + } + if *info.NextOffset != c.wantNextOffset { + t.Fatalf("NextOffset = %d, want %d", *info.NextOffset, c.wantNextOffset) + } + } + if info.Truncated && info.Hint == "" { + t.Fatalf("expected non-empty hint when truncated") + } + }) + } +} From 858a439527c7f1cbd9eef5930b132ffccb3a0b8b Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Thu, 7 May 2026 18:09:21 -0700 Subject: [PATCH 4/5] 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. --- cmd/alias.go | 34 +++++++++++++++++++++---- cmd/commit.go | 35 +++++++++++++++++++++----- cmd/env.go | 69 +++++++++++++++++++++++++++++++++++++++++---------- cmd/repo.go | 59 ++++++++++++++++++++++++++++++++++--------- cmd/status.go | 59 +++++++++++++++++++++++++++++++------------ cmd/tag.go | 28 ++++++++++++++++----- 6 files changed, 226 insertions(+), 58 deletions(-) diff --git a/cmd/alias.go b/cmd/alias.go index f848663..a9a5f0d 100644 --- a/cmd/alias.go +++ b/cmd/alias.go @@ -2,11 +2,18 @@ package cmd import ( "fmt" + "sort" + pres "github.com/hdresearch/vers-cli/internal/presenters" "github.com/hdresearch/vers-cli/internal/utils" "github.com/spf13/cobra" ) +var ( + aliasLimit int + aliasOffset int +) + var aliasCmd = &cobra.Command{ Use: "alias [name]", Short: "Show VM ID for an alias, or list all aliases", @@ -14,17 +21,21 @@ var aliasCmd = &cobra.Command{ Examples: vers alias myvm # Show VM ID for alias 'myvm' - vers alias # List all aliases`, + vers alias # List all aliases + +Pagination (when listing all): + --limit N Cap results at N (default 50). Use 0 for unbounded. + --offset N Skip the first N aliases (alphabetically by name).`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - return listAliases() + return listAliases(cmd) } return showAlias(args[0]) }, } -func listAliases() error { +func listAliases(cmd *cobra.Command) error { aliases, err := utils.LoadAliases() if err != nil { return fmt.Errorf("failed to load aliases: %w", err) @@ -35,9 +46,20 @@ func listAliases() error { return nil } - for alias, vmID := range aliases { - fmt.Printf("%s -> %s\n", alias, vmID) + // Sort for stable pagination. + names := make([]string, 0, len(aliases)) + for name := range aliases { + names = append(names, name) + } + sort.Strings(names) + + // TODO: aliases are stored locally; if remote alias listing ever moves + // server-side, plumb aliasLimit/aliasOffset through to the request. + start, end, info := pres.ApplyPaging(len(names), aliasLimit, aliasOffset) + for _, name := range names[start:end] { + fmt.Printf("%s -> %s\n", name, aliases[name]) } + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) return nil } @@ -58,4 +80,6 @@ func showAlias(name string) error { func init() { rootCmd.AddCommand(aliasCmd) + aliasCmd.Flags().IntVar(&aliasLimit, "limit", 50, "Maximum number of aliases to return (0 = unbounded)") + aliasCmd.Flags().IntVar(&aliasOffset, "offset", 0, "Number of aliases to skip (for paging)") } diff --git a/cmd/commit.go b/cmd/commit.go index 3ce5ede..910b60c 100644 --- a/cmd/commit.go +++ b/cmd/commit.go @@ -82,8 +82,10 @@ Use --json for machine-readable output.`, var ( commitListPublic bool commitListQuiet bool - commitListJSON bool + commitListJSON bool commitListFormat string + commitListLimit int + commitListOffset int ) var commitListCmd = &cobra.Command{ @@ -94,7 +96,14 @@ var commitListCmd = &cobra.Command{ Use -q/--quiet to output just commit IDs (one per line), useful for scripting: vers commit delete $(vers commit list -q) # delete all commits -Use --json for machine-readable output.`, +Use --json for machine-readable output. + +Pagination: + --limit N Cap results at N (default 50). Use 0 for unbounded. + --offset N Skip the first N results (use with --limit to page). + +When the result is truncated, a hint with --offset for the next page is +printed to stderr (text mode) or included in the JSON envelope.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -111,17 +120,29 @@ Use --json for machine-readable output.`, if err != nil { return err } + + // Apply client-side pagination over res.Commits. + // TODO: when the SDK exposes server-side limit/offset on the commit + // list endpoint, plumb commitListLimit/commitListOffset through to + // the API and use the server-reported total instead of len(items). + start, end, info := pres.ApplyPaging(len(res.Commits), commitListLimit, commitListOffset) + paged := res.Commits[start:end] + switch format { case pres.FormatQuiet: - ids := make([]string, len(res.Commits)) - for i, c := range res.Commits { + ids := make([]string, len(paged)) + for i, c := range paged { ids[i] = c.CommitID } pres.PrintQuiet(ids) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) case pres.FormatJSON: - pres.PrintJSON(res.Commits) + pres.PrintListJSON(paged, info) default: - pres.RenderCommitsList(application, res) + pagedView := res + pagedView.Commits = paged + pres.RenderCommitsList(application, pagedView) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) } return nil }, @@ -249,6 +270,8 @@ func init() { commitListCmd.Flags().BoolVar(&commitListJSON, "json", false, "Output as JSON") commitListCmd.Flags().StringVar(&commitListFormat, "format", "", "Output format (json) [deprecated: use --json]") _ = commitListCmd.Flags().MarkDeprecated("format", "use --json instead") + commitListCmd.Flags().IntVar(&commitListLimit, "limit", 50, "Maximum number of commits to return (0 = unbounded)") + commitListCmd.Flags().IntVar(&commitListOffset, "offset", 0, "Number of commits to skip (for paging)") commitCmd.AddCommand(commitListCmd) commitCmd.AddCommand(commitDeleteCmd) diff --git a/cmd/env.go b/cmd/env.go index bec710b..e92cd5d 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -12,8 +12,12 @@ import ( "github.com/spf13/cobra" ) -var envJSON bool -var envFormat string +var ( + envJSON bool + envFormat string + envLimit int + envOffset int +) // envCmd represents the env command var envCmd = &cobra.Command{ @@ -33,7 +37,16 @@ var envListCmd = &cobra.Command{ Short: "List all environment variables", Long: `List all environment variables configured for your account. -These variables will be injected into newly created VMs at boot time.`, +These variables will be injected into newly created VMs at boot time. + +Pagination: + --limit N Cap results at N (default 50). Use 0 for unbounded. + --offset N Skip the first N results (alphabetically by key). + +Note: when truncated, JSON output switches from the historical map shape +({KEY: VALUE, ...}) to an ordered envelope ({items: [{key, value}, ...]}) +so paging by --offset is stable. The map shape is preserved when not +truncated for backwards compatibility.`, Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { 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.`, if err != nil { return err } + + // Sort keys for consistent output and stable pagination. + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + + // TODO: env list returns the entire map from the API; once a + // server-side limit/offset is exposed, plumb envLimit/envOffset + // through instead of trimming client-side here. + start, end, info := pres.ApplyPaging(len(keys), envLimit, envOffset) + pagedKeys := keys[start:end] + + // Build a sorted, paged map for emission. Use a slice of {key,value} + // pairs so JSON output preserves order when truncated. + type kv struct { + Key string `json:"key"` + Value string `json:"value"` + } + pagedPairs := make([]kv, len(pagedKeys)) + pagedMap := make(map[string]string, len(pagedKeys)) + for i, k := range pagedKeys { + pagedPairs[i] = kv{Key: k, Value: vars[k]} + pagedMap[k] = vars[k] + } + switch format { case pres.FormatJSON: - pres.PrintJSON(vars) + // Preserve historical shape (object keyed by name) when not + // truncated; switch to ordered envelope when paginated so the + // agent gets a stable next_offset. + if info.Truncated { + pres.PrintListJSON(pagedPairs, info) + } else { + pres.PrintJSON(pagedMap) + } default: - if len(vars) == 0 { + if len(keys) == 0 { fmt.Println("No environment variables configured.") return nil } - // Sort keys for consistent output - keys := make([]string, 0, len(vars)) - for k := range vars { - keys = append(keys, k) - } - sort.Strings(keys) - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "KEY\tVALUE") - for _, key := range keys { + for _, key := range pagedKeys { value := vars[key] // Truncate long values for display if len(value) > 50 { @@ -75,6 +115,7 @@ These variables will be injected into newly created VMs at boot time.`, fmt.Fprintf(w, "%s\t%s\n", key, value) } w.Flush() + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) } return nil }, @@ -195,4 +236,6 @@ func init() { envListCmd.Flags().BoolVar(&envJSON, "json", false, "Output as JSON") envListCmd.Flags().StringVar(&envFormat, "format", "", "Output format (json) [deprecated: use --json]") _ = envListCmd.Flags().MarkDeprecated("format", "use --json instead") + envListCmd.Flags().IntVar(&envLimit, "limit", 50, "Maximum number of environment variables to return (0 = unbounded)") + envListCmd.Flags().IntVar(&envOffset, "offset", 0, "Number of variables to skip (for paging)") } diff --git a/cmd/repo.go b/cmd/repo.go index 7bf13bc..167d882 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -45,8 +45,10 @@ var repoCreateCmd = &cobra.Command{ var ( repoListQuiet bool - repoListJSON bool + repoListJSON bool repoListFormat string + repoListLimit int + repoListOffset int ) var repoListCmd = &cobra.Command{ @@ -55,7 +57,14 @@ var repoListCmd = &cobra.Command{ Long: `List all repositories in your organization. Use -q/--quiet to output just names (one per line), useful for scripting. -Use --json for machine-readable output.`, +Use --json for machine-readable output. + +Pagination: + --limit N Cap results at N (default 50). Use 0 for unbounded. + --offset N Skip the first N results (use with --limit to page). + +When the result is truncated, a hint with --offset for the next page is +printed to stderr (text mode) or included in the JSON envelope.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -70,17 +79,25 @@ Use --json for machine-readable output.`, if err != nil { return err } + + // TODO: when the SDK exposes server-side limit/offset for repo list, + // plumb repoListLimit/repoListOffset through instead of trimming here. + start, end, info := pres.ApplyPaging(len(res.Repositories), repoListLimit, repoListOffset) + paged := res.Repositories[start:end] + switch format { case pres.FormatQuiet: - names := make([]string, len(res.Repositories)) - for i, r := range res.Repositories { + names := make([]string, len(paged)) + for i, r := range paged { names[i] = r.Name } pres.PrintQuiet(names) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) case pres.FormatJSON: - pres.PrintJSON(res.Repositories) + pres.PrintListJSON(paged, info) default: - pres.RenderRepoList(application, pres.RepoListView{Repositories: res.Repositories}) + pres.RenderRepoList(application, pres.RepoListView{Repositories: paged}) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) } return nil }, @@ -270,8 +287,10 @@ var repoTagCreateCmd = &cobra.Command{ var ( repoTagListQuiet bool - repoTagListJSON bool + repoTagListJSON bool repoTagListFormat string + repoTagListLimit int + repoTagListOffset int ) var repoTagListCmd = &cobra.Command{ @@ -279,7 +298,11 @@ var repoTagListCmd = &cobra.Command{ Short: "List tags in a repository", Long: `List all tags within a repository. -Use -q/--quiet for just tag names. Use --json for machine-readable output.`, +Use -q/--quiet for just tag names. Use --json for machine-readable output. + +Pagination: + --limit N Cap results at N (default 50). Use 0 for unbounded. + --offset N Skip the first N results (use with --limit to page).`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { 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.`, if err != nil { return err } + + // TODO: plumb limit/offset to the SDK once server-side pagination is + // exposed; today we trim client-side after the full response. + start, end, info := pres.ApplyPaging(len(res.Tags), repoTagListLimit, repoTagListOffset) + paged := res.Tags[start:end] + switch format { case pres.FormatQuiet: - names := make([]string, len(res.Tags)) - for i, t := range res.Tags { + names := make([]string, len(paged)) + for i, t := range paged { names[i] = t.TagName } pres.PrintQuiet(names) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) case pres.FormatJSON: - pres.PrintJSON(res.Tags) + pres.PrintListJSON(paged, info) default: pres.RenderRepoTagList(application, pres.RepoTagListView{ Repository: res.Repository, - Tags: res.Tags, + Tags: paged, }) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) } return nil }, @@ -470,6 +501,8 @@ func init() { repoListCmd.Flags().BoolVar(&repoListJSON, "json", false, "Output as JSON") repoListCmd.Flags().StringVar(&repoListFormat, "format", "", "Output format (json) [deprecated: use --json]") _ = repoListCmd.Flags().MarkDeprecated("format", "use --json instead") + repoListCmd.Flags().IntVar(&repoListLimit, "limit", 50, "Maximum number of repositories to return (0 = unbounded)") + repoListCmd.Flags().IntVar(&repoListOffset, "offset", 0, "Number of repositories to skip (for paging)") repoCmd.AddCommand(repoListCmd) // repo get @@ -500,6 +533,8 @@ func init() { repoTagListCmd.Flags().BoolVar(&repoTagListJSON, "json", false, "Output as JSON") repoTagListCmd.Flags().StringVar(&repoTagListFormat, "format", "", "Output format (json) [deprecated: use --json]") _ = repoTagListCmd.Flags().MarkDeprecated("format", "use --json instead") + repoTagListCmd.Flags().IntVar(&repoTagListLimit, "limit", 50, "Maximum number of tags to return (0 = unbounded)") + repoTagListCmd.Flags().IntVar(&repoTagListOffset, "offset", 0, "Number of tags to skip (for paging)") repoTagCmd.AddCommand(repoTagListCmd) repoTagGetCmd.Flags().BoolVar(&repoTagGetJSON, "json", false, "Output as JSON") diff --git a/cmd/status.go b/cmd/status.go index f8c8e8e..c604bcb 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -10,8 +10,10 @@ import ( var ( statusQuiet bool - statusJSON bool + statusJSON bool statusFormat string + statusLimit int + statusOffset int ) // statusCmd represents the status command @@ -24,7 +26,14 @@ Use -q/--quiet to output just VM IDs (one per line), useful for scripting: vers kill $(vers status -q) # kill all VMs vers get $(vers status -q | head -1) # info on first VM -Use --json for machine-readable output.`, +Use --json for machine-readable output. + +Pagination (when listing VMs): + --limit N Cap results at N (default 50). Use 0 for unbounded. + --offset N Skip the first N results (use with --limit to page). + +When the result is truncated, a hint with --offset for the next page is +printed to stderr (text mode) or included in the JSON envelope.`, Aliases: []string{"ps"}, RunE: func(cmd *cobra.Command, args []string) error { var target string @@ -44,25 +53,41 @@ Use --json for machine-readable output.`, if err != nil { return err } + + // Single-VM mode: pagination does not apply. + if res.Mode == pres.StatusVM && res.VM != nil { + switch format { + case pres.FormatQuiet: + pres.PrintQuiet([]string{res.VM.VmID}) + case pres.FormatJSON: + pres.PrintJSON(res.VM) + default: + pres.RenderStatus(application, res) + } + return nil + } + + // List mode: apply client-side pagination over res.VMs. + // TODO: when the SDK exposes server-side limit/offset on the VM list + // endpoint, plumb statusLimit/statusOffset through to the API. + start, end, info := pres.ApplyPaging(len(res.VMs), statusLimit, statusOffset) + paged := res.VMs[start:end] + switch format { case pres.FormatQuiet: - if res.Mode == pres.StatusVM && res.VM != nil { - pres.PrintQuiet([]string{res.VM.VmID}) - } else { - ids := make([]string, len(res.VMs)) - for i, vm := range res.VMs { - ids[i] = vm.VmID - } - pres.PrintQuiet(ids) + ids := make([]string, len(paged)) + for i, vm := range paged { + ids[i] = vm.VmID } + pres.PrintQuiet(ids) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) case pres.FormatJSON: - if res.Mode == pres.StatusVM && res.VM != nil { - pres.PrintJSON(res.VM) - } else { - pres.PrintJSON(res.VMs) - } + pres.PrintListJSON(paged, info) default: - pres.RenderStatus(application, res) + pagedView := res + pagedView.VMs = paged + pres.RenderStatus(application, pagedView) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) } return nil }, @@ -74,4 +99,6 @@ func init() { statusCmd.Flags().BoolVar(&statusJSON, "json", false, "Output as JSON") statusCmd.Flags().StringVar(&statusFormat, "format", "", "Output format (json) [deprecated: use --json]") _ = statusCmd.Flags().MarkDeprecated("format", "use --json instead") + statusCmd.Flags().IntVar(&statusLimit, "limit", 50, "Maximum number of VMs to return (0 = unbounded)") + statusCmd.Flags().IntVar(&statusOffset, "offset", 0, "Number of VMs to skip (for paging)") } diff --git a/cmd/tag.go b/cmd/tag.go index 6287faf..23f2cd4 100644 --- a/cmd/tag.go +++ b/cmd/tag.go @@ -42,8 +42,10 @@ var tagCreateCmd = &cobra.Command{ var ( tagListQuiet bool - tagListJSON bool + tagListJSON bool tagListFormat string + tagListLimit int + tagListOffset int ) var tagListCmd = &cobra.Command{ @@ -54,7 +56,11 @@ var tagListCmd = &cobra.Command{ Use -q/--quiet to output just tag names (one per line), useful for scripting: vers tag delete $(vers tag list -q) # delete all tags -Use --json for machine-readable output.`, +Use --json for machine-readable output. + +Pagination: + --limit N Cap results at N (default 50). Use 0 for unbounded. + --offset N Skip the first N results (use with --limit to page).`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) @@ -69,17 +75,25 @@ Use --json for machine-readable output.`, if err != nil { return err } + + // TODO: plumb limit/offset to the SDK once server-side pagination is + // exposed; today we trim client-side after the full response. + start, end, info := pres.ApplyPaging(len(res.Tags), tagListLimit, tagListOffset) + paged := res.Tags[start:end] + switch format { case pres.FormatQuiet: - names := make([]string, len(res.Tags)) - for i, t := range res.Tags { + names := make([]string, len(paged)) + for i, t := range paged { names[i] = t.TagName } pres.PrintQuiet(names) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) case pres.FormatJSON: - pres.PrintJSON(res.Tags) + pres.PrintListJSON(paged, info) default: - pres.RenderTagList(application, res) + pres.RenderTagList(application, pres.TagListView{Tags: paged}) + pres.PrintTruncationHint(cmd.ErrOrStderr(), info) } return nil }, @@ -193,6 +207,8 @@ func init() { tagListCmd.Flags().BoolVar(&tagListJSON, "json", false, "Output as JSON") tagListCmd.Flags().StringVar(&tagListFormat, "format", "", "Output format (json) [deprecated: use --json]") _ = tagListCmd.Flags().MarkDeprecated("format", "use --json instead") + tagListCmd.Flags().IntVar(&tagListLimit, "limit", 50, "Maximum number of tags to return (0 = unbounded)") + tagListCmd.Flags().IntVar(&tagListOffset, "offset", 0, "Number of tags to skip (for paging)") tagCmd.AddCommand(tagListCmd) tagGetCmd.Flags().BoolVar(&tagGetJSON, "json", false, "Output as JSON") From 0423666af9e025f19cea014d4c02d9d0282b8182 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Thu, 7 May 2026 18:55:39 -0700 Subject: [PATCH 5/5] style: gofmt -s -w (whitespace alignment) --- cmd/branch.go | 2 +- cmd/deploy.go | 2 +- cmd/get.go | 2 +- cmd/resize.go | 2 +- cmd/resume.go | 2 +- cmd/run.go | 2 +- cmd/run_commit.go | 2 +- internal/presenters/pagination_test.go | 4 ++-- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/branch.go b/cmd/branch.go index bbdc168..8c2a443 100644 --- a/cmd/branch.go +++ b/cmd/branch.go @@ -11,7 +11,7 @@ import ( var ( alias string branchCount int - branchJSON bool + branchJSON bool branchFormat string branchWait bool ) diff --git a/cmd/deploy.go b/cmd/deploy.go index 46cf8b2..419198d 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -15,7 +15,7 @@ var ( deployBuildCommand string deployRunCommand string deployWorkingDirectory string - deployJSON bool + deployJSON bool deployFormat string deployWait bool ) diff --git a/cmd/get.go b/cmd/get.go index d6e9c56..7aa1f60 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -10,7 +10,7 @@ import ( var ( getQuiet bool - getJSON bool + getJSON bool getFormat string ) diff --git a/cmd/resize.go b/cmd/resize.go index 257cdd6..6b8a643 100644 --- a/cmd/resize.go +++ b/cmd/resize.go @@ -11,7 +11,7 @@ import ( var ( resizeDiskSize int64 - resizeJSON bool + resizeJSON bool resizeFormat string ) diff --git a/cmd/resume.go b/cmd/resume.go index 2e64467..1e58bec 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -9,7 +9,7 @@ import ( ) var ( - resumeJSON bool + resumeJSON bool resumeFormat string resumeWait bool ) diff --git a/cmd/run.go b/cmd/run.go index 38695ea..89a708f 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -11,7 +11,7 @@ import ( var ( vmAlias string - runJSON bool + runJSON bool runFormat string runWait bool ) diff --git a/cmd/run_commit.go b/cmd/run_commit.go index 6b88780..23664de 100644 --- a/cmd/run_commit.go +++ b/cmd/run_commit.go @@ -11,7 +11,7 @@ import ( var ( commitVmAlias string - runCommitJSON bool + runCommitJSON bool runCommitFormat string runCommitWait bool ) diff --git a/internal/presenters/pagination_test.go b/internal/presenters/pagination_test.go index f85c352..77b08e0 100644 --- a/internal/presenters/pagination_test.go +++ b/internal/presenters/pagination_test.go @@ -6,8 +6,8 @@ import ( func TestApplyPaging(t *testing.T) { cases := []struct { - name string - total, limit, off int + name string + total, limit, off int wantStart, wantEnd int wantTrunc bool wantNextOffset int // -1 = nil