@wangkh/skill-sync-cli 0.1.0 → 0.2.1
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/README.md +48 -4
- package/README.zh-CN.md +29 -4
- package/RELEASING.md +8 -13
- package/bin/skill-sync.mjs +42 -2
- package/package.json +9 -2
- package/src/commands.mjs +208 -23
- package/src/mirror.mjs +36 -0
- package/src/remote.mjs +51 -0
- package/src/revision.mjs +55 -0
- package/src/runner.mjs +34 -6
- package/src/state.mjs +63 -0
package/README.md
CHANGED
|
@@ -91,6 +91,18 @@ default user file. An explicit missing or invalid file produces an error when
|
|
|
91
91
|
reading; it never falls back to another manifest. Clone a remote configuration
|
|
92
92
|
repository first, then pass the path to its local JSON file.
|
|
93
93
|
|
|
94
|
+
To bootstrap from a manifest that is already published online, pass its file URL
|
|
95
|
+
to `init --from`. Gitee and GitHub `blob` page URLs are converted to raw file
|
|
96
|
+
URLs automatically, and the download is validated before anything is written:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npx @wangkh/skill-sync-cli init --from https://gitee.com/ai_1024/skill-sync/blob/main/skills.json
|
|
100
|
+
npx @wangkh/skill-sync-cli init --from https://gitee.com/ai_1024/skill-sync/blob/main/skills.json --config ./my-skills/skills.json
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Without `--config`, the manifest is written to the default user location;
|
|
104
|
+
existing files still require `--force`.
|
|
105
|
+
|
|
94
106
|
The `npx skills` package itself does not discover these files. This wrapper
|
|
95
107
|
reads the selected manifest, then delegates each installation to `npx skills`.
|
|
96
108
|
|
|
@@ -198,6 +210,9 @@ npx @wangkh/skill-sync-cli plan --config ./my-skills/skills.json
|
|
|
198
210
|
npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json
|
|
199
211
|
```
|
|
200
212
|
|
|
213
|
+
You can skip the clone and initialize directly from the published file URL with
|
|
214
|
+
`npx @wangkh/skill-sync-cli init --from <url>`.
|
|
215
|
+
|
|
201
216
|
After committing and pushing future manifest edits, update each existing clone:
|
|
202
217
|
|
|
203
218
|
```bash
|
|
@@ -218,6 +233,35 @@ stops the batch; `npx @wangkh/skill-sync-cli sync --continue-on-error` attempts
|
|
|
218
233
|
entries and still exits with a failure status. Use `npx @wangkh/skill-sync-cli sync --dry-run` to
|
|
219
234
|
preview without installing.
|
|
220
235
|
|
|
236
|
+
Entries that share `source`, `ref`, `agents`, `scope`, and `copy` are installed
|
|
237
|
+
with a single `skills add` call (`--skill name-a name-b`), so the repository is
|
|
238
|
+
fetched once. When a batched call fails, `sync` falls back to installing each
|
|
239
|
+
entry in that batch individually, which identifies the failing name. Failed
|
|
240
|
+
installs are retried up to `--retries <n>` times (default 2) with a short
|
|
241
|
+
backoff; interruptions are never retried.
|
|
242
|
+
|
|
243
|
+
`sync` records the installed revision per group in
|
|
244
|
+
`~/.config/skill-sync/state.json`. On later runs it checks the remote with
|
|
245
|
+
`git ls-remote` (immutable commit refs need no check) and skips groups whose
|
|
246
|
+
revision and installed files are unchanged, so unchanged skills are not
|
|
247
|
+
downloaded again. If the update check cannot reach the remote but the previous
|
|
248
|
+
install is still present, the current copy is kept. Use `--force` to reinstall
|
|
249
|
+
regardless.
|
|
250
|
+
|
|
251
|
+
On networks that cannot reach GitHub directly, pass `--github-mirror <url>`
|
|
252
|
+
(or set `SKILL_SYNC_GITHUB_MIRROR`) to route GitHub clones and update checks
|
|
253
|
+
through a mirror:
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
npx @wangkh/skill-sync-cli sync --github-mirror https://gh-proxy.com/
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The mirror is injected as a git `insteadOf` rewrite for the commands this tool
|
|
260
|
+
runs, so your global git configuration is not changed. Free public mirrors seen
|
|
261
|
+
working include `https://gh-proxy.com/`, `https://ghproxy.net/`, and
|
|
262
|
+
`https://ghfast.top/`. To speed up the `skills` CLI download itself on a fresh
|
|
263
|
+
machine, configure an npm registry mirror such as `https://registry.npmmirror.com`.
|
|
264
|
+
|
|
221
265
|
For project-scoped skills, set:
|
|
222
266
|
|
|
223
267
|
```json
|
|
@@ -263,7 +307,7 @@ npm run check
|
|
|
263
307
|
npm run test:package
|
|
264
308
|
```
|
|
265
309
|
|
|
266
|
-
This creates `dist/wangkh-skill-sync-cli-0.
|
|
310
|
+
This creates `dist/wangkh-skill-sync-cli-0.2.0.tgz` and `dist/package-check.json`. The
|
|
267
311
|
package check runs real npx commands offline, with a temporary home directory
|
|
268
312
|
and a fresh npm cache. It verifies initialization, external manifests, previews, and
|
|
269
313
|
error handling, then removes the temporary environment. The archive and report
|
|
@@ -272,9 +316,9 @@ remain in `dist/`.
|
|
|
272
316
|
You can run that archive directly. Replace the path with its absolute location:
|
|
273
317
|
|
|
274
318
|
```bash
|
|
275
|
-
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.
|
|
276
|
-
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.
|
|
277
|
-
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.
|
|
319
|
+
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.2.0.tgz skill-sync-cli --help
|
|
320
|
+
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.2.0.tgz skill-sync-cli init
|
|
321
|
+
npx --yes --package /absolute/path/wangkh-skill-sync-cli-0.2.0.tgz skill-sync-cli plan
|
|
278
322
|
```
|
|
279
323
|
|
|
280
324
|
These manual commands use your normal user configuration directory. To select a
|
package/README.zh-CN.md
CHANGED
|
@@ -48,6 +48,15 @@ npx @wangkh/skill-sync-cli path --config ./my-skills/skills.json
|
|
|
48
48
|
|
|
49
49
|
每次使用仓库清单时都传入 `--config`;省略时使用默认用户配置。远程配置仓库先用 Git 克隆到本机,再把本地 JSON 文件路径交给这个参数。
|
|
50
50
|
|
|
51
|
+
如果清单已经发布在 Gitee 或 GitHub 上,可以用 `init --from` 直接按链接初始化;`blob` 页面链接会自动转换为 raw 文件地址,下载后会先校验清单格式再写入:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx @wangkh/skill-sync-cli init --from https://gitee.com/ai_1024/skill-sync/blob/main/skills.json
|
|
55
|
+
npx @wangkh/skill-sync-cli init --from https://gitee.com/ai_1024/skill-sync/blob/main/skills.json --config ./my-skills/skills.json
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
不传 `--config` 时,清单写入默认用户配置位置;目标文件已存在时仍需显式加 `--force`。
|
|
59
|
+
|
|
51
60
|
## 清单格式
|
|
52
61
|
|
|
53
62
|
```json
|
|
@@ -111,6 +120,18 @@ npx --yes skills@1.5.22 add vercel-labs/skills --agent opencode --skill find-ski
|
|
|
111
120
|
|
|
112
121
|
两个 `--yes` 分别用于 npx 下载安装工具和 Skills 安装确认。`sync` 会按清单顺序安装;再次运行会重新安装或更新这些条目,可能覆盖目标位置同名 Skill 的内容。从清单移除或禁用一个条目后,已经安装的内容仍会保留。
|
|
113
122
|
|
|
123
|
+
`source`、`ref`、`agents`、`scope`、`copy` 相同的多个条目会合并成一次 `skills add`(一个 `--skill` 后跟多个名称),仓库只获取一次。合并安装失败时,会回退为逐个安装,便于定位具体失败的条目。
|
|
124
|
+
|
|
125
|
+
`sync` 会把每个分组的已安装版本记录在 `~/.config/skill-sync/state.json`。之后的运行会用 `git ls-remote` 检查远端版本(固定为 commit SHA 的 `ref` 无需检查),版本和已安装文件都没有变化时跳过该组,不再重复下载。检查更新失败但本地文件仍然存在时,会保留现有安装。需要强制重装时使用 `--force`。
|
|
126
|
+
|
|
127
|
+
如果所在网络无法直接访问 GitHub,可以用 `--github-mirror <url>`(或环境变量 `SKILL_SYNC_GITHUB_MIRROR`)让 clone 和版本检查走镜像:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npx @wangkh/skill-sync-cli sync --github-mirror https://gh-proxy.com/
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
镜像通过 git 的 `insteadOf` 重写注入,只对本次命令生效,不会修改全局 git 配置。实测可用的免费公益镜像包括 `https://gh-proxy.com/`、`https://ghproxy.net/`、`https://ghfast.top/`。新机器上如果 `skills` CLI 下载慢,可以配置 npm 镜像源 `https://registry.npmmirror.com`。
|
|
134
|
+
|
|
114
135
|
只预览,或者在单个 Skill 失败后继续处理其他条目:
|
|
115
136
|
|
|
116
137
|
```bash
|
|
@@ -120,6 +141,8 @@ npx @wangkh/skill-sync-cli sync --continue-on-error
|
|
|
120
141
|
|
|
121
142
|
默认遇到安装失败就停止并返回非零退出码。使用 `--continue-on-error` 后会继续执行,其间发生失败仍返回非零退出码。
|
|
122
143
|
|
|
144
|
+
失败的安装会自动重试,默认 2 次并带有短暂退避;可以用 `--retries <n>` 调整次数,`--retries 0` 表示不重试。中断信号不会被重试。
|
|
145
|
+
|
|
123
146
|
使用 `scope: "project"` 时,安装目标是运行命令的目录,与清单所在目录相互独立。在目标应用项目目录运行:
|
|
124
147
|
|
|
125
148
|
```bash
|
|
@@ -150,6 +173,8 @@ npx @wangkh/skill-sync-cli plan --config ./my-skills/skills.json
|
|
|
150
173
|
npx @wangkh/skill-sync-cli sync --config ./my-skills/skills.json
|
|
151
174
|
```
|
|
152
175
|
|
|
176
|
+
也可以不克隆,直接用 `npx @wangkh/skill-sync-cli init --from <文件链接>` 按链接初始化。
|
|
177
|
+
|
|
153
178
|
以后修改清单并提交、推送后,在已经克隆仓库的电脑上执行:
|
|
154
179
|
|
|
155
180
|
```bash
|
|
@@ -190,14 +215,14 @@ npm run check
|
|
|
190
215
|
npm run test:package
|
|
191
216
|
```
|
|
192
217
|
|
|
193
|
-
命令会生成 `dist/wangkh-skill-sync-cli-0.
|
|
218
|
+
命令会生成 `dist/wangkh-skill-sync-cli-0.2.0.tgz` 和验证报告 `dist/package-check.json`。安装包验证通过真实 npx 命令离线执行,使用临时用户目录和全新的 npm 缓存,检查初始化、外部清单、预览和错误处理。验证结束后清理临时环境,保留安装包及报告。
|
|
194
219
|
|
|
195
220
|
也可以直接用 npx 运行安装包。请把路径替换为安装包的实际绝对路径:
|
|
196
221
|
|
|
197
222
|
```powershell
|
|
198
|
-
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.
|
|
199
|
-
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.
|
|
200
|
-
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.
|
|
223
|
+
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.2.0.tgz" skill-sync-cli --help
|
|
224
|
+
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.2.0.tgz" skill-sync-cli init
|
|
225
|
+
npx --yes --package "C:\path\to\wangkh-skill-sync-cli-0.2.0.tgz" skill-sync-cli plan
|
|
201
226
|
```
|
|
202
227
|
|
|
203
228
|
这些手动命令默认使用正常的用户配置目录。使用仓库清单时,在子命令后加上 `--config "C:\path\to\my-skills\skills.json"`。
|
package/RELEASING.md
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
# 发布 0.
|
|
1
|
+
# 发布 0.2.0
|
|
2
2
|
|
|
3
3
|
## 发布信息
|
|
4
4
|
|
|
5
5
|
| 项目 | 值 |
|
|
6
6
|
| --- | --- |
|
|
7
7
|
| npm 包名 | `@wangkh/skill-sync-cli` |
|
|
8
|
-
| 版本 | `0.
|
|
8
|
+
| 版本 | `0.2.0` |
|
|
9
9
|
| 命令入口 | `npx @wangkh/skill-sync-cli`;安装后的可执行命令为 `skill-sync-cli` |
|
|
10
10
|
| 计划 GitHub 仓库 | `https://github.com/yudaiyan/skill-sync-cli` |
|
|
11
11
|
| 默认上游 CLI | `skills@1.5.22` |
|
|
12
12
|
| 默认配置位置 | `~/.config/skill-sync/skills.json` |
|
|
13
13
|
| 仓库清单 | `--config /path/to/my-skills/skills.json`,也支持 `-c` |
|
|
14
14
|
|
|
15
|
-
2026-09-10
|
|
15
|
+
0.1.0 已于 2026-09-10 发布到 npm;首次尝试非作用域包名 `skill-sync-cli` 时被拒(与已存在的 `skillsync-cli` 过于相似),随后改用 `@wangkh/skill-sync-cli`。发布需要 npm 账号的 2FA。GitHub 仓库已创建:`https://github.com/yudaiyan/skill-sync-cli`。0.2.0 新增 `init --from <url>`,支持从 Gitee / GitHub 文件链接初始化清单。
|
|
16
16
|
|
|
17
17
|
## 生成并验证安装包
|
|
18
18
|
|
|
@@ -25,22 +25,17 @@ npm run test:package
|
|
|
25
25
|
|
|
26
26
|
`test:package` 会实际执行 `npm pack`,随后用独立的用户目录和 npm 缓存离线运行安装包。输出:
|
|
27
27
|
|
|
28
|
-
- `dist/wangkh-skill-sync-cli-0.
|
|
28
|
+
- `dist/wangkh-skill-sync-cli-0.2.0.tgz`:可分发的 npm 安装包。
|
|
29
29
|
- `dist/package-check.json`:包摘要、文件清单、执行环境和通过的检查。
|
|
30
30
|
|
|
31
31
|
`dist/` 已加入 Git 忽略规则。修改代码或文档后,需要重新生成安装包,使报告对应最终内容。
|
|
32
32
|
|
|
33
33
|
## 正式发布流程
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
gh repo create yudaiyan/skill-sync-cli --public --source . --remote origin --push
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
确认 npm 登录身份,然后从项目源码目录发布:
|
|
35
|
+
确认改动已提交并推送 `main`,然后确认 npm 登录身份,从项目源码目录发布:
|
|
42
36
|
|
|
43
37
|
```bash
|
|
38
|
+
git push origin main
|
|
44
39
|
npm whoami --registry=https://registry.npmjs.org/
|
|
45
40
|
npm publish
|
|
46
41
|
```
|
|
@@ -51,8 +46,8 @@ npm publish
|
|
|
51
46
|
|
|
52
47
|
```bash
|
|
53
48
|
npm view @wangkh/skill-sync-cli version
|
|
54
|
-
npx --yes @wangkh/skill-sync-cli@0.
|
|
55
|
-
npx --yes @wangkh/skill-sync-cli@0.
|
|
49
|
+
npx --yes @wangkh/skill-sync-cli@0.2.0 --version
|
|
50
|
+
npx --yes @wangkh/skill-sync-cli@0.2.0 --help
|
|
56
51
|
```
|
|
57
52
|
|
|
58
53
|
## 后续版本
|
package/bin/skill-sync.mjs
CHANGED
|
@@ -25,9 +25,12 @@ Usage:
|
|
|
25
25
|
|
|
26
26
|
Options:
|
|
27
27
|
-c, --config <file> Use this JSON manifest (relative or absolute path)
|
|
28
|
+
--from <url> Initialize from a remote manifest URL (init only)
|
|
28
29
|
--dry-run Print commands without downloading anything
|
|
29
30
|
--continue-on-error Continue after a skill installation fails
|
|
30
|
-
--
|
|
31
|
+
--retries <n> Retry each failed install up to n times (default 2)
|
|
32
|
+
--github-mirror <url> Clone GitHub sources through a mirror (sync only)
|
|
33
|
+
--force Replace the selected manifest with init, or reinstall with sync
|
|
31
34
|
-h, --help Show this help
|
|
32
35
|
-v, --version Show the version
|
|
33
36
|
|
|
@@ -38,6 +41,7 @@ Examples:
|
|
|
38
41
|
npx ${COMMAND_NAME} sync
|
|
39
42
|
npx ${COMMAND_NAME} init --config ./my-skills/skills.json
|
|
40
43
|
npx ${COMMAND_NAME} sync --config ./my-skills/skills.json
|
|
44
|
+
npx ${COMMAND_NAME} init --from https://gitee.com/ai_1024/skill-sync/blob/main/skills.json
|
|
41
45
|
|
|
42
46
|
Without --config, use ~/.config/skill-sync/skills.json.
|
|
43
47
|
Relative config paths and project installations use the current working directory.
|
|
@@ -62,6 +66,16 @@ function parseArgs(argv) {
|
|
|
62
66
|
options.dryRun = true
|
|
63
67
|
} else if (arg === "--continue-on-error") {
|
|
64
68
|
options.continueOnError = true
|
|
69
|
+
} else if (arg === "--retries" || arg.startsWith("--retries=")) {
|
|
70
|
+
if (options.retries !== undefined) {
|
|
71
|
+
throw new Error("Specify --retries only once")
|
|
72
|
+
}
|
|
73
|
+
const inline = arg.startsWith("--retries=")
|
|
74
|
+
const value = inline ? arg.slice("--retries=".length) : argv[++index]
|
|
75
|
+
if (value === undefined || !/^\d+$/.test(value)) {
|
|
76
|
+
throw new Error("--retries requires a non-negative integer")
|
|
77
|
+
}
|
|
78
|
+
options.retries = Number(value)
|
|
65
79
|
} else if (arg === "--force") {
|
|
66
80
|
options.force = true
|
|
67
81
|
} else if (arg === "--config" || arg === "-c" || arg.startsWith("--config=")) {
|
|
@@ -74,6 +88,26 @@ function parseArgs(argv) {
|
|
|
74
88
|
throw new Error("--config requires a file path")
|
|
75
89
|
}
|
|
76
90
|
options.configFile = value
|
|
91
|
+
} else if (arg === "--github-mirror" || arg.startsWith("--github-mirror=")) {
|
|
92
|
+
if (options.githubMirror !== undefined) {
|
|
93
|
+
throw new Error("Specify --github-mirror only once")
|
|
94
|
+
}
|
|
95
|
+
const inline = arg.startsWith("--github-mirror=")
|
|
96
|
+
const value = inline ? arg.slice("--github-mirror=".length) : argv[++index]
|
|
97
|
+
if (value === undefined || value.trim() === "" || (!inline && value.startsWith("-"))) {
|
|
98
|
+
throw new Error("--github-mirror requires a URL")
|
|
99
|
+
}
|
|
100
|
+
options.githubMirror = value
|
|
101
|
+
} else if (arg === "--from" || arg.startsWith("--from=")) {
|
|
102
|
+
if (options.from !== undefined) {
|
|
103
|
+
throw new Error("Specify --from only once")
|
|
104
|
+
}
|
|
105
|
+
const inline = arg.startsWith("--from=")
|
|
106
|
+
const value = inline ? arg.slice("--from=".length) : argv[++index]
|
|
107
|
+
if (value === undefined || value.trim() === "" || (!inline && value.startsWith("-"))) {
|
|
108
|
+
throw new Error("--from requires a URL")
|
|
109
|
+
}
|
|
110
|
+
options.from = value
|
|
77
111
|
} else if (arg.startsWith("-")) {
|
|
78
112
|
throw new Error(`Unknown option: ${arg}`)
|
|
79
113
|
} else {
|
|
@@ -104,6 +138,9 @@ async function main() {
|
|
|
104
138
|
if (options.positional.length > 1) {
|
|
105
139
|
throw new Error(`Unexpected argument: ${options.positional[1]}`)
|
|
106
140
|
}
|
|
141
|
+
if (options.from !== undefined && command !== "init") {
|
|
142
|
+
throw new Error("--from is only supported by init")
|
|
143
|
+
}
|
|
107
144
|
|
|
108
145
|
const filename = resolveConfigPath(options.configFile)
|
|
109
146
|
if (command === "init") {
|
|
@@ -117,7 +154,10 @@ async function main() {
|
|
|
117
154
|
if (command === "validate") return validateCommand(filename)
|
|
118
155
|
if (command === "plan") return planCommand(filename)
|
|
119
156
|
if (command === "sync") {
|
|
120
|
-
return syncCommand(filename,
|
|
157
|
+
return syncCommand(filename, {
|
|
158
|
+
...options,
|
|
159
|
+
githubMirror: options.githubMirror ?? process.env.SKILL_SYNC_GITHUB_MIRROR,
|
|
160
|
+
})
|
|
121
161
|
}
|
|
122
162
|
|
|
123
163
|
throw new Error(`Unknown command: ${command}`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wangkh/skill-sync-cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Sync declared agent skills across machines through the skills CLI",
|
|
5
5
|
"author": "wkh",
|
|
6
6
|
"repository": {
|
|
@@ -11,7 +11,14 @@
|
|
|
11
11
|
"bugs": {
|
|
12
12
|
"url": "https://github.com/yudaiyan/skill-sync-cli/issues"
|
|
13
13
|
},
|
|
14
|
-
"keywords": [
|
|
14
|
+
"keywords": [
|
|
15
|
+
"skills",
|
|
16
|
+
"agent-skills",
|
|
17
|
+
"cli",
|
|
18
|
+
"dotfiles",
|
|
19
|
+
"opencode",
|
|
20
|
+
"codex"
|
|
21
|
+
],
|
|
15
22
|
"type": "module",
|
|
16
23
|
"bin": {
|
|
17
24
|
"skill-sync-cli": "bin/skill-sync.mjs"
|
package/src/commands.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { access, mkdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import os from "node:os"
|
|
2
3
|
import path from "node:path"
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises"
|
|
3
5
|
import {
|
|
4
6
|
readManifest,
|
|
5
7
|
normalizeManifest,
|
|
@@ -7,10 +9,27 @@ import {
|
|
|
7
9
|
} from "./manifest.mjs"
|
|
8
10
|
import {
|
|
9
11
|
commandFor,
|
|
12
|
+
commandForGroup,
|
|
10
13
|
displayCommand,
|
|
14
|
+
groupSkills,
|
|
11
15
|
runCommand,
|
|
12
16
|
statusCode,
|
|
13
17
|
} from "./runner.mjs"
|
|
18
|
+
import { mirrorGitEnv } from "./mirror.mjs"
|
|
19
|
+
import { gitUrlForSource, isCommitSha, probeRevision } from "./revision.mjs"
|
|
20
|
+
import {
|
|
21
|
+
allInstalled,
|
|
22
|
+
readState,
|
|
23
|
+
setStateEntry,
|
|
24
|
+
stateEntry,
|
|
25
|
+
stateKey,
|
|
26
|
+
statePath,
|
|
27
|
+
writeState,
|
|
28
|
+
} from "./state.mjs"
|
|
29
|
+
import { downloadManifest } from "./remote.mjs"
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_RETRIES = 2
|
|
32
|
+
const DEFAULT_RETRY_DELAY_MS = 2000
|
|
14
33
|
|
|
15
34
|
const SAMPLE_MANIFEST = {
|
|
16
35
|
version: 1,
|
|
@@ -46,6 +65,50 @@ export async function validateCommand(manifestFilename) {
|
|
|
46
65
|
return 0
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
function sourceLabel(group) {
|
|
69
|
+
const { source, ref } = group[0]
|
|
70
|
+
return `${source}${ref ? ` @ ${ref}` : ""}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function groupLabel(group) {
|
|
74
|
+
if (group.length === 1) return `[${group[0].name}] ${sourceLabel(group)}`
|
|
75
|
+
return `[${sourceLabel(group)}] ${group.length} skills: ${group.map((skill) => skill.name).join(", ")}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printDescriptions(group) {
|
|
79
|
+
for (const skill of group) {
|
|
80
|
+
if (!skill.description) continue
|
|
81
|
+
console.log(group.length === 1 ? skill.description : `# ${skill.name}: ${skill.description}`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function reportFailure(label, result) {
|
|
86
|
+
if (result.error) console.error(result.error.message)
|
|
87
|
+
if (result.signal) console.error(`[${label}] terminated by ${result.signal}`)
|
|
88
|
+
console.error(`[${label}] failed with exit code ${statusCode(result)}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveGroupRevision(skills, probe, probeOptions) {
|
|
92
|
+
const { source, ref } = skills[0]
|
|
93
|
+
if (isCommitSha(ref)) return { status: "known", revision: ref.toLowerCase() }
|
|
94
|
+
const url = gitUrlForSource(source)
|
|
95
|
+
if (!url) return { status: "unknown" }
|
|
96
|
+
return probe(url, ref, probeOptions)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function runWithRetries(execute, command, { cwd, env, retries, retryDelayMs, label }) {
|
|
100
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
101
|
+
const result = execute(command, { cwd, env })
|
|
102
|
+
const code = statusCode(result)
|
|
103
|
+
if (code === 0 || result.signal || attempt >= retries) return result
|
|
104
|
+
|
|
105
|
+
const delayMs = retryDelayMs * (attempt + 1)
|
|
106
|
+
const delayText = delayMs > 0 ? ` in ${Math.round(delayMs / 1000)}s` : ""
|
|
107
|
+
console.error(`[${label}] attempt ${attempt + 1}/${retries + 1} failed with exit code ${code}; retrying${delayText}`)
|
|
108
|
+
if (delayMs > 0) await sleep(delayMs)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
49
112
|
export async function planCommand(manifestFilename) {
|
|
50
113
|
const { path: filename, manifest } = await readManifest(manifestFilename)
|
|
51
114
|
const skills = enabledSkills(manifest)
|
|
@@ -56,9 +119,11 @@ export async function planCommand(manifestFilename) {
|
|
|
56
119
|
return 0
|
|
57
120
|
}
|
|
58
121
|
|
|
59
|
-
for (const
|
|
60
|
-
|
|
61
|
-
|
|
122
|
+
for (const group of groupSkills(skills)) {
|
|
123
|
+
for (const skill of group) {
|
|
124
|
+
if (skill.description) console.log(`# ${skill.name}: ${skill.description}`)
|
|
125
|
+
}
|
|
126
|
+
console.log(displayCommand(commandForGroup(group, manifest.cli)))
|
|
62
127
|
}
|
|
63
128
|
return 0
|
|
64
129
|
}
|
|
@@ -67,36 +132,138 @@ export async function syncCommand(manifestFilename, options = {}) {
|
|
|
67
132
|
const { path: filename, manifest } = await readManifest(manifestFilename)
|
|
68
133
|
const skills = enabledSkills(manifest)
|
|
69
134
|
const execute = options.run ?? runCommand
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
135
|
+
const retries = options.retries ?? DEFAULT_RETRIES
|
|
136
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
|
|
137
|
+
const home = options.home ?? os.homedir()
|
|
138
|
+
const cwd = options.cwd ?? process.cwd()
|
|
139
|
+
const probe = options.probe ?? probeRevision
|
|
140
|
+
const gitEnv = mirrorGitEnv(options.githubMirror)
|
|
74
141
|
|
|
75
142
|
if (skills.length === 0) {
|
|
76
143
|
console.log("No enabled skills.")
|
|
77
144
|
return 0
|
|
78
145
|
}
|
|
79
146
|
|
|
147
|
+
// Resolve the full plan before installing so invalid refs cannot cause a
|
|
148
|
+
// partially applied manifest.
|
|
149
|
+
const groups = groupSkills(skills).map((group) => ({
|
|
150
|
+
skills: group,
|
|
151
|
+
command: commandForGroup(group, manifest.cli),
|
|
152
|
+
}))
|
|
153
|
+
|
|
154
|
+
const stateFilename = options.stateFile ?? statePath(home)
|
|
155
|
+
const state = await readState(stateFilename)
|
|
156
|
+
|
|
80
157
|
console.log(`Syncing ${skills.length} skill${skills.length === 1 ? "" : "s"} from ${filename}`)
|
|
81
158
|
let failures = 0
|
|
159
|
+
let skipped = 0
|
|
160
|
+
let stopped = false
|
|
82
161
|
|
|
83
|
-
for (const
|
|
84
|
-
|
|
85
|
-
if (skill.description) console.log(skill.description)
|
|
162
|
+
for (const group of groups) {
|
|
163
|
+
if (stopped) break
|
|
86
164
|
|
|
165
|
+
console.log(`\n${groupLabel(group.skills)}`)
|
|
87
166
|
if (options.dryRun) {
|
|
88
|
-
|
|
167
|
+
printDescriptions(group.skills)
|
|
168
|
+
console.log(displayCommand(group.command))
|
|
89
169
|
continue
|
|
90
170
|
}
|
|
91
171
|
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
172
|
+
const key = stateKey(group.skills)
|
|
173
|
+
const entry = stateEntry(state, filename, key)
|
|
174
|
+
const installedHere = allInstalled(group.skills, {
|
|
175
|
+
global: group.skills[0].scope === "global",
|
|
176
|
+
cwd,
|
|
177
|
+
home,
|
|
178
|
+
})
|
|
179
|
+
// Only a previous install can be skipped, so check the remote before
|
|
180
|
+
// installing only when there is a record and the files are still present.
|
|
181
|
+
// A first install records the revision afterwards.
|
|
182
|
+
let revision = null
|
|
183
|
+
|
|
184
|
+
if (!options.force && entry && installedHere) {
|
|
185
|
+
revision = resolveGroupRevision(group.skills, probe, { env: gitEnv })
|
|
186
|
+
if (revision.status === "known" && entry.revision === revision.revision) {
|
|
187
|
+
console.log(`# unchanged (${revision.revision.slice(0, 7)}); skipping, use --force to reinstall`)
|
|
188
|
+
skipped += 1
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
// The remote cannot be checked right now, but the group was installed
|
|
192
|
+
// before and its files are still present, so keep the current copy.
|
|
193
|
+
if (revision.status === "error") {
|
|
194
|
+
console.error(`[${sourceLabel(group.skills)}] update check failed (${revision.message}); keeping the installed copy`)
|
|
195
|
+
skipped += 1
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let groupFailed = false
|
|
201
|
+
|
|
202
|
+
if (group.skills.length === 1) {
|
|
203
|
+
const skill = group.skills[0]
|
|
204
|
+
printDescriptions(group.skills)
|
|
205
|
+
const result = await runWithRetries(execute, group.command, {
|
|
206
|
+
cwd: options.cwd,
|
|
207
|
+
env: gitEnv,
|
|
208
|
+
retries,
|
|
209
|
+
retryDelayMs,
|
|
210
|
+
label: skill.name,
|
|
211
|
+
})
|
|
212
|
+
if (statusCode(result) !== 0) {
|
|
213
|
+
failures += 1
|
|
214
|
+
groupFailed = true
|
|
215
|
+
reportFailure(skill.name, result)
|
|
216
|
+
if (result.signal || !options.continueOnError) stopped = true
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// A batched command shares one fetch. Since a failure could come from a
|
|
220
|
+
// single bad name or a transient error, retry each entry individually
|
|
221
|
+
// when the batch does not succeed.
|
|
222
|
+
const batch = execute(group.command, { cwd: options.cwd, env: gitEnv })
|
|
223
|
+
if (statusCode(batch) !== 0) {
|
|
224
|
+
if (batch.signal) {
|
|
225
|
+
failures += group.skills.length
|
|
226
|
+
groupFailed = true
|
|
227
|
+
reportFailure(sourceLabel(group.skills), batch)
|
|
228
|
+
stopped = true
|
|
229
|
+
} else {
|
|
230
|
+
console.error(`[${sourceLabel(group.skills)}] batch install failed; retrying each skill individually`)
|
|
231
|
+
for (const skill of group.skills) {
|
|
232
|
+
const result = await runWithRetries(execute, commandFor(skill, manifest.cli), {
|
|
233
|
+
cwd: options.cwd,
|
|
234
|
+
env: gitEnv,
|
|
235
|
+
retries,
|
|
236
|
+
retryDelayMs,
|
|
237
|
+
label: skill.name,
|
|
238
|
+
})
|
|
239
|
+
if (statusCode(result) !== 0) {
|
|
240
|
+
failures += 1
|
|
241
|
+
groupFailed = true
|
|
242
|
+
reportFailure(skill.name, result)
|
|
243
|
+
if (result.signal || !options.continueOnError) {
|
|
244
|
+
stopped = true
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!groupFailed) {
|
|
254
|
+
if (!revision) revision = resolveGroupRevision(group.skills, probe, { env: gitEnv })
|
|
255
|
+
if (revision.status === "known") {
|
|
256
|
+
setStateEntry(state, filename, key, {
|
|
257
|
+
revision: revision.revision,
|
|
258
|
+
skills: group.skills.map((skill) => skill.name),
|
|
259
|
+
installedAt: new Date().toISOString(),
|
|
260
|
+
})
|
|
261
|
+
try {
|
|
262
|
+
await writeState(stateFilename, state)
|
|
263
|
+
} catch (error) {
|
|
264
|
+
console.error(`skill-sync: cannot write state ${stateFilename}: ${error.message}`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
100
267
|
}
|
|
101
268
|
}
|
|
102
269
|
|
|
@@ -105,11 +272,16 @@ export async function syncCommand(manifestFilename, options = {}) {
|
|
|
105
272
|
return 1
|
|
106
273
|
}
|
|
107
274
|
|
|
108
|
-
if (!options.dryRun)
|
|
275
|
+
if (!options.dryRun) {
|
|
276
|
+
const skippedText = skipped > 0
|
|
277
|
+
? ` Skipped ${skipped} unchanged group${skipped === 1 ? "" : "s"}.`
|
|
278
|
+
: ""
|
|
279
|
+
console.log(`\nSync complete.${skippedText}`)
|
|
280
|
+
}
|
|
109
281
|
return 0
|
|
110
282
|
}
|
|
111
283
|
|
|
112
|
-
export async function initCommand(manifestFilename, { force = false } = {}) {
|
|
284
|
+
export async function initCommand(manifestFilename, { force = false, from, fetchImpl } = {}) {
|
|
113
285
|
const filename = path.resolve(manifestFilename)
|
|
114
286
|
if (!force) {
|
|
115
287
|
try {
|
|
@@ -120,12 +292,25 @@ export async function initCommand(manifestFilename, { force = false } = {}) {
|
|
|
120
292
|
}
|
|
121
293
|
}
|
|
122
294
|
|
|
295
|
+
let text = `${JSON.stringify(SAMPLE_MANIFEST, null, 2)}\n`
|
|
296
|
+
let source
|
|
297
|
+
if (from !== undefined) {
|
|
298
|
+
const downloaded = await downloadManifest(from, { fetchImpl })
|
|
299
|
+
source = downloaded.url
|
|
300
|
+
try {
|
|
301
|
+
normalizeManifest(JSON.parse(downloaded.text))
|
|
302
|
+
} catch (error) {
|
|
303
|
+
throw new Error(`Remote manifest at ${source} is invalid: ${error.message}`)
|
|
304
|
+
}
|
|
305
|
+
text = downloaded.text.endsWith("\n") ? downloaded.text : `${downloaded.text}\n`
|
|
306
|
+
}
|
|
307
|
+
|
|
123
308
|
await mkdir(path.dirname(filename), { recursive: true })
|
|
124
|
-
await writeFile(filename,
|
|
309
|
+
await writeFile(filename, text, {
|
|
125
310
|
encoding: "utf8",
|
|
126
311
|
flag: force ? "w" : "wx",
|
|
127
312
|
})
|
|
128
|
-
console.log(`Created ${filename}`)
|
|
313
|
+
console.log(source ? `Created ${filename} from ${source}` : `Created ${filename}`)
|
|
129
314
|
return 0
|
|
130
315
|
}
|
|
131
316
|
|
package/src/mirror.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const GITHUB_BASE = "https://github.com/"
|
|
2
|
+
|
|
3
|
+
export function normalizeMirror(mirror) {
|
|
4
|
+
if (mirror === undefined || mirror === null || mirror === "") return undefined
|
|
5
|
+
if (typeof mirror !== "string") throw new Error("GitHub mirror must be a URL")
|
|
6
|
+
|
|
7
|
+
let url
|
|
8
|
+
try {
|
|
9
|
+
url = new URL(mirror.trim())
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`Invalid GitHub mirror URL: ${mirror}`)
|
|
12
|
+
}
|
|
13
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
14
|
+
throw new Error(`GitHub mirror must be an http(s) URL: ${mirror}`)
|
|
15
|
+
}
|
|
16
|
+
url.hash = ""
|
|
17
|
+
url.search = ""
|
|
18
|
+
const href = url.toString()
|
|
19
|
+
return href.endsWith("/") ? href : `${href}/`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Git rewrites https://github.com/... to <mirror>https://github.com/... through
|
|
23
|
+
// the GIT_CONFIG_* environment variables, so the rewrite applies to every git
|
|
24
|
+
// process this tool starts without touching the user's global configuration.
|
|
25
|
+
export function mirrorGitEnv(mirror, baseEnv = process.env) {
|
|
26
|
+
const normalized = normalizeMirror(mirror)
|
|
27
|
+
if (!normalized) return undefined
|
|
28
|
+
|
|
29
|
+
const env = { ...baseEnv }
|
|
30
|
+
const parsed = Number.parseInt(env.GIT_CONFIG_COUNT ?? "0", 10)
|
|
31
|
+
const start = Number.isInteger(parsed) && parsed >= 0 ? parsed : 0
|
|
32
|
+
env.GIT_CONFIG_COUNT = String(start + 1)
|
|
33
|
+
env[`GIT_CONFIG_KEY_${start}`] = `url.${normalized}${GITHUB_BASE}.insteadOf`
|
|
34
|
+
env[`GIT_CONFIG_VALUE_${start}`] = GITHUB_BASE
|
|
35
|
+
return env
|
|
36
|
+
}
|
package/src/remote.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024
|
|
2
|
+
|
|
3
|
+
export function normalizeManifestUrl(input) {
|
|
4
|
+
let parsed
|
|
5
|
+
try {
|
|
6
|
+
parsed = new URL(input)
|
|
7
|
+
} catch {
|
|
8
|
+
return input
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (parsed.hostname === "gitee.com") {
|
|
12
|
+
const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/(.+)$/)
|
|
13
|
+
if (match) return `${parsed.origin}/${match[1]}/${match[2]}/raw/${match[3]}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (parsed.hostname === "github.com") {
|
|
17
|
+
const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/blob\/(.+)$/)
|
|
18
|
+
if (match) return `https://raw.githubusercontent.com/${match[1]}/${match[2]}/${match[3]}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return input
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function downloadManifest(url, { fetchImpl = fetch, timeoutMs = 20000 } = {}) {
|
|
25
|
+
if (typeof url !== "string" || !/^https?:\/\//i.test(url)) {
|
|
26
|
+
throw new Error("--from requires an http(s) URL")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const normalized = normalizeManifestUrl(url)
|
|
30
|
+
let response
|
|
31
|
+
try {
|
|
32
|
+
response = await fetchImpl(normalized, {
|
|
33
|
+
redirect: "follow",
|
|
34
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
35
|
+
headers: { "user-agent": "skill-sync-cli" },
|
|
36
|
+
})
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw new Error(`Cannot download manifest from ${normalized}: ${error.message}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new Error(`Cannot download manifest from ${normalized}: HTTP ${response.status}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const text = await response.text()
|
|
46
|
+
if (Buffer.byteLength(text, "utf8") > MAX_MANIFEST_BYTES) {
|
|
47
|
+
throw new Error(`Remote manifest is too large (limit ${MAX_MANIFEST_BYTES} bytes): ${normalized}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { url: normalized, text: text.replace(/^\uFEFF/, "") }
|
|
51
|
+
}
|
package/src/revision.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
|
|
3
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/i
|
|
4
|
+
const SHORTHAND = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/
|
|
5
|
+
const KNOWN_GIT_HOSTS = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|gitee\.com)$/i
|
|
6
|
+
|
|
7
|
+
export function isCommitSha(ref) {
|
|
8
|
+
return typeof ref === "string" && COMMIT_SHA.test(ref)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Returns a URL git ls-remote can probe, or null when the source is not a
|
|
12
|
+
// plain Git repository (direct downloads, archives, unknown hosts).
|
|
13
|
+
export function gitUrlForSource(source) {
|
|
14
|
+
if (SHORTHAND.test(source) && !source.split("/").some((part) => part.startsWith("."))) {
|
|
15
|
+
return `https://github.com/${source}.git`
|
|
16
|
+
}
|
|
17
|
+
if (/^git@[^:]+:.+/.test(source)) return source
|
|
18
|
+
|
|
19
|
+
let url
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(source)
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null
|
|
26
|
+
if (!url.pathname.endsWith(".git") && !KNOWN_GIT_HOSTS.test(url.hostname)) return null
|
|
27
|
+
|
|
28
|
+
const tree = url.pathname.indexOf("/tree/")
|
|
29
|
+
if (tree !== -1) url.pathname = url.pathname.slice(0, tree)
|
|
30
|
+
if (!url.pathname.endsWith(".git")) url.pathname += ".git"
|
|
31
|
+
url.search = ""
|
|
32
|
+
url.hash = ""
|
|
33
|
+
return url.toString()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function probeRevision(url, ref, { timeoutMs = 10000, env = process.env } = {}) {
|
|
37
|
+
const result = spawnSync("git", ["ls-remote", url, ref ?? "HEAD"], {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
timeout: timeoutMs,
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
env: { ...env, GIT_TERMINAL_PROMPT: "0" },
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (result.error) return { status: "error", message: result.error.message }
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
const message = (result.stderr || "").trim() || `git ls-remote exited with code ${result.status}`
|
|
47
|
+
return { status: "error", message }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const line = (result.stdout || "").split(/\r?\n/).find((entry) => entry.trim() !== "")
|
|
51
|
+
if (!line) return { status: "unknown" }
|
|
52
|
+
const revision = line.trim().split(/\s+/)[0]
|
|
53
|
+
if (!COMMIT_SHA.test(revision)) return { status: "unknown" }
|
|
54
|
+
return { status: "known", revision: revision.toLowerCase() }
|
|
55
|
+
}
|
package/src/runner.mjs
CHANGED
|
@@ -18,22 +18,50 @@ function packageSpec(cli) {
|
|
|
18
18
|
return `${cli.package}@${cli.version}`
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
function groupKey(skill) {
|
|
22
|
+
return JSON.stringify([
|
|
23
|
+
skill.source,
|
|
24
|
+
skill.ref ?? null,
|
|
25
|
+
skill.agents,
|
|
26
|
+
skill.scope,
|
|
27
|
+
skill.copy,
|
|
28
|
+
])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// skills add accepts multiple names after one --skill, so entries that share
|
|
32
|
+
// every command-level flag can be installed with a single call (one fetch).
|
|
33
|
+
export function groupSkills(skills) {
|
|
34
|
+
const groups = new Map()
|
|
35
|
+
for (const skill of skills) {
|
|
36
|
+
const key = groupKey(skill)
|
|
37
|
+
const group = groups.get(key)
|
|
38
|
+
if (group) group.push(skill)
|
|
39
|
+
else groups.set(key, [skill])
|
|
40
|
+
}
|
|
41
|
+
return [...groups.values()]
|
|
42
|
+
}
|
|
23
43
|
|
|
24
|
-
|
|
44
|
+
export function commandForGroup(skills, cli) {
|
|
45
|
+
const [{ source, ref, agents, scope, copy }] = skills
|
|
46
|
+
const args = ["--yes", packageSpec(cli), "add", sourceWithRef(source, ref)]
|
|
47
|
+
|
|
48
|
+
for (const agent of agents) {
|
|
25
49
|
args.push("--agent", agent)
|
|
26
50
|
}
|
|
27
51
|
|
|
28
|
-
args.push("--skill", skill.name)
|
|
52
|
+
args.push("--skill", ...skills.map((skill) => skill.name))
|
|
29
53
|
|
|
30
|
-
if (
|
|
31
|
-
if (
|
|
54
|
+
if (scope === "global") args.push("--global")
|
|
55
|
+
if (copy) args.push("--copy")
|
|
32
56
|
args.push("--yes")
|
|
33
57
|
|
|
34
58
|
return { executable: npxExecutable(), args }
|
|
35
59
|
}
|
|
36
60
|
|
|
61
|
+
export function commandFor(skill, cli) {
|
|
62
|
+
return commandForGroup([skill], cli)
|
|
63
|
+
}
|
|
64
|
+
|
|
37
65
|
function quoteForDisplay(value) {
|
|
38
66
|
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(value)) return value
|
|
39
67
|
return process.platform === "win32"
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
|
|
6
|
+
export const STATE_VERSION = 1
|
|
7
|
+
export const STATE_FILENAME = "state.json"
|
|
8
|
+
|
|
9
|
+
export function statePath(home = os.homedir()) {
|
|
10
|
+
return path.join(home, ".config", "skill-sync", STATE_FILENAME)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The key covers everything that changes the installed result except the
|
|
14
|
+
// remote revision, so a new skill name or flag in the same group forces a
|
|
15
|
+
// fresh install.
|
|
16
|
+
export function stateKey(skills) {
|
|
17
|
+
const { source, ref, agents, scope, copy } = skills[0]
|
|
18
|
+
return JSON.stringify([
|
|
19
|
+
source,
|
|
20
|
+
ref ?? null,
|
|
21
|
+
agents,
|
|
22
|
+
scope,
|
|
23
|
+
copy,
|
|
24
|
+
skills.map((skill) => skill.name),
|
|
25
|
+
])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function readState(filename) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(await readFile(filename, "utf8"))
|
|
31
|
+
if (parsed?.version === STATE_VERSION && parsed.manifests && typeof parsed.manifests === "object") {
|
|
32
|
+
return parsed
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// A missing or unreadable state file only disables skipping.
|
|
36
|
+
}
|
|
37
|
+
return { version: STATE_VERSION, manifests: {} }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function writeState(filename, state) {
|
|
41
|
+
await mkdir(path.dirname(filename), { recursive: true })
|
|
42
|
+
await writeFile(filename, `${JSON.stringify(state, null, 2)}\n`, "utf8")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function stateEntry(state, manifestFilename, key) {
|
|
46
|
+
return state.manifests[manifestFilename]?.[key]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function setStateEntry(state, manifestFilename, key, entry) {
|
|
50
|
+
if (!state.manifests[manifestFilename]) state.manifests[manifestFilename] = {}
|
|
51
|
+
state.manifests[manifestFilename][key] = entry
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// skills installs a canonical copy under .agents/skills for both global and
|
|
55
|
+
// project scope, independently of the target agents and copy/symlink mode.
|
|
56
|
+
export function canonicalSkillPath(name, { global: isGlobal, cwd, home }) {
|
|
57
|
+
const base = isGlobal ? home : cwd
|
|
58
|
+
return path.join(base, ".agents", "skills", name)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function allInstalled(skills, options) {
|
|
62
|
+
return skills.every((skill) => existsSync(canonicalSkillPath(skill.name, options)))
|
|
63
|
+
}
|