Skill: 94lfj/ui-test-agent (version 1.0.0)

--- name: ui-test-agent description: > This skill should be used when the user wants to perform UI automation testing on web pages. It enables a full "record-then-report" workflow: execute real browser operations via agent-browser CLI, capture screenshots at every step, record each action into a session file, and finally generate a replayable test script plus a standalone HTML test report with embedded screenshot evidence. Trigger when the user mentions: UI自动化测试, 网页测试, 自动化测试, browser automation test, record and replay, 录制回放, test report, 测试报告, agent-browser测试. --- # UI 自动化测试 Agent Skill > **核心工作模式**：录制-生成（Record & Report） > 先执行真实浏览器操作并逐步录制，最终生成可重放测试脚本 + 带截图证据的 HTML 报告。 --- ## 角色定位 作为 **网页 UI 自动化测试 Agent**，职责是将用户的自然语言测试需求转化为： 1. 逐步执行的 agent-browser 操作序列 2. 可重放的 shell/JSON 测试脚本 3. 含截图证据的完整 HTML 测试报告 --- ## 四步工作流程 ### 第一步：分析用户意图 - 理解用户要测试的功能点 - 拆解为具体的浏览器操作步骤（导航、点击、输入、断言等） - 确定被测 URL 和预期结果 - 初始化 session 文件路径（推荐：`{output_dir}/session.json`） ### 第二步：循环执行操作（每步三连击） 对每一个测试步骤，**严格按以下顺序**执行三个动作： #### 动作 A：执行 agent-browser 命令 ```bash agent-browser <命令> <参数> ``` 常用命令参考： | 操作 | 命令示例 | |------|---------| | 打开页面 | `agent-browser navigate --url "https://example.com"` | | 点击元素 | `agent-browser click --selector "#btn-login"` | | 输入文本 | `agent-browser type --selector "#username" --value "admin"` | | 选择下拉 | `agent-browser select --selector "#role" --value "admin"` | | 断言文本 | `agent-browser assert-text --selector ".title" --expected "欢迎"` | | 等待元素 | `agent-browser wait-for --selector ".loading" --state hidden` | | 滚动页面 | `agent-browser scroll --direction down --amount 300` | #### 动作 B：截图（必须用 `get_screenshot` 工具） > ⚠️ **禁止** 用 `agent-browser screenshot` 命令截图。必须调用 `get_screenshot` 工具获取当前页面截图，将返回的截图路径保存备用。 #### 动作 C：录制步骤 调用脚本 `record_step.py` 将本步操作记录到 session 文件： ```bash python "{SKILL_DIR}/scripts/record_step.py" \ --session "{session_file}" \ --step-num {N} \ --description "步骤描述" \ --command "执行的完整 agent-browser 命令" \ --screenshot "{截图路径}" \ --status "passed|failed|skipped" \ --selector "目标元素选择器（如有）" \ --input-value "输入值（如有）" \ --url "当前页面 URL" ``` > 若 agent-browser 命令执行失败，`--status` 设为 `failed` 并填写 `--error-msg`； > 失败后仍须继续录制，不要中断整体流程。 ### 第三步：生成测试脚本 所有步骤录制完毕后，调用 `save_test_script.py`： ```bash python "{SKILL_DIR}/scripts/save_test_script.py" \ --session "{session_file}" \ --output-dir "{output_dir}" \ --test-name "测试用例名称" \ --description "测试描述" \ --format both ``` 将生成： - `{test_name}.sh`：Linux/macOS 可直接执行的 shell 脚本 - `{test_name}.bat`：Windows 可直接执行的批处理脚本 - `{test_name}.test.json`：结构化 JSON 测试用例 ### 第四步：生成 HTML 测试报告 调用 `save_test_report.py` 生成完整报告： ```bash python "{SKILL_DIR}/scripts/save_test_report.py" \ --session "{session_file}" \ --output "{output_dir}/test_report.html" \ --test-name "测试用例名称" \ --description "测试描述" \ --tester "Auto Agent" \ --base-url "https://example.com" ``` 报告特性： - 📸 截图内嵌为 Base64，单 HTML 文件无外部依赖 - 🎨 步骤颜色标注（绿=通过/红=失败/黄=跳过） - 📊 汇总统计（总步骤、通过数、失败数、通过率） - 🔍 命令详情可折叠展开 --- ## 输出目录约定 推荐统一输出到： ``` {workspace}/ui-test-results/{test_name}_{timestamp}/ ├── session.json # 录制的原始 session ├── screenshots/ # 各步截图 ├── {test_name}.sh # Shell 回放脚本 ├── {test_name}.bat # Windows 回放脚本 ├── {test_name}.test.json # JSON 用例文件 └── test_report.html # HTML 测试报告 ``` --- ## 关键约束 1. **每步必须截图**：动作 B（`get_screenshot`）不可跳过，截图是报告的主要证据 2. **先录制后生成**：必须等所有步骤录制完毕，再执行第三步和第四步 3. **失败不中断**：单步失败时记录错误继续，最终在报告中标红，不要提前终止整体流程 4. **session 路径一致**：第二步中所有 `record_step.py` 调用必须使用同一个 session 文件路径 5. **SKILL_DIR 变量**：脚本路径应使用该 skill 目录的绝对路径（`~/.workbuddy/skills/ui-test-agent/`） --- ## 脚本路径速查 | 脚本 | 用途 | |------|------| | `scripts/record_step.py` | 录制单步操作到 session | | `scripts/save_test_script.py` | 将 session 导出为可重放脚本 | | `scripts/save_test_report.py` | 将 session 导出为 HTML 报告 |

