Skill: zhao-zwl/team-resurrection (version 1.1.2)

--- name: team-resurrection description: 打包/搬家/分身 Agent 团队配置。保留 SOUL.md、团队成员、skills，换电脑或开新团队时一键搞定。执行前展示配置内容供用户确认，支持 --dry-run/--no-cron/--no-restart 细粒度控制。 metadata: openclaw: requires: bins: [openclaw] install: [] version: "1.1.2" permissions: - action: "修改 ~/.qclaw/openclaw.json（添加 agent 配置）" risk: "修改运行时配置，影响 agent spawn 行为" mitigation: "修改前展示完整 diff，用户确认后再写入；allowAgents 默认最小白名单，不用通配符" - action: "读写 ~/.qclaw/ 目录（复制 workspace、备份配置）" risk: "可能暴露 MEMORY.md/TOOLS.md 等敏感文件" mitigation: "打包时标注敏感文件，搬家包妥善保管" - action: "创建 cron 定时任务" risk: "定时任务可自动执行任意命令" mitigation: "执行前展示所有 cron 任务内容（含 payload），用户确认后自动创建" - action: "重启 OpenClaw Gateway" risk: "中断当前所有会话和正在运行的任务" mitigation: "执行前明确告知将重启，用户确认后自动执行" - action: "打包含敏感文件（MEMORY.md/TOOLS.md 可能含 API keys、账号密码）" risk: "搬家包泄露会暴露所有敏感配置和私人记忆" mitigation: "SKILL.md 明确警告不要分享搬家包；建议传输时加密" --- # Team Resurrection —— 团队打包 / 搬家 / 分身工具 > **触发：** 用户说「我要搬家」「换电脑了」「帮我分身一个团队」「备份团队」等。 > > **核心理念：** 搬家·分身·备份，三合一。 > > **⚠️ 权限说明**：本 skill 会修改 openclaw.json、创建 cron 任务、重启 Gateway。所有敏感操作前自动展示内容供用户确认，确认后全自动执行。allowAgents 使用最小白名单（仅实际成员ID），不使用通配符 `["*"]`。 > > **🔐 安全提示**： > - **搬家包含敏感信息** — pack.py 会打包 MEMORY.md（含个人记忆/偏好）、TOOLS.md（含 API keys、账号、服务器地址）等文件。**不要公开分享搬家包**，传输时使用加密介质。 > - **务必先 `--dry-run`** — 在新环境运行前，先用 `python3 migrate.py 搬家包.zip --dry-run` 审查 diff，确认无误后再正式执行。 > - **敏感操作可跳过** — 使用 `--no-cron` 跳过 cron 创建、`--no-restart` 跳过 Gateway 重启，逐步验证。 > - **检查 cron payload** — 执行前检查 cron_tasks.json 中的 payload 字段，确认不会触发意外操作。 > - **确认备份存在** — 操作前确认 `~/.qclaw/backup/` 下有备份目录，如需回滚可恢复。 > - **低信任环境** — 如对搬家包来源不确定，先逐行审计 pack.py/migrate.py/clone.py，或在隔离环境中测试。 > > **三大场景：** > - 🚚 **搬家** — 换电脑、重装系统，一键把整个团队迁到新环境 > - 👯 **分身** — 同环境快速复制一支团队，跑实验、做测试、开新项目 > - 📦 **打包** — 备份团队快照，随时可还原 --- ## 零、快速开始 ### 场景一：搬家（跨环境迁移） ``` 用户（旧环境）：我要搬家 Agent：好的，开始打包... [执行 pack.py] 打包完成：~/一键搬家包/Agent搬家包_YYYYMMDD.zip 请把搬家包复制到新环境 用户（新环境）：[把搬家包丢给agent] 这是一键搬家包，帮我搬家 Agent：[执行 migrate.py] 🔒 备份 → 🔧 Main Agent 配置 → 复制身份/成员/skills → 合并配置 → 重启 ✅ 搬家完成！ ``` ### 场景二：分身（同环境复制） ``` 用户：帮我分身一个团队，后缀用"测试" Agent：[执行 clone.py] 检测到 10 人团队 → 备份配置 → 复制 workspace（加后缀）→ 追加配置 → 重启 原团队：毒舌 / 小策 / 老墨 / ... 分身： 毒舌-测试 / 小策-测试 / 老墨-测试 / ... ✅ 分身完成！ ``` ### 场景三：备份 ``` 用户：帮我打一个搬家包 Agent：[执行 pack.py] ✅ 打包完成：~/一键搬家包/xxx.zip ``` --- ## 一、核心概念 ### 1.1 团队激活三要素 子代理要正确激活，必须同时满足三个条件： | 要素 | 参数 | 作用 | |------|------|------| | **身份** | `agentId` | 控制读哪个 SOUL.md（人格定义） | | **隔离** | `cwd` | 控制子代理的 workspace 根目录 | | **权限** | `allowAgents` | 白名单，允许 spawn 哪些 agentId | **关键发现：** - `agentId` 控制人格，不控制 workspace - `cwd` 才控制 workspace 隔离 - 不设 cwd → 所有子代理读父代理的 workspace ### 1.2 三大功能对比 | | 打包 `pack.py` | 搬家 `migrate.py` | 分身 `clone.py` | |---|---|---|---| | **场景** | 备份/跨环境迁移 | 新环境恢复 | 同环境复制 | | **输入** | 当前环境 | 搬家包 zip | 当前环境 | | **输出** | zip 文件 | 配置+文件落地 | 配置+文件落地 | | **重命名** | 不需要 | 不需要 | ✅ ID/路径加后缀 | | **配置策略** | 收集快照 | deep merge | 追加（不覆盖） | | **需要传输** | 是（zip） | 否 | 否 | ### 1.3 备份与回滚 **所有操作前自动备份：** - 目标：`~/.qclaw/backup/` - 搬家备份：`搬家备份_YYYYMMDD_HHMMSS/` - 分身备份：`clone备份_YYYYMMDD_HHMMSS/` **回滚方法：** ```bash # 搬家回滚 cp -r ~/.qclaw/backup/搬家备份_xxx/* ~/.qclaw/ # 分身回滚（只需还原配置） cp ~/.qclaw/backup/clone备份_xxx/openclaw.json ~/.qclaw/openclaw.json openclaw gateway restart ``` ### 1.4 配置合并策略 **核心原则：不覆盖现有配置。** - **搬家（migrate.py）**：deep merge（agents/hooks 字段级合并，其他保留） - **分身（clone.py）**：追加新 agent（不触碰原有 agent 条目） - hooks.allowedAgentIds：取并集 --- ## 二、脚本说明 ### 2.1 脚本清单 | 脚本 | 功能 | 版本 | |------|------|------| | `pack.py` | 打包所有资料（生成搬家包） | v2.0 | | `migrate.py` | 一键搬家执行（从搬家包恢复） | v3.0 | | `clone.py` | 一键分身（同环境复制团队） | v1.0 🆕 | | ~~`setup_config.py`~~ | ~~仅更新配置~~ | ❌ 已废弃 | | ~~`init.sh`~~ | ~~创建 main agent~~ | ❌ 已废弃 | ### 2.2 pack.py（打包脚本） **功能：** 自动检测当前 workspace，收集所有资料，生成搬家包 **执行方式：** ```bash cd skills/team-resurrection python3 pack.py ``` **输出：** `~/一键搬家包/{Agent名称}搬家包_YYYYMMDD_HHMMSS.zip` **v2.0 改进：** - ✅ 不再硬编码 workspace 路径，自动检测 active workspace - ✅ 自动推断 agent 名称（从 SOUL.md/IDENTITY.md） - ✅ README.md 根据实际内容动态生成 ### 2.3 migrate.py（搬家脚本） **功能：** 完整的一键搬家流程 **执行方式：** ```bash # 基本用法：传入搬家包路径 python3 migrate.py /path/to/搬家包.zip # 审查模式：只看不做 python3 migrate.py /path/to/搬家包.zip --dry-run # 跳过危险操作 python3 migrate.py /path/to/搬家包.zip --no-cron --no-restart # 交互式（在搬家包目录内运行） unzip 搬家包.zip && cd 搬家包 && python3 migrate.py ``` **参数说明：** | 参数 | 说明 | |------|------| | `--dry-run` | 只展示将要执行的操作，不实际执行 | | `--no-cron` | 跳过 cron 任务创建 | | `--no-restart` | 跳过 Gateway 重启 | **执行步骤：** | Step | 内容 | 说明 | |------|------|------| | 0 | 备份现有配置 | 自动检测已有配置，备份到 `~/.qclaw/backup/` | | 0.5 | Main Agent 配置 | **交互选择**（指向/新建/覆盖） | | 1 | 复制身份文件 | SOUL.md / MEMORY.md / TOOLS.md 等 | | 2 | 复制团队成员 | 自动检测，有则复制，无则跳过 | | 3 | 复制 skills | 整个 skills 目录覆盖 | | 4 | 合并 agent 配置 | deep merge，保护新环境其他配置 | | 5 | 创建 cron 任务 | 跳过已存在的 | | 6 | 重启 Gateway | 等待 5 秒让 channel 注册 | **Main Agent 选项：** ``` 选项 1：指向现有 agent → 把当前 agent 指向新 workspace 选项 2：新建 main agent 实例 → 创建新目录 + 配置 选项 3：覆盖现有 main agent → ⚠️ 需二次确认 ``` ### 2.4 clone.py（分身脚本）🆕 **功能：** 在同一环境下复制整个团队，所有 ID/路径加后缀避免冲突 **执行方式：** ```bash # 交互式（会询问后缀） cd skills/team-resurrection python3 clone.py # 指定后缀 python3 clone.py --suffix "测试" ``` **执行步骤：** | Step | 内容 | 说明 | |------|------|------| | 1 | 检测当前团队 | 读 openclaw.json，找 main + 成员 | | 2 | 询问分身后缀 | 默认 `copy`，可自定义 | | 3 | 备份配置 | → `~/.qclaw/backup/clone备份_xxx/` | | 4 | 复制 workspace | 所有 workspace 目录名加后缀 | | 5 | 复制 agentDir | 所有 agentDir 目录名加后缀 | | 6 | 重命名 agent ID | 所有 ID 加后缀（如 `xiaoce` → `xiaoce-测试`） | | 7 | 更新 AGENTS.md | 新 workspace 的 AGENTS.md 中 ID 同步更新 | | 8 | 追加到 openclaw.json | 不覆盖原有 agent，只追加新 agent | | 9 | 重启 Gateway | 等待 5 秒让 channel 注册 | **分身命名示例：** ``` 后缀: 测试 原团队： 分身： 毒舌 (agent-ba01c6a8) 毒舌-测试 (agent-ba01c6a8-测试) 小策 (xiaoce) 小策-测试 (xiaoce-测试) 老墨 (laomo) 老墨-测试 (laomo-测试) ... workspace: ~/.qclaw/workspace-agent-ba01c6a8/ → ~/.qclaw/workspace-agent-ba01c6a8-测试/ ~/.qclaw/workspace-agent-ba01c6a8/xiaoce/ → ~/.qclaw/workspace-agent-ba01c6a8-测试/xiaoce-测试/ ``` **冲突检测：** 执行前自动检查目标路径是否已存在，冲突则中止（不覆盖）。 --- ## 三、搬家包结构 ``` 搬家包/ ├── README.md ← 动态生成（含团队成员列表） ├── migrate.py ← 一键执行脚本 │ ├── 身份层/ ← Main Agent │ ├── SOUL.md / MEMORY.md / TOOLS.md / AGENTS.md / IDENTITY.md / USER.md │ └── memory/ │ ├── 团队成员层/ ← 子代理（自动检测，有则包含） │ ├── 成员A/SOUL.md │ └── ... │ ├── skills/ ← Skills ├── openclaw-agents.json ← Agent 配置片段 ├── cron_tasks.json ← Cron 任务清单 └── 工作目录说明.md ← git 工作目录提示 ``` ### 通用化 | 场景 | pack.py | migrate.py | clone.py | |------|---------|-----------|----------| | 有团队 | 打包成员 | 复制成员 | 复制+重命名 | | 无团队 | 跳过 | 跳过 | 只复制 main | | 有 cron | 打包 | 创建 | 不涉及 | | 无 cron | 跳过 | 跳过 | 不涉及 | --- ## 四、配置文件说明 ### 4.1 openclaw.json 核心配置 ```json { "agents": { "list": [ { "id": "main", "name": "Main Agent" }, { "id": "member-a", "name": "成员A", "workspace": "/Users/xxx/.qclaw/workspace-xxx/member-a", "agentDir": "/Users/xxx/.qclaw/agents/member-a/agent" } ], "defaults": { "model": { "primary": "qclaw/modelroute" }, "maxConcurrent": 10, "subagents": { "allowAgents": ["*"] } } }, "hooks": { "allowedAgentIds": ["member-a", "member-b"] } } ``` ### 4.2 关键配置项 | 配置项 | 说明 | |--------|------| | `agents.list[].id` | Agent 标识 | | `agents.list[].name` | 显示名 | | `agents.list[].workspace` | Agent workspace 绝对路径 | | `agents.list[].agentDir` | Agent 独立目录（含 models.json） | | `agents.defaults.subagents.allowAgents` | Spawn 白名单，`["*"]` 允许所有 | | `hooks.allowedAgentIds` | hooks 触发白名单 | --- ## 五、常见问题排查 ### 问题1：`agentId is not allowed` **原因：** openclaw.json 缺少 `allowAgents` 白名单 **解决：** migrate.py / clone.py 执行时自动补上最小白名单（仅实际成员ID），如需通配符请手动修改 ### 问题2：子代理读错 SOUL.md **原因：** spawn 时没传 `cwd` 参数 **解决：** 必须同时传 `agentId` + `cwd`： ```javascript sessions_spawn({ agentId: "member-a", cwd: "/Users/xxx/.qclaw/workspace-xxx/member-a", mode: "run", task: "..." }) ``` ### 问题3：Gateway 重启后 spawn 报错 `unknown channel` **原因：** Gateway 重启时 channel 注册需要几秒 **解决：** 重启后等 5-10 秒再 spawn ### 问题4：分身后 ID 冲突 **现象：** clone.py 报"目标 workspace 已存在" **解决：** 换一个后缀，或手动删除旧分身： ```bash rm -rf ~/.qclaw/workspace-xxx-旧后缀 # 然后从 openclaw.json 中删除对应 agent 条目 ``` ### 问题5：搬家后配置丢失 **原因：** v1.0 用 config replace **解决：** v2.0+ 已修复（deep merge）。回滚：`cp -r ~/.qclaw/backup/搬家备份_xxx/* ~/.qclaw/` --- ## 六、验证清单 ### 搬家验证 - [ ] `openclaw gateway status` 显示 running - [ ] `ls ~/.qclaw/workspace-*/SOUL.md` 找到 SOUL.md - [ ] openclaw.json 包含所有 agent 配置 - [ ] `agents.defaults.subagents.allowAgents` 已设置（最小白名单，仅实际成员ID） - [ ] 测试 spawn 至少 2 个 agent，确认人格正确 ### 分身验证 - [ ] openclaw.json 中同时存在原团队和分身团队 - [ ] 分身 workspace 路径带后缀 - [ ] 分身 agent ID 带后缀 - [ ] 用分身 ID spawn，确认读的是分身 workspace 的 SOUL.md - [ ] 原团队 spawn 不受影响 --- ## 七、维护建议 1. **定期打包**：重大变更后重新 `pack.py` 2. **备份保留**：至少保留最近 3 次备份 3. **分身清理**：实验完成后删除分身 workspace + 从 openclaw.json 移除条目 4. **日志记录**：所有配置变更记录到 `memory/YYYY-MM-DD.md` ---

