Release v1.0.114: add sync.ps1 so new skills can mirror plaintext locally
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
2026-09-24 10:06:34 +08:00
parent a8403ce9b4
commit e32d04ec50
9 changed files with 717 additions and 3 deletions

View File

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

View File

@@ -1,7 +1,7 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.113
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

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.113"
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

@@ -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 时可保留 |