path: scripts/save_test_script.py
content: #!/usr/bin/env python3
"""
save_test_script.py — 将录制的 session 保存为可重放的测试脚本

生成两种格式：
  1. Shell 脚本 (.sh / .bat)：直接调用 agent-browser 命令
  2. JSON 脚本（.test.json）：结构化测试用例，可被其他工具解析

用法:
    python save_test_script.py \
        --session <session_file.json> \
        --output-dir <输出目录> \
        --test-name "测试用例名称" \
        [--format shell|json|both]
"""

import argparse
import json
import os
import sys
from datetime import datetime


def parse_args():
    parser = argparse.ArgumentParser(description="将 session 保存为测试脚本")
    parser.add_argument("--session",    required=True, help="session JSON 文件路径")
    parser.add_argument("--output-dir", required=True, help="脚本输出目录")
    parser.add_argument("--test-name",  required=True, help="测试用例名称")
    parser.add_argument("--format",     default="both", choices=["shell", "json", "both"],
                        help="输出格式：shell / json / both（默认 both）")
    parser.add_argument("--description", default="", help="测试用例描述")
    return parser.parse_args()


def load_session(path: str) -> dict:
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def sanitize_filename(name: str) -> str:
    return "".join(c if c.isalnum() or c in "._- " else "_" for c in name).strip().replace(" ", "_")


def generate_shell_script(session: dict, test_name: str, description: str) -> str:
    lines = [
        "#!/usr/bin/env bash",
        "# =========================================",
        f"# 测试脚本: {test_name}",
        f"# 描述: {description}",
        f"# 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
        f"# 原始 session: {session.get('session_id', 'unknown')}",
        "# =========================================",
        "",
        'set -e  # 任意步骤失败则退出',
        "",
        'echo "🚀 开始执行测试: ' + test_name + '"',
        "",
    ]

    for step in session.get("steps", []):
        num  = step["step_num"]
        desc = step["description"]
        cmd  = step["command"]
        lines.append(f'echo "▶ Step {num}: {desc}"')
        lines.append(cmd)
        lines.append("")

    lines += [
        'echo "✅ 测试执行完成: ' + test_name + '"',
        "",
    ]
    return "\n".join(lines)


def generate_bat_script(session: dict, test_name: str, description: str) -> str:
    lines = [
        "@echo off",
        "REM =========================================",
        f"REM 测试脚本: {test_name}",
        f"REM 描述: {description}",
        f"REM 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
        "REM =========================================",
        "",
        f'echo 开始执行测试: {test_name}',
        "",
    ]

    for step in session.get("steps", []):
        num  = step["step_num"]
        desc = step["description"]
        cmd  = step["command"]
        lines.append(f'echo Step {num}: {desc}')
        lines.append(cmd)
        lines.append("if errorlevel 1 goto :error")
        lines.append("")

    lines += [
        f'echo 测试执行完成: {test_name}',
        "exit /b 0",
        "",
        ":error",
        "echo 测试执行失败，请检查上述步骤",
        "exit /b 1",
    ]
    return "\n".join(lines)


def generate_json_script(session: dict, test_name: str, description: str) -> dict:
    steps = []
    for step in session.get("steps", []):
        steps.append({
            "step":        step["step_num"],
            "description": step["description"],
            "action":      _parse_action(step["command"]),
            "command":     step["command"],
            "selector":    step.get("selector", ""),
            "input_value": step.get("input_value", ""),
            "url":         step.get("url", ""),
            "expected":    "页面正常展示",
            "status":      step.get("status", "passed"),
        })

    return {
        "test_name":   test_name,
        "description": description,
        "generated_at": datetime.now().isoformat(),
        "session_id":  session.get("session_id", ""),
        "total_steps": len(steps),
        "steps":       steps,
    }


def _parse_action(command: str) -> str:
    """从 agent-browser 命令中提取动作类型。"""
    cmd = command.lower()
    if "navigate" in cmd or "open" in cmd or "goto" in cmd:
        return "navigate"
    if "click" in cmd:
        return "click"
    if "type" in cmd or "fill" in cmd or "input" in cmd:
        return "type"
    if "select" in cmd:
        return "select"
    if "scroll" in cmd:
        return "scroll"
    if "wait" in cmd:
        return "wait"
    if "assert" in cmd or "expect" in cmd or "check" in cmd:
        return "assert"
    return "action"


def main():
    args = parse_args()

    if not os.path.exists(args.session):
        print(f"❌ Session 文件不存在: {args.session}", file=sys.stderr)
        sys.exit(1)

    session  = load_session(args.session)
    os.makedirs(args.output_dir, exist_ok=True)
    safe_name = sanitize_filename(args.test_name)
    outputs   = []

    if args.format in ("shell", "both"):
        # 生成 .sh 脚本
        sh_content  = generate_shell_script(session, args.test_name, args.description)
        sh_path = os.path.join(args.output_dir, f"{safe_name}.sh")
        with open(sh_path, "w", encoding="utf-8", newline="\n") as f:
            f.write(sh_content)
        outputs.append(sh_path)

        # 生成 Windows .bat 脚本
        bat_content = generate_bat_script(session, args.test_name, args.description)
        bat_path = os.path.join(args.output_dir, f"{safe_name}.bat")
        with open(bat_path, "w", encoding="utf-8", newline="\r\n") as f:
            f.write(bat_content)
        outputs.append(bat_path)

    if args.format in ("json", "both"):
        json_data = generate_json_script(session, args.test_name, args.description)
        json_path = os.path.join(args.output_dir, f"{safe_name}.test.json")
        with open(json_path, "w", encoding="utf-8") as f:
            json.dump(json_data, f, ensure_ascii=False, indent=2)
        outputs.append(json_path)

    print(f"✅ 测试脚本已保存:")
    for p in outputs:
        print(f"   📄 {p}")


if __name__ == "__main__":
    main()


path: scripts/save_test_report.py
content: #!/usr/bin/env python3
"""
save_test_report.py — 将录制的 session 生成完整的 HTML 测试报告

特性：
  - 嵌入 Base64 截图，报告为单文件无外部依赖
  - 步骤时间轴展示，可折叠查看命令详情
  - 通过/失败/跳过步骤颜色标注
  - 汇总统计：总步骤数、耗时、通过率

用法:
    python save_test_report.py \
        --session <session_file.json> \
        --output <report.html> \
        --test-name "测试名称" \
        [--description "描述"] \
        [--tester "测试人"]
"""

import argparse
import base64
import json
import os
import sys
from datetime import datetime


def parse_args():
    parser = argparse.ArgumentParser(description="生成 HTML 测试报告")
    parser.add_argument("--session",     required=True,  help="session JSON 文件路径")
    parser.add_argument("--output",      required=True,  help="输出 HTML 报告路径")
    parser.add_argument("--test-name",   required=True,  help="测试用例名称")
    parser.add_argument("--description", default="",     help="测试描述")
    parser.add_argument("--tester",      default="Auto Agent", help="测试人")
    parser.add_argument("--base-url",    default="",     help="被测系统基础 URL")
    return parser.parse_args()


def load_session(path: str) -> dict:
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def embed_image(path: str) -> str:
    """将图片转为 base64 data URI，若文件不存在则返回占位符。"""
    if not path or not os.path.exists(path):
        return ""
    ext = os.path.splitext(path)[1].lower().lstrip(".")
    mime = {"jpg": "jpeg", "jpeg": "jpeg", "png": "png",
            "gif": "gif", "webp": "webp"}.get(ext, "png")
    with open(path, "rb") as f:
        data = base64.b64encode(f.read()).decode()
    return f"data:image/{mime};base64,{data}"


def status_badge(status: str) -> str:
    colors = {"passed": "#22c55e", "failed": "#ef4444", "skipped": "#f59e0b"}
    labels = {"passed": "✅ 通过", "failed": "❌ 失败", "skipped": "⏭ 跳过"}
    color = colors.get(status, "#6b7280")
    label = labels.get(status, status.upper())
    return (f'<span style="background:{color};color:#fff;padding:2px 10px;'
            f'border-radius:12px;font-size:12px;font-weight:600">{label}</span>')


def render_step(step: dict, idx: int) -> str:
    num       = step["step_num"]
    desc      = step["description"]
    cmd       = step.get("command", "")
    status    = step.get("status", "passed")
    selector  = step.get("selector", "")
    inp_val   = step.get("input_value", "")
    url       = step.get("url", "")
    ts        = step.get("timestamp", "")
    error     = step.get("error_msg", "")
    shot_path = step.get("screenshot", "")
    img_src   = embed_image(shot_path)

    border_colors = {"passed": "#22c55e", "failed": "#ef4444", "skipped": "#f59e0b"}
    border = border_colors.get(status, "#6b7280")

    screenshot_html = ""
    if img_src:
        screenshot_html = f"""
        <div style="margin-top:12px">
          <img src="{img_src}" alt="step-{num}-screenshot"
               style="max-width:100%;border-radius:8px;border:1px solid #e2e8f0;
                      box-shadow:0 2px 8px rgba(0,0,0,.12);cursor:pointer"
               onclick="this.style.maxWidth=this.style.maxWidth==='100%'?'none':'100%'" />
          <div style="font-size:11px;color:#94a3b8;margin-top:4px">
            📸 {os.path.basename(shot_path) if shot_path else "截图"}
          </div>
        </div>"""

    meta_parts = []
    if url:
        meta_parts.append(f'<span>🌐 <code>{url}</code></span>')
    if selector:
        meta_parts.append(f'<span>🎯 选择器: <code>{selector}</code></span>')
    if inp_val:
        meta_parts.append(f'<span>⌨️ 输入值: <code>{inp_val}</code></span>')
    if ts:
        meta_parts.append(f'<span>🕐 {ts[:19].replace("T"," ")}</span>')
    meta_html = ('<div style="display:flex;flex-wrap:wrap;gap:12px;font-size:12px;'
                 f'color:#64748b;margin-top:6px">{"".join(meta_parts)}</div>' if meta_parts else "")

    error_html = ""
    if error:
        error_html = f"""
        <div style="background:#fef2f2;border:1px solid #fecaca;border-radius:6px;
                    padding:8px 12px;margin-top:8px;font-size:13px;color:#dc2626">
          ⚠️ {error}
        </div>"""

    return f"""
    <div id="step-{idx}" style="border-left:4px solid {border};background:#fff;
         border-radius:8px;padding:16px 20px;margin-bottom:16px;
         box-shadow:0 1px 4px rgba(0,0,0,.06)">
      <div style="display:flex;align-items:center;gap:12px;margin-bottom:4px">
        <span style="background:#f1f5f9;color:#475569;font-weight:700;font-size:13px;
                     border-radius:6px;padding:2px 8px;min-width:36px;text-align:center">
          #{num}
        </span>
        <span style="font-weight:600;font-size:15px;color:#1e293b;flex:1">{desc}</span>
        {status_badge(status)}
      </div>
      {meta_html}
      <details style="margin-top:10px">
        <summary style="cursor:pointer;font-size:13px;color:#6366f1;user-select:none">
          🖥 查看命令
        </summary>
        <pre style="background:#0f172a;color:#e2e8f0;padding:12px 16px;border-radius:6px;
                    font-size:12px;margin-top:8px;overflow-x:auto;white-space:pre-wrap">{cmd}</pre>
      </details>
      {error_html}
      {screenshot_html}
    </div>"""


