Compare commits

...

2 Commits

Author SHA1 Message Date
e32d04ec50 Release v1.0.114: add sync.ps1 so new skills can mirror plaintext locally
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-09-24 10:06:34 +08:00
a8403ce9b4 Release v1.0.113: document default_sort may use hidden columns
All checks were successful
技能自动化发布 / release (push) Successful in 13s
2026-09-23 10:14:20 +08:00
14 changed files with 896 additions and 4 deletions

View File

@@ -9,6 +9,16 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.114
- 技能根目录增加 `sync.ps1`,和 `release.ps1` 并排。在本机把明文同步到已安装的技能目录,用来在宿主里验收
- 市场上的最新版本和加密包仍走 `release.ps1`。同步后退出资料管理再打开,不要点技能市场的「更新」
## 1.0.113
- 固化数据管理契约:`visible=0` 只藏界面;`default_sort_json` 仍可引用隐藏的非敏感物理列(含主键)
- 元数据校验:排序列必须真实存在;引用隐藏列仅提示、不报错
## 1.0.112
- 新技能的名字不必再以平台或 `default` 结尾。没有抖音、小红书、亚马逊这类外部平台时,用动词加名词即可

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.112
version: 1.0.114
author: 深圳匠厂科技有限公司
metadata:
openclaw:

View File

@@ -192,6 +192,7 @@ checklist_version: 1.0
- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免)
- [ ] A9-3 **P0** **NEED_MANUAL** 入口定位表中定为「挂」的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过;未在定位表中规划且未声明的入口可标 N/A(须引用定位表理由),**禁止**用「未声明」掩盖「从未做入口定位」
- [ ] A9-4 **P0** 对外版本一致(POLICY-RELEASE-001):`SKILL.md` version = `SKILL_VERSION` = 发布 git tag(无前缀);`release.ps1` 打 tag 前会硬校验,漏改不得发布;CHANGELOG 有对应小节
- [ ] A9-4b **P1** 本机宿主验收用根目录 `.\sync.ps1` 覆盖已安装明文;市场上的最新版本和加密包仍走 `.\release.ps1`。同步后退出资料管理再打开,不要点技能市场「更新」
- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
---

View File

@@ -74,6 +74,7 @@ your-skill/
├─ CHANGELOG.md
├─ requirements.txt
├─ release.ps1
├─ sync.ps1
└─ SKILL.md
```
@@ -85,6 +86,8 @@ your-skill/
- `DEMO.md`:市场 **演示** Tab(`demo_video_url` + `demo_md`)
- `CHANGELOG.md`:市场 **更新日志** Tab(按版本小节同步 `changelog`)
- `SKILL.md`:面向 LLM / 平台的技能入口
- `release.ps1`:发布到服务器(提交、tag、CI 加密包)
- `sync.ps1`:把明文同步到本机宿主,供界面验收
- `references/`:Agent 运行/编排/调用时渐进加载的补充上下文(如 CLI、SCHEMA)
- `development/`:面向开发者与 AI 编程代理的开发资料、需求、测试与技术规范
- `scripts/`:放真正的代码
@@ -173,6 +176,7 @@ scripts/
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.11`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
- 根目录 `sync.ps1` 是开发者手动执行的本机同步,允许用 `uv pip install` 把本技能 `requirements.txt` 装进宿主共享 venv。业务代码和其它脚本仍然不要自己装包。
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
- `pkg>=1.2.0,<2`
- 对**原生扩展 / 高风险依赖**(如 `chromadb`、`onnxruntime`)建议 pin 或窄范围,避免无上限:
@@ -747,6 +751,33 @@ python scripts/main.py <your-command>
详见 `TESTING.md`。
## 14.6 同步到本机宿主
`sync.ps1` 和 `release.ps1` 并排,都在技能根目录执行。`release.ps1` 发布到服务器并触发加密打包;`sync.ps1` 把当前明文覆盖到本机已安装的技能目录,用来在宿主里验收,不必每次都走服务器构建。
在技能根目录:
```powershell
.\sync.ps1
.\sync.ps1 -DryRun
.\sync.ps1 -SkipDeps
.\sync.ps1 -ForceDeps
.\sync.ps1 -SkipInitDb
```
脚本会:
- 按 `SKILL.md` 的 slug 镜像到 `%USERPROFILE%\.openclaw\skills\<slug>`,排除 `.git` 等开发目录
- 去掉安装目录里残留的 `pyarmor_runtime_*`
- 把 `requirements.txt` 装进宿主共享 venv(与宿主安装技能同一条路径)。依赖未变时可跳过;`-SkipDeps` 只跳过安装,仍会跑 `init-db`
- 用宿主 `settings.json` 里的当前登录用户跑 `scripts\main.py init-db`。没有登录用户时停止,不会猜用户 ID
同步完成后:先停掉该技能正在跑的任务,退出资料管理再打开。不要点技能市场的「更新」,那会用加密包装掉这份明文。
技能至少要从市场安装过一次,宿主才会启用它。`sync.ps1` 覆盖的是已经装上的那一份。市场上的「最新版本」仍只在 `release.ps1` 的 CI 成功后变化;本机「当前版本」来自这次同步写进去的 `SKILL.md`。
`sync.ps1` 须为 UTF-8 无 BOM,且不要写中文:Windows PowerShell 5.1 会把无 BOM 的中文脚本解析错。
## 15. 发布到正式环境验证
### release workflow 通道

View File

