mirror of
https://github.com/tradecatlabs/vibe-coding-cn.git
synced 2026-08-21 23:08:06 +00:00
feat: skills - upgrade auto-tmux automation
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# skills/auto-tmux/scripts
|
||||
|
||||
本目录承载 `auto-tmux` 的可执行脚本层。脚本必须安全封装 tmux 常用操作,减少手写 `capture-pane` / `send-keys` 时的误控风险。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
scripts/
|
||||
├── AGENTS.md # 本文件:脚本维护规则
|
||||
├── README.md # 面向人类的脚本入口说明
|
||||
└── auto-tmux.sh # 统一 tmux 自动化命令入口
|
||||
```
|
||||
|
||||
## 维护规则
|
||||
|
||||
- 脚本必须使用 Bash 标准能力,不引入额外运行时依赖。
|
||||
- 发送按键类功能必须先验证目标 pane 存在,并优先打印上下文。
|
||||
- 捕获输出默认脱敏;只有显式 `--no-redact` 才允许输出原文。
|
||||
- 批量救援必须通过 pattern 匹配触发,不允许无条件向所有 pane 发送输入。
|
||||
- 新增脚本后必须更新 `README.md`、`SKILL.md`、`references/index.md`,并运行 `bash -n` 与 skill strict 校验。
|
||||
@@ -0,0 +1,35 @@
|
||||
# auto-tmux scripts
|
||||
|
||||
`scripts/` 存放 `auto-tmux` 的可执行辅助脚本,把常见 tmux 操作封装成安全、可复用、可审计的命令。
|
||||
|
||||
## 入口
|
||||
|
||||
- [`auto-tmux.sh`](./auto-tmux.sh) - 统一命令入口,支持拓扑、读取、发送、巡检、救援、录制、等待和 AI 工作台初始化。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 查看帮助
|
||||
skills/auto-tmux/scripts/auto-tmux.sh help
|
||||
|
||||
# 查看 tmux 拓扑
|
||||
skills/auto-tmux/scripts/auto-tmux.sh topology
|
||||
|
||||
target="$(tmux list-panes -t ai-hub:worker1 -F '#S:#I.#P' | head -n 1)"
|
||||
|
||||
# 读取指定 pane 最近 80 行
|
||||
skills/auto-tmux/scripts/auto-tmux.sh capture -t "$target" -n 80
|
||||
|
||||
# 发送命令并回车
|
||||
skills/auto-tmux/scripts/auto-tmux.sh send -t "$target" --text "make test" --enter
|
||||
|
||||
# 扫描某个 session 的错误
|
||||
skills/auto-tmux/scripts/auto-tmux.sh scan --session ai-hub --pattern "ERROR|Traceback"
|
||||
```
|
||||
|
||||
## 安全约束
|
||||
|
||||
- 默认对捕获输出做密钥脱敏。
|
||||
- `send` 会先打印目标 pane 最近上下文,再发送按键。
|
||||
- 危险命令默认拒绝,必须显式 `--force`。
|
||||
- 批量操作必须使用明确的 session/window/pane 目标。
|
||||
Executable
+428
@@ -0,0 +1,428 @@
|
||||
#!/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 topology [--session NAME]
|
||||
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 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 hub [--session NAME] [--workers N] [--cmd CMD] [--commander-cmd CMD] [--attach]
|
||||
auto-tmux.sh wait -t TARGET --pattern REGEX [--timeout SEC] [--interval SEC] [-n LINES]
|
||||
|
||||
Target format:
|
||||
<session>:<window>.<pane>
|
||||
|
||||
Examples:
|
||||
auto-tmux.sh topology
|
||||
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 rescue -t <target-from-topology> --pattern "(y/n)" --reply y
|
||||
auto-tmux.sh scan --session ai-hub --pattern "ERROR|Traceback"
|
||||
auto-tmux.sh hub --session ai-hub --workers 3 --cmd "codex"
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warn: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
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_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_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_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_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_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 ;;
|
||||
topology) cmd_topology "$@" ;;
|
||||
capture) cmd_capture "$@" ;;
|
||||
send) cmd_send "$@" ;;
|
||||
rescue) cmd_rescue "$@" ;;
|
||||
scan) cmd_scan "$@" ;;
|
||||
record) cmd_record "$@" ;;
|
||||
hub) cmd_hub "$@" ;;
|
||||
wait) cmd_wait "$@" ;;
|
||||
*) die "unknown command: $cmd" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user