diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4a9cedc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + lint: + name: shellcheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: shellcheck (uses repo .shellcheckrc) + run: shellcheck bin/claude-team bin/team-session-start install.sh scripts/generate-agents.sh tests/run.sh + + test-linux: + name: tests (linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run test suite + run: bash tests/run.sh + + test-macos: + name: tests (macos) + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - name: Install current bash (macOS ships 3.2, below the Bash 4+ floor) + run: brew list bash >/dev/null 2>&1 || brew install bash + - name: Run test suite + run: | + bash --version | head -1 + bash tests/run.sh diff --git a/.shellcheckrc b/.shellcheckrc index 4d9d2e4..32d7765 100644 --- a/.shellcheckrc +++ b/.shellcheckrc @@ -1,10 +1,6 @@ # ShellCheck configuration for claude-team-cli # https://www.shellcheck.net/wiki/ -# Allow sourcing from paths determined at runtime -# (e.g., sourcing profile files from ~/.claude/team/) -disable=SC1090 - # Allow expressions in single-quoted strings (used in sed patterns) disable=SC2016 diff --git a/README.md b/README.md index 6410ec2..604d94a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ > Your AI development team. Sixteen specialists, one CLI, zero meetings. -![License: MIT](https://img.shields.io/badge/license-MIT-blue) ![Bash 3.2+](https://img.shields.io/badge/bash-3.2%2B-green) ![Works with Claude Code](https://img.shields.io/badge/works%20with-Claude%20Code-8A2BE2) +![License: MIT](https://img.shields.io/badge/license-MIT-blue) ![Bash 4+](https://img.shields.io/badge/bash-4%2B-green) ![Works with Claude Code](https://img.shields.io/badge/works%20with-Claude%20Code-8A2BE2) --- @@ -813,7 +813,7 @@ claude-team-cli/ ## Requirements - macOS or Linux -- Bash 3.2+ +- Bash 4+ (macOS ships Bash 3.2 at `/bin/bash`; install a current Bash with `brew install bash`) - [Claude Code](https://claude.ai/code) --- diff --git a/bin/claude-team b/bin/claude-team index 3cd7cc6..f210589 100755 --- a/bin/claude-team +++ b/bin/claude-team @@ -4,6 +4,14 @@ set -euo pipefail +# Bash 4+ required. macOS ships Bash 3.2 at /bin/bash for licensing reasons; +# a current Bash is one 'brew install bash' away. +if [[ -z "${BASH_VERSINFO:-}" ]] || (( BASH_VERSINFO[0] < 4 )); then + echo "error: claude-team requires Bash 4 or newer (this is ${BASH_VERSION:-not bash})." >&2 + echo "macOS ships Bash 3.2; install a current Bash with: brew install bash" >&2 + exit 1 +fi + PROFILES_DIR="${CLAUDE_TEAM_PROFILES:-$HOME/.claude/team}" CLAUDE_MD="$HOME/.claude/CLAUDE.md" BRANCHES_INDEX="$HOME/.claude/branches/INDEX.md" @@ -17,14 +25,13 @@ COORD_END="" bold() { printf '\033[1m%s\033[0m' "$*"; } green() { printf '\033[32m%s\033[0m' "$*"; } -cyan() { printf '\033[36m%s\033[0m' "$*"; } dim() { printf '\033[2m%s\033[0m' "$*"; } red() { printf '\033[31m%s\033[0m' "$*"; } die() { echo "$(red "error:") $*" >&2; exit 1; } -lowercase() { echo "$1" | tr '[:upper:]' '[:lower:]'; } -capitalize() { echo "$1" | awk '{print toupper(substr($0,1,1)) substr($0,2)}'; } +lowercase() { printf '%s\n' "${1,,}"; } +capitalize() { printf '%s\n' "${1^}"; } require_profiles_dir() { [[ -d "$PROFILES_DIR" ]] || die "Profiles directory not found: $PROFILES_DIR @@ -39,14 +46,65 @@ resolve_name() { echo "$profile" } +# Title parsing. Profile titles use the form "# Name — Role"; both parts +# split at the FIRST em dash so a role may itself contain one. +profile_title() { + grep -m1 '^# ' "$1" | sed 's/^# //' +} + +title_name() { + printf '%s\n' "${1%% —*}" +} + +title_role() { + printf '%s\n' "${1#*— }" +} + get_active() { [[ -f "$CLAUDE_MD" ]] || { echo ""; return; } # Extract the name line from the active block - awk "/$MARKER_START/,/$MARKER_END/" "$CLAUDE_MD" \ - | grep -m1 '^# ' \ - | sed 's/^# //' \ - | sed 's/ —.*//' \ - || echo "" + local title + title=$(awk "/$MARKER_START/,/$MARKER_END/" "$CLAUDE_MD" | grep -m1 '^# ' | sed 's/^# //' || true) + title_name "$title" +} + +# Replace the marked block in a file, or append it if absent. All edits go +# through a temp file and land with mv: a redirection straight onto the +# target would truncate it before the pipeline runs, so any failure could +# destroy the user's file. +block_install() { + local file="$1" start="$2" end="$3" content="$4" + touch "$file" + local tmp + tmp=$(mktemp) + if grep -qF "$start" "$file"; then + awk -v m="$start" 'index($0, m){exit} {print}' "$file" > "$tmp" + printf '%s\n%s\n%s\n' "$start" "$content" "$end" >> "$tmp" + awk -v m="$end" 'found{print} index($0, m){found=1}' "$file" >> "$tmp" + else + cat "$file" > "$tmp" + printf '\n%s\n%s\n%s\n' "$start" "$content" "$end" >> "$tmp" + fi + mv "$tmp" "$file" +} + +# Remove the marked block from a file atomically. Blank lines the removed +# block leaves at end-of-file are trimmed; everything above it, including +# the user's own trailing spaces and leading blank lines, is preserved. +block_remove() { + local file="$1" start="$2" end="$3" + local tmp + tmp=$(mktemp) + awk -v s="$start" -v e="$end" ' + index($0, s){skip=1} + skip && index($0, e){skip=0; next} + !skip{lines[++n]=$0} + END{ + while (n > 0 && lines[n] ~ /^[[:space:]]*$/) n-- + for (i = 1; i <= n; i++) print lines[i] + } + ' "$file" > "$tmp" + mv "$tmp" "$file" } # ─── Commands ──────────────────────────────────────────────────────────────── @@ -65,12 +123,10 @@ cmd_list() { local name name=$(basename "$profile" .md) [[ "$name" == coordinator* ]] && continue # coordinator profiles are not team members - # Extract the title line (first # heading) - local title - title=$(grep -m1 '^# ' "$profile" | sed 's/^# //') - # Extract the role from the title (after " — ") - local role - role=$(echo "$title" | sed 's/.*— //') + # Extract the title line (first # heading) and the role after the dash + local title role + title=$(profile_title "$profile") + role=$(title_role "$title") local name_cap name_cap=$(capitalize "$name") @@ -108,30 +164,12 @@ cmd_use() { local profile profile=$(resolve_name "$name") - # Ensure CLAUDE.md exists - touch "$CLAUDE_MD" - local content content=$(cat "$profile") - - local block - block=$(printf '%s\n%s\n%s' "$MARKER_START" "$content" "$MARKER_END") - - if grep -qF "$MARKER_START" "$CLAUDE_MD"; then - # Replace existing block by splitting around the markers - local tmp - tmp=$(mktemp) - awk "/$MARKER_START/{exit} {print}" "$CLAUDE_MD" > "$tmp" - printf '%s\n%s\n%s\n' "$MARKER_START" "$content" "$MARKER_END" >> "$tmp" - awk "/$MARKER_END/{found=1; next} found{print}" "$CLAUDE_MD" >> "$tmp" - mv "$tmp" "$CLAUDE_MD" - else - # Append block - printf '\n%s\n' "$block" >> "$CLAUDE_MD" - fi + block_install "$CLAUDE_MD" "$MARKER_START" "$MARKER_END" "$content" local display_name - display_name=$(grep -m1 '^# ' "$profile" | sed 's/^# //' | sed 's/ —.*//') + display_name=$(title_name "$(profile_title "$profile")") echo "" echo "$(green "✓") $(bold "$display_name") is now active." @@ -154,14 +192,7 @@ cmd_reset() { local active active=$(get_active) - local tmp - tmp=$(mktemp) - awk "/$MARKER_START/{found=1} found && /$MARKER_END/{found=0; next} !found{print}" \ - "$CLAUDE_MD" > "$tmp" - # Remove trailing blank lines left by removed block - sed -e 's/[[:space:]]*$//' "$tmp" | \ - awk 'NF{found=1} found' > "$CLAUDE_MD" || true - rm -f "$tmp" + block_remove "$CLAUDE_MD" "$MARKER_START" "$MARKER_END" echo "" if [[ -n "$active" ]]; then @@ -210,24 +241,15 @@ _coordinator_install() { [[ -f "$coord_file" ]] || die "Coordinator profile not found: $coord_file Run the installer first: bash install.sh" - touch "$CLAUDE_MD" - local content + local content refreshed=false content=$(cat "$coord_file") + if grep -qF "$COORD_START" "$CLAUDE_MD" 2>/dev/null; then refreshed=true; fi + block_install "$CLAUDE_MD" "$COORD_START" "$COORD_END" "$content" - if grep -qF "$COORD_START" "$CLAUDE_MD"; then - local tmp - tmp=$(mktemp) - awk "/$COORD_START/{exit} {print}" "$CLAUDE_MD" > "$tmp" - printf '%s\n%s\n%s\n' "$COORD_START" "$content" "$COORD_END" >> "$tmp" - awk "/$COORD_END/{found=1; next} found{print}" "$CLAUDE_MD" >> "$tmp" - mv "$tmp" "$CLAUDE_MD" - echo "" + echo "" + if [[ "$refreshed" == true ]]; then echo "$(green "✓") Coordinator $(bold "$mode_label") — profile refreshed." else - local block - block=$(printf '%s\n%s\n%s' "$COORD_START" "$content" "$COORD_END") - printf '\n%s\n' "$block" >> "$CLAUDE_MD" - echo "" echo "$(green "✓") Coordinator $(bold "$mode_label")." echo "$(dim "$mode_desc")" fi @@ -262,13 +284,7 @@ cmd_coordinator() { return fi - local tmp - tmp=$(mktemp) - awk "/$COORD_START/{found=1} found && /$COORD_END/{found=0; next} !found{print}" \ - "$CLAUDE_MD" > "$tmp" - sed -e 's/[[:space:]]*$//' "$tmp" | \ - awk 'NF{found=1} found' > "$CLAUDE_MD" || true - rm -f "$tmp" + block_remove "$CLAUDE_MD" "$COORD_START" "$COORD_END" echo "" echo "$(green "✓") Coordinator $(bold "off")." @@ -339,12 +355,30 @@ ensure_branches_index() { fi } -get_active_branch() { - local project="$1" +# Print one field (by number) from the first active index row for a project. +# Fields: | 2=date | 3=project | 4=branch | 5=plan | 6=status | 7=notes | +# Comparison is exact string equality after trimming, never a regex: project +# and branch names legally contain regex metacharacters ('release/1.2.3'), +# which as patterns cross-match other rows or fail to compile at all. +index_active_field() { + local project="$1" field="$2" [[ -f "$BRANCHES_INDEX" ]] || { echo ""; return; } - awk -F'|' -v proj="$project" \ - '$3 ~ " " proj " " && $6 ~ / active / { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $4); print $4; exit }' \ - "$BRANCHES_INDEX" + awk -F'|' -v proj="$project" -v fld="$field" ' + { + p = $3; st = $6 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", p) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", st) + if (p == proj && st == "active") { + v = $fld + gsub(/^[[:space:]]+|[[:space:]]+$/, "", v) + print v + exit + } + }' "$BRANCHES_INDEX" +} + +get_active_branch() { + index_active_field "$1" 4 } # ─── Branch commands ────────────────────────────────────────────────────────── @@ -401,16 +435,20 @@ cmd_branch_close() { local tmp tmp=$(mktemp) - awk -F'|' -v proj="$project" -v ns=" $new_status " ' - $3 ~ " " proj " " && $6 ~ / active / { - d=$2; sub(/^[[:space:]]+/,"",d); sub(/[[:space:]]+$/,"",d) - p=$3; sub(/^[[:space:]]+/,"",p); sub(/[[:space:]]+$/,"",p) - b=$4; sub(/^[[:space:]]+/,"",b); sub(/[[:space:]]+$/,"",b) - s=$5; sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s) - printf "| %s | %s | %s | %s |%s| |\n", d, p, b, s, ns - next + awk -F'|' -v proj="$project" -v ns="$new_status" ' + { + p = $3; st = $6 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", p) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", st) + if (p == proj && st == "active") { + d=$2; sub(/^[[:space:]]+/,"",d); sub(/[[:space:]]+$/,"",d) + b=$4; sub(/^[[:space:]]+/,"",b); sub(/[[:space:]]+$/,"",b) + s=$5; sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s) + printf "| %s | %s | %s | %s | %s | |\n", d, p, b, s, ns + next + } + print } - { print } ' "$BRANCHES_INDEX" > "$tmp" && mv "$tmp" "$BRANCHES_INDEX" echo "" @@ -442,13 +480,9 @@ cmd_branch_status() { if [[ -n "$active" ]]; then echo "$(bold "Active branch:") $(green "$active") $(dim "($project)")" # Show linked plan if any - if [[ -f "$BRANCHES_INDEX" ]]; then - local plan_slug - plan_slug=$(awk -F'|' -v proj="$project" \ - '$3 ~ " " proj " " && $6 ~ / active / { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $5); print $5; exit }' \ - "$BRANCHES_INDEX") - [[ -n "$plan_slug" ]] && echo "$(dim "Linked plan:") $plan_slug" - fi + local plan_slug + plan_slug=$(index_active_field "$project" 5) + [[ -n "$plan_slug" ]] && echo "$(dim "Linked plan:") $plan_slug" else echo "$(bold "Active branch:") $(red "none")" echo "$(dim " No branch registered for '$project'.")" @@ -782,15 +816,19 @@ Run this from inside a session worktree, or use 'claude-team branch done' for no local tmp tmp=$(mktemp) awk -F'|' -v proj="$project" -v br="$branch" ' - $3 ~ " " proj " " && $4 ~ " " br " " && $6 ~ / active / { - d=$2; sub(/^[[:space:]]+/,"",d); sub(/[[:space:]]+$/,"",d) - p=$3; sub(/^[[:space:]]+/,"",p); sub(/[[:space:]]+$/,"",p) - b=$4; sub(/^[[:space:]]+/,"",b); sub(/[[:space:]]+$/,"",b) - s=$5; sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s) - printf "| %s | %s | %s | %s | merged | worktree |\n", d, p, b, s - next + { + p = $3; b = $4; st = $6 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", p) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", b) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", st) + if (p == proj && b == br && st == "active") { + d=$2; sub(/^[[:space:]]+/,"",d); sub(/[[:space:]]+$/,"",d) + s=$5; sub(/^[[:space:]]+/,"",s); sub(/[[:space:]]+$/,"",s) + printf "| %s | %s | %s | %s | merged | worktree |\n", d, p, b, s + next + } + print } - { print } ' "$BRANCHES_INDEX" > "$tmp" && mv "$tmp" "$BRANCHES_INDEX" # Remove worktree (must be called from outside the worktree) @@ -816,21 +854,31 @@ cmd_session_list() { echo "" return fi - local found=0 - while IFS= read -r line; do - if echo "$line" | grep -q '| active |'; then - local project branch worktree_path - project=$(echo "$line" | awk -F'|' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$3); print $3}') - branch=$(echo "$line" | awk -F'|' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$4); print $4}') - worktree_path=$(get_worktree_path "$project" "$branch") - if [[ -d "$worktree_path" ]]; then - printf ' %-25s %-40s %s\n' "$(bold "$project")" "$branch" "$(dim "$worktree_path")" - else - printf ' %-25s %-40s %s\n' "$(bold "$project")" "$branch" "$(dim "(no worktree)")" - fi - found=1 + # One awk pass extracts all active rows as tab-separated project/branch + # pairs; the loop then only formats, with no per-line subshells. + local rows + rows=$(awk -F'|' ' + { + st = $6 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", st) + if (st == "active") { + p = $3; b = $4 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", p) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", b) + print p "\t" b + } + }' "$BRANCHES_INDEX") + local found=0 project branch worktree_path + while IFS=$'\t' read -r project branch; do + [[ -n "$project" ]] || continue + worktree_path=$(get_worktree_path "$project" "$branch") + if [[ -d "$worktree_path" ]]; then + printf ' %-25s %-40s %s\n' "$(bold "$project")" "$branch" "$(dim "$worktree_path")" + else + printf ' %-25s %-40s %s\n' "$(bold "$project")" "$branch" "$(dim "(no worktree)")" fi - done < "$BRANCHES_INDEX" + found=1 + done <<< "$rows" [[ $found -eq 0 ]] && echo " $(dim "(none)")" echo "" } diff --git a/bin/team-session-start b/bin/team-session-start index be065eb..b537a3e 100755 --- a/bin/team-session-start +++ b/bin/team-session-start @@ -32,9 +32,15 @@ else project=$(basename "$root") active="" if [[ -f "$INDEX" ]]; then - active=$(awk -F'|' -v proj="$project" \ - '$3 ~ " " proj " " && $6 ~ / active / { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $4); print $4; exit }' \ - "$INDEX") + # Exact match on the trimmed project field, never a regex: project names + # legally contain regex metacharacters (mirrors bin/claude-team). + active=$(awk -F'|' -v proj="$project" ' + { + p = $3; st = $6 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", p) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", st) + if (p == proj && st == "active") { b = $4; gsub(/^[[:space:]]+|[[:space:]]+$/, "", b); print b; exit } + }' "$INDEX") fi if [[ -n "$active" ]]; then echo "Claude Team session context: project '${project}' has registered active branch '${active}' (from ~/.claude/branches/INDEX.md). All work this session goes on that branch." diff --git a/install.sh b/install.sh index cfaf5f6..6343497 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,15 @@ set -euo pipefail +# Bash 4+ required. macOS ships Bash 3.2 at /bin/bash for licensing reasons; +# a current Bash is one 'brew install bash' away. +if [[ -z "${BASH_VERSINFO:-}" ]] || (( BASH_VERSINFO[0] < 4 )); then + echo "error: install.sh requires Bash 4 or newer (this is ${BASH_VERSION:-not bash})." >&2 + echo "macOS ships Bash 3.2; install a current Bash with: brew install bash" >&2 + echo "Then run: bash install.sh" >&2 + exit 1 +fi + REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROFILES_SRC="$REPO_DIR/profiles" PROFILES_DST="$HOME/.claude/team" @@ -112,40 +121,18 @@ printf " Enable the coordinator now? [casual/prod/n] (default: casual) " read -r coord_answer coord_answer="${coord_answer:-casual}" -CLAUDE_MD="$HOME/.claude/CLAUDE.md" -COORD_START="" -COORD_END="" - -_install_coord() { - local coord_file="$1" - local label="$2" - local content - content=$(cat "$coord_file") - local block - block=$(printf '%s\n%s\n%s' "$COORD_START" "$content" "$COORD_END") - if grep -qF "$COORD_START" "$CLAUDE_MD"; then - local tmp - tmp=$(mktemp) - awk "/$COORD_START/{exit} {print}" "$CLAUDE_MD" > "$tmp" - printf '%s\n%s\n%s\n' "$COORD_START" "$content" "$COORD_END" >> "$tmp" - awk "/$COORD_END/{found=1; next} found{print}" "$CLAUDE_MD" >> "$tmp" - mv "$tmp" "$CLAUDE_MD" - else - printf '\n%s\n' "$block" >> "$CLAUDE_MD" - fi - echo " $(green "✓") Coordinator enabled ($label mode)." -} - -touch "$CLAUDE_MD" +# Delegate to the CLI just installed: it owns the marker-block editing +# (atomic replace-or-append), so the logic lives in exactly one place and +# this path is the same one the test suite exercises. case "$(echo "$coord_answer" | tr '[:upper:]' '[:lower:]')" in prod) - _install_coord "$PROFILES_DST/coordinator-prod.md" "prod" + "$BIN_SRC" coordinator prod ;; n|no) echo " $(dim "Skipped. Enable later with: claude-team coordinator on")" ;; *) - _install_coord "$PROFILES_DST/coordinator.md" "casual" + "$BIN_SRC" coordinator on ;; esac diff --git a/scripts/generate-agents.sh b/scripts/generate-agents.sh index 1415947..42561ff 100755 --- a/scripts/generate-agents.sh +++ b/scripts/generate-agents.sh @@ -26,9 +26,11 @@ for profile in "$PROFILES"/*.md; do name=$(basename "$profile" .md) case "$name" in coordinator*) continue ;; esac + # Split "Name — Role" at the FIRST em dash (mirrors bin/claude-team), so a + # role may itself contain one without being truncated. title=$(grep -m1 '^# ' "$profile" | sed 's/^# //') - role=$(echo "$title" | sed 's/.*— //') - display=$(echo "$title" | sed 's/ —.*//') + role="${title#*— }" + display="${title%% —*}" model=$(awk -v p="$name" '$1 == p { print $2; exit }' "$TIERS" 2>/dev/null || echo "") out="$AGENTS/$name.md" diff --git a/tests/run.sh b/tests/run.sh index a66579e..f3c6d15 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -8,6 +8,13 @@ set -uo pipefail +# Bash 4+ required, same floor as the CLI under test. +if [[ -z "${BASH_VERSINFO:-}" ]] || (( BASH_VERSINFO[0] < 4 )); then + echo "error: tests/run.sh requires Bash 4 or newer (this is ${BASH_VERSION:-not bash})." >&2 + echo "macOS ships Bash 3.2; install a current Bash with: brew install bash" >&2 + exit 1 +fi + REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CLI="$REPO_DIR/bin/claude-team" PROFILES_DIR="$REPO_DIR/profiles" @@ -34,15 +41,17 @@ run_cmd() { ok() { PASS=$((PASS + 1)); printf " \033[32m✓\033[0m %s\n" "$1"; } fail() { FAIL=$((FAIL + 1)); ERRORS+=("$1"); printf " \033[31m✗\033[0m %s\n" "$1"; } +# Patterns are POSIX EREs (grep -E): alternation is plain '|', and BRE-only +# GNU extensions like '\|' must not be used (BSD grep rejects them). assert_contains() { local name="$1" pattern="$2" output="$3" - if echo "$output" | grep -q "$pattern"; then ok "$name" + if grep -Eq "$pattern" <<< "$output"; then ok "$name" else fail "$name (expected: '$pattern')"; fi } assert_not_contains() { local name="$1" pattern="$2" output="$3" - if ! echo "$output" | grep -q "$pattern"; then ok "$name" + if ! grep -Eq "$pattern" <<< "$output"; then ok "$name" else fail "$name (must not contain: '$pattern')"; fi } @@ -217,6 +226,72 @@ assert_exits_nonzero "use without name exits nonzero" "$CLI" use assert_exits_nonzero "show without name exits nonzero" "$CLI" show echo "" +# content preservation: use/reset and coordinator on/off must round-trip the +# user's own CLAUDE.md byte-for-byte (leading blanks and trailing spaces +# included), and must not leave a stray trailing blank line behind. +echo "content preservation on block removal" + +FIXTURE="$TEST_HOME/claude-md-fixture" +printf '\n# My Notes\n\nline with trailing spaces \nlast line\n' > "$FIXTURE" + +cp "$FIXTURE" "$CLAUDE_MD" +run_cmd use robin >/dev/null +run_cmd reset >/dev/null +if cmp -s "$CLAUDE_MD" "$FIXTURE"; then ok "use + reset round-trips user content byte-for-byte" +else fail "use + reset round-trips user content byte-for-byte"; fi + +cp "$FIXTURE" "$CLAUDE_MD" +run_cmd coordinator on >/dev/null +run_cmd coordinator off >/dev/null +if cmp -s "$CLAUDE_MD" "$FIXTURE"; then ok "coordinator on + off round-trips user content byte-for-byte" +else fail "coordinator on + off round-trips user content byte-for-byte"; fi +echo "" + +# branch index lookups must treat project and branch names as literal strings, +# not regexes: dots must not cross-match similarly named projects, and regex +# metacharacters like parentheses must not crash the lookup. +echo "branch index metachar regression" + +META_BASE=$(mktemp -d) +mkdir -p "$META_BASE/my.app" "$META_BASE/myXapp" "$META_BASE/app(1)" +git init -q "$META_BASE/my.app" 2>/dev/null +git init -q "$META_BASE/myXapp" 2>/dev/null +git init -q "$META_BASE/app(1)" 2>/dev/null + +(cd "$META_BASE/myXapp" && run_cmd branch start release/9.9.9 >/dev/null) +out=$(cd "$META_BASE/my.app" && run_cmd branch status 2>&1) +assert_contains "dotted project does not cross-match similar project" "none|No branch|no active|No active" "$out" +assert_not_contains "dotted project does not see other project's branch" "release/9\.9\.9" "$out" + +(cd "$META_BASE/my.app" && run_cmd branch start release/1.2.3 >/dev/null) +out=$(cd "$META_BASE/my.app" && run_cmd branch status) +assert_contains "dotted branch name registers and resolves" "release/1\.2\.3" "$out" + +if (cd "$META_BASE/app(1)" && run_cmd branch start feat/x >/dev/null 2>&1); then + ok "paren project name does not crash branch start" +else + fail "paren project name does not crash branch start" +fi +out=$(cd "$META_BASE/app(1)" && run_cmd branch status 2>&1) +assert_contains "paren project name resolves its branch" "feat/x" "$out" + +(cd "$META_BASE/my.app" && run_cmd branch "done" >/dev/null 2>&1) +(cd "$META_BASE/myXapp" && run_cmd branch "done" >/dev/null 2>&1) +(cd "$META_BASE/app(1)" && run_cmd branch abandon >/dev/null 2>&1) +rm -rf "$META_BASE" +echo "" + +# title parsing: "Name — Role" splits at the FIRST em dash, so a role may +# itself contain one without being truncated. +echo "title parsing" +TITLE_DIR=$(mktemp -d) +cp "$PROFILES_DIR/robin.md" "$TITLE_DIR/robin.md" +printf '# Testy — Role One — Extended\n\nBody.\n' > "$TITLE_DIR/testy.md" +out=$(CLAUDE_TEAM_PROFILES="$TITLE_DIR" HOME="$TEST_HOME" "$CLI" list) +assert_contains "multi-dash title keeps the full role" "Role One — Extended" "$out" +rm -rf "$TITLE_DIR" +echo "" + # branch commands echo "branch commands" @@ -224,7 +299,7 @@ BRANCHES_INDEX="$TEST_HOME/.claude/branches/INDEX.md" # status warns when no index exists out=$(run_cmd branch status 2>&1) -assert_contains "branch status warns when no active branch" "none\|No branch\|no active\|No active" "$out" +assert_contains "branch status warns when no active branch" "none|No branch|no active|No active" "$out" # start registers a branch run_cmd branch start feat/test-branch >/dev/null @@ -241,13 +316,13 @@ assert_contains "branch status shows active branch" "feat/test-branch" "$out assert_exits_nonzero "branch start blocked when active exists" "$CLI" branch start feat/duplicate # done marks merged -out=$(run_cmd branch done 2>&1) +out=$(run_cmd branch "done" 2>&1) assert_contains "branch done output mentions merged" "merged" "$out" assert_file_has "branch done updates status in index" "$BRANCHES_INDEX" "merged" # status after done shows none out=$(run_cmd branch status 2>&1) -assert_contains "branch status after done shows none" "none\|No branch\|no active\|No active" "$out" +assert_contains "branch status after done shows none" "none|No branch|no active|No active" "$out" # start with --plan links a plan slug run_cmd branch start feat/with-plan --plan some-plan-slug >/dev/null @@ -290,7 +365,7 @@ SESSION_INDEX="$TEST_HOME/.claude/branches/INDEX.md" # session status warns when no session marker out=$(cd "$SESSION_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session status 2>&1) -assert_contains "session status warns when no session" "none\|No.*session\|not in\|Not in" "$out" +assert_contains "session status warns when no session" "none|No.*session|not in|Not in" "$out" # session start creates worktree directory (cd "$SESSION_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start feat/session-test >/dev/null 2>&1) @@ -319,7 +394,7 @@ assert_file_has "session start writes active status" "$SESSION_INDEX" "active" # session start blocked if branch already active out=$(cd "$SESSION_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start feat/session-test 2>&1) -assert_contains "session start blocked if already active" "already\|active" "$out" +assert_contains "session start blocked if already active" "already|active" "$out" # session status reads marker from worktree out=$(cd "$SESSION_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session status 2>&1) @@ -330,7 +405,7 @@ out=$(cd "$SESSION_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOM assert_contains "session list shows active branch" "feat/session-test" "$out" # session done marks merged in INDEX.md and removes worktree -(cd "$SESSION_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session done >/dev/null 2>&1) +(cd "$SESSION_WT" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" >/dev/null 2>&1) assert_file_has "session done marks merged in index" "$SESSION_INDEX" "merged" if [[ ! -d "$SESSION_WT" ]]; then ok "session done removes worktree directory"; else fail "session done removes worktree directory"; fi @@ -339,6 +414,15 @@ if [[ ! -d "$SESSION_WT" ]]; then ok "session done removes worktree directory"; SESSION_WT2="$SESSION_WORKTREES/$(basename "$SESSION_REPO")/feat-session-plan" assert_file_has "session start --plan writes slug to marker" "$SESSION_WT2/.claude-session" "^plan_slug=my-plan-slug" +# session done must handle branch names containing regex metacharacters: +# the index rewrite matches them as literal strings, not patterns +(cd "$SESSION_REPO" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session start 'feat/(v1.2.3)' >/dev/null 2>&1) +SESSION_WT3="$SESSION_WORKTREES/$(basename "$SESSION_REPO")/feat-(v1.2.3)" +if [[ -d "$SESSION_WT3" ]]; then ok "session start handles metachar branch name"; else fail "session start handles metachar branch name"; fi +(cd "$SESSION_WT3" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$TEST_HOME" "$CLI" session "done" >/dev/null 2>&1) +assert_file_has "session done marks metachar branch merged" "$SESSION_INDEX" "feat/(v1\.2\.3).*merged" +if [[ ! -d "$SESSION_WT3" ]]; then ok "session done removes metachar worktree"; else fail "session done removes metachar worktree"; fi + rm -rf "$SESSION_REPO" echo "" @@ -380,7 +464,9 @@ if command -v python3 >/dev/null 2>&1; then fi fi -agent_count=$(ls "$REPO_DIR/agents"/*.md 2>/dev/null | wc -l | tr -d ' ') +agent_files=("$REPO_DIR"/agents/*.md) +agent_count=${#agent_files[@]} +[[ -e "${agent_files[0]}" ]] || agent_count=0 if [[ "$agent_count" == "16" ]]; then ok "16 persona subagents generated"; else fail "16 persona subagents generated (got $agent_count)"; fi assert_contains "akira agent carries model tier" "model: claude-fable-5" "$(cat "$REPO_DIR/agents/akira.md")" assert_contains "agents marked as generated" "GENERATED from profiles" "$(cat "$REPO_DIR/agents/robin.md")" @@ -404,6 +490,37 @@ rm -rf "$HOOK_REPO" echo "" +# install.sh end to end into an isolated HOME: files land where the CLI +# expects them, and the coordinator prompt delegates to the CLI code path +echo "install.sh" + +INSTALL_HOME=$(mktemp -d) +if (cd "$REPO_DIR" && echo "n" | HOME="$INSTALL_HOME" bash install.sh >/dev/null 2>&1); then + ok "install.sh runs clean with coordinator skipped" +else + fail "install.sh runs clean with coordinator skipped" +fi +if [[ -f "$INSTALL_HOME/.claude/team/robin.md" ]]; then ok "install.sh installs profiles"; else fail "install.sh installs profiles"; fi +if [[ -f "$INSTALL_HOME/.claude/team/tiers.conf" ]]; then ok "install.sh installs tiers.conf"; else fail "install.sh installs tiers.conf"; fi +if [[ -f "$INSTALL_HOME/.claude/commands/robin.md" ]]; then ok "install.sh installs slash commands"; else fail "install.sh installs slash commands"; fi +if [[ -f "$INSTALL_HOME/.claude/agents/robin.md" ]]; then ok "install.sh installs subagents"; else fail "install.sh installs subagents"; fi +if [[ -L "$INSTALL_HOME/.local/bin/claude-team" ]]; then ok "install.sh symlinks the CLI"; else fail "install.sh symlinks the CLI"; fi +if ! grep -qF "CLAUDE-COORDINATOR:START" "$INSTALL_HOME/.claude/CLAUDE.md" 2>/dev/null; then + ok "install.sh skips coordinator when told no" +else + fail "install.sh skips coordinator when told no" +fi + +if (cd "$REPO_DIR" && echo "prod" | HOME="$INSTALL_HOME" bash install.sh >/dev/null 2>&1); then + ok "install.sh runs clean with coordinator prod" +else + fail "install.sh runs clean with coordinator prod" +fi +assert_file_has "install.sh prod answer installs prod coordinator" "$INSTALL_HOME/.claude/CLAUDE.md" "CLAUDE-COORD-MODE: prod" +rm -rf "$INSTALL_HOME" + +echo "" + # ─── Summary ───────────────────────────────────────────────────────────────── echo "────────────────────────────────"