diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f3b876..ab8569a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 184ea7a..13423d8 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index d28b7d3..5b22f93 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 之外。它是给需要固定化多终端协作流程的用户准备的可选自动化能力。 + ## 使用流程 ``` diff --git a/scripts/lib/squad-tmux-launcher-helpers.sh b/scripts/lib/squad-tmux-launcher-helpers.sh new file mode 100644 index 0000000..e128a23 --- /dev/null +++ b/scripts/lib/squad-tmux-launcher-helpers.sh @@ -0,0 +1,293 @@ +#!/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 + if (( ${#candidates[@]} > 0 )); then + for existing in "${candidates[@]}"; do + if [[ "$existing" == "$candidate" ]]; then + found=1 + break + fi + done + fi + 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 + + if (( ${#candidates[@]} > 0 )); then + printf '%s\n' "${candidates[@]}" + fi +} + +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}" +} + +copy_array_or_empty() { + local target_name="$1" + local source_name="$2" + + eval "$target_name=()" + if ! declare -p "$source_name" >/dev/null 2>&1; then + return 0 + fi + + eval 'if ((${#'"$source_name"'[@]} > 0)); then '"$target_name"'=("${'"$source_name"'[@]}"); fi' +} + +repo_worktree_location_slug() { + local repo_root="$1" + local normalized="${repo_root#/}" + printf '%s' "$(slugify_path_component "$normalized")" +} + +expand_path_from_base() { + local path="$1" + local base_dir="$2" + + if [[ "$path" == "~" ]]; then + path="$HOME" + elif [[ "${path:0:2}" == "~/" ]]; then + path="$HOME/${path:2}" + 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" + case "$path" in + */../*|*/./*|../*|./*|*/..|*/.) + return 1 + ;; + esac + [[ "$path" == "$base" || "$path" == "$base"/* ]] +} + +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="" + local requested_common_dir="" + local repo_common_dir="" + + 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 + requested_common_dir="$(git -C "$requested_path" rev-parse --git-common-dir 2>/dev/null || true)" + repo_common_dir="$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null || true)" + if [[ -n "$requested_common_dir" && "$requested_common_dir" != /* ]]; then + requested_common_dir="$requested_path/$requested_common_dir" + fi + if [[ -n "$repo_common_dir" && "$repo_common_dir" != /* ]]; then + repo_common_dir="$repo_root/$repo_common_dir" + fi + if [[ -n "$requested_common_dir" && -n "$repo_common_dir" && "$requested_common_dir" != "$repo_common_dir" ]]; then + echo "Error: requested worktree path belongs to a different repository: $requested_path" >&2 + return 1 + fi + + 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" +} diff --git a/scripts/squad-tmux-launch.sh b/scripts/squad-tmux-launch.sh new file mode 100644 index 0000000..f818eef --- /dev/null +++ b/scripts/squad-tmux-launch.sh @@ -0,0 +1,756 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/lib/squad-tmux-launcher-helpers.sh" + +usage() { + cat <<'EOF' +Usage: + scripts/squad-tmux-launch.sh [options] + +Options: + --task-file Use a specific task brief file + --session-name Override tmux session name + --workers Override worker pane count + --worktree-branch Enable worktree mode and use this branch name + --worktree-path Override worktree leaf directory name + --worktree-base Base ref for new worktree branches (default: HEAD) + --worktree-location Override worktree parent directory + --no-worktree Disable worktree mode even if config enables it + --no-setup Skip `squad setup claude` + --no-attach Create/start session but do not attach + --dry-run Generate prompt/summary/map only; do not run squad/tmux/claude + --reuse-session Reuse an existing tmux session instead of failing + -h, --help Show this help + +Task source priority: + 1. --task-file + 2. /.squad/run-task.md + +Project config: + /.squad/launcher.yaml + +Worktree config: + /.squad/launcher.yaml -> workspace.worktree + Default location when enabled without an explicit path: + ~/.local/share/squad/worktrees/ + +Generated files: + /.squad/quickstart/generated-*.md + /.squad/quickstart//generated-*.md (when worktree mode is enabled) + +Examples: + scripts/squad-tmux-launch.sh /path/to/project + scripts/squad-tmux-launch.sh /path/to/project --task-file /tmp/task.md + scripts/squad-tmux-launch.sh /path/to/project --worktree-branch feat/my-task + scripts/squad-tmux-launch.sh /path/to/project --dry-run --no-setup + scripts/squad-tmux-launch.sh /path/to/project --reuse-session --session-name my-squad +EOF +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Error: missing required command: $cmd" >&2 + exit 1 + fi +} + +normalize_session_name() { + local value="$1" + value="$(printf '%s' "$value" | tr ' /:@' '----')" + value="${value//[^A-Za-z0-9._-]/-}" + printf '%s' "${value:-squad-session}" +} + +wait_for_pane_command() { + local target="$1" + local timeout_secs="$2" + shift 2 + local expected_commands=("$@") + local start_ts + start_ts="$(date +%s)" + + while true; do + local current + current="$(tmux display-message -p -t "$target" "#{pane_current_command}")" + + local expected="" + for expected in "${expected_commands[@]}"; do + if [[ "$current" == "$expected" ]]; then + return 0 + fi + done + + if (( "$(date +%s)" - start_ts >= timeout_secs )); then + echo "Error: pane $target did not start one of [${expected_commands[*]}] within ${timeout_secs}s (current: $current)" >&2 + return 1 + fi + + sleep 1 + done +} + +wait_for_agent_count() { + local workspace="$1" + local expected_count="$2" + local timeout_secs="$3" + local start_ts + start_ts="$(date +%s)" + + while true; do + local current_count + current_count="$( + ( + cd "$workspace" + squad agents --json 2>/dev/null || true + ) | awk 'NF { count += 1 } END { print count + 0 }' + )" + + if (( current_count >= expected_count )); then + return 0 + fi + + if (( "$(date +%s)" - start_ts >= timeout_secs )); then + echo "Error: only ${current_count}/${expected_count} agents joined squad within ${timeout_secs}s" >&2 + return 1 + fi + + sleep 1 + done +} + +send_tmux_text() { + local target="$1" + local text="$2" + tmux load-buffer - <<<"$text" + tmux paste-buffer -t "$target" + tmux send-keys -t "$target" Enter + tmux delete-buffer +} + +load_launcher_config() { + local config_path="$1" + if [[ ! -f "$config_path" ]]; then + return 0 + fi + + require_cmd ruby + + eval "$( + ruby - "$config_path" <<'RUBY' +require "yaml" +require "shellwords" + +file = ARGV[0] +data = + if File.exist?(file) + YAML.safe_load( + File.read(file), + permitted_classes: [], + permitted_symbols: [], + aliases: false, + ) || {} + else + {} + end + +def lookup(hash, *keys) + keys.reduce(hash) do |acc, key| + break nil unless acc.is_a?(Hash) + acc[key] || acc[key.to_sym] + end +end + +def emit_scalar(name, value) + value = nil if value.respond_to?(:empty?) && value.empty? + rendered = value.nil? ? "''" : Shellwords.escape(value.to_s) + puts "#{name}=#{rendered}" +end + +def emit_array(name, value) + items = Array(value).compact.map(&:to_s) + print "#{name}=(" + items.each do |item| + print "#{Shellwords.escape(item)} " + end + puts ")" +end + +emit_scalar("CFG_PROJECT_NAME", lookup(data, "project", "name")) +emit_scalar("CFG_SESSION_NAME", lookup(data, "project", "session_name")) +emit_scalar("CFG_CLAUDE_COMMAND", lookup(data, "runtime", "claude_command")) +emit_array("CFG_CLAUDE_ARGS", lookup(data, "runtime", "claude_args")) +emit_scalar("CFG_MANAGER_ROLE", lookup(data, "runtime", "manager_role")) +emit_scalar("CFG_WORKER_ROLE", lookup(data, "runtime", "worker_role")) +emit_scalar("CFG_INSPECTOR_ROLE", lookup(data, "runtime", "inspector_role")) +emit_scalar("CFG_WORKERS", lookup(data, "runtime", "workers")) +emit_array("CFG_INIT_ARGS", lookup(data, "workspace", "init_args")) +emit_scalar("CFG_WORKTREE_ENABLED", lookup(data, "workspace", "worktree", "enabled")) +emit_scalar("CFG_WORKTREE_LOCATION", lookup(data, "workspace", "worktree", "location")) +emit_scalar("CFG_WORKTREE_PATH", lookup(data, "workspace", "worktree", "path")) +emit_scalar("CFG_WORKTREE_BRANCH", lookup(data, "workspace", "worktree", "branch")) +emit_scalar("CFG_WORKTREE_BASE_REF", lookup(data, "workspace", "worktree", "base_ref")) +emit_array("CFG_FOCUS_FILES", lookup(data, "focus", "files")) +emit_array("CFG_FOCUS_DOCS", lookup(data, "focus", "docs")) +emit_array("CFG_CONSTRAINTS", lookup(data, "constraints")) +RUBY + )" +} + +build_manager_prompt() { + local output_path="$1" + local project_name="$2" + local workspace_dir="$3" + local task_file="$4" + + { + echo "# Squad Manager Prompt" + echo + echo "Coordinate the current squad collaboration run using the project context below and the task brief that follows." + echo + echo "## Project Context" + echo "- Project: \`$project_name\`" + echo "- Config root: \`$source_project_dir\`" + echo "- Workspace root: \`$workspace_dir\`" + echo "- Session: \`$session_name\`" + echo "- Worker count: \`$workers\`" + echo "- Manager role: \`$manager_role\`" + echo "- Worker role base: \`$worker_role\`" + echo "- Inspector role: \`$inspector_role\`" + echo + + if (( worktree_enabled == 1 )); then + echo "## Worktree" + echo "- Enabled: \`true\`" + echo "- Repo root: \`$git_repo_root\`" + echo "- Worktree root: \`$worktree_root\`" + echo "- Branch: \`$worktree_branch\`" + echo "- Base ref: \`$worktree_base_ref\`" + echo + fi + + if (( ${#focus_files[@]} > 0 )); then + echo "## Focus Files" + for item in "${focus_files[@]}"; do + echo "- \`$item\`" + done + echo + fi + + if (( ${#focus_docs[@]} > 0 )); then + echo "## Focus Docs" + for item in "${focus_docs[@]}"; do + echo "- \`$item\`" + done + echo + fi + + if (( ${#constraints[@]} > 0 )); then + echo "## Constraints" + for item in "${constraints[@]}"; do + echo "- $item" + done + echo + fi + + cat <<'EOF' +## Execution Principles +- Start with read-only analysis and build a baseline before assigning work. +- Prefer `squad task create / ack / complete` when state tracking matters. +- The manager coordinates, delegates, reviews, and closes the loop; avoid taking the main implementation work personally. +- Each task must include a goal, touched files, behavior constraints, and acceptance criteria. +- Every completed worker task should be reviewed by the inspector. +- Do not validate only the happy path; cover failures, fallback behavior, recovery, and regressions. +- If worktree mode is enabled, all code changes, tests, and commits must happen in `Workspace root`. + +## Task Brief +EOF + echo + cat "$task_file" + } >"$output_path" +} + +build_inspector_prompt() { + local output_path="$1" + local project_name="$2" + local workspace_dir="$3" + local task_file="$4" + local inspector_source="$5" + + { + echo "# Squad Inspector Prompt" + echo + echo "Review the current squad output as the inspector, prioritizing bugs, regressions, documentation drift, and missing tests." + echo + echo "## Project Context" + echo "- Project: \`$project_name\`" + echo "- Config root: \`$source_project_dir\`" + echo "- Workspace root: \`$workspace_dir\`" + echo "- Session: \`$session_name\`" + echo "- Manager role: \`$manager_role\`" + echo "- Inspector role: \`$inspector_role\`" + echo + + if (( worktree_enabled == 1 )); then + echo "## Worktree" + echo "- Repo root: \`$git_repo_root\`" + echo "- Worktree root: \`$worktree_root\`" + echo "- Branch: \`$worktree_branch\`" + echo + fi + + if (( ${#focus_files[@]} > 0 )); then + echo "## Focus Files" + for item in "${focus_files[@]}"; do + echo "- \`$item\`" + done + echo + fi + + if (( ${#constraints[@]} > 0 )); then + echo "## Constraints" + for item in "${constraints[@]}"; do + echo "- $item" + done + echo + fi + + cat <<'EOF' +## Review Principles +- Findings first. Order them by severity. +- Prioritise behavioral regressions, broken assumptions, and missing tests. +- Check README and docs against the actual implementation when relevant. +- If there are no blocking findings, say so explicitly and note residual risks. + +EOF + + if [[ -f "$inspector_source" ]]; then + echo "## Inspector Brief" + echo + cat "$inspector_source" + echo + fi + + cat <<'EOF' +## Task Brief +EOF + echo + cat "$task_file" + + if [[ ! -f "$inspector_source" ]]; then + cat <<'EOF' +## Review Checklist +Use the task brief above to confirm: +- the implementation satisfies the goal rather than only making tests pass +- no behavior regressions or compatibility breaks were introduced +- README, configuration guidance, and diagnostics still match the implementation +- tests genuinely cover the change objective +EOF + fi + } >"$output_path" +} + +build_run_summary() { + local output_path="$1" + { + echo "# Squad Run Summary" + echo + echo "- Project: \`$project_name\`" + echo "- Config root: \`$source_project_dir\`" + echo "- Workspace root: \`$workspace_dir\`" + echo "- Session: \`$session_name\`" + echo "- Task file: \`$task_file\`" + echo "- Inspector prompt source: \`$inspector_prompt_source\`" + echo "- Launcher config: \`$launcher_config\`" + echo "- Claude launch: \`$claude_launch_command\`" + echo "- Workers: \`$workers\`" + echo "- Dry run: \`$dry_run\`" + echo "- No setup: \`$no_setup\`" + echo "- No attach: \`$no_attach\`" + echo "- Reuse session: \`$reuse_session\`" + echo "- Worktree enabled: \`$worktree_enabled\`" + echo "- Git repo root: \`$git_repo_root\`" + echo "- Worktree root: \`$worktree_root\`" + echo "- Worktree branch: \`$worktree_branch\`" + echo "- Worktree base ref: \`$worktree_base_ref\`" + echo "- Worktree config root: \`$source_project_dir\`" + echo + echo "Generated files:" + echo "- \`$prompt_file\`" + echo "- \`$inspector_prompt_file\`" + echo "- \`$summary_file\`" + echo "- \`$terminal_map_file\`" + } >"$output_path" +} + +build_terminal_map() { + local output_path="$1" + { + echo "# Terminal Map" + echo + echo "- tmux session: \`$session_name\`" + echo "- workspace: \`$workspace_dir\`" + echo + echo "| Pane | Role | Command |" + echo "| --- | --- | --- |" + for i in "${!pane_labels[@]}"; do + echo "| $i | \`${pane_labels[$i]}\` | \`${pane_commands[$i]}\` |" + done + } >"$output_path" +} + +no_setup=0 +no_attach=0 +dry_run=0 +reuse_session=0 +no_worktree=0 +task_file_override="" +session_name_override="" +workers_override="" +worktree_branch_override="" +worktree_path_override="" +worktree_base_ref_override="" +worktree_location_override="" +positional=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --task-file) + task_file_override="${2:-}" + [[ -n "$task_file_override" ]] || { echo "Error: --task-file requires a path" >&2; exit 1; } + shift 2 + ;; + --session-name) + session_name_override="${2:-}" + [[ -n "$session_name_override" ]] || { echo "Error: --session-name requires a value" >&2; exit 1; } + shift 2 + ;; + --workers) + workers_override="${2:-}" + [[ -n "$workers_override" ]] || { echo "Error: --workers requires a value" >&2; exit 1; } + shift 2 + ;; + --worktree-branch) + worktree_branch_override="${2:-}" + [[ -n "$worktree_branch_override" ]] || { echo "Error: --worktree-branch requires a value" >&2; exit 1; } + shift 2 + ;; + --worktree-path) + worktree_path_override="${2:-}" + [[ -n "$worktree_path_override" ]] || { echo "Error: --worktree-path requires a value" >&2; exit 1; } + shift 2 + ;; + --worktree-base) + worktree_base_ref_override="${2:-}" + [[ -n "$worktree_base_ref_override" ]] || { echo "Error: --worktree-base requires a value" >&2; exit 1; } + shift 2 + ;; + --worktree-location) + worktree_location_override="${2:-}" + [[ -n "$worktree_location_override" ]] || { echo "Error: --worktree-location requires a value" >&2; exit 1; } + shift 2 + ;; + --no-worktree) + no_worktree=1 + shift + ;; + --no-setup) + no_setup=1 + shift + ;; + --no-attach) + no_attach=1 + shift + ;; + --dry-run) + dry_run=1 + shift + ;; + --reuse-session) + reuse_session=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + positional+=("$1") + shift + ;; + esac +done + +if (( ${#positional[@]} < 1 )); then + usage >&2 + exit 1 +fi + +project_dir="${positional[0]}" +if [[ ! -d "$project_dir" ]]; then + echo "Error: project directory does not exist: $project_dir" >&2 + exit 1 +fi + +cd "$project_dir" +project_dir="$(pwd -P)" +source_project_dir="$project_dir" + +launcher_config="$project_dir/.squad/launcher.yaml" +default_task_file="$project_dir/.squad/run-task.md" +task_file="${task_file_override:-$default_task_file}" + +if [[ ! -f "$task_file" ]]; then + echo "Error: task brief not found: $task_file" >&2 + echo "Provide --task-file or create $default_task_file" >&2 + exit 1 +fi + +CFG_PROJECT_NAME="" +CFG_SESSION_NAME="" +CFG_CLAUDE_COMMAND="" +CFG_CLAUDE_ARGS=() +CFG_MANAGER_ROLE="" +CFG_WORKER_ROLE="" +CFG_INSPECTOR_ROLE="" +CFG_WORKERS="" +CFG_INIT_ARGS=() +CFG_WORKTREE_ENABLED="" +CFG_WORKTREE_LOCATION="" +CFG_WORKTREE_PATH="" +CFG_WORKTREE_BRANCH="" +CFG_WORKTREE_BASE_REF="" +CFG_FOCUS_FILES=() +CFG_FOCUS_DOCS=() +CFG_CONSTRAINTS=() + +load_launcher_config "$launcher_config" + +project_name="${CFG_PROJECT_NAME:-$(basename "$project_dir")}" +session_name="${session_name_override:-${CFG_SESSION_NAME:-${project_name}-squad}}" +session_name="$(normalize_session_name "$session_name")" +claude_command="${CFG_CLAUDE_COMMAND:-claude}" +if [[ "$claude_command" == "~" ]]; then + claude_command="$HOME" +elif [[ "${claude_command:0:2}" == "~/" ]]; then + claude_command="$HOME/${claude_command:2}" +fi +manager_role="${CFG_MANAGER_ROLE:-manager}" +worker_role="${CFG_WORKER_ROLE:-worker}" +inspector_role="${CFG_INSPECTOR_ROLE:-inspector}" +workers="${workers_override:-${CFG_WORKERS:-2}}" +worktree_enabled=0 +if is_truthy "${CFG_WORKTREE_ENABLED:-}"; then + worktree_enabled=1 +fi +if [[ -n "$worktree_branch_override" || -n "$worktree_path_override" || -n "$worktree_base_ref_override" || -n "$worktree_location_override" ]]; then + worktree_enabled=1 +fi +if (( no_worktree == 1 )); then + worktree_enabled=0 +fi + +if ! [[ "$workers" =~ ^[0-9]+$ ]] || (( workers < 1 )); then + echo "Error: --workers must be an integer >= 1 (got: $workers)" >&2 + exit 1 +fi + +copy_array_or_empty claude_args CFG_CLAUDE_ARGS +copy_array_or_empty init_args CFG_INIT_ARGS +copy_array_or_empty focus_files CFG_FOCUS_FILES +copy_array_or_empty focus_docs CFG_FOCUS_DOCS +copy_array_or_empty constraints CFG_CONSTRAINTS + +if (( ${#init_args[@]} == 0 )); then + init_args=(--refresh-roles) +fi + +git_repo_root="" +project_relative_path="" +worktree_location="" +worktree_branch="" +worktree_path="" +worktree_base_ref="" +worktree_root="" +workspace_dir="$source_project_dir" + +if (( worktree_enabled == 1 )); then + require_cmd git + git_repo_root="$(git -C "$source_project_dir" rev-parse --show-toplevel 2>/dev/null || true)" + if [[ -z "$git_repo_root" ]]; then + echo "Error: worktree mode requires a git repository: $source_project_dir" >&2 + exit 1 + fi + + if [[ "$source_project_dir" == "$git_repo_root" ]]; then + project_relative_path="" + else + project_relative_path="${source_project_dir#$git_repo_root/}" + fi + + default_worktree_location="~/.local/share/squad/worktrees/$(repo_worktree_location_slug "$git_repo_root")" + worktree_location="${worktree_location_override:-${CFG_WORKTREE_LOCATION:-$default_worktree_location}}" + worktree_branch="${worktree_branch_override:-${CFG_WORKTREE_BRANCH:-}}" + worktree_base_ref="${worktree_base_ref_override:-${CFG_WORKTREE_BASE_REF:-HEAD}}" + worktree_path="${worktree_path_override:-${CFG_WORKTREE_PATH:-}}" + + if [[ -z "$worktree_branch" ]]; then + echo "Error: worktree mode requires a branch name via workspace.worktree.branch or --worktree-branch" >&2 + exit 1 + fi + + if [[ -z "$worktree_path" ]]; then + worktree_path="$(slugify_path_component "$worktree_branch")" + fi + + requested_worktree_root="$(resolve_worktree_path "$git_repo_root" "$worktree_location" "$worktree_path")" + ensure_repo_local_worktree_ignored "$git_repo_root" "$requested_worktree_root" + worktree_root="$(ensure_git_worktree "$git_repo_root" "$requested_worktree_root" "$worktree_branch" "$worktree_base_ref" "$dry_run")" + + if [[ -n "$project_relative_path" ]]; then + workspace_dir="$worktree_root/$project_relative_path" + else + workspace_dir="$worktree_root" + fi +else + git_repo_root="$(git -C "$source_project_dir" rev-parse --show-toplevel 2>/dev/null || true)" +fi + +if (( dry_run == 0 )) && [[ ! -d "$workspace_dir" ]]; then + echo "Error: workspace directory does not exist: $workspace_dir" >&2 + exit 1 +fi + +quickstart_dir="$source_project_dir/.squad/quickstart" +if (( worktree_enabled == 1 )); then + quickstart_dir="$quickstart_dir/$(slugify_path_component "$worktree_path")" +fi +mkdir -p "$quickstart_dir" + +claude_launch_command="$(shell_join "$claude_command")" +if (( ${#claude_args[@]} > 0 )); then + claude_launch_command="$(shell_join "$claude_command" "${claude_args[@]}")" +fi +inspector_prompt_source="$source_project_dir/.squad/prompts/inspector.md" + +prompt_file="$quickstart_dir/generated-manager.prompt.md" +inspector_prompt_file="$quickstart_dir/generated-inspector.prompt.md" +summary_file="$quickstart_dir/generated-run-summary.md" +terminal_map_file="$quickstart_dir/generated-terminal-map.md" + +pane_labels=("$manager_role") +pane_commands=("/squad $manager_role") +for ((i = 1; i <= workers; i++)); do + if (( i == 1 )); then + pane_labels+=("$worker_role") + pane_commands+=("/squad $worker_role") + else + pane_labels+=("${worker_role}-${i}") + pane_commands+=("/squad $worker_role ${worker_role}-${i}") + fi +done +pane_labels+=("$inspector_role") +pane_commands+=("/squad $inspector_role") + +build_manager_prompt "$prompt_file" "$project_name" "$workspace_dir" "$task_file" +build_inspector_prompt "$inspector_prompt_file" "$project_name" "$workspace_dir" "$task_file" "$inspector_prompt_source" +build_run_summary "$summary_file" +build_terminal_map "$terminal_map_file" + +if (( dry_run == 1 )); then + echo "Dry run complete." + echo "Config root: $source_project_dir" + echo "Workspace root: $workspace_dir" + echo "Manager prompt: $prompt_file" + echo "Inspector prompt: $inspector_prompt_file" + echo "Run summary: $summary_file" + echo "Terminal map: $terminal_map_file" + exit 0 +fi + +require_cmd squad +require_cmd "$claude_command" +require_cmd tmux + +if (( no_setup == 0 )); then + echo "[1/6] Refreshing Claude /squad command" + squad setup claude +else + echo "[1/6] Skipping squad setup claude (--no-setup)" +fi + +echo "[2/6] Initializing squad workspace" +( + cd "$workspace_dir" + squad init "${init_args[@]}" +) + +if tmux has-session -t "$session_name" 2>/dev/null; then + if (( reuse_session == 0 )); then + echo "Error: tmux session already exists: $session_name" >&2 + echo "Use --reuse-session or run: tmux kill-session -t \"$session_name\"" >&2 + exit 1 + fi + + echo "[3/6] Reusing existing tmux session: $session_name" + echo "Generated prompt: $prompt_file" + if (( no_attach == 0 )); then + if [[ -n "${TMUX:-}" ]]; then + tmux switch-client -t "$session_name" + else + tmux attach -t "$session_name" + fi + fi + exit 0 +fi + +echo "[3/6] Creating tmux session" +start_cmd="cd $(shell_escape "$workspace_dir") && exec $claude_launch_command" +tmux new-session -d -s "$session_name" -n squad "$start_cmd" +for ((i = 1; i < ${#pane_labels[@]}; i++)); do + tmux split-window -t "$session_name":0 "$start_cmd" +done +tmux select-layout -t "$session_name":0 tiled + +for i in "${!pane_labels[@]}"; do + tmux select-pane -t "$session_name":0."$i" -T "${pane_labels[$i]}" +done + +pane_command_aliases=() +while IFS= read -r alias; do + pane_command_aliases+=("$alias") +done < <(pane_command_candidates "$claude_command") +for i in "${!pane_labels[@]}"; do + wait_for_pane_command "$session_name":0."$i" 30 "${pane_command_aliases[@]}" +done + +echo "[4/6] Sending squad commands" +for i in "${!pane_commands[@]}"; do + send_tmux_text "$session_name":0."$i" "${pane_commands[$i]}" +done + +echo "[5/6] Waiting for agents to join squad" +wait_for_agent_count "$workspace_dir" "${#pane_commands[@]}" 90 + +echo "[6/6] Sending manager and inspector prompts" +send_tmux_text "$session_name":0.0 "$(cat "$prompt_file")" +send_tmux_text "$session_name":0."$((${#pane_labels[@]} - 1))" "$(cat "$inspector_prompt_file")" + +echo "Ready." +echo "Config root: $source_project_dir" +echo "Workspace root: $workspace_dir" +echo "tmux session: $session_name" +echo "Manager prompt: $prompt_file" +echo "Inspector prompt: $inspector_prompt_file" +echo "Run summary: $summary_file" +echo "Terminal map: $terminal_map_file" + +if (( no_attach == 0 )); then + if [[ -n "${TMUX:-}" ]]; then + tmux switch-client -t "$session_name" + else + tmux attach -t "$session_name" + fi +fi diff --git a/templates/launcher.yaml.example b/templates/launcher.yaml.example new file mode 100644 index 0000000..66e6507 --- /dev/null +++ b/templates/launcher.yaml.example @@ -0,0 +1,33 @@ +project: + name: my-project + session_name: my-project-squad + +runtime: + claude_command: claude + claude_args: + - --dangerously-skip-permissions + manager_role: manager + worker_role: worker + inspector_role: inspector + workers: 2 + +workspace: + init_args: + - --refresh-roles + worktree: + enabled: true + location: ~/.local/share/squad/worktrees/my-project + branch: feat/example-task + path: example-task + base_ref: HEAD + +focus: + files: + - src/index.ts + - src/app/main.ts + docs: + - README.md + +constraints: + - Keep existing public behavior stable + - Keep config backwards compatible diff --git a/templates/run-task.md.example b/templates/run-task.md.example new file mode 100644 index 0000000..6a623f0 --- /dev/null +++ b/templates/run-task.md.example @@ -0,0 +1,17 @@ +# Task +Describe the current task in one sentence. + +## Background +Add background, problem statement, and any relevant context. + +## Goals +- Goal 1 +- Goal 2 + +## Acceptance +- Acceptance criterion 1 +- Acceptance criterion 2 + +## Risks +- Risk 1 +- Risk 2 diff --git a/tests/squad_tmux_launcher_helpers_test.sh b/tests/squad_tmux_launcher_helpers_test.sh new file mode 100644 index 0000000..d96f499 --- /dev/null +++ b/tests/squad_tmux_launcher_helpers_test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +helpers="$repo_root/scripts/lib/squad-tmux-launcher-helpers.sh" + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +mkdir -p "$tmpdir/bin" "$tmpdir/lib/node_modules/@anthropic-ai/claude-code" +cat >"$tmpdir/lib/node_modules/@anthropic-ai/claude-code/cli.js" <<'EOF' +#!/usr/bin/env node +console.log('stub') +EOF +chmod +x "$tmpdir/lib/node_modules/@anthropic-ai/claude-code/cli.js" +ln -s "../lib/node_modules/@anthropic-ai/claude-code/cli.js" "$tmpdir/bin/claude" + +source "$helpers" + +candidates="$(pane_command_candidates "$tmpdir/bin/claude")" + +printf '%s\n' "$candidates" | grep -qx 'claude' +printf '%s\n' "$candidates" | grep -qx 'cli.js' +printf '%s\n' "$candidates" | grep -qx 'node' + +( + set +u + before_nounset="$(set -o | awk '$1=="nounset" { print $2 }')" + pane_command_candidates "$tmpdir/bin/claude" >/dev/null + empty_source=() + copy_array_or_empty copied_empty empty_source + copy_array_or_empty copied_missing missing_source + after_nounset="$(set -o | awk '$1=="nounset" { print $2 }')" + test "$before_nounset" = "$after_nounset" + test "${#copied_empty[@]}" -eq 0 + test "${#copied_missing[@]}" -eq 0 +) + +is_truthy true +! is_truthy false + +slug="$(slugify_path_component 'feat/mcp-server-sdk-upgrade')" +test "$slug" = "feat-mcp-server-sdk-upgrade" + +repo_dir="$tmpdir/repo" +mkdir -p "$repo_dir" +git -C "$repo_dir" init -b main >/dev/null +repo_dir="$(cd "$repo_dir" && pwd -P)" +git -C "$repo_dir" config user.email "codex@example.com" +git -C "$repo_dir" config user.name "Codex" +echo "hello" >"$repo_dir/README.md" +git -C "$repo_dir" add README.md +git -C "$repo_dir" commit -m "init" >/dev/null + +resolved_root="$(resolve_worktree_root "$repo_dir" ".worktrees")" +test "$resolved_root" = "$repo_dir/.worktrees" + +requested_path="$(resolve_worktree_path "$repo_dir" ".worktrees" "mcp-upgrade")" +test "$requested_path" = "$repo_dir/.worktrees/mcp-upgrade" +test "$(repo_worktree_location_slug "$repo_dir")" != "$(basename "$repo_dir")" +! path_is_within "$repo_dir/.worktrees/../outside" "$repo_dir" + +! ensure_repo_local_worktree_ignored "$repo_dir" "$requested_path" +echo ".worktrees/" >>"$repo_dir/.gitignore" +ensure_repo_local_worktree_ignored "$repo_dir" "$requested_path" + +created_path="$(ensure_git_worktree "$repo_dir" "$requested_path" "feat/mcp-upgrade" "HEAD" 0)" +test "$created_path" = "$requested_path" +test -d "$requested_path" +test "$(git -C "$requested_path" branch --show-current)" = "feat/mcp-upgrade" + +reused_path="$(ensure_git_worktree "$repo_dir" "$repo_dir/.worktrees/other-path" "feat/mcp-upgrade" "HEAD" 0)" +test "$reused_path" = "$requested_path" + +other_repo_dir="$tmpdir/other-repo" +mkdir -p "$other_repo_dir" +git -C "$tmpdir" init -b main other-repo >/dev/null +other_repo_dir="$(cd "$other_repo_dir" && pwd -P)" +git -C "$other_repo_dir" config user.email "codex@example.com" +git -C "$other_repo_dir" config user.name "Codex" +echo "world" >"$other_repo_dir/README.md" +git -C "$other_repo_dir" add README.md +git -C "$other_repo_dir" commit -m "init" >/dev/null + +! ensure_git_worktree "$other_repo_dir" "$requested_path" "feat/mcp-upgrade" "HEAD" 0 + +planned_path="$(ensure_git_worktree "$repo_dir" "$repo_dir/.worktrees/dry-run-path" "feat/dry-run" "HEAD" 1)" +test "$planned_path" = "$repo_dir/.worktrees/dry-run-path" +test ! -d "$repo_dir/.worktrees/dry-run-path" + +echo "PASS: helper functions cover command detection and worktree planning" diff --git a/tests/squad_tmux_launcher_smoke.sh b/tests/squad_tmux_launcher_smoke.sh new file mode 100644 index 0000000..2674052 --- /dev/null +++ b/tests/squad_tmux_launcher_smoke.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +launcher="$repo_root/scripts/squad-tmux-launch.sh" + +tmpdir="$(mktemp -d)" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT + +project_dir="$tmpdir/project" +mkdir -p "$project_dir/.squad/prompts" +git -C "$tmpdir" init -b main project >/dev/null +project_dir="$(cd "$project_dir" && pwd -P)" +git -C "$project_dir" config user.email "codex@example.com" +git -C "$project_dir" config user.name "Codex" +echo "demo" >"$project_dir/README.seed" +git -C "$project_dir" add README.seed +git -C "$project_dir" commit -m "seed" >/dev/null +echo ".worktrees/" >"$project_dir/.gitignore" + +cat >"$project_dir/.squad/launcher.yaml" <<'EOF' +project: + name: demo-project + session_name: demo-project-squad + +runtime: + claude_command: claude + claude_args: + - --dangerously-skip-permissions + manager_role: manager + worker_role: worker + inspector_role: inspector + workers: 2 + +workspace: + init_args: + - --refresh-roles + worktree: + enabled: true + location: .worktrees + branch: feat/feishu-claude-support + path: feishu-claude-support + base_ref: HEAD + +focus: + files: + - src/app/main.ts + - src/platforms/feishuPlatform.js + docs: + - README.md + +constraints: + - Keep Codex runtime behavior unchanged + - Keep config backwards compatible +EOF + +cat >"$project_dir/.squad/run-task.md" <<'EOF' +# Task +Improve Feishu support for Claude Code. + +## Goals +- Stabilize the basic Claude path under Feishu +- Improve streaming and status feedback +- Tighten agent/runtime selection and recovery + +## Acceptance +- Generate the manager prompt +- Generate the terminal mapping +- Keep dry-run free of tmux side effects +EOF + +cat >"$project_dir/.squad/prompts/inspector.md" <<'EOF' +# Inspector Task +Focus on whether the README, path handling, and Claude install compatibility stay aligned with the implementation. +EOF + +bash "$launcher" "$project_dir" --dry-run --no-setup --no-attach + +quickstart_dir="$project_dir/.squad/quickstart/feishu-claude-support" +prompt_file="$quickstart_dir/generated-manager.prompt.md" +inspector_prompt_file="$quickstart_dir/generated-inspector.prompt.md" +summary_file="$quickstart_dir/generated-run-summary.md" +map_file="$quickstart_dir/generated-terminal-map.md" + +test -f "$prompt_file" +test -f "$inspector_prompt_file" +test -f "$summary_file" +test -f "$map_file" + +grep -q "Improve Feishu support for Claude Code" "$prompt_file" +grep -q "README, path handling, and Claude install compatibility" "$inspector_prompt_file" +grep -q "Improve Feishu support for Claude Code" "$inspector_prompt_file" +grep -q "src/platforms/feishuPlatform.js" "$prompt_file" +grep -q "Keep Codex runtime behavior unchanged" "$prompt_file" +grep -q "demo-project-squad" "$summary_file" +grep -q "claude --dangerously-skip-permissions" "$summary_file" +grep -q "generated-inspector.prompt.md" "$summary_file" +grep -q "feat/feishu-claude-support" "$summary_file" +grep -q ".worktrees/feishu-claude-support" "$summary_file" +grep -q "Workspace root" "$prompt_file" +grep -q "Worktree" "$prompt_file" +grep -q "manager" "$map_file" +grep -q "worker-2" "$map_file" +grep -q "inspector" "$map_file" + +same_name_roots=() +for org in org-a org-b; do + repo_dir="$tmpdir/$org/demo" + mkdir -p "$repo_dir/.squad" + git -C "$tmpdir/$org" init -b main demo >/dev/null + git -C "$repo_dir" config user.email "codex@example.com" + git -C "$repo_dir" config user.name "Codex" + echo "seed" >"$repo_dir/README.md" + git -C "$repo_dir" add README.md + git -C "$repo_dir" commit -m "seed" >/dev/null + + cat >"$repo_dir/.squad/launcher.yaml" <<'EOF' +workspace: + worktree: + enabled: true + branch: feat/smoke +EOF + + cat >"$repo_dir/.squad/run-task.md" <<'EOF' +# Task +Minimal task brief +EOF + + output="$(HOME="$tmpdir/home" bash "$launcher" "$repo_dir" --dry-run --no-setup --no-attach)" + same_name_roots+=("$(printf '%s\n' "$output" | awk -F': ' '/^Workspace root: /{print $2; exit}')") + test -f "$repo_dir/.squad/quickstart/feat-smoke/generated-manager.prompt.md" +done + +case "${same_name_roots[0]}" in + "$tmpdir/home/.local/share/squad/worktrees/"*) ;; + *) + echo "Expected worktree path to expand under HOME, got: ${same_name_roots[0]}" >&2 + exit 1 + ;; +esac + +case "${same_name_roots[1]}" in + "$tmpdir/home/.local/share/squad/worktrees/"*) ;; + *) + echo "Expected worktree path to expand under HOME, got: ${same_name_roots[1]}" >&2 + exit 1 + ;; +esac + +test "${same_name_roots[0]}" != "${same_name_roots[1]}" + +tilde_repo="$tmpdir/tilde-repo" +mkdir -p "$tilde_repo/.squad" +git -C "$tmpdir" init -b main tilde-repo >/dev/null +git -C "$tilde_repo" config user.email "codex@example.com" +git -C "$tilde_repo" config user.name "Codex" +echo "tilde" >"$tilde_repo/README.md" +git -C "$tilde_repo" add README.md +git -C "$tilde_repo" commit -m "seed" >/dev/null + +cat >"$tilde_repo/.squad/launcher.yaml" <<'EOF' +runtime: + claude_command: ~/bin/claude + claude_args: + - --dangerously-skip-permissions +EOF + +cat >"$tilde_repo/.squad/run-task.md" <<'EOF' +# Task +Check tilde command expansion +EOF + +HOME="$tmpdir/home" bash "$launcher" "$tilde_repo" --dry-run --no-setup --no-attach >/dev/null +grep -q "$tmpdir/home/bin/claude --dangerously-skip-permissions" "$tilde_repo/.squad/quickstart/generated-run-summary.md" + +echo "PASS: generic launcher dry-run generated expected files"