path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

## [1.0.0] - 2026-04-22

### Added
- 分身复活核心功能：`clone.py` 扫描子代理session找接续点，创建新agent继承记忆继续工作
- `SKILL.md` — 完整使用说明与操作步骤
- `clone.py` — 核心克隆脚本，支持scan/simulate/show三种模式
- `pack.py` — 打包脚本

### 适用场景
- Main agent失联（gateway崩溃、网络中断）
- 任务执行到一半agent无响应
- 需要在多台机器上恢复同一个agent状态


path: MATERIAL_PACKING.md
content: # 一键搬家材料打包清单

> 搬家包完整内容记录，确保一键搬家不遗漏任何关键材料
> 
> **v2.0 更新**：backup/ 目录列入维护，新增 backup_existing() 自动备份逻辑。

---

## 一、搬家包结构

```
搬家包/
├── README.md                    ← 动态生成（v2.0自动推断内容）
├── migrate.py                   ← v2.0 一键执行脚本
│
├── 身份层/                      ← Main Agent（自动检测workspace）
│   ├── SOUL.md                  ← 人格定义（必须）
│   ├── MEMORY.md                ← 长期记忆（必须）
│   ├── TOOLS.md                 ← 工具配置（必须）
│   ├── AGENTS.md                ← 团队成员表（必须）
│   ├── IDENTITY.md              ← 身份标识（必须）
│   ├── USER.md                  ← 用户信息（必须）
│   └── memory/                  ← 历史记录
│
├── 团队成员层/                  ← 团队成员（自动检测，有则包含）
│   ├── 成员A/
│   │   └── SOUL.md
│   ├── 成员B/
│   │   └── SOUL.md
│   └── ...（其他成员）
│
├── skills/                      ← Skills（自动检测，有则包含）
│   ├── team-resurrection/       ← 本skill
│   ├── agent-creator/
│   ├── novel-writer-structure/
│   └── 短流创规则/
│
├── openclaw-agents.json         ← Agent配置片段
└── cron_tasks.json              ← Cron任务清单
```

---

## 二、脚本说明（v2.0）

### 核心脚本

| 脚本 | 功能 | 必需性 |
|------|------|--------|
| `pack.py` | 打包所有资料 | ✅ 必需 |
| `migrate.py` | 一键搬家执行 | ✅ 必需 |
| `setup_config.py` | ~~仅更新配置~~ | ❌ 已废弃 |
| `init.sh` | ~~创建main agent~~ | ❌ 已废弃 |

> ⚠️ v2.0 将 setup_config.py 和 init.sh 的功能全部并入 migrate.py，无需单独执行。

### pack.py（打包脚本）

**功能：** 自动检测 active workspace，收集所有资料，生成搬家包

**执行方式：**
```bash
cd skills/team-resurrection
python3 pack.py
```

**收集内容（v2.0 自动检测）：**
- Main Agent 信息（SOUL.md/MEMORY.md 等，自动推断 workspace）
- 团队成员信息（自动检测，有则打包）
- Skills（自动检测，有则打包）
- Cron 任务配置（自动获取）
- Agent 配置片段

**输出：** `~/一键搬家包/{Agent名称}搬家包_YYYYMMDD_HHMMSS.zip`

### migrate.py（一键执行脚本）

**功能：** 完整的一键搬家流程

**执行方式：**
```bash
unzip Agent搬家包.zip
python3 Agent搬家包/migrate.py
```

**执行步骤：**
1. 检测环境（是否有main agent）
2. **让用户选择如何处理main agent**（不强制）
3. 复制身份文件
4. 复制团队成员
5. 复制skills
6. 更新openclaw.json
7. 创建cron任务
8. 重启Gateway

**用户选择逻辑：**
```
如果有main agent：
  1. 指向现有agent（把当前 agent 指向新 workspace）
  2. 新建main agent实例（保留当前的main，创建新的 main agent）
  3. 覆盖现有main agent（⚠️ 当前的main会被替换）

如果没有main agent：
  1. 指向现有agent
  2. 新建main agent实例
```

### setup_config.py（配置更新脚本）

**功能：** 仅更新配置，不复制文件

**执行方式：**
```bash
python3 setup_config.py
python3 setup_config.py --skip-cron      # 跳过cron创建
python3 setup_config.py --skip-restart   # 跳过Gateway重启
```

**用途：** 如果文件已手动复制，只需更新配置时使用

### init.sh（Step 0脚本）

**功能：** 创建main agent实例

**执行方式：**
```bash
chmod +x init.sh
./init.sh
```

**用途：** 如果新环境没有main agent，且用户选择"新建main agent实例"时使用

---

## 三、身份层文件说明

| 文件 | 作用 | 是否必须 |
|------|------|----------|
| SOUL.md | 人格定义、说话风格、行为规矩 | ✅ 必须 |
| MEMORY.md | 长期记忆、重要事件、决策记录 | ✅ 必须 |
| TOOLS.md | 工具配置、Agent ID、飞书ID | ✅ 必须 |
| AGENTS.md | 团队成员表、职责分工 | ✅ 必须 |
| IDENTITY.md | 身份标识（名字、头像） | ✅ 必须 |
| USER.md | 用户信息（姓名、偏好） | ✅ 必须 |
| memory/ | 历史记录目录 | ⭕ 可选 |

---

## 四、团队成员层说明

**目录命名规范：** `中文名/`（不带旧版ID后缀）

| 成员 | 目录名 | Agent ID | 职责 |
|------|--------|----------|------|
| 成员A | 成员A/ | member-a | 团队成员 |
| 成员B | 成员B/ | member-b | 团队成员 |
| 成员C | 成员C/ | member-c | 团队成员 |
| 成员D | 成员D/ | member-d | 团队成员 |
| 成员I | 成员I/ | member-i | 团队成员 |
| 成员E | 成员E/ | member-e | 团队成员 |
| 成员F | 成员F/ | member-f | 团队成员 |
| 成员G | 成员G/ | member-g | 团队成员 |
| 成员H | 成员H/ | member-h | 团队成员 |

**每个成员目录包含：**
- SOUL.md（人格定义）
- MEMORY.md（长期记忆）
- TOOLS.md（可选）

---

## 五、配置文件说明

### openclaw-agents.json

**位置：** 搬家包根目录

**作用：** Agent配置片段，用于`config.patch`更新

**关键配置：**
```json
{
  "agents": {
    "defaults": {
      "maxConcurrent": 10,
      "subagents": {
        "allowAgents": ["*"]
      }
    },
    "list": [
      {"id": "main", "name": "QClaw"},
      {"id": "member-a", "name": "成员A", "workspace": "...", "agentDir": "..."}
    ]
  },
  "hooks": {
    "allowedAgentIds": ["member-a", "member-b", ...]
  }
}
```

### cron_tasks.json

**位置：** 搬家包根目录

**作用：** Cron任务模板

**任务清单：**
| 任务名 | 频率 | 用途 |
|--------|------|------|
| team-daily-{name} | 每天01:00 | 激活成员 |
| main 每日自迭代 | 每天22:30 | 自我优化 |
| 短流创进度催促 | 每30分钟 | 检查未定稿作品 |

---

## 六、一键搬家完整流程

### 用户视角

```
用户（旧环境）：我要搬家
Agent：好的，开始打包...
       [执行 pack.py]
       打包完成：~/一键搬家包/xxx.zip

用户（新环境）：[把包丢给agent] 这是一键搬家包，帮我搬家
Agent：好的，开始执行...
       [解压 + 执行 migrate.py]
       
       请选择如何处理 Main Agent：
         1. 指向现有agent
         2. 新建main agent实例
         3. 覆盖现有main agent
       
       用户选择：2
       
       复制身份文件 ✓
       复制团队成员 ✓
       复制skills ✓
       更新配置 ✓
       创建cron ✓
       重启Gateway ✓
       ✅ 搬家完成！
```

### 手动执行

```bash
# Step 1: 打包（旧环境）
python3 skills/team-resurrection/pack.py

# Step 2: 复制搬家包到新环境

# Step 3: 执行搬家（新环境）
unzip Agent搬家包.zip
python3 Agent搬家包/migrate.py
```

---

## 七、通用化设计

### 核心原则

**自动适配，无需手动配置：**
- pack.py 会自动检测是否有团队成员
- migrate.py 会自动检测搬家包里是否有团队成员
- README.md 会根据实际情况动态生成

### 自动检测逻辑

#### pack.py（打包脚本）

```python
# Step 2: 收集团队成员信息（可选）
agents = get_agent_info()

# 检测是否有团队成员
has_team = False
if agents:
    for agent in agents:
        if agent.get('id') != 'main':
            has_team = True
            break

if not has_team:
    info("未检测到团队成员，跳过此步骤")
else:
    # 收集团队成员信息...
```

**结果：**
- 有团队成员 → 生成"团队成员层"目录
- 没有团队成员 → 不生成"团队成员层"目录

#### migrate.py（执行脚本）

```python
def copy_team_members(package_dir):
    info("检查是否有团队成员...")
    
    team_dir = package_dir / "团队成员层"
    if not team_dir.exists():
        info("未检测到团队成员，跳过此步骤")
        return True
    
    # 复制团队成员...
```

**结果：**
- 有"团队成员层"目录 → 复制团队成员
- 没有"团队成员层"目录 → 跳过，输出友好提示

### 适用场景

| 场景 | pack.py行为 | migrate.py行为 |
|------|-------------|----------------|
| 有团队的agent | 打包团队成员 | 复制团队成员 |
| 没有团队的agent | 跳过，不打包 | 跳过，不复制 |

---

## 八、验证清单

搬家完成后，逐一检查：

### 基础检查

- [ ] `openclaw gateway status` 显示 running
- [ ] `cat ~/.qclaw/workspace-xxx/SOUL.md | head -20` 显示正确人格
- [ ] `ls ~/.qclaw/workspace-xxx/*/SOUL.md` 显示团队成员文件

### 配置检查

- [ ] openclaw.json 包含所有 agent 配置
- [ ] `agents.defaults.subagents.allowAgents: ["*"]` 已设置
- [ ] 每个 agent 的 workspace 目录存在
- [ ] 每个 workspace 包含 SOUL.md

### 功能检查

- [ ] Gateway 已重启并等待5-10秒
- [ ] 测试 spawn 至少 2 个 agent，确认人格正确
- [ ] Cron 任务已创建（如需要）
- [ ] Skills 已安装（如需要）

---

## 附录：版本变更记录（v2.0）

### v2.0（2026-04-22）

**P0 修复：**
- ✅ pack.py 不再硬编码 workspace-xxx，改为 `detect_active_workspace()` 自动检测
- ✅ migrate.py 新增 `backup_existing()`，搬家前自动备份到 `~/.qclaw/backup/搬家备份_时间戳/`
- ✅ migrate.py 新增 `deep_merge()`，config merge 而非 replace，保护新环境其他配置
- ✅ `prompt_main_agent_handling()` 三个选项逻辑补全（v1.0 选项1/2 只打印警告不执行）

**P1 修复：**
- ✅ 删除了 init.sh/setup_config.py 的"可选执行"误导文档
- ✅ migrate.py 新增 `find_package_dir()`，支持解压后直接 `cd` 进目录运行
- ✅ cron 创建区分"已存在/创建/失败"三类状态

**相关文件：**
- `pack.py` → v2.0，12751 bytes
- `migrate.py` → v2.0，19240 bytes
- `SKILL.md` → v2.0，9768 bytes



path: README.md
content: # Team Resurrection

一键打包 / 搬家 / 分身你的 Agent 团队配置。
保留 SOUL.md、MEMORY.md、团队成员、skills 等所有资料，换电脑或开新团队时一键搞定。

---

## 安装

```bash
clawhub install team-resurrection
```

---

## ⚠️ 权限说明

本 skill 执行时需要以下权限：

| 操作 | 文件 | 说明 |
|------|------|------|
| 读取/修改 | `~/.qclaw/openclaw.json` | 添加 agent 配置 |
| 读写文件系统 | `~/.qclaw/` | 复制 workspace、备份配置 |
| 创建 | cron 定时任务 | 定时任务迁移 |
| 执行 | `openclaw gateway restart` | 重启 Gateway 使配置生效 |

**安全措施：** 所有操作前自动备份到 `~/.qclaw/backup/`

---

## 特性

- **三合一**：打包 · 搬家 · 分身，一键切换
- **自动检测**：不硬编码路径，智能识别当前 workspace
- **安全优先**：操作前自动备份，配置 deep merge 不覆盖其他设置

## 快速开始

### 打包（备份 / 准备搬家）

```bash
cd ~/.qclaw/workspace/skills/team-resurrection
python3 pack.py
```

生成：`~/一键搬家包/{Agent名称}搬家包_YYYYMMDD.zip`

### 搬家（新环境恢复）

```bash
unzip 搬家包.zip && cd 搬家包 && python3 migrate.py
```

### 分身（同环境复制）

```bash
python3 clone.py --suffix "测试"
```

---

## 三大功能

| 功能 | 脚本 | 场景 |
|------|------|------|
| 打包 | `pack.py` | 备份 / 跨环境迁移 |
| 搬家 | `migrate.py` | 新环境恢复 |
| 分身 | `clone.py` | 同环境复制团队 |

---

## 版本

- **1.1.0**（2026-04-24）— 新增--dry-run/--no-cron/--no-restart细粒度控制，agents.list追加不替换，copy_team用配置路径，Zip Slip防御，审查展示cron payload

path: clone.py
content: #!/usr/bin/env python3
"""
clone.py - 一键分身脚本 v1.0
功能：在同一环境下复制整个团队（避免 ID/路径冲突）

用法：
    python3 clone.py                    # 交互式
    python3 clone.py --suffix "测试"    # 指定后缀

流程：
    1. 检测当前团队
    2. 询问分身后缀
    3. 备份配置
    4. 复制所有 workspace（加后缀）
    5. 复制所有 agentDir（加后缀）
    6. 重命名所有 agent ID（加后缀）
    7. 追加到 openclaw.json（不覆盖）
    8. 重启 Gateway
"""

import os
import sys
import json
import shutil
import subprocess
from pathlib import Path
from datetime import datetime

# =============================================
# 颜色输出
# =============================================
GREEN  = '\033[0;32m'
RED    = '\033[0;31m'
YELLOW = '\033[1;33m'
BLUE   = '\033[0;34m'
NC     = '\033[0m'

def log(msg):   print(f"{GREEN}[✓]{NC} {msg}")
def err(msg):   print(f"{RED}[✗]{NC} {msg}")
def warn(msg):  print(f"{YELLOW}[!]{NC} {msg}")
def info(msg):  print(f"{BLUE}[i]{NC} {msg}")

def run_cmd(cmd):
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    return result.stdout.strip(), result.returncode

# =============================================
# 路径常量
# =============================================

QCLAW_DIR      = Path.home() / ".qclaw"
OPENCLAW_JSON  = QCLAW_DIR / "openclaw.json"
BACKUP_DIR     = QCLAW_DIR / "backup"

# =============================================
# 备份
# =============================================

def backup_config():
    """备份 openclaw.json"""
    if not OPENCLAW_JSON.exists():
        return None

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_path = BACKUP_DIR / f"clone备份_{timestamp}" / "openclaw.json"
    backup_path.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(OPENCLAW_JSON, backup_path)
    log(f"配置已备份到 {backup_path.parent}")
    return backup_path.parent

# =============================================
# 团队检测
# =============================================

def detect_team():
    """检测当前团队结构，返回 main + members + config"""
    if not OPENCLAW_JSON.exists():
        err("未找到 openclaw.json")
        sys.exit(1)

    with open(OPENCLAW_JSON, "r", encoding="utf-8") as f:
        config = json.load(f)

    agents_list = config.get("agents", {}).get("list", [])

    main_agent = None
    team_members = []

    for agent in agents_list:
        aid = agent.get("id", "")
        if aid == "main":
            main_agent = agent
        elif agent.get("workspace"):
            team_members.append(agent)

    # fallback: 通过 workspace 特征找 main
    if not main_agent:
        for agent in agents_list:
            ws = agent.get("workspace", "")
            if ws and Path(ws).exists():
                p = Path(ws)
                if (p / "SOUL.md").exists() and (p / "MEMORY.md").exists():
                    main_agent = agent
                    break

    if not main_agent:
        err("未检测到 main agent")
        sys.exit(1)

    return {
        "main": main_agent,
        "members": team_members,
        "config": config,
    }

# =============================================
# 复制 & 重命名
# =============================================

def rename_id(original_id: str, suffix: str) -> str:
    """给 agent ID 加后缀，避免重复"""
    if original_id.endswith(f"-{suffix}"):
        return original_id
    return f"{original_id}-{suffix}"


def rename_workspace_path(original_path: str, suffix: str) -> str:
    """给 workspace 路径加后缀"""
    p = Path(original_path)
    return str(p.parent / f"{p.name}-{suffix}")


def rename_agentdir_path(original_path: str, suffix: str) -> str:
    """给 agentDir 路径加后缀"""
    p = Path(original_path)
    return str(p.parent / f"{p.name}-{suffix}")


def safe_copytree(src: Path, dst: Path, label: str = ""):
    """安全复制目录，目标已存在则跳过"""
    if dst.exists():
        warn(f"  目标已存在，跳过：{dst.name}")
        return False
    shutil.copytree(src, dst)
    log(f"  复制 {label or src.name} → {dst.name}")
    return True


def patch_agents_md(workspace: Path, suffix: str):
    """
    更新 AGENTS.md 中引用的 agent ID，给所有成员 ID 加后缀。
    简单正则替换，不破坏格式。
    """
    agents_md = workspace / "AGENTS.md"
    if not agents_md.exists():
        return

    import re
    content = agents_md.read_text(encoding="utf-8")

    # 替换 agent-xxxx 格式的 ID
    def _replace(m):
        aid = m.group(0)
        if aid.endswith(f"-{suffix}"):
            return aid
        return f"{aid}-{suffix}"

    new_content = re.sub(r"agent-[a-f0-9]+", _replace, content)

    if new_content != content:
        agents_md.write_text(new_content, encoding="utf-8")
        log(f"  更新 {agents_md.name}（agent ID 已加后缀）")

# =============================================
# 配置合并
# =============================================

def deep_merge(base, patch):
    """深度合并 dict"""
    result = base.copy()
    for k, v in patch.items():
        if k in result and isinstance(result[k], dict) and isinstance(v, dict):
            result[k] = deep_merge(result[k], v)
        else:
            result[k] = v
    return result


