From c23107079d412060059c65808af034decafd7fce Mon Sep 17 00:00:00 2001 From: ysyneu Date: Mon, 15 Jun 2026 17:55:29 +0800 Subject: [PATCH] feat(cli): drop pure-rename curated shadows (insight team/channel/responder, member list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues the generated-as-single-source convergence (after the table renderer and relative-time keystones). Removes the curated commands whose only difference from their generated twin is flag spelling — a clean rename, now that generated --start-time/--end-time accept relative time: - insight team/channel/responder: curated --since/--until → generated --start-time/--end-time (relative-time capable) PLUS the generated twin exposes severities, *_ids, fields, aggregate-unit, … (net gain). - member list: curated --name/--email → generated --query (the curated command already folded both into the SDK Query field; generated also keeps --role-id/--orderby/--asc and adds the rest of the member API). To preserve the curated human tables, the generic renderer gains an optional per-column formatter (colSpec.Format); DimensionInsightItem and ResponderInsightItem seeds render ACK%/MTTA/MTTR with the same percent/ duration formatting the curated tables used. json/toon output unchanged. insight top-alerts and incidents are KEPT curated for now — their generated twins differ by more than flag spelling (--limit→--k, offset→cursor pagination), so they're not pure renames. e2e: member filter tests migrated to --query. Doc/scan.sh references in fc-safari migrate in a paired PR; the dropped commands only disappear for agents once a new CLI is released into the sandbox, so the two land together. --- e2e/resource_filter_test.go | 59 +++++---- e2e/resource_list_test.go | 4 +- internal/cli/display_columns.go | 57 ++++++++- internal/cli/generic_table.go | 47 ++++++- internal/cli/generic_table_test.go | 25 ++++ internal/cli/insight.go | 193 +---------------------------- internal/cli/member.go | 98 --------------- internal/cli/root.go | 13 +- 8 files changed, 165 insertions(+), 331 deletions(-) delete mode 100644 internal/cli/member.go diff --git a/e2e/resource_filter_test.go b/e2e/resource_filter_test.go index 46a528c..cbbe195 100644 --- a/e2e/resource_filter_test.go +++ b/e2e/resource_filter_test.go @@ -4,6 +4,7 @@ package e2e_test import ( "encoding/json" + "fmt" "strings" "testing" ) @@ -81,7 +82,19 @@ func TestChannelListNoTrunc(t *testing.T) { // Member filters // --------------------------------------------------------------------------- -// Test 132: member list --name +// memberByID reports whether any row has the given member_id. +func memberByID(items []map[string]any, id string) bool { + for _, item := range items { + if fmt.Sprintf("%v", item["member_id"]) == id { + return true + } + } + return false +} + +// Test 132: member list --query by name. The generated --query matches name OR +// email server-side, so we assert the seed member is found (not that every row +// matches by name — a result may match via email). func TestMemberListNameFilter(t *testing.T) { r := runCLI(t, "member", "list", "--json") requireSuccess(t, r) @@ -90,13 +103,16 @@ func TestMemberListNameFilter(t *testing.T) { t.Skip("no members available") } + seedID := fmt.Sprintf("%v", members[0]["member_id"]) filter := mustStringField(t, members[0], "member_name") - r = runCLI(t, "member", "list", "--name", filter, "--json") + r = runCLI(t, "member", "list", "--query", filter, "--json") requireSuccess(t, r) - requireAllMatchSubstring(t, decodeObjectList(t, r.Stdout), "member_name", filter) + if !memberByID(decodeObjectList(t, r.Stdout), seedID) { + t.Fatalf("--query %q did not return seed member %s", filter, seedID) + } } -// Test 133: member list --email +// Test 133: member list --query by email. func TestMemberListEmailFilter(t *testing.T) { r := runCLI(t, "member", "list", "--json") requireSuccess(t, r) @@ -105,37 +121,20 @@ func TestMemberListEmailFilter(t *testing.T) { t.Skip("no members available") } + seedID := fmt.Sprintf("%v", members[0]["member_id"]) filter := mustStringField(t, members[0], "email") - r = runCLI(t, "member", "list", "--email", filter, "--json") - requireSuccess(t, r) - requireAllMatchSubstring(t, decodeObjectList(t, r.Stdout), "email", filter) -} - -// Test 134: member list --name + --email combined -func TestMemberListNameAndEmailFilter(t *testing.T) { - r := runCLI(t, "member", "list", "--json") + r = runCLI(t, "member", "list", "--query", filter, "--json") requireSuccess(t, r) - members := decodeObjectList(t, r.Stdout) - if len(members) == 0 { - t.Skip("no members available") + if !memberByID(decodeObjectList(t, r.Stdout), seedID) { + t.Fatalf("--query %q did not return seed member %s", filter, seedID) } +} - nameFilter := mustStringField(t, members[0], "member_name") - emailFilter := mustStringField(t, members[0], "email") - r = runCLI(t, "member", "list", "--name", nameFilter, "--email", emailFilter, "--json") +// Test 134: member list --query returns valid JSON for an arbitrary term. +func TestMemberListQueryReturnsJSON(t *testing.T) { + r := runCLI(t, "member", "list", "--query", "a", "--json") requireSuccess(t, r) - results := decodeObjectList(t, r.Stdout) - if len(results) == 0 { - t.Fatalf("expected at least one result for name=%q email=%q", nameFilter, emailFilter) - } - for _, item := range results { - if !strings.Contains(mustStringField(t, item, "member_name"), nameFilter) { - t.Fatalf("member_name did not match combined filter %q", nameFilter) - } - if !strings.Contains(mustStringField(t, item, "email"), emailFilter) { - t.Fatalf("email did not match combined filter %q", emailFilter) - } - } + requireValidJSON(t, r.Stdout) } // Test 135: member list --page 1 diff --git a/e2e/resource_list_test.go b/e2e/resource_list_test.go index 803cc32..ecda3db 100644 --- a/e2e/resource_list_test.go +++ b/e2e/resource_list_test.go @@ -50,9 +50,9 @@ func TestMemberListJSON(t *testing.T) { // Test 139: member list empty results func TestMemberListNoResults(t *testing.T) { - r := runCLI(t, "member", "list", "--name", "nonexistent_xyz_999", "--email", "nonexistent_xyz") + r := runCLI(t, "member", "list", "--query", "nonexistent_xyz_999") requireSuccess(t, r) - requireContains(t, r.Stdout, "No members found.") + requireContains(t, r.Stdout, "No results.") } // --------------------------------------------------------------------------- diff --git a/internal/cli/display_columns.go b/internal/cli/display_columns.go index 6602926..f270ee9 100644 --- a/internal/cli/display_columns.go +++ b/internal/cli/display_columns.go @@ -1,14 +1,47 @@ package cli +import ( + "fmt" + + "github.com/flashcatcloud/flashduty-cli/internal/output" +) + // colSpec is a display-only column for the generic table renderer: which row // field to show, its header, and an optional width cap. It NEVER affects flags or // json/toon output — a wrong entry degrades a single table column at worst, it // can't cause a functional error. Field is the Go struct field name on the row -// type; timestamp fields are detected and formatted automatically. +// type; timestamp fields are detected and formatted automatically. Format, when +// set, renders the raw field value with a semantic formatter (percent, duration) +// the default scalar rendering doesn't apply — display-only, like the rest. type colSpec struct { Header string Field string MaxWidth int + Format func(any) string +} + +// fmtPercent renders a 0..1 ratio as a whole-number percentage ("85%"), matching +// the curated insight tables. A non-float value yields "". +func fmtPercent(v any) string { + if f, ok := v.(float64); ok { + return fmt.Sprintf("%.0f%%", f*100) + } + return "" +} + +// fmtSecondsDuration renders a seconds count (int or float) as a human duration +// ("2m 30s"), matching the curated insight MTTA/MTTR/engaged columns. +func fmtSecondsDuration(v any) string { + switch n := v.(type) { + case float64: + return output.FormatDurationFloat(n) + case int64: + return output.FormatDuration(int(n)) + case int: + return output.FormatDuration(n) + default: + return "" + } } // displayColumns maps a go-flashduty response row type (by Go type name) to its @@ -106,4 +139,26 @@ var displayColumns = map[string][]colSpec{ {Header: "EMAIL", Field: "Email"}, {Header: "STATUS", Field: "Status"}, }, + // DimensionInsightItem backs both `insight team` and `insight channel` (same + // Go type, different populated name field) — show both name columns, the + // irrelevant one renders empty. Columns mirror the curated insight tables. + "DimensionInsightItem": { + {Header: "TEAM", Field: "TeamName", MaxWidth: 30}, + {Header: "CHANNEL", Field: "ChannelName", MaxWidth: 30}, + {Header: "INCIDENTS", Field: "TotalIncidentCnt"}, + {Header: "ACK%", Field: "AcknowledgementPct", Format: fmtPercent}, + {Header: "MTTA", Field: "MeanSecondsToAck", Format: fmtSecondsDuration}, + {Header: "MTTR", Field: "MeanSecondsToClose", Format: fmtSecondsDuration}, + {Header: "NOISE_REDUCTION", Field: "NoiseReductionPct", Format: fmtPercent}, + {Header: "ALERTS", Field: "TotalAlertCnt"}, + {Header: "EVENTS", Field: "TotalAlertEventCnt"}, + }, + "ResponderInsightItem": { + {Header: "RESPONDER", Field: "ResponderName", MaxWidth: 30}, + {Header: "INCIDENTS", Field: "TotalIncidentCnt"}, + {Header: "ACK%", Field: "AcknowledgementPct", Format: fmtPercent}, + {Header: "MTTA", Field: "MeanSecondsToAck", Format: fmtSecondsDuration}, + {Header: "INTERRUPTIONS", Field: "TotalInterruptions"}, + {Header: "ENGAGED", Field: "TotalEngagedSeconds", Format: fmtSecondsDuration}, + }, } diff --git a/internal/cli/generic_table.go b/internal/cli/generic_table.go index 1a2fea8..6b22fd6 100644 --- a/internal/cli/generic_table.go +++ b/internal/cli/generic_table.go @@ -143,10 +143,15 @@ func columnsForType(rowType reflect.Type) []output.Column { if specs, ok := displayColumns[rowType.Name()]; ok { cols := make([]output.Column, 0, len(specs)) for _, s := range specs { + field, format := s.Field, s.Format + fieldFn := func(item any) string { return fieldString(item, field) } + if format != nil { + fieldFn = func(item any) string { return format(fieldValue(item, field)) } + } cols = append(cols, output.Column{ Header: s.Header, MaxWidth: s.MaxWidth, - Field: func(item any) string { return fieldString(item, s.Field) }, + Field: fieldFn, }) } return cols @@ -205,17 +210,31 @@ func renderVertical(ctx *RunContext, v reflect.Value) error { return ctx.Printer.Print(rows, cols) } -// fieldString reads the named Go field from a row item (deref'ing a pointer row) -// and formats it. An absent field yields "" rather than panicking, so a stale -// displayColumns entry degrades a column instead of crashing the command. -func fieldString(item any, goField string) string { +// derefStruct dereferences pointer chains and returns the underlying struct +// reflect.Value. The second return is false when item is nil, not a struct after +// dereferencing, or any pointer in the chain is nil. +func derefStruct(item any) (reflect.Value, bool) { rv := reflect.ValueOf(item) for rv.Kind() == reflect.Pointer { if rv.IsNil() { - return "" + return reflect.Value{}, false } rv = rv.Elem() } + if rv.Kind() != reflect.Struct { + return reflect.Value{}, false + } + return rv, true +} + +// fieldString reads the named Go field from a row item and formats it as a +// string. An absent field yields "" rather than panicking, so a stale +// displayColumns entry degrades a column instead of crashing the command. +func fieldString(item any, goField string) string { + rv, ok := derefStruct(item) + if !ok { + return "" + } fv := rv.FieldByName(goField) if !fv.IsValid() { return "" @@ -223,6 +242,22 @@ func fieldString(item any, goField string) string { return scalarString(fv) } +// fieldValue reads the named Go field's raw value from a row item, returning +// nil when absent. It feeds a colSpec.Format function so a column can apply +// semantic formatting (percent, duration) that the default scalar rendering +// doesn't know about. +func fieldValue(item any, goField string) any { + rv, ok := derefStruct(item) + if !ok { + return nil + } + fv := rv.FieldByName(goField) + if !fv.IsValid() || !fv.CanInterface() { + return nil + } + return fv.Interface() +} + // scalarString formats a scalar (or timestamp) reflect value. Non-scalars yield // "" — the generic table never renders nested objects/arrays. func scalarString(fv reflect.Value) string { diff --git a/internal/cli/generic_table_test.go b/internal/cli/generic_table_test.go index 8379543..eb6262e 100644 --- a/internal/cli/generic_table_test.go +++ b/internal/cli/generic_table_test.go @@ -66,6 +66,29 @@ func TestRenderGenericTable_DisplayColumns(t *testing.T) { } } +func TestRenderGenericTable_FormattedColumns(t *testing.T) { + // insight rows carry ratio/seconds fields the curated tables rendered as + // percent/duration; the colSpec.Format path must apply that, not print the + // raw float. + var buf bytes.Buffer + rows := []flashduty.DimensionInsightItem{ + {TeamName: "sre", TotalIncidentCnt: 4, AcknowledgementPct: 0.85, MeanSecondsToAck: 150}, + } + if err := renderGenericTable(tableCtx(&buf), rows); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + for _, want := range []string{"TEAM", "ACK%", "MTTA", "sre", "85%"} { + if !strings.Contains(got, want) { + t.Errorf("output missing %q\n---\n%s", want, got) + } + } + // The raw ratio must NOT leak — proves Format ran instead of scalarString. + if strings.Contains(got, "0.85") { + t.Errorf("raw ratio leaked; Format not applied\n%s", got) + } +} + func TestRenderGenericTable_Heuristic(t *testing.T) { var buf bytes.Buffer resp := &fakeListResp{Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1} @@ -156,6 +179,8 @@ var displayColumnSamples = []any{ flashduty.FieldItem{}, flashduty.WarRoomItem{}, flashduty.WarRoomPersonItem{}, + flashduty.DimensionInsightItem{}, + flashduty.ResponderInsightItem{}, } // TestDisplayColumns_FieldsResolve guards against typos: every displayColumns diff --git a/internal/cli/insight.go b/internal/cli/insight.go index 492ab08..182c75b 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -15,200 +15,15 @@ func newInsightCmd() *cobra.Command { Use: "insight", Short: "Query insight metrics", } - cmd.AddCommand(newInsightTeamCmd()) - cmd.AddCommand(newInsightChannelCmd()) - cmd.AddCommand(newInsightResponderCmd()) + // insight team/channel/responder are now served by the generated commands + // (richer flag set: severities, *_ids, fields, aggregate-unit, …; relative + // time on --start-time/--end-time). Their human tables are preserved via the + // DimensionInsightItem / ResponderInsightItem entries in display_columns.go. cmd.AddCommand(newInsightTopAlertsCmd()) cmd.AddCommand(newInsightIncidentsCmd()) return cmd } -func newInsightTeamCmd() *cobra.Command { - var since, until string - - cmd := &cobra.Command{ - Use: "team", - Short: "Query insights by team", - Long: curatedLong("Query incident response insight metrics aggregated by team over a time window.", "Analytics", "ByTeam"), - RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) - } - - result, _, err := ctx.Client.Analytics.ByTeam(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ - StartTime: startTime, - EndTime: endTime, - }) - if err != nil { - return err - } - - cols := []output.Column{ - {Header: "TEAM", MaxWidth: 30, Field: func(v any) string { - return v.(flashduty.DimensionInsightItem).TeamName - }}, - {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalIncidentCnt) - }}, - {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).AcknowledgementPct*100) - }}, - {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToAck) - }}, - {Header: "MTTR", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToClose) - }}, - {Header: "NOISE_REDUCTION", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).NoiseReductionPct*100) - }}, - {Header: "ALERTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertCnt) - }}, - {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertEventCnt) - }}, - } - - return ctx.PrintTotal(result.Items, cols, len(result.Items)) - }) - }, - } - - cmd.Flags().StringVar(&since, "since", "7d", "Start time") - cmd.Flags().StringVar(&until, "until", "now", "End time") - - return cmd -} - -func newInsightChannelCmd() *cobra.Command { - var since, until string - - cmd := &cobra.Command{ - Use: "channel", - Short: "Query insights by channel", - Long: curatedLong("Query incident response insight metrics aggregated by channel over a time window.", "Analytics", "ByChannel"), - RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) - } - - result, _, err := ctx.Client.Analytics.ByChannel(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ - StartTime: startTime, - EndTime: endTime, - }) - if err != nil { - return err - } - - cols := []output.Column{ - {Header: "CHANNEL", MaxWidth: 30, Field: func(v any) string { - return v.(flashduty.DimensionInsightItem).ChannelName - }}, - {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalIncidentCnt) - }}, - {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).AcknowledgementPct*100) - }}, - {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToAck) - }}, - {Header: "MTTR", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.DimensionInsightItem).MeanSecondsToClose) - }}, - {Header: "NOISE_REDUCTION", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.DimensionInsightItem).NoiseReductionPct*100) - }}, - {Header: "ALERTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertCnt) - }}, - {Header: "EVENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.DimensionInsightItem).TotalAlertEventCnt) - }}, - } - - return ctx.PrintTotal(result.Items, cols, len(result.Items)) - }) - }, - } - - cmd.Flags().StringVar(&since, "since", "7d", "Start time") - cmd.Flags().StringVar(&until, "until", "now", "End time") - - return cmd -} - -func newInsightResponderCmd() *cobra.Command { - var since, until string - - cmd := &cobra.Command{ - Use: "responder", - Short: "Query insights by responder", - Long: curatedLong("Query incident response insight metrics aggregated by responder over a time window.", "Analytics", "ByResponder"), - RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - startTime, err := timeutil.Parse(since) - if err != nil { - return fmt.Errorf("invalid --since: %w", err) - } - endTime, err := timeutil.Parse(until) - if err != nil { - return fmt.Errorf("invalid --until: %w", err) - } - - result, _, err := ctx.Client.Analytics.ByResponder(cmdContext(ctx.Cmd), &flashduty.InsightQueryRequest{ - StartTime: startTime, - EndTime: endTime, - }) - if err != nil { - return err - } - - cols := []output.Column{ - {Header: "RESPONDER", MaxWidth: 30, Field: func(v any) string { - return v.(flashduty.ResponderInsightItem).ResponderName - }}, - {Header: "INCIDENTS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.ResponderInsightItem).TotalIncidentCnt) - }}, - {Header: "ACK%", Field: func(v any) string { - return fmt.Sprintf("%.0f%%", v.(flashduty.ResponderInsightItem).AcknowledgementPct*100) - }}, - {Header: "MTTA", Field: func(v any) string { - return output.FormatDurationFloat(v.(flashduty.ResponderInsightItem).MeanSecondsToAck) - }}, - {Header: "INTERRUPTIONS", Field: func(v any) string { - return fmt.Sprintf("%d", v.(flashduty.ResponderInsightItem).TotalInterruptions) - }}, - {Header: "ENGAGED", Field: func(v any) string { - return output.FormatDuration(int(v.(flashduty.ResponderInsightItem).TotalEngagedSeconds)) - }}, - } - - return ctx.PrintTotal(result.Items, cols, len(result.Items)) - }) - }, - } - - cmd.Flags().StringVar(&since, "since", "7d", "Start time") - cmd.Flags().StringVar(&until, "until", "now", "End time") - - return cmd -} - func newInsightTopAlertsCmd() *cobra.Command { var label, since, until string var limit int diff --git a/internal/cli/member.go b/internal/cli/member.go deleted file mode 100644 index ce8a983..0000000 --- a/internal/cli/member.go +++ /dev/null @@ -1,98 +0,0 @@ -package cli - -import ( - "fmt" - "strconv" - - "github.com/flashcatcloud/go-flashduty" - "github.com/spf13/cobra" - - "github.com/flashcatcloud/flashduty-cli/internal/output" -) - -func newMemberCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "member", - Short: "Manage members", - } - cmd.AddCommand(newMemberListCmd()) - return cmd -} - -func newMemberListCmd() *cobra.Command { - var name, email string - var page int - var limit int - var roleID int64 - var orderBy string - var asc bool - - cmd := &cobra.Command{ - Use: "list", - Short: "List members", - Long: curatedLong("List members in your account.", "Members", "MemberList"), - RunE: func(cmd *cobra.Command, args []string) error { - return runCommand(cmd, args, func(ctx *RunContext) error { - // go-flashduty's MemberListRequest exposes a single search - // keyword (Query); the legacy SDK split name/email into separate - // filters. Both --name and --email are keyword searches against - // the same backend, so fold them into Query (name takes precedence). - query := name - if query == "" { - query = email - } - req := &flashduty.MemberListRequest{ - Query: query, - Orderby: orderBy, - Asc: asc, - } - req.Page = page - req.Limit = limit - if roleID != 0 { - req.RoleID = uint64(roleID) - } - - result, _, err := ctx.Client.Members.MemberList(cmdContext(ctx.Cmd), req) - if err != nil { - return err - } - - // MemberList returns member rows; an empty list renders the - // "no members" path (structured: empty set; plain: a message). - if len(result.Items) > 0 { - cols := []output.Column{ - {Header: "ID", Field: func(v any) string { return strconv.FormatUint(v.(flashduty.MemberItem).MemberID, 10) }}, - {Header: "NAME", Field: func(v any) string { return v.(flashduty.MemberItem).MemberName }}, - {Header: "EMAIL", Field: func(v any) string { return v.(flashduty.MemberItem).Email }}, - {Header: "STATUS", Field: func(v any) string { return v.(flashduty.MemberItem).Status }}, - {Header: "TIMEZONE", Field: func(v any) string { return v.(flashduty.MemberItem).TimeZone }}, - } - if err := ctx.Printer.Print(result.Items, cols); err != nil { - return err - } - } else { - if ctx.Structured() { - return ctx.Printer.Print([]struct{}{}, nil) - } - _, _ = fmt.Fprintln(ctx.Writer, "No members found.") - return nil - } - - if !ctx.Structured() { - _, _ = fmt.Fprintf(ctx.Writer, "Total: %d\n", result.Total) - } - return nil - }) - }, - } - - cmd.Flags().StringVar(&name, "name", "", "Search by name") - cmd.Flags().StringVar(&email, "email", "", "Search by email") - cmd.Flags().IntVar(&page, "page", 1, "Page number") - cmd.Flags().IntVar(&limit, "limit", 20, "Page size, max 100 (default 20)") - cmd.Flags().StringVar(&orderBy, "orderby", "", "Sort field: created_at, updated_at, member_name") - cmd.Flags().BoolVar(&asc, "asc", false, "Sort in ascending order") - cmd.Flags().Int64Var(&roleID, "role-id", 0, "Filter to members holding this role ID") - - return cmd -} diff --git a/internal/cli/root.go b/internal/cli/root.go index 0c27475..3901648 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -31,10 +31,12 @@ var ( flagOutputFormat string ) -var updateNotice *update.CheckResult -var updateCheckWarning string -var isTerminalFn = term.IsTerminal -var checkForUpdateAutoFn = update.CheckForUpdateAuto +var ( + updateNotice *update.CheckResult + updateCheckWarning string + isTerminalFn = term.IsTerminal + checkForUpdateAutoFn = update.CheckForUpdateAuto +) var rootCmd = &cobra.Command{ Use: "flashduty", @@ -105,7 +107,8 @@ func init() { rootCmd.AddCommand(newConfigCmd()) rootCmd.AddCommand(newIncidentCmd()) rootCmd.AddCommand(newChangeCmd()) - rootCmd.AddCommand(newMemberCmd()) + // member is served by the generated command group (member list now exposes + // --query/--role-id/--orderby/--asc and the full member API surface). rootCmd.AddCommand(newTeamCmd()) rootCmd.AddCommand(newChannelCmd()) rootCmd.AddCommand(newFieldCmd())