├── .gitignore ├── docs └── demo.gif ├── repository ├── testdata │ ├── bash.txt │ └── zsh.txt ├── alias_test.go ├── history_test.go ├── history.go └── alias.go ├── go.mod ├── .github └── workflows │ ├── test.yml │ ├── golangci-lint.yml │ ├── release.yml │ └── update-install-script.yml ├── .goreleaser.yaml ├── ui ├── selection_manager.go ├── history_list.go ├── history_list_component.go └── confirm_alias_component.go ├── LICENSE ├── install.sh ├── config └── config.go ├── main.go ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/barthr/redo/HEAD/docs/demo.gif -------------------------------------------------------------------------------- /repository/testdata/bash.txt: -------------------------------------------------------------------------------- 1 | htop 2 | echo "test" 3 | docker system prune 4 | -------------------------------------------------------------------------------- /repository/testdata/zsh.txt: -------------------------------------------------------------------------------- 1 | : 1650622819:0;htop 2 | : 1650622898:0;echo "test" 3 | : 1650622911:0;docker system prune 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/barthr/redo 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.15.0 7 | github.com/charmbracelet/bubbletea v0.23.2 8 | github.com/charmbracelet/lipgloss v0.7.1 9 | github.com/stretchr/testify v1.7.1 10 | mvdan.cc/sh/v3 v3.6.0 11 | ) 12 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [ push, pull_request ] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [ 1.17.x, 1.18.x, 1.19.x ] 8 | os: [ ubuntu-latest, macos-latest ] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - uses: actions/setup-go@v3 12 | with: 13 | go-version: ${{ matrix.go-version }} 14 | - uses: actions/checkout@v3 15 | - run: go test ./... -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - master 8 | - main 9 | pull_request: 10 | permissions: 11 | contents: read 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/setup-go@v2 18 | - uses: actions/checkout@v2 19 | - name: golangci-lint 20 | uses: golangci/golangci-lint-action@v2 21 | with: 22 | version: latest -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - darwin 10 | archives: 11 | - replacements: 12 | darwin: Darwin 13 | linux: Linux 14 | 386: i386 15 | amd64: x86_64 16 | checksum: 17 | name_template: 'checksums.txt' 18 | snapshot: 19 | name_template: "{{ incpatch .Version }}-next" 20 | changelog: 21 | sort: asc 22 | filters: 23 | exclude: 24 | - '^docs:' 25 | - '^test:' 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | with: 18 | fetch-depth: 0 19 | - name: Fetch all tags 20 | run: git fetch --force --tags 21 | - name: Set up Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: 1.17 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v2 27 | with: 28 | distribution: goreleaser 29 | version: latest 30 | args: release --rm-dist 31 | env: 32 | GO386: softfloat 33 | GITHUB_TOKEN: ${{ secrets.GH_SECRET }} -------------------------------------------------------------------------------- /ui/selection_manager.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | var ( 4 | selectionManager = &SelectionManager{items: []*HistoryItem{}} 5 | ) 6 | 7 | type SelectionManager struct { 8 | items []*HistoryItem 9 | } 10 | 11 | func (r *SelectionManager) Add(item *HistoryItem) { 12 | if r.Contains(item) { 13 | return 14 | } 15 | r.items = append(r.items, item) 16 | } 17 | 18 | func (r *SelectionManager) Remove(item *HistoryItem) { 19 | index := r.IndexOf(item) 20 | if index == -1 { 21 | return 22 | } 23 | r.items = append(r.items[:index], r.items[index+1:]...) 24 | } 25 | 26 | func (r *SelectionManager) Contains(item *HistoryItem) bool { 27 | return r.IndexOf(item) != -1 28 | } 29 | 30 | func (r *SelectionManager) IndexOf(item *HistoryItem) int { 31 | for i, v := range r.items { 32 | if v == item { 33 | return i 34 | } 35 | } 36 | return -1 37 | } 38 | -------------------------------------------------------------------------------- /repository/alias_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestAliasRepository_CreateInvalidName(t *testing.T) { 10 | file, err := os.CreateTemp("", "alias_test") 11 | assert.NoError(t, err) 12 | 13 | InitAliasRepository(file.Name()) 14 | 15 | repository := GetAliasRepository() 16 | _, err = repository.Create(Alias{ 17 | Name: "i nvalid", 18 | Commands: []string{"echo test"}, 19 | }) 20 | 21 | assert.Error(t, err, "invalid alias name") 22 | } 23 | 24 | func TestAliasRepository_CreateValidName(t *testing.T) { 25 | file, err := os.CreateTemp("", "alias_test") 26 | assert.NoError(t, err) 27 | 28 | InitAliasRepository(file.Name()) 29 | 30 | repository := GetAliasRepository() 31 | _, err = repository.Create(Alias{ 32 | Name: "valid", 33 | Commands: []string{"echo test"}, 34 | }) 35 | 36 | assert.NoError(t, err) 37 | } 38 | -------------------------------------------------------------------------------- /repository/history_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestGetHistoryZsh(t *testing.T) { 9 | InitHistoryRepository("testdata/zsh.txt") 10 | 11 | repository := GetHistoryRepository() 12 | history, err := repository.GetHistory() 13 | 14 | assert.NoError(t, err) 15 | assert.Len(t, history, 3) 16 | 17 | assert.Contains(t, history, "htop") 18 | assert.Contains(t, history, `echo "test"`) 19 | assert.Contains(t, history, `docker system prune`) 20 | } 21 | 22 | func TestGetHistoryBash(t *testing.T) { 23 | InitHistoryRepository("testdata/bash.txt") 24 | 25 | repository := GetHistoryRepository() 26 | history, err := repository.GetHistory() 27 | 28 | assert.NoError(t, err) 29 | assert.Len(t, history, 3) 30 | 31 | assert.Contains(t, history, "htop") 32 | assert.Contains(t, history, `echo "test"`) 33 | assert.Contains(t, history, `docker system prune`) 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/update-install-script.yml: -------------------------------------------------------------------------------- 1 | name: Set Tag in Install Script 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | jobs: 8 | set-tag: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Code 12 | uses: actions/checkout@v2 13 | - name: Set Tag in Install Script 14 | run: | 15 | # Get the tag name from the release event payload 16 | TAG_NAME=$(echo ${{ github.event.release.tag_name }}) 17 | 18 | # Replace in the install script with the tag name 19 | sed -i "s//$TAG_NAME/g" install.sh 20 | 21 | # Commit the changes 22 | git config --global user.email "actions@github.com" 23 | git config --global user.name "GitHub Actions" 24 | git add install.sh 25 | git commit -m "Set in install script to $TAG_NAME" 26 | 27 | # Push the changes directly to the master branch 28 | git push origin HEAD:master 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2022 Barthr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /repository/history.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | historyRepository *HistoryRepository 11 | ) 12 | 13 | func GetHistoryRepository() *HistoryRepository { 14 | return historyRepository 15 | } 16 | 17 | type HistoryRepository struct { 18 | historyPath string 19 | } 20 | 21 | func InitHistoryRepository(historyPath string) { 22 | historyRepository = &HistoryRepository{historyPath: historyPath} 23 | } 24 | 25 | func (repository *HistoryRepository) GetHistory() ([]string, error) { 26 | readFile, err := os.Open(repository.historyPath) 27 | if err != nil { 28 | return nil, err 29 | } 30 | defer readFile.Close() 31 | 32 | fileScanner := bufio.NewScanner(readFile) 33 | fileScanner.Split(bufio.ScanLines) 34 | 35 | var result []string 36 | for fileScanner.Scan() { 37 | parsedLine := strings.Split(fileScanner.Text(), ";") 38 | result = append(result, parsedLine[len(parsedLine)-1]) 39 | } 40 | // reverse array 41 | for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { 42 | result[i], result[j] = result[j], result[i] 43 | } 44 | return result, nil 45 | } 46 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set variables 4 | OS=$(uname -s | tr '[:upper:]' '[:lower:]') 5 | ARCH=$(uname -m) 6 | TAG_WITHOUT_VERSION_PREFIX=$(echo "v0.8.4" | sed 's/^v//') 7 | 8 | if [ "$OS" = "darwin" ]; then 9 | if [ "$ARCH" = "arm64" ]; then 10 | BINARY_NAME="redo_${TAG_WITHOUT_VERSION_PREFIX}_Darwin_arm64.tar.gz" 11 | else 12 | BINARY_NAME="redo_${TAG_WITHOUT_VERSION_PREFIX}_Darwin_x86_64.tar.gz" 13 | fi 14 | elif [ "$OS" = "linux" ]; then 15 | if [ "$ARCH" = "arm64" ]; then 16 | BINARY_NAME="redo_${TAG_WITHOUT_VERSION_PREFIX}_Linux_arm64.tar.gz" 17 | else 18 | BINARY_NAME="redo_${TAG_WITHOUT_VERSION_PREFIX}_Linux_x86_64.tar.gz" 19 | fi 20 | else 21 | echo "Unsupported OS/ARCH: $OS/$ARCH" 22 | exit 1 23 | fi 24 | 25 | RELEASE_URL="https://github.com/barthr/redo/releases/download/v0.8.4/${BINARY_NAME}" 26 | INSTALL_PATH="/usr/local/bin" 27 | 28 | # Download the binary 29 | curl -L -o redo.tar.gz $RELEASE_URL 30 | 31 | # Extract the binary and move it to the installation path 32 | echo "Extracting and installing redo binary to $INSTALL_PATH..." 33 | tar -zxvf redo.tar.gz redo 34 | sudo mv redo "$INSTALL_PATH" 35 | 36 | # Make the binary executable 37 | sudo chmod +x $INSTALL_PATH/redo 38 | 39 | #Cleanup 40 | rm redo.tar.gz 41 | 42 | # Print success message 43 | echo "Successfully installed redo to $INSTALL_PATH" 44 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "text/template" 7 | ) 8 | 9 | var ( 10 | aliasTemplate = template.Must(template.New("base").Parse(`#!/usr/bin/env sh 11 | # This file is generated by redo and includes active aliases. 12 | `)) 13 | ) 14 | 15 | type Config struct { 16 | AliasPath string 17 | ConfigDir string 18 | HistoryPath string 19 | Editor string 20 | } 21 | 22 | func (c *Config) FromEnv() { 23 | c.AliasPath = os.Getenv("REDO_ALIAS_PATH") 24 | c.ConfigDir = os.Getenv("REDO_CONFIG_PATH") 25 | c.HistoryPath = os.Getenv("REDO_HISTORY_PATH") 26 | c.Editor = os.Getenv("REDO_EDITOR") 27 | 28 | const defaultRedoConfigDir = "/redo" 29 | if c.ConfigDir == "" { 30 | dir, _ := os.UserConfigDir() 31 | c.ConfigDir = dir + defaultRedoConfigDir 32 | } 33 | if c.AliasPath == "" { 34 | c.AliasPath = c.ConfigDir + "/aliases" 35 | } 36 | if c.HistoryPath == "" { 37 | c.HistoryPath = os.Getenv("HISTFILE") 38 | } 39 | if c.Editor == "" { 40 | c.Editor = os.Getenv("EDITOR") 41 | } 42 | } 43 | 44 | func (c *Config) Validate() { 45 | if c.HistoryPath == "" { 46 | log.Fatalln("REDO_HISTORY_PATH or HISTFILE env variable must be set") 47 | } 48 | if c.Editor == "" { 49 | log.Fatalln("REDO_EDITOR or EDITOR env variable must be set") 50 | } 51 | } 52 | 53 | func (c *Config) EnsureAliasFileExists() error { 54 | if err := os.MkdirAll(c.ConfigDir, 0777); err != nil { 55 | return err 56 | } 57 | file, err := os.OpenFile(c.AliasPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666) 58 | if err != nil { 59 | return err 60 | } 61 | defer file.Close() 62 | 63 | stat, err := file.Stat() 64 | if err != nil { 65 | return err 66 | } 67 | if stat.Size() == 0 { 68 | return aliasTemplate.Execute(file, nil) 69 | } 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /ui/history_list.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "github.com/charmbracelet/bubbles/list" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "io" 9 | ) 10 | 11 | var ( 12 | itemStyle = lipgloss.NewStyle().PaddingLeft(4) 13 | currentItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")).Width(150) 14 | selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("#00ff00")) 15 | paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) 16 | helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) 17 | ) 18 | 19 | type HistoryItem struct { 20 | Command string 21 | } 22 | 23 | func NewHistoryItem(historyEntry string) *HistoryItem { 24 | return &HistoryItem{Command: historyEntry} 25 | } 26 | 27 | func (h HistoryItem) FilterValue() string { 28 | return h.Command 29 | } 30 | 31 | func (h *HistoryItem) isSelected() bool { 32 | return selectionManager.Contains(h) 33 | } 34 | 35 | type HistoryItemDelegate struct{} 36 | 37 | func (h HistoryItemDelegate) Height() int { return 1 } 38 | func (h HistoryItemDelegate) Spacing() int { return 0 } 39 | func (h HistoryItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } 40 | 41 | func (h HistoryItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 42 | historyItem, ok := listItem.(*HistoryItem) 43 | if !ok { 44 | return 45 | } 46 | var str string 47 | if historyItem.isSelected() { 48 | str = selectedItemStyle.Render(fmt.Sprintf("%d. %s (%d)", index+1, historyItem.Command, selectionManager.IndexOf(historyItem)+1)) 49 | } else { 50 | str = fmt.Sprintf("%d. %s", index+1, historyItem.Command) 51 | } 52 | 53 | fn := itemStyle.Render 54 | if index == m.Index() { 55 | fn = func(s ...string) string { 56 | var str = []string{"> "} 57 | return currentItemStyle.Render(append(str, s...)...) 58 | } 59 | } 60 | 61 | fmt.Fprint(w, fn(str)) 62 | } 63 | -------------------------------------------------------------------------------- /ui/history_list_component.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | const ( 11 | defaultWidth = 20 12 | listHeight = 30 13 | ) 14 | 15 | var ( 16 | titleStyle = lipgloss.NewStyle().MarginLeft(2) 17 | ) 18 | 19 | type errMsg struct { 20 | err error 21 | aliasName string 22 | } 23 | 24 | func (e errMsg) Error() string { return e.err.Error() } 25 | 26 | type HistoryItemListComponent struct { 27 | list list.Model 28 | quitting bool 29 | selected map[int]*HistoryItem 30 | } 31 | 32 | func NewHistoryItemListComponent(items []list.Item) *HistoryItemListComponent { 33 | l := list.New(items, HistoryItemDelegate{}, defaultWidth, listHeight) 34 | l.Title = "Which commands would you like to combine?" 35 | l.SetShowStatusBar(false) 36 | l.SetFilteringEnabled(true) 37 | l.Styles.Title = titleStyle 38 | l.Styles.PaginationStyle = paginationStyle 39 | l.Styles.HelpStyle = helpStyle 40 | l.AdditionalShortHelpKeys = func() []key.Binding { 41 | return []key.Binding{ 42 | key.NewBinding(key.WithHelp("space", "toggle item")), 43 | key.NewBinding(key.WithHelp("enter", "confirm selection")), 44 | } 45 | } 46 | 47 | return &HistoryItemListComponent{list: l, selected: map[int]*HistoryItem{}} 48 | } 49 | 50 | func (h HistoryItemListComponent) Init() tea.Cmd { return nil } 51 | func (h HistoryItemListComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 52 | switch msg := msg.(type) { 53 | case tea.WindowSizeMsg: 54 | h.list.SetWidth(msg.Width) 55 | return h, nil 56 | 57 | case tea.KeyMsg: 58 | switch keypress := msg.String(); keypress { 59 | case "ctrl+c": 60 | h.quitting = true 61 | return h, tea.Quit 62 | 63 | case " ": 64 | item, ok := h.list.SelectedItem().(*HistoryItem) 65 | if !ok { 66 | break 67 | } 68 | 69 | if item.isSelected() { 70 | selectionManager.Remove(item) 71 | } else { 72 | selectionManager.Add(item) 73 | } 74 | case "enter": 75 | if len(selectionManager.items) != 0 { 76 | return newConfirmAliasComponent(), nil 77 | } 78 | } 79 | } 80 | 81 | var cmd tea.Cmd 82 | h.list, cmd = h.list.Update(msg) 83 | return h, cmd 84 | } 85 | 86 | func (h HistoryItemListComponent) View() string { 87 | return "\n" + h.list.View() 88 | } 89 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "os/exec" 9 | 10 | "github.com/barthr/redo/config" 11 | "github.com/barthr/redo/repository" 12 | "github.com/barthr/redo/ui" 13 | "github.com/charmbracelet/bubbles/list" 14 | tea "github.com/charmbracelet/bubbletea" 15 | ) 16 | 17 | var ( 18 | cfg = new(config.Config) 19 | 20 | version string 21 | 22 | date string 23 | ) 24 | 25 | const ( 26 | helpText = ` 27 | |‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾| 28 | | REDO | 29 | |__________________________________________________________________| 30 | 31 | A Command Line tool to manage all of your shell aliases at one place. 32 | 33 | Usage: 34 | redo (opens up the default browser to create aliases) 35 | redo [command] 36 | 37 | Available Commands: 38 | help Prints out this help text. 39 | alias-file Prints out the path to the alias file. 40 | edit Opens the alias file in your editor (default: %s). 41 | ` 42 | ) 43 | 44 | func main() { 45 | cfg.FromEnv() 46 | if err := cfg.EnsureAliasFileExists(); err != nil { 47 | log.Fatalf("Failed to create alias file %s with error: %s", cfg.AliasPath, err) 48 | } 49 | 50 | repository.InitHistoryRepository(cfg.HistoryPath) 51 | repository.InitAliasRepository(cfg.AliasPath) 52 | defer repository.Close() 53 | 54 | flag.Parse() 55 | 56 | cfg.Validate() 57 | 58 | if len(os.Args) > 1 { 59 | switch os.Args[1] { 60 | case "help": 61 | fmt.Fprintf(os.Stdout, helpText, cfg.Editor) 62 | os.Exit(0) 63 | case "alias-file": 64 | fmt.Println(cfg.AliasPath) 65 | os.Exit(0) 66 | case "edit": 67 | openEditor() 68 | os.Exit(0) 69 | case "version": 70 | fmt.Println("Redo was built on", date, "with version", version) 71 | default: 72 | log.Fatalf("Command: %s not found", os.Args[1]) 73 | } 74 | } else { 75 | history, err := repository.GetHistoryRepository().GetHistory() 76 | if err != nil { 77 | log.Fatalf("Failed fetching history: %v", err) 78 | } 79 | 80 | var historyItems []list.Item 81 | for _, historyItem := range history { 82 | historyItems = append(historyItems, ui.NewHistoryItem(historyItem)) 83 | } 84 | listComponent := ui.NewHistoryItemListComponent(historyItems) 85 | 86 | runTeaProgram(listComponent) 87 | } 88 | } 89 | 90 | func openEditor() { 91 | cmd := exec.Command(cfg.Editor, cfg.AliasPath) 92 | cmd.Stdin = os.Stdin 93 | cmd.Stdout = os.Stdout 94 | _ = cmd.Run() 95 | } 96 | 97 | func runTeaProgram(root tea.Model) { 98 | if _, err := tea.NewProgram(root).Run(); err != nil { 99 | fmt.Println("Error running program:", err) 100 | os.Exit(1) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /repository/alias.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "mvdan.cc/sh/v3/syntax" 7 | "os" 8 | "strings" 9 | "text/template" 10 | "time" 11 | ) 12 | 13 | var ( 14 | aliasRepository *AliasRepository 15 | ) 16 | 17 | func GetAliasRepository() *AliasRepository { 18 | return aliasRepository 19 | } 20 | 21 | type Alias struct { 22 | Name string 23 | Commands []string 24 | } 25 | 26 | var functionTemplate = template.Must(template.New("function").Parse(` 27 | # Generated on {{ .Timestamp }}. 28 | {{ .Alias.Name }}() { {{ range .Alias.Commands }} 29 | {{ . }}{{ end }} 30 | } 31 | `)) 32 | 33 | type AliasRepository struct { 34 | aliasFile *os.File 35 | parser *syntax.File 36 | } 37 | 38 | func Close() { 39 | if err := aliasRepository.aliasFile.Close(); err != nil { 40 | log.Fatalf("Failed to close alias file: %v", err) 41 | } 42 | } 43 | 44 | func InitAliasRepository(aliasFile string) { 45 | file, err := os.OpenFile(aliasFile, os.O_RDWR|os.O_APPEND, 0644) 46 | if err != nil { 47 | log.Fatalf("Failed opening alias file: %s: %s", aliasFile, err) 48 | } 49 | aliasRepository = &AliasRepository{aliasFile: file} 50 | aliasRepository.refreshParser() 51 | } 52 | 53 | func (ar *AliasRepository) Create(alias Alias) (string, error) { 54 | function, err := ar.generateFunction(alias) 55 | if err != nil { 56 | return "", err 57 | } 58 | err = ar.validateFunction(function) 59 | if err != nil { 60 | return "", err 61 | } 62 | err = functionTemplate.Execute(ar.aliasFile, map[string]interface{}{ 63 | "Alias": alias, 64 | "Timestamp": time.Now().Format(time.RFC3339), 65 | }) 66 | ar.refreshParser() 67 | return function, err 68 | } 69 | 70 | func (ar *AliasRepository) generateFunction(alias Alias) (string, error) { 71 | buffer := &bytes.Buffer{} 72 | err := functionTemplate.Execute(buffer, map[string]interface{}{ 73 | "Alias": alias, 74 | "Timestamp": time.Now().Format(time.RFC3339), 75 | }) 76 | return buffer.String(), err 77 | } 78 | 79 | func (ar *AliasRepository) validateFunction(function string) error { 80 | _, err := syntax.NewParser().Parse(strings.NewReader(function), "") 81 | return err 82 | } 83 | 84 | func (ar *AliasRepository) Exists(aliasName string) (bool, error) { 85 | declarations, err := ar.functionDeclarations() 86 | if err != nil { 87 | return false, err 88 | } 89 | for _, declaration := range declarations { 90 | if declaration == aliasName { 91 | return true, nil 92 | } 93 | } 94 | return false, nil 95 | } 96 | 97 | func (ar *AliasRepository) functionDeclarations() ([]string, error) { 98 | var result []string 99 | syntax.Walk(ar.parser, func(node syntax.Node) bool { 100 | switch node := node.(type) { 101 | case *syntax.FuncDecl: 102 | result = append(result, node.Name.Value) 103 | } 104 | return true 105 | }) 106 | return result, nil 107 | } 108 | 109 | func (ar *AliasRepository) refreshParser() { 110 | var err error 111 | ar.parser, err = syntax.NewParser().Parse(ar.aliasFile, "") 112 | if err != nil { 113 | panic(err) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /ui/confirm_alias_component.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/barthr/redo/repository" 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | var quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 2) 13 | var infoTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) 14 | var warnTextStyle = lipgloss.NewStyle(). 15 | UnsetPadding(). 16 | UnsetMargins(). 17 | Foreground(lipgloss.Color("#ffa500")) 18 | 19 | type ConfirmAliasComponent struct { 20 | textInput textinput.Model 21 | err error 22 | finalized bool 23 | selected []*HistoryItem 24 | quit bool 25 | function string 26 | } 27 | 28 | func newConfirmAliasComponent() *ConfirmAliasComponent { 29 | textInput := textinput.New() 30 | textInput.Placeholder = "" 31 | textInput.Focus() 32 | textInput.CharLimit = 156 33 | textInput.Width = 20 34 | 35 | return &ConfirmAliasComponent{textInput: textInput, selected: selectionManager.items} 36 | } 37 | 38 | func (c *ConfirmAliasComponent) Init() tea.Cmd { 39 | return textinput.Blink 40 | } 41 | 42 | func (c *ConfirmAliasComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 43 | var cmd tea.Cmd 44 | 45 | switch msg := msg.(type) { 46 | case tea.KeyMsg: 47 | switch msg.Type { 48 | case tea.KeyEscape, tea.KeyCtrlC: 49 | c.quit = true 50 | return c, tea.Quit 51 | case tea.KeyEnter: 52 | return c, c.createNewFunction 53 | } 54 | case errMsg: 55 | c.err = msg 56 | c.textInput.Focus() 57 | return c, nil 58 | case functionMsg: 59 | c.finalized = true 60 | c.function = string(msg) 61 | return c, tea.Quit 62 | } 63 | 64 | c.textInput, cmd = c.textInput.Update(msg) 65 | return c, cmd 66 | } 67 | 68 | type functionMsg string 69 | 70 | func (c *ConfirmAliasComponent) createNewFunction() tea.Msg { 71 | aliasName := c.textInput.Value() 72 | if aliasName == "" || len(c.selected) == 0 { 73 | return errMsg{err: errors.New("can't add empty alias or empty commands"), aliasName: aliasName} 74 | } 75 | 76 | exists, err := repository.GetAliasRepository().Exists(aliasName) 77 | if err == nil && exists { 78 | return errMsg{err: fmt.Errorf("sorry that aliasName already exists: " + aliasName), aliasName: aliasName} 79 | } 80 | 81 | var commands []string 82 | for _, historyItem := range c.selected { 83 | commands = append(commands, historyItem.Command) 84 | } 85 | 86 | var function string 87 | function, err = repository.GetAliasRepository().Create(repository.Alias{ 88 | Name: aliasName, 89 | Commands: commands, 90 | }) 91 | if err != nil { 92 | return errMsg{err, aliasName} 93 | } 94 | return functionMsg(function) 95 | } 96 | 97 | func (c *ConfirmAliasComponent) View() string { 98 | aliasName := c.textInput.Value() 99 | if c.quit { 100 | return "" 101 | } 102 | 103 | var result string 104 | if c.err != nil { 105 | result += warnTextStyle.Render( 106 | fmt.Sprintf("Something failed when trying to create the alias with name %s: %s", c.err.(errMsg).aliasName, c.err.Error()), 107 | ) 108 | } 109 | 110 | if !c.finalized { 111 | result += fmt.Sprintf( 112 | "\n\nWhat’s the name of the alias?\n\n%s\n\n%s", 113 | c.textInput.View(), 114 | "(esc to quit)", 115 | ) + "\n" 116 | 117 | return result 118 | } 119 | 120 | infoText := infoTextStyle.Render(fmt.Sprintf("Successfully added alias with name: %s\nPlease source your alias file to make your alias active in the current shell \n\n$ source $(redo alias-file)", aliasName)) 121 | 122 | return quitTextStyle.Render(fmt.Sprintf("%s\n %s", infoText, c.function)) 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redo 2 | 3 | ![Release](https://github.com/barthr/redo/actions/workflows/release.yml/badge.svg) 4 | ![CI](https://github.com/barthr/redo/actions/workflows/golangci-lint.yml/badge.svg) 5 | ![Test](https://github.com/barthr/redo/actions/workflows/test.yml/badge.svg) 6 | 7 | Redo is a command line application to easily create reusable functions in your own shell. Think of redo like an 8 | interactive way combine multiple commands from your shell history in a single command. This can be handy for quickly 9 | re-doing multiple commands for example deleting and starting a new docker container. 10 | 11 |

12 | 13 |

14 | 15 | * [Features](#features) 16 | * [Installation](#installation) 17 | * [Prebuilt binaries](#prebuilt-binaries) 18 | * [Install from source](#install-from-source) 19 | * [Quickstart](#quickstart) 20 | * [Configuration](#configuration) 21 | * [Shortcuts](#shortcuts) 22 | * [Roadmap](#roadmap) 23 | * [Acknowledgements](#acknowledgements) 24 | * [License](#license) 25 | 26 | ## Features 27 | 28 | - Easily create reusable functions from shell history 29 | - Shell agnostic, can be used with ZSH, Bash etc. 30 | - Aliases are stored in a single file which can be put in version control 31 | 32 | ## Installation 33 | 34 | ### Prebuilt binaries 35 | 36 | Using the provided installation script 37 | 38 | ```bash 39 | bash <(curl -s https://raw.githubusercontent.com/barthr/redo/master/install.sh) 40 | ``` 41 | 42 | Download one of the prebuilt binaries from: https://github.com/barthr/redo/releases and run the following command 43 | 44 | ```bash 45 | 46 | tar -xf redo && sudo mv redo /usr/local/bin 47 | ``` 48 | 49 | ### Install from source 50 | 51 | ```bash 52 | go install github.com/barthr/redo@latest 53 | ``` 54 | 55 | *After downloading add the following line to your* `~/.bashrc` or `~/.zshrc` 56 | 57 | ```bash 58 | source "$(redo alias-file)" 59 | ``` 60 | 61 | This will make sure that the aliases from redo are loaded on every shell session. 62 | 63 | ## Quickstart 64 | 65 | redo contains a couple of commands, which can be used to create reusable functions. 66 | 67 | 1. `redo` - Opens up the interactive window to create a new function 68 | 2. `redo alias-file` - Prints the path to the functions file 69 | 3. `redo edit` - Opens the functions file in your configured editor 70 | 4. `redo help` - Prints a help message which includes information about all the commands 71 | 72 | ## Configuration 73 | 74 | Redo can mostly run without requiring any specific configuration, however it is possible to customize this configuration 75 | by setting the following environment variables: 76 | 77 | `REDO_ALIAS_PATH`: The path where the alias file of redo is stored (defaults to aliases file in user config dir) 78 | 79 | `REDO_CONFIG_PATH`: The config path for redo (defaults to user config dir) 80 | 81 | `REDO_HISTORY_PATH`: The location of the history file which redo uses to source commands (*defaults to HISTFILE **if it 82 | is 83 | exported**) 84 | 85 | `REDO_EDITOR`: The editor you want to use when running commands like `redo edit` (defaults to EDITOR **if it is exported 86 | **) 87 | 88 | ## Shortcuts 89 | 90 | Redo can be bind to a shortcut, so you can easily summon it without calling it directly. 91 | 92 | **zsh CTRL+e summons redo**: 93 | Put the following line in your zshrc file 94 | 95 | ```zsh 96 | bindkey -s '^e' 'redo^M' 97 | ``` 98 | 99 | **bash CTRL+e summons redo**: 100 | Put the following line in your bashrc file or bash_profile 101 | 102 | ```bash 103 | bind '"\C-e":"redo\n"' 104 | ``` 105 | 106 | ## Roadmap 107 | 108 | - ~Reordering of selected tasks~ 109 | - Easy listing/deletion of functions 110 | - Inline editing of shell functions 111 | - Prebuilt binaries published as .deb .rpm .yum etc. 112 | 113 | ## Acknowledgements 114 | 115 | - [Bubbletea TUI framework](https://github.com/charmbracelet/bubbletea) 116 | - [Sh](https://github.com/mvdan/sh) 117 | 118 | ## License 119 | 120 | [MIT](https://choosealicense.com/licenses/mit/) 121 | 122 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 4 | github.com/aymanbagabas/go-osc52 v1.2.1 h1:q2sWUyDcozPLcLabEMd+a+7Ea2DitxZVN9hTxab9L4E= 5 | github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 8 | github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= 9 | github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= 10 | github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= 11 | github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps= 12 | github.com/charmbracelet/bubbletea v0.23.2/go.mod h1:FaP3WUivcTM0xOKNmhciz60M6I+weYLF76mr1JyI7sM= 13 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 14 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 15 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 16 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 17 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 18 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 19 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 20 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 21 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 22 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 23 | github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= 24 | github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 25 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 26 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 27 | github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4= 28 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 29 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 30 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 31 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 32 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 33 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 34 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 35 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 36 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 37 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 38 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 39 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 40 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 41 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 42 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 43 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 44 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 45 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 46 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 47 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 48 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 49 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 50 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 51 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 52 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 53 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 54 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 55 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 56 | github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= 57 | github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= 58 | github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 59 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 60 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 61 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 62 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 63 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 64 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 65 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 66 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 67 | github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= 68 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 69 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= 71 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 72 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 73 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 74 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 75 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 76 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 77 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 79 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 80 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 81 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 83 | golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= 84 | golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 85 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 86 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 87 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 88 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 89 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 90 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 91 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 92 | mvdan.cc/editorconfig v0.2.0/go.mod h1:lvnnD3BNdBYkhq+B4uBuFFKatfp02eB6HixDvEz91C0= 93 | mvdan.cc/sh/v3 v3.6.0 h1:gtva4EXJ0dFNvl5bHjcUEvws+KRcDslT8VKheTYkbGU= 94 | mvdan.cc/sh/v3 v3.6.0/go.mod h1:U4mhtBLZ32iWhif5/lD+ygy1zrgaQhUu+XFy7C8+TTA= 95 | --------------------------------------------------------------------------------