def merge_agents_config(original_config: dict, new_agents: list, suffix: str) -> dict:
    """
    把分身后的 agent 追加到 openclaw.json。
    只追加，不覆盖原有。
    """
    config = json.loads(json.dumps(original_config))  # deep copy

    if "agents" not in config:
        config["agents"] = {}
    if "list" not in config["agents"]:
        config["agents"]["list"] = []

    existing_ids = {a.get("id") for a in config["agents"]["list"]}

    for agent in new_agents:
        if agent["id"] in existing_ids:
            warn(f"  agent 已存在，跳过: {agent['id']}")
            continue
        config["agents"]["list"].append(agent)
        log(f"  追加 agent: {agent['id']} ({agent.get('name', 'N/A')})")

    # 确保 allowAgents（最小白名单，不用通配符）
    all_ids = [a["id"] for a in config["agents"]["list"] if a.get("id") != "main"]
    if "defaults" not in config["agents"]:
        config["agents"]["defaults"] = {}
    if "subagents" not in config["agents"]["defaults"]:
        config["agents"]["defaults"]["subagents"] = {}
    existing_allow = config["agents"]["defaults"]["subagents"].get("allowAgents", [])
    if existing_allow != ["*"]:
        # 合并白名单，不用通配符
        config["agents"]["defaults"]["subagents"]["allowAgents"] = sorted(set(existing_allow + all_ids))
    # 如果已有通配符，保留用户原有设置

    # hooks.allowedAgentIds 追加
    new_member_ids = [a["id"] for a in new_agents if a["id"] != "main"]
    if new_member_ids:
        if "hooks" not in config:
            config["hooks"] = {}
        existing_hook_ids = set(config.get("hooks", {}).get("allowedAgentIds", []))
        config["hooks"]["allowedAgentIds"] = sorted(existing_hook_ids | set(new_member_ids))

    return config

# =============================================
# Gateway 重启
# =============================================

def restart_gateway():
    """重启 Gateway（用户已通过确认流程）"""
    info("重启 Gateway...")
    print()
    warn("⚠️ 重启将中断当前所有会话和正在运行的任务")
    output, code = run_cmd("openclaw gateway restart")
    if code == 0:
        log("  Gateway 已重启")
        info("  等待 5 秒让 channel 注册...")
        import time
        time.sleep(5)
    else:
        warn("  Gateway 重启可能失败")
        warn("  请手动执行：openclaw gateway restart")
    return True

# =============================================
# 主流程
# =============================================

def main():

    print()
    print("=" * 60)
    print("  一键分身 - Team Clone v1.0")
    print("=" * 60)
    print()

    # ---- Step 1: 检测团队 ----
    info("检测当前团队...")
    team = detect_team()

    main_agent = team["main"]
    members    = team["members"]

    main_name = main_agent.get("name", main_agent.get("id", "Unknown"))
    main_ws   = main_agent.get("workspace", "N/A")

    log(f"Main Agent: {main_name}")
    log(f"Workspace:  {main_ws}")
    log(f"团队成员:   {len(members)} 人")
    for m in members:
        log(f"  - {m.get('name', m.get('id'))} ({m.get('id')})")
    print()

    # ---- Step 2: 询问后缀 ----
    suffix = "copy"
    if "--suffix" in sys.argv:
        idx = sys.argv.index("--suffix")
        if idx + 1 < len(sys.argv):
            suffix = sys.argv[idx + 1]
    else:
        user_input = input("请输入分身后缀（默认 copy，回车使用默认）: ").strip()
        if user_input:
            suffix = user_input

    log(f"分身后缀: {suffix}")
    print()

    # ---- 预检：新路径是否冲突 ----
    new_main_ws = rename_workspace_path(main_agent.get("workspace", ""), suffix)
    if new_main_ws and Path(new_main_ws).exists():
        err(f"目标 workspace 已存在：{new_main_ws}")
        err("请更换后缀或手动删除后再试")
        return

    # ---- Step 2.5: 操作确认 ----
    print("─" * 40)
    print()
    print("即将执行以下操作：")
    print(f"  · 复制 {1 + len(members)} 个 workspace（加后缀 -{suffix}）")
    print(f"  · 追加 {1 + len(members)} 个 agent 到 openclaw.json")
    print(f"  · allowAgents 白名单将更新为包含新成员 ID")
    print(f"  · 自动重启 Gateway（将中断当前会话）")
    print()
    warn("⚠️ 确认后将自动执行以上所有操作")
    print()
    
    confirm = input("确认继续？（y/n）：").strip().lower()
    if confirm != 'y':
        err("用户取消，分身中止")
        return
    print()
    log("用户已确认，开始执行...")
    print()

    # ---- Step 3: 备份 ----
    backup_config()
    print()

    # ---- Step 4: 复制 workspace & agentDir ----
    new_agents = []

    # 4.1 Main Agent
    info("复制 Main Agent...")
    old_main_ws = Path(main_agent["workspace"])
    new_main_ws_path = Path(new_main_ws)
    safe_copytree(old_main_ws, new_main_ws_path, main_name)
    patch_agents_md(new_main_ws_path, suffix)

    new_main_agent = {
        "id":   rename_id(main_agent.get("id", "main"), suffix),
        "name": f"{main_agent.get('name', 'Main')}-{suffix}",
        "workspace": new_main_ws,
    }

    # agentDir
    if main_agent.get("agentDir"):
        old_dir = Path(main_agent["agentDir"])
        new_dir_path = rename_agentdir_path(main_agent["agentDir"], suffix)
        if old_dir.exists():
            safe_copytree(old_dir, Path(new_dir_path), "agentDir")
        new_main_agent["agentDir"] = new_dir_path

    new_agents.append(new_main_agent)
    print()

    # 4.2 团队成员
    if members:
        info(f"复制 {len(members)} 个团队成员...")
        for member in members:
            old_ws = Path(member["workspace"])
            if not old_ws.exists():
                warn(f"  workspace 不存在，跳过: {old_ws}")
                continue

            new_ws_path = rename_workspace_path(member["workspace"], suffix)
            safe_copytree(old_ws, Path(new_ws_path), member.get("name", ""))
            patch_agents_md(Path(new_ws_path), suffix)

            new_member = {
                "id":   rename_id(member.get("id", ""), suffix),
                "name": f"{member.get('name', '')}-{suffix}",
                "workspace": new_ws_path,
            }

            if member.get("agentDir"):
                old_dir = Path(member["agentDir"])
                new_dir_path = rename_agentdir_path(member["agentDir"], suffix)
                if old_dir.exists():
                    safe_copytree(old_dir, Path(new_dir_path))
                new_member["agentDir"] = new_dir_path

            new_agents.append(new_member)
            log(f"  {member.get('name')} → {new_member['name']} ({new_member['id']})")
        print()

    # ---- Step 5: 合并配置 ----
    info("合并配置到 openclaw.json...")
    new_config = merge_agents_config(team["config"], new_agents, suffix)

    with open(OPENCLAW_JSON, "w", encoding="utf-8") as f:
        json.dump(new_config, f, indent=2, ensure_ascii=False)

    log(f"openclaw.json 已更新")
    print()

    # ---- Step 6: 提醒重启 Gateway ----
    restart_gateway()
    print()

    # ---- 完成报告 ----
    print("=" * 60)
    print("  ✅ 分身完成！")
    print("=" * 60)
    print()
    print(f"新团队信息:")
    for a in new_agents:
        role = "Main" if a == new_agents[0] else "成员"
        print(f"  [{role}] {a.get('name')} (id={a['id']})")
        print(f"         workspace: {a.get('workspace')}")
        if a.get("agentDir"):
            print(f"         agentDir:  {a['agentDir']}")
    print()
    print("📋 验证步骤:")
    print("  1. openclaw gateway status")
    print("  2. 测试 spawn 分身 agent（用新的 ID）")
    print("  3. 如需回滚：cp -r ~/.qclaw/backup/clone备份_xxx/openclaw.json ~/.qclaw/openclaw.json")
    print()


if __name__ == "__main__":
    import sys
    main()


path: cron_tasks.json
content: {
  "description": "Cron 任务清单示例（通用模板）",
  "note": "这是模板文件，实际 cron 由 migrate.py 根据此模板在新环境创建",
  "tasks": [
    {
      "name": "main 每日自迭代",
      "schedule": "cron 0 22 * * * Asia/Shanghai",
      "sessionTarget": "isolated",
      "payload": {
        "kind": "agentTurn",
        "message": "执行每日自迭代：读 memory/、读团队 SOUL/SKILL、更新 MEMORY.md、commit push。"
      }
    }
  ]
}

path: migrate.py
content: #!/usr/bin/env python3
"""
一键搬家执行脚本 v3.0（审查确认 + 细粒度控制）
功能：审查确认 → 备份 → 合并配置 → 复制文件 → 完成搬家

v3.0 变更：
- 新增 --dry-run / --no-cron / --no-restart 细粒度控制
- agents.list 按 agent.id 追加/更新，不整体替换
- 审查步骤移到修改配置之前
- copy_team_members 使用配置中的实际 workspace 路径
- Zip Slip 路径穿越防御
- 审查展示 cron payload
- restart_gateway 改 subprocess list 参数
"""

import os
import sys
import json
import shutil
import subprocess
import time
import zipfile
import argparse
import difflib
from datetime import datetime
from pathlib import Path

# =============================================
# 颜色输出
# =============================================
GREEN  = '\033[0;32m'
RED    = '\033[0;31m'
YELLOW = '\033[1;33m'
BLUE   = '\033[0;34m'
NC     = '\033[0m'

def log(msg):   print(f"{GREEN}[✓]{NC} {msg}")
def err(msg):   print(f"{RED}[✗]{NC} {msg}")
def warn(msg):  print(f"{YELLOW}[!]{NC} {msg}")
def info(msg):  print(f"{BLUE}[i]{NC} {msg}")

def run_cmd(cmd):
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    return result.stdout.strip(), result.returncode

# =============================================
# 备份模块
# =============================================

BACKUP_DIR = Path.home() / ".qclaw" / "backup"

def backup_existing():
    """
    搬家前备份现有配置到 ~/.qclaw/backup/
    每次搬家创建带时间戳的子目录
    """
    info("检查是否需要备份...")
    
    targets = []
    
    # 检查 workspace
    for d in (Path.home() / ".qclaw").iterdir():
        if d.is_dir() and d.name.startswith("workspace-"):
            targets.append(d)
    
    # 检查 openclaw.json
    config = Path.home() / ".qclaw" / "openclaw.json"
    if config.exists():
        targets.append(config)
    
    if not targets:
        info("  未检测到现有配置，无需备份")
        return
    
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup_sub = BACKUP_DIR / f"搬家备份_{timestamp}"
    backup_sub.mkdir(parents=True, exist_ok=True)
    
    info(f"  备份现有配置到：{backup_sub}")
    count = 0
    for t in targets:
        try:
            dst = backup_sub / t.name
            shutil.copytree(t, dst, dirs_exist_ok=True)
            count += 1
        except Exception as e:
            warn(f"  备份失败 {t.name}：{e}")
    
    log(f"  已备份 {count} 项")

# =============================================
# 配置合并模块
# =============================================

def load_json(path):
    with open(path, 'r', encoding='utf-8') as f:
        return json.load(f)

