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=