Compare commits

..

3 Commits

Author SHA1 Message Date
766d162245 fix: harden skill template contracts and scaffolding
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Align scaffold slug replacement, Action manifest strict boundaries, metadata prune API, field permission validation, and task_logs readonly semantics with documented template contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 11:55:46 +08:00
35c20fc0f2 feat: standardize skill data management and actions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 11:23:14 +08:00
48a86e56f1 chore: auto release commit (2026-07-03 18:50:07)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-03 18:50:08 +08:00
42 changed files with 3624 additions and 638 deletions

5
.openclaw-skill-template Normal file
View File

@@ -0,0 +1,5 @@
# 此文件仅存在于 skill-template 源仓库。
# 用 tools/scaffold_skill 创建新技能时,脚本会从目标目录删除本文件。
# 若业务技能仓库中仍存在本文件,说明未正确 scaffoldtests 会失败。
template_slug: your-skill-slug
template_version: "1.0.32"

View File

@@ -79,11 +79,13 @@ python {baseDir}/scripts/main.py version
## 定制入口 ## 定制入口
1. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词 1. 从本模板创建新业务技能时,**不得保留模板 `.git`**;优先使用 [`tools/scaffold_skill.ps1`](tools/scaffold_skill.ps1)(见 [`tools/README.md`](tools/README.md)
2. `development/DEVELOPMENT.md` 完成开发与文档定制 2. 业务技能仓库中**不得**存在 `.openclaw-skill-template`(仅 template 源仓库保留)
3. 用户市场说明写入根 `README.md`Agent 调用契约见 `references/CLI.md``references/SCHEMA.md` 3. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词
4. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION` 4. `development/DEVELOPMENT.md` 完成开发与文档定制
5. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`(元数据表 `_jiangchang_*` + `CREATE TABLE` 物理顺序) 5. 用户市场说明写入根 `README.md`Agent 调用契约见 `references/CLI.md``references/SCHEMA.md``references/ACTIONS.md`
6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`
7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`(元数据表 `_jiangchang_*` + `CREATE TABLE` 物理顺序)。
## 技能命名与 slug ## 技能命名与 slug
@@ -92,7 +94,7 @@ python {baseDir}/scripts/main.py version
- 复制为新业务技能前**必须**阅读 `development/NAMING.md` - 复制为新业务技能前**必须**阅读 `development/NAMING.md`
- `name`frontmatter**用户可见的中文业务名称**`description` 是说明,不是名称。 - `name`frontmatter**用户可见的中文业务名称**`description` 是说明,不是名称。
- `metadata.openclaw.slug`**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug不使用中文。 - `metadata.openclaw.slug`**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug不使用中文。
- 复制后全局替换 `your-skill-slug`;正式技能不得保留英文占位 `name` 或 slug。 - 复制后全局替换 `your-skill-slug``your_skill_slug`(脚手架会自动处理);正式技能不得保留英文占位 `name` 或 slug。
## 平台元数据 ## 平台元数据

View File

@@ -1,8 +1,11 @@
# assets # assets
- `actions.json`Skill Action manifest 最小示例(**可选**;不需要宿主入口时可删除)。
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。 - `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema 示例(`additionalProperties: true` 允许扩展字段)。 - `schemas/`:轻量 JSON Schema`skill-actions.schema.json` 为**新技能严格规范**`task-log-record.schema.json`)。
`skill-actions.schema.json` 使用 `additionalProperties: false` 表达模板推荐契约;宿主运行时可能更宽松以兼容历史 manifest。`row`/`batch` placements 与 `bind`/`concurrency`/`locks` 为预留能力,当前模板示例不得使用。
- 用户市场说明见根目录 [`README.md`](../README.md) - 用户市场说明见根目录 [`README.md`](../README.md)
- Agent 调用/编排参考见 [`references/`](../references/) - Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md)
- 开发规范见 [`development/`](../development/) - 开发规范见 [`development/`](../development/)

39
assets/actions.json Normal file
View File

@@ -0,0 +1,39 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [
{
"id": "health",
"label": "运行检查",
"description": "检查技能运行环境并返回结构化结果。",
"placements": ["skill-detail", "agent"],
"entrypoint": {
"type": "cli",
"command": "health",
"args": []
}
},
{
"id": "version",
"label": "查看版本",
"description": "返回技能版本和机器标识。",
"placements": ["skill-detail"],
"entrypoint": {
"type": "cli",
"command": "version",
"args": []
}
},
{
"id": "config-path",
"label": "查看配置位置",
"description": "返回技能配置文件位置。",
"placements": ["skill-detail"],
"entrypoint": {
"type": "cli",
"command": "config-path",
"args": []
}
}
]
}

View File

@@ -0,0 +1,128 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
"title": "SkillActionManifest",
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范schemaVersion 1。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。",
"type": "object",
"required": ["schemaVersion", "skill", "actions"],
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"skill": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"description": "与 SKILL.md metadata.openclaw.slug 一致"
},
"actions": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/action" }
}
},
"$defs": {
"placement": {
"type": "string",
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
"description": "模板 Phase 1 稳定支持 toolbar/cron/agent/skill-detailrow/batch 为预留入口,新技能示例不得使用"
},
"scalarArg": {
"type": ["string", "number", "boolean"]
},
"inputProperty": {
"type": "object",
"required": ["type"],
"additionalProperties": true,
"properties": {
"type": {
"type": "string",
"enum": ["string", "number", "boolean", "object"],
"description": "模板 Phase 1 仅推荐 string/number/boolean根 inputSchema.type=object 的 properties 使用基础标量类型"
},
"title": { "type": "string" },
"description": { "type": "string" },
"default": {},
"enum": {
"type": "array",
"minItems": 1
},
"sensitive": { "type": "boolean" }
}
},
"inputSchema": {
"type": "object",
"required": ["type", "properties"],
"additionalProperties": false,
"properties": {
"type": { "const": "object" },
"required": {
"type": "array",
"items": { "type": "string" }
},
"properties": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/inputProperty" }
}
}
},
"entrypoint": {
"type": "object",
"required": ["type", "command", "args"],
"additionalProperties": false,
"properties": {
"type": { "const": "cli" },
"command": {
"type": "string",
"minLength": 1
},
"args": {
"type": "array",
"items": { "$ref": "#/$defs/scalarArg" },
"description": "仅允许 string/number/boolean 标量数组"
}
}
},
"confirmation": {
"type": "object",
"required": ["message"],
"additionalProperties": false,
"properties": {
"message": {
"type": "string",
"minLength": 1
}
}
},
"action": {
"type": "object",
"required": ["id", "label", "description", "placements", "entrypoint"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
"minLength": 1
},
"label": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string",
"minLength": 1
},
"placements": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/placement" }
},
"entrypoint": { "$ref": "#/$defs/entrypoint" },
"inputSchema": { "$ref": "#/$defs/inputSchema" },
"confirmation": { "$ref": "#/$defs/confirmation" }
},
"description": "模板 Phase 1 不包含 bind/concurrency/locks 等预留能力字段;宿主类型可能定义这些字段,但新技能不得依赖"
}
}
}

View File

@@ -37,17 +37,22 @@ scripts/service/<domain>_adapter/
base.py # 数据契约dataclass+ AdapterBase 接口 base.py # 数据契约dataclass+ AdapterBase 接口
mock.py # 离线仿真,给 CI/单测 mock.py # 离线仿真,给 CI/单测
real_api.py # 真实系统 API real_api.py # 真实系统 API
sim_rpa.py # 仿真平台 RPA sim_rpa.py # 仿真平台 RPA adapter委托 *_playwright.py
real_rpa.py # 真实系统 RPA占位谨慎实现 real_rpa.py # 真实系统 RPA占位谨慎实现
scripts/service/
<platform>_playwright.py # RPA 主流程(厚);与薄 sim_rpa.py 并列
account_client.py # account-manager subprocess 唯一封装
browser_session.py # async persistent context 启动
``` ```
模板示例(均在 `examples/`**不在** `scripts/` 模板示例(均在 `examples/`**不在** `scripts/`
- **仿真浏览器 RPA 完整案例**`examples/simulator_browser_rpa/` — mock + simulator_rpa + sandbox 页面 + adapter 分层 + 测试 - **仿真浏览器 RPA 完整案例**`examples/simulator_browser_rpa/` async mock + simulator_rpa + `simulator_playwright.py` + sandbox 页面 + adapter 分层 + 测试
- **真实浏览器 RPA 完整案例**`examples/real_browser_rpa/` — 登录/验证码/滚动采集 - **真实浏览器 RPA 完整案例**`examples/real_browser_rpa/` — 登录/验证码/滚动采集 + `account_client.py`
- **真实 API / 仿真 API**`examples/real_api/``examples/simulator_api/` — 当前为规划占位,尚未沉淀可复制实现 - **真实 API / 仿真 API**`examples/real_api/``examples/simulator_api/` — 当前为规划占位,尚未沉淀可复制实现
复制 adapter 分层时,以 `examples/simulator_browser_rpa/scripts/service/adapter/` 为权威参考,在新技能中创建 `scripts/service/<domain>_adapter/` 并按 README copy map 选择性复制。 复制 adapter 分层时,以修正后的 `examples/simulator_browser_rpa/`(薄 adapter + `simulator_playwright.py`)与 `examples/real_browser_rpa/scripts/service/account_client.py` 为权威参考,在新技能中创建 `scripts/service/<domain>_adapter/` 并按 README copy map 选择性复制。
> 先判断属于四象限哪一种(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`。示例是**参考架构与边界**,不是业务代码原样复制。 > 先判断属于四象限哪一种(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`。示例是**参考架构与边界**,不是业务代码原样复制。
@@ -93,7 +98,7 @@ def get_adapter():
1.`SKILL.md``metadata.openclaw.dependencies.required` 声明。 1.`SKILL.md``metadata.openclaw.dependencies.required` 声明。
2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**`call_sibling_json`**不要**在 `task_service.py``task_rpa.py` 等业务流程文件中到处散落 `subprocess.run` 2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**`call_sibling_json`**不要**在 `task_service.py``task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`
3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。 3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa``real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/``util/``db/`)。 4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/``util/``db/`)。
5. **pick lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease需在运维文档说明排查方式查 account-manager lease 列表 / 手动释放)。 5. **pick lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease需在运维文档说明排查方式查 account-manager lease 列表 / 手动释放)。

View File

@@ -23,6 +23,7 @@ OPENCLAW_TEST_TARGET=mock # mock | simulator_rpa | real_api | real_
TARGET_BASE_URL=https://sandbox.jc2009.com TARGET_BASE_URL=https://sandbox.jc2009.com
# ── 默认账号(仅非敏感标识;密码走 account-manager── # ── 默认账号(仅非敏感标识;密码走 account-manager──
# 浏览器 RPA simulator_rpa 联调账号来自 account-managerplatform + profile_dir不是此处的 DEFAULT_LOGIN_ID
DEFAULT_LOGIN_ID=04110001 DEFAULT_LOGIN_ID=04110001
# ── 浏览器 / RPA ── # ── 浏览器 / RPA ──

View File

@@ -80,11 +80,13 @@
你不应该直接在这个仓库里开发业务,而应该: 你不应该直接在这个仓库里开发业务,而应该:
0. 按 [`NAMING.md`](NAMING.md) 确定 slug`{verb}-{noun-phrase}-{platform}` 0. 按 [`NAMING.md`](NAMING.md) 确定 slug`{verb}-{noun-phrase}-{platform}`
1. 复制这个目录 1. **优先**用 [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1) 创建新目录(见 [`tools/README.md`](../tools/README.md)
2. 改成新 skill 的目录名(与 slug 一致 2. 在新目录内 `git init` 并绑定**本技能**远端(**不得**保留模板 `.git`
3. 把占位内容替换掉 3. 把占位内容替换掉
4. 再开始写业务逻辑 4. 再开始写业务逻辑
> **Git 红线**:禁止资源管理器整文件夹复制后保留模板 `.git``git remote -v` 必须指向新技能仓库,不能仍是 skill-template。
## 2. 新 skill 的标准目录结构 ## 2. 新 skill 的标准目录结构
复制模板后,你应该保留下面这套结构: 复制模板后,你应该保留下面这套结构:
@@ -168,10 +170,13 @@ scripts/
- `examples/real_browser_rpa/scripts/service/task_rpa.py` - `examples/real_browser_rpa/scripts/service/task_rpa.py`
- `examples/real_browser_rpa/scripts/service/account_client.py` - `examples/real_browser_rpa/scripts/service/account_client.py`
- 若是 **仿真浏览器 RPA**(自有 sandbox、表单批量提交、可控 DOM**必须先读** `examples/simulator_browser_rpa/README.md`,再按需复制到 `scripts/service/` - 若是 **仿真浏览器 RPA**(自有 sandbox、表单批量提交、可控 DOM**必须先读** `examples/simulator_browser_rpa/README.md`,再按需复制到 `scripts/service/`
- `examples/simulator_browser_rpa/scripts/service/browser_session.py` - `examples/simulator_browser_rpa/scripts/service/browser_session.py`async
- `examples/simulator_browser_rpa/scripts/service/adapter/` - `examples/simulator_browser_rpa/scripts/service/account_client.py`subprocess对齐 `real_browser_rpa`
- `examples/simulator_browser_rpa/scripts/service/task_service.py` - `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py`RPA 主流程,复制后改名为 `<platform>_playwright.py`
- `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock
- `examples/simulator_browser_rpa/scripts/service/task_service.py`async 编排)
- `sandbox/demo_app.html` 仅留在 examples不进入生产 skill - `sandbox/demo_app.html` 仅留在 examples不进入生产 skill
- **禁止**`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess
3. **只用共享库,不在 skill 里重写反反爬** 3. **只用共享库,不在 skill 里重写反反爬**
```python ```python
from jiangchang_skill_core import config from jiangchang_skill_core import config
@@ -244,12 +249,52 @@ release workflow 会对 `scripts/` 下的 Python 源码做加密/打包。当前
### 第一步:复制模板并改目录名 ### 第一步:复制模板并改目录名
例如你要开发 `your-skill-slug`(或 `disburse-payroll-icbc` 一类领域 skill 例如你要开发 `disburse-payroll-icbc` 一类领域 skill(目录名 = slug
1. 复制 `skill-template` #### 推荐方式(首选)
2. 新目录改成与 `slug` 一致的名称(如 `your-skill-slug`
3. 初始化为独立 git 仓库 在 **skill-template 仓库根目录**执行:
4. 关联它自己的远端仓库
```powershell
.\tools\scaffold_skill.ps1 -Slug disburse-payroll-icbc -Destination D:\OpenClaw\client-gdcm\disburse-payroll-icbc
cd D:\OpenClaw\client-gdcm\disburse-payroll-icbc
git init
git remote add origin <你的新技能 Gitea/Git 仓库 URL>
git remote -v # 确认 origin 不是 skill-template
```
跨平台可用 `python tools/scaffold_skill.py --slug ... --destination ...`(见 [`tools/README.md`](../tools/README.md))。
脚手架会排除 `.git`、缓存与 `.env`,并删除 `.openclaw-skill-template` 标记;**不会**自动 `git init`。
#### 禁止方式
| 做法 | 后果 |
|------|------|
| ❌ 资源管理器整文件夹复制后不做 Git 清理 | 隐藏 `.git` 被带走push 串到 template |
| ❌ 保留模板 `.git` 只改 `remote url` | 历史、分支、对象库仍属 template |
| ❌ 未删 `.git` 就 `git init` | 嵌套/混乱仓库,难以排查 |
#### 若已手工复制(补救)
```powershell
cd <新技能目录>
Remove-Item -Recurse -Force .git
Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
git remote -v
```
#### AI / 编程代理复制红线
| 禁止 | 说明 |
|------|------|
| 保留模板 `.git` | 必须先 `Remove-Item -Recurse -Force .git` 再 `git init` |
| `origin` 仍指向 skill-template | 复制后必须 `git remote -v` 自检 |
| 保留 `.openclaw-skill-template` | 仅 template 源仓库可有;业务技能不得保留 |
| 复制 `.pytest_cache` / `__pycache__` | 用 scaffold 已排除;手工复制应删 |
目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。 目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。
@@ -367,7 +412,8 @@ metadata:
- `README.md` — Agent 参考索引(**不是**市场说明;不得含 YAML frontmatter 或 `description` - `README.md` — Agent 参考索引(**不是**市场说明;不得含 YAML frontmatter 或 `description`
- `CLI.md` — 命令、参数、默认值、兄弟技能调用方式 - `CLI.md` — 命令、参数、默认值、兄弟技能调用方式
- `SCHEMA.md` — 数据库路径、核心表结构、日志表结构 - `SCHEMA.md` — 数据库路径、`_jiangchang_*` 数据管理契约、日志表结构
- `ACTIONS.md` — Skill Action manifest、宿主入口、参数与执行契约
- 可按需增加 `ERRORS.md`、`INTEGRATION.md` 等编排向补充 - 可按需增加 `ERRORS.md`、`INTEGRATION.md` 等编排向补充
**`development/`**(开发者 / AI 编程代理) **`development/`**(开发者 / AI 编程代理)
@@ -383,11 +429,14 @@ metadata:
建议放: 建议放:
- `actions.json`
宿主 Action 入口 manifest可选见 `references/ACTIONS.md`
- `examples/` - `examples/`
比如 `version` 输出示例、`log-get` 输出示例 比如 `version` 输出示例、`log-get` 输出示例
- `schemas/` - `schemas/`
比如日志记录、机读 JSON 的 schema 比如 `skill-actions.schema.json`、日志记录、机读 JSON 的 schema
不要把正式研发文档放到 `assets/`。 不要把正式研发文档放到 `assets/`。
用户说明进根 `README.md`Agent 编排资料进 `references/`;开发规范进 `development/`。 用户说明进根 `README.md`Agent 编排资料进 `references/`;开发规范进 `development/`。
@@ -448,6 +497,8 @@ metadata:
只做增删查改(模板默认表:`task_logs` 只做增删查改(模板默认表:`task_logs`
- `db/display_metadata.py` - `db/display_metadata.py`
创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md` 创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md`
- `db/display_metadata_validator.py`
元数据完整性、editable 权限、options_json 等自检
- `db/timestamp_columns.py` - `db/timestamp_columns.py`
标准时间字段 trigger 与维护约定(`created_at` / `updated_at`Unix 秒级) 标准时间字段 trigger 与维护约定(`created_at` / `updated_at`Unix 秒级)
@@ -457,6 +508,24 @@ metadata:
- 打印用户提示 - 打印用户提示
- 拼接业务流程 - 拼接业务流程
## 11.5 Skill Action manifest可选
若技能需要出现在宿主**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中,提供 `assets/actions.json`。
- 契约说明见 [`references/ACTIONS.md`](../references/ACTIONS.md)
- JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)
- 模板最小示例仅暴露 `health` / `version` / `config-path` 三个 CLI 子命令
- 没有宿主直接操作需求时,**可以删除** `actions.json`;删除后勿保留失效引用
- 复制为新技能后scaffold 会自动将 `your-skill-slug` 与 `your_skill_slug` 替换为新 slug后者用于 `LOG_LOGGER_NAME` 等下划线形式action 业务命令由技能作者自行调整
宿主执行方式:
```text
python {skillRoot}/scripts/main.py <command> <args...>
```
CLI 必须提供稳定退出码、结构化成功输出,以及 `ERROR:<CODE>:` / `HINT:` 错误契约(详见 `references/ACTIONS.md`)。
## 12. 如何接兄弟技能 ## 12. 如何接兄弟技能
如果 skill 要依赖兄弟 skill不要在业务代码里写死绝对路径。 如果 skill 要依赖兄弟 skill不要在业务代码里写死绝对路径。
@@ -699,6 +768,9 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] 源码/文档/配置为 **UTF-8 without BOM**Python 文件不得含 `U+FEFF` - [ ] 源码/文档/配置为 **UTF-8 without BOM**Python 文件不得含 `U+FEFF`
- [ ] 没有新增默认必跑测试访问真实网络或浏览器 - [ ] 没有新增默认必跑测试访问真实网络或浏览器
- [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀 - [ ] 如有 integration 测试需求,已写在 `tests/integration/` 下并保持 `.sample` 后缀
- [ ] 本仓库**不是** skill-template 的误复制(根目录**无** `.openclaw-skill-template`
- [ ] `git remote -v` 指向**本技能**远端URL 不含 skill-template 仓库名
- [ ] `git log` 首条提交属于本技能(非模板历史)
## 17. 常见错误 ## 17. 常见错误
@@ -758,12 +830,12 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
表现: 表现:
- slug 形如 `amazon-download-settlement-report` - slug 形如 `erp-download-settlement-report`(平台段错误地放在最前)
- 语义校验或 `test_slug_naming` 失败 - 语义校验或 `test_slug_naming` 失败
要改: 要改:
- 动词放首段,平台放末段,例如 `download-settlement-report-amazon` - 动词放首段,平台放末段,例如 `download-settlement-report-erp`
- 见 [`NAMING.md`](NAMING.md) - 见 [`NAMING.md`](NAMING.md)
### 错误 6noun 段过长导致超 48 字符 ### 错误 6noun 段过长导致超 48 字符
@@ -789,6 +861,25 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- `SKILL.md` 的 `metadata.openclaw.slug` - `SKILL.md` 的 `metadata.openclaw.slug`
- `scripts/util/constants.py` 的 `SKILL_SLUG` - `scripts/util/constants.py` 的 `SKILL_SLUG`
### 错误 8复制模板时带走了 `.git`
表现:
- `git remote -v` 仍显示 skill-template 的 origin
- push 到错误仓库,或 log 里全是模板历史
- 新技能目录里仍有 `.openclaw-skill-template`
修复:
```powershell
Remove-Item -Recurse -Force .git
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
```
以后复制请优先用 `tools/scaffold_skill.ps1`(见 [`tools/README.md`](../tools/README.md))。
## 18. 推荐开发顺序总结 ## 18. 推荐开发顺序总结
如果让一个新人照着做,我建议他按这个顺序: 如果让一个新人照着做,我建议他按这个顺序:

View File

@@ -165,10 +165,11 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
## 9. 复制新技能流程 ## 9. 复制新技能流程
1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符 1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符
2. **复制目录**`skill-template` 复制为新目录,**目录名 = slug** 2. **复制目录****推荐** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)`-Slug` 与目录名一致);勿整包复制带走 `.git`
2.5. **清理 Git若未用 scaffold** — 删除 `.git``.openclaw-skill-template``.pytest_cache`;再 `git init``git remote add origin`
3. **全局替换**`your-skill-slug` → 新 slug更新 `SKILL.md``constants.py`、文档中的占位 3. **全局替换**`your-skill-slug` → 新 slug更新 `SKILL.md``constants.py`、文档中的占位
4. **填写 REQUIREMENTS** — 完成 [`REQUIREMENTS.md`](REQUIREMENTS.md) §0 技能标识 4. **填写 REQUIREMENTS** — 完成 [`REQUIREMENTS.md`](REQUIREMENTS.md) §0 技能标识
5. **开发 & 自检**`python tests/run_tests.py -v`,含 slug 语义测试 5. **开发 & 自检**`python tests/run_tests.py -v`,含 slug 语义测试`git remote -v` 确认非 template
--- ---

View File

@@ -11,5 +11,7 @@
7. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 7. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
8. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 8. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)`scaffold_skill.ps1`)。
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。 Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。
用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。 用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。

View File

@@ -138,7 +138,7 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
- `examples/real_browser_rpa/scripts/service/task_rpa.py` — RPA 主流程 - `examples/real_browser_rpa/scripts/service/task_rpa.py` — RPA 主流程
- `examples/real_browser_rpa/scripts/service/account_client.py` — account-manager CLI 封装 - `examples/real_browser_rpa/scripts/service/account_client.py` — account-manager CLI 封装
仿真浏览器 RPA 的 adapter 分层权威参考见 `examples/simulator_browser_rpa/scripts/service/adapter/`**不能**替代真实浏览器 RPA 规范。 仿真浏览器 RPA 的 adapter 分层权威参考见 `examples/simulator_browser_rpa/`(含 async `simulator_playwright.py`、薄 `simulator_rpa` adapter、`account_client.py` subprocess**不能**替代真实浏览器 RPA 规范。
### 1.6 仿真浏览器 RPA 示例(必读) ### 1.6 仿真浏览器 RPA 示例(必读)
@@ -146,12 +146,32 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
1. **必须先阅读** `examples/simulator_browser_rpa/README.md` 1. **必须先阅读** `examples/simulator_browser_rpa/README.md`
2. 再参考: 2. 再参考:
- `examples/simulator_browser_rpa/scripts/service/browser_session.py` - `examples/simulator_browser_rpa/scripts/service/browser_session.py` — async persistent context**不含 goto**
- `examples/simulator_browser_rpa/scripts/service/adapter/`base / mock / simulator_rpa / dispatch - `examples/simulator_browser_rpa/scripts/service/simulator_playwright.py` — RPA 主流程(厚
- `examples/simulator_browser_rpa/scripts/service/task_service.py` - `examples/simulator_browser_rpa/scripts/service/adapter/`(薄 `simulator_rpa` + mock + dispatch
- `examples/simulator_browser_rpa/scripts/service/account_client.py` — account-manager subprocess
- `examples/simulator_browser_rpa/scripts/service/task_service.py` — async 编排
- `examples/simulator_browser_rpa/sandbox/demo_app.html` - `examples/simulator_browser_rpa/sandbox/demo_app.html`
> **先选类型再写代码**:真实第三方网站 → `real_browser_rpa`;自有 sandbox → `simulator_browser_rpa`。不要跨类型照抄 selector 或业务流程。 > **先选类型再写代码**:真实第三方网站 → `real_browser_rpa`;自有 sandbox / 行业仿真 → `simulator_browser_rpa`。不要跨类型照抄 selector 或业务流程。
### 1.7 行业仿真平台浏览器 RPAjc2009 类)
适用:**自有或共享 `sandbox.jc2009.com` 等行业仿真平台**,不是真实高风控第三方站。
工程范式与 §1.5 相同:
| 项 | 标准 |
|----|------|
| Playwright | **async** 贯穿;禁止 sync Playwright 用于完整 RPA 主路径 |
| 分层 | **薄 adapter** + `{domain}_playwright.py`(示例:`simulator_playwright.py`+ `account_client.py` subprocess |
| 登录 | **双层**:门户 HITL`#portal-user` / `#portal-pass` 类泛化 DOM+ 业务系统登录(技能内自写) |
| 账号 | `url` 用行业根,不用 `/login``auth_strategy=per_session_manual``pick_web_account` + `release_lease` |
| 禁止 | **不要** `import account-manager``rpa_helpers` 等内部模块 |
| Selector | 用户可见文案 / `get_by_role` / `name` 优先;`data-testid` 有则用、无则 fallback共享 sandbox **不要求**为技能加 testid |
| Profile | Chrome persistent profile 可能缓存旧 SPA → 联调排障:手工 `--user-data-dir` 清站点数据 |
权威 example**修正后的** `examples/simulator_browser_rpa/`(不是任何生产技能仓库)。
--- ---

