Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6644dedd0e | |||
| cef7e366cf | |||
| afe0788488 | |||
| c49fcba157 | |||
| bf3bb9cd10 | |||
| 166007b460 | |||
| 78c24a515d | |||
| dfe6ade2d8 | |||
| e32d04ec50 | |||
| a8403ce9b4 | |||
| 8c8811e777 |
@@ -1,6 +1,7 @@
|
||||
# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行
|
||||
OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock
|
||||
|
||||
# @required
|
||||
# 目标网站地址:技能要访问的网站或服务地址
|
||||
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可
|
||||
|
||||
@@ -52,3 +53,6 @@ HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调
|
||||
|
||||
# 临时目录:运行中临时文件的位置(不上侧栏)
|
||||
# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp
|
||||
|
||||
# MySQL 云库连接:技能走云库时在用户数据目录配置 data-sources.json,并在此填写 DSN
|
||||
# MYSQL_DSN=mysql://user:password@192.168.0.20:3306/your_database # 留空或不写=本地 SQLite;须 mysql:// 开头且含库名;匠厂 Host 须 ≥ 6.9.28;见 references/DATA_SOURCES.md
|
||||
|
||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -9,6 +9,69 @@
|
||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||
|
||||
## 1.0.122
|
||||
|
||||
- 沉淀 Action **静态枚举中文 label**:`enum` + **`enumNames`**,或绑列复用列 `options_json` 数组
|
||||
- 约定宿主优先序:**绑列 options → enumNames → 纯 enum**;固定小枚举不要造假 Lookup
|
||||
- 更新 Schema / ACTIONS / SCHEMA / CHECKLIST A4-13;新增 `enum-names-action.example.json`
|
||||
|
||||
## 1.0.121
|
||||
|
||||
- 沉淀 Lookup **分页勾选小表**:`display_type: lookup` + `presentation: "paged_table"` + 可选 `page_size`(仍是 Lookup,不要新造控件类型)
|
||||
- 约定 `skill_cli` 追加 `--page` / `--limit`,stdout 为一行 `{"items","total"}`;0 条也是成功,界面显示「暂无可选项」
|
||||
- 只读列表命令不要在入口加载整包 platform-kit 或 Playwright;同库一个过滤条件优先 `local_sql`
|
||||
- 更新 Schema / ACTIONS / SCHEMA / CHECKLIST A4-12;新增 `lookup-paged-table.action.example.json`
|
||||
|
||||
## 1.0.120
|
||||
|
||||
- 正式沉淀行菜单显隐契约 **`bind.visibleWhen`**(宿主已支持:按当前行列值显示/隐藏行按钮)
|
||||
- 与表单字段 `visibleWhen`、Policy L3 预留消歧;更新 Schema / ACTIONS / POLICY / CHECKLIST A4-11 / RUNTIME
|
||||
- 新增示例 `bind-visible-when-row.action.example.json`
|
||||
|
||||
## 1.0.119
|
||||
|
||||
- 沉淀匠厂宿主 **Action Lookup 三源**:`local_sql`(同库)/ **`skill_cli`(兄弟技能 CLI JSON)** / `external_api`(预留)
|
||||
- 明确列 Lookup 仍仅 `local_sql`;跨技能账号列表用 `skill_cli`,**禁止**本库镜像缓存表
|
||||
- 更新 `ACTIONS.md` / `SCHEMA.md` / `skill-actions.schema.json` / CHECKLIST A4-6 / `ACCOUNT_MANAGER.md`;新增 `lookup-skill-cli.action.example.json`
|
||||
|
||||
## 1.0.118
|
||||
|
||||
- 沉淀浏览器 RPA 窗口/viewport 最佳实践(`RPA.md` §1.2.1):`no_viewport=True` + `--start-maximized`,禁止 `viewport=None` 与 CDP/JS 中途改窗
|
||||
- `RPA_BROWSER.md` 补充 Shadow DOM 双 input 陷阱与 Cookie 可选 dismiss 模式
|
||||
- 新增 `POLICY-RPA-007` 文档标记与 launch 禁止模式门禁;CHECKLIST A5-4 对齐
|
||||
|
||||
## 1.0.117
|
||||
|
||||
- 沉淀匠厂 Host ≥ 6.9.28 **多数据源**契约:可选 MySQL 云库 + `data-sources.json` 声明;默认仍为本地 SQLite
|
||||
- 新增 `references/DATA_SOURCES.md`、`assets/data-sources.example.json`(SQLite 默认)、`assets/examples/data-sources.mysql.example.json`
|
||||
- `scripts/db/backend.py`、`cursor_compat.py` 与双后端 `connection.py`:CLI / Action 与宿主 UI 读写同一库
|
||||
- `.env.example` 增加 `MYSQL_DSN` 说明;CHECKLIST / CONFIG / DATA_PATHS / SCHEMA / ACTIONS 口径同步
|
||||
|
||||
## 1.0.116
|
||||
|
||||
- 沉淀宿主 **年 / 年月** 表单字段契约:`display_type: year`(`YYYY`)、`year_month` / `month`(`YYYY-MM`);与 `date` / `datetime_unix_seconds` 对称,Action 与 CRUD 均适用
|
||||
- 更新 `ACTIONS.md`、`SCHEMA.md`、`skill-actions.schema.json`;新增 `assets/examples/year-month-action.example.json`;`display_metadata.py` 增加 `YEAR` / `YEAR_MONTH` 常量
|
||||
|
||||
## 1.0.115
|
||||
|
||||
- 沉淀匠厂配置管理必填项契约:`.env.example` 在行头上方用独立一行 `# @required` 声明;宿主显示红色 `*` 并在保存时校验非空
|
||||
- 模板示例已为 `TARGET_BASE_URL` 加上示范;新增 `POLICY-CONFIG-005` 门禁错误用法
|
||||
|
||||
## 1.0.114
|
||||
|
||||
- 技能根目录增加 `sync.ps1`,和 `release.ps1` 并排。在本机把明文同步到已安装的技能目录,用来在宿主里验收
|
||||
- 市场上的最新版本和加密包仍走 `release.ps1`。同步后退出资料管理再打开,不要点技能市场的「更新」
|
||||
|
||||
## 1.0.113
|
||||
|
||||
- 固化数据管理契约:`visible=0` 只藏界面;`default_sort_json` 仍可引用隐藏的非敏感物理列(含主键)
|
||||
- 元数据校验:排序列必须真实存在;引用隐藏列仅提示、不报错
|
||||
|
||||
## 1.0.112
|
||||
|
||||
- 新技能的名字不必再以平台或 `default` 结尾。没有抖音、小红书、亚马逊这类外部平台时,用动词加名词即可
|
||||
- 不要再把 `default` 当作占位平台。已经发布、名字末尾是 `default` 的技能不用改名
|
||||
|
||||
## 1.0.111
|
||||
|
||||
- 沉淀数据管理呈现加法:同一 `cellPresentation.enabled` 支持单元格、整行、整列、行列交叉区域(闭集 tone)
|
||||
|
||||
7
SKILL.md
7
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 技能开发模板(通用业务版)
|
||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||
version: 1.0.111
|
||||
version: 1.0.122
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
@@ -39,6 +39,7 @@ allowed-tools:
|
||||
- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。其中 `row` 表示**行级入口**(宿主可呈现为操作列、行右键、行内「⋯」等;技能配什么就显示什么,无独立菜单 DSL)。数据管理技能按钮顺序见 `actions` **数组相对顺序**(详见 `references/ACTIONS.md`),无独立 sortOrder。
|
||||
- **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。
|
||||
- **`bind.selectionMode`(可选)**:表勾选如何驱动 Action(`required` / `none` / `optional`);省略由宿主推断。同一能力勿拆两个同义顶栏按钮,见 `references/ACTIONS.md`。
|
||||
- **`bind.visibleWhen`(可选)**:`row` 菜单/按钮按当前行列值显隐(如仅已采用可入队);省略则始终显示。≠ 表单字段 `visibleWhen`,≠ Policy L3。见 `references/ACTIONS.md`「行菜单显隐」。
|
||||
- **用户可见文案**:数据管理弹窗 / 任务中心文案须全中文产品用语;勿把 `pickCount`、`async`、`pending`、CLI 子命令写进弹窗(见 `references/ACTIONS.md`「用户可见文案」)。
|
||||
- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。
|
||||
- **`policy`(可选)**:需要按用户/角色控制数据管理按钮或行列时,在 `actions.json` 顶层启用 Data Policy,由技能 `policy` CLI 输出 Snapshot;**未启用 = 与今日一致(L0)**。详见 `references/POLICY.md`。用户/角色数据源(飞书/库/API)属技能内部,宿主不托管。
|
||||
@@ -155,11 +156,11 @@ python {baseDir}/scripts/main.py init-db
|
||||
4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制;开发前先读 `development/PRODUCT_SURFACES.md` 并填写入口定位表。
|
||||
5. 用户市场四 Tab 材料写入根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.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`。
|
||||
7. 本地 SQLite 或可选 MySQL 云库:数据源声明见 `references/DATA_SOURCES.md`;中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。
|
||||
|
||||
## 技能命名与 slug
|
||||
|
||||
- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。
|
||||
- 标准格式:`{verb}-{noun-phrase}`,绑定外部平台时才加 `-{platform}`(详见 `development/NAMING.md`)。不要用 `default` 占位。
|
||||
- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case;`name`、`description`、根 `README.md`:**中文**。
|
||||
- 复制为新业务技能前**必须**阅读 `development/NAMING.md`。
|
||||
- `name`(frontmatter):**用户可见的中文业务名称**;`description` 是说明,不是名称。
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
- **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。
|
||||
- `html/example-report.html`:HTML 快照页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`。不要放进 `pages/`。
|
||||
- `html/pages/*.html`:装完就出现在侧栏的壳子页(宿主直接读安装包,不经生成)。页面用 `jiangchang.call` 调本技能已声明的命令。见 `references/ACTIONS.md`「HTML 页面报告」。模板仓库不要在这里放示例页。
|
||||
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。
|
||||
- `data-sources.example.json`:声明式数据源默认示例(SQLite `{skillSlug}.db`);宿主安装时可复制到用户目录。MySQL 参考见 `examples/data-sources.mysql.example.json`。
|
||||
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `data-sources.mysql.example.json`、`path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`render-html.action.example.json` 等;虚构路径与数据)。
|
||||
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。
|
||||
|
||||
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping` / `columnMapping` / **`labelColumn`**(选择摘要标签列)/ **`selectionMode`**(`required` \| `none` \| `optional`:表勾选如何驱动 Action;省略则宿主兼容推断)。placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层在会应用勾选时展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。弹窗 `description` / 确认黄条 / 参数说明须**用户可见中文**(勿写 `pickCount`/`async`/`pending`/CLI 子命令;见 ACTIONS.md「用户可见文案」)。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
|
||||
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping` / `columnMapping` / **`labelColumn`**(选择摘要标签列)/ **`selectionMode`**(`required` \| `none` \| `optional`:表勾选如何驱动 Action;省略则宿主兼容推断)/ **`visibleWhen`**(行菜单/行按钮按当前行列值显隐)。Action Lookup 可选 **`presentation: "paged_table"`** + **`page_size`**(分页勾选小表,仍是 `display_type: lookup`)。placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层在会应用勾选时展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。弹窗 `description` / 确认黄条 / 参数说明须**用户可见中文**(勿写 `pickCount`/`async`/`pending`/CLI 子命令;见 ACTIONS.md「用户可见文案」)。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
|
||||
|
||||
- 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md);写法契约见 [`development/TUTORIAL_SURFACES.md`](../development/TUTORIAL_SURFACES.md)
|
||||
- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md))
|
||||
|
||||
11
assets/data-sources.example.json
Normal file
11
assets/data-sources.example.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sources": [
|
||||
{
|
||||
"id": "primary",
|
||||
"kind": "sqlite",
|
||||
"displayName": "本地库",
|
||||
"path": "{skillSlug}.db"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
# 示例 JSON
|
||||
|
||||
- `data-sources.mysql.example.json`:MySQL 云库 `data-sources.json` 形状参考(复制到用户数据目录并配 `.env` 中 `MYSQL_DSN`;见 `references/DATA_SOURCES.md`)。
|
||||
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
|
||||
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
|
||||
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
|
||||
- `skill-grid-presentation.example.json`:数据管理整行 / 整列 / 区域呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
|
||||
- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。
|
||||
- `lookup-action.example.json`:Action 内联 Lookup 形状参考(单选 + `multiple: true` 多选并列;跨表引用勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。
|
||||
- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ Policy L3)。
|
||||
- `lookup-action.example.json`:Action 内联 Lookup(**local_sql**)形状参考(单选 + `multiple: true`;同库跨表勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。
|
||||
- `lookup-skill-cli.action.example.json`:Action Lookup **`skill_cli`** 形状参考(打开表单时宿主调兄弟技能 CLI 抽 JSON 选项;跨技能账号列表勿再造本库缓存表;见同节)。
|
||||
- `lookup-paged-table.action.example.json`:Lookup **分页勾选小表** `presentation: "paged_table"` 形状参考(仍是 `display_type: lookup`;见 `references/ACTIONS.md`「分页勾选小表」)。
|
||||
- `year-month-action.example.json`:Action / CRUD 账期月份字段 `display_type: year_month` 形状参考(见 `references/ACTIONS.md`「表单字段类型对照」)。
|
||||
- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ `bind.visibleWhen`,≠ Policy L3)。
|
||||
- `bind-visible-when-row.action.example.json`:行菜单显隐 `bind.visibleWhen` 形状参考(按当前行状态显示/隐藏行按钮;见 `references/ACTIONS.md`「行菜单显隐」)。
|
||||
- `selection-label-column.action.example.json`:选择摘要 `bind.labelColumn` 形状参考(见 `references/ACTIONS.md`「选择摘要」)。
|
||||
- `selection-mode-optional.action.example.json`:`bind.selectionMode: "optional"` 智能勾选/按数量合并形状参考;用户可见文案为口语中文(见 `references/ACTIONS.md`「勾选驱动模式」「用户可见文案」)。
|
||||
- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。
|
||||
|
||||
39
assets/examples/bind-visible-when-row.action.example.json
Normal file
39
assets/examples/bind-visible-when-row.action.example.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "approve-item",
|
||||
"label": "采用",
|
||||
"description": "把当前记录改为已采用。仅待确认或已跳过的行显示此按钮。",
|
||||
"placements": ["row", "agent"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "direct",
|
||||
"bind": {
|
||||
"tables": ["business_items"],
|
||||
"visibleWhen": {
|
||||
"field": "status",
|
||||
"in": ["pending_review", "skipped"]
|
||||
},
|
||||
"labelColumn": "display_name",
|
||||
"inputMapping": {
|
||||
"itemId": "$row.$pk"
|
||||
}
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["itemId"],
|
||||
"properties": {
|
||||
"itemId": {
|
||||
"type": "number",
|
||||
"title": "编号",
|
||||
"description": "行内触发时自动填入,一般无需修改",
|
||||
"readOnly": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "approve",
|
||||
"args": ["--id", "{{itemId}}"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
}
|
||||
}
|
||||
11
assets/examples/data-sources.mysql.example.json
Normal file
11
assets/examples/data-sources.mysql.example.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"sources": [
|
||||
{
|
||||
"id": "primary",
|
||||
"kind": "mysql",
|
||||
"displayName": "业务云库",
|
||||
"connectionEnv": "MYSQL_DSN"
|
||||
}
|
||||
]
|
||||
}
|
||||
51
assets/examples/enum-names-action.example.json
Normal file
51
assets/examples/enum-names-action.example.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"_comment": "形状参考:Action 静态枚举用 enum + enumNames 显示中文;提交仍是 enum value。有对应表列时优先 column 绑列复用 options_json。完整契约见 references/ACTIONS.md「静态枚举 label」。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "create-ticket",
|
||||
"label": "新建工单(示例)",
|
||||
"description": "演示 enumNames:界面显示中文,CLI 仍收到英文枚举值。",
|
||||
"placements": ["toolbar", "agent"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["tickets"]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "ticket-create",
|
||||
"args": [
|
||||
"--category", "{{category}}",
|
||||
"--priority", "{{priority}}",
|
||||
"--title", "{{title}}"
|
||||
]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["title", "category"],
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"title": "标题"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"title": "类型",
|
||||
"column": "category",
|
||||
"enum": ["incident", "request", "consult", "other"],
|
||||
"enumNames": ["故障", "需求", "咨询", "其他"]
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"title": "优先级",
|
||||
"enum": ["low", "medium", "high", "urgent"],
|
||||
"enumNames": ["低", "中", "高", "紧急"],
|
||||
"description": "无对应列时仅靠 enumNames 展示中文"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "形状参考:Action 参数引用同库另一张表时用 Lookup。单选与多选同一 lookup 对象,多选只加 multiple:true。完整契约见 references/ACTIONS.md「跨表引用:Lookup」与 references/SCHEMA.md。",
|
||||
"_comment": "形状参考:Action 参数引用同库另一张表时用 Lookup(local_sql)。单选与多选同一 lookup 对象,多选只加 multiple:true。跨技能列表见 lookup-skill-cli.action.example.json。完整契约见 references/ACTIONS.md「跨表引用:Lookup」。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
|
||||
47
assets/examples/lookup-paged-table.action.example.json
Normal file
47
assets/examples/lookup-paged-table.action.example.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"_comment": "形状参考:Action Lookup 分页勾选小表。仍是 display_type=lookup,加 presentation=paged_table。完整契约见 references/ACTIONS.md「分页勾选小表」。不要新造 display_type。skill_cli 的 stdout 须为一行 {items,total};0 条也成功退出。列表命令不要在 main.py 入口加载整包 jiangchang_skill_core 或 Playwright。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "import-picked",
|
||||
"label": "导入文章",
|
||||
"description": "从已生成文章里勾选要导入的,可翻页。",
|
||||
"placements": ["toolbar"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["articles"],
|
||||
"selectionMode": "none"
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "import-articles",
|
||||
"args": ["--ids", "{{articleIds}}"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["articleIds"],
|
||||
"properties": {
|
||||
"articleIds": {
|
||||
"type": "string",
|
||||
"title": "选择文章",
|
||||
"description": "勾选要导入的文章。可翻页,可本页全选。",
|
||||
"display_type": "lookup",
|
||||
"lookup": {
|
||||
"source": "skill_cli",
|
||||
"skill": "geo-generate-article-all",
|
||||
"command": "title",
|
||||
"args": ["list-generated-json", "--platform", "toutiaohao"],
|
||||
"value_column": "id",
|
||||
"label_column": "title",
|
||||
"multiple": true,
|
||||
"presentation": "paged_table",
|
||||
"page_size": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
51
assets/examples/lookup-skill-cli.action.example.json
Normal file
51
assets/examples/lookup-skill-cli.action.example.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"_comment": "形状参考:Action Lookup 的 skill_cli 源——打开表单时宿主 sync 调用兄弟技能 CLI,从 stdout JSON 抽选项。完整契约见 references/ACTIONS.md「跨表引用:Lookup」。本例对标 account-manager 遗留 list-json(stdout 为账号数组,字段 id / account_label);其它依赖技能请核对自己的命令与 JSON 形状。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "publish-with-account",
|
||||
"label": "发布(示例)",
|
||||
"description": "演示跨技能选账号:skill_cli 调 account-manager,勿在本技能维护账号缓存表。",
|
||||
"placements": ["toolbar", "cron"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["articles"],
|
||||
"selectionMode": "none"
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "run",
|
||||
"args": ["--account-id", "{{accountId}}", "--pick", "{{pickCount}}"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["pickCount"],
|
||||
"properties": {
|
||||
"pickCount": {
|
||||
"type": "number",
|
||||
"title": "发布数量",
|
||||
"description": "从未发布队列领取的条数",
|
||||
"default": 1,
|
||||
"minimum": 1
|
||||
},
|
||||
"accountId": {
|
||||
"type": "string",
|
||||
"title": "发布账号",
|
||||
"description": "不选则由系统自动分配;选定后本次全部使用该号。",
|
||||
"display_type": "lookup",
|
||||
"lookup": {
|
||||
"source": "skill_cli",
|
||||
"skill": "account-manager",
|
||||
"command": "list-json",
|
||||
"args": ["toutiao"],
|
||||
"value_column": "id",
|
||||
"label_column": "account_label"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
40
assets/examples/year-month-action.example.json
Normal file
40
assets/examples/year-month-action.example.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"_comment": "形状参考:账期/月份字段用 year_month,宿主渲染年月选择器。完整契约见 references/ACTIONS.md「表单字段类型对照」与 references/SCHEMA.md。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "create-period-job",
|
||||
"label": "新建账期作业(示例)",
|
||||
"description": "演示 year_month 字段:提交值为 YYYY-MM 字符串,不要用 date。",
|
||||
"placements": ["toolbar"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["close_jobs"]
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "create-job",
|
||||
"args": ["--period", "{{period}}", "--title", "{{title}}"]
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["period"],
|
||||
"properties": {
|
||||
"period": {
|
||||
"type": "string",
|
||||
"title": "月份",
|
||||
"description": "格式 YYYY-MM,例如 2026-05",
|
||||
"display_type": "year_month"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"title": "标题",
|
||||
"description": "可留空,默认按月份生成"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
all
|
||||
batch
|
||||
multi
|
||||
default
|
||||
|
||||
@@ -85,11 +85,53 @@
|
||||
"mutationKind": {
|
||||
"type": "string",
|
||||
"enum": ["create", "update", "delete", "import"],
|
||||
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
|
||||
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写已注册数据源(默认 SQLite)。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
|
||||
},
|
||||
"scalarArg": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
},
|
||||
"visibleWhenRule": {
|
||||
"description": "条件显隐规则。支持 {field,equals} / {field,in:[...]},或单键简写 {status:\"approved\"} / {status:[\"a\",\"b\"]}。",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["field", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": { "type": "string", "minLength": 1 },
|
||||
"equals": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["field", "in"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": { "type": "string", "minLength": 1 },
|
||||
"in": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"maxProperties": 1,
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{ "type": ["string", "number", "boolean"] },
|
||||
{
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"inputProperty": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
@@ -105,7 +147,13 @@
|
||||
"default": {},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"minItems": 1
|
||||
"minItems": 1,
|
||||
"description": "静态枚举取值(提交/存库用)。展示中文时配 enumNames,或绑列复用 options_json;勿用 Lookup 冒充固定小枚举。"
|
||||
},
|
||||
"enumNames": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"description": "可选。与 enum 一一对应的展示文案(JSON Schema / OpenAPI 风格)。缺项或空串时宿主回退为对应 enum 原文。有对应表列时优先绑列复用 options_json(见 ACTIONS.md「静态枚举 label」)。"
|
||||
},
|
||||
"sensitive": { "type": "boolean" },
|
||||
"readOnly": {
|
||||
@@ -120,16 +168,16 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "可选。绑定业务表物理列名;Action 弹窗(toolbar/batch)与 replaces create/update 抽屉均可复用该列的中文名与 lookup 下拉"
|
||||
"description": "可选。绑定业务表物理列名;Action 弹窗(toolbar/batch)与 replaces create/update 抽屉可复用该列的中文名与 options(静态枚举 options_json 数组,或已解析的 lookup)。"
|
||||
},
|
||||
"display_type": {
|
||||
"type": "string",
|
||||
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。表单控件提示;与 format 二选一即可。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。"
|
||||
"enum": ["textarea", "year", "year_month", "month", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。表单控件提示;与 format 二选一即可。year/year_month=年或年月选择器(YYYY / YYYY-MM;month 为 year_month 别名)。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"enum": ["textarea", "year", "year_month", "month", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。display_type 别名,语义相同。"
|
||||
},
|
||||
"pathRoot": {
|
||||
@@ -139,74 +187,45 @@
|
||||
},
|
||||
"lookup": {
|
||||
"$ref": "#/$defs/lookupConfig",
|
||||
"description": "可选。内联同库表关联 Lookup(与列 options_json lookup 对象同构)。用于当前表没有对应列时;推荐同时设 display_type=lookup。见 ACTIONS.md「跨表引用:Lookup」。"
|
||||
"description": "可选。Action 内联 Lookup:local_sql(同库表)/ skill_cli(兄弟技能 CLI JSON)/ external_api(预留)。打开表单时由宿主解析。推荐同时设 display_type=lookup。见 ACTIONS.md「跨表引用:Lookup」。"
|
||||
},
|
||||
"visibleWhen": {
|
||||
"description": "可选。表单字段条件显隐。宿主在数据管理/定时任务参数表单中:规则不满足则隐藏该字段、提交时省略、且不校验 required。支持 {field,equals} / {field,in:[...]},或单键简写 {collectMode:\"按关键词采集\"} / {collectMode:[\"a\",\"b\"]}。Agent/CLI 无 UI 时同样按当前 input 求值后剥离隐藏字段。",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["field", "equals"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": { "type": "string", "minLength": 1 },
|
||||
"equals": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["field", "in"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"field": { "type": "string", "minLength": 1 },
|
||||
"in": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"maxProperties": 1,
|
||||
"additionalProperties": {
|
||||
"oneOf": [
|
||||
{ "type": ["string", "number", "boolean"] },
|
||||
{
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": ["string", "number", "boolean"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
"description": "可选。表单字段条件显隐。宿主在数据管理/定时任务参数表单中:规则不满足则隐藏该字段、提交时省略、且不校验 required。Agent/CLI 无 UI 时同样按当前 input 求值后剥离隐藏字段。≠ bind.visibleWhen(行菜单),≠ Policy L3。",
|
||||
"allOf": [{ "$ref": "#/$defs/visibleWhenRule" }]
|
||||
}
|
||||
}
|
||||
},
|
||||
"lookupConfig": {
|
||||
"type": "object",
|
||||
"required": ["ref_table", "value_column", "label_column"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["local_sql", "skill_cli", "external_api"],
|
||||
"description": "可选。显式数据源;省略时:有 ref_table→local_sql,有 skill→skill_cli,有 url→external_api。"
|
||||
},
|
||||
"lookup_source": {
|
||||
"type": "string",
|
||||
"enum": ["local_sql", "skill_cli", "external_api"],
|
||||
"description": "可选。source 别名。"
|
||||
},
|
||||
"ref_table": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "同库被引用表名"
|
||||
"description": "local_sql:同库被引用表名"
|
||||
},
|
||||
"value_column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "提交值列(编号 / 外键)"
|
||||
"description": "提交值列;local_sql 必填;skill_cli/external_api 默认可为 id"
|
||||
},
|
||||
"label_column": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "主文案列"
|
||||
"description": "主文案列;local_sql 必填;skill_cli 默认可为 display_label"
|
||||
},
|
||||
"extra_column": {
|
||||
"type": "string",
|
||||
@@ -218,18 +237,101 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
||||
"description": "可选。等值过滤列"
|
||||
"description": "可选。local_sql 等值过滤列"
|
||||
},
|
||||
"filter_value": {
|
||||
"type": ["string", "number", "boolean", "null"],
|
||||
"description": "可选。与 filter_column 配对的等值"
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "skill_cli:兄弟/依赖技能 slug"
|
||||
},
|
||||
"skill_slug": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "skill_cli:skill 别名"
|
||||
},
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "skill_cli:CLI 子命令(argv[0])"
|
||||
},
|
||||
"cli_command": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "skill_cli:command 别名"
|
||||
},
|
||||
"args": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
{ "type": "string" }
|
||||
],
|
||||
"description": "skill_cli:追加 argv;数组或逗号分隔串"
|
||||
},
|
||||
"items_path": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "skill_cli / external_api:stdout JSON 内选项数组点路径,默认 items"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "external_api(预留):HTTP URL"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "external_api:url 别名"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST"],
|
||||
"description": "external_api:HTTP 方法,默认 GET"
|
||||
},
|
||||
"multiple": {
|
||||
"type": "boolean",
|
||||
"description": "可选。true=多选 Lookup(存/提交 TEXT 逗号分隔 id);省略或 false=单选(默认)。与 CRUD options_json lookup 同构。"
|
||||
"description": "可选。true=多选 Lookup(存/提交 TEXT 逗号分隔 id);省略或 false=单选(默认)。"
|
||||
},
|
||||
"presentation": {
|
||||
"type": "string",
|
||||
"enum": ["select", "paged_table"],
|
||||
"description": "可选。省略或 select=下拉/勾选/Chip。paged_table=分页勾选小表(编号|文案,可翻页、可本页全选)。仍是 lookup,不要新造 display_type。"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"description": "可选。presentation=paged_table 时每页条数,默认 10,最大 100。"
|
||||
}
|
||||
},
|
||||
"description": "同库 FK 式 Lookup。展示:value · label;有 extra 时 value · label(extra)。multiple 时多选。宿主亦接受 camelCase 键名。"
|
||||
"oneOf": [
|
||||
{
|
||||
"required": ["ref_table", "value_column", "label_column"],
|
||||
"description": "local_sql(同库表)"
|
||||
},
|
||||
{
|
||||
"anyOf": [
|
||||
{ "required": ["skill", "command"] },
|
||||
{ "required": ["skill", "cli_command"] },
|
||||
{ "required": ["skill_slug", "command"] },
|
||||
{ "required": ["skill_slug", "cli_command"] }
|
||||
],
|
||||
"description": "skill_cli(兄弟技能 CLI JSON)"
|
||||
},
|
||||
{
|
||||
"anyOf": [
|
||||
{ "required": ["url"] },
|
||||
{ "required": ["endpoint"] }
|
||||
],
|
||||
"description": "external_api(预留)"
|
||||
}
|
||||
],
|
||||
"description": "Action Lookup:local_sql / skill_cli / external_api。展示:value · label;有 extra 时 value · label(extra)。宿主亦接受 camelCase。列 Lookup 仅 local_sql,见 SCHEMA.md。"
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
@@ -316,9 +418,13 @@
|
||||
"type": "string",
|
||||
"enum": ["required", "none", "optional"],
|
||||
"description": "可选。数据管理勾选如何驱动本 Action:required=必须有勾选(无勾选禁用);none=忽略表勾选(不出现假「已选择」);optional=有勾选走 $selection.ids,无勾选走 pickCount/maxLeads 等空选数量字段。省略时宿主推断:有 $selection.ids 或 batch → required,否则 → none。与 placements/executionProfile 正交。"
|
||||
},
|
||||
"visibleWhen": {
|
||||
"description": "可选。placements 含 row 时:按当前行原始列值决定是否出现在行菜单/行按钮(宿主已支持)。形状同表单字段 visibleWhen。省略=始终显示。≠ inputSchema 字段 visibleWhen,≠ Policy L3 预留。见 ACTIONS.md「行菜单显隐」。",
|
||||
"allOf": [{ "$ref": "#/$defs/visibleWhenRule" }]
|
||||
}
|
||||
},
|
||||
"description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping / labelColumn / selectionMode。concurrency/locks 勿写入。"
|
||||
"description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping / labelColumn / selectionMode / visibleWhen。concurrency/locks 勿写入。"
|
||||
},
|
||||
"hostEffectId": {
|
||||
"type": "string",
|
||||
|
||||
@@ -93,6 +93,17 @@ finally:
|
||||
|
||||
## 5. 账号选择与多账号
|
||||
|
||||
### 5.0 数据管理 / 定时任务表单选账号(Action Lookup)
|
||||
|
||||
若 Action 参数需要用户**点选账号**(而非手填编号):
|
||||
|
||||
- 在 `actions.json` 用 **`display_type: lookup` + `lookup.source: skill_cli`**,指向 `account-manager` 能吐 JSON 选项列表的 CLI(字段名 / `items_path` 以 account-manager 文档与实际 stdout 为准)。
|
||||
- **禁止**在本技能库再建「账号选项缓存表」再 `local_sql`——易过期,且定时任务等入口容易空下拉。
|
||||
- 契约与示例见 [`references/ACTIONS.md`](../references/ACTIONS.md)「跨表引用:Lookup」、`assets/examples/lookup-skill-cli.action.example.json`。
|
||||
|
||||
表单 Lookup **只负责填参数**;真正开浏览器前的 ensure / pick / 租约仍按下文与 `account_client` 执行,二者不要混成「打开表单就 pick-web」。
|
||||
|
||||
### 5.1 运行时选择顺序
|
||||
选择优先级:
|
||||
|
||||
1. 用户明确指定纯数字 `DEFAULT_ACCOUNT_ID`:先确认账号存在且平台一致,再通过可定位该账号的 pick 流程取得租约;不得只 `get` 后返回空租约。
|
||||
|
||||
@@ -108,11 +108,14 @@ checklist_version: 1.0
|
||||
- [ ] A4-3 **P0** 异步任务在任务中心能看到进行中与结束状态;结束有成功或失败结论
|
||||
- [ ] A4-4 **P1** 进行中的进度/步骤信息对用户可理解
|
||||
- [ ] A4-5 **P1** 已对外暴露的动作参数覆盖主路径关键输入(无“命令行能做、动作入口不能做”的关键缺口)
|
||||
- [ ] A4-6 **P0** 若 Action 需引用同库另一张业务表(话术/平台/账号等):参数为 Lookup 下拉(内联 `lookup` 或绑列),**不是**手填内部编号;可选「自动」可留空提交
|
||||
- [ ] A4-7 **P1** 若 Action 有互斥采集/操作模式(如「按关键词 / 按主页」):用 `inputSchema` 字段级 **`visibleWhen`** 联动显隐,勿只靠 `description` 灰字说明「仅某某时填写」(见 `references/ACTIONS.md`;勿与 Policy L3 行态 `visibleWhen` 混淆)
|
||||
- [ ] A4-8 **P1** 行内/勾选打开的 `form`:有「已选择」时参数区不应再重复映射主键(`inputMapping` + `readOnly`);需要强制露出时用 `revealInForm`,**禁止**用 `visibleWhen` 藏主键(会不提交)
|
||||
- [ ] A4-6 **P0** 若 Action 需引用业务行(话术/平台/账号等):参数为 Lookup 下拉(**不是**手填内部编号);同库用 `local_sql`(内联或绑列),跨技能用 **`skill_cli`**(勿在本库造镜像缓存表);可选「自动」可留空提交(见 `references/ACTIONS.md`)
|
||||
- [ ] A4-7 **P1** 若 Action 有互斥采集/操作模式(如「按关键词 / 按主页」):用 `inputSchema` 字段级 **`visibleWhen`** 联动显隐,勿只靠 `description` 灰字说明「仅某某时填写」(见 `references/ACTIONS.md`;勿与 **`bind.visibleWhen`** 行菜单、Policy L3 混淆)
|
||||
- [ ] A4-8 **P1** 行内/勾选打开的 `form`:有「已选择」时参数区不应再重复映射主键(`inputMapping` + `readOnly`);需要强制露出时用 `revealInForm`,**禁止**用表单 `visibleWhen` 藏主键(会不提交)
|
||||
- [ ] A4-9 **P1** 若同一能力既支持「勾选处理」又支持「按数量从队列领取」:用一个 Action + `bind.selectionMode: "optional"`(勿拆两个同义顶栏按钮);无勾选可点、有勾选显示「已选择」、空选数量字段用 `pickCount` 等约定名(见 `references/ACTIONS.md`)
|
||||
- [ ] A4-10 **P0** 数据管理弹窗与任务失败文案:全中文产品用语;禁止在 `description` / `confirmation.message` / 参数 `description` / 用户向 `message` 写英文字段名、CLI 子命令、`async`/`pending`/`status=` 等(Agent 路由放 `SKILL.md`;见 `references/ACTIONS.md`「用户可见文案」)
|
||||
- [ ] A4-11 **P1** 若行操作应按业务状态显隐(如仅待确认可「采用」、仅已采用可「加入待写」):用 **`bind.visibleWhen`**(对当前行列值求值;存库原始值如 `approved`);勿指望 Policy L3;CLI 侧仍须校验状态(见 `references/ACTIONS.md`「行菜单显隐」)
|
||||
- [ ] A4-12 **P1** 若要在表单里翻页勾选一批业务行(文章、平台等):用 Lookup **`presentation: "paged_table"`**(仍是 `display_type: lookup`,勿新造类型);`skill_cli` 的 stdout 为一行 `{"items","total"}`,0 条也成功退出;该列表命令不要在入口加载整包 platform-kit 或 Playwright(见 `references/ACTIONS.md`「分页勾选小表」)
|
||||
- [ ] A4-13 **P1** 若 Action 有固定小枚举(状态/船司/优先级等)需中文展示:用 `enum` + **`enumNames`**,或绑列复用列 `options_json` 数组;宿主优先序为绑列 options → enumNames → 纯 enum;**禁止**为中文展示造假 Lookup(见 `references/ACTIONS.md`「静态枚举 label」)
|
||||
---
|
||||
|
||||
## 5. 账号与浏览器会话(网页 RPA 适用;其他形态标 N/A 或改测等价项)
|
||||
@@ -120,7 +123,7 @@ checklist_version: 1.0
|
||||
- [ ] A5-1 **P0** 未准备好账号/浏览器档案时,主路径失败并提示,不会默默用本机默认浏览器身份跑起来
|
||||
- [ ] A5-2 **P0** 登录策略为 required 时:未登录不能把做不成的事当成成功
|
||||
- [ ] A5-3 **P0** 交付联调默认能看见浏览器窗口(有头);不能把无头当作唯一可验收形态
|
||||
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入)
|
||||
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入);`no_viewport=True` + `--start-maximized`,无「先小后大」或右侧留白/scrollbar 后遗症(见 `RPA.md` §1.2.1)
|
||||
- [ ] A5-5 **P1** 验证码或人工确认:有等待与超时;超时后任务失败可理解,不无限挂死或假成功
|
||||
- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活
|
||||
- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在
|
||||
@@ -147,9 +150,11 @@ checklist_version: 1.0
|
||||
- [ ] A6-10 **P1** 有 HTML 侧栏预览时(S0-12):预览区横向铺满、无横向滚动条;重新生成后内容与页头时间更新;布局符合 `references/ACTIONS.md`「宿主预览布局」。无 HTML 报告 → **N/A**
|
||||
- [ ] A6-11 **P1** 业务**状态 / 启用类**列若用户需要筛表或顶栏搜中文:`searchable: 1` 且 `options_json`(或 `boolean` + `options_json`)的 **value 与存库一致**;仅 `searchable: 0` 则宿主不提供列筛与全局搜索。见 `references/SCHEMA.md`「列筛选与全局搜索」
|
||||
- [ ] A6-12 **P2** `display_type: boolean` 的展示列推荐同时配置 `options_json` 中文 label(如 停用/启用),避免仅靠宿主默认「是/否」;无 boolean 列 → **N/A**
|
||||
- [ ] A6-13 **P2** 账期/月份/生效月等 `YYYY-MM` 字段用 `display_type: year_month`(或 Action 同义 `format`),**不要**只用 `description` 写格式却留纯文本框;完整日才用 `date`
|
||||
- [ ] A6-13 **P0** 新技能**不得**创建 `task_logs`;过程/进度/业务结果走运行日志、任务中心、业务表(见 `LOGGING.md`、`SCHEMA.md`)
|
||||
- [ ] A6-14 **P0** 废弃用户可见表时:`drop_deprecated_user_table` 物理删表 + 清元数据;**禁止**只删元数据留物理表;有迁移时须提供旧库回归测试(见 `SCHEMA.md`、`tests/samples/test_table_deprecation_migration.py.sample`)
|
||||
- [ ] A6-15 **P1** 若启用 `cellPresentation`:旁路表不得出现在数据管理侧栏;整行着色不得铺到详情每个字段;禁止 `all`×`all`;tone 闭集。未启用 → **N/A**(见 `references/CELL_PRESENTATION.md`)
|
||||
- [ ] A6-16 **P0** 若走 **MySQL 云库**:用户目录 `data-sources.json` + `.env` DSN 已配置;Host ≥ 6.9.28;CLI / Action 与数据管理 UI 读写**同一库**(抽测 list/create);业务 DDL 支持所选后端。纯 SQLite → **N/A**(见 `references/DATA_SOURCES.md`)
|
||||
|
||||
---
|
||||
|
||||
@@ -192,6 +197,7 @@ checklist_version: 1.0
|
||||
- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免)
|
||||
- [ ] A9-3 **P0** **NEED_MANUAL** 入口定位表中定为「挂」的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过;未在定位表中规划且未声明的入口可标 N/A(须引用定位表理由),**禁止**用「未声明」掩盖「从未做入口定位」
|
||||
- [ ] A9-4 **P0** 对外版本一致(POLICY-RELEASE-001):`SKILL.md` version = `SKILL_VERSION` = 发布 git tag(无前缀);`release.ps1` 打 tag 前会硬校验,漏改不得发布;CHANGELOG 有对应小节
|
||||
- [ ] A9-4b **P1** 本机宿主验收用根目录 `.\sync.ps1` 覆盖已安装明文;市场上的最新版本和加密包仍走 `.\release.ps1`。同步后退出资料管理再打开,不要点技能市场「更新」
|
||||
- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
|
||||
|
||||
---
|
||||
|
||||
@@ -26,6 +26,45 @@
|
||||
|
||||
**不要**依赖 `# ── 分组 ──` 装饰线给整组做唯一说明:宿主只会把行头挂到**紧挨着的下一个活跃 key**,后面的 key 不会自动继承。分组可用空行区分;**逐 key 自带行头(+ 行尾)才是硬要求**。
|
||||
|
||||
### 必填项:`# @required`(匠厂配置管理)
|
||||
|
||||
匠厂宿主「数据管理 → 配置管理」支持把部分配置项标为**必填**:模板在**行头注释上方**加独立一行 `# @required`,宿主会在 key 前显示红色 `*`,并在键值模式保存时拦截空值。
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **写法** | `# @required` 单独占一行,紧挨在该 key 的**中文行头**上方 |
|
||||
| **语义** | 保存时该 key 的值不得为空(trim 后);模板里可以有默认值 |
|
||||
| **展示** | `@required` 行本身**不展示**给用户,只用于打标 |
|
||||
| **声明位置** | 只在仓库 `.env.example` 声明;用户数据目录 `.env` 里的 `@required` 不算 |
|
||||
|
||||
正例:
|
||||
|
||||
```ini
|
||||
# @required
|
||||
# 目标网站地址:技能要访问的网站或服务地址
|
||||
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改
|
||||
|
||||
# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名)
|
||||
DEFAULT_LOGIN_ID= # 留空=按平台自动选用;本项未标 @required,宿主不强制
|
||||
```
|
||||
|
||||
反例(禁止):
|
||||
|
||||
```ini
|
||||
# 目标网站地址(必填)
|
||||
TARGET_BASE_URL= # @required ← 禁止写在行尾
|
||||
|
||||
# @required
|
||||
TARGET_BASE_URL= # ← 禁止:@required 下方缺少中文行头
|
||||
|
||||
# @required
|
||||
# 共用说明
|
||||
KEY_A=
|
||||
KEY_B= # ← 禁止:一个 @required 给多个 key 共用
|
||||
```
|
||||
|
||||
**机器门禁**:`POLICY-CONFIG-005` 校验 `@required` 用法;与 `POLICY-CONFIG-004`(行头/行尾中文)一并生效。
|
||||
|
||||
### 必须做到
|
||||
|
||||
1. 每个会进入配置管理的活跃 `KEY=` 都有完整中文**行头**。
|
||||
@@ -191,8 +230,25 @@ config.get_float("STEP_DELAY_MIN", 1.0)
|
||||
- `python scripts/main.py doctor`:检查浏览器、Profile/账号、`.env` 落盘、设备等。
|
||||
- `python scripts/main.py setup`:初始化演示账号、首次落盘 `.env`。
|
||||
|
||||
## MySQL 云库(可选)
|
||||
|
||||
技能若走 MySQL 云库(匠厂 Host **≥ 6.9.28**),须同时满足:
|
||||
|
||||
1. 用户数据目录有 [`data-sources.json`](../references/DATA_SOURCES.md)(声明 `kind: mysql` 与 `connectionEnv`)
|
||||
2. `.env` 配置对应 DSN,模板默认为 `MYSQL_DSN`
|
||||
|
||||
```env
|
||||
# MySQL 云库连接(留空=本地 SQLite)
|
||||
MYSQL_DSN=mysql://user:password@host:3306/database_name
|
||||
```
|
||||
|
||||
**不要**把 DSN 写进 `data-sources.json`。技能 CLI 通过 `scripts/db/backend.py` 读取同一 key,与宿主 UI 保持一致。
|
||||
|
||||
完整契约见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md)。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `DATA_SOURCES.md` — 多数据源声明(SQLite 默认 / MySQL opt-in)
|
||||
- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释)
|
||||
- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收
|
||||
- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释)
|
||||
|
||||
@@ -52,8 +52,9 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
|
||||
| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` |
|
||||
| `rpa-artifacts/` | RPA 失败截图、录屏中间产物 | `get_rpa_artifacts_dir(batch_id)` |
|
||||
| `videos/` | RPA 录屏成片 MP4 | `get_videos_dir()` |
|
||||
| `{skill_slug}.db` | SQLite | `get_db_path()` |
|
||||
| `.env` | 用户配置 | `config.get_env_file_path()` |
|
||||
| `{skill_slug}.db` | SQLite(默认本地库) | `get_db_path()` |
|
||||
| `data-sources.json` | 声明式数据源(可选;不配则宿主自动扫描 `.db`) | 用户目录根;格式见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md) |
|
||||
| `.env` | 用户配置(含可选 `MYSQL_DSN`) | `config.get_env_file_path()` |
|
||||
|
||||
复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ your-skill/
|
||||
├─ CHANGELOG.md
|
||||
├─ requirements.txt
|
||||
├─ release.ps1
|
||||
├─ sync.ps1
|
||||
└─ SKILL.md
|
||||
```
|
||||
|
||||
@@ -85,6 +86,8 @@ your-skill/
|
||||
- `DEMO.md`:市场 **演示** Tab(`demo_video_url` + `demo_md`)
|
||||
- `CHANGELOG.md`:市场 **更新日志** Tab(按版本小节同步 `changelog`)
|
||||
- `SKILL.md`:面向 LLM / 平台的技能入口
|
||||
- `release.ps1`:发布到服务器(提交、tag、CI 加密包)
|
||||
- `sync.ps1`:把明文同步到本机宿主,供界面验收
|
||||
- `references/`:Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA)
|
||||
- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范
|
||||
- `scripts/`:放真正的代码
|
||||
@@ -115,8 +118,8 @@ scripts/
|
||||
不写具体业务逻辑
|
||||
|
||||
- `db/`
|
||||
作用:SQLite 连接、建表、repository
|
||||
不写页面操作和业务编排
|
||||
作用:数据库连接(默认 SQLite;配 `MYSQL_DSN` 时为 MySQL)、建表、repository
|
||||
不写页面操作和业务编排;多数据源契约见 [`../references/DATA_SOURCES.md`](../references/DATA_SOURCES.md)
|
||||
|
||||
- `service/`
|
||||
作用:核心业务逻辑
|
||||
@@ -173,6 +176,7 @@ scripts/
|
||||
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.11`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
|
||||
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
|
||||
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
|
||||
- 根目录 `sync.ps1` 是开发者手动执行的本机同步,允许用 `uv pip install` 把本技能 `requirements.txt` 装进宿主共享 venv。业务代码和其它脚本仍然不要自己装包。
|
||||
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
|
||||
- `pkg>=1.2.0,<2`
|
||||
- 对**原生扩展 / 高风险依赖**(如 `chromadb`、`onnxruntime`)建议 pin 或窄范围,避免无上限:
|
||||
@@ -747,6 +751,33 @@ python scripts/main.py <your-command>
|
||||
|
||||
详见 `TESTING.md`。
|
||||
|
||||
## 14.6 同步到本机宿主
|
||||
|
||||
`sync.ps1` 和 `release.ps1` 并排,都在技能根目录执行。`release.ps1` 发布到服务器并触发加密打包;`sync.ps1` 把当前明文覆盖到本机已安装的技能目录,用来在宿主里验收,不必每次都走服务器构建。
|
||||
|
||||
在技能根目录:
|
||||
|
||||
```powershell
|
||||
.\sync.ps1
|
||||
.\sync.ps1 -DryRun
|
||||
.\sync.ps1 -SkipDeps
|
||||
.\sync.ps1 -ForceDeps
|
||||
.\sync.ps1 -SkipInitDb
|
||||
```
|
||||
|
||||
脚本会:
|
||||
|
||||
- 按 `SKILL.md` 的 slug 镜像到 `%USERPROFILE%\.openclaw\skills\<slug>`,排除 `.git` 等开发目录
|
||||
- 去掉安装目录里残留的 `pyarmor_runtime_*`
|
||||
- 把 `requirements.txt` 装进宿主共享 venv(与宿主安装技能同一条路径)。依赖未变时可跳过;`-SkipDeps` 只跳过安装,仍会跑 `init-db`
|
||||
- 用宿主 `settings.json` 里的当前登录用户跑 `scripts\main.py init-db`。没有登录用户时停止,不会猜用户 ID
|
||||
|
||||
同步完成后:先停掉该技能正在跑的任务,退出资料管理再打开。不要点技能市场的「更新」,那会用加密包装掉这份明文。
|
||||
|
||||
技能至少要从市场安装过一次,宿主才会启用它。`sync.ps1` 覆盖的是已经装上的那一份。市场上的「最新版本」仍只在 `release.ps1` 的 CI 成功后变化;本机「当前版本」来自这次同步写进去的 `SKILL.md`。
|
||||
|
||||
`sync.ps1` 须为 UTF-8 无 BOM,且不要写中文:Windows PowerShell 5.1 会把无 BOM 的中文脚本解析错。
|
||||
|
||||
## 15. 发布到正式环境验证
|
||||
|
||||
### release workflow 通道
|
||||
@@ -925,7 +956,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
每个新 skill 发布前,建议技术人员逐条确认:
|
||||
|
||||
- [ ] 目录结构符合当前模板
|
||||
- [ ] slug 符合 [`NAMING.md`](NAMING.md)(verb-noun-platform)
|
||||
- [ ] slug 符合 [`NAMING.md`](NAMING.md)(动词-名词,平台可选;不要用 `default` 占位)
|
||||
- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致
|
||||
- [ ] `SKILL.md` 中 slug、名称、描述都已替换
|
||||
- [ ] `SKILL.md` 的 `developer_ids` 已换成**本人**匠厂用户 ID(设置 → 用户信息 → 复制),不是模板示例 ID
|
||||
@@ -936,6 +967,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
- [ ] 没有残留旧平台名
|
||||
- [ ] `health` / `version` 可运行
|
||||
- [ ] `.env.example` 注释已按 [`CONFIG.md`](CONFIG.md)「行头 = 是什么、行尾 = 怎么填」写全(面向配置管理用户;无 `见 development/...` 等开发文档指向)
|
||||
- [ ] 业务必填项已在 `.env.example` 用独立一行 `# @required` 标记(见 [`CONFIG.md`](CONFIG.md) §必填项);未标则宿主不显示红色 `*`
|
||||
- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去
|
||||
- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag)
|
||||
- [ ] `.github/workflows/release_skill.yaml` 存在
|
||||
|
||||
@@ -13,23 +13,24 @@
|
||||
| ❌ **不修改** | 其他仓库(尤其 `account-manager`)不在本文约束范围内 |
|
||||
| ✅ **模板占位** | 模板本体的 `your-skill-slug` 占位必须继续通过全部测试 |
|
||||
|
||||
**新技能勿仿**历史 2 段无平台形态(如 `reconcile-finance`)或不符合 verb-noun-platform 的旧 repo 名;校验器对历史 repo **不追溯**,但规范中明确标注为反模式。
|
||||
**新技能**使用「动词-名词」,外部平台可选。不要再为了凑段数追加 `default`。校验器不追溯已上线仓库;已经发布、末尾是 `default` 的技能不用改名。
|
||||
|
||||
---
|
||||
|
||||
## 2. 命名格式
|
||||
|
||||
标准形态(约 95% 新技能):
|
||||
标准形态:
|
||||
|
||||
```text
|
||||
{verb}-{noun-phrase}-{platform}
|
||||
{verb}-{noun-phrase}
|
||||
{verb}-{noun-phrase}-{platform} # 仅当技能绑定外部平台时
|
||||
```
|
||||
|
||||
| 部分 | 规则 |
|
||||
|------|------|
|
||||
| **verb** | 1 个词,来自 [动词白名单](#5-动词白名单) |
|
||||
| **noun-phrase** | 1~3 个词(按 `-` 分段),描述业务对象 |
|
||||
| **platform** | 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) |
|
||||
| **platform** | **可选**。没有抖音、小红书、亚马逊这类外部系统时省略。有平台时 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) |
|
||||
|
||||
**展示层分工不变:**
|
||||
|
||||
@@ -44,8 +45,8 @@
|
||||
|
||||
| 约束 | 值 |
|
||||
|------|-----|
|
||||
| 格式 | `{verb}-{noun-phrase}-{platform}`(标准型,默认) |
|
||||
| 总段数(按 `-` 分割) | **3~5 段**(标准型);scope 型见 [§4](#4-三类合法形态) |
|
||||
| 格式 | `{verb}-{noun-phrase}`;有外部平台时才加 `-{platform}` |
|
||||
| 总段数(按 `-` 分割) | **2~5 段** |
|
||||
| 总长度 | **≤ 48 字符** |
|
||||
| 字符集 | 小写字母、数字、连字符;kebab-case |
|
||||
| 禁止 | 中文 slug、下划线、**平台放最前**(如 `amazon-download-xxx`) |
|
||||
@@ -56,12 +57,26 @@
|
||||
|
||||
## 4. 三类合法形态
|
||||
|
||||
### 4.1 标准型(95%)
|
||||
### 4.1 无平台(常见)
|
||||
|
||||
`verb-noun-platform` 或 `verb-noun-noun-platform`(3~5 段)
|
||||
`verb-noun` 或 `verb-noun-noun`(2~4 段)
|
||||
|
||||
- 第 1 段 ∈ 动词白名单
|
||||
- 最后 1 段(或 hyphenated 平台的最后 2 段)∈ 平台白名单
|
||||
- 其余为 noun-phrase(1~3 段)
|
||||
- **不要**追加 `default`
|
||||
|
||||
示例:
|
||||
|
||||
- `generate-monthly-statements`
|
||||
- `reconcile-finance`
|
||||
- `process-article`
|
||||
|
||||
### 4.2 标准型(绑定外部平台)
|
||||
|
||||
`verb-noun-platform`(在无平台形态后加平台,总段数仍 ≤ 5)
|
||||
|
||||
- 第 1 段 ∈ 动词白名单
|
||||
- 最后 1 段(或 hyphenated 平台的最后若干段)∈ 平台白名单
|
||||
- 中间为 noun-phrase(1~3 段)
|
||||
|
||||
示例:
|
||||
@@ -71,19 +86,19 @@
|
||||
- `enter-sales-receipt-kingdee`
|
||||
- `fill-export-declaration-single-window`
|
||||
|
||||
### 4.2 scope 型(少见)
|
||||
### 4.3 scope 型(少见)
|
||||
|
||||
`verb-noun-scope`
|
||||
|
||||
- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi` | `default`
|
||||
- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi`
|
||||
- 第 1 段 ∈ 动词白名单
|
||||
- 总段数 3~5
|
||||
- 至少还有一段名词(因此最少 3 段)
|
||||
|
||||
示例:`generate-article-all`
|
||||
|
||||
> 说明:`geo-generate-article-all` 若作为新技能且不在豁免表、首段 `geo` 不在动词表,应写为 `generate-article-all`。
|
||||
> `default` 已从 scope 词表移除。它不是平台,也不表示范围。新技能不要写 `generate-article-default` 这类占位。已发布且末尾为 `default` 的仓库保持原 slug;同步本校验后只会警告,不会因此失败。
|
||||
|
||||
### 4.3 历史豁免(不校验语义)
|
||||
### 4.4 历史豁免(不校验语义)
|
||||
|
||||
以下 slug 跳过 verb/platform 语义校验(见 `LEGACY_EXEMPT_SLUGS`):
|
||||
|
||||
@@ -121,7 +136,7 @@
|
||||
| lianlian | single-window | export-rebate |
|
||||
| etax | | |
|
||||
|
||||
scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`、`default`
|
||||
scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`。不含 `default`。
|
||||
|
||||
---
|
||||
|
||||
@@ -164,7 +179,7 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
|
||||
## 9. 复制新技能流程
|
||||
|
||||
1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符
|
||||
1. **先定 slug** — `{verb}-{noun-phrase}`,仅在绑定外部平台时加 `-{platform}`。确认动词/平台在白名单内且 ≤ 48 字符。不要用 `default` 占位。
|
||||
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`、文档中的占位
|
||||
@@ -179,10 +194,12 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
|
||||
| slug | 说明 |
|
||||
|------|------|
|
||||
| `download-settlement-report-amazon` | 标准型,4 段 |
|
||||
| `add-product-shopee` | 标准型,3 段 |
|
||||
| `enter-sales-receipt-kingdee` | 标准型 |
|
||||
| `fill-export-declaration-single-window` | 标准型,平台含连字符 |
|
||||
| `generate-monthly-statements` | 无平台 |
|
||||
| `reconcile-finance` | 无平台,动词 + 一个名词 |
|
||||
| `download-settlement-report-amazon` | 有平台 |
|
||||
| `add-product-shopee` | 有平台 |
|
||||
| `enter-sales-receipt-kingdee` | 有平台 |
|
||||
| `fill-export-declaration-single-window` | 有平台,平台含连字符 |
|
||||
| `generate-article-all` | scope 型 |
|
||||
|
||||
### ❌ 反例
|
||||
@@ -191,7 +208,7 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
|------|------|
|
||||
| `amazon-download-settlement-report` | 平台在最前 |
|
||||
| `enter-cross-border-sales-receipt-voucher-kingdee` | 过长、noun 臃肿 |
|
||||
| `reconcile-finance` | 新技能禁止 2 段无平台 |
|
||||
| `generate-article-default` | `default` 不是平台;应写成 `generate-article` |
|
||||
| `download_settlement_amazon` | 非 kebab-case(下划线) |
|
||||
|
||||
---
|
||||
@@ -201,14 +218,14 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
- **平台前置**:`amazon-download-...`、`shopee-add-...`
|
||||
- **英文占位 name**:复制后仍保留 `My Skill` 或纯英文 slug 风格名称
|
||||
- **只改目录名**:`SKILL.md` slug 与 `constants.SKILL_SLUG` 未同步
|
||||
- **noun 堆砌**:中间段过多导致超 48 字符或难以阅读
|
||||
- **仿历史 2 段 repo**:`reconcile-finance`、`content-manager` 等
|
||||
- **用 default 占位**:`generate-article-default`。没有外部平台就省略末段
|
||||
- **名词堆砌**:中间段过多导致超 48 字符或难以阅读
|
||||
|
||||
---
|
||||
|
||||
## 12. 演进说明(当前不启用)
|
||||
|
||||
当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**,新技能一律使用本文 verb-noun-platform 标准型或 scope 型。
|
||||
当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**。新技能使用动词-名词;只有绑定外部平台时才使用 verb-noun-platform,或使用 scope 型。
|
||||
|
||||
---
|
||||
|
||||
@@ -218,9 +235,9 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
|
||||
| 历史 slug | 说明 |
|
||||
|-----------|------|
|
||||
| `account-manager` | 跨平台账号管理,非 verb-noun-platform |
|
||||
| `reconcile-finance` | 2 段无 platform |
|
||||
| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家) |
|
||||
| `account-manager` | 跨平台账号管理,非动词-名词 |
|
||||
| `reconcile-finance` | 现已是合法的无平台形态;旧文档曾把它标成反例 |
|
||||
| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家);新技能若要表达链家,先把平台加入白名单 |
|
||||
| `skill-template` | 模板仓库本身 |
|
||||
|
||||
校验器通过 `LEGACY_EXEMPT_SLUGS` 对模板占位与少数豁免 slug 跳过语义检查;**新复制出去的业务 skill 不在豁免表内**,必须满足 §3 硬约束。
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
| POLICY-RUNTIME-002 | `requirements.txt` 非注释依赖行不得声明 `jiangchang-platform-kit` / `playwright` | development/RUNTIME.md §共享 Python Runtime;development/DEVELOPMENT.md §3.1 requirements.txt 依赖规范 | hard | 解析依赖行 | `tests/test_development_policy_guard.py::TestPolicyRuntime002`(另见 `tests/test_platform_import.py`) |
|
||||
| POLICY-RUNTIME-003 | Agent/CLI 文档须要求共享 Python(python-runtime / PATH 上的 `python` + `{baseDir}`);须明确**禁止**对技能脚本使用 `uv run python` | development/RUNTIME.md;references/CLI.md;SKILL.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_cli_md_requires_shared_python_not_uv_run`;`tests/test_docs_standards.py::test_skill_md_requires_shared_python_not_uv_run` |
|
||||
| POLICY-AGENT-001 | `SKILL.md` 须含「用户意图路由表」结构;`CLI.md` 须含短 CLI 白名单 / Action 强制黑名单表;用法回答须禁止无故粘贴 CLI、禁止为答用法列目录挖 references | SKILL.md;references/CLI.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_skill_md_has_intent_routing_table`;`tests/test_docs_standards.py::test_cli_md_has_agent_routing_tables`;`tests/test_docs_standards.py::test_skill_md_how_to_forbids_cli_paste` |
|
||||
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`) | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
|
||||
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`;根目录 `sync.ps1` 除外,它是开发者手动执行的本机同步,用 `uv pip install` 装进宿主共享 venv) | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
|
||||
| POLICY-CONFIG-001 | 仓库根目录必须存在 `.env.example` | development/CONFIG.md §核心原则、§标准配置项 | hard | 文件存在性 | `tests/test_development_policy_guard.py::TestPolicyConfig001` |
|
||||
| POLICY-CONFIG-002 | `.gitignore` 必须忽略 `.env` 与 `*.env.local` | development/CONFIG.md §红线:敏感信息不进 .env | hard | 解析 `.gitignore` 行 | `tests/test_development_policy_guard.py::TestPolicyConfig002` |
|
||||
| POLICY-CONFIG-003 | 业务代码不得直接 `os.environ` / `os.getenv` / `os.putenv` / `environ.get`;须经 `jiangchang_skill_core.config.get*`(允许 `main.py`、测试、config_bootstrap/runtime 边界文件) | development/CONFIG.md §配置 bootstrap;development/ADAPTER.md §档位 dispatch | hard | 扫描 `scripts/**/*.py` 并排除边界文件 | `tests/test_development_policy_guard.py::TestPolicyConfig003` |
|
||||
| POLICY-CONFIG-004 | `.env.example` 每个活跃 `KEY=` 必须有中文**行头**(是什么)与中文**行尾**(怎么填);注释禁止 `development/`、`*.md` 文档指向、`account-manager`、`adapter 档位` 等开发黑话 | development/CONFIG.md §.env.example 注释 = 配置管理用户文案 | hard | 解析 `.env.example` 活跃行与注释 | `tests/test_development_policy_guard.py::TestPolicyConfig004` |
|
||||
| POLICY-CONFIG-005 | 若 `.env.example` 使用 `# @required`,须单独占一行且在下一行中文行头之上;禁止写在行尾;禁止无中文行头或多 key 共用 | development/CONFIG.md §必填项:# @required | hard | 解析 `.env.example` 活跃行与 `@required` 标记 | `tests/test_development_policy_guard.py::TestPolicyConfig005` |
|
||||
| POLICY-TESTING-001 | 默认根目录 `tests/test_*.py` 不得硬编码 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`(白名单仅 `test_adapter_profile_policy.py`,用于档位 helper 自测) | development/TESTING.md §默认必跑测试要做什么、§4 测试目标档位 | hard | 扫描 `tests/test_*.py` 非注释行;白名单外禁止上述文本 | `tests/test_development_policy_guard.py::TestPolicyTesting001` |
|
||||
| POLICY-TESTING-002 | `tests/integration/` 下 Python 测试文件若存在,默认应为 `.sample`,不得进入默认套件 | development/TESTING.md §7 真实联调测试的安全约束、§8 新 skill 的最小测试清单 | hard | 检查 `tests/integration/*.py` 无 `.sample` 后缀 | `tests/test_development_policy_guard.py::TestPolicyTesting002` |
|
||||
| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖;development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py`) |
|
||||
@@ -22,6 +23,7 @@
|
||||
| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器;实现上应走 ensure-web);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` |
|
||||
| POLICY-RPA-005 | 必须保留 AI + RPA 总纲,明确 Agent / RPA 职责、授权范围、禁止破解/逆向/私有协议/安全验证绕过、人工介入、状态等待;真实业务默认动作节奏为 1~3 秒 | development/AI_RPA.md §4–§6;development/RPA.md §0.0/§0.3 | hard(文档与默认配置) / soft(真实行为) | 检查总纲关键边界;解析 `.env.example` 的 `STEP_DELAY_MIN=1.0` / `STEP_DELAY_MAX=3.0` | `tests/test_development_policy_guard.py::TestPolicyRpa005` |
|
||||
| POLICY-RPA-006 | 浏览器 RPA 必须保留 account-manager 生命周期标准;所有样例账号选择路径都须返回非空 `profile_dir + lease_token`,指定数字账号 ID 不得退化为无租约 `account get` | development/ACCOUNT_MANAGER.md;development/ADAPTER.md §兄弟依赖;development/RPA.md §0.2 | hard | 检查标准文档关键标记;扫描真实/仿真 `account_client.py` 的指定 ID 带租约实现并禁止空租约赋值 | `tests/test_development_policy_guard.py::TestPolicyRpa006` |
|
||||
| POLICY-RPA-007 | 网页 RPA 启动须 `no_viewport=True` + `--start-maximized`;禁止 `viewport=None`、CDP/JS 中途改窗;`RPA.md` §1.2.1 须保留窗口标准 | development/RPA.md §1.2.1;development/RPA_BROWSER.md §4;examples/real_browser_rpa | hard | 文档标记 + 扫描含 `launch_persistent_*` 的 Python 禁止模式;校验 real_browser 示例 | `tests/test_development_policy_guard.py::TestPolicyRpa007` |
|
||||
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
|
||||
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
|
||||
| POLICY-RELEASE-001 | 发布 tag(去掉前缀)必须等于 `SKILL.md` 的 `version:` 且等于 `scripts/util/constants.py` 的 `SKILL_VERSION`;`release.ps1` 打 tag 前硬失败,不自动改写字段 | development/DEVELOPMENT.md §15 发布;宿主市场「最新版本」来自 tag、「当前版本」来自包内 `SKILL.md` | hard | `release.ps1` 门禁 + 日常版本字段对齐测试 | `release.ps1`(`Assert-SkillReleaseVersionAlignment`);`tests/test_development_policy_guard.py::TestPolicyRelease001`;`tests/test_skill_metadata.py::test_skill_version_alignment` |
|
||||
@@ -60,7 +62,7 @@
|
||||
| 是否满足完整快照语义 / 空结果保护实现 | references/SCHEMA.md | 见 `test_sync_contract.py.sample` |
|
||||
| 是否把系统导出与业务同步混淆 | references/ACTIONS.md;SCHEMA.md | 文档 + sample;勿做源码关键词误报扫描 |
|
||||
| `db/` 层不得调浏览器或拼业务流程 | development/DEVELOPMENT.md §11 | 需跨文件调用图 |
|
||||
| slug 命名语义(verb-noun-platform) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy |
|
||||
| slug 命名语义(动词-名词,平台可选) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy |
|
||||
| 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 |
|
||||
| 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` |
|
||||
| RPA 真实轨迹、选择器纪律、状态等待、HITL 超时与有限重试是否正确实现 | development/AI_RPA.md;development/RPA.md §0–§1 | 文档和默认节奏已守护,真实行为与 DOM 质量仍需人工验收 |
|
||||
|
||||
@@ -189,18 +189,35 @@ BrowserType.launch_persistent_context: Arguments can not specify page to be open
|
||||
|
||||
**正确做法(生产路径):**
|
||||
|
||||
1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, ...)`
|
||||
1. `launch_persistent_context(user_data_dir=profile_dir, executable_path=chrome, args=chrome_args, locale="zh-CN", no_viewport=True, ...)`
|
||||
2. `page = await context.new_page()`
|
||||
3. `await page.goto(start_url, wait_until="domcontentloaded", timeout=60000)`
|
||||
|
||||
对强风控站点,如果需要更拟人,可以启动后通过**地址栏输入 / 真实点击进入**目标页,但**仍然不能把 URL 塞进 launch args**。
|
||||
|
||||
#### 1.2.1 窗口与 viewport(`POLICY-RPA-007`)
|
||||
|
||||
persistent profile 会记住上次窗口大小;**最大化与 layout 同步**须按下列标准,避免「先小后大、右侧留白、scrollbar 后遗症」:
|
||||
|
||||
| 必须 | 禁止 |
|
||||
|------|------|
|
||||
| `no_viewport=True`(页面 layout 跟随真实窗口) | `viewport=None` 或固定 `viewport={width,height}` 冒充全屏 |
|
||||
| launch args 含 `--start-maximized` | CDP `Browser.setWindowBounds` / JS `resizeTo` **中途改窗** |
|
||||
| 真实站:`browser_session` 内 **启动后尽快 `goto` 业务页** | 长时间停空白页再导航、goto 后再 CDP 最大化 |
|
||||
| 优先复制 `examples/real_browser_rpa/scripts/service/browser_session.py` | 自造 maximize / CDP 窗口 hack |
|
||||
|
||||
说明:
|
||||
|
||||
- **`no_viewport=True` ≠ `viewport=None`**。后者在 Playwright 中不等于「跟窗口走」,易导致 OS 窗口已最大化但页面仍按窄 viewport 排版。
|
||||
- **仿真站**(`examples/simulator_browser_rpa`)可在 `browser_session` 只做 launch、由上层 `goto`;仍须 **`no_viewport=True`**。
|
||||
- 共享库 `jiangchang_skill_core.rpa.launch_persistent_browser` 已内置 `no_viewport=True`;自写 `browser_session` 时勿漏。
|
||||
|
||||
完整参考实现见:
|
||||
|
||||
- `examples/real_browser_rpa/README.md`(**开发真实浏览器 RPA 类 skill 时必须先读**)
|
||||
- `examples/real_browser_rpa/scripts/service/browser_session.py`
|
||||
|
||||
**生产路径不要依赖 CDP 接管现有页面**;CDP 仅用于诊断或桌面宿主已打开浏览器的场景。
|
||||
**生产路径不要依赖 CDP 接管现有页面**;CDP 仅用于诊断或桌面宿主已打开浏览器的场景(**含禁止用 CDP 最大化窗口**)。
|
||||
|
||||
### 1.3 HITL / 验证码
|
||||
|
||||
|
||||
@@ -90,6 +90,19 @@ button = row.get_by_role("button", name="添加")
|
||||
|
||||
Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。closed Shadow DOM 无法正常读取时,评估目标应用是否提供可访问性属性,不能注入破解。
|
||||
|
||||
**Web Component 双 input 陷阱**(常见于 `<mc-input>` 等宿主):
|
||||
|
||||
- 轻 DOM 内可能有 **hidden 同步用 input**(`aria-hidden="true"`、`tabindex="-1"`)。
|
||||
- 裸写 `host >> input` 时 Playwright 常默认匹配**第一个** hidden input,等 `visible` 会超时。
|
||||
- 做法:等 **host 可见**作 page ready;输入用 shadow 内真实字段(如 `>> #mc-input-track-input` 或 `>> input.input:not([aria-hidden="true"])`);或 click host + keyboard。
|
||||
|
||||
### Cookie / 同意条(可选出现)
|
||||
|
||||
Profile 已点过「允许全部」后,下次可能**不再出现** Cookie 条。不要无限等待:
|
||||
|
||||
- 短超时(约 2~3s)探测可见 → 点击;
|
||||
- 超时或无元素 → **skip 继续**,不要阻塞主流程。
|
||||
|
||||
### 动态列表
|
||||
|
||||
等待列表完成加载后再读取;`locator.all()` 不会等待动态列表稳定。滚动或刷新后重新获取 locator。
|
||||
@@ -109,6 +122,7 @@ Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。close
|
||||
## 6. 禁止模式
|
||||
|
||||
- 用全页所有 `input` 的尺寸和位置猜搜索框。
|
||||
- 对 Web Component 裸用 `host >> input` 且不等 shadow 内真实可编辑 input。
|
||||
- 用 `.first` 绕过严格模式。
|
||||
- 从 DevTools 复制深层 `nth-child` 或绝对 XPath。
|
||||
- 只等 URL 含 `search` 就判定结果成功。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| 文档 | 职责 |
|
||||
|------|------|
|
||||
| 本文 | sync/async、任务中心、Agent 禁令、单内核编排、队列 pick、同步 vs 导出 |
|
||||
| [`../references/ACTIONS.md`](../references/ACTIONS.md) | `actions.json` 字段、placements、`bind.tables`、`bind.selectionMode`、**用户可见文案**、inputSchema、跨表 Lookup、字段条件显隐 `visibleWhen`、本地路径 `file`/`directory` + 可选 **`pathRoot`**、选择摘要「已选择」与映射 **`readOnly` / `revealInForm`**、**按钮中文名 `label` 参考** |
|
||||
| [`../references/ACTIONS.md`](../references/ACTIONS.md) | `actions.json` 字段、placements、`bind.tables`、`bind.selectionMode`、`bind.visibleWhen`(行菜单显隐)、**用户可见文案**、inputSchema、跨表 Lookup(含分页勾选 `presentation: paged_table`)、字段条件显隐 `visibleWhen`、本地路径 `file`/`directory` + 可选 **`pathRoot`**、选择摘要「已选择」与映射 **`readOnly` / `revealInForm`**、**按钮中文名 `label` 参考** |
|
||||
| [`LOGGING.md`](LOGGING.md) | `job_context` / `emit` / `finish` / Run Journal |
|
||||
| [`DATA_PATHS.md`](DATA_PATHS.md) | 下载/导入等落盘路径 |
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。
|
||||
2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。
|
||||
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`hoverHint`**(见 ACTIONS.md):数据管理工具栏悬停;省略 = 不显示,不回退说明或确认黄条。可选 **`visibleWhen`**(见 ACTIONS.md):Action 参数表单字段条件显隐;省略 = 字段始终平铺;**勿与** Policy L3 行态 `visibleWhen` 混淆;**勿**用其藏映射主键。可选 **`pathRoot`**(见 ACTIONS.md):`file`/`directory` 原生选路对话框起始目录(相对技能数据根;省略默认 `{skill_data}/input`)。可选 **`bind.labelColumn`**(见 ACTIONS.md):选择摘要可读标签列;省略 = 宿主常见列名启发。可选 **`bind.selectionMode`**(见 ACTIONS.md):表勾选如何驱动 Action(`required` / `none` / `optional`);省略 = 宿主按 `$selection.ids` / `batch` 推断。可选 **`revealInForm`**(见 ACTIONS.md):有「已选择」时仍强制显示某 `readOnly` 字段;省略 = 映射只读默认不占 form 参数区(仍提交)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格/整行/整列/区域语义标色;省略 = P0。**用户可见文案**(见 ACTIONS.md「用户可见文案」):弹窗 `description` / 确认黄条 / 参数说明 / 任务失败 `message` 须全中文产品用语;Agent 路由勿塞进弹窗。
|
||||
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`hoverHint`**(见 ACTIONS.md):数据管理工具栏悬停;省略 = 不显示,不回退说明或确认黄条。可选 **表单 `visibleWhen`**(见 ACTIONS.md):Action 参数表单字段条件显隐;省略 = 字段始终平铺;**勿与** **`bind.visibleWhen`**(行菜单)或 Policy L3 混淆;**勿**用其藏映射主键。可选 **`pathRoot`**(见 ACTIONS.md):`file`/`directory` 原生选路对话框起始目录(相对技能数据根;省略默认 `{skill_data}/input`)。可选 **`bind.labelColumn`**(见 ACTIONS.md):选择摘要可读标签列;省略 = 宿主常见列名启发。可选 **`bind.selectionMode`**(见 ACTIONS.md):表勾选如何驱动 Action(`required` / `none` / `optional`);省略 = 宿主按 `$selection.ids` / `batch` 推断。可选 **`bind.visibleWhen`**(见 ACTIONS.md「行菜单显隐」):`row` 菜单按当前行列值显隐;省略 = 始终显示。可选 **`revealInForm`**(见 ACTIONS.md):有「已选择」时仍强制显示某 `readOnly` 字段;省略 = 映射只读默认不占 form 参数区(仍提交)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格/整行/整列/区域语义标色;省略 = P0。**用户可见文案**(见 ACTIONS.md「用户可见文案」):弹窗 `description` / 确认黄条 / 参数说明 / 任务失败 `message` 须全中文产品用语;Agent 路由勿塞进弹窗。
|
||||
4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。
|
||||
5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。
|
||||
6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- **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。
|
||||
- **开发规范守护(hard policy)**:[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) ——规则索引见 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md),只执行可机器检测的 hard 规则。
|
||||
- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——verb-noun-platform 命名规范校验。
|
||||
- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——动词-名词命名;平台可选,`default` 不再作为占位。
|
||||
- **HTML 报告壳**:[`tests/test_html_page_service.py`](../tests/test_html_page_service.py) 守护 `assets/html/example-report.html` 的数据管理 iframe 预览布局基线,并守护 ACTIONS.md 里的壳子页目录 `assets/html/pages` 与 `jiangchang.call`。业务技能若有自定义 `assets/html/*.html` 或 `pages/*.html`,建议在自有仓库加同类静态断言(见 `references/ACTIONS.md`「宿主预览布局」)。不要把 `example-report.html` 放进 `pages/`。
|
||||
|
||||
**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。
|
||||
|
||||
@@ -91,6 +91,7 @@ real_browser_rpa/
|
||||
- **不要**在技能内安装 Playwright;浏览器与 Python 包由宿主/runtime 提供
|
||||
- `launch_persistent_context` 的 `args` **只放 Chrome 参数**;**不要把 URL 放进 `args`**
|
||||
- 页面必须通过 `new_page()` + `goto()`,或通过真实地址栏/点击进入
|
||||
- **必须** `no_viewport=True` + `--start-maximized`;**禁止** `viewport=None` 与 CDP/JS 中途改窗(见 `development/RPA.md` §1.2.1、`POLICY-RPA-007`)
|
||||
|
||||
## 复制边界:哪些能复制,哪些必须替换
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ async def start_browser_session(
|
||||
"""
|
||||
启动持久化 Chrome profile,new_page + goto 入口页,返回 (playwright, context, page)。
|
||||
|
||||
窗口标准(POLICY-RPA-007):no_viewport=True + --start-maximized;勿用固定 viewport 或 CDP 改窗。
|
||||
真实 skill 需要安装 playwright 与 jiangchang_skill_core。
|
||||
"""
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
@@ -84,7 +84,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
| 字段 / 层 | 只决定什么 | 不决定什么 |
|
||||
|-----------|------------|------------|
|
||||
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 |
|
||||
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写 SQLite;**import 时 UI 仍用宿主「导入数据」面板** |
|
||||
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写**已注册数据源**(默认本地 SQLite);**import 时 UI 仍用宿主「导入数据」面板** |
|
||||
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
|
||||
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
|
||||
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
|
||||
@@ -107,10 +107,10 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
|
||||
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 |
|
||||
| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` \| `"import"` — 接管默认新增/编辑/删除/导入(须 `bind.tables`) |
|
||||
| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`、`columnMapping`、**`labelColumn`**、**`selectionMode`**(行内/批量预填、选择摘要标签、勾选驱动模式,见下节) |
|
||||
| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`、`columnMapping`、**`labelColumn`**、**`selectionMode`**、**`visibleWhen`**(行内/批量预填、选择摘要标签、勾选驱动模式、**行菜单显隐**,见下节) |
|
||||
| **policy** | 可选;`enabled` + `entrypoint`(推荐 `policy` CLI);见 POLICY.md。未启用 = L0 |
|
||||
| **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载格子与 grid 呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 |
|
||||
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly`、可选 **`revealInForm`**;可选 `display_type` / `format`(含 `file` / `directory` 等);可选 **`pathRoot`**(file/directory 起始目录,相对技能数据根);可选 **`visibleWhen`**(字段条件显隐,见专节) |
|
||||
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、可选 **`enumNames`**、`default`、`required`、`sensitive`、`readOnly`、可选 **`revealInForm`**;可选 `column`(绑列复用 options);可选 `display_type` / `format`(含 `file` / `directory` 等);可选 **`pathRoot`**(file/directory 起始目录,相对技能数据根);可选 **`visibleWhen`**(字段条件显隐,见专节) |
|
||||
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
|
||||
| **hoverHint** | 可选字符串。数据管理**工具栏悬停**文案;与 `description` / `confirmation.message` **独立**。省略则不显示悬停,不回退。短名已够懂时不要写。需宿主已支持该字段;当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出 |
|
||||
| **visibleWhen** | 可选。写在 **property** 上(非 action 顶层):依赖同表单其它字段值决定是否展示;隐藏字段不提交、不校验 `required`。**勿与** [`POLICY.md`](POLICY.md) L3 行态 `visibleWhen` 混淆;也**勿**用它藏「已选择」已表达的映射主键(见「选择摘要」) |
|
||||
@@ -133,7 +133,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
|
||||
规则:
|
||||
|
||||
- `bind` 为 object,`additionalProperties: false`;当前允许 `tables`(必填键当存在 bind 时)与可选 `inputMapping`、`columnMapping`、**`labelColumn`**、**`selectionMode`**。
|
||||
- `bind` 为 object,`additionalProperties: false`;当前允许 `tables`(必填键当存在 bind 时)与可选 `inputMapping`、`columnMapping`、**`labelColumn`**、**`selectionMode`**、**`visibleWhen`**。
|
||||
- `tables` 非空数组、元素唯一、英文 **snake_case**、不得为空字符串。
|
||||
- **`placements` 含 `toolbar` 时必须显式声明非空 `bind.tables`**(模板 Schema `if/then` + 自动测试强制)。
|
||||
- `row` / `batch` **建议**同样声明 `bind.tables`,避免在无关表上露出按钮;未声明时宿主可能按兼容逻辑在更多表上展示。
|
||||
@@ -143,8 +143,42 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
- **`columnMapping`(可选)**:把 inputSchema 属性名映射到物理列名,供表单复用列展示名 / lookup。
|
||||
- **`labelColumn`(可选)**:物理列名,供数据管理 Action 弹层「已选择」摘要展示 `#id + 标签`。**仅影响展示,不改变 CLI/提交 payload,也不决定业务是否跳过某些状态。** 省略时宿主用常见列名启发(如 `title` / `name` / `nickname` 等);再没有则只显示编号。列名不常见、或启发会选错列时再显式声明。
|
||||
- **`selectionMode`(可选)**:数据管理**表勾选**如何驱动本 Action(与 `placements` / `executionProfile` 正交)。见下节「勾选驱动模式」。
|
||||
- **`visibleWhen`(可选)**:`placements` 含 `row` 时,按**当前行原始列值**决定是否出现在行菜单 / 行按钮。见下节「行菜单显隐」。
|
||||
- `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。
|
||||
|
||||
### 数据管理:行菜单显隐 `bind.visibleWhen`
|
||||
|
||||
行级 Action 常需按业务状态显隐,例如:已采用不再出「采用」;仅已采用才出「加入待写」。**宿主已支持**写在 `bind.visibleWhen`(对当前行求值;不匹配则该行菜单不出现此项)。这是正式 Action 契约,**不是** Policy L3 预留,也**不是**表单字段 `inputSchema.properties.*.visibleWhen`。
|
||||
|
||||
| | `bind.visibleWhen` | 表单字段 `visibleWhen` | Policy L3(预留) |
|
||||
|--|--------------------|------------------------|-------------------|
|
||||
| 作用对象 | 行菜单 / 行按钮是否出现 | Action 参数表单里某个控件 | (未冻结)权限策略等 |
|
||||
| 求值上下文 | **当前行列值** | 当前表单参数值 | — |
|
||||
| 省略时 | 始终显示 | 字段始终显示 | — |
|
||||
|
||||
**写法(形状与表单字段相同):**
|
||||
|
||||
```json
|
||||
"bind": {
|
||||
"tables": ["long_tails"],
|
||||
"visibleWhen": { "status": "approved" },
|
||||
"inputMapping": { "longTailId": "$row.$pk" }
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
"visibleWhen": { "field": "status", "in": ["pending_review", "skipped"] }
|
||||
```
|
||||
|
||||
也支持 `{ "field": "status", "equals": "approved" }`。规则缺失或无法解析时宿主视为始终可见(兼容旧技能)。
|
||||
|
||||
**要点:**
|
||||
|
||||
1. 只影响 **UI 是否展示**;CLI / Agent 仍可直接调同一 entrypoint,技能侧仍应校验状态。
|
||||
2. 比较用行的**存库原始值**(如 `approved`),不是界面中文「已采用」。
|
||||
3. 顶栏 / batch 按钮不受 `bind.visibleWhen` 约束(无「当前行」);批量入口靠勾选 + 技能跳过非法状态。
|
||||
4. 示例:[`assets/examples/bind-visible-when-row.action.example.json`](../assets/examples/bind-visible-when-row.action.example.json)。
|
||||
|
||||
### 数据管理:勾选驱动模式 `bind.selectionMode`
|
||||
|
||||
同一业务能力应尽量用**一个** Action(多个 `placements`),避免「勾选下载」与「按数量处理队列」拆成两个同义顶栏按钮、却让用户误以为勾选生效。
|
||||
@@ -231,7 +265,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
|
||||
## `replaces`(可选:接管默认新增 / 编辑 / 删除 / 导入)
|
||||
|
||||
有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。
|
||||
有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写默认 CRUD。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。
|
||||
|
||||
### 怎么选
|
||||
|
||||
@@ -250,7 +284,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
2. **写库**按表能力分流:
|
||||
- 有 `replaces: import` → 把选中文件路径 + 出错规则交给该 Action(技能解析并写入)
|
||||
- 仅有 `replaces: create` → 宿主按行调用 create(兼容)
|
||||
- 都没有 → 宿主直写 SQLite
|
||||
- 都没有 → 宿主直写已注册数据源(默认 SQLite)
|
||||
|
||||
**不要**再做一张技能自己的「开始执行」导入表单来冒充导入体验。`placements` 对 import 保持 `[]`;`description` 写明「由宿主导入面板调用」。
|
||||
|
||||
@@ -403,13 +437,14 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**
|
||||
- 点默认「导入」时**固定**打开宿主导入面板;确认后若有 `replaces:import` 则调用该 Action(`interaction` 不决定是否再弹技能表单)。
|
||||
- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。
|
||||
- `import` 约定:`filePath`(或 `file`)+ 可选 `onError`(`skip`/`abort` 或上例中文枚举);技能自行解析文件与表头。
|
||||
- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏写按钮(优先于 `replaces`)。
|
||||
- **列元数据复用(lookup)**:Action 表单(含 toolbar/batch 弹窗与 create/update 抽屉)可将 `inputSchema` 字段对齐业务列,复用中文名与 lookup 下拉:
|
||||
- 优先让属性名 = 物理列名(如 `platform_key`);
|
||||
- 或 `bind.columnMapping`:`{ "platform": "platform_key" }`;
|
||||
- 或字段上写 `"column": "platform_key"`。
|
||||
- 当前表没有对应列时,用字段内联 `lookup`(见下文「跨表引用:Lookup」)。
|
||||
- **不要**在 actions.json 里写死会过期的全量平台 `enum`,也**不要**让用户手填内部编号。
|
||||
- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏宿主默认新增/编辑/导入与直写删除;**若声明了 `replaces: delete`,宿主仍显示「删除」并走技能 CLI**(不直写 SQLite)。
|
||||
- **列元数据复用**:Action 表单(含 toolbar/batch 弹窗与 create/update 抽屉)可将 `inputSchema` 字段对齐业务列,复用中文名与列上 **options**(静态枚举 `options_json` **数组**,或已解析的 lookup):
|
||||
- 优先让属性名 = 物理列名(如 `carrier_code` / `platform_key`);
|
||||
- 或 `bind.columnMapping`:`{ "carrierCode": "carrier_code" }`;
|
||||
- 或字段上写 `"column": "carrier_code"`(推荐:驼峰属性名时显式写出,避免只靠启发式)。
|
||||
- 当前表没有对应列、且选项是**固定小枚举**时:用 `enum` + **`enumNames`**(见下文「静态枚举 label」),**不要**为中文展示去造 Lookup。
|
||||
- 当前表没有对应列、且选项是**跨表/跨技能业务行**时:用字段内联 `lookup`(见下文「跨表引用:Lookup」)。
|
||||
- **不要**在 actions.json 里写死会过期的全量业务行 `enum`,也**不要**让用户手填内部编号。
|
||||
|
||||
## `interaction`(点击交互方式)
|
||||
|
||||
@@ -746,7 +781,7 @@ HINT: <next step>
|
||||
|
||||
### 行级入口 `row`(宿主呈现契约)
|
||||
|
||||
- **技能决定有什么**:`placements` 含 `"row"`(且通过 `bind.tables` / policy 等过滤)的 Action,构成该表行的技能菜单项集合;配什么就出现什么。
|
||||
- **技能决定有什么**:`placements` 含 `"row"`(且通过 `bind.tables` / policy 等过滤)的 Action,构成该表行的技能菜单项集合;再用可选 **`bind.visibleWhen`** 按行状态过滤(见「行菜单显隐」)。
|
||||
- **宿主决定怎么摆**:可用操作列、行右键、行内「⋯」等呈现**同一批** Action;点击均走同一 `launch`(`placement: "row"`),`interaction` / `executionProfile` 与壳无关。
|
||||
- **顺序**:各壳内技能项顺序 = 下方「数据管理技能按钮顺序」契约(`actions` 数组相对顺序)。
|
||||
- **宿主内置项**:查看 / 编辑 / 删除由宿主提供,**不是**技能 `actions.json` 配置出来的;若出现在行右键,须与技能项分组(例如分隔线后),且不得要求技能再配一份菜单 DSL。
|
||||
@@ -776,6 +811,7 @@ HINT: <next step>
|
||||
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
|
||||
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
|
||||
- 把「默认展示列」与 `visible=0` 硬隐藏混淆——默认展示列见 [`SCHEMA.md`](SCHEMA.md) 的 `default_visible_columns_json`。
|
||||
- 误以为 `visible=0` 的列不能出现在 `default_sort_json`——隐藏只影响展示/筛选/搜索;ORDER BY 仍可用非敏感物理列(含隐藏主键)。
|
||||
- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。
|
||||
|
||||
### Action 类型 × placements(常见示例,不是限制)
|
||||
@@ -909,14 +945,37 @@ const data = await jiangchang.call("方法名", { date: "2026-09-19" });
|
||||
|
||||
### 跨表引用:Lookup(宿主已支持;勿让用户填编号)
|
||||
|
||||
当 Action 参数要引用**同库另一张业务表**的一行(话术模板、平台、账号等)时,必须用 Lookup 下拉,**禁止**做成「请填写编号」的数字框(用户记不住内部 id,体验差)。
|
||||
当 Action 参数要引用**一行业务实体**(话术模板、平台、账号等)时,必须用 Lookup 下拉,**禁止**做成「请填写编号」的数字框(用户记不住内部 id,体验差)。
|
||||
|
||||
| 场景 | 怎么配 | 说明 |
|
||||
|------|--------|------|
|
||||
| 当前表已有同含义列,且列上已是 CRUD lookup | `column` / `bind.columnMapping`,或属性名 = 列名 | toolbar/batch 弹窗与 create/update 抽屉都会复用该列已解析的选项 |
|
||||
| 当前表没有该列(如在「潜客」表上选「话术模板」) | `display_type: "lookup"` + 字段内联 `lookup` 对象 | 打开表单时宿主按当前技能库解析选项 |
|
||||
#### 两种 Lookup 入口(勿混)
|
||||
|
||||
内联 `lookup` 与列元数据 `options_json` **同构**(见 [`SCHEMA.md`](SCHEMA.md)「跨表引用 lookup」):
|
||||
| 种类 | 声明位置 | 何时解析 | 允许的数据源 |
|
||||
|------|----------|----------|--------------|
|
||||
| **列 Lookup** | `_jiangchang_columns.display_type = lookup` + `options_json` 对象 | 拉表 schema 时 | **仅** `local_sql`(同库表) |
|
||||
| **Action Lookup** | `inputSchema.properties.*.lookup`(推荐同时 `display_type: lookup`) | **打开动作表单** / 字段因 `visibleWhen` 变为可见时 | `local_sql` / **`skill_cli`** / `external_api`(预留) |
|
||||
|
||||
固定小枚举仍用 `options_json` **数组**或 Action `enum`,**不走** Lookup 解析器。
|
||||
|
||||
#### Action Lookup 三种 source(匠厂宿主已统一)
|
||||
|
||||
| `source` | 何时用 | 核心字段 | 推断(可省略显式 `source`) |
|
||||
|----------|--------|----------|------------------------------|
|
||||
| `local_sql` | 选项在**本技能库**另一张表 | `ref_table` + `value_column` + `label_column` | 有 `ref_table` |
|
||||
| `skill_cli` | 选项在**兄弟/依赖技能**(如 account-manager 账号列表) | `skill` + `command`;可选 `args` / `items_path` / `value_column` / `label_column` | 有 `skill` / `skill_slug` |
|
||||
| `external_api` | 第三方 HTTP(预留) | `url`(或 `endpoint`) | 有 `url` / `endpoint` |
|
||||
|
||||
**选型:**
|
||||
|
||||
| 场景 | 怎么配 |
|
||||
|------|--------|
|
||||
| 当前表已有同含义列,且列上已是 CRUD lookup | `column` / `bind.columnMapping`,或属性名 = 列名(复用列上已解析的 **local_sql** 选项) |
|
||||
| 同库另一张表、当前表没有对应列 | Action 内联 `lookup` → `local_sql`(可只写 `ref_table`…) |
|
||||
| 跨技能列表(账号、批次、其它技能业务行) | Action 内联 `lookup` → **`skill_cli`**(`skill` + `command`) |
|
||||
| 固定三五个状态 | `enum` / 列 `options_json` 数组,**不要** Lookup |
|
||||
|
||||
**反模式(跨技能):** 在本技能再造一张「账号选项缓存表」再 `local_sql`——选项会过期,定时任务等入口也容易空下拉。应直接 `skill_cli` 调依赖技能。
|
||||
|
||||
#### `local_sql`(同库;与列 `options_json` 对象同构)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -953,29 +1012,124 @@ const data = await jiangchang.call("方法名", { date: "2026-09-19" });
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `ref_table` | 是 | 同库被引用表 |
|
||||
| `ref_table` | 是(local_sql) | 同库被引用表 |
|
||||
| `value_column` | 是 | 提交给 CLI 的值(业务 id / 编号) |
|
||||
| `label_column` | 是 | 主文案列 |
|
||||
| `extra_column` | 否 | 第三展示列(任意列名,不限「备注」) |
|
||||
| `filter_column` / `filter_value` | 否 | 等值过滤 |
|
||||
| `multiple` | 否 | `true` = 多选;省略/`false` = 单选(默认) |
|
||||
|
||||
**下拉/选项展示(宿主统一,技能只配列):** 单选为 `{value} · {label}`;有非空 `extra_column` 时为 `{value} · {label}({extra})`。例:`2 · 通用私信-留资`、`2 · 通用私信-留资(dm)`。
|
||||
列 Lookup 字段表与加载时机见 [`SCHEMA.md`](SCHEMA.md)「跨表引用 lookup」(**仅 local_sql**)。
|
||||
|
||||
#### `skill_cli`(兄弟/依赖技能 CLI)
|
||||
|
||||
打开表单时,宿主对声明的技能执行一次 **sync** CLI,从 **stdout JSON** 抽出选项列表(默认路径 `items`)。
|
||||
|
||||
```json
|
||||
{
|
||||
"accountId": {
|
||||
"type": "string",
|
||||
"title": "发布账号",
|
||||
"description": "不选则由系统自动分配。",
|
||||
"display_type": "lookup",
|
||||
"lookup": {
|
||||
"source": "skill_cli",
|
||||
"skill": "account-manager",
|
||||
"command": "list-json",
|
||||
"args": ["toutiao"],
|
||||
"value_column": "id",
|
||||
"label_column": "account_label"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `skill` / `skill_slug` | 是(二选一) | 兄弟技能 slug |
|
||||
| `command` / `cli_command` | 是(二选一) | CLI 子命令(argv 第一段) |
|
||||
| `args` | 否 | 追加参数;数组或逗号串 |
|
||||
| `value_column` | 否 | 默认 `id` |
|
||||
| `label_column` | 否 | 默认 `display_label` |
|
||||
| `extra_column` | 否 | 第三展示列 |
|
||||
| `items_path` | 否 | JSON 内数组点路径,默认 `items`;若 stdout 本身就是数组可省略 |
|
||||
| `multiple` | 否 | 同 local_sql |
|
||||
|
||||
**依赖技能侧约定:** 该命令须成功退出并打印可解析 JSON;宿主接受**顶层数组**,或对象内由 `items_path`(默认 `items`)指向的数组。元素须含 value/label 列。具体子命令与字段名以依赖技能文档为准(上例对标 account-manager 遗留 `list-json <platform>`,字段为 `id` / `account_label`;其它技能请核对 stdout)。
|
||||
|
||||
**与运行时分流:** 表单选账号用 `skill_cli` Lookup;真正开浏览器 / 占租约仍走本技能 `account_client`(见 [`development/ACCOUNT_MANAGER.md`](../development/ACCOUNT_MANAGER.md)),二者不要混成「表单打开就 pick-web」。
|
||||
|
||||
#### 展示、多选与可选空
|
||||
|
||||
**下拉/选项展示(宿主统一,技能只配列):** 单选为 `{value} · {label}`;有非空 `extra_column` 时为 `{value} · {label}({extra})`。列表/详情单元格优先 `listLabel`(不带 id 前缀)。例:`2 · 通用私信-留资`。
|
||||
|
||||
**多选存值与模板:** Action / CRUD 存 **`TEXT` 逗号分隔 id**(如 `1,2,5`);`{{engineIds}}` 原样替换进 CLI。多选字段在 `inputSchema` 里用 `type: "string"`(逗号串),不要用尚未稳定的 JSON array 当业务控件。
|
||||
|
||||
**多选控件(宿主策略):** 选项少(建议 ≤6)用勾选列表;选项多用字段内 Chip + 可搜索下拉(不是再开业务弹窗)。
|
||||
**多选控件(宿主策略):** 选项少(建议 ≤6)用勾选列表;选项多用字段内 Chip + 可搜索下拉(不是再开业务弹窗)。需要编号+文案的小表、翻页、本页全选时,用下面的 `paged_table`,不要再做一套控件。
|
||||
|
||||
#### 分页勾选小表(`presentation: "paged_table"`)
|
||||
|
||||
仍是 **`display_type: "lookup"`**。在已有 `lookup` 上增加 `presentation` 与可选 `page_size`。提交值与 `multiple: true` 相同:**TEXT 逗号分隔 id**。`local_sql` 与 `skill_cli` 都能用。列 Lookup(`_jiangchang_columns`)**不要**声明 `paged_table`。
|
||||
|
||||
```json
|
||||
{
|
||||
"articleIds": {
|
||||
"type": "string",
|
||||
"title": "选择文章",
|
||||
"description": "勾选要导入的文章。可翻页,可本页全选。",
|
||||
"display_type": "lookup",
|
||||
"lookup": {
|
||||
"source": "skill_cli",
|
||||
"skill": "geo-generate-article-all",
|
||||
"command": "title",
|
||||
"args": ["list-generated-json", "--platform", "toutiaohao"],
|
||||
"value_column": "id",
|
||||
"label_column": "title",
|
||||
"multiple": true,
|
||||
"presentation": "paged_table",
|
||||
"page_size": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `presentation` | 用小表时必填 | 只认 `"paged_table"`。省略或 `"select"` = 现有下拉 / 勾选 / Chip |
|
||||
| `page_size` | 否 | 每页条数,默认 10,限制 1–100 |
|
||||
| `multiple` | 建议 `true` | 小表按多选提交;一个都不勾时把字段放进 `required`,宿主拒绝空提交 |
|
||||
|
||||
**`skill_cli` 分页:** 宿主在 argv 后追加 `--page`、`--limit`。stdout **只有一行 JSON 对象**:`{"items":[{"id":"1","title":"标题"}],"total":42}`。`total` 是过滤后的总条数,不是本页条数。命令可以自己切片;若一次返回全量且带 `total`,宿主也会按页切开。
|
||||
|
||||
**空列表:** 分页小表把 `{"items":[],"total":0}` 且退出码 0 当成成功,界面显示「暂无可选项」。普通下拉 Lookup 仍把 0 条当成命令失败。不要为「没有数据」退出码 1,也不要往 stdout 打日志。
|
||||
|
||||
**同库过滤只有一列:** `local_sql` 的 `filter_column` / `filter_value` 只支持一个等值条件。要「已启用并且提示词齐全」这类两个条件,用一条只读 `skill_cli`,或先做成同库视图再 `local_sql`。同库、一个条件就能列出来的,优先 `local_sql`(打开即查库)。`skill_cli` 每次打开和翻页都会再起进程。
|
||||
|
||||
**只读列表命令要轻:** 供这张表调用的命令,在 `scripts/main.py` 分到该命令之前,不要 `import jiangchang_skill_core` 整包(包初始化会带上授权客户端),也不要导入 Playwright / 发布栈。0 条也打印空 `items` 后退出码 0。健康检查、真正执行任务仍走 `cli/app.py` 的 `setup_skill_logging`,不要把那条路径拆掉。
|
||||
|
||||
**反模式(在上一节之外再加):**
|
||||
|
||||
- 新造 `display_type: "paged_lookup"` 或另一套分页控件(应只加 `lookup.presentation`)
|
||||
- 列表命令在模块顶导入浏览器 / RPA / 整包 platform-kit,打开小表要等数秒
|
||||
- 没有数据时让命令失败,小表里出现英文报错而不是「暂无可选项」
|
||||
- 用两个 `filter_column` 表达 AND(宿主只认一个等值过滤)
|
||||
|
||||
形状参考:[`assets/examples/lookup-paged-table.action.example.json`](../assets/examples/lookup-paged-table.action.example.json)。
|
||||
|
||||
**可选「自动」(单选常见):** 字段不要放进 `required`;不要用 `minimum: 1` 卡死留空。宿主会省略空的可选数字参数,避免「提交的数据无效」。
|
||||
|
||||
**解析失败:** 单字段 Lookup 失败时该字段选项为空,表单仍可开(可留空提交的场景不受阻)。
|
||||
|
||||
**反模式(勿做):**
|
||||
|
||||
- 标题写成「某某编号」,说明里教用户去左侧表抄 id
|
||||
- 用静态 `enum` 罗列会增长/变更的业务行(会过期)
|
||||
- 在技能里自己拼假 label 列代替宿主统一格式(难维护)
|
||||
- 另造 `lookup_multi` / 第二套 display_type(应只加 `multiple: true`)
|
||||
- 另造 `lookup_multi` / `paged_lookup` / 第二套 display_type(多选只加 `multiple: true`;小表只加 `presentation: "paged_table"`)
|
||||
- 为跨技能列表在本库维护镜像缓存表再 `local_sql`(应用 `skill_cli`)
|
||||
- 在列 `options_json` 上声明 `skill_cli`(列 Lookup **只**支持同库)
|
||||
|
||||
形状参考:[`assets/examples/lookup-action.example.json`](../assets/examples/lookup-action.example.json)。
|
||||
形状参考:[`assets/examples/lookup-action.example.json`](../assets/examples/lookup-action.example.json)、[`assets/examples/lookup-skill-cli.action.example.json`](../assets/examples/lookup-skill-cli.action.example.json)、[`assets/examples/lookup-paged-table.action.example.json`](../assets/examples/lookup-paged-table.action.example.json)。
|
||||
|
||||
### 表单字段类型对照(须与 CRUD 对称)
|
||||
|
||||
@@ -988,9 +1142,11 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
|
||||
| JSON 长文本 | `type: object`/`array`(以 JSON 字符串编辑)或 `textarea` | `display_type: json` / `textarea` |
|
||||
| 数字 | `type: number` | `display_type: number` 或 INT/REAL/NUMERIC |
|
||||
| 开关 | `type: boolean` | `display_type: boolean` |
|
||||
| 枚举下拉 | `enum: [...]` | `options_json` 数组 |
|
||||
| 关联下拉(Lookup) | 内联 `display_type: lookup` + `lookup`(可选 `multiple`),或绑列 | 同左;多选存 `TEXT` 逗号分隔 id |
|
||||
| 枚举下拉 | `enum: [...]` + 可选 **`enumNames`**;有对应列时再加 `column` / 属性名对齐,复用列 `options_json` | `options_json` **数组** `{value,label}` |
|
||||
| 关联下拉(Lookup) | 内联 `display_type: lookup` + `lookup`(`local_sql` / `skill_cli` / `external_api`;可选 `multiple`;可选 `presentation: "paged_table"`),或绑列 | 列侧仅同库 `local_sql`;多选存 `TEXT` 逗号分隔 id;分页小表只在 Action |
|
||||
| 密码 | `sensitive: true` | 列 `sensitive` |
|
||||
| 年份 | `display_type` 或 `format`: `year` | `display_type: year` → 存 `YYYY` |
|
||||
| 年月 | `display_type` 或 `format`: `year_month`(别名 `month`) | `display_type: year_month` → 存 `YYYY-MM` |
|
||||
| 日期(日) | `display_type` 或 `format`: `date` | `display_type: date` → 存 `YYYY-MM-DD` |
|
||||
| 日期时间 | `display_type` 或 `format`: `datetime_unix_seconds` | 同左;**存/传 Unix 秒** |
|
||||
| URL(薄层) | `display_type` 或 `format`: `url` | `display_type: url` |
|
||||
@@ -1005,14 +1161,43 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
|
||||
| **列筛选「值」** | **无对称**(Action 无表格筛选) | 见 [`SCHEMA.md`](SCHEMA.md)「数据管理:列筛选与全局搜索」:枚举 / boolean / lookup 在筛选栏用**下拉 Select**(非开关、非分段单选);存库仍用 value,展示与搜索认 label |
|
||||
|
||||
**本版不做(勿宣传):** 浏览器内文件上传控件、复杂 `oneOf` / nested object 专用表单;`visibleWhen` 多条件 AND/OR 树、跨 Action 依赖、CRUD 新增/编辑对称显隐;Action 参数表单的列筛选对称。
|
||||
(**Lookup 多选**已支持:同一 `lookup` + `multiple: true`,见上文;不是任意标签数组。)
|
||||
(**本地路径选择**已支持:`file` / `directory` + 可选 `pathRoot`,见下节;提交值仍是本机绝对路径字符串,不是 multipart 上传。)
|
||||
(**Lookup 多选**已支持:同一 `lookup` + `multiple: true`,见上文;不是任意标签数组。**分页勾选小表**已支持:同一 `lookup` + `presentation: "paged_table"`,见「分页勾选小表」。)
|
||||
(**本地路径选择**已支持:`file` / `directory` + 可选 `pathRoot`,见下节;提交值仍是本机绝对路径字符串,不是 multipart 上传。)
|
||||
(**静态枚举中文 label**已支持:Action `enumNames` 与列 `options_json` 数组对称,见下节「静态枚举 label」。)
|
||||
|
||||
### 静态枚举 label(`enum` / `enumNames` / 绑列)
|
||||
|
||||
固定小枚举(状态、船司代码、优先级等)**不要**走 Lookup。CRUD 用列 `options_json` 数组;Action 表单要与「新增记录」对齐中文展示时,按下面做。
|
||||
|
||||
**宿主解析优先序(高 → 低):**
|
||||
|
||||
1. **绑列 options**:字段通过 `column` / `columnMapping` / 属性名(含 camelCase→snake_case)对齐到当前表列,且该列已有解析后的 `options`(来自 `options_json` 数组或 lookup)→ 用列上的 `{value,label}`。
|
||||
2. **`enumNames`**:与 `enum` 下标一一对应的展示文案;缺项或空串回退为该位 `enum` 原文。
|
||||
3. **纯 `enum`**:无 label 时宿主只能显示代码值(体验差,新技能避免)。
|
||||
|
||||
```json
|
||||
"carrierCode": {
|
||||
"type": "string",
|
||||
"title": "船司",
|
||||
"column": "carrier_code",
|
||||
"enum": ["maersk", "hmm"],
|
||||
"enumNames": ["MAERSK 马士基", "HMM 现代商船"]
|
||||
}
|
||||
```
|
||||
|
||||
推荐:
|
||||
|
||||
- 表上已有同义枚举列 → **显式 `column`**(或属性名 = 列名)+ 列侧写好 `options_json`;`enum`/`enumNames` 可保留作无列上下文时的兜底(如 agent-only Action)。
|
||||
- 无对应列、仅 Action 表单需要 → 只写 `enum` + `enumNames`(见 [`assets/examples/enum-names-action.example.json`](../assets/examples/enum-names-action.example.json))。
|
||||
- **禁止**为了中文展示把固定枚举改成假 Lookup。
|
||||
|
||||
日期时间约定:
|
||||
|
||||
- Action 提交与 CRUD 写库统一用 Unix **秒**整数(与 SCHEMA 标准时间字段一致)
|
||||
- `year` 只表示公历年份,值为 `YYYY`(如 `2026`)
|
||||
- `year_month` 只表示年月,值为 `YYYY-MM`(如 `2026-05`);**不要**用 `date` 表示账期/月份
|
||||
- `date` 只表示日历日,值为 `YYYY-MM-DD`
|
||||
- 脏/无法归一的旧预设:日期控件可回退为普通文本,避免卡死表单
|
||||
- 脏/无法归一的旧预设:日期/年月控件可回退为普通文本,避免卡死表单
|
||||
|
||||
### 本地路径选择与 `pathRoot`
|
||||
|
||||
@@ -1073,11 +1258,14 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
|
||||
|
||||
形状参考:[`assets/examples/path-root-file-action.example.json`](../assets/examples/path-root-file-action.example.json)。
|
||||
|
||||
账期/月份(`YYYY-MM`)字段形状参考:[`assets/examples/year-month-action.example.json`](../assets/examples/year-month-action.example.json)。
|
||||
|
||||
### 当前宿主稳定支持(摘要)
|
||||
|
||||
- 根:`object`;属性:`string` / `number` / `boolean`(`object`/`array` 仅作 JSON 多行编辑兼容,新技能慎用)
|
||||
- `enum` / `default` / `required` / `sensitive` / `readOnly` / 可选 `revealInForm`
|
||||
- `display_type` / `format`:`textarea` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`** | **`file`** | **`directory`**
|
||||
- `enum` / 可选 **`enumNames`** / `default` / `required` / `sensitive` / `readOnly` / 可选 `revealInForm`
|
||||
- 可选 **`column`**:绑列复用 displayName 与 options(静态枚举或 lookup)
|
||||
- `display_type` / `format`:`textarea` | **`year`** | **`year_month`** / `month` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`** | **`file`** | **`directory`**
|
||||
- 可选 **`pathRoot`**(仅 `file` / `directory`):相对技能数据根的起始目录;省略默认 `{skill_data}/input`
|
||||
- 字段内联 `lookup`(与 CRUD `options_json` lookup 对象同构;可选 `extra_column`、`multiple`)
|
||||
- 属性级 `description`(表单标签下方灰字说明)
|
||||
@@ -1090,8 +1278,10 @@ Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件
|
||||
|
||||
用于「先选模式,再出对应参数」类表单。写在 `inputSchema.properties.<field>` 上(**不是** action 顶层字段)。
|
||||
|
||||
> **消歧义:** 本文的 `visibleWhen` = **Action 参数表单控件显隐**。
|
||||
> [`POLICY.md`](POLICY.md) L3 预留的 `visibleWhen` = **行态/权限策略**(未冻结),**不是**同一能力。勿混用、勿互相替代。
|
||||
> **消歧义:**
|
||||
> - **表单** `inputSchema.properties.*.visibleWhen` = Action **参数控件**显隐。
|
||||
> - **`bind.visibleWhen`** = 数据管理 **行菜单 / 行按钮**显隐(见上文「行菜单显隐」)。
|
||||
> - [`POLICY.md`](POLICY.md) L3 预留的行态 `visibleWhen` = **权限策略**(未冻结),**不是**以上二者。勿混用、勿互相替代。
|
||||
|
||||
```json
|
||||
"keyword": {
|
||||
@@ -1155,6 +1345,12 @@ CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展
|
||||
"description": "最多返回多少条;填 0 表示不限制(若业务支持)",
|
||||
"default": 50
|
||||
},
|
||||
"period": {
|
||||
"type": "string",
|
||||
"title": "账期月份",
|
||||
"display_type": "year_month",
|
||||
"description": "格式 YYYY-MM,例如 2026-05"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string",
|
||||
"title": "截止日期",
|
||||
@@ -1198,8 +1394,9 @@ CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展
|
||||
约定:
|
||||
|
||||
- `boolean` 在宿主中渲染为开关
|
||||
- `enum` 在宿主中渲染为选择框
|
||||
- `enum` 在宿主中渲染为选择框(或短列表分段控件);**展示文案**优先绑列 options,其次 `enumNames`,再次纯 `enum` 值
|
||||
- `type: number` 渲染为数字输入
|
||||
- `display_type`/`format: year` 渲染为年份选择;`year_month` / `month` 渲染为年月选择
|
||||
- `display_type`/`format: date` 渲染为日期(日);`datetime_unix_seconds` 渲染为日期时间并按 **Unix 秒**提交
|
||||
- `display_type`/`format: url` / `email` 为薄层输入类型(校验从宽,勿当强校验网关)
|
||||
- `required` 字段在提交前校验
|
||||
|
||||
@@ -44,7 +44,7 @@ python {baseDir}/scripts/main.py init-db
|
||||
- **`health`**:只读 runtime 诊断,**不下载、不修复 media-assets,不执行业务动作**;不输出敏感值。
|
||||
- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。
|
||||
- **`version`**:输出 JSON(`version`、`skill`)。
|
||||
- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
|
||||
- **`init-db`**:幂等创建/迁移当前 active 后端(默认本地 SQLite;配 `MYSQL_DSN` 时为 MySQL 云库,见 `references/DATA_SOURCES.md`),含 `_jiangchang_*` 展示元数据。成功输出一行 JSON(`ok` / `skill` / `db_path` 或等价字段),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
|
||||
- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。
|
||||
- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。
|
||||
- **排查顺序**(由快到慢):
|
||||
|
||||
139
references/DATA_SOURCES.md
Normal file
139
references/DATA_SOURCES.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 多数据源声明(SQLite / MySQL 云库)
|
||||
|
||||
本文说明技能如何向匠厂宿主**声明**业务数据库,以及技能 CLI / Action 如何与宿主读写**同一物理库**。
|
||||
|
||||
**产品定位:** 默认仍是本地 SQLite(`{skill_slug}.db`)。需要多用户共享、集中运维或云库时,可显式声明 **MySQL**。UI / API 契约不变;`_jiangchang_*` 展示元数据仍写在同一逻辑库中。
|
||||
|
||||
**宿主版本:** 匠厂 Host **≥ 6.9.28** 才支持 `data-sources.json` 与 MySQL 连接器。
|
||||
|
||||
**完整参考实现:** [`process-work-order`](https://git.jc2009.com/client-commons/process-work-order) @ v1.0.2(工单管理 + MySQL 云库试点)。
|
||||
|
||||
---
|
||||
|
||||
## 何时用哪种后端
|
||||
|
||||
| 场景 | 推荐 |
|
||||
|------|------|
|
||||
| 单机、离线、小数据量、便携部署 | **SQLite**(默认,不配声明文件亦可) |
|
||||
| 多用户共享、运维集中管库、与外部系统共库 | **MySQL 云库**(显式 opt-in) |
|
||||
|
||||
**硬规则:一个 `sourceId` 对应一个物理库。** 禁止双写、禁止 SQLite 与 MySQL 同步镜像。
|
||||
|
||||
---
|
||||
|
||||
## 文件位置
|
||||
|
||||
均在**用户数据目录**(不是安装包目录):
|
||||
|
||||
```
|
||||
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/
|
||||
├── data-sources.json # 声明式数据源(可选;见下)
|
||||
├── .env # MYSQL_DSN 等连接配置
|
||||
└── {skill_slug}.db # SQLite 模式下的本地库文件
|
||||
```
|
||||
|
||||
安装/升级时,若用户目录尚无 `data-sources.json` 且技能包内有 `assets/data-sources.example.json`,宿主会**复制一次**示例(不覆盖已有文件)。
|
||||
|
||||
模板默认示例为 **SQLite 显式声明**(与自动扫描 `{skill_slug}.db` 等价)。改走 MySQL 时,请参照 `assets/examples/data-sources.mysql.example.json` 手工替换用户目录中的 `data-sources.json`,并在 `.env` 配置 DSN。
|
||||
|
||||
若不需要声明式接入,可删除用户目录中的 `data-sources.json`,宿主恢复自动扫描 `.db` 文件。
|
||||
|
||||
---
|
||||
|
||||
## `data-sources.json` 格式
|
||||
|
||||
根对象:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `version` | `1` | 固定为 1 |
|
||||
| `sources` | 数组 | 至少一条数据源 |
|
||||
|
||||
每条 `sources[]`:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | 是 | 英文标识,如 `primary` |
|
||||
| `kind` | 是 | `sqlite` 或 `mysql` |
|
||||
| `displayName` | 否 | 侧栏展示名;多库时建议填写 |
|
||||
| `path` | sqlite 必填 | 相对技能数据目录的路径;可用 `{skillSlug}` 占位 |
|
||||
| `connectionEnv` | mysql 必填 | `.env` 中 DSN 的环境变量名,如 `MYSQL_DSN` |
|
||||
|
||||
### SQLite 示例(模板默认)
|
||||
|
||||
见 [`../assets/data-sources.example.json`](../assets/data-sources.example.json)。
|
||||
|
||||
### MySQL 示例
|
||||
|
||||
见 [`../assets/examples/data-sources.mysql.example.json`](../assets/examples/data-sources.mysql.example.json)。
|
||||
|
||||
---
|
||||
|
||||
## `.env` 与 DSN
|
||||
|
||||
MySQL 连接串写在用户 `.env`(或进程环境变量),**不要**写进 `data-sources.json`:
|
||||
|
||||
```env
|
||||
# MySQL 云库连接(不配则走本地 SQLite)
|
||||
MYSQL_DSN=mysql://user:password@192.168.0.20:3306/your_database
|
||||
```
|
||||
|
||||
格式:`mysql://` 或 `mysql2://`,须含库名。注释写法须符合 [`../development/CONFIG.md`](../development/CONFIG.md)(配置管理会展示给用户)。
|
||||
|
||||
技能 Python 侧:`scripts/db/backend.py` 读取同一 `MYSQL_DSN`;有值则 `resolve_backend()` 为 `mysql`,否则 `sqlite`。
|
||||
|
||||
---
|
||||
|
||||
## 技能代码职责
|
||||
|
||||
### 宿主 UI(数据管理)
|
||||
|
||||
宿主读取用户目录 `data-sources.json` + `.env`,经 Host 内置连接器访问库。**不依赖**技能 `connection.py`。
|
||||
|
||||
### CLI / Action / `init-db`
|
||||
|
||||
**必须**与宿主使用同一后端策略,否则会出现「UI 看云库、CLI 写本地 `.db`」的分裂。
|
||||
|
||||
模板已提供:
|
||||
|
||||
| 模块 | 作用 |
|
||||
|------|------|
|
||||
| `scripts/db/backend.py` | `resolve_backend()`、`parse_mysql_dsn()` |
|
||||
| `scripts/db/cursor_compat.py` | 统一 `?` 占位符与 `executescript` |
|
||||
| `scripts/db/connection.py` | `get_conn()` 双分支;`init_db()` 幂等建 `_jiangchang_*` |
|
||||
|
||||
业务表 DDL / 迁移仍在技能自己的 `schema` / `init_db()` 中按 `conn.backend` 分支处理(见工单参考实现)。
|
||||
|
||||
可选:`describe_active_store()` 用于 health / 排障,输出当前连的是 sqlite 还是 mysql。
|
||||
|
||||
---
|
||||
|
||||
## MySQL 当前限制(Host)
|
||||
|
||||
- 不支持 `tableAdmin`(建表/改表 UI)
|
||||
- 不支持 `cellPresentation`
|
||||
- 元数据与 CRUD 与 SQLite 路径一致
|
||||
|
||||
表结构变更仍由技能 `init-db` / 迁移脚本负责。
|
||||
|
||||
---
|
||||
|
||||
## 开发 Checklist(云库)
|
||||
|
||||
复制新业务技能并选择 MySQL 时:
|
||||
|
||||
1. Host ≥ 6.9.28
|
||||
2. 用户目录放置 `data-sources.json`(mysql 源 + `connectionEnv`)
|
||||
3. `.env` 配置 `MYSQL_DSN`(或你在 json 里声明的 env 名)
|
||||
4. `requirements.txt` 含 `PyMySQL>=1.1.0`(模板已声明)
|
||||
5. 业务 DDL 同时支持 sqlite / mysql 分支(或仅部署 mysql)
|
||||
6. **抽测**:数据管理 UI 与 CLI(如 list / create)读写同一库
|
||||
7. 见 [`../development/CHECKLIST.md`](../development/CHECKLIST.md) A6-16
|
||||
|
||||
---
|
||||
|
||||
## 与 SCHEMA / ACTIONS 的关系
|
||||
|
||||
- [`SCHEMA.md`](SCHEMA.md):`_jiangchang_*` 契约适用于**已注册数据源**上的表,不限于本地文件。
|
||||
- [`ACTIONS.md`](ACTIONS.md):无 `replaces` 时宿主直写**已注册数据源**(默认本地 SQLite)。
|
||||
- [`CLI.md`](CLI.md):`init-db` 对当前 active backend 幂等初始化元数据。
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
|
||||
> **目标:** 冻结「启用方式 + Snapshot 最小字段 + L2 scope 白名单」,使多数技能(L0)零配置,需要权限的技能(L1/L2)有稳定扩展面。
|
||||
> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(行态 `visibleWhen` 等)。
|
||||
> **注意:** L3 预留的行态 `visibleWhen` **不是** Action 参数表单的 `inputSchema.properties.*.visibleWhen`(表单控件显隐,见 [`ACTIONS.md`](ACTIONS.md)「字段条件显隐」)。二者同名不同义,勿混用。
|
||||
> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(菜单级藏表、`rows.mode=delegate` 等)。
|
||||
> **注意:**
|
||||
> - 行菜单按业务状态显隐请用 Action 契约 **`bind.visibleWhen`**(见 [`ACTIONS.md`](ACTIONS.md)「行菜单显隐」)——宿主已支持,**不属于**本 Policy L3。
|
||||
> - L3 预留的行态 `visibleWhen` **不是** Action 参数表单的 `inputSchema.properties.*.visibleWhen`,也**不是** `bind.visibleWhen`。三者同名不同义,勿混用。
|
||||
> **配套 Schema:** [`../assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json);启用块见 [`../assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
|
||||
|
||||
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)。本契约增加第六轴:**policy(当前身份允许什么)**。
|
||||
@@ -17,7 +19,7 @@
|
||||
| **L0** | 无 `policy`,或 `policy.enabled !== true` | **与今日完全一致**:不拉 Snapshot,不按身份过滤按钮/行/列 |
|
||||
| **L1** | `policy.enabled: true`,Snapshot 至少给出 Action 授权 | 数据管理展示 skill Action 前与 allowlist 求交;可选限制宿主内置能力 |
|
||||
| **L2** | 在 L1 基础上 Snapshot 带 `data.tables[table]` | 列表查询应用声明式 `rows.scope`;列按 `columns` 投影 |
|
||||
| **L3** | (预留,本契约不冻结行为) | 如**行态** `visibleWhen`、菜单级藏表、`rows.mode=delegate` 等(≠ Action 表单 `inputSchema.visibleWhen`) |
|
||||
| **L3** | (预留,本契约不冻结行为) | 如菜单级藏表、`rows.mode=delegate`、更复杂的行态策略字段等(≠ `bind.visibleWhen` 行菜单,≠ 表单 `inputSchema.visibleWhen`) |
|
||||
|
||||
**80% 技能停留 L0。** 启用 policy 的技能必须实现约定 CLI,并在写路径自行再鉴权(见 §7)。
|
||||
|
||||
|
||||
@@ -18,10 +18,11 @@
|
||||
| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口与触发摘要 |
|
||||
| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 |
|
||||
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口、按钮中文名 `label` 参考(按入口定位表挂载;先读 `development/PRODUCT_SURFACES.md`) |
|
||||
| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
|
||||
| [`SCHEMA.md`](SCHEMA.md) | 业务表与数据管理展示契约(SQLite / MySQL) |
|
||||
| [`DATA_SOURCES.md`](DATA_SOURCES.md) | 多数据源声明:SQLite 默认、可选 MySQL 云库(Host ≥ 6.9.28) |
|
||||
| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) |
|
||||
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理呈现:格子 / 整行 / 整列 / 区域语义 tone(用途不限;多数技能 P0 不配) |
|
||||
| [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md) | 业务行生命周期:处理队列的可操作/进行中/终态角色与重做规则(有处理行 Action 时适用) |
|
||||
| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) |
|
||||
|
||||
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。
|
||||
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;云库 / 声明式数据源读 `DATA_SOURCES.md`;权限 opt-in 读 `POLICY.md`;格子/行/列标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 数据存储与数据管理展示规范
|
||||
|
||||
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
|
||||
本文是 skill **已注册数据源**(默认本地 SQLite 文件;可选 MySQL 云库,见 [`DATA_SOURCES.md`](DATA_SOURCES.md))与匠厂宿主「数据管理」页面展示契约的**权威说明**。
|
||||
|
||||
**产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
### 技能负责
|
||||
|
||||
- 定义真实 SQLite 表结构
|
||||
- 定义真实业务表结构(SQLite 或 MySQL,按技能所选后端)
|
||||
- 使用英文 `snake_case` 表名和字段名
|
||||
- 使用 `_jiangchang_tables` 提供表级展示元数据
|
||||
- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明)
|
||||
@@ -180,11 +180,36 @@ JSON 数组(可多字段,按顺序):
|
||||
|
||||
规则:
|
||||
|
||||
- `column` 必须是该表真实列名;敏感列会被宿主忽略
|
||||
- `column` 必须是该表**真实物理列名**(存在于 SQLite 表);不存在则宿主忽略该段或技能校验报错
|
||||
- **可以**引用 `visible=0` 的列(含隐藏的主键 `id`)。`visible=0` 只表示界面不展示 / 不进默认 SELECT·筛选·搜索;**不表示不能 ORDER BY**
|
||||
- 敏感列(宿主标为 sensitive)不得用于排序;技能侧勿把密钥类字段写进 `default_sort_json`
|
||||
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
|
||||
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
|
||||
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
|
||||
|
||||
与 `visible` 的分工(勿再踩坑):
|
||||
|
||||
| 能力 | `visible=0` 是否仍可用 |
|
||||
|------|------------------------|
|
||||
| 表格列展示 / 「列」面板 | 否(硬隐藏) |
|
||||
| 默认 SELECT、列筛选、顶栏搜索 | 否 |
|
||||
| **`default_sort_json` / ORDER BY** | **是**(非敏感列) |
|
||||
| 行主键定位(`$row.$pk` 等) | 是(主键即使隐藏仍应可排序、可定位;宿主查询层保留) |
|
||||
|
||||
示例(编号硬隐藏,但仍按编号排序——合法):
|
||||
|
||||
```python
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="period_rule_overrides",
|
||||
display_name="本月默认金额",
|
||||
readonly=1,
|
||||
default_sort_json='[{"column":"period","direction":"desc"},{"column":"id","direction":"asc"}]',
|
||||
)
|
||||
# id 列元数据可见性:
|
||||
# upsert_column_metadata(..., column_name="id", visible=0, ...)
|
||||
```
|
||||
|
||||
示例(消息时间线):
|
||||
|
||||
```python
|
||||
@@ -277,6 +302,8 @@ upsert_column_metadata(
|
||||
- 只写英文物理名而不写中文 `display_name`
|
||||
- 让元数据引用不存在的表
|
||||
- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来)
|
||||
- 误以为「列 `visible=0` 就不能写进 `default_sort_json`」——排序与展示已拆开;隐藏编号/主键仍可排序(须列真实存在且非敏感)
|
||||
- 把不存在的列名写进 `default_sort_json`(技能校验应报错;宿主也会忽略无效段)
|
||||
|
||||
### `_jiangchang_columns` 字段级标准
|
||||
|
||||
@@ -312,6 +339,8 @@ upsert_column_metadata(
|
||||
| (枚举) | 任意 + `options_json` **数组** | 标签映射 | 下拉选择 | 选项 `value` |
|
||||
| `lookup` | 跨表引用 id;`options_json` **对象**;可选 `multiple` | 单选:标签映射;多选:多标签(如「豆包、元宝」) | 单选下拉;多选见下文 | 单选:业务 id 标量;多选:`TEXT` 逗号分隔 id(非 SQLite 外键) |
|
||||
| (敏感) | 任意;列 `sensitive=1` | 掩码/隐藏策略由宿主定 | 新建可用密码框;编辑策略与既有宿主一致 | 原文(勿日志明文) |
|
||||
| `year` | `TEXT` 推荐 `YYYY` | 原文 | **年份选择** | `YYYY` 字符串 |
|
||||
| `year_month` | `TEXT` 推荐 `YYYY-MM` | 原文 | **年月选择** | `YYYY-MM` 字符串;别名 `month` |
|
||||
| `date` | `TEXT` 推荐 `YYYY-MM-DD`,或可安全归一的值 | 日期 | **日期选择**(日) | `YYYY-MM-DD` |
|
||||
| `datetime_unix_seconds` | `INTEGER` | `YYYY-MM-DD HH:mm:ss` | **日期时间选择**;提交写回 **Unix 秒** | Unix **秒**整数;禁止改成 ISO 字符串 |
|
||||
| `url` | `TEXT` | 原文(可作链接) | 单行输入 `type=url`(薄层;校验从宽) | 原文 |
|
||||
@@ -411,7 +440,7 @@ Action 参数表单的 `title` / `description` / `default` / `display_type` 见
|
||||
| `boolean` | 下拉(有 `options_json` 用其 label;否则宿主默认可选「是/否」) | 等于 / 不等于 / 为空 | `display_type: boolean`;**推荐同时写 `options_json`** 指定中文 label |
|
||||
| `lookup` 单选 | 下拉 | 等于 / 不等于 / 为空 | `display_type: lookup` + lookup 对象 |
|
||||
| `lookup` + `multiple: true` | 列筛选 UI 演进中;**全局搜索**已支持 | 等于时按逗号分隔 id 的 token 匹配 | 多选存 `TEXT` 逗号分隔 id |
|
||||
| `date` / `datetime_unix_seconds` | 日期 / 日期时间 | 比较类 | 时间列通常 `searchable: 0` |
|
||||
| `year` / `year_month` / `date` / `datetime_unix_seconds` | 年 / 年月 / 日期 / 日期时间 | 比较类 | 账期/月份列推荐 `year_month`;时间列通常 `searchable: 0` |
|
||||
| 敏感列 | **不可筛** | — | 宿主强制排除 |
|
||||
|
||||
**全局搜索扩展(用户可搜中文)**
|
||||
@@ -592,7 +621,7 @@ readonly = 0
|
||||
|
||||
| 层级 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 |
|
||||
| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/导入与直写删除;若技能声明 `replaces: delete` 仍可显示删除并走 CLI |
|
||||
| 相关业务字段 `editable` | `0` | 元数据权限只约束宿主 UI,不限制技能代码写库 |
|
||||
|
||||
模板**默认不建**此类审计表。过程看 `jiangchang.log`,进度/终态看任务中心,业务结果看业务表。
|
||||
@@ -658,6 +687,8 @@ CREATE TABLE demo_items (
|
||||
|
||||
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
|
||||
|
||||
**与 Action 表单对称:** 数据管理「新增/编辑」读列 `options_json`;Skill Action 弹窗用 `enum` + 可选 **`enumNames`**,或字段绑列(`column` / 属性名对齐)直接复用本列 options。宿主优先序:**绑列 options → enumNames → 纯 enum**。详见 [`ACTIONS.md`](ACTIONS.md)「静态枚举 label」。固定小枚举**不要**改成 Lookup。
|
||||
|
||||
### 2)跨表引用 lookup(对象,用于关联 id 字段)
|
||||
|
||||
当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组)。这是**展示与表单选择**契约,**不是** SQLite 外键。
|
||||
@@ -699,6 +730,8 @@ CREATE TABLE demo_items (
|
||||
|
||||
宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 选项列表。
|
||||
|
||||
> **列 Lookup 仅支持同库 `local_sql`。** Action 表单若要从兄弟技能拉选项(如 account-manager 账号列表),须在 `actions.json` 的 `inputSchema.lookup` 使用 **`skill_cli`**,见 [`ACTIONS.md`](ACTIONS.md)「跨表引用:Lookup」。不要在列 `options_json` 上声明 `skill` / `command`。
|
||||
|
||||
**存库约定:**
|
||||
|
||||
| 模式 | 物理列建议 | 存什么 |
|
||||
@@ -719,8 +752,9 @@ CREATE TABLE demo_items (
|
||||
- 单选:下拉 Select(现状)
|
||||
- 多选且选项较少(建议 ≤6):勾选列表
|
||||
- 多选且选项较多:字段内 Chip + 可搜索下拉(**不是**再开一层业务弹窗)
|
||||
- **分页勾选小表**(`lookup.presentation: "paged_table"`)只属于 **Action** 参数表单,列 Lookup 不声明。见 [`ACTIONS.md`](ACTIONS.md)「分页勾选小表」
|
||||
|
||||
Action 弹窗内联 Lookup 与上表 **同构**(字段名一致,含 `multiple`),见 [`ACTIONS.md`](ACTIONS.md)「跨表引用:Lookup」。
|
||||
Action 弹窗内联 Lookup 与上表 **同构**(字段名一致,含 `multiple`;Action 另可加 `presentation` / `page_size`),见 [`ACTIONS.md`](ACTIONS.md)「跨表引用:Lookup」。
|
||||
|
||||
示例:账号表的平台字段 → 平台信息表;凭据表的账号编号 → 账号信息表;私信 Action 选话术 → `message_templates`;抽检 Action 多选引擎 → `engines` + `multiple: true`。
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
# 公共依赖由宿主共享 runtime 提供;这里只声明技能特有 Python 依赖。
|
||||
PyMySQL>=1.1.0
|
||||
|
||||
35
scripts/db/backend.py
Normal file
35
scripts/db/backend.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""数据库后端选择:配置了 MYSQL_DSN 时走 MySQL,否则回退本地 SQLite。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from jiangchang_skill_core import config
|
||||
|
||||
DbBackend = Literal["sqlite", "mysql"]
|
||||
|
||||
|
||||
def get_mysql_dsn() -> str:
|
||||
return (config.get("MYSQL_DSN") or "").strip()
|
||||
|
||||
|
||||
def resolve_backend() -> DbBackend:
|
||||
return "mysql" if get_mysql_dsn() else "sqlite"
|
||||
|
||||
|
||||
def parse_mysql_dsn(dsn: str) -> dict[str, object]:
|
||||
text = dsn.strip()
|
||||
if not text.startswith(("mysql://", "mysql2://")):
|
||||
raise ValueError("MYSQL_DSN must be a mysql:// URL.")
|
||||
parsed = urlparse(text)
|
||||
database = (parsed.path or "").lstrip("/")
|
||||
if not database:
|
||||
raise ValueError("MYSQL_DSN must include a database name.")
|
||||
return {
|
||||
"host": parsed.hostname or "127.0.0.1",
|
||||
"port": parsed.port or 3306,
|
||||
"user": unquote(parsed.username or ""),
|
||||
"password": unquote(parsed.password or ""),
|
||||
"database": database,
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
"""SQLite 连接与幂等初始化模板。
|
||||
"""SQLite / MySQL 连接与幂等初始化模板。
|
||||
|
||||
模板默认只建数据管理元数据表。业务表由复制后的技能自行 CREATE,并同步展示元数据。
|
||||
配置了 MYSQL_DSN 时走 MySQL 云库,否则使用本地 {skill_slug}.db。见 references/DATA_SOURCES.md。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
|
||||
from db.backend import get_mysql_dsn, parse_mysql_dsn, resolve_backend
|
||||
from db.cursor_compat import AdaptedConnection
|
||||
from db.display_metadata import init_display_metadata
|
||||
from util.runtime_paths import get_db_path
|
||||
|
||||
|
||||
def get_conn() -> sqlite3.Connection:
|
||||
return sqlite3.connect(get_db_path())
|
||||
def get_conn() -> AdaptedConnection:
|
||||
backend = resolve_backend()
|
||||
if backend == "mysql":
|
||||
import pymysql
|
||||
|
||||
params = parse_mysql_dsn(get_mysql_dsn())
|
||||
raw = pymysql.connect(
|
||||
host=str(params["host"]),
|
||||
port=int(params["port"]),
|
||||
user=str(params["user"]),
|
||||
password=str(params["password"]),
|
||||
database=str(params["database"]),
|
||||
charset="utf8mb4",
|
||||
autocommit=False,
|
||||
)
|
||||
return AdaptedConnection(raw, "mysql")
|
||||
|
||||
raw = sqlite3.connect(get_db_path())
|
||||
return AdaptedConnection(raw, "sqlite")
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
@@ -23,3 +43,18 @@ def init_db() -> None:
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def describe_active_store() -> dict[str, str]:
|
||||
backend = resolve_backend()
|
||||
if backend == "mysql":
|
||||
params = parse_mysql_dsn(get_mysql_dsn())
|
||||
return {
|
||||
"backend": "mysql",
|
||||
"host": str(params["host"]),
|
||||
"database": str(params["database"]),
|
||||
}
|
||||
return {
|
||||
"backend": "sqlite",
|
||||
"db_path": get_db_path(),
|
||||
}
|
||||
|
||||
96
scripts/db/cursor_compat.py
Normal file
96
scripts/db/cursor_compat.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""统一 SQLite / MySQL 游标:占位符 ? 与 executescript。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from db.backend import DbBackend
|
||||
|
||||
_PLACEHOLDER_RE = re.compile(r"\?")
|
||||
|
||||
|
||||
def adapt_sql(sql: str, backend: DbBackend) -> str:
|
||||
if backend != "mysql":
|
||||
return sql
|
||||
return _PLACEHOLDER_RE.sub("%s", sql)
|
||||
|
||||
|
||||
def _split_sql_script(script: str) -> list[str]:
|
||||
parts: list[str] = []
|
||||
for chunk in script.split(";"):
|
||||
stmt = chunk.strip()
|
||||
if stmt:
|
||||
parts.append(stmt)
|
||||
return parts
|
||||
|
||||
|
||||
class AdaptedCursor:
|
||||
backend: DbBackend
|
||||
|
||||
def __init__(self, cursor: Any, backend: DbBackend) -> None:
|
||||
self._cursor = cursor
|
||||
self.backend = backend
|
||||
|
||||
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any:
|
||||
adapted = adapt_sql(sql, self.backend)
|
||||
if params is None:
|
||||
return self._cursor.execute(adapted)
|
||||
return self._cursor.execute(adapted, params)
|
||||
|
||||
def executemany(self, sql: str, seq_of_params: list[tuple[Any, ...]]) -> Any:
|
||||
return self._cursor.executemany(adapt_sql(sql, self.backend), seq_of_params)
|
||||
|
||||
def fetchone(self) -> Any:
|
||||
return self._cursor.fetchone()
|
||||
|
||||
def fetchall(self) -> list[Any]:
|
||||
return self._cursor.fetchall()
|
||||
|
||||
@property
|
||||
def lastrowid(self) -> int:
|
||||
return int(self._cursor.lastrowid)
|
||||
|
||||
@property
|
||||
def rowcount(self) -> int:
|
||||
return int(self._cursor.rowcount)
|
||||
|
||||
def executescript(self, script: str) -> None:
|
||||
if self.backend == "sqlite":
|
||||
self._cursor.connection.executescript(script)
|
||||
return
|
||||
for stmt in _split_sql_script(script):
|
||||
self.execute(stmt)
|
||||
|
||||
|
||||
class AdaptedConnection:
|
||||
backend: DbBackend
|
||||
|
||||
def __init__(self, conn: Any, backend: DbBackend) -> None:
|
||||
self._conn = conn
|
||||
self.backend = backend
|
||||
|
||||
def cursor(self) -> AdaptedCursor:
|
||||
return AdaptedCursor(self._conn.cursor(), self.backend)
|
||||
|
||||
def commit(self) -> None:
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def execute(self, sql: str, params: tuple[Any, ...] | list[Any] | None = None) -> Any:
|
||||
cur = self.cursor()
|
||||
return cur.execute(sql, params)
|
||||
|
||||
def executemany(self, sql: str, seq_of_params: list[tuple[Any, ...]]) -> Any:
|
||||
cur = self.cursor()
|
||||
return cur.executemany(sql, seq_of_params)
|
||||
|
||||
def executescript(self, script: str) -> None:
|
||||
if self.backend == "sqlite":
|
||||
self._conn.executescript(script)
|
||||
return
|
||||
cur = self.cursor()
|
||||
for stmt in _split_sql_script(script):
|
||||
cur.execute(stmt)
|
||||
@@ -22,6 +22,8 @@ LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
|
||||
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
|
||||
|
||||
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
|
||||
YEAR_MONTH = "year_month"
|
||||
YEAR = "year"
|
||||
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
|
||||
|
||||
_JIANGCHANG_TABLES_DDL = """
|
||||
@@ -103,6 +105,7 @@ def upsert_table_metadata(
|
||||
default_sort_json: 宿主数据管理默认排序,JSON 数组,例如
|
||||
'[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]'
|
||||
为空则宿主回退到 updated_at → created_at → 主键。
|
||||
排序列须为真实物理列;**允许**引用 visible=0 的非敏感列(隐藏≠不能 ORDER BY)。
|
||||
|
||||
default_visible_columns_json: 宿主数据管理默认展示列,JSON 字符串数组,例如
|
||||
'["stat_type","period","stage"]'
|
||||
|
||||
@@ -279,6 +279,95 @@ def validate_filter_search_metadata(
|
||||
return [], warnings
|
||||
|
||||
|
||||
def parse_default_sort_json(raw: object) -> tuple[list[dict[str, str]], list[str]]:
|
||||
"""解析 default_sort_json;返回 (specs, errors)。specs 项含 column / direction。"""
|
||||
errors: list[str] = []
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return [], []
|
||||
text = str(raw).strip()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return [], ["default_sort_json 不是合法 JSON"]
|
||||
if not isinstance(parsed, list):
|
||||
return [], ["default_sort_json 必须是 JSON 数组"]
|
||||
out: list[dict[str, str]] = []
|
||||
for index, item in enumerate(parsed):
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"default_sort_json[{index}] 必须是对象")
|
||||
continue
|
||||
column = str(
|
||||
item.get("column")
|
||||
or item.get("columnName")
|
||||
or item.get("column_name")
|
||||
or ""
|
||||
).strip()
|
||||
direction_raw = str(item.get("direction") or "desc").strip().lower()
|
||||
if not column:
|
||||
errors.append(f"default_sort_json[{index}] 缺少 column")
|
||||
continue
|
||||
if direction_raw not in {"asc", "desc"}:
|
||||
errors.append(f"default_sort_json[{index}].direction 只能是 asc 或 desc")
|
||||
continue
|
||||
out.append({"column": column, "direction": direction_raw})
|
||||
return out, errors
|
||||
|
||||
|
||||
def validate_default_sort_metadata(
|
||||
cursor,
|
||||
table_name: str,
|
||||
*,
|
||||
physical_columns: list[str] | None = None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""校验表级 default_sort_json:列须真实存在;允许 visible=0;不存在则报错。
|
||||
|
||||
契约:visible=0 只藏界面,ORDER BY 仍可用非敏感物理列(含隐藏主键)。
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
table_cols = {
|
||||
str(row[1]) for row in cursor.execute("PRAGMA table_info(_jiangchang_tables)").fetchall()
|
||||
}
|
||||
if "default_sort_json" not in table_cols:
|
||||
return [], []
|
||||
cursor.execute(
|
||||
"SELECT default_sort_json FROM _jiangchang_tables WHERE table_name = ?",
|
||||
(table_name,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return [], []
|
||||
specs, parse_errors = parse_default_sort_json(row[0])
|
||||
for msg in parse_errors:
|
||||
errors.append(f"{table_name}: {msg}")
|
||||
if parse_errors:
|
||||
return errors, warnings
|
||||
|
||||
physical = set(physical_columns or list_physical_column_names(cursor, table_name))
|
||||
for spec in specs:
|
||||
column = spec["column"]
|
||||
if column not in physical:
|
||||
errors.append(
|
||||
f"{table_name}: default_sort_json 引用了不存在的列 {column!r}"
|
||||
)
|
||||
continue
|
||||
# visible=0 合法:不报错。仅提示契约,避免作者误删排序列。
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT visible FROM _jiangchang_columns
|
||||
WHERE table_name = ? AND column_name = ?
|
||||
""",
|
||||
(table_name, column),
|
||||
)
|
||||
meta = cursor.fetchone()
|
||||
if meta is not None and meta[0] is not None and int(meta[0]) == 0:
|
||||
warnings.append(
|
||||
f"{table_name}.{column}: default_sort_json 引用了 visible=0 列"
|
||||
"(合法:隐藏列仍可 ORDER BY;见 references/SCHEMA.md)"
|
||||
)
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def validate_column_permissions(
|
||||
cursor,
|
||||
table_name: str,
|
||||
@@ -558,6 +647,14 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
|
||||
_, filter_warnings = validate_filter_search_metadata(cur, table_name)
|
||||
report.warnings.extend(filter_warnings)
|
||||
|
||||
sort_errors, sort_warnings = validate_default_sort_metadata(
|
||||
cur,
|
||||
table_name,
|
||||
physical_columns=physical_columns,
|
||||
)
|
||||
report.errors.extend(sort_errors)
|
||||
report.warnings.extend(sort_warnings)
|
||||
|
||||
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
|
||||
for table_name, column_name in cur.fetchall():
|
||||
if table_name in METADATA_TABLES:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.111"
|
||||
SKILL_VERSION = "1.0.122"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
|
||||
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
_MAX_SLUG_LENGTH = 48
|
||||
_MIN_SEGMENTS = 3
|
||||
_MIN_SEGMENTS = 2
|
||||
_MAX_SEGMENTS = 5
|
||||
_MAX_NOUN_SEGMENTS = 3
|
||||
|
||||
@@ -60,7 +60,9 @@ _BUILTIN_PLATFORMS = frozenset(
|
||||
"etax",
|
||||
}
|
||||
)
|
||||
_BUILTIN_SCOPES = frozenset({"all", "batch", "multi", "default"})
|
||||
_BUILTIN_SCOPES = frozenset({"all", "batch", "multi"})
|
||||
# 旧技能用 default 占位末段。不再当成合法 scope;校验只警告,避免同步后挡住已发布仓库。
|
||||
_DEPRECATED_PLATFORM_PLACEHOLDER = "default"
|
||||
|
||||
LEGACY_EXEMPT_SLUGS: frozenset[str] = frozenset(
|
||||
{
|
||||
@@ -117,11 +119,12 @@ def _match_platform_suffix(
|
||||
|
||||
def classify_slug_form(segments: list[str], *, slug: str = "") -> str:
|
||||
"""
|
||||
返回: 'legacy_exempt' | 'standard' | 'scope' | 'invalid'
|
||||
返回: 'legacy_exempt' | 'standard' | 'scope' | 'plain' | 'invalid'
|
||||
|
||||
- legacy_exempt: slug in LEGACY_EXEMPT_SLUGS
|
||||
- standard: 末段匹配 platforms, 首段 in verbs, 3<=len<=5
|
||||
- scope: 末段 in scopes, 首段 in verbs, 3<=len<=5
|
||||
- standard: 末段匹配 platforms,首段 in verbs,2<=len<=5,且动词后至少还有一段名词
|
||||
- scope: 末段 in scopes(all/batch/multi),首段 in verbs,至少 verb-noun-scope
|
||||
- plain: 动词 + 名词,无平台、无 scope
|
||||
- invalid: 其他
|
||||
"""
|
||||
if slug in LEGACY_EXEMPT_SLUGS:
|
||||
@@ -138,12 +141,14 @@ def classify_slug_form(segments: list[str], *, slug: str = "") -> str:
|
||||
return "invalid"
|
||||
|
||||
if segments[-1] in scopes:
|
||||
if len(segments) < 3:
|
||||
return "invalid"
|
||||
return "scope"
|
||||
|
||||
if _match_platform_suffix(segments, platforms) is not None:
|
||||
return "standard"
|
||||
|
||||
return "invalid"
|
||||
return "plain"
|
||||
|
||||
|
||||
def validate_slug_semantics(
|
||||
@@ -173,8 +178,8 @@ def validate_slug_semantics(
|
||||
|
||||
if segment_count < _MIN_SEGMENTS:
|
||||
msg = (
|
||||
f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(verb-noun-platform),"
|
||||
f"当前为 {segment_count} 段:{slug!r};新技能禁止 2 段无平台形态(如 reconcile-finance)"
|
||||
f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(动词-名词,平台可选),"
|
||||
f"当前为 {segment_count} 段:{slug!r}"
|
||||
)
|
||||
(errors if strict else warnings).append(msg)
|
||||
return errors, warnings
|
||||
@@ -197,9 +202,6 @@ def validate_slug_semantics(
|
||||
|
||||
form = classify_slug_form(segments, slug=slug)
|
||||
if form == "scope":
|
||||
if segments[-1] not in scopes:
|
||||
msg = f"slug 末段应为 scope 白名单中的词:{segments[-1]!r}(slug={slug!r})"
|
||||
(errors if strict else warnings).append(msg)
|
||||
noun_count = segment_count - 2
|
||||
elif form == "standard":
|
||||
platform_match = _match_platform_suffix(segments, platforms)
|
||||
@@ -210,13 +212,17 @@ def validate_slug_semantics(
|
||||
else:
|
||||
_platform_name, platform_part_count = platform_match
|
||||
noun_count = segment_count - 1 - platform_part_count
|
||||
elif form == "plain":
|
||||
noun_count = segment_count - 1
|
||||
if segments[-1] == _DEPRECATED_PLATFORM_PLACEHOLDER:
|
||||
warnings.append(
|
||||
"slug 末段 default 已不再作为平台占位;没有外部平台时直接省略。"
|
||||
f"已发布的技能不用改名:{slug!r}"
|
||||
)
|
||||
else:
|
||||
if segment_count >= _MIN_SEGMENTS:
|
||||
if segments[-1] not in scopes:
|
||||
platform_match = _match_platform_suffix(segments, platforms)
|
||||
if platform_match is None:
|
||||
msg = f"slug 末段应匹配平台或 scope 白名单:{slug!r}"
|
||||
(errors if strict else warnings).append(msg)
|
||||
if segments and segments[-1] in scopes and segment_count < 3:
|
||||
msg = f"scope 型 slug 至少为动词-名词-scope:{slug!r}"
|
||||
(errors if strict else warnings).append(msg)
|
||||
noun_count = 0
|
||||
|
||||
if noun_count > _MAX_NOUN_SEGMENTS:
|
||||
|
||||
668
sync.ps1
Normal file
668
sync.ps1
Normal file
@@ -0,0 +1,668 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Sync this skill's plaintext source into the local OpenClaw skills directory.
|
||||
|
||||
.DESCRIPTION
|
||||
Local development only. Does not commit, tag, push, or trigger Gitea CI.
|
||||
release.ps1 remains the formal publish path.
|
||||
Ships in skill-template beside release.ps1. Scaffold copies this file into each new skill.
|
||||
|
||||
Mirrors this repo onto ~/.openclaw/skills/<slug> and removes marketplace
|
||||
pyarmor_runtime_* leftovers. Stop any running job for this skill first;
|
||||
otherwise the copy can fail because files are in use.
|
||||
|
||||
Dependencies follow the host skill:direct-install path:
|
||||
uv pip install --python <JIANGCHANG_DATA_ROOT>/python-runtime/.venv/Scripts/python.exe -r requirements.txt
|
||||
Packages are not installed into system Python. Unchanged requirements that
|
||||
are already present in the shared venv are skipped.
|
||||
|
||||
platform_kit_min_version is provided by jiangchang-platform-kit in the shared
|
||||
runtime. This script only checks it. It does not upgrade the kit. If the
|
||||
installed kit is too old, install or update the skill once from the market,
|
||||
or upgrade the host.
|
||||
|
||||
After the copy, this script runs scripts\main.py init-db the same way the
|
||||
host does after a market install. The user id is read from
|
||||
%APPDATA%\jiangchang\settings.json (jiangchangUserId), which the host writes
|
||||
on login and logout. It is never hardcoded. An empty id or _anon stops the
|
||||
script so the anonymous data folder is not initialized by mistake.
|
||||
-SkipInitDb skips that step. -SkipDeps still runs init-db.
|
||||
|
||||
No Chinese literals in this file: the skill repo requires UTF-8 without BOM,
|
||||
and Windows PowerShell 5.1 would mis-parse a no-BOM UTF-8 script.
|
||||
|
||||
.EXAMPLE
|
||||
.\sync.ps1
|
||||
.\sync.ps1 -DryRun
|
||||
.\sync.ps1 -ForceDeps
|
||||
.\sync.ps1 -SkipDeps
|
||||
.\sync.ps1 -SkipInitDb
|
||||
.\sync.ps1 -DataRoot D:\jiangchang-data
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Destination,
|
||||
[string]$DataRoot,
|
||||
[switch]$SkipDeps,
|
||||
[switch]$ForceDeps,
|
||||
[switch]$SkipInitDb,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$SourceRoot = $PSScriptRoot
|
||||
$PypiMirrors = @(
|
||||
@{ Name = "aliyun"; Url = "https://mirrors.aliyun.com/pypi/simple/" },
|
||||
@{ Name = "tuna"; Url = "https://pypi.tuna.tsinghua.edu.cn/simple/" },
|
||||
@{ Name = "tencent"; Url = "https://mirrors.cloud.tencent.com/pypi/simple/" },
|
||||
@{ Name = "huawei"; Url = "https://repo.huaweicloud.com/repository/pypi/simple/" },
|
||||
@{ Name = "pypi"; Url = "https://pypi.org/simple/" }
|
||||
)
|
||||
|
||||
function Read-Utf8Text {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
return [System.IO.File]::ReadAllText($Path)
|
||||
}
|
||||
|
||||
function Get-SkillFrontmatter {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillMdPath)
|
||||
$raw = Read-Utf8Text -Path $SkillMdPath
|
||||
if ($raw -notmatch '(?s)\A---\s*\r?\n(.*?)\r?\n---') {
|
||||
throw "SKILL.md is missing frontmatter: $SkillMdPath"
|
||||
}
|
||||
return $Matches[1]
|
||||
}
|
||||
|
||||
function Get-FrontmatterScalar {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
$pattern = '(?m)^\s*' + [regex]::Escape($Key) + ':\s*["'']?([^\s"''#]+)["'']?\s*$'
|
||||
$match = [regex]::Match($Body, $pattern)
|
||||
if (-not $match.Success) {
|
||||
return $null
|
||||
}
|
||||
return $match.Groups[1].Value.Trim()
|
||||
}
|
||||
|
||||
function Get-RequirementsLines {
|
||||
param([Parameter(Mandatory = $true)][string]$Content)
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($raw in ($Content -split '\r?\n')) {
|
||||
$trimmed = $raw.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith('#')) {
|
||||
continue
|
||||
}
|
||||
$withoutInline = ($trimmed -split '\s+#', 2)[0].Trim()
|
||||
if ($withoutInline) {
|
||||
$lines.Add($withoutInline)
|
||||
}
|
||||
}
|
||||
return @($lines)
|
||||
}
|
||||
|
||||
function Get-RequirementsHash {
|
||||
param([Parameter(Mandatory = $true)][string]$Content)
|
||||
$normalized = (@(Get-RequirementsLines -Content $Content)) -join "`n"
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
|
||||
$hash = $sha.ComputeHash($bytes)
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-DistributionNames {
|
||||
param([Parameter(Mandatory = $true)][string[]]$Lines)
|
||||
$names = New-Object System.Collections.Generic.List[string]
|
||||
$seen = New-Object 'System.Collections.Generic.HashSet[string]'
|
||||
foreach ($line in $Lines) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed -or $trimmed.StartsWith('#') -or $trimmed.StartsWith('-')) {
|
||||
continue
|
||||
}
|
||||
if ($trimmed -match '^(https?:|git\+|svn\+|hg\+|file:)') {
|
||||
continue
|
||||
}
|
||||
$match = [regex]::Match($trimmed, '^([A-Za-z0-9][A-Za-z0-9._-]*)')
|
||||
if (-not $match.Success) {
|
||||
continue
|
||||
}
|
||||
$name = $match.Groups[1].Value
|
||||
$key = $name.ToLowerInvariant().Replace('_', '-')
|
||||
if ($seen.Add($key)) {
|
||||
$names.Add($name)
|
||||
}
|
||||
}
|
||||
return @($names)
|
||||
}
|
||||
|
||||
function Resolve-SkillDataRoot {
|
||||
param([string]$Explicit)
|
||||
if ($Explicit -and $Explicit.Trim()) {
|
||||
return $Explicit.Trim().TrimEnd('\')
|
||||
}
|
||||
|
||||
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
|
||||
if (Test-Path -LiteralPath $settingsPath) {
|
||||
try {
|
||||
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
|
||||
$fromSettings = [string]$settings.jiangchangSkillDataRoot
|
||||
if ($fromSettings -and $fromSettings.Trim()) {
|
||||
return $fromSettings.Trim().TrimEnd('\')
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Could not read $settingsPath. Falling back to the default data root. $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidate in @("D:\jiangchang-data", (Join-Path $env:USERPROFILE ".jiangchang-data"))) {
|
||||
$python = Join-Path $candidate "python-runtime\.venv\Scripts\python.exe"
|
||||
if (Test-Path -LiteralPath $python) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
return "D:\jiangchang-data"
|
||||
}
|
||||
|
||||
function Resolve-JiangchangUserId {
|
||||
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
|
||||
if (-not (Test-Path -LiteralPath $settingsPath)) {
|
||||
throw "Jiangchang settings were not found: $settingsPath`nOpen Jiangchang and log in, then run this script again."
|
||||
}
|
||||
try {
|
||||
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Could not read the logged-in user from $settingsPath. $($_.Exception.Message)"
|
||||
}
|
||||
$userId = ""
|
||||
if ($null -ne $settings -and $settings.PSObject.Properties.Name -contains "jiangchangUserId" -and $null -ne $settings.jiangchangUserId) {
|
||||
$userId = ([string]$settings.jiangchangUserId).Trim()
|
||||
}
|
||||
if (-not $userId -or $userId -eq "_anon") {
|
||||
throw "Jiangchang has no logged-in user. Open Jiangchang and log in, then run this script again. This script does not guess a user id."
|
||||
}
|
||||
$normalized = $userId.Replace("\", "/")
|
||||
if ($normalized.Contains("..") -or $normalized.Contains("/") -or $userId.StartsWith("\") -or $userId -match '^[A-Za-z]:') {
|
||||
throw "jiangchangUserId is not a single folder name: $userId"
|
||||
}
|
||||
return $userId
|
||||
}
|
||||
|
||||
function Test-InitDbJsonOk {
|
||||
param([Parameter(Mandatory = $true)][string]$Text)
|
||||
foreach ($line in ($Text -split "`r?`n")) {
|
||||
$trimmed = $line.Trim()
|
||||
if (-not $trimmed.StartsWith("{")) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
$parsed = $trimmed | ConvertFrom-Json
|
||||
if ($parsed.ok -eq $true) {
|
||||
return $true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-InitDbUnknownCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Text,
|
||||
[Parameter(Mandatory = $true)][int]$ExitCode
|
||||
)
|
||||
if ($ExitCode -eq 2) {
|
||||
return $true
|
||||
}
|
||||
$lower = $Text.ToLowerInvariant()
|
||||
if ($lower.Contains("invalid choice") -or $lower.Contains("unrecognized arguments") -or $lower.Contains("unknown command")) {
|
||||
return $true
|
||||
}
|
||||
if ($lower.Contains("invalid") -and $lower.Contains("init-db")) {
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Invoke-SkillInitDb {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string]$SkillDir,
|
||||
[Parameter(Mandatory = $true)][string]$DataRoot,
|
||||
[Parameter(Mandatory = $true)][string]$UserId,
|
||||
[switch]$DryRun
|
||||
)
|
||||
$mainPy = Join-Path $SkillDir "scripts\main.py"
|
||||
if (-not (Test-Path -LiteralPath $mainPy)) {
|
||||
Write-Host "No scripts\main.py. Skipping init-db."
|
||||
return
|
||||
}
|
||||
if ($DryRun) {
|
||||
Write-Host "DryRun: would run init-db" -ForegroundColor Yellow
|
||||
Write-Host " python $PythonExe"
|
||||
Write-Host " script $mainPy init-db"
|
||||
Write-Host " data $DataRoot"
|
||||
Write-Host " user $UserId"
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $PythonExe)) {
|
||||
throw "Shared Python was not found: $PythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot."
|
||||
}
|
||||
|
||||
Write-Host ">> Initialize the logged-in user's database (init-db)" -ForegroundColor DarkGray
|
||||
Write-Host " user=$UserId"
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $PythonExe
|
||||
$psi.WorkingDirectory = $SkillDir
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.Arguments = '"' + $mainPy + '" init-db'
|
||||
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
|
||||
$psi.EnvironmentVariables["JIANGCHANG_DATA_ROOT"] = $DataRoot
|
||||
$psi.EnvironmentVariables["JIANGCHANG_USER_ID"] = $UserId
|
||||
$psi.EnvironmentVariables["JIANGCHANG_RUN_MODE"] = "cli"
|
||||
$psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
$proc = New-Object System.Diagnostics.Process
|
||||
$proc.StartInfo = $psi
|
||||
[void]$proc.Start()
|
||||
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $proc.StandardError.ReadToEndAsync()
|
||||
if (-not $proc.WaitForExit(60000)) {
|
||||
try { $proc.Kill() } catch { }
|
||||
throw "init-db timed out after 60 seconds."
|
||||
}
|
||||
$proc.WaitForExit()
|
||||
$stdout = $stdoutTask.Result
|
||||
$stderr = $stderrTask.Result
|
||||
$combined = ($stdout + "`n" + $stderr).Trim()
|
||||
if ($stdout.Trim()) {
|
||||
Write-Host $stdout.Trim()
|
||||
}
|
||||
if ($stderr.Trim()) {
|
||||
Write-Host $stderr.Trim()
|
||||
}
|
||||
|
||||
if (Test-InitDbJsonOk -Text $combined) {
|
||||
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
if ($proc.ExitCode -eq 0 -and $combined -match '(?m)^OK:\s*INIT_DB\b') {
|
||||
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
if (Test-InitDbUnknownCommand -Text $combined -ExitCode $proc.ExitCode) {
|
||||
Write-Warning "This skill has no init-db command. Database initialization was skipped."
|
||||
return
|
||||
}
|
||||
|
||||
$detail = $stderr.Trim()
|
||||
if (-not $detail) {
|
||||
$detail = $stdout.Trim()
|
||||
}
|
||||
if ($detail.Length -gt 500) {
|
||||
$detail = $detail.Substring($detail.Length - 500)
|
||||
}
|
||||
if (-not $detail) {
|
||||
$detail = "init-db exited with code $($proc.ExitCode)"
|
||||
}
|
||||
throw "init-db failed for user ${UserId}: $detail"
|
||||
}
|
||||
|
||||
function Resolve-UvExecutable {
|
||||
$cmd = Get-Command uv -ErrorAction SilentlyContinue
|
||||
if ($cmd -and $cmd.Source) {
|
||||
return $cmd.Source
|
||||
}
|
||||
throw "uv was not found on PATH. The host installs skill requirements with uv pip, not pip."
|
||||
}
|
||||
|
||||
function Test-DistributionsPresent {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string[]]$Names
|
||||
)
|
||||
if ($Names.Count -eq 0) {
|
||||
return $false
|
||||
}
|
||||
$probe = @'
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
import sys
|
||||
missing = []
|
||||
for n in sys.argv[1:]:
|
||||
try:
|
||||
version(n)
|
||||
except PackageNotFoundError:
|
||||
missing.append(n)
|
||||
sys.stderr.write("missing:" + ",".join(missing) if missing else "")
|
||||
raise SystemExit(1 if missing else 0)
|
||||
'@
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
& $PythonExe -c $probe @Names 2>&1 | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InstalledDistributionVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PythonExe,
|
||||
[Parameter(Mandatory = $true)][string]$Name
|
||||
)
|
||||
$prev = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
$output = & $PythonExe -c "from importlib.metadata import version, PackageNotFoundError`nimport sys`nname=sys.argv[1]`ntry:`n print(version(name))`nexcept PackageNotFoundError:`n print('')" $Name 2>$null
|
||||
$code = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $prev
|
||||
}
|
||||
if ($code -ne 0) {
|
||||
return $null
|
||||
}
|
||||
return ([string]$output).Trim()
|
||||
}
|
||||
|
||||
function Compare-VersionPrefix {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Installed,
|
||||
[Parameter(Mandatory = $true)][string]$Required
|
||||
)
|
||||
function Convert-ToVersion([string]$Text) {
|
||||
$core = ($Text -split '[^0-9.]', 2)[0].Trim('.')
|
||||
$parts = @($core -split '\.' | Where-Object { $_ -ne '' })
|
||||
while ($parts.Count -lt 3) {
|
||||
$parts += '0'
|
||||
}
|
||||
$take = @($parts | Select-Object -First 3)
|
||||
return [version]($take -join '.')
|
||||
}
|
||||
try {
|
||||
return (Convert-ToVersion $Installed) -ge (Convert-ToVersion $Required)
|
||||
} catch {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RobocopyMirror {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Source,
|
||||
[Parameter(Mandatory = $true)][string]$Dest,
|
||||
[switch]$ListOnly
|
||||
)
|
||||
$excludeDirs = @(
|
||||
".git", ".github", "__pycache__", ".pytest_cache", "htmlcov",
|
||||
"dist", "build", ".venv", "_fix_workspace", ".skill-requirements"
|
||||
)
|
||||
$excludeFiles = @(".env", "*.env.local", "*.pyc", "*.pyo")
|
||||
$robocopyArgs = @($Source, $Dest, "/MIR", "/R:2", "/W:1", "/XD") + $excludeDirs + @("/XF") + $excludeFiles
|
||||
if ($ListOnly) {
|
||||
$robocopyArgs += "/L"
|
||||
}
|
||||
& robocopy.exe @robocopyArgs
|
||||
$code = $LASTEXITCODE
|
||||
if ($code -ge 8) {
|
||||
throw "Copy failed (robocopy exit $code). Stop the running skill job in Task Center, then retry."
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-PyarmorRuntimes {
|
||||
param([Parameter(Mandatory = $true)][string]$Dest)
|
||||
$removed = @()
|
||||
$dirs = @(Get-ChildItem -LiteralPath $Dest -Recurse -Directory -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "pyarmor_runtime_*" })
|
||||
foreach ($dir in $dirs) {
|
||||
Remove-Item -LiteralPath $dir.FullName -Recurse -Force
|
||||
$removed += $dir.FullName
|
||||
}
|
||||
return $removed
|
||||
}
|
||||
|
||||
function Assert-PlainMainPy {
|
||||
param([Parameter(Mandatory = $true)][string]$MainPy)
|
||||
if (-not (Test-Path -LiteralPath $MainPy)) {
|
||||
throw "scripts\main.py is missing after sync: $MainPy"
|
||||
}
|
||||
$head = Read-Utf8Text -Path $MainPy
|
||||
if ($head -match 'pyarmor_runtime_|__pyarmor__') {
|
||||
throw "scripts\main.py is still PyArmor-encrypted. Re-run .\sync.ps1 so the whole scripts tree is replaced."
|
||||
}
|
||||
}
|
||||
|
||||
function Write-RequirementsState {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RuntimeDir,
|
||||
[Parameter(Mandatory = $true)][string]$Slug,
|
||||
[Parameter(Mandatory = $true)][string]$RequirementsPath,
|
||||
[Parameter(Mandatory = $true)][string]$RequirementsHash,
|
||||
[string]$RuntimeFingerprint
|
||||
)
|
||||
$stateDir = Join-Path $RuntimeDir ".skill-requirements"
|
||||
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
|
||||
$state = [ordered]@{
|
||||
skill = $Slug
|
||||
requirementsPath = $RequirementsPath
|
||||
requirementsHash = $RequirementsHash
|
||||
runtimeFingerprint = $RuntimeFingerprint
|
||||
installedAt = [DateTime]::UtcNow.ToString("o")
|
||||
status = "ok"
|
||||
}
|
||||
$json = ($state | ConvertTo-Json -Depth 4) + "`n"
|
||||
$statePath = Join-Path $stateDir "$Slug.json"
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
[System.IO.File]::WriteAllText($statePath, $json, $utf8)
|
||||
}
|
||||
|
||||
$skillMd = Join-Path $SourceRoot "SKILL.md"
|
||||
$sourceMain = Join-Path $SourceRoot "scripts\main.py"
|
||||
$sourceReq = Join-Path $SourceRoot "requirements.txt"
|
||||
if (-not (Test-Path -LiteralPath $skillMd)) {
|
||||
throw "SKILL.md not found: $skillMd"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $sourceMain)) {
|
||||
throw "scripts\main.py not found: $sourceMain"
|
||||
}
|
||||
|
||||
$frontmatter = Get-SkillFrontmatter -SkillMdPath $skillMd
|
||||
$slug = Get-FrontmatterScalar -Body $frontmatter -Key "slug"
|
||||
$version = Get-FrontmatterScalar -Body $frontmatter -Key "version"
|
||||
$platformKitMin = Get-FrontmatterScalar -Body $frontmatter -Key "platform_kit_min_version"
|
||||
if (-not $slug) {
|
||||
throw "metadata.openclaw.slug in SKILL.md is empty, so the install directory cannot be determined."
|
||||
}
|
||||
|
||||
if (-not $Destination -or -not $Destination.Trim()) {
|
||||
$Destination = Join-Path $env:USERPROFILE ".openclaw\skills\$slug"
|
||||
}
|
||||
$Destination = $Destination.Trim().TrimEnd('\')
|
||||
|
||||
$sourceFull = [System.IO.Path]::GetFullPath($SourceRoot)
|
||||
$destFull = [System.IO.Path]::GetFullPath($Destination)
|
||||
if ($sourceFull.TrimEnd('\') -eq $destFull.TrimEnd('\')) {
|
||||
throw "Source and destination are the same directory: $sourceFull"
|
||||
}
|
||||
|
||||
$resolvedDataRoot = Resolve-SkillDataRoot -Explicit $DataRoot
|
||||
$runtimeDir = Join-Path $resolvedDataRoot "python-runtime"
|
||||
$pythonExe = Join-Path $runtimeDir ".venv\Scripts\python.exe"
|
||||
$resolvedUserId = $null
|
||||
if (-not $SkipInitDb) {
|
||||
$resolvedUserId = Resolve-JiangchangUserId
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Local skill sync (does not publish to the skill market)" -ForegroundColor Cyan
|
||||
Write-Host " source $sourceFull"
|
||||
Write-Host " dest $destFull"
|
||||
Write-Host " slug $slug"
|
||||
Write-Host " version $(if ($version) { $version } else { '(none)' })"
|
||||
Write-Host " data $resolvedDataRoot"
|
||||
Write-Host " python $pythonExe"
|
||||
Write-Host " user $(if ($SkipInitDb) { '(init-db skipped)' } else { $resolvedUserId })"
|
||||
if ($DryRun) {
|
||||
Write-Host " mode DryRun (no writes)" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path -LiteralPath $destFull)) {
|
||||
Write-Warning "Destination does not exist yet. Files can be created, but if this skill was never installed from the market the host may not have enabled it. Install it once, then use this script to overwrite."
|
||||
}
|
||||
|
||||
$configPath = Join-Path $env:USERPROFILE ".openclaw\openclaw.json"
|
||||
if (Test-Path -LiteralPath $configPath) {
|
||||
try {
|
||||
$config = Read-Utf8Text -Path $configPath | ConvertFrom-Json
|
||||
$entry = $null
|
||||
if ($config.PSObject.Properties.Name -contains "skills" -and $null -ne $config.skills -and $config.skills.PSObject.Properties.Name -contains "entries" -and $null -ne $config.skills.entries) {
|
||||
$entryProp = $config.skills.entries.PSObject.Properties[$slug]
|
||||
if ($entryProp) {
|
||||
$entry = $entryProp.Value
|
||||
}
|
||||
}
|
||||
if ($null -eq $entry) {
|
||||
Write-Warning "openclaw.json has no skills.entries.$slug. Install the skill once from the market, or chat and data management may not see it."
|
||||
} elseif ($entry.PSObject.Properties.Name -contains "enabled" -and $entry.enabled -eq $false) {
|
||||
Write-Warning "skills.entries.$slug is disabled. Syncing files does not re-enable it."
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Could not check openclaw.json enablement: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ">> Mirror source onto the install directory (exclude .git, drop encrypted runtime)" -ForegroundColor DarkGray
|
||||
Invoke-RobocopyMirror -Source $sourceFull -Dest $destFull -ListOnly:$DryRun
|
||||
if (-not $DryRun) {
|
||||
$removed = @(Remove-PyarmorRuntimes -Dest $destFull)
|
||||
foreach ($path in $removed) {
|
||||
Write-Host " removed $path"
|
||||
}
|
||||
Assert-PlainMainPy -MainPy (Join-Path $destFull "scripts\main.py")
|
||||
Write-Host "scripts\main.py is plaintext." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "DryRun: pyarmor_runtime_* was not deleted, and no destination file was changed." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$skipInstall = $false
|
||||
if ($SkipDeps) {
|
||||
Write-Host "Skipped dependency install (-SkipDeps)." -ForegroundColor Yellow
|
||||
$skipInstall = $true
|
||||
} elseif (-not (Test-Path -LiteralPath $pythonExe)) {
|
||||
throw "Shared Python was not found: $pythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot. If the copy already finished, install dependencies before running the skill."
|
||||
} else {
|
||||
$reqPath = if ($DryRun) { $sourceReq } else { Join-Path $destFull "requirements.txt" }
|
||||
if (-not (Test-Path -LiteralPath $reqPath)) {
|
||||
Write-Host "No requirements.txt. Skipping dependency install."
|
||||
$skipInstall = $true
|
||||
} else {
|
||||
$reqContent = Read-Utf8Text -Path $reqPath
|
||||
$reqLines = @(Get-RequirementsLines -Content $reqContent)
|
||||
if ($reqLines.Count -eq 0) {
|
||||
Write-Host "requirements.txt has no effective lines. Skipping install."
|
||||
$skipInstall = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $skipInstall) {
|
||||
|
||||
$reqHash = Get-RequirementsHash -Content $reqContent
|
||||
$stampPath = Join-Path $runtimeDir ".jiangchang-runtime-stamp.json"
|
||||
$runtimeFingerprint = ""
|
||||
if (Test-Path -LiteralPath $stampPath) {
|
||||
try {
|
||||
$stamp = Read-Utf8Text -Path $stampPath | ConvertFrom-Json
|
||||
$runtimeFingerprint = [string]$stamp.fingerprint
|
||||
} catch {
|
||||
$runtimeFingerprint = ""
|
||||
}
|
||||
}
|
||||
|
||||
$statePath = Join-Path $runtimeDir ".skill-requirements\$slug.json"
|
||||
$previousHash = ""
|
||||
$previousFingerprint = ""
|
||||
$previousStatus = ""
|
||||
if (Test-Path -LiteralPath $statePath) {
|
||||
try {
|
||||
$previous = Read-Utf8Text -Path $statePath | ConvertFrom-Json
|
||||
$previousHash = [string]$previous.requirementsHash
|
||||
$previousFingerprint = [string]$previous.runtimeFingerprint
|
||||
$previousStatus = [string]$previous.status
|
||||
} catch {
|
||||
$previousStatus = ""
|
||||
}
|
||||
}
|
||||
|
||||
$distributionNames = @(Get-DistributionNames -Lines $reqLines)
|
||||
$unchanged = (
|
||||
-not $ForceDeps -and
|
||||
$previousStatus -eq "ok" -and
|
||||
$previousHash -eq $reqHash -and
|
||||
$runtimeFingerprint -and
|
||||
$previousFingerprint -eq $runtimeFingerprint
|
||||
)
|
||||
if ($unchanged -and $distributionNames.Count -gt 0 -and (Test-DistributionsPresent -PythonExe $pythonExe -Names $distributionNames)) {
|
||||
Write-Host "Requirements unchanged and already present in the shared environment. Skipping uv pip install." -ForegroundColor Green
|
||||
} elseif ($DryRun) {
|
||||
Write-Host "DryRun: would run uv pip install -r requirements.txt" -ForegroundColor Yellow
|
||||
$distributionNames | ForEach-Object { Write-Host " - $_" }
|
||||
} else {
|
||||
$uv = Resolve-UvExecutable
|
||||
Write-Host ">> Install skill requirements into the shared environment" -ForegroundColor DarkGray
|
||||
Write-Host " uv=$uv"
|
||||
$installed = $false
|
||||
foreach ($mirror in $PypiMirrors) {
|
||||
Write-Host ">> uv pip install --index-url $($mirror.Url)" -ForegroundColor DarkGray
|
||||
& $uv pip install --index-url $mirror.Url --python $pythonExe -r $reqPath
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$installed = $true
|
||||
break
|
||||
}
|
||||
Write-Warning "Mirror $($mirror.Name) failed (exit $LASTEXITCODE). Trying the next one."
|
||||
}
|
||||
if (-not $installed) {
|
||||
throw "requirements.txt install failed. Code is already at $destFull, but the shared environment is missing dependencies."
|
||||
}
|
||||
Write-RequirementsState -RuntimeDir $runtimeDir -Slug $slug -RequirementsPath $reqPath -RequirementsHash $reqHash -RuntimeFingerprint $runtimeFingerprint
|
||||
Write-Host "Dependencies are installed in the shared environment." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if ($platformKitMin -and (Test-Path -LiteralPath $pythonExe)) {
|
||||
$kitVersion = Get-InstalledDistributionVersion -PythonExe $pythonExe -Name "jiangchang-platform-kit"
|
||||
if (-not $kitVersion) {
|
||||
Write-Warning "jiangchang-platform-kit is not installed in the shared environment, but this skill requires >= $platformKitMin."
|
||||
} elseif (-not (Compare-VersionPrefix -Installed $kitVersion -Required $platformKitMin)) {
|
||||
Write-Warning "jiangchang-platform-kit $kitVersion is below this skill's requirement >= $platformKitMin. This script does not upgrade the platform kit."
|
||||
} else {
|
||||
Write-Host "platform kit $kitVersion satisfies >= $platformKitMin." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if ($SkipInitDb) {
|
||||
Write-Host "Skipped init-db (-SkipInitDb). The database was not updated." -ForegroundColor Yellow
|
||||
} else {
|
||||
$initSkillDir = $destFull
|
||||
if ($DryRun -and -not (Test-Path -LiteralPath (Join-Path $destFull "scripts\main.py"))) {
|
||||
$initSkillDir = $sourceFull
|
||||
}
|
||||
Invoke-SkillInitDb -PythonExe $pythonExe -SkillDir $initSkillDir -DataRoot $resolvedDataRoot -UserId $resolvedUserId -DryRun:$DryRun
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Done. Stop any running job, leave Data Management, then open it again." -ForegroundColor Cyan
|
||||
Write-Host "Do not click Update in the skill market. That replaces this source with the encrypted package." -ForegroundColor Cyan
|
||||
@@ -74,7 +74,14 @@ def _validate_bind_tables(action_id: str, bind: object) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not isinstance(bind, dict):
|
||||
return [f"{action_id}: bind must be object"]
|
||||
allowed = {"tables", "inputMapping", "columnMapping", "labelColumn", "selectionMode"}
|
||||
allowed = {
|
||||
"tables",
|
||||
"inputMapping",
|
||||
"columnMapping",
|
||||
"labelColumn",
|
||||
"selectionMode",
|
||||
"visibleWhen",
|
||||
}
|
||||
unknown = set(bind.keys()) - allowed
|
||||
if unknown:
|
||||
errors.append(f"{action_id}: bind only allows {sorted(allowed)}, got {sorted(bind.keys())}")
|
||||
@@ -103,6 +110,9 @@ def _validate_bind_tables(action_id: str, bind: object) -> list[str]:
|
||||
errors.append(
|
||||
f"{action_id}: bind.selectionMode must be required|none|optional, got {selection_mode!r}"
|
||||
)
|
||||
visible_when = bind.get("visibleWhen")
|
||||
if visible_when is not None and (not isinstance(visible_when, dict) or not visible_when):
|
||||
errors.append(f"{action_id}: bind.visibleWhen must be a non-empty object")
|
||||
return errors
|
||||
|
||||
|
||||
@@ -280,6 +290,29 @@ class TestActionsManifest(unittest.TestCase):
|
||||
action_props = schema["$defs"]["action"]["properties"]
|
||||
self.assertNotIn("visibleWhen", action_props)
|
||||
|
||||
def test_schema_defines_enum_names_on_input_property(self) -> None:
|
||||
schema = _load_actions_schema()
|
||||
props = schema["$defs"]["inputProperty"]["properties"]
|
||||
self.assertIn("enum", props)
|
||||
self.assertIn("enumNames", props)
|
||||
self.assertEqual(props["enumNames"].get("type"), "array")
|
||||
self.assertEqual(props["enumNames"].get("minItems"), 1)
|
||||
self.assertIn("column", props)
|
||||
|
||||
def test_enum_names_example_exists_and_aligns_labels(self) -> None:
|
||||
path = os.path.join(
|
||||
get_skill_root(), "assets", "examples", "enum-names-action.example.json"
|
||||
)
|
||||
self.assertTrue(os.path.isfile(path), msg="enum-names example must exist")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
example = json.load(f)
|
||||
props = example["actions"][0]["inputSchema"]["properties"]
|
||||
category = props["category"]
|
||||
self.assertEqual(len(category["enum"]), len(category["enumNames"]))
|
||||
self.assertEqual(category.get("column"), "category")
|
||||
priority = props["priority"]
|
||||
self.assertEqual(len(priority["enum"]), len(priority["enumNames"]))
|
||||
|
||||
def test_default_manifest_omits_visible_when(self) -> None:
|
||||
for action in _load_actions_manifest()["actions"]:
|
||||
properties = ((action.get("inputSchema") or {}).get("properties")) or {}
|
||||
@@ -522,6 +555,51 @@ class TestActionsManifest(unittest.TestCase):
|
||||
}
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
|
||||
def test_schema_allows_bind_visible_when(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.fail("jsonschema is required")
|
||||
schema = _load_actions_schema()
|
||||
bind_props = schema["$defs"]["bind"]["properties"]
|
||||
self.assertIn("visibleWhen", bind_props)
|
||||
self.assertIn("visibleWhenRule", schema["$defs"])
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "probe-bind-visible-when",
|
||||
"label": "采用",
|
||||
"description": "验证 bind.visibleWhen 可通过 Schema",
|
||||
"placements": ["row"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "direct",
|
||||
"bind": {
|
||||
"tables": ["demo_items"],
|
||||
"visibleWhen": {
|
||||
"field": "status",
|
||||
"in": ["pending_review", "skipped"],
|
||||
},
|
||||
"inputMapping": {"itemId": "$row.$pk"},
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["itemId"],
|
||||
"properties": {
|
||||
"itemId": {"type": "number", "readOnly": True},
|
||||
},
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "approve",
|
||||
"args": ["--id", "{{itemId}}"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
|
||||
def test_action_examples_user_facing_copy_avoids_jargon(self) -> None:
|
||||
"""示例 Action 的用户可见字符串不得含常见开发黑话(属性名本身除外)。"""
|
||||
examples_dir = os.path.join(get_skill_root(), "assets", "examples")
|
||||
@@ -740,11 +818,16 @@ class TestActionsManifest(unittest.TestCase):
|
||||
"labelColumn",
|
||||
"selectionMode",
|
||||
"勾选驱动",
|
||||
"行菜单显隐",
|
||||
"bind.visibleWhen",
|
||||
"用户可见文案",
|
||||
"双受众",
|
||||
"已选择",
|
||||
"revealInForm",
|
||||
"映射只读",
|
||||
"enumNames",
|
||||
"静态枚举 label",
|
||||
"绑列 options",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||||
self.assertNotIn(
|
||||
|
||||
49
tests/test_db_backend.py
Normal file
49
tests/test_db_backend.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库后端选择:默认 SQLite,DSN 解析。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from _support import IsolatedDataRoot
|
||||
|
||||
|
||||
class TestDbBackend(unittest.TestCase):
|
||||
def test_resolve_backend_defaults_to_sqlite(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.backend import resolve_backend
|
||||
|
||||
self.assertEqual(resolve_backend(), "sqlite")
|
||||
|
||||
def test_parse_mysql_dsn(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.backend import parse_mysql_dsn
|
||||
|
||||
params = parse_mysql_dsn("mysql://root:secret@192.168.0.20:3306/demo_db")
|
||||
self.assertEqual(params["host"], "192.168.0.20")
|
||||
self.assertEqual(params["port"], 3306)
|
||||
self.assertEqual(params["user"], "root")
|
||||
self.assertEqual(params["password"], "secret")
|
||||
self.assertEqual(params["database"], "demo_db")
|
||||
|
||||
def test_resolve_backend_uses_mysql_dsn_from_env(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.backend import resolve_backend
|
||||
|
||||
os.environ["MYSQL_DSN"] = "mysql://u:p@127.0.0.1:3306/t"
|
||||
try:
|
||||
self.assertEqual(resolve_backend(), "mysql")
|
||||
finally:
|
||||
os.environ.pop("MYSQL_DSN", None)
|
||||
|
||||
def test_describe_active_store_sqlite(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.connection import describe_active_store
|
||||
|
||||
info = describe_active_store()
|
||||
self.assertEqual(info["backend"], "sqlite")
|
||||
self.assertIn("db_path", info)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -30,6 +30,7 @@ POLICY_IDS = (
|
||||
"POLICY-CONFIG-002",
|
||||
"POLICY-CONFIG-003",
|
||||
"POLICY-CONFIG-004",
|
||||
"POLICY-CONFIG-005",
|
||||
"POLICY-TESTING-001",
|
||||
"POLICY-TESTING-002",
|
||||
"POLICY-RPA-001",
|
||||
@@ -38,6 +39,7 @@ POLICY_IDS = (
|
||||
"POLICY-RPA-004",
|
||||
"POLICY-RPA-005",
|
||||
"POLICY-RPA-006",
|
||||
"POLICY-RPA-007",
|
||||
"POLICY-PACKAGING-001",
|
||||
"POLICY-PACKAGING-002",
|
||||
"POLICY-RELEASE-001",
|
||||
@@ -96,8 +98,11 @@ CONFIG_ENV_FORBIDDEN_PATTERNS = (
|
||||
)
|
||||
|
||||
CONFIG_004_SOURCE = "development/CONFIG.md §.env.example 注释 = 配置管理用户文案"
|
||||
CONFIG_005_SOURCE = "development/CONFIG.md §必填项:# @required"
|
||||
CONFIG_004_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
CONFIG_004_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||||
CONFIG_005_REQUIRED_MARKER_RE = re.compile(r"^@required\b", re.IGNORECASE)
|
||||
CONFIG_005_INLINE_REQUIRED_RE = re.compile(r"@required\b", re.IGNORECASE)
|
||||
# 注释中禁止出现的开发向指向 / 黑话(大小写不敏感子串)
|
||||
CONFIG_004_FORBIDDEN_COMMENT_SUBSTRINGS = (
|
||||
"development/",
|
||||
@@ -201,6 +206,26 @@ RPA_006_EXAMPLE_CLIENTS = (
|
||||
"examples/simulator_browser_rpa/scripts/service/account_client.py",
|
||||
)
|
||||
|
||||
RPA_007_SOURCE = (
|
||||
"development/RPA.md §1.2.1; development/RPA_BROWSER.md §4; "
|
||||
"examples/real_browser_rpa; development/POLICY_MATRIX.md"
|
||||
)
|
||||
RPA_007_DOC_MARKERS = (
|
||||
"no_viewport=True",
|
||||
"viewport=None",
|
||||
"setWindowBounds",
|
||||
"POLICY-RPA-007",
|
||||
)
|
||||
RPA_007_LAUNCH_MARKERS = RPA_004_LAUNCH_MARKERS
|
||||
RPA_007_FORBIDDEN_PATTERNS = (
|
||||
"viewport=None",
|
||||
"setWindowBounds",
|
||||
"maximize_browser_window",
|
||||
)
|
||||
RPA_007_REAL_BROWSER_SESSION = (
|
||||
"examples/real_browser_rpa/scripts/service/browser_session.py"
|
||||
)
|
||||
|
||||
LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md"
|
||||
LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
|
||||
LOGGING_003_SOURCE = "development/LOGGING.md"
|
||||
@@ -316,6 +341,34 @@ def _config_004_comment_offenders(path: str) -> list[str]:
|
||||
return offenders
|
||||
|
||||
|
||||
def _is_env_required_marker(text: str) -> bool:
|
||||
return bool(CONFIG_005_REQUIRED_MARKER_RE.match(text.strip()))
|
||||
|
||||
|
||||
def _config_005_offenders(path: str) -> list[str]:
|
||||
offenders: list[str] = []
|
||||
for lineno, key, _value, headers, inline in _iter_env_example_active_entries(path):
|
||||
if inline and CONFIG_005_INLINE_REQUIRED_RE.search(inline):
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= 禁止在行尾写 @required(须单独一行放在中文行头上方)"
|
||||
)
|
||||
|
||||
required_markers = [part for part in headers if _is_env_required_marker(part)]
|
||||
non_marker_headers = [part for part in headers if not _is_env_required_marker(part)]
|
||||
if not required_markers:
|
||||
continue
|
||||
if not non_marker_headers:
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= 使用了 # @required 但缺少中文行头"
|
||||
)
|
||||
continue
|
||||
if not any(CONFIG_004_CJK_RE.search(part) for part in non_marker_headers):
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= # @required 下方须有中文行头(说明「是什么」)"
|
||||
)
|
||||
return offenders
|
||||
|
||||
|
||||
def _rel(skill_root: str, path: str) -> str:
|
||||
return os.path.relpath(path, skill_root).replace("\\", "/")
|
||||
|
||||
@@ -406,6 +459,12 @@ def _scan_install_delivery_files(skill_root: str) -> list[str]:
|
||||
continue
|
||||
if suffix == ".sh" and not path.endswith(".sh"):
|
||||
continue
|
||||
# Root sync.ps1 is the manual local-host mirror. It installs
|
||||
# requirements into the shared venv the same way the host does.
|
||||
if os.path.basename(path) == "sync.ps1" and os.path.normcase(
|
||||
os.path.dirname(os.path.abspath(path))
|
||||
) == os.path.normcase(os.path.abspath(skill_root)):
|
||||
continue
|
||||
text = _read_text(path)
|
||||
for pattern in INSTALL_FORBIDDEN_PATTERNS:
|
||||
if pattern.search(text):
|
||||
@@ -569,6 +628,23 @@ class TestPolicyConfig004(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyConfig005(unittest.TestCase):
|
||||
def test_env_example_required_markers_are_well_formed(self) -> None:
|
||||
path = os.path.join(get_skill_root(), ".env.example")
|
||||
self.assertTrue(os.path.isfile(path), msg=".env.example missing")
|
||||
offenders = _config_005_offenders(path)
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-CONFIG-005",
|
||||
CONFIG_005_SOURCE,
|
||||
"\n".join(offenders)
|
||||
or "every # @required marker needs its own Chinese header line above the key",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyTesting001(unittest.TestCase):
|
||||
def test_root_tests_do_not_hardcode_real_targets(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
@@ -782,6 +858,84 @@ class TestPolicyRpa005(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyRpa007(unittest.TestCase):
|
||||
def test_rpa_md_documents_viewport_window_standard(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
path = os.path.join(skill_root, "development", "RPA.md")
|
||||
text = _read_text(path)
|
||||
missing = [marker for marker in RPA_007_DOC_MARKERS if marker not in text]
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-RPA-007",
|
||||
RPA_007_SOURCE,
|
||||
"RPA.md missing viewport/window markers: " + ", ".join(missing),
|
||||
),
|
||||
)
|
||||
|
||||
def test_real_browser_example_uses_no_viewport(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
path = os.path.join(skill_root, *RPA_007_REAL_BROWSER_SESSION.split("/"))
|
||||
text = _read_text(path)
|
||||
self.assertIn(
|
||||
"no_viewport=True",
|
||||
text,
|
||||
msg=_policy_msg(
|
||||
"POLICY-RPA-007",
|
||||
RPA_007_SOURCE,
|
||||
f"{RPA_007_REAL_BROWSER_SESSION} must use no_viewport=True",
|
||||
),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"viewport=None",
|
||||
text,
|
||||
msg=_policy_msg(
|
||||
"POLICY-RPA-007",
|
||||
RPA_007_SOURCE,
|
||||
f"{RPA_007_REAL_BROWSER_SESSION} must not use viewport=None",
|
||||
),
|
||||
)
|
||||
|
||||
def test_launch_persistent_forbids_bad_viewport_and_cdp_maximize(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
rel_paths: list[str] = []
|
||||
for root_name in ("scripts", "examples"):
|
||||
root = os.path.join(skill_root, root_name)
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if d not in SKIP_DIR_NAMES]
|
||||
rel_dir = os.path.relpath(dirpath, skill_root).replace("\\", "/")
|
||||
if any(rel_dir == p or rel_dir.startswith(p + "/") for p in SKIP_REL_PREFIXES):
|
||||
continue
|
||||
for name in filenames:
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(dirpath, name), skill_root).replace(
|
||||
"\\", "/"
|
||||
)
|
||||
rel_paths.append(rel)
|
||||
violations: list[str] = []
|
||||
for rel in rel_paths:
|
||||
text = _read_text(os.path.join(skill_root, *rel.split("/")))
|
||||
if not any(marker in text for marker in RPA_007_LAUNCH_MARKERS):
|
||||
continue
|
||||
for pattern in RPA_007_FORBIDDEN_PATTERNS:
|
||||
if pattern in text:
|
||||
violations.append(f"{rel}: forbidden {pattern!r}")
|
||||
self.assertEqual(
|
||||
violations,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-RPA-007",
|
||||
RPA_007_SOURCE,
|
||||
"launch_persistent files with forbidden viewport/window hacks: "
|
||||
+ "; ".join(violations),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyRpa006(unittest.TestCase):
|
||||
def test_account_manager_lifecycle_doc_has_required_markers(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
|
||||
@@ -595,6 +595,53 @@ metadata:
|
||||
self.assertIn("readonly = 1", text)
|
||||
self.assertIn("seed_column_metadata_batch", text)
|
||||
self.assertIn("valid_column_names", text)
|
||||
self.assertIn("可以**引用 `visible=0` 的列", text)
|
||||
self.assertIn("default_sort_json` / ORDER BY", text)
|
||||
|
||||
def test_default_sort_json_allows_hidden_column_and_rejects_missing(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
from db.display_metadata import (
|
||||
init_display_metadata_tables,
|
||||
seed_column_metadata_batch,
|
||||
upsert_table_metadata,
|
||||
)
|
||||
from db.display_metadata_validator import validate_default_sort_metadata
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
cur.execute(
|
||||
"CREATE TABLE demo_sort (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"
|
||||
)
|
||||
upsert_table_metadata(
|
||||
cur,
|
||||
table_name="demo_sort",
|
||||
display_name="排序示例",
|
||||
default_sort_json='[{"column":"title","direction":"asc"},{"column":"id","direction":"desc"}]',
|
||||
)
|
||||
seed_column_metadata_batch(
|
||||
cur,
|
||||
table_name="demo_sort",
|
||||
columns=(
|
||||
{"column_name": "id", "display_name": "编号", "visible": 0, "editable": 0},
|
||||
{"column_name": "title", "display_name": "标题", "visible": 1, "editable": 1},
|
||||
),
|
||||
valid_column_names=["id", "title"],
|
||||
)
|
||||
errors, warnings = validate_default_sort_metadata(cur, "demo_sort")
|
||||
self.assertEqual(errors, [])
|
||||
self.assertTrue(any("visible=0" in w and "id" in w for w in warnings))
|
||||
|
||||
upsert_table_metadata(
|
||||
cur,
|
||||
table_name="demo_sort",
|
||||
display_name="排序示例",
|
||||
default_sort_json='[{"column":"missing_col","direction":"asc"}]',
|
||||
)
|
||||
errors, _ = validate_default_sort_metadata(cur, "demo_sort")
|
||||
self.assertTrue(any("missing_col" in e for e in errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_default_visible_columns_json_upsert_and_migrate(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
|
||||
@@ -358,6 +358,7 @@ class TestDocsStandards(unittest.TestCase):
|
||||
"新增/编辑表单:字段说明与默认值(宿主支持)",
|
||||
"表单与展示:稳定字段类型(宿主支持,须与 Action 对称)",
|
||||
"datetime_unix_seconds",
|
||||
"year_month",
|
||||
"display_type: \"number\"",
|
||||
"extra_column",
|
||||
"{value} · {label}",
|
||||
@@ -384,18 +385,48 @@ class TestDocsStandards(unittest.TestCase):
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
|
||||
|
||||
def test_data_sources_md_documents_mysql_and_declaration(self) -> None:
|
||||
text = self._read("references/DATA_SOURCES.md")
|
||||
for marker in (
|
||||
"data-sources.json",
|
||||
"MYSQL_DSN",
|
||||
"6.9.28",
|
||||
"resolve_backend",
|
||||
"一个 `sourceId`",
|
||||
"process-work-order",
|
||||
"tableAdmin",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"DATA_SOURCES.md missing {marker!r}")
|
||||
self.assertTrue(os.path.isfile(os.path.join(get_skill_root(), "assets/data-sources.example.json")))
|
||||
self.assertTrue(
|
||||
os.path.isfile(os.path.join(get_skill_root(), "assets/examples/data-sources.mysql.example.json"))
|
||||
)
|
||||
env_example = self._read(".env.example")
|
||||
self.assertIn("MYSQL_DSN", env_example)
|
||||
checklist = self._read("development/CHECKLIST.md")
|
||||
self.assertIn("A6-16", checklist)
|
||||
self.assertIn("DATA_SOURCES.md", checklist)
|
||||
|
||||
def test_actions_md_documents_form_schema_split(self) -> None:
|
||||
text = self._read("references/ACTIONS.md")
|
||||
for marker in (
|
||||
"inputSchema 与参数表单",
|
||||
"表单字段类型对照(须与 CRUD 对称)",
|
||||
"跨表引用:Lookup",
|
||||
"display_type: \"lookup\"",
|
||||
'"display_type": "lookup"',
|
||||
"extra_column",
|
||||
"multiple",
|
||||
"skill_cli",
|
||||
"paged_table",
|
||||
"page_size",
|
||||
"暂无可选项",
|
||||
"local_sql",
|
||||
"external_api",
|
||||
"items_path",
|
||||
"数据管理新增/编辑",
|
||||
"_jiangchang_columns.description",
|
||||
"datetime_unix_seconds",
|
||||
"year_month",
|
||||
"SCHEMA.md",
|
||||
"字段条件显隐",
|
||||
"visibleWhen",
|
||||
@@ -403,6 +434,41 @@ class TestDocsStandards(unittest.TestCase):
|
||||
"列筛选",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(get_skill_root(), "assets/examples/lookup-skill-cli.action.example.json")
|
||||
),
|
||||
msg="lookup-skill-cli.action.example.json missing",
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(get_skill_root(), "assets/examples/lookup-paged-table.action.example.json")
|
||||
),
|
||||
msg="lookup-paged-table.action.example.json missing",
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
os.path.join(get_skill_root(), "assets/examples/enum-names-action.example.json")
|
||||
),
|
||||
msg="enum-names-action.example.json missing",
|
||||
)
|
||||
checklist = self._read("development/CHECKLIST.md")
|
||||
self.assertIn("skill_cli", checklist)
|
||||
self.assertIn("A4-12", checklist)
|
||||
self.assertIn("A4-13", checklist)
|
||||
self.assertIn("enumNames", checklist)
|
||||
self.assertIn("paged_table", checklist)
|
||||
account_mgr = self._read("development/ACCOUNT_MANAGER.md")
|
||||
self.assertIn("skill_cli", account_mgr)
|
||||
schema = self._read("references/SCHEMA.md")
|
||||
self.assertIn("skill_cli", schema)
|
||||
self.assertIn("enumNames", schema)
|
||||
schema_json = self._read("assets/schemas/skill-actions.schema.json")
|
||||
self.assertIn("skill_cli", schema_json)
|
||||
self.assertIn("paged_table", schema_json)
|
||||
self.assertIn("enumNames", schema_json)
|
||||
self.assertIn("page_size", schema_json)
|
||||
self.assertIn('"skill"', schema_json)
|
||||
|
||||
def test_actions_md_disambiguates_visible_when_from_policy(self) -> None:
|
||||
actions = self._read("references/ACTIONS.md")
|
||||
@@ -421,6 +487,7 @@ class TestDocsStandards(unittest.TestCase):
|
||||
def test_visible_when_example_is_listed(self) -> None:
|
||||
text = self._read("assets/examples/README.md")
|
||||
self.assertIn("visible-when-action.example.json", text)
|
||||
self.assertIn("bind-visible-when-row.action.example.json", text)
|
||||
self.assertIn("selection-mode-optional.action.example.json", text)
|
||||
self.assertIn("skill-cell-presentation.example.json", text)
|
||||
self.assertIn("skill-grid-presentation.example.json", text)
|
||||
@@ -432,15 +499,20 @@ class TestDocsStandards(unittest.TestCase):
|
||||
self.assertIn("A4-9", checklist)
|
||||
self.assertIn("selectionMode", checklist)
|
||||
self.assertIn("A4-10", checklist)
|
||||
self.assertIn("A4-11", checklist)
|
||||
self.assertIn("bind.visibleWhen", checklist)
|
||||
self.assertIn("用户可见文案", checklist)
|
||||
runtime = self._read("development/SKILL_ACTION_RUNTIME.md")
|
||||
self.assertIn("visibleWhen", runtime)
|
||||
self.assertIn("revealInForm", runtime)
|
||||
self.assertIn("selectionMode", runtime)
|
||||
self.assertIn("bind.visibleWhen", runtime)
|
||||
self.assertIn("用户可见文案", runtime)
|
||||
actions = self._read("references/ACTIONS.md")
|
||||
self.assertIn("用户可见文案", actions)
|
||||
self.assertIn("双受众", actions)
|
||||
self.assertIn("行菜单显隐", actions)
|
||||
self.assertIn("bind.visibleWhen", actions)
|
||||
|
||||
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
|
||||
forbidden = (
|
||||
|
||||
@@ -44,6 +44,8 @@ class TestSlugNaming(unittest.TestCase):
|
||||
for slug in (
|
||||
"download-settlement-report-amazon",
|
||||
"add-product-shopee",
|
||||
"generate-monthly-statements",
|
||||
"reconcile-finance",
|
||||
"generate-article-all",
|
||||
"your-skill-slug",
|
||||
"account-manager",
|
||||
@@ -56,8 +58,7 @@ class TestSlugNaming(unittest.TestCase):
|
||||
cases = (
|
||||
"amazon-download-settlement-report",
|
||||
"enter-cross-border-sales-receipt-voucher-kingdee",
|
||||
"reconcile-finance",
|
||||
"download-settlement-report-unknownplat",
|
||||
"generate",
|
||||
"foobar-settlement-report-amazon",
|
||||
)
|
||||
for slug in cases:
|
||||
@@ -78,6 +79,19 @@ class TestSlugNaming(unittest.TestCase):
|
||||
self.assertTrue(errors)
|
||||
self.assertTrue(any("48" in err for err in errors))
|
||||
|
||||
def test_plain_slug_without_platform(self) -> None:
|
||||
slug = "generate-monthly-statements"
|
||||
self.assertEqual(classify_slug_form(parse_slug_segments(slug), slug=slug), "plain")
|
||||
errors, warnings = validate_slug_semantics(slug)
|
||||
self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}")
|
||||
self.assertEqual(warnings, [])
|
||||
|
||||
def test_default_suffix_warns_but_does_not_fail(self) -> None:
|
||||
slug = "generate-article-default"
|
||||
errors, warnings = validate_slug_semantics(slug)
|
||||
self.assertEqual(errors, [], msg=f"errors={errors!r}")
|
||||
self.assertTrue(any("default" in item for item in warnings), msg=f"warnings={warnings!r}")
|
||||
|
||||
def test_legacy_exempt_set(self) -> None:
|
||||
self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS)
|
||||
self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS)
|
||||
|
||||
@@ -51,6 +51,7 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
|
||||
- **不**自动生成业务表名,**不**给 `health` / `version` / `config-path` 自动加 `toolbar`
|
||||
- **不**生成任何具体平台业务 Action
|
||||
- **不**自动 `git init`;打印后续 Git 与测试命令
|
||||
- 根目录同时复制 `release.ps1`(发布到服务器)和 `sync.ps1`(同步明文到本机宿主)
|
||||
|
||||
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
|
||||
|
||||
@@ -58,6 +59,8 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `release.ps1` | 发布到服务器;保留 |
|
||||
| `sync.ps1` | 把明文同步到本机宿主;保留。用法见 `development/DEVELOPMENT.md` §14.6 |
|
||||
| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 |
|
||||
| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 |
|
||||
| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 |
|
||||
|
||||
Reference in New Issue
Block a user