@wangkh/skill-sync-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +299 -0
- package/README.zh-CN.md +207 -0
- package/RELEASING.md +60 -0
- package/bin/skill-sync.mjs +132 -0
- package/package.json +48 -0
- package/schemas/skills-sync.schema.json +67 -0
- package/skills.json.example +24 -0
- package/src/commands.mjs +134 -0
- package/src/config.mjs +21 -0
- package/src/manifest.mjs +228 -0
- package/src/runner.mjs +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# skill-sync-cli
|
|
2
|
+
|
|
3
|
+
[中文使用说明](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
`skill-sync-cli` keeps a portable list of third-party agent skills and restores
|
|
6
|
+
them on another machine by invoking the `skills` CLI.
|
|
7
|
+
|
|
8
|
+
The manifest records the source and skill name. The actual download, parsing,
|
|
9
|
+
installation, and per-agent linking remain delegated to `npx skills`.
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
Requires Node.js 18.17+, npm, and Git. Once this package is published to npm,
|
|
14
|
+
run these commands from any directory. Before the first release, use
|
|
15
|
+
[a source checkout or local package](#run-before-the-first-release).
|
|
16
|
+
|
|
17
|
+
Create your manifest:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx @wangkh/skill-sync-cli init
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This creates:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
Windows: %USERPROFILE%/.config/skill-sync/skills.json
|
|
27
|
+
macOS/Linux: ~/.config/skill-sync/skills.json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Edit this file to list your skills, then preview the installation commands:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx @wangkh/skill-sync-cli plan
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Sync the skills:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npx @wangkh/skill-sync-cli sync
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Validate without downloading:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx @wangkh/skill-sync-cli validate
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Show which file will be used:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx @wangkh/skill-sync-cli path
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`init` preserves an existing manifest. Use `npx @wangkh/skill-sync-cli init --force`
|
|
55
|
+
when you intend to replace it with the sample.
|
|
56
|
+
|
|
57
|
+
To keep the manifest in its own Git repository, use
|
|
58
|
+
`npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json`.
|
|
59
|
+
See [the cross-machine workflow](#cross-machine-workflow).
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
`skill-sync-cli` reads one manifest per invocation. The default location is:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
Windows: %USERPROFILE%/.config/skill-sync/skills.json
|
|
67
|
+
macOS/Linux: ~/.config/skill-sync/skills.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
On Windows, this normally resolves to
|
|
71
|
+
`C:\Users\<user>\.config\skill-sync\skills.json`.
|
|
72
|
+
|
|
73
|
+
Use `--config <file>` (or `-c <file>`) to select a local JSON file for any command:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx @wangkh/skill-sync-cli init --config ./my-skills/skills.json
|
|
77
|
+
npx @wangkh/skill-sync-cli validate --config ./my-skills/skills.json
|
|
78
|
+
npx @wangkh/skill-sync-cli plan --config ./my-skills/skills.json
|
|
79
|
+
npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json
|
|
80
|
+
npx @wangkh/skill-sync-cli path --config ./my-skills/skills.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The option can appear before or after the command; `--config=path` also works.
|
|
84
|
+
Relative paths resolve from the working directory, absolute paths work as given,
|
|
85
|
+
and a leading `~/` expands to your home directory. Quote paths containing spaces.
|
|
86
|
+
`path` prints the selected absolute filename. `init` creates its parent directories
|
|
87
|
+
and preserves existing files unless `--force` is supplied.
|
|
88
|
+
|
|
89
|
+
Pass `--config` each time you want to use that manifest. Omitting it selects the
|
|
90
|
+
default user file. An explicit missing or invalid file produces an error when
|
|
91
|
+
reading; it never falls back to another manifest. Clone a remote configuration
|
|
92
|
+
repository first, then pass the path to its local JSON file.
|
|
93
|
+
|
|
94
|
+
The `npx skills` package itself does not discover these files. This wrapper
|
|
95
|
+
reads the selected manifest, then delegates each installation to `npx skills`.
|
|
96
|
+
|
|
97
|
+
## Manifest
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"version": 1,
|
|
102
|
+
"cli": {
|
|
103
|
+
"package": "skills",
|
|
104
|
+
"version": "1.5.22"
|
|
105
|
+
},
|
|
106
|
+
"defaults": {
|
|
107
|
+
"agents": ["opencode"],
|
|
108
|
+
"scope": "global",
|
|
109
|
+
"copy": true
|
|
110
|
+
},
|
|
111
|
+
"skills": [
|
|
112
|
+
{
|
|
113
|
+
"name": "find-skills",
|
|
114
|
+
"source": "vercel-labs/skills",
|
|
115
|
+
"description": "Discover and install more skills"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"name": "agent-browser",
|
|
119
|
+
"source": "vercel-labs/agent-browser",
|
|
120
|
+
"description": "Browser automation",
|
|
121
|
+
"ref": "main",
|
|
122
|
+
"agents": ["opencode", "codex"],
|
|
123
|
+
"scope": "global"
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Supported source strings are the same ones accepted by `skills add`, such as
|
|
130
|
+
GitHub shorthand, GitHub/GitLab URLs, Git URLs, direct skill URLs, and archives.
|
|
131
|
+
Local filesystem paths are rejected because they are not portable between
|
|
132
|
+
machines.
|
|
133
|
+
|
|
134
|
+
`ref` is optional. For reproducible installations, use a tag or commit SHA
|
|
135
|
+
instead of a moving branch such as `main`.
|
|
136
|
+
|
|
137
|
+
## Fields
|
|
138
|
+
|
|
139
|
+
### `cli`
|
|
140
|
+
|
|
141
|
+
Controls which `skills` package is executed. Pinning the version makes setup
|
|
142
|
+
behavior repeatable:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
"cli": { "package": "skills", "version": "1.5.22" }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Use `"latest"` when you explicitly want the newest CLI.
|
|
149
|
+
|
|
150
|
+
### `defaults`
|
|
151
|
+
|
|
152
|
+
Defaults apply to every skill unless overridden on the skill entry:
|
|
153
|
+
|
|
154
|
+
- `agents`: target agent names, default `opencode`
|
|
155
|
+
- `scope`: `global` or `project`, default `global`
|
|
156
|
+
- `copy`: use copies instead of symlinks, default `true`
|
|
157
|
+
|
|
158
|
+
Copy mode is the safer default on Windows because it does not require
|
|
159
|
+
Developer Mode or symlink privileges. Set `copy` to `false` when you want the
|
|
160
|
+
canonical-copy-plus-symlink layout provided by `skills`.
|
|
161
|
+
|
|
162
|
+
### `skills`
|
|
163
|
+
|
|
164
|
+
Each entry requires:
|
|
165
|
+
|
|
166
|
+
- `name`: the skill name passed to `skills add --skill`
|
|
167
|
+
- `source`: the remote source passed to `skills add`
|
|
168
|
+
|
|
169
|
+
Optional fields are `description`, `ref`, `enabled`, `agents`, `scope`, and `copy`.
|
|
170
|
+
|
|
171
|
+
`description` records why you use a skill and appears in the installation plan.
|
|
172
|
+
|
|
173
|
+
Disabled entries remain in the manifest but are skipped by `sync`.
|
|
174
|
+
|
|
175
|
+
## Cross-Machine Workflow
|
|
176
|
+
|
|
177
|
+
Create a dedicated configuration repository and initialize its manifest:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
git init -b main my-skills
|
|
181
|
+
npx @wangkh/skill-sync-cli init --config ./my-skills/skills.json
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Edit `my-skills/skills.json`, then save it in Git:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
git -C my-skills add skills.json
|
|
188
|
+
git -C my-skills commit -m "chore: add skill manifest"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Push this repository to your Git hosting service. On another machine, install
|
|
192
|
+
Node.js, npm, and Git, then clone the configuration repository and use its file
|
|
193
|
+
directly. Replace the example URL with your repository:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
git clone https://github.com/YOUR_NAME/my-skills.git my-skills
|
|
197
|
+
npx @wangkh/skill-sync-cli plan --config ./my-skills/skills.json
|
|
198
|
+
npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
After committing and pushing future manifest edits, update each existing clone:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
git -C my-skills pull --ff-only
|
|
205
|
+
npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Git transfers the manifest between machines; `sync` installs the skills declared
|
|
209
|
+
in the local file. Each machine can clone the repository into a different folder.
|
|
210
|
+
|
|
211
|
+
The `skills` CLI will also maintain its own local lock files for update
|
|
212
|
+
tracking. This tool's manifest is the portable declaration of what should be
|
|
213
|
+
installed; it is intentionally separate from those machine-local state files.
|
|
214
|
+
|
|
215
|
+
Sync reinstalls enabled entries and can overwrite installed skills with the same
|
|
216
|
+
name. Removing or disabling an entry does not uninstall it. By default a failure
|
|
217
|
+
stops the batch; `npx @wangkh/skill-sync-cli sync --continue-on-error` attempts the remaining
|
|
218
|
+
entries and still exits with a failure status. Use `npx @wangkh/skill-sync-cli sync --dry-run` to
|
|
219
|
+
preview without installing.
|
|
220
|
+
|
|
221
|
+
For project-scoped skills, set:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
"defaults": {
|
|
225
|
+
"agents": ["opencode"],
|
|
226
|
+
"scope": "project",
|
|
227
|
+
"copy": true
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Project scope uses the command's working directory, independently of the manifest
|
|
232
|
+
location. From the target application project, run:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
npx @wangkh/skill-sync-cli sync --config /path/to/my-skills/skills.json
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Run Before the First Release
|
|
239
|
+
|
|
240
|
+
From a source checkout, the following commands work without installing project
|
|
241
|
+
dependencies:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
npm run init
|
|
245
|
+
npm run plan
|
|
246
|
+
npm run sync
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
To select a manifest with an npm script, forward the option after `--`:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
npm run plan -- --config ../my-skills/skills.json
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`npm run sync` runs in this package's directory. For project scope in another
|
|
256
|
+
directory, run `node /absolute/path/to/skill-sync-cli/bin/skill-sync.mjs sync`
|
|
257
|
+
from the target project.
|
|
258
|
+
|
|
259
|
+
To verify the packaged CLI before publishing:
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
npm run check
|
|
263
|
+
npm run test:package
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
This creates `dist/wangkh-skill-sync-cli-0.1.0.tgz` and `dist/package-check.json`. The
|
|
267
|
+
package check runs real npx commands offline, with a temporary home directory
|
|
268
|
+
and a fresh npm cache. It verifies initialization, external manifests, previews, and
|
|
269
|
+
error handling, then removes the temporary environment. The archive and report
|
|
270
|
+
remain in `dist/`.
|
|
271
|
+
|
|
272
|
+
You can run that archive directly. Replace the path with its absolute location:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.1.0.tgz skill-sync-cli --help
|
|
276
|
+
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.1.0.tgz skill-sync-cli init
|
|
277
|
+
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.1.0.tgz skill-sync-cli plan
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
These manual commands use your normal user configuration directory. To select a
|
|
281
|
+
repository manifest, append `--config /path/to/my-skills/skills.json` after the
|
|
282
|
+
subcommand.
|
|
283
|
+
|
|
284
|
+
## Security
|
|
285
|
+
|
|
286
|
+
A skill is agent instructions, and may include scripts or references that
|
|
287
|
+
change how an agent operates. Review third-party skills before adding them to
|
|
288
|
+
the manifest. Prefer trusted repositories, HTTPS, and immutable commit refs.
|
|
289
|
+
|
|
290
|
+
## Development
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
npm run check
|
|
294
|
+
npm run test:package
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
See [the release notes for maintainers](RELEASING.md) for the planned repository,
|
|
298
|
+
package details, and publication procedure. `npm publish` runs both checks through
|
|
299
|
+
`prepublishOnly`.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# skill-sync-cli
|
|
2
|
+
|
|
3
|
+
用一份 JSON 清单记录常用开源 Skills 的名称、来源和用途,通过一个命令批量调用 `npx skills add` 安装。适合把个人清单保存在 dotfiles 仓库中,在新电脑上恢复环境。
|
|
4
|
+
|
|
5
|
+
## 开始使用
|
|
6
|
+
|
|
7
|
+
需要 Node.js 18.17+、npm 和 Git。首次运行时,npx 会获取本工具;执行安装时,再获取清单指定版本的 `skills` CLI。
|
|
8
|
+
|
|
9
|
+
发布到 npm 后,可以在任意目录运行以下命令。首个版本发布前,使用下文的[源码或本地安装包](#发布前使用)。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# 新环境:创建初始清单;如果已有清单,跳过这一步
|
|
13
|
+
npx @wangkh/skill-sync-cli init
|
|
14
|
+
|
|
15
|
+
# 查看配置文件的位置
|
|
16
|
+
npx @wangkh/skill-sync-cli path
|
|
17
|
+
|
|
18
|
+
# 编辑清单后检查格式并预览安装命令
|
|
19
|
+
npx @wangkh/skill-sync-cli validate
|
|
20
|
+
npx @wangkh/skill-sync-cli plan
|
|
21
|
+
|
|
22
|
+
# 批量安装已启用的 Skills
|
|
23
|
+
npx @wangkh/skill-sync-cli sync
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
默认配置文件位于用户目录中:
|
|
27
|
+
|
|
28
|
+
- Windows:`%USERPROFILE%\.config\skill-sync\skills.json`
|
|
29
|
+
- macOS / Linux:`~/.config/skill-sync/skills.json`
|
|
30
|
+
|
|
31
|
+
创建后,编辑这个文件填写自己常用的 Skills。`init` 默认保留已有文件;如果确实要用示例覆盖当前清单,可以运行 `npx @wangkh/skill-sync-cli init --force`。
|
|
32
|
+
|
|
33
|
+
## 指定配置文件
|
|
34
|
+
|
|
35
|
+
所有命令都支持 `--config <文件路径>`,可以直接使用独立 Git 仓库里的清单:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx @wangkh/skill-sync-cli init --config ./my-skills/skills.json
|
|
39
|
+
npx @wangkh/skill-sync-cli validate --config ./my-skills/skills.json
|
|
40
|
+
npx @wangkh/skill-sync-cli plan --config ./my-skills/skills.json
|
|
41
|
+
npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json
|
|
42
|
+
npx @wangkh/skill-sync-cli path --config ./my-skills/skills.json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
也支持 `-c <文件路径>` 和 `--config=文件路径`,参数可以放在子命令前或后。相对路径以运行命令的目录为基准,支持绝对路径和 `~/`;包含空格的路径需要加引号。
|
|
46
|
+
|
|
47
|
+
`path` 显示本次选中的绝对路径。`init` 会创建所需的父目录,已有文件需要显式加 `--force` 才能覆盖。读取时,如果指定文件不存在或内容无效,命令会报错。
|
|
48
|
+
|
|
49
|
+
每次使用仓库清单时都传入 `--config`;省略时使用默认用户配置。远程配置仓库先用 Git 克隆到本机,再把本地 JSON 文件路径交给这个参数。
|
|
50
|
+
|
|
51
|
+
## 清单格式
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"version": 1,
|
|
56
|
+
"cli": {
|
|
57
|
+
"package": "skills",
|
|
58
|
+
"version": "1.5.22"
|
|
59
|
+
},
|
|
60
|
+
"defaults": {
|
|
61
|
+
"agents": ["opencode"],
|
|
62
|
+
"scope": "global",
|
|
63
|
+
"copy": true
|
|
64
|
+
},
|
|
65
|
+
"skills": [
|
|
66
|
+
{
|
|
67
|
+
"name": "find-skills",
|
|
68
|
+
"source": "vercel-labs/skills",
|
|
69
|
+
"description": "查找和安装更多 Skills"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "agent-browser",
|
|
73
|
+
"source": "vercel-labs/agent-browser",
|
|
74
|
+
"description": "浏览器自动化",
|
|
75
|
+
"enabled": false
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
每个 Skill 的 `name`、`source` 必填,其他字段可选:
|
|
82
|
+
|
|
83
|
+
| 字段 | 含义 |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `name` | 上游 Skill 的准确名称,传给 `--skill` |
|
|
86
|
+
| `source` | GitHub 的 `owner/repo`、远程 Git URL、Skill URL 或下载地址 |
|
|
87
|
+
| `description` | 自己记录的用途说明,预览时显示 |
|
|
88
|
+
| `enabled` | 是否安装,默认 `true`;设为 `false` 可暂时停用 |
|
|
89
|
+
| `ref` | Git 仓库的分支、标签或 commit SHA,省略则使用上游默认版本 |
|
|
90
|
+
| `agents` | 安装到哪些工具,如 `["opencode", "codex", "claude-code"]` |
|
|
91
|
+
| `scope` | `global` 安装到用户目录,`project` 安装到运行命令的工作目录 |
|
|
92
|
+
| `copy` | `true` 复制文件,`false` 使用上游 CLI 的符号链接方式 |
|
|
93
|
+
|
|
94
|
+
`agents`、`scope`、`copy` 优先使用条目自己的值,其次使用 `defaults`,未配置时分别为 `["opencode"]`、`"global"`、`true`。
|
|
95
|
+
|
|
96
|
+
同一仓库的多个 Skills 分别写成多个条目。查找准确名称可以运行:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx --yes skills@1.5.22 add vercel-labs/agent-browser --list
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`ref` 用于 Git 来源,会编码成 `source#ref`。对于已经包含分支的 `/tree/...` URL 或直接下载地址,在 `source` 中指定版本即可。固定 CLI 版本只固定安装工具;要固定某个 Skill 的内容,需要为 Git 来源指定 commit SHA。
|
|
103
|
+
|
|
104
|
+
## 安装行为
|
|
105
|
+
|
|
106
|
+
上面的第一个条目会生成:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
npx --yes skills@1.5.22 add vercel-labs/skills --agent opencode --skill find-skills --global --copy --yes
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
两个 `--yes` 分别用于 npx 下载安装工具和 Skills 安装确认。`sync` 会按清单顺序安装;再次运行会重新安装或更新这些条目,可能覆盖目标位置同名 Skill 的内容。从清单移除或禁用一个条目后,已经安装的内容仍会保留。
|
|
113
|
+
|
|
114
|
+
只预览,或者在单个 Skill 失败后继续处理其他条目:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npx @wangkh/skill-sync-cli sync --dry-run
|
|
118
|
+
npx @wangkh/skill-sync-cli sync --continue-on-error
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
默认遇到安装失败就停止并返回非零退出码。使用 `--continue-on-error` 后会继续执行,其间发生失败仍返回非零退出码。
|
|
122
|
+
|
|
123
|
+
使用 `scope: "project"` 时,安装目标是运行命令的目录,与清单所在目录相互独立。在目标应用项目目录运行:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npx @wangkh/skill-sync-cli sync --config /path/to/my-skills/skills.json
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## 用独立 Git 仓库跨机器同步
|
|
130
|
+
|
|
131
|
+
先创建一个保存配置的仓库,并生成清单:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
git init -b main my-skills
|
|
135
|
+
npx @wangkh/skill-sync-cli init --config ./my-skills/skills.json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
编辑 `my-skills/skills.json` 后提交:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
git -C my-skills add skills.json
|
|
142
|
+
git -C my-skills commit -m "chore: add skill manifest"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
把这个仓库推送到自己的 Git 托管平台。其他电脑安装 Node.js、npm 和 Git 后,克隆配置仓库并直接使用其中的清单。请替换下面的示例地址:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
git clone https://github.com/YOUR_NAME/my-skills.git my-skills
|
|
149
|
+
npx @wangkh/skill-sync-cli plan --config ./my-skills/skills.json
|
|
150
|
+
npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
以后修改清单并提交、推送后,在已经克隆仓库的电脑上执行:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
git -C my-skills pull --ff-only
|
|
157
|
+
npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Git 负责在机器之间传递清单,`sync` 根据本地清单安装 Skills。每台电脑可以把配置仓库放在不同目录,通过 `--config` 指向对应文件。
|
|
161
|
+
|
|
162
|
+
清单是这个脚本定义的格式;脚本读取后逐项调用官方 `skills` CLI。上游的 `.skill-lock.json` 继续由官方 CLI 维护。
|
|
163
|
+
|
|
164
|
+
## 发布前使用
|
|
165
|
+
|
|
166
|
+
获取项目源码后,可以在源码目录直接运行,无需安装项目依赖:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
npm run init
|
|
170
|
+
npm run plan
|
|
171
|
+
npm run sync
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
通过 npm 脚本指定配置时,把参数放在 `--` 后面:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npm run plan -- --config ../my-skills/skills.json
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`npm run sync` 的工作目录是本项目目录。如果清单采用项目安装范围,需要在目标项目目录运行脚本的绝对路径:
|
|
181
|
+
|
|
182
|
+
```powershell
|
|
183
|
+
node "C:\path\to\skill-sync-cli\bin\skill-sync.mjs" sync
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
生成并验证可分发的安装包:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
npm run check
|
|
190
|
+
npm run test:package
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
命令会生成 `dist/wangkh-skill-sync-cli-0.1.0.tgz` 和验证报告 `dist/package-check.json`。安装包验证通过真实 npx 命令离线执行,使用临时用户目录和全新的 npm 缓存,检查初始化、外部清单、预览和错误处理。验证结束后清理临时环境,保留安装包及报告。
|
|
194
|
+
|
|
195
|
+
也可以直接用 npx 运行安装包。请把路径替换为安装包的实际绝对路径:
|
|
196
|
+
|
|
197
|
+
```powershell
|
|
198
|
+
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.1.0.tgz" skill-sync-cli --help
|
|
199
|
+
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.1.0.tgz" skill-sync-cli init
|
|
200
|
+
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.1.0.tgz" skill-sync-cli plan
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
这些手动命令默认使用正常的用户配置目录。使用仓库清单时,在子命令后加上 `--config "C:\path\to\my-skills\skills.json"`。
|
|
204
|
+
|
|
205
|
+
维护者发布流程见 [RELEASING.md](RELEASING.md)。从源码运行 `npm publish` 时,会自动执行单元测试和安装包验证。
|
|
206
|
+
|
|
207
|
+
参考:[skills 官方文档](https://github.com/vercel-labs/skills)、[示例清单](skills.json.example)、[JSON Schema](schemas/skills-sync.schema.json)。
|
package/RELEASING.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# 发布 0.1.0
|
|
2
|
+
|
|
3
|
+
## 发布信息
|
|
4
|
+
|
|
5
|
+
| 项目 | 值 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| npm 包名 | `@wangkh/skill-sync-cli` |
|
|
8
|
+
| 版本 | `0.1.0` |
|
|
9
|
+
| 命令入口 | `npx @wangkh/skill-sync-cli`;安装后的可执行命令为 `skill-sync-cli` |
|
|
10
|
+
| 计划 GitHub 仓库 | `https://github.com/yudaiyan/skill-sync-cli` |
|
|
11
|
+
| 默认上游 CLI | `skills@1.5.22` |
|
|
12
|
+
| 默认配置位置 | `~/.config/skill-sync/skills.json` |
|
|
13
|
+
| 仓库清单 | `--config /path/to/my-skills/skills.json`,也支持 `-c` |
|
|
14
|
+
|
|
15
|
+
2026-09-10 首次发布时,npm 公共注册表拒绝了非作用域包名 `skill-sync-cli`,理由是它与已存在的 `skillsync-cli` 过于相似,并建议改用 `@wangkh/skill-sync-cli`;当前包名已采用该建议,发布需要 npm 账号的 2FA。GitHub CLI 登录账号为 `yudaiyan`,仓库地址按 `https://github.com/yudaiyan/skill-sync-cli` 准备。
|
|
16
|
+
|
|
17
|
+
## 生成并验证安装包
|
|
18
|
+
|
|
19
|
+
在项目源码目录运行:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm run check
|
|
23
|
+
npm run test:package
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`test:package` 会实际执行 `npm pack`,随后用独立的用户目录和 npm 缓存离线运行安装包。输出:
|
|
27
|
+
|
|
28
|
+
- `dist/wangkh-skill-sync-cli-0.1.0.tgz`:可分发的 npm 安装包。
|
|
29
|
+
- `dist/package-check.json`:包摘要、文件清单、执行环境和通过的检查。
|
|
30
|
+
|
|
31
|
+
`dist/` 已加入 Git 忽略规则。修改代码或文档后,需要重新生成安装包,使报告对应最终内容。
|
|
32
|
+
|
|
33
|
+
## 正式发布流程
|
|
34
|
+
|
|
35
|
+
完成发布准备提交后,创建计划仓库并推送 `main`。例如,选择公开仓库时:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
gh repo create yudaiyan/skill-sync-cli --public --source . --remote origin --push
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
确认 npm 登录身份,然后从项目源码目录发布:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm whoami --registry=https://registry.npmjs.org/
|
|
45
|
+
npm publish
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`npm publish` 会执行 `prepublishOnly`,重新运行单元测试和安装包验证。npm 认证、双重验证及包名权限由 npm 处理。`publishConfig` 指向公共 npm 注册表。
|
|
49
|
+
|
|
50
|
+
发布成功后,从其他目录检查 npm 上的版本和用户入口:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm view @wangkh/skill-sync-cli version
|
|
54
|
+
npx --yes @wangkh/skill-sync-cli@0.1.0 --version
|
|
55
|
+
npx --yes @wangkh/skill-sync-cli@0.1.0 --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 后续版本
|
|
59
|
+
|
|
60
|
+
更新 `package.json` 的版本,CLI 的 `--version` 会读取同一版本号。同步修改文档中的安装包文件名,运行上述检查,再提交和发布。
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from "node:module"
|
|
4
|
+
import {
|
|
5
|
+
initCommand,
|
|
6
|
+
planCommand,
|
|
7
|
+
syncCommand,
|
|
8
|
+
validateCommand,
|
|
9
|
+
} from "../src/commands.mjs"
|
|
10
|
+
import { resolveConfigPath } from "../src/config.mjs"
|
|
11
|
+
|
|
12
|
+
const { name: COMMAND_NAME, version: VERSION } = createRequire(import.meta.url)("../package.json")
|
|
13
|
+
function help() {
|
|
14
|
+
console.log(`${COMMAND_NAME} ${VERSION}
|
|
15
|
+
|
|
16
|
+
Sync skills declared in a JSON manifest by delegating downloads to npx skills.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
npx ${COMMAND_NAME} <command> [options]
|
|
20
|
+
${COMMAND_NAME} sync [options]
|
|
21
|
+
${COMMAND_NAME} plan [options]
|
|
22
|
+
${COMMAND_NAME} validate [options]
|
|
23
|
+
${COMMAND_NAME} path [options]
|
|
24
|
+
${COMMAND_NAME} init [options]
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
-c, --config <file> Use this JSON manifest (relative or absolute path)
|
|
28
|
+
--dry-run Print commands without downloading anything
|
|
29
|
+
--continue-on-error Continue after a skill installation fails
|
|
30
|
+
--force Replace the selected manifest with init
|
|
31
|
+
-h, --help Show this help
|
|
32
|
+
-v, --version Show the version
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
npx ${COMMAND_NAME} init
|
|
36
|
+
npx ${COMMAND_NAME} path
|
|
37
|
+
npx ${COMMAND_NAME} plan
|
|
38
|
+
npx ${COMMAND_NAME} sync
|
|
39
|
+
npx ${COMMAND_NAME} init --config ./my-skills/skills.json
|
|
40
|
+
npx ${COMMAND_NAME} sync --config ./my-skills/skills.json
|
|
41
|
+
|
|
42
|
+
Without --config, use ~/.config/skill-sync/skills.json.
|
|
43
|
+
Relative config paths and project installations use the current working directory.
|
|
44
|
+
`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const options = {
|
|
49
|
+
dryRun: false,
|
|
50
|
+
continueOnError: false,
|
|
51
|
+
force: false,
|
|
52
|
+
positional: [],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
56
|
+
const arg = argv[index]
|
|
57
|
+
if (arg === "-h" || arg === "--help") {
|
|
58
|
+
options.help = true
|
|
59
|
+
} else if (arg === "-v" || arg === "--version") {
|
|
60
|
+
options.version = true
|
|
61
|
+
} else if (arg === "--dry-run") {
|
|
62
|
+
options.dryRun = true
|
|
63
|
+
} else if (arg === "--continue-on-error") {
|
|
64
|
+
options.continueOnError = true
|
|
65
|
+
} else if (arg === "--force") {
|
|
66
|
+
options.force = true
|
|
67
|
+
} else if (arg === "--config" || arg === "-c" || arg.startsWith("--config=")) {
|
|
68
|
+
if (options.configFile !== undefined) {
|
|
69
|
+
throw new Error("Specify --config only once")
|
|
70
|
+
}
|
|
71
|
+
const inline = arg.startsWith("--config=")
|
|
72
|
+
const value = inline ? arg.slice("--config=".length) : argv[++index]
|
|
73
|
+
if (value === undefined || value.trim() === "" || (!inline && value.startsWith("-"))) {
|
|
74
|
+
throw new Error("--config requires a file path")
|
|
75
|
+
}
|
|
76
|
+
options.configFile = value
|
|
77
|
+
} else if (arg.startsWith("-")) {
|
|
78
|
+
throw new Error(`Unknown option: ${arg}`)
|
|
79
|
+
} else {
|
|
80
|
+
options.positional.push(arg)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return options
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const options = parseArgs(process.argv.slice(2))
|
|
89
|
+
const command = options.positional[0] ?? "help"
|
|
90
|
+
|
|
91
|
+
if (options.help) {
|
|
92
|
+
help()
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
if (options.version) {
|
|
96
|
+
console.log(VERSION)
|
|
97
|
+
return 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (command === "help") {
|
|
101
|
+
help()
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
if (options.positional.length > 1) {
|
|
105
|
+
throw new Error(`Unexpected argument: ${options.positional[1]}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const filename = resolveConfigPath(options.configFile)
|
|
109
|
+
if (command === "init") {
|
|
110
|
+
return initCommand(filename, options)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (command === "path") {
|
|
114
|
+
console.log(filename)
|
|
115
|
+
return 0
|
|
116
|
+
}
|
|
117
|
+
if (command === "validate") return validateCommand(filename)
|
|
118
|
+
if (command === "plan") return planCommand(filename)
|
|
119
|
+
if (command === "sync") {
|
|
120
|
+
return syncCommand(filename, options)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new Error(`Unknown command: ${command}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const code = await main()
|
|
128
|
+
process.exitCode = code
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error(`${COMMAND_NAME}: ${error.message}`)
|
|
131
|
+
process.exitCode = 1
|
|
132
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wangkh/skill-sync-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sync declared agent skills across machines through the skills CLI",
|
|
5
|
+
"author": "wkh",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/yudaiyan/skill-sync-cli.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/yudaiyan/skill-sync-cli#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/yudaiyan/skill-sync-cli/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["skills", "agent-skills", "cli", "dotfiles", "opencode", "codex"],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"skill-sync-cli": "bin/skill-sync.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"src",
|
|
22
|
+
"schemas",
|
|
23
|
+
"README.md",
|
|
24
|
+
"README.zh-CN.md",
|
|
25
|
+
"RELEASING.md",
|
|
26
|
+
"skills.json.example",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"init": "node ./bin/skill-sync.mjs init",
|
|
31
|
+
"path": "node ./bin/skill-sync.mjs path",
|
|
32
|
+
"validate": "node ./bin/skill-sync.mjs validate",
|
|
33
|
+
"plan": "node ./bin/skill-sync.mjs plan",
|
|
34
|
+
"sync": "node ./bin/skill-sync.mjs sync",
|
|
35
|
+
"test": "node --test",
|
|
36
|
+
"check": "npm test",
|
|
37
|
+
"test:package": "node ./scripts/verify-package.mjs",
|
|
38
|
+
"prepublishOnly": "npm run check && npm run test:package"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public",
|
|
42
|
+
"registry": "https://registry.npmjs.org/"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18.17"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT"
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "skill-sync-cli manifest",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"additionalProperties": false,
|
|
6
|
+
"required": ["version", "skills"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"version": {
|
|
9
|
+
"const": 1
|
|
10
|
+
},
|
|
11
|
+
"cli": {
|
|
12
|
+
"$ref": "#/$defs/cli"
|
|
13
|
+
},
|
|
14
|
+
"defaults": {
|
|
15
|
+
"$ref": "#/$defs/defaults"
|
|
16
|
+
},
|
|
17
|
+
"skills": {
|
|
18
|
+
"type": "array",
|
|
19
|
+
"minItems": 1,
|
|
20
|
+
"items": {
|
|
21
|
+
"$ref": "#/$defs/skill"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"$defs": {
|
|
26
|
+
"cli": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"additionalProperties": false,
|
|
29
|
+
"properties": {
|
|
30
|
+
"package": { "type": "string", "minLength": 1 },
|
|
31
|
+
"version": { "type": "string", "minLength": 1 }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"defaults": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": false,
|
|
37
|
+
"properties": {
|
|
38
|
+
"agents": {
|
|
39
|
+
"type": "array",
|
|
40
|
+
"minItems": 1,
|
|
41
|
+
"items": { "type": "string", "minLength": 1 }
|
|
42
|
+
},
|
|
43
|
+
"scope": { "enum": ["global", "project"] },
|
|
44
|
+
"copy": { "type": "boolean" }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"skill": {
|
|
48
|
+
"type": "object",
|
|
49
|
+
"additionalProperties": false,
|
|
50
|
+
"required": ["name", "source"],
|
|
51
|
+
"properties": {
|
|
52
|
+
"name": { "type": "string", "minLength": 1 },
|
|
53
|
+
"source": { "type": "string", "minLength": 1 },
|
|
54
|
+
"description": { "type": "string", "minLength": 1 },
|
|
55
|
+
"ref": { "type": "string", "minLength": 1 },
|
|
56
|
+
"enabled": { "type": "boolean" },
|
|
57
|
+
"agents": {
|
|
58
|
+
"type": "array",
|
|
59
|
+
"minItems": 1,
|
|
60
|
+
"items": { "type": "string", "minLength": 1 }
|
|
61
|
+
},
|
|
62
|
+
"scope": { "enum": ["global", "project"] },
|
|
63
|
+
"copy": { "type": "boolean" }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"cli": {
|
|
4
|
+
"package": "skills",
|
|
5
|
+
"version": "1.5.22"
|
|
6
|
+
},
|
|
7
|
+
"defaults": {
|
|
8
|
+
"agents": ["opencode"],
|
|
9
|
+
"scope": "global",
|
|
10
|
+
"copy": true
|
|
11
|
+
},
|
|
12
|
+
"skills": [
|
|
13
|
+
{
|
|
14
|
+
"name": "find-skills",
|
|
15
|
+
"source": "vercel-labs/skills",
|
|
16
|
+
"description": "查找和安装更多 Skills"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "agent-browser",
|
|
20
|
+
"source": "vercel-labs/agent-browser",
|
|
21
|
+
"description": "网页浏览、表单操作和浏览器自动化"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { access, mkdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
readManifest,
|
|
5
|
+
normalizeManifest,
|
|
6
|
+
DEFAULT_SKILLS_CLI,
|
|
7
|
+
} from "./manifest.mjs"
|
|
8
|
+
import {
|
|
9
|
+
commandFor,
|
|
10
|
+
displayCommand,
|
|
11
|
+
runCommand,
|
|
12
|
+
statusCode,
|
|
13
|
+
} from "./runner.mjs"
|
|
14
|
+
|
|
15
|
+
const SAMPLE_MANIFEST = {
|
|
16
|
+
version: 1,
|
|
17
|
+
cli: {
|
|
18
|
+
package: "skills",
|
|
19
|
+
version: DEFAULT_SKILLS_CLI,
|
|
20
|
+
},
|
|
21
|
+
defaults: {
|
|
22
|
+
agents: ["opencode"],
|
|
23
|
+
scope: "global",
|
|
24
|
+
copy: true,
|
|
25
|
+
},
|
|
26
|
+
skills: [
|
|
27
|
+
{
|
|
28
|
+
name: "find-skills",
|
|
29
|
+
source: "vercel-labs/skills",
|
|
30
|
+
description: "查找和安装更多 Skills",
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function enabledSkills(manifest) {
|
|
36
|
+
return manifest.skills.filter((skill) => skill.enabled)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function validateCommand(manifestFilename) {
|
|
40
|
+
const { path: filename, manifest } = await readManifest(manifestFilename)
|
|
41
|
+
const enabled = enabledSkills(manifest)
|
|
42
|
+
for (const skill of enabled) commandFor(skill, manifest.cli)
|
|
43
|
+
console.log(`Valid manifest: ${filename}`)
|
|
44
|
+
console.log(`Declared skills: ${manifest.skills.length}`)
|
|
45
|
+
console.log(`Enabled skills: ${enabled.length}`)
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function planCommand(manifestFilename) {
|
|
50
|
+
const { path: filename, manifest } = await readManifest(manifestFilename)
|
|
51
|
+
const skills = enabledSkills(manifest)
|
|
52
|
+
|
|
53
|
+
console.log(`Manifest: ${filename}`)
|
|
54
|
+
if (skills.length === 0) {
|
|
55
|
+
console.log("No enabled skills.")
|
|
56
|
+
return 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const skill of skills) {
|
|
60
|
+
if (skill.description) console.log(`# ${skill.name}: ${skill.description}`)
|
|
61
|
+
console.log(displayCommand(commandFor(skill, manifest.cli)))
|
|
62
|
+
}
|
|
63
|
+
return 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function syncCommand(manifestFilename, options = {}) {
|
|
67
|
+
const { path: filename, manifest } = await readManifest(manifestFilename)
|
|
68
|
+
const skills = enabledSkills(manifest)
|
|
69
|
+
const execute = options.run ?? runCommand
|
|
70
|
+
|
|
71
|
+
// Resolve the full plan before installing so invalid refs cannot cause a
|
|
72
|
+
// partially applied manifest.
|
|
73
|
+
const commands = skills.map((skill) => ({ skill, command: commandFor(skill, manifest.cli) }))
|
|
74
|
+
|
|
75
|
+
if (skills.length === 0) {
|
|
76
|
+
console.log("No enabled skills.")
|
|
77
|
+
return 0
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(`Syncing ${skills.length} skill${skills.length === 1 ? "" : "s"} from ${filename}`)
|
|
81
|
+
let failures = 0
|
|
82
|
+
|
|
83
|
+
for (const { skill, command } of commands) {
|
|
84
|
+
console.log(`\n[${skill.name}] ${skill.source}${skill.ref ? ` @ ${skill.ref}` : ""}`)
|
|
85
|
+
if (skill.description) console.log(skill.description)
|
|
86
|
+
|
|
87
|
+
if (options.dryRun) {
|
|
88
|
+
console.log(displayCommand(command))
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const result = execute(command, { cwd: options.cwd })
|
|
93
|
+
const code = statusCode(result)
|
|
94
|
+
if (code !== 0) {
|
|
95
|
+
failures += 1
|
|
96
|
+
if (result.error) console.error(result.error.message)
|
|
97
|
+
if (result.signal) console.error(`[${skill.name}] terminated by ${result.signal}`)
|
|
98
|
+
console.error(`[${skill.name}] failed with exit code ${code}`)
|
|
99
|
+
if (result.signal || !options.continueOnError) break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (failures > 0) {
|
|
104
|
+
console.error(`Sync failed for ${failures} skill${failures === 1 ? "" : "s"}.`)
|
|
105
|
+
return 1
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!options.dryRun) console.log("\nSync complete.")
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function initCommand(manifestFilename, { force = false } = {}) {
|
|
113
|
+
const filename = path.resolve(manifestFilename)
|
|
114
|
+
if (!force) {
|
|
115
|
+
try {
|
|
116
|
+
await access(filename)
|
|
117
|
+
throw new Error(`Manifest already exists: ${filename} (use --force to replace it)`)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (error.code !== "ENOENT") throw error
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await mkdir(path.dirname(filename), { recursive: true })
|
|
124
|
+
await writeFile(filename, `${JSON.stringify(SAMPLE_MANIFEST, null, 2)}\n`, {
|
|
125
|
+
encoding: "utf8",
|
|
126
|
+
flag: force ? "w" : "wx",
|
|
127
|
+
})
|
|
128
|
+
console.log(`Created ${filename}`)
|
|
129
|
+
return 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function sampleManifest() {
|
|
133
|
+
return normalizeManifest(SAMPLE_MANIFEST)
|
|
134
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os from "node:os"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export const USER_CONFIG_RELATIVE_PATH = [".config", "skill-sync", "skills.json"]
|
|
5
|
+
|
|
6
|
+
export function configPath(home = os.homedir()) {
|
|
7
|
+
return path.join(home, ...USER_CONFIG_RELATIVE_PATH)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveConfigPath(filename, { cwd = process.cwd(), userHome = os.homedir() } = {}) {
|
|
11
|
+
if (filename === undefined) return configPath(userHome)
|
|
12
|
+
if (typeof filename !== "string" || filename.trim() === "") {
|
|
13
|
+
throw new Error("--config requires a file path")
|
|
14
|
+
}
|
|
15
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(filename) || filename.startsWith("git@")) {
|
|
16
|
+
throw new Error("--config expects a local file path. Clone the configuration repository first.")
|
|
17
|
+
}
|
|
18
|
+
if (filename === "~") return userHome
|
|
19
|
+
if (/^~[\\/]/.test(filename)) return path.resolve(userHome, filename.slice(2))
|
|
20
|
+
return path.resolve(cwd, filename)
|
|
21
|
+
}
|
package/src/manifest.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export const MANIFEST_VERSION = 1
|
|
5
|
+
export const DEFAULT_SKILLS_CLI = "1.5.22"
|
|
6
|
+
export const DEFAULT_AGENTS = ["opencode"]
|
|
7
|
+
export const DEFAULT_SCOPE = "global"
|
|
8
|
+
export const DEFAULT_COPY = true
|
|
9
|
+
|
|
10
|
+
function fail(message) {
|
|
11
|
+
throw new Error(`Invalid skills manifest: ${message}`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isPlainObject(value) {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertString(value, label, { allowEmpty = false } = {}) {
|
|
19
|
+
if (typeof value !== "string" || (!allowEmpty && value.trim() === "")) {
|
|
20
|
+
fail(`${label} must be a non-empty string`)
|
|
21
|
+
}
|
|
22
|
+
return value.trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertStringArray(value, label) {
|
|
26
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
27
|
+
fail(`${label} must be a non-empty array of strings`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = value.map((item, index) =>
|
|
31
|
+
assertString(item, `${label}[${index}]`),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if (new Set(result).size !== result.length) {
|
|
35
|
+
fail(`${label} must not contain duplicate values`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assertBoolean(value, label) {
|
|
42
|
+
if (typeof value !== "boolean") fail(`${label} must be a boolean`)
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLocalSource(source) {
|
|
47
|
+
return (
|
|
48
|
+
source === "." ||
|
|
49
|
+
source === ".." ||
|
|
50
|
+
source.startsWith("./") ||
|
|
51
|
+
source.startsWith("../") ||
|
|
52
|
+
source.startsWith("/") ||
|
|
53
|
+
/^[a-zA-Z]:[\\/]/.test(source)
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validateSource(source, label) {
|
|
58
|
+
if (isLocalSource(source)) {
|
|
59
|
+
fail(`${label} is a local path; use a remote Git, URL, or archive source`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (/\r|\n|\0/.test(source)) {
|
|
63
|
+
fail(`${label} contains a control character`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return source
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateRef(ref, label) {
|
|
70
|
+
if (/[\r\n\0#]/.test(ref)) {
|
|
71
|
+
fail(`${label} contains an invalid character`)
|
|
72
|
+
}
|
|
73
|
+
return ref
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeDefaults(rawDefaults) {
|
|
77
|
+
if (rawDefaults === undefined) return {
|
|
78
|
+
agents: [...DEFAULT_AGENTS],
|
|
79
|
+
scope: DEFAULT_SCOPE,
|
|
80
|
+
copy: DEFAULT_COPY,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!isPlainObject(rawDefaults)) fail("defaults must be an object")
|
|
84
|
+
|
|
85
|
+
const agents = rawDefaults.agents === undefined
|
|
86
|
+
? [...DEFAULT_AGENTS]
|
|
87
|
+
: assertStringArray(rawDefaults.agents, "defaults.agents")
|
|
88
|
+
const scope = rawDefaults.scope === undefined
|
|
89
|
+
? DEFAULT_SCOPE
|
|
90
|
+
: assertString(rawDefaults.scope, "defaults.scope")
|
|
91
|
+
const copy = rawDefaults.copy === undefined
|
|
92
|
+
? DEFAULT_COPY
|
|
93
|
+
: assertBoolean(rawDefaults.copy, "defaults.copy")
|
|
94
|
+
|
|
95
|
+
if (scope !== "global" && scope !== "project") {
|
|
96
|
+
fail('defaults.scope must be "global" or "project"')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { agents, scope, copy }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeCli(rawCli) {
|
|
103
|
+
if (rawCli === undefined) {
|
|
104
|
+
return { package: "skills", version: DEFAULT_SKILLS_CLI }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!isPlainObject(rawCli)) fail("cli must be an object")
|
|
108
|
+
|
|
109
|
+
const packageName = rawCli.package === undefined
|
|
110
|
+
? "skills"
|
|
111
|
+
: assertString(rawCli.package, "cli.package")
|
|
112
|
+
const version = rawCli.version === undefined
|
|
113
|
+
? DEFAULT_SKILLS_CLI
|
|
114
|
+
: assertString(rawCli.version, "cli.version")
|
|
115
|
+
|
|
116
|
+
if (/\s|[;&|<>`$]/.test(packageName)) {
|
|
117
|
+
fail("cli.package contains shell-like characters")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (/\s|[;&|<>`$]/.test(version)) {
|
|
121
|
+
fail("cli.version contains shell-like characters")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { package: packageName, version }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeSkill(rawSkill, index, defaults) {
|
|
128
|
+
if (!isPlainObject(rawSkill)) fail(`skills[${index}] must be an object`)
|
|
129
|
+
|
|
130
|
+
const name = assertString(rawSkill.name, `skills[${index}].name`)
|
|
131
|
+
const source = validateSource(
|
|
132
|
+
assertString(rawSkill.source, `skills[${index}].source`),
|
|
133
|
+
`skills[${index}].source`,
|
|
134
|
+
)
|
|
135
|
+
const ref = rawSkill.ref === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: validateRef(assertString(rawSkill.ref, `skills[${index}].ref`), `skills[${index}].ref`)
|
|
138
|
+
const description = rawSkill.description === undefined
|
|
139
|
+
? undefined
|
|
140
|
+
: assertString(rawSkill.description, `skills[${index}].description`)
|
|
141
|
+
const enabled = rawSkill.enabled === undefined
|
|
142
|
+
? true
|
|
143
|
+
: assertBoolean(rawSkill.enabled, `skills[${index}].enabled`)
|
|
144
|
+
const agents = rawSkill.agents === undefined
|
|
145
|
+
? [...defaults.agents]
|
|
146
|
+
: assertStringArray(rawSkill.agents, `skills[${index}].agents`)
|
|
147
|
+
const scope = rawSkill.scope === undefined
|
|
148
|
+
? defaults.scope
|
|
149
|
+
: assertString(rawSkill.scope, `skills[${index}].scope`)
|
|
150
|
+
const copy = rawSkill.copy === undefined
|
|
151
|
+
? defaults.copy
|
|
152
|
+
: assertBoolean(rawSkill.copy, `skills[${index}].copy`)
|
|
153
|
+
|
|
154
|
+
if (scope !== "global" && scope !== "project") {
|
|
155
|
+
fail(`skills[${index}].scope must be "global" or "project"`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const unknownKeys = Object.keys(rawSkill).filter((key) =>
|
|
159
|
+
!["name", "source", "description", "ref", "enabled", "agents", "scope", "copy"].includes(key),
|
|
160
|
+
)
|
|
161
|
+
if (unknownKeys.length > 0) {
|
|
162
|
+
fail(`skills[${index}] contains unknown field(s): ${unknownKeys.join(", ")}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { name, source, description, ref, enabled, agents, scope, copy }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function normalizeManifest(raw) {
|
|
169
|
+
if (!isPlainObject(raw)) fail("root value must be an object")
|
|
170
|
+
|
|
171
|
+
if (raw.version !== MANIFEST_VERSION) {
|
|
172
|
+
fail(`version must be ${MANIFEST_VERSION}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!Array.isArray(raw.skills) || raw.skills.length === 0) {
|
|
176
|
+
fail("skills must be a non-empty array")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const defaults = normalizeDefaults(raw.defaults)
|
|
180
|
+
const cli = normalizeCli(raw.cli)
|
|
181
|
+
const skills = raw.skills.map((skill, index) =>
|
|
182
|
+
normalizeSkill(skill, index, defaults),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const identities = new Set()
|
|
186
|
+
for (const skill of skills) {
|
|
187
|
+
const identity = JSON.stringify([
|
|
188
|
+
skill.source,
|
|
189
|
+
skill.ref ?? null,
|
|
190
|
+
skill.name,
|
|
191
|
+
skill.scope,
|
|
192
|
+
skill.agents,
|
|
193
|
+
])
|
|
194
|
+
if (identities.has(identity)) {
|
|
195
|
+
fail(`duplicate skill declaration: ${skill.name} from ${skill.source}`)
|
|
196
|
+
}
|
|
197
|
+
identities.add(identity)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { version: MANIFEST_VERSION, cli, defaults, skills }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function readManifest(filename) {
|
|
204
|
+
const absolutePath = path.resolve(filename)
|
|
205
|
+
let text
|
|
206
|
+
try {
|
|
207
|
+
text = await readFile(absolutePath, "utf8")
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw new Error(`Cannot read manifest ${absolutePath}: ${error.message}`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let raw
|
|
213
|
+
try {
|
|
214
|
+
raw = JSON.parse(text)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
throw new Error(`Cannot parse manifest ${absolutePath}: ${error.message}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { path: absolutePath, manifest: normalizeManifest(raw) }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function sourceWithRef(source, ref) {
|
|
223
|
+
if (!ref) return source
|
|
224
|
+
if (source.includes("#")) {
|
|
225
|
+
throw new Error(`Source already contains a ref fragment; remove ref or use one source ref: ${source}`)
|
|
226
|
+
}
|
|
227
|
+
return `${source}#${encodeURIComponent(ref)}`
|
|
228
|
+
}
|
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { sourceWithRef } from "./manifest.mjs"
|
|
5
|
+
|
|
6
|
+
function npxExecutable() {
|
|
7
|
+
return process.platform === "win32" ? "npx.cmd" : "npx"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function packageSpec(cli) {
|
|
11
|
+
if (!cli.version || cli.version === "latest") return cli.package
|
|
12
|
+
if (cli.package.startsWith("@")) {
|
|
13
|
+
const slash = cli.package.indexOf("/")
|
|
14
|
+
const at = cli.package.indexOf("@", slash)
|
|
15
|
+
if (at !== -1) return cli.package
|
|
16
|
+
}
|
|
17
|
+
if (cli.package.includes("@")) return cli.package
|
|
18
|
+
return `${cli.package}@${cli.version}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function commandFor(skill, cli) {
|
|
22
|
+
const args = ["--yes", packageSpec(cli), "add", sourceWithRef(skill.source, skill.ref)]
|
|
23
|
+
|
|
24
|
+
for (const agent of skill.agents) {
|
|
25
|
+
args.push("--agent", agent)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
args.push("--skill", skill.name)
|
|
29
|
+
|
|
30
|
+
if (skill.scope === "global") args.push("--global")
|
|
31
|
+
if (skill.copy) args.push("--copy")
|
|
32
|
+
args.push("--yes")
|
|
33
|
+
|
|
34
|
+
return { executable: npxExecutable(), args }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function quoteForDisplay(value) {
|
|
38
|
+
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(value)) return value
|
|
39
|
+
return process.platform === "win32"
|
|
40
|
+
? `'${value.replaceAll("'", "''")}'`
|
|
41
|
+
: `'${value.replaceAll("'", "'\\''")}'`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function displayCommand(command) {
|
|
45
|
+
return [command.executable, ...command.args].map(quoteForDisplay).join(" ")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function windowsNpxCli(env) {
|
|
49
|
+
const searchPath = Object.entries(env).find(([key]) => key.toLowerCase() === "path")?.[1] ?? ""
|
|
50
|
+
const candidates = [
|
|
51
|
+
env.npm_execpath && path.join(path.dirname(env.npm_execpath), "npx-cli.js"),
|
|
52
|
+
...searchPath.split(path.delimiter).filter(Boolean).map((directory) =>
|
|
53
|
+
path.join(directory.replace(/^"|"$/g, ""), "node_modules", "npm", "bin", "npx-cli.js"),
|
|
54
|
+
),
|
|
55
|
+
path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npx-cli.js"),
|
|
56
|
+
]
|
|
57
|
+
const filename = candidates.find((candidate) => candidate && existsSync(candidate))
|
|
58
|
+
if (!filename) {
|
|
59
|
+
throw new Error("Cannot find npm's npx-cli.js. Install Node.js with npm, then run npm run sync.")
|
|
60
|
+
}
|
|
61
|
+
return filename
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function runCommand(command, { cwd, env = process.env, timeout } = {}) {
|
|
65
|
+
try {
|
|
66
|
+
// Windows cannot spawn .cmd files directly. Run npx's JS entry point so
|
|
67
|
+
// manifest values stay literal arguments and never pass through a shell.
|
|
68
|
+
const useNode = process.platform === "win32" && command.executable === "npx.cmd"
|
|
69
|
+
return spawnSync(useNode ? process.execPath : command.executable,
|
|
70
|
+
useNode ? [windowsNpxCli(env), ...command.args] : command.args, {
|
|
71
|
+
cwd,
|
|
72
|
+
env,
|
|
73
|
+
timeout,
|
|
74
|
+
stdio: "inherit",
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
})
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return { status: null, error }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function statusCode(result) {
|
|
83
|
+
if (typeof result.status === "number") return result.status
|
|
84
|
+
if (result.error) return 1
|
|
85
|
+
return 1
|
|
86
|
+
}
|