From 35c20fc0f2d822ef818aca1c7aeba7f5cd76257e Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sat, 11 Jul 2026 11:23:14 +0800 Subject: [PATCH] feat: standardize skill data management and actions Co-authored-by: Cursor --- SKILL.md | 2 +- assets/README.md | 5 +- assets/actions.json | 39 +++ assets/schemas/skill-actions.schema.json | 124 +++++++ development/DEVELOPMENT.md | 28 +- development/TESTING.md | 5 +- references/ACTIONS.md | 161 +++++++++ references/README.md | 5 +- references/SCHEMA.md | 273 ++++++++++++---- scripts/db/display_metadata.py | 109 +++++- scripts/db/display_metadata_validator.py | 98 ++++++ tests/test_actions_manifest.py | 186 +++++++++++ tests/test_data_management_contract.py | 400 +++++++++++++++++++++++ tests/test_docs_standards.py | 30 ++ tests/test_scaffold_guard.py | 52 +++ tools/README.md | 14 + tools/scaffold_skill.ps1 | 27 ++ tools/scaffold_skill.py | 44 +++ 18 files changed, 1534 insertions(+), 68 deletions(-) create mode 100644 assets/actions.json create mode 100644 assets/schemas/skill-actions.schema.json create mode 100644 references/ACTIONS.md create mode 100644 tests/test_actions_manifest.py create mode 100644 tests/test_data_management_contract.py diff --git a/SKILL.md b/SKILL.md index c7c2ffb..1ea37bd 100644 --- a/SKILL.md +++ b/SKILL.md @@ -83,7 +83,7 @@ python {baseDir}/scripts/main.py version 2. 业务技能仓库中**不得**存在 `.openclaw-skill-template`(仅 template 源仓库保留)。 3. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词。 4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制。 -5. 用户市场说明写入根 `README.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`。 +5. 用户市场说明写入根 `README.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`、`references/ACTIONS.md`。 6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。 7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`(元数据表 `_jiangchang_*` + `CREATE TABLE` 物理顺序)。 diff --git a/assets/README.md b/assets/README.md index c130f28..89c8aa3 100644 --- a/assets/README.md +++ b/assets/README.md @@ -1,8 +1,9 @@ # assets +- `actions.json`:Skill Action manifest 最小示例(可选;不需要宿主入口时可删除)。 - `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 -- `schemas/`:轻量 JSON Schema 示例(`additionalProperties: true` 允许扩展字段)。 +- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json`、`task-log-record.schema.json` 等)。 - 用户市场说明见根目录 [`README.md`](../README.md) -- Agent 调用/编排参考见 [`references/`](../references/) +- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md)) - 开发规范见 [`development/`](../development/) diff --git a/assets/actions.json b/assets/actions.json new file mode 100644 index 0000000..c047984 --- /dev/null +++ b/assets/actions.json @@ -0,0 +1,39 @@ +{ + "schemaVersion": 1, + "skill": "your-skill-slug", + "actions": [ + { + "id": "health", + "label": "运行检查", + "description": "检查技能运行环境并返回结构化结果。", + "placements": ["skill-detail", "agent"], + "entrypoint": { + "type": "cli", + "command": "health", + "args": [] + } + }, + { + "id": "version", + "label": "查看版本", + "description": "返回技能版本和机器标识。", + "placements": ["skill-detail"], + "entrypoint": { + "type": "cli", + "command": "version", + "args": [] + } + }, + { + "id": "config-path", + "label": "查看配置位置", + "description": "返回技能配置文件位置。", + "placements": ["skill-detail"], + "entrypoint": { + "type": "cli", + "command": "config-path", + "args": [] + } + } + ] +} diff --git a/assets/schemas/skill-actions.schema.json b/assets/schemas/skill-actions.schema.json new file mode 100644 index 0000000..5410052 --- /dev/null +++ b/assets/schemas/skill-actions.schema.json @@ -0,0 +1,124 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openclaw.local/skill-template/skill-actions.schema.json", + "title": "SkillActionManifest", + "description": "匠厂宿主 Skill Action manifest 通用契约(schemaVersion 1)", + "type": "object", + "required": ["schemaVersion", "skill", "actions"], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "type": "integer", + "const": 1 + }, + "skill": { + "type": "string", + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "与 SKILL.md metadata.openclaw.slug 一致" + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/action" } + } + }, + "$defs": { + "placement": { + "type": "string", + "enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"] + }, + "scalarArg": { + "type": ["string", "number", "boolean"] + }, + "inputProperty": { + "type": "object", + "required": ["type"], + "additionalProperties": true, + "properties": { + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object"] + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "default": {}, + "enum": { + "type": "array", + "minItems": 1 + }, + "sensitive": { "type": "boolean" } + } + }, + "inputSchema": { + "type": "object", + "required": ["type", "properties"], + "additionalProperties": false, + "properties": { + "type": { "const": "object" }, + "required": { + "type": "array", + "items": { "type": "string" } + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/inputProperty" } + } + } + }, + "entrypoint": { + "type": "object", + "required": ["type", "command", "args"], + "additionalProperties": false, + "properties": { + "type": { "const": "cli" }, + "command": { + "type": "string", + "minLength": 1 + }, + "args": { + "type": "array", + "items": { "$ref": "#/$defs/scalarArg" } + } + } + }, + "confirmation": { + "type": "object", + "required": ["message"], + "additionalProperties": false, + "properties": { + "message": { + "type": "string", + "minLength": 1 + } + } + }, + "action": { + "type": "object", + "required": ["id", "label", "description", "placements", "entrypoint"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + }, + "placements": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/placement" } + }, + "entrypoint": { "$ref": "#/$defs/entrypoint" }, + "inputSchema": { "$ref": "#/$defs/inputSchema" }, + "confirmation": { "$ref": "#/$defs/confirmation" } + } + } + } +} diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index 9a996bb..5172870 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -412,7 +412,8 @@ metadata: - `README.md` — Agent 参考索引(**不是**市场说明;不得含 YAML frontmatter 或 `description`) - `CLI.md` — 命令、参数、默认值、兄弟技能调用方式 -- `SCHEMA.md` — 数据库路径、核心表结构、日志表结构 +- `SCHEMA.md` — 数据库路径、`_jiangchang_*` 数据管理契约、日志表结构 +- `ACTIONS.md` — Skill Action manifest、宿主入口、参数与执行契约 - 可按需增加 `ERRORS.md`、`INTEGRATION.md` 等编排向补充 **`development/`**(开发者 / AI 编程代理) @@ -428,11 +429,14 @@ metadata: 建议放: +- `actions.json` + 宿主 Action 入口 manifest(可选;见 `references/ACTIONS.md`) + - `examples/` 比如 `version` 输出示例、`log-get` 输出示例 - `schemas/` - 比如日志记录、机读 JSON 的 schema + 比如 `skill-actions.schema.json`、日志记录、机读 JSON 的 schema 不要把正式研发文档放到 `assets/`。 用户说明进根 `README.md`;Agent 编排资料进 `references/`;开发规范进 `development/`。 @@ -493,6 +497,8 @@ metadata: 只做增删查改(模板默认表:`task_logs`) - `db/display_metadata.py` 创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md`) +- `db/display_metadata_validator.py` + 元数据完整性、editable 权限、options_json 等自检 - `db/timestamp_columns.py` 标准时间字段 trigger 与维护约定(`created_at` / `updated_at`,Unix 秒级) @@ -502,6 +508,24 @@ metadata: - 打印用户提示 - 拼接业务流程 +## 11.5 Skill Action manifest(可选) + +若技能需要出现在宿主**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中,提供 `assets/actions.json`。 + +- 契约说明见 [`references/ACTIONS.md`](../references/ACTIONS.md) +- JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) +- 模板最小示例仅暴露 `health` / `version` / `config-path` 三个 CLI 子命令 +- 没有宿主直接操作需求时,**可以删除** `actions.json`;删除后勿保留失效引用 +- 复制为新技能后,scaffold 会自动将 `your-skill-slug` 替换为新 slug;action 业务命令由技能作者自行调整 + +宿主执行方式: + +```text +python {skillRoot}/scripts/main.py +``` + +CLI 必须提供稳定退出码、结构化成功输出,以及 `ERROR::` / `HINT:` 错误契约(详见 `references/ACTIONS.md`)。 + ## 12. 如何接兄弟技能 如果 skill 要依赖兄弟 skill,不要在业务代码里写死绝对路径。 diff --git a/development/TESTING.md b/development/TESTING.md index 030c603..81dcc8c 100644 --- a/development/TESTING.md +++ b/development/TESTING.md @@ -28,7 +28,8 @@ - 运行时:`runtime_paths` 与 **`JIANGCHANG_*` 隔离**; - `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py); - SQLite 骨架:`task_logs` 创建幂等与仓储读写; -- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序(见 `test_display_metadata.py`); +- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序、editable 权限(见 `test_display_metadata.py`、`test_data_management_contract.py`); +- Skill Action manifest:`assets/actions.json` 结构、CLI 映射、placements 与 inputSchema 守护(见 `test_actions_manifest.py`); - 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`); - **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。 @@ -157,6 +158,8 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t - [ ] 至少有 1 个缺必填字段 / 非法输入测试。 - [ ] 如果有 adapter,至少覆盖 timeout / unauthorized / invalid response(可用 fake 模拟)。 - [ ] 如果有解析 / 计算 / 校验类业务,至少保留 1 组 golden fixture(脱敏)。 +- [ ] 若有 `assets/actions.json`,`tests/test_actions_manifest.py` 通过;不需要 Action 时可删除 manifest。 +- [ ] 若有本地 SQLite 业务表,`_jiangchang_*` 元数据与 `tests/test_data_management_contract.py` 规则一致。 - [ ] 真实 API / 真实 RPA 测试只放 `tests/integration/`,并且默认 `.sample`,不默认运行。 --- diff --git a/references/ACTIONS.md b/references/ACTIONS.md new file mode 100644 index 0000000..821f632 --- /dev/null +++ b/references/ACTIONS.md @@ -0,0 +1,161 @@ +# Skill Action Manifest(匠厂宿主) + +本文件说明 `assets/actions.json` 与 `scripts/main.py` CLI 的对应关系,供 Agent 编排与宿主 **Skill Action Runtime** 使用。 + +**边界:** 用户市场说明见根目录 [`README.md`](../README.md);CLI 参数与错误码细节见 [`CLI.md`](CLI.md)。 + +## Action 是否必需 + +Action 是**可选能力**: + +- 没有宿主直接操作需求的技能,**可以不提供** `actions.json`。 +- 需要出现在**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中的技能,**必须**提供 `assets/actions.json`。 + +## 文件位置 + +```text +{skill_root}/assets/actions.json +``` + +宿主扫描路径示例: + +```text +~/.openclaw/skills/your-skill-slug/assets/actions.json +``` + +## 顶层结构 + +| 字段 | 必填 | 说明 | +|------|------|------| +| `schemaVersion` | 是 | 当前固定为 `1` | +| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 | +| `actions` | 是 | Action 数组,每项 `id` 唯一 | + +模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。 + +## 执行模型 + +宿主将 action 转为 argv,再执行: + +```text +python {skill_root}/scripts/main.py +``` + +- `entrypoint.type` 当前只支持 `cli` +- `entrypoint.command` → CLI 子命令(须真实存在) +- `entrypoint.args` → 子命令参数(**必须为 JSON 数组**) +- `{{fieldName}}` → 来自 `inputSchema` 用户输入的简单模板替换 + +完整子命令定义见 [`scripts/cli/app.py`](../scripts/cli/app.py)。 + +### CLI 输出契约 + +技能 CLI 必须: + +- 使用稳定的退出码 +- 成功时输出可解析的结构化结果 +- 失败时输出稳定错误信息 +- 对可预期错误提供稳定错误码 +- 对用户可处理的问题提供 `HINT` +- 不依赖 Agent 连续对话才能完成 +- 不自行实现宿主任务中心 +- 长时间任务允许异步执行,由宿主任务中心承载状态 +- 不在 CLI 中硬编码宿主安装目录 +- 不直接依赖某个具体 UI 页面 + +建议错误输出格式: + +```text +ERROR:: +HINT: +``` + +## placements(入口位置) + +| 值 | 含义 | +|----|------| +| `toolbar` | 数据管理第一排技能按钮 | +| `skill-detail` | 技能详情或技能市场详情页 | +| `agent` | Agent 可直接调用 | +| `cron` | 定时任务可直接调用 | + +**预留能力(当前宿主可能尚未完整支持,模板示例请勿使用):** + +| 值 | 说明 | +|----|------| +| `row` | 行内操作(未来扩展) | +| `batch` | 批量操作(未来扩展) | + +可以在 manifest 中作为未来扩展值定义,但不要让技能作者误以为 `row` / `batch` 已经完整可用。 + +## inputSchema 与参数表单 + +当前宿主稳定支持: + +- `object` / `string` / `number` / `boolean` +- `enum` / `default` / `required` / `sensitive` +- 简单模板替换(`{{fieldName}}`) + +示例: + +```json +{ + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "title": "查询条件" + }, + "limit": { + "type": "number", + "title": "返回数量", + "default": 50 + } + } + } +} +``` + +约定: + +- `boolean` 在宿主中渲染为开关 +- `enum` 在宿主中渲染为选择框 +- `required` 字段在提交前校验 +- `sensitive` 字段不得出现在低信任入口 +- 需要确认的副作用操作使用 `confirmation.message` +- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明 +- `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null` +- **不要**在当前模板中宣传 `nested object`、`oneOf`、复杂数组等尚未稳定支持的能力 + +## 风险与入口配置 + +- 查询、诊断、版本检查可以放到 `skill-detail` 或 `agent` +- 低风险、常用动作才考虑放 `toolbar` +- 会修改本地数据、访问外部系统、执行长时间任务的动作必须谨慎配置 +- 有副作用的动作应添加 `confirmation.message` +- 高风险动作不应默认出现在 `toolbar` +- 含 `sensitive` 参数的 action 不应放入 `toolbar` 或 `cron`,除非有明确安全设计 +- 不要为了「按钮多」而暴露所有 CLI 子命令 +- 一个 CLI 命令可以拆成多个语义清晰的 action,但每个 action 必须有明确语义 + +## 模板示例 action 一览 + +| id | CLI | placements | 说明 | +|----|-----|------------|------| +| `health` | `health` | skill-detail, agent | 运行环境检查 | +| `version` | `version` | skill-detail | 版本 JSON | +| `config-path` | `config-path` | skill-detail | 配置路径 JSON | + +复制为新技能后,请按业务需要增删 action;不需要宿主入口时可删除整个 `actions.json`。 + +## 与宿主 API(参考) + +| 宿主 API | 用途 | +|----------|------| +| `GET /api/skill-actions/{skillSlug}` | 读取 manifest | +| `POST /api/skill-actions/run` | `{ skillSlug, actionId, input?, source? }` | +| `GET /api/skill-actions/jobs` | 任务中心 | + +具体路径与字段以宿主实现为准;技能侧只需保证 manifest 与 CLI 契约稳定。 diff --git a/references/README.md b/references/README.md index 2e17d8b..ce7c310 100644 --- a/references/README.md +++ b/references/README.md @@ -13,7 +13,8 @@ | 根目录 [`README.md`](../README.md) | 用户市场详情页说明 | | [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口 | | [`CLI.md`](CLI.md) | 命令行入口、参数与调用契约 | -| [`SCHEMA.md`](SCHEMA.md) | 输入输出、数据库与字段说明 | +| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 | +| [`ACTIONS.md`](ACTIONS.md) | Skill Action manifest 与宿主入口契约 | | [`../development/`](../development/) | 开发、测试、技术规范(给开发者 / AI 编程代理) | -按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`;运行时环境细节见 `../development/RUNTIME.md`。 +按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`;配置宿主按钮/定时任务/Agent 入口时读 `ACTIONS.md`;运行时环境细节见 `../development/RUNTIME.md`。 diff --git a/references/SCHEMA.md b/references/SCHEMA.md index d654264..4057b6d 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -5,10 +5,27 @@ ## 职责边界 -| 层级 | 负责内容 | -|------|----------| -| **技能(本仓库)** | `SKILL.md.name` 中文技能名;英文物理表/字段名;`_jiangchang_*` 中文展示元数据;`CREATE TABLE` 字段物理顺序 | -| **宿主(匠厂)** | 读取元数据与 PRAGMA 顺序并展示;不猜测业务语义、不按英文字段名重排 | +### 技能负责 + +- 定义真实 SQLite 表结构 +- 使用英文 `snake_case` 表名和字段名 +- 使用 `_jiangchang_tables` 提供表级展示元数据 +- 使用 `_jiangchang_columns` 提供字段级展示元数据 +- 定义哪些字段可见、可搜索、可创建、可编辑 +- 定义字段展示类型和枚举选项 +- 保证元数据与真实数据库结构一致 +- 保证元数据初始化幂等 + +### 宿主负责 + +- 读取 `_jiangchang_*` 元数据 +- 读取 SQLite `PRAGMA table_info` +- 根据 schema 渲染数据管理页面 +- 根据 `editable` 推导字段是否可创建或编辑 +- 执行通用查询、新增、编辑、删除、导入、导出 +- 对字段名、表名、类型和权限进行最终校验 +- 不猜测技能业务含义 +- 不根据具体技能名称写特殊逻辑 SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`;SQL 文件里的 `-- 注释` 也不会成为可查询结构。 因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info` 的 `cid`)。 @@ -27,9 +44,9 @@ metadata: --- ``` -- `name`:用户可见的中文业务名称(可含品牌,如 `GEO 文章生成`、`1688 联系人采集`)。 +- `name`:用户可见的中文业务名称。 - `description`:能力说明,不是技能名称。 -- `metadata.openclaw.slug`:机器标识,kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md);本文件仅描述数据库与 kebab-case 格式。 +- `metadata.openclaw.slug`:机器标识,kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md)。 - 复制模板后不得将 `your-skill-slug`、`My Skill` 等占位内容作为正式技能发布名称。 ## 数据库路径与命名 @@ -38,48 +55,145 @@ metadata: {JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill-slug}/{skill-slug}.db ``` -- 数据库文件:英文 slug,例如 `scrape-contacts.db`、`account-manager.db`(默认 `{skill-slug}.db`)。 -- 业务表、字段:**英文 snake_case** 物理名,例如 `task_logs`、`created_at`。 +- 数据库文件:英文 slug,例如 `your-skill-slug.db`(默认 `{skill-slug}.db`)。 +- 业务表、字段:**英文 snake_case** 物理名,例如 `demo_items`、`task_logs`、`created_at`。 - **禁止**用中文作为 SQLite 真实表名或字段名。 +## 外部来源只读与本地数据可写 + +某些技能会从外部系统读取数据并写入技能自己的本地数据库。需要区分: + +```text +外部来源数据库: + 可能只读,不应被修改 + +技能本地数据库: + 可以根据业务语义允许新增、编辑或删除 +``` + +不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。 + ## 元数据表(宿主兼容) -宿主读取字段(与 `jiangchang` `metadata-resolver.ts` 一致): +宿主读取字段(与匠厂 `metadata-resolver` 一致): -### `_jiangchang_tables` +### `_jiangchang_tables` 表级标准 | 字段 | 必填 | 说明 | |------|------|------| -| `table_name` | 是 | 物理表名 | -| `display_name` | 是 | 中文表名(用户可见) | +| `table_name` | 是 | 真实英文表名 | +| `display_name` | 是 | 用户可见的中文业务名称 | | `description` | 否 | 表说明 | -| `sort_order` | 否 | 宿主目录排序;与字段顺序无关 | -| `visible` | 是 | `1` 展示 / `0` 隐藏 | -| `readonly` | 是 | `1` 只读 / `0` 可写(业务语义) | +| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 | +| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 | +| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 | -### `_jiangchang_columns` +禁止: + +- 使用中文真实表名 +- 使用 `_schema_meta` 作为新的正式契约 +- 只写英文物理名而不写中文 `display_name` +- 让元数据引用不存在的表 + +### `_jiangchang_columns` 字段级标准 | 字段 | 必填 | 说明 | |------|------|------| -| `table_name` | 是 | 物理表名 | -| `column_name` | 是 | 物理字段名 | -| `display_name` | 是 | 中文字段名(用户可见) | +| `table_name` | 是 | 所属物理表 | +| `column_name` | 是 | 真实英文字段名 | +| `display_name` | 是 | 用户可见的中文字段名 | | `description` | 否 | 字段说明 | -| `display_order` | 否 | **遗留兼容字段;模板写入时固定 NULL,重复初始化会清理历史非空值,不得用于调整展示顺序** | -| `visible` | 是 | 是否展示 | -| `searchable` | 是 | 是否可搜索 | -| `editable` | 是 | 是否可编辑(与宿主权限结合) | -| `display_type` | 否 | 如 `text` / `textarea` / `datetime_unix_seconds`(标准时间字段见下文) | +| `display_order` | 否 | 兼容字段,默认保持 `NULL` | +| `visible` | 是 | 是否在数据管理表格中展示 | +| `searchable` | 是 | 是否参与搜索 | +| `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 | +| `display_type` | 否 | 字段渲染类型(如 `text`、`textarea`、`datetime_unix_seconds`) | | `options_json` | 否 | 枚举选项 JSON | 这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。 -写入应**幂等**(`INSERT … ON CONFLICT DO UPDATE`),数据库升级时同步维护元数据。 +## editable 与 createable 的关系 -## 业务字段物理顺序 +技能模板只维护 `editable` 这一份字段权限声明: -用户在「数据管理」中看到的列顺序 = `CREATE TABLE` 定义顺序 = `PRAGMA table_info` 的 `cid` 顺序。 -**宿主不会**按英文字段名、`display_order` 或语义规则重排。 +```text +skill metadata: + editable + +host runtime: + createable / editable +``` + +宿主根据以下因素计算字段是否能够出现在新增或编辑表单中: + +- 表级 `readonly` +- 字段 `editable` +- 主键 +- generated 字段 +- sensitive 字段 +- BLOB 类型 +- not-null 约束 +- default value + +通用规则: + +```text +新增表单: + editable = 1 + 且不是主键 + 且不是 generated + 且不是敏感字段 + 且不是 BLOB + +编辑表单: + editable = 1 + 且不是主键 + 且不是 generated + 且不是敏感字段 + 且不是 BLOB +``` + +若一张表: + +```text +readonly = 0 +但所有字段 editable = 0 +``` + +则: + +- 这张表技术上可写,但不适合手工新增或编辑 +- 宿主不应打开空白新增表单 +- 技能应通过自身业务流程产生数据 +- 这不是宿主猜测业务,而是技能通过元数据声明的结果 + +## 系统字段标准 + +通常保持 `editable = 0` 的字段类型包括: + +- 自增主键 +- generated 字段 +- `created_at` / `updated_at` +- 同步时间戳 +- 内容哈希 +- 计算结果 +- 内部状态 +- 删除标记 +- 审计字段 +- 敏感凭据或密钥字段 + +不要把具体业务字段名写死;按字段类型和职责设置 `editable`。 + +## 表结构标准 + +- 业务表和字段使用英文 `snake_case` +- 可写表优先使用单字段主键 +- 自增主键优先放在 `CREATE TABLE` 第一列 +- `created_at` 和 `updated_at` 放在业务字段末尾 +- 字段展示顺序以 `CREATE TABLE` 和 `PRAGMA table_info(cid)` 为准 +- 不依赖 `display_order` 重新排列字段 +- 不要让宿主通过英文字段名推断展示顺序 +- 复杂字段需要通过 `display_type` 或 `visible` 明确处理 推荐顺序(可按业务裁剪,但已有字段应遵守相对位置): @@ -90,6 +204,7 @@ metadata: 4. 状态、结果、错误等辅助字段 5. created_at(如有) 6. updated_at(如有) +``` ## 标准时间字段(新技能统一规范) @@ -101,7 +216,7 @@ metadata: | 存储格式 | Unix 秒级整数 | | 展示元数据 `display_type` | `datetime_unix_seconds` | | 中文名 | `created_at` → **创建时间**;`updated_at` → **更新时间** | -| 用户编辑 | `editable = 0`(系统字段,宿主只展示格式化结果,不写回字符串) | +| 用户编辑 | `editable = 0`(系统字段,宿主只展示格式化结果) | | 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` | | 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` | @@ -109,7 +224,7 @@ metadata: - **created_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。 - **updated_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。 -- **updated_at 更新**:模板默认表 `task_logs` 通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新;若技能所有 UPDATE 都经过统一 repository,也可在 repository 层设置 `updated_at = unixepoch()`,**不要**叠加两套机制。 +- **updated_at 更新**:模板默认表 `task_logs` 通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新。 实现参考: @@ -117,9 +232,7 @@ metadata: - 中文元数据种子:`scripts/db/display_metadata.py` - 自检:`scripts/db/display_metadata_validator.py` -宿主(匠厂)只负责把 `display_type = datetime_unix_seconds` 格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不修改**数据库原始值。 - -> `datetime_unix_milliseconds`、`datetime_iso` 是宿主兼容能力,**不是**新技能模板推荐标准。 +宿主只负责把 `display_type = datetime_unix_seconds` 格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不修改**数据库原始值。 ### 模板默认:`task_logs` @@ -140,33 +253,79 @@ CREATE TABLE IF NOT EXISTS task_logs ( ); ``` -插入时可省略 `created_at`、`updated_at`;`updated_at` 在 UPDATE 时由 trigger `task_logs_set_updated_at` 自动维护(见 `scripts/db/timestamp_columns.py`)。 - 对应中文展示(由 `init_db()` 写入元数据表): -| 物理字段 | 中文名 | -|----------|--------| -| id | 编号 | -| task_type | 任务类型 | -| target_id | 目标编号 | -| input_id | 输入编号 | -| input_title | 输入标题 | -| status | 状态 | -| error_msg | 错误信息 | -| result_summary | 结果摘要 | -| created_at | 创建时间(`display_type = datetime_unix_seconds`,不可编辑) | -| updated_at | 更新时间(`display_type = datetime_unix_seconds`,不可编辑) | +| 物理字段 | 中文名 | editable | +|----------|--------|----------| +| id | 编号 | 0 | +| task_type | 任务类型 | 1 | +| target_id | 目标编号 | 1 | +| input_id | 输入编号 | 1 | +| input_title | 输入标题 | 1 | +| status | 状态 | 1 | +| error_msg | 错误信息 | 1 | +| result_summary | 结果摘要 | 1 | +| created_at | 创建时间 | 0 | +| updated_at | 更新时间 | 0 | -表中文名:**任务日志**。 +表中文名:**任务日志**;表级 `readonly = 0`。 -禁止事项: +### 通用示例:`demo_items`(测试 fixture 参考) -- 不要把 `created_at` 放在第一列; -- 不要把 `id` 随意放在中间; -- 不要指望宿主按英文字段名排序; -- 若需调整历史表字段顺序,应通过规范迁移**重建表**,而不是在宿主侧配置掩盖。 +以下表结构用于模板测试演示数据管理契约,**不是**模板默认业务表: -复合主键或特殊表结构,请在本文档或技能自有 SCHEMA 补充中说明例外。 +```sql +CREATE TABLE demo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'draft', + generated_value TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +``` + +对应元数据要点: + +| 物理字段 | editable | 说明 | +|----------|----------|------| +| id | 0 | 自增主键 | +| title | 1 | 业务字段 | +| description | 1 | 业务字段 | +| status | 1 | 枚举,`options_json` 演示 | +| generated_value | 0 | 系统/计算字段 | +| created_at | 0 | 系统时间 | +| updated_at | 0 | 系统时间 | + +## 枚举字段 options_json + +`options_json` 为 JSON 数组,每项包含 `value` 与 `label`: + +```json +[ + {"value": "draft", "label": "草稿"}, + {"value": "published", "label": "已发布"}, + {"value": "archived", "label": "已归档"} +] +``` + +宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。 + +## 幂等初始化标准 + +- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化 +- 多次执行初始化不能重复记录 +- 重新初始化可以更新展示名、描述、`visible`、`searchable`、`editable`、`display_type`、`options_json` +- 元数据不能残留旧字段(删除或重命名字段时必须同步清理) +- 元数据不能引用不存在的表或字段 +- 新增字段或表时必须同步增加元数据 +- 不要直接修改用户真实数据库作为迁移方式 + +实现 helper: + +- `upsert_table_metadata` / `upsert_column_metadata` — 幂等写入 +- `prune_stale_column_metadata` — 清理已删除物理字段的元数据 ## 中文命名质量 @@ -188,17 +347,19 @@ CREATE TABLE IF NOT EXISTS task_logs ( |----------|-----------|-----------|----------| | 发布类 | publish | 账号 ID | 内容 ID | | 工资代发 | disburse | 付款账户 | 批次 ID | -| 对账 | reconcile | 银行/平台 | 对账批次 ID | +| 对账 | reconcile | 平台标识 | 对账批次 ID | | 发票验真 | verify | 税务地区 | 发票批次 ID | 业务特有字段优先放入 `result_summary`(JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。 ## 开发自检 -- `init_db()` 后运行 `tests/test_display_metadata.py`。 +- `init_db()` 后运行 `tests/test_display_metadata.py` 与 `tests/test_data_management_contract.py`。 +- 若提供宿主 Action 入口,运行 `tests/test_actions_manifest.py`。 - 复制为真实技能后,使用 `scripts/db/display_metadata_validator.py` 中的校验函数检查 SKILL 名称、slug 与元数据完整性。 ## 模板原则 - 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。 - 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。 +- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。 diff --git a/scripts/db/display_metadata.py b/scripts/db/display_metadata.py index 4f67165..5ba3f8e 100644 --- a/scripts/db/display_metadata.py +++ b/scripts/db/display_metadata.py @@ -113,15 +113,16 @@ def upsert_column_metadata( searchable: int = 1, editable: int = 1, display_type: str | None = None, + options_json: str | None = None, ) -> None: # display_order 保留为 NULL:宿主按 PRAGMA table_info cid 展示字段,不读 display_order。 cursor.execute( """ INSERT INTO _jiangchang_columns ( table_name, column_name, display_name, description, - display_order, visible, searchable, editable, display_type + display_order, visible, searchable, editable, display_type, options_json ) - VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?) ON CONFLICT(table_name, column_name) DO UPDATE SET display_name = excluded.display_name, description = excluded.description, @@ -129,9 +130,20 @@ def upsert_column_metadata( visible = excluded.visible, searchable = excluded.searchable, editable = excluded.editable, - display_type = excluded.display_type + display_type = excluded.display_type, + options_json = excluded.options_json """, - (table_name, column_name, display_name, description, visible, searchable, editable, display_type), + ( + table_name, + column_name, + display_name, + description, + visible, + searchable, + editable, + display_type, + options_json, + ), ) @@ -156,9 +168,98 @@ def seed_task_logs_display_metadata(cursor) -> None: searchable=int(column["searchable"]), editable=int(column["editable"]), display_type=str(column["display_type"]) if column.get("display_type") else None, + options_json=str(column["options_json"]) if column.get("options_json") else None, ) +def seed_column_metadata_batch( + cursor, + *, + table_name: str, + columns: tuple[dict[str, object], ...], +) -> None: + for column in columns: + upsert_column_metadata( + cursor, + table_name=table_name, + column_name=str(column["column_name"]), + display_name=str(column["display_name"]), + description=str(column["description"]) if column.get("description") else None, + visible=int(column.get("visible", 1)), + searchable=int(column.get("searchable", 1)), + editable=int(column.get("editable", 1)), + display_type=str(column["display_type"]) if column.get("display_type") else None, + options_json=str(column["options_json"]) if column.get("options_json") else None, + ) + + +def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None: + """删除表中已不存在物理字段的元数据行(幂等初始化后不得残留旧字段)。""" + valid = set(valid_column_names) + cursor.execute( + "SELECT column_name FROM _jiangchang_columns WHERE table_name = ?", + (table_name,), + ) + for (column_name,) in cursor.fetchall(): + if str(column_name) not in valid: + cursor.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?", + (table_name, column_name), + ) + + +def get_table_readonly(cursor, table_name: str) -> int | None: + cursor.execute( + "SELECT readonly FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + row = cursor.fetchone() + if not row: + return None + return int(row[0] or 0) + + +def get_column_editable(cursor, table_name: str, column_name: str) -> int | None: + cursor.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column_name), + ) + row = cursor.fetchone() + if not row: + return None + return int(row[0] or 0) + + +def list_editable_business_columns(cursor, table_name: str) -> list[str]: + """返回 editable=1 的业务字段名(不含宿主侧主键/generated 等二次过滤)。""" + physical = list_physical_column_names(cursor, table_name) + cursor.execute( + """ + SELECT column_name, editable + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + editable_set = { + str(column_name) + for column_name, editable in cursor.fetchall() + if editable is not None and int(editable) == 1 + } + return [name for name in physical if name in editable_set] + + +def table_allows_manual_create(cursor, table_name: str) -> bool: + """表级 readonly=0 且至少有一个 editable=1 字段时,宿主才应提供空白新增表单。""" + readonly = get_table_readonly(cursor, table_name) + if readonly is None or int(readonly) != 0: + return False + return bool(list_editable_business_columns(cursor, table_name)) + + def init_display_metadata(cursor) -> None: init_display_metadata_tables(cursor) seed_task_logs_display_metadata(cursor) diff --git a/scripts/db/display_metadata_validator.py b/scripts/db/display_metadata_validator.py index 1b21cec..ee83470 100644 --- a/scripts/db/display_metadata_validator.py +++ b/scripts/db/display_metadata_validator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import os import re import sqlite3 @@ -14,7 +15,9 @@ from db.display_metadata import ( TASK_LOGS_COLUMN_METADATA, TASK_LOGS_TABLE, iter_user_tables, + list_editable_business_columns, list_physical_column_names, + table_allows_manual_create, ) from db.timestamp_columns import has_updated_at_trigger from util.constants import SKILL_SLUG @@ -165,6 +168,93 @@ def _default_uses_unixepoch(default_value: object) -> bool: return "unixepoch" in text +def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]: + """校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。""" + errors: list[str] = [] + warnings: list[str] = [] + if options_json is None or str(options_json).strip() == "": + return errors, warnings + try: + parsed = json.loads(str(options_json)) + except json.JSONDecodeError as exc: + errors.append(f"options_json 不是合法 JSON:{exc}") + return errors, warnings + if not isinstance(parsed, list): + errors.append("options_json 必须是 JSON 数组") + return errors, warnings + for index, item in enumerate(parsed): + if not isinstance(item, dict): + errors.append(f"options_json[{index}] 必须是对象") + continue + if "value" not in item or "label" not in item: + errors.append(f"options_json[{index}] 必须包含 value 与 label") + return errors, warnings + + +def validate_column_permissions( + cursor, + table_name: str, + *, + physical_columns: list[str] | None = None, +) -> tuple[list[str], list[str]]: + """校验常见系统字段 editable=0,并识别技术上可写但不适合手工新增的表。""" + errors: list[str] = [] + warnings: list[str] = [] + ordered = physical_columns or list_physical_column_names(cursor, table_name) + column_info = _table_column_info(cursor, table_name) + + cursor.execute( + """ + SELECT column_name, editable, options_json + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + meta_by_column = { + str(row[0]): {"editable": row[1], "options_json": row[2]} + for row in cursor.fetchall() + } + + for column_name in ordered: + meta = meta_by_column.get(column_name) + if not meta: + continue + + physical = column_info.get(column_name) or {} + pk = int(physical.get("pk") or 0) + editable = meta["editable"] + if pk and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是主键,editable 应为 0") + + if column_name == "id" and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.id 的 editable 应为 0(自增主键)") + + opt_errors, _ = validate_options_json(meta.get("options_json")) + for item in opt_errors: + errors.append(f"{table_name}.{column_name}: {item}") + + cursor.execute( + "SELECT readonly FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + readonly_row = cursor.fetchone() + readonly = int(readonly_row[0]) if readonly_row else None + editable_columns = list_editable_business_columns(cursor, table_name) + if readonly == 0 and not editable_columns: + warnings.append( + f"{table_name}: 表级 readonly=0 但所有字段 editable=0," + "宿主不应打开空白新增表单;数据应由技能业务流程产生" + ) + if readonly == 1 and editable_columns: + warnings.append( + f"{table_name}: 表级 readonly=1 但存在 editable=1 字段," + "宿主应以表级只读为准" + ) + + return errors, warnings + + def _expected_timestamp_display_name(column_name: str) -> str: if column_name == "created_at": return "创建时间" @@ -338,6 +428,14 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b report.errors.extend(ts_errors) report.warnings.extend(ts_warnings) + perm_errors, perm_warnings = validate_column_permissions( + cur, + table_name, + physical_columns=physical_columns, + ) + report.errors.extend(perm_errors) + report.warnings.extend(perm_warnings) + cur.execute("SELECT table_name, column_name FROM _jiangchang_columns") for table_name, column_name in cur.fetchall(): if table_name in METADATA_TABLES: diff --git a/tests/test_actions_manifest.py b/tests/test_actions_manifest.py new file mode 100644 index 0000000..c5c1e75 --- /dev/null +++ b/tests/test_actions_manifest.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。""" +from __future__ import annotations + +import json +import os +import re +import unittest + +from _support import get_skill_root + +from cli.app import build_parser +from util.constants import SKILL_SLUG + +_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" +_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"}) +_RESERVED_PLACEMENTS = frozenset({"row", "batch"}) +_FORBIDDEN_BUSINESS_TERMS = ( + "wechat", + "微信", + "contacts", + "wxid", + "account_wxid", + "contact.db", + "scrape-contacts", + "douyin", + "抖音", + "xiaohongshu", + "小红书", + "公众号", + "logistics", + "物流", +) + +_TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}") + + +def _load_actions_manifest() -> dict: + path = os.path.join(get_skill_root(), "assets", "actions.json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _cli_subcommands() -> set[str]: + parser = build_parser() + subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined] + return set(subs.keys()) + + +def _collect_template_vars(args: list) -> set[str]: + found: set[str] = set() + for item in args: + if not isinstance(item, str): + continue + for match in _TEMPLATE_ARG_PATTERN.finditer(item): + found.add(match.group(1)) + return found + + +class TestActionsManifest(unittest.TestCase): + def test_manifest_exists_and_schema(self) -> None: + manifest = _load_actions_manifest() + self.assertEqual(manifest.get("schemaVersion"), 1) + skill = manifest.get("skill") + self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG)) + self.assertIsInstance(manifest.get("actions"), list) + self.assertGreater(len(manifest["actions"]), 0) + + def test_action_ids_unique_and_cli_valid(self) -> None: + manifest = _load_actions_manifest() + subs = _cli_subcommands() + seen: set[str] = set() + for action in manifest["actions"]: + action_id = action["id"] + self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}") + seen.add(action_id) + + self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty") + self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty") + + entry = action["entrypoint"] + self.assertEqual(entry["type"], "cli") + self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI") + + args = entry.get("args") + self.assertIsInstance(args, list, msg=f"{action_id} args must be array") + for item in args: + self.assertIsInstance( + item, + (str, int, float, bool), + msg=f"{action_id} arg item must be scalar", + ) + self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object") + self.assertIsNotNone(item, msg=f"{action_id} arg must not be null") + + placements = action.get("placements") + self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array") + self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty") + for placement in placements: + self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}") + + def test_template_example_does_not_use_reserved_placements(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + placements = set(action.get("placements") or []) + overlap = placements & _RESERVED_PLACEMENTS + self.assertFalse( + overlap, + msg=f"{action['id']} uses reserved placements not yet supported in template: {overlap}", + ) + + def test_template_variables_have_input_schema(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + args = action.get("entrypoint", {}).get("args") or [] + template_vars = _collect_template_vars(args) + if not template_vars: + continue + schema = action.get("inputSchema") or {} + properties = schema.get("properties") or {} + for var in template_vars: + self.assertIn( + var, + properties, + msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it", + ) + + def test_required_fields_exist_in_properties(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + schema = action.get("inputSchema") or {} + required = schema.get("required") or [] + properties = schema.get("properties") or {} + for field in required: + self.assertIn( + field, + properties, + msg=f"{action['id']} required field {field!r} missing from properties", + ) + + def test_sensitive_params_not_on_toolbar(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + props = (action.get("inputSchema") or {}).get("properties") or {} + has_sensitive = any(p.get("sensitive") for p in props.values()) + if has_sensitive and "toolbar" in (action.get("placements") or []): + self.fail(f"{action['id']} has sensitive input but is on toolbar") + + def test_confirmation_message_structure(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + confirmation = action.get("confirmation") + if confirmation is None: + continue + self.assertIsInstance(confirmation, dict) + message = confirmation.get("message") + self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty") + + def test_manifest_has_no_specific_business_skill_names(self) -> None: + path = os.path.join(get_skill_root(), "assets", "actions.json") + with open(path, encoding="utf-8") as f: + raw = f.read().lower() + for term in _FORBIDDEN_BUSINESS_TERMS: + self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}") + + def test_actions_md_exists_and_links_manifest(self) -> None: + path = os.path.join(get_skill_root(), "references", "ACTIONS.md") + self.assertTrue(os.path.isfile(path)) + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn("actions.json", text) + self.assertIn("schemaVersion", text) + for term in _FORBIDDEN_BUSINESS_TERMS: + self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}") + + def test_skill_actions_schema_file_exists(self) -> None: + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + self.assertTrue(os.path.isfile(path)) + with open(path, encoding="utf-8") as f: + schema = json.load(f) + self.assertEqual(schema.get("title"), "SkillActionManifest") + self.assertIn("actions", schema.get("properties", {})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_data_management_contract.py b/tests/test_data_management_contract.py new file mode 100644 index 0000000..0cca207 --- /dev/null +++ b/tests/test_data_management_contract.py @@ -0,0 +1,400 @@ +# -*- coding: utf-8 -*- +"""通用数据管理契约测试(demo_items fixture,不含具体业务技能内容)。""" +from __future__ import annotations + +import json +import sqlite3 +import unittest + +from _support import IsolatedDataRoot + +DEMO_ITEMS_DDL = """ +CREATE TABLE IF NOT EXISTS demo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'draft', + generated_value TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); +CREATE TRIGGER IF NOT EXISTS demo_items_set_updated_at +AFTER UPDATE ON demo_items +FOR EACH ROW +BEGIN + UPDATE demo_items + SET updated_at = unixepoch() + WHERE id = NEW.id AND updated_at = OLD.updated_at; +END; +""" + +DEMO_ITEMS_TABLE_METADATA = { + "table_name": "demo_items", + "display_name": "示例记录", + "description": "通用数据管理契约演示表", + "visible": 1, + "readonly": 0, +} + +DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + {"column_name": "id", "display_name": "编号", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "title", "display_name": "标题", "visible": 1, "searchable": 1, "editable": 1}, + {"column_name": "description", "display_name": "描述", "visible": 1, "searchable": 1, "editable": 1}, + { + "column_name": "status", + "display_name": "状态", + "visible": 1, + "searchable": 1, + "editable": 1, + "options_json": json.dumps( + [ + {"value": "draft", "label": "草稿"}, + {"value": "published", "label": "已发布"}, + {"value": "archived", "label": "已归档"}, + ], + ensure_ascii=False, + ), + }, + {"column_name": "generated_value", "display_name": "生成值", "visible": 1, "searchable": 0, "editable": 0}, + { + "column_name": "created_at", + "display_name": "创建时间", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, + { + "column_name": "updated_at", + "display_name": "更新时间", + "visible": 1, + "searchable": 0, + "editable": 0, + "display_type": "datetime_unix_seconds", + }, +) + + +def _init_demo_items_fixture(conn: sqlite3.Connection) -> None: + from db.display_metadata import ( + init_display_metadata_tables, + list_physical_column_names, + prune_stale_column_metadata, + seed_column_metadata_batch, + upsert_table_metadata, + ) + + cur = conn.cursor() + init_display_metadata_tables(cur) + cur.executescript(DEMO_ITEMS_DDL) + meta = DEMO_ITEMS_TABLE_METADATA + upsert_table_metadata( + cur, + table_name=str(meta["table_name"]), + display_name=str(meta["display_name"]), + description=str(meta["description"]), + visible=int(meta["visible"]), + readonly=int(meta["readonly"]), + ) + seed_column_metadata_batch(cur, table_name="demo_items", columns=DEMO_ITEMS_COLUMN_METADATA) + physical = list_physical_column_names(cur, "demo_items") + prune_stale_column_metadata(cur, "demo_items", physical) + conn.commit() + + +class TestDataManagementContract(unittest.TestCase): + def test_demo_items_metadata_permissions(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import ( + get_column_editable, + get_table_readonly, + list_editable_business_columns, + table_allows_manual_create, + ) + + cur = conn.cursor() + self.assertEqual(get_table_readonly(cur, "demo_items"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "id"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1) + self.assertEqual(get_column_editable(cur, "demo_items", "generated_value"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "created_at"), 0) + self.assertEqual(get_column_editable(cur, "demo_items", "updated_at"), 0) + + editable_cols = list_editable_business_columns(cur, "demo_items") + self.assertEqual(editable_cols, ["title", "description", "status"]) + self.assertTrue(table_allows_manual_create(cur, "demo_items")) + finally: + conn.close() + + def test_demo_items_init_idempotent_no_duplicates(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'demo_items'") + self.assertEqual(int(cur.fetchone()[0]), 1) + cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'demo_items'") + self.assertEqual(int(cur.fetchone()[0]), 7) + finally: + conn.close() + + def test_reinit_updates_stale_editable_value(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "UPDATE _jiangchang_columns SET editable = 0 WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + _init_demo_items_fixture(conn) + from db.display_metadata import get_column_editable + + self.assertEqual(get_column_editable(conn.cursor(), "demo_items", "title"), 1) + finally: + conn.close() + + def test_missing_table_metadata_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'") + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items" in err for err in report.errors)) + finally: + conn.close() + + def test_missing_column_metadata_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("demo_items.title" in err for err in report.errors)) + finally: + conn.close() + + def test_metadata_reference_to_missing_column_detected(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'not_exists', '不存在', 1, 1, 0) + """ + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("not_exists" in err for err in report.errors)) + finally: + conn.close() + + def test_empty_display_name_detected_for_visible_column(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + "UPDATE _jiangchang_columns SET display_name = '' WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + conn.commit() + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertFalse(report.ok) + self.assertTrue(any("display_name 为空" in err for err in report.errors)) + finally: + conn.close() + + def test_visible_columns_use_chinese_display_name(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute( + """ + SELECT column_name, display_name + FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND visible = 1 + """ + ) + for column_name, display_name in cur.fetchall(): + self.assertTrue( + any("\u4e00" <= ch <= "\u9fff" for ch in str(display_name)), + msg=f"{column_name} display_name should be Chinese: {display_name!r}", + ) + finally: + conn.close() + + def test_timestamp_fields_type_default_and_editable(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + for column_name in ("created_at", "updated_at"): + cur.execute(f"PRAGMA table_info('demo_items')") + info = {row[1]: row for row in cur.fetchall()} + row = info[column_name] + self.assertIn("INT", str(row[2]).upper()) + self.assertIn("unixepoch", str(row[4]).lower()) + cur.execute( + """ + SELECT editable, display_type FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND column_name = ? + """, + (column_name,), + ) + editable, display_type = cur.fetchone() + self.assertEqual(int(editable), 0) + self.assertEqual(display_type, "datetime_unix_seconds") + finally: + conn.close() + + def test_physical_order_matches_pragma_cid(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import list_physical_column_names + + cur = conn.cursor() + expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA] + physical = list_physical_column_names(cur, "demo_items") + self.assertEqual(physical, expected) + cur.execute("PRAGMA table_info('demo_items')") + cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))] + self.assertEqual(cid_order, expected) + finally: + conn.close() + + def test_options_json_parses_for_enum_field(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + cur = conn.cursor() + cur.execute( + """ + SELECT options_json FROM _jiangchang_columns + WHERE table_name = 'demo_items' AND column_name = 'status' + """ + ) + raw = cur.fetchone()[0] + from db.display_metadata_validator import validate_options_json + + errors, _ = validate_options_json(raw) + self.assertEqual(errors, []) + parsed = json.loads(raw) + self.assertEqual(len(parsed), 3) + self.assertEqual(parsed[0]["value"], "draft") + finally: + conn.close() + + def test_system_table_no_editable_fields_not_manual_create(self) -> None: + conn = sqlite3.connect(":memory:") + try: + from db.display_metadata import ( + init_display_metadata_tables, + seed_column_metadata_batch, + table_allows_manual_create, + upsert_table_metadata, + ) + + cur = conn.cursor() + init_display_metadata_tables(cur) + cur.execute( + """ + CREATE TABLE source_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id TEXT NOT NULL, + synced_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + """ + ) + upsert_table_metadata( + cur, + table_name="source_records", + display_name="来源记录", + description="只由同步流程写入", + visible=1, + readonly=0, + ) + seed_column_metadata_batch( + cur, + table_name="source_records", + columns=( + {"column_name": "id", "display_name": "编号", "editable": 0}, + {"column_name": "item_id", "display_name": "记录编号", "editable": 0}, + {"column_name": "synced_at", "display_name": "同步时间", "editable": 0, "display_type": "datetime_unix_seconds"}, + ), + ) + conn.commit() + self.assertFalse(table_allows_manual_create(cur, "source_records")) + finally: + conn.close() + + def test_table_readonly_blocks_manual_create_even_with_editable_fields(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute("UPDATE _jiangchang_tables SET readonly = 1 WHERE table_name = 'demo_items'") + conn.commit() + from db.display_metadata import table_allows_manual_create + + self.assertFalse(table_allows_manual_create(conn.cursor(), "demo_items")) + finally: + conn.close() + + def test_prune_stale_column_metadata(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0) + """ + ) + conn.commit() + from db.display_metadata import list_physical_column_names, prune_stale_column_metadata + + cur = conn.cursor() + physical = list_physical_column_names(cur, "demo_items") + prune_stale_column_metadata(cur, "demo_items", physical) + conn.commit() + cur.execute( + "SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'" + ) + self.assertIsNone(cur.fetchone()) + finally: + conn.close() + + def test_demo_items_validation_passes(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata_validator import validate_display_metadata + + report = validate_display_metadata(conn) + self.assertTrue(report.ok, msg="; ".join(report.errors)) + finally: + conn.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py index 1935317..ba40a91 100644 --- a/tests/test_docs_standards.py +++ b/tests/test_docs_standards.py @@ -228,6 +228,33 @@ class TestDocsStandards(unittest.TestCase): path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1") self.assertTrue(os.path.isfile(path), msg="tools/scaffold_skill.ps1 must exist") + def test_actions_manifest_and_docs_exist(self) -> None: + root = get_skill_root() + self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json"))) + self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md"))) + self.assertTrue( + os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json")) + ) + self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py"))) + self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py"))) + + def test_tools_readme_documents_actions_scaffold(self) -> None: + text = self._read("tools/README.md") + self.assertIn("actions.json", text) + self.assertIn("your-skill-slug", text) + + def test_schema_md_documents_data_management_contract(self) -> None: + text = self._read("references/SCHEMA.md") + for marker in ( + "_jiangchang_tables", + "_jiangchang_columns", + "editable", + "options_json", + "demo_items", + "readonly", + ): + self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}") + def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None: forbidden = ( "download-settlement-report-amazon", @@ -240,6 +267,9 @@ class TestDocsStandards(unittest.TestCase): "development/DEVELOPMENT.md", "development/CONFIG.md", "development/TESTING.md", + "references/SCHEMA.md", + "references/ACTIONS.md", + "tools/README.md", "examples/README.md", "examples/simulator_browser_rpa/README.md", ) diff --git a/tests/test_scaffold_guard.py b/tests/test_scaffold_guard.py index 1a399d1..d410a80 100644 --- a/tests/test_scaffold_guard.py +++ b/tests/test_scaffold_guard.py @@ -2,13 +2,16 @@ """skill-template 脚手架与 Git 防串库守护。""" from __future__ import annotations +import json import os +import tempfile import unittest from _support import get_skill_root TEMPLATE_MARKER = ".openclaw-skill-template" TEMPLATE_SLUG = "your-skill-slug" +SCAFFOLD_TEST_SLUG = "query-demo-records" def _read_skill_slug() -> str: @@ -59,6 +62,55 @@ class TestScaffoldGuard(unittest.TestCase): msg="DEVELOPMENT.md must mention git remote self-check", ) + def test_scaffold_replaces_slug_and_copies_actions_manifest(self) -> None: + slug = _read_skill_slug() + if slug != TEMPLATE_SLUG: + self.skipTest("仅在 skill-template 源仓库运行 scaffold 集成测试") + + import subprocess + import sys + + repo_root = get_skill_root() + scaffold_py = os.path.join(repo_root, "tools", "scaffold_skill.py") + with tempfile.TemporaryDirectory() as tmp: + dest = os.path.join(tmp, SCAFFOLD_TEST_SLUG) + proc = subprocess.run( + [ + sys.executable, + scaffold_py, + "--slug", + SCAFFOLD_TEST_SLUG, + "--destination", + dest, + ], + cwd=repo_root, + capture_output=True, + text=True, + ) + self.assertEqual(proc.returncode, 0, msg=proc.stderr or proc.stdout) + + actions_path = os.path.join(dest, "assets", "actions.json") + self.assertTrue(os.path.isfile(actions_path), msg="scaffold must copy assets/actions.json") + with open(actions_path, encoding="utf-8") as f: + manifest = json.load(f) + self.assertEqual(manifest.get("skill"), SCAFFOLD_TEST_SLUG) + self.assertNotIn(TEMPLATE_SLUG, json.dumps(manifest)) + + actions_md = os.path.join(dest, "references", "ACTIONS.md") + self.assertTrue(os.path.isfile(actions_md), msg="scaffold must copy references/ACTIONS.md") + + schema_path = os.path.join(dest, "assets", "schemas", "skill-actions.schema.json") + self.assertTrue(os.path.isfile(schema_path)) + + marker = os.path.join(dest, TEMPLATE_MARKER) + self.assertFalse(os.path.isfile(marker)) + self.assertFalse(os.path.isdir(os.path.join(dest, ".git"))) + + constants_path = os.path.join(dest, "scripts", "util", "constants.py") + with open(constants_path, encoding="utf-8") as f: + constants_text = f.read() + self.assertNotIn(TEMPLATE_SLUG, constants_text) + if __name__ == "__main__": unittest.main() diff --git a/tools/README.md b/tools/README.md index 82239a3..b785f63 100644 --- a/tools/README.md +++ b/tools/README.md @@ -22,10 +22,24 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new - 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致) - **排除**:`.git`、`.pytest_cache`、`__pycache__`、`.env`、`.env.local`、`*.pyc` - 删除目标中的 `.openclaw-skill-template` 标记 +- 将文本文件中的 `your-skill-slug` 替换为新 slug(含 `assets/actions.json`、`SKILL.md`、`constants.py` 等) +- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action - **不**自动 `git init`;打印后续 Git 与测试命令 目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。 +## 脚手架复制后必须保留 / 修改 / 可删除的文件 + +| 文件 | 说明 | +|------|------| +| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 | +| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 | +| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 | +| `tests/test_actions_manifest.py` | 若删除 `actions.json`,应同步删除或改写此测试 | +| `tests/test_data_management_contract.py` | 通用契约测试;建议保留,按需扩展本技能表结构测试 | + +`demo_items` 等示例表仅存在于模板测试中,**不会**作为 scaffold 后的默认业务表复制进新技能。 + ## 手工复制后的补救 若已整目录复制,必须先清理再初始化: diff --git a/tools/scaffold_skill.ps1 b/tools/scaffold_skill.ps1 index f8ed246..84db8c5 100644 --- a/tools/scaffold_skill.ps1 +++ b/tools/scaffold_skill.ps1 @@ -34,6 +34,7 @@ function Write-ScaffoldNextSteps([string]$TargetPath) { Write-Host " git init" Write-Host " git remote add origin <你的新技能仓库 URL>" Write-Host " git remote -v" + Write-Host " # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件" Write-Host " python tests/run_tests.py -v" Write-Host "" } @@ -70,6 +71,8 @@ if ($targetExists) { $excludeDirs = @(".git", ".pytest_cache", "__pycache__") $excludeFiles = @(".env", ".env.local") +$templatePlaceholderSlug = "your-skill-slug" +$textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample") function Copy-TemplateTree { param( @@ -96,6 +99,28 @@ function Copy-TemplateTree { } } +function Replace-TemplatePlaceholders { + param( + [string]$Root, + [string]$Slug + ) + Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object { + $name = $_.Name + if ($excludeFiles -contains $name) { return } + $ext = $_.Extension.ToLowerInvariant() + if ($textFileSuffixes -notcontains $ext -and $name -ne ".env.example") { return } + try { + $text = [System.IO.File]::ReadAllText($_.FullName) + } + catch { + return + } + if ($text -notlike "*$templatePlaceholderSlug*") { return } + $updated = $text.Replace($templatePlaceholderSlug, $Slug) + [System.IO.File]::WriteAllText($_.FullName, $updated) + } +} + Write-Host "Copy template: $TemplateRoot -> $TargetPath" -ForegroundColor Cyan Copy-TemplateTree -Source $TemplateRoot -Dest $TargetPath @@ -104,4 +129,6 @@ if (Test-Path -LiteralPath $marker) { Remove-Item -LiteralPath $marker -Force } +Replace-TemplatePlaceholders -Root $TargetPath -Slug $Slug + Write-ScaffoldNextSteps -TargetPath (Resolve-Path -LiteralPath $TargetPath).Path diff --git a/tools/scaffold_skill.py b/tools/scaffold_skill.py index c951f4a..689e6ba 100644 --- a/tools/scaffold_skill.py +++ b/tools/scaffold_skill.py @@ -11,6 +11,19 @@ from pathlib import Path EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"} EXCLUDE_FILE_NAMES = {".env", ".env.local"} KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") +TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" +TEXT_FILE_SUFFIXES = { + ".md", + ".py", + ".json", + ".yaml", + ".yml", + ".txt", + ".ini", + ".ps1", + ".sample", + ".env.example", +} def _ignore_copy(_dir: str, names: list[str]) -> set[str]: @@ -23,6 +36,34 @@ def _ignore_copy(_dir: str, names: list[str]) -> set[str]: return ignored +def _should_replace_placeholders(path: Path) -> bool: + if path.name in EXCLUDE_FILE_NAMES: + return False + if path.suffix.lower() in TEXT_FILE_SUFFIXES: + return True + if path.name == ".env.example": + return True + return False + + +def _replace_template_placeholders(target: Path, slug: str) -> None: + """将模板占位 slug 替换为新技能 slug(不修改 action 业务命令结构)。""" + for path in target.rglob("*"): + if not path.is_file(): + continue + if path.name in EXCLUDE_FILE_NAMES: + continue + if path.suffix.lower() not in TEXT_FILE_SUFFIXES and path.name != ".env.example": + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + if TEMPLATE_PLACEHOLDER_SLUG not in text: + continue + path.write_text(text.replace(TEMPLATE_PLACEHOLDER_SLUG, slug), encoding="utf-8") + + def _print_next_steps(target: Path) -> None: print() print("=== 脚手架复制完成 ===") @@ -34,6 +75,7 @@ def _print_next_steps(target: Path) -> None: print(" git init") print(" git remote add origin <你的新技能仓库 URL>") print(" git remote -v # 确认 remote 不是 skill-template") + print(" # 确认 assets/actions.json 中 skill 已替换为新 slug;不需要 Action 时可删除该文件") print(" python tests/run_tests.py -v") print() @@ -98,6 +140,8 @@ def main(argv: list[str] | None = None) -> int: if marker.is_file(): marker.unlink() + _replace_template_placeholders(target, slug) + _print_next_steps(target) return 0