fix: harden skill template contracts and scaffolding
All checks were successful
技能自动化发布 / release (push) Successful in 4s
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:
2
SKILL.md
2
SKILL.md
@@ -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,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))
|
||||
|
||||
@@ -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-detail;row/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 等预留能力字段;宿主类型可能定义这些字段,但新技能不得依赖"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` 替换为新 slug;action 业务命令由技能作者自行调整
|
||||
- 复制为新技能后,scaffold 会自动将 `your-skill-slug` 与 `your_skill_slug` 替换为新 slug(后者用于 `LOG_LOGGER_NAME` 等下划线形式);action 业务命令由技能作者自行调整
|
||||
|
||||
宿主执行方式:
|
||||
|
||||
|
||||
@@ -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,再执行:
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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` → **错误信息**。
|
||||
|
||||
@@ -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,18 +157,11 @@ 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(
|
||||
seed_column_metadata_batch(
|
||||
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,
|
||||
columns=TASK_LOGS_COLUMN_METADATA,
|
||||
valid_column_names=[str(column["column_name"]) for column in TASK_LOGS_COLUMN_METADATA],
|
||||
)
|
||||
|
||||
|
||||
@@ -176,8 +169,10 @@ 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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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 与测试命令
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user