def deep_merge(base, patch):
    """
    深度合并两个 dict。
    - patch 中有而 base 中无的键：直接添加
    - 两者都有且都是 dict：递归合并
    - 两者都有但类型不同：patch 覆盖 base
    """
    result = base.copy()
    for k, v in patch.items():
        if k in result and isinstance(result[k], dict) and isinstance(v, dict):
            result[k] = deep_merge(result[k], v)
        else:
            result[k] = v
    return result

def merge_config(package_dir):
    """
    将搬家包中的 agents 配置与现有 openclaw.json 深度合并。
    只合并 agents 和 hooks 相关字段，保留其他所有配置。
    """
    info("合并 agent 配置（保护新环境原有配置）...")
    
    agents_file = package_dir / "openclaw-agents.json"
    if not agents_file.exists():
        warn("  找不到 openclaw-agents.json，跳过配置更新")
        return True
    
    agents_patch = load_json(agents_file)
    
    config_path = Path.home() / ".qclaw" / "openclaw.json"
    
    if config_path.exists():
        existing = load_json(config_path)
        info(f"  现有配置 keys：{list(existing.keys())}")
    else:
        existing = {}
        warn("  openclaw.json 不存在，将从零创建")
    
    # 深度合并 agents 和 hooks
    merged = existing.copy()
    
    if 'agents' in agents_patch:
        if 'agents' not in merged:
            merged['agents'] = {}
        merged['agents'] = deep_merge(merged['agents'], agents_patch['agents'])
        
        # allowAgents: 如果搬家包含 ["*"]，转换为最小白名单（仅实际成员ID）
        defaults = merged.get('agents', {}).get('defaults', {})
        subagents = defaults.get('subagents', {})
        if subagents.get('allowAgents') == ["*"]:
            all_agent_ids = [a.get('id') for a in merged.get('agents', {}).get('list', [])
                             if a.get('id') and a.get('id') != 'main']
            subagents['allowAgents'] = all_agent_ids
            log(f"  allowAgents 已从 ["*"] 转为最小白名单（{len(all_agent_ids)} 个成员ID）")
        
        log("  agents 配置已合并")
    
    if 'hooks' in agents_patch:
        if 'hooks' not in merged:
            merged['hooks'] = {}
        # hooks.allowedAgentIds 需要合并（追加而非覆盖）
        existing_ids = set(merged['hooks'].get('allowedAgentIds', []))
        patch_ids = set(agents_patch['hooks'].get('allowedAgentIds', []))
        merged['hooks']['allowedAgentIds'] = list(existing_ids | patch_ids)
        log("  hooks 配置已合并")
    
    # 展示 diff（before → after）
    if config_path.exists():
        existing_text = json.dumps(existing, indent=2, ensure_ascii=False).splitlines(keepends=True)
        merged_text = json.dumps(merged, indent=2, ensure_ascii=False).splitlines(keepends=True)
        diff = list(difflib.unified_diff(existing_text, merged_text,
                                          fromfile="现有 openclaw.json", tofile="合并后",
                                          n=3))
        if diff:
            info("  openclaw.json 变更 diff：")
            for line in diff[:60]:
                line = line.rstrip("\n")
                if line.startswith("+") and not line.startswith("+++"):
                    print(f"    {GREEN}{line}{NC}")
                elif line.startswith("-") and not line.startswith("---"):
                    print(f"    {RED}{line}{NC}")
                elif line.startswith("@@"):
                    print(f"    {YELLOW}{line}{NC}")
                else:
                    print(f"    {line}")
            if len(diff) > 60:
                info(f"    ...（共 {len(diff)} 行 diff，已截断）")
        else:
            info("  openclaw.json 无变更")
    else:
        info("  将创建新的 openclaw.json")

    # 写回
    with open(config_path, 'w', encoding='utf-8') as f:
        json.dump(merged, f, indent=2, ensure_ascii=False)
    
    log(f"  openclaw.json 已更新（合并后）")
    return True

# =============================================
# 主流程步骤
# =============================================

def find_package_dir():
    """
    找到搬家包目录。
    优先顺序：
    1. 命令行参数传入（zip 路径或目录路径）
    2. 当前目录本身就是包目录（解压后直接 cd 进去）
    3. 当前目录下有 搬家包_* 目录
    4. 当前目录下有 *.zip
    """
    # 检查命令行参数
    if len(sys.argv) > 1:
        arg = Path(sys.argv[1])
        if arg.is_file() and arg.suffix == '.zip':
            # 解压 zip
            extract_dir = arg.with_suffix("")
            if not extract_dir.exists():
                info(f"  解压：{arg.name}")
                with zipfile.ZipFile(arg, 'r') as zip_ref:
                    for member in zip_ref.namelist():
                        member_path = (arg.parent / member).resolve()
                        if not str(member_path).startswith(str(arg.parent.resolve())):
                            err(f"  安全警告：zip包含路径穿越文件 {member}，中止")
                            return None
                    zip_ref.extractall(arg.parent)
            if (extract_dir / "身份层").exists():
                return extract_dir
        elif arg.is_dir() and (arg / "身份层").exists():
            return arg
    
    # 如果目录里有人身份层，说明当前就在包目录里
    if Path("身份层").exists():
        return Path.cwd()
    
    # 找搬家包_* 子目录
    for d in Path.cwd().iterdir():
        if d.is_dir() and d.name.startswith("搬家包_") and (d / "身份层").exists():
            return d
    
    # 找 zip 文件并解压
    zips = list(Path.cwd().glob("*.zip")) + list(Path.cwd().glob("搬家*.zip"))
    for zf in zips:
        info(f"  发现搬家包：{zf.name}")
        extract_dir = zf.with_suffix("")  # 去掉 .zip
        if extract_dir.exists():
            warn(f"  解压目录已存在，跳过解压")
        else:
            info(f"  解压到：{extract_dir}")
            with zipfile.ZipFile(zf, 'r') as zip_ref:
                for member in zip_ref.namelist():
                    member_path = (Path.cwd() / member).resolve()
                    if not str(member_path).startswith(str(Path.cwd().resolve())):
                        err(f"  安全警告：zip包含路径穿越文件 {member}，跳过此包")
                        continue
                zip_ref.extractall(Path.cwd())
        if (extract_dir / "身份层").exists():
            return extract_dir
    
    return None

def copy_identity_files(package_dir):
    """复制身份文件（backup 已在前面做过）"""
    info("复制身份文件...")
    
    identity_dir = package_dir / "身份层"
    if not identity_dir.exists():
        err("  找不到身份层目录")
        return False
    
    workspace = Path.home() / ".qclaw"
    workspace.mkdir(parents=True, exist_ok=True)
    
    # 找到 main agent workspace（从 agents 配置中读）
    config_path = workspace / "openclaw.json"
    main_ws = None
    
    if config_path.exists():
        config = load_json(config_path)
        for agent in config.get('agents', {}).get('list', []):
            if agent.get('id') == 'main':
                main_ws = agent.get('workspace', '')
                break
    
    # fallback：找 workspace-agent-* 目录
    if not main_ws:
        candidates = [d for d in workspace.iterdir()
                      if d.is_dir() and d.name.startswith("workspace-")]
        if candidates:
            # 直接取第一个候选 workspace（由 openclaw.json 的 main agent 配置决定）
            main_ws = str(candidates[0])
    
    if not main_ws:
        # 最后一个 fallback
        main_ws = str(workspace / "workspace-main")
    
    ws_path = Path(main_ws)
    ws_path.mkdir(parents=True, exist_ok=True)
    
    copied = 0
    for fname in ["SOUL.md", "MEMORY.md", "TOOLS.md", "AGENTS.md", "IDENTITY.md", "USER.md"]:
        src = identity_dir / fname
        if src.exists():
            shutil.copy2(src, ws_path / fname)
            copied += 1
    
    memory_src = identity_dir / "memory"
    if memory_src.exists():
        memory_dst = ws_path / "memory"
        memory_dst.mkdir(parents=True, exist_ok=True)
        for f in memory_src.glob("*.md"):
            shutil.copy2(f, memory_dst / f.name)
        copied += 1
    
    log(f"  已复制 {copied} 项到 {ws_path.name}/")
    return True

def copy_team_members(package_dir):
    """复制团队成员（自动检测）"""
    info("检查团队成员...")
    
    team_dir = package_dir / "团队成员层"
    if not team_dir.exists():
        info("  未检测到团队成员，跳过")
        return True
    
    workspace_base = Path.home() / ".qclaw"
    
    # 找到 main workspace 父目录
    main_ws = None
    config_path = workspace_base / "openclaw.json"
    if config_path.exists():
        config = load_json(config_path)
        for agent in config.get('agents', {}).get('list', []):
            if agent.get('id') == 'main':
                main_ws = agent.get('workspace', '')
                break
    
    if not main_ws:
        candidates = [d for d in workspace_base.iterdir()
                      if d.is_dir() and d.name.startswith("workspace-")]
        if candidates:
            main_ws = str(candidates[0])
    
    if main_ws:
        main_ws_path = Path(main_ws)
    else:
        main_ws_path = workspace_base / "workspace-main"
    
    # 读 openclaw-agents.json 获取每个成员的实际 workspace 路径
    agents_file = package_dir / "openclaw-agents.json"
    member_workspaces = {}
    if agents_file.exists():
        try:
            agents_data = load_json(agents_file)
            for a in agents_data.get('agents', {}).get('list', []):
                if a.get('id') != 'main' and a.get('workspace'):
                    member_workspaces[a.get('name', a.get('id'))] = a['workspace']
        except:
            pass
    
    count = 0
    for member_dir in team_dir.iterdir():
        if member_dir.is_dir():
            member_name = member_dir.name
            if member_name in member_workspaces:
                dst = Path(member_workspaces[member_name])
            else:
                dst = main_ws_path / member_name
            dst.mkdir(parents=True, exist_ok=True)
            for fname in ["SOUL.md", "MEMORY.md", "TOOLS.md"]:
                src = member_dir / fname
                if src.exists():
                    shutil.copy2(src, dst / fname)
            count += 1
    
    log(f"  已复制 {count} 个团队成员")
    return True

def copy_skills(package_dir):
    """复制 skills"""
    info("复制 skills...")
    
    skills_src = package_dir / "skills"
    if not skills_src.exists():
        warn("  未检测到 skills")
        return True
    
    workspace_base = Path.home() / ".qclaw"
    
    # 找 main workspace
    main_ws = None
    config_path = workspace_base / "openclaw.json"
    if config_path.exists():
        config = load_json(config_path)
        for agent in config.get('agents', {}).get('list', []):
            if agent.get('id') == 'main':
                main_ws = agent.get('workspace', '')
                break
    
    if not main_ws:
        candidates = [d for d in workspace_base.iterdir()
                      if d.is_dir() and d.name.startswith("workspace-")]
        if candidates:
            main_ws = str(candidates[0])
    
    if main_ws:
        ws_path = Path(main_ws)
    else:
        ws_path = workspace_base / "workspace-main"
    
    skills_dst = ws_path / "skills"
    skills_dst.mkdir(parents=True, exist_ok=True)
    
    count = 0
    for skill_dir in skills_src.iterdir():
        if skill_dir.is_dir():
            dst = skills_dst / skill_dir.name
            shutil.copytree(skill_dir, dst, dirs_exist_ok=True)
            count += 1
    
    log(f"  已复制 {count} 个 skills")
    return True

def create_cron_tasks(package_dir):
    """创建 cron 任务（用户已在审查步骤确认）"""
    info("创建 cron 任务...")
    
    cron_file = package_dir / "cron_tasks.json"
    if not cron_file.exists():
        cron_file = package_dir / "cron_jobs.json"
    if not cron_file.exists():
        warn("  未检测到 cron 配置")
        return True
    
    cron_tasks = load_json(cron_file)
    
    # 检查已存在
    output, _ = run_cmd("openclaw tasks list --json 2>/dev/null")
    try:
        existing = json.loads(output) if output else []
        existing_names = {t.get('name', '') for t in existing}
    except:
        existing_names = set()
    
    created = 0
    skipped = 0
    failed = 0
    
    for task in cron_tasks:
        task_name = task.get('name', 'unnamed')
        
        if task_name in existing_names:
            skipped += 1
            info(f"  跳过已存在：{task_name}")
            continue
        
        task_json = json.dumps(task, ensure_ascii=False)
        result = subprocess.run(
            ["openclaw", "tasks", "add", "--job", task_json],
            capture_output=True, text=True
        )
        code = result.returncode
        
        if code == 0:
            log(f"  创建：{task_name}")
            created += 1
        else:
            err(f"  创建失败：{task_name}")
            failed += 1
    
    info(f"  结果：{created}个创建，{skipped}个跳过，{failed}个失败")
    return True

def restart_gateway():
    """重启 Gateway（可通过 --no-restart 跳过）"""
    info("重启 Gateway...")
    result = subprocess.run(
        ["openclaw", "gateway", "restart"],
        capture_output=True, text=True
    )
    
    if result.returncode == 0:
        log("  Gateway 已重启")
        info("  等待5秒让 channel 注册...")
        time.sleep(5)
    else:
        warn("  Gateway 重启命令可能失败")
        warn("  请手动执行：openclaw gateway restart")
    
    return True

# =============================================
# 用户交互
# =============================================

def prompt_main_agent_handling():
    """
    让用户选择如何处理 main agent。
    这次做完整逻辑：选项1/2/3 都有实际执行内容。
    """
    workspace_base = Path.home() / ".qclaw"
    config_path = workspace_base / "openclaw.json"
    
    has_main = False
    if config_path.exists():
        config = load_json(config_path)
        agents = config.get('agents', {}).get('list', [])
        has_main = any(a.get('id') == 'main' for a in agents)
    
    print()
    print("请选择如何处理 Main Agent：")
    print()
    print("  1. 指向现有 agent（把当前 agent 变成你的主控）")
    print("  2. 新建 main agent 实例（另起一个，保留当前配置）")
    if has_main:
        print("  3. 覆盖现有 main agent（⚠️ 替换现有主控）")
    print()
    
    choices = ["1", "2"]
    if has_main:
        choices.append("3")
    
    while True:
        choice = input("请输入选项（" + "/".join(choices) + "）：").strip()
        if choice in choices:
            break
        err("无效选项，请重新输入")
    
    # ---- 选项 1：指向现有 agent ----
    if choice == "1":
        info("将当前 agent 配置为 main...")
        
        if not config_path.exists():
            err("  openclaw.json 不存在，无法配置")
            return False
        
        config = load_json(config_path)
        
        # 找 workspace-agent-* 目录，优先选含 SOUL.md 的
        main_ws = None
        candidates = [d for d in workspace_base.iterdir()
                      if d.is_dir() and d.name.startswith("workspace-")]
        
        for c in candidates:
            if (c / "SOUL.md").exists():
                main_ws = str(c)
                break
        
        if not main_ws and candidates:
            main_ws = str(candidates[0])
        
        if main_ws:
            log(f"  找到 workspace：{main_ws}")
            
            # 把这个 agent 设为 main（添加或更新 agents.list）
            agents_list = config.get('agents', {}).get('list', [])
            
            # 移除已有的 main
            agents_list = [a for a in agents_list if a.get('id') != 'main']
            
            # 添加 main
            agents_list.insert(0, {
                "id": "main",
                "workspace": main_ws,
                "name": "Main Agent"
            })
            
            config['agents']['list'] = agents_list
            
            with open(config_path, 'w', encoding='utf-8') as f:
                json.dump(config, f, indent=2, ensure_ascii=False)
            
            log("  已将当前 agent 设为 main")
        else:
            warn("  未能自动找到 agent workspace")
            warn("  请手动在 openclaw.json 中设置 main agent 的 workspace")
        
        return True
    
    # ---- 选项 2：新建 main agent 实例 ----
    elif choice == "2":
        info("创建新的 main agent 实例...")
        
        # 创建目录结构
        new_ws = workspace_base / "workspace-new-main"
        new_ws.mkdir(parents=True, exist_ok=True)
        (new_ws / "memory").mkdir(exist_ok=True)
        (new_ws / "skills").mkdir(exist_ok=True)
        
        # 创建 agentDir
        agent_dir = workspace_base / "agents" / "main" / "agent"
        agent_dir.mkdir(parents=True, exist_ok=True)
        
        # 创建 models.json
        models_config = {
            "providers": {
                "qclaw": {
                    "baseUrl": "http://127.0.0.1:19000/proxy/llm",
                    "apiKey": "__QCLAW_AUTH_GATEWAY_MANAGED__",
                    "api": "openai-completions",
                    "models": [
                        {"id": "modelroute", "name": "modelroute", "input": ["text", "image"]}
                    ]
                }
            }
        }
        with open(agent_dir / "models.json", 'w', encoding='utf-8') as f:
            json.dump(models_config, f, indent=2)
        
        # 更新 openclaw.json：添加 main agent
        if not config_path.exists():
            config = {}
        else:
            config = load_json(config_path)
        
        if 'agents' not in config:
            config['agents'] = {}
        if 'list' not in config['agents']:
            config['agents']['list'] = []
        
        # 移除已有的 main
        config['agents']['list'] = [a for a in config['agents']['list']
                                     if a.get('id') != 'main']
        # 添加新 main
        config['agents']['list'].insert(0, {
            "id": "main",
            "workspace": str(new_ws),
            "name": "Main Agent",
            "agentDir": str(agent_dir)
        })
        
        with open(config_path, 'w', encoding='utf-8') as f:
            json.dump(config, f, indent=2, ensure_ascii=False)
        
        log(f"  已创建 main agent：{new_ws}")
        return True
    
    # ---- 选项 3：覆盖 ----
    elif choice == "3":
        print()
        confirm = input("⚠️ 确认覆盖现有 main agent？输入 'yes' 继续：").strip()
        if confirm.lower() != 'yes':
            err("取消操作")
            return False
        
        info("将覆盖现有 main agent...")
        return True
    
    return True

# =============================================
# 主流程
# =============================================

def review_and_confirm(package_dir, args=None):
    """
    搬家前审查步骤——在执行任何敏感操作之前，
    展示即将写入的配置内容，由用户确认后继续。
    
    这是安全关键步骤，不允许跳过。
    """
    print()
    print("🔍 Step 0.8: 搬家包内容审查")
    print("─" * 40)
    print()
    print("以下内容将从搬家包写入你的系统，请仔细核对：")
    print()
    
    # ---- 1. agents 配置 ----
    agents_file = package_dir / "openclaw-agents.json"
    if agents_file.exists():
        print("【1/3】Agent 配置（将写入 ~/.qclaw/openclaw.json）：")
        agents_patch = load_json(agents_file)
        
        # agents.list
        agents_list = agents_patch.get('agents', {}).get('list', [])
        if agents_list:
            print(f"  将追加/更新 {len(agents_list)} 个 agent（不删除现有 agent）：")
            for a in agents_list:
                aid = a.get('id', '?')
                name = a.get('name', '?')
                ws = a.get('workspace', '?')
                print(f"    · {aid}（{name}）→ {ws}")
        
        # subagents 配置
        defaults = agents_patch.get('agents', {}).get('defaults', {})
        allow = defaults.get('subagents', {}).get('allowAgents', [])
        if allow == ["*"]:
            print()
            print("  ⚠️ 注意：搬家包设置了 allowAgents 为 [\"*\"]")
            print("    安装时会改为最小白名单（仅实际成员ID）。")
            print("    如确实需要通配符，请安装后手动修改。")
        
        # hooks 配置
        hooks_ids = agents_patch.get('hooks', {}).get('allowedAgentIds', [])
        if hooks_ids:
            print()
            print(f"  hooks.allowedAgentIds 将追加：{hooks_ids}")
    else:
        print("【1/3】Agent 配置：无")
    
    print()
    
    # ---- 2. cron 任务 ----
    cron_file = package_dir / "cron_tasks.json"
    if not cron_file.exists():
        cron_file = package_dir / "cron_jobs.json"
    if cron_file.exists():
        cron_tasks = load_json(cron_file)
        print(f"【2/3】Cron 定时任务（将创建 {len(cron_tasks)} 个）：")
        for t in cron_tasks:
            tname = t.get('name', 'unnamed')
            schedule = t.get('schedule', '?')
            payload_preview = str(t.get('payload', ''))[:80]
            print(f"    · {tname} — {schedule}")
            if payload_preview:
                print(f"      payload: {payload_preview}...")
    else:
        print("【2/3】Cron 任务：无")
    
    print()
    
    # ---- 3. 文件清单 ----
    print("【3/3】将从搬家包复制的文件：")
    
    # 身份文件
    identity_dir = package_dir / "身份层"
    if identity_dir.exists():
        identity_files = list(identity_dir.glob("*.md"))
        print(f"  身份文件（→ main workspace）：{len(identity_files)} 个")
        for f in identity_files:
            print(f"    · {f.name}")
        # 敏感文件提示
        if (identity_dir / "MEMORY.md").exists():
            print("    ⚠️ 含 MEMORY.md（可能含私人信息）")
        if (identity_dir / "TOOLS.md").exists():
            print("    ⚠️ 含 TOOLS.md（可能含 API keys）")
    
    # 团队成员
    team_dir = package_dir / "团队成员层"
    if team_dir.exists():
        members = [d for d in team_dir.iterdir() if d.is_dir()]
        print(f"  团队成员：{len(members)} 个 → main workspace/{members[0].name if members else '??'}/")
        for m in members:
            print(f"    · {m.name}/")
    
    # skills
    skills_src = package_dir / "skills"
    if skills_src.exists():
        skills = [d for d in skills_src.iterdir() if d.is_dir()]
        print(f"  Skills：{len(skills)} 个 → workspace/skills/")
        for s in skills:
            print(f"    · {s.name}/")
    
    print()
    print("─" * 40)
    print()
    

    if args and args.dry_run:
        print("🔍 DRY RUN 模式：以下操作只展示不执行")
    else:
        print("⚠️ 确认后将执行：复制文件 → 合并配置" + (" → 创建 cron" if not (args and args.no_cron) else "（跳过cron）") + (" → 重启 Gateway" if not (args and args.no_restart) else "（跳过重启）"))
    # ---- 用户确认 ----
    while True:
        print("是否确认执行以上操作？")
        print()
        print("  y  — 确认，继续搬家")
        print("  n  — 取消，中止操作")
        print()
        choice = input("请输入（y/n）：").strip().lower()
        if choice == 'y':
            print()
            log("用户已确认，继续执行...")
            return True
        elif choice == 'n':
            print()
            err("用户取消，搬家中止")
            return False
        else:
            err("无效选项，请输入 y 或 n")