def render_report(session: dict, test_name: str, description: str,
                  tester: str, base_url: str) -> str:
    steps     = session.get("steps", [])
    total     = len(steps)
    passed    = sum(1 for s in steps if s.get("status") == "passed")
    failed    = sum(1 for s in steps if s.get("status") == "failed")
    skipped   = total - passed - failed
    pass_rate = f"{passed/total*100:.1f}%" if total else "—"
    created   = session.get("created_at", "")[:19].replace("T", " ")
    generated = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

    status_color = "#22c55e" if failed == 0 else "#ef4444"
    status_label = "全部通过" if failed == 0 else f"{failed} 步失败"

    steps_html = "".join(render_step(s, i + 1) for i, s in enumerate(steps))

    return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>UI 测试报告 — {test_name}</title>
<style>
  *{{box-sizing:border-box;margin:0;padding:0}}
  body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
        background:#f8fafc;color:#1e293b;line-height:1.6}}
  code{{font-family:"Fira Code","Cascadia Code",Consolas,monospace;
        background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:.9em}}
  details>summary::-webkit-details-marker{{display:none}}
  details>summary::marker{{display:none}}
  @media print{{.no-print{{display:none}}}}
</style>
</head>
<body>
<!-- ===== HEADER ===== -->
<div style="background:linear-gradient(135deg,#1e293b 0%,#334155 100%);
            color:#fff;padding:32px 40px">
  <div style="max-width:960px;margin:0 auto">
    <div style="display:flex;align-items:center;gap:16px;margin-bottom:8px">
      <span style="font-size:28px">🧪</span>
      <h1 style="font-size:24px;font-weight:700">{test_name}</h1>
      <span style="background:{status_color};padding:4px 14px;border-radius:20px;
                   font-size:13px;font-weight:600;margin-left:auto">{status_label}</span>
    </div>
    <p style="color:#94a3b8;font-size:14px">{description}</p>
  </div>
</div>

<!-- ===== META INFO ===== -->
<div style="background:#fff;border-bottom:1px solid #e2e8f0;padding:0 40px">
  <div style="max-width:960px;margin:0 auto;display:flex;flex-wrap:wrap;gap:0">
    {"".join(f'''<div style="padding:14px 24px;border-right:1px solid #f1f5f9;text-align:center">
      <div style="font-size:11px;color:#94a3b8;text-transform:uppercase;letter-spacing:.5px">{k}</div>
      <div style="font-size:16px;font-weight:700;color:#1e293b;margin-top:2px">{v}</div>
    </div>''' for k, v in [
        ("总步骤", str(total)),
        ("通过", f'<span style="color:#22c55e">{passed}</span>'),
        ("失败", f'<span style="color:#ef4444">{failed}</span>'),
        ("跳过", f'<span style="color:#f59e0b">{skipped}</span>'),
        ("通过率", f'<span style="color:{status_color}">{pass_rate}</span>'),
        ("测试人", tester),
        ("开始时间", created or "—"),
        ("生成时间", generated),
    ])}
    {"" if not base_url else f'<div style="padding:14px 24px;text-align:center"><div style="font-size:11px;color:#94a3b8">被测系统</div><div style="font-size:14px;font-weight:600;color:#6366f1;margin-top:2px"><a href="{base_url}" target="_blank" style="color:inherit">{base_url}</a></div></div>'}
  </div>
</div>

<!-- ===== STEPS ===== -->
<div style="max-width:960px;margin:32px auto;padding:0 24px">
  <h2 style="font-size:16px;font-weight:700;color:#475569;margin-bottom:20px;
             text-transform:uppercase;letter-spacing:.5px">执行步骤</h2>
  {steps_html if steps_html else '<p style="color:#94a3b8;text-align:center;padding:40px">暂无步骤记录</p>'}
</div>

