diff --git a/Taskfile.yml b/Taskfile.yml index b16238f..7cc0801 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -19,6 +19,7 @@ tasks: build: desc: build the cli + aliases: [b] deps: - deps cmds: @@ -26,11 +27,13 @@ tasks: test: desc: run tests + aliases: [t] cmds: - go test ./... -cover vuln: desc: run govulncheck + aliases: [v] cmds: - go tool govulncheck {{.CLI_ARGS}} ./... diff --git a/cmd/auth_list.go b/cmd/auth_list.go index fd9cce5..534b488 100644 --- a/cmd/auth_list.go +++ b/cmd/auth_list.go @@ -11,6 +11,10 @@ var authListCmd = &cobra.Command{ Use: "list", Short: "List stored API keys", RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + entries, activeTeam, err := runAuthList() if err != nil { return err @@ -34,13 +38,35 @@ var authListCmd = &cobra.Command{ return nil } - t := newStyledTable(cmd.OutOrStdout(), "NAME", "ACTIVE", "API KEY") + headers := []string{"NAME", "ACTIVE", "API KEY"} + rows := make([][]string, 0, len(entries)) for _, e := range entries { active := "" if e.Name == activeTeam { active = "*" } - t.Row(e.Name, active, maskKey(e.APIKey)) + rows = append(rows, []string{e.Name, active, maskKey(e.APIKey)}) + } + + if isPicking(cmd) { + out := cmd.OutOrStdout() + return runPicker(headers, rows, []pickBinding{{ + Key: "enter", + Label: "switch team", + Action: func(rowIdx int) error { + name := entries[rowIdx].Name + if err := runAuthUse(name); err != nil { + return err + } + fmt.Fprintf(out, "Active team set to %q.\n", name) + return nil + }, + }}) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) } return t.Render() }, @@ -59,5 +85,6 @@ func runAuthList() ([]config.KeyEntry, string, error) { } func init() { + addPickFlag(authListCmd) authCmd.AddCommand(authListCmd) } diff --git a/cmd/campaigns.go b/cmd/campaigns.go index c6fa236..ac4a91e 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -36,6 +36,10 @@ var campaignsListCmd = &cobra.Command{ Use: "list", Short: "List campaigns", RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + cfg, err := loadConfig() if err != nil { return err @@ -58,16 +62,30 @@ var campaignsListCmd = &cobra.Command{ return nil } - t := newStyledTable(cmd.OutOrStdout(), "ID", "MESSAGE ID", "NAME", "STATUS", "SUBJECT", "UPDATED") + headers := []string{"ID", "MESSAGE ID", "NAME", "STATUS", "SUBJECT", "UPDATED"} + rows := make([][]string, 0, len(campaigns)) for _, c := range campaigns { - t.Row( + rows = append(rows, []string{ c.CampaignID, deref(c.EmailMessageID), c.Name, c.Status, c.Subject, c.UpdatedAt, - ) + }) + } + + if isPicking(cmd) { + out := cmd.OutOrStdout() + return runPicker(headers, rows, []pickBinding{ + copyColumnBinding("enter", "copy id", "campaign ID", rows, 0, out), + copyColumnBinding("alt-enter", "copy messageId", "message ID", rows, 1, out), + }) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) } return t.Render() }, @@ -172,6 +190,7 @@ var campaignsGetCmd = &cobra.Command{ func init() { addPaginationFlags(campaignsListCmd) + addPickFlag(campaignsListCmd) campaignsCmd.AddCommand(campaignsListCmd) campaignsCmd.AddCommand(campaignsGetCmd) diff --git a/cmd/contact_properties.go b/cmd/contact_properties.go index 873aa1f..3907020 100644 --- a/cmd/contact_properties.go +++ b/cmd/contact_properties.go @@ -25,6 +25,10 @@ var contactPropertiesListCmd = &cobra.Command{ Use: "list", Short: "List contact properties", RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + customOnly, _ := cmd.Flags().GetBool("custom") cfg, err := loadConfig() @@ -49,9 +53,21 @@ var contactPropertiesListCmd = &cobra.Command{ return nil } - t := newStyledTable(cmd.OutOrStdout(), "KEY", "LABEL", "TYPE") + headers := []string{"KEY", "LABEL", "TYPE"} + rows := make([][]string, 0, len(props)) for _, p := range props { - t.Row(p.Key, p.Label, p.Type) + rows = append(rows, []string{p.Key, p.Label, p.Type}) + } + + if isPicking(cmd) { + return runPicker(headers, rows, []pickBinding{ + copyColumnBinding("enter", "copy key", "property key", rows, 0, cmd.OutOrStdout()), + }) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) } return t.Render() }, @@ -83,6 +99,7 @@ var contactPropertiesCreateCmd = &cobra.Command{ func init() { contactPropertiesListCmd.Flags().Bool("custom", false, "Only list custom properties") + addPickFlag(contactPropertiesListCmd) contactPropertiesCmd.AddCommand(contactPropertiesListCmd) contactPropertiesCreateCmd.Flags().String("name", "", "Property name (camelCase, e.g. planName)") diff --git a/cmd/lists.go b/cmd/lists.go index 5dc07e4..69bb2eb 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -21,6 +21,10 @@ var listsListCmd = &cobra.Command{ Use: "list", Short: "List mailing lists", RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + cfg, err := loadConfig() if err != nil { return err @@ -43,15 +47,28 @@ var listsListCmd = &cobra.Command{ return nil } - t := newStyledTable(cmd.OutOrStdout(), "ID", "NAME", "DESCRIPTION", "PUBLIC") + headers := []string{"ID", "NAME", "DESCRIPTION", "PUBLIC"} + rows := make([][]string, 0, len(lists)) for _, l := range lists { - t.Row(l.ID, l.Name, l.Description, fmt.Sprintf("%v", l.IsPublic)) + rows = append(rows, []string{l.ID, l.Name, l.Description, fmt.Sprintf("%v", l.IsPublic)}) + } + + if isPicking(cmd) { + return runPicker(headers, rows, []pickBinding{ + copyColumnBinding("enter", "copy id", "list ID", rows, 0, cmd.OutOrStdout()), + }) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) } return t.Render() }, } func init() { + addPickFlag(listsListCmd) listsCmd.AddCommand(listsListCmd) rootCmd.AddCommand(listsCmd) } diff --git a/cmd/picker.go b/cmd/picker.go new file mode 100644 index 0000000..f19a3ca --- /dev/null +++ b/cmd/picker.go @@ -0,0 +1,218 @@ +package cmd + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + + "github.com/atotto/clipboard" + "github.com/spf13/cobra" +) + +const pickerHeaderLines = 2 + +func addPickFlag(cmd *cobra.Command) { + cmd.Flags().Bool("pick", false, "Interactively pick a row with fzf") +} + +func isPicking(cmd *cobra.Command) bool { + v, _ := cmd.Flags().GetBool("pick") + return v +} + +// reject --pick combined with --output json +func validatePickFlags(cmd *cobra.Command) error { + if isPicking(cmd) && isJSONOutput() { + return errors.New("--pick cannot be combined with --output json") + } + return nil +} + +// pickBinding is a single key → action mapping inside the picker. the first +// binding passed to runPicker is the default (key must be "enter"). +type pickBinding struct { + Key string + Label string + Action func(rowIdx int) error +} + +// render the styled table for headers/rows and prefix each data line +// with "\t" so fzf can identify the original row regardless of +// filtering/reordering. header lines pass through unchanged. +func buildPickerInput(headers []string, rows [][]string) ([]byte, error) { + var buf bytes.Buffer + t := newStyledTable(&buf, headers...) + for _, r := range rows { + t.Row(r...) + } + if err := t.Render(); err != nil { + return nil, err + } + + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) <= pickerHeaderLines { + return nil, errors.New("no rows to pick") + } + + var out bytes.Buffer + for i := range pickerHeaderLines { + out.WriteByte('\t') + out.WriteString(lines[i]) + out.WriteByte('\n') + } + for idx, line := range lines[pickerHeaderLines:] { + fmt.Fprintf(&out, "%d\t%s\n", idx, line) + } + return out.Bytes(), nil +} + +// parse a single fzf row line of "\t" and validate idx. +func parsePickerSelection(s string, numRows int) (int, error) { + s = strings.TrimRight(s, "\n") + if s == "" { + return 0, errors.New("empty selection") + } + prefix, _, ok := strings.Cut(s, "\t") + if !ok { + return 0, errors.New("unexpected selection format") + } + idx, err := strconv.Atoi(prefix) + if err != nil { + return 0, fmt.Errorf("invalid row index: %w", err) + } + if idx < 0 || idx >= numRows { + return 0, fmt.Errorf("row index %d out of range [0, %d)", idx, numRows) + } + return idx, nil +} + +// parse fzf --expect output: first line is the pressed key (empty for the +// default Enter), second line is the row prefixed by buildPickerInput. +func parsePickerOutput(s string, numRows int) (key string, rowIdx int, err error) { + s = strings.TrimRight(s, "\n") + keyLine, rowLine, ok := strings.Cut(s, "\n") + if !ok { + return "", 0, errors.New("unexpected fzf output format") + } + rowIdx, err = parsePickerSelection(rowLine, numRows) + if err != nil { + return "", 0, err + } + return keyLine, rowIdx, nil +} + +// build an fzf --color spec that matches the CLI's fang color scheme: +// the table column header color is reused for accents (prompt, pointer, +// matches, etc.) and the comment color for info text. base scheme +// follows the detected terminal background. +func pickerColorSpec() string { + cs := fangColorScheme() + base := "dark" + if !isDarkBackground() { + base = "light" + } + accent := hexColor(cs.Title) + dim := hexColor(cs.Comment) + return fmt.Sprintf( + "%s,header:%s,prompt:%s,pointer:%s,marker:%s,spinner:%s,hl:%s,hl+:%s,info:%s", + base, accent, accent, accent, accent, accent, accent, accent, dim, + ) +} + +func renderPickerHeader(bindings []pickBinding) string { + parts := make([]string, len(bindings)) + for i, b := range bindings { + parts[i] = fmt.Sprintf("%s ▶ %s", b.Key, b.Label) + } + return " " + strings.Join(parts, " ") + " " +} + +func runPicker(headers []string, rows [][]string, bindings []pickBinding) error { + if len(bindings) == 0 { + return errors.New("runPicker: at least one binding required") + } + if _, err := exec.LookPath("fzf"); err != nil { + return errors.New("--pick requires fzf to be installed and on PATH") + } + + input, err := buildPickerInput(headers, rows) + if err != nil { + return err + } + + args := []string{ + "--ansi", + "--layout", "reverse-list", + "--header-lines", strconv.Itoa(pickerHeaderLines), + "--delimiter", "\t", + "--with-nth", "2..", + "--header", renderPickerHeader(bindings), + "--header-first", + "--color", pickerColorSpec(), + } + + expect := make([]string, 0, len(bindings)-1) + for _, b := range bindings[1:] { + expect = append(expect, b.Key) + } + if len(expect) > 0 { + args = append(args, "--expect", strings.Join(expect, ",")) + } + + fzf := exec.Command("fzf", args...) + fzf.Stdin = bytes.NewReader(input) + fzf.Stderr = os.Stderr + selBytes, err := fzf.Output() + if err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + switch exitErr.ExitCode() { + case 1, 130: + return nil + } + } + return fmt.Errorf("fzf: %w", err) + } + + output := string(selBytes) + if strings.TrimRight(output, "\n") == "" { + return nil + } + + var key string + var rowIdx int + if len(expect) == 0 { + rowIdx, err = parsePickerSelection(output, len(rows)) + } else { + key, rowIdx, err = parsePickerOutput(output, len(rows)) + } + if err != nil { + return err + } + + for _, b := range bindings { + if (key == "" && b.Key == "enter") || b.Key == key { + return b.Action(rowIdx) + } + } + return fmt.Errorf("no binding for key %q", key) +} + +func copyColumnBinding(key, headerLabel, copyLabel string, rows [][]string, col int, out io.Writer) pickBinding { + return pickBinding{ + Key: key, + Label: headerLabel, + Action: func(rowIdx int) error { + v := rows[rowIdx][col] + if err := clipboard.WriteAll(v); err != nil { + return fmt.Errorf("failed to copy to clipboard: %w", err) + } + fmt.Fprintf(out, "Copied %s: %s\n", copyLabel, v) + return nil + }, + } +} diff --git a/cmd/picker_test.go b/cmd/picker_test.go new file mode 100644 index 0000000..0517c1d --- /dev/null +++ b/cmd/picker_test.go @@ -0,0 +1,215 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestParsePickerSelection(t *testing.T) { + tests := []struct { + name string + input string + numRows int + want int + wantErr bool + }{ + {name: "valid first", input: "0\tfoo bar\n", numRows: 3, want: 0}, + {name: "valid mid", input: "2\tcell\n", numRows: 5, want: 2}, + {name: "no trailing newline", input: "1\tx", numRows: 5, want: 1}, + {name: "empty", input: "", numRows: 3, wantErr: true}, + {name: "newline only", input: "\n", numRows: 3, wantErr: true}, + {name: "no tab", input: "0nothing", numRows: 3, wantErr: true}, + {name: "non-numeric", input: "abc\tx", numRows: 3, wantErr: true}, + {name: "negative", input: "-1\tx", numRows: 3, wantErr: true}, + {name: "out of range", input: "5\tx", numRows: 3, wantErr: true}, + {name: "zero numRows", input: "0\tx", numRows: 0, wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := parsePickerSelection(tc.input, tc.numRows) + if (err != nil) != tc.wantErr { + t.Fatalf("parsePickerSelection(%q, %d) error = %v, wantErr = %v", tc.input, tc.numRows, err, tc.wantErr) + } + if !tc.wantErr && got != tc.want { + t.Fatalf("parsePickerSelection(%q, %d) = %d, want %d", tc.input, tc.numRows, got, tc.want) + } + }) + } +} + +func TestBuildPickerInput(t *testing.T) { + headers := []string{"ID", "NAME"} + rows := [][]string{ + {"a", "alpha"}, + {"b", "beta"}, + {"c", "gamma"}, + } + + out, err := buildPickerInput(headers, rows) + if err != nil { + t.Fatalf("buildPickerInput: %v", err) + } + + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") + wantLines := pickerHeaderLines + len(rows) + if got := len(lines); got != wantLines { + t.Fatalf("got %d lines, want %d. output: %q", got, wantLines, out) + } + + // header lines must NOT round-trip through parsePickerSelection + for i := range pickerHeaderLines { + if _, err := parsePickerSelection(lines[i], len(rows)); err == nil { + t.Errorf("header line %d (%q) unexpectedly parsed as a selection", i, lines[i]) + } + } + + // header lines must start with "\t" so fzf's --with-nth 2.. still + // displays the rendered header text (the empty field before the tab + // is what gets stripped). + for i := range pickerHeaderLines { + if !strings.HasPrefix(lines[i], "\t") { + t.Errorf("header line %d (%q) missing leading tab", i, lines[i]) + continue + } + if rest := strings.TrimPrefix(lines[i], "\t"); rest == "" { + t.Errorf("header line %d has empty content after leading tab", i) + } + } + + // data lines must round-trip and the index must match position + for dataIdx, line := range lines[pickerHeaderLines:] { + idx, err := parsePickerSelection(line, len(rows)) + if err != nil { + t.Errorf("data line %d (%q) failed to parse: %v", dataIdx, line, err) + continue + } + if idx != dataIdx { + t.Errorf("data line %d parsed to idx %d, want %d", dataIdx, idx, dataIdx) + } + } +} + +func TestBuildPickerInputEmpty(t *testing.T) { + if _, err := buildPickerInput([]string{"ID"}, nil); err == nil { + t.Fatalf("expected error for empty rows, got nil") + } +} + +func TestParsePickerOutput(t *testing.T) { + tests := []struct { + name string + input string + numRows int + wantKey string + wantIdx int + wantErr bool + }{ + {name: "default key", input: "\n0\tfoo\n", numRows: 3, wantKey: "", wantIdx: 0}, + {name: "named key", input: "alt-enter\n2\trow\n", numRows: 5, wantKey: "alt-enter", wantIdx: 2}, + {name: "no trailing newline", input: "alt-enter\n1\tx", numRows: 3, wantKey: "alt-enter", wantIdx: 1}, + {name: "single line", input: "0\tfoo\n", numRows: 3, wantErr: true}, + {name: "empty", input: "", numRows: 3, wantErr: true}, + {name: "bad row", input: "alt-enter\nbad\n", numRows: 3, wantErr: true}, + {name: "row out of range", input: "alt-enter\n9\tx\n", numRows: 3, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotKey, gotIdx, err := parsePickerOutput(tc.input, tc.numRows) + if (err != nil) != tc.wantErr { + t.Fatalf("parsePickerOutput(%q, %d) error = %v, wantErr = %v", tc.input, tc.numRows, err, tc.wantErr) + } + if tc.wantErr { + return + } + if gotKey != tc.wantKey || gotIdx != tc.wantIdx { + t.Fatalf("parsePickerOutput(%q, %d) = (%q, %d), want (%q, %d)", tc.input, tc.numRows, gotKey, gotIdx, tc.wantKey, tc.wantIdx) + } + }) + } +} + +func TestRenderPickerHeader(t *testing.T) { + tests := []struct { + name string + bindings []pickBinding + want string + }{ + { + name: "single", + bindings: []pickBinding{{Key: "enter", Label: "id"}}, + want: " enter ▶ id ", + }, + { + name: "two", + bindings: []pickBinding{ + {Key: "enter", Label: "id"}, + {Key: "alt-enter", Label: "messageId"}, + }, + want: " enter ▶ id alt-enter ▶ messageId ", + }, + { + name: "three", + bindings: []pickBinding{ + {Key: "enter", Label: "id"}, + {Key: "alt-enter", Label: "messageId"}, + {Key: "ctrl-y", Label: "name"}, + }, + want: " enter ▶ id alt-enter ▶ messageId ctrl-y ▶ name ", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := renderPickerHeader(tc.bindings); got != tc.want { + t.Fatalf("renderPickerHeader = %q, want %q", got, tc.want) + } + }) + } +} + +func TestValidatePickFlags(t *testing.T) { + saved := outputFormat + t.Cleanup(func() { outputFormat = saved }) + + makeCmd := func(t *testing.T, pick bool) *cobra.Command { + t.Helper() + c := &cobra.Command{} + addPickFlag(c) + if pick { + if err := c.Flags().Set("pick", "true"); err != nil { + t.Fatalf("set pick flag: %v", err) + } + } + return c + } + + t.Run("neither set", func(t *testing.T) { + outputFormat = "text" + if err := validatePickFlags(makeCmd(t, false)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("pick only", func(t *testing.T) { + outputFormat = "text" + if err := validatePickFlags(makeCmd(t, true)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("json only", func(t *testing.T) { + outputFormat = "json" + if err := validatePickFlags(makeCmd(t, false)); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("both set", func(t *testing.T) { + outputFormat = "json" + if err := validatePickFlags(makeCmd(t, true)); err == nil { + t.Fatalf("expected error, got nil") + } + }) +} diff --git a/cmd/styles.go b/cmd/styles.go index 7e2b2c0..f925d48 100644 --- a/cmd/styles.go +++ b/cmd/styles.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "image/color" "io" "os" "strings" @@ -14,14 +15,25 @@ import ( "github.com/charmbracelet/x/term" ) -var fangColorScheme = sync.OnceValue(func() fang.ColorScheme { - isDark := true - if term.IsTerminal(os.Stdout.Fd()) { - isDark = lipgloss.HasDarkBackground(os.Stdin, os.Stdout) +var isDarkBackground = sync.OnceValue(func() bool { + if !term.IsTerminal(os.Stdout.Fd()) { + return true } - return fang.DefaultColorScheme(lipgloss.LightDark(isDark)) + return lipgloss.HasDarkBackground(os.Stdin, os.Stdout) +}) + +var fangColorScheme = sync.OnceValue(func() fang.ColorScheme { + return fang.DefaultColorScheme(lipgloss.LightDark(isDarkBackground())) }) +func hexColor(c color.Color) string { + if c == nil { + return "" + } + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8)) +} + type styledTable struct { out io.Writer t *table.Table diff --git a/cmd/transactional.go b/cmd/transactional.go index 435d193..d42d01f 100644 --- a/cmd/transactional.go +++ b/cmd/transactional.go @@ -80,6 +80,10 @@ var transactionalListCmd = &cobra.Command{ Use: "list", Short: "List published transactional emails", RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePickFlags(cmd); err != nil { + return err + } + cfg, err := loadConfig() if err != nil { return err @@ -103,9 +107,21 @@ var transactionalListCmd = &cobra.Command{ return nil } - t := newStyledTable(cmd.OutOrStdout(), "ID", "NAME", "LAST UPDATED", "VARIABLES") + headers := []string{"ID", "NAME", "LAST UPDATED", "VARIABLES"} + rows := make([][]string, 0, len(emails)) for _, e := range emails { - t.Row(e.ID, e.Name, e.LastUpdated, strings.Join(e.DataVariables, ", ")) + rows = append(rows, []string{e.ID, e.Name, e.LastUpdated, strings.Join(e.DataVariables, ", ")}) + } + + if isPicking(cmd) { + return runPicker(headers, rows, []pickBinding{ + copyColumnBinding("enter", "copy id", "transactional ID", rows, 0, cmd.OutOrStdout()), + }) + } + + t := newStyledTable(cmd.OutOrStdout(), headers...) + for _, r := range rows { + t.Row(r...) } return t.Render() }, @@ -181,6 +197,7 @@ var transactionalSendCmd = &cobra.Command{ func init() { addPaginationFlags(transactionalListCmd) + addPickFlag(transactionalListCmd) transactionalCmd.AddCommand(transactionalListCmd) addTransactionalSendFlags(transactionalSendCmd) diff --git a/go.mod b/go.mod index 99c4d7d..e0df29c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.2 require ( charm.land/fang/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.1 + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/x/term v0.2.2 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 82e77ae..00b8885 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=