@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 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`.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }