fix: harden skill template contracts and scaffolding
All checks were successful
技能自动化发布 / release (push) Successful in 4s

Align scaffold slug replacement, Action manifest strict boundaries, metadata prune API, field permission validation, and task_logs readonly semantics with documented template contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 11:55:46 +08:00
parent 35c20fc0f2
commit 766d162245
17 changed files with 536 additions and 72 deletions

View File

@@ -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。
## 平台元数据

View File

@@ -1,8 +1,10 @@
# assets
- `actions.json`Skill Action manifest 最小示例(可选;不需要宿主入口时可删除)。
- `actions.json`Skill Action manifest 最小示例(**可选**;不需要宿主入口时可删除)。
- `examples/`CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema`skill-actions.schema.json``task-log-record.schema.json` 等)。
- `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/)(含 [`ACTIONS.md`](../references/ACTIONS.md)

View File

@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
"title": "SkillActionManifest",
"description": "匠厂宿主 Skill Action manifest 通用契约schemaVersion 1",
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范schemaVersion 1。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。",
"type": "object",
"required": ["schemaVersion", "skill", "actions"],
"additionalProperties": false,
@@ -25,7 +25,8 @@
"$defs": {
"placement": {
"type": "string",
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"]
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
"description": "模板 Phase 1 稳定支持 toolbar/cron/agent/skill-detailrow/batch 为预留入口,新技能示例不得使用"
},
"scalarArg": {
"type": ["string", "number", "boolean"]
@@ -37,7 +38,8 @@
"properties": {
"type": {
"type": "string",
"enum": ["string", "number", "boolean", "object"]
"enum": ["string", "number", "boolean", "object"],
"description": "模板 Phase 1 仅推荐 string/number/boolean根 inputSchema.type=object 的 properties 使用基础标量类型"
},
"title": { "type": "string" },
"description": { "type": "string" },
@@ -77,7 +79,8 @@
},
"args": {
"type": "array",
"items": { "$ref": "#/$defs/scalarArg" }
"items": { "$ref": "#/$defs/scalarArg" },
"description": "仅允许 string/number/boolean 标量数组"
}
}
},
@@ -118,7 +121,8 @@
"entrypoint": { "$ref": "#/$defs/entrypoint" },
"inputSchema": { "$ref": "#/$defs/inputSchema" },
"confirmation": { "$ref": "#/$defs/confirmation" }
}
},
"description": "模板 Phase 1 不包含 bind/concurrency/locks 等预留能力字段;宿主类型可能定义这些字段,但新技能不得依赖"
}
}
}

View File

@@ -516,7 +516,7 @@ metadata:
- 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` 替换为新 slugaction 业务命令由技能作者自行调整
- 复制为新技能后scaffold 会自动将 `your-skill-slug` 与 `your_skill_slug` 替换为新 slug后者用于 `LOG_LOGGER_NAME` 等下划线形式)action 业务命令由技能作者自行调整
宿主执行方式:

View File

@@ -4,6 +4,17 @@
**边界:** 用户市场说明见根目录 [`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 是**可选能力**
@@ -33,6 +44,29 @@ Action 是**可选能力**
模板最小示例见 [`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再执行

View File

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

View File

@@ -236,6 +236,16 @@ readonly = 0
### 模板默认:`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
@@ -258,17 +268,17 @@ CREATE TABLE IF NOT EXISTS task_logs (
| 物理字段 | 中文名 | editable |
|----------|--------|----------|
| id | 编号 | 0 |
| task_type | 任务类型 | 1 |
| target_id | 目标编号 | 1 |
| input_id | 输入编号 | 1 |
| input_title | 输入标题 | 1 |
| status | 状态 | 1 |
| error_msg | 错误信息 | 1 |
| result_summary | 结果摘要 | 1 |
| 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 = 0`
表中文名:**任务日志**;表级 `readonly = 1`(宿主只读;技能 repository 仍可写入)
### 通用示例:`demo_items`(测试 fixture 参考)
@@ -325,8 +335,41 @@ CREATE TABLE demo_items (
实现 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**——模板不会猜测真实物理字段,避免误删;文档与实现均以此为准。
不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。
## 中文命名质量
- 使用普通用户能理解的业务用语:`task_logs`**任务日志**`error_msg`**错误信息**

View File

@@ -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": "创建时间",
@@ -157,27 +157,22 @@ def seed_task_logs_display_metadata(cursor) -> None:
visible=int(meta["visible"]),
readonly=int(meta["readonly"]),
)
for column in TASK_LOGS_COLUMN_METADATA:
upsert_column_metadata(
cursor,
table_name=TASK_LOGS_TABLE,
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"]),
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,
)
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: tuple[dict[str, object], ...],
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,
@@ -191,6 +186,8 @@ def seed_column_metadata_batch(
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:

View File

@@ -161,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
@@ -202,6 +229,7 @@ def validate_column_permissions(
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(
"""
@@ -216,13 +244,13 @@ def validate_column_permissions(
for row in cursor.fetchall()
}
for column_name in ordered:
meta = meta_by_column.get(column_name)
if not meta:
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
physical = column_info.get(column_name) or {}
pk = int(physical.get("pk") or 0)
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")
@@ -230,6 +258,12 @@ def validate_column_permissions(
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}")

View File

@@ -15,6 +15,9 @@ 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",
"微信",
@@ -57,6 +60,12 @@ def _collect_template_vars(args: list) -> set[str]:
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()
@@ -181,6 +190,48 @@ class TestActionsManifest(unittest.TestCase):
self.assertEqual(schema.get("title"), "SkillActionManifest")
self.assertIn("actions", schema.get("properties", {}))
def test_skill_actions_schema_json_parses(self) -> None:
schema = _load_actions_schema()
self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1)
self.assertTrue(schema.get("additionalProperties") is False)
self.assertIn("action", schema.get("$defs", {}))
def test_template_manifest_matches_schema_required_keys(self) -> None:
manifest = _load_actions_manifest()
schema = _load_actions_schema()
for key in _REQUIRED_MANIFEST_KEYS:
self.assertIn(key, manifest)
self.assertIn(key, schema.get("properties", {}))
for action in manifest["actions"]:
for key in _REQUIRED_ACTION_KEYS:
self.assertIn(key, action)
def test_template_example_does_not_use_reserved_action_fields(self) -> None:
manifest = _load_actions_manifest()
for action in manifest["actions"]:
overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS
self.assertFalse(
overlap,
msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}",
)
def test_actions_md_documents_strict_vs_host_boundary(self) -> None:
path = os.path.join(get_skill_root(), "references", "ACTIONS.md")
with open(path, encoding="utf-8") as f:
text = f.read()
for marker in (
"严格规范",
"向后兼容",
"Phase 1",
"bind",
"concurrency",
"locks",
"row",
"batch",
"additionalProperties",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
if __name__ == "__main__":
unittest.main()

View File

@@ -75,11 +75,10 @@ DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
)
def _init_demo_items_fixture(conn: sqlite3.Connection) -> None:
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,
prune_stale_column_metadata,
seed_column_metadata_batch,
upsert_table_metadata,
)
@@ -96,9 +95,13 @@ def _init_demo_items_fixture(conn: sqlite3.Connection) -> None:
visible=int(meta["visible"]),
readonly=int(meta["readonly"]),
)
seed_column_metadata_batch(cur, table_name="demo_items", columns=DEMO_ITEMS_COLUMN_METADATA)
physical = list_physical_column_names(cur, "demo_items")
prune_stale_column_metadata(cur, "demo_items", physical)
seed_column_metadata_batch(
cur,
table_name="demo_items",
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=physical if prune else None,
)
conn.commit()
@@ -371,11 +374,16 @@ class TestDataManagementContract(unittest.TestCase):
"""
)
conn.commit()
from db.display_metadata import list_physical_column_names, prune_stale_column_metadata
from db.display_metadata import list_physical_column_names, seed_column_metadata_batch
cur = conn.cursor()
physical = list_physical_column_names(cur, "demo_items")
prune_stale_column_metadata(cur, "demo_items", physical)
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'"
@@ -384,6 +392,77 @@ class TestDataManagementContract(unittest.TestCase):
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:

View File

@@ -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()

View File

@@ -242,6 +242,12 @@ class TestDocsStandards(unittest.TestCase):
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")

View File

@@ -10,8 +10,10 @@ 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:
@@ -27,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(
@@ -37,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(
@@ -64,7 +66,7 @@ class TestScaffoldGuard(unittest.TestCase):
def test_scaffold_replaces_slug_and_copies_actions_manifest(self) -> None:
slug = _read_skill_slug()
if slug != TEMPLATE_SLUG:
if slug != TEMPLATE_SLUG_KEBAB:
self.skipTest("仅在 skill-template 源仓库运行 scaffold 集成测试")
import subprocess
@@ -94,7 +96,9 @@ class TestScaffoldGuard(unittest.TestCase):
with open(actions_path, encoding="utf-8") as f:
manifest = json.load(f)
self.assertEqual(manifest.get("skill"), SCAFFOLD_TEST_SLUG)
self.assertNotIn(TEMPLATE_SLUG, json.dumps(manifest))
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")
@@ -109,7 +113,32 @@ class TestScaffoldGuard(unittest.TestCase):
constants_path = os.path.join(dest, "scripts", "util", "constants.py")
with open(constants_path, encoding="utf-8") as f:
constants_text = f.read()
self.assertNotIn(TEMPLATE_SLUG, constants_text)
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__":

View File

@@ -22,7 +22,10 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
- 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致)
- **排除**`.git``.pytest_cache``__pycache__``.env``.env.local``*.pyc`
- 删除目标中的 `.openclaw-skill-template` 标记
- 将文本文件中的 `your-skill-slug` 替换为新 slug(含 `assets/actions.json``SKILL.md``constants.py` 等)
- 将文本文件中的模板占位 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 与测试命令

View File

@@ -71,7 +71,8 @@ if ($targetExists) {
$excludeDirs = @(".git", ".pytest_cache", "__pycache__")
$excludeFiles = @(".env", ".env.local")
$templatePlaceholderSlug = "your-skill-slug"
$templatePlaceholderSlugKebab = "your-skill-slug"
$templatePlaceholderSlugUnderscore = "your_skill_slug"
$textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample")
function Copy-TemplateTree {
@@ -104,6 +105,7 @@ function Replace-TemplatePlaceholders {
[string]$Root,
[string]$Slug
)
$underscoreSlug = $Slug.Replace("-", "_")
Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object {
$name = $_.Name
if ($excludeFiles -contains $name) { return }
@@ -115,8 +117,12 @@ function Replace-TemplatePlaceholders {
catch {
return
}
if ($text -notlike "*$templatePlaceholderSlug*") { return }
$updated = $text.Replace($templatePlaceholderSlug, $Slug)
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)
}
}

View File

@@ -11,7 +11,8 @@ from pathlib import Path
EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"}
EXCLUDE_FILE_NAMES = {".env", ".env.local"}
KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
TEMPLATE_PLACEHOLDER_SLUG_KEBAB = "your-skill-slug"
TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE = "your_skill_slug"
TEXT_FILE_SUFFIXES = {
".md",
".py",
@@ -46,8 +47,13 @@ def _should_replace_placeholders(path: Path) -> bool:
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
@@ -59,9 +65,14 @@ def _replace_template_placeholders(target: Path, slug: str) -> None:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if TEMPLATE_PLACEHOLDER_SLUG not in text:
if (
TEMPLATE_PLACEHOLDER_SLUG_KEBAB not in text
and TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE not in text
):
continue
path.write_text(text.replace(TEMPLATE_PLACEHOLDER_SLUG, slug), encoding="utf-8")
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: