Skip to content

Commit 79a2022

Browse files
committed
feat: implement the rest of lists using OutputTable
Furthermore, removed the PrintText option in ListCmd to avoid inconsistencies. Also updated AGENTS.md with up to date examples.
1 parent 17edf2f commit 79a2022

14 files changed

Lines changed: 129 additions & 72 deletions

AGENTS.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,7 @@ Each subcommand group lives in `internal/cmd/<group>/`:
9494
All leaf commands are built using one of five generic base types. Always prefer these over raw `cobra.Command`.
9595

9696
#### `base.ListCmd[T]`
97-
For listing resources. No args, no flags needed (extend via `BaseCobraCommand` if flags are required).
98-
99-
Prefer `OutputTable` over `PrintText` for table output. When `OutputTable` is set, the base automatically registers `--no-headers` and handles header suppression. `PrintText` is used as a fallback when `OutputTable` is not set.
97+
For listing resources. `OutputTable` must be set. The base automatically registers `--no-headers` and handles header suppression. By default the command takes no positional args; set `Args` to accept them.
10098

10199
```go
102100
base.ListCmd[*foov1.ListFoosResponse]{
@@ -105,11 +103,11 @@ base.ListCmd[*foov1.ListFoosResponse]{
105103
Fetch: func(s *state.State, cmd *cobra.Command) (*foov1.ListFoosResponse, error) {
106104
// call gRPC, return response
107105
},
108-
OutputTable: func(_ *cobra.Command, w io.Writer, resp *foov1.ListFoosResponse) output.Renderable {
106+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *foov1.ListFoosResponse) (output.TableRenderer, error) {
109107
t := output.NewTable[*foov1.Foo](w)
110108
t.AddField("ID", func(v *foov1.Foo) string { return v.GetId() })
111-
t.SetItems(resp.Items)
112-
return t
109+
t.SetItems(resp.GetItems())
110+
return t, nil
113111
},
114112
}.CobraCommand(s)
115113
```

internal/cmd/account/list.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ qcloud account list --json`,
3434

3535
return client.Account().ListAccounts(ctx, &accountv1.ListAccountsRequest{})
3636
},
37-
PrintText: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountsResponse) error {
37+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountsResponse) (output.TableRenderer, error) {
3838
t := output.NewTable[*accountv1.Account](w)
3939
t.AddField("ID", func(v *accountv1.Account) string { return v.GetId() })
4040
t.AddField("NAME", func(v *accountv1.Account) string { return v.GetName() })
@@ -45,8 +45,8 @@ qcloud account list --json`,
4545
}
4646
return ""
4747
})
48-
t.Write(resp.GetItems())
49-
return nil
48+
t.SetItems(resp.GetItems())
49+
return t, nil
5050
},
5151
}.CobraCommand(s)
5252
}

internal/cmd/account/list_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,19 @@ func TestAccountList_Empty(t *testing.T) {
8484
assert.Contains(t, stdout, "ID")
8585
assert.Contains(t, stdout, "NAME")
8686
}
87+
88+
func TestAccountList_NoHeaders(t *testing.T) {
89+
env := testutil.NewTestEnv(t)
90+
91+
env.AccountServer.ListAccountsCalls.Returns(&accountv1.ListAccountsResponse{
92+
Items: []*accountv1.Account{
93+
{Id: "acct-001", Name: "Production"},
94+
},
95+
}, nil)
96+
97+
stdout, _, err := testutil.Exec(t, env, "account", "list", "--no-headers")
98+
require.NoError(t, err)
99+
assert.NotContains(t, stdout, "ID")
100+
assert.NotContains(t, stdout, "NAME")
101+
assert.Contains(t, stdout, "acct-001")
102+
}

internal/cmd/account/member_list.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ qcloud account member list --json`,
4848

4949
return resp, nil
5050
},
51-
PrintText: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountMembersResponse) error {
51+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountMembersResponse) (output.TableRenderer, error) {
5252
t := output.NewTable[*accountv1.AccountMember](w)
5353
t.AddField("ID", func(v *accountv1.AccountMember) string {
5454
return v.GetAccountMember().GetId()
@@ -65,8 +65,8 @@ qcloud account member list --json`,
6565
}
6666
return ""
6767
})
68-
t.Write(resp.GetItems())
69-
return nil
68+
t.SetItems(resp.GetItems())
69+
return t, nil
7070
},
7171
}.CobraCommand(s)
7272
}

internal/cmd/account/member_list_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,19 @@ func TestMemberList_Empty(t *testing.T) {
106106
assert.Contains(t, stdout, "ID")
107107
assert.Contains(t, stdout, "EMAIL")
108108
}
109+
110+
func TestMemberList_NoHeaders(t *testing.T) {
111+
env := testutil.NewTestEnv(t)
112+
113+
env.AccountServer.ListAccountMembersCalls.Returns(&accountv1.ListAccountMembersResponse{
114+
Items: []*accountv1.AccountMember{
115+
{AccountMember: &iamv1.User{Id: "user-001", Email: "[email protected]"}},
116+
},
117+
}, nil)
118+
119+
stdout, _, err := testutil.Exec(t, env, "account", "member", "list", "--no-headers")
120+
require.NoError(t, err)
121+
assert.NotContains(t, stdout, "ID")
122+
assert.NotContains(t, stdout, "EMAIL")
123+
assert.Contains(t, stdout, "user-001")
124+
}

internal/cmd/base/list.go

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,31 @@ import (
1212
// ListCmd defines a command for fetching and displaying a list response.
1313
// T is the full response proto message (e.g. *clusterv1.ListClustersResponse).
1414
//
15-
// At least one of OutputTable or PrintText must be set. OutputTable is
16-
// preferred; when set, --no-headers is automatically registered and handled.
17-
// PrintText is the legacy fallback for commands that have not yet migrated.
15+
// OutputTable must be set. When set, --no-headers is automatically registered
16+
// and handled.
1817
type ListCmd[T any] struct {
1918
Use string
2019
Short string
2120
Long string
2221
Example string
22+
Args cobra.PositionalArgs // optional; defaults to cobra.NoArgs
2323
Fetch func(s *state.State, cmd *cobra.Command) (T, error)
2424
OutputTable func(cmd *cobra.Command, out io.Writer, resp T) (output.TableRenderer, error)
25-
PrintText func(cmd *cobra.Command, out io.Writer, resp T) error
2625
ValidArgsFunction func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective)
2726
}
2827

2928
// CobraCommand builds a cobra.Command from this ListCmd.
3029
func (lc ListCmd[T]) CobraCommand(s *state.State) *cobra.Command {
30+
posArgs := lc.Args
31+
if posArgs == nil {
32+
posArgs = cobra.NoArgs
33+
}
3134
cmd := &cobra.Command{
3235
Use: lc.Use,
3336
Short: lc.Short,
3437
Long: lc.Long,
3538
Example: lc.Example,
36-
Args: cobra.NoArgs,
39+
Args: posArgs,
3740
RunE: func(cmd *cobra.Command, args []string) error {
3841
resp, err := lc.Fetch(s, cmd)
3942
if err != nil {
@@ -42,25 +45,17 @@ func (lc ListCmd[T]) CobraCommand(s *state.State) *cobra.Command {
4245
if s.Config.JSONOutput() {
4346
return output.PrintJSON(cmd.OutOrStdout(), resp)
4447
}
45-
if lc.OutputTable != nil {
46-
r, err := lc.OutputTable(cmd, cmd.OutOrStdout(), resp)
47-
if err != nil {
48-
return err
49-
}
50-
noHeaders, _ := cmd.Flags().GetBool("no-headers")
51-
r.SetNoHeaders(noHeaders)
52-
r.Render()
53-
return nil
54-
}
55-
if lc.PrintText != nil {
56-
return lc.PrintText(cmd, cmd.OutOrStdout(), resp)
48+
r, err := lc.OutputTable(cmd, cmd.OutOrStdout(), resp)
49+
if err != nil {
50+
return err
5751
}
58-
panic("ListCmd: OutputTable or PrintText must be set")
52+
noHeaders, _ := cmd.Flags().GetBool("no-headers")
53+
r.SetNoHeaders(noHeaders)
54+
r.Render()
55+
return nil
5956
},
6057
}
61-
if lc.OutputTable != nil {
62-
cmd.Flags().Bool("no-headers", false, "Do not print column headers")
63-
}
58+
cmd.Flags().Bool("no-headers", false, "Do not print column headers")
6459
if lc.ValidArgsFunction != nil {
6560
cmd.ValidArgsFunction = lc.ValidArgsFunction
6661
}

internal/cmd/base/list_test.go

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package base_test
22

33
import (
44
"bytes"
5-
"fmt"
65
"io"
76
"testing"
87

@@ -49,43 +48,34 @@ func TestListCmd_OutputTable(t *testing.T) {
4948
assert.Contains(t, stdout, "hello")
5049
}
5150

52-
func TestListCmd_OutputTable_NoHeaders(t *testing.T) {
51+
func TestListCmd_WithArgs(t *testing.T) {
5352
lc := base.ListCmd[string]{
54-
Use: "test",
55-
Fetch: fetchHello,
53+
Use: "test <value>",
54+
Args: cobra.ExactArgs(1),
55+
Fetch: func(_ *state.State, cmd *cobra.Command) (string, error) {
56+
return cmd.Flags().Arg(0), nil
57+
},
5658
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) (output.TableRenderer, error) {
5759
return stringTableRenderer(out, resp), nil
5860
},
5961
}
6062

61-
stdout, err := execListCmd(t, lc, "--no-headers")
63+
stdout, err := execListCmd(t, lc, "world")
6264
require.NoError(t, err)
63-
assert.NotContains(t, stdout, "VALUE")
64-
assert.Contains(t, stdout, "hello")
65+
assert.Contains(t, stdout, "world")
6566
}
6667

67-
func TestListCmd_PrintText(t *testing.T) {
68+
func TestListCmd_OutputTable_NoHeaders(t *testing.T) {
6869
lc := base.ListCmd[string]{
6970
Use: "test",
7071
Fetch: fetchHello,
71-
PrintText: func(_ *cobra.Command, out io.Writer, resp string) error {
72-
_, err := fmt.Fprint(out, resp)
73-
return err
72+
OutputTable: func(_ *cobra.Command, out io.Writer, resp string) (output.TableRenderer, error) {
73+
return stringTableRenderer(out, resp), nil
7474
},
7575
}
7676

77-
stdout, err := execListCmd(t, lc)
77+
stdout, err := execListCmd(t, lc, "--no-headers")
7878
require.NoError(t, err)
79-
assert.Equal(t, "hello", stdout)
80-
}
81-
82-
func TestListCmd_NeitherOutputTableNorPrintText_Panics(t *testing.T) {
83-
lc := base.ListCmd[string]{
84-
Use: "test",
85-
Fetch: fetchHello,
86-
}
87-
88-
assert.Panics(t, func() {
89-
_, _ = execListCmd(t, lc)
90-
})
79+
assert.NotContains(t, stdout, "VALUE")
80+
assert.Contains(t, stdout, "hello")
9181
}

internal/cmd/cluster/key_list.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ import (
1616
)
1717

1818
func newKeyListCommand(s *state.State) *cobra.Command {
19-
return base.DescribeCmd[*clusterauthv2.ListDatabaseApiKeysResponse]{
19+
return base.ListCmd[*clusterauthv2.ListDatabaseApiKeysResponse]{
2020
Use: "list <cluster-id>",
2121
Short: "List API keys for a cluster",
2222
Example: `# List API keys for a cluster
2323
qcloud cluster key list 7b2ea926-724b-4de2-b73a-8675c42a6ebe`,
2424
Args: util.ExactArgs(1, "a cluster ID"),
25-
Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*clusterauthv2.ListDatabaseApiKeysResponse, error) {
26-
clusterID := args[0]
25+
Fetch: func(s *state.State, cmd *cobra.Command) (*clusterauthv2.ListDatabaseApiKeysResponse, error) {
26+
clusterID := cmd.Flags().Arg(0)
2727

2828
ctx := cmd.Context()
2929
client, err := s.Client(ctx)
@@ -46,7 +46,7 @@ qcloud cluster key list 7b2ea926-724b-4de2-b73a-8675c42a6ebe`,
4646

4747
return resp, nil
4848
},
49-
PrintText: func(_ *cobra.Command, w io.Writer, resp *clusterauthv2.ListDatabaseApiKeysResponse) error {
49+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *clusterauthv2.ListDatabaseApiKeysResponse) (output.TableRenderer, error) {
5050
t := output.NewTable[*clusterauthv2.DatabaseApiKey](w)
5151
t.AddField("ID", func(v *clusterauthv2.DatabaseApiKey) string {
5252
return v.GetId()
@@ -69,8 +69,8 @@ qcloud cluster key list 7b2ea926-724b-4de2-b73a-8675c42a6ebe`,
6969
}
7070
return ""
7171
})
72-
t.Write(resp.GetItems())
73-
return nil
72+
t.SetItems(resp.GetItems())
73+
return t, nil
7474
},
7575
ValidArgsFunction: completion.ClusterIDCompletion(s),
7676
}.CobraCommand(s)

internal/cmd/iam/permission_list.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,16 @@ qcloud iam permission list --json`,
4242
AccountId: accountID,
4343
})
4444
},
45-
PrintText: func(_ *cobra.Command, w io.Writer, resp *iamv1.ListPermissionsResponse) error {
45+
OutputTable: func(_ *cobra.Command, w io.Writer, resp *iamv1.ListPermissionsResponse) (output.TableRenderer, error) {
4646
t := output.NewTable[*iamv1.Permission](w)
4747
t.AddField("PERMISSION", func(v *iamv1.Permission) string {
4848
return v.GetValue()
4949
})
5050
t.AddField("CATEGORY", func(v *iamv1.Permission) string {
5151
return v.GetCategory()
5252
})
53-
t.Write(resp.GetPermissions())
54-
return nil
53+
t.SetItems(resp.GetPermissions())
54+
return t, nil
5555
},
5656
}.CobraCommand(s)
5757
}

internal/cmd/iam/permission_list_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,17 @@ func TestPermissionList_BackendError(t *testing.T) {
6969
_, _, err := testutil.Exec(t, env, "iam", "permission", "list")
7070
require.Error(t, err)
7171
}
72+
73+
func TestPermissionList_NoHeaders(t *testing.T) {
74+
env := testutil.NewTestEnv(t)
75+
76+
env.IAMServer.ListPermissionsCalls.Returns(&iamv1.ListPermissionsResponse{
77+
Permissions: []*iamv1.Permission{{Value: "read:clusters", Category: new("Cluster")}},
78+
}, nil)
79+
80+
stdout, _, err := testutil.Exec(t, env, "iam", "permission", "list", "--no-headers")
81+
require.NoError(t, err)
82+
assert.NotContains(t, stdout, "PERMISSION")
83+
assert.NotContains(t, stdout, "CATEGORY")
84+
assert.Contains(t, stdout, "read:clusters")
85+
}

0 commit comments

Comments
 (0)