def parse_args():
    parser = argparse.ArgumentParser(description="一键搬家执行脚本 v3.0")
    parser.add_argument("package", nargs="?", help="搬家包 zip 或目录路径")
    parser.add_argument("--dry-run", action="store_true", help="只展示不执行")
    parser.add_argument("--no-cron", action="store_true", help="跳过 cron 任务创建")
    parser.add_argument("--no-restart", action="store_true", help="跳过 Gateway 重启")
    return parser.parse_args()

def main():
    args = parse_args()

    print()
    print("=" * 60)
    print("  一键搬家执行脚本 v3.0（审查确认 + 细粒度控制）")
    if args.dry_run:
        print("  🔍 DRY RUN 模式——只展示不执行")
    print("=" * 60)
    print()

    # ---- 找到搬家包 ----
    info("定位搬家包...")
    package_dir = find_package_dir()

    if not package_dir:
        err("找不到搬家包！")
        print()
        print("用法：python3 migrate.py <搬家包路径> [--dry-run] [--no-cron] [--no-restart]")
        return

    log(f"  搬家包目录：{package_dir.name}/")
    print()

    # ---- 搬家包审查（必须在修改配置之前） ----
    if not review_and_confirm(package_dir, args):
        return

    # ---- dry-run: 展示 merge diff 但不写入 ----
    if args.dry_run:
        print()
        info("DRY RUN: 展示配置合并 diff（不写入文件）...")
        agents_file = package_dir / "openclaw-agents.json"
        if agents_file.exists():
            agents_patch = load_json(agents_file)
            config_path = Path.home() / ".qclaw" / "openclaw.json"
            existing = load_json(config_path) if config_path.exists() else {}
            merged = existing.copy()
            if 'agents' in agents_patch:
                if 'agents' not in merged:
                    merged['agents'] = {}
                if 'defaults' in agents_patch['agents']:
                    if 'defaults' not in merged['agents']:
                        merged['agents']['defaults'] = {}
                    merged['agents']['defaults'] = deep_merge(
                        merged['agents']['defaults'], agents_patch['agents']['defaults'])
                if 'list' in agents_patch['agents']:
                    if 'list' not in merged['agents']:
                        merged['agents']['list'] = []
                    existing_ids = {a.get('id') for a in merged['agents']['list']}
                    for agent in agents_patch['agents']['list']:
                        aid = agent.get('id')
                        if aid in existing_ids:
                            merged['agents']['list'] = [
                                agent if a.get('id') == aid else a
                                for a in merged['agents']['list']
                            ]
                        else:
                            merged['agents']['list'].append(agent)
            if 'hooks' in agents_patch:
                if 'hooks' not in merged:
                    merged['hooks'] = {}
                existing_ids = set(merged['hooks'].get('allowedAgentIds', []))
                patch_ids = set(agents_patch['hooks'].get('allowedAgentIds', []))
                merged['hooks']['allowedAgentIds'] = list(existing_ids | patch_ids)
            existing_text = json.dumps(existing, indent=2, ensure_ascii=False).splitlines(keepends=True)
            merged_text = json.dumps(merged, indent=2, ensure_ascii=False).splitlines(keepends=True)
            import difflib
            diff = list(difflib.unified_diff(existing_text, merged_text,
                                              fromfile="现有 openclaw.json", tofile="合并后", n=3))
            if diff:
                info("  openclaw.json 变更 diff：")
                for line in diff[:80]:
                    line = line.rstrip("\n")
                    if line.startswith("+") and not line.startswith("+++"):
                        print(f"    {GREEN}{line}{NC}")
                    elif line.startswith("-") and not line.startswith("---"):
                        print(f"    {RED}{line}{NC}")
                    elif line.startswith("@@"):
                        print(f"    {YELLOW}{line}{NC}")
                    else:
                        print(f"    {line}")
            else:
                info("  openclaw.json 无变更")
        print()
        print("=" * 60)
        print("  🔍 DRY RUN 完成——以上操作未实际执行")
        print("  去掉 --dry-run 参数后重新运行以执行搬家")
        print("=" * 60)
        return

    # ---- 备份 ----
    print("🔒 Step 0: 备份现有配置...")
    backup_existing()
    print()

    # ---- 用户选择 main agent 处理方式 ----
    print("🔧 Step 0.5: Main Agent 配置...")
    if not prompt_main_agent_handling():
        err("Main Agent 配置失败，中止搬家")
        return
    print()

    # ---- 主流程 ----
    steps = [
        ("Step 1: 复制身份文件",     copy_identity_files),
        ("Step 2: 复制团队成员",     copy_team_members),
        ("Step 3: 复制 skills",       copy_skills),
        ("Step 4: 合并 agent 配置",  merge_config),
    ]

    if not args.no_cron:
        steps.append(("Step 5: 创建 cron 任务", create_cron_tasks))
    else:
        info("跳过 cron 任务创建（--no-cron）")

    if not args.no_restart:
        steps.append(("Step 6: 重启 Gateway", restart_gateway))
    else:
        info("跳过 Gateway 重启（--no-restart），请手动执行：openclaw gateway restart")

    for label, fn in steps:
        print(f"{label}...")
        result = fn(package_dir)
        if not result:
            err(f"{label} 失败")
        print()

    # ---- 完成 ----
    print("=" * 60)
    print("  ✅ 一键搬家完成！")
    print("=" * 60)
    print()
    print("📋 验证步骤：")
    print()
    print("  1. 检查 SOUL.md：")
    print("     ls ~/.qclaw/workspace-*/SOUL.md")
    print()
    print("  2. 测试子代理激活：")
    print("     在 agent 对话中说：测试激活团队成员")
    print()
    if args.no_restart:
        print("  3. 手动重启 Gateway：")
        print("     openclaw gateway restart")
        print()
    print("  检查 cron 任务：")
    print("     openclaw tasks list")
    print()
    print("  如需回滚：")
    backup_list = sorted(BACKUP_DIR.iterdir(), key=lambda d: d.name)
    if backup_list:
        latest = backup_list[-1].name
        print(f"     cp -r {BACKUP_DIR / latest}/* ~/.qclaw/")
    print()
    print("=" * 60)
    print()


if __name__ == "__main__":
    main()


path: openclaw-agents.json
content: {
  "description": "Agent 配置示例（通用模板）",
  "note": "这是模板文件，实际配置由 migrate.py 在新环境 deep merge",
  "agents": {
    "list": [
      {
        "id": "member-a",
        "name": "成员A",
        "workspace": "/Users/xxx/.qclaw/workspace-xxx/成员A",
        "agentDir": "/Users/xxx/.qclaw/agents/member-a/agent"
      },
      {
        "id": "member-b",
        "name": "成员B",
        "workspace": "/Users/xxx/.qclaw/workspace-xxx/成员B",
        "agentDir": "/Users/xxx/.qclaw/agents/member-b/agent"
      },
      {
        "id": "member-c",
        "name": "成员C",
        "workspace": "/Users/xxx/.qclaw/workspace-xxx/成员C",
        "agentDir": "/Users/xxx/.qclaw/agents/member-c/agent"
      }
    ],
    "defaults": {
      "subagents": {
        "allowAgents": [
          "*"
        ]
      }
    }
  }
}

path: pack.py
content: #!/usr/bin/env python3
"""
一键搬家打包脚本 v2.0(通用版)
功能:自动收集所有资料,生成搬家包

修复:
- v2.0: 改为自动检测当前 active workspace，不再硬编码具体路径
"""

import os
import json
import shutil
import subprocess
from datetime import datetime
from pathlib import Path

# =============================================
# 颜色输出
# =============================================
GREEN = '\033[0;32m'
RED = '\033[0;31m'
YELLOW = '\033[1;33m'
BLUE = '\033[0;34m'
NC = '\033[0m'

def log(msg): print(f"{GREEN}[✓]{NC} {msg}")
def err(msg): print(f"{RED}[✗]{NC} {msg}")
def warn(msg): print(f"{YELLOW}[!]{NC} {msg}")
def info(msg): print(f"{BLUE}[i]{NC} {msg}")

def run_cmd(cmd):
    result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
    return result.stdout.strip(), result.returncode

# =============================================
# 核心:自动检测当前 active workspace
# =============================================

def detect_active_workspace():
    """
    自动检测当前 agent 的 active workspace。

    策略:
    1. 读取 ~/.qclaw/openclaw.json 的 agents.list
    2. 找到 main agent 的 workspace 路径
    3. 验证该目录包含 SOUL.md

    返回:Path 对象 或 None
    """
    config_path = Path.home() / ".qclaw" / "openclaw.json"

    if not config_path.exists():
        warn("找不到 openclaw.json,尝试从当前工作目录推断")
        # fallback:从 cwd 推断
        cwd = Path.cwd()
        if (cwd / "SOUL.md").exists():
            return cwd
        return None

    with open(config_path, 'r', encoding='utf-8') as f:
        config = json.load(f)

    agents = config.get('agents', {}).get('list', [])

    # 找 main agent
    for agent in agents:
        if agent.get('id') == 'main':
            workspace = agent.get('workspace', '')
            if workspace:
                wp = Path(workspace)
                if wp.exists() and (wp / "SOUL.md").exists():
                    return wp

    # fallback：从agents.list推断主agent workspace
    # 策略：收集所有workspace路径的父级链，找包含SOUL.md的最深公共父目录
    workspaces = [Path(a['workspace']) for a in agents
                  if a.get('workspace') and Path(a.get('workspace', '')).exists()]
    if workspaces:
        # 收集所有workspace的所有祖先目录（到home为止）
        candidate_dirs = set()
        home = Path.home()
        for ws in workspaces:
            for parent in ws.parents:
                if parent == home or not str(parent).startswith(str(home)):
                    break
                candidate_dirs.add(parent)
        # 在候选目录中找包含SOUL.md+MEMORY.md的，选最深的（最接近workspace）
        for candidate in sorted(candidate_dirs, key=lambda x: len(x.parts), reverse=True):
            if (candidate / 'SOUL.md').exists() and (candidate / 'MEMORY.md').exists():
                return candidate

    # fallback：遍历 workspace- 开头的目录，找包含 SOUL.md + MEMORY.md 的
    workspace_base = Path.home() / ".qclaw"
    for d in sorted(workspace_base.iterdir(), key=lambda x: len(x.name)):
        if d.is_dir() and d.name.startswith("workspace-"):
            if (d / "SOUL.md").exists() and (d / "MEMORY.md").exists():
                return d

    # fallback:找当前 cwd 的父链
    cwd = Path.cwd()
    for parent in [cwd] + list(cwd.parents):
        if (parent / "SOUL.md").exists() and (parent / "MEMORY.md").exists():
            return parent

    return None

def get_agent_name(workspace):
    """从 workspace 推断 agent 名称"""
    memory = workspace / "MEMORY.md"
    identity = workspace / "IDENTITY.md"

    name = workspace.name  # 默认用目录名

    if identity.exists():
        with open(identity, 'r', encoding='utf-8') as f:
            content = f.read()
        for line in content.split('\n'):
            if line.startswith('**What to call them:**'):
                return line.split('**What to call them:**')[1].strip().rstrip('*').strip()

    return name

# =============================================
# 主流程
# =============================================

def get_agent_info():
    """获取 agent 配置信息"""
    config_path = Path.home() / ".qclaw" / "openclaw.json"
    if not config_path.exists():
        return []
    with open(config_path, 'r', encoding='utf-8') as f:
        config = json.load(f)
    return config.get('agents', {}).get('list', [])

