├── .github └── workflows │ ├── push-to-bunny.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── broadcast │ └── broadcast.go ├── checkpoint │ └── checkpoint.go ├── kill │ └── kill.go ├── ls │ └── ls.go ├── prompt │ └── prompt.go ├── reset │ └── reset.go ├── run │ └── run.go └── watch │ └── auto.go ├── go.mod ├── go.sum ├── pkg ├── agents │ └── agents.go ├── config │ └── config.go └── state │ └── state.go ├── uzi ├── uzi-site ├── .gitignore ├── CLAUDE.md ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── banner-messages.txt ├── commit-types.txt ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html ├── media │ └── 16x9steppecommander.png ├── sprites │ ├── bombers │ │ ├── bomber-sprite-1.png │ │ └── bomber-sprite-2.png │ ├── fighters │ │ ├── fighter-sprite-1.png │ │ └── fighter-sprite-2.png │ ├── projectiles │ │ ├── missile-sprite-1.png │ │ └── missile-sprite-2.png │ ├── ships │ │ └── carrier-sprite-1.png │ ├── specials │ │ └── sr-71-sprite.png │ └── subs │ │ └── sub-sprite-1.png ├── uzi-opengraph.png └── uzi.yaml ├── uzi.go └── uzi.yaml /.github/workflows/push-to-bunny.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Site to Bunny 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.head_ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | workflow_dispatch: 9 | push: 10 | branches: 11 | - "main" 12 | paths: 13 | - "uzi-site/**" 14 | - "workflows/push-to-bunny.yml" 15 | 16 | jobs: 17 | build_push_website: 18 | name: Push Website 19 | runs-on: blacksmith-16vcpu-ubuntu-2404 20 | steps: 21 | - name: Checkout the repo 22 | uses: actions/checkout@v4 23 | 24 | - name: Upload Dir to Bunny 25 | env: 26 | BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} 27 | STORAGE_ZONE: uzi-site 28 | UPLOAD_PATH: uzi-site 29 | run: | 30 | find "$UPLOAD_PATH" -type f -print0 | xargs -0 -I{} -P 4 sh -c ' 31 | file="{}" 32 | # Get the relative path of the file 33 | relative_path="${file#$UPLOAD_PATH/}" 34 | # Construct the URL for the BunnyCDN storage 35 | upload_url="https://storage.bunnycdn.com/$STORAGE_ZONE/$relative_path" 36 | # Upload the file to BunnyCDN 37 | echo -n "Uploading $file ... " 38 | curl --request PUT --url "$upload_url" \ 39 | --header "AccessKey: $BUNNY_API_KEY" \ 40 | --header "Content-Type: application/octet-stream" \ 41 | --header "accept: application/json" \ 42 | --data-binary "@$file" \ 43 | -s \ 44 | -w " \n" 45 | ' 46 | echo "Done!" 47 | - name: Purge Cache 48 | env: 49 | BUNNY_NET_API_KEY: ${{ secrets.BUNNY_NET_API_KEY }} 50 | ZONE_ID: ${{ secrets.ZONE_ID }} 51 | run: | 52 | curl --request POST \ 53 | --url https://api.bunny.net/pullzone/${ZONE_ID}/purgeCache \ 54 | --header "AccessKey: ${BUNNY_NET_API_KEY}" \ 55 | --header 'content-type: application/json' 56 | 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: '1.21' 21 | 22 | - name: Run GoReleaser 23 | uses: goreleaser/goreleaser-action@v5 24 | with: 25 | distribution: goreleaser 26 | version: latest 27 | args: release --clean 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | uzi 2 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | 5 | builds: 6 | - id: uzi 7 | binary: uzi 8 | main: ./uzi.go 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | - windows 15 | goarch: 16 | - amd64 17 | - arm64 18 | ignore: 19 | - goos: windows 20 | goarch: arm64 21 | ldflags: 22 | - -s -w 23 | - -X main.version={{.Version}} 24 | - -X main.commit={{.ShortCommit}} 25 | - -X main.date={{.Date}} 26 | 27 | archives: 28 | - format: tar.gz 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- title .Os }}_ 32 | {{- if eq .Arch "amd64" }}x86_64 33 | {{- else if eq .Arch "386" }}i386 34 | {{- else }}{{ .Arch }}{{ end }} 35 | format_overrides: 36 | - goos: windows 37 | format: zip 38 | files: 39 | - LICENSE 40 | - README.md 41 | 42 | checksum: 43 | name_template: 'checksums.txt' 44 | algorithm: sha256 45 | 46 | changelog: 47 | sort: asc 48 | filters: 49 | exclude: 50 | - '^docs:' 51 | - '^test:' 52 | - '^ci:' 53 | - Merge pull request 54 | - Merge branch 55 | 56 | release: 57 | name_template: '{{ .ProjectName }} {{ .Version }}' 58 | draft: false 59 | prerelease: false -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Devflow, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for uzi.go 2 | 3 | # Variables 4 | GO_FILES := $(wildcard *.go) 5 | BINARY := uzi 6 | 7 | # Default target 8 | all: build 9 | 10 | # Build the Go binary 11 | build: 12 | go build -o $(BINARY) $(GO_FILES) 13 | 14 | # Run the Go program 15 | run: build 16 | ./$(BINARY) 17 | 18 | # Clean up build artifacts 19 | clean: 20 | rm -f $(BINARY) 21 | 22 | .PHONY: all build run clean -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | Whitepaper 10 | 11 |