View File

@@ -28,7 +28,8 @@
- 运行时:`runtime_paths`**`JIANGCHANG_*` 隔离** - 运行时:`runtime_paths`**`JIANGCHANG_*` 隔离**
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py) - `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py)
- SQLite 骨架:`task_logs` 创建幂等与仓储读写; - SQLite 骨架:`task_logs` 创建幂等与仓储读写;
- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序(见 `test_display_metadata.py` - 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序、editable 权限(见 `test_display_metadata.py``test_data_management_contract.py`
- Skill Action manifest`assets/actions.json` 结构、CLI 映射、placements 与 inputSchema 守护(见 `test_actions_manifest.py`
- 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py` - 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`
- **adapter profile**[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。 - **adapter profile**[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
- **发布打包守护**[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。 - **发布打包守护**[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
@@ -130,6 +131,20 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
--- ---
## 7.1 simulator_rpa 联调检查表
浏览器 RPA 走 `simulator_rpa` 档位联调前,逐项确认:
- [ ] 数据目录 `.env``OPENCLAW_TEST_TARGET=simulator_rpa`(非 `mock` / `unit`
- [ ] account-manager`platform ensure` + 账号 `active` + 有 `profile_dir`
- [ ] 目标 sandbox UI 已部署(跨团队;本地可用 `sandbox/demo_app.html`
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(`--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `python tests/run_tests.py -v` 仍全部通过mock 离线example 内 `pytest` 不启真实浏览器
参考实现:`examples/simulator_browser_rpa/README.md`
---
## 8. 新 skill 的最小测试清单 ## 8. 新 skill 的最小测试清单
以下清单 **原文摘自 [`tests/README.md`](../tests/README.md) 「新技能最小测试清单」**(复制新仓库后逐项勾选): 以下清单 **原文摘自 [`tests/README.md`](../tests/README.md) 「新技能最小测试清单」**(复制新仓库后逐项勾选):
@@ -143,6 +158,8 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
- [ ] 至少有 1 个缺必填字段 / 非法输入测试。 - [ ] 至少有 1 个缺必填字段 / 非法输入测试。
- [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。 - [ ] 如果有 adapter至少覆盖 timeout / unauthorized / invalid response可用 fake 模拟)。
- [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏 - [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture脱敏
- [ ] 若有 `assets/actions.json``tests/test_actions_manifest.py` 通过;不需要 Action 时可删除 manifest。
- [ ] 若有本地 SQLite 业务表,`_jiangchang_*` 元数据与 `tests/test_data_management_contract.py` 规则一致。
- [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。 - [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。
--- ---

View File

@@ -19,13 +19,23 @@
## 两类浏览器 RPA 的区别 ## 两类浏览器 RPA 的区别
| | real_browser_rpa | simulator_browser_rpa | | | real_browser_rpa | simulator_browser_rpa(修正后) |
|---|---|---| |---|---|---|
| 目标 | 真实第三方网页 | 自有仿真页面 / sandbox | | 目标 | 真实第三方网页 | 自有仿真页面 / sandbox / jc2009 类 |
| 风控 | 高(验证码、登录态、反爬) | 低DOM 可控) | | 风控 | 高(验证码、登录态、反爬) | 低DOM 可控) |
| 账号 | 通常需 account-manager + profile | 示例可用 env / demo 账号 | | 账号 | account-manager + profile | account-manager + profile`simulator_rpa` 档) |
| 用途 | 生产级真实采集/操作参考 | adapter 分层 + 表单 RPA 教学 | | 用途 | 生产级真实采集/操作参考 | adapter 分层 + 表单 RPA 教学 |
### 工程范式对比
| 工程范式 | real_browser_rpa | simulator_browser_rpa修正后 |
|---|---|---|
| async Playwright | ✅ | ✅ |
| 薄 adapter + `*_playwright.py` | `task_rpa.py` | `simulator_playwright.py` |
| account-manager subprocess | ✅ | ✅(`simulator_rpa` 档位) |
两者工程范式一致,差异在目标系统(真实站 vs 仿真站/本地 demo
## 如何选择示例 ## 如何选择示例
| 你的场景 | 参考目录 | | 你的场景 | 参考目录 |

View File

@@ -1,20 +1,20 @@
# Simulator Browser RPA Example # Simulator Browser RPA Example
这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 Playwright 完成登录、表单填写、弹窗确认、结果解析。 这是一个**仿真浏览器 RPA** 的可复制参考实现:在可控 sandbox 页面上,通过 **async Playwright** 完成门户门闩、两步登录、表单填写、PIN 弹窗、结果解析。
本示例抽象自已跑通的「批量提交类」浏览器自动化 skill但已去业务化不包含真实银行/真实资金语义。 本示例抽象自已跑通的「工行批量提交类」浏览器自动化 skill 的工程范式,但已**去业务化**——不包含真实银行/真实资金语义,也**不是** Amazon 或任何生产技能的复制
## 适合什么场景 ## 适合什么场景
- 自有 **仿真系统 / sandbox / training 页面** - 自有 **行业仿真系统** / `sandbox.jc2009.com` / 本地 `sandbox/demo_app.html`
- 需要 **浏览器 RPA** 做端到端演示或回归 - 需要 **浏览器 RPA** 做端到端演示或回归
- 表单较复杂多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析 - 表单较复杂:门户 HITL、多步登录、Tab 切换、动态增行、PIN 弹窗、成功页解析
- 需要 **adapter 分层**mockCI与 simulator_rpa半集成可切换 - 需要 **adapter 分层**`mock`CI`simulator_rpa`(半集成)可切换
## 不适合什么场景 ## 不适合什么场景
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md) - **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA,后续参考 `real_api` - 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill - **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
## 目录结构 ## 目录结构
@@ -23,22 +23,24 @@
simulator_browser_rpa/ simulator_browser_rpa/
├── README.md ├── README.md
├── sandbox/ ├── sandbox/
│ └── demo_app.html # 本地仿真页面(稳定 data-testid / name │ └── demo_app.html # 本地仿真页面(门户门闩 + 批量提交教学 DOM
├── scripts/ ├── scripts/
│ ├── service/ │ ├── service/
│ │ ├── browser_session.py # 浏览器启动URL 仅 page.goto │ │ ├── browser_session.py # async 启动 persistent context不含 goto
│ │ ├── account_client.py # 账号来源集中封装 │ │ ├── account_client.py # account-manager subprocess对齐 real_browser_rpa
│ │ ├── task_service.py # 编排层 │ │ ├── simulator_playwright.py # RPA 主流程(厚)
│ │ ├── task_service.py # async 编排层
│ │ └── adapter/ │ │ └── adapter/
│ │ ├── base.py # BatchItem / BatchSubmitResult 契约 │ │ ├── base.py # BatchItem / BatchSubmitResult 契约
│ │ ├── mock.py # mock/unit 档 │ │ ├── mock.py # mock/unit 档
│ │ ├── simulator_rpa.py │ │ ├── simulator_rpa.py # 薄 adapterpick / lease / 委托 RPA
│ │ └── __init__.py # select_adapter │ │ └── __init__.py # select_adapter
│ └── util/ │ └── util/
│ ├── constants.py │ ├── constants.py
│ └── logging.py │ └── logging.py
└── tests/ └── tests/
├── test_adapter_dispatch.py ├── test_adapter_dispatch.py
├── test_no_rpa_helpers_import.py
├── test_simulator_rpa_unit.py ├── test_simulator_rpa_unit.py
└── test_stop_reason.py └── test_stop_reason.py
``` ```
@@ -47,91 +49,95 @@ simulator_browser_rpa/
| 文件 | 职责 | | 文件 | 职责 |
|---|---| |---|---|
| `browser_session.py` | 系统 Chrome/Edge + launch args 规范;支持 profile / 无 profile | | `browser_session.py` | async 系统 Chrome/Edge + launch args**不在此 goto** |
| `account_client.py` | 示例从环境变量读账号;真实 skill 可在此集中对接 account-manager | | `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account` / `release_lease` |
| `adapter/base.py` | 数据契约与 `BatchAdapterBase` | | `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
| `adapter/simulator_rpa.py` | **薄** adapterpick 账号、lease、委托 RPA、`finally release_lease` |
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI | | `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
| `adapter/simulator_rpa.py` | 仿真 RPA 主流程 | | `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
| `task_service.py` | 校验输入 → 选 adapter → 提交 → 返回 summary | | `sandbox/demo_app.html` | 可控 DOM含可选门户门闩 + 原批量提交流程 |
| `sandbox/demo_app.html` | 可控 DOM配合 selector 稳定 |
| `tests/` | dispatch、URL 不进 launch args、失败转结果 |
## 核心流程 ## 核心流程
1. `task_service.run_batch_submit(target, items)` 校验参数 1. `await run_batch_submit(target, items)` 校验参数
2. `select_adapter()``OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA 2. `select_adapter()``OPENCLAW_TEST_TARGET` 选择 mock 或 simulator RPA
3. simulator 档:`pick_simulator_account()` `set_credentials()``submit_batch()` 3. `simulator_rpa` 档:`SimulatorBrowserRpaAdapter.submit_batch` `pick_web_account(sim_batch)``submit_batch_rpa``release_lease`
4. RPA登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id` 4. RPA门户门闩(若可见则 HITL 轮询)→ 登录两步 → 批量页 → 填表 → 提交 → PIN → 解析 `batch_id`
5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult` 5. 失败截图(有 `artifacts_dir` 时)→ 返回 `BatchSubmitResult`
## 复制到新 skill 时怎么做 ## 两档仿真目标
按 copy map **选择性复制**到 `scripts/`**不要**整包照搬本目录,**不要**把本 README 当作新技能根 README。 | 档位 | 目标 | 说明 |
|---|---|---|
| 本地教学 | `sandbox/demo_app.html``file://` | 结构教学 + mock 单测默认 |
| 线上联调 | `https://sandbox.jc2009.com/...` | 设 `SIMULATOR_BASE_URL` + account-manager `sim_batch` 账号 |
联调时账号 `url` 用行业根路径,**不要**写 `/login` 子路径;`auth_strategy=per_session_manual`
## account-manager 注册示例(泛化)
```bash
python .../account-manager/scripts/main.py account add-web \
--platform sim_batch \
--login-id demo001 \
--url file:///.../demo_app.html \
--environment simulator \
--auth-strategy per_session_manual
```
线上 sandbox 时将 `--url` 换为 `https://sandbox.jc2009.com/...` 占位路径(按团队部署填写)。
**PIN/密码演示值**RPA 从 account 的 `login_id` 取账号;`password` / `token_pin` 允许从 account 字段或环境变量 `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` 回退;验证码固定演示值 `0000`(仅 sandbox 用)。
## Copy map复制到新技能 `scripts/` ## Copy map复制到新技能 `scripts/`
| 来源examples | 目标(新技能) | 说明 | | 来源examples | 目标(新技能) | 说明 |
|---|---|---| |---|---|---|
| `examples/simulator_browser_rpa/scripts/service/browser_session.py` | `scripts/service/browser_session.py` | URL 仅 `page.goto`,不进 launch args | | `browser_session.py` | `scripts/service/browser_session.py` | asyncURL 仅 `page.goto`,不进 launch args |
| `examples/simulator_browser_rpa/scripts/service/account_client.py` | `scripts/service/account_client.py` | 账号来源集中封装 | | `account_client.py` | `scripts/service/account_client.py` | subprocess对齐 `real_browser_rpa` |
| `examples/simulator_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 编排层,合并业务逻辑 | | `simulator_playwright.py` | `scripts/service/<platform>_playwright.py` | RPA 主流程(厚) |
| `examples/simulator_browser_rpa/scripts/service/adapter/` | `scripts/service/<domain>_adapter/` | **adapter 分层权威参考** | | `adapter/` | `scripts/service/<domain>_adapter/` | `simulator_rpa` + mock |
| `examples/simulator_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 须替换业务常量 | | `task_service.py` | `scripts/service/task_service.py` | async 编排 |
| `examples/simulator_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选 | | `util/constants.py` | `scripts/util/constants.py` | 须替换 `TARGET_PLATFORM` 等业务常量 |
| `examples/simulator_browser_rpa/tests/` | `tests/` | 可按需复制 dispatch / unit 测试 | | `tests/` | `tests/` | 可按需复制 dispatch / unit / 禁止 import 守护 |
| `examples/simulator_browser_rpa/sandbox/demo_app.html` | — | **仅仿真测试**,不进入生产 skill | | `sandbox/demo_app.html` | — | **仅仿真测试**,不进入生产 skill |
| `examples/simulator_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
**边界说明:**
- `sandbox/` 只用于仿真测试与本地回归,**不进入**真实生产 skill
- `adapter/` 分层base / mock / simulator_rpa是仿真浏览器 RPA 的权威参考
- `mock` / `simulator_rpa` 只用于测试与仿真,**不要**混入 `real_browser_rpa` 的真实站点逻辑
- `demo_app.html` 是可运行沙箱页面,**不是**真实业务系统
**保留:**
- adapter 分层base / mock / simulator_rpa / `select_adapter`
- `browser_session.py` 启动规范(**URL 不进 launch args**
- `account_client.py` 集中封装模式
- 关键节点结构化日志 + 失败截图
- 测试dispatch、launch args、错误转 `BatchSubmitResult`
**替换:**
- `BatchItem` 字段与校验规则
- `sandbox/demo_app.html``SIMULATOR_BASE_URL` 指向的页面
- 全部 DOM selector必须 F12 实测)
- `task_service.py` 输入输出与 DB/任务日志
- mock 返回字段、成功页 `batch_id` 解析逻辑
## 明确禁止 ## 明确禁止
- 不要复制 `__pycache__``.pytest_cache``artifacts/``recordings/` 等生成物 - **不要** `import account-manager` 内部模块(`rpa_helpers``inject_account_manager_scripts_path``get_account_credential`
- **不要在技能侧**执行 `playwright install` / `pip install playwright`Python 包由宿主共享 runtime 提供) - **不要** sync Playwright 用于完整 RPA 主路径
- **不要把 URL 放进** `launch_persistent_context(args=[...])`
- **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess - **不要在** `task_service.py` / RPA 主流程里散落 account-manager subprocess
- **不要把真实生产系统**配置到 `simulator_rpa` 档位 - **不要在技能侧**执行 `playwright install` / `pip install playwright`
- 不要照抄 slug、logger 名、演示账号到生产 skill - **不要把 URL 放进** `launch_persistent_context(args=[...])`
- **不要** vendor `scripts/jiangchang_skill_core/`
- **不要**照抄 slug、logger 名、演示账号到生产 skill
> 旧版 `pick_simulator_account()` / `SimulatorAccount` / `set_credentials()` 已废弃;账号统一由 account-manager 下发。
## 联调检查表
- [ ] 数据目录 `.env``OPENCLAW_TEST_TARGET=simulator_rpa`(非 mock
- [ ] account-manager`platform ensure` + 账号 `active` + 有 `profile_dir`
- [ ] 目标 sandbox UI 已部署(跨团队)
- [ ] 失败先查 `rpa-artifacts/` 截图,再查 Chrome profile 缓存(可用 `--user-data-dir` 手工打开清站点数据)
- [ ] 默认 `tests/run_tests.py` / 本 example `pytest` 仍全部通过mock 离线)
## 依赖说明 ## 依赖说明
- Python 3.10+ - Python 3.10+
- Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要写入 `requirements.txt`,不要 `pip install playwright` - Python 包 `playwright`:由**宿主共享 runtime** 提供;技能侧不要 `pip install playwright`
- 默认使用**系统 Chrome/Edge**,不使用 Playwright 内置 Chromium - 默认使用**系统 Chrome/Edge**;技能侧不要 `playwright install`
- 技能侧不要执行 `playwright install` / `playwright install chromium` - `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选)
- 本地独立开发若缺 Playwright Python 包,仅作开发机临时补装;生产由宿主解决
- `jiangchang_skill_core.runtime_env.find_chrome_executable`(可选,用于查找系统浏览器)
## 环境变量(示例) ## 环境变量(示例)
| 变量 | 说明 | | 变量 | 说明 |
|---|---| |---|---|
| `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock默认 → Simulator RPA | | `OPENCLAW_TEST_TARGET` | `mock`/`unit` → Mock`simulator_rpa` → 仿真 RPA |
| `SIMULATOR_BASE_URL` | 仿真页 URL默认本地 `sandbox/demo_app.html` 的 file URI | | `SIMULATOR_BASE_URL` | 仿真页 URL默认本地 `demo_app.html` 的 file URI |
| `SIMULATOR_LOGIN_ID` / `PASSWORD` / `TOKEN_PIN` | 演示账号 | | `SIMULATOR_PASSWORD` / `SIMULATOR_TOKEN_PIN` | 演示密码/PIN 回退(账号字段优先) |
| `SIMULATOR_PROFILE_DIR` | 可选 persistent profile | | `PORTAL_LOGIN_WAIT_SEC` | 门户 HITL 超时(默认 120 |
| `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头模式CI 可用) | | `OPENCLAW_BROWSER_HEADLESS` | `0` 默认有头;`1` 无头CI 可用) |
## 运行测试 ## 运行测试
@@ -140,9 +146,9 @@ cd examples/simulator_browser_rpa
python -m pytest tests/ -v python -m pytest tests/ -v
``` ```
测试为 mock/纯函数,**不需要启动真实浏览器**。 测试为 mock/纯函数,**不启动真实浏览器**。
## 当前状态 ## 当前状态
- 示例代码与 sandbox 页面已就绪,可作为仿真浏览器 RPA 复制起点 - async Playwright + 薄 adapter + `simulator_playwright.py` 工程范式已对齐 `real_browser_rpa`
- 未接入完整 CLI、数据库与 entitlement。 - 未接入完整 CLI、数据库与 entitlement。

View File

@@ -15,7 +15,16 @@
<body> <body>
<h1>仿真业务系统 · 批量提交(示例)</h1> <h1>仿真业务系统 · 批量提交(示例)</h1>
<section id="login-step1-section"> <section id="portal-section">
<h2>平台门户登录(示例)</h2>
<form name="portalGate">
<label>门户账号 <input id="portal-user" name="portalUser" /></label>
<label>门户密码 <input id="portal-pass" name="portalPass" type="password" /></label>
<button type="button" id="portal-enter">进入业务系统</button>
</form>
</section>
<section id="login-step1-section" class="hidden">
<h2>登录 · 第一步</h2> <h2>登录 · 第一步</h2>
<form name="simLoginStep1" data-testid="login-step1"> <form name="simLoginStep1" data-testid="login-step1">
<label>账号 <input name="loginId" data-testid="login-id" /></label> <label>账号 <input name="loginId" data-testid="login-id" /></label>
@@ -69,6 +78,11 @@
const DEMO_CAPTCHA = "0000"; const DEMO_CAPTCHA = "0000";
let rowCount = 0; let rowCount = 0;
document.getElementById('portal-enter').addEventListener('click', function () {
document.getElementById('portal-section').classList.add('hidden');
document.getElementById('login-step1-section').classList.remove('hidden');
});
document.querySelector('form[name="simLoginStep1"]').addEventListener('submit', function (e) { document.querySelector('form[name="simLoginStep1"]').addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
document.getElementById('login-step1-section').classList.add('hidden'); document.getElementById('login-step1-section').classList.add('hidden');

View File

@@ -1,40 +1,199 @@
"""仿真账号来源封装(示例级;真实 skill 可在此集中对接 account-manager)。""" """account-manager CLI 集成(仅 subprocess不 import 兄弟 skill 内部模块)。"""
from __future__ import annotations from __future__ import annotations
import json
import logging
import os import os
from dataclasses import dataclass import subprocess
from typing import Optional import sys
from typing import Any, Dict, List, Optional
from util.constants import ( from jiangchang_skill_core.runtime_env import get_sibling_skills_root
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD, from util.constants import LEASE_HOLDER, LEASE_TTL_SEC, TARGET_PLATFORM
DEFAULT_DEMO_TOKEN_PIN, from util.logging import mask_text
ENV_SIMULATOR_LOGIN_ID,
ENV_SIMULATOR_PASSWORD, logger = logging.getLogger(__name__)
ENV_SIMULATOR_PROFILE_DIR,
ENV_SIMULATOR_TOKEN_PIN, PLACEHOLDER_PLATFORM = TARGET_PLATFORM
ACCOUNT_SETUP_MESSAGE = (
f"未找到可用的 {PLACEHOLDER_PLATFORM} 账号,所以还没有打开浏览器。"
f"请先在 account-manager 中添加 platform={PLACEHOLDER_PLATFORM}"
"status=active、带 profile_dir 的账号后重新运行。"
)
LEASE_BUSY_MESSAGE = "目标平台账号当前被其他任务占用,请等待释放租约后重试。"
class AccountManagerError(Exception):
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
def mask_login_id(login_id: str) -> str:
return mask_text(login_id)
def _resolve_account_manager_main() -> str:
"""解析 account-manager CLI 入口路径。
优先级ACCOUNT_MANAGER_ROOT → get_sibling_skills_root → 开发机兜底。
"""
env_root = (os.getenv("ACCOUNT_MANAGER_ROOT") or "").strip()
if env_root and os.path.isfile(os.path.join(env_root, "scripts", "main.py")):
return os.path.join(os.path.abspath(env_root), "scripts", "main.py")
scripts_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
skills_root = get_sibling_skills_root(scripts_dir)
candidate = os.path.join(skills_root, "account-manager", "scripts", "main.py")
if os.path.isfile(candidate):
return candidate
dev = r"D:\OpenClaw\client-commons\account-manager\scripts\main.py"
if os.path.isfile(dev):
return dev
raise AccountManagerError(
"ACCOUNT_NOT_FOUND",
"未找到 account-manager请配置 ACCOUNT_MANAGER_ROOT 或安装 account-manager 技能。",
) )
@dataclass def _parse_last_json(stdout: str) -> Any:
class SimulatorAccount: lines = [ln.strip() for ln in (stdout or "").splitlines() if ln.strip()]
login_id: str for raw in reversed(lines):
password: str if raw.startswith("ERROR:"):
token_pin: str continue
profile_dir: str = "" try:
return json.loads(raw)
except json.JSONDecodeError:
continue
raise AccountManagerError("UNKNOWN_ERROR", "stdout 中没有可解析的 JSON。")
def pick_simulator_account() -> SimulatorAccount: def _run_argv(argv_suffix: List[str]) -> subprocess.CompletedProcess[str]:
""" main_py = _resolve_account_manager_main()
示例账号来源:优先读环境变量,否则返回内置 demo 账号。 env = os.environ.copy()
return subprocess.run(
真实 skill 若依赖 account-manager应把 subprocess / sibling_bridge 调用 [sys.executable, main_py, *argv_suffix],
集中在本模块,不要散落到 task_service 或 RPA 主流程中。 capture_output=True,
""" text=True,
return SimulatorAccount( encoding="utf-8",
login_id=(os.getenv(ENV_SIMULATOR_LOGIN_ID) or DEFAULT_DEMO_LOGIN_ID).strip(), errors="replace",
password=(os.getenv(ENV_SIMULATOR_PASSWORD) or DEFAULT_DEMO_PASSWORD).strip(), env=env,
token_pin=(os.getenv(ENV_SIMULATOR_TOKEN_PIN) or DEFAULT_DEMO_TOKEN_PIN).strip(),
profile_dir=(os.getenv(ENV_SIMULATOR_PROFILE_DIR) or "").strip(),
) )
def _normalize_error_code(raw: str) -> str:
code = (raw or "").strip()
if code.startswith("ERROR:"):
code = code[6:]
return code
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or ""))
message = str(err.get("message") or "")
if code == "LEASE_CONFLICT" or "LEASE_CONFLICT" in code:
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE)
if code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in code:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "PICK_WEB_FAILED", message or "pick-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回的账号缺少 profile_dir。")
return payload
def _pick_web_with_lease(platform: str) -> Dict[str, Any]:
proc = _run_argv(
[
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
]
)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
if isinstance(payload, dict) and payload.get("success") is False:
return _validate_pick_payload(payload)
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "pick-web 返回格式异常。")
return payload
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
raw = _parse_last_json(out)
if isinstance(raw, dict) and "data" in raw and isinstance(raw["data"], dict):
data = raw["data"]
elif isinstance(raw, dict):
data = raw
else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower():
raise AccountManagerError(
"ACCOUNT_NOT_FOUND",
f"账号 {account_id} 不是 {platform} 平台账号。",
)
profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir
data["lease_token"] = ""
return data
def pick_web_account(platform: str, account_id: Optional[str] = None) -> dict:
"""获取网页账号profile_dir + lease_token"""
platform_key = (platform or PLACEHOLDER_PLATFORM).strip() or PLACEHOLDER_PLATFORM
if account_id:
try:
aid = int(account_id)
except (TypeError, ValueError):
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 ID 无效:{account_id}")
return _pick_by_id(platform_key, aid)
try:
return _pick_web_with_lease(platform_key)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND") or "NO_ACCOUNT" in exc.code:
raise AccountManagerError("ACCOUNT_SETUP_REQUIRED", ACCOUNT_SETUP_MESSAGE) from exc
raise
def release_lease(lease_token: Optional[str]) -> None:
token = (lease_token or "").strip()
if not token:
return
try:
proc = _run_argv(["lease", "release", token])
if proc.returncode != 0:
logger.warning("lease_release_failed stdout=%s", (proc.stdout or "").strip())
except Exception:
logger.warning("lease_release_exception", exc_info=True)

View File

@@ -28,5 +28,5 @@ class BatchSubmitResult:
class BatchAdapterBase: class BatchAdapterBase:
name: str = "base" name: str = "base"
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult: async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
raise NotImplementedError raise NotImplementedError

View File

@@ -11,7 +11,7 @@ from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
class MockBatchAdapter(BatchAdapterBase): class MockBatchAdapter(BatchAdapterBase):
name = "mock" name = "mock"
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult: async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
del target del target
if not items: if not items:
return BatchSubmitResult( return BatchSubmitResult(

View File

@@ -1,78 +1,45 @@
"""仿真浏览器 RPA:操作 sandbox/demo_app.html 完成批量提交""" """仿真浏览器 RPA 薄 adapterpick 账号、lease、委托 RPA、release lease"""
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import random
import re
import time
from typing import List, Optional from typing import List, Optional
from jiangchang_skill_core import config from jiangchang_skill_core import config
from service.account_client import AccountManagerError, pick_web_account, release_lease
from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult from service.adapter.base import BatchAdapterBase, BatchItem, BatchSubmitResult
from service.browser_session import browser_session, find_chrome_executable from service.browser_session import find_chrome_executable
from util.constants import ( from service.simulator_playwright import submit_batch_rpa
DEFAULT_DEMO_CAPTCHA, from util.constants import TARGET_PLATFORM, resolve_simulator_base_url
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD,
DEFAULT_DEMO_TOKEN_PIN,
DEFAULT_TIMEOUT_MS,
LOGIN_NAVIGATE_TIMEOUT_MS,
SUBMIT_NAVIGATE_TIMEOUT_MS,
resolve_simulator_base_url,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_PAUSE_MIN_MS = 500
_PAUSE_MAX_MS = 2000
def _resolve_entry_url(account: dict) -> str:
class RpaError(Exception): url = str(account.get("url") or "").strip()
def __init__(self, message: str, screenshot_path: Optional[str] = None): if url:
super().__init__(message) return url.rstrip("/")
self.screenshot_path = screenshot_path return resolve_simulator_base_url()
class SimulatorBrowserRpaAdapter(BatchAdapterBase): class SimulatorBrowserRpaAdapter(BatchAdapterBase):
name = "simulator_browser_rpa" name = "simulator_rpa"
def __init__( def __init__(
self, self,
base_url: Optional[str] = None,
headless: Optional[bool] = None,
artifacts_dir: Optional[str] = None, artifacts_dir: Optional[str] = None,
headless: Optional[bool] = None,
) -> None: ) -> None:
self.base_url = (base_url or resolve_simulator_base_url()).rstrip("/") self.artifacts_dir = artifacts_dir
if headless is None: if headless is None:
self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False) self.headless = config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
else: else:
self.headless = headless self.headless = headless
self.artifacts_dir = artifacts_dir
self._login_id: str = DEFAULT_DEMO_LOGIN_ID async def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
self._password: str = DEFAULT_DEMO_PASSWORD
self._token_pin: str = DEFAULT_DEMO_TOKEN_PIN
self._profile_dir: Optional[str] = None
def set_credentials(
self,
login_id: str,
password: str,
token_pin: str,
profile_dir: str,
) -> None:
self._login_id = login_id
self._password = password
self._token_pin = token_pin
stripped = (profile_dir or "").strip()
self._profile_dir = stripped or None
def submit_batch(self, target: str, items: List[BatchItem]) -> BatchSubmitResult:
try: try:
from playwright.sync_api import sync_playwright # noqa: F401 from playwright.async_api import async_playwright # noqa: F401
except ImportError: except ImportError:
return BatchSubmitResult( return BatchSubmitResult(
ok=False, ok=False,
@@ -106,236 +73,37 @@ class SimulatorBrowserRpaAdapter(BatchAdapterBase):
artifacts={"adapter": self.name}, artifacts={"adapter": self.name},
) )
start_url = self.base_url lease_token: Optional[str] = None
try: try:
with browser_session( account = pick_web_account(TARGET_PLATFORM)
start_url, lease_token = account.get("lease_token")
profile_dir=self._profile_dir, base_url = _resolve_entry_url(account)
return await submit_batch_rpa(
account,
target=target,
items=items,
base_url=base_url,
artifacts_dir=self.artifacts_dir,
headless=self.headless, headless=self.headless,
) as page:
try:
logger.info("rpa_login_start url=%s", start_url)
self._login(page)
logger.info("rpa_login_done")
self._goto_batch_page(page)
logger.info("rpa_batch_page_ready")
self._fill_batch_form(page, target, items)
logger.info("rpa_batch_form_filled rows=%s", len(items))
batch_id = self._submit_and_get_batch_id(page)
logger.info("rpa_submit_success batch_id=%s", batch_id)
total = sum(it.amount for it in items)
return BatchSubmitResult(
ok=True,
batch_id=batch_id,
submitted_count=len(items),
submitted_amount=total,
error_msg=None,
artifacts={"adapter": self.name},
) )
except RpaError as exc: except AccountManagerError as exc:
logger.error("rpa_failed: %s", exc)
arts = {"adapter": self.name}
if exc.screenshot_path:
arts["screenshot"] = exc.screenshot_path
return BatchSubmitResult( return BatchSubmitResult(
ok=False, ok=False,
batch_id=None, batch_id=None,
submitted_count=0, submitted_count=0,
submitted_amount=0.0, submitted_amount=0.0,
error_msg=str(exc), error_msg=f"ERROR:{exc.code} {exc.message}",
artifacts=arts, artifacts={"adapter": self.name},
) )
except Exception as exc: except Exception as exc:
sp = self._safe_screenshot(page, "unexpected_error") logger.exception("adapter_unexpected: %s", exc)
logger.exception("rpa_unexpected: %s", exc)
arts = {"adapter": self.name}
if sp:
arts["screenshot"] = sp
return BatchSubmitResult( return BatchSubmitResult(
ok=False, ok=False,
batch_id=None, batch_id=None,
submitted_count=0, submitted_count=0,
submitted_amount=0.0, submitted_amount=0.0,
error_msg=f"未预期异常:{type(exc).__name__}: {exc}", error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
artifacts=arts,
)
except Exception as outer:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"启动浏览器失败:{outer}",
artifacts={"adapter": self.name}, artifacts={"adapter": self.name},
) )
finally:
def _pause(self, min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None: release_lease(lease_token)
time.sleep(random.uniform(min_ms, max_ms) / 1000.0)
def _login(self, page) -> None:
try:
page.wait_for_selector('form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
self._pause()
page.fill('form[name="simLoginStep1"] input[name="loginId"]', self._login_id)
self._pause()
page.click('form[name="simLoginStep1"] button[type="submit"]')
except Exception as exc:
sp = self._safe_screenshot(page, "login_step1_failed")
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
self._pause()
page.fill('form[name="simLoginStep2"] input[name="password"]', self._password)
page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
self._pause()
page.click('form[name="simLoginStep2"] button[type="submit"]')
except Exception as exc:
sp = self._safe_screenshot(page, "login_step2_failed")
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "login_navigate_failed")
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
def _goto_batch_page(self, page) -> None:
try:
page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
self._pause()
except Exception as exc:
sp = self._safe_screenshot(page, "goto_batch_failed")
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
def _select_target_account(self, page, target: str) -> None:
sel = page.locator('select[name="fromAccountNo"]')
sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
n = sel.locator("option").count()
matched_value: Optional[str] = None
first_value: Optional[str] = None
for i in range(n):
opt = sel.locator("option").nth(i)
val = opt.get_attribute("value") or ""
if not val.strip():
continue
label_txt = opt.inner_text()
if first_value is None:
first_value = val
if target.strip() and target.strip() in label_txt:
matched_value = val
break
pick = matched_value or first_value
if not pick:
raise RpaError("来源账户下拉无可选项")
if not matched_value and target.strip():
logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick)
sel.select_option(value=pick)
self._pause()
def _fill_batch_form(self, page, target: str, items: List[BatchItem]) -> None:
try:
page.get_by_role("button", name=re.compile(r"页面填写")).click()
except Exception as exc:
sp = self._safe_screenshot(page, "tab_switch_failed")
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
try:
self._select_target_account(page, target)
except RpaError:
raise
except Exception as exc:
sp = self._safe_screenshot(page, "select_account_failed")
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
try:
existing = page.locator("tr[data-row-index]").count()
need_more = max(0, len(items) - existing)
add_btn = page.get_by_role("button", name=re.compile(r"新增一行"))
for _ in range(need_more):
self._pause(400, 900)
add_btn.click()
except Exception as exc:
sp = self._safe_screenshot(page, "add_rows_failed")
raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc
for it in items:
try:
row = page.locator(f'tr[data-row-index="{it.row_index}"]')
row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
self._pause(300, 800)
row.locator('input[name="name"]').fill(it.name)
row.locator('input[name="account"]').fill(it.account)
row.locator('input[name="note"]').fill(it.note or "")
row.locator('input[name="amount"]').fill(f"{it.amount}")
except Exception as exc:
sp = self._safe_screenshot(page, f"fill_row_{it.row_index}_failed")
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
try:
page.fill('input[name="purpose"]', "仿真批量提交示例")
except Exception as exc:
logger.warning("填写用途失败(非致命):%s", exc)
def _submit_and_get_batch_id(self, page) -> str:
try:
self._pause(800, 1500)
page.locator('form[name="simBatchForm"] button[type="submit"]').click()
except Exception as exc:
sp = self._safe_screenshot(page, "click_submit_failed")
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
try:
page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "pin_dialog_not_open")
raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc
try:
pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]')
self._pause(500, 1200)
pin_input.fill(self._token_pin)
except Exception as exc:
sp = self._safe_screenshot(page, "fill_pin_failed")
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
try:
page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
except Exception as exc:
sp = self._safe_screenshot(page, "click_confirm_failed")
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
try:
page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = self._safe_screenshot(page, "success_area_not_visible")
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
batch_id = page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
if not batch_id:
batch_id = page.locator('[data-testid="batch-id"]').inner_text().strip()
url = page.url
if not batch_id and "#/batch/" in url:
batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/")
if not batch_id:
sp = self._safe_screenshot(page, "batch_id_empty")
raise RpaError(f"无法解析 batch_idurl={url}", screenshot_path=sp)
return batch_id
def _safe_screenshot(self, page, tag: str) -> Optional[str]:
if not self.artifacts_dir:
return None
try:
os.makedirs(self.artifacts_dir, exist_ok=True)
path = os.path.join(self.artifacts_dir, f"{tag}_{int(time.time())}.png")
page.screenshot(path=path, full_page=True)
logger.info("screenshot_saved path=%s", path)
return path
except Exception as exc:
logger.warning("screenshot_failed tag=%s err=%s", tag, exc)
return None

View File

@@ -1,16 +1,14 @@
"""浏览器会话启动(系统 Chrome/EdgeURL 仅通过 page.goto 打开)。""" """浏览器会话启动(系统 Chrome/Edge + async Playwright persistent context)。"""
from __future__ import annotations from __future__ import annotations
import logging import logging
import os import os
from contextlib import contextmanager from typing import Any, List, Optional, Tuple
from typing import Any, Generator, Iterator, List, Optional, Tuple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_PAGE_TIMEOUT_MS = 15_000 DEFAULT_PAGE_TIMEOUT_MS = 15_000
GOTO_TIMEOUT_MS = 60_000
CHROME_LAUNCH_ARGS: List[str] = [ CHROME_LAUNCH_ARGS: List[str] = [
"--start-maximized", "--start-maximized",
@@ -33,11 +31,11 @@ def _headless_from_env() -> bool:
return v in ("1", "true", "yes", "on") return v in ("1", "true", "yes", "on")
def _find_chrome_executable() -> str | None: def find_chrome_executable() -> str | None:
try: try:
from jiangchang_skill_core.runtime_env import find_chrome_executable from jiangchang_skill_core.runtime_env import find_chrome_executable as _find
chrome = find_chrome_executable() chrome = _find()
if chrome and os.path.isfile(chrome): if chrome and os.path.isfile(chrome):
return chrome return chrome
except ImportError: except ImportError:
@@ -59,46 +57,39 @@ def _clear_node_options() -> None:
os.environ.pop("NODE_OPTIONS", None) os.environ.pop("NODE_OPTIONS", None)
@contextmanager async def start_browser_session(
def browser_session( profile_dir: str,
start_url: str,
*, *,
profile_dir: Optional[str] = None,
headless: Optional[bool] = None, headless: Optional[bool] = None,
) -> Generator[Any, None, None]: ) -> Tuple[Any, Any, Any]:
""" """
同步 Playwright 浏览器会话 启动持久化 Chrome profile返回 (playwright, context, page)
- Playwright Python 包由宿主共享 runtime 提供;技能侧不要 playwright install URL 不在此文件打开;由 simulator_playwright.py 负责 page.goto
- launch args 仅放 Chrome 参数,不含 URL。
- 流程launch → new_page → goto(start_url)
""" """
from playwright.sync_api import sync_playwright from playwright.async_api import async_playwright
_clear_node_options() _clear_node_options()
hl = headless if headless is not None else _headless_from_env() hl = headless if headless is not None else _headless_from_env()
chrome = _find_chrome_executable() chrome = find_chrome_executable()
if not chrome: if not chrome:
raise RuntimeError( raise RuntimeError(
"未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。" "ERROR:MISSING_BROWSER 未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。"
) )
args = chrome_launch_args() args = chrome_launch_args()
logger.info( logger.info(
"browser_launch chrome=%s profile_dir=%s start_url=%s args=%s headless=%s", "browser_launch chrome=%s profile_dir=%s args=%s headless=%s",
chrome, chrome,
profile_dir or "", profile_dir,
start_url,
args, args,
hl, hl,
) )
browser_holder = None pw = await async_playwright().start()
context = None context = None
with sync_playwright() as pw:
try: try:
if profile_dir: context = await pw.chromium.launch_persistent_context(
context = pw.chromium.launch_persistent_context(
user_data_dir=profile_dir, user_data_dir=profile_dir,
headless=hl, headless=hl,
executable_path=chrome, executable_path=chrome,
@@ -107,32 +98,29 @@ def browser_session(
args=args, args=args,
ignore_default_args=["--enable-automation"], ignore_default_args=["--enable-automation"],
) )
else: await context.add_init_script(STEALTH_INIT_SCRIPT)
browser_holder = pw.chromium.launch(
headless=hl,
executable_path=chrome,
args=args,
ignore_default_args=["--enable-automation"],
)
context = browser_holder.new_context(no_viewport=True, locale="zh-CN")
context.add_init_script(STEALTH_INIT_SCRIPT) page = context.pages[0] if context.pages else await context.new_page()
page = context.new_page()
page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS) page.set_default_timeout(DEFAULT_PAGE_TIMEOUT_MS)
page.goto(start_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS) return pw, context, page
yield page except Exception:
finally: if context is not None:
try:
await context.close()
except Exception:
pass
await pw.stop()
raise
async def close_browser_session(playwright: Any, context: Any) -> None:
try: try:
if context is not None: if context is not None:
context.close() await context.close()
except Exception as exc: except Exception:
logger.warning("browser_context_close_failed: %s", exc) pass
if browser_holder is not None:
try: try:
browser_holder.close() if playwright is not None:
except Exception as exc: await playwright.stop()
logger.warning("browser_close_failed: %s", exc) except Exception:
pass
def find_chrome_executable() -> str | None:
return _find_chrome_executable()

View File

@@ -0,0 +1,400 @@
"""仿真批量提交 RPA 主流程async Playwright"""
from __future__ import annotations
import asyncio
import logging
import os
import random
import re
import time
from dataclasses import dataclass
from typing import Any, List, Optional
from jiangchang_skill_core import config
from service.adapter.base import BatchItem, BatchSubmitResult
from service.browser_session import (
close_browser_session,
find_chrome_executable,
start_browser_session,
)
from util.constants import (
DEFAULT_DEMO_CAPTCHA,
DEFAULT_DEMO_LOGIN_ID,
DEFAULT_DEMO_PASSWORD,
DEFAULT_DEMO_TOKEN_PIN,
DEFAULT_TIMEOUT_MS,
ENV_SIMULATOR_PASSWORD,
ENV_SIMULATOR_TOKEN_PIN,
LOGIN_NAVIGATE_TIMEOUT_MS,
SUBMIT_NAVIGATE_TIMEOUT_MS,
portal_login_wait_sec,
)
logger = logging.getLogger(__name__)
GOTO_TIMEOUT_MS = 60_000
_PAUSE_MIN_MS = 500
_PAUSE_MAX_MS = 2000
class RpaError(Exception):
def __init__(self, message: str, screenshot_path: Optional[str] = None):
super().__init__(message)
self.screenshot_path = screenshot_path
@dataclass
class _Credentials:
login_id: str
password: str
token_pin: str
def _resolve_credentials(account: dict[str, Any]) -> _Credentials:
login_id = str(
account.get("login_id") or account.get("username") or DEFAULT_DEMO_LOGIN_ID
).strip()
password = str(
account.get("password")
or os.getenv(ENV_SIMULATOR_PASSWORD)
or config.get(ENV_SIMULATOR_PASSWORD)
or DEFAULT_DEMO_PASSWORD
).strip()
token_pin = str(
account.get("token_pin")
or account.get("pin")
or os.getenv(ENV_SIMULATOR_TOKEN_PIN)
or config.get(ENV_SIMULATOR_TOKEN_PIN)
or DEFAULT_DEMO_TOKEN_PIN
).strip()
return _Credentials(login_id=login_id, password=password, token_pin=token_pin)
def _headless() -> bool:
return config.get_bool("OPENCLAW_BROWSER_HEADLESS", default=False)
async def _pause(min_ms: int = _PAUSE_MIN_MS, max_ms: int = _PAUSE_MAX_MS) -> None:
await asyncio.sleep(random.uniform(min_ms, max_ms) / 1000.0)
async def _visible(locator) -> bool:
try:
return await locator.count() > 0 and await locator.is_visible(timeout=500)
except Exception:
return False
async def _wait_portal_gate(page, wait_sec: int) -> None:
portal_user = page.locator("#portal-user")
try:
if not await _visible(portal_user):
return
except Exception:
return
print(f"[门户] 检测到平台门户登录门闩,请手工完成门户登录,最多等待 {wait_sec} 秒...")
deadline = time.monotonic() + wait_sec
while time.monotonic() < deadline:
try:
portal_section = page.locator("#portal-section")
login_step1 = page.locator('form[name="simLoginStep1"]')
if not await _visible(portal_section) or await _visible(login_step1):
print("[门户] 门户门闩已通过,进入业务系统登录")
return
except Exception:
pass
await asyncio.sleep(2.0)
raise RpaError("ERROR:LOGIN_TIMEOUT 门户登录超时,请重新运行并及时完成门户登录。")
async def _login(page, creds: _Credentials, artifacts_dir: str | None) -> None:
try:
await page.wait_for_selector(
'form[name="simLoginStep1"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS
)
await _pause()
await page.fill('form[name="simLoginStep1"] input[name="loginId"]', creds.login_id)
await _pause()
await page.click('form[name="simLoginStep1"] button[type="submit"]')
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_step1_failed")
raise RpaError(f"登录第一步失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('form[name="simLoginStep2"]', timeout=DEFAULT_TIMEOUT_MS)
await _pause()
await page.fill('form[name="simLoginStep2"] input[name="password"]', creds.password)
await page.fill('form[name="simLoginStep2"] input[name="captcha"]', DEFAULT_DEMO_CAPTCHA)
await _pause()
await page.click('form[name="simLoginStep2"] button[type="submit"]')
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_step2_failed")
raise RpaError(f"登录第二步失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('form[name="simBatchForm"]', timeout=LOGIN_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "login_navigate_failed")
raise RpaError(f"登录后未进入批量提交页:{exc}", screenshot_path=sp) from exc
async def _goto_batch_page(page, artifacts_dir: str | None) -> None:
try:
await page.wait_for_selector('form[name="simBatchForm"]', timeout=DEFAULT_TIMEOUT_MS)
await _pause()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "goto_batch_failed")
raise RpaError(f"进入批量提交页失败:{exc}", screenshot_path=sp) from exc
async def _select_target_account(page, target: str) -> None:
sel = page.locator('select[name="fromAccountNo"]')
await sel.wait_for(timeout=DEFAULT_TIMEOUT_MS)
n = await sel.locator("option").count()
matched_value: Optional[str] = None
first_value: Optional[str] = None
for i in range(n):
opt = sel.locator("option").nth(i)
val = (await opt.get_attribute("value")) or ""
if not val.strip():
continue
label_txt = await opt.inner_text()
if first_value is None:
first_value = val
if target.strip() and target.strip() in label_txt:
matched_value = val
break
pick = matched_value or first_value
if not pick:
raise RpaError("来源账户下拉无可选项")
if not matched_value and target.strip():
logger.warning("target '%s' 未匹配选项,回退 value=%s", target, pick)
await sel.select_option(value=pick)
await _pause()
async def _fill_batch_form(
page, target: str, items: List[BatchItem], artifacts_dir: str | None
) -> None:
try:
await page.get_by_role("button", name=re.compile(r"页面填写")).click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "tab_switch_failed")
raise RpaError(f"切换到页面填写失败:{exc}", screenshot_path=sp) from exc
try:
await _select_target_account(page, target)
except RpaError:
raise
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "select_account_failed")
raise RpaError(f"选择来源账户失败:{exc}", screenshot_path=sp) from exc
try:
existing = await page.locator("tr[data-row-index]").count()
need_more = max(0, len(items) - existing)
add_btn = page.get_by_role("button", name=re.compile(r"新增一行"))
for _ in range(need_more):
await _pause(400, 900)
await add_btn.click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "add_rows_failed")
raise RpaError(f"新增明细行失败:{exc}", screenshot_path=sp) from exc
for it in items:
try:
row = page.locator(f'tr[data-row-index="{it.row_index}"]')
await row.wait_for(timeout=DEFAULT_TIMEOUT_MS)
await _pause(300, 800)
await row.locator('input[name="name"]').fill(it.name)
await row.locator('input[name="account"]').fill(it.account)
await row.locator('input[name="note"]').fill(it.note or "")
await row.locator('input[name="amount"]').fill(f"{it.amount}")
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, f"fill_row_{it.row_index}_failed")
raise RpaError(f"填第 {it.row_index} 行失败:{exc}", screenshot_path=sp) from exc
try:
await page.fill('input[name="purpose"]', "仿真批量提交示例")
except Exception as exc:
logger.warning("填写用途失败(非致命):%s", exc)
async def _submit_and_get_batch_id(page, token_pin: str, artifacts_dir: str | None) -> str:
try:
await _pause(800, 1500)
await page.locator('form[name="simBatchForm"] button[type="submit"]').click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "click_submit_failed")
raise RpaError(f"点击提交失败:{exc}", screenshot_path=sp) from exc
dlg_sel = 'div[role="dialog"][aria-modal="true"]'
try:
await page.wait_for_selector(dlg_sel, timeout=DEFAULT_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "pin_dialog_not_open")
raise RpaError(f"PIN 弹窗未出现:{exc}", screenshot_path=sp) from exc
try:
pin_input = page.locator(f'{dlg_sel} input[type="password"][inputmode="numeric"]')
await _pause(500, 1200)
await pin_input.fill(token_pin)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "fill_pin_failed")
raise RpaError(f"填写 PIN 失败:{exc}", screenshot_path=sp) from exc
try:
await page.locator(dlg_sel).get_by_role("button", name=re.compile(r"确认提交")).click()
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "click_confirm_failed")
raise RpaError(f"确认提交失败:{exc}", screenshot_path=sp) from exc
try:
await page.wait_for_selector('[data-testid="batch-id"]', timeout=SUBMIT_NAVIGATE_TIMEOUT_MS)
except Exception as exc:
sp = await _safe_screenshot(page, artifacts_dir, "success_area_not_visible")
raise RpaError(f"提交后未出现成功区域:{exc}", screenshot_path=sp) from exc
batch_id = await page.locator('[data-testid="batch-id"]').get_attribute("data-batch-id") or ""
if not batch_id:
batch_id = (await page.locator('[data-testid="batch-id"]').inner_text()).strip()
url = page.url
if not batch_id and "#/batch/" in url:
batch_id = url.split("#/batch/", 1)[1].split("?")[0].split("#")[0].strip("/")
if not batch_id:
sp = await _safe_screenshot(page, artifacts_dir, "batch_id_empty")
raise RpaError(f"无法解析 batch_idurl={url}", screenshot_path=sp)
return batch_id
async def _safe_screenshot(page, artifacts_dir: str | None, tag: str) -> Optional[str]:
if not artifacts_dir:
return None
try:
os.makedirs(artifacts_dir, exist_ok=True)
path = os.path.join(artifacts_dir, f"{tag}_{int(time.time())}.png")
await page.screenshot(path=path, full_page=True)
logger.info("screenshot_saved path=%s", path)
return path
except Exception as exc:
logger.warning("screenshot_failed tag=%s err=%s", tag, exc)
return None
async def submit_batch_rpa(
account: dict[str, Any],
*,
target: str,
items: list[BatchItem],
base_url: str,
artifacts_dir: str | None,
video_session: Any = None,
headless: bool | None = None,
) -> BatchSubmitResult:
del video_session # 示例占位;真实 skill 可接入 RpaVideoSession
profile_dir = str(account.get("profile_dir") or "").strip()
if not profile_dir:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="账号缺少 profile_dir",
artifacts={"adapter": "simulator_rpa"},
)
if not items:
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="items 为空",
artifacts={"adapter": "simulator_rpa"},
)
if not find_chrome_executable():
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="未检测到 Chrome 或 Edge 浏览器,请安装系统浏览器后重试。",
artifacts={"adapter": "simulator_rpa"},
)
creds = _resolve_credentials(account)
portal_wait = portal_login_wait_sec()
hl = headless if headless is not None else _headless()
pw = None
context = None
page = None
try:
pw, context, page = await start_browser_session(profile_dir, headless=hl)
logger.info("rpa_goto url=%s", base_url)
await page.goto(base_url, wait_until="domcontentloaded", timeout=GOTO_TIMEOUT_MS)
await _wait_portal_gate(page, portal_wait)
logger.info("rpa_login_start")
await _login(page, creds, artifacts_dir)
logger.info("rpa_login_done")
await _goto_batch_page(page, artifacts_dir)
logger.info("rpa_batch_page_ready")
await _fill_batch_form(page, target, items, artifacts_dir)
logger.info("rpa_batch_form_filled rows=%s", len(items))
batch_id = await _submit_and_get_batch_id(page, creds.token_pin, artifacts_dir)
logger.info("rpa_submit_success batch_id=%s", batch_id)
total = sum(it.amount for it in items)
return BatchSubmitResult(
ok=True,
batch_id=batch_id,
submitted_count=len(items),
submitted_amount=total,
error_msg=None,
artifacts={"adapter": "simulator_rpa"},
)
except RpaError as exc:
logger.error("rpa_failed: %s", exc)
arts = {"adapter": "simulator_rpa"}
if exc.screenshot_path:
arts["screenshot"] = exc.screenshot_path
elif page is not None:
sp = await _safe_screenshot(page, artifacts_dir, "rpa_error")
if sp:
arts["screenshot"] = sp
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=str(exc),
artifacts=arts,
)
except Exception as exc:
logger.exception("rpa_unexpected: %s", exc)
arts = {"adapter": "simulator_rpa"}
if page is not None:
sp = await _safe_screenshot(page, artifacts_dir, "unexpected_error")
if sp:
arts["screenshot"] = sp
return BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg=f"未预期异常:{type(exc).__name__}: {exc}",
artifacts=arts,
)
finally:
await close_browser_session(pw, context)

View File

@@ -7,8 +7,7 @@ import tempfile
import uuid import uuid
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from service.account_client import pick_simulator_account from service.adapter import BatchItem, select_adapter
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter, select_adapter
from util.logging import get_logger from util.logging import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -42,7 +41,7 @@ def _validate_items(items: List[Dict[str, Any]]) -> tuple[List[BatchItem], Optio
return parsed, None return parsed, None
def run_batch_submit( async def run_batch_submit(
target: str, target: str,
items: List[Dict[str, Any]], items: List[Dict[str, Any]],
*, *,
@@ -53,6 +52,7 @@ def run_batch_submit(
最小编排示例。 最小编排示例。
真实 skill 中还应:写 task_logs、entitlement、RpaVideoSession 等。 真实 skill 中还应:写 task_logs、entitlement、RpaVideoSession 等。
账号 pick / lease 在 adapter 内完成,编排层不直接碰 account-manager。
""" """
del force # 示例占位;真实 skill 可用于跳过确认 del force # 示例占位;真实 skill 可用于跳过确认
@@ -70,19 +70,15 @@ def run_batch_submit(
uuid.uuid4().hex[:8], uuid.uuid4().hex[:8],
) )
os.makedirs(art_dir, exist_ok=True) os.makedirs(art_dir, exist_ok=True)
logger.info("batch_submit_start target=%s item_count=%s artifacts_dir=%s", target_key, len(batch_items), art_dir) logger.info(
"batch_submit_start target=%s item_count=%s artifacts_dir=%s",
adapter = select_adapter(artifacts_dir=art_dir) target_key,
if isinstance(adapter, SimulatorBrowserRpaAdapter): len(batch_items),
account = pick_simulator_account() art_dir,
adapter.set_credentials(
account.login_id,
account.password,
account.token_pin,
account.profile_dir,
) )
result = adapter.submit_batch(target_key, batch_items) adapter = select_adapter(artifacts_dir=art_dir)
result = await adapter.submit_batch(target_key, batch_items)
summary: Dict[str, Any] = { summary: Dict[str, Any] = {
"success": result.ok, "success": result.ok,
"target": target_key, "target": target_key,

View File

@@ -5,12 +5,19 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from jiangchang_skill_core import config
SKILL_SLUG = "example-simulator-browser-rpa" SKILL_SLUG = "example-simulator-browser-rpa"
LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa" LOG_LOGGER_NAME = "openclaw.skill.example_simulator_browser_rpa"
TARGET_PLATFORM = "sim_batch"
LEASE_HOLDER = "example-simulator-browser-rpa"
LEASE_TTL_SEC = "1800"
DEFAULT_TIMEOUT_MS = 15_000 DEFAULT_TIMEOUT_MS = 15_000
LOGIN_NAVIGATE_TIMEOUT_MS = 30_000 LOGIN_NAVIGATE_TIMEOUT_MS = 30_000
SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000 SUBMIT_NAVIGATE_TIMEOUT_MS = 60_000
DEFAULT_PORTAL_LOGIN_WAIT_SEC = 120
DEFAULT_DEMO_LOGIN_ID = "demo001" DEFAULT_DEMO_LOGIN_ID = "demo001"
DEFAULT_DEMO_PASSWORD = "demo-password" DEFAULT_DEMO_PASSWORD = "demo-password"
@@ -18,15 +25,26 @@ DEFAULT_DEMO_CAPTCHA = "0000"
DEFAULT_DEMO_TOKEN_PIN = "123456" DEFAULT_DEMO_TOKEN_PIN = "123456"
ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL" ENV_SIMULATOR_BASE_URL = "SIMULATOR_BASE_URL"
ENV_SIMULATOR_LOGIN_ID = "SIMULATOR_LOGIN_ID"
ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD" ENV_SIMULATOR_PASSWORD = "SIMULATOR_PASSWORD"
ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN" ENV_SIMULATOR_TOKEN_PIN = "SIMULATOR_TOKEN_PIN"
ENV_SIMULATOR_PROFILE_DIR = "SIMULATOR_PROFILE_DIR" ENV_PORTAL_LOGIN_WAIT_SEC = "PORTAL_LOGIN_WAIT_SEC"
_EXAMPLE_ROOT = Path(__file__).resolve().parents[2] _EXAMPLE_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html" DEFAULT_DEMO_APP_PATH = _EXAMPLE_ROOT / "sandbox" / "demo_app.html"
def portal_login_wait_sec() -> int:
raw = (
os.getenv(ENV_PORTAL_LOGIN_WAIT_SEC)
or config.get(ENV_PORTAL_LOGIN_WAIT_SEC)
or str(DEFAULT_PORTAL_LOGIN_WAIT_SEC)
)
try:
return max(1, int(str(raw).strip()))
except (TypeError, ValueError):
return DEFAULT_PORTAL_LOGIN_WAIT_SEC
def resolve_simulator_base_url() -> str: def resolve_simulator_base_url() -> str:
env = (os.getenv(ENV_SIMULATOR_BASE_URL) or "").strip() env = (os.getenv(ENV_SIMULATOR_BASE_URL) or "").strip()
if env: if env:

View File

@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import Any
from util.constants import LOG_LOGGER_NAME from util.constants import LOG_LOGGER_NAME
@@ -13,3 +15,24 @@ def get_logger(name: str | None = None) -> logging.Logger:
def configure_logging(level: int = logging.INFO) -> None: def configure_logging(level: int = logging.INFO) -> None:
logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s") logging.basicConfig(level=level, format="%(levelname)s %(name)s %(message)s")
def mask_text(value: str, *, keep_start: int = 2, keep_end: int = 2) -> str:
s = (value or "").strip()
if len(s) >= 11 and s.isdigit():
return s[:3] + "****" + s[-4:]
if len(s) > keep_start + keep_end:
return s[:keep_start] + "****" + s[-keep_end:]
return "****"
def safe_log_value(value: Any) -> str:
if value is None:
return ""
text = str(value)
if re.fullmatch(r"\d{7,}", text):
return mask_text(text)
if "@" in text and len(text) > 6:
local, _, domain = text.partition("@")
return f"{mask_text(local, keep_start=1, keep_end=1)}@{domain}"
return text

View File

@@ -42,20 +42,20 @@ class TestAdapterDispatch(unittest.TestCase):
self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter) self.assertIsInstance(select_adapter(), SimulatorBrowserRpaAdapter)
class TestMockAdapter(unittest.TestCase): class TestMockAdapter(unittest.IsolatedAsyncioTestCase):
def test_mock_success(self) -> None: async def test_mock_success(self) -> None:
items = [ items = [
BatchItem(row_index=1, name="Alice", account="1001", amount=10.5), BatchItem(row_index=1, name="Alice", account="1001", amount=10.5),
BatchItem(row_index=2, name="Bob", account="1002", amount=20.0), BatchItem(row_index=2, name="Bob", account="1002", amount=20.0),
] ]
result = MockBatchAdapter().submit_batch("acct-demo-001", items) result = await MockBatchAdapter().submit_batch("acct-demo-001", items)
self.assertTrue(result.ok) self.assertTrue(result.ok)
self.assertEqual(result.submitted_count, 2) self.assertEqual(result.submitted_count, 2)
self.assertEqual(result.submitted_amount, 30.5) self.assertEqual(result.submitted_amount, 30.5)
self.assertTrue((result.batch_id or "").startswith("MOCK-")) self.assertTrue((result.batch_id or "").startswith("MOCK-"))
def test_mock_empty_items(self) -> None: async def test_mock_empty_items(self) -> None:
result = MockBatchAdapter().submit_batch("acct-demo-001", []) result = await MockBatchAdapter().submit_batch("acct-demo-001", [])
self.assertFalse(result.ok) self.assertFalse(result.ok)
self.assertEqual(result.error_msg, "items 为空") self.assertEqual(result.error_msg, "items 为空")

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""禁止 import account-manager 内部 RPA 辅助模块。"""
from __future__ import annotations
import os
import re
import unittest
from pathlib import Path
FORBIDDEN_PATTERNS = (
re.compile(r"\brpa_helpers\b"),
re.compile(r"\binject_account_manager_scripts_path\b"),
re.compile(r"\bget_account_credential\b"),
)
SCRIPTS_ROOT = Path(__file__).resolve().parents[1] / "scripts"
class TestNoRpaHelpersImport(unittest.TestCase):
def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None:
violations: list[str] = []
for root, _dirs, files in os.walk(SCRIPTS_ROOT):
for name in files:
if not name.endswith(".py"):
continue
path = Path(root) / name
text = path.read_text(encoding="utf-8")
for pattern in FORBIDDEN_PATTERNS:
if pattern.search(text):
violations.append(f"{path.relative_to(SCRIPTS_ROOT)}: {pattern.pattern}")
self.assertEqual(
violations,
[],
msg="Forbidden account-manager internal imports found:\n" + "\n".join(violations),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -2,31 +2,27 @@
"""SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。""" """SimulatorBrowserRpaAdapter 单元测试(不启动真实浏览器)。"""
from __future__ import annotations from __future__ import annotations
import asyncio
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
from service.browser_session import CHROME_LAUNCH_ARGS from service.browser_session import CHROME_LAUNCH_ARGS
from service.simulator_playwright import RpaError
class TestSimulatorRpaUnit(unittest.TestCase): class TestSimulatorRpaUnit(unittest.IsolatedAsyncioTestCase):
def test_set_credentials_and_empty_profile_dir(self) -> None: async def test_submit_batch_empty_items(self) -> None:
adapter = SimulatorBrowserRpaAdapter() adapter = SimulatorBrowserRpaAdapter()
adapter.set_credentials("demo001", "pwd", "pin", " ") result = await adapter.submit_batch("acct-demo-001", [])
self.assertEqual(adapter._login_id, "demo001")
self.assertIsNone(adapter._profile_dir)
def test_submit_batch_empty_items(self) -> None:
adapter = SimulatorBrowserRpaAdapter()
result = adapter.submit_batch("acct-demo-001", [])
self.assertFalse(result.ok) self.assertFalse(result.ok)
self.assertIn("items", result.error_msg or "") self.assertIn("items", result.error_msg or "")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value=None) @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value=None)
def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None: async def test_no_chrome_returns_error_without_launch(self, _chrome: MagicMock) -> None:
adapter = SimulatorBrowserRpaAdapter() adapter = SimulatorBrowserRpaAdapter()
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = adapter.submit_batch("acct-demo-001", items) result = await adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok) self.assertFalse(result.ok)
msg = result.error_msg or "" msg = result.error_msg or ""
self.assertTrue("Chrome" in msg or "Edge" in msg or "浏览器" in msg) self.assertTrue("Chrome" in msg or "Edge" in msg or "浏览器" in msg)
@@ -38,38 +34,100 @@ class TestSimulatorRpaUnit(unittest.TestCase):
self.assertFalse(lowered.startswith("https://")) self.assertFalse(lowered.startswith("https://"))
self.assertFalse(lowered.startswith("file://")) self.assertFalse(lowered.startswith("file://"))
@patch("service.adapter.simulator_rpa.browser_session") @patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
def test_start_url_opened_via_goto_not_launch_args( async def test_adapter_delegates_to_playwright_with_account_url(
self, self,
_chrome: MagicMock, _chrome: MagicMock,
mock_session: MagicMock, mock_pick: MagicMock,
mock_rpa: AsyncMock,
) -> None: ) -> None:
from service.adapter.simulator_rpa import RpaError from service.adapter.base import BatchSubmitResult
adapter = SimulatorBrowserRpaAdapter(base_url="file:///demo/demo_app.html") mock_pick.return_value = {
"login_id": "demo001",
"profile_dir": "/tmp/profile",
"url": "file:///demo/demo_app.html",
"lease_token": "tok-1",
}
mock_rpa.return_value = BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="login fail",
artifacts={"adapter": "simulator_rpa"},
)
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = await adapter.submit_batch("acct-demo-001", items)
page = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = page
mock_cm.__exit__.return_value = False
mock_session.return_value = mock_cm
with patch.object(adapter, "_login", side_effect=RpaError("login fail")):
result = adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok) self.assertFalse(result.ok)
mock_session.assert_called_once() mock_rpa.assert_awaited_once()
called_start_url = mock_session.call_args[0][0] call_kwargs = mock_rpa.call_args.kwargs
self.assertEqual(called_start_url, "file:///demo/demo_app.html") self.assertEqual(call_kwargs["base_url"], "file:///demo/demo_app.html")
for arg in CHROME_LAUNCH_ARGS: for arg in CHROME_LAUNCH_ARGS:
self.assertNotIn(arg, called_start_url) self.assertNotIn(arg, call_kwargs["base_url"])
def test_safe_screenshot_without_artifacts_dir(self) -> None: @patch("service.simulator_playwright.start_browser_session", new_callable=AsyncMock)
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None) async def test_playwright_opens_url_via_goto_not_launch_args(self, mock_start: AsyncMock) -> None:
page = MagicMock() from service.simulator_playwright import submit_batch_rpa
self.assertIsNone(adapter._safe_screenshot(page, "tag"))
page = AsyncMock()
page.goto = AsyncMock()
mock_start.return_value = (MagicMock(), MagicMock(), page)
account = {"profile_dir": "/tmp/p", "login_id": "demo001"}
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
with patch(
"service.simulator_playwright._wait_portal_gate",
new_callable=AsyncMock,
return_value=None,
):
with patch(
"service.simulator_playwright._login",
new_callable=AsyncMock,
side_effect=RpaError("login fail"),
):
with patch(
"service.simulator_playwright.find_chrome_executable",
return_value="C:/Chrome/chrome.exe",
):
with patch(
"service.simulator_playwright.close_browser_session",
new_callable=AsyncMock,
):
result = await submit_batch_rpa(
account,
target="acct-demo-001",
items=items,
base_url="file:///demo/demo_app.html",
artifacts_dir=None,
)
self.assertFalse(result.ok)
page.goto.assert_awaited_once()
goto_url = page.goto.call_args[0][0]
self.assertEqual(goto_url, "file:///demo/demo_app.html")
for arg in CHROME_LAUNCH_ARGS:
self.assertNotIn(arg, goto_url)
async def test_safe_screenshot_without_artifacts_dir(self) -> None:
from service.simulator_playwright import _safe_screenshot
page = AsyncMock()
self.assertIsNone(await _safe_screenshot(page, None, "tag"))
class TestSyncHelpers(unittest.TestCase):
def test_asyncio_run_smoke(self) -> None:
async def _noop() -> bool:
return True
self.assertTrue(asyncio.run(_noop()))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -3,51 +3,70 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from service.adapter import BatchItem, SimulatorBrowserRpaAdapter from service.adapter import BatchItem, SimulatorBrowserRpaAdapter
from service.adapter.simulator_rpa import RpaError from service.adapter.base import BatchSubmitResult
class TestBatchSubmitFailureResult(unittest.TestCase): class TestBatchSubmitFailureResult(unittest.IsolatedAsyncioTestCase):
@patch("service.adapter.simulator_rpa.release_lease")
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
@patch("service.adapter.simulator_rpa.browser_session") async def test_rpa_error_converts_to_failed_result(
def test_rpa_error_converts_to_failed_result(self, mock_session: MagicMock, _chrome: MagicMock) -> None: self,
page = MagicMock() _chrome: MagicMock,
mock_cm = MagicMock() mock_pick: MagicMock,
mock_cm.__enter__.return_value = page mock_rpa: AsyncMock,
mock_cm.__exit__.return_value = False _release: MagicMock,
mock_session.return_value = mock_cm ) -> None:
mock_pick.return_value = {
"profile_dir": "/tmp/profile",
"lease_token": "tok",
}
mock_rpa.return_value = BatchSubmitResult(
ok=False,
batch_id=None,
submitted_count=0,
submitted_amount=0.0,
error_msg="登录第一步失败timeout",
artifacts={"adapter": "simulator_rpa", "screenshot": "/tmp/art/x.png"},
)
adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art") adapter = SimulatorBrowserRpaAdapter(artifacts_dir="/tmp/art")
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = await adapter.submit_batch("acct-demo-001", items)
with patch.object(adapter, "_login", side_effect=RpaError("登录第一步失败timeout", screenshot_path="/tmp/art/x.png")):
result = adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok) self.assertFalse(result.ok)
self.assertIn("登录第一步失败", result.error_msg or "") self.assertIn("登录第一步失败", result.error_msg or "")
self.assertEqual(result.artifacts.get("adapter"), "simulator_browser_rpa") self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa")
self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png") self.assertEqual(result.artifacts.get("screenshot"), "/tmp/art/x.png")
@patch("service.adapter.simulator_rpa.release_lease")
@patch("service.adapter.simulator_rpa.submit_batch_rpa", new_callable=AsyncMock)
@patch("service.adapter.simulator_rpa.pick_web_account")
@patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe") @patch("service.adapter.simulator_rpa.find_chrome_executable", return_value="C:/Chrome/chrome.exe")
@patch("service.adapter.simulator_rpa.browser_session") async def test_unexpected_error_includes_adapter_name(
def test_unexpected_error_includes_adapter_name(self, mock_session: MagicMock, _chrome: MagicMock) -> None: self,
page = MagicMock() _chrome: MagicMock,
mock_cm = MagicMock() mock_pick: MagicMock,
mock_cm.__enter__.return_value = page mock_rpa: AsyncMock,
mock_cm.__exit__.return_value = False _release: MagicMock,
mock_session.return_value = mock_cm ) -> None:
mock_pick.return_value = {
"profile_dir": "/tmp/profile",
"lease_token": "tok",
}
mock_rpa.side_effect = RuntimeError("boom")
adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None) adapter = SimulatorBrowserRpaAdapter(artifacts_dir=None)
items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)] items = [BatchItem(row_index=1, name="A", account="1", amount=1.0)]
result = await adapter.submit_batch("acct-demo-001", items)
with patch.object(adapter, "_login", side_effect=RuntimeError("boom")):
result = adapter.submit_batch("acct-demo-001", items)
self.assertFalse(result.ok) self.assertFalse(result.ok)
self.assertIn("未预期异常", result.error_msg or "") self.assertIn("未预期异常", result.error_msg or "")
self.assertEqual(result.artifacts.get("adapter"), "simulator_browser_rpa") self.assertEqual(result.artifacts.get("adapter"), "simulator_rpa")
self.assertNotIn("screenshot", result.artifacts) self.assertNotIn("screenshot", result.artifacts)

195
references/ACTIONS.md Normal file
View File

@@ -0,0 +1,195 @@
# Skill Action Manifest匠厂宿主
本文件说明 `assets/actions.json``scripts/main.py` CLI 的对应关系,供 Agent 编排与宿主 **Skill Action Runtime** 使用。
**边界:** 用户市场说明见根目录 [`README.md`](../README.md)CLI 参数与错误码细节见 [`CLI.md`](CLI.md)。
## 模板严格规范 vs 宿主向后兼容
| 角色 | 定位 |
|------|------|
| **skill-template** | 新技能推荐的**严格规范**:必填字段、明确类型、`additionalProperties: false` |
| **宿主运行时** | **向后兼容**的历史 manifest 解析器:可能接受部分缺省字段(如 `description``placements``entrypoint.args`、input property `type` |
**新技能不应依赖宿主缺省行为。** 复制模板后按 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) 与本文完整填写 manifest。
JSON Schema 使用 `additionalProperties: false` 表达模板严格边界;这与宿主共享类型中的可选字段并不矛盾——宿主为兼容旧技能可能更宽松,模板为防漂移要求更严。
## Action 是否必需
Action 是**可选能力**
- 没有宿主直接操作需求的技能,**可以不提供** `actions.json`
- 需要出现在**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中的技能,**必须**提供 `assets/actions.json`
## 文件位置
```text
{skill_root}/assets/actions.json
```
宿主扫描路径示例:
```text
~/.openclaw/skills/your-skill-slug/assets/actions.json
```
## 顶层结构
| 字段 | 必填 | 说明 |
|------|------|------|
| `schemaVersion` | 是 | 当前固定为 `1` |
| `skill` | 是 | 必须与 `SKILL.md``metadata.openclaw.slug` 一致 |
| `actions` | 是 | Action 数组,每项 `id` 唯一 |
模板最小示例见 [`assets/actions.json`](../assets/actions.json)JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
## Phase 1 当前稳定支持(新技能应只使用这些)
| 层级 | 稳定字段 / 能力 |
|------|----------------|
| 顶层 | `schemaVersion``skill``actions` |
| action | `id``label``description``placements``entrypoint`;可选 `inputSchema``confirmation` |
| entrypoint | `type = cli``command``args`**必填** JSON 数组,项为 `string` / `number` / `boolean` |
| placements | `toolbar``skill-detail``agent``cron` |
| inputSchema | 根 `type: object`properties 使用 `string` / `number` / `boolean`;支持 `enum``default``required``sensitive` |
| confirmation | `{ "message": "..." }` |
## 预留能力(当前不可当作已实现能力)
以下在宿主共享类型或 Schema 枚举中可能出现,但 **Phase 1 Runtime 尚未完整支持****新模板示例不得使用**
| 类别 | 预留项 | 说明 |
|------|--------|------|
| placements | `row``batch` | 行内 / 批量入口,未来扩展 |
| action 字段 | `bind``concurrency``locks` | 绑定、并发与锁控制,宿主后续实现 |
| inputSchema | `array`、nested `object``oneOf` 等 | 复杂表单与联合类型,不属于当前推荐模板范围 |
可以在文档或 Schema 枚举中保留这些值供未来对齐,但不要让技能作者误以为它们已经可执行。
## 执行模型
宿主将 action 转为 argv再执行
```text
python {skill_root}/scripts/main.py <command> <args...>
```
- `entrypoint.type` 当前只支持 `cli`
- `entrypoint.command` → CLI 子命令(须真实存在)
- `entrypoint.args` → 子命令参数(**必须为 JSON 数组**
- `{{fieldName}}` → 来自 `inputSchema` 用户输入的简单模板替换
完整子命令定义见 [`scripts/cli/app.py`](../scripts/cli/app.py)。
### CLI 输出契约
技能 CLI 必须:
- 使用稳定的退出码
- 成功时输出可解析的结构化结果
- 失败时输出稳定错误信息
- 对可预期错误提供稳定错误码
- 对用户可处理的问题提供 `HINT`
- 不依赖 Agent 连续对话才能完成
- 不自行实现宿主任务中心
- 长时间任务允许异步执行,由宿主任务中心承载状态
- 不在 CLI 中硬编码宿主安装目录
- 不直接依赖某个具体 UI 页面
建议错误输出格式:
```text
ERROR:<CODE>: <message>
HINT: <next step>
```
## placements入口位置
| 值 | 含义 |
|----|------|
| `toolbar` | 数据管理第一排技能按钮 |
| `skill-detail` | 技能详情或技能市场详情页 |
| `agent` | Agent 可直接调用 |
| `cron` | 定时任务可直接调用 |
**预留能力(当前宿主可能尚未完整支持,模板示例请勿使用):**
| 值 | 说明 |
|----|------|
| `row` | 行内操作(未来扩展) |
| `batch` | 批量操作(未来扩展) |
可以在 manifest 中作为未来扩展值定义,但不要让技能作者误以为 `row` / `batch` 已经完整可用。
## inputSchema 与参数表单
当前宿主稳定支持:
- `object` / `string` / `number` / `boolean`
- `enum` / `default` / `required` / `sensitive`
- 简单模板替换(`{{fieldName}}`
示例:
```json
{
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": {
"type": "string",
"title": "查询条件"
},
"limit": {
"type": "number",
"title": "返回数量",
"default": 50
}
}
}
}
```
约定:
- `boolean` 在宿主中渲染为开关
- `enum` 在宿主中渲染为选择框
- `required` 字段在提交前校验
- `sensitive` 字段不得出现在低信任入口
- 需要确认的副作用操作使用 `confirmation.message`
- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明
- `entrypoint.args` 项当前只允许 `string``number``boolean`;不允许对象或 `null`
- **不要**在当前模板中宣传 `nested object``oneOf`、复杂数组等尚未稳定支持的能力
## 风险与入口配置
- 查询、诊断、版本检查可以放到 `skill-detail``agent`
- 低风险、常用动作才考虑放 `toolbar`
- 会修改本地数据、访问外部系统、执行长时间任务的动作必须谨慎配置
- 有副作用的动作应添加 `confirmation.message`
- 高风险动作不应默认出现在 `toolbar`
-`sensitive` 参数的 action 不应放入 `toolbar``cron`,除非有明确安全设计
- 不要为了「按钮多」而暴露所有 CLI 子命令
- 一个 CLI 命令可以拆成多个语义清晰的 action但每个 action 必须有明确语义
## 模板示例 action 一览
| id | CLI | placements | 说明 |
|----|-----|------------|------|
| `health` | `health` | skill-detail, agent | 运行环境检查 |
| `version` | `version` | skill-detail | 版本 JSON |
| `config-path` | `config-path` | skill-detail | 配置路径 JSON |
复制为新技能后,请按业务需要增删 action不需要宿主入口时可删除整个 `actions.json`
## 与宿主 API参考
| 宿主 API | 用途 |
|----------|------|
| `GET /api/skill-actions/{skillSlug}` | 读取 manifest |
| `POST /api/skill-actions/run` | `{ skillSlug, actionId, input?, source? }` |
| `GET /api/skill-actions/jobs` | 任务中心 |
具体路径与字段以宿主实现为准;技能侧只需保证 manifest 与 CLI 契约稳定。

View File

@@ -13,7 +13,8 @@
| 根目录 [`README.md`](../README.md) | 用户市场详情页说明 | | 根目录 [`README.md`](../README.md) | 用户市场详情页说明 |
| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口 | | [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口 |
| [`CLI.md`](CLI.md) | 命令行入口、参数与调用契约 | | [`CLI.md`](CLI.md) | 命令行入口、参数与调用契约 |
| [`SCHEMA.md`](SCHEMA.md) | 输入输出、数据库与字段说明 | | [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
| [`ACTIONS.md`](ACTIONS.md) | Skill Action manifest**可选**能力)与宿主入口契约 |
| [`../development/`](../development/) | 开发、测试、技术规范(给开发者 / AI 编程代理) | | [`../development/`](../development/) | 开发、测试、技术规范(给开发者 / AI 编程代理) |
按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`;运行时环境细节见 `../development/RUNTIME.md` 按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`配置宿主按钮/定时任务/Agent 入口时读 `ACTIONS.md`运行时环境细节见 `../development/RUNTIME.md`

View File

@@ -5,10 +5,27 @@
## 职责边界 ## 职责边界
| 层级 | 负责内容 | ### 技能负责
|------|----------|
| **技能(本仓库)** | `SKILL.md.name` 中文技能名;英文物理表/字段名;`_jiangchang_*` 中文展示元数据;`CREATE TABLE` 字段物理顺序 | - 定义真实 SQLite 表结构
| **宿主(匠厂)** | 读取元数据与 PRAGMA 顺序并展示;不猜测业务语义、不按英文字段名重排 | - 使用英文 `snake_case` 表名和字段名
- 使用 `_jiangchang_tables` 提供表级展示元数据
- 使用 `_jiangchang_columns` 提供字段级展示元数据
- 定义哪些字段可见、可搜索、可创建、可编辑
- 定义字段展示类型和枚举选项
- 保证元数据与真实数据库结构一致
- 保证元数据初始化幂等
### 宿主负责
- 读取 `_jiangchang_*` 元数据
- 读取 SQLite `PRAGMA table_info`
- 根据 schema 渲染数据管理页面
- 根据 `editable` 推导字段是否可创建或编辑
- 执行通用查询、新增、编辑、删除、导入、导出
- 对字段名、表名、类型和权限进行最终校验
- 不猜测技能业务含义
- 不根据具体技能名称写特殊逻辑
SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`SQL 文件里的 `-- 注释` 也不会成为可查询结构。 SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`SQL 文件里的 `-- 注释` 也不会成为可查询结构。
因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info``cid`)。 因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info``cid`)。
@@ -27,9 +44,9 @@ metadata:
--- ---
``` ```
- `name`:用户可见的中文业务名称(可含品牌,如 `GEO 文章生成``1688 联系人采集` - `name`:用户可见的中文业务名称。
- `description`:能力说明,不是技能名称。 - `description`:能力说明,不是技能名称。
- `metadata.openclaw.slug`机器标识kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md);本文件仅描述数据库与 kebab-case 格式 - `metadata.openclaw.slug`机器标识kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md)。
- 复制模板后不得将 `your-skill-slug``My Skill` 等占位内容作为正式技能发布名称。 - 复制模板后不得将 `your-skill-slug``My Skill` 等占位内容作为正式技能发布名称。
## 数据库路径与命名 ## 数据库路径与命名
@@ -38,48 +55,145 @@ metadata:
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db {JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db
``` ```
- 数据库文件:英文 slug例如 `scrape-contacts.db``account-manager.db`(默认 `{skill-slug}.db`)。 - 数据库文件:英文 slug例如 `your-skill-slug.db`(默认 `{skill-slug}.db`)。
- 业务表、字段:**英文 snake_case** 物理名,例如 `task_logs``created_at` - 业务表、字段:**英文 snake_case** 物理名,例如 `demo_items``task_logs``created_at`
- **禁止**用中文作为 SQLite 真实表名或字段名。 - **禁止**用中文作为 SQLite 真实表名或字段名。
## 外部来源只读与本地数据可写
某些技能会从外部系统读取数据并写入技能自己的本地数据库。需要区分:
```text
外部来源数据库:
可能只读,不应被修改
技能本地数据库:
可以根据业务语义允许新增、编辑或删除
```
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly``_jiangchang_columns.editable` 声明本地表的实际写权限。
## 元数据表(宿主兼容) ## 元数据表(宿主兼容)
宿主读取字段(与 `jiangchang` `metadata-resolver.ts` 一致): 宿主读取字段(与匠厂 `metadata-resolver` 一致):
### `_jiangchang_tables` ### `_jiangchang_tables` 表级标准
| 字段 | 必填 | 说明 | | 字段 | 必填 | 说明 |
|------|------|------| |------|------|------|
| `table_name` | 是 | 物理表名 | | `table_name` | 是 | 真实英文表名 |
| `display_name` | 是 | 中文表名(用户可见) | | `display_name` | 是 | 用户可见的中文业务名称 |
| `description` | 否 | 表说明 | | `description` | 否 | 表说明 |
| `sort_order` | 否 | 宿主目录排序;与字段顺序无关 | | `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 |
| `visible` | 是 | `1` 展示 / `0` 隐藏 | | `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 |
| `readonly` | 是 | `1` 只读 / `0` 可写(业务语义) | | `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 |
### `_jiangchang_columns` 禁止:
- 使用中文真实表名
- 使用 `_schema_meta` 作为新的正式契约
- 只写英文物理名而不写中文 `display_name`
- 让元数据引用不存在的表
### `_jiangchang_columns` 字段级标准
| 字段 | 必填 | 说明 | | 字段 | 必填 | 说明 |
|------|------|------| |------|------|------|
| `table_name` | 是 | 物理表 | | `table_name` | 是 | 所属物理表 |
| `column_name` | 是 | 物理字段名 | | `column_name` | 是 | 真实英文字段名 |
| `display_name` | 是 | 中文字段名(用户可见) | | `display_name` | 是 | 用户可见的中文字段名 |
| `description` | 否 | 字段说明 | | `description` | 否 | 字段说明 |
| `display_order` | 否 | **遗留兼容字段;模板写入时固定 NULL重复初始化会清理历史非空值不得用于调整展示顺序** | | `display_order` | 否 | 兼容字段,默认保持 `NULL` |
| `visible` | 是 | 是否展示 | | `visible` | 是 | 是否在数据管理表格中展示 |
| `searchable` | 是 | 是否搜索 | | `searchable` | 是 | 是否参与搜索 |
| `editable` | 是 | 是否可编辑(与宿主权限结合) | | `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 |
| `display_type` | 否 | 如 `text` / `textarea` / `datetime_unix_seconds`(标准时间字段见下文 | | `display_type` | 否 | 字段渲染类型(`text``textarea``datetime_unix_seconds` |
| `options_json` | 否 | 枚举选项 JSON | | `options_json` | 否 | 枚举选项 JSON |
这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。 这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。
写入应**幂等**`INSERT … ON CONFLICT DO UPDATE`),数据库升级时同步维护元数据。 ## editable 与 createable 的关系
## 业务字段物理顺序 技能模板只维护 `editable` 这一份字段权限声明:
用户在「数据管理」中看到的列顺序 = `CREATE TABLE` 定义顺序 = `PRAGMA table_info``cid` 顺序。 ```text
**宿主不会**按英文字段名、`display_order` 或语义规则重排。 skill metadata:
editable
host runtime:
createable / editable
```
宿主根据以下因素计算字段是否能够出现在新增或编辑表单中:
- 表级 `readonly`
- 字段 `editable`
- 主键
- generated 字段
- sensitive 字段
- BLOB 类型
- not-null 约束
- default value
通用规则:
```text
新增表单:
editable = 1
且不是主键
且不是 generated
且不是敏感字段
且不是 BLOB
编辑表单:
editable = 1
且不是主键
且不是 generated
且不是敏感字段
且不是 BLOB
```
若一张表:
```text
readonly = 0
但所有字段 editable = 0
```
则:
- 这张表技术上可写,但不适合手工新增或编辑
- 宿主不应打开空白新增表单
- 技能应通过自身业务流程产生数据
- 这不是宿主猜测业务,而是技能通过元数据声明的结果
## 系统字段标准
通常保持 `editable = 0` 的字段类型包括:
- 自增主键
- generated 字段
- `created_at` / `updated_at`
- 同步时间戳
- 内容哈希
- 计算结果
- 内部状态
- 删除标记
- 审计字段
- 敏感凭据或密钥字段
不要把具体业务字段名写死;按字段类型和职责设置 `editable`
## 表结构标准
- 业务表和字段使用英文 `snake_case`
- 可写表优先使用单字段主键
- 自增主键优先放在 `CREATE TABLE` 第一列
- `created_at``updated_at` 放在业务字段末尾
- 字段展示顺序以 `CREATE TABLE``PRAGMA table_info(cid)` 为准
- 不依赖 `display_order` 重新排列字段
- 不要让宿主通过英文字段名推断展示顺序
- 复杂字段需要通过 `display_type``visible` 明确处理
推荐顺序(可按业务裁剪,但已有字段应遵守相对位置): 推荐顺序(可按业务裁剪,但已有字段应遵守相对位置):
@@ -90,6 +204,7 @@ metadata:
4. 状态、结果、错误等辅助字段 4. 状态、结果、错误等辅助字段
5. created_at如有 5. created_at如有
6. updated_at如有 6. updated_at如有
```
## 标准时间字段(新技能统一规范) ## 标准时间字段(新技能统一规范)
@@ -101,7 +216,7 @@ metadata:
| 存储格式 | Unix 秒级整数 | | 存储格式 | Unix 秒级整数 |
| 展示元数据 `display_type` | `datetime_unix_seconds` | | 展示元数据 `display_type` | `datetime_unix_seconds` |
| 中文名 | `created_at`**创建时间**`updated_at`**更新时间** | | 中文名 | `created_at`**创建时间**`updated_at`**更新时间** |
| 用户编辑 | `editable = 0`(系统字段,宿主只展示格式化结果,不写回字符串 | | 用户编辑 | `editable = 0`(系统字段,宿主只展示格式化结果) |
| 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` | | 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` |
| 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` | | 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` |
@@ -109,7 +224,7 @@ metadata:
- **created_at**`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。 - **created_at**`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。
- **updated_at**`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。 - **updated_at**`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。
- **updated_at 更新**:模板默认表 `task_logs` 通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新;若技能所有 UPDATE 都经过统一 repository也可在 repository 层设置 `updated_at = unixepoch()`**不要**叠加两套机制 - **updated_at 更新**:模板默认表 `task_logs` 通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新。
实现参考: 实现参考:
@@ -117,12 +232,20 @@ metadata:
- 中文元数据种子:`scripts/db/display_metadata.py` - 中文元数据种子:`scripts/db/display_metadata.py`
- 自检:`scripts/db/display_metadata_validator.py` - 自检:`scripts/db/display_metadata_validator.py`
宿主(匠厂)只负责把 `display_type = datetime_unix_seconds` 格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不修改**数据库原始值。 宿主只负责把 `display_type = datetime_unix_seconds` 格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不修改**数据库原始值。
> `datetime_unix_milliseconds`、`datetime_iso` 是宿主兼容能力,**不是**新技能模板推荐标准。
### 模板默认:`task_logs` ### 模板默认:`task_logs`
`task_logs` 是技能内部**系统自动写入**的任务审计日志(由 `task_logs_repository` INSERT/UPDATE不是供用户在宿主数据管理中手工维护的业务表。
权限设计:
| 层级 | 值 | 说明 |
|------|-----|------|
| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 |
| 所有业务字段 `editable` | `0` | 含 `task_type``status``error_msg``result_summary` 等 |
| 技能内部 repository | 正常 INSERT/UPDATE | 元数据权限只约束宿主 UI不限制技能代码写库 |
物理定义(权威顺序): 物理定义(权威顺序):
```sql ```sql
@@ -140,33 +263,112 @@ CREATE TABLE IF NOT EXISTS task_logs (
); );
``` ```
插入时可省略 `created_at``updated_at``updated_at` 在 UPDATE 时由 trigger `task_logs_set_updated_at` 自动维护(见 `scripts/db/timestamp_columns.py`)。
对应中文展示(由 `init_db()` 写入元数据表): 对应中文展示(由 `init_db()` 写入元数据表):
| 物理字段 | 中文名 | | 物理字段 | 中文名 | editable |
|----------|--------| |----------|--------|----------|
| id | 编号 | | id | 编号 | 0 |
| task_type | 任务类型 | | task_type | 任务类型 | 0 |
| target_id | 目标编号 | | target_id | 目标编号 | 0 |
| input_id | 输入编号 | | input_id | 输入编号 | 0 |
| input_title | 输入标题 | | input_title | 输入标题 | 0 |
| status | 状态 | | status | 状态 | 0 |
| error_msg | 错误信息 | | error_msg | 错误信息 | 0 |
| result_summary | 结果摘要 | | result_summary | 结果摘要 | 0 |
| created_at | 创建时间`display_type = datetime_unix_seconds`,不可编辑) | | created_at | 创建时间 | 0 |
| updated_at | 更新时间`display_type = datetime_unix_seconds`,不可编辑) | | updated_at | 更新时间 | 0 |
表中文名:**任务日志**。 表中文名:**任务日志**;表级 `readonly = 1`(宿主只读;技能 repository 仍可写入)
禁止事项: ### 通用示例:`demo_items`(测试 fixture 参考)
- 不要把 `created_at` 放在第一列; 以下表结构用于模板测试演示数据管理契约,**不是**模板默认业务表:
- 不要把 `id` 随意放在中间;
- 不要指望宿主按英文字段名排序;
- 若需调整历史表字段顺序,应通过规范迁移**重建表**,而不是在宿主侧配置掩盖。
复合主键或特殊表结构,请在本文档或技能自有 SCHEMA 补充中说明例外。 ```sql
CREATE TABLE demo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft',
generated_value TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
```
对应元数据要点:
| 物理字段 | editable | 说明 |
|----------|----------|------|
| id | 0 | 自增主键 |
| title | 1 | 业务字段 |
| description | 1 | 业务字段 |
| status | 1 | 枚举,`options_json` 演示 |
| generated_value | 0 | 系统/计算字段 |
| created_at | 0 | 系统时间 |
| updated_at | 0 | 系统时间 |
## 枚举字段 options_json
`options_json` 为 JSON 数组,每项包含 `value``label`
```json
[
{"value": "draft", "label": "草稿"},
{"value": "published", "label": "已发布"},
{"value": "archived", "label": "已归档"}
]
```
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
## 幂等初始化标准
- `_jiangchang_tables``_jiangchang_columns` 必须通过 upsert 初始化
- 多次执行初始化不能重复记录
- 重新初始化可以更新展示名、描述、`visible``searchable``editable``display_type``options_json`
- 元数据不能残留旧字段(删除或重命名字段时必须同步清理)
- 元数据不能引用不存在的表或字段
- 新增字段或表时必须同步增加元数据
- 不要直接修改用户真实数据库作为迁移方式
实现 helper
- `upsert_table_metadata` / `upsert_column_metadata` — 幂等写入
- `seed_column_metadata_batch` — 批量 upsert**可选**传入 `valid_column_names` 时在 upsert 后自动 prune
- `prune_stale_column_metadata` — 清理已删除物理字段的元数据
### 新增业务表时如何初始化
1.`connection.py`(或迁移脚本)中 `CREATE TABLE` 真实物理表。
2.`PRAGMA table_info``list_physical_column_names` 取得**真实物理字段列表**(顺序以 `cid` 为准)。
3. 调用 `upsert_table_metadata` 写入表级元数据。
4. 调用 `seed_column_metadata_batch`,并传入 `valid_column_names=physical_column_names`
```python
from db.display_metadata import list_physical_column_names, seed_column_metadata_batch, upsert_table_metadata
physical = list_physical_column_names(cursor, "demo_items")
upsert_table_metadata(cursor, table_name="demo_items", display_name="示例记录", readonly=0)
seed_column_metadata_batch(
cursor,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=physical,
)
```
传入 `valid_column_names` 时:先 upsert再删除不在物理表中的陈旧 `_jiangchang_columns` 行。
### 字段删除或重命名时如何清理
1. 在 DDL / 迁移中完成物理字段删除或重命名。
2. 重新执行 `seed_column_metadata_batch(..., valid_column_names=当前物理字段列表)`,自动 prune 旧字段元数据。
3. 或单独调用 `prune_stale_column_metadata(cursor, table_name, valid_column_names)`
**不传入 `valid_column_names` 时不会自动 prune**——模板不会猜测真实物理字段,避免误删;文档与实现均以此为准。
不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。
## 中文命名质量 ## 中文命名质量
@@ -188,17 +390,19 @@ CREATE TABLE IF NOT EXISTS task_logs (
|----------|-----------|-----------|----------| |----------|-----------|-----------|----------|
| 发布类 | publish | 账号 ID | 内容 ID | | 发布类 | publish | 账号 ID | 内容 ID |
| 工资代发 | disburse | 付款账户 | 批次 ID | | 工资代发 | disburse | 付款账户 | 批次 ID |
| 对账 | reconcile | 银行/平台 | 对账批次 ID | | 对账 | reconcile | 平台标识 | 对账批次 ID |
| 发票验真 | verify | 税务地区 | 发票批次 ID | | 发票验真 | verify | 税务地区 | 发票批次 ID |
业务特有字段优先放入 `result_summary`JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。 业务特有字段优先放入 `result_summary`JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。
## 开发自检 ## 开发自检
- `init_db()` 后运行 `tests/test_display_metadata.py` - `init_db()` 后运行 `tests/test_display_metadata.py``tests/test_data_management_contract.py`
- 若提供宿主 Action 入口,运行 `tests/test_actions_manifest.py`
- 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。 - 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。
## 模板原则 ## 模板原则
- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。 - 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。
- 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。 - 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。
- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。

View File

@@ -47,20 +47,20 @@ CREATE TABLE IF NOT EXISTS _jiangchang_columns (
TASK_LOGS_TABLE_METADATA = { TASK_LOGS_TABLE_METADATA = {
"table_name": TASK_LOGS_TABLE, "table_name": TASK_LOGS_TABLE,
"display_name": "任务日志", "display_name": "任务日志",
"description": "记录技能任务的执行状态和结果", "description": "记录技能任务的执行状态和结果(系统自动写入,宿主数据管理只读展示)",
"visible": 1, "visible": 1,
"readonly": 0, "readonly": 1,
} }
TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "description": "任务日志唯一编号", "visible": 1, "searchable": 1, "editable": 0}, {"column_name": "id", "display_name": "编号", "description": "任务日志唯一编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 1}, {"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 1}, {"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 1}, {"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 1}, {"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 1}, {"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 1}, {"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 1}, {"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 0},
{ {
"column_name": "created_at", "column_name": "created_at",
"display_name": "创建时间", "display_name": "创建时间",
@@ -113,15 +113,16 @@ def upsert_column_metadata(
searchable: int = 1, searchable: int = 1,
editable: int = 1, editable: int = 1,
display_type: str | None = None, display_type: str | None = None,
options_json: str | None = None,
) -> None: ) -> None:
# display_order 保留为 NULL宿主按 PRAGMA table_info cid 展示字段,不读 display_order。 # display_order 保留为 NULL宿主按 PRAGMA table_info cid 展示字段,不读 display_order。
cursor.execute( cursor.execute(
""" """
INSERT INTO _jiangchang_columns ( INSERT INTO _jiangchang_columns (
table_name, column_name, display_name, description, table_name, column_name, display_name, description,
display_order, visible, searchable, editable, display_type display_order, visible, searchable, editable, display_type, options_json
) )
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
ON CONFLICT(table_name, column_name) DO UPDATE SET ON CONFLICT(table_name, column_name) DO UPDATE SET
display_name = excluded.display_name, display_name = excluded.display_name,
description = excluded.description, description = excluded.description,
@@ -129,9 +130,20 @@ def upsert_column_metadata(
visible = excluded.visible, visible = excluded.visible,
searchable = excluded.searchable, searchable = excluded.searchable,
editable = excluded.editable, editable = excluded.editable,
display_type = excluded.display_type display_type = excluded.display_type,
options_json = excluded.options_json
""", """,
(table_name, column_name, display_name, description, visible, searchable, editable, display_type), (
table_name,
column_name,
display_name,
description,
visible,
searchable,
editable,
display_type,
options_json,
),
) )
@@ -145,18 +157,104 @@ def seed_task_logs_display_metadata(cursor) -> None:
visible=int(meta["visible"]), visible=int(meta["visible"]),
readonly=int(meta["readonly"]), readonly=int(meta["readonly"]),
) )
for column in TASK_LOGS_COLUMN_METADATA: seed_column_metadata_batch(
upsert_column_metadata(
cursor, cursor,
table_name=TASK_LOGS_TABLE, table_name=TASK_LOGS_TABLE,
columns=TASK_LOGS_COLUMN_METADATA,
valid_column_names=[str(column["column_name"]) for column in TASK_LOGS_COLUMN_METADATA],
)
def seed_column_metadata_batch(
cursor,
*,
table_name: str,
columns: Iterable[dict[str, object]],
valid_column_names: Iterable[str] | None = None,
) -> None:
"""批量 upsert 字段元数据;传入 valid_column_names 时会在 upsert 后清理陈旧字段元数据。"""
for column in columns:
upsert_column_metadata(
cursor,
table_name=table_name,
column_name=str(column["column_name"]), column_name=str(column["column_name"]),
display_name=str(column["display_name"]), display_name=str(column["display_name"]),
description=str(column["description"]) if column.get("description") else None, description=str(column["description"]) if column.get("description") else None,
visible=int(column["visible"]), visible=int(column.get("visible", 1)),
searchable=int(column["searchable"]), searchable=int(column.get("searchable", 1)),
editable=int(column["editable"]), editable=int(column.get("editable", 1)),
display_type=str(column["display_type"]) if column.get("display_type") else None, display_type=str(column["display_type"]) if column.get("display_type") else None,
options_json=str(column["options_json"]) if column.get("options_json") else None,
) )
if valid_column_names is not None:
prune_stale_column_metadata(cursor, table_name, valid_column_names)
def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None:
"""删除表中已不存在物理字段的元数据行(幂等初始化后不得残留旧字段)。"""
valid = set(valid_column_names)
cursor.execute(
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
(table_name,),
)
for (column_name,) in cursor.fetchall():
if str(column_name) not in valid:
cursor.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?",
(table_name, column_name),
)
def get_table_readonly(cursor, table_name: str) -> int | None:
cursor.execute(
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cursor.fetchone()
if not row:
return None
return int(row[0] or 0)
def get_column_editable(cursor, table_name: str, column_name: str) -> int | None:
cursor.execute(
"""
SELECT editable FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column_name),
)
row = cursor.fetchone()
if not row:
return None
return int(row[0] or 0)
def list_editable_business_columns(cursor, table_name: str) -> list[str]:
"""返回 editable=1 的业务字段名(不含宿主侧主键/generated 等二次过滤)。"""
physical = list_physical_column_names(cursor, table_name)
cursor.execute(
"""
SELECT column_name, editable
FROM _jiangchang_columns
WHERE table_name = ?
""",
(table_name,),
)
editable_set = {
str(column_name)
for column_name, editable in cursor.fetchall()
if editable is not None and int(editable) == 1
}
return [name for name in physical if name in editable_set]
def table_allows_manual_create(cursor, table_name: str) -> bool:
"""表级 readonly=0 且至少有一个 editable=1 字段时,宿主才应提供空白新增表单。"""
readonly = get_table_readonly(cursor, table_name)
if readonly is None or int(readonly) != 0:
return False
return bool(list_editable_business_columns(cursor, table_name))
def init_display_metadata(cursor) -> None: def init_display_metadata(cursor) -> None:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import re import re
import sqlite3 import sqlite3
@@ -14,7 +15,9 @@ from db.display_metadata import (
TASK_LOGS_COLUMN_METADATA, TASK_LOGS_COLUMN_METADATA,
TASK_LOGS_TABLE, TASK_LOGS_TABLE,
iter_user_tables, iter_user_tables,
list_editable_business_columns,
list_physical_column_names, list_physical_column_names,
table_allows_manual_create,
) )
from db.timestamp_columns import has_updated_at_trigger from db.timestamp_columns import has_updated_at_trigger
from util.constants import SKILL_SLUG from util.constants import SKILL_SLUG
@@ -158,6 +161,33 @@ def _table_column_info(cursor, table_name: str) -> dict[str, dict[str, object]]:
return info return info
def _table_column_xinfo(cursor, table_name: str) -> dict[str, dict[str, object]]:
"""PRAGMA table_xinfo 可识别 generated/hidden 字段SQLite 3.31+)。"""
cursor.execute(f"PRAGMA table_xinfo({_quote_identifier(table_name)})")
info: dict[str, dict[str, object]] = {}
for row in cursor.fetchall():
hidden = int(row[6] or 0) if len(row) > 6 else 0
info[str(row[1])] = {
"cid": int(row[0]),
"name": str(row[1]),
"type": str(row[2] or ""),
"notnull": int(row[3] or 0),
"dflt_value": row[4],
"pk": int(row[5] or 0),
"hidden": hidden,
}
return info
def _is_generated_column(xinfo: dict[str, object]) -> bool:
hidden = int(xinfo.get("hidden") or 0)
return hidden in (2, 3)
def _is_blob_column(column_info: dict[str, object]) -> bool:
return "BLOB" in str(column_info.get("type") or "").upper()
def _default_uses_unixepoch(default_value: object) -> bool: def _default_uses_unixepoch(default_value: object) -> bool:
if default_value is None: if default_value is None:
return False return False
@@ -165,6 +195,100 @@ def _default_uses_unixepoch(default_value: object) -> bool:
return "unixepoch" in text return "unixepoch" in text
def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]:
"""校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。"""
errors: list[str] = []
warnings: list[str] = []
if options_json is None or str(options_json).strip() == "":
return errors, warnings
try:
parsed = json.loads(str(options_json))
except json.JSONDecodeError as exc:
errors.append(f"options_json 不是合法 JSON{exc}")
return errors, warnings
if not isinstance(parsed, list):
errors.append("options_json 必须是 JSON 数组")
return errors, warnings
for index, item in enumerate(parsed):
if not isinstance(item, dict):
errors.append(f"options_json[{index}] 必须是对象")
continue
if "value" not in item or "label" not in item:
errors.append(f"options_json[{index}] 必须包含 value 与 label")
return errors, warnings
def validate_column_permissions(
cursor,
table_name: str,
*,
physical_columns: list[str] | None = None,
) -> tuple[list[str], list[str]]:
"""校验常见系统字段 editable=0并识别技术上可写但不适合手工新增的表。"""
errors: list[str] = []
warnings: list[str] = []
ordered = physical_columns or list_physical_column_names(cursor, table_name)
column_info = _table_column_info(cursor, table_name)
column_xinfo = _table_column_xinfo(cursor, table_name)
cursor.execute(
"""
SELECT column_name, editable, options_json
FROM _jiangchang_columns
WHERE table_name = ?
""",
(table_name,),
)
meta_by_column = {
str(row[0]): {"editable": row[1], "options_json": row[2]}
for row in cursor.fetchall()
}
for column_name, meta in meta_by_column.items():
physical = column_info.get(column_name) or {}
xinfo = column_xinfo.get(column_name) or physical
if not physical and column_name not in column_xinfo:
continue
pk = int(physical.get("pk") or xinfo.get("pk") or 0)
editable = meta["editable"]
if pk and editable is not None and int(editable) != 0:
errors.append(f"{table_name}.{column_name} 是主键editable 应为 0")
if column_name == "id" and editable is not None and int(editable) != 0:
errors.append(f"{table_name}.id 的 editable 应为 0自增主键")
if _is_generated_column(xinfo) and editable is not None and int(editable) != 0:
errors.append(f"{table_name}.{column_name} 是 generated 字段editable 应为 0")
if _is_blob_column(physical or xinfo) and editable is not None and int(editable) != 0:
errors.append(f"{table_name}.{column_name} 是 BLOB 字段editable 应为 0")
opt_errors, _ = validate_options_json(meta.get("options_json"))
for item in opt_errors:
errors.append(f"{table_name}.{column_name}: {item}")
cursor.execute(
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
readonly_row = cursor.fetchone()
readonly = int(readonly_row[0]) if readonly_row else None
editable_columns = list_editable_business_columns(cursor, table_name)
if readonly == 0 and not editable_columns:
warnings.append(
f"{table_name}: 表级 readonly=0 但所有字段 editable=0"
"宿主不应打开空白新增表单;数据应由技能业务流程产生"
)
if readonly == 1 and editable_columns:
warnings.append(
f"{table_name}: 表级 readonly=1 但存在 editable=1 字段,"
"宿主应以表级只读为准"
)
return errors, warnings
def _expected_timestamp_display_name(column_name: str) -> str: def _expected_timestamp_display_name(column_name: str) -> str:
if column_name == "created_at": if column_name == "created_at":
return "创建时间" return "创建时间"
@@ -338,6 +462,14 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
report.errors.extend(ts_errors) report.errors.extend(ts_errors)
report.warnings.extend(ts_warnings) report.warnings.extend(ts_warnings)
perm_errors, perm_warnings = validate_column_permissions(
cur,
table_name,
physical_columns=physical_columns,
)
report.errors.extend(perm_errors)
report.warnings.extend(perm_warnings)
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns") cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
for table_name, column_name in cur.fetchall(): for table_name, column_name in cur.fetchall():
if table_name in METADATA_TABLES: if table_name in METADATA_TABLES:

View File

@@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
"""assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。"""
from __future__ import annotations
import json
import os
import re
import unittest
from _support import get_skill_root
from cli.app import build_parser
from util.constants import SKILL_SLUG
_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"})
_RESERVED_PLACEMENTS = frozenset({"row", "batch"})
_RESERVED_ACTION_FIELDS = frozenset({"bind", "concurrency", "locks"})
_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"})
_REQUIRED_ACTION_KEYS = frozenset({"id", "label", "description", "placements", "entrypoint"})
_FORBIDDEN_BUSINESS_TERMS = (
"wechat",
"微信",
"contacts",
"wxid",
"account_wxid",
"contact.db",
"scrape-contacts",
"douyin",
"抖音",
"xiaohongshu",
"小红书",
"公众号",
"logistics",
"物流",
)
_TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}")
def _load_actions_manifest() -> dict:
path = os.path.join(get_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
def _cli_subcommands() -> set[str]:
parser = build_parser()
subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined]
return set(subs.keys())
def _collect_template_vars(args: list) -> set[str]:
found: set[str] = set()
for item in args:
if not isinstance(item, str):
continue
for match in _TEMPLATE_ARG_PATTERN.finditer(item):
found.add(match.group(1))
return found
def _load_actions_schema() -> dict:
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
class TestActionsManifest(unittest.TestCase):
def test_manifest_exists_and_schema(self) -> None:
manifest = _load_actions_manifest()
self.assertEqual(manifest.get("schemaVersion"), 1)
skill = manifest.get("skill")
self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG))
self.assertIsInstance(manifest.get("actions"), list)
self.assertGreater(len(manifest["actions"]), 0)
def test_action_ids_unique_and_cli_valid(self) -> None:
manifest = _load_actions_manifest()
subs = _cli_subcommands()
seen: set[str] = set()
for action in manifest["actions"]:
action_id = action["id"]
self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}")
seen.add(action_id)
self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty")
self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty")
entry = action["entrypoint"]
self.assertEqual(entry["type"], "cli")
self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI")
args = entry.get("args")
self.assertIsInstance(args, list, msg=f"{action_id} args must be array")
for item in args:
self.assertIsInstance(
item,
(str, int, float, bool),
msg=f"{action_id} arg item must be scalar",
)
self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object")
self.assertIsNotNone(item, msg=f"{action_id} arg must not be null")
placements = action.get("placements")
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
for placement in placements:
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
def test_template_example_does_not_use_reserved_placements(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
placements = set(action.get("placements") or [])
overlap = placements & _RESERVED_PLACEMENTS
self.assertFalse(
overlap,
msg=f"{action['id']} uses reserved placements not yet supported in template: {overlap}",
)
def test_template_variables_have_input_schema(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
args = action.get("entrypoint", {}).get("args") or []
template_vars = _collect_template_vars(args)
if not template_vars:
continue
schema = action.get("inputSchema") or {}
properties = schema.get("properties") or {}
for var in template_vars:
self.assertIn(
var,
properties,
msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it",
)
def test_required_fields_exist_in_properties(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
schema = action.get("inputSchema") or {}
required = schema.get("required") or []
properties = schema.get("properties") or {}
for field in required:
self.assertIn(
field,
properties,
msg=f"{action['id']} required field {field!r} missing from properties",
)
def test_sensitive_params_not_on_toolbar(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
props = (action.get("inputSchema") or {}).get("properties") or {}
has_sensitive = any(p.get("sensitive") for p in props.values())
if has_sensitive and "toolbar" in (action.get("placements") or []):
self.fail(f"{action['id']} has sensitive input but is on toolbar")
def test_confirmation_message_structure(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
confirmation = action.get("confirmation")
if confirmation is None:
continue
self.assertIsInstance(confirmation, dict)
message = confirmation.get("message")
self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty")
def test_manifest_has_no_specific_business_skill_names(self) -> None:
path = os.path.join(get_skill_root(), "assets", "actions.json")
with open(path, encoding="utf-8") as f:
raw = f.read().lower()
for term in _FORBIDDEN_BUSINESS_TERMS:
self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}")
def test_actions_md_exists_and_links_manifest(self) -> None:
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
self.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("actions.json", text)
self.assertIn("schemaVersion", text)
for term in _FORBIDDEN_BUSINESS_TERMS:
self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}")
def test_skill_actions_schema_file_exists(self) -> None:
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
self.assertTrue(os.path.isfile(path))
with open(path, encoding="utf-8") as f:
schema = json.load(f)
self.assertEqual(schema.get("title"), "SkillActionManifest")
self.assertIn("actions", schema.get("properties", {}))
def test_skill_actions_schema_json_parses(self) -> None:
schema = _load_actions_schema()
self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1)
self.assertTrue(schema.get("additionalProperties") is False)
self.assertIn("action", schema.get("$defs", {}))
def test_template_manifest_matches_schema_required_keys(self) -> None:
manifest = _load_actions_manifest()
schema = _load_actions_schema()
for key in _REQUIRED_MANIFEST_KEYS:
self.assertIn(key, manifest)
self.assertIn(key, schema.get("properties", {}))
for action in manifest["actions"]:
for key in _REQUIRED_ACTION_KEYS:
self.assertIn(key, action)
def test_template_example_does_not_use_reserved_action_fields(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS
self.assertFalse(
overlap,
msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}",
)
def test_actions_md_documents_strict_vs_host_boundary(self) -> None:
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
with open(path, encoding="utf-8") as f:
text = f.read()
for marker in (
"严格规范",
"向后兼容",
"Phase 1",
"bind",
"concurrency",
"locks",
"row",
"batch",
"additionalProperties",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,479 @@
# -*- coding: utf-8 -*-
"""通用数据管理契约测试demo_items fixture不含具体业务技能内容"""
from __future__ import annotations
import json
import sqlite3
import unittest
from _support import IsolatedDataRoot
DEMO_ITEMS_DDL = """
CREATE TABLE IF NOT EXISTS demo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'draft',
generated_value TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TRIGGER IF NOT EXISTS demo_items_set_updated_at
AFTER UPDATE ON demo_items
FOR EACH ROW
BEGIN
UPDATE demo_items
SET updated_at = unixepoch()
WHERE id = NEW.id AND updated_at = OLD.updated_at;
END;
"""
DEMO_ITEMS_TABLE_METADATA = {
"table_name": "demo_items",
"display_name": "示例记录",
"description": "通用数据管理契约演示表",
"visible": 1,
"readonly": 0,
}
DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "title", "display_name": "标题", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "description", "display_name": "描述", "visible": 1, "searchable": 1, "editable": 1},
{
"column_name": "status",
"display_name": "状态",
"visible": 1,
"searchable": 1,
"editable": 1,
"options_json": json.dumps(
[
{"value": "draft", "label": "草稿"},
{"value": "published", "label": "已发布"},
{"value": "archived", "label": "已归档"},
],
ensure_ascii=False,
),
},
{"column_name": "generated_value", "display_name": "生成值", "visible": 1, "searchable": 0, "editable": 0},
{
"column_name": "created_at",
"display_name": "创建时间",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
{
"column_name": "updated_at",
"display_name": "更新时间",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
)
def _init_demo_items_fixture(conn: sqlite3.Connection, *, prune: bool = True) -> None:
from db.display_metadata import (
init_display_metadata_tables,
list_physical_column_names,
seed_column_metadata_batch,
upsert_table_metadata,
)
cur = conn.cursor()
init_display_metadata_tables(cur)
cur.executescript(DEMO_ITEMS_DDL)
meta = DEMO_ITEMS_TABLE_METADATA
upsert_table_metadata(
cur,
table_name=str(meta["table_name"]),
display_name=str(meta["display_name"]),
description=str(meta["description"]),
visible=int(meta["visible"]),
readonly=int(meta["readonly"]),
)
physical = list_physical_column_names(cur, "demo_items")
seed_column_metadata_batch(
cur,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=physical if prune else None,
)
conn.commit()
class TestDataManagementContract(unittest.TestCase):
def test_demo_items_metadata_permissions(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
from db.display_metadata import (
get_column_editable,
get_table_readonly,
list_editable_business_columns,
table_allows_manual_create,
)
cur = conn.cursor()
self.assertEqual(get_table_readonly(cur, "demo_items"), 0)
self.assertEqual(get_column_editable(cur, "demo_items", "id"), 0)
self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1)
self.assertEqual(get_column_editable(cur, "demo_items", "generated_value"), 0)
self.assertEqual(get_column_editable(cur, "demo_items", "created_at"), 0)
self.assertEqual(get_column_editable(cur, "demo_items", "updated_at"), 0)
editable_cols = list_editable_business_columns(cur, "demo_items")
self.assertEqual(editable_cols, ["title", "description", "status"])
self.assertTrue(table_allows_manual_create(cur, "demo_items"))
finally:
conn.close()
def test_demo_items_init_idempotent_no_duplicates(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
_init_demo_items_fixture(conn)
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'demo_items'")
self.assertEqual(int(cur.fetchone()[0]), 1)
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'demo_items'")
self.assertEqual(int(cur.fetchone()[0]), 7)
finally:
conn.close()
def test_reinit_updates_stale_editable_value(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"UPDATE _jiangchang_columns SET editable = 0 WHERE table_name = 'demo_items' AND column_name = 'title'"
)
conn.commit()
_init_demo_items_fixture(conn)
from db.display_metadata import get_column_editable
self.assertEqual(get_column_editable(conn.cursor(), "demo_items", "title"), 1)
finally:
conn.close()
def test_missing_table_metadata_detected(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'")
conn.commit()
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("demo_items" in err for err in report.errors))
finally:
conn.close()
def test_missing_column_metadata_detected(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'"
)
conn.commit()
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("demo_items.title" in err for err in report.errors))
finally:
conn.close()
def test_metadata_reference_to_missing_column_detected(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('demo_items', 'not_exists', '不存在', 1, 1, 0)
"""
)
conn.commit()
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("not_exists" in err for err in report.errors))
finally:
conn.close()
def test_empty_display_name_detected_for_visible_column(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"UPDATE _jiangchang_columns SET display_name = '' WHERE table_name = 'demo_items' AND column_name = 'title'"
)
conn.commit()
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("display_name 为空" in err for err in report.errors))
finally:
conn.close()
def test_visible_columns_use_chinese_display_name(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
cur = conn.cursor()
cur.execute(
"""
SELECT column_name, display_name
FROM _jiangchang_columns
WHERE table_name = 'demo_items' AND visible = 1
"""
)
for column_name, display_name in cur.fetchall():
self.assertTrue(
any("\u4e00" <= ch <= "\u9fff" for ch in str(display_name)),
msg=f"{column_name} display_name should be Chinese: {display_name!r}",
)
finally:
conn.close()
def test_timestamp_fields_type_default_and_editable(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
cur = conn.cursor()
for column_name in ("created_at", "updated_at"):
cur.execute(f"PRAGMA table_info('demo_items')")
info = {row[1]: row for row in cur.fetchall()}
row = info[column_name]
self.assertIn("INT", str(row[2]).upper())
self.assertIn("unixepoch", str(row[4]).lower())
cur.execute(
"""
SELECT editable, display_type FROM _jiangchang_columns
WHERE table_name = 'demo_items' AND column_name = ?
""",
(column_name,),
)
editable, display_type = cur.fetchone()
self.assertEqual(int(editable), 0)
self.assertEqual(display_type, "datetime_unix_seconds")
finally:
conn.close()
def test_physical_order_matches_pragma_cid(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
from db.display_metadata import list_physical_column_names
cur = conn.cursor()
expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA]
physical = list_physical_column_names(cur, "demo_items")
self.assertEqual(physical, expected)
cur.execute("PRAGMA table_info('demo_items')")
cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))]
self.assertEqual(cid_order, expected)
finally:
conn.close()
def test_options_json_parses_for_enum_field(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
cur = conn.cursor()
cur.execute(
"""
SELECT options_json FROM _jiangchang_columns
WHERE table_name = 'demo_items' AND column_name = 'status'
"""
)
raw = cur.fetchone()[0]
from db.display_metadata_validator import validate_options_json
errors, _ = validate_options_json(raw)
self.assertEqual(errors, [])
parsed = json.loads(raw)
self.assertEqual(len(parsed), 3)
self.assertEqual(parsed[0]["value"], "draft")
finally:
conn.close()
def test_system_table_no_editable_fields_not_manual_create(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
seed_column_metadata_batch,
table_allows_manual_create,
upsert_table_metadata,
)
cur = conn.cursor()
init_display_metadata_tables(cur)
cur.execute(
"""
CREATE TABLE source_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
synced_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
)
upsert_table_metadata(
cur,
table_name="source_records",
display_name="来源记录",
description="只由同步流程写入",
visible=1,
readonly=0,
)
seed_column_metadata_batch(
cur,
table_name="source_records",
columns=(
{"column_name": "id", "display_name": "编号", "editable": 0},
{"column_name": "item_id", "display_name": "记录编号", "editable": 0},
{"column_name": "synced_at", "display_name": "同步时间", "editable": 0, "display_type": "datetime_unix_seconds"},
),
)
conn.commit()
self.assertFalse(table_allows_manual_create(cur, "source_records"))
finally:
conn.close()
def test_table_readonly_blocks_manual_create_even_with_editable_fields(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute("UPDATE _jiangchang_tables SET readonly = 1 WHERE table_name = 'demo_items'")
conn.commit()
from db.display_metadata import table_allows_manual_create
self.assertFalse(table_allows_manual_create(conn.cursor(), "demo_items"))
finally:
conn.close()
def test_prune_stale_column_metadata(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0)
"""
)
conn.commit()
from db.display_metadata import list_physical_column_names, seed_column_metadata_batch
cur = conn.cursor()
physical = list_physical_column_names(cur, "demo_items")
seed_column_metadata_batch(
cur,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=physical,
)
conn.commit()
cur.execute(
"SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'"
)
self.assertIsNone(cur.fetchone())
finally:
conn.close()
def test_seed_batch_without_valid_column_names_does_not_prune(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn, prune=False)
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0)
"""
)
conn.commit()
from db.display_metadata import seed_column_metadata_batch
seed_column_metadata_batch(
conn.cursor(),
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
)
conn.commit()
cur = conn.cursor()
cur.execute(
"SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'"
)
self.assertIsNotNone(cur.fetchone())
finally:
conn.close()
def test_seed_batch_accepts_list_columns(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn, prune=False)
from db.display_metadata import get_column_editable, seed_column_metadata_batch
columns = list(DEMO_ITEMS_COLUMN_METADATA)
columns[1] = {**columns[1], "display_name": "标题(列表)"}
seed_column_metadata_batch(
conn.cursor(),
table_name="demo_items",
columns=columns,
valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA],
)
conn.commit()
cur = conn.cursor()
cur.execute(
"SELECT display_name FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'"
)
self.assertEqual(cur.fetchone()[0], "标题(列表)")
self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1)
finally:
conn.close()
def test_prune_does_not_remove_existing_column_metadata(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
from db.display_metadata import get_column_editable, seed_column_metadata_batch
cur = conn.cursor()
before = get_column_editable(cur, "demo_items", "title")
seed_column_metadata_batch(
cur,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA],
)
conn.commit()
self.assertEqual(get_column_editable(cur, "demo_items", "title"), before)
finally:
conn.close()
def test_demo_items_validation_passes(self) -> None:
conn = sqlite3.connect(":memory:")
try:
_init_demo_items_fixture(conn)
from db.display_metadata_validator import validate_display_metadata
report = validate_display_metadata(conn)
self.assertTrue(report.ok, msg="; ".join(report.errors))
finally:
conn.close()
if __name__ == "__main__":
unittest.main()

View File

@@ -2,6 +2,7 @@
"""数据管理展示元数据幂等初始化、中文名称、PRAGMA 字段顺序、校验规则。""" """数据管理展示元数据幂等初始化、中文名称、PRAGMA 字段顺序、校验规则。"""
from __future__ import annotations from __future__ import annotations
import os
import sqlite3 import sqlite3
import unittest import unittest
@@ -35,22 +36,39 @@ class TestDisplayMetadata(unittest.TestCase):
try: try:
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"SELECT display_name FROM _jiangchang_tables WHERE table_name = 'task_logs'" "SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = 'task_logs'"
) )
self.assertEqual(cur.fetchone()[0], "任务日志") display_name, readonly = cur.fetchone()
self.assertEqual(display_name, "任务日志")
self.assertEqual(int(readonly), 1)
cur.execute( cur.execute(
""" """
SELECT column_name, display_name SELECT column_name, display_name, editable
FROM _jiangchang_columns FROM _jiangchang_columns
WHERE table_name = 'task_logs' WHERE table_name = 'task_logs'
ORDER BY column_name ORDER BY column_name
""" """
) )
by_name = dict(cur.fetchall()) by_name = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
self.assertEqual(by_name["status"], "状态") self.assertEqual(by_name["status"][0], "状态")
self.assertEqual(by_name["error_msg"], "错误信息") self.assertEqual(int(by_name["status"][1]), 0)
self.assertEqual(by_name["created_at"], "创建时间") self.assertEqual(by_name["error_msg"][0], "错误信息")
self.assertEqual(int(by_name["error_msg"][1]), 0)
self.assertEqual(by_name["created_at"][0], "创建时间")
self.assertEqual(int(by_name["created_at"][1]), 0)
finally:
conn.close()
def test_task_logs_not_manual_create_in_host_ui(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import table_allows_manual_create
init_db()
conn = get_conn()
try:
self.assertFalse(table_allows_manual_create(conn.cursor(), "task_logs"))
finally: finally:
conn.close() conn.close()
@@ -342,6 +360,153 @@ metadata:
) )
self.assertTrue(any("id" in item for item in warnings)) self.assertTrue(any("id" in item for item in warnings))
def test_generated_column_editable_must_be_zero(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE gen_demo (
id INTEGER PRIMARY KEY,
base_value INTEGER NOT NULL,
generated_value INTEGER GENERATED ALWAYS AS (base_value * 2) STORED
);
"""
)
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_column_metadata(
cur,
table_name="gen_demo",
column_name="generated_value",
display_name="生成值",
editable=1,
)
conn.commit()
errors, _ = validate_column_permissions(cur, "gen_demo")
self.assertTrue(any("generated" in err for err in errors))
finally:
conn.close()
def test_blob_column_editable_must_be_zero(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.execute(
"CREATE TABLE blob_demo (id INTEGER PRIMARY KEY, payload BLOB NOT NULL)"
)
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_column_metadata(
cur,
table_name="blob_demo",
column_name="payload",
display_name="载荷",
editable=1,
)
conn.commit()
errors, _ = validate_column_permissions(cur, "blob_demo")
self.assertTrue(any("BLOB" in err for err in errors))
finally:
conn.close()
def test_primary_key_editable_must_be_zero(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.execute("CREATE TABLE pk_demo (id INTEGER PRIMARY KEY, title TEXT)")
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_column_metadata(
cur,
table_name="pk_demo",
column_name="id",
display_name="编号",
editable=1,
)
conn.commit()
errors, _ = validate_column_permissions(cur, "pk_demo")
self.assertTrue(any("主键" in err for err in errors))
finally:
conn.close()
def test_readonly_table_with_editable_fields_warns(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE warn_demo (id INTEGER PRIMARY KEY, title TEXT);
"""
)
from db.display_metadata import (
init_display_metadata_tables,
upsert_column_metadata,
upsert_table_metadata,
)
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_table_metadata(cur, table_name="warn_demo", display_name="警告演示", readonly=1)
upsert_column_metadata(
cur,
table_name="warn_demo",
column_name="title",
display_name="标题",
editable=1,
)
conn.commit()
_, warnings = validate_column_permissions(cur, "warn_demo")
self.assertTrue(any("readonly=1" in item for item in warnings))
finally:
conn.close()
def test_writable_table_without_editable_fields_warns(self) -> None:
conn = sqlite3.connect(":memory:")
try:
conn.executescript(
"""
CREATE TABLE warn_demo2 (id INTEGER PRIMARY KEY, title TEXT);
"""
)
from db.display_metadata import (
init_display_metadata_tables,
upsert_column_metadata,
upsert_table_metadata,
)
from db.display_metadata_validator import validate_column_permissions
cur = conn.cursor()
init_display_metadata_tables(cur)
upsert_table_metadata(cur, table_name="warn_demo2", display_name="警告演示二", readonly=0)
upsert_column_metadata(
cur,
table_name="warn_demo2",
column_name="title",
display_name="标题",
editable=0,
)
conn.commit()
_, warnings = validate_column_permissions(cur, "warn_demo2")
self.assertTrue(any("readonly=0" in item and "editable=0" in item for item in warnings))
finally:
conn.close()
def test_schema_md_documents_task_logs_readonly_semantics(self) -> None:
path = os.path.join(get_skill_root(), "references", "SCHEMA.md")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("readonly = 1", text)
self.assertIn("task_logs", text)
self.assertIn("seed_column_metadata_batch", text)
self.assertIn("valid_column_names", text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -174,6 +174,120 @@ class TestDocsStandards(unittest.TestCase):
self.assertIn("RpaVideoSession", text) self.assertIn("RpaVideoSession", text)
self.assertIn("1.0.17", text) self.assertIn("1.0.17", text)
def test_rpa_md_forbids_rpa_helpers_import(self) -> None:
text = self._read("development/RPA.md")
self.assertIn("rpa_helpers", text)
self.assertTrue(
"不要 import" in text or "不要" in text and "import" in text,
msg="RPA.md must document forbidding rpa_helpers import",
)
def test_rpa_md_covers_simulator_playwright_layering(self) -> None:
text = self._read("development/RPA.md")
self.assertTrue(
"simulator_playwright" in text
or ("薄 adapter" in text and "playwright.py" in text),
msg="RPA.md must describe thin adapter + *_playwright.py layering",
)
def test_rpa_md_covers_industry_simulator_portal(self) -> None:
text = self._read("development/RPA.md")
self.assertTrue(
any(marker in text for marker in ("jc2009", "行业仿真", "门户")),
msg="RPA.md must mention jc2009 / industry simulator / portal gate",
)
def test_simulator_example_readme_covers_async_and_account_manager(self) -> None:
text = self._read("examples/simulator_browser_rpa/README.md")
self.assertIn("async", text.lower())
self.assertTrue(
"account-manager" in text.lower() or "pick-web" in text or "pick_web_account" in text,
msg="simulator_browser_rpa README must mention async and account-manager",
)
def test_development_md_documents_scaffold_and_git_cleanup(self) -> None:
text = self._read("development/DEVELOPMENT.md")
self.assertTrue(
"scaffold_skill" in text or "scaffold_skill.ps1" in text,
msg="DEVELOPMENT.md must document scaffold_skill",
)
self.assertTrue(
("Remove-Item" in text and ".git" in text)
or ("删除" in text and ".git" in text),
msg="DEVELOPMENT.md must document deleting .git",
)
def test_naming_md_copy_flow_documents_scaffold_or_git_cleanup(self) -> None:
text = self._read("development/NAMING.md")
self.assertTrue(
"scaffold" in text.lower() or ("清理" in text and ".git" in text),
msg="NAMING.md §9 must mention scaffold or Git cleanup",
)
def test_scaffold_ps1_exists(self) -> None:
path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1")
self.assertTrue(os.path.isfile(path), msg="tools/scaffold_skill.ps1 must exist")
def test_actions_manifest_and_docs_exist(self) -> None:
root = get_skill_root()
self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json")))
self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md")))
self.assertTrue(
os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json"))
)
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py")))
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py")))
def test_tools_readme_documents_actions_scaffold(self) -> None:
text = self._read("tools/README.md")
self.assertIn("actions.json", text)
self.assertIn("your-skill-slug", text)
self.assertIn("your_skill_slug", text)
def test_development_md_documents_both_slug_placeholders(self) -> None:
text = self._read("development/DEVELOPMENT.md")
self.assertIn("your-skill-slug", text)
self.assertIn("your_skill_slug", text)
def test_schema_md_documents_data_management_contract(self) -> None:
text = self._read("references/SCHEMA.md")
for marker in (
"_jiangchang_tables",
"_jiangchang_columns",
"editable",
"options_json",
"demo_items",
"readonly",
):
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
forbidden = (
"download-settlement-report-amazon",
"amazon_sim",
"#amz-email",
)
guarded_docs = (
"development/RPA.md",
"development/ADAPTER.md",
"development/DEVELOPMENT.md",
"development/CONFIG.md",
"development/TESTING.md",
"references/SCHEMA.md",
"references/ACTIONS.md",
"tools/README.md",
"examples/README.md",
"examples/simulator_browser_rpa/README.md",
)
for rel in guarded_docs:
text = self._read(rel)
for fragment in forbidden:
self.assertNotIn(
fragment,
text,
msg=f"{rel} must not reference Amazon implementation {fragment!r}",
)
def test_adapter_md_covers_four_tiers_and_config_get(self) -> None: def test_adapter_md_covers_four_tiers_and_config_get(self) -> None:
text = self._read("development/ADAPTER.md") text = self._read("development/ADAPTER.md")
for marker in ( for marker in (

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""禁止在模板主骨架 scripts/ 中 import account-manager 内部 RPA 辅助模块。"""
from __future__ import annotations
import os
import re
import unittest
from _support import get_skill_root
FORBIDDEN_PATTERNS = (
re.compile(r"\brpa_helpers\b"),
re.compile(r"\binject_account_manager_scripts_path\b"),
re.compile(r"\bget_account_credential\b"),
)
class TestNoRpaHelpersImport(unittest.TestCase):
def test_scripts_tree_has_no_forbidden_account_manager_imports(self) -> None:
scripts_dir = os.path.join(get_skill_root(), "scripts")
violations: list[str] = []
for root, _dirs, files in os.walk(scripts_dir):
for name in files:
if not name.endswith(".py"):
continue
rel = os.path.relpath(os.path.join(root, name), scripts_dir)
text = open(os.path.join(root, name), encoding="utf-8").read()
for pattern in FORBIDDEN_PATTERNS:
if pattern.search(text):
violations.append(f"{rel}: {pattern.pattern}")
self.assertEqual(
violations,
[],
msg="Forbidden account-manager internal imports in scripts/:\n" + "\n".join(violations),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""skill-template 脚手架与 Git 防串库守护。"""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from _support import get_skill_root
TEMPLATE_MARKER = ".openclaw-skill-template"
TEMPLATE_SLUG_KEBAB = "your-skill-slug"
TEMPLATE_SLUG_UNDERSCORE = "your_skill_slug"
SCAFFOLD_TEST_SLUG = "query-demo-records"
SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records"
def _read_skill_slug() -> str:
constants_path = os.path.join(get_skill_root(), "scripts", "util", "constants.py")
with open(constants_path, encoding="utf-8") as f:
for line in f:
if line.strip().startswith("SKILL_SLUG"):
_, _, value = line.partition("=")
return value.strip().strip('"').strip("'")
return ""
class TestScaffoldGuard(unittest.TestCase):
def test_template_repo_has_marker(self) -> None:
slug = _read_skill_slug()
if slug != TEMPLATE_SLUG_KEBAB:
self.skipTest(f"当前 SKILL_SLUG={slug!r},非模板占位,跳过 template marker 断言")
marker = os.path.join(get_skill_root(), TEMPLATE_MARKER)
self.assertTrue(
os.path.isfile(marker),
msg=f"skill-template 源仓库根目录必须存在 {TEMPLATE_MARKER}",
)
def test_business_skill_must_not_keep_template_marker(self) -> None:
slug = _read_skill_slug()
if slug == TEMPLATE_SLUG_KEBAB:
self.skipTest("当前仍为模板占位 slug由 test_template_repo_has_marker 守护")
marker = os.path.join(get_skill_root(), TEMPLATE_MARKER)
self.assertFalse(
os.path.isfile(marker),
msg=(
"业务技能不得保留 .openclaw-skill-template"
"请使用 tools/scaffold_skill 或删除标记文件并检查是否误复制了模板 .git"
),
)
def test_development_md_documents_git_cleanup(self) -> None:
path = os.path.join(get_skill_root(), "development", "DEVELOPMENT.md")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn(".git", text)
self.assertTrue(
"scaffold_skill" in text or "scaffold" in text.lower(),
msg="DEVELOPMENT.md must document scaffold_skill",
)
self.assertTrue(
"remote" in text.lower() or "git remote" in text.lower(),
msg="DEVELOPMENT.md must mention git remote self-check",
)
def test_scaffold_replaces_slug_and_copies_actions_manifest(self) -> None:
slug = _read_skill_slug()
if slug != TEMPLATE_SLUG_KEBAB:
self.skipTest("仅在 skill-template 源仓库运行 scaffold 集成测试")
import subprocess
import sys
repo_root = get_skill_root()
scaffold_py = os.path.join(repo_root, "tools", "scaffold_skill.py")
with tempfile.TemporaryDirectory() as tmp:
dest = os.path.join(tmp, SCAFFOLD_TEST_SLUG)
proc = subprocess.run(
[
sys.executable,
scaffold_py,
"--slug",
SCAFFOLD_TEST_SLUG,
"--destination",
dest,
],
cwd=repo_root,
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 0, msg=proc.stderr or proc.stdout)
actions_path = os.path.join(dest, "assets", "actions.json")
self.assertTrue(os.path.isfile(actions_path), msg="scaffold must copy assets/actions.json")
with open(actions_path, encoding="utf-8") as f:
manifest = json.load(f)
self.assertEqual(manifest.get("skill"), SCAFFOLD_TEST_SLUG)
manifest_raw = json.dumps(manifest)
self.assertNotIn(TEMPLATE_SLUG_KEBAB, manifest_raw)
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, manifest_raw)
actions_md = os.path.join(dest, "references", "ACTIONS.md")
self.assertTrue(os.path.isfile(actions_md), msg="scaffold must copy references/ACTIONS.md")
schema_path = os.path.join(dest, "assets", "schemas", "skill-actions.schema.json")
self.assertTrue(os.path.isfile(schema_path))
marker = os.path.join(dest, TEMPLATE_MARKER)
self.assertFalse(os.path.isfile(marker))
self.assertFalse(os.path.isdir(os.path.join(dest, ".git")))
constants_path = os.path.join(dest, "scripts", "util", "constants.py")
with open(constants_path, encoding="utf-8") as f:
constants_text = f.read()
self.assertNotIn(TEMPLATE_SLUG_KEBAB, constants_text)
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, constants_text)
self.assertIn(f'SKILL_SLUG = "{SCAFFOLD_TEST_SLUG}"', constants_text)
self.assertIn(
f'LOG_LOGGER_NAME = "openclaw.skill.{SCAFFOLD_TEST_SLUG_UNDERSCORE}"',
constants_text,
)
skill_md_path = os.path.join(dest, "SKILL.md")
with open(skill_md_path, encoding="utf-8") as f:
skill_md = f.read()
self.assertIn(f"slug: {SCAFFOLD_TEST_SLUG}", skill_md)
self.assertNotIn(TEMPLATE_SLUG_KEBAB, skill_md)
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, skill_md)
def test_scaffold_ps1_matches_python_placeholder_rules(self) -> None:
slug = _read_skill_slug()
if slug != TEMPLATE_SLUG_KEBAB:
self.skipTest("仅在 skill-template 源仓库运行 scaffold PS1 文案测试")
ps1_path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1")
with open(ps1_path, encoding="utf-8") as f:
text = f.read()
self.assertIn("your-skill-slug", text)
self.assertIn("your_skill_slug", text)
self.assertIn('.Replace("-", "_")', text)
if __name__ == "__main__":
unittest.main()

64
tools/README.md Normal file
View File

@@ -0,0 +1,64 @@
# skill-template 工具
## 推荐:脚手架创建新技能
**不要**用资源管理器整文件夹复制 `skill-template`(会带走隐藏 `.git`,导致 push 串库)。
Windows 首选:
```powershell
cd D:\OpenClaw\client-commons\skill-template
.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill
```
跨平台Python 3.10+
```bash
python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new-skill
```
脚本行为:
- 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致)
- **排除**`.git``.pytest_cache``__pycache__``.env``.env.local``*.pyc`
- 删除目标中的 `.openclaw-skill-template` 标记
- 将文本文件中的模板占位 slug 替换为新 slug
- `your-skill-slug` → 新技能 kebab-case slug`scrape-demo-records`
- `your_skill_slug` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等)
- 覆盖 `assets/actions.json``SKILL.md``scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符
- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action
- **不**自动 `git init`;打印后续 Git 与测试命令
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
## 脚手架复制后必须保留 / 修改 / 可删除的文件
| 文件 | 说明 |
|------|------|
| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 |
| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 |
| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 |
| `tests/test_actions_manifest.py` | 若删除 `actions.json`,应同步删除或改写此测试 |
| `tests/test_data_management_contract.py` | 通用契约测试;建议保留,按需扩展本技能表结构测试 |
`demo_items` 等示例表仅存在于模板测试中,**不会**作为 scaffold 后的默认业务表复制进新技能。
## 手工复制后的补救
若已整目录复制,必须先清理再初始化:
```powershell
cd <新技能目录>
Remove-Item -Recurse -Force .git
Remove-Item -Recurse -Force .pytest_cache -ErrorAction SilentlyContinue
Remove-Item -Force .openclaw-skill-template -ErrorAction SilentlyContinue
git init
git remote add origin <新技能仓库 URL>
git remote -v
```
确认 `origin` **不是** skill-template 的远端 URL。
## 模板标记
源仓库根目录有 `.openclaw-skill-template`(仅 template 保留)。业务技能不得保留该文件;`tests/test_scaffold_guard.py` 会守护。

140
tools/scaffold_skill.ps1 Normal file
View File

@@ -0,0 +1,140 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Scaffold a new skill directory from skill-template (excludes .git).
.EXAMPLE
.\tools\scaffold_skill.ps1 -Slug my-new-skill -Destination D:\OpenClaw\client-gdcm\my-new-skill
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Slug,
[Parameter(Mandatory = $true)]
[string]$Destination,
[switch]$Force
)
$ErrorActionPreference = "Stop"
function Test-KebabSlug([string]$Value) {
return $Value -match '^[a-z0-9]+(-[a-z0-9]+)*$'
}
function Write-ScaffoldNextSteps([string]$TargetPath) {
Write-Host ""
Write-Host "=== 脚手架复制完成 ===" -ForegroundColor Green
Write-Host ("目标目录: " + $TargetPath)
Write-Host ""
Write-Host "请在本机继续执行(未自动 git init避免误操作" -ForegroundColor Yellow
Write-Host (' cd "' + $TargetPath + '"')
Write-Host " Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue"
Write-Host " git init"
Write-Host " git remote add origin <你的新技能仓库 URL>"
Write-Host " git remote -v"
Write-Host " # 确认 assets/actions.json 中 skill 已替换为新 slug不需要 Action 时可删除该文件"
Write-Host " python tests/run_tests.py -v"
Write-Host ""
}
$TemplateRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$TargetPath = $Destination
if (-not (Test-KebabSlug $Slug)) {
throw "Slug must be kebab-case. Got: $Slug"
}
$leaf = Split-Path -Leaf ($TargetPath.TrimEnd('\', '/'))
if ($leaf -ne $Slug) {
throw "Destination leaf '$leaf' must match -Slug '$Slug'."
}
$parent = Split-Path -Parent $TargetPath
if (-not $parent -or -not (Test-Path -LiteralPath $parent)) {
throw "Parent directory does not exist: $parent"
}
$gitPath = Join-Path $TargetPath ".git"
if (Test-Path -LiteralPath $gitPath) {
throw "Target already has .git at $gitPath . Remove it first or use an empty destination."
}
$targetExists = Test-Path -LiteralPath $TargetPath
if ($targetExists) {
$entries = @(Get-ChildItem -LiteralPath $TargetPath -Force -ErrorAction SilentlyContinue)
if ($entries.Count -gt 0 -and -not $Force) {
throw "Target exists and is not empty: $TargetPath . Use -Force or pick an empty directory."
}
}
$excludeDirs = @(".git", ".pytest_cache", "__pycache__")
$excludeFiles = @(".env", ".env.local")
$templatePlaceholderSlugKebab = "your-skill-slug"
$templatePlaceholderSlugUnderscore = "your_skill_slug"
$textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample")
function Copy-TemplateTree {
param(
[string]$Source,
[string]$Dest
)
if (-not (Test-Path -LiteralPath $Dest)) {
New-Item -ItemType Directory -Path $Dest -Force | Out-Null
}
Get-ChildItem -LiteralPath $Source -Force | ForEach-Object {
$name = $_.Name
if ($excludeDirs -contains $name) { return }
if ($excludeFiles -contains $name) { return }
if ($name -like "*.pyc") { return }
$srcPath = $_.FullName
$dstPath = Join-Path $Dest $name
if ($_.PSIsContainer) {
Copy-TemplateTree -Source $srcPath -Dest $dstPath
}
else {
Copy-Item -LiteralPath $srcPath -Destination $dstPath -Force
}
}
}
function Replace-TemplatePlaceholders {
param(
[string]$Root,
[string]$Slug
)
$underscoreSlug = $Slug.Replace("-", "_")
Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object {
$name = $_.Name
if ($excludeFiles -contains $name) { return }
$ext = $_.Extension.ToLowerInvariant()
if ($textFileSuffixes -notcontains $ext -and $name -ne ".env.example") { return }
try {
$text = [System.IO.File]::ReadAllText($_.FullName)
}
catch {
return
}
if (
$text -notlike "*$templatePlaceholderSlugKebab*" -and
$text -notlike "*$templatePlaceholderSlugUnderscore*"
) { return }
$updated = $text.Replace($templatePlaceholderSlugKebab, $Slug)
$updated = $updated.Replace($templatePlaceholderSlugUnderscore, $underscoreSlug)
[System.IO.File]::WriteAllText($_.FullName, $updated)
}
}
Write-Host "Copy template: $TemplateRoot -> $TargetPath" -ForegroundColor Cyan
Copy-TemplateTree -Source $TemplateRoot -Dest $TargetPath
$marker = Join-Path $TargetPath ".openclaw-skill-template"
if (Test-Path -LiteralPath $marker) {
Remove-Item -LiteralPath $marker -Force
}
Replace-TemplatePlaceholders -Root $TargetPath -Slug $Slug
Write-ScaffoldNextSteps -TargetPath (Resolve-Path -LiteralPath $TargetPath).Path

161
tools/scaffold_skill.py Normal file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""跨平台 skill-template 脚手架(与 scaffold_skill.ps1 行为一致)。"""
from __future__ import annotations
import argparse
import re
import shutil
import sys
from pathlib import Path
EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"}
EXCLUDE_FILE_NAMES = {".env", ".env.local"}
KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
TEMPLATE_PLACEHOLDER_SLUG_KEBAB = "your-skill-slug"
TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE = "your_skill_slug"
TEXT_FILE_SUFFIXES = {
".md",
".py",
".json",
".yaml",
".yml",
".txt",
".ini",
".ps1",
".sample",
".env.example",
}
def _ignore_copy(_dir: str, names: list[str]) -> set[str]:
ignored: set[str] = set()
for name in names:
if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES:
ignored.add(name)
elif name.endswith(".pyc"):
ignored.add(name)
return ignored
def _should_replace_placeholders(path: Path) -> bool:
if path.name in EXCLUDE_FILE_NAMES:
return False
if path.suffix.lower() in TEXT_FILE_SUFFIXES:
return True
if path.name == ".env.example":
return True
return False
def _slug_to_underscore(slug: str) -> str:
return slug.replace("-", "_")
def _replace_template_placeholders(target: Path, slug: str) -> None:
"""将模板占位 slug 替换为新技能 slug不修改 action 业务命令结构)。"""
underscore_slug = _slug_to_underscore(slug)
for path in target.rglob("*"):
if not path.is_file():
continue
if path.name in EXCLUDE_FILE_NAMES:
continue
if path.suffix.lower() not in TEXT_FILE_SUFFIXES and path.name != ".env.example":
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if (
TEMPLATE_PLACEHOLDER_SLUG_KEBAB not in text
and TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE not in text
):
continue
updated = text.replace(TEMPLATE_PLACEHOLDER_SLUG_KEBAB, slug)
updated = updated.replace(TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE, underscore_slug)
path.write_text(updated, encoding="utf-8")
def _print_next_steps(target: Path) -> None:
print()
print("=== 脚手架复制完成 ===")
print(f"目标目录: {target}")
print()
print("请在本机继续执行(未自动 git init避免误操作")
print(f' cd "{target}"')
print(" Remove-Item -Recurse -Force .git -ErrorAction SilentlyContinue # 若曾手工复制漏删")
print(" git init")
print(" git remote add origin <你的新技能仓库 URL>")
print(" git remote -v # 确认 remote 不是 skill-template")
print(" # 确认 assets/actions.json 中 skill 已替换为新 slug不需要 Action 时可删除该文件")
print(" python tests/run_tests.py -v")
print()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="从 skill-template 脚手架复制新技能目录")
parser.add_argument("--slug", required=True, help="kebab-case slug须与目标目录名一致")
parser.add_argument("--destination", required=True, help="新技能完整目标路径")
parser.add_argument("--force", action="store_true", help="目标非空时允许覆盖(目标不得含 .git")
args = parser.parse_args(argv)
slug = args.slug.strip()
if not KEBAB_SLUG.match(slug):
print(f"ERROR: Slug 必须是 kebab-case当前: {slug}", file=sys.stderr)
return 1
target = Path(args.destination).resolve()
if target.name != slug:
print(
f"ERROR: 目标目录末段 '{target.name}' 与 slug '{slug}' 不一致。",
file=sys.stderr,
)
return 1
parent = target.parent
if not parent.is_dir():
print(f"ERROR: 父目录不存在: {parent}", file=sys.stderr)
return 1
if (target / ".git").exists():
print(
f"ERROR: 目标已存在 .git请先删除: {target / '.git'}",
file=sys.stderr,
)
return 1
template_root = Path(__file__).resolve().parent.parent
if target.exists():
entries = list(target.iterdir())
if entries and not args.force:
print(
f"ERROR: 目标目录已存在且非空: {target}\n请加 --force 或换空目录。",
file=sys.stderr,
)
return 1
target.mkdir(parents=True, exist_ok=True)
print(f"复制模板: {template_root} -> {target}")
for item in template_root.iterdir():
name = item.name
if name in EXCLUDE_DIR_NAMES or name in EXCLUDE_FILE_NAMES:
continue
if name.endswith(".pyc"):
continue
dest = target / name
if item.is_dir():
shutil.copytree(item, dest, ignore=_ignore_copy, dirs_exist_ok=True)
else:
shutil.copy2(item, dest)
marker = target / ".openclaw-skill-template"
if marker.is_file():
marker.unlink()
_replace_template_placeholders(target, slug)
_print_next_steps(target)
return 0
if __name__ == "__main__":
raise SystemExit(main())