Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ jobs:

- name: cargo test
run: cargo test --all

- name: launcher shell smoke tests
if: runner.os != 'Windows'
run: |
bash tests/squad_tmux_launcher_helpers_test.sh
bash tests/squad_tmux_launcher_smoke.sh
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,28 @@ squad init

That's it. Each agent joins, reads its role instructions, and enters a work loop that checks for messages. The manager breaks down your goal and assigns tasks to workers.

## Optional tmux Launcher

For Unix-like environments that already use Claude Code, this repo also ships an optional helper script:

```bash
scripts/squad-tmux-launch.sh /path/to/project --dry-run
```

It can:
- read project-local launcher config from `.squad/launcher.yaml`
- read a task brief from `.squad/run-task.md`
- generate manager / inspector prompt files under `.squad/quickstart/`
- start a tiled `tmux` session and inject `/squad` commands into Claude panes
- optionally create an isolated git worktree before launching agents

Requirements:
- `tmux`
- `ruby` (used to parse `launcher.yaml`)
- `claude`

This launcher is intentionally separate from the core Rust CLI. Treat it as optional automation for people who want a repeatable multi-terminal workflow.

## Usage Flow

```
Expand Down
22 changes: 22 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,28 @@ squad init

就这么简单。每个 Agent 加入后会读取角色指令,然后进入持续检查消息的工作循环。Manager 会分析你的目标并分配任务给 Worker。

## 可选的 tmux 启动器

如果你在类 Unix 环境里使用 Claude Code,这个仓库还带了一个可选辅助脚本:

```bash
scripts/squad-tmux-launch.sh /path/to/project --dry-run
```

它可以:
- 从 `.squad/launcher.yaml` 读取项目级启动配置
- 从 `.squad/run-task.md` 读取本次任务说明
- 在 `.squad/quickstart/` 下生成 manager / inspector prompt
- 启动平铺布局的 `tmux` 会话,并自动向 Claude pane 注入 `/squad` 命令
- 在启动 agent 前可选地创建独立 git worktree

依赖:
- `tmux`
- `ruby`(用于解析 `launcher.yaml`)
- `claude`

这个启动器刻意保持在核心 Rust CLI 之外。它是给需要固定化多终端协作流程的用户准备的可选自动化能力。

## 使用流程

```
Expand Down
255 changes: 255 additions & 0 deletions scripts/lib/squad-tmux-launcher-helpers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
#!/usr/bin/env bash

shell_escape() {
printf '%q' "$1"
}

shell_join() {
local joined=""
local item=""
for item in "$@"; do
if [[ -n "$joined" ]]; then
joined+=" "
fi
joined+="$(shell_escape "$item")"
done
printf '%s' "$joined"
}

pane_command_candidates() {
local command_name="$1"
local resolved=""
local current=""
local target=""
local shebang=""
local interpreter=""
local candidates=()

add_candidate() {
local candidate="$1"
local existing=""
local found=0
[[ -n "$candidate" ]] || return 0
set +u
for existing in "${candidates[@]}"; do
if [[ "$existing" == "$candidate" ]]; then
found=1
break
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These helpers call set +u / set -u inside a function. Because set is global, sourcing this library from a shell that didn’t enable nounset will end up enabling -u for the rest of the session. Save/restore the prior nounset state (or avoid changing shell options inside helpers).

Copilot uses AI. Check for mistakes.
fi
done
set -u
if (( found == 1 )); then
return 0
fi
candidates+=("$candidate")
}

add_candidate "$(basename "$command_name")"

if command -v "$command_name" >/dev/null 2>&1; then
resolved="$(command -v "$command_name")"
elif [[ -e "$command_name" ]]; then
resolved="$command_name"
fi

if [[ -n "$resolved" ]]; then
add_candidate "$(basename "$resolved")"
current="$resolved"
while [[ -L "$current" ]]; do
target="$(readlink "$current")"
if [[ "$target" == /* ]]; then
current="$target"
else
current="$(dirname "$current")/$target"
fi
done
add_candidate "$(basename "$current")"

if [[ -f "$current" ]]; then
IFS= read -r shebang <"$current" || true
if [[ "$shebang" == "#!"* ]]; then
shebang="${shebang#\#!}"
shebang="${shebang#"${shebang%%[![:space:]]*}"}"
if [[ "$shebang" == */env\ * ]]; then
interpreter="${shebang##*/env }"
interpreter="${interpreter%% *}"
else
interpreter="${shebang%% *}"
interpreter="$(basename "$interpreter")"
fi
add_candidate "$interpreter"
fi
fi
fi

set +u
printf '%s\n' "${candidates[@]}"
set -u
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pane_command_candidates also toggles nounset at function exit (set +u / set -u) which can change the caller’s shell options permanently. Restore the original nounset setting instead of forcing set -u here.

Copilot uses AI. Check for mistakes.

is_truthy() {
local value="${1:-}"
value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
case "$value" in
1|true|yes|on)
return 0
;;
*)
return 1
;;
esac
}