@@ -9,7 +9,7 @@
| POLICY-RUNTIME-002 | `requirements.txt` 非注释依赖行不得声明 `jiangchang-platform-kit` / `playwright` | development/RUNTIME.md §共享 Python Runtime;development/DEVELOPMENT.md §3.1 requirements.txt 依赖规范 | hard | 解析依赖行 | `tests/test_development_policy_guard.py::TestPolicyRuntime002`(另见 `tests/test_platform_import.py`) |
| POLICY-RUNTIME-003 | Agent/CLI 文档须要求共享 Python(python-runtime / PATH 上的 `python` + `{baseDir}`);须明确**禁止**对技能脚本使用 `uv run python` | development/RUNTIME.md;references/CLI.md;SKILL.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_cli_md_requires_shared_python_not_uv_run`;`tests/test_docs_standards.py::test_skill_md_requires_shared_python_not_uv_run` |
| POLICY-AGENT-001 | `SKILL.md` 须含「用户意图路由表」结构;`CLI.md` 须含短 CLI 白名单 / Action 强制黑名单表;用法回答须禁止无故粘贴 CLI、禁止为答用法列目录挖 references | SKILL.md;references/CLI.md | hard | 文档标记扫描 | `tests/test_docs_standards.py::test_skill_md_has_intent_routing_table`;`tests/test_docs_standards.py::test_cli_md_has_agent_routing_tables`;`tests/test_docs_standards.py::test_skill_md_how_to_forbids_cli_paste` |
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`) | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
| POLICY-INSTALL-001 | 交付执行文件不得含 `pip install` / `python -m pip install` / `uv pip install` / `playwright install` | development/RUNTIME.md §共享 Python Runtime;development/RPA.md §1.4 Playwright 与安装边界;development/DEVELOPMENT.md §3.1 | hard | 扫描 `scripts/**/*.py`、`*.ps1`、`*.sh`、`requirements.txt`、`.github/workflows/*`(不扫 `development/*.md`;根目录 `sync.ps1` 除外,它是开发者手动执行的本机同步,用 `uv pip install` 装进宿主共享 venv) | `tests/test_development_policy_guard.py::TestPolicyInstall001` |
| POLICY-CONFIG-001 | 仓库根目录必须存在 `.env.example` | development/CONFIG.md §核心原则、§标准配置项 | hard | 文件存在性 | `tests/test_development_policy_guard.py::TestPolicyConfig001` |
| POLICY-CONFIG-002 | `.gitignore` 必须忽略 `.env` 与 `*.env.local` | development/CONFIG.md §红线:敏感信息不进 .env | hard | 解析 `.gitignore` 行 | `tests/test_development_policy_guard.py::TestPolicyConfig002` |
| POLICY-CONFIG-003 | 业务代码不得直接 `os.environ` / `os.getenv` / `os.putenv` / `environ.get`;须经 `jiangchang_skill_core.config.get*`(允许 `main.py`、测试、config_bootstrap/runtime 边界文件) | development/CONFIG.md §配置 bootstrap;development/ADAPTER.md §档位 dispatch | hard | 扫描 `scripts/**/*.py` 并排除边界文件 | `tests/test_development_policy_guard.py::TestPolicyConfig003` |

View File

@@ -776,6 +776,7 @@ HINT: <next step>
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
- 把「默认展示列」与 `visible=0` 硬隐藏混淆——默认展示列见 [`SCHEMA.md`](SCHEMA.md) 的 `default_visible_columns_json`。
- 误以为 `visible=0` 的列不能出现在 `default_sort_json`——隐藏只影响展示/筛选/搜索;ORDER BY 仍可用非敏感物理列(含隐藏主键)。
- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。
### Action 类型 × placements(常见示例,不是限制)

View File

@@ -180,11 +180,36 @@ JSON 数组(可多字段,按顺序):
规则:
- `column` 必须是该表真实列名;敏感列会被宿主忽略
- `column` 必须是该表**真实物理列名**(存在于 SQLite 表);不存在则宿主忽略该段或技能校验报错
- **可以**引用 `visible=0` 的列(含隐藏的主键 `id`)。`visible=0` 只表示界面不展示 / 不进默认 SELECT·筛选·搜索;**不表示不能 ORDER BY**
- 敏感列(宿主标为 sensitive)不得用于排序;技能侧勿把密钥类字段写进 `default_sort_json`
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
与 `visible` 的分工(勿再踩坑):
| 能力 | `visible=0` 是否仍可用 |
|------|------------------------|
| 表格列展示 / 「列」面板 | 否(硬隐藏) |
| 默认 SELECT、列筛选、顶栏搜索 | 否 |
| **`default_sort_json` / ORDER BY** | **是**(非敏感列) |
| 行主键定位(`$row.$pk` 等) | 是(主键即使隐藏仍应可排序、可定位;宿主查询层保留) |
示例(编号硬隐藏,但仍按编号排序——合法):
```python
upsert_table_metadata(
cursor,
table_name="period_rule_overrides",
display_name="本月默认金额",
readonly=1,
default_sort_json='[{"column":"period","direction":"desc"},{"column":"id","direction":"asc"}]',
)
# id 列元数据可见性:
# upsert_column_metadata(..., column_name="id", visible=0, ...)
```
示例(消息时间线):
```python
@@ -277,6 +302,8 @@ upsert_column_metadata(
- 只写英文物理名而不写中文 `display_name`
- 让元数据引用不存在的表
- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来)
- 误以为「列 `visible=0` 就不能写进 `default_sort_json`」——排序与展示已拆开;隐藏编号/主键仍可排序(须列真实存在且非敏感)
- 把不存在的列名写进 `default_sort_json`(技能校验应报错;宿主也会忽略无效段)
### `_jiangchang_columns` 字段级标准

View File

@@ -103,6 +103,7 @@ def upsert_table_metadata(
default_sort_json: 宿主数据管理默认排序,JSON 数组,例如
'[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]'
为空则宿主回退到 updated_at → created_at → 主键。
排序列须为真实物理列;**允许**引用 visible=0 的非敏感列(隐藏≠不能 ORDER BY)。
default_visible_columns_json: 宿主数据管理默认展示列,JSON 字符串数组,例如
'["stat_type","period","stage"]'

View File

@@ -279,6 +279,95 @@ def validate_filter_search_metadata(
return [], warnings
def parse_default_sort_json(raw: object) -> tuple[list[dict[str, str]], list[str]]:
"""解析 default_sort_json;返回 (specs, errors)。specs 项含 column / direction。"""
errors: list[str] = []
if raw is None or str(raw).strip() == "":
return [], []
text = str(raw).strip()
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return [], ["default_sort_json 不是合法 JSON"]
if not isinstance(parsed, list):
return [], ["default_sort_json 必须是 JSON 数组"]
out: list[dict[str, str]] = []
for index, item in enumerate(parsed):
if not isinstance(item, dict):
errors.append(f"default_sort_json[{index}] 必须是对象")
continue
column = str(
item.get("column")
or item.get("columnName")
or item.get("column_name")
or ""
).strip()
direction_raw = str(item.get("direction") or "desc").strip().lower()
if not column:
errors.append(f"default_sort_json[{index}] 缺少 column")
continue
if direction_raw not in {"asc", "desc"}:
errors.append(f"default_sort_json[{index}].direction 只能是 asc 或 desc")
continue
out.append({"column": column, "direction": direction_raw})
return out, errors
def validate_default_sort_metadata(
cursor,
table_name: str,
*,
physical_columns: list[str] | None = None,
) -> tuple[list[str], list[str]]:
"""校验表级 default_sort_json:列须真实存在;允许 visible=0;不存在则报错。
契约:visible=0 只藏界面,ORDER BY 仍可用非敏感物理列(含隐藏主键)。
"""
errors: list[str] = []
warnings: list[str] = []
table_cols = {
str(row[1]) for row in cursor.execute("PRAGMA table_info(_jiangchang_tables)").fetchall()
}
if "default_sort_json" not in table_cols:
return [], []
cursor.execute(
"SELECT default_sort_json FROM _jiangchang_tables WHERE table_name = ?",
(table_name,),
)
row = cursor.fetchone()
if not row:
return [], []
specs, parse_errors = parse_default_sort_json(row[0])
for msg in parse_errors:
errors.append(f"{table_name}: {msg}")
if parse_errors:
return errors, warnings
physical = set(physical_columns or list_physical_column_names(cursor, table_name))
for spec in specs:
column = spec["column"]
if column not in physical:
errors.append(
f"{table_name}: default_sort_json 引用了不存在的列 {column!r}"
)
continue
# visible=0 合法:不报错。仅提示契约,避免作者误删排序列。
cursor.execute(
"""
SELECT visible FROM _jiangchang_columns
WHERE table_name = ? AND column_name = ?
""",
(table_name, column),
)
meta = cursor.fetchone()
if meta is not None and meta[0] is not None and int(meta[0]) == 0:
warnings.append(
f"{table_name}.{column}: default_sort_json 引用了 visible=0 列"
"(合法:隐藏列仍可 ORDER BY;见 references/SCHEMA.md)"
)
return errors, warnings
def validate_column_permissions(
cursor,
table_name: str,
@@ -558,6 +647,14 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
_, filter_warnings = validate_filter_search_metadata(cur, table_name)
report.warnings.extend(filter_warnings)
sort_errors, sort_warnings = validate_default_sort_metadata(
cur,
table_name,
physical_columns=physical_columns,
)
report.errors.extend(sort_errors)
report.warnings.extend(sort_warnings)
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
for table_name, column_name in cur.fetchall():
if table_name in METADATA_TABLES:

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.112"
SKILL_VERSION = "1.0.114"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.11"

668
sync.ps1 Normal file
View File

@@ -0,0 +1,668 @@
<#
.SYNOPSIS
Sync this skill's plaintext source into the local OpenClaw skills directory.
.DESCRIPTION
Local development only. Does not commit, tag, push, or trigger Gitea CI.
release.ps1 remains the formal publish path.
Ships in skill-template beside release.ps1. Scaffold copies this file into each new skill.
Mirrors this repo onto ~/.openclaw/skills/<slug> and removes marketplace
pyarmor_runtime_* leftovers. Stop any running job for this skill first;
otherwise the copy can fail because files are in use.
Dependencies follow the host skill:direct-install path:
uv pip install --python <JIANGCHANG_DATA_ROOT>/python-runtime/.venv/Scripts/python.exe -r requirements.txt
Packages are not installed into system Python. Unchanged requirements that
are already present in the shared venv are skipped.
platform_kit_min_version is provided by jiangchang-platform-kit in the shared
runtime. This script only checks it. It does not upgrade the kit. If the
installed kit is too old, install or update the skill once from the market,
or upgrade the host.
After the copy, this script runs scripts\main.py init-db the same way the
host does after a market install. The user id is read from
%APPDATA%\jiangchang\settings.json (jiangchangUserId), which the host writes
on login and logout. It is never hardcoded. An empty id or _anon stops the
script so the anonymous data folder is not initialized by mistake.
-SkipInitDb skips that step. -SkipDeps still runs init-db.
No Chinese literals in this file: the skill repo requires UTF-8 without BOM,
and Windows PowerShell 5.1 would mis-parse a no-BOM UTF-8 script.
.EXAMPLE
.\sync.ps1
.\sync.ps1 -DryRun
.\sync.ps1 -ForceDeps
.\sync.ps1 -SkipDeps
.\sync.ps1 -SkipInitDb
.\sync.ps1 -DataRoot D:\jiangchang-data
#>
[CmdletBinding()]
param(
[string]$Destination,
[string]$DataRoot,
[switch]$SkipDeps,
[switch]$ForceDeps,
[switch]$SkipInitDb,
[switch]$DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$SourceRoot = $PSScriptRoot
$PypiMirrors = @(
@{ Name = "aliyun"; Url = "https://mirrors.aliyun.com/pypi/simple/" },
@{ Name = "tuna"; Url = "https://pypi.tuna.tsinghua.edu.cn/simple/" },
@{ Name = "tencent"; Url = "https://mirrors.cloud.tencent.com/pypi/simple/" },
@{ Name = "huawei"; Url = "https://repo.huaweicloud.com/repository/pypi/simple/" },
@{ Name = "pypi"; Url = "https://pypi.org/simple/" }
)
function Read-Utf8Text {
param([Parameter(Mandatory = $true)][string]$Path)
return [System.IO.File]::ReadAllText($Path)
}
function Get-SkillFrontmatter {
param([Parameter(Mandatory = $true)][string]$SkillMdPath)
$raw = Read-Utf8Text -Path $SkillMdPath
if ($raw -notmatch '(?s)\A---\s*\r?\n(.*?)\r?\n---') {
throw "SKILL.md is missing frontmatter: $SkillMdPath"
}
return $Matches[1]
}
function Get-FrontmatterScalar {
param(
[Parameter(Mandatory = $true)][string]$Body,
[Parameter(Mandatory = $true)][string]$Key
)
$pattern = '(?m)^\s*' + [regex]::Escape($Key) + ':\s*["'']?([^\s"''#]+)["'']?\s*$'
$match = [regex]::Match($Body, $pattern)
if (-not $match.Success) {
return $null
}
return $match.Groups[1].Value.Trim()
}
function Get-RequirementsLines {
param([Parameter(Mandatory = $true)][string]$Content)
$lines = New-Object System.Collections.Generic.List[string]
foreach ($raw in ($Content -split '\r?\n')) {
$trimmed = $raw.Trim()
if (-not $trimmed -or $trimmed.StartsWith('#')) {
continue
}
$withoutInline = ($trimmed -split '\s+#', 2)[0].Trim()
if ($withoutInline) {
$lines.Add($withoutInline)
}
}
return @($lines)
}
function Get-RequirementsHash {
param([Parameter(Mandatory = $true)][string]$Content)
$normalized = (@(Get-RequirementsLines -Content $Content)) -join "`n"
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
$hash = $sha.ComputeHash($bytes)
} finally {
$sha.Dispose()
}
return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant()
}
function Get-DistributionNames {
param([Parameter(Mandatory = $true)][string[]]$Lines)
$names = New-Object System.Collections.Generic.List[string]
$seen = New-Object 'System.Collections.Generic.HashSet[string]'
foreach ($line in $Lines) {
$trimmed = $line.Trim()
if (-not $trimmed -or $trimmed.StartsWith('#') -or $trimmed.StartsWith('-')) {
continue
}
if ($trimmed -match '^(https?:|git\+|svn\+|hg\+|file:)') {
continue
}
$match = [regex]::Match($trimmed, '^([A-Za-z0-9][A-Za-z0-9._-]*)')
if (-not $match.Success) {
continue
}
$name = $match.Groups[1].Value
$key = $name.ToLowerInvariant().Replace('_', '-')
if ($seen.Add($key)) {
$names.Add($name)
}
}
return @($names)
}
function Resolve-SkillDataRoot {
param([string]$Explicit)
if ($Explicit -and $Explicit.Trim()) {
return $Explicit.Trim().TrimEnd('\')
}
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
if (Test-Path -LiteralPath $settingsPath) {
try {
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
$fromSettings = [string]$settings.jiangchangSkillDataRoot
if ($fromSettings -and $fromSettings.Trim()) {
return $fromSettings.Trim().TrimEnd('\')
}
} catch {
Write-Warning "Could not read $settingsPath. Falling back to the default data root. $($_.Exception.Message)"
}
}
foreach ($candidate in @("D:\jiangchang-data", (Join-Path $env:USERPROFILE ".jiangchang-data"))) {
$python = Join-Path $candidate "python-runtime\.venv\Scripts\python.exe"
if (Test-Path -LiteralPath $python) {
return $candidate
}
}
return "D:\jiangchang-data"
}
function Resolve-JiangchangUserId {
$settingsPath = Join-Path $env:APPDATA "jiangchang\settings.json"
if (-not (Test-Path -LiteralPath $settingsPath)) {
throw "Jiangchang settings were not found: $settingsPath`nOpen Jiangchang and log in, then run this script again."
}
try {
$settings = Read-Utf8Text -Path $settingsPath | ConvertFrom-Json
} catch {
throw "Could not read the logged-in user from $settingsPath. $($_.Exception.Message)"
}
$userId = ""
if ($null -ne $settings -and $settings.PSObject.Properties.Name -contains "jiangchangUserId" -and $null -ne $settings.jiangchangUserId) {
$userId = ([string]$settings.jiangchangUserId).Trim()
}
if (-not $userId -or $userId -eq "_anon") {
throw "Jiangchang has no logged-in user. Open Jiangchang and log in, then run this script again. This script does not guess a user id."
}
$normalized = $userId.Replace("\", "/")
if ($normalized.Contains("..") -or $normalized.Contains("/") -or $userId.StartsWith("\") -or $userId -match '^[A-Za-z]:') {
throw "jiangchangUserId is not a single folder name: $userId"
}
return $userId
}
function Test-InitDbJsonOk {
param([Parameter(Mandatory = $true)][string]$Text)
foreach ($line in ($Text -split "`r?`n")) {
$trimmed = $line.Trim()
if (-not $trimmed.StartsWith("{")) {
continue
}
try {
$parsed = $trimmed | ConvertFrom-Json
if ($parsed.ok -eq $true) {
return $true
}
} catch {
continue
}
}
return $false
}
function Test-InitDbUnknownCommand {
param(
[Parameter(Mandatory = $true)][string]$Text,
[Parameter(Mandatory = $true)][int]$ExitCode
)
if ($ExitCode -eq 2) {
return $true
}
$lower = $Text.ToLowerInvariant()
if ($lower.Contains("invalid choice") -or $lower.Contains("unrecognized arguments") -or $lower.Contains("unknown command")) {
return $true
}
if ($lower.Contains("invalid") -and $lower.Contains("init-db")) {
return $true
}
return $false
}
function Invoke-SkillInitDb {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string]$SkillDir,
[Parameter(Mandatory = $true)][string]$DataRoot,
[Parameter(Mandatory = $true)][string]$UserId,
[switch]$DryRun
)
$mainPy = Join-Path $SkillDir "scripts\main.py"
if (-not (Test-Path -LiteralPath $mainPy)) {
Write-Host "No scripts\main.py. Skipping init-db."
return
}
if ($DryRun) {
Write-Host "DryRun: would run init-db" -ForegroundColor Yellow
Write-Host " python $PythonExe"
Write-Host " script $mainPy init-db"
Write-Host " data $DataRoot"
Write-Host " user $UserId"
return
}
if (-not (Test-Path -LiteralPath $PythonExe)) {
throw "Shared Python was not found: $PythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot."
}
Write-Host ">> Initialize the logged-in user's database (init-db)" -ForegroundColor DarkGray
Write-Host " user=$UserId"
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $PythonExe
$psi.WorkingDirectory = $SkillDir
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$psi.Arguments = '"' + $mainPy + '" init-db'
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
$psi.EnvironmentVariables["JIANGCHANG_DATA_ROOT"] = $DataRoot
$psi.EnvironmentVariables["JIANGCHANG_USER_ID"] = $UserId
$psi.EnvironmentVariables["JIANGCHANG_RUN_MODE"] = "cli"
$psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"
$proc = New-Object System.Diagnostics.Process
$proc.StartInfo = $psi
[void]$proc.Start()
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit(60000)) {
try { $proc.Kill() } catch { }
throw "init-db timed out after 60 seconds."
}
$proc.WaitForExit()
$stdout = $stdoutTask.Result
$stderr = $stderrTask.Result
$combined = ($stdout + "`n" + $stderr).Trim()
if ($stdout.Trim()) {
Write-Host $stdout.Trim()
}
if ($stderr.Trim()) {
Write-Host $stderr.Trim()
}
if (Test-InitDbJsonOk -Text $combined) {
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
return
}
if ($proc.ExitCode -eq 0 -and $combined -match '(?m)^OK:\s*INIT_DB\b') {
Write-Host "init-db finished for user $UserId." -ForegroundColor Green
return
}
if (Test-InitDbUnknownCommand -Text $combined -ExitCode $proc.ExitCode) {
Write-Warning "This skill has no init-db command. Database initialization was skipped."
return
}
$detail = $stderr.Trim()
if (-not $detail) {
$detail = $stdout.Trim()
}
if ($detail.Length -gt 500) {
$detail = $detail.Substring($detail.Length - 500)
}
if (-not $detail) {
$detail = "init-db exited with code $($proc.ExitCode)"
}
throw "init-db failed for user ${UserId}: $detail"
}
function Resolve-UvExecutable {
$cmd = Get-Command uv -ErrorAction SilentlyContinue
if ($cmd -and $cmd.Source) {
return $cmd.Source
}
throw "uv was not found on PATH. The host installs skill requirements with uv pip, not pip."
}
function Test-DistributionsPresent {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string[]]$Names
)
if ($Names.Count -eq 0) {
return $false
}
$probe = @'
from importlib.metadata import PackageNotFoundError, version
import sys
missing = []
for n in sys.argv[1:]:
try:
version(n)
except PackageNotFoundError:
missing.append(n)
sys.stderr.write("missing:" + ",".join(missing) if missing else "")
raise SystemExit(1 if missing else 0)
'@
$prev = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $PythonExe -c $probe @Names 2>&1 | Out-Null
return ($LASTEXITCODE -eq 0)
} finally {
$ErrorActionPreference = $prev
}
}
function Get-InstalledDistributionVersion {
param(
[Parameter(Mandatory = $true)][string]$PythonExe,
[Parameter(Mandatory = $true)][string]$Name
)
$prev = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & $PythonExe -c "from importlib.metadata import version, PackageNotFoundError`nimport sys`nname=sys.argv[1]`ntry:`n print(version(name))`nexcept PackageNotFoundError:`n print('')" $Name 2>$null
$code = $LASTEXITCODE
} finally {
$ErrorActionPreference = $prev
}
if ($code -ne 0) {
return $null
}
return ([string]$output).Trim()
}
function Compare-VersionPrefix {
param(
[Parameter(Mandatory = $true)][string]$Installed,
[Parameter(Mandatory = $true)][string]$Required
)
function Convert-ToVersion([string]$Text) {
$core = ($Text -split '[^0-9.]', 2)[0].Trim('.')
$parts = @($core -split '\.' | Where-Object { $_ -ne '' })
while ($parts.Count -lt 3) {
$parts += '0'
}
$take = @($parts | Select-Object -First 3)
return [version]($take -join '.')
}
try {
return (Convert-ToVersion $Installed) -ge (Convert-ToVersion $Required)
} catch {
return $true
}
}
function Invoke-RobocopyMirror {
param(
[Parameter(Mandatory = $true)][string]$Source,
[Parameter(Mandatory = $true)][string]$Dest,
[switch]$ListOnly
)
$excludeDirs = @(
".git", ".github", "__pycache__", ".pytest_cache", "htmlcov",
"dist", "build", ".venv", "_fix_workspace", ".skill-requirements"
)
$excludeFiles = @(".env", "*.env.local", "*.pyc", "*.pyo")
$robocopyArgs = @($Source, $Dest, "/MIR", "/R:2", "/W:1", "/XD") + $excludeDirs + @("/XF") + $excludeFiles
if ($ListOnly) {
$robocopyArgs += "/L"
}
& robocopy.exe @robocopyArgs
$code = $LASTEXITCODE
if ($code -ge 8) {
throw "Copy failed (robocopy exit $code). Stop the running skill job in Task Center, then retry."
}
}
function Remove-PyarmorRuntimes {
param([Parameter(Mandatory = $true)][string]$Dest)
$removed = @()
$dirs = @(Get-ChildItem -LiteralPath $Dest -Recurse -Directory -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "pyarmor_runtime_*" })
foreach ($dir in $dirs) {
Remove-Item -LiteralPath $dir.FullName -Recurse -Force
$removed += $dir.FullName
}
return $removed
}
function Assert-PlainMainPy {
param([Parameter(Mandatory = $true)][string]$MainPy)
if (-not (Test-Path -LiteralPath $MainPy)) {
throw "scripts\main.py is missing after sync: $MainPy"
}
$head = Read-Utf8Text -Path $MainPy
if ($head -match 'pyarmor_runtime_|__pyarmor__') {
throw "scripts\main.py is still PyArmor-encrypted. Re-run .\sync.ps1 so the whole scripts tree is replaced."
}
}
function Write-RequirementsState {
param(
[Parameter(Mandatory = $true)][string]$RuntimeDir,
[Parameter(Mandatory = $true)][string]$Slug,
[Parameter(Mandatory = $true)][string]$RequirementsPath,
[Parameter(Mandatory = $true)][string]$RequirementsHash,
[string]$RuntimeFingerprint
)
$stateDir = Join-Path $RuntimeDir ".skill-requirements"
New-Item -ItemType Directory -Force -Path $stateDir | Out-Null
$state = [ordered]@{
skill = $Slug
requirementsPath = $RequirementsPath
requirementsHash = $RequirementsHash
runtimeFingerprint = $RuntimeFingerprint
installedAt = [DateTime]::UtcNow.ToString("o")
status = "ok"
}
$json = ($state | ConvertTo-Json -Depth 4) + "`n"
$statePath = Join-Path $stateDir "$Slug.json"
$utf8 = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($statePath, $json, $utf8)
}
$skillMd = Join-Path $SourceRoot "SKILL.md"
$sourceMain = Join-Path $SourceRoot "scripts\main.py"
$sourceReq = Join-Path $SourceRoot "requirements.txt"
if (-not (Test-Path -LiteralPath $skillMd)) {
throw "SKILL.md not found: $skillMd"
}
if (-not (Test-Path -LiteralPath $sourceMain)) {
throw "scripts\main.py not found: $sourceMain"
}
$frontmatter = Get-SkillFrontmatter -SkillMdPath $skillMd
$slug = Get-FrontmatterScalar -Body $frontmatter -Key "slug"
$version = Get-FrontmatterScalar -Body $frontmatter -Key "version"
$platformKitMin = Get-FrontmatterScalar -Body $frontmatter -Key "platform_kit_min_version"
if (-not $slug) {
throw "metadata.openclaw.slug in SKILL.md is empty, so the install directory cannot be determined."
}
if (-not $Destination -or -not $Destination.Trim()) {
$Destination = Join-Path $env:USERPROFILE ".openclaw\skills\$slug"
}
$Destination = $Destination.Trim().TrimEnd('\')
$sourceFull = [System.IO.Path]::GetFullPath($SourceRoot)
$destFull = [System.IO.Path]::GetFullPath($Destination)
if ($sourceFull.TrimEnd('\') -eq $destFull.TrimEnd('\')) {
throw "Source and destination are the same directory: $sourceFull"
}
$resolvedDataRoot = Resolve-SkillDataRoot -Explicit $DataRoot
$runtimeDir = Join-Path $resolvedDataRoot "python-runtime"
$pythonExe = Join-Path $runtimeDir ".venv\Scripts\python.exe"
$resolvedUserId = $null
if (-not $SkipInitDb) {
$resolvedUserId = Resolve-JiangchangUserId
}
Write-Host ""
Write-Host "Local skill sync (does not publish to the skill market)" -ForegroundColor Cyan
Write-Host " source $sourceFull"
Write-Host " dest $destFull"
Write-Host " slug $slug"
Write-Host " version $(if ($version) { $version } else { '(none)' })"
Write-Host " data $resolvedDataRoot"
Write-Host " python $pythonExe"
Write-Host " user $(if ($SkipInitDb) { '(init-db skipped)' } else { $resolvedUserId })"
if ($DryRun) {
Write-Host " mode DryRun (no writes)" -ForegroundColor Yellow
}
Write-Host ""
if (-not (Test-Path -LiteralPath $destFull)) {
Write-Warning "Destination does not exist yet. Files can be created, but if this skill was never installed from the market the host may not have enabled it. Install it once, then use this script to overwrite."
}
$configPath = Join-Path $env:USERPROFILE ".openclaw\openclaw.json"
if (Test-Path -LiteralPath $configPath) {
try {
$config = Read-Utf8Text -Path $configPath | ConvertFrom-Json
$entry = $null
if ($config.PSObject.Properties.Name -contains "skills" -and $null -ne $config.skills -and $config.skills.PSObject.Properties.Name -contains "entries" -and $null -ne $config.skills.entries) {
$entryProp = $config.skills.entries.PSObject.Properties[$slug]
if ($entryProp) {
$entry = $entryProp.Value
}
}
if ($null -eq $entry) {
Write-Warning "openclaw.json has no skills.entries.$slug. Install the skill once from the market, or chat and data management may not see it."
} elseif ($entry.PSObject.Properties.Name -contains "enabled" -and $entry.enabled -eq $false) {
Write-Warning "skills.entries.$slug is disabled. Syncing files does not re-enable it."
}
} catch {
Write-Warning "Could not check openclaw.json enablement: $($_.Exception.Message)"
}
}
Write-Host ">> Mirror source onto the install directory (exclude .git, drop encrypted runtime)" -ForegroundColor DarkGray
Invoke-RobocopyMirror -Source $sourceFull -Dest $destFull -ListOnly:$DryRun
if (-not $DryRun) {
$removed = @(Remove-PyarmorRuntimes -Dest $destFull)
foreach ($path in $removed) {
Write-Host " removed $path"
}
Assert-PlainMainPy -MainPy (Join-Path $destFull "scripts\main.py")
Write-Host "scripts\main.py is plaintext." -ForegroundColor Green
} else {
Write-Host "DryRun: pyarmor_runtime_* was not deleted, and no destination file was changed." -ForegroundColor Yellow
}
$skipInstall = $false
if ($SkipDeps) {
Write-Host "Skipped dependency install (-SkipDeps)." -ForegroundColor Yellow
$skipInstall = $true
} elseif (-not (Test-Path -LiteralPath $pythonExe)) {
throw "Shared Python was not found: $pythonExe`nOpen Jiangchang and wait for the shared runtime, or pass -DataRoot. If the copy already finished, install dependencies before running the skill."
} else {
$reqPath = if ($DryRun) { $sourceReq } else { Join-Path $destFull "requirements.txt" }
if (-not (Test-Path -LiteralPath $reqPath)) {
Write-Host "No requirements.txt. Skipping dependency install."
$skipInstall = $true
} else {
$reqContent = Read-Utf8Text -Path $reqPath
$reqLines = @(Get-RequirementsLines -Content $reqContent)
if ($reqLines.Count -eq 0) {
Write-Host "requirements.txt has no effective lines. Skipping install."
$skipInstall = $true
}
}
}
if (-not $skipInstall) {
$reqHash = Get-RequirementsHash -Content $reqContent
$stampPath = Join-Path $runtimeDir ".jiangchang-runtime-stamp.json"
$runtimeFingerprint = ""
if (Test-Path -LiteralPath $stampPath) {
try {
$stamp = Read-Utf8Text -Path $stampPath | ConvertFrom-Json
$runtimeFingerprint = [string]$stamp.fingerprint
} catch {
$runtimeFingerprint = ""
}
}
$statePath = Join-Path $runtimeDir ".skill-requirements\$slug.json"
$previousHash = ""
$previousFingerprint = ""
$previousStatus = ""
if (Test-Path -LiteralPath $statePath) {
try {
$previous = Read-Utf8Text -Path $statePath | ConvertFrom-Json
$previousHash = [string]$previous.requirementsHash
$previousFingerprint = [string]$previous.runtimeFingerprint
$previousStatus = [string]$previous.status
} catch {
$previousStatus = ""
}
}
$distributionNames = @(Get-DistributionNames -Lines $reqLines)
$unchanged = (
-not $ForceDeps -and
$previousStatus -eq "ok" -and
$previousHash -eq $reqHash -and
$runtimeFingerprint -and
$previousFingerprint -eq $runtimeFingerprint
)
if ($unchanged -and $distributionNames.Count -gt 0 -and (Test-DistributionsPresent -PythonExe $pythonExe -Names $distributionNames)) {
Write-Host "Requirements unchanged and already present in the shared environment. Skipping uv pip install." -ForegroundColor Green
} elseif ($DryRun) {
Write-Host "DryRun: would run uv pip install -r requirements.txt" -ForegroundColor Yellow
$distributionNames | ForEach-Object { Write-Host " - $_" }
} else {
$uv = Resolve-UvExecutable
Write-Host ">> Install skill requirements into the shared environment" -ForegroundColor DarkGray
Write-Host " uv=$uv"
$installed = $false
foreach ($mirror in $PypiMirrors) {
Write-Host ">> uv pip install --index-url $($mirror.Url)" -ForegroundColor DarkGray
& $uv pip install --index-url $mirror.Url --python $pythonExe -r $reqPath
if ($LASTEXITCODE -eq 0) {
$installed = $true
break
}
Write-Warning "Mirror $($mirror.Name) failed (exit $LASTEXITCODE). Trying the next one."
}
if (-not $installed) {
throw "requirements.txt install failed. Code is already at $destFull, but the shared environment is missing dependencies."
}
Write-RequirementsState -RuntimeDir $runtimeDir -Slug $slug -RequirementsPath $reqPath -RequirementsHash $reqHash -RuntimeFingerprint $runtimeFingerprint
Write-Host "Dependencies are installed in the shared environment." -ForegroundColor Green
}
}
if ($platformKitMin -and (Test-Path -LiteralPath $pythonExe)) {
$kitVersion = Get-InstalledDistributionVersion -PythonExe $pythonExe -Name "jiangchang-platform-kit"
if (-not $kitVersion) {
Write-Warning "jiangchang-platform-kit is not installed in the shared environment, but this skill requires >= $platformKitMin."
} elseif (-not (Compare-VersionPrefix -Installed $kitVersion -Required $platformKitMin)) {
Write-Warning "jiangchang-platform-kit $kitVersion is below this skill's requirement >= $platformKitMin. This script does not upgrade the platform kit."
} else {
Write-Host "platform kit $kitVersion satisfies >= $platformKitMin." -ForegroundColor Green
}
}
if ($SkipInitDb) {
Write-Host "Skipped init-db (-SkipInitDb). The database was not updated." -ForegroundColor Yellow
} else {
$initSkillDir = $destFull
if ($DryRun -and -not (Test-Path -LiteralPath (Join-Path $destFull "scripts\main.py"))) {
$initSkillDir = $sourceFull
}
Invoke-SkillInitDb -PythonExe $pythonExe -SkillDir $initSkillDir -DataRoot $resolvedDataRoot -UserId $resolvedUserId -DryRun:$DryRun
}
Write-Host ""
Write-Host "Done. Stop any running job, leave Data Management, then open it again." -ForegroundColor Cyan
Write-Host "Do not click Update in the skill market. That replaces this source with the encrypted package." -ForegroundColor Cyan

View File

@@ -406,6 +406,12 @@ def _scan_install_delivery_files(skill_root: str) -> list[str]:
continue
if suffix == ".sh" and not path.endswith(".sh"):
continue
# Root sync.ps1 is the manual local-host mirror. It installs
# requirements into the shared venv the same way the host does.
if os.path.basename(path) == "sync.ps1" and os.path.normcase(
os.path.dirname(os.path.abspath(path))
) == os.path.normcase(os.path.abspath(skill_root)):
continue
text = _read_text(path)
for pattern in INSTALL_FORBIDDEN_PATTERNS:
if pattern.search(text):

View File

@@ -595,6 +595,53 @@ metadata:
self.assertIn("readonly = 1", text)
self.assertIn("seed_column_metadata_batch", text)
self.assertIn("valid_column_names", text)
self.assertIn("可以**引用 `visible=0` 的列", text)
self.assertIn("default_sort_json` / ORDER BY", text)
def test_default_sort_json_allows_hidden_column_and_rejects_missing(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
seed_column_metadata_batch,
upsert_table_metadata,
)
from db.display_metadata_validator import validate_default_sort_metadata
cur = conn.cursor()
init_display_metadata_tables(cur)
cur.execute(
"CREATE TABLE demo_sort (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"
)
upsert_table_metadata(
cur,
table_name="demo_sort",
display_name="排序示例",
default_sort_json='[{"column":"title","direction":"asc"},{"column":"id","direction":"desc"}]',
)
seed_column_metadata_batch(
cur,
table_name="demo_sort",
columns=(
{"column_name": "id", "display_name": "编号", "visible": 0, "editable": 0},
{"column_name": "title", "display_name": "标题", "visible": 1, "editable": 1},
),
valid_column_names=["id", "title"],
)
errors, warnings = validate_default_sort_metadata(cur, "demo_sort")
self.assertEqual(errors, [])
self.assertTrue(any("visible=0" in w and "id" in w for w in warnings))
upsert_table_metadata(
cur,
table_name="demo_sort",
display_name="排序示例",
default_sort_json='[{"column":"missing_col","direction":"asc"}]',
)
errors, _ = validate_default_sort_metadata(cur, "demo_sort")
self.assertTrue(any("missing_col" in e for e in errors))
finally:
conn.close()
def test_default_visible_columns_json_upsert_and_migrate(self) -> None:
conn = sqlite3.connect(":memory:")

View File

@@ -51,6 +51,7 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
- **不**自动生成业务表名,**不**给 `health` / `version` / `config-path` 自动加 `toolbar`
- **不**生成任何具体平台业务 Action
- **不**自动 `git init`;打印后续 Git 与测试命令
- 根目录同时复制 `release.ps1`(发布到服务器)和 `sync.ps1`(同步明文到本机宿主)
目标目录若已存在且非空,需加 `-Force` / `--force`(且目标**不得**含 `.git`)。
@@ -58,6 +59,8 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new
| 文件 | 说明 |
|------|------|
| `release.ps1` | 发布到服务器;保留 |
| `sync.ps1` | 把明文同步到本机宿主;保留。用法见 `development/DEVELOPMENT.md` §14.6 |
| `assets/actions.json` | 默认复制;不需要宿主 Action 入口时可删除 |
| `references/ACTIONS.md` | 契约文档;无 `actions.json` 时可保留作参考或按需精简 |
| `assets/schemas/skill-actions.schema.json` | manifest 规范;无 action 时可保留 |