Skip to content

Commit 3852e65

Browse files
authored
Merge pull request #7 from Aias00/feat/tmux-launcher-pr
feat: add optional tmux launcher workflow
2 parents d194b49 + 9b49890 commit 3852e65

File tree

9 files changed

+1424
-0
lines changed

9 files changed

+1424
-0
lines changed

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,9 @@ jobs:
4343

4444
- name: cargo test
4545
run: cargo test --all
46+
47+
- name: launcher shell smoke tests
48+
if: runner.os != 'Windows'
49+
run: |
50+
bash tests/squad_tmux_launcher_helpers_test.sh
51+
bash tests/squad_tmux_launcher_smoke.sh

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,28 @@ squad init
7575

7676
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.
7777

78+
## Optional tmux Launcher
79+
80+
For Unix-like environments that already use Claude Code, this repo also ships an optional helper script:
81+
82+
```bash
83+
scripts/squad-tmux-launch.sh /path/to/project --dry-run
84+
```
85+
86+
It can:
87+
- read project-local launcher config from `.squad/launcher.yaml`
88+
- read a task brief from `.squad/run-task.md`
89+
- generate manager / inspector prompt files under `.squad/quickstart/`
90+
- start a tiled `tmux` session and inject `/squad` commands into Claude panes
91+
- optionally create an isolated git worktree before launching agents
92+
93+
Requirements:
94+
- `tmux`
95+
- `ruby` (used to parse `launcher.yaml`)
96+
- `claude`
97+
98+
This launcher is intentionally separate from the core Rust CLI. Treat it as optional automation for people who want a repeatable multi-terminal workflow.
99+
78100
## Usage Flow
79101

