Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfe6ade2d8 | |||
| e32d04ec50 |
@@ -1,6 +1,7 @@
|
||||
# 运行模式:决定本技能用模拟数据还是真实浏览器/接口去执行
|
||||
OPENCLAW_TEST_TARGET=real_rpa # 可选:mock=本地模拟,simulator_rpa=仿真站点,real_api=真实接口,real_rpa=真实浏览器;模板默认真实浏览器;本地单测/CI 请用 mock
|
||||
|
||||
# @required
|
||||
# 目标网站地址:技能要访问的网站或服务地址
|
||||
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改,一般保持默认即可
|
||||
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -9,6 +9,16 @@
|
||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||
|
||||
## 1.0.115
|
||||
|
||||
- 沉淀匠厂配置管理必填项契约:`.env.example` 在行头上方用独立一行 `# @required` 声明;宿主显示红色 `*` 并在保存时校验非空
|
||||
- 模板示例已为 `TARGET_BASE_URL` 加上示范;新增 `POLICY-CONFIG-005` 门禁错误用法
|
||||
|
||||
## 1.0.114
|
||||
|
||||
- 技能根目录增加 `sync.ps1`,和 `release.ps1` 并排。在本机把明文同步到已安装的技能目录,用来在宿主里验收
|
||||
- 市场上的最新版本和加密包仍走 `release.ps1`。同步后退出资料管理再打开,不要点技能市场的「更新」
|
||||
|
||||
## 1.0.113
|
||||
|
||||
- 固化数据管理契约:`visible=0` 只藏界面;`default_sort_json` 仍可引用隐藏的非敏感物理列(含主键)
|
||||
|
||||
2
SKILL.md
2
SKILL.md
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: 技能开发模板(通用业务版)
|
||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||
version: 1.0.113
|
||||
version: 1.0.115
|
||||
author: 深圳匠厂科技有限公司
|
||||
metadata:
|
||||
openclaw:
|
||||
|
||||
@@ -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** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
|
||||
|
||||
---
|
||||
|
||||
@@ -26,6 +26,45 @@
|
||||
|
||||
**不要**依赖 `# ── 分组 ──` 装饰线给整组做唯一说明:宿主只会把行头挂到**紧挨着的下一个活跃 key**,后面的 key 不会自动继承。分组可用空行区分;**逐 key 自带行头(+ 行尾)才是硬要求**。
|
||||
|
||||
### 必填项:`# @required`(匠厂配置管理)
|
||||
|
||||
匠厂宿主「数据管理 → 配置管理」支持把部分配置项标为**必填**:模板在**行头注释上方**加独立一行 `# @required`,宿主会在 key 前显示红色 `*`,并在键值模式保存时拦截空值。
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **写法** | `# @required` 单独占一行,紧挨在该 key 的**中文行头**上方 |
|
||||
| **语义** | 保存时该 key 的值不得为空(trim 后);模板里可以有默认值 |
|
||||
| **展示** | `@required` 行本身**不展示**给用户,只用于打标 |
|
||||
| **声明位置** | 只在仓库 `.env.example` 声明;用户数据目录 `.env` 里的 `@required` 不算 |
|
||||
|
||||
正例:
|
||||
|
||||
```ini
|
||||
# @required
|
||||
# 目标网站地址:技能要访问的网站或服务地址
|
||||
TARGET_BASE_URL=https://sandbox.jc2009.com # 填写完整网址(含 https://);换环境时再改
|
||||
|
||||
# 默认登录账号:仅填写非敏感的账号标识(如工号/登录名)
|
||||
DEFAULT_LOGIN_ID= # 留空=按平台自动选用;本项未标 @required,宿主不强制
|
||||
```
|
||||
|
||||
反例(禁止):
|
||||
|
||||
```ini
|
||||
# 目标网站地址(必填)
|
||||
TARGET_BASE_URL= # @required ← 禁止写在行尾
|
||||
|
||||
# @required
|
||||
TARGET_BASE_URL= # ← 禁止:@required 下方缺少中文行头
|
||||
|
||||
# @required
|
||||
# 共用说明
|
||||
KEY_A=
|
||||
KEY_B= # ← 禁止:一个 @required 给多个 key 共用
|
||||
```
|
||||
|
||||
**机器门禁**:`POLICY-CONFIG-005` 校验 `@required` 用法;与 `POLICY-CONFIG-004`(行头/行尾中文)一并生效。
|
||||
|
||||
### 必须做到
|
||||
|
||||
1. 每个会进入配置管理的活跃 `KEY=` 都有完整中文**行头**。
|
||||
|
||||
@@ -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 通道
|
||||
@@ -936,6 +967,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
- [ ] 没有残留旧平台名
|
||||
- [ ] `health` / `version` 可运行
|
||||
- [ ] `.env.example` 注释已按 [`CONFIG.md`](CONFIG.md)「行头 = 是什么、行尾 = 怎么填」写全(面向配置管理用户;无 `见 development/...` 等开发文档指向)
|
||||
- [ ] 业务必填项已在 `.env.example` 用独立一行 `# @required` 标记(见 [`CONFIG.md`](CONFIG.md) §必填项);未标则宿主不显示红色 `*`
|
||||
- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去
|
||||
- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag)
|
||||
- [ ] `.github/workflows/release_skill.yaml` 存在
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
| 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` |
|
||||
| POLICY-CONFIG-004 | `.env.example` 每个活跃 `KEY=` 必须有中文**行头**(是什么)与中文**行尾**(怎么填);注释禁止 `development/`、`*.md` 文档指向、`account-manager`、`adapter 档位` 等开发黑话 | development/CONFIG.md §.env.example 注释 = 配置管理用户文案 | hard | 解析 `.env.example` 活跃行与注释 | `tests/test_development_policy_guard.py::TestPolicyConfig004` |
|
||||
| POLICY-CONFIG-005 | 若 `.env.example` 使用 `# @required`,须单独占一行且在下一行中文行头之上;禁止写在行尾;禁止无中文行头或多 key 共用 | development/CONFIG.md §必填项:# @required | hard | 解析 `.env.example` 活跃行与 `@required` 标记 | `tests/test_development_policy_guard.py::TestPolicyConfig005` |
|
||||
| POLICY-TESTING-001 | 默认根目录 `tests/test_*.py` 不得硬编码 `real_api` / `real_rpa` / `OPENCLAW_TEST_TARGET`(白名单仅 `test_adapter_profile_policy.py`,用于档位 helper 自测) | development/TESTING.md §默认必跑测试要做什么、§4 测试目标档位 | hard | 扫描 `tests/test_*.py` 非注释行;白名单外禁止上述文本 | `tests/test_development_policy_guard.py::TestPolicyTesting001` |
|
||||
| POLICY-TESTING-002 | `tests/integration/` 下 Python 测试文件若存在,默认应为 `.sample`,不得进入默认套件 | development/TESTING.md §7 真实联调测试的安全约束、§8 新 skill 的最小测试清单 | hard | 检查 `tests/integration/*.py` 无 `.sample` 后缀 | `tests/test_development_policy_guard.py::TestPolicyTesting002` |
|
||||
| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖;development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py`) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.113"
|
||||
SKILL_VERSION = "1.0.115"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
668
sync.ps1
Normal file
668
sync.ps1
Normal 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
|
||||
@@ -30,6 +30,7 @@ POLICY_IDS = (
|
||||
"POLICY-CONFIG-002",
|
||||
"POLICY-CONFIG-003",
|
||||
"POLICY-CONFIG-004",
|
||||
"POLICY-CONFIG-005",
|
||||
"POLICY-TESTING-001",
|
||||
"POLICY-TESTING-002",
|
||||
"POLICY-RPA-001",
|
||||
@@ -96,8 +97,11 @@ CONFIG_ENV_FORBIDDEN_PATTERNS = (
|
||||
)
|
||||
|
||||
CONFIG_004_SOURCE = "development/CONFIG.md §.env.example 注释 = 配置管理用户文案"
|
||||
CONFIG_005_SOURCE = "development/CONFIG.md §必填项:# @required"
|
||||
CONFIG_004_ENV_KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
CONFIG_004_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||||
CONFIG_005_REQUIRED_MARKER_RE = re.compile(r"^@required\b", re.IGNORECASE)
|
||||
CONFIG_005_INLINE_REQUIRED_RE = re.compile(r"@required\b", re.IGNORECASE)
|
||||
# 注释中禁止出现的开发向指向 / 黑话(大小写不敏感子串)
|
||||
CONFIG_004_FORBIDDEN_COMMENT_SUBSTRINGS = (
|
||||
"development/",
|
||||
@@ -316,6 +320,34 @@ def _config_004_comment_offenders(path: str) -> list[str]:
|
||||
return offenders
|
||||
|
||||
|
||||
def _is_env_required_marker(text: str) -> bool:
|
||||
return bool(CONFIG_005_REQUIRED_MARKER_RE.match(text.strip()))
|
||||
|
||||
|
||||
def _config_005_offenders(path: str) -> list[str]:
|
||||
offenders: list[str] = []
|
||||
for lineno, key, _value, headers, inline in _iter_env_example_active_entries(path):
|
||||
if inline and CONFIG_005_INLINE_REQUIRED_RE.search(inline):
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= 禁止在行尾写 @required(须单独一行放在中文行头上方)"
|
||||
)
|
||||
|
||||
required_markers = [part for part in headers if _is_env_required_marker(part)]
|
||||
non_marker_headers = [part for part in headers if not _is_env_required_marker(part)]
|
||||
if not required_markers:
|
||||
continue
|
||||
if not non_marker_headers:
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= 使用了 # @required 但缺少中文行头"
|
||||
)
|
||||
continue
|
||||
if not any(CONFIG_004_CJK_RE.search(part) for part in non_marker_headers):
|
||||
offenders.append(
|
||||
f".env.example:{lineno}: {key}= # @required 下方须有中文行头(说明「是什么」)"
|
||||
)
|
||||
return offenders
|
||||
|
||||
|
||||
def _rel(skill_root: str, path: str) -> str:
|
||||
return os.path.relpath(path, skill_root).replace("\\", "/")
|
||||
|
||||
@@ -406,6 +438,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):
|
||||
@@ -569,6 +607,23 @@ class TestPolicyConfig004(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyConfig005(unittest.TestCase):
|
||||
def test_env_example_required_markers_are_well_formed(self) -> None:
|
||||
path = os.path.join(get_skill_root(), ".env.example")
|
||||
self.assertTrue(os.path.isfile(path), msg=".env.example missing")
|
||||
offenders = _config_005_offenders(path)
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
msg=_policy_msg(
|
||||
"POLICY-CONFIG-005",
|
||||
CONFIG_005_SOURCE,
|
||||
"\n".join(offenders)
|
||||
or "every # @required marker needs its own Chinese header line above the key",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyTesting001(unittest.TestCase):
|
||||
def test_root_tests_do_not_hardcode_real_targets(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
|
||||
@@ -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 时可保留 |
|
||||
|
||||
Reference in New Issue
Block a user