12 | 13 | ## Installation 14 | 15 | ```bash 16 | go install github.com/devflowinc/uzi@latest 17 | ``` 18 | 19 | Make sure that your GOBIN is in your PATH. 20 | 21 | ```sh 22 | export PATH="$PATH:$HOME/go/bin" 23 | ``` 24 | 25 | ## Features 26 | 27 | - 🤖 Run multiple AI coding agents in parallel 28 | - 🌳 Automatic Git worktree management for isolated development 29 | - 🖥️ Tmux session management for each agent 30 | - 🚀 Automatic development server setup with port management 31 | - 📊 Real-time monitoring of agent status and code changes 32 | - 🔄 Automatic handling of agent prompts and confirmations 33 | - 🎯 Easy checkpoint and merge of agent changes 34 | 35 | ## Prerequisites 36 | 37 | - **Git**: For version control and worktree management 38 | - **Tmux**: For terminal session management 39 | - **Go**: For installing 40 | - **Your AI tool of choice**: Such as `claude`, `codex`, etc. 41 | 42 | ## Configuration 43 | 44 | ### uzi.yaml 45 | 46 | Create a `uzi.yaml` file in your project root to configure Uzi: 47 | 48 | ```yaml 49 | devCommand: cd astrobits && yarn && yarn dev --port $PORT 50 | portRange: 3000-3010 51 | ``` 52 | 53 | #### Configuration Options 54 | 55 | - **`devCommand`**: The command to start your development server. Use `$PORT` as a placeholder for the port number. 56 | - Example for Next.js: `npm install && npm run dev -- --port $PORT` 57 | - Example for Vite: `npm install && npm run dev -- --port $PORT` 58 | - Example for Django: `pip install -r requirements.txt && python manage.py runserver 0.0.0.0:$PORT` 59 | - **`portRange`**: The range of ports Uzi can use for development servers (format: `start-end`) 60 | 61 | **Important**: The `devCommand` should include all necessary setup steps (like `npm install`, `pip install`, etc.) as each agent runs in an isolated worktree with its own dependencies. 62 | 63 | ## Basic Workflow 64 | 65 | 1. **Start agents with a task:** 66 | 67 | ```bash 68 | uzi prompt --agents claude:3,codex:2 "Implement a REST API for user management with authentication" 69 | ``` 70 | 71 | 2. **Run uzi auto** 72 | 73 | uzi auto automatically presses Enter to confirm all tool calls 74 | 75 | ``` 76 | uzi auto 77 | ``` 78 | 79 | 3. **Monitor agent progress:** 80 | 81 | ```bash 82 | uzi ls -w # Watch mode 83 | ``` 84 | 85 | 4. **Send additional instructions:** 86 | 87 | ```bash 88 | uzi broadcast "Make sure to add input validation" 89 | ``` 90 | 91 | 5. **Merge completed work:** 92 | ```bash 93 | uzi checkpoint funny-elephant "feat: add user management API" 94 | ``` 95 | 96 | ## Commands 97 | 98 | ### `uzi prompt` (alias: `uzi p`) 99 | 100 | Creates new agent sessions with the specified prompt. 101 | 102 | ```bash 103 | uzi prompt --agents claude:2,codex:1 "Build a todo app with React" 104 | ``` 105 | 106 | **Options:** 107 | 108 | - `--agents`: Specify agents and counts in format `agent:count[,agent:count...]` 109 | - Use `random` as agent name for random agent names 110 | - Example: `--agents claude:2,random:3` 111 | 112 | ### `uzi ls` (alias: `uzi l`) 113 | 114 | Lists all active agent sessions with their status. 115 | 116 | ```bash 117 | uzi ls # List active sessions 118 | uzi ls -w # Watch mode - refreshes every second 119 | ``` 120 | 121 | ``` 122 | AGENT MODEL STATUS DIFF ADDR PROMPT 123 | brian codex ready +0/-0 http://localhost:3003 make a component that looks similar to @astrobits/src/components/Button/ that creates a Tooltip in the same style. Ensure that you include a reference to it and examples on the main page. 124 | gregory codex ready +0/-0 http://localhost:3001 make a component that ` 125 | ``` 126 | 127 | ### `uzi auto` (alias: `uzi a`) 128 | 129 | Monitors all agent sessions and automatically handles prompts. 130 | 131 | ```bash 132 | uzi auto 133 | ``` 134 | 135 | **Features:** 136 | 137 | - Auto-presses Enter for trust prompts 138 | - Handles continuation confirmations 139 | - Runs in the background until interrupted (Ctrl+C) 140 | 141 | ### `uzi kill` (alias: `uzi k`) 142 | 143 | Terminates agent sessions and cleans up resources. 144 | 145 | ```bash 146 | uzi kill agent-name # Kill specific agent 147 | uzi kill all # Kill all agents 148 | ``` 149 | 150 | ### `uzi run` (alias: `uzi r`) 151 | 152 | Executes a command in all active agent sessions. 153 | 154 | ```bash 155 | uzi run "git status" # Run in all agents 156 | uzi run --delete "npm test" # Run and delete the window after 157 | ``` 158 | 159 | **Options:** 160 | 161 | - `--delete`: Remove the tmux window after running the command 162 | 163 | ### `uzi broadcast` (alias: `uzi b`) 164 | 165 | Sends a message to all active agent sessions. 166 | 167 | ```bash 168 | uzi broadcast "Please add error handling to all API calls" 169 | ``` 170 | 171 | ### `uzi checkpoint` (alias: `uzi c`) 172 | 173 | Makes a commit and rebases changes from an agent's worktree into your current branch. 174 | 175 | ```bash 176 | uzi checkpoint agent-name "feat: implement user authentication" 177 | ``` 178 | 179 | ### `uzi reset` 180 | 181 | Removes all Uzi data and configuration. 182 | 183 | ```bash 184 | uzi reset 185 | ``` 186 | 187 | **Warning**: This deletes all data in `~/.local/share/uzi` 188 | 189 | ### Advanced Usage 190 | 191 | **Running different AI tools:** 192 | 193 | ```bash 194 | uzi prompt --agents=claude:2,aider:2,cursor:1 "Refactor the authentication system" 195 | ``` 196 | 197 | **Using random agent names:** 198 | 199 | ```bash 200 | uzi prompt --agents=random:5 "Fix all TypeScript errors" 201 | ``` 202 | 203 | **Running tests across all agents:** 204 | 205 | ```bash 206 | uzi run "npm test" 207 | ``` 208 | -------------------------------------------------------------------------------- /cmd/broadcast/broadcast.go: -------------------------------------------------------------------------------- 1 | package broadcast 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/devflowinc/uzi/pkg/state" 11 | 12 | "github.com/charmbracelet/log" 13 | "github.com/peterbourgon/ff/v3/ffcli" 14 | ) 15 | 16 | var ( 17 | fs = flag.NewFlagSet("uzi broadcast", flag.ExitOnError) 18 | CmdBroadcast = &ffcli.Command{ 19 | Name: "broadcast", 20 | ShortUsage: "uzi broadcast ", 21 | ShortHelp: "Send a message to all active agent sessions", 22 | FlagSet: fs, 23 | Exec: executeBroadcast, 24 | } 25 | ) 26 | 27 | func executeBroadcast(ctx context.Context, args []string) error { 28 | if len(args) == 0 { 29 | return fmt.Errorf("message argument is required") 30 | } 31 | 32 | message := strings.Join(args, " ") 33 | log.Debug("Broadcasting message", "message", message) 34 | 35 | // Get state manager to read from config 36 | sm := state.NewStateManager() 37 | if sm == nil { 38 | return fmt.Errorf("could not initialize state manager") 39 | } 40 | 41 | // Get active sessions from state 42 | activeSessions, err := sm.GetActiveSessionsForRepo() 43 | if err != nil { 44 | log.Error("Error getting active sessions", "error", err) 45 | return err 46 | } 47 | 48 | if len(activeSessions) == 0 { 49 | return fmt.Errorf("no active agent sessions found") 50 | } 51 | 52 | fmt.Printf("Broadcasting message to %d agent sessions:\n", len(activeSessions)) 53 | 54 | // Send message to each session 55 | for _, session := range activeSessions { 56 | fmt.Printf("\n=== %s ===\n", session) 57 | 58 | // Send the message to the agent window 59 | sendKeysCmd := exec.Command("tmux", "send-keys", "-t", session+":agent", message, "Enter") 60 | if err := sendKeysCmd.Run(); err != nil { 61 | log.Error("Failed to send message to session", "session", session, "error", err) 62 | continue 63 | } 64 | exec.Command("tmux", "send-keys", "-t", session+":agent", "Enter").Run() 65 | } 66 | 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /cmd/checkpoint/checkpoint.go: -------------------------------------------------------------------------------- 1 | package checkpoint 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | 12 | "github.com/devflowinc/uzi/pkg/state" 13 | 14 | "github.com/charmbracelet/log" 15 | "github.com/peterbourgon/ff/v3/ffcli" 16 | ) 17 | 18 | var ( 19 | fs = flag.NewFlagSet("uzi checkpoint", flag.ExitOnError) 20 | CmdCheckpoint = &ffcli.Command{ 21 | Name: "checkpoint", 22 | ShortUsage: "uzi checkpoint ", 23 | ShortHelp: "Rebase changes from an agent worktree into the current worktree and commit", 24 | FlagSet: fs, 25 | Exec: executeCheckpoint, 26 | } 27 | ) 28 | 29 | func executeCheckpoint(ctx context.Context, args []string) error { 30 | if len(args) < 2 { 31 | return fmt.Errorf("agent name and commit message arguments are required") 32 | } 33 | 34 | agentName := args[0] 35 | commitMessage := args[1] 36 | log.Debug("Checkpointing changes from agent", "agent", agentName) 37 | 38 | // Get state manager to read from config 39 | sm := state.NewStateManager() 40 | if sm == nil { 41 | return fmt.Errorf("could not initialize state manager") 42 | } 43 | 44 | // Get active sessions from state 45 | activeSessions, err := sm.GetActiveSessionsForRepo() 46 | if err != nil { 47 | log.Error("Error getting active sessions", "error", err) 48 | return err 49 | } 50 | 51 | // Find the session with the matching agent name 52 | var sessionToCheckpoint string 53 | for _, session := range activeSessions { 54 | // Extract agent name from session name (format: agent-projectDir-gitHash-agentName) 55 | parts := strings.Split(session, "-") 56 | if len(parts) >= 4 && parts[0] == "agent" { 57 | // Join all parts after the first 3 (in case agent name contains hyphens) 58 | sessionAgentName := strings.Join(parts[3:], "-") 59 | if sessionAgentName == agentName { 60 | sessionToCheckpoint = session 61 | break 62 | } 63 | } 64 | } 65 | 66 | if sessionToCheckpoint == "" { 67 | return fmt.Errorf("no active session found for agent: %s", agentName) 68 | } 69 | 70 | // Get session state to find worktree path 71 | states := make(map[string]state.AgentState) 72 | if data, err := os.ReadFile(sm.GetStatePath()); err != nil { 73 | return fmt.Errorf("error reading state file: %v", err) 74 | } else { 75 | if err := json.Unmarshal(data, &states); err != nil { 76 | return fmt.Errorf("error parsing state file: %v", err) 77 | } 78 | } 79 | 80 | sessionState, ok := states[sessionToCheckpoint] 81 | if !ok || sessionState.WorktreePath == "" { 82 | return fmt.Errorf("invalid state for session: %s", sessionToCheckpoint) 83 | } 84 | 85 | // Get the actual branch name from the state 86 | agentBranchName := sessionState.BranchName 87 | 88 | // Get current directory (should be the main worktree) 89 | currentDir, err := os.Getwd() 90 | if err != nil { 91 | return fmt.Errorf("error getting current directory: %v", err) 92 | } 93 | 94 | // Get the current branch name in the main worktree 95 | getCurrentBranchCmd := exec.CommandContext(ctx, "git", "branch", "--show-current") 96 | getCurrentBranchCmd.Dir = currentDir 97 | currentBranchOutput, err := getCurrentBranchCmd.Output() 98 | if err != nil { 99 | return fmt.Errorf("error getting current branch: %v", err) 100 | } 101 | currentBranch := strings.TrimSpace(string(currentBranchOutput)) 102 | 103 | // Check if agent branch exists 104 | checkBranchCmd := exec.CommandContext(ctx, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+agentBranchName) 105 | checkBranchCmd.Dir = currentDir 106 | if err := checkBranchCmd.Run(); err != nil { 107 | return fmt.Errorf("agent branch does not exist: %s", agentBranchName) 108 | } 109 | 110 | // Stage all changes and commit on the agent branch 111 | addCmd := exec.CommandContext(ctx, "git", "add", ".") 112 | addCmd.Dir = sessionState.WorktreePath 113 | if err := addCmd.Run(); err != nil { 114 | return fmt.Errorf("error staging changes: %v", err) 115 | } 116 | 117 | commitCmd := exec.CommandContext(ctx, "git", "commit", "-am", commitMessage) 118 | commitCmd.Dir = sessionState.WorktreePath 119 | commitCmd.Stdout = os.Stdout 120 | commitCmd.Stderr = os.Stderr 121 | if err := commitCmd.Run(); err != nil { 122 | log.Warn("No unstaged changes to commit, rebasing") 123 | } 124 | 125 | // Get the base commit where the agent branch diverged 126 | mergeBaseCmd := exec.CommandContext(ctx, "git", "merge-base", currentBranch, agentBranchName) 127 | mergeBaseCmd.Dir = currentDir 128 | mergeBaseOutput, err := mergeBaseCmd.Output() 129 | if err != nil { 130 | return fmt.Errorf("error finding merge base: %v", err) 131 | } 132 | mergeBase := strings.TrimSpace(string(mergeBaseOutput)) 133 | 134 | // Check if there are any changes to rebase 135 | diffCmd := exec.CommandContext(ctx, "git", "rev-list", "--count", mergeBase+".."+agentBranchName) 136 | diffCmd.Dir = currentDir 137 | diffOutput, err := diffCmd.Output() 138 | if err != nil { 139 | return fmt.Errorf("error checking for changes: %v", err) 140 | } 141 | changeCount := strings.TrimSpace(string(diffOutput)) 142 | 143 | fmt.Printf("Checkpointing %s commits from agent: %s\n", changeCount, agentName) 144 | 145 | // Rebase the agent branch onto the current branch 146 | rebaseCmd := exec.CommandContext(ctx, "git", "rebase", agentBranchName) 147 | rebaseCmd.Dir = currentDir 148 | rebaseCmd.Stdout = os.Stdout 149 | rebaseCmd.Stderr = os.Stderr 150 | if err := rebaseCmd.Run(); err != nil { 151 | return fmt.Errorf("error rebasing agent changes: %v", err) 152 | } 153 | 154 | fmt.Printf("Successfully checkpointed changes from agent: %s\n", agentName) 155 | fmt.Printf("Successfully committed changes with message: %s\n", commitMessage) 156 | return nil 157 | } 158 | -------------------------------------------------------------------------------- /cmd/kill/kill.go: -------------------------------------------------------------------------------- 1 | package kill 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/devflowinc/uzi/pkg/state" 13 | 14 | "github.com/charmbracelet/log" 15 | "github.com/peterbourgon/ff/v3/ffcli" 16 | ) 17 | 18 | var ( 19 | fs = flag.NewFlagSet("uzi kill", flag.ExitOnError) 20 | CmdKill = &ffcli.Command{ 21 | Name: "kill", 22 | ShortUsage: "uzi kill [|all]", 23 | ShortHelp: "Delete tmux session and git worktree for the specified agent", 24 | FlagSet: fs, 25 | Exec: executeKill, 26 | } 27 | ) 28 | 29 | // killSession kills a single session and cleans up its associated resources 30 | func killSession(ctx context.Context, sessionName, agentName string, sm *state.StateManager) error { 31 | log.Debug("Deleting tmux session and git worktree", "session", sessionName, "agent", agentName) 32 | 33 | // Kill tmux session if it exists 34 | checkSession := exec.CommandContext(ctx, "tmux", "has-session", "-t", sessionName) 35 | if err := checkSession.Run(); err == nil { 36 | // Session exists, kill it 37 | killCmd := exec.CommandContext(ctx, "tmux", "kill-session", "-t", sessionName) 38 | if err := killCmd.Run(); err != nil { 39 | log.Error("Error killing tmux session", "session", sessionName, "error", err) 40 | } else { 41 | log.Debug("Killed tmux session", "session", sessionName) 42 | } 43 | } 44 | 45 | // Remove git worktree 46 | worktreePath := filepath.Join(filepath.Dir(os.Args[0]), "..", agentName) 47 | if _, err := os.Stat(worktreePath); err == nil { 48 | // Get worktree path from state 49 | worktreeInfo, err := sm.GetWorktreeInfo(sessionName) 50 | if err != nil { 51 | log.Error("Error getting worktree info", "session", sessionName, "error", err) 52 | return fmt.Errorf("failed to get worktree info: %w", err) 53 | } 54 | 55 | // First, remove the worktree 56 | removeCmd := exec.CommandContext(ctx, "git", "worktree", "remove", "--force", worktreeInfo.WorktreePath) 57 | removeCmd.Dir = filepath.Dir(os.Args[0]) 58 | if err := removeCmd.Run(); err != nil { 59 | log.Error("Error removing git worktree", "path", worktreeInfo.WorktreePath, "error", err) 60 | return fmt.Errorf("failed to remove git worktree: %w", err) 61 | } 62 | log.Debug("Removed git worktree", "path", worktreeInfo.WorktreePath) 63 | 64 | // Then delete the branch 65 | deleteBranchCmd := exec.CommandContext(ctx, "git", "branch", "-D", agentName) 66 | deleteBranchCmd.Dir = filepath.Dir(os.Args[0]) 67 | if err := deleteBranchCmd.Run(); err != nil { 68 | log.Error("Error deleting git branch", "branch", agentName, "error", err) 69 | return fmt.Errorf("failed to delete git branch: %w", err) 70 | } 71 | log.Debug("Deleted git branch", "branch", agentName) 72 | } 73 | 74 | // Delete from config store (~/.local/share/uzi/) 75 | homeDir, err := os.UserHomeDir() 76 | if err == nil { 77 | // Remove worktree directory from config store 78 | configWorktreePath := filepath.Join(homeDir, ".local", "share", "uzi", "worktrees", agentName) 79 | if _, err := os.Stat(configWorktreePath); err == nil { 80 | if err := os.RemoveAll(configWorktreePath); err != nil { 81 | log.Error("Error removing config worktree", "path", configWorktreePath, "error", err) 82 | } else { 83 | log.Debug("Removed config worktree", "path", configWorktreePath) 84 | } 85 | } 86 | 87 | // Remove worktree state directory 88 | worktreeStatePath := filepath.Join(homeDir, ".local", "share", "uzi", "worktree", sessionName) 89 | if _, err := os.Stat(worktreeStatePath); err == nil { 90 | if err := os.RemoveAll(worktreeStatePath); err != nil { 91 | log.Error("Error removing worktree state", "path", worktreeStatePath, "error", err) 92 | } else { 93 | log.Debug("Removed worktree state", "path", worktreeStatePath) 94 | } 95 | } 96 | 97 | // Remove from state.json 98 | if err := sm.RemoveState(sessionName); err != nil { 99 | log.Error("Error removing state entry", "session", sessionName, "error", err) 100 | } else { 101 | log.Debug("Removed state entry", "session", sessionName) 102 | } 103 | } 104 | 105 | return nil 106 | } 107 | 108 | // killAll kills all sessions for the current git repository 109 | func killAll(ctx context.Context, sm *state.StateManager) error { 110 | log.Debug("Deleting all agents for repository") 111 | 112 | // Get active sessions from state 113 | activeSessions, err := sm.GetActiveSessionsForRepo() 114 | if err != nil { 115 | log.Error("Error getting active sessions", "error", err) 116 | return err 117 | } 118 | 119 | if len(activeSessions) == 0 { 120 | fmt.Println("No active sessions found") 121 | return nil 122 | } 123 | 124 | killedCount := 0 125 | for _, sessionName := range activeSessions { 126 | // Extract agent name from session name (assuming format: repo-agentName) 127 | parts := strings.Split(sessionName, "-") 128 | if len(parts) < 2 { 129 | log.Warn("Unexpected session name format", "session", sessionName) 130 | continue 131 | } 132 | agentName := parts[len(parts)-1] // Get the last part as agent name 133 | 134 | if err := killSession(ctx, sessionName, agentName, sm); err != nil { 135 | log.Error("Error killing session", "session", sessionName, "error", err) 136 | continue 137 | } 138 | 139 | killedCount++ 140 | fmt.Printf("Deleted agent: %s\n", agentName) 141 | } 142 | 143 | fmt.Printf("Successfully deleted %d agent(s)\n", killedCount) 144 | return nil 145 | } 146 | 147 | func executeKill(ctx context.Context, args []string) error { 148 | if len(args) == 0 { 149 | return fmt.Errorf("agent name argument is required") 150 | } 151 | 152 | agentName := args[0] 153 | 154 | // Get state manager to read from config 155 | sm := state.NewStateManager() 156 | if sm == nil { 157 | return fmt.Errorf("could not initialize state manager") 158 | } 159 | 160 | // Handle "all" case 161 | if agentName == "all" { 162 | return killAll(ctx, sm) 163 | } 164 | 165 | // Get active sessions from state 166 | activeSessions, err := sm.GetActiveSessionsForRepo() 167 | if err != nil { 168 | log.Error("Error getting active sessions", "error", err) 169 | return err 170 | } 171 | 172 | // Find the session with the matching agent name 173 | var sessionToKill string 174 | for _, session := range activeSessions { 175 | if strings.HasSuffix(session, "-"+agentName) { 176 | sessionToKill = session 177 | break 178 | } 179 | } 180 | 181 | if sessionToKill == "" { 182 | log.Debug("No active tmux session found for agent", "agent", agentName) 183 | return fmt.Errorf("no active session found for agent: %s", agentName) 184 | } 185 | 186 | // Kill the specific session 187 | if err := killSession(ctx, sessionToKill, agentName, sm); err != nil { 188 | return err 189 | } 190 | 191 | fmt.Printf("Deleted agent: %s\n", agentName) 192 | return nil 193 | } 194 | -------------------------------------------------------------------------------- /cmd/ls/ls.go: -------------------------------------------------------------------------------- 1 | package ls 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "regexp" 12 | "sort" 13 | "strings" 14 | "text/tabwriter" 15 | "time" 16 | 17 | "github.com/devflowinc/uzi/pkg/config" 18 | "github.com/devflowinc/uzi/pkg/state" 19 | 20 | "github.com/peterbourgon/ff/v3/ffcli" 21 | ) 22 | 23 | var ( 24 | fs = flag.NewFlagSet("uzi ls", flag.ExitOnError) 25 | configPath = fs.String("config", config.GetDefaultConfigPath(), "path to config file") 26 | allSessions = fs.Bool("a", false, "show all sessions including inactive") 27 | watchMode = fs.Bool("w", false, "watch mode - refresh output every second") 28 | CmdLs = &ffcli.Command{ 29 | Name: "ls", 30 | ShortUsage: "uzi ls [-a] [-w]", 31 | ShortHelp: "List active agent sessions", 32 | FlagSet: fs, 33 | Exec: executeLs, 34 | } 35 | ) 36 | 37 | func getGitDiffTotals(sessionName string, stateManager *state.StateManager) (int, int) { 38 | // Get session state to find worktree path 39 | states := make(map[string]state.AgentState) 40 | if data, err := os.ReadFile(stateManager.GetStatePath()); err != nil { 41 | return 0, 0 42 | } else { 43 | if err := json.Unmarshal(data, &states); err != nil { 44 | return 0, 0 45 | } 46 | } 47 | 48 | sessionState, ok := states[sessionName] 49 | if !ok || sessionState.WorktreePath == "" { 50 | return 0, 0 51 | } 52 | 53 | shellCmdString := "git add -A . && git diff --cached --shortstat HEAD && git reset HEAD > /dev/null" 54 | 55 | cmd := exec.Command("sh", "-c", shellCmdString) 56 | cmd.Dir = sessionState.WorktreePath 57 | 58 | var out bytes.Buffer 59 | var stderr bytes.Buffer 60 | cmd.Stdout = &out 61 | cmd.Stderr = &stderr 62 | 63 | if err := cmd.Run(); err != nil { 64 | return 0, 0 65 | } 66 | 67 | output := out.String() 68 | 69 | insertions := 0 70 | deletions := 0 71 | 72 | insRe := regexp.MustCompile(`(\d+) insertion(?:s)?\(\+\)`) 73 | delRe := regexp.MustCompile(`(\d+) deletion(?:s)?\(\-\)`) 74 | 75 | if m := insRe.FindStringSubmatch(output); len(m) > 1 { 76 | fmt.Sscanf(m[1], "%d", &insertions) 77 | } 78 | if m := delRe.FindStringSubmatch(output); len(m) > 1 { 79 | fmt.Sscanf(m[1], "%d", &deletions) 80 | } 81 | 82 | return insertions, deletions 83 | } 84 | 85 | func getPaneContent(sessionName string) (string, error) { 86 | cmd := exec.Command("tmux", "capture-pane", "-t", sessionName+":agent", "-p") 87 | output, err := cmd.Output() 88 | if err != nil { 89 | return "", err 90 | } 91 | return string(output), nil 92 | } 93 | 94 | func getAgentStatus(sessionName string) string { 95 | content, err := getPaneContent(sessionName) 96 | if err != nil { 97 | return "unknown" 98 | } 99 | 100 | if strings.Contains(content, "esc to interrupt") || strings.Contains(content, "Thinking") { 101 | return "running" 102 | } 103 | return "ready" 104 | } 105 | 106 | func formatStatus(status string) string { 107 | switch status { 108 | case "ready": 109 | return "\033[32mready\033[0m" // Green 110 | case "running": 111 | return "\033[33mrunning\033[0m" // Orange/Yellow 112 | default: 113 | return status 114 | } 115 | } 116 | 117 | func formatTime(t time.Time) string { 118 | now := time.Now() 119 | diff := now.Sub(t) 120 | 121 | if diff < time.Hour { 122 | return fmt.Sprintf("%2dm", int(diff.Minutes())) 123 | } else if diff < 24*time.Hour { 124 | return fmt.Sprintf("%2dh", int(diff.Hours())) 125 | } else if diff < 7*24*time.Hour { 126 | return fmt.Sprintf("%2dd", int(diff.Hours()/24)) 127 | } 128 | return t.Format("Jan 02") 129 | } 130 | 131 | func printSessions(stateManager *state.StateManager, activeSessions []string) error { 132 | // Load all states to sort by UpdatedAt 133 | states := make(map[string]state.AgentState) 134 | if data, err := os.ReadFile(stateManager.GetStatePath()); err == nil { 135 | if err := json.Unmarshal(data, &states); err != nil { 136 | return fmt.Errorf("error parsing state file: %w", err) 137 | } 138 | } 139 | 140 | // Create a slice of sessions with their states for sorting 141 | type sessionInfo struct { 142 | name string 143 | state state.AgentState 144 | } 145 | var sessions []sessionInfo 146 | for _, sessionName := range activeSessions { 147 | if state, ok := states[sessionName]; ok { 148 | sessions = append(sessions, sessionInfo{name: sessionName, state: state}) 149 | } 150 | } 151 | 152 | // Sort by UpdatedAt (most recent first) 153 | sort.Slice(sessions, func(i, j int) bool { 154 | return sessions[i].state.UpdatedAt.After(sessions[j].state.UpdatedAt) 155 | }) 156 | 157 | // Long format with tabwriter for alignment 158 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) 159 | 160 | // Print header 161 | fmt.Fprintf(w, "AGENT\tMODEL\tSTATUS DIFF\tADDR\tPROMPT\n") 162 | 163 | // Print sessions 164 | for _, session := range sessions { 165 | sessionName := session.name 166 | state := session.state 167 | 168 | // Extract agent name from session name 169 | parts := strings.Split(sessionName, "-") 170 | agentName := sessionName 171 | if len(parts) >= 4 && parts[0] == "agent" { 172 | agentName = strings.Join(parts[3:], "-") 173 | } 174 | 175 | status := getAgentStatus(sessionName) 176 | insertions, deletions := getGitDiffTotals(sessionName, stateManager) 177 | 178 | // Format diff stats with colors 179 | var changes string 180 | if insertions == 0 && deletions == 0 { 181 | changes = "\033[32m+0\033[0m/\033[31m-0\033[0m" 182 | } else { 183 | // ANSI color codes: green for additions, red for deletions 184 | changes = fmt.Sprintf("\033[32m+%d\033[0m/\033[31m-%d\033[0m", insertions, deletions) 185 | } 186 | 187 | // Get model name, default to "unknown" if empty (for backward compatibility) 188 | model := state.Model 189 | if model == "" { 190 | model = "unknown" 191 | } 192 | 193 | // Format: agent model status addr changes prompt 194 | addr := "" 195 | if state.Port != 0 { 196 | addr = fmt.Sprintf("http://localhost:%d", state.Port) 197 | } 198 | fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", 199 | agentName, 200 | model, 201 | formatStatus(status), 202 | changes, 203 | addr, 204 | state.Prompt, 205 | ) 206 | } 207 | w.Flush() 208 | 209 | return nil 210 | } 211 | 212 | func clearScreen() { 213 | fmt.Print("\033[H\033[2J") 214 | } 215 | 216 | func executeLs(ctx context.Context, args []string) error { 217 | stateManager := state.NewStateManager() 218 | if stateManager == nil { 219 | return fmt.Errorf("failed to create state manager") 220 | } 221 | 222 | if *watchMode { 223 | // Watch mode - refresh every second 224 | ticker := time.NewTicker(1 * time.Second) 225 | defer ticker.Stop() 226 | 227 | // Initial display 228 | clearScreen() 229 | activeSessions, err := stateManager.GetActiveSessionsForRepo() 230 | if err != nil { 231 | return fmt.Errorf("error getting active sessions: %w", err) 232 | } 233 | 234 | if len(activeSessions) == 0 { 235 | fmt.Println("No active sessions found") 236 | } else { 237 | if err := printSessions(stateManager, activeSessions); err != nil { 238 | return err 239 | } 240 | } 241 | 242 | // Watch loop 243 | for { 244 | select { 245 | case <-ctx.Done(): 246 | return ctx.Err() 247 | case <-ticker.C: 248 | clearScreen() 249 | activeSessions, err := stateManager.GetActiveSessionsForRepo() 250 | if err != nil { 251 | fmt.Printf("Error getting active sessions: %v\n", err) 252 | continue 253 | } 254 | 255 | if len(activeSessions) == 0 { 256 | fmt.Println("No active sessions found") 257 | } else { 258 | if err := printSessions(stateManager, activeSessions); err != nil { 259 | fmt.Printf("Error printing sessions: %v\n", err) 260 | } 261 | } 262 | } 263 | } 264 | } else { 265 | // Single run mode 266 | activeSessions, err := stateManager.GetActiveSessionsForRepo() 267 | if err != nil { 268 | return fmt.Errorf("error getting active sessions: %w", err) 269 | } 270 | 271 | if len(activeSessions) == 0 { 272 | fmt.Println("No active sessions found") 273 | return nil 274 | } 275 | 276 | return printSessions(stateManager, activeSessions) 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /cmd/prompt/prompt.go: -------------------------------------------------------------------------------- 1 | package prompt 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "net" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/devflowinc/uzi/pkg/agents" 16 | "github.com/devflowinc/uzi/pkg/config" 17 | "github.com/devflowinc/uzi/pkg/state" 18 | 19 | "github.com/charmbracelet/log" 20 | "github.com/peterbourgon/ff/v3/ffcli" 21 | ) 22 | 23 | type AgentConfig struct { 24 | Command string 25 | Count int 26 | } 27 | 28 | var ( 29 | fs = flag.NewFlagSet("uzi prompt", flag.ExitOnError) 30 | agentsFlag = fs.String("agents", "claude:1", "agents to run with their commands and counts (e.g., 'claude:1,codex:2'). Use 'random' as agent name to select a random agent name.") 31 | configPath = fs.String("config", config.GetDefaultConfigPath(), "path to config file") 32 | CmdPrompt = &ffcli.Command{ 33 | Name: "prompt", 34 | ShortUsage: "uzi prompt --agents=AGENT:COUNT[,AGENT:COUNT...] prompt text...", 35 | ShortHelp: "Run the prompt command with specified agents and counts", 36 | FlagSet: fs, 37 | Exec: executePrompt, 38 | } 39 | ) 40 | 41 | // parseAgents parses the agents flag value into a map of agent configs 42 | func parseAgents(agentsStr string) (map[string]AgentConfig, error) { 43 | agentConfigs := make(map[string]AgentConfig) 44 | 45 | // Split by comma for multiple agent configurations 46 | agentPairs := strings.Split(agentsStr, ",") 47 | 48 | for _, pair := range agentPairs { 49 | // Split by colon for agent:count 50 | parts := strings.Split(strings.TrimSpace(pair), ":") 51 | if len(parts) != 2 { 52 | return nil, fmt.Errorf("invalid agent format: %s (expected agent:count)", pair) 53 | } 54 | 55 | agent := strings.TrimSpace(parts[0]) 56 | countStr := strings.TrimSpace(parts[1]) 57 | 58 | count, err := strconv.Atoi(countStr) 59 | if err != nil { 60 | return nil, fmt.Errorf("invalid count for agent %s: %s", agent, countStr) 61 | } 62 | 63 | if count < 1 { 64 | return nil, fmt.Errorf("count must be at least 1 for agent %s", agent) 65 | } 66 | 67 | // The command is the same as the agent name by default 68 | agentConfigs[agent] = AgentConfig{ 69 | Command: agent, 70 | Count: count, 71 | } 72 | } 73 | 74 | return agentConfigs, nil 75 | } 76 | 77 | // isPortAvailable checks if a port is available for use 78 | func isPortAvailable(port int) bool { 79 | address := fmt.Sprintf(":%d", port) 80 | listener, err := net.Listen("tcp", address) 81 | if err != nil { 82 | return false 83 | } 84 | listener.Close() 85 | return true 86 | } 87 | 88 | // findAvailablePort finds the first available port in the given range, excluding already assigned ports 89 | func findAvailablePort(startPort, endPort int, assignedPorts []int) (int, error) { 90 | for port := startPort; port <= endPort; port++ { 91 | // Check if port is already assigned in this execution 92 | alreadyAssigned := false 93 | for _, assignedPort := range assignedPorts { 94 | if port == assignedPort { 95 | alreadyAssigned = true 96 | break 97 | } 98 | } 99 | if alreadyAssigned { 100 | continue 101 | } 102 | 103 | // Check if port is actually available 104 | if isPortAvailable(port) { 105 | return port, nil 106 | } 107 | } 108 | return 0, fmt.Errorf("no available ports in range %d-%d", startPort, endPort) 109 | } 110 | 111 | func executePrompt(ctx context.Context, args []string) error { 112 | if len(args) == 0 { 113 | return fmt.Errorf("prompt argument is required") 114 | } 115 | 116 | // Load config 117 | cfg, err := config.LoadConfig(*configPath) 118 | if err != nil { 119 | log.Warn("Error loading config, using default values", "error", err) 120 | cfg = &config.Config{} // Use default or empty config 121 | } 122 | if cfg.DevCommand == nil || *cfg.DevCommand == "" { 123 | log.Info("Dev command not set in config, skipping dev server startup.") 124 | } 125 | if cfg.PortRange == nil || *cfg.PortRange == "" { 126 | log.Info("Port range not set in config, skipping dev server startup.") 127 | } 128 | 129 | promptText := strings.Join(args, " ") 130 | log.Debug("Running prompt command", "prompt", promptText) 131 | 132 | // Track assigned ports to prevent collisions between iterations 133 | var assignedPorts []int 134 | 135 | // Parse agents 136 | agentConfigs, err := parseAgents(*agentsFlag) 137 | if err != nil { 138 | return fmt.Errorf("error parsing agents: %s", err) 139 | } 140 | 141 | for agent, config := range agentConfigs { 142 | for i := 0; i < config.Count; i++ { 143 | // Always get a random agent name for the session/branch/worktree names 144 | randomAgentName := agents.GetRandomAgent() 145 | 146 | // Use the specified agent for the command (unless it's "random") 147 | commandToUse := config.Command 148 | if agent == "random" { 149 | // If agent is "random", use the random name for the command too 150 | commandToUse = randomAgentName 151 | } 152 | 153 | fmt.Printf("%s: %s: %s\n", randomAgentName, commandToUse, promptText) 154 | 155 | // Check if git worktree exists 156 | // Get the current git hash 157 | gitHashCmd := exec.CommandContext(ctx, "git", "rev-parse", "--short", "HEAD") 158 | gitHashCmd.Dir = filepath.Dir(os.Args[0]) 159 | gitHashOutput, err := gitHashCmd.Output() 160 | if err != nil { 161 | log.Error("Error getting git hash", "error", err) 162 | continue 163 | } 164 | gitHash := strings.TrimSpace(string(gitHashOutput)) 165 | 166 | // Get the git repository name from remote URL 167 | gitRemoteCmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin") 168 | gitRemoteCmd.Dir = filepath.Dir(os.Args[0]) 169 | gitRemoteOutput, err := gitRemoteCmd.Output() 170 | if err != nil { 171 | log.Error("Error getting git remote", "error", err) 172 | continue 173 | } 174 | remoteURL := strings.TrimSpace(string(gitRemoteOutput)) 175 | // Extract repository name from URL (handle both https and ssh formats) 176 | repoName := filepath.Base(remoteURL) 177 | projectDir := strings.TrimSuffix(repoName, ".git") 178 | 179 | // Create unique identifier using timestamp and iteration 180 | timestamp := time.Now().Unix() 181 | uniqueId := fmt.Sprintf("%d-%d", timestamp, i) 182 | 183 | // Create unique branch and worktree names using the random agent name 184 | branchName := fmt.Sprintf("%s-%s-%s-%s", randomAgentName, projectDir, gitHash, uniqueId) 185 | worktreeName := fmt.Sprintf("%s-%s-%s-%s", randomAgentName, projectDir, gitHash, uniqueId) 186 | 187 | // Prefix the tmux session name with the git hash and use random agent name 188 | sessionName := fmt.Sprintf("agent-%s-%s-%s", projectDir, gitHash, randomAgentName) 189 | 190 | // Get home directory for worktree storage 191 | homeDir, err := os.UserHomeDir() 192 | if err != nil { 193 | log.Error("Error getting home directory", "error", err) 194 | continue 195 | } 196 | 197 | worktreesDir := filepath.Join(homeDir, ".local", "share", "uzi", "worktrees") 198 | if err := os.MkdirAll(worktreesDir, 0755); err != nil { 199 | log.Error("Error creating worktrees directory", "error", err) 200 | continue 201 | } 202 | 203 | worktreePath := filepath.Join(worktreesDir, worktreeName) 204 | var selectedPort int 205 | // Create git worktree 206 | cmd := fmt.Sprintf("git worktree add -b %s %s", branchName, worktreePath) 207 | cmdExec := exec.CommandContext(ctx, "sh", "-c", cmd) 208 | cmdExec.Dir = filepath.Dir(os.Args[0]) 209 | if err := cmdExec.Run(); err != nil { 210 | log.Error("Error creating git worktree", "command", cmd, "error", err) 211 | continue 212 | } 213 | 214 | // Create tmux session 215 | cmd = fmt.Sprintf("tmux new-session -d -s %s -c %s", sessionName, worktreePath) 216 | cmdExec = exec.CommandContext(ctx, "sh", "-c", cmd) 217 | if err := cmdExec.Run(); err != nil { 218 | log.Error("Error creating tmux session", "command", cmd, "error", err) 219 | continue 220 | } 221 | 222 | // Rename the first window to "agent" 223 | renameCmd := fmt.Sprintf("tmux rename-window -t %s:0 agent", sessionName) 224 | renameExec := exec.CommandContext(ctx, "sh", "-c", renameCmd) 225 | if err := renameExec.Run(); err != nil { 226 | log.Error("Error renaming tmux window", "command", renameCmd, "error", err) 227 | continue 228 | } 229 | 230 | // Create uzi-dev pane and run dev command if configured 231 | if cfg.DevCommand == nil || *cfg.DevCommand == "" || cfg.PortRange == nil || *cfg.PortRange == "" { 232 | // Hit enter in the agent pane 233 | hitEnterCmd := fmt.Sprintf("tmux send-keys -t %s:agent C-m", sessionName) 234 | hitEnterExec := exec.CommandContext(ctx, "sh", "-c", hitEnterCmd) 235 | if err := hitEnterExec.Run(); err != nil { 236 | log.Error("Error hitting enter in tmux", "command", hitEnterCmd, "error", err) 237 | } 238 | 239 | // Always run send-keys command to the agent pane 240 | tmuxCmd := fmt.Sprintf("tmux send-keys -t %s:agent '%s \"%%s\"' C-m", sessionName, commandToUse) 241 | tmuxCmdExec := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf(tmuxCmd, promptText)) 242 | tmuxCmdExec.Dir = worktreePath 243 | if err := tmuxCmdExec.Run(); err != nil { 244 | log.Error("Error sending keys to tmux", "command", tmuxCmd, "error", err) 245 | continue 246 | } 247 | 248 | // Save state before continuing (no port since dev server not started) 249 | stateManager := state.NewStateManager() 250 | if stateManager != nil { 251 | if err := stateManager.SaveState(promptText, branchName, sessionName, worktreePath, commandToUse); err != nil { 252 | log.Error("Error saving state", "error", err) 253 | } 254 | } 255 | continue 256 | } 257 | 258 | ports := strings.Split(*cfg.PortRange, "-") 259 | if len(ports) != 2 { 260 | log.Warn("Invalid port range format in config", "portRange", *cfg.PortRange) 261 | continue 262 | } 263 | 264 | startPort, _ := strconv.Atoi(ports[0]) 265 | endPort, _ := strconv.Atoi(ports[1]) 266 | if startPort <= 0 || endPort <= 0 || endPort < startPort { 267 | log.Warn("Invalid port range in config", "portRange", *cfg.PortRange) 268 | continue 269 | } 270 | 271 | selectedPort, err = findAvailablePort(startPort, endPort, assignedPorts) 272 | if err != nil { 273 | log.Error("Error finding available port", "error", err) 274 | continue 275 | } 276 | 277 | devCmdTemplate := *cfg.DevCommand 278 | devCmd := strings.Replace(devCmdTemplate, "$PORT", strconv.Itoa(selectedPort), 1) 279 | 280 | // Create new window named uzi-dev 281 | newWindowCmd := fmt.Sprintf("tmux new-window -t %s -n uzi-dev -c %s", sessionName, worktreePath) 282 | newWindowExec := exec.CommandContext(ctx, "sh", "-c", newWindowCmd) 283 | if err := newWindowExec.Run(); err != nil { 284 | log.Error("Error creating new tmux window for dev server", "command", newWindowCmd, "error", err) 285 | continue 286 | } 287 | 288 | // Send dev command to the new window 289 | sendDevCmd := fmt.Sprintf("tmux send-keys -t %s:uzi-dev '%s' C-m", sessionName, devCmd) 290 | sendDevCmdExec := exec.CommandContext(ctx, "sh", "-c", sendDevCmd) 291 | if err := sendDevCmdExec.Run(); err != nil { 292 | log.Error("Error sending dev command to tmux", "command", sendDevCmd, "error", err) 293 | } 294 | 295 | // Hit enter in the agent pane 296 | hitEnterCmd := fmt.Sprintf("tmux send-keys -t %s:agent C-m", sessionName) 297 | hitEnterExec := exec.CommandContext(ctx, "sh", "-c", hitEnterCmd) 298 | if err := hitEnterExec.Run(); err != nil { 299 | log.Error("Error hitting enter in tmux", "command", hitEnterCmd, "error", err) 300 | } 301 | 302 | assignedPorts = append(assignedPorts, selectedPort) 303 | 304 | // Always run send-keys command to the agent pane 305 | tmuxCmd := fmt.Sprintf("tmux send-keys -t %s:agent '%s \"%%s\"' C-m", sessionName, commandToUse) 306 | tmuxCmdExec := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf(tmuxCmd, promptText)) 307 | tmuxCmdExec.Dir = worktreePath 308 | if err := tmuxCmdExec.Run(); err != nil { 309 | log.Error("Error sending keys to tmux", "command", tmuxCmd, "error", err) 310 | continue 311 | } 312 | 313 | // Save state after successful prompt execution 314 | stateManager := state.NewStateManager() 315 | if stateManager != nil { 316 | if err := stateManager.SaveStateWithPort(promptText, branchName, sessionName, worktreePath, commandToUse, selectedPort); err != nil { 317 | log.Error("Error saving state", "error", err) 318 | } 319 | } 320 | } 321 | } 322 | 323 | return nil 324 | } 325 | -------------------------------------------------------------------------------- /cmd/reset/reset.go: -------------------------------------------------------------------------------- 1 | package reset 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/charmbracelet/log" 13 | "github.com/peterbourgon/ff/v3/ffcli" 14 | ) 15 | 16 | var ( 17 | fs = flag.NewFlagSet("uzi reset", flag.ExitOnError) 18 | CmdReset = &ffcli.Command{ 19 | Name: "reset", 20 | ShortUsage: "uzi reset", 21 | ShortHelp: "Delete all data stored in ~/.local/share/uzi", 22 | FlagSet: fs, 23 | Exec: executeReset, 24 | } 25 | ) 26 | 27 | func executeReset(ctx context.Context, args []string) error { 28 | homeDir, err := os.UserHomeDir() 29 | if err != nil { 30 | return fmt.Errorf("could not get user home directory: %w", err) 31 | } 32 | 33 | uziDataPath := filepath.Join(homeDir, ".local", "share", "uzi") 34 | 35 | // Check if the directory exists 36 | if _, err := os.Stat(uziDataPath); os.IsNotExist(err) { 37 | log.Debug("Uzi data directory does not exist", "path", uziDataPath) 38 | fmt.Println("No uzi data found to reset") 39 | return nil 40 | } 41 | 42 | // Ask for confirmation 43 | fmt.Printf("This will permanently delete all uzi data from %s\n", uziDataPath) 44 | fmt.Print("Are you sure you want to continue? (y/N): ") 45 | 46 | reader := bufio.NewReader(os.Stdin) 47 | response, err := reader.ReadString('\n') 48 | if err != nil { 49 | return fmt.Errorf("failed to read user input: %w", err) 50 | } 51 | 52 | response = strings.ToLower(strings.TrimSpace(response)) 53 | if response != "y" && response != "yes" { 54 | fmt.Println("Reset cancelled") 55 | return nil 56 | } 57 | 58 | // Remove the entire uzi data directory 59 | if err := os.RemoveAll(uziDataPath); err != nil { 60 | log.Error("Error removing uzi data directory", "path", uziDataPath, "error", err) 61 | return fmt.Errorf("failed to remove uzi data directory: %w", err) 62 | } 63 | 64 | log.Debug("Removed uzi data directory", "path", uziDataPath) 65 | fmt.Printf("Successfully reset all uzi data from %s\n", uziDataPath) 66 | return nil 67 | } -------------------------------------------------------------------------------- /cmd/run/run.go: -------------------------------------------------------------------------------- 1 | package run 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "os/exec" 9 | "strings" 10 | "github.com/devflowinc/uzi/pkg/config" 11 | "github.com/devflowinc/uzi/pkg/state" 12 | 13 | "github.com/charmbracelet/log" 14 | "github.com/peterbourgon/ff/v3/ffcli" 15 | ) 16 | 17 | var ( 18 | fs = flag.NewFlagSet("uzi run", flag.ExitOnError) 19 | deletePanel = fs.Bool("delete", false, "delete the panel after running the command") 20 | configPath = fs.String("config", config.GetDefaultConfigPath(), "path to config file") 21 | CmdRun = &ffcli.Command{ 22 | Name: "run", 23 | ShortUsage: "uzi run ", 24 | ShortHelp: "Run a command in all agent sessions", 25 | FlagSet: fs, 26 | Exec: executeRun, 27 | } 28 | ) 29 | 30 | func executeRun(ctx context.Context, args []string) error { 31 | log.Debug("Running run command") 32 | 33 | if len(args) == 0 { 34 | return fmt.Errorf("no command provided") 35 | } 36 | 37 | command := strings.Join(args, " ") 38 | 39 | // Get state manager to read from config 40 | sm := state.NewStateManager() 41 | if sm == nil { 42 | return fmt.Errorf("could not initialize state manager") 43 | } 44 | 45 | // Get active sessions from state 46 | activeSessions, err := sm.GetActiveSessionsForRepo() 47 | if err != nil { 48 | log.Error("Error getting active sessions", "error", err) 49 | return err 50 | } 51 | 52 | if len(activeSessions) == 0 { 53 | return fmt.Errorf("no active agent sessions found") 54 | } 55 | 56 | fmt.Printf("Running command '%s' in %d agent sessions:\n", command, len(activeSessions)) 57 | 58 | // Execute command in each session 59 | for _, session := range activeSessions { 60 | fmt.Printf("\n=== %s ===\n", session) 61 | 62 | // Create a new window without specifying name or target to get next unused index 63 | // Use -P to print the window info in format session:index 64 | newWindowCmd := exec.Command("tmux", "new-window", "-t", session, "-P", "-F", "#{window_index}", "-c", "#{session_path}") 65 | windowIndexBytes, err := newWindowCmd.Output() 66 | if err != nil { 67 | log.Error("Failed to create new window", "session", session, "error", err) 68 | continue 69 | } 70 | 71 | windowIndex := strings.TrimSpace(string(windowIndexBytes)) 72 | windowTarget := session + ":" + windowIndex 73 | 74 | sendKeysCmd := exec.Command("tmux", "send-keys", "-t", windowTarget, command, "Enter") 75 | if err := sendKeysCmd.Run(); err != nil { 76 | log.Error("Failed to send command ", command, " tosession", session, "error", err) 77 | continue 78 | } 79 | 80 | // Capture the output from the pane 81 | captureCmd := exec.Command("tmux", "capture-pane", "-t", windowTarget, "-p") 82 | var captureOut bytes.Buffer 83 | captureCmd.Stdout = &captureOut 84 | if err := captureCmd.Run(); err != nil { 85 | log.Error("Failed to capture output", "session", session, "error", err) 86 | } else { 87 | output := strings.TrimSpace(captureOut.String()) 88 | if output != "" { 89 | fmt.Println(output) 90 | } 91 | } 92 | 93 | // If delete flag is set, kill the window after capturing output 94 | if *deletePanel { 95 | killWindowCmd := exec.Command("tmux", "kill-window", "-t", windowTarget) 96 | if err := killWindowCmd.Run(); err != nil { 97 | log.Error("Failed to kill window", "session", session, "window", windowTarget, "error", err) 98 | } 99 | } 100 | } 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /cmd/watch/auto.go: -------------------------------------------------------------------------------- 1 | package watch 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/sha256" 7 | "flag" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "os/signal" 12 | "strings" 13 | "sync" 14 | "syscall" 15 | "time" 16 | 17 | "github.com/devflowinc/uzi/pkg/state" 18 | 19 | "github.com/charmbracelet/log" 20 | "github.com/peterbourgon/ff/v3/ffcli" 21 | ) 22 | 23 | type AgentWatcher struct { 24 | stateManager *state.StateManager 25 | watchedSessions map[string]*SessionMonitor 26 | mu sync.RWMutex 27 | quit chan bool 28 | } 29 | 30 | type SessionMonitor struct { 31 | sessionName string 32 | prevOutputHash []byte 33 | lastUpdated time.Time 34 | updateCount int 35 | noUpdateCount int 36 | } 37 | 38 | func NewAgentWatcher() *AgentWatcher { 39 | return &AgentWatcher{ 40 | stateManager: state.NewStateManager(), 41 | watchedSessions: make(map[string]*SessionMonitor), 42 | quit: make(chan bool), 43 | } 44 | } 45 | 46 | func (aw *AgentWatcher) hashContent(content []byte) []byte { 47 | hash := sha256.Sum256(content) 48 | return hash[:] 49 | } 50 | 51 | func (aw *AgentWatcher) capturePaneContent(sessionName string) (string, error) { 52 | cmd := exec.Command("tmux", "capture-pane", "-t", sessionName+":agent", "-p") 53 | output, err := cmd.Output() 54 | if err != nil { 55 | return "", err 56 | } 57 | return string(output), nil 58 | } 59 | 60 | func (aw *AgentWatcher) sendKeys(sessionName string, keys string) error { 61 | cmd := exec.Command("tmux", "send-keys", "-t", sessionName+":agent", keys) 62 | return cmd.Run() 63 | } 64 | 65 | func (aw *AgentWatcher) tapEnter(sessionName string) error { 66 | return aw.sendKeys(sessionName, "Enter") 67 | } 68 | 69 | func (aw *AgentWatcher) hasUpdated(sessionName string) (bool, bool, error) { 70 | content, err := aw.capturePaneContent(sessionName) 71 | if err != nil { 72 | return false, false, err 73 | } 74 | 75 | // Check for specific prompts that need auto-enter 76 | hasPrompt := false 77 | 78 | // Check for Claude trust prompt 79 | if strings.Contains(content, "Do you trust the files in this folder?") { 80 | hasPrompt = true 81 | } 82 | 83 | // Check for general continuation prompts 84 | if strings.Contains(content, "Press Enter to continue") || 85 | strings.Contains(content, "Continue? (Y/n)") || 86 | strings.Contains(content, "Do you want to proceed?") || 87 | (strings.Contains(content, "Allow command") && !strings.Contains(content, "Thinking")) || 88 | strings.Contains(content, "Do you want to") || 89 | strings.Contains(content, "Proceed? (y/N)") { 90 | hasPrompt = true 91 | } 92 | 93 | aw.mu.Lock() 94 | monitor, exists := aw.watchedSessions[sessionName] 95 | if !exists { 96 | // First time monitoring this session 97 | aw.watchedSessions[sessionName] = &SessionMonitor{ 98 | sessionName: sessionName, 99 | prevOutputHash: aw.hashContent([]byte(content)), 100 | lastUpdated: time.Now(), 101 | updateCount: 0, 102 | noUpdateCount: 0, 103 | } 104 | aw.mu.Unlock() 105 | return false, hasPrompt, nil 106 | } 107 | 108 | // Compare current content hash with previous 109 | currentHash := aw.hashContent([]byte(content)) 110 | if !bytes.Equal(currentHash, monitor.prevOutputHash) { 111 | monitor.prevOutputHash = currentHash 112 | monitor.lastUpdated = time.Now() 113 | monitor.updateCount++ 114 | monitor.noUpdateCount = 0 115 | aw.mu.Unlock() 116 | return true, hasPrompt, nil 117 | } 118 | 119 | monitor.noUpdateCount++ 120 | aw.mu.Unlock() 121 | return false, hasPrompt, nil 122 | } 123 | 124 | func (aw *AgentWatcher) watchSession(sessionName string) { 125 | log.Info("Starting to watch session", "session", sessionName) 126 | 127 | for { 128 | select { 129 | case <-aw.quit: 130 | return 131 | default: 132 | updated, hasPrompt, err := aw.hasUpdated(sessionName) 133 | if err != nil { 134 | log.Error("Error checking session update", "session", sessionName, "error", err) 135 | time.Sleep(2 * time.Second) 136 | continue 137 | } 138 | 139 | if updated { 140 | log.Debug("Session updated", "session", sessionName) 141 | } else { 142 | // Check if session has no prompt and hasn't updated in 3 cycles 143 | } 144 | 145 | if hasPrompt { 146 | log.Info("Auto-pressing Enter for prompt", "session", sessionName) 147 | if err := aw.tapEnter(sessionName); err != nil { 148 | log.Error("Failed to send Enter", "session", sessionName, "error", err) 149 | } else { 150 | log.Info("Successfully sent Enter", "session", sessionName) 151 | } 152 | } 153 | 154 | time.Sleep(500 * time.Millisecond) // Check every 500ms 155 | } 156 | } 157 | } 158 | 159 | func (aw *AgentWatcher) refreshActiveSessions() error { 160 | activeSessions, err := aw.stateManager.GetActiveSessionsForRepo() 161 | if err != nil { 162 | return fmt.Errorf("failed to get active sessions: %w", err) 163 | } 164 | 165 | // Stop watching sessions that are no longer active 166 | aw.mu.Lock() 167 | for sessionName := range aw.watchedSessions { 168 | found := false 169 | for _, activeSession := range activeSessions { 170 | if activeSession == sessionName { 171 | found = true 172 | break 173 | } 174 | } 175 | if !found { 176 | log.Info("Session no longer active, stopping watch", "session", sessionName) 177 | delete(aw.watchedSessions, sessionName) 178 | } 179 | } 180 | aw.mu.Unlock() 181 | 182 | // Start watching new active sessions 183 | for _, sessionName := range activeSessions { 184 | aw.mu.RLock() 185 | _, exists := aw.watchedSessions[sessionName] 186 | aw.mu.RUnlock() 187 | if !exists { 188 | go aw.watchSession(sessionName) 189 | } 190 | } 191 | 192 | return nil 193 | } 194 | 195 | func (aw *AgentWatcher) Start() { 196 | log.Info("Starting Agent Watcher") 197 | 198 | // Set up signal handling for graceful shutdown 199 | sigChan := make(chan os.Signal, 1) 200 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 201 | 202 | // Refresh active sessions periodically 203 | refreshTicker := time.NewTicker(5 * time.Second) 204 | defer refreshTicker.Stop() 205 | 206 | go func() { 207 | for { 208 | select { 209 | case <-refreshTicker.C: 210 | if err := aw.refreshActiveSessions(); err != nil { 211 | log.Error("Failed to refresh active sessions", "error", err) 212 | } 213 | case <-aw.quit: 214 | return 215 | } 216 | } 217 | }() 218 | 219 | // Initial refresh 220 | if err := aw.refreshActiveSessions(); err != nil { 221 | log.Error("Failed initial session refresh", "error", err) 222 | } 223 | 224 | // Wait for signal 225 | <-sigChan 226 | log.Info("Shutting down Agent Watcher") 227 | close(aw.quit) 228 | } 229 | 230 | var CmdWatch = &ffcli.Command{ 231 | Name: "auto", 232 | ShortUsage: "uzi auto", 233 | ShortHelp: "Automatically manage active agent sessions", 234 | LongHelp: ` 235 | The auto command monitors all active agent sessions in the current repository 236 | and automatically presses Enter when it detects prompts that require user input, 237 | such as trust prompts or continuation confirmations. It can also handle other 238 | automated tasks in the future. 239 | 240 | This is useful for hands-free operation of multiple agents. 241 | `, 242 | FlagSet: func() *flag.FlagSet { 243 | fs := flag.NewFlagSet("auto", flag.ExitOnError) 244 | return fs 245 | }(), 246 | Exec: func(ctx context.Context, args []string) error { 247 | watcher := NewAgentWatcher() 248 | watcher.Start() 249 | return nil 250 | }, 251 | } 252 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/devflowinc/uzi 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | github.com/charmbracelet/log v0.4.2 7 | github.com/peterbourgon/ff/v3 v3.4.0 8 | gopkg.in/yaml.v3 v3.0.1 9 | ) 10 | 11 | require ( 12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 14 | github.com/charmbracelet/lipgloss v1.1.0 // indirect 15 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 16 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 17 | github.com/charmbracelet/x/term v0.2.1 // indirect 18 | github.com/go-logfmt/logfmt v0.6.0 // indirect 19 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 20 | github.com/mattn/go-isatty v0.0.20 // indirect 21 | github.com/mattn/go-runewidth v0.0.16 // indirect 22 | github.com/muesli/termenv v0.16.0 // indirect 23 | github.com/rivo/uniseg v0.4.7 // indirect 24 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 25 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 26 | golang.org/x/sys v0.32.0 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 4 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 5 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 6 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 7 | github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 8 | github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 9 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 10 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 11 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 12 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 13 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 14 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 18 | github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 24 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 25 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 26 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 27 | github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= 28 | github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 32 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 33 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 34 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 35 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 36 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 37 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 38 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 39 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 40 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 42 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 43 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 44 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 45 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 47 | -------------------------------------------------------------------------------- /pkg/agents/agents.go: -------------------------------------------------------------------------------- 1 | package agents 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // AgentNames contains all available agent names 10 | const AgentNames = `john 11 | emily 12 | michael 13 | sarah 14 | david 15 | jessica 16 | christopher 17 | ashley 18 | matthew 19 | amanda 20 | james 21 | elizabeth 22 | robert 23 | jennifer 24 | william 25 | rachel 26 | daniel 27 | laura 28 | thomas 29 | hannah 30 | joshua 31 | megan 32 | ryan 33 | nicole 34 | andrew 35 | stephanie 36 | justin 37 | rebecca 38 | brandon 39 | lisa 40 | samuel 41 | katherine 42 | benjamin 43 | samantha 44 | nicholas 45 | alexandra 46 | tyler 47 | victoria 48 | alexander 49 | olivia 50 | anthony 51 | emma 52 | kevin 53 | madison 54 | brian 55 | abigail 56 | jason 57 | isabella 58 | eric 59 | sophia 60 | adam 61 | ava 62 | steven 63 | mia 64 | timothy 65 | charlotte 66 | mark 67 | amelia 68 | donald 69 | harper 70 | paul 71 | evelyn 72 | george 73 | abigail 74 | kenneth 75 | emily 76 | edward 77 | elizabeth 78 | brian 79 | sofia 80 | ronald 81 | avery 82 | kevin 83 | ella 84 | jason 85 | scarlett 86 | matthew 87 | grace 88 | gary 89 | chloe 90 | timothy 91 | camila 92 | jose 93 | penelope 94 | larry 95 | layla 96 | jeffrey 97 | lillian 98 | frank 99 | nora 100 | scott 101 | zoey 102 | eric 103 | mila 104 | stephen 105 | aubrey 106 | andrew 107 | violet 108 | raymond 109 | claire 110 | gregory 111 | bella 112 | joshua 113 | aurora 114 | jerry 115 | lucy 116 | dennis 117 | anna 118 | walter 119 | sarah 120 | peter 121 | caroline 122 | harold 123 | genesis 124 | douglas 125 | emilia 126 | henry 127 | kennedy` 128 | 129 | // GetRandomAgent returns a random agent name from the embedded list 130 | func GetRandomAgent() string { 131 | agents := strings.Split(strings.TrimSpace(AgentNames), "\n") 132 | rand.Seed(time.Now().UnixNano()) 133 | return agents[rand.Intn(len(agents))] 134 | } -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | type Config struct { 10 | DevCommand *string `yaml:"devCommand"` 11 | PortRange *string `yaml:"portRange"` 12 | } 13 | 14 | func DefaultConfig() Config { 15 | return Config{ 16 | DevCommand: nil, 17 | PortRange: nil, 18 | } 19 | } 20 | 21 | // LoadConfig loads the configuration from the specified path 22 | func LoadConfig(path string) (*Config, error) { 23 | data, err := os.ReadFile(path) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | var config Config 29 | if err := yaml.Unmarshal(data, &config); err != nil { 30 | return nil, err 31 | } 32 | 33 | return &config, nil 34 | } 35 | 36 | // GetDefaultConfigPath returns the default path for the config file 37 | func GetDefaultConfigPath() string { 38 | return "uzi.yaml" 39 | } 40 | -------------------------------------------------------------------------------- /pkg/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/charmbracelet/log" 13 | ) 14 | 15 | type AgentState struct { 16 | GitRepo string `json:"git_repo"` 17 | BranchFrom string `json:"branch_from"` 18 | BranchName string `json:"branch_name"` 19 | Prompt string `json:"prompt"` 20 | WorktreePath string `json:"worktree_path"` 21 | Port int `json:"port,omitempty"` 22 | Model string `json:"model"` 23 | CreatedAt time.Time `json:"created_at"` 24 | UpdatedAt time.Time `json:"updated_at"` 25 | } 26 | 27 | type StateManager struct { 28 | statePath string 29 | } 30 | 31 | func NewStateManager() *StateManager { 32 | homeDir, err := os.UserHomeDir() 33 | if err != nil { 34 | log.Error("Error getting home directory", "error", err) 35 | return nil 36 | } 37 | 38 | statePath := filepath.Join(homeDir, ".local", "share", "uzi", "state.json") 39 | return &StateManager{statePath: statePath} 40 | } 41 | 42 | func (sm *StateManager) ensureStateDir() error { 43 | dir := filepath.Dir(sm.statePath) 44 | return os.MkdirAll(dir, 0755) 45 | } 46 | 47 | func (sm *StateManager) getGitRepo() string { 48 | cmd := exec.Command("git", "config", "--get", "remote.origin.url") 49 | output, err := cmd.Output() 50 | if err != nil { 51 | log.Debug("Could not get git remote URL", "error", err) 52 | return "" 53 | } 54 | return strings.TrimSpace(string(output)) 55 | } 56 | 57 | func (sm *StateManager) getBranchFrom() string { 58 | // Get the main/master branch name 59 | cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD") 60 | output, err := cmd.Output() 61 | if err != nil { 62 | // Fallback to main 63 | return "main" 64 | } 65 | 66 | ref := strings.TrimSpace(string(output)) 67 | parts := strings.Split(ref, "/") 68 | if len(parts) > 0 { 69 | return parts[len(parts)-1] 70 | } 71 | return "main" 72 | } 73 | 74 | func (sm *StateManager) isActiveInTmux(sessionName string) bool { 75 | cmd := exec.Command("tmux", "has-session", "-t", sessionName) 76 | return cmd.Run() == nil 77 | } 78 | 79 | func (sm *StateManager) GetActiveSessionsForRepo() ([]string, error) { 80 | // Load existing state 81 | states := make(map[string]AgentState) 82 | if data, err := os.ReadFile(sm.statePath); err != nil { 83 | if os.IsNotExist(err) { 84 | return []string{}, nil 85 | } 86 | return nil, err 87 | } else { 88 | if err := json.Unmarshal(data, &states); err != nil { 89 | return nil, err 90 | } 91 | } 92 | 93 | currentRepo := sm.getGitRepo() 94 | if currentRepo == "" { 95 | return []string{}, nil 96 | } 97 | 98 | var activeSessions []string 99 | for sessionName, state := range states { 100 | if state.GitRepo == currentRepo && sm.isActiveInTmux(sessionName) { 101 | activeSessions = append(activeSessions, sessionName) 102 | } 103 | } 104 | 105 | return activeSessions, nil 106 | } 107 | 108 | func (sm *StateManager) SaveState(prompt, branchName, sessionName, worktreePath, model string) error { 109 | return sm.SaveStateWithPort(prompt, branchName, sessionName, worktreePath, model, 0) 110 | } 111 | 112 | func (sm *StateManager) SaveStateWithPort(prompt, branchName, sessionName, worktreePath, model string, port int) error { 113 | if err := sm.ensureStateDir(); err != nil { 114 | return err 115 | } 116 | 117 | // Load existing state 118 | states := make(map[string]AgentState) 119 | if data, err := os.ReadFile(sm.statePath); err == nil { 120 | json.Unmarshal(data, &states) 121 | } 122 | 123 | // Create new state entry 124 | now := time.Now() 125 | agentState := AgentState{ 126 | GitRepo: sm.getGitRepo(), 127 | BranchFrom: sm.getBranchFrom(), 128 | BranchName: branchName, 129 | Prompt: prompt, 130 | WorktreePath: worktreePath, 131 | Port: port, 132 | Model: model, 133 | UpdatedAt: now, 134 | } 135 | 136 | // Set created time if this is a new entry 137 | if existing, exists := states[sessionName]; exists { 138 | agentState.CreatedAt = existing.CreatedAt 139 | } else { 140 | agentState.CreatedAt = now 141 | } 142 | 143 | states[sessionName] = agentState 144 | 145 | // Store the worktree branch in agent-specific file 146 | if err := sm.storeWorktreeBranch(sessionName); err != nil { 147 | log.Error("Error storing worktree branch", "error", err) 148 | } 149 | 150 | // Save to file 151 | data, err := json.MarshalIndent(states, "", " ") 152 | if err != nil { 153 | return err 154 | } 155 | 156 | return os.WriteFile(sm.statePath, data, 0644) 157 | } 158 | 159 | func (sm *StateManager) getCurrentBranch() string { 160 | cmd := exec.Command("git", "branch", "--show-current") 161 | output, err := cmd.Output() 162 | if err != nil { 163 | log.Debug("Could not get current branch", "error", err) 164 | return "" 165 | } 166 | return strings.TrimSpace(string(output)) 167 | } 168 | 169 | func (sm *StateManager) storeWorktreeBranch(sessionName string) error { 170 | homeDir, err := os.UserHomeDir() 171 | if err != nil { 172 | return err 173 | } 174 | 175 | agentDir := filepath.Join(homeDir, ".local", "share", "uzi", "worktree", sessionName) 176 | if err := os.MkdirAll(agentDir, 0755); err != nil { 177 | return err 178 | } 179 | 180 | branchFile := filepath.Join(agentDir, "tree") 181 | currentBranch := sm.getCurrentBranch() 182 | if currentBranch == "" { 183 | return nil 184 | } 185 | 186 | return os.WriteFile(branchFile, []byte(currentBranch), 0644) 187 | } 188 | 189 | func (sm *StateManager) GetStatePath() string { 190 | return sm.statePath 191 | } 192 | 193 | func (sm *StateManager) RemoveState(sessionName string) error { 194 | // Load existing state 195 | states := make(map[string]AgentState) 196 | if data, err := os.ReadFile(sm.statePath); err != nil { 197 | if os.IsNotExist(err) { 198 | return nil // No state file, nothing to remove 199 | } 200 | return err 201 | } else { 202 | if err := json.Unmarshal(data, &states); err != nil { 203 | return err 204 | } 205 | } 206 | 207 | // Remove the session from the state 208 | delete(states, sessionName) 209 | 210 | // Save updated state to file 211 | data, err := json.MarshalIndent(states, "", " ") 212 | if err != nil { 213 | return err 214 | } 215 | 216 | return os.WriteFile(sm.statePath, data, 0644) 217 | } 218 | 219 | // GetWorktreeInfo returns the worktree information for a given session 220 | func (sm *StateManager) GetWorktreeInfo(sessionName string) (*AgentState, error) { 221 | // Load existing state 222 | states := make(map[string]AgentState) 223 | if data, err := os.ReadFile(sm.statePath); err != nil { 224 | return nil, fmt.Errorf("error reading state file: %w", err) 225 | } else { 226 | if err := json.Unmarshal(data, &states); err != nil { 227 | return nil, fmt.Errorf("error parsing state file: %w", err) 228 | } 229 | } 230 | 231 | state, ok := states[sessionName] 232 | if !ok { 233 | return nil, fmt.Errorf("no state found for session: %s", sessionName) 234 | } 235 | 236 | return &state, nil 237 | } 238 | -------------------------------------------------------------------------------- /uzi: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi -------------------------------------------------------------------------------- /uzi-site/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | package-lock.json 4 | package.json 5 | *.log -------------------------------------------------------------------------------- /uzi-site/CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is the "uzi-site" project - the first website for Uzi. The repository is currently minimal with only a README.md file. 8 | 9 | ## Current State 10 | 11 | The repository is in its initial state with no build system, dependencies, or source code yet established. Future development will likely involve setting up a web framework and development environment. 12 | 13 | ## Development Commands 14 | 15 | No build, test, or development commands are currently configured. These will need to be established as the project grows. -------------------------------------------------------------------------------- /uzi-site/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/android-chrome-192x192.png -------------------------------------------------------------------------------- /uzi-site/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/android-chrome-512x512.png -------------------------------------------------------------------------------- /uzi-site/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/apple-touch-icon.png -------------------------------------------------------------------------------- /uzi-site/banner-messages.txt: -------------------------------------------------------------------------------- 1 | Q2 2025, LLMS ARE THE MASTERS OF DEVELOPMENT, ONLY HORSELESS CARRIAGES STAND BEFORE THEM, REPOS ARE NOW BATTLEFIELDS | THIS IS A WAR DO NOT THINK IT IS NOT | LITERALLY TENS OF BILLIONS ARE BEING SET ON FIRE FOR YOUR ATTENTION AND FOR YOUR LOYALTY | UZI IS THE FULL-AUTO OF CODE GEN | WHY ARE YOU NOT GIVING YOUR TEAM INSANELY ASSYMETRIC ADVANTAGES| BTW THIS IS WHAT TEN BILLION LOOKS LIKE IN NUMBERS - $10,000,000,000 USD | WHY WAIT FOR SMARTER MODELS WHEN YOU CAN RUN 50 GENERATIONS AT THE SAME TIME | INFERENCE IS TAX-DEDUCTIBLE ANYWAYS (NFA/DYOR) | AT LARGE ENOUGH NUMBERS, THE FACE OF RANDOMNESS SMILES | YOU ARE NOT SPENDING ENOUGH ON CODE GENERATION, ACTUALLY | WE CAN EVEN COME TRAIN YOU HOW TO USE IT | CLINE, CURSOR, WINDSURF, CLAUDE CODE, CODEX, CODY, AIDER, ETC, | IF THE GAME FEELS TOO EASY WITH UZI THAT'S BECAUSE IT IS, IT'S THAT EASY | YOU KNOW WHAT THEY SAY DO NOT KNOCK IT UNTIL YOU TRY IT | IN TMUX WE TRUST | -------------------------------------------------------------------------------- /uzi-site/commit-types.txt: -------------------------------------------------------------------------------- 1 | feat 2 | fix 3 | chore 4 | refactor 5 | perf 6 | test 7 | docs 8 | style 9 | ci 10 | build 11 | revert 12 | wip 13 | init 14 | temp 15 | merge 16 | hotfix -------------------------------------------------------------------------------- /uzi-site/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/favicon-16x16.png -------------------------------------------------------------------------------- /uzi-site/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/favicon-32x32.png -------------------------------------------------------------------------------- /uzi-site/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/favicon.ico -------------------------------------------------------------------------------- /uzi-site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 13 | 17 | 18 | 19 | 20 | 21 | 25 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 42 | 46 | 47 | 48 | 52 | 56 | 57 | 58 | 59 | 60 | 61 | Uzi - Multi-Agent Development Tool 62 | 332 | 333 | 334 | 335 | 336 | 342 | 350 | 351 | 352 | 353 |
354 |
355 |
356 | 357 | 420 | 421 | 422 |
423 |

