@wangkh/skill-sync-cli 0.2.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 CHANGED
@@ -233,6 +233,35 @@ 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
+
236
265
  For project-scoped skills, set:
237
266
 
238
267
  ```json
package/README.zh-CN.md CHANGED
@@ -120,6 +120,18 @@ 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
+
123
135
  只预览,或者在单个 Skill 失败后继续处理其他条目:
124
136
 
125
137
  ```bash
@@ -129,6 +141,8 @@ npx @wangkh/skill-sync-cli sync --continue-on-error
129
141
 
130
142
  默认遇到安装失败就停止并返回非零退出码。使用 `--continue-on-error` 后会继续执行,其间发生失败仍返回非零退出码。
131
143
 
144
+ 失败的安装会自动重试,默认 2 次并带有短暂退避;可以用 `--retries <n>` 调整次数,`--retries 0` 表示不重试。中断信号不会被重试。
145
+
132
146
  使用 `scope: "project"` 时,安装目标是运行命令的目录,与清单所在目录相互独立。在目标应用项目目录运行:
133
147
 
134
148
  ```bash
@@ -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
- --force Replace the selected manifest with init
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, options)
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.0",
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": ["skills", "agent-skills", "cli", "dotfiles", "opencode", "codex"],
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,12 +9,28 @@ 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
+
16
34
  const SAMPLE_MANIFEST = {
17
35
  version: 1,
18
36
  cli: {
@@ -47,6 +65,50 @@ export async function validateCommand(manifestFilename) {
47
65
  return 0
48
66
  }
49
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
+
50
112
  export async function planCommand(manifestFilename) {
51
113
  const { path: filename, manifest } = await readManifest(manifestFilename)
52
114
  const skills = enabledSkills(manifest)
@@ -57,9 +119,11 @@ export async function planCommand(manifestFilename) {
57
119
  return 0
58
120
  }
59
121
 
60
- for (const skill of skills) {
61
- if (skill.description) console.log(`# ${skill.name}: ${skill.description}`)
62
- console.log(displayCommand(commandFor(skill, manifest.cli)))
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)))
63
127
  }
64
128
  return 0
65
129
  }
@@ -68,36 +132,138 @@ export async function syncCommand(manifestFilename, options = {}) {
68
132
  const { path: filename, manifest } = await readManifest(manifestFilename)
69
133
  const skills = enabledSkills(manifest)
70
134
  const execute = options.run ?? runCommand
71
-
72
- // Resolve the full plan before installing so invalid refs cannot cause a
73
- // partially applied manifest.
74
- const commands = skills.map((skill) => ({ skill, command: commandFor(skill, manifest.cli) }))
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)
75
141
 
76
142
  if (skills.length === 0) {
77
143
  console.log("No enabled skills.")
78
144
  return 0
79
145
  }
80
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
+
81
157
  console.log(`Syncing ${skills.length} skill${skills.length === 1 ? "" : "s"} from ${filename}`)
82
158
  let failures = 0
159
+ let skipped = 0
160
+ let stopped = false
83
161
 
84
- for (const { skill, command } of commands) {
85
- console.log(`\n[${skill.name}] ${skill.source}${skill.ref ? ` @ ${skill.ref}` : ""}`)
86
- if (skill.description) console.log(skill.description)
162
+ for (const group of groups) {
163
+ if (stopped) break
87
164
 
165
+ console.log(`\n${groupLabel(group.skills)}`)
88
166
  if (options.dryRun) {
89
- console.log(displayCommand(command))
167
+ printDescriptions(group.skills)
168
+ console.log(displayCommand(group.command))
90
169
  continue
91
170
  }
92
171
 
93
- const result = execute(command, { cwd: options.cwd })
94
- const code = statusCode(result)
95
- if (code !== 0) {
96
- failures += 1
97
- if (result.error) console.error(result.error.message)
98
- if (result.signal) console.error(`[${skill.name}] terminated by ${result.signal}`)
99
- console.error(`[${skill.name}] failed with exit code ${code}`)
100
- if (result.signal || !options.continueOnError) break
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
+ }
101
267
  }
102
268
  }
103
269
 
@@ -106,7 +272,12 @@ export async function syncCommand(manifestFilename, options = {}) {
106
272
  return 1
107
273
  }
108
274
 
109
- if (!options.dryRun) console.log("\nSync complete.")
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
+ }
110
281
  return 0
111
282
  }
112
283
 
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
+ }
@@ -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
- export function commandFor(skill, cli) {
22
- const args = ["--yes", packageSpec(cli), "add", sourceWithRef(skill.source, skill.ref)]
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
- for (const agent of skill.agents) {
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 (skill.scope === "global") args.push("--global")
31
- if (skill.copy) args.push("--copy")
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
+ }