Files
2026-06-02 20:26:44 +08:00

725 lines
22 KiB
Bash

#!/usr/bin/env bash
# auto-tmux: safe tmux automation wrapper for Codex/AI operator workflows.
set -euo pipefail
PROG="${0##*/}"
DEFAULT_LINES=120
DEFAULT_RESCUE_PATTERN='(\(y/n\)|\[y/N\]|\[Y/n\]|continue\?|proceed\?|confirm|确认|是否继续)'
DANGEROUS_PATTERN='(^|[[:space:];|&])(rm[[:space:]]+-rf|mkfs|dd[[:space:]]+if=|shutdown|reboot|halt|poweroff|tmux[[:space:]]+kill-server|git[[:space:]]+reset[[:space:]]+--hard|git[[:space:]]+clean[[:space:]]+-fd|DROP[[:space:]]+(DATABASE|TABLE))'
usage() {
cat <<'EOF'
auto-tmux: safe tmux automation wrapper
Usage:
auto-tmux.sh help
auto-tmux.sh doctor [--session NAME]
auto-tmux.sh topology [--session NAME]
auto-tmux.sh inspect -t TARGET [-n LINES] [--no-redact]
auto-tmux.sh capture -t TARGET [-n LINES] [--save FILE] [--no-redact]
auto-tmux.sh send -t TARGET (--text TEXT | --key KEY) [--enter] [--clear] [--force] [--dry-run]
auto-tmux.sh paste -t TARGET --file FILE [--enter] [--clear] [--force] [--dry-run]
auto-tmux.sh broadcast --session NAME (--text TEXT | --key KEY) [--enter] [--clear] [--force] [--dry-run]
auto-tmux.sh rescue -t TARGET [--pattern REGEX] [--reply TEXT] [-n LINES] [--force] [--dry-run]
auto-tmux.sh scan [--session NAME] [-n LINES] [--pattern REGEX] [--rescue] [--reply TEXT] [--save-dir DIR]
auto-tmux.sh record start -t TARGET [--dir DIR]
auto-tmux.sh record stop -t TARGET
auto-tmux.sh snapshot [--session NAME] [--dir DIR] [-n LINES]
auto-tmux.sh hub [--session NAME] [--workers N] [--cmd CMD] [--commander-cmd CMD] [--attach]
auto-tmux.sh cleanup --session NAME [--dry-run] [--force]
auto-tmux.sh wait -t TARGET --pattern REGEX [--timeout SEC] [--interval SEC] [-n LINES]
Target format:
<session>:<window>.<pane>
Examples:
auto-tmux.sh doctor --session ai-hub
auto-tmux.sh topology
auto-tmux.sh inspect -t <target-from-topology> -n 40
auto-tmux.sh capture -t <target-from-topology> -n 80
auto-tmux.sh send -t <target-from-topology> --text "make test" --enter
auto-tmux.sh paste -t <target-from-topology> --file /tmp/prompt.md --enter --dry-run
auto-tmux.sh broadcast --session ai-hub --text "pwd" --enter --dry-run
auto-tmux.sh rescue -t <target-from-topology> --pattern "(y/n)" --reply y
auto-tmux.sh scan --session ai-hub --pattern "ERROR|Traceback"
auto-tmux.sh snapshot --session ai-hub --dir /tmp/auto-tmux-snapshot
auto-tmux.sh hub --session ai-hub --workers 3 --cmd "codex"
auto-tmux.sh cleanup --session ai-hub --dry-run
EOF
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
warn() {
printf 'warn: %s\n' "$*" >&2
}
status_line() {
local status="$1"
local message="$2"
printf '[%s] %s\n' "$status" "$message"
}
require_tmux() {
command -v tmux >/dev/null 2>&1 || die "tmux is not installed or not in PATH"
tmux list-sessions >/dev/null 2>&1 || die "no tmux server/session found"
}
target_exists() {
local target="$1"
tmux display-message -pt "$target" '#{pane_id}' >/dev/null 2>&1
}
require_target() {
local target="$1"
[[ -n "$target" ]] || die "missing -t TARGET"
target_exists "$target" || die "tmux target not found: $target"
}
redact() {
sed -E \
-e 's/(OPENAI_API_KEY|ANTHROPIC_API_KEY|GITHUB_TOKEN|GH_TOKEN|API_KEY|TOKEN|PASSWORD|SECRET)=([^[:space:]]+)/\1=<redacted>/g' \
-e 's/(Bearer[[:space:]]+)[A-Za-z0-9._~+\/=-]+/\1<redacted>/g' \
-e 's/(sk-[A-Za-z0-9_-]{12,})/<redacted-openai-key>/g'
}
capture_raw() {
local target="$1"
local lines="$2"
tmux capture-pane -t "$target" -p -S "-$lines"
}
capture_print() {
local target="$1"
local lines="$2"
local no_redact="$3"
if [[ "$no_redact" == "1" ]]; then
capture_raw "$target" "$lines"
else
capture_raw "$target" "$lines" | redact
fi
}
is_dangerous_text() {
local text="$1"
echo "$text" | grep -Eiq "$DANGEROUS_PATTERN"
}
cancel_copy_mode_if_needed() {
local target="$1"
local in_mode
in_mode="$(tmux display-message -pt "$target" '#{pane_in_mode}' 2>/dev/null || echo 0)"
if [[ "$in_mode" == "1" ]]; then
tmux send-keys -t "$target" -X cancel
fi
}
pane_list() {
local session="$1"
if [[ -n "$session" ]]; then
tmux list-panes -a -F '#S:#I.#P' | awk -F: -v s="$session" '$1 == s {print}'
else
tmux list-panes -a -F '#S:#I.#P'
fi
}
cmd_doctor() {
local session=""
while [[ $# -gt 0 ]]; do
case "$1" in
--session) session="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown doctor option: $1" ;;
esac
done
local failures=0
local script_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if command -v tmux >/dev/null 2>&1; then
status_line "OK" "tmux found: $(tmux -V)"
else
status_line "FAIL" "tmux not found in PATH"
failures=$((failures + 1))
fi
if tmux list-sessions >/dev/null 2>&1; then
status_line "OK" "tmux server is reachable"
else
status_line "WARN" "tmux server has no reachable session yet"
fi
for script in auto-tmux.sh swarm-state.sh swarm-brief.sh swarm-watch.sh swarm-archive.sh swarm-board.sh swarm-deps-graph.sh swarm-export.sh swarm-timeline.sh swarm-blockers.sh swarm-results.sh swarm-report-pack.sh swarm-assign.sh swarm-health.sh remote-readonly.sh record-summary.sh check-jsonl.sh review-checklist.sh verify-report-pack.sh incident-report.sh safety-check.sh render-swarm-prompt.sh swarm-dispatch.sh auto-tmux-smoke-test.sh validate-auto-tmux.sh; do
if [[ -x "$script_dir/$script" ]]; then
status_line "OK" "script executable: $script"
else
status_line "FAIL" "script missing or not executable: $script"
failures=$((failures + 1))
fi
done
local root
root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -n "$root" ]]; then
for rel in skills/auto-tmux/assets/oh-my-tmux skills/auto-tmux/assets/tmux-src; do
if [[ -e "$root/$rel" ]]; then
status_line "OK" "asset exists: $rel"
else
status_line "WARN" "asset missing: $rel (run git submodule update --init --recursive)"
fi
done
else
status_line "WARN" "not inside a git worktree; skipped asset checks"
fi
if [[ -n "$session" ]]; then
if tmux has-session -t "$session" 2>/dev/null; then
status_line "OK" "session exists: $session"
tmux list-panes -t "$session" -F ' pane #S:#{window_index}.#{pane_index} cmd=#{pane_current_command}'
else
status_line "FAIL" "session not found: $session"
failures=$((failures + 1))
fi
fi
if (( failures > 0 )); then
return 1
fi
}
cmd_topology() {
local session=""
while [[ $# -gt 0 ]]; do
case "$1" in
--session) session="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown topology option: $1" ;;
esac
done
require_tmux
if [[ -n "$session" ]]; then
tmux has-session -t "$session" 2>/dev/null || die "session not found: $session"
printf 'session: %s\n' "$session"
tmux list-windows -t "$session" -F ' window #{window_index}: #{window_name} #{window_flags}'
tmux list-panes -a -F '#S #S:#{window_index}.#{pane_index} #{pane_current_command} #{pane_current_path}' |
awk -v s="$session" '$1 == s {print " pane target=" $2 " cmd=" $3 " path=" $4}'
else
tmux list-sessions -F 'session: #{session_name} windows=#{session_windows} attached=#{session_attached}'
tmux list-windows -a -F ' window #S:#{window_index}: #{window_name} #{window_flags}'
tmux list-panes -a -F ' pane #S:#{window_index}.#{pane_index} cmd=#{pane_current_command} path=#{pane_current_path}'
fi
}
cmd_inspect() {
local target=""
local lines=40
local no_redact="0"
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) target="${2:-}"; shift 2 ;;
-n|--lines) lines="${2:-}"; shift 2 ;;
--no-redact) no_redact="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown inspect option: $1" ;;
esac
done
require_tmux
require_target "$target"
printf '# pane inspect\n\n'
tmux display-message -pt "$target" 'target=#{session_name}:#{window_index}.#{pane_index}'
tmux display-message -pt "$target" 'pane_id=#{pane_id}'
tmux display-message -pt "$target" 'window=#{window_name}'
tmux display-message -pt "$target" 'command=#{pane_current_command}'
tmux display-message -pt "$target" 'pid=#{pane_pid}'
tmux display-message -pt "$target" 'path=#{pane_current_path}'
tmux display-message -pt "$target" 'title=#{pane_title}'
tmux display-message -pt "$target" 'in_mode=#{pane_in_mode}'
tmux display-message -pt "$target" 'size=#{pane_width}x#{pane_height}'
printf '\n## recent output\n\n'
capture_print "$target" "$lines" "$no_redact"
}
cmd_broadcast() {
local session=""
local text=""
local key=""
local enter="0"
local clear="0"
local force="0"
local dry_run="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--session) session="${2:-}"; shift 2 ;;
--text) text="${2:-}"; shift 2 ;;
--key) key="${2:-}"; shift 2 ;;
--enter) enter="1"; shift ;;
--clear) clear="1"; shift ;;
--force) force="1"; shift ;;
--dry-run) dry_run="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown broadcast option: $1" ;;
esac
done
require_tmux
[[ -n "$session" ]] || die "broadcast requires --session NAME"
tmux has-session -t "$session" 2>/dev/null || die "session not found: $session"
[[ -n "$text" || -n "$key" ]] || die "broadcast requires --text TEXT or --key KEY"
[[ -z "$text" || -z "$key" ]] || die "broadcast accepts only one of --text or --key"
if [[ -n "$text" && "$force" != "1" ]] && is_dangerous_text "$text"; then
die "refusing dangerous broadcast text without --force: $text"
fi
local panes=()
mapfile -t panes < <(pane_list "$session")
[[ "${#panes[@]}" -gt 0 ]] || die "no panes found in session: $session"
printf 'broadcast targets (%s):\n' "${#panes[@]}"
printf ' %s\n' "${panes[@]}"
local pane
for pane in "${panes[@]}"; do
if [[ "$dry_run" == "1" ]]; then
printf '[dry-run] would send to %s\n' "$pane"
continue
fi
if [[ -n "$text" ]]; then
local args=(-t "$pane" --text "$text")
[[ "$enter" == "1" ]] && args+=(--enter)
[[ "$clear" == "1" ]] && args+=(--clear)
[[ "$force" == "1" ]] && args+=(--force)
cmd_send "${args[@]}"
else
local args=(-t "$pane" --key "$key")
[[ "$enter" == "1" ]] && args+=(--enter)
[[ "$clear" == "1" ]] && args+=(--clear)
[[ "$force" == "1" ]] && args+=(--force)
cmd_send "${args[@]}"
fi
done
}
cmd_capture() {
local target=""
local lines="$DEFAULT_LINES"
local save_file=""
local no_redact="0"
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) target="${2:-}"; shift 2 ;;
-n|--lines) lines="${2:-}"; shift 2 ;;
--save) save_file="${2:-}"; shift 2 ;;
--no-redact) no_redact="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown capture option: $1" ;;
esac
done
require_tmux
require_target "$target"
if [[ -n "$save_file" ]]; then
mkdir -p "$(dirname "$save_file")"
capture_print "$target" "$lines" "$no_redact" | tee "$save_file"
else
capture_print "$target" "$lines" "$no_redact"
fi
}
cmd_send() {
local target=""
local text=""
local key=""
local enter="0"
local clear="0"
local force="0"
local dry_run="0"
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) target="${2:-}"; shift 2 ;;
--text) text="${2:-}"; shift 2 ;;
--key) key="${2:-}"; shift 2 ;;
--enter) enter="1"; shift ;;
--clear) clear="1"; shift ;;
--force) force="1"; shift ;;
--dry-run) dry_run="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown send option: $1" ;;
esac
done
require_tmux
require_target "$target"
[[ -n "$text" || -n "$key" ]] || die "send requires --text TEXT or --key KEY"
[[ -z "$text" || -z "$key" ]] || die "send accepts only one of --text or --key"
printf -- '--- current context: %s ---\n' "$target" >&2
capture_print "$target" 40 0 >&2 || true
if [[ -n "$text" && "$force" != "1" ]] && is_dangerous_text "$text"; then
die "refusing dangerous text without --force: $text"
fi
if [[ "$dry_run" == "1" ]]; then
printf '[dry-run] target=%s clear=%s text=%q key=%q enter=%s\n' "$target" "$clear" "$text" "$key" "$enter"
return 0
fi
cancel_copy_mode_if_needed "$target"
if [[ "$clear" == "1" ]]; then
tmux send-keys -t "$target" C-u
fi
if [[ -n "$text" ]]; then
tmux send-keys -t "$target" -l "$text"
else
tmux send-keys -t "$target" "$key"
fi
if [[ "$enter" == "1" ]]; then
tmux send-keys -t "$target" Enter
fi
}
cmd_paste() {
local target=""
local file=""
local enter="0"
local clear="0"
local force="0"
local dry_run="0"
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) target="${2:-}"; shift 2 ;;
--file) file="${2:-}"; shift 2 ;;
--enter) enter="1"; shift ;;
--clear) clear="1"; shift ;;
--force) force="1"; shift ;;
--dry-run) dry_run="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown paste option: $1" ;;
esac
done
require_tmux
require_target "$target"
[[ -n "$file" ]] || die "paste requires --file FILE"
[[ -f "$file" ]] || die "paste file not found: $file"
printf -- '--- current context: %s ---\n' "$target" >&2
capture_print "$target" 40 0 >&2 || true
if [[ "$force" != "1" ]] && is_dangerous_text "$(cat "$file")"; then
die "refusing dangerous paste file without --force: $file"
fi
if [[ "$dry_run" == "1" ]]; then
printf '[dry-run] target=%s clear=%s file=%s bytes=%s enter=%s\n' \
"$target" "$clear" "$file" "$(wc -c < "$file" | tr -d ' ')" "$enter"
return 0
fi
cancel_copy_mode_if_needed "$target"
if [[ "$clear" == "1" ]]; then
tmux send-keys -t "$target" C-u
fi
local buffer_name="auto-tmux-paste-$$"
tmux load-buffer -b "$buffer_name" "$file"
tmux paste-buffer -t "$target" -b "$buffer_name" -d
if [[ "$enter" == "1" ]]; then
tmux send-keys -t "$target" Enter
fi
}
cmd_rescue() {
local target=""
local pattern="$DEFAULT_RESCUE_PATTERN"
local reply="y"
local lines=80
local force="0"
local dry_run="0"
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) target="${2:-}"; shift 2 ;;
--pattern) pattern="${2:-}"; shift 2 ;;
--reply) reply="${2:-}"; shift 2 ;;
-n|--lines) lines="${2:-}"; shift 2 ;;
--force) force="1"; shift ;;
--dry-run) dry_run="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown rescue option: $1" ;;
esac
done
require_tmux
require_target "$target"
local output
output="$(capture_raw "$target" "$lines")"
printf '%s\n' "$output" | redact
if printf '%s\n' "$output" | grep -Eiq "$pattern"; then
printf '[match] target=%s pattern=%s reply=%q\n' "$target" "$pattern" "$reply" >&2
if [[ "$dry_run" == "1" ]]; then
printf '[dry-run] would send reply to %s\n' "$target"
else
if [[ "$force" == "1" ]]; then
cmd_send -t "$target" --text "$reply" --enter --force
else
cmd_send -t "$target" --text "$reply" --enter
fi
fi
else
printf '[skip] no rescue pattern matched for %s\n' "$target" >&2
fi
}
cmd_scan() {
local session=""
local lines=80
local pattern=""
local rescue="0"
local reply="y"
local save_dir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--session) session="${2:-}"; shift 2 ;;
-n|--lines) lines="${2:-}"; shift 2 ;;
--pattern) pattern="${2:-}"; shift 2 ;;
--rescue) rescue="1"; shift ;;
--reply) reply="${2:-}"; shift 2 ;;
--save-dir) save_dir="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown scan option: $1" ;;
esac
done
require_tmux
if [[ -n "$save_dir" ]]; then
mkdir -p "$save_dir"
fi
local pane output safe_name
while IFS= read -r pane; do
[[ -n "$pane" ]] || continue
printf '\n===== [%s] =====\n' "$pane"
output="$(capture_raw "$pane" "$lines")"
printf '%s\n' "$output" | redact
if [[ -n "$save_dir" ]]; then
safe_name="${pane//[:.]/-}.log"
printf '%s\n' "$output" | redact > "$save_dir/$safe_name"
fi
if [[ -n "$pattern" ]] && printf '%s\n' "$output" | grep -Eiq "$pattern"; then
printf '[match] %s matched %s\n' "$pane" "$pattern" >&2
if [[ "$rescue" == "1" ]]; then
cmd_send -t "$pane" --text "$reply" --enter
fi
fi
done < <(pane_list "$session")
}
cmd_record() {
local action="${1:-}"
[[ -n "$action" ]] || die "record requires start or stop"
shift || true
local target=""
local dir="/tmp/auto-tmux-records"
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) target="${2:-}"; shift 2 ;;
--dir) dir="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown record option: $1" ;;
esac
done
require_tmux
require_target "$target"
case "$action" in
start)
mkdir -p "$dir"
local safe_name="${target//[:.]/-}.log"
local file="$dir/$safe_name"
tmux pipe-pane -t "$target" -o "cat >> '$file'"
printf 'recording %s -> %s\n' "$target" "$file"
;;
stop)
tmux pipe-pane -t "$target"
printf 'recording stopped for %s\n' "$target"
;;
*)
die "record action must be start or stop"
;;
esac
}
cmd_snapshot() {
local session=""
local dir="/tmp/auto-tmux-snapshot-$(date +%Y%m%d-%H%M%S)"
local lines=120
while [[ $# -gt 0 ]]; do
case "$1" in
--session) session="${2:-}"; shift 2 ;;
--dir) dir="${2:-}"; shift 2 ;;
-n|--lines) lines="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown snapshot option: $1" ;;
esac
done
require_tmux
mkdir -p "$dir/panes"
{
printf 'auto-tmux snapshot\n'
printf 'created_at=%s\n' "$(date -Is)"
printf 'session=%s\n\n' "${session:-<all>}"
cmd_topology ${session:+--session "$session"}
} > "$dir/topology.txt"
local pane safe_name
while IFS= read -r pane; do
[[ -n "$pane" ]] || continue
safe_name="${pane//[:.]/-}.log"
capture_print "$pane" "$lines" 0 > "$dir/panes/$safe_name"
done < <(pane_list "$session")
printf 'snapshot written: %s\n' "$dir"
}
cmd_hub() {
local session="ai-hub"
local workers=3
local worker_cmd="bash"
local commander_cmd="bash"
local attach="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--session) session="${2:-}"; shift 2 ;;
--workers) workers="${2:-}"; shift 2 ;;
--cmd) worker_cmd="${2:-}"; shift 2 ;;
--commander-cmd) commander_cmd="${2:-}"; shift 2 ;;
--attach) attach="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown hub option: $1" ;;
esac
done
command -v tmux >/dev/null 2>&1 || die "tmux is not installed or not in PATH"
if tmux has-session -t "$session" 2>/dev/null; then
warn "session already exists: $session"
tmux list-windows -t "$session" -F '#S:#I #{window_name}'
else
tmux new-session -d -s "$session" -n commander "$commander_cmd"
local i
for i in $(seq 1 "$workers"); do
tmux new-window -t "$session" -n "worker$i" "$worker_cmd"
done
tmux select-window -t "$session:commander"
printf 'created tmux AI hub: %s workers=%s cmd=%q\n' "$session" "$workers" "$worker_cmd"
fi
if [[ "$attach" == "1" ]]; then
tmux attach -t "$session"
fi
}
cmd_cleanup() {
local session=""
local dry_run="0"
local force="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--session) session="${2:-}"; shift 2 ;;
--dry-run) dry_run="1"; shift ;;
--force) force="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) die "unknown cleanup option: $1" ;;
esac
done
command -v tmux >/dev/null 2>&1 || die "tmux is not installed or not in PATH"
[[ -n "$session" ]] || die "cleanup requires --session NAME"
tmux has-session -t "$session" 2>/dev/null || die "session not found: $session"
local attached windows
attached="$(tmux display-message -pt "$session" '#{session_attached}' 2>/dev/null || printf '0')"
windows="$(tmux display-message -pt "$session" '#{session_windows}' 2>/dev/null || printf '?')"
printf 'cleanup target: session=%s windows=%s attached=%s\n' "$session" "$windows" "$attached"
if [[ "$dry_run" == "1" ]]; then
printf '[dry-run] would kill session: %s\n' "$session"
return 0
fi
[[ "$force" == "1" ]] || die "cleanup requires --force without --dry-run"
if [[ "$attached" != "0" ]]; then
warn "session is attached; killing anyway because --force was provided"
fi
tmux kill-session -t "$session"
printf 'session killed: %s\n' "$session"
}
cmd_wait() {
local target=""
local pattern=""
local timeout=60
local interval=2
local lines=80
while [[ $# -gt 0 ]]; do
case "$1" in
-t|--target) target="${2:-}"; shift 2 ;;
--pattern) pattern="${2:-}"; shift 2 ;;
--timeout) timeout="${2:-}"; shift 2 ;;
--interval) interval="${2:-}"; shift 2 ;;
-n|--lines) lines="${2:-}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) die "unknown wait option: $1" ;;
esac
done
require_tmux
require_target "$target"
[[ -n "$pattern" ]] || die "wait requires --pattern REGEX"
local start now output
start="$(date +%s)"
while true; do
output="$(capture_raw "$target" "$lines")"
if printf '%s\n' "$output" | grep -Eiq "$pattern"; then
printf '%s\n' "$output" | redact
printf '[matched] target=%s pattern=%s\n' "$target" "$pattern" >&2
return 0
fi
now="$(date +%s)"
if (( now - start >= timeout )); then
printf '%s\n' "$output" | redact
die "timeout waiting for pattern: $pattern"
fi
sleep "$interval"
done
}
main() {
local cmd="${1:-help}"
shift || true
case "$cmd" in
help|-h|--help) usage ;;
doctor) cmd_doctor "$@" ;;
topology) cmd_topology "$@" ;;
inspect) cmd_inspect "$@" ;;
capture) cmd_capture "$@" ;;
send) cmd_send "$@" ;;
paste) cmd_paste "$@" ;;
broadcast) cmd_broadcast "$@" ;;
rescue) cmd_rescue "$@" ;;
scan) cmd_scan "$@" ;;
record) cmd_record "$@" ;;
snapshot) cmd_snapshot "$@" ;;
hub) cmd_hub "$@" ;;
cleanup) cmd_cleanup "$@" ;;
wait) cmd_wait "$@" ;;
*) die "unknown command: $cmd" ;;
esac
}
main "$@"