Executable
+224
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
将 Markdown 文件中的 Mermaid 图表渲染为图片,并通过 Pandoc 导出为 docx/pdf。
|
||||
|
||||
用法:
|
||||
python scripts/md_export.py docs/论文.md --format docx
|
||||
python scripts/md_export.py docs/论文.md --format pdf
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _ensure_chrome_env() -> None:
|
||||
"""确保 mmdc 能找到 Chrome/Chromium 可执行文件。"""
|
||||
if os.environ.get("PUPPETEER_EXECUTABLE_PATH"):
|
||||
return
|
||||
|
||||
# 优先检查系统 Chrome/Chromium
|
||||
for candidate in (
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"chromium-browser",
|
||||
"chromium",
|
||||
):
|
||||
path = shutil.which(candidate)
|
||||
if path:
|
||||
os.environ["PUPPETEER_EXECUTABLE_PATH"] = path
|
||||
return
|
||||
|
||||
# 检查 Puppeteer 缓存目录
|
||||
cache_dir = Path.home() / ".cache" / "puppeteer"
|
||||
if cache_dir.exists():
|
||||
for shell_dir in sorted(cache_dir.glob("chrome-headless-shell/linux-*")):
|
||||
binary = (
|
||||
shell_dir / "chrome-headless-shell-linux64" / "chrome-headless-shell"
|
||||
)
|
||||
if binary.exists():
|
||||
os.environ["PUPPETEER_EXECUTABLE_PATH"] = str(binary)
|
||||
return
|
||||
|
||||
for chrome_dir in sorted(cache_dir.glob("chrome/linux-*")):
|
||||
binary = chrome_dir / "chrome-linux64" / "chrome"
|
||||
if binary.exists():
|
||||
os.environ["PUPPETEER_EXECUTABLE_PATH"] = str(binary)
|
||||
return
|
||||
|
||||
|
||||
def find_project_root() -> Path:
|
||||
"""从脚本位置向上查找项目根目录(包含 AGENTS.md 或 CLAUDE.md 的目录)。"""
|
||||
current = Path(__file__).resolve().parent
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "AGENTS.md").exists() or (parent / "CLAUDE.md").exists():
|
||||
return parent
|
||||
return current
|
||||
|
||||
|
||||
def extract_mermaid_blocks(content: str) -> list[str]:
|
||||
"""从 Markdown 内容中提取所有 mermaid 代码块。"""
|
||||
return re.findall(r"```mermaid\n(.*?)\n```", content, re.DOTALL)
|
||||
|
||||
|
||||
def render_mermaid_to_png(
|
||||
mermaid_code: str,
|
||||
output_path: Path,
|
||||
mmd_path: Path,
|
||||
) -> Path:
|
||||
"""用 mmdc 将 mermaid 代码渲染为 PNG 图片,同时保留 .mmd 源文件。"""
|
||||
mmd_path.write_text(mermaid_code, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["mmdc", "-i", str(mmd_path), "-o", str(output_path), "-b", "white"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return output_path
|
||||
|
||||
|
||||
def replace_mermaid_with_images(
|
||||
content: str,
|
||||
image_dir: Path,
|
||||
file_stem: str,
|
||||
) -> tuple[str, list[Path]]:
|
||||
"""将 mermaid 代码块替换为图片引用,返回替换后的内容和图片路径列表。"""
|
||||
counter = 0
|
||||
image_paths: list[Path] = []
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
png_name = f"{file_stem}-mermaid-{counter}.png"
|
||||
mmd_name = f"{file_stem}-mermaid-{counter}.mmd"
|
||||
png_path = image_dir / png_name
|
||||
mmd_path = image_dir / mmd_name
|
||||
|
||||
render_mermaid_to_png(match.group(1), png_path, mmd_path)
|
||||
image_paths.append(png_path)
|
||||
return f""
|
||||
|
||||
result = re.sub(r"```mermaid\n(.*?)\n```", _replace, content, flags=re.DOTALL)
|
||||
return result, image_paths
|
||||
|
||||
|
||||
def get_resource_path(input_file: Path) -> Path:
|
||||
"""获取 Pandoc 资源路径(图片所在目录的父目录)。"""
|
||||
return input_file.parent
|
||||
|
||||
|
||||
def build_pandoc_command(
|
||||
input_md: Path,
|
||||
output_file: Path,
|
||||
fmt: str,
|
||||
resource_path: Path,
|
||||
) -> list[str]:
|
||||
"""构建 pandoc 命令行参数。"""
|
||||
cmd = [
|
||||
"pandoc",
|
||||
str(input_md),
|
||||
"-o",
|
||||
str(output_file),
|
||||
"--resource-path",
|
||||
str(resource_path),
|
||||
]
|
||||
|
||||
if fmt == "pdf":
|
||||
cmd.extend(
|
||||
[
|
||||
"--pdf-engine=xelatex",
|
||||
"-V",
|
||||
"CJKmainfont=Noto Sans CJK SC",
|
||||
"-V",
|
||||
"geometry:margin=2.5cm",
|
||||
]
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def export(input_file: Path, fmt: str) -> Path:
|
||||
"""执行完整的导出流程。"""
|
||||
if not input_file.exists():
|
||||
print(f"错误: 文件不存在 {input_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not shutil.which("mmdc"):
|
||||
print("错误: 未找到 mmdc,请先安装 @mermaid-js/mermaid-cli", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not shutil.which("pandoc"):
|
||||
print("错误: 未找到 pandoc,请先安装 pandoc", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
content = input_file.read_text(encoding="utf-8")
|
||||
mermaid_blocks = extract_mermaid_blocks(content)
|
||||
|
||||
if not mermaid_blocks:
|
||||
print("未发现 mermaid 图表,直接导出...")
|
||||
output_file = input_file.with_suffix(f".{fmt}")
|
||||
resource_path = get_resource_path(input_file)
|
||||
subprocess.run(
|
||||
build_pandoc_command(input_file, output_file, fmt, resource_path),
|
||||
check=True,
|
||||
)
|
||||
print(f"导出完成: {output_file}")
|
||||
return output_file
|
||||
|
||||
# 准备图片目录
|
||||
image_dir = input_file.parent / "images"
|
||||
image_dir.mkdir(exist_ok=True)
|
||||
file_stem = input_file.stem
|
||||
|
||||
# 替换 mermaid 为图片引用,写入临时文件
|
||||
replaced_content, image_paths = replace_mermaid_with_images(
|
||||
content,
|
||||
image_dir,
|
||||
file_stem,
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix=".md",
|
||||
encoding="utf-8",
|
||||
dir=input_file.parent,
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp.write(replaced_content)
|
||||
tmp_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
output_file = input_file.with_suffix(f".{fmt}")
|
||||
resource_path = get_resource_path(input_file)
|
||||
cmd = build_pandoc_command(tmp_path, output_file, fmt, resource_path)
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"导出完成: {output_file}")
|
||||
print(f"渲染了 {len(image_paths)} 张 mermaid 图片:")
|
||||
for p in image_paths:
|
||||
print(f" - {p}")
|
||||
return output_file
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="将 Markdown 中的 Mermaid 图表渲染为图片后导出为 docx/pdf",
|
||||
)
|
||||
parser.add_argument("input", type=Path, help="输入的 Markdown 文件路径")
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["docx", "pdf"],
|
||||
default="docx",
|
||||
help="输出格式 (默认: docx)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
export(args.input.resolve(), args.format)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_ensure_chrome_env()
|
||||
main()
|
||||
Executable
+232
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env bash
|
||||
# pdf2md.sh — 将 PDF 文件转换为结构化 Markdown
|
||||
# 依赖: poppler-utils (pdftotext)
|
||||
# 用法: pdf2md.sh <input.pdf> [-o output.md] [-f N] [-l N] [--raw] [--no-toc]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# === 默认值 ===
|
||||
INPUT=""
|
||||
OUTPUT=""
|
||||
FIRST_PAGE=""
|
||||
LAST_PAGE=""
|
||||
RAW_MODE=false
|
||||
NO_TOC=false
|
||||
|
||||
# === 参数解析 ===
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: pdf2md.sh <input.pdf> [选项]
|
||||
|
||||
选项:
|
||||
-o FILE 输出文件路径 (默认: 同名 .md, "-" 表示 stdout)
|
||||
-f N 起始页码
|
||||
-l N 结束页码
|
||||
--raw 跳过后处理,输出原始文本
|
||||
--no-toc 不生成目录
|
||||
-h, --help 显示帮助信息
|
||||
|
||||
示例:
|
||||
pdf2md.sh doc.pdf
|
||||
pdf2md.sh doc.pdf -o output.md
|
||||
pdf2md.sh doc.pdf -f 1 -l 10
|
||||
pdf2md.sh doc.pdf --raw -o -
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o) OUTPUT="$2"; shift 2 ;;
|
||||
-f) FIRST_PAGE="$2"; shift 2 ;;
|
||||
-l) LAST_PAGE="$2"; shift 2 ;;
|
||||
--raw) RAW_MODE=true; shift ;;
|
||||
--no-toc) NO_TOC=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
-*) echo "未知选项: $1" >&2; exit 1 ;;
|
||||
*)
|
||||
if [[ -z "$INPUT" ]]; then
|
||||
INPUT="$1"
|
||||
else
|
||||
echo "错误: 多个输入文件" >&2; exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# === 校验输入 ===
|
||||
if [[ -z "$INPUT" ]]; then
|
||||
echo "错误: 未指定输入 PDF 文件" >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ ! -f "$INPUT" ]]; then
|
||||
echo "错误: 文件不存在: $INPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v pdftotext &>/dev/null; then
|
||||
echo "错误: pdftotext 未安装。请运行: sudo apt-get install poppler-utils" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# === 确定输出路径 ===
|
||||
if [[ -z "$OUTPUT" ]]; then
|
||||
OUTPUT="${INPUT%.pdf}.md"
|
||||
fi
|
||||
|
||||
# === 构建 pdftotext 命令 ===
|
||||
PDFTOTEXT_ARGS=(-layout -enc UTF-8 -eol unix -nopgbrk)
|
||||
[[ -n "$FIRST_PAGE" ]] && PDFTOTEXT_ARGS+=(-f "$FIRST_PAGE")
|
||||
[[ -n "$LAST_PAGE" ]] && PDFTOTEXT_ARGS+=(-l "$LAST_PAGE")
|
||||
|
||||
# === 提取文本 ===
|
||||
RAW_TEXT=$(pdftotext "${PDFTOTEXT_ARGS[@]}" "$INPUT" - 2>/dev/null) || {
|
||||
echo "错误: pdftotext 执行失败" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# === 原始模式直接输出 ===
|
||||
if [[ "$RAW_MODE" == true ]]; then
|
||||
if [[ "$OUTPUT" == "-" ]]; then
|
||||
echo "$RAW_TEXT"
|
||||
else
|
||||
echo "$RAW_TEXT" > "$OUTPUT"
|
||||
echo "已输出原始文本: $OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# === 后处理: 转换为结构化 Markdown ===
|
||||
# 使用 Python 做后处理(更可靠的正则和文本分析)
|
||||
RESULT=$(python3 -c "
|
||||
import sys
|
||||
import re
|
||||
|
||||
def is_list_item(line):
|
||||
'''检测是否为列表项'''
|
||||
return bool(re.match(r'^[-\u2022*]\s', line) or re.match(r'^\d+[.)]\s', line))
|
||||
|
||||
def is_separator(line):
|
||||
'''检测是否为分隔线'''
|
||||
return bool(re.match(r'^[-=_*]{3,}$', line))
|
||||
|
||||
def is_all_upper(line):
|
||||
'''检测是否全大写(允许数字、标点、空格)'''
|
||||
stripped = re.sub(r'[A-Z0-9\s:._\-/\\()&,;!?\x27\x22]', '', line)
|
||||
return len(stripped) == 0 and len(line) > 3
|
||||
|
||||
def process(text):
|
||||
lines = text.split('\n')
|
||||
output = []
|
||||
toc = []
|
||||
prev_blank = True
|
||||
in_code = False
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
length = len(stripped)
|
||||
leading = len(line) - len(line.lstrip())
|
||||
|
||||
# 空行
|
||||
if length == 0:
|
||||
if in_code:
|
||||
output.append('\`\`\`')
|
||||
in_code = False
|
||||
prev_blank = True
|
||||
continue
|
||||
|
||||
# --- 代码块检测: 连续缩进 >= 4 空格 ---
|
||||
if leading >= 4 and not in_code:
|
||||
# 检查前一行是否也是缩进的(或当前已连续)
|
||||
output.append('\`\`\`')
|
||||
output.append(line[4:])
|
||||
in_code = True
|
||||
prev_blank = False
|
||||
continue
|
||||
elif in_code and leading >= 4:
|
||||
output.append(line[4:])
|
||||
prev_blank = False
|
||||
continue
|
||||
elif in_code and leading < 4:
|
||||
output.append('\`\`\`')
|
||||
in_code = False
|
||||
|
||||
# --- 列表项 (优先级高于标题检测) ---
|
||||
if is_list_item(stripped):
|
||||
# 将 bullet 统一为 -
|
||||
fixed = re.sub(r'^[\u2022*]\s', '- ', stripped)
|
||||
output.append(fixed)
|
||||
prev_blank = False
|
||||
continue
|
||||
|
||||
# --- 分隔线 ---
|
||||
if is_separator(stripped):
|
||||
output.append('')
|
||||
output.append('---')
|
||||
prev_blank = True
|
||||
continue
|
||||
|
||||
# --- 表格检测: 含 | 或连续制表符 ---
|
||||
if '|' in stripped or (leading > 0 and '\t' in stripped):
|
||||
output.append(stripped)
|
||||
prev_blank = False
|
||||
continue
|
||||
|
||||
# --- 标题检测 ---
|
||||
# 条件: 前有空行 + 短行(<=60字符) + 非列表项 + 非分隔线
|
||||
if prev_blank and 3 < length <= 80:
|
||||
if is_all_upper(stripped):
|
||||
# 全大写 → ## 级标题
|
||||
title = stripped
|
||||
output.append('')
|
||||
output.append(f'## {title}')
|
||||
toc.append(f'- {title}')
|
||||
prev_blank = False
|
||||
continue
|
||||
elif length <= 60:
|
||||
# 短行 + 前空行 → ### 级标题
|
||||
output.append('')
|
||||
output.append(f'### {stripped}')
|
||||
prev_blank = False
|
||||
continue
|
||||
|
||||
# --- 普通文本 ---
|
||||
output.append(stripped)
|
||||
prev_blank = False
|
||||
|
||||
if in_code:
|
||||
output.append('\`\`\`')
|
||||
|
||||
return output, toc
|
||||
|
||||
raw = sys.stdin.read()
|
||||
out_lines, toc = process(raw)
|
||||
|
||||
# 文档标题
|
||||
filename = '${INPUT##*/}'
|
||||
filename = filename.rsplit('.', 1)[0]
|
||||
print(f'# {filename}')
|
||||
print()
|
||||
|
||||
# 目录
|
||||
if toc and len(toc) > 0:
|
||||
print('## 目录')
|
||||
print()
|
||||
for item in toc:
|
||||
print(item)
|
||||
print()
|
||||
|
||||
# 正文
|
||||
for line in out_lines:
|
||||
print(line)
|
||||
" <<< "$RAW_TEXT")
|
||||
|
||||
# === 写入输出 ===
|
||||
if [[ "$OUTPUT" == "-" ]]; then
|
||||
echo "$RESULT"
|
||||
else
|
||||
echo "$RESULT" > "$OUTPUT"
|
||||
echo "已生成: $OUTPUT ($(wc -l < "$OUTPUT") 行)"
|
||||
fi
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# Read JSON data that Claude Code sends to stdin
|
||||
input=$(cat)
|
||||
|
||||
# Extract fields using jq
|
||||
MODEL=$(echo "$input" | jq -r '.model.display_name' | sed 's/\[1m\]/[200k]/')
|
||||
DIR=$(echo "$input" | jq -r '.workspace.current_dir')
|
||||
V=$(echo "$input" | jq -r '.version')
|
||||
|
||||
# 计算上下文使用占比
|
||||
# JSON 中 used_percentage 是相对模型实际窗口(如 GLM 的 1M)计算的,
|
||||
# 但 /context 命令显示的是相对 auto-compact 窗口(200k)的占比。
|
||||
# 因此需要用实际 token 数 / 200000 来获得正确的百分比。
|
||||
AUTO_COMPACT_WINDOW=200000
|
||||
PCT=$(echo "$input" | jq -r --argjson w "$AUTO_COMPACT_WINDOW" '
|
||||
((
|
||||
(.context_window.current_usage.input_tokens // 0) +
|
||||
(.context_window.current_usage.cache_creation_input_tokens // 0) +
|
||||
(.context_window.current_usage.cache_read_input_tokens // 0)
|
||||
) * 100 / $w) | round
|
||||
')
|
||||
|
||||
BRANCH=""
|
||||
if command -v jj >/dev/null 2>&1 && jj repo root >/dev/null 2>&1; then
|
||||
BRANCH="🌿 $(jj log -r '@-' -T 'bookmarks' --no-graph 2>/dev/null)"
|
||||
elif git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
BRANCH="🌿 $(git branch --show-current 2>/dev/null)"
|
||||
fi
|
||||
|
||||
REMOTE=""
|
||||
if command -v jj >/dev/null 2>&1 && jj repo root >/dev/null 2>&1; then
|
||||
REMOTE="🌐 $(jj git remote list 2>/dev/null | head -1 | awk '{print $2}')"
|
||||
elif git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
REMOTE="🌐 $(git remote get-url origin 2>/dev/null)"
|
||||
fi
|
||||
|
||||
echo "版本:v$V | 模型:$MODEL | 上下文:${PCT}%"
|
||||
Executable
+285
@@ -0,0 +1,285 @@
|
||||
#!/bin/bash
|
||||
# test-tmux-send-prompt-integ.sh — tmux-send-prompt.sh 真实集成测试(可观察模式)
|
||||
#
|
||||
# 用法: bash scripts/test-tmux-send-prompt-integ.sh
|
||||
#
|
||||
# 布局:
|
||||
# ┌──────────────────┬──────────────────┐
|
||||
# │ cc pane (左) │ ctrl pane (右) │
|
||||
# │ 接收命令并回复 │ 运行测试、显示 │
|
||||
# └──────────────────┴──────────────────┘
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ─── 配置 ───
|
||||
SESSION="test-send"
|
||||
SCRIPT="/workspace/scripts/tmux-send-prompt.sh"
|
||||
TEST_DIR=$(mktemp -d)
|
||||
|
||||
# ─── 辅助函数 ───
|
||||
|
||||
wait_for_prompt() {
|
||||
local pane_id="$1" timeout="${2:-120}" desc="${3:-cc prompt}"
|
||||
echo -e " \e[36m⏳\e[0m 等待 ${desc}(最多 ${timeout}s)..."
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
local last_lines
|
||||
last_lines=$(tmux capture-pane -t "$pane_id" -p 2>/dev/null | tail -5 || true)
|
||||
if echo "$last_lines" | grep -q '❯'; then
|
||||
echo -e " \e[36m✓\e[0m ${desc} 就绪(等待 ${i}s)"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo -e " \e[31m✗\e[0m ${desc} 超时"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ─── 清理旧 session ───
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "═══ tmux-send-prompt.sh 集成测试(真实 cc)═══"
|
||||
echo ""
|
||||
|
||||
# ─── 0. 环境检查 ───
|
||||
echo "── 0. 环境准备 ──"
|
||||
command -v tmux &>/dev/null || { echo " ✗ 系统未安装 tmux"; exit 0; }
|
||||
command -v claude &>/dev/null || { echo " ✗ claude 未安装"; exit 0; }
|
||||
echo " ✓ tmux + claude 环境就绪"
|
||||
|
||||
# ─── 1. 创建 tmux session ───
|
||||
echo ""
|
||||
echo "── 1. 创建测试环境 ──"
|
||||
|
||||
tmux new-session -d -s "$SESSION" -x 200 -y 50
|
||||
CC_PANE=$(tmux display-message -t "$SESSION" -p '#{pane_id}')
|
||||
tmux split-window -t "$SESSION" -h
|
||||
CTRL_PANE=$(tmux display-message -t "$SESSION" -p '#{pane_id}')
|
||||
|
||||
echo " ✓ session=$SESSION cc=$CC_PANE ctrl=$CTRL_PANE"
|
||||
|
||||
echo " → 在 $CC_PANE 启动 claude..."
|
||||
tmux send-keys -t "$CC_PANE" "source ~/.bashrc && claude --dangerously-skip-permissions --allow-dangerously-skip-permissions" Enter
|
||||
|
||||
if ! wait_for_prompt "$CC_PANE" 120 "cc 启动"; then
|
||||
echo " ✗ cc 启动失败"
|
||||
tmux capture-pane -t "$CC_PANE" -p | tail -10
|
||||
exit 1
|
||||
fi
|
||||
echo " ✓ cc 启动成功"
|
||||
|
||||
# ─── 2. 生成测试脚本 ───
|
||||
echo ""
|
||||
echo "── 2. 生成测试脚本 ──"
|
||||
|
||||
RUNNER="${TEST_DIR}/runner.sh"
|
||||
|
||||
# 步骤1: 写入 header(单引号 heredoc,不做任何替换)
|
||||
cat > "$RUNNER" <<HEADER
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
CC_PANE="${CC_PANE}"
|
||||
SCRIPT="${SCRIPT}"
|
||||
TEST_DIR="${TEST_DIR}"
|
||||
SESSION="${SESSION}"
|
||||
HEADER
|
||||
|
||||
# 步骤2: 追加颜色定义(用单引号 heredoc 保留 $'...' 原样)
|
||||
cat >> "$RUNNER" <<'COLORS'
|
||||
|
||||
G=$'\e[32m'
|
||||
R=$'\e[31m'
|
||||
Y=$'\e[33m'
|
||||
B=$'\e[1m'
|
||||
N=$'\e[0m'
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
result() {
|
||||
local label="$1" ok="$2" detail="${3:-}"
|
||||
if [[ "$ok" == "PASS" ]]; then
|
||||
((pass++)) || true
|
||||
echo " ${G}✓ PASS${N} ${label}${detail:+ — ${detail}}"
|
||||
else
|
||||
((fail++)) || true
|
||||
echo " ${R}✗ FAIL${N} ${label}${detail:+ — ${detail}}"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_prompt() {
|
||||
local pane_id="$1" timeout="${2:-60}"
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
tmux capture-pane -t "$pane_id" -p 2>/dev/null | tail -5 | grep -q '❯' && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_response() {
|
||||
local pane_id="$1" keyword="$2" timeout="${3:-90}"
|
||||
for i in $(seq 1 "$timeout"); do
|
||||
tmux capture-pane -t "$pane_id" -p -S -50 2>/dev/null | grep -qF "$keyword" && { sleep 2; return 0; }
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "${B}═══ 开始测试 ═══${N}"
|
||||
echo ""
|
||||
COLORS
|
||||
|
||||
# 步骤3: 追加测试场景(单引号 heredoc,变量由运行时 bash 解释)
|
||||
cat >> "$RUNNER" <<'SCENARIOS'
|
||||
|
||||
# ── 场景1: 短文本字符串 ──
|
||||
echo "${B}── 场景1: 短文本字符串 ──${N}"
|
||||
bash "$SCRIPT" "$CC_PANE" "请回复 TEST_OK_短文本" 2>&1 | while read -r line; do echo " $line"; done
|
||||
if wait_for_response "$CC_PANE" "TEST_OK_短文本"; then
|
||||
result "短文本 → cc 收到并回复" "PASS"
|
||||
else
|
||||
result "短文本 → cc 收到并回复" "FAIL"
|
||||
fi
|
||||
wait_for_prompt "$CC_PANE" 30
|
||||
|
||||
# ── 场景2: 文件路径 ──
|
||||
echo ""
|
||||
echo "${B}── 场景2: 文件路径 → cc 自行读取 ──${N}"
|
||||
cat > "${TEST_DIR}/test-prompt.md" <<'PROMPT'
|
||||
请回复以下确认码:FILE_OK_读取成功
|
||||
不要做其他任何事情,只回复确认码。
|
||||
PROMPT
|
||||
echo " ✓ 测试文件已创建: ${TEST_DIR}/test-prompt.md"
|
||||
|
||||
bash "$SCRIPT" "$CC_PANE" "${TEST_DIR}/test-prompt.md" 2>&1 | while read -r line; do echo " $line"; done
|
||||
sleep 3
|
||||
if wait_for_response "$CC_PANE" "FILE_OK"; then
|
||||
result "文件路径 → cc 读取文件并回复" "PASS"
|
||||
else
|
||||
result "文件路径 → cc 读取文件并回复" "FAIL"
|
||||
fi
|
||||
wait_for_prompt "$CC_PANE" 30
|
||||
|
||||
# ── 场景3: 相对路径文件 ──
|
||||
echo ""
|
||||
echo "${B}── 场景3: 相对路径文件 ──${N}"
|
||||
mkdir -p "${TEST_DIR}/subdir"
|
||||
cat > "${TEST_DIR}/subdir/relative.md" <<'PROMPT'
|
||||
请回复确认码:RELATIVE_OK
|
||||
PROMPT
|
||||
|
||||
pushd "$TEST_DIR" > /dev/null
|
||||
bash "$SCRIPT" "$CC_PANE" "subdir/relative.md" 2>&1 | while read -r line; do echo " $line"; done
|
||||
popd > /dev/null
|
||||
sleep 3
|
||||
if wait_for_response "$CC_PANE" "RELATIVE_OK"; then
|
||||
result "相对路径 → cc 读取成功" "PASS"
|
||||
else
|
||||
result "相对路径 → cc 读取成功" "FAIL"
|
||||
fi
|
||||
wait_for_prompt "$CC_PANE" 30
|
||||
|
||||
# ── 场景4: 长文本字符串 ──
|
||||
echo ""
|
||||
echo "${B}── 场景4: 长文本字符串(200+ 字)──${N}"
|
||||
LONG_TEXT="请回复确认码:LONG_OK。以下是一段长文本用于测试:密码管理系统需要满足以下安全要求:第一,所有敏感数据必须使用 AES-256-GCM 加密存储;第二,密码哈希使用 Argon2id 算法,参数为 64MB 内存和 3 次迭代;第三,设备认证使用 Ed25519 椭圆曲线签名;第四,传输层使用 TLS 1.3 双向认证;第五,主密码最少 8 位,必须包含大小写字母、数字和特殊符号。请只回复确认码 LONG_OK。"
|
||||
|
||||
bash "$SCRIPT" "$CC_PANE" "$LONG_TEXT" 2>&1 | while read -r line; do echo " $line"; done
|
||||
if wait_for_response "$CC_PANE" "LONG_OK"; then
|
||||
result "长文本(200+字)→ cc 收到并回复" "PASS"
|
||||
else
|
||||
result "长文本(200+字)→ cc 收到并回复" "FAIL"
|
||||
fi
|
||||
wait_for_prompt "$CC_PANE" 30
|
||||
|
||||
# ── 场景5: 特殊字符 ──
|
||||
echo ""
|
||||
echo "${B}── 场景5: 特殊字符文本 ──${N}"
|
||||
SPECIAL_TEXT='请回复确认码:SPECIAL_OK,包含符号:|、&、$、"hello"、(test)、[array]、{json: true}'
|
||||
bash "$SCRIPT" "$CC_PANE" "$SPECIAL_TEXT" 2>&1 | while read -r line; do echo " $line"; done
|
||||
if wait_for_response "$CC_PANE" "SPECIAL_OK"; then
|
||||
result "特殊字符文本 → cc 收到并回复" "PASS"
|
||||
else
|
||||
result "特殊字符文本 → cc 收到并回复" "FAIL"
|
||||
fi
|
||||
wait_for_prompt "$CC_PANE" 30
|
||||
|
||||
# ── 场景6: 自定义 submit-delay ──
|
||||
echo ""
|
||||
echo "${B}── 场景6: 自定义 submit-delay=3 ──${N}"
|
||||
bash "$SCRIPT" "$CC_PANE" "请回复确认码:DELAY_OK" 3 2>&1 | while read -r line; do echo " $line"; done
|
||||
if wait_for_response "$CC_PANE" "DELAY_OK"; then
|
||||
result "自定义 delay=3 → cc 收到并回复" "PASS"
|
||||
else
|
||||
result "自定义 delay=3 → cc 收到并回复" "FAIL"
|
||||
fi
|
||||
wait_for_prompt "$CC_PANE" 30
|
||||
|
||||
# ── 场景7: 多行内容文件 ──
|
||||
echo ""
|
||||
echo "${B}── 场景7: 多行内容文件 ──${N}"
|
||||
cat > "${TEST_DIR}/multiline.md" <<'PROMPT'
|
||||
请按以下步骤回复:
|
||||
|
||||
1. 回复确认码:MULTI_OK
|
||||
2. 不要执行其他任何操作
|
||||
3. 直接回复确认码即可
|
||||
|
||||
感谢配合。
|
||||
PROMPT
|
||||
|
||||
bash "$SCRIPT" "$CC_PANE" "${TEST_DIR}/multiline.md" 2>&1 | while read -r line; do echo " $line"; done
|
||||
sleep 3
|
||||
if wait_for_response "$CC_PANE" "MULTI_OK"; then
|
||||
result "多行文件 → cc 读取并回复" "PASS"
|
||||
else
|
||||
result "多行文件 → cc 读取并回复" "FAIL"
|
||||
fi
|
||||
wait_for_prompt "$CC_PANE" 30
|
||||
|
||||
# ── 场景8: 不存在的路径 ──
|
||||
echo ""
|
||||
echo "${B}── 场景8: 不存在的路径 → 视为文本 ──${N}"
|
||||
bash "$SCRIPT" "$CC_PANE" "请回复确认码:NOTEXIST_OK" 2>&1 | while read -r line; do echo " $line"; done
|
||||
if wait_for_response "$CC_PANE" "NOTEXIST_OK"; then
|
||||
result "不存在路径字符串 → cc 收到并回复" "PASS"
|
||||
else
|
||||
result "不存在路径字符串 → cc 收到并回复" "FAIL"
|
||||
fi
|
||||
|
||||
# ── 结果汇总 ──
|
||||
echo ""
|
||||
echo "════════════════════════════════════"
|
||||
echo " ${G}通过: ${pass}${N} ${R}失败: ${fail}${N} 总计: $((pass + fail))"
|
||||
echo "════════════════════════════════════"
|
||||
echo ""
|
||||
echo "提示: 按 Ctrl+B 再按 D 可脱离 session"
|
||||
echo " 执行 tmux kill-session -t $SESSION 可清理"
|
||||
SCENARIOS
|
||||
|
||||
chmod +x "$RUNNER"
|
||||
echo " ✓ 测试脚本已生成: $RUNNER"
|
||||
|
||||
# ─── 3. 切换到测试 session ───
|
||||
echo ""
|
||||
echo "── 3. 启动测试 → 切换到 session ──"
|
||||
echo " 左 pane: cc(接收命令)"
|
||||
echo " 右 pane: 测试运行器(显示进度)"
|
||||
echo ""
|
||||
echo " ══════════════════════════════════"
|
||||
echo " 即将切换到 tmux session: $SESSION"
|
||||
echo " 按 Ctrl+B 再按 s 可切换回原 session"
|
||||
echo " ══════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# 向 ctrl pane 发送执行命令
|
||||
tmux send-keys -t "$CTRL_PANE" "sleep 1 && bash $RUNNER" Enter
|
||||
|
||||
# 切换到测试 session
|
||||
if [[ -n "${TMUX:-}" ]]; then
|
||||
exec tmux switch-client -t "$SESSION"
|
||||
else
|
||||
exec tmux attach-session -t "$SESSION"
|
||||
fi
|
||||
Executable
+390
@@ -0,0 +1,390 @@
|
||||
#!/bin/bash
|
||||
# test-tmux-send-prompt.sh — tmux-send-prompt.sh 的测试套件
|
||||
#
|
||||
# 用法: bash scripts/test-tmux-send-prompt.sh
|
||||
#
|
||||
# 通过 mock tmux 命令隔离测试,覆盖:
|
||||
# 1. 参数校验(无参数、缺参数)
|
||||
# 2. 文本字符串直接发送
|
||||
# 3. 文件路径 → 生成「请读取」指令
|
||||
# 4. 相对路径 → 解析为绝对路径
|
||||
# 5. 不存在的路径 → 视为文本
|
||||
# 6. 自定义 submit-delay
|
||||
# 7. 特殊字符(空格、中文、符号)
|
||||
# 8. 目录路径 → 视为文本
|
||||
# 9. 空/有内容的文件 → 均生成指令
|
||||
# 10. tmux 不可用场景
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT="/workspace/scripts/tmux-send-prompt.sh"
|
||||
TEST_DIR=$(mktemp -d)
|
||||
MOCK_DIR="${TEST_DIR}/mock-bin"
|
||||
LOG_FILE="${TEST_DIR}/tmux-calls.log"
|
||||
RESULTS=()
|
||||
|
||||
# ─── 颜色输出 ───
|
||||
GREEN='\033[32m'
|
||||
RED='\033[31m'
|
||||
YELLOW='\033[33m'
|
||||
RESET='\033[0m'
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
skip=0
|
||||
|
||||
# ─── 辅助函数 ───
|
||||
|
||||
result() {
|
||||
local label="$1" ok="$2" detail="${3:-}"
|
||||
if [[ "$ok" == "PASS" ]]; then
|
||||
((pass++)) || true
|
||||
echo -e " ${GREEN}✓ PASS${RESET} ${label}${detail:+ — ${detail}}"
|
||||
elif [[ "$ok" == "SKIP" ]]; then
|
||||
((skip++)) || true
|
||||
echo -e " ${YELLOW}⊘ SKIP${RESET} ${label}${detail:+ — ${detail}}"
|
||||
else
|
||||
((fail++)) || true
|
||||
echo -e " ${RED}✗ FAIL${RESET} ${label}${detail:+ — ${detail}}"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$TEST_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ─── 创建 mock tmux ───
|
||||
# mock tmux 记录所有调用到日志文件
|
||||
create_mock_tmux() {
|
||||
mkdir -p "$MOCK_DIR"
|
||||
cat > "${MOCK_DIR}/tmux" <<'MOCK'
|
||||
#!/bin/bash
|
||||
echo "$@" >> "$(dirname "$0")/../tmux-calls.log"
|
||||
# capture-pane 返回含 prompt 标记的内容(模拟 cc 就绪)
|
||||
if [[ "$1" == "capture-pane" ]]; then
|
||||
echo "Some output"
|
||||
echo "Some output"
|
||||
echo "❯ "
|
||||
fi
|
||||
# send-keys 不做任何事,直接成功
|
||||
MOCK
|
||||
chmod +x "${MOCK_DIR}/tmux"
|
||||
}
|
||||
|
||||
# 清空调用日志
|
||||
clear_log() {
|
||||
: > "$LOG_FILE"
|
||||
}
|
||||
|
||||
# 读取调用日志
|
||||
read_log() {
|
||||
cat "$LOG_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# 在 mock 环境中运行脚本
|
||||
run_script() {
|
||||
PATH="${MOCK_DIR}:${PATH}" "$SCRIPT" "$@" 2>&1 || true
|
||||
}
|
||||
|
||||
# 获取脚本退出码
|
||||
run_script_code() {
|
||||
PATH="${MOCK_DIR}:${PATH}" "$SCRIPT" "$@" >/dev/null 2>&1 && echo "0" || echo "$?"
|
||||
}
|
||||
|
||||
# ─── 创建测试文件 ───
|
||||
create_test_file() {
|
||||
local name="$1"
|
||||
local content="${2:-测试内容}"
|
||||
local filepath="${TEST_DIR}/${name}"
|
||||
echo "$content" > "$filepath"
|
||||
echo "$filepath"
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
echo ""
|
||||
echo "═══ tmux-send-prompt.sh 测试套件 ═══"
|
||||
echo ""
|
||||
|
||||
# ─── 1. 参数校验 ───
|
||||
echo "── 1. 参数校验 ──"
|
||||
|
||||
# 1a. 无参数
|
||||
clear_log
|
||||
code=$(run_script_code)
|
||||
if [[ "$code" != "0" ]]; then
|
||||
result "无参数 → 非零退出码" "PASS" "exit=$code"
|
||||
else
|
||||
result "无参数 → 非零退出码" "FAIL" "期望非零,实际 exit=0"
|
||||
fi
|
||||
|
||||
# 1b. 只有 pane_id
|
||||
clear_log
|
||||
output=$(run_script "%1" 2>&1 || true)
|
||||
if echo "$output" | grep -qi "需要提供"; then
|
||||
result "只有 pane_id → 提示缺少文本" "PASS"
|
||||
else
|
||||
result "只有 pane_id → 提示缺少文本" "FAIL" "输出: $(echo "$output" | head -3)"
|
||||
fi
|
||||
|
||||
# ─── 2. 文本字符串 ───
|
||||
echo ""
|
||||
echo "── 2. 文本字符串直接发送 ──"
|
||||
create_mock_tmux
|
||||
|
||||
# 2a. 短文本
|
||||
clear_log
|
||||
output=$(run_script "%1" "你好世界")
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -qF "你好世界"; then
|
||||
result "短文本 → send-keys 原文发送" "PASS"
|
||||
else
|
||||
result "短文本 → send-keys 原文发送" "FAIL" "日志: $(echo "$log" | head -5)"
|
||||
fi
|
||||
|
||||
# 2b. 非文件路径字符串(不存在)
|
||||
clear_log
|
||||
output=$(run_script "%1" "这个路径不存在/abc.txt" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -qF "这个路径不存在/abc.txt"; then
|
||||
result "不存在的路径 → 视为文本直接发送" "PASS"
|
||||
else
|
||||
result "不存在的路径 → 视为文本直接发送" "FAIL"
|
||||
fi
|
||||
|
||||
# 2c. 普通字符串不应包含"请读取"
|
||||
if ! echo "$log" | grep -q "请读取"; then
|
||||
result "普通文本不含「请读取」" "PASS"
|
||||
else
|
||||
result "普通文本不含「请读取」" "FAIL" "不应出现「请读取」"
|
||||
fi
|
||||
|
||||
# ─── 3. 文件路径 → 生成指令 ───
|
||||
echo ""
|
||||
echo "── 3. 文件路径 → 生成「请读取」指令 ──"
|
||||
|
||||
# 3a. 绝对路径文件
|
||||
test_file=$(create_test_file "prompt.md" "检查密码策略配置")
|
||||
clear_log
|
||||
output=$(run_script "%1" "$test_file" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
expected="请读取 ${test_file} 并执行其中的内容"
|
||||
if echo "$log" | grep -qF "$expected"; then
|
||||
result "文件(绝对路径)→ 生成指令" "PASS"
|
||||
else
|
||||
result "文件(绝对路径)→ 生成指令" "FAIL" "期望含: $expected"
|
||||
fi
|
||||
|
||||
# 3b. 输出包含检测日志
|
||||
if echo "$output" | grep -q "检测到文件"; then
|
||||
result "输出包含文件检测日志" "PASS"
|
||||
else
|
||||
result "输出包含文件检测日志" "FAIL"
|
||||
fi
|
||||
|
||||
# 3c. 文件内容不应被直接发送
|
||||
file_content="检查密码策略配置"
|
||||
if ! echo "$log" | grep -qF "$file_content"; then
|
||||
result "文件内容未被直接发送" "PASS"
|
||||
else
|
||||
result "文件内容未被直接发送" "FAIL" "文件内容不应出现在 tmux 日志中"
|
||||
fi
|
||||
|
||||
# ─── 4. 相对路径 → 解析为绝对路径 ───
|
||||
echo ""
|
||||
echo "── 4. 相对路径解析 ──"
|
||||
|
||||
# 4a. 相对路径文件
|
||||
rel_file=$(create_test_file "relative-test.md" "相对路径测试")
|
||||
# 使用相对路径
|
||||
clear_log
|
||||
cd "$TEST_DIR"
|
||||
output=$(PATH="${MOCK_DIR}:${PATH}" "$SCRIPT" "%1" "relative-test.md" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -qF "请读取 ${rel_file}"; then
|
||||
result "相对路径 → 解析为绝对路径" "PASS"
|
||||
else
|
||||
result "相对路径 → 解析为绝对路径" "FAIL" "日志: $(echo "$log" | head -3)"
|
||||
fi
|
||||
cd /workspace
|
||||
|
||||
# ─── 5. 特殊字符 ───
|
||||
echo ""
|
||||
echo "── 5. 特殊字符处理 ──"
|
||||
|
||||
# 5a. 含空格的文件名
|
||||
space_file=$(create_test_file "space test.md" "空格文件内容")
|
||||
clear_log
|
||||
output=$(run_script "%1" "$space_file" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -qF "请读取"; then
|
||||
result "含空格的文件名 → 正常处理" "PASS"
|
||||
else
|
||||
result "含空格的文件名 → 正常处理" "FAIL"
|
||||
fi
|
||||
|
||||
# 5b. 含中文的文本
|
||||
clear_log
|
||||
output=$(run_script "%1" "你好,请执行安全审计任务!" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -qF "你好,请执行安全审计任务!"; then
|
||||
result "中文文本 → 原样发送" "PASS"
|
||||
else
|
||||
result "中文文本 → 原样发送" "FAIL"
|
||||
fi
|
||||
|
||||
# 5c. 含特殊符号的文本
|
||||
clear_log
|
||||
output=$(run_script "%1" 'echo "hello" | grep foo' 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -qF 'echo "hello" | grep foo'; then
|
||||
result "特殊符号文本 → 原样发送" "PASS"
|
||||
else
|
||||
result "特殊符号文本 → 原样发送" "FAIL"
|
||||
fi
|
||||
|
||||
# ─── 6. 自定义 submit-delay ───
|
||||
echo ""
|
||||
echo "── 6. submit-delay 参数 ──"
|
||||
|
||||
# 6a. 默认 delay=1(通过日志验证 send-keys 调用次数)
|
||||
clear_log
|
||||
output=$(run_script "%1" "测试默认delay" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
send_count=$(echo "$log" | grep -c "send-keys" || true)
|
||||
if [[ "$send_count" -ge 2 ]]; then
|
||||
result "默认 delay → send-keys 至少调用 2 次(文本+Enter)" "PASS" "实际 ${send_count} 次"
|
||||
else
|
||||
result "默认 delay → send-keys 至少调用 2 次" "FAIL" "实际 ${send_count} 次"
|
||||
fi
|
||||
|
||||
# 6b. 自定义 delay
|
||||
clear_log
|
||||
output=$(run_script "%1" "自定义delay测试" 2 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -q "send-keys"; then
|
||||
result "自定义 delay=2 → 正常执行" "PASS"
|
||||
else
|
||||
result "自定义 delay=2 → 正常执行" "FAIL"
|
||||
fi
|
||||
|
||||
# ─── 7. 目录路径 ───
|
||||
echo ""
|
||||
echo "── 7. 边界情况 ──"
|
||||
|
||||
# 7a. 传入目录(不是文件 -f 失败)
|
||||
clear_log
|
||||
output=$(run_script "%1" "/tmp" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -qF "/tmp"; then
|
||||
result "目录路径 → 视为文本发送" "PASS"
|
||||
else
|
||||
result "目录路径 → 视为文本发送" "FAIL"
|
||||
fi
|
||||
if ! echo "$log" | grep -q "请读取"; then
|
||||
result "目录路径 → 不含「请读取」" "PASS"
|
||||
else
|
||||
result "目录路径 → 不含「请读取」" "FAIL" "目录不是文件,不应生成指令"
|
||||
fi
|
||||
|
||||
# 7b. 空文件(文件存在但内容为空)
|
||||
empty_file=$(create_test_file "empty.md" "")
|
||||
clear_log
|
||||
output=$(run_script "%1" "$empty_file" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if echo "$log" | grep -qF "请读取 ${empty_file}"; then
|
||||
result "空文件 → 仍生成「请读取」指令" "PASS"
|
||||
else
|
||||
result "空文件 → 仍生成「请读取」指令" "FAIL"
|
||||
fi
|
||||
|
||||
# 7c. 多行内容的文件
|
||||
multi_file=$(create_test_file "multi.md" "第一行
|
||||
第二行
|
||||
第三行
|
||||
第四行")
|
||||
clear_log
|
||||
output=$(run_script "%1" "$multi_file" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
if ! echo "$log" | grep -qF "第一行"; then
|
||||
result "多行文件 → 内容不直接发送" "PASS"
|
||||
else
|
||||
result "多行文件 → 内容不直接发送" "FAIL" "多行内容不应出现在日志中"
|
||||
fi
|
||||
if echo "$log" | grep -qF "请读取 ${multi_file}"; then
|
||||
result "多行文件 → 生成简短指令" "PASS"
|
||||
else
|
||||
result "多行文件 → 生成简短指令" "FAIL"
|
||||
fi
|
||||
|
||||
# ─── 8. send-keys 调用模式验证 ───
|
||||
echo ""
|
||||
echo "── 8. tmux 调用模式 ──"
|
||||
|
||||
# 8a. 验证 send-keys 被调用两次(文本 + Enter)
|
||||
clear_log
|
||||
output=$(run_script "%1" "模式验证" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
enter_count=$(echo "$log" | grep -c "send-keys.*Enter" || true)
|
||||
text_count=$(echo "$log" | grep -c "send-keys.*模式验证" || true)
|
||||
if [[ "$text_count" -ge 1 && "$enter_count" -ge 1 ]]; then
|
||||
result "send-keys 调用模式:文本 + Enter" "PASS" "文本${text_count}次 Enter${enter_count}次"
|
||||
else
|
||||
result "send-keys 调用模式:文本 + Enter" "FAIL" "文本${text_count}次 Enter${enter_count}次"
|
||||
fi
|
||||
|
||||
# 8b. 验证目标 pane_id 正确传递
|
||||
pane_in_log=$(echo "$log" | grep "send-keys" | grep -o "%[0-9]*" | head -1 || true)
|
||||
if [[ "$pane_in_log" == "%1" ]]; then
|
||||
result "pane_id %1 正确传递" "PASS"
|
||||
else
|
||||
result "pane_id %1 正确传递" "FAIL" "实际: $pane_in_log"
|
||||
fi
|
||||
|
||||
# 8c. 不同 pane_id
|
||||
clear_log
|
||||
output=$(run_script "%3" "pane3测试" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
pane_in_log=$(echo "$log" | grep "send-keys" | grep -o "%[0-9]*" | head -1 || true)
|
||||
if [[ "$pane_in_log" == "%3" ]]; then
|
||||
result "pane_id %3 正确传递" "PASS"
|
||||
else
|
||||
result "pane_id %3 正确传递" "FAIL" "实际: $pane_in_log"
|
||||
fi
|
||||
|
||||
# 8d. capture-pane 被调用(等待 prompt + 验证提交)
|
||||
clear_log
|
||||
output=$(run_script "%1" "capture测试" 2>&1 || true)
|
||||
log=$(read_log)
|
||||
capture_count=$(echo "$log" | grep -c "capture-pane" || true)
|
||||
if [[ "$capture_count" -ge 1 ]]; then
|
||||
result "capture-pane 被调用" "PASS" "共 ${capture_count} 次"
|
||||
else
|
||||
result "capture-pane 被调用" "FAIL"
|
||||
fi
|
||||
|
||||
# ─── 9. 无 tmux 环境 ───
|
||||
echo ""
|
||||
echo "── 9. 无 tmux 环境 ──"
|
||||
|
||||
# 不使用 mock,直接运行(系统可能无 tmux 或无 session)
|
||||
if ! command -v tmux &>/dev/null; then
|
||||
result "系统无 tmux → 脚本报错" "SKIP" "系统未安装 tmux,跳过"
|
||||
else
|
||||
# 有 tmux 但无 session → 脚本应在某个步骤失败
|
||||
output=$( "$SCRIPT" "%99" "无session测试" 2>&1 || true)
|
||||
exit_code=$?
|
||||
# 预期:tmux 操作会失败,但由于 set -e 脚本会退出
|
||||
result "无 tmux session → 脚本异常退出" "PASS" "exit=$exit_code"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
echo ""
|
||||
echo "════════════════════════════════════"
|
||||
echo -e " ${GREEN}通过: ${pass}${RESET} ${RED}失败: ${fail}${RESET} ${YELLOW}跳过: ${skip}${RESET} 总计: $((pass + fail + skip))"
|
||||
echo "════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
if [[ "$fail" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
Executable
+392
@@ -0,0 +1,392 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# tmux 实时监控面板 — 管理与显示
|
||||
#
|
||||
# 使用方法:
|
||||
# bash scripts/tmux-monitor.sh # 创建监控面板 (5秒刷新)
|
||||
# bash scripts/tmux-monitor.sh <秒数> # 创建监控面板 (自定义刷新)
|
||||
# bash scripts/tmux-monitor.sh -k # 关闭监控面板
|
||||
# bash scripts/tmux-monitor.sh -r # 重启监控面板
|
||||
# bash scripts/tmux-monitor.sh -h # 显示帮助
|
||||
# bash scripts/tmux-monitor.sh --display # (内部) 运行显示循环
|
||||
# ==============================================================================
|
||||
|
||||
SCRIPT_PATH="/workspace/scripts/tmux-monitor.sh"
|
||||
|
||||
# ── 参数解析 ──────────────────────────────────────────────
|
||||
|
||||
ACTION="create"
|
||||
INTERVAL=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--display)
|
||||
ACTION="display"; shift
|
||||
;;
|
||||
-k|--kill)
|
||||
ACTION="kill"; shift
|
||||
;;
|
||||
-r|--restart)
|
||||
ACTION="restart"; shift
|
||||
;;
|
||||
-h|--help)
|
||||
ACTION="help"; shift
|
||||
;;
|
||||
-*)
|
||||
echo "未知选项: $1" >&2; exit 1
|
||||
;;
|
||||
*)
|
||||
INTERVAL="$1"; shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
INTERVAL="${INTERVAL:-1}"
|
||||
|
||||
# ── 前置检查 ──────────────────────────────────────────────
|
||||
|
||||
if ! command -v tmux &>/dev/null; then
|
||||
echo "错误: tmux 未安装" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 管理函数 ──────────────────────────────────────────────
|
||||
|
||||
find_monitor_pane() {
|
||||
tmux list-panes -F '#{pane_index} #{pane_start_command}' 2>/dev/null \
|
||||
| grep -i 'tmux-monitor' \
|
||||
| head -1 \
|
||||
| awk '{print $1}'
|
||||
}
|
||||
|
||||
do_kill() {
|
||||
local pane_idx
|
||||
pane_idx=$(find_monitor_pane)
|
||||
if [ -n "$pane_idx" ]; then
|
||||
tmux kill-pane -t "$pane_idx"
|
||||
echo "监控面板 (Pane ${pane_idx}) 已关闭"
|
||||
return 0
|
||||
else
|
||||
echo "未找到运行中的监控面板"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
do_create() {
|
||||
if [ -z "${TMUX:-}" ]; then
|
||||
echo "错误: 需要在 tmux 会话内运行" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 关闭已有监控面板
|
||||
local pane_idx
|
||||
pane_idx=$(find_monitor_pane)
|
||||
if [ -n "$pane_idx" ]; then
|
||||
tmux kill-pane -t "$pane_idx"
|
||||
fi
|
||||
|
||||
# 右侧监控面板固定宽度 60
|
||||
local monitor_width=60
|
||||
|
||||
tmux split-window -h -l "$monitor_width" \
|
||||
"TMUX_MONITOR_INTERVAL=${INTERVAL} bash ${SCRIPT_PATH} --display"
|
||||
|
||||
echo "监控面板已启动 (${INTERVAL} 秒刷新)"
|
||||
}
|
||||
|
||||
# ── 非显示操作: 执行后退出 ───────────────────────────────
|
||||
|
||||
if [ "$ACTION" != "display" ]; then
|
||||
case "$ACTION" in
|
||||
help)
|
||||
echo "用法: bash $(basename "$0") [选项] [刷新间隔]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " (空) 创建监控面板 (默认 ${INTERVAL} 秒刷新)"
|
||||
echo " <秒数> 创建监控面板 (自定义刷新间隔)"
|
||||
echo " -k, --kill 关闭监控面板"
|
||||
echo " -r, --restart 重启监控面板"
|
||||
echo " -h, --help 显示帮助信息"
|
||||
;;
|
||||
kill)
|
||||
do_kill
|
||||
;;
|
||||
restart)
|
||||
do_kill 2>/dev/null || true
|
||||
do_create
|
||||
;;
|
||||
create)
|
||||
do_create
|
||||
;;
|
||||
esac
|
||||
exit $?
|
||||
fi
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 以下为 display 模式: 运行监控显示循环
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
# 颜色定义 — 使用 $'...' ANSI-C 引号,赋值时即产生真正的 ESC 字节
|
||||
readonly R=$'\033[0;31m' # RED
|
||||
readonly G=$'\033[0;32m' # GREEN
|
||||
readonly Y=$'\033[0;33m' # YELLOW
|
||||
readonly B=$'\033[0;34m' # BLUE
|
||||
readonly P=$'\033[0;35m' # PURPLE
|
||||
readonly C=$'\033[0;36m' # CYAN
|
||||
readonly D=$'\033[0;90m' # GRAY/DIM
|
||||
readonly BD=$'\033[1m' # BOLD
|
||||
readonly N=$'\033[0m' # RESET
|
||||
|
||||
# 刷新间隔(秒)— 由管理模式通过 TMUX_MONITOR_INTERVAL 传入
|
||||
INTERVAL="${TMUX_MONITOR_INTERVAL:-1}"
|
||||
|
||||
# ── 工具函数 ──────────────────────────────────────────────
|
||||
|
||||
# 生成指定宽度的分隔线
|
||||
make_sep() {
|
||||
local width="${1:-80}"
|
||||
local sep_char='─'
|
||||
printf '%*s' "$width" '' | tr ' ' "$sep_char"
|
||||
}
|
||||
|
||||
# ── 显示层级常量 ─────────────────────────────────────────
|
||||
# 根据终端宽度决定显示详细程度
|
||||
LVL_MINIMAL=0 # < 50 列:仅显示核心信息
|
||||
LVL_COMPACT=1 # 50-69 列:省略 PID、路径、子命令
|
||||
LVL_NORMAL=2 # 70-99 列:省略子命令详情
|
||||
LVL_FULL=3 # >= 100 列:显示全部
|
||||
|
||||
get_display_level() {
|
||||
local cols="$1"
|
||||
if [ "$cols" -lt 50 ]; then
|
||||
echo "$LVL_MINIMAL"
|
||||
elif [ "$cols" -lt 70 ]; then
|
||||
echo "$LVL_COMPACT"
|
||||
elif [ "$cols" -lt 100 ]; then
|
||||
echo "$LVL_NORMAL"
|
||||
else
|
||||
echo "$LVL_FULL"
|
||||
fi
|
||||
}
|
||||
|
||||
# 记录终端尺寸,用于检测 resize
|
||||
last_cols=0
|
||||
last_rows=0
|
||||
|
||||
while true; do
|
||||
# ── 检测终端尺寸变化,清屏防重叠 ──
|
||||
cur_cols=$(tput cols 2>/dev/null || echo 80)
|
||||
cur_rows=$(tput lines 2>/dev/null || echo 24)
|
||||
if [ "$cur_cols" != "$last_cols" ] || [ "$cur_rows" != "$last_rows" ]; then
|
||||
tput clear
|
||||
last_cols=$cur_cols
|
||||
last_rows=$cur_rows
|
||||
fi
|
||||
|
||||
# 当前显示层级
|
||||
level=$(get_display_level "$cur_cols")
|
||||
|
||||
# 动态分隔线
|
||||
SEP=$(make_sep "$cur_cols")
|
||||
|
||||
# ── 构建输出到缓冲区 ──
|
||||
buf=""
|
||||
buf="${buf}\n"
|
||||
buf="${buf}${BD}${SEP}${N}\n"
|
||||
|
||||
# 标题行:自适应宽度
|
||||
title=" tmux 实时监控 $(date '+%H:%M:%S')"
|
||||
if [ "$level" -ge "$LVL_COMPACT" ]; then
|
||||
buf="${buf}${C}${BD}${title}${N}\n"
|
||||
else
|
||||
buf="${buf}${C}${BD} 监控 $(date '+%H:%M:%S')${N}\n"
|
||||
fi
|
||||
|
||||
buf="${buf}${BD}${SEP}${N}\n"
|
||||
buf="${buf}\n"
|
||||
|
||||
cur_session=$(tmux display-message -p '#{session_name}' 2>/dev/null)
|
||||
|
||||
# 一次性获取所有面板信息(含状态字段)
|
||||
panes_info=$(tmux list-panes -a -F \
|
||||
'#{session_name}|#{session_created}|#{session_attached}|#{window_index}|#{window_name}|#{window_active}|#{window_width}x#{window_height}|#{pane_index}|#{pane_title}|#{pane_current_command}|#{pane_current_path}|#{pane_active}|#{pane_pid}|#{pane_width}x#{pane_height}|#{pane_dead}|#{pane_dead_status}|#{pane_in_mode}|#{pane_mode}' \
|
||||
2>/dev/null)
|
||||
|
||||
total_sessions=0
|
||||
total_windows=0
|
||||
total_panes=0
|
||||
session_idx=0
|
||||
|
||||
if [ -n "$panes_info" ]; then
|
||||
prev_session=""
|
||||
prev_window=""
|
||||
|
||||
while IFS='|' read -r ssn s_created s_attached widx wname w_active w_size pidx ptitle cmd path active pid p_size p_dead p_dead_status p_in_mode p_mode; do
|
||||
# ── 新会话 ──
|
||||
if [ "$ssn" != "$prev_session" ]; then
|
||||
[ -n "$prev_session" ] && buf="${buf}\n"
|
||||
total_sessions=$((total_sessions + 1))
|
||||
session_idx=$((session_idx + 1))
|
||||
|
||||
if [ "$ssn" = "$cur_session" ]; then
|
||||
s_marker="${G}●${N}"
|
||||
s_name="${BD}${G}${ssn}${N}"
|
||||
else
|
||||
s_marker="${D}○${N}"
|
||||
s_name="${P}${ssn}${N}"
|
||||
fi
|
||||
|
||||
# Session 状态标签
|
||||
s_status=""
|
||||
if [ "$s_attached" = "0" ]; then
|
||||
s_status=" ${Y}${BD}[detached]${N}"
|
||||
fi
|
||||
|
||||
s_time=""
|
||||
if [ "$level" -ge "$LVL_NORMAL" ] && [ -n "$s_created" ] && [ "$s_created" -gt 0 ] 2>/dev/null; then
|
||||
s_time=$(date -d "@$s_created" '+%m-%d %H:%M' 2>/dev/null)
|
||||
fi
|
||||
|
||||
# 根据宽度调整 Session 行
|
||||
if [ "$level" -ge "$LVL_COMPACT" ]; then
|
||||
s_line=" ${s_marker} ${BD}Session${N} ${session_idx} ${s_name}${s_status}"
|
||||
else
|
||||
s_line=" ${s_marker} ${BD}S${N}${session_idx} ${s_name}${s_status}"
|
||||
fi
|
||||
[ -n "$s_time" ] && s_line="${s_line} ${D}[自 ${s_time}]${N}"
|
||||
buf="${buf}${s_line}\n"
|
||||
|
||||
prev_session="$ssn"
|
||||
prev_window=""
|
||||
fi
|
||||
|
||||
# ── 新窗口 ──
|
||||
if [ "$widx:$wname" != "$prev_window" ]; then
|
||||
total_windows=$((total_windows + 1))
|
||||
|
||||
if [ "$w_active" = "1" ] && [ "$ssn" = "$cur_session" ]; then
|
||||
w_marker="${G}●${N}"
|
||||
w_name="${BD}${G}${wname}${N}"
|
||||
else
|
||||
w_marker="${D}○${N}"
|
||||
w_name="${Y}${wname}${N}"
|
||||
fi
|
||||
|
||||
# Window 状态标签
|
||||
w_status=""
|
||||
if [ "$w_active" = "1" ] && [ "$ssn" = "$cur_session" ]; then
|
||||
w_status=" ${G}[active]${N}"
|
||||
fi
|
||||
|
||||
# 根据宽度决定是否显示尺寸
|
||||
if [ "$level" -ge "$LVL_COMPACT" ]; then
|
||||
w_line=" ${D}├─${N} ${w_marker} ${BD}Win${N} ${widx} ${w_name}${w_status} ${D}[${w_size}]${N}"
|
||||
else
|
||||
w_line=" ${D}├${N} ${w_marker} ${w_name}${w_status}"
|
||||
fi
|
||||
buf="${buf}${w_line}\n"
|
||||
prev_window="$widx:$wname"
|
||||
fi
|
||||
|
||||
# ── 面板 ──
|
||||
total_panes=$((total_panes + 1))
|
||||
|
||||
if [ "$active" = "1" ] && [ "$ssn" = "$cur_session" ]; then
|
||||
p_marker="${G}●${N}"
|
||||
else
|
||||
p_marker="${D}○${N}"
|
||||
fi
|
||||
|
||||
case "$cmd" in
|
||||
bash|zsh|fish|sh) cmd_color="$D" ;;
|
||||
vim|nvim|vi) cmd_color="$G" ;;
|
||||
python|python3) cmd_color="$Y" ;;
|
||||
node) cmd_color="$G" ;;
|
||||
ssh) cmd_color="$R" ;;
|
||||
tmux-monitor.sh|tmux_monitor.sh) cmd_color="$C" ;;
|
||||
*) cmd_color="$N" ;;
|
||||
esac
|
||||
|
||||
# 路径缩写:根据层级决定缩写程度
|
||||
short_path=""
|
||||
if [ "$level" -ge "$LVL_NORMAL" ]; then
|
||||
short_path=$(echo "$path" | sed "s|^$HOME|~|" | awk -F/ '{
|
||||
if(NF>3) print ".."/$(NF-1)/$NF; else print $0
|
||||
}')
|
||||
elif [ "$level" -ge "$LVL_COMPACT" ]; then
|
||||
short_path=$(echo "$path" | sed "s|^$HOME|~|" | awk -F/ '{
|
||||
print $NF
|
||||
}')
|
||||
fi
|
||||
|
||||
# 子命令:仅在 FULL 级别显示
|
||||
child_cmd=""
|
||||
if [ "$level" -ge "$LVL_FULL" ] && [ "$(uname)" = "Linux" ]; then
|
||||
child_pid=$(ps --ppid "$pid" -o pid= 2>/dev/null | head -1 | tr -d ' ')
|
||||
if [ -n "$child_pid" ]; then
|
||||
child_cmd=$(ps -p "$child_pid" -o args= 2>/dev/null)
|
||||
fi
|
||||
fi
|
||||
|
||||
# 格式: 根据层级决定显示内容
|
||||
p_title="${ptitle}"
|
||||
[ -z "$p_title" ] && p_title="$cmd"
|
||||
|
||||
# Pane 状态标签
|
||||
p_status=""
|
||||
if [ "$p_dead" = "1" ]; then
|
||||
p_status=" ${R}${BD}[dead:${p_dead_status}]${N}"
|
||||
fi
|
||||
if [ "$p_in_mode" = "1" ] && [ -n "$p_mode" ]; then
|
||||
p_status="${p_status} ${Y}[${p_mode}]${N}"
|
||||
fi
|
||||
if [ "$active" = "1" ] && [ "$ssn" = "$cur_session" ]; then
|
||||
p_status="${p_status} ${G}[active]${N}"
|
||||
fi
|
||||
|
||||
if [ "$level" -ge "$LVL_NORMAL" ]; then
|
||||
# NORMAL / FULL: Pane 编号 + 标题 + 命令 + 尺寸 + 状态
|
||||
p_line=" ${D}│ ├─${N} ${p_marker} ${BD}Pane${N} ${pidx} ${C}${p_title}${N} ${cmd_color}${cmd}${N} ${D}[${p_size}]${N}${p_status}"
|
||||
if [ "$level" -ge "$LVL_FULL" ]; then
|
||||
p_line="${p_line} ${D}PID:${pid}${N}"
|
||||
fi
|
||||
elif [ "$level" -ge "$LVL_COMPACT" ]; then
|
||||
# COMPACT: Pane 编号 + 标题 + 命令
|
||||
p_line=" ${D}│ ├${N} ${p_marker} ${BD}P${N}${pidx} ${C}${p_title}${N} ${cmd_color}${cmd}${N}"
|
||||
else
|
||||
# MINIMAL: 仅标题 + 命令
|
||||
p_line=" ${D}│${N} ${p_marker} ${C}${p_title}${N}"
|
||||
fi
|
||||
|
||||
buf="${buf}${p_line}\n"
|
||||
|
||||
if [ -n "$child_cmd" ]; then
|
||||
c_line=" ${D}│ │ ↳${N} ${D}${child_cmd}${N}"
|
||||
buf="${buf}${c_line}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$short_path" ]; then
|
||||
sp_line=" ${D}│ │ ${N}${D}${short_path}${N}"
|
||||
buf="${buf}${sp_line}\n"
|
||||
fi
|
||||
|
||||
done <<< "$panes_info"
|
||||
fi
|
||||
|
||||
# ── 系统概要 ──
|
||||
buf="${buf}\n"
|
||||
buf="${buf}${D}${SEP}${N}\n"
|
||||
buf="${buf} ${BD}总计:${N} ${total_sessions} 会话 | ${total_windows} 窗口 | ${total_panes} 面板\n"
|
||||
buf="${buf}${D}${SEP}${N}\n"
|
||||
buf="${buf} ${D}每 ${INTERVAL} 秒刷新 | Ctrl+C 退出${N}\n"
|
||||
|
||||
# ── 双缓冲输出:移到顶部 → 写入 → 清除尾部残留 ──
|
||||
# 每行末尾添加 \033[K(清除到行尾),防止短行覆盖长行时残留旧字符
|
||||
buf="${buf//\\n/\\033[K\\n}"
|
||||
|
||||
tput cup 0 0
|
||||
printf '%b' "$buf"
|
||||
tput ed
|
||||
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# tmux-send-prompt.sh — 可靠地向 tmux pane 中的 cc 发送提示词
|
||||
#
|
||||
# 解决的问题:
|
||||
# 1. 发送长文本后 Enter 可能未被正确接收
|
||||
# 2. 前一个命令还没执行完就发送新命令
|
||||
# 3. 无法确认命令是否已成功提交
|
||||
# 4. 文件内容过长时 send-keys 不可靠
|
||||
#
|
||||
# 用法:
|
||||
# tmux-send-prompt.sh <pane_id> <text_string|text_file> [--submit-delay <seconds>]
|
||||
#
|
||||
# 行为:
|
||||
# - 文本字符串: 直接发送
|
||||
# - 文件路径: 发送简短指令,让 cc 自己读取文件并执行
|
||||
#
|
||||
# 示例:
|
||||
# tmux-send-prompt.sh %1 "短文本提示"
|
||||
# tmux-send-prompt.sh %1 /path/to/prompt.md
|
||||
# tmux-send-prompt.sh %1 "文本" --submit-delay 2
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PANE_ID="${1:?用法: tmux-send-prompt.sh <pane_id> <text|file> [--submit-delay <seconds>]}"
|
||||
TEXT_OR_FILE="${2:?需要提供文本或文件路径}"
|
||||
SUBMIT_DELAY="${3:-1}"
|
||||
|
||||
# 构建发送内容:文件则让 cc 自行读取,文本则直接发送
|
||||
if [[ -f "$TEXT_OR_FILE" ]]; then
|
||||
# 解析为绝对路径,确保 cc 能正确找到文件
|
||||
FILE_PATH=$(realpath "$TEXT_OR_FILE")
|
||||
TEXT="请读取 ${FILE_PATH} 并执行其中的内容"
|
||||
echo "[tmux-send] 检测到文件: ${FILE_PATH},将指示 cc 自行读取"
|
||||
else
|
||||
TEXT="$TEXT_OR_FILE"
|
||||
fi
|
||||
|
||||
# 步骤1: 等待 cc 的 prompt 出现(❯ 字符)
|
||||
echo "[tmux-send] 等待 cc 的 prompt 就绪..."
|
||||
for i in $(seq 1 30); do
|
||||
LAST_LINE=$(tmux capture-pane -t "$PANE_ID" -p | tail -3 | grep -c '❯' || true)
|
||||
if [[ "$LAST_LINE" -ge 1 ]]; then
|
||||
echo "[tmux-send] cc 已就绪 (等待 ${i}s)"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 步骤2: 发送文本(不带 Enter)
|
||||
tmux send-keys -t "$PANE_ID" "$TEXT"
|
||||
sleep "${SUBMIT_DELAY}"
|
||||
|
||||
# 步骤3: 单独发送 Enter
|
||||
tmux send-keys -t "$PANE_ID" Enter
|
||||
sleep 2
|
||||
|
||||
# 步骤4: 验证 — 检查 ❯ 是否消失(cc 正在处理输入)
|
||||
# 注意: 不能用文本匹配验证,因为 cc 会在输出中回显用户消息,导致误判
|
||||
sleep 2
|
||||
for i in $(seq 1 5); do
|
||||
CAPTURED=$(tmux capture-pane -t "$PANE_ID" -p | tail -5)
|
||||
if ! echo "$CAPTURED" | grep -q '❯'; then
|
||||
echo "[tmux-send] 命令已成功提交 (第 $i 次检查)"
|
||||
exit 0
|
||||
fi
|
||||
# ❯ 仍在,可能 Enter 未生效,重试
|
||||
echo "[tmux-send] ❯ 仍在,重试 Enter 第 $i 次..."
|
||||
tmux send-keys -t "$PANE_ID" Enter
|
||||
sleep 3
|
||||
done
|
||||
|
||||
echo "[tmux-send] 警告: 5次重试后 ❯ 仍存在,请手动检查"
|
||||
exit 1
|
||||
Executable
+327
@@ -0,0 +1,327 @@
|
||||
#!/bin/bash
|
||||
# tmux-team.sh — 管理 tmux 4 Pane 团队工作空间
|
||||
#
|
||||
# 使用方法:
|
||||
# bash scripts/tmux-team.sh # 创建默认会话 isos-team
|
||||
# bash scripts/tmux-team.sh <名称> # 创建指定名称的会话
|
||||
# bash scripts/tmux-team.sh -k [名称] # 杀死会话 (默认 isos-team)
|
||||
# bash scripts/tmux-team.sh -l # 列出所有会话
|
||||
# bash scripts/tmux-team.sh -a [名称] # 附加到已有会话
|
||||
# bash scripts/tmux-team.sh -h # 显示帮助
|
||||
#
|
||||
# 行为 (create):
|
||||
# - 已在 tmux 中: 直接在当前窗口构建布局
|
||||
# - 不在 tmux 中: 启动新 tmux 会话并附加
|
||||
#
|
||||
# 视觉布局:
|
||||
# +----------+------------------------------------------+
|
||||
# | | Pane 2 (后端) | Pane 3 (前端) |
|
||||
# | Pane 1 +------------------------------------------+
|
||||
# | (项目) | Pane 4 (测试) |
|
||||
# | | |
|
||||
# +----------+------------------------------------------+
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
WORKSPACE="/workspace"
|
||||
DEFAULT_SESSION="isos-team"
|
||||
|
||||
# ── 参数解析 ──────────────────────────────────────────────
|
||||
|
||||
ACTION="create"
|
||||
SESSION_NAME=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-k|--kill)
|
||||
ACTION="kill"
|
||||
shift
|
||||
if [ $# -gt 0 ] && [ "${1:0:1}" != "-" ]; then
|
||||
SESSION_NAME="$1"; shift
|
||||
fi
|
||||
;;
|
||||
-l|--list)
|
||||
ACTION="list"; shift
|
||||
;;
|
||||
-a|--attach)
|
||||
ACTION="attach"
|
||||
shift
|
||||
if [ $# -gt 0 ] && [ "${1:0:1}" != "-" ]; then
|
||||
SESSION_NAME="$1"; shift
|
||||
fi
|
||||
;;
|
||||
-h|--help)
|
||||
ACTION="help"; shift
|
||||
;;
|
||||
-*)
|
||||
echo "未知选项: $1" >&2; exit 1
|
||||
;;
|
||||
*)
|
||||
SESSION_NAME="$1"; shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SESSION_NAME="${SESSION_NAME:-$DEFAULT_SESSION}"
|
||||
|
||||
# ── 前置检查 ──────────────────────────────────────────────
|
||||
|
||||
if ! command -v tmux &>/dev/null; then
|
||||
echo "错误: tmux 未安装" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 简单操作: 直接执行后退出 ──────────────────────────────
|
||||
|
||||
case "$ACTION" in
|
||||
help)
|
||||
echo "用法: bash $(basename "$0") [选项] [会话名称]"
|
||||
echo ""
|
||||
echo "选项:"
|
||||
echo " (空) 创建默认会话 ($DEFAULT_SESSION)"
|
||||
echo " <名称> 创建指定名称的会话"
|
||||
echo " -k [名称] 杀死会话 (默认 $DEFAULT_SESSION)"
|
||||
echo " -l, --list 列出所有 tmux 会话"
|
||||
echo " -a [名称] 附加到已有会话"
|
||||
echo " -h, --help 显示帮助信息"
|
||||
exit 0
|
||||
;;
|
||||
list)
|
||||
if tmux ls 2>/dev/null; then true; else echo "无活跃 tmux 会话"; fi
|
||||
exit 0
|
||||
;;
|
||||
kill)
|
||||
if tmux kill-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
echo "会话 '$SESSION_NAME' 已终止"
|
||||
exit 0
|
||||
else
|
||||
echo "会话 '$SESSION_NAME' 不存在" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
attach)
|
||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
exec tmux attach-session -t "$SESSION_NAME"
|
||||
else
|
||||
echo "会话 '$SESSION_NAME' 不存在" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# 以下为 create 操作
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
# ── 基准比例 (源自 229×64 设计) ──────────────────────────
|
||||
# Pane 1 宽度: 68/229 ≈ 30%
|
||||
# Pane 4 高度: 20/64 ≈ 31%
|
||||
# Pane 3 宽度: 79/161 ≈ 49% (占右区域)
|
||||
# 使用先乘后除避免 bash 整数截断误差
|
||||
|
||||
BASE_W=229
|
||||
BASE_H=64
|
||||
BASE_P1_W=68
|
||||
BASE_P4_H=20
|
||||
BASE_P3_W=79
|
||||
BASE_RIGHT_W=$((BASE_W - BASE_P1_W)) # 161
|
||||
|
||||
# ── 获取终端尺寸 ──────────────────────────────────────────
|
||||
|
||||
get_terminal_size() {
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
TERM_W=$(tmux display-message -p '#{window_width}')
|
||||
TERM_H=$(tmux display-message -p '#{window_height}')
|
||||
else
|
||||
TERM_W=$(tput cols)
|
||||
TERM_H=$(tput lines)
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 等比例缩放计算 ───────────────────────────────────────
|
||||
|
||||
calc_proportions() {
|
||||
local w="$1" h="$2"
|
||||
|
||||
PANE1_W=$((w * BASE_P1_W / BASE_W))
|
||||
PANE4_H=$((h * BASE_P4_H / BASE_H))
|
||||
local right_w=$((w - PANE1_W))
|
||||
PANE3_W=$((right_w * BASE_P3_W / BASE_RIGHT_W))
|
||||
|
||||
# 保底最小值,防止极端小窗口出现 0 尺寸
|
||||
PANE1_W=$((PANE1_W < 40 ? 40 : PANE1_W))
|
||||
PANE4_H=$((PANE4_H < 8 ? 8 : PANE4_H))
|
||||
PANE3_W=$((PANE3_W < 30 ? 30 : PANE3_W))
|
||||
}
|
||||
|
||||
# ── 等待 shell 就绪并发送命令 ─────────────────────────────
|
||||
# 等待 pane 中的 shell 完成初始化(出现提示符),再发送命令
|
||||
# 解决: 在非 tmux 环境下,新 session 的 shell 可能未加载 .bashrc 别名
|
||||
|
||||
send_command_to_pane() {
|
||||
local pane="$1"
|
||||
local cmd="$2"
|
||||
local max_wait=15
|
||||
|
||||
# 等待 shell 提示符出现(说明 .bashrc 已加载,别名可用)
|
||||
for i in $(seq 1 "$max_wait"); do
|
||||
if tmux capture-pane -t "$pane" -p 2>/dev/null | tail -3 \
|
||||
| grep -qE '(\$|#|❯|>)\s*$'; then
|
||||
echo "[tmux-team] pane $pane shell 就绪 (等待 ${i}s)"
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# 分步发送: 先文本,再 Enter(避免 Enter 丢失)
|
||||
tmux send-keys -t "$pane" "$cmd"
|
||||
sleep 0.3
|
||||
tmux send-keys -t "$pane" Enter
|
||||
|
||||
# 验证命令是否已提交
|
||||
sleep 1
|
||||
local verify="${cmd:0:10}"
|
||||
for i in $(seq 1 3); do
|
||||
local captured
|
||||
captured=$(tmux capture-pane -t "$pane" -p | tail -10)
|
||||
if echo "$captured" | grep -qF "$verify"; then
|
||||
echo "[tmux-team] Enter 未生效,重试第 $i 次..."
|
||||
tmux send-keys -t "$pane" Enter
|
||||
sleep 1
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "[tmux-team] 警告: pane $pane 命令 '$cmd' 提交状态不确定"
|
||||
}
|
||||
|
||||
# ── 构建布局 (通用函数) ───────────────────────────────────
|
||||
# 参数: $1 = 初始 pane target, $2 = 宽, $3 = 高
|
||||
|
||||
build_layout() {
|
||||
local base_pane="$1"
|
||||
local w="$2" h="$3"
|
||||
calc_proportions "$w" "$h"
|
||||
|
||||
# Step 1: 水平分屏 → Pane 1 (左) | 右区域
|
||||
local right_id
|
||||
right_id=$(tmux split-window -h -t "$base_pane" -c "$WORKSPACE" -P -F '#{pane_id}')
|
||||
tmux resize-pane -t "$base_pane" -x "$PANE1_W"
|
||||
|
||||
# Step 2: 右区域垂直分出底部 Pane 4
|
||||
local bottom_id
|
||||
bottom_id=$(tmux split-window -v -t "$right_id" -l "$PANE4_H" -c "$WORKSPACE" -P -F '#{pane_id}')
|
||||
|
||||
# Step 3: 右上水平分为两列: Pane 2 | Pane 3
|
||||
local right_col2_id
|
||||
right_col2_id=$(tmux split-window -h -t "$right_id" -l "$PANE3_W" -c "$WORKSPACE" -P -F '#{pane_id}')
|
||||
|
||||
# ── 命名窗格 ──
|
||||
tmux select-pane -t "$base_pane" -T "项目"
|
||||
tmux select-pane -t "$right_id" -T "后端"
|
||||
tmux select-pane -t "$right_col2_id" -T "前端"
|
||||
tmux select-pane -t "$bottom_id" -T "测试"
|
||||
|
||||
# ── 启动 AI 编码工具(等待 shell 就绪后再发送,附带角色名称)──
|
||||
local runner="${TOOL_RUNNER:-cc}"
|
||||
send_command_to_pane "$base_pane" "${runner} --name \"项目\""
|
||||
send_command_to_pane "$right_id" "${runner} --name \"后端\""
|
||||
send_command_to_pane "$right_col2_id" "${runner} --name \"前端\""
|
||||
send_command_to_pane "$bottom_id" "${runner} --name \"测试\""
|
||||
|
||||
# ── 发送角色提示词 ──
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# 根据 runner 类型选择提示词目录
|
||||
if [ "$runner" = "oc" ]; then
|
||||
PROMPT_DIR="$(dirname "$SCRIPT_DIR")/.opencode/agents"
|
||||
PROMPT_FILES=("isos-pm.md" "isos-backend.md" "isos-frontend.md" "isos-test.md")
|
||||
else
|
||||
PROMPT_DIR="$(dirname "$SCRIPT_DIR")/.claude/agents"
|
||||
PROMPT_FILES=("isos-project-manager-agent.md" "isos-backend-agent.md" "isos-frontend-agent.md" "isos-test-agent.md")
|
||||
fi
|
||||
|
||||
# 并行发送,tmux-send-prompt.sh 会等待各工具就绪
|
||||
bash "$SCRIPT_DIR/tmux-send-prompt.sh" "$base_pane" "${PROMPT_DIR}/${PROMPT_FILES[0]}" &
|
||||
bash "$SCRIPT_DIR/tmux-send-prompt.sh" "$right_id" "${PROMPT_DIR}/${PROMPT_FILES[1]}" &
|
||||
bash "$SCRIPT_DIR/tmux-send-prompt.sh" "$right_col2_id" "${PROMPT_DIR}/${PROMPT_FILES[2]}" &
|
||||
bash "$SCRIPT_DIR/tmux-send-prompt.sh" "$bottom_id" "${PROMPT_DIR}/${PROMPT_FILES[3]}" &
|
||||
wait
|
||||
|
||||
# 选中主 Agent 窗格
|
||||
tmux select-pane -t "$base_pane"
|
||||
}
|
||||
|
||||
# ── 输出信息 ──────────────────────────────────────────────
|
||||
|
||||
print_info() {
|
||||
echo ""
|
||||
echo "窗格布局:"
|
||||
echo " +----------+------------------------------------------+"
|
||||
echo " | | Pane 2 (后端) | Pane 3 (前端) |"
|
||||
echo " | Pane 1 +------------------------------------------+"
|
||||
echo " | (项目) | Pane 4 (测试) |"
|
||||
echo " | | |"
|
||||
echo " +----------+------------------------------------------+"
|
||||
echo ""
|
||||
echo "角色分配:"
|
||||
echo " Pane 1: 项目 — 项目管理、任务协调"
|
||||
echo " Pane 2: 后端 — 服务端开发 (FastAPI + SQLite)"
|
||||
echo " Pane 3: 前端 — 桌面端开发 (Svelte 5 + PyWebView)"
|
||||
echo " Pane 4: 测试 — 全级别测试和覆盖率"
|
||||
echo ""
|
||||
echo "所有窗格已启动 AI 编码工具并加载角色提示词"
|
||||
}
|
||||
|
||||
# ── 创建布局 ──────────────────────────────────────────────
|
||||
|
||||
if [ -n "${TMUX:-}" ]; then
|
||||
# ── 情况 1: 已在 tmux 中 → 重命名会话并在当前窗口构建布局 ──
|
||||
|
||||
tmux rename-session -t "$(tmux display-message -p '#{session_name}')" "$SESSION_NAME" 2>/dev/null || true
|
||||
tmux rename-window -t "$(tmux display-message -p '#{window_id}')" "ISOS-Team" 2>/dev/null || true
|
||||
current_pane=$(tmux display-message -p '#{pane_id}')
|
||||
|
||||
# 检查当前窗口是否已有多个 pane
|
||||
pane_count=$(tmux list-panes -t "$(tmux display-message -p '#{window_id}')" | wc -l)
|
||||
if [ "$pane_count" -gt 1 ]; then
|
||||
echo "当前窗口已有 ${pane_count} 个窗格,跳过布局创建"
|
||||
echo "请先关闭多余窗格或切换到新窗口后再试"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
get_terminal_size
|
||||
build_layout "$current_pane" "$TERM_W" "$TERM_H"
|
||||
echo "团队工作空间已在当前窗口创建 (${TERM_W}×${TERM_H})"
|
||||
print_info
|
||||
|
||||
else
|
||||
# ── 情况 2: 不在 tmux 中 → 创建新会话并附加 ──
|
||||
|
||||
# 如果会话已存在,直接附加
|
||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
echo "会话 '$SESSION_NAME' 已存在,附加..."
|
||||
exec tmux attach-session -t "$SESSION_NAME"
|
||||
fi
|
||||
|
||||
get_terminal_size
|
||||
|
||||
# 创建新会话 (需要短暂后台创建以构建布局)
|
||||
tmux new-session -d -s "$SESSION_NAME" -c "$WORKSPACE"
|
||||
tmux rename-window -t "$SESSION_NAME:1" "ISOS-Team"
|
||||
|
||||
build_layout "$SESSION_NAME:1.1" "$TERM_W" "$TERM_H"
|
||||
|
||||
echo "团队工作空间 '$SESSION_NAME' 已创建 (${TERM_W}×${TERM_H})"
|
||||
print_info
|
||||
|
||||
if [ "${OPENCODE:-}" = "1" ] || [ "${CLAUDECODE:-}" = "1" ]; then
|
||||
# Claude Code 环境中无法 attach,仅打印提示
|
||||
echo "提示: 在 AI 编码工具中执行,跳过 attach"
|
||||
echo "请在终端中运行: tmux attach -t $SESSION_NAME"
|
||||
else
|
||||
# 用户终端中直接执行,自动 attach
|
||||
echo "正在附加到会话..."
|
||||
exec tmux attach-session -t "$SESSION_NAME"
|
||||
fi
|
||||
fi
|
||||
Reference in New Issue
Block a user