def get_cron_tasks():
    """获取 cron 任务列表"""
    output, code = run_cmd("openclaw tasks list --json")
    if code == 0 and output:
        try:
            return json.loads(output)
        except:
            pass
    return []

def main():
    print()
    print("=" * 60)
    print("  一键搬家打包脚本 v2.0")
    print(f"  时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print("=" * 60)
    print()

    # ---- 自动检测 active workspace ----
    info("检测当前 active workspace...")
    main_workspace = detect_active_workspace()

    if not main_workspace:
        err("无法自动检测 active workspace")
        err("请手动设置 MAIN_WORKSPACE 环境变量,或确保 openclaw.json 中有 main agent 配置")
        print()
        print("手动指定示例:")
        print("  export MAIN_WORKSPACE=/Users/xxx/.qclaw/workspace-agent-xxx")
        print("  python3 pack.py")
        return

    agent_name = get_agent_name(main_workspace)
    log(f"检测到 workspace:{main_workspace}")
    log(f"Agent 名称:{agent_name}")
    print()

    # 创建输出目录
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    package_name = f"{agent_name}搬家包_{timestamp}"
    OUTPUT_DIR = Path.home() / "一键搬家包"
    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
    package_dir = OUTPUT_DIR / package_name
    package_dir.mkdir(parents=True, exist_ok=True)
    info(f"输出目录:{package_dir}")
    print()

    # ------------------------------------------
    # Step 1: 收集 Main Agent 信息
    # ------------------------------------------
    print("📦 Step 1: 收集 Main Agent 信息...")

    main_dir = package_dir / "身份层"
    main_dir.mkdir(exist_ok=True)

    core_files = ["SOUL.md", "MEMORY.md", "TOOLS.md", "AGENTS.md", "IDENTITY.md", "USER.md", "SKILLS.md"]
    sensitive_files = ["MEMORY.md", "TOOLS.md"]  # 可能含私人信息/API keys
    for filename in core_files:
        src = main_workspace / filename
        if src.exists():
            shutil.copy2(src, main_dir / filename)
            flag = " ⚠️ 可能含敏感数据" if filename in sensitive_files else ""
            log(f"  {filename}{flag}")

    memory_src = main_workspace / "memory"
    if memory_src.exists():
        memory_dst = main_dir / "memory"
        memory_dst.mkdir(exist_ok=True)
        for f in memory_src.glob("*.md"):
            shutil.copy2(f, memory_dst / f.name)
        log(f"  memory/")

    print()

    # ------------------------------------------
    # Step 2: 收集团队成员(自动检测)
    # ------------------------------------------
    print("👥 Step 2: 收集团队成员...")

    agents = get_agent_info()
    team_members = []

    # 自动检测是否有团队成员
    has_team = False
    if agents:
        for agent in agents:
            if agent.get('id') != 'main':
                has_team = True
                break

    if not has_team:
        info("  未检测到团队成员,跳过")
    else:
        for agent in agents:
            agent_id = agent.get('id', '')
            if agent_id == 'main':
                continue

            name = agent.get('name', agent_id)
            workspace = agent.get('workspace', '')

            if workspace and Path(workspace).exists():
                team_dir = package_dir / "团队成员层" / name
                team_dir.mkdir(parents=True, exist_ok=True)

                for filename in ["SOUL.md", "MEMORY.md", "TOOLS.md"]:
                    src = Path(workspace) / filename
                    if src.exists():
                        shutil.copy2(src, team_dir / filename)

                team_members.append({"id": agent_id, "name": name, "workspace": workspace})
                log(f"  {name} ({agent_id})")

    info(f"  共 {len(team_members)} 个团队成员")
    print()

    # ------------------------------------------
    # Step 3: 收集 Skills
    # ------------------------------------------
    print("🔧 Step 3: 收集 Skills...")

    skills_src = main_workspace / "skills"
    skill_count = 0
    if skills_src.exists():
        skills_dst = package_dir / "skills"
        skills_dst.mkdir(exist_ok=True)

        for skill_dir in skills_src.iterdir():
            if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
                dst = skills_dst / skill_dir.name
                shutil.copytree(skill_dir, dst, dirs_exist_ok=True)
                log(f"  {skill_dir.name}/")
                skill_count += 1

    if skill_count == 0:
        warn("  未检测到 skills")
    print()

    # ------------------------------------------
    # Step 4: 收集 Cron 任务配置
    # ------------------------------------------
    print("⏰ Step 4: 收集 Cron 任务配置...")

    cron_tasks = get_cron_tasks()
    if cron_tasks:
        cron_config = []
        for task in cron_tasks:
            if isinstance(task, str):
                cron_config.append({"name": task})
            elif isinstance(task, dict):
                cron_config.append({
                    "name": task.get('name', ''),
                    "schedule": task.get('schedule', {}),
                    "sessionTarget": task.get('sessionTarget', 'isolated'),
                    "payload": task.get('payload', {})
                })

        with open(package_dir / "cron_tasks.json", 'w', encoding='utf-8') as f:
            json.dump(cron_config, f, indent=2, ensure_ascii=False)

        log(f"  cron_tasks.json ({len(cron_tasks)} 个任务)")
    else:
        warn("  未能获取 cron 任务列表")
    print()

    # ------------------------------------------
    # Step 4.5: 检测工作目录
    # ------------------------------------------
    print("📁 Step 4.5: 检测工作目录...")

    # 扫描常见工作目录
    common_work_dirs = [
        Path.home() / "tales",
        Path.home() / "work",
        Path.home() / "projects",
        Path.home() / "workspace",
    ]

    found_work_dirs = []
    for d in common_work_dirs:
        if d.exists() and d.is_dir():
            # 检查是否 git 仓库
            if (d / ".git").exists():
                found_work_dirs.append(str(d))
                log(f"  {d} (git仓库)")

    # 生成工作目录提示文件
    work_dir_note = f"""# 工作目录

打包时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

## 检测到的 git 工作目录

{chr(10).join(f'- {d}' for d in found_work_dirs) if found_work_dirs else '(未检测到常见工作目录)'}

## 搬家后请手动处理

1. 将上述目录的 git remote 设置为新环境的地址
2. 如果是新 clone，确保 .git/config 中的 user.name 和 user.email 正确
3. 建议: 在新环境执行一次 `git status` 确认仓库正常

## 提示

tales/ 目录是团队的创作成果仓库。
建议把它放在固定位置，并在新环境 clone 后更新路径配置。
"""

    with open(package_dir / "工作目录说明.md", 'w', encoding='utf-8') as f:
        f.write(work_dir_note)
    log("  工作目录说明.md")
    print()

    # ------------------------------------------
    # Step 5: 生成 Agent 配置片段
    # ------------------------------------------
    print("📝 Step 5: 生成 Agent 配置片段...")

    if agents:
        # 收集所有非 main 的 agent ID 作为白名单
        agent_ids = [a['id'] for a in agents if a.get('id') != 'main']
        agents_config = {
            "agents": {
                "defaults": {
                    "model": {"primary": "qclaw/modelroute"},
                    "maxConcurrent": 10,
                    "subagents": {"allowAgents": agent_ids}
                },
                "list": agents
            },
            "hooks": {
                "allowedAgentIds": agent_ids
            }
        }

        with open(package_dir / "openclaw-agents.json", 'w', encoding='utf-8') as f:
            json.dump(agents_config, f, indent=2, ensure_ascii=False)

        log("  openclaw-agents.json")
    print()

    # ------------------------------------------
    # Step 6: 生成 README.md
    # ------------------------------------------
    print("📄 Step 6: 生成 README.md...")

    team_tree = ""
    if has_team:
        team_tree = f"""
├── 团队成员层/          ← {len(team_members)} 个团队成员
│   ├── 小A/
│   └── 小B/
│   └── ..."""

    readme_content = f"""# 一键搬家包

**生成时间:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
**Agent:** {agent_name}
**Workspace:** {main_workspace}
"""

    if has_team:
        readme_content += f"**团队成员:** {len(team_members)} 人\n\n成员列表:\n"
        for m in team_members:
            readme_content += f"- **{m['name']}** (`{m['id']}`)\n"

    readme_content += f"""
---

## 包内容

```
搬家包/
├── 身份层/              ← Main Agent 核心文件
│   ├── SOUL.md
│   ├── MEMORY.md
│   ├── TOOLS.md
│   ├── AGENTS.md
│   ├── IDENTITY.md
│   ├── USER.md
│   └── memory/{team_tree}
│
├── skills/              ← Skills({skill_count}个)
│
├── openclaw-agents.json ← Agent 配置片段
├── cron_tasks.json      ← Cron 任务配置
├── 工作目录说明.md      ← git工作目录提示
│
├── README.md            ← 本文件
└── migrate.py           ← 一键执行脚本
```

---

## 使用方法

### 自动执行(推荐)

把这个包丢给 agent,说:

```
这是一键搬家包,帮我搬家
```

agent 会自动执行:
1. 备份现有配置
2. 合并 agent 配置(不覆盖其他配置)
3. 复制身份文件 + 团队成员 + skills
4. 创建 cron 任务
5. 重启 Gateway

### 手动执行

```bash
unzip 搬家包.zip
cd 搬家包
python3 migrate.py
```

---

## 注意事项

1. 搬家前会**自动备份**现有配置到 `~/.qclaw/backup/`
2. agent 配置会与现有配置**合并**,不会覆盖其他配置
3. allowAgents 使用**最小白名单**（仅实际成员ID），不使用通配符
4. 执行前**展示所有变更内容**，用户确认后全自动执行
5. MEMORY.md 和 TOOLS.md 可能含敏感数据，请妥善保管搬家包

---

**生成脚本:** skills/team-resurrection/pack.py v2.0
"""

    with open(package_dir / "README.md", 'w', encoding='utf-8') as f:
        f.write(readme_content)

    # 把 migrate.py 也复制进包(从 skill 目录复制)
    skill_dir = main_workspace / "skills" / "team-resurrection"
    migrate_src = skill_dir / "migrate.py"
    if migrate_src.exists():
        shutil.copy2(migrate_src, package_dir / "migrate.py")
        log("  migrate.py")

    log("  README.md")
    print()

    # ------------------------------------------
    # Step 7: 打包
    # ------------------------------------------
    print("📦 Step 7: 打包...")

    zip_file = str(package_dir) + ".zip"
    shutil.make_archive(str(package_dir), 'zip', package_dir)

    zip_size = os.path.getsize(zip_file)
    size_mb = zip_size / (1024 * 1024)

    log(f"  打包完成:{zip_file}")
    info(f"  包大小:{size_mb:.2f} MB")

    shutil.rmtree(package_dir)

    print()

    # 完成报告
    print("=" * 60)
    print("  打包完成")
    print("=" * 60)
    print()
    print(f"✅ 输出文件:{zip_file}")
    print(f"✅ 包大小:{size_mb:.2f} MB")
    print()
    print("📋 包内容:")
    print(f"   - Main Agent({agent_name})")
    print(f"   - 团队成员:{len(team_members)} 人")
    print(f"   - Skills:{skill_count} 个")
    print(f"   - Cron任务:{len(cron_tasks)} 个")
    if found_work_dirs:
        print(f"   - 工作目录:{len(found_work_dirs)} 个(含git仓库)")
    print()
    print("📋 后续步骤:")
    print()
    print("   1. 把搬家包复制到新环境")
    print("   2. 把搬家包丢给 agent,说:'这是一键搬家包,帮我搬家'")
    print()
    print("=" * 60)
    print()

if __name__ == "__main__":
    main()


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