80102
```

README.zh-CN.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,28 @@ squad init
7777

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

80+
## 可选的 tmux 启动器
81+
82+
如果你在类 Unix 环境里使用 Claude Code,这个仓库还带了一个可选辅助脚本:
83+
84+
```bash
85+
scripts/squad-tmux-launch.sh /path/to/project --dry-run
86+
```
87+
88+
它可以:
89+
-`.squad/launcher.yaml` 读取项目级启动配置
90+
-`.squad/run-task.md` 读取本次任务说明
91+
-`.squad/quickstart/` 下生成 manager / inspector prompt
92+
- 启动平铺布局的 `tmux` 会话,并自动向 Claude pane 注入 `/squad` 命令
93+
- 在启动 agent 前可选地创建独立 git worktree
94+
95+
依赖:
96+
- `tmux`
97+
- `ruby`(用于解析 `launcher.yaml`
98+
- `claude`
99+
100+
这个启动器刻意保持在核心 Rust CLI 之外。它是给需要固定化多终端协作流程的用户准备的可选自动化能力。
101+
80102
## 使用流程
81103

82104
```
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
#!/usr/bin/env bash
2+
3+
shell_escape() {
4+
printf '%q' "$1"
5+
}
6+
7+
shell_join() {
8+
local joined=""
9+
local item=""
10+
for item in "$@"; do
11+
if [[ -n "$joined" ]]; then
12+
joined+=" "
13+
fi
14+
joined+="$(shell_escape "$item")"
15+
done
16+
printf '%s' "$joined"
17+
}
18+
19+
pane_command_candidates() {
20+
local command_name="$1"
21+
local resolved=""
22+
local current=""
23+
local target=""
24+
local shebang=""
25+
local interpreter=""
26+
local candidates=()
27+
28+
add_candidate() {
29+
local candidate="$1"
30+
local existing=""
31+
local found=0
32+
[[ -n "$candidate" ]] || return 0
33+
if (( ${#candidates[@]} > 0 )); then
34+
for existing in "${candidates[@]}"; do
35+
if [[ "$existing" == "$candidate" ]]; then
36+
found=1
37+
break
38+
fi
39+
done
40+
fi
41+
if (( found == 1 )); then
42+
return 0
43+
fi
44+
candidates+=("$candidate")
45+
}
46+
47+
add_candidate "$(basename "$command_name")"
48+
49+
if command -v "$command_name" >/dev/null 2>&1; then
50+
resolved="$(command -v "$command_name")"
51+
elif [[ -e "$command_name" ]]; then
52+
resolved="$command_name"
53+
fi
54+
55+
if [[ -n "$resolved" ]]; then
56+
add_candidate "$(basename "$resolved")"
57+
current="$resolved"
58+
while [[ -L "$current" ]]; do
59+
target="$(readlink "$current")"
60+
if [[ "$target" == /* ]]; then
61+
current="$target"
62+
else
63+
current="$(dirname "$current")/$target"
64+
fi
65+
done
66+
add_candidate "$(basename "$current")"
67+
68+
if [[ -f "$current" ]]; then
69+
IFS= read -r shebang <"$current" || true
70+
if [[ "$shebang" == "#!"* ]]; then
71+
shebang="${shebang#\#!}"
72+
shebang="${shebang#"${shebang%%[![:space:]]*}"}"
73+
if [[ "$shebang" == */env\ * ]]; then
74+
interpreter="${shebang##*/env }"
75+
interpreter="${interpreter%% *}"
76+
else
77+
interpreter="${shebang%% *}"
78+
interpreter="$(basename "$interpreter")"
79+
fi
80+
add_candidate "$interpreter"
81+
fi
82+
fi
83+
fi
84+
85+
if (( ${#candidates[@]} > 0 )); then
86+
printf '%s\n' "${candidates[@]}"
87+
fi
88+
}
89+
90+
is_truthy() {
91+
local value="${1:-}"
92+
value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
93+
case "$value" in
94+
1|true|yes|on)
95+
return 0
96+
;;
97+
*)
98+
return 1
99+
;;
100+
esac
101+
}
102+
103+
slugify_path_component() {
104+
local value="$1"
105+
value="$(printf '%s' "$value" | tr ' /:@' '----')"
106+
value="${value//[^A-Za-z0-9._-]/-}"
107+
printf '%s' "${value:-worktree}"
108+
}
109+
110+
copy_array_or_empty() {
111+
local target_name="$1"
112+
local source_name="$2"
113+
114+
eval "$target_name=()"
115+
if ! declare -p "$source_name" >/dev/null 2>&1; then
116+
return 0
117+
fi
118+
119+
eval 'if ((${#'"$source_name"'[@]} > 0)); then '"$target_name"'=("${'"$source_name"'[@]}"); fi'
120+
}
121+
122+
repo_worktree_location_slug() {
123+
local repo_root="$1"
124+
local normalized="${repo_root#/}"
125+
printf '%s' "$(slugify_path_component "$normalized")"
126+
}
127+
128+
expand_path_from_base() {
129+
local path="$1"
130+
local base_dir="$2"
131+
132+
if [[ "$path" == "~" ]]; then
133+
path="$HOME"
134+
elif [[ "${path:0:2}" == "~/" ]]; then
135+
path="$HOME/${path:2}"
136+
elif [[ "$path" != /* ]]; then
137+
path="$base_dir/$path"
138+
fi
139+
140+
printf '%s' "$path"
141+
}
142+
143+
resolve_worktree_root() {
144+
local repo_root="$1"
145+
local location="$2"
146+
expand_path_from_base "${location:-.worktrees}" "$repo_root"
147+
}
148+
149+
resolve_worktree_path() {
150+
local repo_root="$1"
151+
local location="$2"
152+
local leaf_name="$3"
153+
local root=""
154+
root="$(resolve_worktree_root "$repo_root" "$location")"
155+
if [[ -n "$leaf_name" ]]; then
156+
printf '%s/%s' "$root" "$leaf_name"
157+
else
158+
printf '%s' "$root"
159+
fi
160+
}
161+
162+
path_is_within() {
163+
local path="$1"
164+
local base="$2"
165+
case "$path" in
166+
*/../*|*/./*|../*|./*|*/..|*/.)
167+
return 1
168+
;;
169+
esac
170+
[[ "$path" == "$base" || "$path" == "$base"/* ]]
171+
}
172+
173+
ensure_repo_local_worktree_ignored() {
174+
local repo_root="$1"
175+
local path="$2"
176+
local rel_path=""
177+
178+
if ! path_is_within "$path" "$repo_root"; then
179+
return 0
180+
fi
181+
182+
if [[ "$path" == "$repo_root" ]]; then
183+
echo "Error: worktree path cannot be the repository root: $path" >&2
184+
return 1
185+
fi
186+
187+
rel_path="${path#$repo_root/}"
188+
if git -C "$repo_root" check-ignore -q "$rel_path"; then
189+
return 0
190+
fi
191+
192+
echo "Error: repo-local worktree path is not ignored by git: $rel_path" >&2
193+
echo "Add an ignore rule for that path or use a worktree location outside the repository." >&2
194+
return 1
195+
}
196+
197+
find_worktree_path_for_branch() {
198+
local repo_root="$1"
199+
local branch_name="$2"
200+
local line=""
201+
local current_path=""
202+
local current_branch=""
203+
204+
while IFS= read -r line; do
205+
case "$line" in
206+
worktree\ *)
207+
current_path="${line#worktree }"
208+
;;
209+
branch\ refs/heads/*)
210+
current_branch="${line#branch refs/heads/}"
211+
if [[ "$current_branch" == "$branch_name" ]]; then
212+
printf '%s\n' "$current_path"
213+
return 0
214+
fi
215+
;;
216+
esac
217+
done < <(git -C "$repo_root" worktree list --porcelain)
218+
219+
return 1
220+
}
221+
222+
ensure_git_worktree() {
223+
local repo_root="$1"
224+
local requested_path="$2"
225+
local branch_name="$3"
226+
local base_ref="$4"
227+
local dry_run="${5:-0}"
228+
local existing_branch_path=""
229+
local current_branch=""
230+
local requested_common_dir=""
231+
local repo_common_dir=""
232+
233+
if [[ -z "$branch_name" ]]; then
234+
echo "Error: worktree branch name is required" >&2
235+
return 1
236+
fi
237+
238+
existing_branch_path="$(find_worktree_path_for_branch "$repo_root" "$branch_name" || true)"
239+
if [[ -n "$existing_branch_path" ]]; then
240+
printf '%s\n' "$existing_branch_path"
241+
return 0
242+
fi
243+
244+
if [[ -f "$requested_path/.git" || -d "$requested_path/.git" ]]; then
245+
requested_common_dir="$(git -C "$requested_path" rev-parse --git-common-dir 2>/dev/null || true)"
246+
repo_common_dir="$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null || true)"
247+
if [[ -n "$requested_common_dir" && "$requested_common_dir" != /* ]]; then
248+
requested_common_dir="$requested_path/$requested_common_dir"
249+
fi
250+
if [[ -n "$repo_common_dir" && "$repo_common_dir" != /* ]]; then
251+
repo_common_dir="$repo_root/$repo_common_dir"
252+
fi
253+
if [[ -n "$requested_common_dir" && -n "$repo_common_dir" && "$requested_common_dir" != "$repo_common_dir" ]]; then
254+
echo "Error: requested worktree path belongs to a different repository: $requested_path" >&2
255+
return 1
256+
fi
257+
258+
current_branch="$(git -C "$requested_path" branch --show-current 2>/dev/null || true)"
259+
if [[ -n "$current_branch" && "$current_branch" != "$branch_name" ]]; then
260+
echo "Error: requested worktree path already exists on branch '$current_branch': $requested_path" >&2
261+
return 1
262+
fi
263+
printf '%s\n' "$requested_path"
264+
return 0
265+
fi
266+
267+
if [[ -e "$requested_path" && ! -d "$requested_path" ]]; then
268+
echo "Error: requested worktree path exists and is not a directory: $requested_path" >&2
269+
return 1
270+
fi
271+
272+
if [[ -d "$requested_path" ]]; then
273+
if [[ -n "$(find "$requested_path" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
274+
echo "Error: requested worktree path exists and is not an empty git worktree: $requested_path" >&2
275+
return 1
276+
fi
277+
fi
278+
279+
if (( dry_run == 1 )); then
280+
printf '%s\n' "$requested_path"
281+
return 0
282+
fi
283+
284+
mkdir -p "$(dirname "$requested_path")"
285+
286+
if git -C "$repo_root" show-ref --verify --quiet "refs/heads/$branch_name"; then
287+
git -C "$repo_root" worktree add "$requested_path" "$branch_name" >/dev/null
288+
else
289+
git -C "$repo_root" worktree add "$requested_path" -b "$branch_name" "${base_ref:-HEAD}" >/dev/null
290+
fi
291+
292+
printf '%s\n' "$requested_path"
293+
}

0 commit comments

Comments
 (0)