slugify_path_component() {
local value="$1"
value="$(printf '%s' "$value" | tr ' /:@' '----')"
value="${value//[^A-Za-z0-9._-]/-}"
printf '%s' "${value:-worktree}"
}

expand_path_from_base() {
local path="$1"
local base_dir="$2"

if [[ "$path" == "~" ]]; then
path="$HOME"
elif [[ "$path" == "~/"* ]]; then
path="$HOME/${path#~/}"
elif [[ "$path" != /* ]]; then
path="$base_dir/$path"
fi

printf '%s' "$path"
}

resolve_worktree_root() {
local repo_root="$1"
local location="$2"
expand_path_from_base "${location:-.worktrees}" "$repo_root"
}

resolve_worktree_path() {
local repo_root="$1"
local location="$2"
local leaf_name="$3"
local root=""
root="$(resolve_worktree_root "$repo_root" "$location")"
if [[ -n "$leaf_name" ]]; then
printf '%s/%s' "$root" "$leaf_name"
else
printf '%s' "$root"
fi
}

path_is_within() {
local path="$1"
local base="$2"
[[ "$path" == "$base" || "$path" == "$base"/* ]]
Comment thread
Aias00 marked this conversation as resolved.
}

ensure_repo_local_worktree_ignored() {
local repo_root="$1"
local path="$2"
local rel_path=""

if ! path_is_within "$path" "$repo_root"; then
return 0
fi

if [[ "$path" == "$repo_root" ]]; then
echo "Error: worktree path cannot be the repository root: $path" >&2
return 1
fi

rel_path="${path#$repo_root/}"
if git -C "$repo_root" check-ignore -q "$rel_path"; then
return 0
fi

echo "Error: repo-local worktree path is not ignored by git: $rel_path" >&2
echo "Add an ignore rule for that path or use a worktree location outside the repository." >&2
return 1
}

find_worktree_path_for_branch() {
local repo_root="$1"
local branch_name="$2"
local line=""
local current_path=""
local current_branch=""

while IFS= read -r line; do
case "$line" in
worktree\ *)
current_path="${line#worktree }"
;;
branch\ refs/heads/*)
current_branch="${line#branch refs/heads/}"
if [[ "$current_branch" == "$branch_name" ]]; then
printf '%s\n' "$current_path"
return 0
fi
;;
esac
done < <(git -C "$repo_root" worktree list --porcelain)

return 1
}

ensure_git_worktree() {
local repo_root="$1"
local requested_path="$2"
local branch_name="$3"
local base_ref="$4"
local dry_run="${5:-0}"
local existing_branch_path=""
local current_branch=""

if [[ -z "$branch_name" ]]; then
echo "Error: worktree branch name is required" >&2
return 1
fi

existing_branch_path="$(find_worktree_path_for_branch "$repo_root" "$branch_name" || true)"
if [[ -n "$existing_branch_path" ]]; then
printf '%s\n' "$existing_branch_path"
return 0
fi

if [[ -f "$requested_path/.git" || -d "$requested_path/.git" ]]; then
current_branch="$(git -C "$requested_path" branch --show-current 2>/dev/null || true)"
if [[ -n "$current_branch" && "$current_branch" != "$branch_name" ]]; then
echo "Error: requested worktree path already exists on branch '$current_branch': $requested_path" >&2
return 1
fi
printf '%s\n' "$requested_path"
return 0
fi

if [[ -e "$requested_path" && ! -d "$requested_path" ]]; then
echo "Error: requested worktree path exists and is not a directory: $requested_path" >&2
return 1
fi

if [[ -d "$requested_path" ]]; then
if [[ -n "$(find "$requested_path" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
echo "Error: requested worktree path exists and is not an empty git worktree: $requested_path" >&2
return 1
fi
fi

if (( dry_run == 1 )); then
printf '%s\n' "$requested_path"
return 0
fi

mkdir -p "$(dirname "$requested_path")"

if git -C "$repo_root" show-ref --verify --quiet "refs/heads/$branch_name"; then
git -C "$repo_root" worktree add "$requested_path" "$branch_name" >/dev/null
else
git -C "$repo_root" worktree add "$requested_path" -b "$branch_name" "${base_ref:-HEAD}" >/dev/null
fi

printf '%s\n' "$requested_path"
}
Loading
Loading