├── .github └── workflows │ ├── build.yml │ ├── deploy-pages.yml │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── app ├── app.go └── help.go ├── assets └── screenshot.png ├── clean.sh ├── clean_hard.sh ├── cmd ├── cmd.go └── cmd_test │ └── testutils.go ├── config ├── config.go └── state.go ├── daemon ├── daemon.go ├── daemon_unix.go └── daemon_windows.go ├── go.mod ├── go.sum ├── install.sh ├── keys └── keys.go ├── log └── log.go ├── main.go ├── session ├── git │ ├── diff.go │ ├── util.go │ ├── util_test.go │ ├── worktree.go │ ├── worktree_branch.go │ ├── worktree_git.go │ └── worktree_ops.go ├── instance.go ├── storage.go └── tmux │ ├── pty.go │ ├── tmux.go │ ├── tmux_test.go │ ├── tmux_unix.go │ └── tmux_windows.go ├── ui ├── consts.go ├── diff.go ├── err.go ├── list.go ├── menu.go ├── overlay │ ├── overlay.go │ ├── textInput.go │ └── textOverlay.go ├── preview.go └── tabbed_window.go └── web ├── .gitignore ├── README.md ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── public ├── file.svg ├── globe.svg ├── vercel.svg └── window.svg ├── src └── app │ ├── components │ ├── CopyButton.module.css │ ├── CopyButton.tsx │ ├── ThemeToggle.module.css │ └── ThemeToggle.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.module.css │ └── page.tsx └── tsconfig.json /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '**.go' 8 | - 'go.mod' 9 | - 'go.sum' 10 | pull_request: 11 | branches: [ main ] 12 | paths: 13 | - '**.go' 14 | - 'go.mod' 15 | - 'go.sum' 16 | 17 | jobs: 18 | build: 19 | name: Build Binary and Test 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | goos: [linux, darwin, windows] 24 | goarch: [amd64, arm64] 25 | exclude: 26 | - goos: windows 27 | goarch: arm64 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Set up Go 33 | uses: actions/setup-go@v4 34 | with: 35 | go-version: '1.23' 36 | cache: true 37 | 38 | - name: Run tests 39 | run: go test -v ./... 40 | 41 | - name: Build 42 | env: 43 | GOOS: ${{ matrix.goos }} 44 | GOARCH: ${{ matrix.goarch }} 45 | run: | 46 | BINARY_NAME=claude-squad 47 | if [ "${{ matrix.goos }}" = "windows" ]; then 48 | BINARY_NAME=$BINARY_NAME.exe 49 | fi 50 | go build -v -o build/${{ matrix.goos }}_${{ matrix.goarch }}/$BINARY_NAME 51 | 52 | - name: Upload artifacts 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: claude-squad-${{ matrix.goos }}-${{ matrix.goarch }} 56 | path: build/${{ matrix.goos }}_${{ matrix.goarch }}/* 57 | retention-days: 7 58 | -------------------------------------------------------------------------------- /.github/workflows/deploy-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'web/*' 9 | pull_request: 10 | branches: 11 | - main 12 | paths: 13 | - 'web/*' 14 | 15 | permissions: 16 | contents: read 17 | pages: write 18 | id-token: write 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | 27 | - name: Setup Node 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: 'npm' 32 | cache-dependency-path: web/package-lock.json 33 | 34 | - name: Install dependencies 35 | run: cd web && npm ci 36 | 37 | - name: Update Next.js config for static export 38 | run: | 39 | cd web 40 | # Add output: 'export' to next.config.ts 41 | 42 | - name: Build website 43 | run: cd web && npm run build 44 | 45 | - name: Upload artifact 46 | uses: actions/upload-pages-artifact@v3 47 | with: 48 | path: ./web/out 49 | 50 | deploy: 51 | if: github.event_name == 'push' # Only deploy on push to main, not on PR 52 | environment: 53 | name: github-pages 54 | url: ${{ steps.deployment.outputs.page_url }} 55 | runs-on: ubuntu-latest 56 | needs: build 57 | steps: 58 | - name: Deploy to GitHub Pages 59 | id: deployment 60 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '**.go' 8 | - 'go.mod' 9 | - 'go.sum' 10 | pull_request: 11 | branches: [ main ] 12 | paths: 13 | - '**.go' 14 | - 'go.mod' 15 | - 'go.sum' 16 | 17 | jobs: 18 | golangci: 19 | name: lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: '1.23' 28 | cache: false 29 | 30 | - name: golangci-lint 31 | uses: golangci/golangci-lint-action@v4 32 | with: 33 | version: latest 34 | args: --timeout=5m --out-format=line-number 35 | 36 | - name: Check formatting 37 | run: | 38 | if [ -n "$(gofmt -l .)" ]; then 39 | echo "The following files are not formatted correctly:" 40 | gofmt -l . 41 | echo "Fix these issues" 42 | gofmt -d . 43 | exit 1 44 | fi -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | permissions: write-all 11 | name: Build Release 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Check out code into the Go module directory 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 1.23 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version: '1.23' 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | version: latest 29 | args: release --clean 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | HOMEBREW_REPO_TOKEN: ${{ secrets.BREW_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .aider* 3 | claude-squad 4 | **.DS_Store 5 | cs 6 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2.9 2 | 3 | builds: 4 | - binary: claude-squad 5 | goos: 6 | - darwin 7 | - linux 8 | - windows 9 | goarch: 10 | - amd64 11 | - arm64 12 | env: 13 | - CGO_ENABLED=0 14 | 15 | archives: 16 | - format: tar.gz 17 | name_template: >- 18 | {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} 19 | format_overrides: 20 | - goos: windows 21 | format: zip 22 | 23 | release: 24 | prerelease: auto 25 | draft: true 26 | replace_existing_draft: true 27 | 28 | checksum: 29 | name_template: 'checksums.txt' 30 | 31 | changelog: 32 | use: github 33 | 34 | filters: 35 | exclude: 36 | - "^docs:" 37 | - typo 38 | - "^refactor" 39 | - "^chore" 40 | 41 | brews: 42 | - 43 | name: claude-squad 44 | url_template: "https://github.com/smtg-ai/claude-squad/releases/download/{{ .Tag }}/{{ .ArtifactName }}" 45 | commit_author: 46 | name: goreleaserbot 47 | email: bot@goreleaser.com 48 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 49 | directory: Formula 50 | homepage: "https://smtg-ai.github.io/claude-squad/" 51 | description: "Manage multiple AI agents like Claude Code, Aider, and more. 10x your productivity." 52 | license: "AGPL-3.0" 53 | skip_upload: false 54 | dependencies: 55 | - name: tmux 56 | - name: gh 57 | type: optional 58 | test: | 59 | system "#{bin}/cs version" 60 | repository: 61 | owner: smtg-ai 62 | name: homebrew-claude-squad 63 | branch: brew-tap 64 | token: "{{ .Env.HOMEBREW_REPO_TOKEN }}" 65 | pull_request: 66 | enabled: true 67 | draft: true 68 | base: 69 | owner: smtg-ai 70 | name: homebrew-claude-squad 71 | branch: main -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering contributing to our project! This document outlines the process for contributing. 4 | 5 | ## Development Setup 6 | 7 | 1. Fork the repository 8 | 2. Clone your fork: `git clone https://github.com/YOUR-USERNAME/claude-squad.git` 9 | 3. Add the upstream repository: `git remote add upstream https://github.com/smtg-ai/claude-squad.git` 10 | 4. Install dependencies: `go mod download` 11 | 12 | ## Code Standards 13 | 14 | ### Lint 15 | 16 | You can run the following command to lint the code: 17 | 18 | ```bash 19 | gofmt -w . 20 | ``` 21 | 22 | ### Testing 23 | 24 | Please include tests for new features or bug fixes. 25 | 26 | ## Questions? 27 | 28 | Feel free to open an issue for any questions about contributing. 29 | 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Claude Squad [![CI](https://github.com/smtg-ai/claude-squad/actions/workflows/build.yml/badge.svg)](https://github.com/smtg-ai/claude-squad/actions/workflows/build.yml) [![GitHub Release](https://img.shields.io/github/v/release/smtg-ai/claude-squad)](https://github.com/smtg-ai/claude-squad/releases/latest) 2 | 3 | [Claude Squad](https://smtg-ai.github.io/claude-squad/) is a terminal app that manages multiple [Claude Code](https://github.com/anthropics/claude-code), [Codex](https://github.com/openai/codex) (and other local agents including [Aider](https://github.com/Aider-AI/aider)) in separate workspaces, allowing you to work on multiple tasks simultaneously. 4 | 5 | 6 | ![Claude Squad Screenshot](assets/screenshot.png) 7 | 8 | ### Highlights 9 | - Complete tasks in the background (including yolo / auto-accept mode!) 10 | - Manage instances and tasks in one terminal window 11 | - Review changes before applying them, checkout changes before pushing them 12 | - Each task gets its own isolated git workspace, so no conflicts 13 | 14 |
15 | 16 | https://github.com/user-attachments/assets/aef18253-e58f-4525-9032-f5a3d66c975a 17 | 18 |
19 | 20 | ### Installation 21 | 22 | The easiest way to install `claude-squad` is by running the following command: 23 | 24 | ```bash 25 | curl -fsSL https://raw.githubusercontent.com/stmg-ai/claude-squad/main/install.sh | bash 26 | ``` 27 | 28 | This will install the `cs` binary to `~/.local/bin` and add it to your PATH. To install with a different name, use the `--name` flag: 29 | 30 | ```bash 31 | curl -fsSL https://raw.githubusercontent.com/stmg-ai/claude-squad/main/install.sh | bash -s -- --name 32 | ``` 33 | 34 | Alternatively, you can also install `claude-squad` by building from source or installing a [pre-built binary](https://github.com/smtg-ai/claude-squad/releases). 35 | 36 | ### Prerequisites 37 | 38 | - [tmux](https://github.com/tmux/tmux/wiki/Installing) 39 | - [gh](https://cli.github.com/) 40 | 41 | ### Usage 42 | 43 | ``` 44 | Usage: 45 | cs [flags] 46 | cs [command] 47 | 48 | Available Commands: 49 | completion Generate the autocompletion script for the specified shell 50 | debug Print debug information like config paths 51 | help Help about any command 52 | reset Reset all stored instances 53 | version Print the version number of claude-squad 54 | 55 | Flags: 56 | -y, --autoyes [experimental] If enabled, all instances will automatically accept prompts for claude code & aider 57 | -h, --help help for claude-squad 58 | -p, --program string Program to run in new instances (e.g. 'aider --model ollama_chat/gemma3:1b') 59 | ``` 60 | 61 | Run the application with: 62 | 63 | ```bash 64 | cs 65 | ``` 66 | 67 |
68 | 69 | Using Claude Squad with other AI assistants: 70 | - For [Codex](https://github.com/openai/codex): Set your API key with `export OPENAI_API_KEY=` 71 | - Launch with specific assistants: 72 | - Codex: `cs -p "codex"` 73 | - Aider: `cs -p "aider ..."` 74 | - Make this the default, by modifying the config file (locate with `cs debug`) 75 | 76 |
77 | 78 | #### Menu 79 | The menu at the bottom of the screen shows available commands: 80 | 81 | ##### Instance/Session Management 82 | - `n` - Create a new session 83 | - `N` - Create a new session with a prompt 84 | - `D` - Kill (delete) the selected session 85 | - `↑/j`, `↓/k` - Navigate between sessions 86 | 87 | ##### Actions 88 | - `↵/o` - Attach to the selected session to reprompt 89 | - `ctrl-q` - Detach from session 90 | - `s` - Commit and push branch to github 91 | - `c` - Checkout. Commits changes and pauses the session 92 | - `r` - Resume a paused session 93 | - `?` - Show help menu 94 | 95 | ##### Navigation 96 | - `tab` - Switch between preview tab and diff tab 97 | - `q` - Quit the application 98 | - `shift-↓/↑` - scroll in diff view 99 | 100 | ### How It Works 101 | 102 | 1. **tmux** to create isolated terminal sessions for each agent 103 | 2. **git worktrees** to isolate codebases so each session works on its own branch 104 | 3. A simple TUI interface for easy navigation and management 105 | 106 | ### License 107 | 108 | [AGPL-3.0](LICENSE.md) 109 | 110 | ### Star History 111 | 112 | [![Star History Chart](https://api.star-history.com/svg?repos=smtg-ai/claude-squad&type=Date)](https://www.star-history.com/#smtg-ai/claude-squad&Date) 113 | -------------------------------------------------------------------------------- /app/help.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "claude-squad/log" 5 | "claude-squad/session" 6 | "claude-squad/ui" 7 | "claude-squad/ui/overlay" 8 | "fmt" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | type helpType int 15 | 16 | // Make a help state type enum 17 | const ( 18 | helpTypeGeneral helpType = iota 19 | helpTypeInstanceStart 20 | helpTypeInstanceAttach 21 | helpTypeInstanceCheckout 22 | ) 23 | 24 | // Help screen bit flags for tracking in config 25 | const ( 26 | HelpFlagGeneral uint32 = 1 << helpTypeGeneral 27 | HelpFlagInstanceStart uint32 = 1 << helpTypeInstanceStart 28 | HelpFlagInstanceAttach uint32 = 1 << helpTypeInstanceAttach 29 | HelpFlagInstanceCheckout uint32 = 1 << helpTypeInstanceCheckout 30 | ) 31 | 32 | var ( 33 | titleStyle = lipgloss.NewStyle().Bold(true).Underline(true).Foreground(lipgloss.Color("#7D56F4")) 34 | headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#36CFC9")) 35 | keyStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFCC00")) 36 | descStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) 37 | ) 38 | 39 | func (h helpType) ToContent(instance *session.Instance) string { 40 | switch h { 41 | case helpTypeGeneral: 42 | content := lipgloss.JoinVertical(lipgloss.Left, 43 | titleStyle.Render("Claude Squad"), 44 | "", 45 | "A terminal UI that manages multiple Claude Code (and other local agents) in separate workspaces.", 46 | "", 47 | headerStyle.Render("Managing:"), 48 | keyStyle.Render("n")+descStyle.Render(" - Create a new session"), 49 | keyStyle.Render("N")+descStyle.Render(" - Create a new session with a prompt"), 50 | keyStyle.Render("D")+descStyle.Render(" - Kill (delete) the selected session"), 51 | keyStyle.Render("↑/j, ↓/k")+descStyle.Render(" - Navigate between sessions"), 52 | keyStyle.Render("↵/o")+descStyle.Render(" - Attach to the selected session"), 53 | keyStyle.Render("ctrl-q")+descStyle.Render(" - Detach from session"), 54 | "", 55 | headerStyle.Render("Handoff:"), 56 | keyStyle.Render("p")+descStyle.Render(" - Commit and push branch to github"), 57 | keyStyle.Render("c")+descStyle.Render(" - Checkout: commit changes and pause session"), 58 | keyStyle.Render("r")+descStyle.Render(" - Resume a paused session"), 59 | "", 60 | headerStyle.Render("Other:"), 61 | keyStyle.Render("tab")+descStyle.Render(" - Switch between preview and diff tabs"), 62 | keyStyle.Render("shift-↓/↑")+descStyle.Render(" - Scroll in diff view"), 63 | keyStyle.Render("q")+descStyle.Render(" - Quit the application"), 64 | ) 65 | return content 66 | 67 | case helpTypeInstanceStart: 68 | content := lipgloss.JoinVertical(lipgloss.Left, 69 | titleStyle.Render("Instance Created"), 70 | "", 71 | descStyle.Render("New session created:"), 72 | descStyle.Render(fmt.Sprintf("• Git branch: %s (isolated worktree)", lipgloss.NewStyle().Bold(true).Render(instance.Branch))), 73 | descStyle.Render(fmt.Sprintf("• %s running in background tmux session", lipgloss.NewStyle().Bold(true).Render(instance.Program))), 74 | "", 75 | headerStyle.Render("Managing:"), 76 | keyStyle.Render("↵/o")+descStyle.Render(" - Attach to the session to interact with it directly"), 77 | keyStyle.Render("tab")+descStyle.Render(" - Switch preview panes to view session diff"), 78 | keyStyle.Render("D")+descStyle.Render(" - Kill (delete) the selected session"), 79 | "", 80 | headerStyle.Render("Handoff:"), 81 | keyStyle.Render("c")+descStyle.Render(" - Checkout this instance's branch"), 82 | keyStyle.Render("p")+descStyle.Render(" - Push branch to GitHub to create a PR"), 83 | ) 84 | return content 85 | 86 | case helpTypeInstanceAttach: 87 | content := lipgloss.JoinVertical(lipgloss.Left, 88 | titleStyle.Render("Attaching to Instance"), 89 | "", 90 | descStyle.Render("To detach from a session, press ")+keyStyle.Render("ctrl-q"), 91 | ) 92 | return content 93 | 94 | case helpTypeInstanceCheckout: 95 | content := lipgloss.JoinVertical(lipgloss.Left, 96 | titleStyle.Render("Checkout Instance"), 97 | "", 98 | "Changes will be committed and pushed to GitHub. The branch name has been copied to your clipboard for you to checkout.", 99 | "", 100 | "Feel free to make changes to the branch and commit them. When resuming, the session will continue from where you left off.", 101 | "", 102 | headerStyle.Render("Commands:"), 103 | keyStyle.Render("c")+descStyle.Render(" - Checkout: commit changes and pause session"), 104 | keyStyle.Render("r")+descStyle.Render(" - Resume a paused session"), 105 | ) 106 | return content 107 | } 108 | return "" 109 | } 110 | 111 | // showHelpScreen displays the help screen overlay if it hasn't been shown before 112 | func (m *home) showHelpScreen(helpType helpType, onDismiss func()) (tea.Model, tea.Cmd) { 113 | // Get the flag for this help type 114 | var helpFlag uint32 115 | switch helpType { 116 | case helpTypeGeneral: 117 | helpFlag = HelpFlagGeneral 118 | case helpTypeInstanceStart: 119 | helpFlag = HelpFlagInstanceStart 120 | case helpTypeInstanceAttach: 121 | helpFlag = HelpFlagInstanceAttach 122 | case helpTypeInstanceCheckout: 123 | helpFlag = HelpFlagInstanceCheckout 124 | } 125 | 126 | // Check if this help screen has been seen before 127 | // Only show if we're showing the general help screen or the corresponding flag is not set in the seen bitmask. 128 | if helpType == helpTypeGeneral || (m.appState.GetHelpScreensSeen()&helpFlag) == 0 { 129 | // Mark this help screen as seen and save state 130 | if err := m.appState.SetHelpScreensSeen(m.appState.GetHelpScreensSeen() | helpFlag); err != nil { 131 | log.WarningLog.Printf("Failed to save help screen state: %v", err) 132 | } 133 | 134 | content := helpType.ToContent(m.list.GetSelectedInstance()) 135 | 136 | m.textOverlay = overlay.NewTextOverlay(content) 137 | m.textOverlay.OnDismiss = onDismiss 138 | m.state = stateHelp 139 | return m, nil 140 | } 141 | 142 | // Skip displaying the help screen 143 | if onDismiss != nil { 144 | onDismiss() 145 | } 146 | return m, nil 147 | } 148 | 149 | // handleHelpState handles key events when in help state 150 | func (m *home) handleHelpState(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 151 | // Any key press will close the help overlay 152 | shouldClose := m.textOverlay.HandleKeyPress(msg) 153 | if shouldClose { 154 | m.state = stateDefault 155 | return m, tea.Sequence( 156 | tea.WindowSize(), 157 | func() tea.Msg { 158 | m.menu.SetState(ui.StateDefault) 159 | return nil 160 | }, 161 | ) 162 | } 163 | 164 | return m, nil 165 | } 166 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smtg-ai/claude-squad/946434e341f51e0dfbcb919a83a628c5826d6267/assets/screenshot.png -------------------------------------------------------------------------------- /clean.sh: -------------------------------------------------------------------------------- 1 | tmux kill-server 2 | rm -rf worktree* 3 | rm -rf ~/.claude-squad 4 | -------------------------------------------------------------------------------- /clean_hard.sh: -------------------------------------------------------------------------------- 1 | tmux kill-server 2 | rm -rf worktree* 3 | rm -rf ~/.claude-squad 4 | git worktree prune 5 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | ) 7 | 8 | type Executor interface { 9 | Run(cmd *exec.Cmd) error 10 | Output(cmd *exec.Cmd) ([]byte, error) 11 | } 12 | 13 | type Exec struct{} 14 | 15 | func (e Exec) Run(cmd *exec.Cmd) error { 16 | return cmd.Run() 17 | } 18 | 19 | func (e Exec) Output(cmd *exec.Cmd) ([]byte, error) { 20 | return cmd.Output() 21 | } 22 | 23 | func MakeExecutor() Executor { 24 | return Exec{} 25 | } 26 | 27 | func ToString(cmd *exec.Cmd) string { 28 | if cmd == nil { 29 | return "" 30 | } 31 | return strings.Join(cmd.Args, " ") 32 | } 33 | -------------------------------------------------------------------------------- /cmd/cmd_test/testutils.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "os/exec" 5 | ) 6 | 7 | type MockCmdExec struct { 8 | RunFunc func(cmd *exec.Cmd) error 9 | OutputFunc func(cmd *exec.Cmd) ([]byte, error) 10 | } 11 | 12 | func (e MockCmdExec) Run(cmd *exec.Cmd) error { 13 | return e.RunFunc(cmd) 14 | } 15 | 16 | func (e MockCmdExec) Output(cmd *exec.Cmd) ([]byte, error) { 17 | return e.OutputFunc(cmd) 18 | } 19 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "claude-squad/log" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | const ConfigFileName = "config.json" 12 | 13 | // GetConfigDir returns the path to the application's configuration directory 14 | func GetConfigDir() (string, error) { 15 | homeDir, err := os.UserHomeDir() 16 | if err != nil { 17 | return "", fmt.Errorf("failed to get config home directory: %w", err) 18 | } 19 | return filepath.Join(homeDir, ".claude-squad"), nil 20 | } 21 | 22 | // Config represents the application configuration 23 | type Config struct { 24 | // DefaultProgram is the default program to run in new instances 25 | DefaultProgram string `json:"default_program"` 26 | // AutoYes is a flag to automatically accept all prompts. 27 | AutoYes bool `json:"auto_yes"` 28 | // DaemonPollInterval is the interval (ms) at which the daemon polls sessions for autoyes mode. 29 | DaemonPollInterval int `json:"daemon_poll_interval"` 30 | // BranchPrefix is the prefix used for git branches created by the application. 31 | BranchPrefix string `json:"branch_prefix"` 32 | } 33 | 34 | // DefaultConfig returns the default configuration 35 | func DefaultConfig() *Config { 36 | return &Config{ 37 | DefaultProgram: "claude", 38 | AutoYes: false, 39 | DaemonPollInterval: 1000, 40 | BranchPrefix: "session/", 41 | } 42 | } 43 | 44 | // LoadConfig loads the configuration from disk. If it cannot be done, we return the default configuration. 45 | func LoadConfig() *Config { 46 | configDir, err := GetConfigDir() 47 | if err != nil { 48 | log.ErrorLog.Printf("failed to get config directory: %v", err) 49 | return DefaultConfig() 50 | } 51 | 52 | configPath := filepath.Join(configDir, ConfigFileName) 53 | data, err := os.ReadFile(configPath) 54 | if err != nil { 55 | if os.IsNotExist(err) { 56 | // Create and save default config if file doesn't exist 57 | defaultCfg := DefaultConfig() 58 | if saveErr := saveConfig(defaultCfg); saveErr != nil { 59 | log.WarningLog.Printf("failed to save default config: %v", saveErr) 60 | } 61 | return defaultCfg 62 | } 63 | 64 | log.WarningLog.Printf("failed to get config file: %v", err) 65 | return DefaultConfig() 66 | } 67 | 68 | var config Config 69 | if err := json.Unmarshal(data, &config); err != nil { 70 | log.ErrorLog.Printf("failed to parse config file: %v", err) 71 | return DefaultConfig() 72 | } 73 | 74 | return &config 75 | } 76 | 77 | // saveConfig saves the configuration to disk 78 | func saveConfig(config *Config) error { 79 | configDir, err := GetConfigDir() 80 | if err != nil { 81 | return fmt.Errorf("failed to get config directory: %w", err) 82 | } 83 | 84 | if err := os.MkdirAll(configDir, 0755); err != nil { 85 | return fmt.Errorf("failed to create config directory: %w", err) 86 | } 87 | 88 | configPath := filepath.Join(configDir, ConfigFileName) 89 | data, err := json.MarshalIndent(config, "", " ") 90 | if err != nil { 91 | return fmt.Errorf("failed to marshal config: %w", err) 92 | } 93 | 94 | return os.WriteFile(configPath, data, 0644) 95 | } 96 | 97 | // SaveConfig exports the saveConfig function for use by other packages 98 | func SaveConfig(config *Config) error { 99 | return saveConfig(config) 100 | } 101 | -------------------------------------------------------------------------------- /config/state.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "claude-squad/log" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | const ( 12 | StateFileName = "state.json" 13 | InstancesFileName = "instances.json" 14 | ) 15 | 16 | // InstanceStorage handles instance-related operations 17 | type InstanceStorage interface { 18 | // SaveInstances saves the raw instance data 19 | SaveInstances(instancesJSON json.RawMessage) error 20 | // GetInstances returns the raw instance data 21 | GetInstances() json.RawMessage 22 | // DeleteAllInstances removes all stored instances 23 | DeleteAllInstances() error 24 | } 25 | 26 | // AppState handles application-level state 27 | type AppState interface { 28 | // GetHelpScreensSeen returns the bitmask of seen help screens 29 | GetHelpScreensSeen() uint32 30 | // SetHelpScreensSeen updates the bitmask of seen help screens 31 | SetHelpScreensSeen(seen uint32) error 32 | } 33 | 34 | // StateManager combines instance storage and app state management 35 | type StateManager interface { 36 | InstanceStorage 37 | AppState 38 | } 39 | 40 | // State represents the application state that persists between sessions 41 | type State struct { 42 | // HelpScreensSeen is a bitmask tracking which help screens have been shown 43 | HelpScreensSeen uint32 `json:"help_screens_seen"` 44 | // Instances stores the serialized instance data as raw JSON 45 | InstancesData json.RawMessage `json:"instances"` 46 | } 47 | 48 | // DefaultState returns the default state 49 | func DefaultState() *State { 50 | return &State{ 51 | HelpScreensSeen: 0, 52 | InstancesData: json.RawMessage("[]"), 53 | } 54 | } 55 | 56 | // LoadState loads the state from disk. If it cannot be done, we return the default state. 57 | func LoadState() *State { 58 | configDir, err := GetConfigDir() 59 | if err != nil { 60 | log.ErrorLog.Printf("failed to get config directory: %v", err) 61 | return DefaultState() 62 | } 63 | 64 | statePath := filepath.Join(configDir, StateFileName) 65 | data, err := os.ReadFile(statePath) 66 | if err != nil { 67 | if os.IsNotExist(err) { 68 | // Create and save default state if file doesn't exist 69 | defaultState := DefaultState() 70 | if saveErr := SaveState(defaultState); saveErr != nil { 71 | log.WarningLog.Printf("failed to save default state: %v", saveErr) 72 | } 73 | return defaultState 74 | } 75 | 76 | log.WarningLog.Printf("failed to get state file: %v", err) 77 | return DefaultState() 78 | } 79 | 80 | var state State 81 | if err := json.Unmarshal(data, &state); err != nil { 82 | log.ErrorLog.Printf("failed to parse state file: %v", err) 83 | return DefaultState() 84 | } 85 | 86 | return &state 87 | } 88 | 89 | // SaveState saves the state to disk 90 | func SaveState(state *State) error { 91 | configDir, err := GetConfigDir() 92 | if err != nil { 93 | return fmt.Errorf("failed to get config directory: %w", err) 94 | } 95 | 96 | if err := os.MkdirAll(configDir, 0755); err != nil { 97 | return fmt.Errorf("failed to create config directory: %w", err) 98 | } 99 | 100 | statePath := filepath.Join(configDir, StateFileName) 101 | data, err := json.MarshalIndent(state, "", " ") 102 | if err != nil { 103 | return fmt.Errorf("failed to marshal state: %w", err) 104 | } 105 | 106 | return os.WriteFile(statePath, data, 0644) 107 | } 108 | 109 | // InstanceStorage interface implementation 110 | 111 | // SaveInstances saves the raw instance data 112 | func (s *State) SaveInstances(instancesJSON json.RawMessage) error { 113 | s.InstancesData = instancesJSON 114 | return SaveState(s) 115 | } 116 | 117 | // GetInstances returns the raw instance data 118 | func (s *State) GetInstances() json.RawMessage { 119 | return s.InstancesData 120 | } 121 | 122 | // DeleteAllInstances removes all stored instances 123 | func (s *State) DeleteAllInstances() error { 124 | s.InstancesData = json.RawMessage("[]") 125 | return SaveState(s) 126 | } 127 | 128 | // AppState interface implementation 129 | 130 | // GetHelpScreensSeen returns the bitmask of seen help screens 131 | func (s *State) GetHelpScreensSeen() uint32 { 132 | return s.HelpScreensSeen 133 | } 134 | 135 | // SetHelpScreensSeen updates the bitmask of seen help screens 136 | func (s *State) SetHelpScreensSeen(seen uint32) error { 137 | s.HelpScreensSeen = seen 138 | return SaveState(s) 139 | } 140 | -------------------------------------------------------------------------------- /daemon/daemon.go: -------------------------------------------------------------------------------- 1 | package daemon 2 | 3 | import ( 4 | "claude-squad/config" 5 | "claude-squad/log" 6 | "claude-squad/session" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "os/signal" 11 | "path/filepath" 12 | "sync" 13 | "syscall" 14 | "time" 15 | ) 16 | 17 | // RunDaemon runs the daemon process which iterates over all sessions and runs AutoYes mode on them. 18 | // It's expected that the main process kills the daemon when the main process starts. 19 | func RunDaemon(cfg *config.Config) error { 20 | log.InfoLog.Printf("starting daemon") 21 | state := config.LoadState() 22 | storage, err := session.NewStorage(state) 23 | if err != nil { 24 | return fmt.Errorf("failed to initialize storage: %w", err) 25 | } 26 | 27 | instances, err := storage.LoadInstances() 28 | if err != nil { 29 | return fmt.Errorf("failed to load instacnes: %w", err) 30 | } 31 | for _, instance := range instances { 32 | // Assume AutoYes is true if the daemon is running. 33 | instance.AutoYes = true 34 | } 35 | 36 | pollInterval := time.Duration(cfg.DaemonPollInterval) * time.Millisecond 37 | 38 | // If we get an error for a session, it's likely that we'll keep getting the error. Log every 30 seconds. 39 | everyN := log.NewEvery(60 * time.Second) 40 | 41 | wg := &sync.WaitGroup{} 42 | wg.Add(1) 43 | stopCh := make(chan struct{}) 44 | go func() { 45 | defer wg.Done() 46 | ticker := time.NewTimer(pollInterval) 47 | for { 48 | for _, instance := range instances { 49 | // We only store started instances, but check anyway. 50 | if instance.Started() && !instance.Paused() { 51 | if _, hasPrompt := instance.HasUpdated(); hasPrompt { 52 | instance.TapEnter() 53 | if err := instance.UpdateDiffStats(); err != nil { 54 | if everyN.ShouldLog() { 55 | log.WarningLog.Printf("could not update diff stats for %s: %v", instance.Title, err) 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | // Handle stop before ticker. 63 | select { 64 | case <-stopCh: 65 | return 66 | default: 67 | } 68 | 69 | <-ticker.C 70 | ticker.Reset(pollInterval) 71 | } 72 | }() 73 | 74 | // Notify on SIGINT (Ctrl+C) and SIGTERM. Save instances before 75 | sigChan := make(chan os.Signal, 1) 76 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 77 | sig := <-sigChan 78 | log.InfoLog.Printf("received signal %s", sig.String()) 79 | 80 | // Stop the goroutine so we don't race. 81 | close(stopCh) 82 | wg.Wait() 83 | 84 | if err := storage.SaveInstances(instances); err != nil { 85 | log.ErrorLog.Printf("failed to save instances when terminating daemon: %v", err) 86 | } 87 | return nil 88 | } 89 | 90 | // LaunchDaemon launches the daemon process. 91 | func LaunchDaemon() error { 92 | // Find the claude squad binary. 93 | execPath, err := os.Executable() 94 | if err != nil { 95 | return fmt.Errorf("failed to get executable path: %w", err) 96 | } 97 | 98 | cmd := exec.Command(execPath, "--daemon") 99 | 100 | // Detach the process from the parent 101 | cmd.Stdin = nil 102 | cmd.Stdout = nil 103 | cmd.Stderr = nil 104 | 105 | // Set process group to prevent signals from propagating 106 | cmd.SysProcAttr = getSysProcAttr() 107 | 108 | if err := cmd.Start(); err != nil { 109 | return fmt.Errorf("failed to start child process: %w", err) 110 | } 111 | 112 | log.InfoLog.Printf("started daemon child process with PID: %d", cmd.Process.Pid) 113 | 114 | // Save PID to a file for later management 115 | pidDir, err := config.GetConfigDir() 116 | if err != nil { 117 | return fmt.Errorf("failed to get config directory: %w", err) 118 | } 119 | 120 | pidFile := filepath.Join(pidDir, "daemon.pid") 121 | if err := os.WriteFile(pidFile, []byte(fmt.Sprintf("%d", cmd.Process.Pid)), 0644); err != nil { 122 | return fmt.Errorf("failed to write PID file: %w", err) 123 | } 124 | 125 | // Don't wait for the child to exit, it's detached 126 | return nil 127 | } 128 | 129 | // StopDaemon attempts to stop a running daemon process if it exists. Returns no error if the daemon is not found 130 | // (assumes the daemon does not exist). 131 | func StopDaemon() error { 132 | pidDir, err := config.GetConfigDir() 133 | if err != nil { 134 | return fmt.Errorf("failed to get config directory: %w", err) 135 | } 136 | 137 | pidFile := filepath.Join(pidDir, "daemon.pid") 138 | data, err := os.ReadFile(pidFile) 139 | if err != nil { 140 | if os.IsNotExist(err) { 141 | return nil 142 | } 143 | return fmt.Errorf("failed to read PID file: %w", err) 144 | } 145 | 146 | var pid int 147 | if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil { 148 | return fmt.Errorf("invalid PID file format: %w", err) 149 | } 150 | 151 | proc, err := os.FindProcess(pid) 152 | if err != nil { 153 | return fmt.Errorf("failed to find daemon process: %w", err) 154 | } 155 | 156 | if err := proc.Kill(); err != nil { 157 | return fmt.Errorf("failed to stop daemon process: %w", err) 158 | } 159 | 160 | // Clean up PID file 161 | if err := os.Remove(pidFile); err != nil { 162 | return fmt.Errorf("failed to remove PID file: %w", err) 163 | } 164 | 165 | log.InfoLog.Printf("daemon process (PID: %d) stopped successfully", pid) 166 | return nil 167 | } 168 | -------------------------------------------------------------------------------- /daemon/daemon_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package daemon 4 | 5 | import ( 6 | "syscall" 7 | ) 8 | 9 | // getSysProcAttr returns platform-specific process attributes for detaching the child process 10 | func getSysProcAttr() *syscall.SysProcAttr { 11 | return &syscall.SysProcAttr{ 12 | Setsid: true, // Create a new session 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /daemon/daemon_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package daemon 4 | 5 | import ( 6 | "golang.org/x/sys/windows" 7 | "syscall" 8 | ) 9 | 10 | // getSysProcAttr returns platform-specific process attributes for detaching the child process 11 | func getSysProcAttr() *syscall.SysProcAttr { 12 | return &syscall.SysProcAttr{ 13 | CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module claude-squad 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/atotto/clipboard v0.1.4 9 | github.com/charmbracelet/bubbles v0.20.0 10 | github.com/charmbracelet/bubbletea v1.3.4 11 | github.com/charmbracelet/lipgloss v1.0.0 12 | github.com/creack/pty v1.1.24 13 | github.com/go-git/go-git/v5 v5.14.0 14 | github.com/mattn/go-runewidth v0.0.16 15 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 16 | github.com/muesli/reflow v0.3.0 17 | github.com/muesli/termenv v0.15.2 18 | github.com/spf13/cobra v1.9.1 19 | github.com/stretchr/testify v1.10.0 20 | golang.org/x/sys v0.31.0 21 | golang.org/x/term v0.30.0 22 | ) 23 | 24 | require ( 25 | dario.cat/mergo v1.0.0 // indirect 26 | github.com/Microsoft/go-winio v0.6.2 // indirect 27 | github.com/ProtonMail/go-crypto v1.1.5 // indirect 28 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 29 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 30 | github.com/charmbracelet/x/term v0.2.1 // indirect 31 | github.com/cloudflare/circl v1.6.0 // indirect 32 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 33 | github.com/davecgh/go-spew v1.1.1 // indirect 34 | github.com/emirpasic/gods v1.18.1 // indirect 35 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 36 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 37 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 38 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 39 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 40 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 41 | github.com/kevinburke/ssh_config v1.2.0 // indirect 42 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 43 | github.com/mattn/go-isatty v0.0.20 // indirect 44 | github.com/mattn/go-localereader v0.0.1 // indirect 45 | github.com/muesli/cancelreader v0.2.2 // indirect 46 | github.com/pjbgf/sha1cd v0.3.2 // indirect 47 | github.com/pmezard/go-difflib v1.0.0 // indirect 48 | github.com/rivo/uniseg v0.4.7 // indirect 49 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 50 | github.com/skeema/knownhosts v1.3.1 // indirect 51 | github.com/spf13/pflag v1.0.6 // indirect 52 | github.com/xanzy/ssh-agent v0.3.3 // indirect 53 | golang.org/x/crypto v0.35.0 // indirect 54 | golang.org/x/net v0.36.0 // indirect 55 | golang.org/x/sync v0.11.0 // indirect 56 | golang.org/x/text v0.22.0 // indirect 57 | gopkg.in/warnings.v0 v0.1.2 // indirect 58 | gopkg.in/yaml.v3 v3.0.1 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 4 | github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 5 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 6 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 7 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 8 | github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4= 9 | github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 10 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 11 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 12 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 13 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 14 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 15 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 16 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 18 | github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= 19 | github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= 20 | github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= 21 | github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= 22 | github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= 23 | github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= 24 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 25 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 26 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 27 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 28 | github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 29 | github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 30 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 31 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 32 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 33 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 34 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 35 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 36 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 37 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 38 | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 39 | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 40 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 41 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 42 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 43 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 44 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 45 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 46 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 47 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 48 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 49 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 50 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 51 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 52 | github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= 53 | github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= 54 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 55 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 56 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 57 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 58 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 59 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 60 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 61 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 62 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 63 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 64 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 65 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 66 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 67 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 68 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 69 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 70 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 71 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 72 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 73 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 74 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 75 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 76 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 77 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 78 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 79 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 80 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 81 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 82 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 83 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 84 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 85 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 86 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 87 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 88 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 89 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 90 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 91 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 92 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 93 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 94 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 95 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 96 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 97 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 98 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 99 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 100 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 101 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 102 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 103 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 104 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 105 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 106 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 107 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 108 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 109 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 110 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 111 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 112 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 113 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 114 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 115 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 116 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 117 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 118 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 119 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 120 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 121 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 122 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 123 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 124 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 125 | golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= 126 | golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= 127 | golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= 128 | golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 129 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 130 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 131 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 132 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 133 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 134 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 135 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 136 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 137 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 138 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 139 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 140 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 141 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 142 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 143 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 144 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 145 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 146 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 147 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 148 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 149 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 150 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 151 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 152 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 153 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 154 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 155 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 156 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | setup_shell_and_path() { 6 | BIN_DIR=${BIN_DIR:-$HOME/.local/bin} 7 | 8 | case $SHELL in 9 | */zsh) 10 | PROFILE=$HOME/.zshrc 11 | ;; 12 | */bash) 13 | PROFILE=$HOME/.bashrc 14 | ;; 15 | */fish) 16 | PROFILE=$HOME/.config/fish/config.fish 17 | ;; 18 | */ash) 19 | PROFILE=$HOME/.profile 20 | ;; 21 | *) 22 | echo "could not detect shell, manually add ${BIN_DIR} to your PATH." 23 | exit 1 24 | esac 25 | 26 | if [[ ":$PATH:" != *":${BIN_DIR}:"* ]]; then 27 | echo >> "$PROFILE" && echo "export PATH=\"\$PATH:$BIN_DIR\"" >> "$PROFILE" 28 | fi 29 | } 30 | 31 | detect_platform_and_arch() { 32 | PLATFORM="$(uname | tr '[:upper:]' '[:lower:]')" 33 | if [[ "$PLATFORM" == mingw*_nt* ]]; then 34 | PLATFORM="windows" 35 | fi 36 | 37 | ARCHITECTURE="$(uname -m)" 38 | if [ "${ARCHITECTURE}" = "x86_64" ]; then 39 | # Redirect stderr to /dev/null to avoid printing errors if non Rosetta. 40 | if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then 41 | ARCHITECTURE="arm64" # Rosetta. 42 | else 43 | ARCHITECTURE="amd64" # Intel. 44 | fi 45 | elif [ "${ARCHITECTURE}" = "arm64" ] || [ "${ARCHITECTURE}" = "aarch64" ]; then 46 | ARCHITECTURE="arm64" # Arm. 47 | else 48 | ARCHITECTURE="amd64" # Amd. 49 | fi 50 | 51 | if [[ "$PLATFORM" == "windows" ]]; then 52 | ARCHIVE_EXT=".zip" 53 | EXTENSION=".exe" 54 | else 55 | ARCHIVE_EXT=".tar.gz" 56 | EXTENSION="" 57 | fi 58 | } 59 | 60 | get_latest_version() { 61 | # Get latest version from GitHub API, including prereleases 62 | API_RESPONSE=$(curl -sS "https://api.github.com/repos/smtg-ai/claude-squad/releases") 63 | if [ $? -ne 0 ]; then 64 | echo "Failed to connect to GitHub API" 65 | exit 1 66 | fi 67 | 68 | if echo "$API_RESPONSE" | grep -q "Not Found"; then 69 | echo "No releases found in the repository" 70 | exit 1 71 | fi 72 | 73 | # Get the first release (latest) from the array 74 | LATEST_VERSION=$(echo "$API_RESPONSE" | grep -m1 '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' | sed 's/^v//') 75 | if [ -z "$LATEST_VERSION" ]; then 76 | echo "Failed to parse version from GitHub API response:" 77 | echo "$API_RESPONSE" | grep -v "upload_url" # Filter out long upload_url line 78 | exit 1 79 | fi 80 | echo "$LATEST_VERSION" 81 | } 82 | 83 | download_release() { 84 | local version=$1 85 | local binary_url=$2 86 | local archive_name=$3 87 | local tmp_dir=$4 88 | 89 | echo "Downloading binary from $binary_url" 90 | DOWNLOAD_OUTPUT=$(curl -sS -L -f -w '%{http_code}' "$binary_url" -o "${tmp_dir}/${archive_name}" 2>&1) 91 | HTTP_CODE=$? 92 | 93 | if [ $HTTP_CODE -ne 0 ]; then 94 | echo "Error: Failed to download release asset" 95 | echo "This could be because:" 96 | echo "1. The release ${version} doesn't have assets uploaded yet" 97 | echo "2. The asset for ${PLATFORM}_${ARCHITECTURE} wasn't built" 98 | echo "3. The asset name format has changed" 99 | echo "" 100 | echo "Expected asset name: ${archive_name}" 101 | echo "URL attempted: ${binary_url}" 102 | if [ "$version" == "latest" ]; then 103 | echo "" 104 | echo "Tip: Try installing a specific version instead of 'latest'" 105 | echo "Available versions:" 106 | echo "$API_RESPONSE" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' | sed 's/^v//' 107 | fi 108 | rm -rf "$tmp_dir" 109 | exit 1 110 | fi 111 | } 112 | 113 | extract_and_install() { 114 | local tmp_dir=$1 115 | local archive_name=$2 116 | local bin_dir=$3 117 | local extension=$4 118 | 119 | if [[ "$PLATFORM" == "windows" ]]; then 120 | if ! unzip -t "${tmp_dir}/${archive_name}" > /dev/null 2>&1; then 121 | echo "Error: Downloaded file is not a valid zip archive" 122 | rm -rf "$tmp_dir" 123 | exit 1 124 | fi 125 | ensure unzip "${tmp_dir}/${archive_name}" -d "$tmp_dir" 126 | else 127 | if ! tar tzf "${tmp_dir}/${archive_name}" > /dev/null 2>&1; then 128 | echo "Error: Downloaded file is not a valid tar.gz archive" 129 | rm -rf "$tmp_dir" 130 | exit 1 131 | fi 132 | ensure tar xzf "${tmp_dir}/${archive_name}" -C "$tmp_dir" 133 | fi 134 | 135 | if [ ! -d "$bin_dir" ]; then 136 | mkdir -p "$bin_dir" 137 | fi 138 | 139 | # Remove existing binary if upgrading 140 | if [ "$UPGRADE_MODE" = true ] && [ -f "$bin_dir/$INSTALL_NAME${extension}" ]; then 141 | echo "Removing previous installation from $bin_dir/$INSTALL_NAME${extension}" 142 | rm -f "$bin_dir/$INSTALL_NAME${extension}" 143 | fi 144 | 145 | # Install binary with desired name 146 | mv "${tmp_dir}/claude-squad${extension}" "$bin_dir/$INSTALL_NAME${extension}" 147 | rm -rf "$tmp_dir" 148 | 149 | if [ ! -f "$bin_dir/$INSTALL_NAME${extension}" ]; then 150 | echo "Installation failed, could not find $bin_dir/$INSTALL_NAME${extension}" 151 | exit 1 152 | fi 153 | 154 | chmod +x "$bin_dir/$INSTALL_NAME${extension}" 155 | 156 | echo "" 157 | if [ "$UPGRADE_MODE" = true ]; then 158 | echo "Successfully upgraded '$INSTALL_NAME' to:" 159 | else 160 | echo "Installed as '$INSTALL_NAME':" 161 | fi 162 | echo "$("$bin_dir/$INSTALL_NAME${extension}" version)" 163 | } 164 | 165 | check_command_exists() { 166 | if command -v "$INSTALL_NAME" &> /dev/null; then 167 | EXISTING_PATH=$(which "$INSTALL_NAME") 168 | echo "Found existing installation of '$INSTALL_NAME' at $EXISTING_PATH" 169 | echo "Will upgrade to the latest version" 170 | UPGRADE_MODE=true 171 | else 172 | UPGRADE_MODE=false 173 | fi 174 | } 175 | 176 | check_and_install_dependencies() { 177 | echo "Checking for required dependencies..." 178 | 179 | # Check for tmux 180 | if ! command -v tmux &> /dev/null; then 181 | echo "tmux is not installed. Installing tmux..." 182 | 183 | if [[ "$PLATFORM" == "darwin" ]]; then 184 | # macOS 185 | if command -v brew &> /dev/null; then 186 | ensure brew install tmux 187 | else 188 | echo "Homebrew is not installed. Please install Homebrew first to install tmux." 189 | echo "Visit https://brew.sh for installation instructions." 190 | exit 1 191 | fi 192 | elif [[ "$PLATFORM" == "linux" ]]; then 193 | # Linux 194 | if command -v apt-get &> /dev/null; then 195 | ensure sudo apt-get update 196 | ensure sudo apt-get install -y tmux 197 | elif command -v dnf &> /dev/null; then 198 | ensure sudo dnf install -y tmux 199 | elif command -v yum &> /dev/null; then 200 | ensure sudo yum install -y tmux 201 | elif command -v pacman &> /dev/null; then 202 | ensure sudo pacman -S --noconfirm tmux 203 | else 204 | echo "Could not determine package manager. Please install tmux manually." 205 | exit 1 206 | fi 207 | elif [[ "$PLATFORM" == "windows" ]]; then 208 | echo "For Windows, please install tmux via WSL or another method." 209 | exit 1 210 | fi 211 | 212 | echo "tmux installed successfully." 213 | else 214 | echo "tmux is already installed." 215 | fi 216 | 217 | # Check for GitHub CLI (gh) 218 | if ! command -v gh &> /dev/null; then 219 | echo "GitHub CLI (gh) is not installed. Installing GitHub CLI..." 220 | 221 | if [[ "$PLATFORM" == "darwin" ]]; then 222 | # macOS 223 | if command -v brew &> /dev/null; then 224 | ensure brew install gh 225 | else 226 | echo "Homebrew is not installed. Please install Homebrew first to install GitHub CLI." 227 | echo "Visit https://brew.sh for installation instructions." 228 | exit 1 229 | fi 230 | elif [[ "$PLATFORM" == "linux" ]]; then 231 | # Linux 232 | if command -v apt-get &> /dev/null; then 233 | echo "Installing GitHub CLI on Debian/Ubuntu..." 234 | ensure curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg 235 | ensure sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg 236 | ensure echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null 237 | ensure sudo apt-get update 238 | ensure sudo apt-get install -y gh 239 | elif command -v dnf &> /dev/null; then 240 | ensure sudo dnf install -y 'dnf-command(config-manager)' 241 | ensure sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo 242 | ensure sudo dnf install -y gh 243 | elif command -v yum &> /dev/null; then 244 | ensure sudo yum install -y yum-utils 245 | ensure sudo yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo 246 | ensure sudo yum install -y gh 247 | elif command -v pacman &> /dev/null; then 248 | ensure sudo pacman -S --noconfirm github-cli 249 | else 250 | echo "Could not determine package manager. Please install GitHub CLI manually." 251 | echo "Visit https://github.com/cli/cli#installation for installation instructions." 252 | exit 1 253 | fi 254 | elif [[ "$PLATFORM" == "windows" ]]; then 255 | echo "For Windows, please install GitHub CLI manually." 256 | echo "Visit https://github.com/cli/cli#installation for installation instructions." 257 | exit 1 258 | fi 259 | 260 | echo "GitHub CLI (gh) installed successfully." 261 | else 262 | echo "GitHub CLI (gh) is already installed." 263 | fi 264 | 265 | echo "All dependencies are installed." 266 | } 267 | 268 | main() { 269 | # Parse command line arguments 270 | INSTALL_NAME="cs" 271 | UPGRADE_MODE=false 272 | 273 | while [[ $# -gt 0 ]]; do 274 | case $1 in 275 | --name) 276 | INSTALL_NAME="$2" 277 | shift 2 278 | ;; 279 | *) 280 | echo "Unknown option: $1" 281 | echo "Usage: install.sh [--name ]" 282 | exit 1 283 | ;; 284 | esac 285 | done 286 | 287 | check_command_exists 288 | detect_platform_and_arch 289 | 290 | check_and_install_dependencies 291 | 292 | setup_shell_and_path 293 | 294 | VERSION=${VERSION:-"latest"} 295 | if [[ "$VERSION" == "latest" ]]; then 296 | VERSION=$(get_latest_version) 297 | fi 298 | 299 | RELEASE_URL="https://github.com/smtg-ai/claude-squad/releases/download/v${VERSION}" 300 | ARCHIVE_NAME="claude-squad_${VERSION}_${PLATFORM}_${ARCHITECTURE}${ARCHIVE_EXT}" 301 | BINARY_URL="${RELEASE_URL}/${ARCHIVE_NAME}" 302 | TMP_DIR=$(mktemp -d) 303 | 304 | download_release "$VERSION" "$BINARY_URL" "$ARCHIVE_NAME" "$TMP_DIR" 305 | extract_and_install "$TMP_DIR" "$ARCHIVE_NAME" "$BIN_DIR" "$EXTENSION" 306 | } 307 | 308 | # Run a command that should never fail. If the command fails execution 309 | # will immediately terminate with an error showing the failing 310 | # command. 311 | ensure() { 312 | if ! "$@"; then err "command failed: $*"; fi 313 | } 314 | 315 | err() { 316 | echo "$1" >&2 317 | exit 1 318 | } 319 | 320 | main "$@" || exit 1 321 | -------------------------------------------------------------------------------- /keys/keys.go: -------------------------------------------------------------------------------- 1 | package keys 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | ) 6 | 7 | type KeyName int 8 | 9 | const ( 10 | KeyUp KeyName = iota 11 | KeyDown 12 | KeyEnter 13 | KeyNew 14 | KeyKill 15 | KeyQuit 16 | KeyReview 17 | KeyPush 18 | KeySubmit 19 | 20 | KeyTab // Tab is a special keybinding for switching between panes. 21 | KeySubmitName // SubmitName is a special keybinding for submitting the name of a new instance. 22 | 23 | KeyCheckout 24 | KeyResume 25 | KeyPrompt // New key for entering a prompt 26 | KeyHelp // Key for showing help screen 27 | 28 | // Diff keybindings 29 | KeyShiftUp 30 | KeyShiftDown 31 | ) 32 | 33 | // GlobalKeyStringsMap is a global, immutable map string to keybinding. 34 | var GlobalKeyStringsMap = map[string]KeyName{ 35 | "up": KeyUp, 36 | "k": KeyUp, 37 | "down": KeyDown, 38 | "j": KeyDown, 39 | "shift+up": KeyShiftUp, 40 | "shift+down": KeyShiftDown, 41 | "N": KeyPrompt, 42 | "enter": KeyEnter, 43 | "o": KeyEnter, 44 | "n": KeyNew, 45 | "D": KeyKill, 46 | "q": KeyQuit, 47 | "tab": KeyTab, 48 | "c": KeyCheckout, 49 | "r": KeyResume, 50 | "p": KeySubmit, 51 | "?": KeyHelp, 52 | } 53 | 54 | // GlobalkeyBindings is a global, immutable map of KeyName tot keybinding. 55 | var GlobalkeyBindings = map[KeyName]key.Binding{ 56 | KeyUp: key.NewBinding( 57 | key.WithKeys("up", "k"), 58 | key.WithHelp("↑/k", "up"), 59 | ), 60 | KeyDown: key.NewBinding( 61 | key.WithKeys("down", "j"), 62 | key.WithHelp("↓/j", "down"), 63 | ), 64 | KeyShiftUp: key.NewBinding( 65 | key.WithKeys("shift+up"), 66 | key.WithHelp("shift+↑", "scroll"), 67 | ), 68 | KeyShiftDown: key.NewBinding( 69 | key.WithKeys("shift+down"), 70 | key.WithHelp("shift+↓", "scroll"), 71 | ), 72 | KeyEnter: key.NewBinding( 73 | key.WithKeys("enter", "o"), 74 | key.WithHelp("↵/o", "open"), 75 | ), 76 | KeyNew: key.NewBinding( 77 | key.WithKeys("n"), 78 | key.WithHelp("n", "new"), 79 | ), 80 | KeyKill: key.NewBinding( 81 | key.WithKeys("D"), 82 | key.WithHelp("D", "kill"), 83 | ), 84 | KeyHelp: key.NewBinding( 85 | key.WithKeys("?"), 86 | key.WithHelp("?", "help"), 87 | ), 88 | KeyQuit: key.NewBinding( 89 | key.WithKeys("q"), 90 | key.WithHelp("q", "quit"), 91 | ), 92 | KeySubmit: key.NewBinding( 93 | key.WithKeys("p"), 94 | key.WithHelp("p", "push branch"), 95 | ), 96 | KeyPrompt: key.NewBinding( 97 | key.WithKeys("N"), 98 | key.WithHelp("N", "new with prompt"), 99 | ), 100 | KeyCheckout: key.NewBinding( 101 | key.WithKeys("c"), 102 | key.WithHelp("c", "checkout"), 103 | ), 104 | KeyTab: key.NewBinding( 105 | key.WithKeys("tab"), 106 | key.WithHelp("tab", "switch tab"), 107 | ), 108 | KeyResume: key.NewBinding( 109 | key.WithKeys("r"), 110 | key.WithHelp("r", "resume"), 111 | ), 112 | 113 | // -- Special keybindings -- 114 | 115 | KeySubmitName: key.NewBinding( 116 | key.WithKeys("enter"), 117 | key.WithHelp("enter", "submit name"), 118 | ), 119 | } 120 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | ) 10 | 11 | var ( 12 | WarningLog *log.Logger 13 | InfoLog *log.Logger 14 | ErrorLog *log.Logger 15 | ) 16 | 17 | var logFileName = filepath.Join(os.TempDir(), "claudesquad.log") 18 | 19 | var globalLogFile *os.File 20 | 21 | // Initialize should be called once at the beginning of the program to set up logging. 22 | // defer Close() after calling this function. It sets the go log output to the file in 23 | // the os temp directory. 24 | 25 | func Initialize(daemon bool) { 26 | f, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) 27 | if err != nil { 28 | panic(fmt.Sprintf("could not open log file: %s", err)) 29 | } 30 | 31 | // Set log format to include timestamp and file/line number 32 | log.SetFlags(log.LstdFlags | log.Lshortfile) 33 | 34 | fmtS := "%s" 35 | if daemon { 36 | fmtS = "[DAEMON] %s" 37 | } 38 | InfoLog = log.New(f, fmt.Sprintf(fmtS, "INFO:"), log.Ldate|log.Ltime|log.Lshortfile) 39 | WarningLog = log.New(f, fmt.Sprintf(fmtS, "WARNING:"), log.Ldate|log.Ltime|log.Lshortfile) 40 | ErrorLog = log.New(f, fmt.Sprintf(fmtS, "ERROR:"), log.Ldate|log.Ltime|log.Lshortfile) 41 | 42 | globalLogFile = f 43 | } 44 | 45 | func Close() { 46 | _ = globalLogFile.Close() 47 | // TODO: maybe only print if verbose flag is set? 48 | fmt.Println("wrote logs to " + logFileName) 49 | } 50 | 51 | // Every is used to log at most once every timeout duration. 52 | type Every struct { 53 | timeout time.Duration 54 | timer *time.Timer 55 | } 56 | 57 | func NewEvery(timeout time.Duration) *Every { 58 | return &Every{timeout: timeout} 59 | } 60 | 61 | // ShouldLog returns true if the timeout has passed since the last log. 62 | func (e *Every) ShouldLog() bool { 63 | if e.timer == nil { 64 | e.timer = time.NewTimer(e.timeout) 65 | e.timer.Reset(e.timeout) 66 | return true 67 | } 68 | 69 | select { 70 | case <-e.timer.C: 71 | e.timer.Reset(e.timeout) 72 | return true 73 | default: 74 | return false 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "claude-squad/app" 5 | cmd2 "claude-squad/cmd" 6 | "claude-squad/config" 7 | "claude-squad/daemon" 8 | "claude-squad/log" 9 | "claude-squad/session" 10 | "claude-squad/session/git" 11 | "claude-squad/session/tmux" 12 | "context" 13 | "encoding/json" 14 | "fmt" 15 | "path/filepath" 16 | 17 | "github.com/spf13/cobra" 18 | ) 19 | 20 | var ( 21 | version = "1.0.0" 22 | programFlag string 23 | autoYesFlag bool 24 | daemonFlag bool 25 | rootCmd = &cobra.Command{ 26 | Use: "claude-squad", 27 | Short: "Claude Squad - A terminal-based session manager", 28 | RunE: func(cmd *cobra.Command, args []string) error { 29 | ctx := context.Background() 30 | log.Initialize(daemonFlag) 31 | defer log.Close() 32 | 33 | if daemonFlag { 34 | cfg := config.LoadConfig() 35 | err := daemon.RunDaemon(cfg) 36 | log.ErrorLog.Printf("failed to start daemon %v", err) 37 | return err 38 | } 39 | 40 | // Check if we're in a git repository 41 | currentDir, err := filepath.Abs(".") 42 | if err != nil { 43 | return fmt.Errorf("failed to get current directory: %w", err) 44 | } 45 | 46 | if !git.IsGitRepo(currentDir) { 47 | return fmt.Errorf("error: claude-squad must be run from within a git repository") 48 | } 49 | 50 | cfg := config.LoadConfig() 51 | 52 | // Program flag overrides config 53 | program := cfg.DefaultProgram 54 | if programFlag != "" { 55 | program = programFlag 56 | } 57 | // AutoYes flag overrides config 58 | autoYes := cfg.AutoYes 59 | if autoYesFlag { 60 | autoYes = true 61 | } 62 | if autoYes { 63 | defer func() { 64 | if err := daemon.LaunchDaemon(); err != nil { 65 | log.ErrorLog.Printf("failed to launch daemon: %v", err) 66 | } 67 | }() 68 | } 69 | // Kill any daemon that's running. 70 | if err := daemon.StopDaemon(); err != nil { 71 | log.ErrorLog.Printf("failed to stop daemon: %v", err) 72 | } 73 | 74 | return app.Run(ctx, program, autoYes) 75 | }, 76 | } 77 | 78 | resetCmd = &cobra.Command{ 79 | Use: "reset", 80 | Short: "Reset all stored instances", 81 | RunE: func(cmd *cobra.Command, args []string) error { 82 | log.Initialize(false) 83 | defer log.Close() 84 | 85 | state := config.LoadState() 86 | storage, err := session.NewStorage(state) 87 | if err != nil { 88 | return fmt.Errorf("failed to initialize storage: %w", err) 89 | } 90 | if err := storage.DeleteAllInstances(); err != nil { 91 | return fmt.Errorf("failed to reset storage: %w", err) 92 | } 93 | fmt.Println("Storage has been reset successfully") 94 | 95 | if err := tmux.CleanupSessions(cmd2.MakeExecutor()); err != nil { 96 | return fmt.Errorf("failed to cleanup tmux sessions: %w", err) 97 | } 98 | fmt.Println("Tmux sessions have been cleaned up") 99 | 100 | if err := git.CleanupWorktrees(); err != nil { 101 | return fmt.Errorf("failed to cleanup worktrees: %w", err) 102 | } 103 | fmt.Println("Worktrees have been cleaned up") 104 | 105 | // Kill any daemon that's running. 106 | if err := daemon.StopDaemon(); err != nil { 107 | return err 108 | } 109 | fmt.Println("daemon has been stopped") 110 | 111 | return nil 112 | }, 113 | } 114 | 115 | debugCmd = &cobra.Command{ 116 | Use: "debug", 117 | Short: "Print debug information like config paths", 118 | RunE: func(cmd *cobra.Command, args []string) error { 119 | cfg := config.LoadConfig() 120 | 121 | configDir, err := config.GetConfigDir() 122 | if err != nil { 123 | return fmt.Errorf("failed to get config directory: %w", err) 124 | } 125 | configJson, _ := json.MarshalIndent(cfg, "", " ") 126 | 127 | fmt.Printf("Config: %s\n%s\n", filepath.Join(configDir, config.ConfigFileName), configJson) 128 | 129 | return nil 130 | }, 131 | } 132 | 133 | versionCmd = &cobra.Command{ 134 | Use: "version", 135 | Short: "Print the version number of claude-squad", 136 | Run: func(cmd *cobra.Command, args []string) { 137 | fmt.Printf("claude-squad version %s\n", version) 138 | fmt.Printf("https://github.com/smtg-ai/claude-squad/releases/tag/v%s\n", version) 139 | }, 140 | } 141 | ) 142 | 143 | func init() { 144 | rootCmd.Flags().StringVarP(&programFlag, "program", "p", "", 145 | "Program to run in new instances (e.g. 'aider --model ollama_chat/gemma3:1b')") 146 | rootCmd.Flags().BoolVarP(&autoYesFlag, "autoyes", "y", false, 147 | "[experimental] If enabled, all instances will automatically accept prompts") 148 | rootCmd.Flags().BoolVar(&daemonFlag, "daemon", false, "Run a program that loads all sessions"+ 149 | " and runs autoyes mode on them.") 150 | 151 | // Hide the daemonFlag as it's only for internal use 152 | err := rootCmd.Flags().MarkHidden("daemon") 153 | if err != nil { 154 | panic(err) 155 | } 156 | 157 | rootCmd.AddCommand(debugCmd) 158 | rootCmd.AddCommand(versionCmd) 159 | rootCmd.AddCommand(resetCmd) 160 | } 161 | 162 | func main() { 163 | if err := rootCmd.Execute(); err != nil { 164 | fmt.Println(err) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /session/git/diff.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // DiffStats holds statistics about the changes in a diff 8 | type DiffStats struct { 9 | // Content is the full diff content 10 | Content string 11 | // Added is the number of added lines 12 | Added int 13 | // Removed is the number of removed lines 14 | Removed int 15 | // Error holds any error that occurred during diff computation 16 | // This allows propagating setup errors (like missing base commit) without breaking the flow 17 | Error error 18 | } 19 | 20 | func (d *DiffStats) IsEmpty() bool { 21 | return d.Added == 0 && d.Removed == 0 && d.Content == "" 22 | } 23 | 24 | // Diff returns the git diff between the worktree and the base branch along with statistics 25 | func (g *GitWorktree) Diff() *DiffStats { 26 | stats := &DiffStats{} 27 | 28 | // -N stages untracked files (intent to add), including them in the diff 29 | _, err := g.runGitCommand(g.worktreePath, "add", "-N", ".") 30 | if err != nil { 31 | stats.Error = err 32 | return stats 33 | } 34 | 35 | content, err := g.runGitCommand(g.worktreePath, "--no-pager", "diff", g.GetBaseCommitSHA()) 36 | if err != nil { 37 | stats.Error = err 38 | return stats 39 | } 40 | lines := strings.Split(content, "\n") 41 | for _, line := range lines { 42 | if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { 43 | stats.Added++ 44 | } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { 45 | stats.Removed++ 46 | } 47 | } 48 | stats.Content = content 49 | 50 | return stats 51 | } 52 | -------------------------------------------------------------------------------- /session/git/util.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/go-git/go-git/v5" 11 | ) 12 | 13 | // sanitizeBranchName transforms an arbitrary string into a Git branch name friendly string. 14 | // Note: Git branch names have several rules, so this function uses a simple approach 15 | // by allowing only a safe subset of characters. 16 | func sanitizeBranchName(s string) string { 17 | // Convert to lower-case 18 | s = strings.ToLower(s) 19 | 20 | // Replace spaces with a dash 21 | s = strings.ReplaceAll(s, " ", "-") 22 | 23 | // Remove any characters not allowed in our safe subset. 24 | // Here we allow: letters, digits, dash, underscore, slash, and dot. 25 | re := regexp.MustCompile(`[^a-z0-9\-_/.]+`) 26 | s = re.ReplaceAllString(s, "") 27 | 28 | // Replace multiple dashes with a single dash (optional cleanup) 29 | reDash := regexp.MustCompile(`-+`) 30 | s = reDash.ReplaceAllString(s, "-") 31 | 32 | // Trim leading and trailing dashes or slashes to avoid issues 33 | s = strings.Trim(s, "-/") 34 | 35 | return s 36 | } 37 | 38 | // checkGHCLI checks if GitHub CLI is installed and configured 39 | func checkGHCLI() error { 40 | // Check if gh is installed 41 | if _, err := exec.LookPath("gh"); err != nil { 42 | return fmt.Errorf("GitHub CLI (gh) is not installed. Please install it first") 43 | } 44 | 45 | // Check if gh is authenticated 46 | cmd := exec.Command("gh", "auth", "status") 47 | if err := cmd.Run(); err != nil { 48 | return fmt.Errorf("GitHub CLI is not configured. Please run 'gh auth login' first") 49 | } 50 | 51 | return nil 52 | } 53 | 54 | // IsGitRepo checks if the given path is within a git repository 55 | func IsGitRepo(path string) bool { 56 | for { 57 | _, err := git.PlainOpen(path) 58 | if err == nil { 59 | return true 60 | } 61 | 62 | parent := filepath.Dir(path) 63 | if parent == path { 64 | return false 65 | } 66 | path = parent 67 | } 68 | } 69 | 70 | func findGitRepoRoot(path string) (string, error) { 71 | currentPath := path 72 | for { 73 | _, err := git.PlainOpen(currentPath) 74 | if err == nil { 75 | // Found the repository root 76 | return currentPath, nil 77 | } 78 | 79 | parent := filepath.Dir(currentPath) 80 | if parent == currentPath { 81 | // Reached the filesystem root without finding a repository 82 | return "", fmt.Errorf("failed to find Git repository root from path: %s", path) 83 | } 84 | currentPath = parent 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /session/git/util_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSanitizeBranchName(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | input string 11 | expected string 12 | }{ 13 | { 14 | name: "simple lowercase string", 15 | input: "feature", 16 | expected: "feature", 17 | }, 18 | { 19 | name: "string with spaces", 20 | input: "new feature branch", 21 | expected: "new-feature-branch", 22 | }, 23 | { 24 | name: "mixed case string", 25 | input: "FeAtUrE BrAnCh", 26 | expected: "feature-branch", 27 | }, 28 | { 29 | name: "string with special characters", 30 | input: "feature!@#$%^&*()", 31 | expected: "feature", 32 | }, 33 | { 34 | name: "string with allowed special characters", 35 | input: "feature/sub_branch.v1", 36 | expected: "feature/sub_branch.v1", 37 | }, 38 | { 39 | name: "string with multiple dashes", 40 | input: "feature---branch", 41 | expected: "feature-branch", 42 | }, 43 | { 44 | name: "string with leading and trailing dashes", 45 | input: "-feature-branch-", 46 | expected: "feature-branch", 47 | }, 48 | { 49 | name: "string with leading and trailing slashes", 50 | input: "/feature/branch/", 51 | expected: "feature/branch", 52 | }, 53 | { 54 | name: "empty string", 55 | input: "", 56 | expected: "", 57 | }, 58 | { 59 | name: "complex mixed case with special chars", 60 | input: "USER/Feature Branch!@#$%^&*()/v1.0", 61 | expected: "user/feature-branch/v1.0", 62 | }, 63 | } 64 | 65 | for _, tt := range tests { 66 | t.Run(tt.name, func(t *testing.T) { 67 | got := sanitizeBranchName(tt.input) 68 | if got != tt.expected { 69 | t.Errorf("sanitizeBranchName(%q) = %q, want %q", tt.input, got, tt.expected) 70 | } 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /session/git/worktree.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "claude-squad/config" 5 | "claude-squad/log" 6 | "fmt" 7 | "path/filepath" 8 | "time" 9 | ) 10 | 11 | func getWorktreeDirectory() (string, error) { 12 | configDir, err := config.GetConfigDir() 13 | if err != nil { 14 | return "", err 15 | } 16 | 17 | return filepath.Join(configDir, "worktrees"), nil 18 | } 19 | 20 | // GitWorktree manages git worktree operations for a session 21 | type GitWorktree struct { 22 | // Path to the repository 23 | repoPath string 24 | // Path to the worktree 25 | worktreePath string 26 | // Name of the session 27 | sessionName string 28 | // Branch name for the worktree 29 | branchName string 30 | // Base commit hash for the worktree 31 | baseCommitSHA string 32 | } 33 | 34 | func NewGitWorktreeFromStorage(repoPath string, worktreePath string, sessionName string, branchName string, baseCommitSHA string) *GitWorktree { 35 | return &GitWorktree{ 36 | repoPath: repoPath, 37 | worktreePath: worktreePath, 38 | sessionName: sessionName, 39 | branchName: branchName, 40 | baseCommitSHA: baseCommitSHA, 41 | } 42 | } 43 | 44 | // NewGitWorktree creates a new GitWorktree instance 45 | func NewGitWorktree(repoPath string, sessionName string) (tree *GitWorktree, branchname string, err error) { 46 | cfg := config.LoadConfig() 47 | sanitizedName := sanitizeBranchName(sessionName) 48 | branchName := fmt.Sprintf("%s%s", cfg.BranchPrefix, sanitizedName) 49 | 50 | // Convert repoPath to absolute path 51 | absPath, err := filepath.Abs(repoPath) 52 | if err != nil { 53 | log.ErrorLog.Printf("git worktree path abs error, falling back to repoPath %s: %s", repoPath, err) 54 | // If we can't get absolute path, use original path as fallback 55 | absPath = repoPath 56 | } 57 | 58 | repoPath, err = findGitRepoRoot(absPath) 59 | if err != nil { 60 | return nil, "", err 61 | } 62 | 63 | worktreeDir, err := getWorktreeDirectory() 64 | if err != nil { 65 | return nil, "", err 66 | } 67 | 68 | worktreePath := filepath.Join(worktreeDir, sanitizedName) 69 | worktreePath = worktreePath + "_" + fmt.Sprintf("%x", time.Now().UnixNano()) 70 | 71 | return &GitWorktree{ 72 | repoPath: repoPath, 73 | sessionName: sessionName, 74 | branchName: branchName, 75 | worktreePath: worktreePath, 76 | }, branchName, nil 77 | } 78 | 79 | // GetWorktreePath returns the path to the worktree 80 | func (g *GitWorktree) GetWorktreePath() string { 81 | return g.worktreePath 82 | } 83 | 84 | // GetBranchName returns the name of the branch associated with this worktree 85 | func (g *GitWorktree) GetBranchName() string { 86 | return g.branchName 87 | } 88 | 89 | // GetRepoPath returns the path to the repository 90 | func (g *GitWorktree) GetRepoPath() string { 91 | return g.repoPath 92 | } 93 | 94 | // GetRepoName returns the name of the repository (last part of the repoPath). 95 | func (g *GitWorktree) GetRepoName() string { 96 | return filepath.Base(g.repoPath) 97 | } 98 | 99 | // GetBaseCommitSHA returns the base commit SHA for the worktree 100 | func (g *GitWorktree) GetBaseCommitSHA() string { 101 | return g.baseCommitSHA 102 | } 103 | -------------------------------------------------------------------------------- /session/git/worktree_branch.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/go-git/go-git/v5" 8 | "github.com/go-git/go-git/v5/plumbing" 9 | ) 10 | 11 | // cleanupExistingBranch performs a thorough cleanup of any existing branch or reference 12 | func (g *GitWorktree) cleanupExistingBranch(repo *git.Repository) error { 13 | branchRef := plumbing.NewBranchReferenceName(g.branchName) 14 | 15 | // Try to remove the branch reference 16 | if err := repo.Storer.RemoveReference(branchRef); err != nil && err != plumbing.ErrReferenceNotFound { 17 | return fmt.Errorf("failed to remove branch reference %s: %w", g.branchName, err) 18 | } 19 | 20 | // Remove any worktree-specific references 21 | worktreeRef := plumbing.NewReferenceFromStrings( 22 | fmt.Sprintf("worktrees/%s/HEAD", g.branchName), 23 | "", 24 | ) 25 | if err := repo.Storer.RemoveReference(worktreeRef.Name()); err != nil && err != plumbing.ErrReferenceNotFound { 26 | return fmt.Errorf("failed to remove worktree reference for %s: %w", g.branchName, err) 27 | } 28 | 29 | // Clean up configuration entries 30 | cfg, err := repo.Config() 31 | if err != nil { 32 | return fmt.Errorf("failed to get repository config: %w", err) 33 | } 34 | 35 | delete(cfg.Branches, g.branchName) 36 | worktreeSection := fmt.Sprintf("worktree.%s", g.branchName) 37 | cfg.Raw.RemoveSection(worktreeSection) 38 | 39 | if err := repo.Storer.SetConfig(cfg); err != nil { 40 | return fmt.Errorf("failed to update repository config after removing branch %s: %w", g.branchName, err) 41 | } 42 | 43 | return nil 44 | } 45 | 46 | // combineErrors combines multiple errors into a single error 47 | func (g *GitWorktree) combineErrors(errs []error) error { 48 | if len(errs) == 0 { 49 | return nil 50 | } 51 | if len(errs) == 1 { 52 | return errs[0] 53 | } 54 | 55 | errMsg := "multiple errors occurred:" 56 | for _, err := range errs { 57 | errMsg += "\n - " + err.Error() 58 | } 59 | return errors.New(errMsg) 60 | } 61 | -------------------------------------------------------------------------------- /session/git/worktree_git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "claude-squad/log" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | ) 9 | 10 | // runGitCommand executes a git command and returns any error 11 | func (g *GitWorktree) runGitCommand(path string, args ...string) (string, error) { 12 | baseArgs := []string{"-C", path} 13 | cmd := exec.Command("git", append(baseArgs, args...)...) 14 | 15 | output, err := cmd.CombinedOutput() 16 | if err != nil { 17 | return "", fmt.Errorf("git command failed: %s (%w)", output, err) 18 | } 19 | 20 | return string(output), nil 21 | } 22 | 23 | // PushChanges commits and pushes changes in the worktree to the remote branch 24 | func (g *GitWorktree) PushChanges(commitMessage string, open bool) error { 25 | if err := checkGHCLI(); err != nil { 26 | return err 27 | } 28 | 29 | // Check if there are any changes to commit 30 | isDirty, err := g.IsDirty() 31 | if err != nil { 32 | return fmt.Errorf("failed to check for changes: %w", err) 33 | } 34 | 35 | if isDirty { 36 | // Stage all changes 37 | if _, err := g.runGitCommand(g.worktreePath, "add", "."); err != nil { 38 | log.ErrorLog.Print(err) 39 | return fmt.Errorf("failed to stage changes: %w", err) 40 | } 41 | 42 | // Create commit 43 | if _, err := g.runGitCommand(g.worktreePath, "commit", "-m", commitMessage, "--no-verify"); err != nil { 44 | log.ErrorLog.Print(err) 45 | return fmt.Errorf("failed to commit changes: %w", err) 46 | } 47 | } 48 | 49 | // First push the branch to remote to ensure it exists 50 | pushCmd := exec.Command("gh", "repo", "sync", "--source", "-b", g.branchName) 51 | pushCmd.Dir = g.worktreePath 52 | if err := pushCmd.Run(); err != nil { 53 | // If sync fails, try creating the branch on remote first 54 | gitPushCmd := exec.Command("git", "push", "-u", "origin", g.branchName) 55 | gitPushCmd.Dir = g.worktreePath 56 | if pushOutput, pushErr := gitPushCmd.CombinedOutput(); pushErr != nil { 57 | log.ErrorLog.Print(pushErr) 58 | return fmt.Errorf("failed to push branch: %s (%w)", pushOutput, pushErr) 59 | } 60 | } 61 | 62 | // Now sync with remote 63 | syncCmd := exec.Command("gh", "repo", "sync", "-b", g.branchName) 64 | syncCmd.Dir = g.worktreePath 65 | if output, err := syncCmd.CombinedOutput(); err != nil { 66 | log.ErrorLog.Print(err) 67 | return fmt.Errorf("failed to sync changes: %s (%w)", output, err) 68 | } 69 | 70 | // Open the branch in the browser 71 | if open { 72 | if err := g.OpenBranchURL(); err != nil { 73 | // Just log the error but don't fail the push operation 74 | log.ErrorLog.Printf("failed to open branch URL: %v", err) 75 | } 76 | } 77 | 78 | return nil 79 | } 80 | 81 | // IsDirty checks if the worktree has uncommitted changes 82 | func (g *GitWorktree) IsDirty() (bool, error) { 83 | output, err := g.runGitCommand(g.worktreePath, "status", "--porcelain") 84 | if err != nil { 85 | return false, fmt.Errorf("failed to check worktree status: %w", err) 86 | } 87 | return len(output) > 0, nil 88 | } 89 | 90 | // IsBranchCheckedOut checks if the instance branch is currently checked out 91 | func (g *GitWorktree) IsBranchCheckedOut() (bool, error) { 92 | output, err := g.runGitCommand(g.repoPath, "branch", "--show-current") 93 | if err != nil { 94 | return false, fmt.Errorf("failed to get current branch: %w", err) 95 | } 96 | return strings.TrimSpace(string(output)) == g.branchName, nil 97 | } 98 | 99 | // OpenBranchURL opens the branch URL in the default browser 100 | func (g *GitWorktree) OpenBranchURL() error { 101 | // Check if GitHub CLI is available 102 | if err := checkGHCLI(); err != nil { 103 | return err 104 | } 105 | 106 | cmd := exec.Command("gh", "browse", "--branch", g.branchName) 107 | cmd.Dir = g.worktreePath 108 | if err := cmd.Run(); err != nil { 109 | return fmt.Errorf("failed to open branch URL: %w", err) 110 | } 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /session/git/worktree_ops.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "claude-squad/log" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/go-git/go-git/v5" 12 | "github.com/go-git/go-git/v5/plumbing" 13 | ) 14 | 15 | // Setup creates a new worktree for the session 16 | func (g *GitWorktree) Setup() error { 17 | // Check if branch exists first 18 | repo, err := git.PlainOpen(g.repoPath) 19 | if err != nil { 20 | return fmt.Errorf("failed to open repository: %w", err) 21 | } 22 | 23 | branchRef := plumbing.NewBranchReferenceName(g.branchName) 24 | if _, err := repo.Reference(branchRef, false); err == nil { 25 | // Branch exists, use SetupFromExistingBranch 26 | return g.SetupFromExistingBranch() 27 | } 28 | 29 | // Branch doesn't exist, create new worktree from HEAD 30 | return g.SetupNewWorktree() 31 | } 32 | 33 | // SetupFromExistingBranch creates a worktree from an existing branch 34 | func (g *GitWorktree) SetupFromExistingBranch() error { 35 | // Ensure worktrees directory exists 36 | worktreesDir := filepath.Join(g.repoPath, "worktrees") 37 | if err := os.MkdirAll(worktreesDir, 0755); err != nil { 38 | return fmt.Errorf("failed to create worktrees directory: %w", err) 39 | } 40 | 41 | // Clean up any existing worktree first 42 | _, _ = g.runGitCommand(g.repoPath, "worktree", "remove", "-f", g.worktreePath) // Ignore error if worktree doesn't exist 43 | 44 | // Create a new worktree from the existing branch 45 | if _, err := g.runGitCommand(g.repoPath, "worktree", "add", g.worktreePath, g.branchName); err != nil { 46 | return fmt.Errorf("failed to create worktree from branch %s: %w", g.branchName, err) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // SetupNewWorktree creates a new worktree from HEAD 53 | func (g *GitWorktree) SetupNewWorktree() error { 54 | // Ensure worktrees directory exists 55 | worktreesDir := filepath.Join(g.repoPath, "worktrees") 56 | if err := os.MkdirAll(worktreesDir, 0755); err != nil { 57 | return fmt.Errorf("failed to create worktrees directory: %w", err) 58 | } 59 | 60 | // Clean up any existing worktree first 61 | _, _ = g.runGitCommand(g.repoPath, "worktree", "remove", "-f", g.worktreePath) // Ignore error if worktree doesn't exist 62 | 63 | // Open the repository 64 | repo, err := git.PlainOpen(g.repoPath) 65 | if err != nil { 66 | return fmt.Errorf("failed to open repository: %w", err) 67 | } 68 | 69 | // Clean up any existing branch or reference 70 | if err := g.cleanupExistingBranch(repo); err != nil { 71 | return fmt.Errorf("failed to cleanup existing branch: %w", err) 72 | } 73 | 74 | output, err := g.runGitCommand(g.repoPath, "rev-parse", "HEAD") 75 | if err != nil { 76 | if strings.Contains(err.Error(), "fatal: ambiguous argument 'HEAD'") || 77 | strings.Contains(err.Error(), "fatal: not a valid object name") || 78 | strings.Contains(err.Error(), "fatal: HEAD: not a valid object name") { 79 | return fmt.Errorf("this appears to be a brand new repository: please create an initial commit before creating an instance") 80 | } 81 | return fmt.Errorf("failed to get HEAD commit hash: %w", err) 82 | } 83 | headCommit := strings.TrimSpace(string(output)) 84 | g.baseCommitSHA = headCommit 85 | 86 | // Create a new worktree from the HEAD commit 87 | // Otherwise, we'll inherit uncommitted changes from the previous worktree. 88 | // This way, we can start the worktree with a clean slate. 89 | // TODO: we might want to give an option to use main/master instead of the current branch. 90 | if _, err := g.runGitCommand(g.repoPath, "worktree", "add", "-b", g.branchName, g.worktreePath, headCommit); err != nil { 91 | return fmt.Errorf("failed to create worktree from commit %s: %w", headCommit, err) 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // Cleanup removes the worktree and associated branch 98 | func (g *GitWorktree) Cleanup() error { 99 | var errs []error 100 | 101 | // Check if worktree path exists before attempting removal 102 | if _, err := os.Stat(g.worktreePath); err == nil { 103 | // Remove the worktree using git command 104 | if _, err := g.runGitCommand(g.repoPath, "worktree", "remove", "-f", g.worktreePath); err != nil { 105 | errs = append(errs, err) 106 | } 107 | } else if !os.IsNotExist(err) { 108 | // Only append error if it's not a "not exists" error 109 | errs = append(errs, fmt.Errorf("failed to check worktree path: %w", err)) 110 | } 111 | 112 | // Open the repository for branch cleanup 113 | repo, err := git.PlainOpen(g.repoPath) 114 | if err != nil { 115 | errs = append(errs, fmt.Errorf("failed to open repository for cleanup: %w", err)) 116 | return g.combineErrors(errs) 117 | } 118 | 119 | branchRef := plumbing.NewBranchReferenceName(g.branchName) 120 | 121 | // Check if branch exists before attempting removal 122 | if _, err := repo.Reference(branchRef, false); err == nil { 123 | if err := repo.Storer.RemoveReference(branchRef); err != nil { 124 | errs = append(errs, fmt.Errorf("failed to remove branch %s: %w", g.branchName, err)) 125 | } 126 | } else if err != plumbing.ErrReferenceNotFound { 127 | errs = append(errs, fmt.Errorf("error checking branch %s existence: %w", g.branchName, err)) 128 | } 129 | 130 | // Prune the worktree to clean up any remaining references 131 | if err := g.Prune(); err != nil { 132 | errs = append(errs, err) 133 | } 134 | 135 | if len(errs) > 0 { 136 | return g.combineErrors(errs) 137 | } 138 | 139 | return nil 140 | } 141 | 142 | // Remove removes the worktree but keeps the branch 143 | func (g *GitWorktree) Remove() error { 144 | // Remove the worktree using git command 145 | if _, err := g.runGitCommand(g.repoPath, "worktree", "remove", "-f", g.worktreePath); err != nil { 146 | return fmt.Errorf("failed to remove worktree: %w", err) 147 | } 148 | 149 | return nil 150 | } 151 | 152 | // Prune removes all working tree administrative files and directories 153 | func (g *GitWorktree) Prune() error { 154 | if _, err := g.runGitCommand(g.repoPath, "worktree", "prune"); err != nil { 155 | return fmt.Errorf("failed to prune worktrees: %w", err) 156 | } 157 | return nil 158 | } 159 | 160 | // CleanupWorktrees removes all worktrees and their associated branches 161 | func CleanupWorktrees() error { 162 | worktreesDir, err := getWorktreeDirectory() 163 | if err != nil { 164 | return fmt.Errorf("failed to get worktree directory: %w", err) 165 | } 166 | 167 | entries, err := os.ReadDir(worktreesDir) 168 | if err != nil { 169 | return fmt.Errorf("failed to read worktree directory: %w", err) 170 | } 171 | 172 | // Get a list of all branches associated with worktrees 173 | cmd := exec.Command("git", "worktree", "list", "--porcelain") 174 | output, err := cmd.Output() 175 | if err != nil { 176 | return fmt.Errorf("failed to list worktrees: %w", err) 177 | } 178 | 179 | // Parse the output to extract branch names 180 | worktreeBranches := make(map[string]string) 181 | currentWorktree := "" 182 | lines := strings.Split(string(output), "\n") 183 | for _, line := range lines { 184 | if strings.HasPrefix(line, "worktree ") { 185 | currentWorktree = strings.TrimPrefix(line, "worktree ") 186 | } else if strings.HasPrefix(line, "branch ") { 187 | branchPath := strings.TrimPrefix(line, "branch ") 188 | // Extract branch name from refs/heads/branch-name 189 | branchName := strings.TrimPrefix(branchPath, "refs/heads/") 190 | if currentWorktree != "" { 191 | worktreeBranches[currentWorktree] = branchName 192 | } 193 | } 194 | } 195 | 196 | for _, entry := range entries { 197 | if entry.IsDir() { 198 | worktreePath := filepath.Join(worktreesDir, entry.Name()) 199 | 200 | // Delete the branch associated with this worktree if found 201 | for path, branch := range worktreeBranches { 202 | if strings.Contains(path, entry.Name()) { 203 | // Delete the branch 204 | deleteCmd := exec.Command("git", "branch", "-D", branch) 205 | if err := deleteCmd.Run(); err != nil { 206 | // Log the error but continue with other worktrees 207 | log.ErrorLog.Printf("failed to delete branch %s: %v", branch, err) 208 | } 209 | break 210 | } 211 | } 212 | 213 | // Remove the worktree directory 214 | os.RemoveAll(worktreePath) 215 | } 216 | } 217 | 218 | // You have to prune the cleaned up worktrees. 219 | cmd = exec.Command("git", "worktree", "prune") 220 | _, err = cmd.Output() 221 | if err != nil { 222 | return fmt.Errorf("failed to prune worktrees: %w", err) 223 | } 224 | 225 | return nil 226 | } 227 | -------------------------------------------------------------------------------- /session/instance.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "claude-squad/log" 5 | "claude-squad/session/git" 6 | "claude-squad/session/tmux" 7 | "path/filepath" 8 | 9 | "fmt" 10 | "os" 11 | "strings" 12 | "time" 13 | 14 | "github.com/atotto/clipboard" 15 | ) 16 | 17 | type Status int 18 | 19 | const ( 20 | // Running is the status when the instance is running and claude is working. 21 | Running Status = iota 22 | // Ready is if the claude instance is ready to be interacted with (waiting for user input). 23 | Ready 24 | // Loading is if the instance is loading (if we are starting it up or something). 25 | Loading 26 | // Paused is if the instance is paused (worktree removed but branch preserved). 27 | Paused 28 | ) 29 | 30 | // Instance is a running instance of claude code. 31 | type Instance struct { 32 | // Title is the title of the instance. 33 | Title string 34 | // Path is the path to the workspace. 35 | Path string 36 | // Branch is the branch of the instance. 37 | Branch string 38 | // Status is the status of the instance. 39 | Status Status 40 | // Program is the program to run in the instance. 41 | Program string 42 | // Height is the height of the instance. 43 | Height int 44 | // Width is the width of the instance. 45 | Width int 46 | // CreatedAt is the time the instance was created. 47 | CreatedAt time.Time 48 | // UpdatedAt is the time the instance was last updated. 49 | UpdatedAt time.Time 50 | // AutoYes is true if the instance should automatically press enter when prompted. 51 | AutoYes bool 52 | // Prompt is the initial prompt to pass to the instance on startup 53 | Prompt string 54 | 55 | // DiffStats stores the current git diff statistics 56 | diffStats *git.DiffStats 57 | 58 | // The below fields are initialized upon calling Start(). 59 | 60 | started bool 61 | // tmuxSession is the tmux session for the instance. 62 | tmuxSession *tmux.TmuxSession 63 | // gitWorktree is the git worktree for the instance. 64 | gitWorktree *git.GitWorktree 65 | } 66 | 67 | // ToInstanceData converts an Instance to its serializable form 68 | func (i *Instance) ToInstanceData() InstanceData { 69 | data := InstanceData{ 70 | Title: i.Title, 71 | Path: i.Path, 72 | Branch: i.Branch, 73 | Status: i.Status, 74 | Height: i.Height, 75 | Width: i.Width, 76 | CreatedAt: i.CreatedAt, 77 | UpdatedAt: time.Now(), 78 | Program: i.Program, 79 | AutoYes: i.AutoYes, 80 | } 81 | 82 | // Only include worktree data if gitWorktree is initialized 83 | if i.gitWorktree != nil { 84 | data.Worktree = GitWorktreeData{ 85 | RepoPath: i.gitWorktree.GetRepoPath(), 86 | WorktreePath: i.gitWorktree.GetWorktreePath(), 87 | SessionName: i.Title, 88 | BranchName: i.gitWorktree.GetBranchName(), 89 | BaseCommitSHA: i.gitWorktree.GetBaseCommitSHA(), 90 | } 91 | } 92 | 93 | // Only include diff stats if they exist 94 | if i.diffStats != nil { 95 | data.DiffStats = DiffStatsData{ 96 | Added: i.diffStats.Added, 97 | Removed: i.diffStats.Removed, 98 | Content: i.diffStats.Content, 99 | } 100 | } 101 | 102 | return data 103 | } 104 | 105 | // FromInstanceData creates a new Instance from serialized data 106 | func FromInstanceData(data InstanceData) (*Instance, error) { 107 | instance := &Instance{ 108 | Title: data.Title, 109 | Path: data.Path, 110 | Branch: data.Branch, 111 | Status: data.Status, 112 | Height: data.Height, 113 | Width: data.Width, 114 | CreatedAt: data.CreatedAt, 115 | UpdatedAt: data.UpdatedAt, 116 | Program: data.Program, 117 | gitWorktree: git.NewGitWorktreeFromStorage( 118 | data.Worktree.RepoPath, 119 | data.Worktree.WorktreePath, 120 | data.Worktree.SessionName, 121 | data.Worktree.BranchName, 122 | data.Worktree.BaseCommitSHA, 123 | ), 124 | diffStats: &git.DiffStats{ 125 | Added: data.DiffStats.Added, 126 | Removed: data.DiffStats.Removed, 127 | Content: data.DiffStats.Content, 128 | }, 129 | } 130 | 131 | if instance.Paused() { 132 | instance.started = true 133 | instance.tmuxSession = tmux.NewTmuxSession(instance.Title, instance.Program) 134 | } else { 135 | if err := instance.Start(false); err != nil { 136 | return nil, err 137 | } 138 | } 139 | 140 | return instance, nil 141 | } 142 | 143 | // Options for creating a new instance 144 | type InstanceOptions struct { 145 | // Title is the title of the instance. 146 | Title string 147 | // Path is the path to the workspace. 148 | Path string 149 | // Program is the program to run in the instance (e.g. "claude", "aider --model ollama_chat/gemma3:1b") 150 | Program string 151 | // If AutoYes is true, then 152 | AutoYes bool 153 | } 154 | 155 | func NewInstance(opts InstanceOptions) (*Instance, error) { 156 | t := time.Now() 157 | 158 | // Convert path to absolute 159 | absPath, err := filepath.Abs(opts.Path) 160 | if err != nil { 161 | return nil, fmt.Errorf("failed to get absolute path: %w", err) 162 | } 163 | 164 | return &Instance{ 165 | Title: opts.Title, 166 | Status: Ready, 167 | Path: absPath, 168 | Program: opts.Program, 169 | Height: 0, 170 | Width: 0, 171 | CreatedAt: t, 172 | UpdatedAt: t, 173 | AutoYes: false, 174 | }, nil 175 | } 176 | 177 | func (i *Instance) RepoName() (string, error) { 178 | if !i.started { 179 | return "", fmt.Errorf("cannot get repo name for instance that has not been started") 180 | } 181 | return i.gitWorktree.GetRepoName(), nil 182 | } 183 | 184 | func (i *Instance) SetStatus(status Status) { 185 | i.Status = status 186 | } 187 | 188 | // firstTimeSetup is true if this is a new instance. Otherwise, it's one loaded from storage. 189 | func (i *Instance) Start(firstTimeSetup bool) error { 190 | if i.Title == "" { 191 | return fmt.Errorf("instance title cannot be empty") 192 | } 193 | 194 | tmuxSession := tmux.NewTmuxSession(i.Title, i.Program) 195 | i.tmuxSession = tmuxSession 196 | 197 | if firstTimeSetup { 198 | gitWorktree, branchName, err := git.NewGitWorktree(i.Path, i.Title) 199 | if err != nil { 200 | return fmt.Errorf("failed to create git worktree: %w", err) 201 | } 202 | i.gitWorktree = gitWorktree 203 | i.Branch = branchName 204 | } 205 | 206 | // Setup error handler to cleanup resources on any error 207 | var setupErr error 208 | defer func() { 209 | if setupErr != nil { 210 | if cleanupErr := i.Kill(); cleanupErr != nil { 211 | setupErr = fmt.Errorf("%v (cleanup error: %v)", setupErr, cleanupErr) 212 | } 213 | } else { 214 | i.started = true 215 | } 216 | }() 217 | 218 | if !firstTimeSetup { 219 | // Reuse existing session 220 | if err := tmuxSession.Restore(); err != nil { 221 | setupErr = fmt.Errorf("failed to restore existing session: %w", err) 222 | return setupErr 223 | } 224 | } else { 225 | // Setup git worktree first 226 | if err := i.gitWorktree.Setup(); err != nil { 227 | setupErr = fmt.Errorf("failed to setup git worktree: %w", err) 228 | return setupErr 229 | } 230 | 231 | // Create new session 232 | if err := i.tmuxSession.Start(i.gitWorktree.GetWorktreePath()); err != nil { 233 | // Cleanup git worktree if tmux session creation fails 234 | if cleanupErr := i.gitWorktree.Cleanup(); cleanupErr != nil { 235 | err = fmt.Errorf("%v (cleanup error: %v)", err, cleanupErr) 236 | } 237 | setupErr = fmt.Errorf("failed to start new session: %w", err) 238 | return setupErr 239 | } 240 | } 241 | 242 | i.SetStatus(Running) 243 | 244 | return nil 245 | } 246 | 247 | // Kill terminates the instance and cleans up all resources 248 | func (i *Instance) Kill() error { 249 | if !i.started { 250 | // If instance was never started, just return success 251 | return nil 252 | } 253 | 254 | var errs []error 255 | 256 | // Always try to cleanup both resources, even if one fails 257 | // Clean up tmux session first since it's using the git worktree 258 | if i.tmuxSession != nil { 259 | if err := i.tmuxSession.Close(); err != nil { 260 | errs = append(errs, fmt.Errorf("failed to close tmux session: %w", err)) 261 | } 262 | } 263 | 264 | // Then clean up git worktree 265 | if i.gitWorktree != nil { 266 | if err := i.gitWorktree.Cleanup(); err != nil { 267 | errs = append(errs, fmt.Errorf("failed to cleanup git worktree: %w", err)) 268 | } 269 | } 270 | 271 | return i.combineErrors(errs) 272 | } 273 | 274 | // combineErrors combines multiple errors into a single error 275 | func (i *Instance) combineErrors(errs []error) error { 276 | if len(errs) == 0 { 277 | return nil 278 | } 279 | if len(errs) == 1 { 280 | return errs[0] 281 | } 282 | 283 | errMsg := "multiple cleanup errors occurred:" 284 | for _, err := range errs { 285 | errMsg += "\n - " + err.Error() 286 | } 287 | return fmt.Errorf("%s", errMsg) 288 | } 289 | 290 | // Close is an alias for Kill to maintain backward compatibility 291 | func (i *Instance) Close() error { 292 | if !i.started { 293 | return fmt.Errorf("cannot close instance that has not been started") 294 | } 295 | return i.Kill() 296 | } 297 | 298 | func (i *Instance) Preview() (string, error) { 299 | if !i.started || i.Status == Paused { 300 | return "", nil 301 | } 302 | return i.tmuxSession.CapturePaneContent() 303 | } 304 | 305 | func (i *Instance) HasUpdated() (updated bool, hasPrompt bool) { 306 | if !i.started { 307 | return false, false 308 | } 309 | return i.tmuxSession.HasUpdated() 310 | } 311 | 312 | // TapEnter sends an enter key press to the tmux session if AutoYes is enabled. 313 | func (i *Instance) TapEnter() { 314 | if !i.started || !i.AutoYes { 315 | return 316 | } 317 | if err := i.tmuxSession.TapEnter(); err != nil { 318 | log.ErrorLog.Printf("error tapping enter: %v", err) 319 | } 320 | } 321 | 322 | func (i *Instance) Attach() (chan struct{}, error) { 323 | if !i.started { 324 | return nil, fmt.Errorf("cannot attach instance that has not been started") 325 | } 326 | return i.tmuxSession.Attach() 327 | } 328 | 329 | func (i *Instance) SetPreviewSize(width, height int) error { 330 | if !i.started || i.Status == Paused { 331 | return fmt.Errorf("cannot set preview size for instance that has not been started or " + 332 | "is paused") 333 | } 334 | return i.tmuxSession.SetDetachedSize(width, height) 335 | } 336 | 337 | // GetGitWorktree returns the git worktree for the instance 338 | func (i *Instance) GetGitWorktree() (*git.GitWorktree, error) { 339 | if !i.started { 340 | return nil, fmt.Errorf("cannot get git worktree for instance that has not been started") 341 | } 342 | return i.gitWorktree, nil 343 | } 344 | 345 | func (i *Instance) Started() bool { 346 | return i.started 347 | } 348 | 349 | // SetTitle sets the title of the instance. Returns an error if the instance has started. 350 | // We cant change the title once it's been used for a tmux session etc. 351 | func (i *Instance) SetTitle(title string) error { 352 | if i.started { 353 | return fmt.Errorf("cannot change title of a started instance") 354 | } 355 | i.Title = title 356 | return nil 357 | } 358 | 359 | func (i *Instance) Paused() bool { 360 | return i.Status == Paused 361 | } 362 | 363 | // TmuxAlive returns true if the tmux session is alive. This is a sanity check before attaching. 364 | func (i *Instance) TmuxAlive() bool { 365 | return i.tmuxSession.DoesSessionExist() 366 | } 367 | 368 | // Pause stops the tmux session and removes the worktree, preserving the branch 369 | func (i *Instance) Pause() error { 370 | if !i.started { 371 | return fmt.Errorf("cannot pause instance that has not been started") 372 | } 373 | if i.Status == Paused { 374 | return fmt.Errorf("instance is already paused") 375 | } 376 | 377 | var errs []error 378 | 379 | // Check if there are any changes to commit 380 | if dirty, err := i.gitWorktree.IsDirty(); err != nil { 381 | errs = append(errs, fmt.Errorf("failed to check if worktree is dirty: %w", err)) 382 | log.ErrorLog.Print(err) 383 | } else if dirty { 384 | // Commit changes with timestamp 385 | commitMsg := fmt.Sprintf("[claudesquad] update from '%s' on %s (paused)", i.Title, time.Now().Format(time.RFC822)) 386 | if err := i.gitWorktree.PushChanges(commitMsg, false); err != nil { 387 | errs = append(errs, fmt.Errorf("failed to commit changes: %w", err)) 388 | log.ErrorLog.Print(err) 389 | // Return early if we can't commit changes to avoid corrupted state 390 | return i.combineErrors(errs) 391 | } 392 | } 393 | 394 | // Close tmux session first since it's using the git worktree 395 | if err := i.tmuxSession.Close(); err != nil { 396 | errs = append(errs, fmt.Errorf("failed to close tmux session: %w", err)) 397 | log.ErrorLog.Print(err) 398 | // Return early if we can't close tmux to avoid corrupted state 399 | return i.combineErrors(errs) 400 | } 401 | 402 | // Check if worktree exists before trying to remove it 403 | if _, err := os.Stat(i.gitWorktree.GetWorktreePath()); err == nil { 404 | // Remove worktree but keep branch 405 | if err := i.gitWorktree.Remove(); err != nil { 406 | errs = append(errs, fmt.Errorf("failed to remove git worktree: %w", err)) 407 | log.ErrorLog.Print(err) 408 | return i.combineErrors(errs) 409 | } 410 | 411 | // Only prune if remove was successful 412 | if err := i.gitWorktree.Prune(); err != nil { 413 | errs = append(errs, fmt.Errorf("failed to prune git worktrees: %w", err)) 414 | log.ErrorLog.Print(err) 415 | return i.combineErrors(errs) 416 | } 417 | } 418 | 419 | if err := i.combineErrors(errs); err != nil { 420 | log.ErrorLog.Print(err) 421 | return err 422 | } 423 | 424 | i.SetStatus(Paused) 425 | _ = clipboard.WriteAll(i.gitWorktree.GetBranchName()) 426 | return nil 427 | } 428 | 429 | // Resume recreates the worktree and restarts the tmux session 430 | func (i *Instance) Resume() error { 431 | if !i.started { 432 | return fmt.Errorf("cannot resume instance that has not been started") 433 | } 434 | if i.Status != Paused { 435 | return fmt.Errorf("can only resume paused instances") 436 | } 437 | 438 | // Check if branch is checked out 439 | if checked, err := i.gitWorktree.IsBranchCheckedOut(); err != nil { 440 | log.ErrorLog.Print(err) 441 | return fmt.Errorf("failed to check if branch is checked out: %w", err) 442 | } else if checked { 443 | return fmt.Errorf("cannot resume: branch is checked out, please switch to a different branch") 444 | } 445 | 446 | // Setup git worktree 447 | if err := i.gitWorktree.Setup(); err != nil { 448 | log.ErrorLog.Print(err) 449 | return fmt.Errorf("failed to setup git worktree: %w", err) 450 | } 451 | 452 | // Create new tmux session 453 | if err := i.tmuxSession.Start(i.gitWorktree.GetWorktreePath()); err != nil { 454 | log.ErrorLog.Print(err) 455 | // Cleanup git worktree if tmux session creation fails 456 | if cleanupErr := i.gitWorktree.Cleanup(); cleanupErr != nil { 457 | err = fmt.Errorf("%v (cleanup error: %v)", err, cleanupErr) 458 | log.ErrorLog.Print(err) 459 | } 460 | return fmt.Errorf("failed to start new session: %w", err) 461 | } 462 | 463 | i.SetStatus(Running) 464 | return nil 465 | } 466 | 467 | // UpdateDiffStats updates the git diff statistics for this instance 468 | func (i *Instance) UpdateDiffStats() error { 469 | if !i.started { 470 | i.diffStats = nil 471 | return nil 472 | } 473 | 474 | if i.Status == Paused { 475 | // Keep the previous diff stats if the instance is paused 476 | return nil 477 | } 478 | 479 | stats := i.gitWorktree.Diff() 480 | if stats.Error != nil { 481 | if strings.Contains(stats.Error.Error(), "base commit SHA not set") { 482 | // Worktree is not fully set up yet, not an error 483 | i.diffStats = nil 484 | return nil 485 | } 486 | return fmt.Errorf("failed to get diff stats: %w", stats.Error) 487 | } 488 | 489 | i.diffStats = stats 490 | return nil 491 | } 492 | 493 | // GetDiffStats returns the current git diff statistics 494 | func (i *Instance) GetDiffStats() *git.DiffStats { 495 | return i.diffStats 496 | } 497 | 498 | // SendPrompt sends a prompt to the tmux session 499 | func (i *Instance) SendPrompt(prompt string) error { 500 | if !i.started { 501 | return fmt.Errorf("instance not started") 502 | } 503 | if i.tmuxSession == nil { 504 | return fmt.Errorf("tmux session not initialized") 505 | } 506 | if err := i.tmuxSession.SendKeys(prompt); err != nil { 507 | return fmt.Errorf("error sending keys to tmux session: %w", err) 508 | } 509 | 510 | // Brief pause to prevent carriage return from being interpreted as newline 511 | time.Sleep(100 * time.Millisecond) 512 | if err := i.tmuxSession.TapEnter(); err != nil { 513 | return fmt.Errorf("error tapping enter: %w", err) 514 | } 515 | 516 | return nil 517 | } 518 | -------------------------------------------------------------------------------- /session/storage.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "claude-squad/config" 5 | "encoding/json" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | // InstanceData represents the serializable data of an Instance 11 | type InstanceData struct { 12 | Title string `json:"title"` 13 | Path string `json:"path"` 14 | Branch string `json:"branch"` 15 | Status Status `json:"status"` 16 | Height int `json:"height"` 17 | Width int `json:"width"` 18 | CreatedAt time.Time `json:"created_at"` 19 | UpdatedAt time.Time `json:"updated_at"` 20 | AutoYes bool `json:"auto_yes"` 21 | 22 | Program string `json:"program"` 23 | Worktree GitWorktreeData `json:"worktree"` 24 | DiffStats DiffStatsData `json:"diff_stats"` 25 | } 26 | 27 | // GitWorktreeData represents the serializable data of a GitWorktree 28 | type GitWorktreeData struct { 29 | RepoPath string `json:"repo_path"` 30 | WorktreePath string `json:"worktree_path"` 31 | SessionName string `json:"session_name"` 32 | BranchName string `json:"branch_name"` 33 | BaseCommitSHA string `json:"base_commit_sha"` 34 | } 35 | 36 | // DiffStatsData represents the serializable data of a DiffStats 37 | type DiffStatsData struct { 38 | Added int `json:"added"` 39 | Removed int `json:"removed"` 40 | Content string `json:"content"` 41 | } 42 | 43 | // Storage handles saving and loading instances using the state interface 44 | type Storage struct { 45 | state config.InstanceStorage 46 | } 47 | 48 | // NewStorage creates a new storage instance 49 | func NewStorage(state config.InstanceStorage) (*Storage, error) { 50 | return &Storage{ 51 | state: state, 52 | }, nil 53 | } 54 | 55 | // SaveInstances saves the list of instances to disk 56 | func (s *Storage) SaveInstances(instances []*Instance) error { 57 | // Convert instances to InstanceData 58 | data := make([]InstanceData, 0) 59 | for _, instance := range instances { 60 | if instance.Started() { 61 | data = append(data, instance.ToInstanceData()) 62 | } 63 | } 64 | 65 | // Marshal to JSON 66 | jsonData, err := json.Marshal(data) 67 | if err != nil { 68 | return fmt.Errorf("failed to marshal instances: %w", err) 69 | } 70 | 71 | return s.state.SaveInstances(jsonData) 72 | } 73 | 74 | // LoadInstances loads the list of instances from disk 75 | func (s *Storage) LoadInstances() ([]*Instance, error) { 76 | jsonData := s.state.GetInstances() 77 | 78 | var instancesData []InstanceData 79 | if err := json.Unmarshal(jsonData, &instancesData); err != nil { 80 | return nil, fmt.Errorf("failed to unmarshal instances: %w", err) 81 | } 82 | 83 | instances := make([]*Instance, len(instancesData)) 84 | for i, data := range instancesData { 85 | instance, err := FromInstanceData(data) 86 | if err != nil { 87 | return nil, fmt.Errorf("failed to create instance %s: %w", data.Title, err) 88 | } 89 | instances[i] = instance 90 | } 91 | 92 | return instances, nil 93 | } 94 | 95 | // DeleteInstance removes an instance from storage 96 | func (s *Storage) DeleteInstance(title string) error { 97 | instances, err := s.LoadInstances() 98 | if err != nil { 99 | return fmt.Errorf("failed to load instances: %w", err) 100 | } 101 | 102 | found := false 103 | newInstances := make([]*Instance, 0) 104 | for _, instance := range instances { 105 | data := instance.ToInstanceData() 106 | if data.Title != title { 107 | newInstances = append(newInstances, instance) 108 | } else { 109 | found = true 110 | } 111 | } 112 | 113 | if !found { 114 | return fmt.Errorf("instance not found: %s", title) 115 | } 116 | 117 | return s.SaveInstances(newInstances) 118 | } 119 | 120 | // UpdateInstance updates an existing instance in storage 121 | func (s *Storage) UpdateInstance(instance *Instance) error { 122 | instances, err := s.LoadInstances() 123 | if err != nil { 124 | return fmt.Errorf("failed to load instances: %w", err) 125 | } 126 | 127 | data := instance.ToInstanceData() 128 | found := false 129 | for i, existing := range instances { 130 | existingData := existing.ToInstanceData() 131 | if existingData.Title == data.Title { 132 | instances[i] = instance 133 | found = true 134 | break 135 | } 136 | } 137 | 138 | if !found { 139 | return fmt.Errorf("instance not found: %s", data.Title) 140 | } 141 | 142 | return s.SaveInstances(instances) 143 | } 144 | 145 | // DeleteAllInstances removes all stored instances 146 | func (s *Storage) DeleteAllInstances() error { 147 | return s.state.DeleteAllInstances() 148 | } 149 | -------------------------------------------------------------------------------- /session/tmux/pty.go: -------------------------------------------------------------------------------- 1 | package tmux 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | 7 | "github.com/creack/pty" 8 | ) 9 | 10 | type PtyFactory interface { 11 | Start(cmd *exec.Cmd) (*os.File, error) 12 | Close() 13 | } 14 | 15 | // Pty starts a "real" pseudo-terminal (PTY) using the creack/pty package. 16 | type Pty struct{} 17 | 18 | func (pt Pty) Start(cmd *exec.Cmd) (*os.File, error) { 19 | return pty.Start(cmd) 20 | } 21 | 22 | func (pt Pty) Close() {} 23 | 24 | func MakePtyFactory() PtyFactory { 25 | return Pty{} 26 | } 27 | -------------------------------------------------------------------------------- /session/tmux/tmux.go: -------------------------------------------------------------------------------- 1 | package tmux 2 | 3 | import ( 4 | "bytes" 5 | "claude-squad/cmd" 6 | "claude-squad/log" 7 | "context" 8 | "crypto/sha256" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "os" 13 | "os/exec" 14 | "regexp" 15 | "strings" 16 | "sync" 17 | "time" 18 | 19 | "github.com/creack/pty" 20 | ) 21 | 22 | const ProgramClaude = "claude" 23 | 24 | const ProgramAider = "aider" 25 | 26 | // TmuxSession represents a managed tmux session 27 | type TmuxSession struct { 28 | // Initialized by NewTmuxSession 29 | // 30 | // The name of the tmux session and the sanitized name used for tmux commands. 31 | sanitizedName string 32 | program string 33 | // ptyFactory is used to create a PTY for the tmux session. 34 | ptyFactory PtyFactory 35 | // cmdExec is used to execute commands in the tmux session. 36 | cmdExec cmd.Executor 37 | 38 | // Initialized by Start or Restore 39 | // 40 | // ptmx is a PTY is running the tmux attach command. This can be resized to change the 41 | // stdout dimensions of the tmux pane. On detach, we close it and set a new one. 42 | // This should never be nil. 43 | ptmx *os.File 44 | // monitor monitors the tmux pane content and sends signals to the UI when it's status changes 45 | monitor *statusMonitor 46 | 47 | // Initialized by Attach 48 | // Deinitilaized by Detach 49 | // 50 | // Channel to be closed at the very end of detaching. Used to signal callers. 51 | attachCh chan struct{} 52 | // While attached, we use some goroutines to manage the window size and stdin/stdout. This stuff 53 | // is used to terminate them on Detach. We don't want them to outlive the attached window. 54 | ctx context.Context 55 | cancel func() 56 | wg *sync.WaitGroup 57 | } 58 | 59 | const TmuxPrefix = "claudesquad_" 60 | 61 | var whiteSpaceRegex = regexp.MustCompile(`\s+`) 62 | 63 | func toClaudeSquadTmuxName(str string) string { 64 | str = whiteSpaceRegex.ReplaceAllString(str, "") 65 | str = strings.ReplaceAll(str, ".", "_") // tmux replaces all . with _ 66 | return fmt.Sprintf("%s%s", TmuxPrefix, str) 67 | } 68 | 69 | // NewTmuxSession creates a new TmuxSession with the given name and program. 70 | func NewTmuxSession(name string, program string) *TmuxSession { 71 | return newTmuxSession(name, program, MakePtyFactory(), cmd.MakeExecutor()) 72 | } 73 | 74 | func newTmuxSession(name string, program string, ptyFactory PtyFactory, cmdExec cmd.Executor) *TmuxSession { 75 | return &TmuxSession{ 76 | sanitizedName: toClaudeSquadTmuxName(name), 77 | program: program, 78 | ptyFactory: ptyFactory, 79 | cmdExec: cmdExec, 80 | } 81 | } 82 | 83 | // Start creates and starts a new tmux session, then attaches to it. Program is the command to run in 84 | // the session (ex. claude). workdir is the git worktree directory. 85 | func (t *TmuxSession) Start(workDir string) error { 86 | // Check if the session already exists 87 | if t.DoesSessionExist() { 88 | return fmt.Errorf("tmux session already exists: %s", t.sanitizedName) 89 | } 90 | 91 | // Create a new detached tmux session and start claude in it 92 | cmd := exec.Command("tmux", "new-session", "-d", "-s", t.sanitizedName, "-c", workDir, t.program) 93 | 94 | ptmx, err := t.ptyFactory.Start(cmd) 95 | if err != nil { 96 | // Cleanup any partially created session if any exists. 97 | if t.DoesSessionExist() { 98 | cleanupCmd := exec.Command("tmux", "kill-session", "-t", t.sanitizedName) 99 | if cleanupErr := t.cmdExec.Run(cleanupCmd); cleanupErr != nil { 100 | err = fmt.Errorf("%v (cleanup error: %v)", err, cleanupErr) 101 | } 102 | } 103 | return fmt.Errorf("error starting tmux session: %w", err) 104 | } 105 | 106 | // We need to close the ptmx, but we shouldn't close it before the command above finishes. 107 | // So, we poll for completion before closing. 108 | timeout := time.After(2 * time.Second) 109 | for !t.DoesSessionExist() { 110 | select { 111 | case <-timeout: 112 | // Cleanup on window size update failure 113 | if cleanupErr := t.Close(); cleanupErr != nil { 114 | err = fmt.Errorf("%v (cleanup error: %v)", err, cleanupErr) 115 | } 116 | return fmt.Errorf("timed out waiting for tmux session %s: %v", t.sanitizedName, err) 117 | default: 118 | time.Sleep(time.Millisecond * 10) 119 | } 120 | } 121 | ptmx.Close() 122 | 123 | err = t.Restore() 124 | if err != nil { 125 | if cleanupErr := t.Close(); cleanupErr != nil { 126 | err = fmt.Errorf("%v (cleanup error: %v)", err, cleanupErr) 127 | } 128 | return fmt.Errorf("error restoring tmux session: %w", err) 129 | } 130 | 131 | if t.program == ProgramClaude || strings.HasPrefix(t.program, ProgramAider) { 132 | searchString := "Do you trust the files in this folder?" 133 | tapFunc := t.TapEnter 134 | iterations := 5 135 | if t.program != ProgramClaude { 136 | searchString = "Open documentation url for more info" 137 | tapFunc = t.TapDAndEnter 138 | iterations = 10 // Aider takes longer to start :/ 139 | } 140 | // Deal with "do you trust the files" screen by sending an enter keystroke. 141 | for i := 0; i < iterations; i++ { 142 | time.Sleep(200 * time.Millisecond) 143 | content, err := t.CapturePaneContent() 144 | if err != nil { 145 | log.ErrorLog.Printf("could not check 'do you trust the files screen': %v", err) 146 | } 147 | if strings.Contains(content, searchString) { 148 | if err := tapFunc(); err != nil { 149 | log.ErrorLog.Printf("could not tap enter on trust screen: %v", err) 150 | } 151 | break 152 | } 153 | } 154 | } 155 | return nil 156 | } 157 | 158 | // Restore attaches to an existing session and restores the window size 159 | func (t *TmuxSession) Restore() error { 160 | ptmx, err := t.ptyFactory.Start(exec.Command("tmux", "attach-session", "-t", t.sanitizedName)) 161 | if err != nil { 162 | return fmt.Errorf("error opening PTY: %w", err) 163 | } 164 | t.ptmx = ptmx 165 | t.monitor = newStatusMonitor() 166 | return nil 167 | } 168 | 169 | type statusMonitor struct { 170 | // Store hashes to save memory. 171 | prevOutputHash []byte 172 | } 173 | 174 | func newStatusMonitor() *statusMonitor { 175 | return &statusMonitor{} 176 | } 177 | 178 | // hash hashes the string. 179 | func (m *statusMonitor) hash(s string) []byte { 180 | h := sha256.New() 181 | // TODO: this allocation sucks since the string is probably large. Ideally, we hash the string directly. 182 | h.Write([]byte(s)) 183 | return h.Sum(nil) 184 | } 185 | 186 | // TapEnter sends an enter keystroke to the tmux pane. 187 | func (t *TmuxSession) TapEnter() error { 188 | _, err := t.ptmx.Write([]byte{0x0D}) 189 | if err != nil { 190 | return fmt.Errorf("error sending enter keystroke to PTY: %w", err) 191 | } 192 | return nil 193 | } 194 | 195 | // TapDAndEnter sends 'D' followed by an enter keystroke to the tmux pane. 196 | func (t *TmuxSession) TapDAndEnter() error { 197 | _, err := t.ptmx.Write([]byte{0x44, 0x0D}) 198 | if err != nil { 199 | return fmt.Errorf("error sending enter keystroke to PTY: %w", err) 200 | } 201 | return nil 202 | } 203 | 204 | func (t *TmuxSession) SendKeys(keys string) error { 205 | _, err := t.ptmx.Write([]byte(keys)) 206 | return err 207 | } 208 | 209 | // HasUpdated checks if the tmux pane content has changed since the last tick. It also returns true if 210 | // the tmux pane has a prompt for aider or claude code. 211 | func (t *TmuxSession) HasUpdated() (updated bool, hasPrompt bool) { 212 | content, err := t.CapturePaneContent() 213 | if err != nil { 214 | log.ErrorLog.Printf("error capturing pane content in status monitor: %v", err) 215 | return false, false 216 | } 217 | 218 | // Only set hasPrompt for claude and aider. Use these strings to check for a prompt. 219 | if t.program == ProgramClaude { 220 | hasPrompt = strings.Contains(content, "No, and tell Claude what to do differently") 221 | } else if strings.HasPrefix(t.program, ProgramAider) { 222 | hasPrompt = strings.Contains(content, "(Y)es/(N)o/(D)on't ask again") 223 | } 224 | 225 | if !bytes.Equal(t.monitor.hash(content), t.monitor.prevOutputHash) { 226 | t.monitor.prevOutputHash = t.monitor.hash(content) 227 | return true, hasPrompt 228 | } 229 | return false, hasPrompt 230 | } 231 | 232 | func (t *TmuxSession) Attach() (chan struct{}, error) { 233 | t.attachCh = make(chan struct{}) 234 | 235 | t.wg = &sync.WaitGroup{} 236 | t.wg.Add(1) 237 | t.ctx, t.cancel = context.WithCancel(context.Background()) 238 | 239 | // The first goroutine should terminate when the ptmx is closed. We use the 240 | // waitgroup to wait for it to finish. 241 | // The 2nd one returns when you press escape to Detach. It doesn't need to be 242 | // in the waitgroup because is the goroutine doing the Detaching; it waits for 243 | // all the other ones. 244 | go func() { 245 | defer t.wg.Done() 246 | _, _ = io.Copy(os.Stdout, t.ptmx) 247 | }() 248 | 249 | go func() { 250 | // Close the channel after 50ms 251 | timeoutCh := make(chan struct{}) 252 | go func() { 253 | time.Sleep(50 * time.Millisecond) 254 | close(timeoutCh) 255 | }() 256 | 257 | // Read input from stdin and check for Ctrl+q 258 | buf := make([]byte, 32) 259 | for { 260 | nr, err := os.Stdin.Read(buf) 261 | if err != nil { 262 | if err == io.EOF { 263 | break 264 | } 265 | continue 266 | } 267 | 268 | // Nuke the first bytes of stdin, up to 64, to prevent tmux from reading it. 269 | // When we attach, there tends to be terminal control sequences like ?[?62c0;95;0c or 270 | // ]10;rgb:f8f8f8. The control sequences depend on the terminal (warp vs iterm). We should use regex ideally 271 | // but this works well for now. Log this for debugging. 272 | // 273 | // There seems to always be control characters, but I think it's possible for there not to be. The heuristic 274 | // here can be: if there's characters within 50ms, then assume they are control characters and nuke them. 275 | select { 276 | case <-timeoutCh: 277 | default: 278 | log.InfoLog.Printf("nuked first stdin: %s", buf[:nr]) 279 | continue 280 | } 281 | 282 | // Check for Ctrl+q (ASCII 17) 283 | if nr == 1 && buf[0] == 17 { 284 | // Detach from the session 285 | t.Detach() 286 | return 287 | } 288 | 289 | // Forward other input to tmux 290 | _, _ = t.ptmx.Write(buf[:nr]) 291 | } 292 | }() 293 | 294 | t.monitorWindowSize() 295 | return t.attachCh, nil 296 | } 297 | 298 | // Detach disconnects from the current tmux session. It panics if detaching fails. At the moment, there's no 299 | // way to recover from a failed detach. 300 | func (t *TmuxSession) Detach() { 301 | // TODO: control flow is a bit messy here. If there's an error, 302 | // I'm not sure if we get into a bad state. Needs testing. 303 | defer func() { 304 | close(t.attachCh) 305 | t.attachCh = nil 306 | t.cancel = nil 307 | t.ctx = nil 308 | t.wg = nil 309 | }() 310 | 311 | // Close the attached pty session. 312 | err := t.ptmx.Close() 313 | if err != nil { 314 | // This is a fatal error. We can't detach if we can't close the PTY. It's better to just panic and have the 315 | // user re-invoke the program than to ruin their terminal pane. 316 | msg := fmt.Sprintf("error closing attach pty session: %v", err) 317 | log.ErrorLog.Println(msg) 318 | panic(msg) 319 | } 320 | // Attach goroutines should die on EOF due to the ptmx closing. Call 321 | // t.Restore to set a new t.ptmx. 322 | if err = t.Restore(); err != nil { 323 | // This is a fatal error. Our invariant that a started TmuxSession always has a valid ptmx is violated. 324 | msg := fmt.Sprintf("error closing attach pty session: %v", err) 325 | log.ErrorLog.Println(msg) 326 | panic(msg) 327 | } 328 | 329 | // Cancel goroutines created by Attach. 330 | t.cancel() 331 | t.wg.Wait() 332 | } 333 | 334 | // Close terminates the tmux session and cleans up resources 335 | func (t *TmuxSession) Close() error { 336 | var errs []error 337 | 338 | if t.ptmx != nil { 339 | if err := t.ptmx.Close(); err != nil { 340 | errs = append(errs, fmt.Errorf("error closing PTY: %w", err)) 341 | } 342 | t.ptmx = nil 343 | } 344 | 345 | cmd := exec.Command("tmux", "kill-session", "-t", t.sanitizedName) 346 | if err := t.cmdExec.Run(cmd); err != nil { 347 | errs = append(errs, fmt.Errorf("error killing tmux session: %w", err)) 348 | } 349 | 350 | if len(errs) == 0 { 351 | return nil 352 | } 353 | if len(errs) == 1 { 354 | return errs[0] 355 | } 356 | 357 | errMsg := "multiple errors occurred during cleanup:" 358 | for _, err := range errs { 359 | errMsg += "\n - " + err.Error() 360 | } 361 | return errors.New(errMsg) 362 | } 363 | 364 | // SetDetachedSize set the width and height of the session while detached. This makes the 365 | // tmux output conform to the specified shape. 366 | func (t *TmuxSession) SetDetachedSize(width, height int) error { 367 | return t.updateWindowSize(width, height) 368 | } 369 | 370 | // updateWindowSize updates the window size of the PTY. 371 | func (t *TmuxSession) updateWindowSize(cols, rows int) error { 372 | return pty.Setsize(t.ptmx, &pty.Winsize{ 373 | Rows: uint16(rows), 374 | Cols: uint16(cols), 375 | X: 0, 376 | Y: 0, 377 | }) 378 | } 379 | 380 | func (t *TmuxSession) DoesSessionExist() bool { 381 | // Using "-t name" does a prefix match, which is wrong. `-t=` does an exact match. 382 | existsCmd := exec.Command("tmux", "has-session", fmt.Sprintf("-t=%s", t.sanitizedName)) 383 | return t.cmdExec.Run(existsCmd) == nil 384 | } 385 | 386 | // CapturePaneContent captures the content of the tmux pane 387 | func (t *TmuxSession) CapturePaneContent() (string, error) { 388 | // Add -e flag to preserve escape sequences (ANSI color codes) 389 | cmd := exec.Command("tmux", "capture-pane", "-p", "-e", "-J", "-t", t.sanitizedName) 390 | output, err := t.cmdExec.Output(cmd) 391 | if err != nil { 392 | return "", fmt.Errorf("error capturing pane content: %v", err) 393 | } 394 | return string(output), nil 395 | } 396 | 397 | // CapturePaneContentWithOptions captures the pane content with additional options 398 | // start and end specify the starting and ending line numbers (use "-" for the start/end of history) 399 | func (t *TmuxSession) CapturePaneContentWithOptions(start, end string) (string, error) { 400 | // Add -e flag to preserve escape sequences (ANSI color codes) 401 | cmd := exec.Command("tmux", "capture-pane", "-p", "-e", "-J", "-S", start, "-E", end, "-t", t.sanitizedName) 402 | output, err := t.cmdExec.Output(cmd) 403 | if err != nil { 404 | return "", fmt.Errorf("failed to capture tmux pane content with options: %v", err) 405 | } 406 | return string(output), nil 407 | } 408 | 409 | // CleanupSessions kills all tmux sessions that start with "session-" 410 | func CleanupSessions(cmdExec cmd.Executor) error { 411 | // First try to list sessions 412 | cmd := exec.Command("tmux", "ls") 413 | output, err := cmdExec.Output(cmd) 414 | 415 | // If there's an error and it's because no server is running, that's fine 416 | // Exit code 1 typically means no sessions exist 417 | if err != nil { 418 | if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 419 | return nil // No sessions to clean up 420 | } 421 | return fmt.Errorf("failed to list tmux sessions: %v", err) 422 | } 423 | 424 | re := regexp.MustCompile(fmt.Sprintf(`%s.*:`, TmuxPrefix)) 425 | matches := re.FindAllString(string(output), -1) 426 | for i, match := range matches { 427 | matches[i] = match[:strings.Index(match, ":")] 428 | } 429 | 430 | for _, match := range matches { 431 | log.InfoLog.Printf("cleaning up session: %s", match) 432 | if err := cmdExec.Run(exec.Command("tmux", "kill-session", "-t", match)); err != nil { 433 | return fmt.Errorf("failed to kill tmux session %s: %v", match, err) 434 | } 435 | } 436 | return nil 437 | } 438 | -------------------------------------------------------------------------------- /session/tmux/tmux_test.go: -------------------------------------------------------------------------------- 1 | package tmux 2 | 3 | import ( 4 | cmd2 "claude-squad/cmd" 5 | "fmt" 6 | "math/rand" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | "testing" 12 | 13 | "claude-squad/cmd/cmd_test" 14 | 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | type MockPtyFactory struct { 19 | t *testing.T 20 | 21 | // Array of commands and the corresponding file handles representing PTYs. 22 | cmds []*exec.Cmd 23 | files []*os.File 24 | } 25 | 26 | func (pt *MockPtyFactory) Start(cmd *exec.Cmd) (*os.File, error) { 27 | filePath := filepath.Join(pt.t.TempDir(), fmt.Sprintf("pty-%s-%d", pt.t.Name(), rand.Int31())) 28 | f, err := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR, 0644) 29 | if err == nil { 30 | pt.cmds = append(pt.cmds, cmd) 31 | pt.files = append(pt.files, f) 32 | } 33 | return f, err 34 | } 35 | 36 | func (pt *MockPtyFactory) Close() {} 37 | 38 | func NewMockPtyFactory(t *testing.T) *MockPtyFactory { 39 | return &MockPtyFactory{ 40 | t: t, 41 | } 42 | } 43 | 44 | func TestSanitizeName(t *testing.T) { 45 | session := NewTmuxSession("asdf", "program") 46 | require.Equal(t, TmuxPrefix+"asdf", session.sanitizedName) 47 | 48 | session = NewTmuxSession("a sd f . . asdf", "program") 49 | require.Equal(t, TmuxPrefix+"asdf__asdf", session.sanitizedName) 50 | } 51 | 52 | func TestStartTmuxSession(t *testing.T) { 53 | ptyFactory := NewMockPtyFactory(t) 54 | 55 | created := false 56 | cmdExec := cmd_test.MockCmdExec{ 57 | RunFunc: func(cmd *exec.Cmd) error { 58 | if strings.Contains(cmd.String(), "has-session") && !created { 59 | created = true 60 | return fmt.Errorf("session already exists") 61 | } 62 | return nil 63 | }, 64 | OutputFunc: func(cmd *exec.Cmd) ([]byte, error) { 65 | return []byte("output"), nil 66 | }, 67 | } 68 | 69 | workdir := t.TempDir() 70 | session := newTmuxSession("test-session", "claude", ptyFactory, cmdExec) 71 | 72 | err := session.Start(workdir) 73 | require.NoError(t, err) 74 | require.Equal(t, 2, len(ptyFactory.cmds)) 75 | require.Equal(t, fmt.Sprintf("tmux new-session -d -s claudesquad_test-session -c %s claude", workdir), 76 | cmd2.ToString(ptyFactory.cmds[0])) 77 | require.Equal(t, "tmux attach-session -t claudesquad_test-session", 78 | cmd2.ToString(ptyFactory.cmds[1])) 79 | 80 | require.Equal(t, 2, len(ptyFactory.files)) 81 | 82 | // File should be closed. 83 | _, err = ptyFactory.files[0].Stat() 84 | require.Error(t, err) 85 | // File should be open 86 | _, err = ptyFactory.files[1].Stat() 87 | require.NoError(t, err) 88 | } 89 | -------------------------------------------------------------------------------- /session/tmux/tmux_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package tmux 4 | 5 | import ( 6 | "claude-squad/log" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "golang.org/x/term" 13 | ) 14 | 15 | // monitorWindowSize monitors and handles window resize events while attached. 16 | func (t *TmuxSession) monitorWindowSize() { 17 | winchChan := make(chan os.Signal, 1) 18 | signal.Notify(winchChan, syscall.SIGWINCH) 19 | // Send initial SIGWINCH to trigger the first resize 20 | _ = syscall.Kill(syscall.Getpid(), syscall.SIGWINCH) 21 | 22 | everyN := log.NewEvery(60 * time.Second) 23 | 24 | doUpdate := func() { 25 | // Use the current terminal height and width. 26 | cols, rows, err := term.GetSize(int(os.Stdin.Fd())) 27 | if err != nil { 28 | if everyN.ShouldLog() { 29 | log.ErrorLog.Printf("failed to update window size: %v", err) 30 | } 31 | } else { 32 | if err := t.updateWindowSize(cols, rows); err != nil { 33 | if everyN.ShouldLog() { 34 | log.ErrorLog.Printf("failed to update window size: %v", err) 35 | } 36 | } 37 | } 38 | } 39 | // Do one at the end of the function to set the initial size. 40 | defer doUpdate() 41 | 42 | // Debounce resize events 43 | t.wg.Add(2) 44 | debouncedWinch := make(chan os.Signal, 1) 45 | go func() { 46 | defer t.wg.Done() 47 | var resizeTimer *time.Timer 48 | for { 49 | select { 50 | case <-t.ctx.Done(): 51 | return 52 | case <-winchChan: 53 | if resizeTimer != nil { 54 | resizeTimer.Stop() 55 | } 56 | resizeTimer = time.AfterFunc(50*time.Millisecond, func() { 57 | select { 58 | case debouncedWinch <- syscall.SIGWINCH: 59 | case <-t.ctx.Done(): 60 | } 61 | }) 62 | } 63 | } 64 | }() 65 | go func() { 66 | defer t.wg.Done() 67 | defer signal.Stop(winchChan) 68 | // Handle resize events 69 | for { 70 | select { 71 | case <-t.ctx.Done(): 72 | return 73 | case <-debouncedWinch: 74 | doUpdate() 75 | } 76 | } 77 | }() 78 | } 79 | -------------------------------------------------------------------------------- /session/tmux/tmux_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package tmux 4 | 5 | import ( 6 | "claude-squad/log" 7 | "os" 8 | "time" 9 | 10 | "golang.org/x/term" 11 | ) 12 | 13 | // monitorWindowSize monitors and handles window resize events while attached. 14 | func (t *TmuxSession) monitorWindowSize() { 15 | // Use the current terminal height and width. 16 | doUpdate := func() { 17 | cols, rows, err := term.GetSize(int(os.Stdin.Fd())) 18 | if err != nil { 19 | log.ErrorLog.Printf("failed to update window size: %v", err) 20 | } else { 21 | if err := t.updateWindowSize(cols, rows); err != nil { 22 | log.ErrorLog.Printf("failed to update window size: %v", err) 23 | } 24 | } 25 | } 26 | 27 | // Do one at the start to set the initial size 28 | doUpdate() 29 | 30 | // On Windows, we'll just periodically check for window size changes 31 | // since SIGWINCH is not available 32 | ticker := time.NewTicker(250 * time.Millisecond) 33 | defer ticker.Stop() 34 | 35 | var lastCols, lastRows int 36 | lastCols, lastRows, _ = term.GetSize(int(os.Stdin.Fd())) 37 | 38 | t.wg.Add(1) 39 | go func() { 40 | defer t.wg.Done() 41 | for { 42 | select { 43 | case <-t.ctx.Done(): 44 | return 45 | case <-ticker.C: 46 | cols, rows, err := term.GetSize(int(os.Stdin.Fd())) 47 | if err != nil { 48 | continue 49 | } 50 | if cols != lastCols || rows != lastRows { 51 | lastCols, lastRows = cols, rows 52 | doUpdate() 53 | } 54 | } 55 | } 56 | }() 57 | } 58 | -------------------------------------------------------------------------------- /ui/consts.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | var FallBackText = lipgloss.JoinVertical(lipgloss.Center, ` 6 | ░█████╗░██╗░░░░░░█████╗░██╗░░░██╗██████╗░███████╗ 7 | ██╔══██╗██║░░░░░██╔══██╗██║░░░██║██╔══██╗██╔════╝ 8 | ██║░░╚═╝██║░░░░░███████║██║░░░██║██║░░██║█████╗░░ 9 | ██║░░██╗██║░░░░░██╔══██║██║░░░██║██║░░██║██╔══╝░░ 10 | ╚█████╔╝███████╗██║░░██║╚██████╔╝██████╔╝███████╗ 11 | ░╚════╝░╚══════╝╚═╝░░╚═╝░╚═════╝░╚═════╝░╚══════╝ 12 | `, ` 13 | ░██████╗░██████╗░██╗░░░██╗░█████╗░██████╗░ 14 | ██╔════╝██╔═══██╗██║░░░██║██╔══██╗██╔══██╗ 15 | ╚█████╗░██║██╗██║██║░░░██║███████║██║░░██║ 16 | ░╚═══██╗╚██████╔╝██║░░░██║██╔══██║██║░░██║ 17 | ██████╔╝░╚═██╔═╝░╚██████╔╝██║░░██║██████╔╝ 18 | `) 19 | -------------------------------------------------------------------------------- /ui/diff.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "claude-squad/session" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/viewport" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | var ( 13 | AdditionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")) 14 | DeletionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#ef4444")) 15 | HunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#0ea5e9")) 16 | ) 17 | 18 | type DiffPane struct { 19 | viewport viewport.Model 20 | diff string 21 | stats string 22 | width int 23 | height int 24 | } 25 | 26 | func NewDiffPane() *DiffPane { 27 | return &DiffPane{ 28 | viewport: viewport.New(0, 0), 29 | } 30 | } 31 | 32 | func (d *DiffPane) SetSize(width, height int) { 33 | d.width = width 34 | d.height = height 35 | d.viewport.Width = width 36 | d.viewport.Height = height 37 | // Update viewport content if diff exists 38 | if d.diff != "" || d.stats != "" { 39 | d.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Left, d.stats, d.diff)) 40 | } 41 | } 42 | 43 | func (d *DiffPane) SetDiff(instance *session.Instance) { 44 | centeredFallbackMessage := lipgloss.Place( 45 | d.width, 46 | d.height, 47 | lipgloss.Center, 48 | lipgloss.Center, 49 | "No changes", 50 | ) 51 | 52 | if instance == nil || !instance.Started() { 53 | d.viewport.SetContent(centeredFallbackMessage) 54 | return 55 | } 56 | 57 | stats := instance.GetDiffStats() 58 | if stats == nil { 59 | // Show loading message if worktree is not ready 60 | centeredMessage := lipgloss.Place( 61 | d.width, 62 | d.height, 63 | lipgloss.Center, 64 | lipgloss.Center, 65 | "Setting up worktree...", 66 | ) 67 | d.viewport.SetContent(centeredMessage) 68 | return 69 | } 70 | 71 | if stats.Error != nil { 72 | // Show error message 73 | centeredMessage := lipgloss.Place( 74 | d.width, 75 | d.height, 76 | lipgloss.Center, 77 | lipgloss.Center, 78 | fmt.Sprintf("Error: %v", stats.Error), 79 | ) 80 | d.viewport.SetContent(centeredMessage) 81 | return 82 | } 83 | 84 | if stats.IsEmpty() { 85 | d.stats = "" 86 | d.diff = "" 87 | d.viewport.SetContent(centeredFallbackMessage) 88 | } else { 89 | additions := AdditionStyle.Render(fmt.Sprintf("%d additions(+)", stats.Added)) 90 | deletions := DeletionStyle.Render(fmt.Sprintf("%d deletions(-)", stats.Removed)) 91 | d.stats = lipgloss.JoinHorizontal(lipgloss.Center, additions, " ", deletions) 92 | d.diff = colorizeDiff(stats.Content) 93 | d.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Left, d.stats, d.diff)) 94 | } 95 | } 96 | 97 | func (d *DiffPane) String() string { 98 | return d.viewport.View() 99 | } 100 | 101 | // ScrollUp scrolls the viewport up 102 | func (d *DiffPane) ScrollUp() { 103 | d.viewport.LineUp(1) 104 | } 105 | 106 | // ScrollDown scrolls the viewport down 107 | func (d *DiffPane) ScrollDown() { 108 | d.viewport.LineDown(1) 109 | } 110 | 111 | func colorizeDiff(diff string) string { 112 | var coloredOutput strings.Builder 113 | 114 | lines := strings.Split(diff, "\n") 115 | for _, line := range lines { 116 | if len(line) > 0 { 117 | if strings.HasPrefix(line, "@@") { 118 | // Color hunk headers cyan 119 | coloredOutput.WriteString(HunkStyle.Render(line) + "\n") 120 | } else if line[0] == '+' && (len(line) == 1 || line[1] != '+') { 121 | // Color added lines green, excluding metadata like '+++' 122 | coloredOutput.WriteString(AdditionStyle.Render(line) + "\n") 123 | } else if line[0] == '-' && (len(line) == 1 || line[1] != '-') { 124 | // Color removed lines red, excluding metadata like '---' 125 | coloredOutput.WriteString(DeletionStyle.Render(line) + "\n") 126 | } else { 127 | // Print metadata and unchanged lines without color 128 | coloredOutput.WriteString(line + "\n") 129 | } 130 | } else { 131 | // Preserve empty lines 132 | coloredOutput.WriteString("\n") 133 | } 134 | } 135 | 136 | return coloredOutput.String() 137 | } 138 | -------------------------------------------------------------------------------- /ui/err.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "strings" 6 | ) 7 | 8 | type ErrBox struct { 9 | height, width int 10 | err error 11 | } 12 | 13 | var errStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ 14 | Light: "#FF0000", 15 | Dark: "#FF0000", 16 | }) 17 | 18 | func NewErrBox() *ErrBox { 19 | return &ErrBox{} 20 | } 21 | 22 | func (e *ErrBox) SetError(err error) { 23 | e.err = err 24 | } 25 | 26 | func (e *ErrBox) Clear() { 27 | e.err = nil 28 | } 29 | 30 | func (e *ErrBox) SetSize(width, height int) { 31 | e.width = width 32 | e.height = height 33 | } 34 | 35 | func (e *ErrBox) String() string { 36 | var err string 37 | if e.err != nil { 38 | err = e.err.Error() 39 | lines := strings.Split(err, "\n") 40 | err = strings.Join(lines, "//") 41 | if len(err) > e.width-3 && e.width-3 >= 0 { 42 | err = err[:e.width-3] + "..." 43 | } 44 | } 45 | return lipgloss.Place(e.width, e.height, lipgloss.Center, lipgloss.Center, errStyle.Render(err)) 46 | } 47 | -------------------------------------------------------------------------------- /ui/list.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "claude-squad/log" 5 | "claude-squad/session" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | 10 | "github.com/charmbracelet/bubbles/spinner" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | const readyIcon = "● " 15 | const pausedIcon = "⏸ " 16 | 17 | var readyStyle = lipgloss.NewStyle(). 18 | Foreground(lipgloss.AdaptiveColor{Light: "#51bd73", Dark: "#51bd73"}) 19 | 20 | var addedLinesStyle = lipgloss.NewStyle(). 21 | Foreground(lipgloss.AdaptiveColor{Light: "#51bd73", Dark: "#51bd73"}) 22 | 23 | var removedLinesStyle = lipgloss.NewStyle(). 24 | Foreground(lipgloss.Color("#de613e")) 25 | 26 | var pausedStyle = lipgloss.NewStyle(). 27 | Foreground(lipgloss.AdaptiveColor{Light: "#888888", Dark: "#888888"}) 28 | 29 | var titleStyle = lipgloss.NewStyle(). 30 | Padding(1, 1, 0, 1). 31 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) 32 | 33 | var listDescStyle = lipgloss.NewStyle(). 34 | Padding(0, 1, 1, 1). 35 | Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}) 36 | 37 | var selectedTitleStyle = lipgloss.NewStyle(). 38 | Padding(1, 1, 0, 1). 39 | Background(lipgloss.Color("#dde4f0")). 40 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#1a1a1a"}) 41 | 42 | var selectedDescStyle = lipgloss.NewStyle(). 43 | Padding(0, 1, 1, 1). 44 | Background(lipgloss.Color("#dde4f0")). 45 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#1a1a1a"}) 46 | 47 | var mainTitle = lipgloss.NewStyle(). 48 | Background(lipgloss.Color("62")). 49 | Foreground(lipgloss.Color("230")) 50 | 51 | var autoYesStyle = lipgloss.NewStyle(). 52 | Background(lipgloss.Color("#dde4f0")). 53 | Foreground(lipgloss.Color("#1a1a1a")) 54 | 55 | type List struct { 56 | items []*session.Instance 57 | selectedIdx int 58 | height, width int 59 | renderer *InstanceRenderer 60 | autoyes bool 61 | 62 | // map of repo name to number of instances using it. Used to display the repo name only if there are 63 | // multiple repos in play. 64 | repos map[string]int 65 | } 66 | 67 | func NewList(spinner *spinner.Model, autoYes bool) *List { 68 | return &List{ 69 | items: []*session.Instance{}, 70 | renderer: &InstanceRenderer{spinner: spinner}, 71 | repos: make(map[string]int), 72 | autoyes: autoYes, 73 | } 74 | } 75 | 76 | // SetSize sets the height and width of the list. 77 | func (l *List) SetSize(width, height int) { 78 | l.width = width 79 | l.height = height 80 | l.renderer.setWidth(width) 81 | } 82 | 83 | // SetSessionPreviewSize sets the height and width for the tmux sessions. This makes the stdout line have the correct 84 | // width and height. 85 | func (l *List) SetSessionPreviewSize(width, height int) (err error) { 86 | for i, item := range l.items { 87 | if !item.Started() || item.Paused() { 88 | continue 89 | } 90 | 91 | if innerErr := item.SetPreviewSize(width, height); innerErr != nil { 92 | err = errors.Join( 93 | err, fmt.Errorf("could not set preview size for instance %d: %v", i, innerErr)) 94 | } 95 | } 96 | return 97 | } 98 | 99 | func (l *List) NumInstances() int { 100 | return len(l.items) 101 | } 102 | 103 | // InstanceRenderer handles rendering of session.Instance objects 104 | type InstanceRenderer struct { 105 | spinner *spinner.Model 106 | width int 107 | } 108 | 109 | func (r *InstanceRenderer) setWidth(width int) { 110 | r.width = AdjustPreviewWidth(width) 111 | } 112 | 113 | // ɹ and ɻ are other options. 114 | const branchIcon = "Ꮧ" 115 | 116 | func (r *InstanceRenderer) Render(i *session.Instance, idx int, selected bool, hasMultipleRepos bool) string { 117 | prefix := fmt.Sprintf(" %d. ", idx) 118 | if idx >= 10 { 119 | prefix = prefix[:len(prefix)-1] 120 | } 121 | titleS := selectedTitleStyle 122 | descS := selectedDescStyle 123 | if !selected { 124 | titleS = titleStyle 125 | descS = listDescStyle 126 | } 127 | 128 | // add spinner next to title if it's running 129 | var join string 130 | switch i.Status { 131 | case session.Running: 132 | join = fmt.Sprintf("%s ", r.spinner.View()) 133 | case session.Ready: 134 | join = readyStyle.Render(readyIcon) 135 | case session.Paused: 136 | join = pausedStyle.Render(pausedIcon) 137 | default: 138 | } 139 | 140 | // Cut the title if it's too long 141 | titleText := i.Title 142 | widthAvail := r.width - 3 - len(prefix) - 1 143 | if widthAvail > 0 && widthAvail < len(titleText) && len(titleText) >= widthAvail-3 { 144 | titleText = titleText[:widthAvail-3] + "..." 145 | } 146 | title := titleS.Render(lipgloss.JoinHorizontal( 147 | lipgloss.Left, 148 | lipgloss.Place(r.width-3, 1, lipgloss.Left, lipgloss.Center, fmt.Sprintf("%s %s", prefix, titleText)), 149 | " ", 150 | join, 151 | )) 152 | 153 | stat := i.GetDiffStats() 154 | 155 | var diff string 156 | var addedDiff, removedDiff string 157 | if stat == nil || stat.Error != nil || stat.IsEmpty() { 158 | // Don't show diff stats if there's an error or if they don't exist 159 | addedDiff = "" 160 | removedDiff = "" 161 | diff = "" 162 | } else { 163 | addedDiff = fmt.Sprintf("+%d", stat.Added) 164 | removedDiff = fmt.Sprintf("-%d ", stat.Removed) 165 | diff = lipgloss.JoinHorizontal( 166 | lipgloss.Center, 167 | addedLinesStyle.Background(descS.GetBackground()).Render(addedDiff), 168 | lipgloss.Style{}.Background(descS.GetBackground()).Foreground(descS.GetForeground()).Render(","), 169 | removedLinesStyle.Background(descS.GetBackground()).Render(removedDiff), 170 | ) 171 | } 172 | 173 | remainingWidth := r.width 174 | remainingWidth -= len(prefix) 175 | remainingWidth -= len(branchIcon) 176 | 177 | diffWidth := len(addedDiff) + len(removedDiff) 178 | if diffWidth > 0 { 179 | diffWidth += 1 180 | } 181 | 182 | // Use fixed width for diff stats to avoid layout issues 183 | remainingWidth -= diffWidth 184 | 185 | branch := i.Branch 186 | if i.Started() && hasMultipleRepos { 187 | repoName, err := i.RepoName() 188 | if err != nil { 189 | log.ErrorLog.Printf("could not get repo name in instance renderer: %v", err) 190 | } else { 191 | branch += fmt.Sprintf(" (%s)", repoName) 192 | } 193 | } 194 | // Don't show branch if there's no space for it. Or show ellipsis if it's too long. 195 | if remainingWidth < 0 { 196 | branch = "" 197 | } else if remainingWidth < len(branch) { 198 | if remainingWidth < 3 { 199 | branch = "" 200 | } else { 201 | // We know the remainingWidth is at least 4 and branch is longer than that, so this is safe. 202 | branch = branch[:remainingWidth-3] + "..." 203 | } 204 | } 205 | remainingWidth -= len(branch) 206 | 207 | // Add spaces to fill the remaining width. 208 | spaces := "" 209 | if remainingWidth > 0 { 210 | spaces = strings.Repeat(" ", remainingWidth) 211 | } 212 | 213 | branchLine := fmt.Sprintf("%s %s-%s%s%s", strings.Repeat(" ", len(prefix)), branchIcon, branch, spaces, diff) 214 | 215 | // join title and subtitle 216 | text := lipgloss.JoinVertical( 217 | lipgloss.Left, 218 | title, 219 | descS.Render(branchLine), 220 | ) 221 | 222 | return text 223 | } 224 | 225 | func (l *List) String() string { 226 | const titleText = " Instances " 227 | const autoYesText = " auto-yes " 228 | 229 | // Write the title. 230 | var b strings.Builder 231 | b.WriteString("\n") 232 | b.WriteString("\n") 233 | 234 | // Write title line 235 | // add padding of 2 because the border on list items adds some extra characters 236 | titleWidth := AdjustPreviewWidth(l.width) + 2 237 | if !l.autoyes { 238 | b.WriteString(lipgloss.Place( 239 | titleWidth, 1, lipgloss.Left, lipgloss.Bottom, mainTitle.Render(titleText))) 240 | } else { 241 | title := lipgloss.Place( 242 | titleWidth/2, 1, lipgloss.Left, lipgloss.Bottom, mainTitle.Render(titleText)) 243 | autoYes := lipgloss.Place( 244 | titleWidth-(titleWidth/2), 1, lipgloss.Right, lipgloss.Bottom, autoYesStyle.Render(autoYesText)) 245 | b.WriteString(lipgloss.JoinHorizontal( 246 | lipgloss.Top, title, autoYes)) 247 | } 248 | 249 | b.WriteString("\n") 250 | b.WriteString("\n") 251 | 252 | // Render the list. 253 | for i, item := range l.items { 254 | b.WriteString(l.renderer.Render(item, i+1, i == l.selectedIdx, len(l.repos) > 1)) 255 | if i != len(l.items)-1 { 256 | b.WriteString("\n\n") 257 | } 258 | } 259 | return lipgloss.Place(l.width, l.height, lipgloss.Left, lipgloss.Top, b.String()) 260 | } 261 | 262 | // Down selects the next item in the list. 263 | func (l *List) Down() { 264 | if len(l.items) == 0 { 265 | return 266 | } 267 | if l.selectedIdx < len(l.items)-1 { 268 | l.selectedIdx++ 269 | } 270 | } 271 | 272 | // Kill selects the next item in the list. 273 | func (l *List) Kill() { 274 | if len(l.items) == 0 { 275 | return 276 | } 277 | targetInstance := l.items[l.selectedIdx] 278 | 279 | // Kill the tmux session 280 | if err := targetInstance.Kill(); err != nil { 281 | log.ErrorLog.Printf("could not kill instance: %v", err) 282 | } 283 | 284 | // If you delete the last one in the list, select the previous one. 285 | if l.selectedIdx == len(l.items)-1 { 286 | defer l.Up() 287 | } 288 | 289 | // Unregister the reponame. 290 | repoName, err := targetInstance.RepoName() 291 | if err != nil { 292 | log.ErrorLog.Printf("could not get repo name: %v", err) 293 | } else { 294 | l.rmRepo(repoName) 295 | } 296 | 297 | // Since there's items after this, the selectedIdx can stay the same. 298 | l.items = append(l.items[:l.selectedIdx], l.items[l.selectedIdx+1:]...) 299 | } 300 | 301 | func (l *List) Attach() (chan struct{}, error) { 302 | targetInstance := l.items[l.selectedIdx] 303 | return targetInstance.Attach() 304 | } 305 | 306 | // Up selects the prev item in the list. 307 | func (l *List) Up() { 308 | if len(l.items) == 0 { 309 | return 310 | } 311 | if l.selectedIdx > 0 { 312 | l.selectedIdx-- 313 | } 314 | } 315 | 316 | func (l *List) addRepo(repo string) { 317 | if _, ok := l.repos[repo]; !ok { 318 | l.repos[repo] = 0 319 | } 320 | l.repos[repo]++ 321 | } 322 | 323 | func (l *List) rmRepo(repo string) { 324 | if _, ok := l.repos[repo]; !ok { 325 | log.ErrorLog.Printf("repo %s not found", repo) 326 | return 327 | } 328 | l.repos[repo]-- 329 | if l.repos[repo] == 0 { 330 | delete(l.repos, repo) 331 | } 332 | } 333 | 334 | // AddInstance adds a new instance to the list. It returns a finalizer function that should be called when the instance 335 | // is started. If the instance was restored from storage or is paused, you can call the finalizer immediately. 336 | // When creating a new one and entering the name, you want to call the finalizer once the name is done. 337 | func (l *List) AddInstance(instance *session.Instance) (finalize func()) { 338 | l.items = append(l.items, instance) 339 | // The finalizer registers the repo name once the instance is started. 340 | return func() { 341 | repoName, err := instance.RepoName() 342 | if err != nil { 343 | log.ErrorLog.Printf("could not get repo name: %v", err) 344 | return 345 | } 346 | 347 | l.addRepo(repoName) 348 | } 349 | } 350 | 351 | // GetSelectedInstance returns the currently selected instance 352 | func (l *List) GetSelectedInstance() *session.Instance { 353 | if len(l.items) == 0 { 354 | return nil 355 | } 356 | return l.items[l.selectedIdx] 357 | } 358 | 359 | // SetSelectedInstance sets the selected index. Noop if the index is out of bounds. 360 | func (l *List) SetSelectedInstance(idx int) { 361 | if idx >= len(l.items) { 362 | return 363 | } 364 | l.selectedIdx = idx 365 | } 366 | 367 | // GetInstances returns all instances in the list 368 | func (l *List) GetInstances() []*session.Instance { 369 | return l.items 370 | } 371 | -------------------------------------------------------------------------------- /ui/menu.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "claude-squad/keys" 5 | "strings" 6 | 7 | "claude-squad/session" 8 | 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | var keyStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ 13 | Light: "#655F5F", 14 | Dark: "#7F7A7A", 15 | }) 16 | 17 | var descStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ 18 | Light: "#7A7474", 19 | Dark: "#9C9494", 20 | }) 21 | 22 | var sepStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ 23 | Light: "#DDDADA", 24 | Dark: "#3C3C3C", 25 | }) 26 | 27 | var actionGroupStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99")) 28 | 29 | var separator = " • " 30 | var verticalSeparator = " │ " 31 | 32 | var menuStyle = lipgloss.NewStyle(). 33 | Foreground(lipgloss.Color("205")) 34 | 35 | // MenuState represents different states the menu can be in 36 | type MenuState int 37 | 38 | const ( 39 | StateDefault MenuState = iota 40 | StateEmpty 41 | StateNewInstance 42 | StatePrompt 43 | ) 44 | 45 | type Menu struct { 46 | options []keys.KeyName 47 | height, width int 48 | state MenuState 49 | instance *session.Instance 50 | isInDiffTab bool 51 | 52 | // keyDown is the key which is pressed. The default is -1. 53 | keyDown keys.KeyName 54 | } 55 | 56 | var defaultMenuOptions = []keys.KeyName{keys.KeyNew, keys.KeyPrompt, keys.KeyHelp, keys.KeyQuit} 57 | var newInstanceMenuOptions = []keys.KeyName{keys.KeySubmitName} 58 | var promptMenuOptions = []keys.KeyName{keys.KeySubmitName} 59 | 60 | func NewMenu() *Menu { 61 | return &Menu{ 62 | options: defaultMenuOptions, 63 | state: StateEmpty, 64 | isInDiffTab: false, 65 | keyDown: -1, 66 | } 67 | } 68 | 69 | func (m *Menu) Keydown(name keys.KeyName) { 70 | m.keyDown = name 71 | } 72 | 73 | func (m *Menu) ClearKeydown() { 74 | m.keyDown = -1 75 | } 76 | 77 | // SetState updates the menu state and options accordingly 78 | func (m *Menu) SetState(state MenuState) { 79 | m.state = state 80 | m.updateOptions() 81 | } 82 | 83 | // SetInstance updates the current instance and refreshes menu options 84 | func (m *Menu) SetInstance(instance *session.Instance) { 85 | m.instance = instance 86 | // Only change the state if we're not in a special state (NewInstance or Prompt) 87 | if m.state != StateNewInstance && m.state != StatePrompt { 88 | if m.instance != nil { 89 | m.state = StateDefault 90 | } else { 91 | m.state = StateEmpty 92 | } 93 | } 94 | m.updateOptions() 95 | } 96 | 97 | // SetInDiffTab updates whether we're currently in the diff tab 98 | func (m *Menu) SetInDiffTab(inDiffTab bool) { 99 | m.isInDiffTab = inDiffTab 100 | m.updateOptions() 101 | } 102 | 103 | // updateOptions updates the menu options based on current state and instance 104 | func (m *Menu) updateOptions() { 105 | switch m.state { 106 | case StateEmpty: 107 | m.options = defaultMenuOptions 108 | case StateDefault: 109 | if m.instance != nil { 110 | // When there is an instance, show that instance's options 111 | m.addInstanceOptions() 112 | } else { 113 | // When there is no instance, show the empty state 114 | m.options = defaultMenuOptions 115 | } 116 | case StateNewInstance: 117 | m.options = newInstanceMenuOptions 118 | case StatePrompt: 119 | m.options = promptMenuOptions 120 | } 121 | } 122 | 123 | func (m *Menu) addInstanceOptions() { 124 | // Instance management group 125 | options := []keys.KeyName{keys.KeyNew, keys.KeyKill} 126 | 127 | // Action group 128 | actionGroup := []keys.KeyName{keys.KeyEnter, keys.KeySubmit} 129 | if m.instance.Status == session.Paused { 130 | actionGroup = append(actionGroup, keys.KeyResume) 131 | } else { 132 | actionGroup = append(actionGroup, keys.KeyCheckout) 133 | } 134 | 135 | // Navigation group (when in diff tab) 136 | if m.isInDiffTab { 137 | actionGroup = append(actionGroup, keys.KeyShiftUp) 138 | } 139 | 140 | // System group 141 | systemGroup := []keys.KeyName{keys.KeyTab, keys.KeyHelp, keys.KeyQuit} 142 | 143 | // Combine all groups 144 | options = append(options, actionGroup...) 145 | options = append(options, systemGroup...) 146 | 147 | m.options = options 148 | } 149 | 150 | // SetSize sets the width of the window. The menu will be centered horizontally within this width. 151 | func (m *Menu) SetSize(width, height int) { 152 | m.width = width 153 | m.height = height 154 | } 155 | 156 | func (m *Menu) String() string { 157 | var s strings.Builder 158 | 159 | // Define group boundaries 160 | groups := []struct { 161 | start int 162 | end int 163 | }{ 164 | {0, 2}, // Instance management group (n, d) 165 | {2, 5}, // Action group (enter, submit, pause/resume) 166 | {6, 8}, // System group (tab, help, q) 167 | } 168 | 169 | for i, k := range m.options { 170 | binding := keys.GlobalkeyBindings[k] 171 | 172 | var ( 173 | localActionStyle = actionGroupStyle 174 | localKeyStyle = keyStyle 175 | localDescStyle = descStyle 176 | ) 177 | if m.keyDown == k { 178 | localActionStyle = localActionStyle.Underline(true) 179 | localKeyStyle = localKeyStyle.Underline(true) 180 | localDescStyle = localDescStyle.Underline(true) 181 | } 182 | 183 | var inActionGroup bool 184 | switch m.state { 185 | case StateEmpty: 186 | // For empty state, the action group is the first group 187 | inActionGroup = i <= 1 188 | default: 189 | // For other states, the action group is the second group 190 | inActionGroup = i >= groups[1].start && i < groups[1].end 191 | } 192 | 193 | if inActionGroup { 194 | s.WriteString(localActionStyle.Render(binding.Help().Key)) 195 | s.WriteString(" ") 196 | s.WriteString(localActionStyle.Render(binding.Help().Desc)) 197 | } else { 198 | s.WriteString(localKeyStyle.Render(binding.Help().Key)) 199 | s.WriteString(" ") 200 | s.WriteString(localDescStyle.Render(binding.Help().Desc)) 201 | } 202 | 203 | // Add appropriate separator 204 | if i != len(m.options)-1 { 205 | isGroupEnd := false 206 | for _, group := range groups { 207 | if i == group.end-1 { 208 | s.WriteString(sepStyle.Render(verticalSeparator)) 209 | isGroupEnd = true 210 | break 211 | } 212 | } 213 | if !isGroupEnd { 214 | s.WriteString(sepStyle.Render(separator)) 215 | } 216 | } 217 | } 218 | 219 | centeredMenuText := menuStyle.Render(s.String()) 220 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, centeredMenuText) 221 | } 222 | -------------------------------------------------------------------------------- /ui/overlay/overlay.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/mattn/go-runewidth" 10 | "github.com/muesli/ansi" 11 | "github.com/muesli/reflow/truncate" 12 | "github.com/muesli/termenv" 13 | ) 14 | 15 | // Most of this code is modified from https://github.com/charmbracelet/lipgloss/pull/102 16 | 17 | // WhitespaceOption sets a styling rule for rendering whitespace. 18 | type WhitespaceOption func(*whitespace) 19 | 20 | // Split a string into lines, additionally returning the size of the widest 21 | // line. 22 | func getLines(s string) (lines []string, widest int) { 23 | lines = strings.Split(s, "\n") 24 | 25 | for _, l := range lines { 26 | w := ansi.PrintableRuneWidth(l) 27 | if widest < w { 28 | widest = w 29 | } 30 | } 31 | 32 | return lines, widest 33 | } 34 | 35 | func CalculateCenterCoordinates(foregroundLines []string, backgroundLines []string, foregroundWidth, backgroundWidth int) (int, int) { 36 | // Calculate the x-coordinate to horizontally center the foreground text. 37 | x := (backgroundWidth - foregroundWidth) / 2 38 | 39 | // Calculate the y-coordinate to vertically center the foreground text. 40 | y := (len(backgroundLines) - len(foregroundLines)) / 2 41 | 42 | return x, y 43 | } 44 | 45 | // PlaceOverlay places fg on top of bg with an optional shadow effect. 46 | // If center is true, the foreground is centered on the background; otherwise, the provided x and y are used. 47 | func PlaceOverlay( 48 | x, y int, 49 | fg, bg string, 50 | shadow bool, 51 | center bool, 52 | opts ...WhitespaceOption, 53 | ) string { 54 | fgLines, fgWidth := getLines(fg) 55 | bgLines, bgWidth := getLines(bg) 56 | bgHeight := len(bgLines) 57 | fgHeight := len(fgLines) 58 | 59 | // Apply a fade effect to the background by directly modifying each line 60 | // Create a new array of background lines with the fade effect applied 61 | fadedBgLines := make([]string, len(bgLines)) 62 | 63 | // Compile regular expressions for ANSI color codes 64 | // Match background color codes like \x1b[48;2;R;G;Bm or \x1b[48;5;Nm 65 | bgColorRegex := regexp.MustCompile(`\x1b\[48;[25];[0-9;]+m`) 66 | 67 | // Match foreground color codes like \x1b[38;2;R;G;Bm or \x1b[38;5;Nm 68 | fgColorRegex := regexp.MustCompile(`\x1b\[38;[25];[0-9;]+m`) 69 | 70 | // Match simple color codes like \x1b[31m 71 | simpleColorRegex := regexp.MustCompile(`\x1b\[[0-9]+m`) 72 | 73 | for i, line := range bgLines { 74 | // Replace background color codes with a faded version 75 | content := bgColorRegex.ReplaceAllString(line, "\x1b[48;5;236m") // Dark gray background 76 | 77 | // Replace foreground color codes with a faded version 78 | content = fgColorRegex.ReplaceAllString(content, "\x1b[38;5;240m") // Medium gray foreground 79 | 80 | // Replace simple color codes with a faded version 81 | content = simpleColorRegex.ReplaceAllStringFunc(content, func(match string) string { 82 | // Skip reset codes 83 | if match == "\x1b[0m" { 84 | return match 85 | } 86 | // Replace with dimmed color 87 | return "\x1b[38;5;240m" // Medium gray 88 | }) 89 | 90 | fadedBgLines[i] = content 91 | } 92 | 93 | // Replace the original background with the faded version 94 | bgLines = fadedBgLines 95 | 96 | // Determine placement coordinates 97 | placeX, placeY := x, y 98 | if center { 99 | placeX, placeY = CalculateCenterCoordinates(fgLines, bgLines, fgWidth, bgWidth) 100 | } 101 | 102 | // Handle shadow if enabled 103 | if shadow { 104 | // Define shadow style and character 105 | shadowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#333333")) 106 | shadowChar := shadowStyle.Render("░") 107 | 108 | // Create shadow string with same dimensions as foreground 109 | shadowLines := make([]string, fgHeight) 110 | for i := 0; i < fgHeight; i++ { 111 | shadowLines[i] = strings.Repeat(shadowChar, fgWidth) 112 | } 113 | shadowStr := strings.Join(shadowLines, "\n") 114 | 115 | // Place shadow on background at an offset (e.g., +1, +1) 116 | const shadowOffsetX, shadowOffsetY = 1, 1 117 | _ = PlaceOverlay(placeX+shadowOffsetX, placeY+shadowOffsetY, shadowStr, bg, false, false, opts...) 118 | } 119 | 120 | // Check if foreground exceeds background size 121 | if fgWidth >= bgWidth && fgHeight >= bgHeight { 122 | return fg // Return foreground if it's larger than background 123 | } 124 | 125 | // Clamp coordinates to ensure foreground fits within background 126 | placeX = clamp(placeX, 0, bgWidth-fgWidth) 127 | placeY = clamp(placeY, 0, bgHeight-fgHeight) 128 | 129 | // Apply whitespace options 130 | ws := &whitespace{} 131 | for _, opt := range opts { 132 | opt(ws) 133 | } 134 | 135 | // Build the output string 136 | var b strings.Builder 137 | for i, bgLine := range bgLines { 138 | if i > 0 { 139 | b.WriteByte('\n') 140 | } 141 | if i < placeY || i >= placeY+fgHeight { 142 | b.WriteString(bgLine) 143 | continue 144 | } 145 | 146 | pos := 0 147 | if placeX > 0 { 148 | left := truncate.String(bgLine, uint(placeX)) 149 | pos = ansi.PrintableRuneWidth(left) 150 | b.WriteString(left) 151 | if pos < placeX { 152 | b.WriteString(ws.render(placeX - pos)) 153 | pos = placeX 154 | } 155 | } 156 | 157 | fgLine := fgLines[i-placeY] 158 | b.WriteString(fgLine) 159 | pos += ansi.PrintableRuneWidth(fgLine) 160 | 161 | right := cutLeft(bgLine, pos) 162 | bgLineWidth := ansi.PrintableRuneWidth(bgLine) 163 | rightWidth := ansi.PrintableRuneWidth(right) 164 | if rightWidth <= bgLineWidth-pos { 165 | b.WriteString(ws.render(bgLineWidth - rightWidth - pos)) 166 | } 167 | b.WriteString(right) 168 | } 169 | 170 | return b.String() 171 | } 172 | 173 | func cutLeft(s string, cutWidth int) string { 174 | var ( 175 | pos int 176 | isAnsi bool 177 | ab bytes.Buffer 178 | b bytes.Buffer 179 | ) 180 | for _, c := range s { 181 | var w int 182 | if c == ansi.Marker || isAnsi { 183 | isAnsi = true 184 | ab.WriteRune(c) 185 | if ansi.IsTerminator(c) { 186 | isAnsi = false 187 | if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) { 188 | ab.Reset() 189 | } 190 | } 191 | } else { 192 | w = runewidth.RuneWidth(c) 193 | } 194 | 195 | if pos >= cutWidth { 196 | if b.Len() == 0 { 197 | if ab.Len() > 0 { 198 | b.Write(ab.Bytes()) 199 | } 200 | if pos-cutWidth > 1 { 201 | b.WriteByte(' ') 202 | continue 203 | } 204 | } 205 | b.WriteRune(c) 206 | } 207 | pos += w 208 | } 209 | return b.String() 210 | } 211 | 212 | func clamp(v, lower, upper int) int { 213 | return min(max(v, lower), upper) 214 | } 215 | 216 | func max(a, b int) int { 217 | if a > b { 218 | return a 219 | } 220 | return b 221 | } 222 | 223 | func min(a, b int) int { 224 | if a < b { 225 | return a 226 | } 227 | return b 228 | } 229 | 230 | type whitespace struct { 231 | style termenv.Style 232 | chars string 233 | } 234 | 235 | // Render whitespaces. 236 | func (w whitespace) render(width int) string { 237 | if w.chars == "" { 238 | w.chars = " " 239 | } 240 | 241 | r := []rune(w.chars) 242 | j := 0 243 | b := strings.Builder{} 244 | 245 | // Cycle through runes and print them into the whitespace. 246 | for i := 0; i < width; { 247 | b.WriteRune(r[j]) 248 | j++ 249 | if j >= len(r) { 250 | j = 0 251 | } 252 | i += ansi.PrintableRuneWidth(string(r[j])) 253 | } 254 | 255 | // Fill any extra gaps white spaces. This might be necessary if any runes 256 | // are more than one cell wide, which could leave a one-rune gap. 257 | short := width - ansi.PrintableRuneWidth(b.String()) 258 | if short > 0 { 259 | b.WriteString(strings.Repeat(" ", short)) 260 | } 261 | 262 | return w.style.Styled(b.String()) 263 | } 264 | -------------------------------------------------------------------------------- /ui/overlay/textInput.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/textarea" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | // TextInputOverlay represents a text input overlay with state management. 10 | type TextInputOverlay struct { 11 | textarea textarea.Model 12 | Title string 13 | FocusIndex int // 0 for text input, 1 for enter button 14 | Submitted bool 15 | Canceled bool 16 | OnSubmit func() 17 | width, height int 18 | } 19 | 20 | // NewTextInputOverlay creates a new text input overlay with the given title and initial value. 21 | func NewTextInputOverlay(title string, initialValue string) *TextInputOverlay { 22 | ti := textarea.New() 23 | ti.SetValue(initialValue) 24 | ti.Focus() 25 | ti.ShowLineNumbers = false 26 | ti.Prompt = "" 27 | ti.FocusedStyle.CursorLine = lipgloss.NewStyle() 28 | 29 | // Ensure no character limit 30 | ti.CharLimit = 0 31 | // Ensure no maximum height limit 32 | ti.MaxHeight = 0 33 | 34 | return &TextInputOverlay{ 35 | textarea: ti, 36 | Title: title, 37 | FocusIndex: 0, 38 | Submitted: false, 39 | Canceled: false, 40 | } 41 | } 42 | 43 | func (t *TextInputOverlay) SetSize(width, height int) { 44 | t.textarea.SetHeight(height) // Set textarea height to 10 lines 45 | t.width = width 46 | t.height = height 47 | } 48 | 49 | // Init initializes the text input overlay model 50 | func (t *TextInputOverlay) Init() tea.Cmd { 51 | return textarea.Blink 52 | } 53 | 54 | // View renders the model's view 55 | func (t *TextInputOverlay) View() string { 56 | return t.Render() 57 | } 58 | 59 | // HandleKeyPress processes a key press and updates the state accordingly. 60 | // Returns true if the overlay should be closed. 61 | func (t *TextInputOverlay) HandleKeyPress(msg tea.KeyMsg) bool { 62 | switch msg.Type { 63 | case tea.KeyTab: 64 | // Toggle focus between input and enter button. 65 | t.FocusIndex = (t.FocusIndex + 1) % 2 66 | if t.FocusIndex == 0 { 67 | t.textarea.Focus() 68 | } else { 69 | t.textarea.Blur() 70 | } 71 | return false 72 | case tea.KeyShiftTab: 73 | // Toggle focus in reverse. 74 | t.FocusIndex = (t.FocusIndex + 1) % 2 75 | if t.FocusIndex == 0 { 76 | t.textarea.Focus() 77 | } else { 78 | t.textarea.Blur() 79 | } 80 | return false 81 | case tea.KeyEsc: 82 | t.Canceled = true 83 | return true 84 | case tea.KeyEnter: 85 | if t.FocusIndex == 1 { 86 | // Enter button is focused, so submit. 87 | t.Submitted = true 88 | if t.OnSubmit != nil { 89 | t.OnSubmit() 90 | } 91 | return true 92 | } 93 | fallthrough // Send enter key to textarea 94 | default: 95 | if t.FocusIndex == 0 { 96 | t.textarea, _ = t.textarea.Update(msg) 97 | } 98 | return false 99 | } 100 | } 101 | 102 | // GetValue returns the current value of the text input. 103 | func (t *TextInputOverlay) GetValue() string { 104 | return t.textarea.Value() 105 | } 106 | 107 | // IsSubmitted returns whether the form was submitted. 108 | func (t *TextInputOverlay) IsSubmitted() bool { 109 | return t.Submitted 110 | } 111 | 112 | // IsCanceled returns whether the form was canceled. 113 | func (t *TextInputOverlay) IsCanceled() bool { 114 | return t.Canceled 115 | } 116 | 117 | // SetOnSubmit sets a callback function for form submission. 118 | func (t *TextInputOverlay) SetOnSubmit(onSubmit func()) { 119 | t.OnSubmit = onSubmit 120 | } 121 | 122 | // Render renders the text input overlay. 123 | func (t *TextInputOverlay) Render() string { 124 | // Create styles 125 | style := lipgloss.NewStyle(). 126 | Border(lipgloss.RoundedBorder()). 127 | BorderForeground(lipgloss.Color("62")). 128 | Padding(1, 2) 129 | 130 | titleStyle := lipgloss.NewStyle(). 131 | Foreground(lipgloss.Color("62")). 132 | Bold(true). 133 | MarginBottom(1) 134 | 135 | buttonStyle := lipgloss.NewStyle(). 136 | Foreground(lipgloss.Color("7")) 137 | 138 | focusedButtonStyle := buttonStyle 139 | focusedButtonStyle = focusedButtonStyle. 140 | Background(lipgloss.Color("62")). 141 | Foreground(lipgloss.Color("0")) 142 | 143 | // Set textarea width to fit within the overlay 144 | t.textarea.SetWidth(t.width - 6) // Account for padding and borders 145 | 146 | // Build the view 147 | content := titleStyle.Render(t.Title) + "\n" 148 | content += t.textarea.View() + "\n\n" 149 | 150 | // Render enter button with appropriate style 151 | enterButton := " Enter " 152 | if t.FocusIndex == 1 { 153 | enterButton = focusedButtonStyle.Render(enterButton) 154 | } else { 155 | enterButton = buttonStyle.Render(enterButton) 156 | } 157 | content += enterButton 158 | 159 | return style.Render(content) 160 | } 161 | -------------------------------------------------------------------------------- /ui/overlay/textOverlay.go: -------------------------------------------------------------------------------- 1 | package overlay 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | // TextOverlay represents a text screen overlay 9 | type TextOverlay struct { 10 | // Whether the overlay has been dismissed 11 | Dismissed bool 12 | // Callback function to be called when the overlay is dismissed 13 | OnDismiss func() 14 | // Content to display in the overlay 15 | content string 16 | 17 | width int 18 | } 19 | 20 | // NewTextOverlay creates a new text screen overlay with the given title and content 21 | func NewTextOverlay(content string) *TextOverlay { 22 | return &TextOverlay{ 23 | Dismissed: false, 24 | content: content, 25 | } 26 | } 27 | 28 | // HandleKeyPress processes a key press and updates the state 29 | // Returns true if the overlay should be closed 30 | func (t *TextOverlay) HandleKeyPress(msg tea.KeyMsg) bool { 31 | // Close on any key 32 | t.Dismissed = true 33 | // Call the OnDismiss callback if it exists 34 | if t.OnDismiss != nil { 35 | t.OnDismiss() 36 | } 37 | return true 38 | } 39 | 40 | // Render renders the text overlay 41 | func (t *TextOverlay) Render(opts ...WhitespaceOption) string { 42 | // Create styles 43 | style := lipgloss.NewStyle(). 44 | Border(lipgloss.RoundedBorder()). 45 | BorderForeground(lipgloss.Color("62")). 46 | Padding(1, 2). 47 | Width(t.width) 48 | 49 | // Apply the border style and return 50 | return style.Render(t.content) 51 | } 52 | 53 | func (t *TextOverlay) SetWidth(width int) { 54 | t.width = width 55 | } 56 | -------------------------------------------------------------------------------- /ui/preview.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "claude-squad/session" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | var previewPaneStyle = lipgloss.NewStyle(). 12 | Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) 13 | 14 | type PreviewPane struct { 15 | width int 16 | height int 17 | 18 | previewState previewState 19 | } 20 | 21 | type previewState struct { 22 | // fallback is true if the preview pane is displaying fallback text 23 | fallback bool 24 | // text is the text displayed in the preview pane 25 | text string 26 | } 27 | 28 | func NewPreviewPane() *PreviewPane { 29 | return &PreviewPane{} 30 | } 31 | 32 | func (p *PreviewPane) SetSize(width, maxHeight int) { 33 | p.width = width 34 | p.height = maxHeight 35 | } 36 | 37 | // setFallbackState sets the preview state with fallback text and a message 38 | func (p *PreviewPane) setFallbackState(message string) { 39 | p.previewState = previewState{ 40 | fallback: true, 41 | text: lipgloss.JoinVertical(lipgloss.Center, FallBackText, "", message), 42 | } 43 | } 44 | 45 | // Updates the preview pane content with the tmux pane content 46 | func (p *PreviewPane) UpdateContent(instance *session.Instance) error { 47 | switch { 48 | case instance == nil: 49 | p.setFallbackState("No agents running yet. Spin up a new instance with 'n' to get started!") 50 | return nil 51 | case instance.Status == session.Paused: 52 | p.setFallbackState(lipgloss.JoinVertical(lipgloss.Center, 53 | "Session is paused. Press 'r' to resume.", 54 | "", 55 | lipgloss.NewStyle(). 56 | Foreground(lipgloss.AdaptiveColor{ 57 | Light: "#FFD700", 58 | Dark: "#FFD700", 59 | }). 60 | Render(fmt.Sprintf( 61 | "The instance can be checked out at '%s' (copied to your clipboard)", 62 | instance.Branch, 63 | )), 64 | )) 65 | return nil 66 | } 67 | 68 | content, err := instance.Preview() 69 | if err != nil { 70 | return err 71 | } 72 | 73 | if len(content) == 0 { 74 | p.setFallbackState("No agents running yet. Spin up a new instance with 'n' to get started!") 75 | return nil 76 | } 77 | 78 | p.previewState = previewState{ 79 | fallback: false, 80 | text: content, 81 | } 82 | return nil 83 | } 84 | 85 | // Returns the preview pane content as a string. 86 | func (p *PreviewPane) String() string { 87 | if p.width == 0 || p.height == 0 { 88 | return strings.Repeat("\n", p.height) 89 | } 90 | 91 | if p.previewState.fallback { 92 | // Calculate available height for fallback text 93 | availableHeight := p.height - 3 - 4 // 2 for borders, 1 for margin, 1 for padding 94 | 95 | // Count the number of lines in the fallback text 96 | fallbackLines := len(strings.Split(p.previewState.text, "\n")) 97 | 98 | // Calculate padding needed above and below to center the content 99 | totalPadding := availableHeight - fallbackLines 100 | topPadding := 0 101 | bottomPadding := 0 102 | if totalPadding > 0 { 103 | topPadding = totalPadding / 2 104 | bottomPadding = totalPadding - topPadding // accounts for odd numbers 105 | } 106 | 107 | // Build the centered content 108 | var lines []string 109 | if topPadding > 0 { 110 | lines = append(lines, strings.Repeat("\n", topPadding)) 111 | } 112 | lines = append(lines, p.previewState.text) 113 | if bottomPadding > 0 { 114 | lines = append(lines, strings.Repeat("\n", bottomPadding)) 115 | } 116 | 117 | // Center both vertically and horizontally 118 | return previewPaneStyle. 119 | Width(p.width). 120 | Align(lipgloss.Center). 121 | Render(strings.Join(lines, "")) 122 | } 123 | 124 | // Calculate available height accounting for border and margin 125 | availableHeight := p.height - 1 // 1 for ellipsis 126 | 127 | lines := strings.Split(p.previewState.text, "\n") 128 | 129 | // Truncate if we have more lines than available height 130 | if availableHeight > 0 { 131 | if len(lines) > availableHeight { 132 | lines = lines[:availableHeight] 133 | lines = append(lines, "...") 134 | } else { 135 | // Pad with empty lines to fill available height 136 | padding := availableHeight - len(lines) 137 | lines = append(lines, make([]string, padding)...) 138 | } 139 | } 140 | 141 | content := strings.Join(lines, "\n") 142 | rendered := previewPaneStyle.Width(p.width).Render(content) 143 | return rendered 144 | } 145 | -------------------------------------------------------------------------------- /ui/tabbed_window.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "claude-squad/session" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | func tabBorderWithBottom(left, middle, right string) lipgloss.Border { 10 | border := lipgloss.RoundedBorder() 11 | border.BottomLeft = left 12 | border.Bottom = middle 13 | border.BottomRight = right 14 | return border 15 | } 16 | 17 | var ( 18 | inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴") 19 | activeTabBorder = tabBorderWithBottom("┘", " ", "└") 20 | highlightColor = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} 21 | inactiveTabStyle = lipgloss.NewStyle(). 22 | Border(inactiveTabBorder, true). 23 | BorderForeground(highlightColor). 24 | AlignHorizontal(lipgloss.Center) 25 | activeTabStyle = inactiveTabStyle. 26 | Border(activeTabBorder, true). 27 | AlignHorizontal(lipgloss.Center) 28 | windowStyle = lipgloss.NewStyle(). 29 | BorderForeground(highlightColor). 30 | Border(lipgloss.NormalBorder(), false, true, true, true) 31 | ) 32 | 33 | const ( 34 | PreviewTab = iota 35 | DiffTab 36 | ) 37 | 38 | type Tab struct { 39 | Name string 40 | Render func(width int, height int) string 41 | } 42 | 43 | // TabbedWindow has tabs at the top of a pane which can be selected. The tabs 44 | // take up one rune of height. 45 | type TabbedWindow struct { 46 | tabs []string 47 | 48 | activeTab int 49 | height int 50 | width int 51 | 52 | preview *PreviewPane 53 | diff *DiffPane 54 | } 55 | 56 | func NewTabbedWindow(preview *PreviewPane, diff *DiffPane) *TabbedWindow { 57 | return &TabbedWindow{ 58 | tabs: []string{ 59 | "Preview", 60 | "Diff", 61 | }, 62 | preview: preview, 63 | diff: diff, 64 | } 65 | } 66 | 67 | // AdjustPreviewWidth adjusts the width of the preview pane to be 90% of the provided width. 68 | func AdjustPreviewWidth(width int) int { 69 | return int(float64(width) * 0.9) 70 | } 71 | 72 | func (w *TabbedWindow) SetSize(width, height int) { 73 | w.width = AdjustPreviewWidth(width) 74 | w.height = height 75 | 76 | // Calculate the content height by subtracting: 77 | // 1. Tab height (including border and padding) 78 | // 2. Window style vertical frame size 79 | // 3. Additional padding/spacing (2 for the newline and spacing) 80 | tabHeight := activeTabStyle.GetVerticalFrameSize() + 1 81 | contentHeight := height - tabHeight - windowStyle.GetVerticalFrameSize() - 2 82 | contentWidth := w.width - windowStyle.GetHorizontalFrameSize() 83 | 84 | w.preview.SetSize(contentWidth, contentHeight) 85 | w.diff.SetSize(contentWidth, contentHeight) 86 | } 87 | 88 | func (w *TabbedWindow) GetPreviewSize() (width, height int) { 89 | return w.preview.width, w.preview.height 90 | } 91 | 92 | func (w *TabbedWindow) Toggle() { 93 | w.activeTab = (w.activeTab + 1) % len(w.tabs) 94 | } 95 | 96 | // UpdatePreview updates the content of the preview pane. instance may be nil. 97 | func (w *TabbedWindow) UpdatePreview(instance *session.Instance) error { 98 | if w.activeTab != PreviewTab { 99 | return nil 100 | } 101 | return w.preview.UpdateContent(instance) 102 | } 103 | 104 | func (w *TabbedWindow) UpdateDiff(instance *session.Instance) { 105 | if w.activeTab != DiffTab { 106 | return 107 | } 108 | w.diff.SetDiff(instance) 109 | } 110 | 111 | // Add these new methods for handling scroll events 112 | func (w *TabbedWindow) ScrollUp() { 113 | if w.activeTab == 1 { // Diff tab 114 | w.diff.ScrollUp() 115 | } 116 | } 117 | 118 | func (w *TabbedWindow) ScrollDown() { 119 | if w.activeTab == 1 { // Diff tab 120 | w.diff.ScrollDown() 121 | } 122 | } 123 | 124 | // IsInDiffTab returns true if the diff tab is currently active 125 | func (w *TabbedWindow) IsInDiffTab() bool { 126 | return w.activeTab == 1 127 | } 128 | 129 | func (w *TabbedWindow) String() string { 130 | if w.width == 0 || w.height == 0 { 131 | return "" 132 | } 133 | 134 | var renderedTabs []string 135 | 136 | tabWidth := w.width / len(w.tabs) 137 | lastTabWidth := w.width - tabWidth*(len(w.tabs)-1) 138 | tabHeight := activeTabStyle.GetVerticalFrameSize() + 1 // get padding border margin size + 1 for character height 139 | 140 | for i, t := range w.tabs { 141 | width := tabWidth 142 | if i == len(w.tabs)-1 { 143 | width = lastTabWidth 144 | } 145 | 146 | var style lipgloss.Style 147 | isFirst, isLast, isActive := i == 0, i == len(w.tabs)-1, i == w.activeTab 148 | if isActive { 149 | style = activeTabStyle 150 | } else { 151 | style = inactiveTabStyle 152 | } 153 | border, _, _, _, _ := style.GetBorder() 154 | if isFirst && isActive { 155 | border.BottomLeft = "│" 156 | } else if isFirst && !isActive { 157 | border.BottomLeft = "├" 158 | } else if isLast && isActive { 159 | border.BottomRight = "│" 160 | } else if isLast && !isActive { 161 | border.BottomRight = "┤" 162 | } 163 | style = style.Border(border) 164 | style = style.Width(width - 1) 165 | renderedTabs = append(renderedTabs, style.Render(t)) 166 | } 167 | 168 | row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) 169 | var content string 170 | if w.activeTab == 0 { 171 | content = w.preview.String() 172 | } else { 173 | content = w.diff.String() 174 | } 175 | window := windowStyle.Render( 176 | lipgloss.Place( 177 | w.width, w.height-2-windowStyle.GetVerticalFrameSize()-tabHeight, 178 | lipgloss.Left, lipgloss.Top, content)) 179 | 180 | return lipgloss.JoinVertical(lipgloss.Left, "\n", row, window) 181 | } 182 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /web/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /web/next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | output: "export", 5 | basePath: process.env.NODE_ENV === "production" ? "/claude-squad" : "", 6 | /* config options here */ 7 | }; 8 | 9 | export default nextConfig; -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "claude-squad", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "react": "^19.0.0", 13 | "react-dom": "^19.0.0", 14 | "next": "15.3.2" 15 | }, 16 | "devDependencies": { 17 | "typescript": "^5", 18 | "@types/node": "^20", 19 | "@types/react": "^19", 20 | "@types/react-dom": "^19", 21 | "eslint": "^9", 22 | "eslint-config-next": "15.3.2", 23 | "@eslint/eslintrc": "^3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /web/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/app/components/CopyButton.module.css: -------------------------------------------------------------------------------- 1 | .copyButton { 2 | border: none; 3 | background: transparent; 4 | cursor: pointer; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | color: var(--foreground); 9 | opacity: 0.7; 10 | transition: opacity 0.2s; 11 | padding: 8px; 12 | border-radius: 4px; 13 | position: absolute; 14 | top: 4px; 15 | right: 4px; 16 | } 17 | 18 | .copyButton:hover { 19 | opacity: 1; 20 | background: var(--gray-alpha-100); 21 | } 22 | 23 | .copyButton:focus { 24 | outline: none; 25 | box-shadow: 0 0 0 2px var(--accent-color); 26 | } 27 | 28 | /* Adjust copy button position on mobile devices */ 29 | @media (max-width: 600px) { 30 | .copyButton { 31 | display: none; 32 | } 33 | } -------------------------------------------------------------------------------- /web/src/app/components/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import styles from "./CopyButton.module.css"; 5 | 6 | interface CopyButtonProps { 7 | textToCopy: string; 8 | } 9 | 10 | export default function CopyButton({ textToCopy }: CopyButtonProps) { 11 | const [copied, setCopied] = useState(false); 12 | 13 | const handleCopy = async () => { 14 | try { 15 | await navigator.clipboard.writeText(textToCopy); 16 | setCopied(true); 17 | setTimeout(() => setCopied(false), 2000); 18 | } catch (err) { 19 | console.error("Failed to copy text: ", err); 20 | } 21 | }; 22 | 23 | return ( 24 | 41 | ); 42 | } -------------------------------------------------------------------------------- /web/src/app/components/ThemeToggle.module.css: -------------------------------------------------------------------------------- 1 | .themeToggle { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 36px; 6 | height: 36px; 7 | border-radius: 50%; 8 | border: none; 9 | background: var(--gray-alpha-100); 10 | color: var(--foreground); 11 | cursor: pointer; 12 | transition: all 0.2s ease; 13 | } 14 | 15 | .themeToggle:hover { 16 | background: var(--gray-alpha-200); 17 | } 18 | 19 | @media (max-width: 600px) { 20 | .themeToggle { 21 | width: 32px; 22 | height: 32px; 23 | } 24 | } -------------------------------------------------------------------------------- /web/src/app/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import styles from "./ThemeToggle.module.css"; 5 | 6 | type Theme = "light" | "dark"; 7 | 8 | export default function ThemeToggle() { 9 | const [theme, setTheme] = useState("light"); 10 | 11 | useEffect(() => { 12 | // Get the theme from localStorage or default to light 13 | const savedTheme = localStorage.getItem("theme") as Theme | null; 14 | if (savedTheme && (savedTheme === "light" || savedTheme === "dark")) { 15 | setTheme(savedTheme); 16 | document.documentElement.setAttribute("data-theme", savedTheme); 17 | } else { 18 | // Default to light if no valid theme is saved 19 | document.documentElement.setAttribute("data-theme", "light"); 20 | } 21 | }, []); 22 | 23 | const toggleTheme = () => { 24 | const newTheme = theme === "light" ? "dark" : "light"; 25 | setTheme(newTheme); 26 | localStorage.setItem("theme", newTheme); 27 | document.documentElement.setAttribute("data-theme", newTheme); 28 | }; 29 | 30 | return ( 31 | 55 | ); 56 | } -------------------------------------------------------------------------------- /web/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smtg-ai/claude-squad/946434e341f51e0dfbcb919a83a628c5826d6267/web/src/app/favicon.ico -------------------------------------------------------------------------------- /web/src/app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background: #ffffff; 3 | --foreground: #171717; 4 | --accent-color: #4a55af; 5 | --accent-light: #7983d8; 6 | } 7 | 8 | @media (prefers-color-scheme: dark) { 9 | :root:not([data-theme="light"]) { 10 | --background: #0a0a0a; 11 | --foreground: #ededed; 12 | --accent-color: #6e79d8; 13 | --accent-light: #9098e9; 14 | } 15 | } 16 | 17 | html[data-theme="light"] { 18 | --background: #ffffff; 19 | --foreground: #171717; 20 | --accent-color: #4a55af; 21 | --accent-light: #7983d8; 22 | } 23 | 24 | html[data-theme="dark"] { 25 | --background: #0a0a0a; 26 | --foreground: #ededed; 27 | --accent-color: #6e79d8; 28 | --accent-light: #9098e9; 29 | } 30 | 31 | html, 32 | body { 33 | max-width: 100vw; 34 | overflow-x: hidden; 35 | } 36 | 37 | body { 38 | color: var(--foreground); 39 | background: var(--background); 40 | -webkit-font-smoothing: antialiased; 41 | -moz-osx-font-smoothing: grayscale; 42 | } 43 | 44 | * { 45 | box-sizing: border-box; 46 | padding: 0; 47 | margin: 0; 48 | } 49 | 50 | a { 51 | color: inherit; 52 | text-decoration: none; 53 | } 54 | 55 | h1, h2, h3, h4, h5, h6 { 56 | line-height: 1.2; 57 | margin-top: 0; 58 | } 59 | 60 | pre, code { 61 | font-family: var(--font-geist-mono), monospace; 62 | } 63 | 64 | @media (prefers-color-scheme: dark) { 65 | html:not([data-theme="light"]) { 66 | color-scheme: dark; 67 | } 68 | } 69 | 70 | html[data-theme="dark"] { 71 | color-scheme: dark; 72 | } -------------------------------------------------------------------------------- /web/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, Viewport } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const viewport: Viewport = { 16 | width: "device-width", 17 | initialScale: 1, 18 | maximumScale: 5, 19 | userScalable: true, 20 | }; 21 | 22 | export const metadata: Metadata = { 23 | title: "Claude Squad - Manage Multiple AI Code Assistants", 24 | description: "A terminal app that manages multiple AI code assistants (Claude Code, Codex, Aider, etc.) in separate workspaces, allowing you to work on multiple tasks simultaneously.", 25 | keywords: ["claude", "claude squad", "ai", "code assistant", "terminal", "tmux", "claude code", "codex", "aider"], 26 | authors: [{ name: "smtg-ai" }], 27 | openGraph: { 28 | title: "Claude Squad", 29 | description: "A terminal app that manages multiple AI code assistants in separate workspaces", 30 | url: "https://github.com/smtg-ai/claude-squad", 31 | type: "website", 32 | }, 33 | twitter: { 34 | card: "summary_large_image", 35 | title: "Claude Squad", 36 | description: "A terminal app that manages multiple AI code assistants in separate workspaces", 37 | }, 38 | }; 39 | 40 | export default function RootLayout({ 41 | children, 42 | }: Readonly<{ 43 | children: React.ReactNode; 44 | }>) { 45 | return ( 46 | 47 | 48 | {children} 49 | 50 | 51 | ); 52 | } -------------------------------------------------------------------------------- /web/src/app/page.module.css: -------------------------------------------------------------------------------- 1 | .page { 2 | --gray-rgb: 0, 0, 0; 3 | --gray-alpha-200: rgba(var(--gray-rgb), 0.08); 4 | --gray-alpha-100: rgba(var(--gray-rgb), 0.05); 5 | 6 | --button-primary-hover: #383838; 7 | --button-secondary-hover: #f2f2f2; 8 | 9 | display: grid; 10 | grid-template-rows: auto 1fr auto; 11 | min-height: 100svh; 12 | padding: 0; 13 | gap: 0; 14 | font-family: var(--font-geist-sans); 15 | } 16 | 17 | @media (prefers-color-scheme: dark) { 18 | :root:not([data-theme="light"]) .page { 19 | --gray-rgb: 255, 255, 255; 20 | --gray-alpha-200: rgba(var(--gray-rgb), 0.145); 21 | --gray-alpha-100: rgba(var(--gray-rgb), 0.06); 22 | 23 | --button-primary-hover: #ccc; 24 | --button-secondary-hover: #1a1a1a; 25 | } 26 | } 27 | 28 | html[data-theme="dark"] .page { 29 | --gray-rgb: 255, 255, 255; 30 | --gray-alpha-200: rgba(var(--gray-rgb), 0.145); 31 | --gray-alpha-100: rgba(var(--gray-rgb), 0.06); 32 | 33 | --button-primary-hover: #ccc; 34 | --button-secondary-hover: #1a1a1a; 35 | } 36 | 37 | .main { 38 | display: flex; 39 | flex-direction: column; 40 | gap: 32px; 41 | max-width: 800px; 42 | width: 100%; 43 | margin: 60px auto 20px; 44 | padding: 0 20px; 45 | } 46 | 47 | .header { 48 | display: flex; 49 | justify-content: space-between; 50 | align-items: center; 51 | padding: 20px 15%; 52 | width: 100%; 53 | } 54 | 55 | .headerActions { 56 | display: flex; 57 | gap: 16px; 58 | align-items: center; 59 | } 60 | 61 | .headerButton { 62 | appearance: none; 63 | border-radius: 6px; 64 | height: 36px; 65 | padding: 0 12px; 66 | border: 1px solid var(--gray-alpha-200); 67 | background: transparent; 68 | transition: background 0.2s, color 0.2s, border-color 0.2s; 69 | cursor: pointer; 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | font-size: 14px; 74 | font-weight: 500; 75 | text-decoration: none; 76 | color: var(--foreground); 77 | } 78 | 79 | .title { 80 | font-size: 24px; 81 | margin: 0; 82 | background: linear-gradient(90deg, var(--accent-color), var(--accent-light)); 83 | -webkit-background-clip: text; 84 | background-clip: text; 85 | color: transparent; 86 | font-weight: 700; 87 | font-family: var(--font-geist-mono); 88 | text-transform: lowercase; 89 | letter-spacing: 1px; 90 | } 91 | 92 | .tagline { 93 | font-size: 32px; 94 | text-align: center; 95 | margin: 0 auto; 96 | max-width: 700px; 97 | line-height: 1.3; 98 | } 99 | 100 | .highlight { 101 | font-weight: 600; 102 | color: var(--accent-color); 103 | } 104 | 105 | .tenx { 106 | font-size: 1.8em; 107 | font-weight: 700; 108 | text-decoration: underline; 109 | } 110 | 111 | .demoVideo { 112 | width: 100%; 113 | display: flex; 114 | justify-content: center; 115 | margin: 0 auto; 116 | } 117 | 118 | .video { 119 | max-width: 100%; 120 | border-radius: 10px; 121 | box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1); 122 | width: 100%; 123 | aspect-ratio: 36 / 30; 124 | background-color: var(--gray-alpha-100); 125 | } 126 | 127 | .features { 128 | margin-top: 20px; 129 | } 130 | 131 | .features h2 { 132 | font-size: 28px; 133 | margin-bottom: 16px; 134 | } 135 | 136 | .features ul { 137 | list-style-type: disc; 138 | padding-left: 20px; 139 | margin: 0; 140 | font-size: 16px; 141 | line-height: 1.6; 142 | } 143 | 144 | .features li { 145 | margin-bottom: 8px; 146 | } 147 | 148 | .installation { 149 | background: var(--gray-alpha-100); 150 | padding: 24px; 151 | border-radius: 12px; 152 | width: 100%; 153 | } 154 | 155 | .installation h2 { 156 | font-size: 28px; 157 | margin-top: 0; 158 | margin-bottom: 16px; 159 | } 160 | 161 | .codeBlockWrapper { 162 | position: relative; 163 | display: flex; 164 | background: var(--gray-alpha-200); 165 | border-radius: 8px; 166 | overflow: hidden; 167 | width: 100%; 168 | max-width: 100%; 169 | } 170 | 171 | .codeBlock { 172 | flex: 1; 173 | padding: 16px; 174 | padding-right: 40px; 175 | overflow-x: auto; 176 | font-family: var(--font-geist-mono); 177 | font-size: 14px; 178 | line-height: 1.4; 179 | white-space: pre-wrap; 180 | word-break: break-all; 181 | margin: 0; 182 | width: 100%; 183 | } 184 | 185 | .prerequisites { 186 | margin-top: 16px; 187 | margin-bottom: 0; 188 | font-size: 14px; 189 | opacity: 0.8; 190 | } 191 | 192 | .ctas { 193 | display: flex; 194 | gap: 16px; 195 | margin-top: 0; 196 | margin-bottom: 32px; 197 | flex-wrap: wrap; 198 | justify-content: center; 199 | width: 100%; 200 | } 201 | 202 | .ctas a { 203 | appearance: none; 204 | border-radius: 128px; 205 | height: 48px; 206 | padding: 0 20px; 207 | border: none; 208 | border: 1px solid transparent; 209 | transition: 210 | background 0.2s, 211 | color 0.2s, 212 | border-color 0.2s; 213 | cursor: pointer; 214 | display: flex; 215 | align-items: center; 216 | justify-content: center; 217 | font-size: 16px; 218 | line-height: 20px; 219 | font-weight: 500; 220 | text-decoration: none; 221 | } 222 | 223 | a.primary { 224 | background: var(--foreground); 225 | color: var(--background); 226 | gap: 8px; 227 | } 228 | 229 | a.secondary { 230 | border-color: var(--gray-alpha-200); 231 | min-width: 158px; 232 | } 233 | 234 | .footer { 235 | text-align: center; 236 | font-size: 14px; 237 | color: var(--gray-rgb); 238 | opacity: 0.7; 239 | width: 100%; 240 | padding: 10px 0; 241 | margin-top: 20px; 242 | } 243 | 244 | .footer a { 245 | color: inherit; 246 | text-decoration: underline; 247 | } 248 | 249 | .copyright { 250 | margin: 0; 251 | } 252 | 253 | /* Enable hover only on non-touch devices */ 254 | @media (hover: hover) and (pointer: fine) { 255 | a.primary:hover { 256 | background: var(--button-primary-hover); 257 | border-color: transparent; 258 | } 259 | 260 | a.secondary:hover { 261 | background: var(--button-secondary-hover); 262 | border-color: transparent; 263 | } 264 | 265 | .footer a:hover { 266 | text-decoration: underline; 267 | text-underline-offset: 4px; 268 | opacity: 1; 269 | } 270 | } 271 | 272 | @media (max-width: 768px) { 273 | .header { 274 | padding: 20px 5%; 275 | } 276 | 277 | .main { 278 | margin: 40px auto; 279 | } 280 | 281 | .tagline { 282 | font-size: 26px; 283 | } 284 | 285 | .features h2, .installation h2 { 286 | font-size: 24px; 287 | } 288 | } 289 | 290 | @media (max-width: 600px) { 291 | .header { 292 | padding: 15px 5%; 293 | flex-direction: column; 294 | gap: 15px; 295 | width: 100%; 296 | box-sizing: border-box; 297 | } 298 | 299 | .main { 300 | align-items: center; 301 | margin: 30px auto; 302 | gap: 20px; 303 | padding: 0 15px; 304 | width: 100%; 305 | box-sizing: border-box; 306 | } 307 | 308 | .tagline { 309 | font-size: 22px; 310 | width: 100%; 311 | } 312 | 313 | .headerActions { 314 | width: 100%; 315 | justify-content: center; 316 | flex-wrap: wrap; 317 | gap: 10px; 318 | } 319 | 320 | .headerButton { 321 | font-size: 12px; 322 | height: 32px; 323 | padding: 0 10px; 324 | flex: 0 1 auto; 325 | min-width: 80px; 326 | text-align: center; 327 | } 328 | 329 | .features ul { 330 | padding-left: 15px; 331 | } 332 | 333 | .features li { 334 | margin-bottom: 6px; 335 | } 336 | 337 | .installation { 338 | padding: 16px; 339 | } 340 | 341 | .codeBlockWrapper { 342 | display: flex; 343 | max-width: 100%; 344 | width: 100%; 345 | flex-direction: column; 346 | } 347 | 348 | .codeBlock { 349 | font-size: 12px; 350 | padding: 12px; 351 | width: 100%; 352 | white-space: pre-wrap; 353 | word-break: break-all; 354 | overflow-wrap: break-word; 355 | } 356 | 357 | .ctas { 358 | gap: 10px; 359 | margin-bottom: 20px; 360 | } 361 | 362 | .ctas a { 363 | height: 40px; 364 | padding: 0 15px; 365 | font-size: 14px; 366 | min-width: 120px; 367 | } 368 | } 369 | 370 | @media (prefers-color-scheme: dark) { 371 | .logo { 372 | filter: invert(); 373 | } 374 | } -------------------------------------------------------------------------------- /web/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./page.module.css"; 2 | import dynamic from "next/dynamic"; 3 | 4 | // Import ThemeToggle and CopyButton dynamically to prevent hydration issues 5 | const ThemeToggle = dynamic(() => import("./components/ThemeToggle"), { 6 | }); 7 | 8 | const CopyButton = dynamic(() => import("./components/CopyButton"), { 9 | }); 10 | 11 | export default function Home() { 12 | return ( 13 |
14 |
15 |

claude squad

16 | 35 |
36 |
37 | 38 | 39 |

40 | Manage multiple AI agents like Claude Code, Codex, and Aider.
10x your productivity 41 |

42 | 43 |
44 |
54 | 55 |
56 |

Installation

57 |
58 |
59 |               curl -fsSL https://raw.githubusercontent.com/stmg-ai/claude-squad/main/install.sh | bash
60 |             
61 | 62 |
63 |

64 | Prerequisites: tmux, gh (GitHub CLI) 65 |

66 |
67 | 68 |
69 |

Why use Claude Squad?

70 |
    71 |
  • Supervise multiple agents in one UI
  • 72 |
  • Isolate tasks in git workspaces
  • 73 |
  • Review work before shipping
  • 74 |
75 |
76 |
77 | 82 |
83 | ); 84 | } -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------