<!-- ===== FOOTER ===== -->
<div style="border-top:1px solid #e2e8f0;padding:20px 40px;text-align:center;
            font-size:12px;color:#94a3b8;margin-top:24px">
  由 <strong>ui-test-agent</strong> Skill 自动生成 · {generated}
</div>

<script>
  // 点击图片全屏预览
  document.querySelectorAll('img[alt*="screenshot"]').forEach(img => {{
    img.title = "点击切换缩放";
  }});
</script>
</body>
</html>"""


def main():
    args = parse_args()

    if not os.path.exists(args.session):
        print(f"❌ Session 文件不存在: {args.session}", file=sys.stderr)
        sys.exit(1)

    session = load_session(args.session)
    html    = render_report(session, args.test_name, args.description,
                            args.tester, args.base_url)

    out_dir = os.path.dirname(os.path.abspath(args.output))
    os.makedirs(out_dir, exist_ok=True)
    with open(args.output, "w", encoding="utf-8") as f:
        f.write(html)

    steps  = session.get("steps", [])
    passed = sum(1 for s in steps if s.get("status") == "passed")
    failed = sum(1 for s in steps if s.get("status") == "failed")
    print(f"✅ HTML 测试报告已生成: {args.output}")
    print(f"   📊 共 {len(steps)} 步 | 通过 {passed} | 失败 {failed}")


if __name__ == "__main__":
    main()


path: scripts/record_step.py
content: #!/usr/bin/env python3
"""
record_step.py — 录制单个测试执行步骤
将每步操作记录到 session JSON 文件中，供后续生成脚本和报告使用。

用法:
    python record_step.py \
        --session <session_file.json> \
        --step-num <步骤编号> \
        --description "操作描述" \
        --command "agent-browser 执行的命令" \
        --screenshot <截图路径(可选)> \
        --status <passed|failed|skipped> \
        [--selector "CSS或XPath选择器"] \
        [--input-value "输入的值"] \
        [--url "当前URL"]
"""

import argparse
import json
import os
import sys
from datetime import datetime


def parse_args():
    parser = argparse.ArgumentParser(description="录制测试步骤到 session 文件")
    parser.add_argument("--session",      required=True,  help="session JSON 文件路径")
    parser.add_argument("--step-num",     required=True,  type=int, help="步骤编号（从1开始）")
    parser.add_argument("--description",  required=True,  help="步骤描述")
    parser.add_argument("--command",      required=True,  help="执行的 agent-browser 命令")
    parser.add_argument("--screenshot",   default="",     help="截图文件路径")
    parser.add_argument("--status",       default="passed", choices=["passed", "failed", "skipped"],
                        help="步骤执行状态")
    parser.add_argument("--selector",     default="",     help="目标元素选择器")
    parser.add_argument("--input-value",  default="",     help="输入的值（如有）")
    parser.add_argument("--url",          default="",     help="执行时的页面 URL")
    parser.add_argument("--error-msg",    default="",     help="失败时的错误信息")
    return parser.parse_args()


def load_session(path: str) -> dict:
    """加载或初始化 session 数据。"""
    if os.path.exists(path):
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    return {
        "session_id": datetime.now().strftime("%Y%m%d_%H%M%S"),
        "created_at": datetime.now().isoformat(),
        "steps": []
    }


def save_session(path: str, data: dict):
    os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def main():
    args = parse_args()

    session = load_session(args.session)

    step = {
        "step_num":    args.step_num,
        "description": args.description,
        "command":     args.command,
        "status":      args.status,
        "selector":    args.selector,
        "input_value": args.input_value,
        "url":         args.url,
        "screenshot":  args.screenshot,
        "error_msg":   args.error_msg,
        "timestamp":   datetime.now().isoformat()
    }

    # 如果已有同编号步骤则覆盖，否则追加
    existing_indices = [i for i, s in enumerate(session["steps"]) if s["step_num"] == args.step_num]
    if existing_indices:
        session["steps"][existing_indices[0]] = step
    else:
        session["steps"].append(step)

    # 按步骤编号排序
    session["steps"].sort(key=lambda s: s["step_num"])
    session["updated_at"] = datetime.now().isoformat()

    save_session(args.session, session)
    print(f"✅ Step {args.step_num} recorded → {args.session}")


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)