From db7d6307553bc0b5f2dd78b506ac61a2b3cc0789 Mon Sep 17 00:00:00 2001 From: ben Date: Sun, 15 Feb 2026 15:31:39 +0800 Subject: [PATCH] feat: add interactive Go deploy CLI with Bubble Tea TUI Replaces manual deploy.sh workflow with an interactive TUI for selecting which configs to deploy. Uses Bubble Tea for checkbox selection, confirm screen with backup toggle, and Catppuccin Mocha colored output. --- .gitignore | 1 + AGENTS.md | 15 +- Makefile | 14 ++ cmd/deploy/main.go | 591 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 31 +++ go.sum | 48 ++++ 6 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 cmd/deploy/main.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore index e43b0f9..e5b0a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +/deploy diff --git a/AGENTS.md b/AGENTS.md index af58b3e..0131d5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,20 @@ This is a **configuration-only** dotfiles repository for macOS. There is no buil Installs: Homebrew, CLI tools (tmux, neovim, fzf, fd, ripgrep, etc.), Oh My Zsh, TPM, NVM, fonts. Skips anything already installed. -### Deploy Configs +### Deploy Configs (Go CLI - primary) + +```bash +go run ./cmd/deploy/ # Interactive deploy with TUI +./deploy # Run pre-built binary +go build -o deploy ./cmd/deploy/ # Build the binary +``` + +Interactive Bubble Tea TUI with checkbox selection for targets. Supports: +- `space` to toggle targets, `a` to toggle all, `enter` to deploy +- Backup toggle (`b` on confirm screen) +- Catppuccin Mocha colored output + +### Deploy Configs (Shell - fallback) ```bash ./deploy.sh # Deploy all configs (with backup) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a54fe48 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +BINARY := deploy +CMD := ./cmd/deploy +LDFLAGS := -s -w + +.PHONY: build run clean + +build: + go build -ldflags="$(LDFLAGS)" -o $(BINARY) $(CMD) + +run: + go run $(CMD) + +clean: + rm -f $(BINARY) diff --git a/cmd/deploy/main.go b/cmd/deploy/main.go new file mode 100644 index 0000000..79781c3 --- /dev/null +++ b/cmd/deploy/main.go @@ -0,0 +1,591 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ============================================================================= +// Catppuccin Mocha colors +// ============================================================================= + +var ( + green = lipgloss.Color("#a6e3a1") + red = lipgloss.Color("#f38ba8") + yellow = lipgloss.Color("#f9e2af") + blue = lipgloss.Color("#89b4fa") + mauve = lipgloss.Color("#cba6f7") + dim = lipgloss.Color("#6c7086") + sub = lipgloss.Color("#a6adc8") + + greenStyle = lipgloss.NewStyle().Foreground(green) + redStyle = lipgloss.NewStyle().Foreground(red) + yellowStyle = lipgloss.NewStyle().Foreground(yellow) + blueStyle = lipgloss.NewStyle().Foreground(blue) + mauveStyle = lipgloss.NewStyle().Foreground(mauve) + dimStyle = lipgloss.NewStyle().Foreground(dim) + subStyle = lipgloss.NewStyle().Foreground(sub) + boldMauve = lipgloss.NewStyle().Foreground(mauve).Bold(true) +) + +// ============================================================================= +// Target definitions +// ============================================================================= + +type target struct { + name string + src string // relative to script dir + dest string // relative to $HOME (or absolute) + isDir bool + command string // command to check if app is installed + special string // "zshenv" for special handling +} + +func allTargets() []target { + home := os.Getenv("HOME") + return []target{ + {name: "zsh", src: "zshrc", dest: filepath.Join(home, ".zshrc"), command: "zsh"}, + {name: "nvim", src: "nvim", dest: filepath.Join(home, ".config", "nvim"), isDir: true, command: "nvim"}, + {name: "tmux", src: "tmux.conf", dest: filepath.Join(home, ".tmux.conf"), command: "tmux"}, + {name: "aerospace", src: "aerospace.toml", dest: filepath.Join(home, ".aerospace.toml"), command: "aerospace"}, + {name: "ghostty", src: "config.ghostty", dest: filepath.Join(home, ".config", "ghostty", "config")}, + {name: "git", src: "gitconfig", dest: filepath.Join(home, ".gitconfig"), command: "git"}, + {name: "vscode", src: "vscode-settings-user.json", dest: filepath.Join(home, "Library", "Application Support", "Code", "User", "settings.json")}, + {name: "zshenv", src: "zshenv.example", dest: filepath.Join(home, ".zshenv"), special: "zshenv"}, + } +} + +// ============================================================================= +// Bubble Tea model +// ============================================================================= + +type phase int + +const ( + phaseSelect phase = iota + phaseConfirm + phaseDeploy + phaseDone +) + +type model struct { + targets []target + selected []bool + cursor int + phase phase + + // deploy results + deployed int + skipped int + failed int + results []string + + // config + scriptDir string + backupDir string + doBackup bool +} + +func initialModel(scriptDir string) model { + targets := allTargets() + selected := make([]bool, len(targets)) + return model{ + targets: targets, + selected: selected, + cursor: 0, + phase: phaseSelect, + scriptDir: scriptDir, + backupDir: filepath.Join(os.Getenv("HOME"), ".dotfiles-backup", time.Now().Format("2006-01-02_150405")), + doBackup: true, + } +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch m.phase { + case phaseSelect: + return m.updateSelect(msg) + case phaseConfirm: + return m.updateConfirm(msg) + case phaseDeploy, phaseDone: + return m.updateDone(msg) + } + } + return m, nil +} + +func (m model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + case "down", "j": + if m.cursor < len(m.targets)-1 { + m.cursor++ + } + + case " ": + m.selected[m.cursor] = !m.selected[m.cursor] + + case "a": + allSelected := true + for _, s := range m.selected { + if !s { + allSelected = false + break + } + } + for i := range m.selected { + m.selected[i] = !allSelected + } + + case "enter": + // check if any selected + anySelected := false + for _, s := range m.selected { + if s { + anySelected = true + break + } + } + if anySelected { + m.phase = phaseConfirm + } + } + return m, nil +} + +func (m model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c", "n": + m.phase = phaseSelect + return m, nil + + case "y", "enter": + m.phase = phaseDeploy + m.runDeploy() + m.phase = phaseDone + return m, nil + + case "b": + m.doBackup = !m.doBackup + return m, nil + } + return m, nil +} + +func (m model) updateDone(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "ctrl+c", "enter": + return m, tea.Quit + } + return m, nil +} + +// ============================================================================= +// View +// ============================================================================= + +func (m model) View() string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(boldMauve.Render(" terminal-setup deploy")) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" ─────────────────────────")) + b.WriteString("\n\n") + + switch m.phase { + case phaseSelect: + m.viewSelect(&b) + case phaseConfirm: + m.viewConfirm(&b) + case phaseDeploy: + b.WriteString(dimStyle.Render(" deploying...")) + b.WriteString("\n") + case phaseDone: + m.viewDone(&b) + } + + return b.String() +} + +func (m model) viewSelect(b *strings.Builder) { + b.WriteString(subStyle.Render(" select configs to deploy:")) + b.WriteString("\n\n") + + for i, t := range m.targets { + cursor := " " + if i == m.cursor { + cursor = mauveStyle.Render("> ") + } + + checked := dimStyle.Render("[ ]") + if m.selected[i] { + checked = greenStyle.Render("[x]") + } + + name := dimStyle.Render(t.name) + if m.selected[i] { + name = mauveStyle.Render(t.name) + } + if i == m.cursor { + name = lipgloss.NewStyle().Foreground(mauve).Bold(true).Render(t.name) + } + + arrow := dimStyle.Render(fmt.Sprintf("%-16s", t.src)) + blueStyle.Render(" -> ") + dimStyle.Render(shortPath(t.dest)) + + b.WriteString(fmt.Sprintf(" %s%s %-12s %s\n", cursor, checked, name, arrow)) + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(" space") + subStyle.Render(" toggle ")) + b.WriteString(dimStyle.Render("a") + subStyle.Render(" all ")) + b.WriteString(dimStyle.Render("enter") + subStyle.Render(" deploy ")) + b.WriteString(dimStyle.Render("q") + subStyle.Render(" quit")) + b.WriteString("\n\n") +} + +func (m model) viewConfirm(b *strings.Builder) { + b.WriteString(subStyle.Render(" deploy these configs?")) + b.WriteString("\n\n") + + for i, t := range m.targets { + if m.selected[i] { + b.WriteString(fmt.Sprintf(" %s %s\n", mauveStyle.Render("*"), mauveStyle.Render(t.name))) + } + } + + b.WriteString("\n") + backupStatus := greenStyle.Render("on") + if !m.doBackup { + backupStatus = yellowStyle.Render("off") + } + b.WriteString(fmt.Sprintf(" %s backup: %s\n", subStyle.Render(" "), backupStatus)) + b.WriteString("\n") + b.WriteString(dimStyle.Render(" y/enter") + subStyle.Render(" confirm ")) + b.WriteString(dimStyle.Render("b") + subStyle.Render(" toggle backup ")) + b.WriteString(dimStyle.Render("n") + subStyle.Render(" back")) + b.WriteString("\n\n") +} + +func (m model) viewDone(b *strings.Builder) { + for _, r := range m.results { + b.WriteString(r) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(" ─────────────────────────")) + b.WriteString("\n") + + parts := fmt.Sprintf(" %s", greenStyle.Render(fmt.Sprintf("%d deployed", m.deployed))) + if m.skipped > 0 { + parts += dimStyle.Render(" . ") + yellowStyle.Render(fmt.Sprintf("%d skipped", m.skipped)) + } + if m.failed > 0 { + parts += dimStyle.Render(" . ") + redStyle.Render(fmt.Sprintf("%d failed", m.failed)) + } + b.WriteString(parts) + b.WriteString("\n\n") + + b.WriteString(subStyle.Render(" reload tips:")) + b.WriteString("\n") + b.WriteString(subStyle.Render(" zsh ") + dimStyle.Render("source ~/.zshrc")) + b.WriteString("\n") + b.WriteString(subStyle.Render(" tmux ") + dimStyle.Render("Ctrl-a + I")) + b.WriteString("\n") + b.WriteString(subStyle.Render(" aerospace ") + dimStyle.Render("alt-shift-; then esc")) + b.WriteString("\n\n") + + b.WriteString(dimStyle.Render(" press enter or q to exit")) + b.WriteString("\n\n") +} + +// ============================================================================= +// Deploy logic +// ============================================================================= + +func (m *model) runDeploy() { + selected := m.selectedTargets() + + // Check apps + for _, t := range selected { + if t.command != "" { + if !commandExists(t.command) { + m.results = append(m.results, fmt.Sprintf(" %s %-10s %s", + yellowStyle.Render("[!]"), + dimStyle.Render(t.command), + dimStyle.Render("not found, config will be deployed anyway"), + )) + } + } + } + + // Backup + if m.doBackup { + backed := m.backupTargets(selected) + if backed { + m.results = append(m.results, "") + } + } + + // Deploy + for _, t := range selected { + m.deployTarget(t) + } +} + +func (m *model) backupTargets(targets []target) bool { + anyBacked := false + for _, t := range targets { + if t.special == "zshenv" { + continue + } + if fileExists(t.dest) { + if !anyBacked { + m.results = append(m.results, fmt.Sprintf(" %s %s", + subStyle.Render("backing up to"), + dimStyle.Render(m.backupDir+"/"), + )) + anyBacked = true + } + if err := backupPath(t.dest, m.backupDir); err != nil { + m.results = append(m.results, fmt.Sprintf(" %s %s", + dimStyle.Render("->"), + redStyle.Render(fmt.Sprintf("failed to backup %s: %v", t.dest, err)), + )) + } else { + m.results = append(m.results, fmt.Sprintf(" %s %s", + dimStyle.Render("->"), + dimStyle.Render(t.dest), + )) + } + } + } + return anyBacked +} + +func (m *model) deployTarget(t target) { + src := filepath.Join(m.scriptDir, t.src) + + // Special handling for zshenv + if t.special == "zshenv" { + if fileExists(t.dest) { + m.results = append(m.results, fmt.Sprintf(" %s %s %s", + yellowStyle.Render("[~]"), + mauveStyle.Render(fmt.Sprintf("%-10s", t.name)), + yellowStyle.Render("skipped ($HOME/.zshenv already exists)"), + )) + m.skipped++ + return + } + if err := copyFile(src, t.dest); err != nil { + m.results = append(m.results, fmt.Sprintf(" %s %s %s", + redStyle.Render("[x]"), + mauveStyle.Render(fmt.Sprintf("%-10s", t.name)), + redStyle.Render("failed to copy zshenv.example"), + )) + m.failed++ + return + } + _ = os.Chmod(t.dest, 0600) + m.results = append(m.results, fmt.Sprintf(" %s %s %s %s %s", + greenStyle.Render("[+]"), + mauveStyle.Render(fmt.Sprintf("%-10s", t.name)), + subStyle.Render(t.src), + blueStyle.Render("->"), + subStyle.Render(t.dest), + )) + m.results = append(m.results, fmt.Sprintf(" %s", + yellowStyle.Render("fill in your tokens and keep chmod 600"), + )) + m.deployed++ + return + } + + // Ensure dest parent dir exists + destDir := filepath.Dir(t.dest) + if err := os.MkdirAll(destDir, 0755); err != nil { + m.results = append(m.results, fmt.Sprintf(" %s %s %s", + redStyle.Render("[x]"), + mauveStyle.Render(fmt.Sprintf("%-10s", t.name)), + redStyle.Render(fmt.Sprintf("failed to create %s", destDir)), + )) + m.failed++ + return + } + + var err error + if t.isDir { + err = copyDir(src, t.dest) + } else { + err = copyFile(src, t.dest) + } + + if err != nil { + m.results = append(m.results, fmt.Sprintf(" %s %s %s", + redStyle.Render("[x]"), + mauveStyle.Render(fmt.Sprintf("%-10s", t.name)), + redStyle.Render(fmt.Sprintf("failed to copy %s", t.src)), + )) + m.failed++ + return + } + + destDisplay := shortPath(t.dest) + srcDisplay := t.src + if t.isDir { + srcDisplay += "/" + destDisplay += "/" + } + + m.results = append(m.results, fmt.Sprintf(" %s %s %s %s %s", + greenStyle.Render("[+]"), + mauveStyle.Render(fmt.Sprintf("%-10s", t.name)), + subStyle.Render(srcDisplay), + blueStyle.Render("->"), + subStyle.Render(destDisplay), + )) + m.deployed++ +} + +func (m model) selectedTargets() []target { + var result []target + for i, t := range m.targets { + if m.selected[i] { + result = append(result, t) + } + } + return result +} + +// ============================================================================= +// Helpers +// ============================================================================= + +func shortPath(p string) string { + home := os.Getenv("HOME") + if strings.HasPrefix(p, home) { + return "~" + p[len(home):] + } + return p +} + +func commandExists(cmd string) bool { + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + if fileExists(filepath.Join(dir, cmd)) { + return true + } + } + return false +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func backupPath(src, backupDir string) error { + if err := os.MkdirAll(backupDir, 0755); err != nil { + return err + } + base := filepath.Base(src) + dest := filepath.Join(backupDir, base) + return copyAny(src, dest) +} + +func copyAny(src, dest string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + if info.IsDir() { + return copyDir(src, dest) + } + return copyFile(src, dest) +} + +func copyFile(src, dest string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dest, data, 0644) +} + +func copyDir(src, dest string) error { + // Remove destination first to ensure clean copy + if err := os.RemoveAll(dest); err != nil { + return err + } + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + destPath := filepath.Join(dest, rel) + + if info.IsDir() { + return os.MkdirAll(destPath, info.Mode()) + } + return copyFile(path, destPath) + }) +} + +// ============================================================================= +// Main +// ============================================================================= + +func main() { + // Find script dir (where the repo configs live) + exe, err := os.Executable() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + scriptDir := filepath.Dir(exe) + + // If running via `go run`, use current working directory + if strings.Contains(exe, "go-build") || strings.Contains(exe, "/tmp/") { + scriptDir, _ = os.Getwd() + } + + // Allow override via env + if envDir := os.Getenv("DOTFILES_DIR"); envDir != "" { + scriptDir = envDir + } + + m := initialModel(scriptDir) + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if fm, ok := finalModel.(model); ok && fm.failed > 0 { + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fab6e76 --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module github.com/neb/terminal-setup + +go 1.26.0 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b7000fb --- /dev/null +++ b/go.sum @@ -0,0 +1,48 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=