UZI: THE FULL-AUTO OF CODE GEN

424 |
425 | 428 |
429 |
go install github.com/devflowinc/uzi@latest
430 |
431 |
432 |
433 |
434 | 435 | 436 | 459 | 460 | 509 | 510 | 511 | -------------------------------------------------------------------------------- /uzi-site/media/16x9steppecommander.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/media/16x9steppecommander.png -------------------------------------------------------------------------------- /uzi-site/sprites/bombers/bomber-sprite-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/bombers/bomber-sprite-1.png -------------------------------------------------------------------------------- /uzi-site/sprites/bombers/bomber-sprite-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/bombers/bomber-sprite-2.png -------------------------------------------------------------------------------- /uzi-site/sprites/fighters/fighter-sprite-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/fighters/fighter-sprite-1.png -------------------------------------------------------------------------------- /uzi-site/sprites/fighters/fighter-sprite-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/fighters/fighter-sprite-2.png -------------------------------------------------------------------------------- /uzi-site/sprites/projectiles/missile-sprite-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/projectiles/missile-sprite-1.png -------------------------------------------------------------------------------- /uzi-site/sprites/projectiles/missile-sprite-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/projectiles/missile-sprite-2.png -------------------------------------------------------------------------------- /uzi-site/sprites/ships/carrier-sprite-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/ships/carrier-sprite-1.png -------------------------------------------------------------------------------- /uzi-site/sprites/specials/sr-71-sprite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/specials/sr-71-sprite.png -------------------------------------------------------------------------------- /uzi-site/sprites/subs/sub-sprite-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/sprites/subs/sub-sprite-1.png -------------------------------------------------------------------------------- /uzi-site/uzi-opengraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devflowinc/uzi/421685f65d66180152fb5f63128111ec12566ea5/uzi-site/uzi-opengraph.png -------------------------------------------------------------------------------- /uzi-site/uzi.yaml: -------------------------------------------------------------------------------- 1 | devCommand: cd uzi-site && reload -p $PORT 2 | portRange: 3000-4000 3 | -------------------------------------------------------------------------------- /uzi.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Subtrace, Inc. 2 | // SPDX-License-Identifier: BSD-3-Clause 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "strings" 15 | 16 | "github.com/devflowinc/uzi/cmd/broadcast" 17 | "github.com/devflowinc/uzi/cmd/checkpoint" 18 | "github.com/devflowinc/uzi/cmd/kill" 19 | "github.com/devflowinc/uzi/cmd/ls" 20 | "github.com/devflowinc/uzi/cmd/prompt" 21 | "github.com/devflowinc/uzi/cmd/reset" 22 | "github.com/devflowinc/uzi/cmd/run" 23 | "github.com/devflowinc/uzi/cmd/watch" 24 | 25 | "github.com/peterbourgon/ff/v3/ffcli" 26 | ) 27 | 28 | var subcommands = []*ffcli.Command{ 29 | prompt.CmdPrompt, 30 | ls.CmdLs, 31 | kill.CmdKill, 32 | reset.CmdReset, 33 | run.CmdRun, 34 | checkpoint.CmdCheckpoint, 35 | watch.CmdWatch, 36 | broadcast.CmdBroadcast, 37 | } 38 | 39 | var commandAliases = map[string]*regexp.Regexp{ 40 | "prompt": regexp.MustCompile(`^p(ro(mpt)?)?$`), 41 | "ls": regexp.MustCompile(`^l(s)?$`), 42 | "kill": regexp.MustCompile(`^k(ill)?$`), 43 | "reset": regexp.MustCompile(`^re(set)?$`), 44 | "checkpoint": regexp.MustCompile(`^c(heckpoint)?$`), 45 | "run": regexp.MustCompile(`^r(un)?$`), 46 | "watch": regexp.MustCompile(`^w(atch)?$`), 47 | "broadcast": regexp.MustCompile(`^b(roadcast)?$`), 48 | "attach": regexp.MustCompile(`^a(ttach)?$`), 49 | } 50 | 51 | func main() { 52 | ctx, cancel := context.WithCancel(context.Background()) 53 | defer cancel() 54 | 55 | c := new(ffcli.Command) 56 | c.Name = filepath.Base(os.Args[0]) 57 | c.ShortUsage = "uzi " 58 | c.Subcommands = subcommands 59 | 60 | c.FlagSet = flag.NewFlagSet("uzi", flag.ContinueOnError) 61 | c.FlagSet.SetOutput(os.Stdout) 62 | c.Exec = func(ctx context.Context, args []string) error { 63 | fmt.Fprintf(os.Stdout, "%s\n", c.UsageFunc(c)) 64 | 65 | if len(os.Args) >= 2 { 66 | return fmt.Errorf("unknown command %q", os.Args[1]) 67 | } 68 | return nil 69 | } 70 | 71 | // Resolve command aliases before parsing 72 | args := os.Args[1:] 73 | if len(args) > 0 { 74 | cmdName := args[0] 75 | for realCmd, pattern := range commandAliases { 76 | if pattern.MatchString(cmdName) { 77 | args[0] = realCmd 78 | break 79 | } 80 | } 81 | } 82 | 83 | switch err := c.Parse(args); { 84 | case err == nil: 85 | case errors.Is(err, flag.ErrHelp): 86 | return 87 | case strings.Contains(err.Error(), "flag provided but not defined"): 88 | os.Exit(2) 89 | default: 90 | fmt.Fprintf(os.Stderr, "uzi: error: %v\n", err) 91 | os.Exit(1) 92 | } 93 | 94 | if err := c.Run(ctx); err != nil { 95 | fmt.Fprintf(os.Stderr, "uzi: error: %v\n", err) 96 | os.Exit(1) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /uzi.yaml: -------------------------------------------------------------------------------- 1 | devCommand: cd astrobits && yarn && yarn dev --port $PORT 2 | portRange: 3000-3010 --------------------------------------------------------------------------------