Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 766d162245 | |||
| 35c20fc0f2 |
4
SKILL.md
4
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` 物理顺序)。
|
||||
|
||||
@@ -94,7 +94,7 @@ python {baseDir}/scripts/main.py version
|
||||
- 复制为新业务技能前**必须**阅读 `development/NAMING.md`。
|
||||
- `name`(frontmatter):**用户可见的中文业务名称**;`description` 是说明,不是名称。
|
||||
- `metadata.openclaw.slug`:**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug,不使用中文。
|
||||
- 复制后全局替换 `your-skill-slug`;正式技能不得保留英文占位 `name` 或 slug。
|
||||
- 复制后全局替换 `your-skill-slug` 与 `your_skill_slug`(脚手架会自动处理);正式技能不得保留英文占位 `name` 或 slug。
|
||||
|
||||
## 平台元数据
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# 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` 等)。
|
||||
|
||||
`skill-actions.schema.json` 使用 `additionalProperties: false` 表达模板推荐契约;宿主运行时可能更宽松以兼容历史 manifest。`row`/`batch` placements 与 `bind`/`concurrency`/`locks` 为预留能力,当前模板示例不得使用。
|
||||
|
||||
- 用户市场说明见根目录 [`README.md`](../README.md)
|
||||
- Agent 调用/编排参考见 [`references/`](../references/)
|
||||
- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md))
|
||||
- 开发规范见 [`development/`](../development/)
|
||||
|
||||
39
assets/actions.json
Normal file
39
assets/actions.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "health",
|
||||
"label": "运行检查",
|
||||
"description": "检查技能运行环境并返回结构化结果。",
|
||||
"placements": ["skill-detail", "agent"],
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "health",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "version",
|
||||
"label": "查看版本",
|
||||
"description": "返回技能版本和机器标识。",
|
||||
"placements": ["skill-detail"],
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "version",
|
||||
"args": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "config-path",
|
||||
"label": "查看配置位置",
|
||||
"description": "返回技能配置文件位置。",
|
||||
"placements": ["skill-detail"],
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "config-path",
|
||||
"args": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
128
assets/schemas/skill-actions.schema.json
Normal file
128
assets/schemas/skill-actions.schema.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
|
||||
"title": "SkillActionManifest",
|
||||
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "actions"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"skill": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"description": "与 SKILL.md metadata.openclaw.slug 一致"
|
||||
},
|
||||
"actions": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/action" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"placement": {
|
||||
"type": "string",
|
||||
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
|
||||
"description": "模板 Phase 1 稳定支持 toolbar/cron/agent/skill-detail;row/batch 为预留入口,新技能示例不得使用"
|
||||
},
|
||||
"scalarArg": {
|
||||
"type": ["string", "number", "boolean"]
|
||||
},
|
||||
"inputProperty": {
|
||||
"type": "object",
|
||||
"required": ["type"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["string", "number", "boolean", "object"],
|
||||
"description": "模板 Phase 1 仅推荐 string/number/boolean;根 inputSchema.type=object 的 properties 使用基础标量类型"
|
||||
},
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"default": {},
|
||||
"enum": {
|
||||
"type": "array",
|
||||
"minItems": 1
|
||||
},
|
||||
"sensitive": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["type", "properties"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "const": "object" },
|
||||
"required": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/inputProperty" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "object",
|
||||
"required": ["type", "command", "args"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "const": "cli" },
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/scalarArg" },
|
||||
"description": "仅允许 string/number/boolean 标量数组"
|
||||
}
|
||||
}
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"type": "object",
|
||||
"required": ["id", "label", "description", "placements", "entrypoint"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
||||
"minLength": 1
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"placements": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/placement" }
|
||||
},
|
||||
"entrypoint": { "$ref": "#/$defs/entrypoint" },
|
||||
"inputSchema": { "$ref": "#/$defs/inputSchema" },
|
||||
"confirmation": { "$ref": "#/$defs/confirmation" }
|
||||
},
|
||||
"description": "模板 Phase 1 不包含 bind/concurrency/locks 等预留能力字段;宿主类型可能定义这些字段,但新技能不得依赖"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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` 与 `your_skill_slug` 替换为新 slug(后者用于 `LOG_LOGGER_NAME` 等下划线形式);action 业务命令由技能作者自行调整
|
||||
|
||||
宿主执行方式:
|
||||
|
||||
```text
|
||||
python {skillRoot}/scripts/main.py <command> <args...>
|
||||
```
|
||||
|
||||
CLI 必须提供稳定退出码、结构化成功输出,以及 `ERROR:<CODE>:` / `HINT:` 错误契约(详见 `references/ACTIONS.md`)。
|
||||
|
||||
## 12. 如何接兄弟技能
|
||||
|
||||
如果 skill 要依赖兄弟 skill,不要在业务代码里写死绝对路径。
|
||||
|
||||
@@ -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`,不默认运行。
|
||||
|
||||
---
|
||||
|
||||
195
references/ACTIONS.md
Normal file
195
references/ACTIONS.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Skill Action Manifest(匠厂宿主)
|
||||
|
||||
本文件说明 `assets/actions.json` 与 `scripts/main.py` CLI 的对应关系,供 Agent 编排与宿主 **Skill Action Runtime** 使用。
|
||||
|
||||
**边界:** 用户市场说明见根目录 [`README.md`](../README.md);CLI 参数与错误码细节见 [`CLI.md`](CLI.md)。
|
||||
|
||||
## 模板严格规范 vs 宿主向后兼容
|
||||
|
||||
| 角色 | 定位 |
|
||||
|------|------|
|
||||
| **skill-template** | 新技能推荐的**严格规范**:必填字段、明确类型、`additionalProperties: false` |
|
||||
| **宿主运行时** | **向后兼容**的历史 manifest 解析器:可能接受部分缺省字段(如 `description`、`placements`、`entrypoint.args`、input property `type`) |
|
||||
|
||||
**新技能不应依赖宿主缺省行为。** 复制模板后按 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) 与本文完整填写 manifest。
|
||||
|
||||
JSON Schema 使用 `additionalProperties: false` 表达模板严格边界;这与宿主共享类型中的可选字段并不矛盾——宿主为兼容旧技能可能更宽松,模板为防漂移要求更严。
|
||||
|
||||
## Action 是否必需
|
||||
|
||||
Action 是**可选能力**:
|
||||
|
||||
- 没有宿主直接操作需求的技能,**可以不提供** `actions.json`。
|
||||
- 需要出现在**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中的技能,**必须**提供 `assets/actions.json`。
|
||||
|
||||
## 文件位置
|
||||
|
||||
```text
|
||||
{skill_root}/assets/actions.json
|
||||
```
|
||||
|
||||
宿主扫描路径示例:
|
||||
|
||||
```text
|
||||
~/.openclaw/skills/your-skill-slug/assets/actions.json
|
||||
```
|
||||
|
||||
## 顶层结构
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `schemaVersion` | 是 | 当前固定为 `1` |
|
||||
| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 |
|
||||
| `actions` | 是 | Action 数组,每项 `id` 唯一 |
|
||||
|
||||
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
|
||||
|
||||
## Phase 1 当前稳定支持(新技能应只使用这些)
|
||||
|
||||
| 层级 | 稳定字段 / 能力 |
|
||||
|------|----------------|
|
||||
| 顶层 | `schemaVersion`、`skill`、`actions` |
|
||||
| action | `id`、`label`、`description`、`placements`、`entrypoint`;可选 `inputSchema`、`confirmation` |
|
||||
| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
|
||||
| placements | `toolbar`、`skill-detail`、`agent`、`cron` |
|
||||
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive` |
|
||||
| confirmation | `{ "message": "..." }` |
|
||||
|
||||
## 预留能力(当前不可当作已实现能力)
|
||||
|
||||
以下在宿主共享类型或 Schema 枚举中可能出现,但 **Phase 1 Runtime 尚未完整支持**,**新模板示例不得使用**:
|
||||
|
||||
| 类别 | 预留项 | 说明 |
|
||||
|------|--------|------|
|
||||
| placements | `row`、`batch` | 行内 / 批量入口,未来扩展 |
|
||||
| action 字段 | `bind`、`concurrency`、`locks` | 绑定、并发与锁控制,宿主后续实现 |
|
||||
| inputSchema | `array`、nested `object`、`oneOf` 等 | 复杂表单与联合类型,不属于当前推荐模板范围 |
|
||||
|
||||
可以在文档或 Schema 枚举中保留这些值供未来对齐,但不要让技能作者误以为它们已经可执行。
|
||||
|
||||
## 执行模型
|
||||
|
||||
宿主将 action 转为 argv,再执行:
|
||||
|
||||
```text
|
||||
python {skill_root}/scripts/main.py <command> <args...>
|
||||
```
|
||||
|
||||
- `entrypoint.type` 当前只支持 `cli`
|
||||
- `entrypoint.command` → CLI 子命令(须真实存在)
|
||||
- `entrypoint.args` → 子命令参数(**必须为 JSON 数组**)
|
||||
- `{{fieldName}}` → 来自 `inputSchema` 用户输入的简单模板替换
|
||||
|
||||
完整子命令定义见 [`scripts/cli/app.py`](../scripts/cli/app.py)。
|
||||
|
||||
### CLI 输出契约
|
||||
|
||||
技能 CLI 必须:
|
||||
|
||||
- 使用稳定的退出码
|
||||
- 成功时输出可解析的结构化结果
|
||||
- 失败时输出稳定错误信息
|
||||
- 对可预期错误提供稳定错误码
|
||||
- 对用户可处理的问题提供 `HINT`
|
||||
- 不依赖 Agent 连续对话才能完成
|
||||
- 不自行实现宿主任务中心
|
||||
- 长时间任务允许异步执行,由宿主任务中心承载状态
|
||||
- 不在 CLI 中硬编码宿主安装目录
|
||||
- 不直接依赖某个具体 UI 页面
|
||||
|
||||
建议错误输出格式:
|
||||
|
||||
```text
|
||||
ERROR:<CODE>: <message>
|
||||
HINT: <next step>
|
||||
```
|
||||
|
||||
## placements(入口位置)
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `toolbar` | 数据管理第一排技能按钮 |
|
||||
| `skill-detail` | 技能详情或技能市场详情页 |
|
||||
| `agent` | Agent 可直接调用 |
|
||||
| `cron` | 定时任务可直接调用 |
|
||||
|
||||
**预留能力(当前宿主可能尚未完整支持,模板示例请勿使用):**
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `row` | 行内操作(未来扩展) |
|
||||
| `batch` | 批量操作(未来扩展) |
|
||||
|
||||
可以在 manifest 中作为未来扩展值定义,但不要让技能作者误以为 `row` / `batch` 已经完整可用。
|
||||
|
||||
## inputSchema 与参数表单
|
||||
|
||||
当前宿主稳定支持:
|
||||
|
||||
- `object` / `string` / `number` / `boolean`
|
||||
- `enum` / `default` / `required` / `sensitive`
|
||||
- 简单模板替换(`{{fieldName}}`)
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"title": "查询条件"
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"title": "返回数量",
|
||||
"default": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
约定:
|
||||
|
||||
- `boolean` 在宿主中渲染为开关
|
||||
- `enum` 在宿主中渲染为选择框
|
||||
- `required` 字段在提交前校验
|
||||
- `sensitive` 字段不得出现在低信任入口
|
||||
- 需要确认的副作用操作使用 `confirmation.message`
|
||||
- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明
|
||||
- `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null`
|
||||
- **不要**在当前模板中宣传 `nested object`、`oneOf`、复杂数组等尚未稳定支持的能力
|
||||
|
||||
## 风险与入口配置
|
||||
|
||||
- 查询、诊断、版本检查可以放到 `skill-detail` 或 `agent`
|
||||
- 低风险、常用动作才考虑放 `toolbar`
|
||||
- 会修改本地数据、访问外部系统、执行长时间任务的动作必须谨慎配置
|
||||
- 有副作用的动作应添加 `confirmation.message`
|
||||
- 高风险动作不应默认出现在 `toolbar`
|
||||
- 含 `sensitive` 参数的 action 不应放入 `toolbar` 或 `cron`,除非有明确安全设计
|
||||
- 不要为了「按钮多」而暴露所有 CLI 子命令
|
||||
- 一个 CLI 命令可以拆成多个语义清晰的 action,但每个 action 必须有明确语义
|
||||
|
||||
## 模板示例 action 一览
|
||||
|
||||
| id | CLI | placements | 说明 |
|
||||
|----|-----|------------|------|
|
||||
| `health` | `health` | skill-detail, agent | 运行环境检查 |
|
||||
| `version` | `version` | skill-detail | 版本 JSON |
|
||||
| `config-path` | `config-path` | skill-detail | 配置路径 JSON |
|
||||
|
||||
复制为新技能后,请按业务需要增删 action;不需要宿主入口时可删除整个 `actions.json`。
|
||||
|
||||
## 与宿主 API(参考)
|
||||
|
||||
| 宿主 API | 用途 |
|
||||
|----------|------|
|
||||
| `GET /api/skill-actions/{skillSlug}` | 读取 manifest |
|
||||
| `POST /api/skill-actions/run` | `{ skillSlug, actionId, input?, source? }` |
|
||||
| `GET /api/skill-actions/jobs` | 任务中心 |
|
||||
|
||||
具体路径与字段以宿主实现为准;技能侧只需保证 manifest 与 CLI 契约稳定。
|
||||
@@ -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`。
|
||||
|
||||
@@ -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,12 +232,20 @@ 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`
|
||||
|
||||
`task_logs` 是技能内部**系统自动写入**的任务审计日志(由 `task_logs_repository` INSERT/UPDATE),不是供用户在宿主数据管理中手工维护的业务表。
|
||||
|
||||
权限设计:
|
||||
|
||||
| 层级 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 |
|
||||
| 所有业务字段 `editable` | `0` | 含 `task_type`、`status`、`error_msg`、`result_summary` 等 |
|
||||
| 技能内部 repository | 正常 INSERT/UPDATE | 元数据权限只约束宿主 UI,不限制技能代码写库 |
|
||||
|
||||
物理定义(权威顺序):
|
||||
|
||||
```sql
|
||||
@@ -140,33 +263,112 @@ CREATE TABLE IF NOT EXISTS task_logs (
|
||||
);
|
||||
```
|
||||
|
||||
插入时可省略 `created_at`、`updated_at`;`updated_at` 在 UPDATE 时由 trigger `task_logs_set_updated_at` 自动维护(见 `scripts/db/timestamp_columns.py`)。
|
||||
|
||||
对应中文展示(由 `init_db()` 写入元数据表):
|
||||
|
||||
| 物理字段 | 中文名 |
|
||||
|----------|--------|
|
||||
| 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 | 任务类型 | 0 |
|
||||
| target_id | 目标编号 | 0 |
|
||||
| input_id | 输入编号 | 0 |
|
||||
| input_title | 输入标题 | 0 |
|
||||
| status | 状态 | 0 |
|
||||
| error_msg | 错误信息 | 0 |
|
||||
| result_summary | 结果摘要 | 0 |
|
||||
| created_at | 创建时间 | 0 |
|
||||
| updated_at | 更新时间 | 0 |
|
||||
|
||||
表中文名:**任务日志**。
|
||||
表中文名:**任务日志**;表级 `readonly = 1`(宿主只读;技能 repository 仍可写入)。
|
||||
|
||||
禁止事项:
|
||||
### 通用示例:`demo_items`(测试 fixture 参考)
|
||||
|
||||
- 不要把 `created_at` 放在第一列;
|
||||
- 不要把 `id` 随意放在中间;
|
||||
- 不要指望宿主按英文字段名排序;
|
||||
- 若需调整历史表字段顺序,应通过规范迁移**重建表**,而不是在宿主侧配置掩盖。
|
||||
以下表结构用于模板测试演示数据管理契约,**不是**模板默认业务表:
|
||||
|
||||
复合主键或特殊表结构,请在本文档或技能自有 SCHEMA 补充中说明例外。
|
||||
```sql
|
||||
CREATE TABLE demo_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
generated_value TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
```
|
||||
|
||||
对应元数据要点:
|
||||
|
||||
| 物理字段 | editable | 说明 |
|
||||
|----------|----------|------|
|
||||
| id | 0 | 自增主键 |
|
||||
| title | 1 | 业务字段 |
|
||||
| description | 1 | 业务字段 |
|
||||
| status | 1 | 枚举,`options_json` 演示 |
|
||||
| generated_value | 0 | 系统/计算字段 |
|
||||
| created_at | 0 | 系统时间 |
|
||||
| updated_at | 0 | 系统时间 |
|
||||
|
||||
## 枚举字段 options_json
|
||||
|
||||
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`:
|
||||
|
||||
```json
|
||||
[
|
||||
{"value": "draft", "label": "草稿"},
|
||||
{"value": "published", "label": "已发布"},
|
||||
{"value": "archived", "label": "已归档"}
|
||||
]
|
||||
```
|
||||
|
||||
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
|
||||
|
||||
## 幂等初始化标准
|
||||
|
||||
- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化
|
||||
- 多次执行初始化不能重复记录
|
||||
- 重新初始化可以更新展示名、描述、`visible`、`searchable`、`editable`、`display_type`、`options_json`
|
||||
- 元数据不能残留旧字段(删除或重命名字段时必须同步清理)
|
||||
- 元数据不能引用不存在的表或字段
|
||||
- 新增字段或表时必须同步增加元数据
|
||||
- 不要直接修改用户真实数据库作为迁移方式
|
||||
|
||||
实现 helper:
|
||||
|
||||
- `upsert_table_metadata` / `upsert_column_metadata` — 幂等写入
|
||||
- `seed_column_metadata_batch` — 批量 upsert;**可选**传入 `valid_column_names` 时在 upsert 后自动 prune
|
||||
- `prune_stale_column_metadata` — 清理已删除物理字段的元数据
|
||||
|
||||
### 新增业务表时如何初始化
|
||||
|
||||
1. 在 `connection.py`(或迁移脚本)中 `CREATE TABLE` 真实物理表。
|
||||
2. 用 `PRAGMA table_info` 或 `list_physical_column_names` 取得**真实物理字段列表**(顺序以 `cid` 为准)。
|
||||
3. 调用 `upsert_table_metadata` 写入表级元数据。
|
||||
4. 调用 `seed_column_metadata_batch`,并传入 `valid_column_names=physical_column_names`:
|
||||
|
||||
```python
|
||||
from db.display_metadata import list_physical_column_names, seed_column_metadata_batch, upsert_table_metadata
|
||||
|
||||
physical = list_physical_column_names(cursor, "demo_items")
|
||||
upsert_table_metadata(cursor, table_name="demo_items", display_name="示例记录", readonly=0)
|
||||
seed_column_metadata_batch(
|
||||
cursor,
|
||||
table_name="demo_items",
|
||||
columns=DEMO_ITEMS_COLUMN_METADATA,
|
||||
valid_column_names=physical,
|
||||
)
|
||||
```
|
||||
|
||||
传入 `valid_column_names` 时:先 upsert,再删除不在物理表中的陈旧 `_jiangchang_columns` 行。
|
||||
|
||||
### 字段删除或重命名时如何清理
|
||||
|
||||
1. 在 DDL / 迁移中完成物理字段删除或重命名。
|
||||
2. 重新执行 `seed_column_metadata_batch(..., valid_column_names=当前物理字段列表)`,自动 prune 旧字段元数据。
|
||||
3. 或单独调用 `prune_stale_column_metadata(cursor, table_name, valid_column_names)`。
|
||||
|
||||
**不传入 `valid_column_names` 时不会自动 prune**——模板不会猜测真实物理字段,避免误删;文档与实现均以此为准。
|
||||
|
||||
不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。
|
||||
|
||||
## 中文命名质量
|
||||
|
||||
@@ -188,17 +390,19 @@ CREATE TABLE IF NOT EXISTS task_logs (
|
||||
|----------|-----------|-----------|----------|
|
||||
| 发布类 | publish | 账号 ID | 内容 ID |
|
||||
| 工资代发 | 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` 为单一权威来源。
|
||||
- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。
|
||||
|
||||
@@ -47,20 +47,20 @@ CREATE TABLE IF NOT EXISTS _jiangchang_columns (
|
||||
TASK_LOGS_TABLE_METADATA = {
|
||||
"table_name": TASK_LOGS_TABLE,
|
||||
"display_name": "任务日志",
|
||||
"description": "记录技能任务的执行状态和结果",
|
||||
"description": "记录技能任务的执行状态和结果(系统自动写入,宿主数据管理只读展示)",
|
||||
"visible": 1,
|
||||
"readonly": 0,
|
||||
"readonly": 1,
|
||||
}
|
||||
|
||||
TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||
{"column_name": "id", "display_name": "编号", "description": "任务日志唯一编号", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{
|
||||
"column_name": "created_at",
|
||||
"display_name": "创建时间",
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -145,18 +157,104 @@ def seed_task_logs_display_metadata(cursor) -> None:
|
||||
visible=int(meta["visible"]),
|
||||
readonly=int(meta["readonly"]),
|
||||
)
|
||||
for column in TASK_LOGS_COLUMN_METADATA:
|
||||
seed_column_metadata_batch(
|
||||
cursor,
|
||||
table_name=TASK_LOGS_TABLE,
|
||||
columns=TASK_LOGS_COLUMN_METADATA,
|
||||
valid_column_names=[str(column["column_name"]) for column in TASK_LOGS_COLUMN_METADATA],
|
||||
)
|
||||
|
||||
|
||||
def seed_column_metadata_batch(
|
||||
cursor,
|
||||
*,
|
||||
table_name: str,
|
||||
columns: Iterable[dict[str, object]],
|
||||
valid_column_names: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
"""批量 upsert 字段元数据;传入 valid_column_names 时会在 upsert 后清理陈旧字段元数据。"""
|
||||
for column in columns:
|
||||
upsert_column_metadata(
|
||||
cursor,
|
||||
table_name=TASK_LOGS_TABLE,
|
||||
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["visible"]),
|
||||
searchable=int(column["searchable"]),
|
||||
editable=int(column["editable"]),
|
||||
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,
|
||||
)
|
||||
if valid_column_names is not None:
|
||||
prune_stale_column_metadata(cursor, table_name, valid_column_names)
|
||||
|
||||
|
||||
def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None:
|
||||
"""删除表中已不存在物理字段的元数据行(幂等初始化后不得残留旧字段)。"""
|
||||
valid = set(valid_column_names)
|
||||
cursor.execute(
|
||||
"SELECT column_name FROM _jiangchang_columns WHERE table_name = ?",
|
||||
(table_name,),
|
||||
)
|
||||
for (column_name,) in cursor.fetchall():
|
||||
if str(column_name) not in valid:
|
||||
cursor.execute(
|
||||
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?",
|
||||
(table_name, column_name),
|
||||
)
|
||||
|
||||
|
||||
def get_table_readonly(cursor, table_name: str) -> int | None:
|
||||
cursor.execute(
|
||||
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
|
||||
(table_name,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return int(row[0] or 0)
|
||||
|
||||
|
||||
def get_column_editable(cursor, table_name: str, column_name: str) -> int | None:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT editable FROM _jiangchang_columns
|
||||
WHERE table_name = ? AND column_name = ?
|
||||
""",
|
||||
(table_name, column_name),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return int(row[0] or 0)
|
||||
|
||||
|
||||
def list_editable_business_columns(cursor, table_name: str) -> list[str]:
|
||||
"""返回 editable=1 的业务字段名(不含宿主侧主键/generated 等二次过滤)。"""
|
||||
physical = list_physical_column_names(cursor, table_name)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT column_name, editable
|
||||
FROM _jiangchang_columns
|
||||
WHERE table_name = ?
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
editable_set = {
|
||||
str(column_name)
|
||||
for column_name, editable in cursor.fetchall()
|
||||
if editable is not None and int(editable) == 1
|
||||
}
|
||||
return [name for name in physical if name in editable_set]
|
||||
|
||||
|
||||
def table_allows_manual_create(cursor, table_name: str) -> bool:
|
||||
"""表级 readonly=0 且至少有一个 editable=1 字段时,宿主才应提供空白新增表单。"""
|
||||
readonly = get_table_readonly(cursor, table_name)
|
||||
if readonly is None or int(readonly) != 0:
|
||||
return False
|
||||
return bool(list_editable_business_columns(cursor, table_name))
|
||||
|
||||
|
||||
def init_display_metadata(cursor) -> None:
|
||||
|
||||
@@ -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
|
||||
@@ -158,6 +161,33 @@ def _table_column_info(cursor, table_name: str) -> dict[str, dict[str, object]]:
|
||||
return info
|
||||
|
||||
|
||||
def _table_column_xinfo(cursor, table_name: str) -> dict[str, dict[str, object]]:
|
||||
"""PRAGMA table_xinfo 可识别 generated/hidden 字段(SQLite 3.31+)。"""
|
||||
cursor.execute(f"PRAGMA table_xinfo({_quote_identifier(table_name)})")
|
||||
info: dict[str, dict[str, object]] = {}
|
||||
for row in cursor.fetchall():
|
||||
hidden = int(row[6] or 0) if len(row) > 6 else 0
|
||||
info[str(row[1])] = {
|
||||
"cid": int(row[0]),
|
||||
"name": str(row[1]),
|
||||
"type": str(row[2] or ""),
|
||||
"notnull": int(row[3] or 0),
|
||||
"dflt_value": row[4],
|
||||
"pk": int(row[5] or 0),
|
||||
"hidden": hidden,
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def _is_generated_column(xinfo: dict[str, object]) -> bool:
|
||||
hidden = int(xinfo.get("hidden") or 0)
|
||||
return hidden in (2, 3)
|
||||
|
||||
|
||||
def _is_blob_column(column_info: dict[str, object]) -> bool:
|
||||
return "BLOB" in str(column_info.get("type") or "").upper()
|
||||
|
||||
|
||||
def _default_uses_unixepoch(default_value: object) -> bool:
|
||||
if default_value is None:
|
||||
return False
|
||||
@@ -165,6 +195,100 @@ 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)
|
||||
column_xinfo = _table_column_xinfo(cursor, table_name)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT column_name, editable, options_json
|
||||
FROM _jiangchang_columns
|
||||
WHERE table_name = ?
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
meta_by_column = {
|
||||
str(row[0]): {"editable": row[1], "options_json": row[2]}
|
||||
for row in cursor.fetchall()
|
||||
}
|
||||
|
||||
for column_name, meta in meta_by_column.items():
|
||||
physical = column_info.get(column_name) or {}
|
||||
xinfo = column_xinfo.get(column_name) or physical
|
||||
if not physical and column_name not in column_xinfo:
|
||||
continue
|
||||
|
||||
pk = int(physical.get("pk") or xinfo.get("pk") or 0)
|
||||
editable = meta["editable"]
|
||||
if pk and editable is not None and int(editable) != 0:
|
||||
errors.append(f"{table_name}.{column_name} 是主键,editable 应为 0")
|
||||
|
||||
if column_name == "id" and editable is not None and int(editable) != 0:
|
||||
errors.append(f"{table_name}.id 的 editable 应为 0(自增主键)")
|
||||
|
||||
if _is_generated_column(xinfo) and editable is not None and int(editable) != 0:
|
||||
errors.append(f"{table_name}.{column_name} 是 generated 字段,editable 应为 0")
|
||||
|
||||
if _is_blob_column(physical or xinfo) and editable is not None and int(editable) != 0:
|
||||
errors.append(f"{table_name}.{column_name} 是 BLOB 字段,editable 应为 0")
|
||||
|
||||
opt_errors, _ = validate_options_json(meta.get("options_json"))
|
||||
for item in opt_errors:
|
||||
errors.append(f"{table_name}.{column_name}: {item}")
|
||||
|
||||
cursor.execute(
|
||||
"SELECT readonly FROM _jiangchang_tables WHERE table_name = ?",
|
||||
(table_name,),
|
||||
)
|
||||
readonly_row = cursor.fetchone()
|
||||
readonly = int(readonly_row[0]) if readonly_row else None
|
||||
editable_columns = list_editable_business_columns(cursor, table_name)
|
||||
if readonly == 0 and not editable_columns:
|
||||
warnings.append(
|
||||
f"{table_name}: 表级 readonly=0 但所有字段 editable=0,"
|
||||
"宿主不应打开空白新增表单;数据应由技能业务流程产生"
|
||||
)
|
||||
if readonly == 1 and editable_columns:
|
||||
warnings.append(
|
||||
f"{table_name}: 表级 readonly=1 但存在 editable=1 字段,"
|
||||
"宿主应以表级只读为准"
|
||||
)
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def _expected_timestamp_display_name(column_name: str) -> str:
|
||||
if column_name == "created_at":
|
||||
return "创建时间"
|
||||
@@ -338,6 +462,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:
|
||||
|
||||
237
tests/test_actions_manifest.py
Normal file
237
tests/test_actions_manifest.py
Normal file
@@ -0,0 +1,237 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
|
||||
from _support import get_skill_root
|
||||
|
||||
from cli.app import build_parser
|
||||
from util.constants import SKILL_SLUG
|
||||
|
||||
_TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
|
||||
_ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"})
|
||||
_RESERVED_PLACEMENTS = frozenset({"row", "batch"})
|
||||
_RESERVED_ACTION_FIELDS = frozenset({"bind", "concurrency", "locks"})
|
||||
_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"})
|
||||
_REQUIRED_ACTION_KEYS = frozenset({"id", "label", "description", "placements", "entrypoint"})
|
||||
_FORBIDDEN_BUSINESS_TERMS = (
|
||||
"wechat",
|
||||
"微信",
|
||||
"contacts",
|
||||
"wxid",
|
||||
"account_wxid",
|
||||
"contact.db",
|
||||
"scrape-contacts",
|
||||
"douyin",
|
||||
"抖音",
|
||||
"xiaohongshu",
|
||||
"小红书",
|
||||
"公众号",
|
||||
"logistics",
|
||||
"物流",
|
||||
)
|
||||
|
||||
_TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}")
|
||||
|
||||
|
||||
def _load_actions_manifest() -> dict:
|
||||
path = os.path.join(get_skill_root(), "assets", "actions.json")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _cli_subcommands() -> set[str]:
|
||||
parser = build_parser()
|
||||
subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined]
|
||||
return set(subs.keys())
|
||||
|
||||
|
||||
def _collect_template_vars(args: list) -> set[str]:
|
||||
found: set[str] = set()
|
||||
for item in args:
|
||||
if not isinstance(item, str):
|
||||
continue
|
||||
for match in _TEMPLATE_ARG_PATTERN.finditer(item):
|
||||
found.add(match.group(1))
|
||||
return found
|
||||
|
||||
|
||||
def _load_actions_schema() -> dict:
|
||||
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
class TestActionsManifest(unittest.TestCase):
|
||||
def test_manifest_exists_and_schema(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
self.assertEqual(manifest.get("schemaVersion"), 1)
|
||||
skill = manifest.get("skill")
|
||||
self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG))
|
||||
self.assertIsInstance(manifest.get("actions"), list)
|
||||
self.assertGreater(len(manifest["actions"]), 0)
|
||||
|
||||
def test_action_ids_unique_and_cli_valid(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
subs = _cli_subcommands()
|
||||
seen: set[str] = set()
|
||||
for action in manifest["actions"]:
|
||||
action_id = action["id"]
|
||||
self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}")
|
||||
seen.add(action_id)
|
||||
|
||||
self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty")
|
||||
self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty")
|
||||
|
||||
entry = action["entrypoint"]
|
||||
self.assertEqual(entry["type"], "cli")
|
||||
self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI")
|
||||
|
||||
args = entry.get("args")
|
||||
self.assertIsInstance(args, list, msg=f"{action_id} args must be array")
|
||||
for item in args:
|
||||
self.assertIsInstance(
|
||||
item,
|
||||
(str, int, float, bool),
|
||||
msg=f"{action_id} arg item must be scalar",
|
||||
)
|
||||
self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object")
|
||||
self.assertIsNotNone(item, msg=f"{action_id} arg must not be null")
|
||||
|
||||
placements = action.get("placements")
|
||||
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
|
||||
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
|
||||
for placement in placements:
|
||||
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
|
||||
|
||||
def test_template_example_does_not_use_reserved_placements(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
placements = set(action.get("placements") or [])
|
||||
overlap = placements & _RESERVED_PLACEMENTS
|
||||
self.assertFalse(
|
||||
overlap,
|
||||
msg=f"{action['id']} uses reserved placements not yet supported in template: {overlap}",
|
||||
)
|
||||
|
||||
def test_template_variables_have_input_schema(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
args = action.get("entrypoint", {}).get("args") or []
|
||||
template_vars = _collect_template_vars(args)
|
||||
if not template_vars:
|
||||
continue
|
||||
schema = action.get("inputSchema") or {}
|
||||
properties = schema.get("properties") or {}
|
||||
for var in template_vars:
|
||||
self.assertIn(
|
||||
var,
|
||||
properties,
|
||||
msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it",
|
||||
)
|
||||
|
||||
def test_required_fields_exist_in_properties(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
schema = action.get("inputSchema") or {}
|
||||
required = schema.get("required") or []
|
||||
properties = schema.get("properties") or {}
|
||||
for field in required:
|
||||
self.assertIn(
|
||||
field,
|
||||
properties,
|
||||
msg=f"{action['id']} required field {field!r} missing from properties",
|
||||
)
|
||||
|
||||
def test_sensitive_params_not_on_toolbar(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
props = (action.get("inputSchema") or {}).get("properties") or {}
|
||||
has_sensitive = any(p.get("sensitive") for p in props.values())
|
||||
if has_sensitive and "toolbar" in (action.get("placements") or []):
|
||||
self.fail(f"{action['id']} has sensitive input but is on toolbar")
|
||||
|
||||
def test_confirmation_message_structure(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
confirmation = action.get("confirmation")
|
||||
if confirmation is None:
|
||||
continue
|
||||
self.assertIsInstance(confirmation, dict)
|
||||
message = confirmation.get("message")
|
||||
self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty")
|
||||
|
||||
def test_manifest_has_no_specific_business_skill_names(self) -> None:
|
||||
path = os.path.join(get_skill_root(), "assets", "actions.json")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
raw = f.read().lower()
|
||||
for term in _FORBIDDEN_BUSINESS_TERMS:
|
||||
self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}")
|
||||
|
||||
def test_actions_md_exists_and_links_manifest(self) -> None:
|
||||
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
self.assertIn("actions.json", text)
|
||||
self.assertIn("schemaVersion", text)
|
||||
for term in _FORBIDDEN_BUSINESS_TERMS:
|
||||
self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}")
|
||||
|
||||
def test_skill_actions_schema_file_exists(self) -> None:
|
||||
path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json")
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
with open(path, encoding="utf-8") as f:
|
||||
schema = json.load(f)
|
||||
self.assertEqual(schema.get("title"), "SkillActionManifest")
|
||||
self.assertIn("actions", schema.get("properties", {}))
|
||||
|
||||
def test_skill_actions_schema_json_parses(self) -> None:
|
||||
schema = _load_actions_schema()
|
||||
self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1)
|
||||
self.assertTrue(schema.get("additionalProperties") is False)
|
||||
self.assertIn("action", schema.get("$defs", {}))
|
||||
|
||||
def test_template_manifest_matches_schema_required_keys(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
schema = _load_actions_schema()
|
||||
for key in _REQUIRED_MANIFEST_KEYS:
|
||||
self.assertIn(key, manifest)
|
||||
self.assertIn(key, schema.get("properties", {}))
|
||||
for action in manifest["actions"]:
|
||||
for key in _REQUIRED_ACTION_KEYS:
|
||||
self.assertIn(key, action)
|
||||
|
||||
def test_template_example_does_not_use_reserved_action_fields(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS
|
||||
self.assertFalse(
|
||||
overlap,
|
||||
msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}",
|
||||
)
|
||||
|
||||
def test_actions_md_documents_strict_vs_host_boundary(self) -> None:
|
||||
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
for marker in (
|
||||
"严格规范",
|
||||
"向后兼容",
|
||||
"Phase 1",
|
||||
"bind",
|
||||
"concurrency",
|
||||
"locks",
|
||||
"row",
|
||||
"batch",
|
||||
"additionalProperties",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
479
tests/test_data_management_contract.py
Normal file
479
tests/test_data_management_contract.py
Normal file
@@ -0,0 +1,479 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""通用数据管理契约测试(demo_items fixture,不含具体业务技能内容)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from _support import IsolatedDataRoot
|
||||
|
||||
DEMO_ITEMS_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS demo_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
generated_value TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS demo_items_set_updated_at
|
||||
AFTER UPDATE ON demo_items
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
UPDATE demo_items
|
||||
SET updated_at = unixepoch()
|
||||
WHERE id = NEW.id AND updated_at = OLD.updated_at;
|
||||
END;
|
||||
"""
|
||||
|
||||
DEMO_ITEMS_TABLE_METADATA = {
|
||||
"table_name": "demo_items",
|
||||
"display_name": "示例记录",
|
||||
"description": "通用数据管理契约演示表",
|
||||
"visible": 1,
|
||||
"readonly": 0,
|
||||
}
|
||||
|
||||
DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
|
||||
{"column_name": "id", "display_name": "编号", "visible": 1, "searchable": 1, "editable": 0},
|
||||
{"column_name": "title", "display_name": "标题", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{"column_name": "description", "display_name": "描述", "visible": 1, "searchable": 1, "editable": 1},
|
||||
{
|
||||
"column_name": "status",
|
||||
"display_name": "状态",
|
||||
"visible": 1,
|
||||
"searchable": 1,
|
||||
"editable": 1,
|
||||
"options_json": json.dumps(
|
||||
[
|
||||
{"value": "draft", "label": "草稿"},
|
||||
{"value": "published", "label": "已发布"},
|
||||
{"value": "archived", "label": "已归档"},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
},
|
||||
{"column_name": "generated_value", "display_name": "生成值", "visible": 1, "searchable": 0, "editable": 0},
|
||||
{
|
||||
"column_name": "created_at",
|
||||
"display_name": "创建时间",
|
||||
"visible": 1,
|
||||
"searchable": 0,
|
||||
"editable": 0,
|
||||
"display_type": "datetime_unix_seconds",
|
||||
},
|
||||
{
|
||||
"column_name": "updated_at",
|
||||
"display_name": "更新时间",
|
||||
"visible": 1,
|
||||
"searchable": 0,
|
||||
"editable": 0,
|
||||
"display_type": "datetime_unix_seconds",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _init_demo_items_fixture(conn: sqlite3.Connection, *, prune: bool = True) -> None:
|
||||
from db.display_metadata import (
|
||||
init_display_metadata_tables,
|
||||
list_physical_column_names,
|
||||
seed_column_metadata_batch,
|
||||
upsert_table_metadata,
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
cur.executescript(DEMO_ITEMS_DDL)
|
||||
meta = DEMO_ITEMS_TABLE_METADATA
|
||||
upsert_table_metadata(
|
||||
cur,
|
||||
table_name=str(meta["table_name"]),
|
||||
display_name=str(meta["display_name"]),
|
||||
description=str(meta["description"]),
|
||||
visible=int(meta["visible"]),
|
||||
readonly=int(meta["readonly"]),
|
||||
)
|
||||
physical = list_physical_column_names(cur, "demo_items")
|
||||
seed_column_metadata_batch(
|
||||
cur,
|
||||
table_name="demo_items",
|
||||
columns=DEMO_ITEMS_COLUMN_METADATA,
|
||||
valid_column_names=physical if prune else None,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
class TestDataManagementContract(unittest.TestCase):
|
||||
def test_demo_items_metadata_permissions(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
from db.display_metadata import (
|
||||
get_column_editable,
|
||||
get_table_readonly,
|
||||
list_editable_business_columns,
|
||||
table_allows_manual_create,
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
self.assertEqual(get_table_readonly(cur, "demo_items"), 0)
|
||||
self.assertEqual(get_column_editable(cur, "demo_items", "id"), 0)
|
||||
self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1)
|
||||
self.assertEqual(get_column_editable(cur, "demo_items", "generated_value"), 0)
|
||||
self.assertEqual(get_column_editable(cur, "demo_items", "created_at"), 0)
|
||||
self.assertEqual(get_column_editable(cur, "demo_items", "updated_at"), 0)
|
||||
|
||||
editable_cols = list_editable_business_columns(cur, "demo_items")
|
||||
self.assertEqual(editable_cols, ["title", "description", "status"])
|
||||
self.assertTrue(table_allows_manual_create(cur, "demo_items"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_demo_items_init_idempotent_no_duplicates(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
_init_demo_items_fixture(conn)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'demo_items'")
|
||||
self.assertEqual(int(cur.fetchone()[0]), 1)
|
||||
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'demo_items'")
|
||||
self.assertEqual(int(cur.fetchone()[0]), 7)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_reinit_updates_stale_editable_value(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
conn.execute(
|
||||
"UPDATE _jiangchang_columns SET editable = 0 WHERE table_name = 'demo_items' AND column_name = 'title'"
|
||||
)
|
||||
conn.commit()
|
||||
_init_demo_items_fixture(conn)
|
||||
from db.display_metadata import get_column_editable
|
||||
|
||||
self.assertEqual(get_column_editable(conn.cursor(), "demo_items", "title"), 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_missing_table_metadata_detected(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
conn.execute("DELETE FROM _jiangchang_tables WHERE table_name = 'demo_items'")
|
||||
conn.commit()
|
||||
from db.display_metadata_validator import validate_display_metadata
|
||||
|
||||
report = validate_display_metadata(conn)
|
||||
self.assertFalse(report.ok)
|
||||
self.assertTrue(any("demo_items" in err for err in report.errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_missing_column_metadata_detected(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
conn.execute(
|
||||
"DELETE FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'"
|
||||
)
|
||||
conn.commit()
|
||||
from db.display_metadata_validator import validate_display_metadata
|
||||
|
||||
report = validate_display_metadata(conn)
|
||||
self.assertFalse(report.ok)
|
||||
self.assertTrue(any("demo_items.title" in err for err in report.errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_metadata_reference_to_missing_column_detected(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO _jiangchang_columns
|
||||
(table_name, column_name, display_name, visible, searchable, editable)
|
||||
VALUES ('demo_items', 'not_exists', '不存在', 1, 1, 0)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
from db.display_metadata_validator import validate_display_metadata
|
||||
|
||||
report = validate_display_metadata(conn)
|
||||
self.assertFalse(report.ok)
|
||||
self.assertTrue(any("not_exists" in err for err in report.errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_empty_display_name_detected_for_visible_column(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
conn.execute(
|
||||
"UPDATE _jiangchang_columns SET display_name = '' WHERE table_name = 'demo_items' AND column_name = 'title'"
|
||||
)
|
||||
conn.commit()
|
||||
from db.display_metadata_validator import validate_display_metadata
|
||||
|
||||
report = validate_display_metadata(conn)
|
||||
self.assertFalse(report.ok)
|
||||
self.assertTrue(any("display_name 为空" in err for err in report.errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_visible_columns_use_chinese_display_name(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name, display_name
|
||||
FROM _jiangchang_columns
|
||||
WHERE table_name = 'demo_items' AND visible = 1
|
||||
"""
|
||||
)
|
||||
for column_name, display_name in cur.fetchall():
|
||||
self.assertTrue(
|
||||
any("\u4e00" <= ch <= "\u9fff" for ch in str(display_name)),
|
||||
msg=f"{column_name} display_name should be Chinese: {display_name!r}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_timestamp_fields_type_default_and_editable(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
cur = conn.cursor()
|
||||
for column_name in ("created_at", "updated_at"):
|
||||
cur.execute(f"PRAGMA table_info('demo_items')")
|
||||
info = {row[1]: row for row in cur.fetchall()}
|
||||
row = info[column_name]
|
||||
self.assertIn("INT", str(row[2]).upper())
|
||||
self.assertIn("unixepoch", str(row[4]).lower())
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT editable, display_type FROM _jiangchang_columns
|
||||
WHERE table_name = 'demo_items' AND column_name = ?
|
||||
""",
|
||||
(column_name,),
|
||||
)
|
||||
editable, display_type = cur.fetchone()
|
||||
self.assertEqual(int(editable), 0)
|
||||
self.assertEqual(display_type, "datetime_unix_seconds")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_physical_order_matches_pragma_cid(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
from db.display_metadata import list_physical_column_names
|
||||
|
||||
cur = conn.cursor()
|
||||
expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA]
|
||||
physical = list_physical_column_names(cur, "demo_items")
|
||||
self.assertEqual(physical, expected)
|
||||
cur.execute("PRAGMA table_info('demo_items')")
|
||||
cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))]
|
||||
self.assertEqual(cid_order, expected)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_options_json_parses_for_enum_field(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT options_json FROM _jiangchang_columns
|
||||
WHERE table_name = 'demo_items' AND column_name = 'status'
|
||||
"""
|
||||
)
|
||||
raw = cur.fetchone()[0]
|
||||
from db.display_metadata_validator import validate_options_json
|
||||
|
||||
errors, _ = validate_options_json(raw)
|
||||
self.assertEqual(errors, [])
|
||||
parsed = json.loads(raw)
|
||||
self.assertEqual(len(parsed), 3)
|
||||
self.assertEqual(parsed[0]["value"], "draft")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_system_table_no_editable_fields_not_manual_create(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
from db.display_metadata import (
|
||||
init_display_metadata_tables,
|
||||
seed_column_metadata_batch,
|
||||
table_allows_manual_create,
|
||||
upsert_table_metadata,
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE source_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id TEXT NOT NULL,
|
||||
synced_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
"""
|
||||
)
|
||||
upsert_table_metadata(
|
||||
cur,
|
||||
table_name="source_records",
|
||||
display_name="来源记录",
|
||||
description="只由同步流程写入",
|
||||
visible=1,
|
||||
readonly=0,
|
||||
)
|
||||
seed_column_metadata_batch(
|
||||
cur,
|
||||
table_name="source_records",
|
||||
columns=(
|
||||
{"column_name": "id", "display_name": "编号", "editable": 0},
|
||||
{"column_name": "item_id", "display_name": "记录编号", "editable": 0},
|
||||
{"column_name": "synced_at", "display_name": "同步时间", "editable": 0, "display_type": "datetime_unix_seconds"},
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
self.assertFalse(table_allows_manual_create(cur, "source_records"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_table_readonly_blocks_manual_create_even_with_editable_fields(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
conn.execute("UPDATE _jiangchang_tables SET readonly = 1 WHERE table_name = 'demo_items'")
|
||||
conn.commit()
|
||||
from db.display_metadata import table_allows_manual_create
|
||||
|
||||
self.assertFalse(table_allows_manual_create(conn.cursor(), "demo_items"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_prune_stale_column_metadata(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO _jiangchang_columns
|
||||
(table_name, column_name, display_name, visible, searchable, editable)
|
||||
VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
from db.display_metadata import list_physical_column_names, seed_column_metadata_batch
|
||||
|
||||
cur = conn.cursor()
|
||||
physical = list_physical_column_names(cur, "demo_items")
|
||||
seed_column_metadata_batch(
|
||||
cur,
|
||||
table_name="demo_items",
|
||||
columns=DEMO_ITEMS_COLUMN_METADATA,
|
||||
valid_column_names=physical,
|
||||
)
|
||||
conn.commit()
|
||||
cur.execute(
|
||||
"SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'"
|
||||
)
|
||||
self.assertIsNone(cur.fetchone())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_seed_batch_without_valid_column_names_does_not_prune(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn, prune=False)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO _jiangchang_columns
|
||||
(table_name, column_name, display_name, visible, searchable, editable)
|
||||
VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
from db.display_metadata import seed_column_metadata_batch
|
||||
|
||||
seed_column_metadata_batch(
|
||||
conn.cursor(),
|
||||
table_name="demo_items",
|
||||
columns=DEMO_ITEMS_COLUMN_METADATA,
|
||||
)
|
||||
conn.commit()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'"
|
||||
)
|
||||
self.assertIsNotNone(cur.fetchone())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_seed_batch_accepts_list_columns(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn, prune=False)
|
||||
from db.display_metadata import get_column_editable, seed_column_metadata_batch
|
||||
|
||||
columns = list(DEMO_ITEMS_COLUMN_METADATA)
|
||||
columns[1] = {**columns[1], "display_name": "标题(列表)"}
|
||||
seed_column_metadata_batch(
|
||||
conn.cursor(),
|
||||
table_name="demo_items",
|
||||
columns=columns,
|
||||
valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA],
|
||||
)
|
||||
conn.commit()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT display_name FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'"
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], "标题(列表)")
|
||||
self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_prune_does_not_remove_existing_column_metadata(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
from db.display_metadata import get_column_editable, seed_column_metadata_batch
|
||||
|
||||
cur = conn.cursor()
|
||||
before = get_column_editable(cur, "demo_items", "title")
|
||||
seed_column_metadata_batch(
|
||||
cur,
|
||||
table_name="demo_items",
|
||||
columns=DEMO_ITEMS_COLUMN_METADATA,
|
||||
valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA],
|
||||
)
|
||||
conn.commit()
|
||||
self.assertEqual(get_column_editable(cur, "demo_items", "title"), before)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_demo_items_validation_passes(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
_init_demo_items_fixture(conn)
|
||||
from db.display_metadata_validator import validate_display_metadata
|
||||
|
||||
report = validate_display_metadata(conn)
|
||||
self.assertTrue(report.ok, msg="; ".join(report.errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,6 +2,7 @@
|
||||
"""数据管理展示元数据:幂等初始化、中文名称、PRAGMA 字段顺序、校验规则。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
@@ -35,22 +36,39 @@ class TestDisplayMetadata(unittest.TestCase):
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT display_name FROM _jiangchang_tables WHERE table_name = 'task_logs'"
|
||||
"SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = 'task_logs'"
|
||||
)
|
||||
self.assertEqual(cur.fetchone()[0], "任务日志")
|
||||
display_name, readonly = cur.fetchone()
|
||||
self.assertEqual(display_name, "任务日志")
|
||||
self.assertEqual(int(readonly), 1)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT column_name, display_name
|
||||
SELECT column_name, display_name, editable
|
||||
FROM _jiangchang_columns
|
||||
WHERE table_name = 'task_logs'
|
||||
ORDER BY column_name
|
||||
"""
|
||||
)
|
||||
by_name = dict(cur.fetchall())
|
||||
self.assertEqual(by_name["status"], "状态")
|
||||
self.assertEqual(by_name["error_msg"], "错误信息")
|
||||
self.assertEqual(by_name["created_at"], "创建时间")
|
||||
by_name = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
|
||||
self.assertEqual(by_name["status"][0], "状态")
|
||||
self.assertEqual(int(by_name["status"][1]), 0)
|
||||
self.assertEqual(by_name["error_msg"][0], "错误信息")
|
||||
self.assertEqual(int(by_name["error_msg"][1]), 0)
|
||||
self.assertEqual(by_name["created_at"][0], "创建时间")
|
||||
self.assertEqual(int(by_name["created_at"][1]), 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_task_logs_not_manual_create_in_host_ui(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.connection import get_conn, init_db
|
||||
from db.display_metadata import table_allows_manual_create
|
||||
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
self.assertFalse(table_allows_manual_create(conn.cursor(), "task_logs"))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -342,6 +360,153 @@ metadata:
|
||||
)
|
||||
self.assertTrue(any("id" in item for item in warnings))
|
||||
|
||||
def test_generated_column_editable_must_be_zero(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE gen_demo (
|
||||
id INTEGER PRIMARY KEY,
|
||||
base_value INTEGER NOT NULL,
|
||||
generated_value INTEGER GENERATED ALWAYS AS (base_value * 2) STORED
|
||||
);
|
||||
"""
|
||||
)
|
||||
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
|
||||
from db.display_metadata_validator import validate_column_permissions
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
upsert_column_metadata(
|
||||
cur,
|
||||
table_name="gen_demo",
|
||||
column_name="generated_value",
|
||||
display_name="生成值",
|
||||
editable=1,
|
||||
)
|
||||
conn.commit()
|
||||
errors, _ = validate_column_permissions(cur, "gen_demo")
|
||||
self.assertTrue(any("generated" in err for err in errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_blob_column_editable_must_be_zero(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE blob_demo (id INTEGER PRIMARY KEY, payload BLOB NOT NULL)"
|
||||
)
|
||||
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
|
||||
from db.display_metadata_validator import validate_column_permissions
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
upsert_column_metadata(
|
||||
cur,
|
||||
table_name="blob_demo",
|
||||
column_name="payload",
|
||||
display_name="载荷",
|
||||
editable=1,
|
||||
)
|
||||
conn.commit()
|
||||
errors, _ = validate_column_permissions(cur, "blob_demo")
|
||||
self.assertTrue(any("BLOB" in err for err in errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_primary_key_editable_must_be_zero(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.execute("CREATE TABLE pk_demo (id INTEGER PRIMARY KEY, title TEXT)")
|
||||
from db.display_metadata import init_display_metadata_tables, upsert_column_metadata
|
||||
from db.display_metadata_validator import validate_column_permissions
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
upsert_column_metadata(
|
||||
cur,
|
||||
table_name="pk_demo",
|
||||
column_name="id",
|
||||
display_name="编号",
|
||||
editable=1,
|
||||
)
|
||||
conn.commit()
|
||||
errors, _ = validate_column_permissions(cur, "pk_demo")
|
||||
self.assertTrue(any("主键" in err for err in errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_readonly_table_with_editable_fields_warns(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE warn_demo (id INTEGER PRIMARY KEY, title TEXT);
|
||||
"""
|
||||
)
|
||||
from db.display_metadata import (
|
||||
init_display_metadata_tables,
|
||||
upsert_column_metadata,
|
||||
upsert_table_metadata,
|
||||
)
|
||||
from db.display_metadata_validator import validate_column_permissions
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
upsert_table_metadata(cur, table_name="warn_demo", display_name="警告演示", readonly=1)
|
||||
upsert_column_metadata(
|
||||
cur,
|
||||
table_name="warn_demo",
|
||||
column_name="title",
|
||||
display_name="标题",
|
||||
editable=1,
|
||||
)
|
||||
conn.commit()
|
||||
_, warnings = validate_column_permissions(cur, "warn_demo")
|
||||
self.assertTrue(any("readonly=1" in item for item in warnings))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_writable_table_without_editable_fields_warns(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE warn_demo2 (id INTEGER PRIMARY KEY, title TEXT);
|
||||
"""
|
||||
)
|
||||
from db.display_metadata import (
|
||||
init_display_metadata_tables,
|
||||
upsert_column_metadata,
|
||||
upsert_table_metadata,
|
||||
)
|
||||
from db.display_metadata_validator import validate_column_permissions
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
upsert_table_metadata(cur, table_name="warn_demo2", display_name="警告演示二", readonly=0)
|
||||
upsert_column_metadata(
|
||||
cur,
|
||||
table_name="warn_demo2",
|
||||
column_name="title",
|
||||
display_name="标题",
|
||||
editable=0,
|
||||
)
|
||||
conn.commit()
|
||||
_, warnings = validate_column_permissions(cur, "warn_demo2")
|
||||
self.assertTrue(any("readonly=0" in item and "editable=0" in item for item in warnings))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_schema_md_documents_task_logs_readonly_semantics(self) -> None:
|
||||
path = os.path.join(get_skill_root(), "references", "SCHEMA.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
self.assertIn("readonly = 1", text)
|
||||
self.assertIn("task_logs", text)
|
||||
self.assertIn("seed_column_metadata_batch", text)
|
||||
self.assertIn("valid_column_names", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -228,6 +228,39 @@ 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)
|
||||
self.assertIn("your_skill_slug", text)
|
||||
|
||||
def test_development_md_documents_both_slug_placeholders(self) -> None:
|
||||
text = self._read("development/DEVELOPMENT.md")
|
||||
self.assertIn("your-skill-slug", text)
|
||||
self.assertIn("your_skill_slug", text)
|
||||
|
||||
def test_schema_md_documents_data_management_contract(self) -> None:
|
||||
text = self._read("references/SCHEMA.md")
|
||||
for marker in (
|
||||
"_jiangchang_tables",
|
||||
"_jiangchang_columns",
|
||||
"editable",
|
||||
"options_json",
|
||||
"demo_items",
|
||||
"readonly",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
|
||||
|
||||
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
|
||||
forbidden = (
|
||||
"download-settlement-report-amazon",
|
||||
@@ -240,6 +273,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",
|
||||
)
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
"""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"
|
||||
TEMPLATE_SLUG_KEBAB = "your-skill-slug"
|
||||
TEMPLATE_SLUG_UNDERSCORE = "your_skill_slug"
|
||||
SCAFFOLD_TEST_SLUG = "query-demo-records"
|
||||
SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records"
|
||||
|
||||
|
||||
def _read_skill_slug() -> str:
|
||||
@@ -24,7 +29,7 @@ def _read_skill_slug() -> str:
|
||||
class TestScaffoldGuard(unittest.TestCase):
|
||||
def test_template_repo_has_marker(self) -> None:
|
||||
slug = _read_skill_slug()
|
||||
if slug != TEMPLATE_SLUG:
|
||||
if slug != TEMPLATE_SLUG_KEBAB:
|
||||
self.skipTest(f"当前 SKILL_SLUG={slug!r},非模板占位,跳过 template marker 断言")
|
||||
marker = os.path.join(get_skill_root(), TEMPLATE_MARKER)
|
||||
self.assertTrue(
|
||||
@@ -34,7 +39,7 @@ class TestScaffoldGuard(unittest.TestCase):
|
||||
|
||||
def test_business_skill_must_not_keep_template_marker(self) -> None:
|
||||
slug = _read_skill_slug()
|
||||
if slug == TEMPLATE_SLUG:
|
||||
if slug == TEMPLATE_SLUG_KEBAB:
|
||||
self.skipTest("当前仍为模板占位 slug,由 test_template_repo_has_marker 守护")
|
||||
marker = os.path.join(get_skill_root(), TEMPLATE_MARKER)
|
||||
self.assertFalse(
|
||||
@@ -59,6 +64,82 @@ 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_KEBAB:
|
||||
self.skipTest("仅在 skill-template 源仓库运行 scaffold 集成测试")
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
repo_root = get_skill_root()
|
||||
scaffold_py = os.path.join(repo_root, "tools", "scaffold_skill.py")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dest = os.path.join(tmp, SCAFFOLD_TEST_SLUG)
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
scaffold_py,
|
||||
"--slug",
|
||||
SCAFFOLD_TEST_SLUG,
|
||||
"--destination",
|
||||
dest,
|
||||
],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, msg=proc.stderr or proc.stdout)
|
||||
|
||||
actions_path = os.path.join(dest, "assets", "actions.json")
|
||||
self.assertTrue(os.path.isfile(actions_path), msg="scaffold must copy assets/actions.json")
|
||||
with open(actions_path, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
self.assertEqual(manifest.get("skill"), SCAFFOLD_TEST_SLUG)
|
||||
manifest_raw = json.dumps(manifest)
|
||||
self.assertNotIn(TEMPLATE_SLUG_KEBAB, manifest_raw)
|
||||
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, manifest_raw)
|
||||
|
||||
actions_md = os.path.join(dest, "references", "ACTIONS.md")
|
||||
self.assertTrue(os.path.isfile(actions_md), msg="scaffold must copy references/ACTIONS.md")
|
||||
|
||||
schema_path = os.path.join(dest, "assets", "schemas", "skill-actions.schema.json")
|
||||
self.assertTrue(os.path.isfile(schema_path))
|
||||
|
||||
marker = os.path.join(dest, TEMPLATE_MARKER)
|
||||
self.assertFalse(os.path.isfile(marker))
|
||||
self.assertFalse(os.path.isdir(os.path.join(dest, ".git")))
|
||||
|
||||
constants_path = os.path.join(dest, "scripts", "util", "constants.py")
|
||||
with open(constants_path, encoding="utf-8") as f:
|
||||
constants_text = f.read()
|
||||
self.assertNotIn(TEMPLATE_SLUG_KEBAB, constants_text)
|
||||
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, constants_text)
|
||||
self.assertIn(f'SKILL_SLUG = "{SCAFFOLD_TEST_SLUG}"', constants_text)
|
||||
self.assertIn(
|
||||
f'LOG_LOGGER_NAME = "openclaw.skill.{SCAFFOLD_TEST_SLUG_UNDERSCORE}"',
|
||||
constants_text,
|
||||
)
|
||||
|
||||
skill_md_path = os.path.join(dest, "SKILL.md")
|
||||
with open(skill_md_path, encoding="utf-8") as f:
|
||||
skill_md = f.read()
|
||||
self.assertIn(f"slug: {SCAFFOLD_TEST_SLUG}", skill_md)
|
||||
self.assertNotIn(TEMPLATE_SLUG_KEBAB, skill_md)
|
||||
self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, skill_md)
|
||||
|
||||
def test_scaffold_ps1_matches_python_placeholder_rules(self) -> None:
|
||||
slug = _read_skill_slug()
|
||||
if slug != TEMPLATE_SLUG_KEBAB:
|
||||
self.skipTest("仅在 skill-template 源仓库运行 scaffold PS1 文案测试")
|
||||
|
||||
ps1_path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1")
|
||||
with open(ps1_path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
self.assertIn("your-skill-slug", text)
|
||||
self.assertIn("your_skill_slug", text)
|
||||
self.assertIn('.Replace("-", "_")', text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -22,10 +22,27 @@ 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` 标记
|
||||
- 将文本文件中的模板占位 slug 替换为新 slug:
|
||||
- `your-skill-slug` → 新技能 kebab-case slug(如 `scrape-demo-records`)
|
||||
- `your_skill_slug` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等)
|
||||
- 覆盖 `assets/actions.json`、`SKILL.md`、`scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符
|
||||
- **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action
|
||||
- **不**自动 `git init`;打印后续 Git 与测试命令
|
||||
|
||||
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
|
||||
|
||||
## 脚手架复制后必须保留 / 修改 / 可删除的文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 |
|
||||
| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 |
|
||||
| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 |
|
||||
| `tests/test_actions_manifest.py` | 若删除 `actions.json`,应同步删除或改写此测试 |
|
||||
| `tests/test_data_management_contract.py` | 通用契约测试;建议保留,按需扩展本技能表结构测试 |
|
||||
|
||||
`demo_items` 等示例表仅存在于模板测试中,**不会**作为 scaffold 后的默认业务表复制进新技能。
|
||||
|
||||
## 手工复制后的补救
|
||||
|
||||
若已整目录复制,必须先清理再初始化:
|
||||
|
||||
@@ -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,9 @@ if ($targetExists) {
|
||||
|
||||
$excludeDirs = @(".git", ".pytest_cache", "__pycache__")
|
||||
$excludeFiles = @(".env", ".env.local")
|
||||
$templatePlaceholderSlugKebab = "your-skill-slug"
|
||||
$templatePlaceholderSlugUnderscore = "your_skill_slug"
|
||||
$textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample")
|
||||
|
||||
function Copy-TemplateTree {
|
||||
param(
|
||||
@@ -96,6 +100,33 @@ function Copy-TemplateTree {
|
||||
}
|
||||
}
|
||||
|
||||
function Replace-TemplatePlaceholders {
|
||||
param(
|
||||
[string]$Root,
|
||||
[string]$Slug
|
||||
)
|
||||
$underscoreSlug = $Slug.Replace("-", "_")
|
||||
Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object {
|
||||
$name = $_.Name
|
||||
if ($excludeFiles -contains $name) { return }
|
||||
$ext = $_.Extension.ToLowerInvariant()
|
||||
if ($textFileSuffixes -notcontains $ext -and $name -ne ".env.example") { return }
|
||||
try {
|
||||
$text = [System.IO.File]::ReadAllText($_.FullName)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
if (
|
||||
$text -notlike "*$templatePlaceholderSlugKebab*" -and
|
||||
$text -notlike "*$templatePlaceholderSlugUnderscore*"
|
||||
) { return }
|
||||
$updated = $text.Replace($templatePlaceholderSlugKebab, $Slug)
|
||||
$updated = $updated.Replace($templatePlaceholderSlugUnderscore, $underscoreSlug)
|
||||
[System.IO.File]::WriteAllText($_.FullName, $updated)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Copy template: $TemplateRoot -> $TargetPath" -ForegroundColor Cyan
|
||||
Copy-TemplateTree -Source $TemplateRoot -Dest $TargetPath
|
||||
|
||||
@@ -104,4 +135,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
|
||||
|
||||
@@ -11,6 +11,20 @@ from pathlib import Path
|
||||
EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"}
|
||||
EXCLUDE_FILE_NAMES = {".env", ".env.local"}
|
||||
KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
TEMPLATE_PLACEHOLDER_SLUG_KEBAB = "your-skill-slug"
|
||||
TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE = "your_skill_slug"
|
||||
TEXT_FILE_SUFFIXES = {
|
||||
".md",
|
||||
".py",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".txt",
|
||||
".ini",
|
||||
".ps1",
|
||||
".sample",
|
||||
".env.example",
|
||||
}
|
||||
|
||||
|
||||
def _ignore_copy(_dir: str, names: list[str]) -> set[str]:
|
||||
@@ -23,6 +37,44 @@ 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 _slug_to_underscore(slug: str) -> str:
|
||||
return slug.replace("-", "_")
|
||||
|
||||
|
||||
def _replace_template_placeholders(target: Path, slug: str) -> None:
|
||||
"""将模板占位 slug 替换为新技能 slug(不修改 action 业务命令结构)。"""
|
||||
underscore_slug = _slug_to_underscore(slug)
|
||||
for path in target.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name in EXCLUDE_FILE_NAMES:
|
||||
continue
|
||||
if path.suffix.lower() not in TEXT_FILE_SUFFIXES and path.name != ".env.example":
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if (
|
||||
TEMPLATE_PLACEHOLDER_SLUG_KEBAB not in text
|
||||
and TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE not in text
|
||||
):
|
||||
continue
|
||||
updated = text.replace(TEMPLATE_PLACEHOLDER_SLUG_KEBAB, slug)
|
||||
updated = updated.replace(TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE, underscore_slug)
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
|
||||
|
||||
def _print_next_steps(target: Path) -> None:
|
||||
print()
|
||||
print("=== 脚手架复制完成 ===")
|
||||
@@ -34,6 +86,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 +151,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user