From 80a2fcc659e78221bd6f2ed2a2fb194736465516 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:11:03 +0200 Subject: [PATCH 01/19] refactor: share cli status rendering --- cmd/grounds/commands/doctor.go | 37 ++++++++-------------- internal/render/message.go | 48 ++++++++++++++++++++++++++++ internal/render/message_test.go | 55 +++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 internal/render/message.go create mode 100644 internal/render/message_test.go diff --git a/cmd/grounds/commands/doctor.go b/cmd/grounds/commands/doctor.go index 5f4765e..ad84e13 100644 --- a/cmd/grounds/commands/doctor.go +++ b/cmd/grounds/commands/doctor.go @@ -94,9 +94,20 @@ func runDoctorChecks(ctx context.Context, out io.Writer, checks []doctorCheck, i } func printCheckResult(out io.Writer, r checkResult) { - fmt.Fprintf(out, "%s %s - %s\n", statusBadge(r.status), r.name, r.summary) + render.StatusLine(out, renderStatus(r.status), r.name, r.summary) for _, detail := range r.details { - fmt.Fprintf(out, " %s %s\n", detailIcon(r.status), detail) + render.DetailLine(out, renderStatus(r.status), detail) + } +} + +func renderStatus(status checkStatus) render.StatusKind { + switch status { + case statusWarn: + return render.StatusWarn + case statusError: + return render.StatusError + default: + return render.StatusOK } } @@ -126,28 +137,6 @@ func printDoctorFooter(out io.Writer, results []checkResult, strict bool) error return nil } -func statusBadge(status checkStatus) string { - switch status { - case statusWarn: - return render.Yellow("[!]") - case statusError: - return render.Red("[✗]") - default: - return render.Green("[✓]") - } -} - -func detailIcon(status checkStatus) string { - switch status { - case statusError: - return render.Red("✗") - case statusWarn: - return render.Yellow("!") - default: - return "•" - } -} - func categoryWord(count int) string { if count == 1 { return "category" diff --git a/internal/render/message.go b/internal/render/message.go new file mode 100644 index 0000000..55ca4fb --- /dev/null +++ b/internal/render/message.go @@ -0,0 +1,48 @@ +package render + +import ( + "fmt" + "io" +) + +type StatusKind string + +const ( + StatusOK StatusKind = "ok" + StatusWarn StatusKind = "warn" + StatusError StatusKind = "error" +) + +func StatusBadge(status StatusKind) string { + switch status { + case StatusWarn: + return Yellow("[!]") + case StatusError: + return Red("[✗]") + default: + return Green("[✓]") + } +} + +func DetailIcon(status StatusKind) string { + switch status { + case StatusError: + return Red("✗") + case StatusWarn: + return Yellow("!") + default: + return "•" + } +} + +func StatusLine(w io.Writer, status StatusKind, subject, summary string) { + fmt.Fprintf(w, "%s %s - %s\n", StatusBadge(status), subject, summary) +} + +func DetailLine(w io.Writer, status StatusKind, detail string) { + fmt.Fprintf(w, " %s %s\n", DetailIcon(status), detail) +} + +func Command(command string) string { + return "`" + command + "`" +} diff --git a/internal/render/message_test.go b/internal/render/message_test.go new file mode 100644 index 0000000..a666816 --- /dev/null +++ b/internal/render/message_test.go @@ -0,0 +1,55 @@ +package render + +import ( + "bytes" + "testing" + + "github.com/fatih/color" +) + +func TestStatusBadgeNoColor(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + if got := StatusBadge(StatusOK); got != "[✓]" { + t.Fatalf("StatusBadge(StatusOK) = %q", got) + } + if got := StatusBadge(StatusWarn); got != "[!]" { + t.Fatalf("StatusBadge(StatusWarn) = %q", got) + } + if got := StatusBadge(StatusError); got != "[✗]" { + t.Fatalf("StatusBadge(StatusError) = %q", got) + } +} + +func TestStatusLine(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + var buf bytes.Buffer + StatusLine(&buf, StatusOK, "Init", "Wrote grounds.yaml") + + want := "[✓] Init - Wrote grounds.yaml\n" + if got := buf.String(); got != want { + t.Fatalf("StatusLine output = %q, want %q", got, want) + } +} + +func TestDetailLine(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + var buf bytes.Buffer + DetailLine(&buf, StatusWarn, "Run "+Command("grounds push")+" to create one.") + + want := " ! Run `grounds push` to create one.\n" + if got := buf.String(); got != want { + t.Fatalf("DetailLine output = %q, want %q", got, want) + } +} + +func TestCommand(t *testing.T) { + if got := Command("grounds version --check"); got != "`grounds version --check`" { + t.Fatalf("Command() = %q", got) + } +} From d457cf0ef6c14ab2678647b41f80706b1ad43194 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:15:09 +0200 Subject: [PATCH 02/19] style: standardize auth and init output --- cmd/grounds/commands/init.go | 6 ++++-- cmd/grounds/commands/init_test.go | 3 +++ cmd/grounds/commands/login.go | 16 ++++++++++++---- cmd/grounds/commands/logout.go | 5 ++--- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/cmd/grounds/commands/init.go b/cmd/grounds/commands/init.go index 73699e8..9b96755 100644 --- a/cmd/grounds/commands/init.go +++ b/cmd/grounds/commands/init.go @@ -8,6 +8,8 @@ import ( "github.com/charmbracelet/huh" "github.com/spf13/cobra" + + "github.com/groundsgg/grounds-cli/internal/render" ) type initFlags struct { @@ -73,7 +75,7 @@ func writeGroundsYaml(out io.Writer, f *initFlags) error { if err := os.WriteFile(path, []byte(body), 0644); err != nil { return err } - fmt.Fprintln(out, "→ Wrote grounds.yaml") - fmt.Fprintln(out, "Next: grounds push") + render.StatusLine(out, render.StatusOK, "Init", "Wrote grounds.yaml") + render.DetailLine(out, render.StatusOK, "Next: run "+render.Command("grounds push")+".") return nil } diff --git a/cmd/grounds/commands/init_test.go b/cmd/grounds/commands/init_test.go index 8b99ba6..c40d25b 100644 --- a/cmd/grounds/commands/init_test.go +++ b/cmd/grounds/commands/init_test.go @@ -26,4 +26,7 @@ func TestInit_NonInteractive(t *testing.T) { if !bytes.Contains(body, []byte("name: my-arena")) { t.Errorf("body = %s", body) } + if got := buf.String(); got != "[✓] Init - Wrote grounds.yaml\n • Next: run `grounds push`.\n" { + t.Fatalf("output = %q", got) + } } diff --git a/cmd/grounds/commands/login.go b/cmd/grounds/commands/login.go index 5f69be3..1b04497 100644 --- a/cmd/grounds/commands/login.go +++ b/cmd/grounds/commands/login.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "encoding/json" - "fmt" "net/http" "strings" "time" @@ -14,6 +13,7 @@ import ( "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/browser" "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/render" ) const ( @@ -41,8 +41,9 @@ func NewLoginCommand() *cobra.Command { return err } - fmt.Fprintln(cmd.OutOrStdout(), "→ Opening browser to", dc.VerificationURI) - fmt.Fprintln(cmd.OutOrStdout(), " Verification code:", dc.UserCode) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Browser", "Opened device login page") + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+dc.VerificationURI) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Code: "+dc.UserCode) _ = browser.OpenURL(dc.VerificationURIComplete) tok, err := device.PollToken(ctx, dc.DeviceCode, dc.CodeVerifier, dc.Interval, dc.ExpiresIn) @@ -59,7 +60,14 @@ func NewLoginCommand() *cobra.Command { return err } - fmt.Fprintln(cmd.OutOrStdout(), "✔ Authenticated as", preferred) + subject := preferred + if subject == "" { + subject = email + } + if subject == "" { + subject = "current user" + } + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged in as "+subject) return nil }, } diff --git a/cmd/grounds/commands/logout.go b/cmd/grounds/commands/logout.go index 34a55e2..be51aa9 100644 --- a/cmd/grounds/commands/logout.go +++ b/cmd/grounds/commands/logout.go @@ -1,12 +1,11 @@ package commands import ( - "fmt" - "github.com/spf13/cobra" "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/render" ) func NewLogoutCommand() *cobra.Command { @@ -21,7 +20,7 @@ func NewLogoutCommand() *cobra.Command { if err := auth.NewStore(cfg.Dir).Delete(); err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "✔ Logged out.") + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged out") return nil }, } From 2793232fcf600b716203d813eb86521a50a3ba0e Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:19:28 +0200 Subject: [PATCH 03/19] test: cover auth output formatting --- cmd/grounds/commands/login.go | 21 +++++++++-------- cmd/grounds/commands/login_test.go | 36 +++++++++++++++++++++++++++++ cmd/grounds/commands/logout_test.go | 21 +++++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 cmd/grounds/commands/login_test.go create mode 100644 cmd/grounds/commands/logout_test.go diff --git a/cmd/grounds/commands/login.go b/cmd/grounds/commands/login.go index 1b04497..721c4b7 100644 --- a/cmd/grounds/commands/login.go +++ b/cmd/grounds/commands/login.go @@ -41,7 +41,7 @@ func NewLoginCommand() *cobra.Command { return err } - render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Browser", "Opened device login page") + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Browser", "Device login page ready") render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+dc.VerificationURI) render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Code: "+dc.UserCode) _ = browser.OpenURL(dc.VerificationURIComplete) @@ -60,19 +60,22 @@ func NewLoginCommand() *cobra.Command { return err } - subject := preferred - if subject == "" { - subject = email - } - if subject == "" { - subject = "current user" - } - render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged in as "+subject) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged in as "+loginSubject(preferred, email)) return nil }, } } +func loginSubject(preferred, email string) string { + if preferred != "" { + return preferred + } + if email != "" { + return email + } + return "current user" +} + func decodeIDToken(idToken string) (email, preferred string) { parts := strings.Split(idToken, ".") if len(parts) != 3 { diff --git a/cmd/grounds/commands/login_test.go b/cmd/grounds/commands/login_test.go new file mode 100644 index 0000000..e78cf57 --- /dev/null +++ b/cmd/grounds/commands/login_test.go @@ -0,0 +1,36 @@ +package commands + +import "testing" + +func TestLoginSubject(t *testing.T) { + tests := []struct { + name string + preferred string + email string + want string + }{ + { + name: "preferred username", + preferred: "player-one", + email: "player@example.com", + want: "player-one", + }, + { + name: "email", + email: "player@example.com", + want: "player@example.com", + }, + { + name: "current user", + want: "current user", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := loginSubject(tt.preferred, tt.email); got != tt.want { + t.Fatalf("loginSubject(%q, %q) = %q, want %q", tt.preferred, tt.email, got, tt.want) + } + }) + } +} diff --git a/cmd/grounds/commands/logout_test.go b/cmd/grounds/commands/logout_test.go new file mode 100644 index 0000000..31bc973 --- /dev/null +++ b/cmd/grounds/commands/logout_test.go @@ -0,0 +1,21 @@ +package commands + +import ( + "bytes" + "testing" +) + +func TestLogoutOutput(t *testing.T) { + t.Setenv("GROUNDS_CONFIG_DIR", t.TempDir()) + + cmd := NewLogoutCommand() + buf := &bytes.Buffer{} + cmd.SetOut(buf) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + if got := buf.String(); got != "[✓] Auth - Logged out\n" { + t.Fatalf("output = %q", got) + } +} From d9916ca9eb7d1338f20928ea4aa40f5f1bd2bf29 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:25:15 +0200 Subject: [PATCH 04/19] style: standardize workspace command output --- cmd/grounds/commands/cluster/delete.go | 9 +++---- cmd/grounds/commands/cluster/down.go | 3 +-- cmd/grounds/commands/cluster/status.go | 4 ++-- cmd/grounds/commands/cluster/up.go | 18 +++++++------- cmd/grounds/commands/cluster/up_test.go | 32 +++++++++++++++++++++++++ internal/render/status.go | 3 ++- internal/render/status_test.go | 13 ++++++---- 7 files changed, 60 insertions(+), 22 deletions(-) diff --git a/cmd/grounds/commands/cluster/delete.go b/cmd/grounds/commands/cluster/delete.go index 9bbb932..9e1b30e 100644 --- a/cmd/grounds/commands/cluster/delete.go +++ b/cmd/grounds/commands/cluster/delete.go @@ -3,12 +3,12 @@ package cluster import ( "context" "errors" - "fmt" "os" "github.com/spf13/cobra" "golang.org/x/term" + "github.com/groundsgg/grounds-cli/internal/render" "github.com/groundsgg/grounds-cli/internal/ui" ) @@ -36,7 +36,7 @@ func newDelete() *cobra.Command { return errors.New("non-interactive delete requires --yes ") } } else { - fmt.Fprintln(cmd.OutOrStdout(), "⚠ This will permanently delete", s.Namespace, "and all its data.") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "This will permanently delete "+s.Namespace+" and all its data") if err := ui.AskTypeName(os.Stdin, cmd.OutOrStdout(), s.Namespace, s.Namespace); err != nil { return err } @@ -48,9 +48,10 @@ func newDelete() *cobra.Command { } switch res.State { case "deleted": - fmt.Fprintln(cmd.OutOrStdout(), "✔ Deleted.") + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Deleted "+s.Namespace) case "deleting": - fmt.Fprintln(cmd.OutOrStdout(), "→ Stuck Terminating; will be cleaned up by the janitor on next run.") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "Delete is still in progress") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Cleanup will continue automatically.") } return nil }, diff --git a/cmd/grounds/commands/cluster/down.go b/cmd/grounds/commands/cluster/down.go index 316fa41..69ca135 100644 --- a/cmd/grounds/commands/cluster/down.go +++ b/cmd/grounds/commands/cluster/down.go @@ -2,7 +2,6 @@ package cluster import ( "context" - "fmt" "github.com/spf13/cobra" @@ -23,7 +22,7 @@ func newDown() *cobra.Command { if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "✔ Paused.") + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Paused") render.Status(cmd.OutOrStdout(), s) return nil }, diff --git a/cmd/grounds/commands/cluster/status.go b/cmd/grounds/commands/cluster/status.go index 946b804..3d4fd13 100644 --- a/cmd/grounds/commands/cluster/status.go +++ b/cmd/grounds/commands/cluster/status.go @@ -2,7 +2,6 @@ package cluster import ( "context" - "fmt" "github.com/spf13/cobra" @@ -23,7 +22,8 @@ func newStatus() *cobra.Command { s, err := c.GetCluster(ctx) if err != nil { if apiErr, ok := err.(*api.Error); ok && apiErr.StatusCode == 404 { - fmt.Fprintln(cmd.OutOrStdout(), "→ no workspace yet. Run 'grounds push' to create one.") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "No workspace found") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Run "+render.Command("grounds push")+" to create one.") return nil } return err diff --git a/cmd/grounds/commands/cluster/up.go b/cmd/grounds/commands/cluster/up.go index 52b40e9..a76fa48 100644 --- a/cmd/grounds/commands/cluster/up.go +++ b/cmd/grounds/commands/cluster/up.go @@ -69,7 +69,7 @@ Profile is locked once a workspace exists. To switch, ` + "`grounds cluster dele if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "✔ Active.") + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Active") render.Status(cmd.OutOrStdout(), s) return nil }, @@ -112,13 +112,15 @@ func loadBundleRequest(bundleRef, overridePath string) (*api.BundleUpRequest, er func renderBundleResult(w interface { Write(p []byte) (int, error) }, res *api.BundleUpResult) { - fmt.Fprintf(w, "✔ %s — bundle %s — %s\n", res.State, res.BundleVersion, res.Namespace) - fmt.Fprintf(w, " components: %d resolved, %d succeeded, %d failed\n", - res.Components.Resolved, len(res.Components.Succeeded), len(res.Components.Failed)) + status := render.StatusOK + summary := fmt.Sprintf("%s with bundle %s in namespace %s", res.State, res.BundleVersion, res.Namespace) if len(res.Components.Failed) > 0 { - fmt.Fprintln(w, " failed:") - for _, f := range res.Components.Failed { - fmt.Fprintf(w, " - %s: %s\n", f.Name, f.Error) - } + status = render.StatusWarn + } + render.StatusLine(w, status, "Workspace", summary) + render.DetailLine(w, status, fmt.Sprintf("Components: %d resolved, %d succeeded, %d failed", + res.Components.Resolved, len(res.Components.Succeeded), len(res.Components.Failed))) + for _, f := range res.Components.Failed { + render.DetailLine(w, render.StatusError, fmt.Sprintf("%s: %s", f.Name, f.Error)) } } diff --git a/cmd/grounds/commands/cluster/up_test.go b/cmd/grounds/commands/cluster/up_test.go index 418bcfd..012f33e 100644 --- a/cmd/grounds/commands/cluster/up_test.go +++ b/cmd/grounds/commands/cluster/up_test.go @@ -1,9 +1,14 @@ package cluster import ( + "bytes" "os" "path/filepath" "testing" + + "github.com/fatih/color" + + "github.com/groundsgg/grounds-cli/internal/api" ) func TestLoadBundleRequest(t *testing.T) { @@ -89,3 +94,30 @@ func writeTempYAML(t *testing.T, content string) string { } return path } + +func TestRenderBundleResult(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + res := &api.BundleUpResult{ + State: "active", + BundleVersion: "0.4.0", + Namespace: "dev-lukas", + } + res.Components.Resolved = 2 + res.Components.Succeeded = []string{"api"} + res.Components.Failed = append(res.Components.Failed, struct { + Name string `json:"name"` + Error string `json:"error"` + }{Name: "worker", Error: "image pull failed"}) + + var buf bytes.Buffer + renderBundleResult(&buf, res) + + want := "[!] Workspace - active with bundle 0.4.0 in namespace dev-lukas\n" + + " ! Components: 2 resolved, 1 succeeded, 1 failed\n" + + " ✗ worker: image pull failed\n" + if got := buf.String(); got != want { + t.Fatalf("renderBundleResult output = %q, want %q", got, want) + } +} diff --git a/internal/render/status.go b/internal/render/status.go index 8b27375..be40fa0 100644 --- a/internal/render/status.go +++ b/internal/render/status.go @@ -51,7 +51,8 @@ func Status(w io.Writer, s *api.ClusterStatus) { if s.State == "paused" { fmt.Fprintln(w) - fmt.Fprintln(w, Yellow("⚠ paused. Next push or 'grounds cluster up' resumes.")) + StatusLine(w, StatusWarn, "Workspace", "Paused") + DetailLine(w, StatusWarn, "Next push or "+Command("grounds cluster up")+" resumes it.") } } diff --git a/internal/render/status_test.go b/internal/render/status_test.go index cce30c8..f8b1ef8 100644 --- a/internal/render/status_test.go +++ b/internal/render/status_test.go @@ -34,16 +34,19 @@ func TestStatus_PausedShowsWarning(t *testing.T) { buf := &bytes.Buffer{} in := time.Now().Add(48 * time.Hour) Status(buf, &api.ClusterStatus{ - Namespace: "user-x", - State: "paused", - Profile: "minigame", - AutoDeleteAt: &in, + Namespace: "user-x", + State: "paused", + Profile: "minigame", + AutoDeleteAt: &in, }) out := buf.String() if !strings.Contains(out, "auto-delete at") { t.Errorf("no auto-delete row\n%s", out) } - if !strings.Contains(out, "paused. Next push") { + if !strings.Contains(out, "Workspace - Paused") { t.Errorf("no warning line\n%s", out) } + if !strings.Contains(out, "Next push or `grounds cluster up` resumes it.") { + t.Errorf("no warning detail\n%s", out) + } } From ca9053f09e213d141b0b05c94b708722eedac5a3 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:30:46 +0200 Subject: [PATCH 05/19] style: improve push command messages --- cmd/grounds/commands/push/list.go | 4 ++-- cmd/grounds/commands/push/push.go | 5 +++-- cmd/grounds/commands/push/push_test.go | 25 +++++++++++++++++++++++++ cmd/grounds/commands/push/retry.go | 5 +++-- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/cmd/grounds/commands/push/list.go b/cmd/grounds/commands/push/list.go index 0b797a3..a25ed44 100644 --- a/cmd/grounds/commands/push/list.go +++ b/cmd/grounds/commands/push/list.go @@ -2,7 +2,6 @@ package push import ( "context" - "fmt" "github.com/spf13/cobra" @@ -41,7 +40,8 @@ func newList() *cobra.Command { } render.Table(cmd.OutOrStdout(), header, rows) if list.NextCursor != "" { - fmt.Fprintln(cmd.OutOrStdout(), "(more available; pagination flag TBD)") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Push", "More results are available") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Pagination is not available in this CLI version.") } return nil }, diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index 48b924c..44561e1 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -12,6 +12,7 @@ import ( "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/config" "github.com/groundsgg/grounds-cli/internal/gradle" + "github.com/groundsgg/grounds-cli/internal/render" ) func NewPushCommand() *cobra.Command { @@ -41,7 +42,7 @@ Targets: } wrapper, err := gradle.FindWrapper(cwd) if err != nil { - return fmt.Errorf("%w\n → not a Gradle project? Run 'grounds init' to scaffold, or cd to your project root", err) + return fmt.Errorf("%w\n ! Not a Gradle project? Run %s to scaffold, or cd to your project root.", err, render.Command("grounds init")) } ctx := context.Background() @@ -61,7 +62,7 @@ Targets: Device: defaultDevice(), } if _, err := src.Token(ctx); err != nil { - return fmt.Errorf("auth refresh failed: %w\n → run 'grounds login' to re-authenticate", err) + return fmt.Errorf("auth refresh failed: %w\n ! Run %s to re-authenticate.", err, render.Command("grounds login")) } } diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index a7747ba..c106b86 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -2,6 +2,7 @@ package push import ( "bytes" + "os" "strings" "testing" ) @@ -41,3 +42,27 @@ func TestPushDefaultTargetIsDev(t *testing.T) { t.Errorf("expected default --target=dev, got %q", flag.DefValue) } } + +func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { + dir := t.TempDir() + cwd, _ := os.Getwd() + defer os.Chdir(cwd) + os.Chdir(dir) + + cmd := newPush() + cmd.SetArgs([]string{}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected missing Gradle wrapper error") + } + got := err.Error() + if !strings.Contains(got, "Run `grounds init`") { + t.Fatalf("error = %q, want command suggestion", got) + } + if strings.Contains(got, "→") || strings.Contains(got, "'grounds init'") { + t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) + } +} diff --git a/cmd/grounds/commands/push/retry.go b/cmd/grounds/commands/push/retry.go index 3910229..3c74ac9 100644 --- a/cmd/grounds/commands/push/retry.go +++ b/cmd/grounds/commands/push/retry.go @@ -2,7 +2,6 @@ package push import ( "context" - "fmt" "io" "os" @@ -11,6 +10,7 @@ import ( "github.com/groundsgg/grounds-cli/internal/api" "github.com/groundsgg/grounds-cli/internal/auth" "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/render" "github.com/groundsgg/grounds-cli/internal/sse" ) @@ -36,7 +36,8 @@ func newRetry() *cobra.Command { if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), "→ Retry triggered for", p.ID, "status:", p.Status) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Push", "Retry triggered for "+p.ID) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Status: "+p.Status) if !follow { return nil } From 0b01bf4188f52da0e51eacdfee1ae5d76c467268 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:35:28 +0200 Subject: [PATCH 06/19] test: cover push output formatting --- cmd/grounds/commands/push/list.go | 9 +++- cmd/grounds/commands/push/push.go | 6 ++- cmd/grounds/commands/push/push_test.go | 60 ++++++++++++++++++++++++-- cmd/grounds/commands/push/retry.go | 8 +++- 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/cmd/grounds/commands/push/list.go b/cmd/grounds/commands/push/list.go index a25ed44..9eea970 100644 --- a/cmd/grounds/commands/push/list.go +++ b/cmd/grounds/commands/push/list.go @@ -2,6 +2,7 @@ package push import ( "context" + "io" "github.com/spf13/cobra" @@ -40,8 +41,7 @@ func newList() *cobra.Command { } render.Table(cmd.OutOrStdout(), header, rows) if list.NextCursor != "" { - render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Push", "More results are available") - render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Pagination is not available in this CLI version.") + renderPushPaginationNote(cmd.OutOrStdout()) } return nil }, @@ -50,3 +50,8 @@ func newList() *cobra.Command { cmd.Flags().IntVar(&limit, "limit", 20, "page size") return cmd } + +func renderPushPaginationNote(out io.Writer) { + render.StatusLine(out, render.StatusWarn, "Push", "More results are available") + render.DetailLine(out, render.StatusWarn, "Pagination is not available in this CLI version.") +} diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index 44561e1..a48adcb 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -62,7 +62,7 @@ Targets: Device: defaultDevice(), } if _, err := src.Token(ctx); err != nil { - return fmt.Errorf("auth refresh failed: %w\n ! Run %s to re-authenticate.", err, render.Command("grounds login")) + return authRefreshError(err) } } @@ -74,6 +74,10 @@ Targets: return cmd } +func authRefreshError(err error) error { + return fmt.Errorf("auth refresh failed: %w\n ! Run %s to re-authenticate.", err, render.Command("grounds login")) +} + // projectIDFrom resolves the global --project flag, falling back to // the GROUNDS_PROJECT env var. Empty string when neither is set — // forge then uses the caller's default project. diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index c106b86..0f36263 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -2,9 +2,14 @@ package push import ( "bytes" + "errors" "os" "strings" "testing" + + "github.com/fatih/color" + + "github.com/groundsgg/grounds-cli/internal/api" ) // Validates the --target flag's allow-list before grounds-push gets @@ -45,16 +50,25 @@ func TestPushDefaultTargetIsDev(t *testing.T) { func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { dir := t.TempDir() - cwd, _ := os.Getwd() - defer os.Chdir(cwd) - os.Chdir(dir) + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("Chdir(%q) error = %v", cwd, err) + } + }) + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error = %v", dir, err) + } cmd := newPush() cmd.SetArgs([]string{}) cmd.SilenceUsage = true cmd.SilenceErrors = true - err := cmd.Execute() + err = cmd.Execute() if err == nil { t.Fatal("expected missing Gradle wrapper error") } @@ -66,3 +80,41 @@ func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) } } + +func TestPushAuthRefreshErrorSuggestsLoginCommand(t *testing.T) { + err := authRefreshError(errors.New("token expired")) + + got := err.Error() + if !strings.Contains(got, "Run `grounds login`") { + t.Fatalf("error = %q, want login command suggestion", got) + } + if strings.Contains(got, "→") || strings.Contains(got, "'grounds login'") { + t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) + } +} + +func TestRenderRetryTriggered(t *testing.T) { + color.NoColor = true + t.Cleanup(func() { color.NoColor = false }) + + var buf bytes.Buffer + renderRetryTriggered(&buf, &api.Push{ID: "push-123", Status: "queued"}) + + want := "[✓] Push - Retry triggered for push-123\n • Status: queued\n" + if got := buf.String(); got != want { + t.Fatalf("retry output = %q, want %q", got, want) + } +} + +func TestRenderPushPaginationNote(t *testing.T) { + color.NoColor = true + t.Cleanup(func() { color.NoColor = false }) + + var buf bytes.Buffer + renderPushPaginationNote(&buf) + + want := "[!] Push - More results are available\n ! Pagination is not available in this CLI version.\n" + if got := buf.String(); got != want { + t.Fatalf("pagination output = %q, want %q", got, want) + } +} diff --git a/cmd/grounds/commands/push/retry.go b/cmd/grounds/commands/push/retry.go index 3c74ac9..12ca206 100644 --- a/cmd/grounds/commands/push/retry.go +++ b/cmd/grounds/commands/push/retry.go @@ -36,8 +36,7 @@ func newRetry() *cobra.Command { if err != nil { return err } - render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Push", "Retry triggered for "+p.ID) - render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Status: "+p.Status) + renderRetryTriggered(cmd.OutOrStdout(), p) if !follow { return nil } @@ -61,3 +60,8 @@ func newRetry() *cobra.Command { cmd.Flags().BoolVar(&follow, "follow", true, "stream logs after retry") return cmd } + +func renderRetryTriggered(out io.Writer, p *api.Push) { + render.StatusLine(out, render.StatusOK, "Push", "Retry triggered for "+p.ID) + render.DetailLine(out, render.StatusOK, "Status: "+p.Status) +} From acdca1884a271f5fcef267785539c63056dd7a7e Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:40:38 +0200 Subject: [PATCH 07/19] style: improve preview command output --- cmd/grounds/commands/preview/preview.go | 30 +++++++++++++------- cmd/grounds/commands/preview/preview_test.go | 9 ++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/cmd/grounds/commands/preview/preview.go b/cmd/grounds/commands/preview/preview.go index 3893e3e..daf06e2 100644 --- a/cmd/grounds/commands/preview/preview.go +++ b/cmd/grounds/commands/preview/preview.go @@ -87,7 +87,8 @@ func newList() *cobra.Command { return err } if len(res.Items) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "no preview environments") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Preview", "No preview environments found") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Run "+render.Command("grounds push --target=staging")+" to create one.") return nil } header := []string{"ID", "PUSH", "NAME", "TYPE", "STATUS", "PINNED", "EXPIRES", "URL"} @@ -145,11 +146,14 @@ func newShow() *cobra.Command { enc.SetIndent("", " ") return enc.Encode(p) } - fmt.Fprintf(cmd.OutOrStdout(), "ID: %s\nPushID: %s\nNamespace: %s\nName: %s (%s)\nStatus: %s\nPinned: %t\nExpires: %s\nURL: %s\n", - p.ID, p.PushID, p.Namespace, - p.Push.ManifestName, p.Push.ManifestType, - p.Push.Status, p.Pinned, - formatTime(p.ExpiresAt), p.PublicURL) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Preview", p.Push.ManifestName+" ("+p.Push.Status+")") + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "ID: "+p.ID) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Push: "+p.PushID) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Namespace: "+p.Namespace) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Type: "+p.Push.ManifestType) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, fmt.Sprintf("Pinned: %t", p.Pinned)) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Expires: "+formatTime(p.ExpiresAt)) + render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+p.PublicURL) return nil }, } @@ -182,11 +186,7 @@ func newPin(pin bool) *cobra.Command { if err != nil { return err } - verb := "pinned" - if !pin { - verb = "unpinned" - } - fmt.Fprintf(cmd.OutOrStdout(), "%s %s (%s)\n", verb, shortID(p.ID), p.Push.ManifestName) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Preview", previewPinSummary(pin, p.ID, p.Push.ManifestName)) return nil }, } @@ -201,6 +201,14 @@ func shortID(s string) string { return s[:8] } +func previewPinSummary(pin bool, id, manifestName string) string { + verb := "Pinned" + if !pin { + verb = "Unpinned" + } + return fmt.Sprintf("%s %s (%s)", verb, shortID(id), manifestName) +} + func formatTime(t *time.Time) string { if t == nil { return "—" diff --git a/cmd/grounds/commands/preview/preview_test.go b/cmd/grounds/commands/preview/preview_test.go index 579d7dc..778fc98 100644 --- a/cmd/grounds/commands/preview/preview_test.go +++ b/cmd/grounds/commands/preview/preview_test.go @@ -40,6 +40,15 @@ func TestPinUseLineDiffersFromUnpin(t *testing.T) { } } +func TestPreviewPinSummary(t *testing.T) { + if got := previewPinSummary(true, "abcdef1234", "plugin-social"); got != "Pinned abcdef12 (plugin-social)" { + t.Fatalf("previewPinSummary(pin) = %q", got) + } + if got := previewPinSummary(false, "abcdef1234", "plugin-social"); got != "Unpinned abcdef12 (plugin-social)" { + t.Fatalf("previewPinSummary(unpin) = %q", got) + } +} + func TestShortIDTruncatesAt8Chars(t *testing.T) { cases := map[string]string{ "abc": "abc", From 6bb9b2baa28dcb570448654ab9132cae542b20e0 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:44:55 +0200 Subject: [PATCH 08/19] style: improve devspace and bundle messages --- cmd/grounds/commands/bundle/list.go | 7 +++++-- cmd/grounds/commands/bundle/show.go | 2 +- cmd/grounds/commands/devspace/generate.go | 7 ++++++- cmd/grounds/commands/devspace/generate_test.go | 6 ++++++ 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/cmd/grounds/commands/bundle/list.go b/cmd/grounds/commands/bundle/list.go index ac3cc04..67cad4d 100644 --- a/cmd/grounds/commands/bundle/list.go +++ b/cmd/grounds/commands/bundle/list.go @@ -6,6 +6,8 @@ import ( "text/tabwriter" "github.com/spf13/cobra" + + "github.com/groundsgg/grounds-cli/internal/render" ) func newList() *cobra.Command { @@ -14,7 +16,7 @@ func newList() *cobra.Command { Short: "List released platform-bundle versions", Long: `Lists released library-platform-bundle versions, newest first. Drafts and prereleases are filtered out. The version with (latest) is -the same one 'grounds cluster up --bundle main' would track today.`, +the same one ` + "`grounds cluster up --bundle main`" + ` would track today.`, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() c, err := buildClient(ctx, cmd) @@ -26,7 +28,8 @@ the same one 'grounds cluster up --bundle main' would track today.`, return err } if len(releases) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "no released bundles found") + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Bundle", "No released bundles found") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Try "+render.Command("grounds bundle show main")+" to inspect the current bundle.") return nil } w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) diff --git a/cmd/grounds/commands/bundle/show.go b/cmd/grounds/commands/bundle/show.go index f36aaac..18800e0 100644 --- a/cmd/grounds/commands/bundle/show.go +++ b/cmd/grounds/commands/bundle/show.go @@ -14,7 +14,7 @@ func newShow() *cobra.Command { Use: "show ", Short: "Show the components in a bundle", Long: `Fetches the parsed bundle.yaml at the given ref and prints the -component table. accepts the same shapes as 'cluster up --bundle': +component table. accepts the same shapes as ` + "`grounds cluster up --bundle`" + `: semver, "v…", the full release tag, or "main" for the latest commit. Examples: diff --git a/cmd/grounds/commands/devspace/generate.go b/cmd/grounds/commands/devspace/generate.go index 6d7d3f2..81b87db 100644 --- a/cmd/grounds/commands/devspace/generate.go +++ b/cmd/grounds/commands/devspace/generate.go @@ -9,6 +9,7 @@ import ( "gopkg.in/yaml.v3" "github.com/groundsgg/grounds-cli/internal/api" + "github.com/groundsgg/grounds-cli/internal/render" ) func newGenerate() *cobra.Command { @@ -67,7 +68,7 @@ Examples: if err := os.WriteFile(outputPath, yaml, 0o644); err != nil { return fmt.Errorf("writing %s: %w", outputPath, err) } - fmt.Fprintf(cmd.OutOrStdout(), "✔ Wrote %d bytes to %s\n", len(yaml), outputPath) + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "DevSpace", generateSuccessSummary(outputPath)) return nil }, } @@ -77,6 +78,10 @@ Examples: return cmd } +func generateSuccessSummary(outputPath string) string { + return "Wrote " + outputPath +} + // loadGenerateInputs picks the bundle-ref + per-component override // out of the override file (when present), with --bundle on the // command line winning over the file's `bundle:` field. diff --git a/cmd/grounds/commands/devspace/generate_test.go b/cmd/grounds/commands/devspace/generate_test.go index 28e63eb..74f657a 100644 --- a/cmd/grounds/commands/devspace/generate_test.go +++ b/cmd/grounds/commands/devspace/generate_test.go @@ -6,6 +6,12 @@ import ( "testing" ) +func TestGenerateSuccessSummary(t *testing.T) { + if got := generateSuccessSummary("./devspace.yaml"); got != "Wrote ./devspace.yaml" { + t.Fatalf("generateSuccessSummary = %q", got) + } +} + func TestLoadGenerateInputs(t *testing.T) { t.Run("bundle flag only, no override", func(t *testing.T) { bundle, override, err := loadGenerateInputs("0.4.0", "", "plugin-social") From 567564f778f8034acac83332917ce2bc61256d59 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:50:29 +0200 Subject: [PATCH 09/19] docs: improve cli command help --- cmd/grounds/commands/bundle/bundle.go | 5 +++-- cmd/grounds/commands/cluster/cluster.go | 6 +++++- cmd/grounds/commands/cluster/up.go | 5 +++-- cmd/grounds/commands/devspace/devspace.go | 5 +++-- cmd/grounds/commands/logs/logs.go | 7 ++++--- cmd/grounds/commands/preview/preview.go | 5 +++-- cmd/grounds/commands/push/push.go | 11 ++++++++--- cmd/grounds/commands/root.go | 4 ++-- cmd/grounds/commands/version.go | 5 +++-- 9 files changed, 34 insertions(+), 19 deletions(-) diff --git a/cmd/grounds/commands/bundle/bundle.go b/cmd/grounds/commands/bundle/bundle.go index 71f3121..b5b4f3c 100644 --- a/cmd/grounds/commands/bundle/bundle.go +++ b/cmd/grounds/commands/bundle/bundle.go @@ -15,8 +15,9 @@ import ( func NewBundleCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "bundle", - Short: "Inspect available platform-test bundles", + Use: "bundle", + Short: "Inspect available platform-test bundles", + Example: " grounds bundle list\n grounds bundle show main\n grounds bundle show 0.4.0", } cmd.AddCommand(newList(), newShow()) return cmd diff --git a/cmd/grounds/commands/cluster/cluster.go b/cmd/grounds/commands/cluster/cluster.go index 34cd4bf..f2753c9 100644 --- a/cmd/grounds/commands/cluster/cluster.go +++ b/cmd/grounds/commands/cluster/cluster.go @@ -14,7 +14,11 @@ import ( ) func NewClusterCommand() *cobra.Command { - cmd := &cobra.Command{Use: "cluster", Short: "Manage your dev workspace lifecycle"} + cmd := &cobra.Command{ + Use: "cluster", + Short: "Manage your dev workspace lifecycle", + Example: " grounds cluster status\n grounds cluster up\n grounds cluster down\n grounds cluster delete", + } cmd.AddCommand(newUp(), newDown(), newDelete(), newStatus()) return cmd } diff --git a/cmd/grounds/commands/cluster/up.go b/cmd/grounds/commands/cluster/up.go index a76fa48..91f213e 100644 --- a/cmd/grounds/commands/cluster/up.go +++ b/cmd/grounds/commands/cluster/up.go @@ -17,8 +17,9 @@ func newUp() *cobra.Command { var bundleRef string var overridePath string cmd := &cobra.Command{ - Use: "up [--profile=minigame|platform] [--bundle= [--override=]]", - Short: "Spawn or resume the workspace", + Use: "up [--profile=minigame|platform] [--bundle= [--override=]]", + Short: "Spawn or resume the workspace", + Example: " grounds cluster up\n grounds cluster up --profile=platform\n grounds cluster up --bundle=0.4.0 --override=./overrides/me.yaml", Long: `Create the workspace if it doesn't exist, or resume it from a paused state. Profiles: diff --git a/cmd/grounds/commands/devspace/devspace.go b/cmd/grounds/commands/devspace/devspace.go index 0e91cb2..8472836 100644 --- a/cmd/grounds/commands/devspace/devspace.go +++ b/cmd/grounds/commands/devspace/devspace.go @@ -15,8 +15,9 @@ import ( func NewDevspaceCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "devspace", - Short: "DevSpace integration helpers", + Use: "devspace", + Short: "DevSpace integration helpers", + Example: " grounds devspace generate plugin-social --bundle main\n grounds devspace generate plugin-social --override ./me.yaml", } cmd.AddCommand(newGenerate()) return cmd diff --git a/cmd/grounds/commands/logs/logs.go b/cmd/grounds/commands/logs/logs.go index bf014b9..5465c58 100644 --- a/cmd/grounds/commands/logs/logs.go +++ b/cmd/grounds/commands/logs/logs.go @@ -18,9 +18,10 @@ import ( func NewLogsCommand() *cobra.Command { var follow bool cmd := &cobra.Command{ - Use: "logs ", - Short: "Stream push logs (or deployment logs via 'grounds logs deployment ')", - Args: cobra.ExactArgs(1), + Use: "logs ", + Short: "Stream push logs (or deployment logs via 'grounds logs deployment ')", + Example: " grounds logs\n grounds logs --follow\n grounds logs deployment ", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return streamLogs(cmd.Context(), args[0], "push", follow) }, diff --git a/cmd/grounds/commands/preview/preview.go b/cmd/grounds/commands/preview/preview.go index daf06e2..ab90eef 100644 --- a/cmd/grounds/commands/preview/preview.go +++ b/cmd/grounds/commands/preview/preview.go @@ -24,8 +24,9 @@ import ( // grounds preview unpin — re-enable TTL sweep func NewPreviewCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "preview", - Short: "Manage preview environments (target=staging deploys)", + Use: "preview", + Short: "Manage staging preview environments", + Example: " grounds preview list\n grounds preview show \n grounds preview pin \n grounds preview unpin ", } cmd.AddCommand(newList(), newShow(), newPin(true), newPin(false)) return cmd diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index a48adcb..7598de0 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -16,7 +16,11 @@ import ( ) func NewPushCommand() *cobra.Command { - cmd := &cobra.Command{Use: "push", Short: "Build and deploy the current project"} + cmd := &cobra.Command{ + Use: "push", + Short: "Build and deploy the current project", + Example: " grounds push\n grounds push --target=staging\n grounds push list --mine", + } cmd.AddCommand(newPush(), newRetry(), newList()) return cmd } @@ -24,8 +28,9 @@ func NewPushCommand() *cobra.Command { func newPush() *cobra.Command { var target string cmd := &cobra.Command{ - Use: "push [--target=dev|staging]", - Short: "Build via Gradle plugin and deploy to a target", + Use: "push [--target=dev|staging]", + Short: "Build via Gradle plugin and deploy to a target", + Example: " grounds push\n grounds push --target=staging", Long: `Build the current project with the grounds-push Gradle plugin and deploy it. Targets: diff --git a/cmd/grounds/commands/root.go b/cmd/grounds/commands/root.go index 2b0bd91..d75f120 100644 --- a/cmd/grounds/commands/root.go +++ b/cmd/grounds/commands/root.go @@ -9,8 +9,8 @@ import ( func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ Use: "grounds", - Short: "Grounds Internal Developer Platform CLI", - Long: "Drives the Grounds platform from the terminal.", + Short: "Grounds developer platform CLI", + Long: "Build, deploy, inspect, and troubleshoot Grounds projects from the terminal.", SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/grounds/commands/version.go b/cmd/grounds/commands/version.go index baa3611..5dfa475 100644 --- a/cmd/grounds/commands/version.go +++ b/cmd/grounds/commands/version.go @@ -20,8 +20,9 @@ func NewVersionCommand() *cobra.Command { var releaseAPIURL string cmd := &cobra.Command{ - Use: "version", - Short: "Print version, commit, and build date", + Use: "version", + Short: "Print version information and check for updates", + Example: " grounds version\n grounds version --check", RunE: func(cmd *cobra.Command, _ []string) error { if _, err := fmt.Fprintf(cmd.OutOrStdout(), "grounds version %s\n commit: %s\n built: %s\n", From 690b8393005226ac9a7ba5f45295566fa76ce571 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 21:56:05 +0200 Subject: [PATCH 10/19] fix: align cli help examples with commands --- cmd/grounds/commands/logs/logs.go | 2 +- cmd/grounds/commands/logs/logs_test.go | 24 ++++++++++++++++++++++++ cmd/grounds/commands/push/push.go | 9 +++------ cmd/grounds/commands/push/push_test.go | 22 ++++++++++++++++++++++ 4 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 cmd/grounds/commands/logs/logs_test.go diff --git a/cmd/grounds/commands/logs/logs.go b/cmd/grounds/commands/logs/logs.go index 5465c58..88d6f7a 100644 --- a/cmd/grounds/commands/logs/logs.go +++ b/cmd/grounds/commands/logs/logs.go @@ -20,7 +20,7 @@ func NewLogsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "logs ", Short: "Stream push logs (or deployment logs via 'grounds logs deployment ')", - Example: " grounds logs\n grounds logs --follow\n grounds logs deployment ", + Example: " grounds logs \n grounds logs --follow\n grounds logs deployment ", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return streamLogs(cmd.Context(), args[0], "push", follow) diff --git a/cmd/grounds/commands/logs/logs_test.go b/cmd/grounds/commands/logs/logs_test.go new file mode 100644 index 0000000..fa89ea8 --- /dev/null +++ b/cmd/grounds/commands/logs/logs_test.go @@ -0,0 +1,24 @@ +package logs + +import ( + "strings" + "testing" +) + +func TestLogsExamplesIncludeRequiredPushID(t *testing.T) { + cmd := NewLogsCommand() + + for _, example := range []string{ + "grounds logs ", + "grounds logs --follow", + "grounds logs deployment ", + } { + if !strings.Contains(cmd.Example, example) { + t.Fatalf("logs examples = %q, want %q", cmd.Example, example) + } + } + + if strings.Contains(cmd.Example, "grounds logs\n") || strings.Contains(cmd.Example, "grounds logs --follow") { + t.Fatalf("logs examples = %q, should include required ", cmd.Example) + } +} diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index 7598de0..89f0aeb 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -16,12 +16,9 @@ import ( ) func NewPushCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "push", - Short: "Build and deploy the current project", - Example: " grounds push\n grounds push --target=staging\n grounds push list --mine", - } - cmd.AddCommand(newPush(), newRetry(), newList()) + cmd := newPush() + cmd.Example = " grounds push\n grounds push --target=staging\n grounds push list --mine" + cmd.AddCommand(newRetry(), newList()) return cmd } diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index 0f36263..cc86189 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -48,6 +48,28 @@ func TestPushDefaultTargetIsDev(t *testing.T) { } } +func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) { + cmd := NewPushCommand() + + if flag := cmd.Flag("target"); flag == nil { + t.Fatal("expected root push command to define --target") + } else if flag.DefValue != "dev" { + t.Fatalf("default --target = %q, want %q", flag.DefValue, "dev") + } + + for _, name := range []string{"list", "retry"} { + if sub, _, err := cmd.Find([]string{name}); err != nil { + t.Fatalf("Find(%q) error = %v", name, err) + } else if sub.Name() != name { + t.Fatalf("Find(%q) = %q, want %q", name, sub.Name(), name) + } + } + + if sub, _, err := cmd.Find([]string{"push"}); err == nil && sub.Name() == "push" && sub != cmd { + t.Fatalf("unexpected nested push subcommand found") + } +} + func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { dir := t.TempDir() cwd, err := os.Getwd() From ccc340660838fc5d7be8aca2b2f97845eb7bd812 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:03:35 +0200 Subject: [PATCH 11/19] fix: reject unexpected push arguments --- cmd/grounds/commands/push/push.go | 1 + cmd/grounds/commands/push/push_test.go | 45 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index 89f0aeb..c2b54e5 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -34,6 +34,7 @@ Targets: dev — long-lived, lands in your personal namespace (user-). staging — ephemeral preview env, fresh namespace per push, auto-deleted after 7 days. Public URL pattern: -pr.dev.grnds.io.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if target != "dev" && target != "staging" { return fmt.Errorf("invalid --target %q: must be \"dev\" or \"staging\"", target) diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index cc86189..2da2ba0 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -70,6 +70,51 @@ func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) { } } +func TestPushRootRejectsUnexpectedArgsBeforeDeployWork(t *testing.T) { + for _, args := range [][]string{ + {"definitely-not-a-command"}, + {"push"}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + cmd := NewPushCommand() + cmd.SetArgs(args) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected unexpected argument error") + } + got := err.Error() + if !strings.Contains(got, "unknown command") { + t.Fatalf("error = %q, want argument validation error", got) + } + if strings.Contains(got, "Run `grounds init`") || strings.Contains(got, "Not a Gradle project") { + t.Fatalf("error = %q, should not enter deploy path", got) + } + }) + } +} + +func TestPushDeployCommandRejectsUnexpectedArgsBeforeDeployWork(t *testing.T) { + cmd := newPush() + cmd.SetArgs([]string{"definitely-not-a-command"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected unexpected argument error") + } + got := err.Error() + if !strings.Contains(got, "unknown command") && !strings.Contains(got, "arg(s)") { + t.Fatalf("error = %q, want argument validation error", got) + } + if strings.Contains(got, "Run `grounds init`") || strings.Contains(got, "Not a Gradle project") { + t.Fatalf("error = %q, should not enter deploy path", got) + } +} + func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { dir := t.TempDir() cwd, err := os.Getwd() From 49e9ffd368e1ad8b6562bd61223b1a43aea09a45 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:09:23 +0200 Subject: [PATCH 12/19] docs: clarify output flag scope --- cmd/grounds/commands/root.go | 2 +- cmd/grounds/commands/root_test.go | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/grounds/commands/root.go b/cmd/grounds/commands/root.go index d75f120..20a8b59 100644 --- a/cmd/grounds/commands/root.go +++ b/cmd/grounds/commands/root.go @@ -26,7 +26,7 @@ func NewRootCommand() *cobra.Command { }, } cmd.PersistentFlags().String("api-url", "", "override API endpoint (also GROUNDS_API_URL)") - cmd.PersistentFlags().String("output", "table", "output format: table | json | yaml") + cmd.PersistentFlags().String("output", "table", "output format for data commands: table | json | yaml") cmd.PersistentFlags().BoolP("verbose", "v", false, "debug logging to stderr") cmd.PersistentFlags().Bool("no-color", false, "disable colour output") cmd.PersistentFlags().String("config", "", "alternative config directory (also GROUNDS_CONFIG_DIR)") diff --git a/cmd/grounds/commands/root_test.go b/cmd/grounds/commands/root_test.go index 54e7f01..b431161 100644 --- a/cmd/grounds/commands/root_test.go +++ b/cmd/grounds/commands/root_test.go @@ -25,3 +25,14 @@ func TestRootCommandAppliesNoColorFlag(t *testing.T) { t.Fatal("expected --no-color to disable color output") } } + +func TestRootOutputFlagMentionsDataCommands(t *testing.T) { + root := NewRootCommand() + flag := root.PersistentFlags().Lookup("output") + if flag == nil { + t.Fatal("missing output flag") + } + if got := flag.Usage; got != "output format for data commands: table | json | yaml" { + t.Fatalf("output flag usage = %q", got) + } +} From a9b83703c8d2601a9d562421532bde3606ae8ca6 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:13:32 +0200 Subject: [PATCH 13/19] style: polish remaining cli output --- .../commands/devspace/generate_test.go | 2 +- cmd/grounds/commands/logs/logs.go | 2 +- cmd/grounds/commands/push/push_test.go | 12 +- .../plans/2026-05-04-cli-ux-consistency.md | 972 ++++++++++++++++++ 4 files changed, 984 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-04-cli-ux-consistency.md diff --git a/cmd/grounds/commands/devspace/generate_test.go b/cmd/grounds/commands/devspace/generate_test.go index 74f657a..67777db 100644 --- a/cmd/grounds/commands/devspace/generate_test.go +++ b/cmd/grounds/commands/devspace/generate_test.go @@ -77,7 +77,7 @@ overrides: {} } }) - t.Run("component not in override file → override is nil", func(t *testing.T) { + t.Run("component missing from override file uses nil override", func(t *testing.T) { path := writeTempYAML(t, ` bundle: 0.4.0 overrides: diff --git a/cmd/grounds/commands/logs/logs.go b/cmd/grounds/commands/logs/logs.go index 88d6f7a..9526479 100644 --- a/cmd/grounds/commands/logs/logs.go +++ b/cmd/grounds/commands/logs/logs.go @@ -19,7 +19,7 @@ func NewLogsCommand() *cobra.Command { var follow bool cmd := &cobra.Command{ Use: "logs ", - Short: "Stream push logs (or deployment logs via 'grounds logs deployment ')", + Short: "Stream push logs, or deployment logs with `grounds logs deployment `", Example: " grounds logs \n grounds logs --follow\n grounds logs deployment ", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index 2da2ba0..9810ef4 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -143,7 +143,7 @@ func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { if !strings.Contains(got, "Run `grounds init`") { t.Fatalf("error = %q, want command suggestion", got) } - if strings.Contains(got, "→") || strings.Contains(got, "'grounds init'") { + if strings.Contains(got, oldArrow()) || strings.Contains(got, singleQuotedCommand("grounds init")) { t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) } } @@ -155,11 +155,19 @@ func TestPushAuthRefreshErrorSuggestsLoginCommand(t *testing.T) { if !strings.Contains(got, "Run `grounds login`") { t.Fatalf("error = %q, want login command suggestion", got) } - if strings.Contains(got, "→") || strings.Contains(got, "'grounds login'") { + if strings.Contains(got, oldArrow()) || strings.Contains(got, singleQuotedCommand("grounds login")) { t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) } } +func oldArrow() string { + return string(rune(0x2192)) +} + +func singleQuotedCommand(command string) string { + return "'" + command + "'" +} + func TestRenderRetryTriggered(t *testing.T) { color.NoColor = true t.Cleanup(func() { color.NoColor = false }) diff --git a/docs/superpowers/plans/2026-05-04-cli-ux-consistency.md b/docs/superpowers/plans/2026-05-04-cli-ux-consistency.md new file mode 100644 index 0000000..fab4b8c --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-cli-ux-consistency.md @@ -0,0 +1,972 @@ +# CLI UX Consistency Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring the remaining Grounds CLI commands up to the UX standard established by `grounds doctor` and `grounds version --check`. + +**Architecture:** Add shared rendering primitives in `internal/render` for status rows, detail rows, command references, and empty states. Then migrate command output incrementally without changing API behavior or machine-readable output. + +**Tech Stack:** Go, Cobra, existing `internal/render` package, existing command tests with focused additions. + +--- + +## File Structure + +- Create `internal/render/message.go`: shared UX helpers for `[✓]`, `[!]`, `[✗]`, detail lines, and command references. +- Create `internal/render/message_test.go`: unit tests for helper output with colors disabled. +- Modify `cmd/grounds/commands/doctor.go`: replace local badge/detail helpers with `internal/render` helpers. +- Modify `cmd/grounds/commands/init.go` and `cmd/grounds/commands/init_test.go`: status-style scaffold output and next-step formatting. +- Modify `cmd/grounds/commands/login.go`: browser/code/auth output formatting. +- Modify `cmd/grounds/commands/logout.go`: status-style logout output. +- Modify `cmd/grounds/commands/cluster/status.go`, `cluster/up.go`, `cluster/down.go`, `cluster/delete.go`, and `internal/render/status.go`: workspace action and empty-state formatting. +- Modify `cmd/grounds/commands/push/push.go`, `push/retry.go`, and `push/list.go`: actionable error suggestions, retry success, pagination note. +- Modify `cmd/grounds/commands/preview/preview.go` and `preview_test.go`: empty state, show layout, pin/unpin output. +- Modify `cmd/grounds/commands/devspace/generate.go` and `generate_test.go`: success output without byte-count noise. +- Modify `cmd/grounds/commands/bundle/list.go` and `bundle/show.go`: empty state and command references. +- Modify `cmd/grounds/commands/root.go`, `version.go`, and subtree root files: improved `Short`, `Long`, and `Example` copy. + +--- + +### Task 1: Shared Render Helpers + +**Files:** +- Create: `internal/render/message.go` +- Create: `internal/render/message_test.go` +- Modify: `cmd/grounds/commands/doctor.go` + +- [ ] **Step 1: Write tests for status rows, details, and command references** + +Create `internal/render/message_test.go`: + +```go +package render + +import ( + "bytes" + "testing" + + "github.com/fatih/color" +) + +func TestStatusBadgeNoColor(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + if got := StatusBadge(StatusOK); got != "[✓]" { + t.Fatalf("StatusBadge(StatusOK) = %q", got) + } + if got := StatusBadge(StatusWarn); got != "[!]" { + t.Fatalf("StatusBadge(StatusWarn) = %q", got) + } + if got := StatusBadge(StatusError); got != "[✗]" { + t.Fatalf("StatusBadge(StatusError) = %q", got) + } +} + +func TestStatusLine(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + var buf bytes.Buffer + StatusLine(&buf, StatusOK, "Init", "Wrote grounds.yaml") + + want := "[✓] Init - Wrote grounds.yaml\n" + if got := buf.String(); got != want { + t.Fatalf("StatusLine output = %q, want %q", got, want) + } +} + +func TestDetailLine(t *testing.T) { + color.NoColor = true + defer func() { color.NoColor = false }() + + var buf bytes.Buffer + DetailLine(&buf, StatusWarn, "Run "+Command("grounds push")+" to create one.") + + want := " ! Run `grounds push` to create one.\n" + if got := buf.String(); got != want { + t.Fatalf("DetailLine output = %q, want %q", got, want) + } +} + +func TestCommand(t *testing.T) { + if got := Command("grounds version --check"); got != "`grounds version --check`" { + t.Fatalf("Command() = %q", got) + } +} +``` + +- [ ] **Step 2: Run the new render tests and verify they fail** + +Run: `go test ./internal/render` + +Expected: FAIL because `StatusBadge`, `StatusLine`, `DetailLine`, `StatusOK`, `StatusWarn`, `StatusError`, and `Command` do not exist. + +- [ ] **Step 3: Implement render helpers** + +Create `internal/render/message.go`: + +```go +package render + +import ( + "fmt" + "io" +) + +type StatusKind string + +const ( + StatusOK StatusKind = "ok" + StatusWarn StatusKind = "warn" + StatusError StatusKind = "error" +) + +func StatusBadge(status StatusKind) string { + switch status { + case StatusWarn: + return Yellow("[!]") + case StatusError: + return Red("[✗]") + default: + return Green("[✓]") + } +} + +func DetailIcon(status StatusKind) string { + switch status { + case StatusError: + return Red("✗") + case StatusWarn: + return Yellow("!") + default: + return "•" + } +} + +func StatusLine(w io.Writer, status StatusKind, subject, summary string) { + fmt.Fprintf(w, "%s %s - %s\n", StatusBadge(status), subject, summary) +} + +func DetailLine(w io.Writer, status StatusKind, detail string) { + fmt.Fprintf(w, " %s %s\n", DetailIcon(status), detail) +} + +func Command(command string) string { + return "`" + command + "`" +} +``` + +- [ ] **Step 4: Replace doctor-local badge helpers** + +Modify `cmd/grounds/commands/doctor.go`: + +```go +func printCheckResult(out io.Writer, r checkResult) { + render.StatusLine(out, renderStatus(r.status), r.name, r.summary) + for _, detail := range r.details { + render.DetailLine(out, renderStatus(r.status), detail) + } +} + +func renderStatus(status checkStatus) render.StatusKind { + switch status { + case statusWarn: + return render.StatusWarn + case statusError: + return render.StatusError + default: + return render.StatusOK + } +} +``` + +Delete the local `statusBadge` and `detailIcon` functions from `doctor.go`. + +- [ ] **Step 5: Run tests** + +Run: `go test ./cmd/grounds/commands ./internal/render` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/render/message.go internal/render/message_test.go cmd/grounds/commands/doctor.go +git commit -m "refactor: share cli status rendering" +``` + +--- + +### Task 2: Init, Login, And Logout Output + +**Files:** +- Modify: `cmd/grounds/commands/init.go` +- Modify: `cmd/grounds/commands/init_test.go` +- Modify: `cmd/grounds/commands/login.go` +- Modify: `cmd/grounds/commands/logout.go` + +- [ ] **Step 1: Add init output assertion** + +Update `TestInit_NonInteractive` in `cmd/grounds/commands/init_test.go`: + +```go + if got := buf.String(); got != "[✓] Init - Wrote grounds.yaml\n • Next: run `grounds push`.\n" { + t.Fatalf("output = %q", got) + } +``` + +- [ ] **Step 2: Run init test and verify it fails** + +Run: `go test ./cmd/grounds/commands -run TestInit_NonInteractive` + +Expected: FAIL because output still uses `→ Wrote grounds.yaml`. + +- [ ] **Step 3: Update init output** + +Modify `writeGroundsYaml` in `cmd/grounds/commands/init.go`: + +```go + render.StatusLine(out, render.StatusOK, "Init", "Wrote grounds.yaml") + render.DetailLine(out, render.StatusOK, "Next: run "+render.Command("grounds push")+".") +``` + +Add import: + +```go +"github.com/groundsgg/grounds-cli/internal/render" +``` + +- [ ] **Step 4: Update login output** + +Modify `cmd/grounds/commands/login.go`: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Browser", "Opened device login page") +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+dc.VerificationURI) +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Code: "+dc.UserCode) +``` + +Replace the final login success line with: + +```go +subject := preferred +if subject == "" { + subject = email +} +if subject == "" { + subject = "current user" +} +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged in as "+subject) +``` + +Add import: + +```go +"github.com/groundsgg/grounds-cli/internal/render" +``` + +- [ ] **Step 5: Update logout output** + +Modify `cmd/grounds/commands/logout.go`: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged out") +``` + +Add import: + +```go +"github.com/groundsgg/grounds-cli/internal/render" +``` + +- [ ] **Step 6: Run tests** + +Run: `go test ./cmd/grounds/commands` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/grounds/commands/init.go cmd/grounds/commands/init_test.go cmd/grounds/commands/login.go cmd/grounds/commands/logout.go +git commit -m "style: standardize auth and init output" +``` + +--- + +### Task 3: Cluster Output And Workspace Empty State + +**Files:** +- Modify: `cmd/grounds/commands/cluster/status.go` +- Modify: `cmd/grounds/commands/cluster/up.go` +- Modify: `cmd/grounds/commands/cluster/down.go` +- Modify: `cmd/grounds/commands/cluster/delete.go` +- Modify: `internal/render/status.go` + +- [ ] **Step 1: Update no-workspace status output** + +Modify the 404 branch in `cmd/grounds/commands/cluster/status.go`: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "No workspace found") +render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Run "+render.Command("grounds push")+" to create one.") +return nil +``` + +- [ ] **Step 2: Update cluster up/down action lines** + +In `cmd/grounds/commands/cluster/up.go`, replace: + +```go +fmt.Fprintln(cmd.OutOrStdout(), "✔ Active.") +``` + +with: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Active") +``` + +In `cmd/grounds/commands/cluster/down.go`, replace: + +```go +fmt.Fprintln(cmd.OutOrStdout(), "✔ Paused.") +``` + +with: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Paused") +``` + +- [ ] **Step 3: Update bundle result output** + +Modify `renderBundleResult` in `cmd/grounds/commands/cluster/up.go`: + +```go +status := render.StatusOK +summary := fmt.Sprintf("%s with bundle %s in namespace %s", res.State, res.BundleVersion, res.Namespace) +if len(res.Components.Failed) > 0 { + status = render.StatusWarn +} +render.StatusLine(w, status, "Workspace", summary) +render.DetailLine(w, status, fmt.Sprintf("Components: %d resolved, %d succeeded, %d failed", + res.Components.Resolved, len(res.Components.Succeeded), len(res.Components.Failed))) +for _, f := range res.Components.Failed { + render.DetailLine(w, render.StatusError, fmt.Sprintf("%s: %s", f.Name, f.Error)) +} +``` + +- [ ] **Step 4: Update delete warning and result output** + +Modify `cmd/grounds/commands/cluster/delete.go`: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "This will permanently delete "+s.Namespace+" and all its data") +``` + +Replace result output: + +```go +case "deleted": + render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Deleted "+s.Namespace) +case "deleting": + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "Delete is still in progress") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Cleanup will continue automatically.") +``` + +Add import: + +```go +"github.com/groundsgg/grounds-cli/internal/render" +``` + +- [ ] **Step 5: Update paused status warning** + +Modify `internal/render/status.go`: + +```go +if s.State == "paused" { + fmt.Fprintln(w) + StatusLine(w, StatusWarn, "Workspace", "Paused") + DetailLine(w, StatusWarn, "Next push or "+Command("grounds cluster up")+" resumes it.") +} +``` + +- [ ] **Step 6: Run tests** + +Run: `go test ./cmd/grounds/commands/cluster ./internal/render` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/grounds/commands/cluster/status.go cmd/grounds/commands/cluster/up.go cmd/grounds/commands/cluster/down.go cmd/grounds/commands/cluster/delete.go internal/render/status.go +git commit -m "style: standardize workspace command output" +``` + +--- + +### Task 4: Push Output And Error Suggestions + +**Files:** +- Modify: `cmd/grounds/commands/push/push.go` +- Modify: `cmd/grounds/commands/push/retry.go` +- Modify: `cmd/grounds/commands/push/list.go` +- Modify: `cmd/grounds/commands/push/push_test.go` + +- [ ] **Step 1: Add focused error-copy test for missing Gradle wrapper** + +Add to `cmd/grounds/commands/push/push_test.go`: + +```go +func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { + dir := t.TempDir() + cwd, _ := os.Getwd() + defer os.Chdir(cwd) + os.Chdir(dir) + + cmd := newPush() + cmd.SetArgs([]string{}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected missing Gradle wrapper error") + } + got := err.Error() + if !strings.Contains(got, "Run `grounds init`") { + t.Fatalf("error = %q, want command suggestion", got) + } + if strings.Contains(got, "→") || strings.Contains(got, "'grounds init'") { + t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) + } +} +``` + +Ensure imports include: + +```go +"os" +"strings" +``` + +- [ ] **Step 2: Run push test and verify it fails** + +Run: `go test ./cmd/grounds/commands/push -run TestPushMissingGradleWrapperSuggestsCommand` + +Expected: FAIL because current error uses `→` and `'grounds init'`. + +- [ ] **Step 3: Update push error suggestions** + +Modify `cmd/grounds/commands/push/push.go`: + +```go +return fmt.Errorf("%w\n ! Not a Gradle project? Run %s to scaffold, or cd to your project root.", err, render.Command("grounds init")) +``` + +and: + +```go +return fmt.Errorf("auth refresh failed: %w\n ! Run %s to re-authenticate.", err, render.Command("grounds login")) +``` + +Add import: + +```go +"github.com/groundsgg/grounds-cli/internal/render" +``` + +- [ ] **Step 4: Update retry output** + +Modify `cmd/grounds/commands/push/retry.go`: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Push", "Retry triggered for "+p.ID) +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Status: "+p.Status) +``` + +Add import: + +```go +"github.com/groundsgg/grounds-cli/internal/render" +``` + +- [ ] **Step 5: Update pagination note** + +Modify `cmd/grounds/commands/push/list.go`: + +```go +if list.NextCursor != "" { + render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Push", "More results are available") + render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Pagination is not available in this CLI version.") +} +``` + +- [ ] **Step 6: Run tests** + +Run: `go test ./cmd/grounds/commands/push` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/grounds/commands/push/push.go cmd/grounds/commands/push/retry.go cmd/grounds/commands/push/list.go cmd/grounds/commands/push/push_test.go +git commit -m "style: improve push command messages" +``` + +--- + +### Task 5: Preview Output And Empty States + +**Files:** +- Modify: `cmd/grounds/commands/preview/preview.go` +- Modify: `cmd/grounds/commands/preview/preview_test.go` + +- [ ] **Step 1: Add unit tests for preview formatting helpers** + +Add to `cmd/grounds/commands/preview/preview_test.go`: + +```go +func TestPreviewPinSummary(t *testing.T) { + if got := previewPinSummary(true, "abcdef1234", "plugin-social"); got != "Pinned abcdef12 (plugin-social)" { + t.Fatalf("previewPinSummary(pin) = %q", got) + } + if got := previewPinSummary(false, "abcdef1234", "plugin-social"); got != "Unpinned abcdef12 (plugin-social)" { + t.Fatalf("previewPinSummary(unpin) = %q", got) + } +} +``` + +- [ ] **Step 2: Run preview tests and verify they fail** + +Run: `go test ./cmd/grounds/commands/preview` + +Expected: FAIL because `previewPinSummary` does not exist. + +- [ ] **Step 3: Add preview pin summary helper** + +Add to `cmd/grounds/commands/preview/preview.go`: + +```go +func previewPinSummary(pin bool, id, manifestName string) string { + verb := "Pinned" + if !pin { + verb = "Unpinned" + } + return fmt.Sprintf("%s %s (%s)", verb, shortID(id), manifestName) +} +``` + +- [ ] **Step 4: Update preview empty state** + +Replace: + +```go +fmt.Fprintln(cmd.OutOrStdout(), "no preview environments") +``` + +with: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Preview", "No preview environments found") +render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Run "+render.Command("grounds push --target=staging")+" to create one.") +``` + +- [ ] **Step 5: Update preview show human output** + +Replace the current `fmt.Fprintf` block with: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Preview", p.Push.ManifestName+" ("+p.Push.Status+")") +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "ID: "+p.ID) +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Push: "+p.PushID) +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Namespace: "+p.Namespace) +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Type: "+p.Push.ManifestType) +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, fmt.Sprintf("Pinned: %t", p.Pinned)) +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Expires: "+formatTime(p.ExpiresAt)) +render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+p.PublicURL) +``` + +- [ ] **Step 6: Update preview pin/unpin output** + +Replace: + +```go +fmt.Fprintf(cmd.OutOrStdout(), "%s %s (%s)\n", verb, shortID(p.ID), p.Push.ManifestName) +``` + +with: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Preview", previewPinSummary(pin, p.ID, p.Push.ManifestName)) +``` + +- [ ] **Step 7: Run tests** + +Run: `go test ./cmd/grounds/commands/preview` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add cmd/grounds/commands/preview/preview.go cmd/grounds/commands/preview/preview_test.go +git commit -m "style: improve preview command output" +``` + +--- + +### Task 6: DevSpace And Bundle Output + +**Files:** +- Modify: `cmd/grounds/commands/devspace/generate.go` +- Modify: `cmd/grounds/commands/devspace/generate_test.go` +- Modify: `cmd/grounds/commands/bundle/list.go` +- Modify: `cmd/grounds/commands/bundle/show.go` + +- [ ] **Step 1: Add DevSpace success summary helper test** + +Add to `cmd/grounds/commands/devspace/generate_test.go`: + +```go +func TestGenerateSuccessSummary(t *testing.T) { + if got := generateSuccessSummary("./devspace.yaml"); got != "Wrote ./devspace.yaml" { + t.Fatalf("generateSuccessSummary = %q", got) + } +} +``` + +- [ ] **Step 2: Run DevSpace tests and verify they fail** + +Run: `go test ./cmd/grounds/commands/devspace` + +Expected: FAIL because `generateSuccessSummary` does not exist. + +- [ ] **Step 3: Add DevSpace summary helper and update output** + +Add to `cmd/grounds/commands/devspace/generate.go`: + +```go +func generateSuccessSummary(outputPath string) string { + return "Wrote " + outputPath +} +``` + +Replace: + +```go +fmt.Fprintf(cmd.OutOrStdout(), "✔ Wrote %d bytes to %s\n", len(yaml), outputPath) +``` + +with: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "DevSpace", generateSuccessSummary(outputPath)) +``` + +Add import: + +```go +"github.com/groundsgg/grounds-cli/internal/render" +``` + +- [ ] **Step 4: Update bundle empty state** + +Modify `cmd/grounds/commands/bundle/list.go`: + +```go +render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Bundle", "No released bundles found") +render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Try "+render.Command("grounds bundle show main")+" to inspect the current bundle.") +return nil +``` + +Add import: + +```go +"github.com/groundsgg/grounds-cli/internal/render" +``` + +- [ ] **Step 5: Update bundle command references** + +Modify `cmd/grounds/commands/bundle/list.go` long text so command references use backticks: + +```go +the same one `grounds cluster up --bundle main` would track today. +``` + +Modify `cmd/grounds/commands/bundle/show.go` long text: + +```go +component table. accepts the same shapes as `grounds cluster up --bundle`: +``` + +- [ ] **Step 6: Run tests** + +Run: `go test ./cmd/grounds/commands/devspace ./cmd/grounds/commands/bundle` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add cmd/grounds/commands/devspace/generate.go cmd/grounds/commands/devspace/generate_test.go cmd/grounds/commands/bundle/list.go cmd/grounds/commands/bundle/show.go +git commit -m "style: improve devspace and bundle messages" +``` + +--- + +### Task 7: Help Text And Examples + +**Files:** +- Modify: `cmd/grounds/commands/root.go` +- Modify: `cmd/grounds/commands/version.go` +- Modify: `cmd/grounds/commands/push/push.go` +- Modify: `cmd/grounds/commands/cluster/cluster.go` +- Modify: `cmd/grounds/commands/cluster/status.go` +- Modify: `cmd/grounds/commands/cluster/up.go` +- Modify: `cmd/grounds/commands/preview/preview.go` +- Modify: `cmd/grounds/commands/devspace/devspace.go` +- Modify: `cmd/grounds/commands/bundle/bundle.go` +- Modify: `cmd/grounds/commands/logs/logs.go` + +- [ ] **Step 1: Update root and version help** + +Modify `cmd/grounds/commands/root.go`: + +```go +Short: "Grounds developer platform CLI", +Long: "Build, deploy, inspect, and troubleshoot Grounds projects from the terminal.", +``` + +Modify `cmd/grounds/commands/version.go`: + +```go +Short: "Print version information and check for updates", +Example: " grounds version\n grounds version --check", +``` + +- [ ] **Step 2: Add push examples** + +Modify `cmd/grounds/commands/push/push.go` root and leaf command examples: + +```go +cmd := &cobra.Command{ + Use: "push", + Short: "Build and deploy the current project", + Example: " grounds push\n grounds push --target=staging\n grounds push list --mine", +} +``` + +For `newPush()`: + +```go +Example: " grounds push\n grounds push --target=staging", +``` + +- [ ] **Step 3: Add cluster examples** + +Add examples to cluster commands: + +```go +Example: " grounds cluster status\n grounds cluster up\n grounds cluster down\n grounds cluster delete", +``` + +For `cluster up`: + +```go +Example: " grounds cluster up\n grounds cluster up --profile=platform\n grounds cluster up --bundle=0.4.0 --override=./overrides/me.yaml", +``` + +- [ ] **Step 4: Add preview examples and improve root short** + +Modify `NewPreviewCommand`: + +```go +Short: "Manage staging preview environments", +Example: " grounds preview list\n grounds preview show \n grounds preview pin \n grounds preview unpin ", +``` + +- [ ] **Step 5: Add devspace, bundle, and logs examples** + +Add examples: + +```go +Example: " grounds devspace generate plugin-social --bundle main\n grounds devspace generate plugin-social --override ./me.yaml", +``` + +```go +Example: " grounds bundle list\n grounds bundle show main\n grounds bundle show 0.4.0", +``` + +```go +Example: " grounds logs\n grounds logs --follow\n grounds logs deployment ", +``` + +- [ ] **Step 6: Run help smoke checks** + +Run: + +```bash +go run ./cmd/grounds --help +go run ./cmd/grounds version --help +go run ./cmd/grounds push --help +go run ./cmd/grounds cluster up --help +go run ./cmd/grounds preview --help +``` + +Expected: each command prints help without errors, examples are visible, and old implementation-leaky wording such as `target=staging deploys` is gone. + +- [ ] **Step 7: Run tests** + +Run: `go test ./cmd/grounds/...` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add cmd/grounds/commands/root.go cmd/grounds/commands/version.go cmd/grounds/commands/push/push.go cmd/grounds/commands/cluster/cluster.go cmd/grounds/commands/cluster/status.go cmd/grounds/commands/cluster/up.go cmd/grounds/commands/preview/preview.go cmd/grounds/commands/devspace/devspace.go cmd/grounds/commands/bundle/bundle.go cmd/grounds/commands/logs/logs.go +git commit -m "docs: improve cli command help" +``` + +--- + +### Task 8: Output Format Contract + +**Files:** +- Modify: `cmd/grounds/commands/root.go` +- Modify: command docs/help where needed + +- [ ] **Step 1: Decide and encode the contract in root help** + +Keep the global `--output` flag, but clarify that structured output is for data commands. + +Modify `cmd/grounds/commands/root.go`: + +```go +cmd.PersistentFlags().String("output", "table", "output format for data commands: table | json | yaml") +``` + +- [ ] **Step 2: Audit commands for accidental `--output` confusion** + +Run: + +```bash +rg -n '"output"|GetString\\("output"\\)|BoolVar.*json|render\\.(JSON|YAML|Table)' cmd/grounds internal/render +``` + +Expected: identify data commands that already render structured output and action commands that only print human status messages. + +- [ ] **Step 3: Add a root help regression test** + +Add to `cmd/grounds/commands/root_test.go`: + +```go +func TestRootOutputFlagMentionsDataCommands(t *testing.T) { + root := NewRootCommand() + flag := root.PersistentFlags().Lookup("output") + if flag == nil { + t.Fatal("missing output flag") + } + if got := flag.Usage; got != "output format for data commands: table | json | yaml" { + t.Fatalf("output flag usage = %q", got) + } +} +``` + +- [ ] **Step 4: Run root tests** + +Run: `go test ./cmd/grounds/commands -run TestRoot` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/grounds/commands/root.go cmd/grounds/commands/root_test.go +git commit -m "docs: clarify output flag scope" +``` + +--- + +### Task 9: Final Verification + +**Files:** +- No new edits expected. + +- [ ] **Step 1: Run full Go tests** + +Run: `go test ./...` + +Expected: PASS. + +- [ ] **Step 2: Run build** + +Run: `go build ./cmd/grounds` + +Expected: PASS and a local `grounds` binary is produced. + +- [ ] **Step 3: Run manual UX smoke checks** + +Run: + +```bash +./grounds --help +./grounds version +./grounds version --check +./grounds doctor +./grounds init --help +./grounds push --help +./grounds cluster status --help +./grounds preview --help +./grounds bundle --help +./grounds devspace --help +``` + +Expected: +- Help text is concise and example-driven. +- Human output uses `[✓]`, `[!]`, or `[✗]`. +- Command suggestions use backticks. +- No output uses `→`. +- No successful one-line command adds unnecessary detail lines. + +- [ ] **Step 4: Scan for old UX patterns** + +Run: + +```bash +rg -n '→|✔|⚠|'"'"'grounds [^'"'"']+'"'"'' cmd/grounds internal/render +``` + +Expected: no remaining old arrow/check/warning symbols in user-facing command output. Single-quoted command references should be gone from help and errors. + +- [ ] **Step 5: Commit verification-only fixes if needed** + +If Step 4 finds old user-facing copy, update the affected file, rerun: + +```bash +go test ./... +go build ./cmd/grounds +``` + +Then commit: + +```bash +git add cmd internal +git commit -m "style: polish remaining cli output" +``` + +--- + +## Self-Review + +**Spec coverage:** The plan covers shared formatting, action command output, empty states, help examples, `--output` clarity, and final scan-based verification. + +**Placeholder scan:** The plan does not use unresolved TODO/TBD language. Each task includes concrete files, snippets, commands, and expected results. + +**Type consistency:** The shared helpers are named `StatusKind`, `StatusOK`, `StatusWarn`, `StatusError`, `StatusBadge`, `DetailIcon`, `StatusLine`, `DetailLine`, and `Command`, and all later tasks use those exact names. From cdfa4b510fa0604945ce497f4e4bc92dab8c8e48 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:18:43 +0200 Subject: [PATCH 14/19] fix: reject unexpected push list arguments --- cmd/grounds/commands/push/list.go | 1 + cmd/grounds/commands/push/push_test.go | 19 + .../plans/2026-05-04-cli-ux-consistency.md | 972 ------------------ 3 files changed, 20 insertions(+), 972 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-04-cli-ux-consistency.md diff --git a/cmd/grounds/commands/push/list.go b/cmd/grounds/commands/push/list.go index 9eea970..84d7973 100644 --- a/cmd/grounds/commands/push/list.go +++ b/cmd/grounds/commands/push/list.go @@ -18,6 +18,7 @@ func newList() *cobra.Command { cmd := &cobra.Command{ Use: "list", Short: "List pushes", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { ctx := context.Background() cfg, err := config.Load("") diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index 9810ef4..eae1cc8 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -96,6 +96,25 @@ func TestPushRootRejectsUnexpectedArgsBeforeDeployWork(t *testing.T) { } } +func TestPushListRejectsUnexpectedArgsBeforeAPIWork(t *testing.T) { + cmd := NewPushCommand() + cmd.SetArgs([]string{"list", "unexpected"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected unexpected argument error") + } + got := err.Error() + if !strings.Contains(got, "unknown command") && !strings.Contains(got, "arg(s)") { + t.Fatalf("error = %q, want argument validation error", got) + } + if strings.Contains(got, "credentials") || strings.Contains(got, "GROUNDS_TOKEN") { + t.Fatalf("error = %q, should not enter auth/API path", got) + } +} + func TestPushDeployCommandRejectsUnexpectedArgsBeforeDeployWork(t *testing.T) { cmd := newPush() cmd.SetArgs([]string{"definitely-not-a-command"}) diff --git a/docs/superpowers/plans/2026-05-04-cli-ux-consistency.md b/docs/superpowers/plans/2026-05-04-cli-ux-consistency.md deleted file mode 100644 index fab4b8c..0000000 --- a/docs/superpowers/plans/2026-05-04-cli-ux-consistency.md +++ /dev/null @@ -1,972 +0,0 @@ -# CLI UX Consistency Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bring the remaining Grounds CLI commands up to the UX standard established by `grounds doctor` and `grounds version --check`. - -**Architecture:** Add shared rendering primitives in `internal/render` for status rows, detail rows, command references, and empty states. Then migrate command output incrementally without changing API behavior or machine-readable output. - -**Tech Stack:** Go, Cobra, existing `internal/render` package, existing command tests with focused additions. - ---- - -## File Structure - -- Create `internal/render/message.go`: shared UX helpers for `[✓]`, `[!]`, `[✗]`, detail lines, and command references. -- Create `internal/render/message_test.go`: unit tests for helper output with colors disabled. -- Modify `cmd/grounds/commands/doctor.go`: replace local badge/detail helpers with `internal/render` helpers. -- Modify `cmd/grounds/commands/init.go` and `cmd/grounds/commands/init_test.go`: status-style scaffold output and next-step formatting. -- Modify `cmd/grounds/commands/login.go`: browser/code/auth output formatting. -- Modify `cmd/grounds/commands/logout.go`: status-style logout output. -- Modify `cmd/grounds/commands/cluster/status.go`, `cluster/up.go`, `cluster/down.go`, `cluster/delete.go`, and `internal/render/status.go`: workspace action and empty-state formatting. -- Modify `cmd/grounds/commands/push/push.go`, `push/retry.go`, and `push/list.go`: actionable error suggestions, retry success, pagination note. -- Modify `cmd/grounds/commands/preview/preview.go` and `preview_test.go`: empty state, show layout, pin/unpin output. -- Modify `cmd/grounds/commands/devspace/generate.go` and `generate_test.go`: success output without byte-count noise. -- Modify `cmd/grounds/commands/bundle/list.go` and `bundle/show.go`: empty state and command references. -- Modify `cmd/grounds/commands/root.go`, `version.go`, and subtree root files: improved `Short`, `Long`, and `Example` copy. - ---- - -### Task 1: Shared Render Helpers - -**Files:** -- Create: `internal/render/message.go` -- Create: `internal/render/message_test.go` -- Modify: `cmd/grounds/commands/doctor.go` - -- [ ] **Step 1: Write tests for status rows, details, and command references** - -Create `internal/render/message_test.go`: - -```go -package render - -import ( - "bytes" - "testing" - - "github.com/fatih/color" -) - -func TestStatusBadgeNoColor(t *testing.T) { - color.NoColor = true - defer func() { color.NoColor = false }() - - if got := StatusBadge(StatusOK); got != "[✓]" { - t.Fatalf("StatusBadge(StatusOK) = %q", got) - } - if got := StatusBadge(StatusWarn); got != "[!]" { - t.Fatalf("StatusBadge(StatusWarn) = %q", got) - } - if got := StatusBadge(StatusError); got != "[✗]" { - t.Fatalf("StatusBadge(StatusError) = %q", got) - } -} - -func TestStatusLine(t *testing.T) { - color.NoColor = true - defer func() { color.NoColor = false }() - - var buf bytes.Buffer - StatusLine(&buf, StatusOK, "Init", "Wrote grounds.yaml") - - want := "[✓] Init - Wrote grounds.yaml\n" - if got := buf.String(); got != want { - t.Fatalf("StatusLine output = %q, want %q", got, want) - } -} - -func TestDetailLine(t *testing.T) { - color.NoColor = true - defer func() { color.NoColor = false }() - - var buf bytes.Buffer - DetailLine(&buf, StatusWarn, "Run "+Command("grounds push")+" to create one.") - - want := " ! Run `grounds push` to create one.\n" - if got := buf.String(); got != want { - t.Fatalf("DetailLine output = %q, want %q", got, want) - } -} - -func TestCommand(t *testing.T) { - if got := Command("grounds version --check"); got != "`grounds version --check`" { - t.Fatalf("Command() = %q", got) - } -} -``` - -- [ ] **Step 2: Run the new render tests and verify they fail** - -Run: `go test ./internal/render` - -Expected: FAIL because `StatusBadge`, `StatusLine`, `DetailLine`, `StatusOK`, `StatusWarn`, `StatusError`, and `Command` do not exist. - -- [ ] **Step 3: Implement render helpers** - -Create `internal/render/message.go`: - -```go -package render - -import ( - "fmt" - "io" -) - -type StatusKind string - -const ( - StatusOK StatusKind = "ok" - StatusWarn StatusKind = "warn" - StatusError StatusKind = "error" -) - -func StatusBadge(status StatusKind) string { - switch status { - case StatusWarn: - return Yellow("[!]") - case StatusError: - return Red("[✗]") - default: - return Green("[✓]") - } -} - -func DetailIcon(status StatusKind) string { - switch status { - case StatusError: - return Red("✗") - case StatusWarn: - return Yellow("!") - default: - return "•" - } -} - -func StatusLine(w io.Writer, status StatusKind, subject, summary string) { - fmt.Fprintf(w, "%s %s - %s\n", StatusBadge(status), subject, summary) -} - -func DetailLine(w io.Writer, status StatusKind, detail string) { - fmt.Fprintf(w, " %s %s\n", DetailIcon(status), detail) -} - -func Command(command string) string { - return "`" + command + "`" -} -``` - -- [ ] **Step 4: Replace doctor-local badge helpers** - -Modify `cmd/grounds/commands/doctor.go`: - -```go -func printCheckResult(out io.Writer, r checkResult) { - render.StatusLine(out, renderStatus(r.status), r.name, r.summary) - for _, detail := range r.details { - render.DetailLine(out, renderStatus(r.status), detail) - } -} - -func renderStatus(status checkStatus) render.StatusKind { - switch status { - case statusWarn: - return render.StatusWarn - case statusError: - return render.StatusError - default: - return render.StatusOK - } -} -``` - -Delete the local `statusBadge` and `detailIcon` functions from `doctor.go`. - -- [ ] **Step 5: Run tests** - -Run: `go test ./cmd/grounds/commands ./internal/render` - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add internal/render/message.go internal/render/message_test.go cmd/grounds/commands/doctor.go -git commit -m "refactor: share cli status rendering" -``` - ---- - -### Task 2: Init, Login, And Logout Output - -**Files:** -- Modify: `cmd/grounds/commands/init.go` -- Modify: `cmd/grounds/commands/init_test.go` -- Modify: `cmd/grounds/commands/login.go` -- Modify: `cmd/grounds/commands/logout.go` - -- [ ] **Step 1: Add init output assertion** - -Update `TestInit_NonInteractive` in `cmd/grounds/commands/init_test.go`: - -```go - if got := buf.String(); got != "[✓] Init - Wrote grounds.yaml\n • Next: run `grounds push`.\n" { - t.Fatalf("output = %q", got) - } -``` - -- [ ] **Step 2: Run init test and verify it fails** - -Run: `go test ./cmd/grounds/commands -run TestInit_NonInteractive` - -Expected: FAIL because output still uses `→ Wrote grounds.yaml`. - -- [ ] **Step 3: Update init output** - -Modify `writeGroundsYaml` in `cmd/grounds/commands/init.go`: - -```go - render.StatusLine(out, render.StatusOK, "Init", "Wrote grounds.yaml") - render.DetailLine(out, render.StatusOK, "Next: run "+render.Command("grounds push")+".") -``` - -Add import: - -```go -"github.com/groundsgg/grounds-cli/internal/render" -``` - -- [ ] **Step 4: Update login output** - -Modify `cmd/grounds/commands/login.go`: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Browser", "Opened device login page") -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+dc.VerificationURI) -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Code: "+dc.UserCode) -``` - -Replace the final login success line with: - -```go -subject := preferred -if subject == "" { - subject = email -} -if subject == "" { - subject = "current user" -} -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged in as "+subject) -``` - -Add import: - -```go -"github.com/groundsgg/grounds-cli/internal/render" -``` - -- [ ] **Step 5: Update logout output** - -Modify `cmd/grounds/commands/logout.go`: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Auth", "Logged out") -``` - -Add import: - -```go -"github.com/groundsgg/grounds-cli/internal/render" -``` - -- [ ] **Step 6: Run tests** - -Run: `go test ./cmd/grounds/commands` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add cmd/grounds/commands/init.go cmd/grounds/commands/init_test.go cmd/grounds/commands/login.go cmd/grounds/commands/logout.go -git commit -m "style: standardize auth and init output" -``` - ---- - -### Task 3: Cluster Output And Workspace Empty State - -**Files:** -- Modify: `cmd/grounds/commands/cluster/status.go` -- Modify: `cmd/grounds/commands/cluster/up.go` -- Modify: `cmd/grounds/commands/cluster/down.go` -- Modify: `cmd/grounds/commands/cluster/delete.go` -- Modify: `internal/render/status.go` - -- [ ] **Step 1: Update no-workspace status output** - -Modify the 404 branch in `cmd/grounds/commands/cluster/status.go`: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "No workspace found") -render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Run "+render.Command("grounds push")+" to create one.") -return nil -``` - -- [ ] **Step 2: Update cluster up/down action lines** - -In `cmd/grounds/commands/cluster/up.go`, replace: - -```go -fmt.Fprintln(cmd.OutOrStdout(), "✔ Active.") -``` - -with: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Active") -``` - -In `cmd/grounds/commands/cluster/down.go`, replace: - -```go -fmt.Fprintln(cmd.OutOrStdout(), "✔ Paused.") -``` - -with: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Paused") -``` - -- [ ] **Step 3: Update bundle result output** - -Modify `renderBundleResult` in `cmd/grounds/commands/cluster/up.go`: - -```go -status := render.StatusOK -summary := fmt.Sprintf("%s with bundle %s in namespace %s", res.State, res.BundleVersion, res.Namespace) -if len(res.Components.Failed) > 0 { - status = render.StatusWarn -} -render.StatusLine(w, status, "Workspace", summary) -render.DetailLine(w, status, fmt.Sprintf("Components: %d resolved, %d succeeded, %d failed", - res.Components.Resolved, len(res.Components.Succeeded), len(res.Components.Failed))) -for _, f := range res.Components.Failed { - render.DetailLine(w, render.StatusError, fmt.Sprintf("%s: %s", f.Name, f.Error)) -} -``` - -- [ ] **Step 4: Update delete warning and result output** - -Modify `cmd/grounds/commands/cluster/delete.go`: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "This will permanently delete "+s.Namespace+" and all its data") -``` - -Replace result output: - -```go -case "deleted": - render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Deleted "+s.Namespace) -case "deleting": - render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Workspace", "Delete is still in progress") - render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Cleanup will continue automatically.") -``` - -Add import: - -```go -"github.com/groundsgg/grounds-cli/internal/render" -``` - -- [ ] **Step 5: Update paused status warning** - -Modify `internal/render/status.go`: - -```go -if s.State == "paused" { - fmt.Fprintln(w) - StatusLine(w, StatusWarn, "Workspace", "Paused") - DetailLine(w, StatusWarn, "Next push or "+Command("grounds cluster up")+" resumes it.") -} -``` - -- [ ] **Step 6: Run tests** - -Run: `go test ./cmd/grounds/commands/cluster ./internal/render` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add cmd/grounds/commands/cluster/status.go cmd/grounds/commands/cluster/up.go cmd/grounds/commands/cluster/down.go cmd/grounds/commands/cluster/delete.go internal/render/status.go -git commit -m "style: standardize workspace command output" -``` - ---- - -### Task 4: Push Output And Error Suggestions - -**Files:** -- Modify: `cmd/grounds/commands/push/push.go` -- Modify: `cmd/grounds/commands/push/retry.go` -- Modify: `cmd/grounds/commands/push/list.go` -- Modify: `cmd/grounds/commands/push/push_test.go` - -- [ ] **Step 1: Add focused error-copy test for missing Gradle wrapper** - -Add to `cmd/grounds/commands/push/push_test.go`: - -```go -func TestPushMissingGradleWrapperSuggestsCommand(t *testing.T) { - dir := t.TempDir() - cwd, _ := os.Getwd() - defer os.Chdir(cwd) - os.Chdir(dir) - - cmd := newPush() - cmd.SetArgs([]string{}) - cmd.SilenceUsage = true - cmd.SilenceErrors = true - - err := cmd.Execute() - if err == nil { - t.Fatal("expected missing Gradle wrapper error") - } - got := err.Error() - if !strings.Contains(got, "Run `grounds init`") { - t.Fatalf("error = %q, want command suggestion", got) - } - if strings.Contains(got, "→") || strings.Contains(got, "'grounds init'") { - t.Fatalf("error = %q, should not use arrows or single-quoted commands", got) - } -} -``` - -Ensure imports include: - -```go -"os" -"strings" -``` - -- [ ] **Step 2: Run push test and verify it fails** - -Run: `go test ./cmd/grounds/commands/push -run TestPushMissingGradleWrapperSuggestsCommand` - -Expected: FAIL because current error uses `→` and `'grounds init'`. - -- [ ] **Step 3: Update push error suggestions** - -Modify `cmd/grounds/commands/push/push.go`: - -```go -return fmt.Errorf("%w\n ! Not a Gradle project? Run %s to scaffold, or cd to your project root.", err, render.Command("grounds init")) -``` - -and: - -```go -return fmt.Errorf("auth refresh failed: %w\n ! Run %s to re-authenticate.", err, render.Command("grounds login")) -``` - -Add import: - -```go -"github.com/groundsgg/grounds-cli/internal/render" -``` - -- [ ] **Step 4: Update retry output** - -Modify `cmd/grounds/commands/push/retry.go`: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Push", "Retry triggered for "+p.ID) -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Status: "+p.Status) -``` - -Add import: - -```go -"github.com/groundsgg/grounds-cli/internal/render" -``` - -- [ ] **Step 5: Update pagination note** - -Modify `cmd/grounds/commands/push/list.go`: - -```go -if list.NextCursor != "" { - render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Push", "More results are available") - render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Pagination is not available in this CLI version.") -} -``` - -- [ ] **Step 6: Run tests** - -Run: `go test ./cmd/grounds/commands/push` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add cmd/grounds/commands/push/push.go cmd/grounds/commands/push/retry.go cmd/grounds/commands/push/list.go cmd/grounds/commands/push/push_test.go -git commit -m "style: improve push command messages" -``` - ---- - -### Task 5: Preview Output And Empty States - -**Files:** -- Modify: `cmd/grounds/commands/preview/preview.go` -- Modify: `cmd/grounds/commands/preview/preview_test.go` - -- [ ] **Step 1: Add unit tests for preview formatting helpers** - -Add to `cmd/grounds/commands/preview/preview_test.go`: - -```go -func TestPreviewPinSummary(t *testing.T) { - if got := previewPinSummary(true, "abcdef1234", "plugin-social"); got != "Pinned abcdef12 (plugin-social)" { - t.Fatalf("previewPinSummary(pin) = %q", got) - } - if got := previewPinSummary(false, "abcdef1234", "plugin-social"); got != "Unpinned abcdef12 (plugin-social)" { - t.Fatalf("previewPinSummary(unpin) = %q", got) - } -} -``` - -- [ ] **Step 2: Run preview tests and verify they fail** - -Run: `go test ./cmd/grounds/commands/preview` - -Expected: FAIL because `previewPinSummary` does not exist. - -- [ ] **Step 3: Add preview pin summary helper** - -Add to `cmd/grounds/commands/preview/preview.go`: - -```go -func previewPinSummary(pin bool, id, manifestName string) string { - verb := "Pinned" - if !pin { - verb = "Unpinned" - } - return fmt.Sprintf("%s %s (%s)", verb, shortID(id), manifestName) -} -``` - -- [ ] **Step 4: Update preview empty state** - -Replace: - -```go -fmt.Fprintln(cmd.OutOrStdout(), "no preview environments") -``` - -with: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Preview", "No preview environments found") -render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Run "+render.Command("grounds push --target=staging")+" to create one.") -``` - -- [ ] **Step 5: Update preview show human output** - -Replace the current `fmt.Fprintf` block with: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Preview", p.Push.ManifestName+" ("+p.Push.Status+")") -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "ID: "+p.ID) -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Push: "+p.PushID) -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Namespace: "+p.Namespace) -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Type: "+p.Push.ManifestType) -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, fmt.Sprintf("Pinned: %t", p.Pinned)) -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Expires: "+formatTime(p.ExpiresAt)) -render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+p.PublicURL) -``` - -- [ ] **Step 6: Update preview pin/unpin output** - -Replace: - -```go -fmt.Fprintf(cmd.OutOrStdout(), "%s %s (%s)\n", verb, shortID(p.ID), p.Push.ManifestName) -``` - -with: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Preview", previewPinSummary(pin, p.ID, p.Push.ManifestName)) -``` - -- [ ] **Step 7: Run tests** - -Run: `go test ./cmd/grounds/commands/preview` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add cmd/grounds/commands/preview/preview.go cmd/grounds/commands/preview/preview_test.go -git commit -m "style: improve preview command output" -``` - ---- - -### Task 6: DevSpace And Bundle Output - -**Files:** -- Modify: `cmd/grounds/commands/devspace/generate.go` -- Modify: `cmd/grounds/commands/devspace/generate_test.go` -- Modify: `cmd/grounds/commands/bundle/list.go` -- Modify: `cmd/grounds/commands/bundle/show.go` - -- [ ] **Step 1: Add DevSpace success summary helper test** - -Add to `cmd/grounds/commands/devspace/generate_test.go`: - -```go -func TestGenerateSuccessSummary(t *testing.T) { - if got := generateSuccessSummary("./devspace.yaml"); got != "Wrote ./devspace.yaml" { - t.Fatalf("generateSuccessSummary = %q", got) - } -} -``` - -- [ ] **Step 2: Run DevSpace tests and verify they fail** - -Run: `go test ./cmd/grounds/commands/devspace` - -Expected: FAIL because `generateSuccessSummary` does not exist. - -- [ ] **Step 3: Add DevSpace summary helper and update output** - -Add to `cmd/grounds/commands/devspace/generate.go`: - -```go -func generateSuccessSummary(outputPath string) string { - return "Wrote " + outputPath -} -``` - -Replace: - -```go -fmt.Fprintf(cmd.OutOrStdout(), "✔ Wrote %d bytes to %s\n", len(yaml), outputPath) -``` - -with: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "DevSpace", generateSuccessSummary(outputPath)) -``` - -Add import: - -```go -"github.com/groundsgg/grounds-cli/internal/render" -``` - -- [ ] **Step 4: Update bundle empty state** - -Modify `cmd/grounds/commands/bundle/list.go`: - -```go -render.StatusLine(cmd.OutOrStdout(), render.StatusWarn, "Bundle", "No released bundles found") -render.DetailLine(cmd.OutOrStdout(), render.StatusWarn, "Try "+render.Command("grounds bundle show main")+" to inspect the current bundle.") -return nil -``` - -Add import: - -```go -"github.com/groundsgg/grounds-cli/internal/render" -``` - -- [ ] **Step 5: Update bundle command references** - -Modify `cmd/grounds/commands/bundle/list.go` long text so command references use backticks: - -```go -the same one `grounds cluster up --bundle main` would track today. -``` - -Modify `cmd/grounds/commands/bundle/show.go` long text: - -```go -component table. accepts the same shapes as `grounds cluster up --bundle`: -``` - -- [ ] **Step 6: Run tests** - -Run: `go test ./cmd/grounds/commands/devspace ./cmd/grounds/commands/bundle` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add cmd/grounds/commands/devspace/generate.go cmd/grounds/commands/devspace/generate_test.go cmd/grounds/commands/bundle/list.go cmd/grounds/commands/bundle/show.go -git commit -m "style: improve devspace and bundle messages" -``` - ---- - -### Task 7: Help Text And Examples - -**Files:** -- Modify: `cmd/grounds/commands/root.go` -- Modify: `cmd/grounds/commands/version.go` -- Modify: `cmd/grounds/commands/push/push.go` -- Modify: `cmd/grounds/commands/cluster/cluster.go` -- Modify: `cmd/grounds/commands/cluster/status.go` -- Modify: `cmd/grounds/commands/cluster/up.go` -- Modify: `cmd/grounds/commands/preview/preview.go` -- Modify: `cmd/grounds/commands/devspace/devspace.go` -- Modify: `cmd/grounds/commands/bundle/bundle.go` -- Modify: `cmd/grounds/commands/logs/logs.go` - -- [ ] **Step 1: Update root and version help** - -Modify `cmd/grounds/commands/root.go`: - -```go -Short: "Grounds developer platform CLI", -Long: "Build, deploy, inspect, and troubleshoot Grounds projects from the terminal.", -``` - -Modify `cmd/grounds/commands/version.go`: - -```go -Short: "Print version information and check for updates", -Example: " grounds version\n grounds version --check", -``` - -- [ ] **Step 2: Add push examples** - -Modify `cmd/grounds/commands/push/push.go` root and leaf command examples: - -```go -cmd := &cobra.Command{ - Use: "push", - Short: "Build and deploy the current project", - Example: " grounds push\n grounds push --target=staging\n grounds push list --mine", -} -``` - -For `newPush()`: - -```go -Example: " grounds push\n grounds push --target=staging", -``` - -- [ ] **Step 3: Add cluster examples** - -Add examples to cluster commands: - -```go -Example: " grounds cluster status\n grounds cluster up\n grounds cluster down\n grounds cluster delete", -``` - -For `cluster up`: - -```go -Example: " grounds cluster up\n grounds cluster up --profile=platform\n grounds cluster up --bundle=0.4.0 --override=./overrides/me.yaml", -``` - -- [ ] **Step 4: Add preview examples and improve root short** - -Modify `NewPreviewCommand`: - -```go -Short: "Manage staging preview environments", -Example: " grounds preview list\n grounds preview show \n grounds preview pin \n grounds preview unpin ", -``` - -- [ ] **Step 5: Add devspace, bundle, and logs examples** - -Add examples: - -```go -Example: " grounds devspace generate plugin-social --bundle main\n grounds devspace generate plugin-social --override ./me.yaml", -``` - -```go -Example: " grounds bundle list\n grounds bundle show main\n grounds bundle show 0.4.0", -``` - -```go -Example: " grounds logs\n grounds logs --follow\n grounds logs deployment ", -``` - -- [ ] **Step 6: Run help smoke checks** - -Run: - -```bash -go run ./cmd/grounds --help -go run ./cmd/grounds version --help -go run ./cmd/grounds push --help -go run ./cmd/grounds cluster up --help -go run ./cmd/grounds preview --help -``` - -Expected: each command prints help without errors, examples are visible, and old implementation-leaky wording such as `target=staging deploys` is gone. - -- [ ] **Step 7: Run tests** - -Run: `go test ./cmd/grounds/...` - -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add cmd/grounds/commands/root.go cmd/grounds/commands/version.go cmd/grounds/commands/push/push.go cmd/grounds/commands/cluster/cluster.go cmd/grounds/commands/cluster/status.go cmd/grounds/commands/cluster/up.go cmd/grounds/commands/preview/preview.go cmd/grounds/commands/devspace/devspace.go cmd/grounds/commands/bundle/bundle.go cmd/grounds/commands/logs/logs.go -git commit -m "docs: improve cli command help" -``` - ---- - -### Task 8: Output Format Contract - -**Files:** -- Modify: `cmd/grounds/commands/root.go` -- Modify: command docs/help where needed - -- [ ] **Step 1: Decide and encode the contract in root help** - -Keep the global `--output` flag, but clarify that structured output is for data commands. - -Modify `cmd/grounds/commands/root.go`: - -```go -cmd.PersistentFlags().String("output", "table", "output format for data commands: table | json | yaml") -``` - -- [ ] **Step 2: Audit commands for accidental `--output` confusion** - -Run: - -```bash -rg -n '"output"|GetString\\("output"\\)|BoolVar.*json|render\\.(JSON|YAML|Table)' cmd/grounds internal/render -``` - -Expected: identify data commands that already render structured output and action commands that only print human status messages. - -- [ ] **Step 3: Add a root help regression test** - -Add to `cmd/grounds/commands/root_test.go`: - -```go -func TestRootOutputFlagMentionsDataCommands(t *testing.T) { - root := NewRootCommand() - flag := root.PersistentFlags().Lookup("output") - if flag == nil { - t.Fatal("missing output flag") - } - if got := flag.Usage; got != "output format for data commands: table | json | yaml" { - t.Fatalf("output flag usage = %q", got) - } -} -``` - -- [ ] **Step 4: Run root tests** - -Run: `go test ./cmd/grounds/commands -run TestRoot` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add cmd/grounds/commands/root.go cmd/grounds/commands/root_test.go -git commit -m "docs: clarify output flag scope" -``` - ---- - -### Task 9: Final Verification - -**Files:** -- No new edits expected. - -- [ ] **Step 1: Run full Go tests** - -Run: `go test ./...` - -Expected: PASS. - -- [ ] **Step 2: Run build** - -Run: `go build ./cmd/grounds` - -Expected: PASS and a local `grounds` binary is produced. - -- [ ] **Step 3: Run manual UX smoke checks** - -Run: - -```bash -./grounds --help -./grounds version -./grounds version --check -./grounds doctor -./grounds init --help -./grounds push --help -./grounds cluster status --help -./grounds preview --help -./grounds bundle --help -./grounds devspace --help -``` - -Expected: -- Help text is concise and example-driven. -- Human output uses `[✓]`, `[!]`, or `[✗]`. -- Command suggestions use backticks. -- No output uses `→`. -- No successful one-line command adds unnecessary detail lines. - -- [ ] **Step 4: Scan for old UX patterns** - -Run: - -```bash -rg -n '→|✔|⚠|'"'"'grounds [^'"'"']+'"'"'' cmd/grounds internal/render -``` - -Expected: no remaining old arrow/check/warning symbols in user-facing command output. Single-quoted command references should be gone from help and errors. - -- [ ] **Step 5: Commit verification-only fixes if needed** - -If Step 4 finds old user-facing copy, update the affected file, rerun: - -```bash -go test ./... -go build ./cmd/grounds -``` - -Then commit: - -```bash -git add cmd internal -git commit -m "style: polish remaining cli output" -``` - ---- - -## Self-Review - -**Spec coverage:** The plan covers shared formatting, action command output, empty states, help examples, `--output` clarity, and final scan-based verification. - -**Placeholder scan:** The plan does not use unresolved TODO/TBD language. Each task includes concrete files, snippets, commands, and expected results. - -**Type consistency:** The shared helpers are named `StatusKind`, `StatusOK`, `StatusWarn`, `StatusError`, `StatusBadge`, `DetailIcon`, `StatusLine`, `DetailLine`, and `Command`, and all later tasks use those exact names. From 759132803bad27036b8ca095170b9e29dba9068c Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:37:41 +0200 Subject: [PATCH 15/19] fix: avoid duplicate cluster down status --- cmd/grounds/commands/cluster/down.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/grounds/commands/cluster/down.go b/cmd/grounds/commands/cluster/down.go index 69ca135..f49621b 100644 --- a/cmd/grounds/commands/cluster/down.go +++ b/cmd/grounds/commands/cluster/down.go @@ -22,7 +22,6 @@ func newDown() *cobra.Command { if err != nil { return err } - render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Workspace", "Paused") render.Status(cmd.OutOrStdout(), s) return nil }, From 8a95721e078e5ef5b560ec95ab9b3c2ab80e9c1f Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:39:46 +0200 Subject: [PATCH 16/19] fix: report browser login fallback accurately --- cmd/grounds/commands/login.go | 17 ++++++++--- cmd/grounds/commands/login_test.go | 45 +++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/cmd/grounds/commands/login.go b/cmd/grounds/commands/login.go index 721c4b7..a684641 100644 --- a/cmd/grounds/commands/login.go +++ b/cmd/grounds/commands/login.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "io" "net/http" "strings" "time" @@ -41,10 +42,7 @@ func NewLoginCommand() *cobra.Command { return err } - render.StatusLine(cmd.OutOrStdout(), render.StatusOK, "Browser", "Device login page ready") - render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "URL: "+dc.VerificationURI) - render.DetailLine(cmd.OutOrStdout(), render.StatusOK, "Code: "+dc.UserCode) - _ = browser.OpenURL(dc.VerificationURIComplete) + printDeviceLoginInstructions(cmd.OutOrStdout(), dc, browser.OpenURL(dc.VerificationURIComplete)) tok, err := device.PollToken(ctx, dc.DeviceCode, dc.CodeVerifier, dc.Interval, dc.ExpiresIn) if err != nil { @@ -66,6 +64,17 @@ func NewLoginCommand() *cobra.Command { } } +func printDeviceLoginInstructions(out io.Writer, dc *auth.DeviceCodeResponse, openErr error) { + if openErr != nil { + render.StatusLine(out, render.StatusWarn, "Browser", "Could not open device login page automatically") + render.DetailLine(out, render.StatusWarn, "URL: "+dc.VerificationURI) + render.DetailLine(out, render.StatusWarn, "Code: "+dc.UserCode) + return + } + render.StatusLine(out, render.StatusOK, "Browser", "Opened device login page") + render.DetailLine(out, render.StatusOK, "Code: "+dc.UserCode) +} + func loginSubject(preferred, email string) string { if preferred != "" { return preferred diff --git a/cmd/grounds/commands/login_test.go b/cmd/grounds/commands/login_test.go index e78cf57..c9cea07 100644 --- a/cmd/grounds/commands/login_test.go +++ b/cmd/grounds/commands/login_test.go @@ -1,6 +1,14 @@ package commands -import "testing" +import ( + "bytes" + "errors" + "testing" + + "github.com/fatih/color" + + "github.com/groundsgg/grounds-cli/internal/auth" +) func TestLoginSubject(t *testing.T) { tests := []struct { @@ -34,3 +42,38 @@ func TestLoginSubject(t *testing.T) { }) } } + +func TestPrintDeviceLoginInstructionsOpenedBrowser(t *testing.T) { + color.NoColor = true + t.Cleanup(func() { color.NoColor = false }) + + var buf bytes.Buffer + printDeviceLoginInstructions(&buf, &auth.DeviceCodeResponse{ + UserCode: "ABCD-EFGH", + VerificationURI: "https://example.test/device", + }, nil) + + want := "[✓] Browser - Opened device login page\n" + + " • Code: ABCD-EFGH\n" + if got := buf.String(); got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func TestPrintDeviceLoginInstructionsBrowserOpenFailed(t *testing.T) { + color.NoColor = true + t.Cleanup(func() { color.NoColor = false }) + + var buf bytes.Buffer + printDeviceLoginInstructions(&buf, &auth.DeviceCodeResponse{ + UserCode: "ABCD-EFGH", + VerificationURI: "https://example.test/device", + }, errors.New("no opener")) + + want := "[!] Browser - Could not open device login page automatically\n" + + " ! URL: https://example.test/device\n" + + " ! Code: ABCD-EFGH\n" + if got := buf.String(); got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} From 48a6ab1bbd30761ed465e53685182189c9de5f08 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:40:49 +0200 Subject: [PATCH 17/19] fix: remove unused global output flag --- cmd/grounds/commands/root.go | 1 - cmd/grounds/commands/root_test.go | 10 +++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/cmd/grounds/commands/root.go b/cmd/grounds/commands/root.go index 20a8b59..651a3da 100644 --- a/cmd/grounds/commands/root.go +++ b/cmd/grounds/commands/root.go @@ -26,7 +26,6 @@ func NewRootCommand() *cobra.Command { }, } cmd.PersistentFlags().String("api-url", "", "override API endpoint (also GROUNDS_API_URL)") - cmd.PersistentFlags().String("output", "table", "output format for data commands: table | json | yaml") cmd.PersistentFlags().BoolP("verbose", "v", false, "debug logging to stderr") cmd.PersistentFlags().Bool("no-color", false, "disable colour output") cmd.PersistentFlags().String("config", "", "alternative config directory (also GROUNDS_CONFIG_DIR)") diff --git a/cmd/grounds/commands/root_test.go b/cmd/grounds/commands/root_test.go index b431161..82f9085 100644 --- a/cmd/grounds/commands/root_test.go +++ b/cmd/grounds/commands/root_test.go @@ -26,13 +26,9 @@ func TestRootCommandAppliesNoColorFlag(t *testing.T) { } } -func TestRootOutputFlagMentionsDataCommands(t *testing.T) { +func TestRootCommandDoesNotAdvertiseUnusedOutputFlag(t *testing.T) { root := NewRootCommand() - flag := root.PersistentFlags().Lookup("output") - if flag == nil { - t.Fatal("missing output flag") - } - if got := flag.Usage; got != "output format for data commands: table | json | yaml" { - t.Fatalf("output flag usage = %q", got) + if flag := root.PersistentFlags().Lookup("output"); flag != nil { + t.Fatalf("unexpected unused output flag: %q", flag.Usage) } } From d52cee278bd88873cd4d76758353dd40b8756d86 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:43:13 +0200 Subject: [PATCH 18/19] test: stabilize logout color output --- cmd/grounds/commands/logout_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/grounds/commands/logout_test.go b/cmd/grounds/commands/logout_test.go index 31bc973..a1d988f 100644 --- a/cmd/grounds/commands/logout_test.go +++ b/cmd/grounds/commands/logout_test.go @@ -3,9 +3,15 @@ package commands import ( "bytes" "testing" + + "github.com/fatih/color" ) func TestLogoutOutput(t *testing.T) { + previous := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = previous }) + t.Setenv("GROUNDS_CONFIG_DIR", t.TempDir()) cmd := NewLogoutCommand() From 13dc41b809ec3342402f80c9589794fbce688660 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Mon, 4 May 2026 22:48:02 +0200 Subject: [PATCH 19/19] feat: complete push target values --- cmd/grounds/commands/push/push.go | 3 +++ cmd/grounds/commands/push/push_test.go | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/cmd/grounds/commands/push/push.go b/cmd/grounds/commands/push/push.go index c2b54e5..9d31ba3 100644 --- a/cmd/grounds/commands/push/push.go +++ b/cmd/grounds/commands/push/push.go @@ -74,6 +74,9 @@ Targets: }, } cmd.Flags().StringVar(&target, "target", "dev", "deploy target: dev (persistent personal ns) or staging (ephemeral preview env, 7d TTL)") + _ = cmd.RegisterFlagCompletionFunc("target", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return []string{"dev", "staging"}, cobra.ShellCompDirectiveNoFileComp + }) return cmd } diff --git a/cmd/grounds/commands/push/push_test.go b/cmd/grounds/commands/push/push_test.go index eae1cc8..79e985e 100644 --- a/cmd/grounds/commands/push/push_test.go +++ b/cmd/grounds/commands/push/push_test.go @@ -4,10 +4,12 @@ import ( "bytes" "errors" "os" + "reflect" "strings" "testing" "github.com/fatih/color" + "github.com/spf13/cobra" "github.com/groundsgg/grounds-cli/internal/api" ) @@ -48,6 +50,23 @@ func TestPushDefaultTargetIsDev(t *testing.T) { } } +func TestPushTargetCompletion(t *testing.T) { + cmd := newPush() + completion, ok := cmd.GetFlagCompletionFunc("target") + if !ok { + t.Fatal("expected --target completion function") + } + + got, directive := completion(cmd, nil, "") + want := []string{"dev", "staging"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("target completions = %v, want %v", got, want) + } + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("completion directive = %v, want %v", directive, cobra.ShellCompDirectiveNoFileComp) + } +} + func TestPushRootOwnsDeployFlagsAndSubcommands(t *testing.T) { cmd := NewPushCommand()