@wangkh/skill-sync-cli 0.2.0 → 0.2.2
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 +49 -0
- package/README.zh-CN.md +30 -0
- package/bin/skill-sync.mjs +27 -2
- package/package.json +10 -2
- package/skills/skill-sync/SKILL.md +91 -0
- package/src/commands.mjs +204 -20
- package/src/mirror.mjs +36 -0
- package/src/revision.mjs +55 -0
- package/src/runner.mjs +42 -7
- package/src/state.mjs +63 -0
package/README.md
CHANGED
|
@@ -233,6 +233,40 @@ stops the batch; `npx @wangkh/skill-sync-cli sync --continue-on-error` attempts
|
|
|
233
233
|
entries and still exits with a failure status. Use `npx @wangkh/skill-sync-cli sync --dry-run` to
|
|
234
234
|
preview without installing.
|
|
235
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
|
+
|
|
265
|
+
Child `skills` commands run with `DO_NOT_TRACK=1`, so telemetry stays off. A
|
|
266
|
+
known Windows exit crash in the `skills` CLI after a completed install is
|
|
267
|
+
tolerated: when the files are in place, `sync` treats the group as installed
|
|
268
|
+
instead of failing.
|
|
269
|
+
|
|
236
270
|
For project-scoped skills, set:
|
|
237
271
|
|
|
238
272
|
```json
|
|
@@ -250,6 +284,21 @@ location. From the target application project, run:
|
|
|
250
284
|
npx @wangkh/skill-sync-cli sync --config /path/to/my-skills/skills.json
|
|
251
285
|
```
|
|
252
286
|
|
|
287
|
+
## Agent Skill
|
|
288
|
+
|
|
289
|
+
The repository ships an agent skill (`skills/skill-sync/SKILL.md`) that teaches
|
|
290
|
+
an agent how to use this CLI. Install it from the repository:
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
npx skills add yudaiyan/skill-sync-cli
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Or declare it in the manifest and let `sync` install it:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{ "name": "skill-sync", "source": "yudaiyan/skill-sync-cli" }
|
|
300
|
+
```
|
|
301
|
+
|
|
253
302
|
## Run Before the First Release
|
|
254
303
|
|
|
255
304
|
From a source checkout, the following commands work without installing project
|
package/README.zh-CN.md
CHANGED
|
@@ -120,6 +120,20 @@ npx --yes skills@1.5.22 add vercel-labs/skills --agent opencode --skill find-ski
|
|
|
120
120
|
|
|
121
121
|
两个 `--yes` 分别用于 npx 下载安装工具和 Skills 安装确认。`sync` 会按清单顺序安装;再次运行会重新安装或更新这些条目,可能覆盖目标位置同名 Skill 的内容。从清单移除或禁用一个条目后,已经安装的内容仍会保留。
|
|
122
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
|
+
|
|
135
|
+
wrapper 会以 `DO_NOT_TRACK=1` 运行 `skills` CLI(关闭遥测)。它在 Windows 上安装完成后偶发退出崩溃(0xC0000409);只要文件已就位,`sync` 会把该组视为安装成功,不再误判失败。
|
|
136
|
+
|
|
123
137
|
只预览,或者在单个 Skill 失败后继续处理其他条目:
|
|
124
138
|
|
|
125
139
|
```bash
|
|
@@ -129,6 +143,8 @@ npx @wangkh/skill-sync-cli sync --continue-on-error
|
|
|
129
143
|
|
|
130
144
|
默认遇到安装失败就停止并返回非零退出码。使用 `--continue-on-error` 后会继续执行,其间发生失败仍返回非零退出码。
|
|
131
145
|
|
|
146
|
+
失败的安装会自动重试,默认 2 次并带有短暂退避;可以用 `--retries <n>` 调整次数,`--retries 0` 表示不重试。中断信号不会被重试。
|
|
147
|
+
|
|
132
148
|
使用 `scope: "project"` 时,安装目标是运行命令的目录,与清单所在目录相互独立。在目标应用项目目录运行:
|
|
133
149
|
|
|
134
150
|
```bash
|
|
@@ -172,6 +188,20 @@ Git 负责在机器之间传递清单,`sync` 根据本地清单安装 Skills
|
|
|
172
188
|
|
|
173
189
|
清单是这个脚本定义的格式;脚本读取后逐项调用官方 `skills` CLI。上游的 `.skill-lock.json` 继续由官方 CLI 维护。
|
|
174
190
|
|
|
191
|
+
## 给 agent 用的 skill
|
|
192
|
+
|
|
193
|
+
仓库自带一个 agent skill(`skills/skill-sync/SKILL.md`),用来告诉 agent 如何使用本 CLI。可以直接安装:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npx skills add yudaiyan/skill-sync-cli
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
也可以加进清单,交给 `sync` 安装:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{ "name": "skill-sync", "source": "yudaiyan/skill-sync-cli" }
|
|
203
|
+
```
|
|
204
|
+
|
|
175
205
|
## 发布前使用
|
|
176
206
|
|
|
177
207
|
获取项目源码后,可以在源码目录直接运行,无需安装项目依赖:
|
package/bin/skill-sync.mjs
CHANGED
|
@@ -28,7 +28,9 @@ Options:
|
|
|
28
28
|
--from <url> Initialize from a remote manifest URL (init only)
|
|
29
29
|
--dry-run Print commands without downloading anything
|
|
30
30
|
--continue-on-error Continue after a skill installation fails
|
|
31
|
-
--
|
|
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
|
|
32
34
|
-h, --help Show this help
|
|
33
35
|
-v, --version Show the version
|
|
34
36
|
|
|
@@ -64,6 +66,16 @@ function parseArgs(argv) {
|
|
|
64
66
|
options.dryRun = true
|
|
65
67
|
} else if (arg === "--continue-on-error") {
|
|
66
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)
|
|
67
79
|
} else if (arg === "--force") {
|
|
68
80
|
options.force = true
|
|
69
81
|
} else if (arg === "--config" || arg === "-c" || arg.startsWith("--config=")) {
|
|
@@ -76,6 +88,16 @@ function parseArgs(argv) {
|
|
|
76
88
|
throw new Error("--config requires a file path")
|
|
77
89
|
}
|
|
78
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
|
|
79
101
|
} else if (arg === "--from" || arg.startsWith("--from=")) {
|
|
80
102
|
if (options.from !== undefined) {
|
|
81
103
|
throw new Error("Specify --from only once")
|
|
@@ -132,7 +154,10 @@ async function main() {
|
|
|
132
154
|
if (command === "validate") return validateCommand(filename)
|
|
133
155
|
if (command === "plan") return planCommand(filename)
|
|
134
156
|
if (command === "sync") {
|
|
135
|
-
return syncCommand(filename,
|
|
157
|
+
return syncCommand(filename, {
|
|
158
|
+
...options,
|
|
159
|
+
githubMirror: options.githubMirror ?? process.env.SKILL_SYNC_GITHUB_MIRROR,
|
|
160
|
+
})
|
|
136
161
|
}
|
|
137
162
|
|
|
138
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.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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"
|
|
@@ -20,6 +27,7 @@
|
|
|
20
27
|
"bin",
|
|
21
28
|
"src",
|
|
22
29
|
"schemas",
|
|
30
|
+
"skills",
|
|
23
31
|
"README.md",
|
|
24
32
|
"README.zh-CN.md",
|
|
25
33
|
"RELEASING.md",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-sync
|
|
3
|
+
description: >-
|
|
4
|
+
Manage the user's portable agent-skill manifest with the skill-sync CLI
|
|
5
|
+
(@wangkh/skill-sync-cli). Use when the user wants to sync, back up, or
|
|
6
|
+
restore agent skills across machines ("同步我的 skills", "恢复 skill 环境",
|
|
7
|
+
"新电脑安装 skills"), add or remove a skill in their manifest
|
|
8
|
+
("把 skill 加到清单"), preview or validate the manifest, or install skills
|
|
9
|
+
when GitHub is unreachable. Also use when a skill installation fails and the
|
|
10
|
+
user asks why or how to retry.
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# skill-sync
|
|
14
|
+
|
|
15
|
+
`@wangkh/skill-sync-cli` keeps a portable JSON manifest of third-party agent
|
|
16
|
+
skills and installs them through the official `skills` CLI. The manifest is the
|
|
17
|
+
single source of truth; the tool only writes it during `init`.
|
|
18
|
+
|
|
19
|
+
## When to use
|
|
20
|
+
|
|
21
|
+
- Sync or restore skills on a machine: `sync`
|
|
22
|
+
- Add a skill to the manifest after finding it: edit the manifest, then `sync`
|
|
23
|
+
- Preview or validate before installing: `plan`, `validate`
|
|
24
|
+
- GitHub is blocked or slow: run `sync --github-mirror https://gh-proxy.com/`
|
|
25
|
+
- An install failed: check retries, the mirror, or `--continue-on-error`
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
Run through npx so no global install is needed:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx @wangkh/skill-sync-cli init # create the manifest (keeps an existing one)
|
|
33
|
+
npx @wangkh/skill-sync-cli path # print the manifest path in use
|
|
34
|
+
npx @wangkh/skill-sync-cli validate # parse and validate without network
|
|
35
|
+
npx @wangkh/skill-sync-cli plan # print the exact skills commands
|
|
36
|
+
npx @wangkh/skill-sync-cli sync # install or update declared skills
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Useful options:
|
|
40
|
+
|
|
41
|
+
| Option | Meaning |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `-c, --config <file>` | Use another manifest file (all commands) |
|
|
44
|
+
| `--from <url>` | `init` from a published manifest URL |
|
|
45
|
+
| `--dry-run` | Preview without installing |
|
|
46
|
+
| `--continue-on-error` | Keep going after a failure; exit code stays non-zero |
|
|
47
|
+
| `--retries <n>` | Retry failed installs (default 2) |
|
|
48
|
+
| `--github-mirror <url>` | Route GitHub clones and update checks through a mirror |
|
|
49
|
+
| `--force` | `init`: replace the file; `sync`: reinstall even if unchanged |
|
|
50
|
+
|
|
51
|
+
## Manifest
|
|
52
|
+
|
|
53
|
+
Default location:
|
|
54
|
+
|
|
55
|
+
- Windows: `%USERPROFILE%\.config\skill-sync\skills.json`
|
|
56
|
+
- macOS/Linux: `~/.config/skill-sync/skills.json`
|
|
57
|
+
|
|
58
|
+
Skill entries require `name` (the upstream skill name) and `source`
|
|
59
|
+
(`owner/repo`, Git URL, skill URL, or archive). Optional fields are
|
|
60
|
+
`description`, `ref`, `enabled`, `agents`, `scope`, and `copy`; shared
|
|
61
|
+
defaults live under `defaults`, and the `skills` CLI version is pinned under
|
|
62
|
+
`cli`.
|
|
63
|
+
|
|
64
|
+
To add a skill, append an entry and run `sync`. Confirm the upstream name
|
|
65
|
+
first:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx --yes skills@1.5.22 add <owner>/<repo> --list
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Behavior worth knowing
|
|
72
|
+
|
|
73
|
+
- Entries sharing `source`, `ref`, `agents`, `scope`, and `copy` install with
|
|
74
|
+
one `skills add` call; a failed batch falls back to installing each skill.
|
|
75
|
+
- `sync` records revisions in `~/.config/skill-sync/state.json` and skips
|
|
76
|
+
groups whose revision and installed files are unchanged. `--force`
|
|
77
|
+
overrides the skip.
|
|
78
|
+
- When the update check cannot reach the remote but the installed copy still
|
|
79
|
+
exists, `sync` keeps it instead of failing.
|
|
80
|
+
- GitHub unreachable: `--github-mirror https://gh-proxy.com/` (alternatives:
|
|
81
|
+
`https://ghproxy.net/`, `https://ghfast.top/`). For the `skills` CLI
|
|
82
|
+
download itself, configure an npm registry mirror such as
|
|
83
|
+
`https://registry.npmmirror.com`.
|
|
84
|
+
|
|
85
|
+
## Security
|
|
86
|
+
|
|
87
|
+
A skill is agent instructions and may run scripts; review third-party skills
|
|
88
|
+
before adding them. Prefer trusted repositories and immutable commit refs.
|
|
89
|
+
|
|
90
|
+
Full documentation lives in the project README and
|
|
91
|
+
`schemas/skills-sync.schema.json`.
|
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,12 +9,31 @@ 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"
|
|
14
29
|
import { downloadManifest } from "./remote.mjs"
|
|
15
30
|
|
|
31
|
+
export const DEFAULT_RETRIES = 2
|
|
32
|
+
const DEFAULT_RETRY_DELAY_MS = 2000
|
|
33
|
+
// The skills CLI occasionally aborts on exit after a completed install on
|
|
34
|
+
// Windows (libuv teardown assertion). These are the observed exit codes.
|
|
35
|
+
const POST_INSTALL_CRASH_CODES = new Set([3221226505, -1073740791])
|
|
36
|
+
|
|
16
37
|
const SAMPLE_MANIFEST = {
|
|
17
38
|
version: 1,
|
|
18
39
|
cli: {
|
|
@@ -47,6 +68,50 @@ export async function validateCommand(manifestFilename) {
|
|
|
47
68
|
return 0
|
|
48
69
|
}
|
|
49
70
|
|
|
71
|
+
function sourceLabel(group) {
|
|
72
|
+
const { source, ref } = group[0]
|
|
73
|
+
return `${source}${ref ? ` @ ${ref}` : ""}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function groupLabel(group) {
|
|
77
|
+
if (group.length === 1) return `[${group[0].name}] ${sourceLabel(group)}`
|
|
78
|
+
return `[${sourceLabel(group)}] ${group.length} skills: ${group.map((skill) => skill.name).join(", ")}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printDescriptions(group) {
|
|
82
|
+
for (const skill of group) {
|
|
83
|
+
if (!skill.description) continue
|
|
84
|
+
console.log(group.length === 1 ? skill.description : `# ${skill.name}: ${skill.description}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function reportFailure(label, result) {
|
|
89
|
+
if (result.error) console.error(result.error.message)
|
|
90
|
+
if (result.signal) console.error(`[${label}] terminated by ${result.signal}`)
|
|
91
|
+
console.error(`[${label}] failed with exit code ${statusCode(result)}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveGroupRevision(skills, probe, probeOptions) {
|
|
95
|
+
const { source, ref } = skills[0]
|
|
96
|
+
if (isCommitSha(ref)) return { status: "known", revision: ref.toLowerCase() }
|
|
97
|
+
const url = gitUrlForSource(source)
|
|
98
|
+
if (!url) return { status: "unknown" }
|
|
99
|
+
return probe(url, ref, probeOptions)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runWithRetries(execute, command, { cwd, env, retries, retryDelayMs, label }) {
|
|
103
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
104
|
+
const result = execute(command, { cwd, env })
|
|
105
|
+
const code = statusCode(result)
|
|
106
|
+
if (code === 0 || result.signal || attempt >= retries) return result
|
|
107
|
+
|
|
108
|
+
const delayMs = retryDelayMs * (attempt + 1)
|
|
109
|
+
const delayText = delayMs > 0 ? ` in ${Math.round(delayMs / 1000)}s` : ""
|
|
110
|
+
console.error(`[${label}] attempt ${attempt + 1}/${retries + 1} failed with exit code ${code}; retrying${delayText}`)
|
|
111
|
+
if (delayMs > 0) await sleep(delayMs)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
50
115
|
export async function planCommand(manifestFilename) {
|
|
51
116
|
const { path: filename, manifest } = await readManifest(manifestFilename)
|
|
52
117
|
const skills = enabledSkills(manifest)
|
|
@@ -57,9 +122,11 @@ export async function planCommand(manifestFilename) {
|
|
|
57
122
|
return 0
|
|
58
123
|
}
|
|
59
124
|
|
|
60
|
-
for (const
|
|
61
|
-
|
|
62
|
-
|
|
125
|
+
for (const group of groupSkills(skills)) {
|
|
126
|
+
for (const skill of group) {
|
|
127
|
+
if (skill.description) console.log(`# ${skill.name}: ${skill.description}`)
|
|
128
|
+
}
|
|
129
|
+
console.log(displayCommand(commandForGroup(group, manifest.cli)))
|
|
63
130
|
}
|
|
64
131
|
return 0
|
|
65
132
|
}
|
|
@@ -68,36 +135,148 @@ export async function syncCommand(manifestFilename, options = {}) {
|
|
|
68
135
|
const { path: filename, manifest } = await readManifest(manifestFilename)
|
|
69
136
|
const skills = enabledSkills(manifest)
|
|
70
137
|
const execute = options.run ?? runCommand
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
138
|
+
const retries = options.retries ?? DEFAULT_RETRIES
|
|
139
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
|
|
140
|
+
const home = options.home ?? os.homedir()
|
|
141
|
+
const cwd = options.cwd ?? process.cwd()
|
|
142
|
+
const probe = options.probe ?? probeRevision
|
|
143
|
+
const gitEnv = mirrorGitEnv(options.githubMirror)
|
|
75
144
|
|
|
76
145
|
if (skills.length === 0) {
|
|
77
146
|
console.log("No enabled skills.")
|
|
78
147
|
return 0
|
|
79
148
|
}
|
|
80
149
|
|
|
150
|
+
// Resolve the full plan before installing so invalid refs cannot cause a
|
|
151
|
+
// partially applied manifest.
|
|
152
|
+
const groups = groupSkills(skills).map((group) => ({
|
|
153
|
+
skills: group,
|
|
154
|
+
command: commandForGroup(group, manifest.cli),
|
|
155
|
+
}))
|
|
156
|
+
|
|
157
|
+
const stateFilename = options.stateFile ?? statePath(home)
|
|
158
|
+
const state = await readState(stateFilename)
|
|
159
|
+
|
|
81
160
|
console.log(`Syncing ${skills.length} skill${skills.length === 1 ? "" : "s"} from ${filename}`)
|
|
82
161
|
let failures = 0
|
|
162
|
+
let skipped = 0
|
|
163
|
+
let stopped = false
|
|
83
164
|
|
|
84
|
-
for (const
|
|
85
|
-
|
|
86
|
-
if (skill.description) console.log(skill.description)
|
|
165
|
+
for (const group of groups) {
|
|
166
|
+
if (stopped) break
|
|
87
167
|
|
|
168
|
+
console.log(`\n${groupLabel(group.skills)}`)
|
|
88
169
|
if (options.dryRun) {
|
|
89
|
-
|
|
170
|
+
printDescriptions(group.skills)
|
|
171
|
+
console.log(displayCommand(group.command))
|
|
90
172
|
continue
|
|
91
173
|
}
|
|
92
174
|
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
175
|
+
const key = stateKey(group.skills)
|
|
176
|
+
const entry = stateEntry(state, filename, key)
|
|
177
|
+
const installScope = {
|
|
178
|
+
global: group.skills[0].scope === "global",
|
|
179
|
+
cwd,
|
|
180
|
+
home,
|
|
181
|
+
}
|
|
182
|
+
const installedHere = allInstalled(group.skills, installScope)
|
|
183
|
+
// A completed install followed by the known exit crash counts as success.
|
|
184
|
+
const executeTolerant = (command, runOptions) => {
|
|
185
|
+
const result = execute(command, runOptions)
|
|
186
|
+
if (POST_INSTALL_CRASH_CODES.has(statusCode(result)) && allInstalled(group.skills, installScope)) {
|
|
187
|
+
console.error(`[${sourceLabel(group.skills)}] the skills CLI crashed after installing (known Windows bug); treating the install as complete`)
|
|
188
|
+
return { status: 0 }
|
|
189
|
+
}
|
|
190
|
+
return result
|
|
191
|
+
}
|
|
192
|
+
// Only a previous install can be skipped, so check the remote before
|
|
193
|
+
// installing only when there is a record and the files are still present.
|
|
194
|
+
// A first install records the revision afterwards.
|
|
195
|
+
let revision = null
|
|
196
|
+
|
|
197
|
+
if (!options.force && entry && installedHere) {
|
|
198
|
+
revision = resolveGroupRevision(group.skills, probe, { env: gitEnv })
|
|
199
|
+
if (revision.status === "known" && entry.revision === revision.revision) {
|
|
200
|
+
console.log(`# unchanged (${revision.revision.slice(0, 7)}); skipping, use --force to reinstall`)
|
|
201
|
+
skipped += 1
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
// The remote cannot be checked right now, but the group was installed
|
|
205
|
+
// before and its files are still present, so keep the current copy.
|
|
206
|
+
if (revision.status === "error") {
|
|
207
|
+
console.error(`[${sourceLabel(group.skills)}] update check failed (${revision.message}); keeping the installed copy`)
|
|
208
|
+
skipped += 1
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let groupFailed = false
|
|
214
|
+
|
|
215
|
+
if (group.skills.length === 1) {
|
|
216
|
+
const skill = group.skills[0]
|
|
217
|
+
printDescriptions(group.skills)
|
|
218
|
+
const result = await runWithRetries(executeTolerant, group.command, {
|
|
219
|
+
cwd: options.cwd,
|
|
220
|
+
env: gitEnv,
|
|
221
|
+
retries,
|
|
222
|
+
retryDelayMs,
|
|
223
|
+
label: skill.name,
|
|
224
|
+
})
|
|
225
|
+
if (statusCode(result) !== 0) {
|
|
226
|
+
failures += 1
|
|
227
|
+
groupFailed = true
|
|
228
|
+
reportFailure(skill.name, result)
|
|
229
|
+
if (result.signal || !options.continueOnError) stopped = true
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// A batched command shares one fetch. Since a failure could come from a
|
|
233
|
+
// single bad name or a transient error, retry each entry individually
|
|
234
|
+
// when the batch does not succeed.
|
|
235
|
+
const batch = executeTolerant(group.command, { cwd: options.cwd, env: gitEnv })
|
|
236
|
+
if (statusCode(batch) !== 0) {
|
|
237
|
+
if (batch.signal) {
|
|
238
|
+
failures += group.skills.length
|
|
239
|
+
groupFailed = true
|
|
240
|
+
reportFailure(sourceLabel(group.skills), batch)
|
|
241
|
+
stopped = true
|
|
242
|
+
} else {
|
|
243
|
+
console.error(`[${sourceLabel(group.skills)}] batch install failed; retrying each skill individually`)
|
|
244
|
+
for (const skill of group.skills) {
|
|
245
|
+
const result = await runWithRetries(executeTolerant, commandFor(skill, manifest.cli), {
|
|
246
|
+
cwd: options.cwd,
|
|
247
|
+
env: gitEnv,
|
|
248
|
+
retries,
|
|
249
|
+
retryDelayMs,
|
|
250
|
+
label: skill.name,
|
|
251
|
+
})
|
|
252
|
+
if (statusCode(result) !== 0) {
|
|
253
|
+
failures += 1
|
|
254
|
+
groupFailed = true
|
|
255
|
+
reportFailure(skill.name, result)
|
|
256
|
+
if (result.signal || !options.continueOnError) {
|
|
257
|
+
stopped = true
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!groupFailed) {
|
|
267
|
+
if (!revision) revision = resolveGroupRevision(group.skills, probe, { env: gitEnv })
|
|
268
|
+
if (revision.status === "known") {
|
|
269
|
+
setStateEntry(state, filename, key, {
|
|
270
|
+
revision: revision.revision,
|
|
271
|
+
skills: group.skills.map((skill) => skill.name),
|
|
272
|
+
installedAt: new Date().toISOString(),
|
|
273
|
+
})
|
|
274
|
+
try {
|
|
275
|
+
await writeState(stateFilename, state)
|
|
276
|
+
} catch (error) {
|
|
277
|
+
console.error(`skill-sync: cannot write state ${stateFilename}: ${error.message}`)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
101
280
|
}
|
|
102
281
|
}
|
|
103
282
|
|
|
@@ -106,7 +285,12 @@ export async function syncCommand(manifestFilename, options = {}) {
|
|
|
106
285
|
return 1
|
|
107
286
|
}
|
|
108
287
|
|
|
109
|
-
if (!options.dryRun)
|
|
288
|
+
if (!options.dryRun) {
|
|
289
|
+
const skippedText = skipped > 0
|
|
290
|
+
? ` Skipped ${skipped} unchanged group${skipped === 1 ? "" : "s"}.`
|
|
291
|
+
: ""
|
|
292
|
+
console.log(`\nSync complete.${skippedText}`)
|
|
293
|
+
}
|
|
110
294
|
return 0
|
|
111
295
|
}
|
|
112
296
|
|
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/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
|
+
}
|
|
43
|
+
|
|
44
|
+
export function commandForGroup(skills, cli) {
|
|
45
|
+
const [{ source, ref, agents, scope, copy }] = skills
|
|
46
|
+
const args = ["--yes", packageSpec(cli), "add", sourceWithRef(source, ref)]
|
|
23
47
|
|
|
24
|
-
for (const agent of
|
|
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"
|
|
@@ -61,6 +89,13 @@ function windowsNpxCli(env) {
|
|
|
61
89
|
return filename
|
|
62
90
|
}
|
|
63
91
|
|
|
92
|
+
// The skills CLI can crash on exit on some Windows setups (a libuv teardown
|
|
93
|
+
// assertion) while its telemetry flush is pending. DO_NOT_TRACK disables that
|
|
94
|
+
// flush so the process exits cleanly; it is also a privacy default.
|
|
95
|
+
export function childEnv(env = process.env) {
|
|
96
|
+
return { ...env, DO_NOT_TRACK: "1" }
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
export function runCommand(command, { cwd, env = process.env, timeout } = {}) {
|
|
65
100
|
try {
|
|
66
101
|
// Windows cannot spawn .cmd files directly. Run npx's JS entry point so
|
|
@@ -69,7 +104,7 @@ export function runCommand(command, { cwd, env = process.env, timeout } = {}) {
|
|
|
69
104
|
return spawnSync(useNode ? process.execPath : command.executable,
|
|
70
105
|
useNode ? [windowsNpxCli(env), ...command.args] : command.args, {
|
|
71
106
|
cwd,
|
|
72
|
-
env,
|
|
107
|
+
env: childEnv(env),
|
|
73
108
|
timeout,
|
|
74
109
|
stdio: "inherit",
|
|
75
110
|
windowsHide: true,
|
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
|
+
}
|