@genspark/cli 1.0.24 → 1.0.25

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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * `gsk skills sync` — client-side materializer for the backend `skills_sync`
3
+ * action.
4
+ *
5
+ * Why client-side: `skills_sync` returns a digest-diffed bundle (base64 file
6
+ * bytes for changed skills, a removal list, per-slug errors); this module
7
+ * applies one such response to disk. Deliberately NOT parity with
8
+ * `setup.sh` (which unconditionally `rm -rf`s then `ln -sfn`s over the mount
9
+ * path — the synced skill always wins): here a pre-existing REAL directory
10
+ * under `mountDir` (an inline-tar builtin, or user content) is never
11
+ * clobbered by a same-named synced skill; the real dir wins and the slug is
12
+ * reported failed instead. Chosen because this materializer runs on a
13
+ * user's laptop, not a disposable sandbox — never `rm -rf`ing directories we
14
+ * didn't create is worth a failed sync entry. Index.ts (Task 6) drives the
15
+ * poll-until-`complete` loop and owns `--check`; this module only ever
16
+ * applies (never diffs) a response.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { error as logError } from '../logger.js';
22
+ import { safeJoin, SLUG_RE } from './skills-pull.js';
23
+ /**
24
+ * Folds the per-batch results of the multi-batch sync loop into single deduped
25
+ * totals. A slug can recur across batches — a server `error` row carries no
26
+ * recordable sha, so the server re-emits it in EVERY batch — and concatenating
27
+ * per-batch arrays would multi-count it, making the summary counts and the
28
+ * exit-4 gate wrong. Each category is a Set (dedup by slug); a slug's outcome
29
+ * in a later batch supersedes an earlier one. It also aggregates the echoed
30
+ * skill rows so `-o json` reflects every batch, not just the final one.
31
+ *
32
+ * Generic over the echoed-row type so the CLI can pass its own row shape
33
+ * without a cast (only `slug` is required here).
34
+ */
35
+ export class SyncAggregator {
36
+ _applied = new Set();
37
+ _removed = new Set();
38
+ _failed = new Set();
39
+ _rows = new Map();
40
+ /** Fold one batch's apply result and its echoed skill rows into the totals. */
41
+ add(result, rows) {
42
+ for (const slug of result.applied)
43
+ this.categorize(slug, this._applied);
44
+ for (const slug of result.removed)
45
+ this.categorize(slug, this._removed);
46
+ for (const slug of result.failed)
47
+ this.categorize(slug, this._failed);
48
+ for (const row of rows)
49
+ this._rows.set(row.slug, row);
50
+ }
51
+ categorize(slug, into) {
52
+ this._applied.delete(slug);
53
+ this._removed.delete(slug);
54
+ this._failed.delete(slug);
55
+ into.add(slug);
56
+ }
57
+ get appliedCount() {
58
+ return this._applied.size;
59
+ }
60
+ get removedCount() {
61
+ return this._removed.size;
62
+ }
63
+ get failedCount() {
64
+ return this._failed.size;
65
+ }
66
+ /** Aggregated echoed rows (deduped by slug, last-wins). */
67
+ get skills() {
68
+ return Array.from(this._rows.values());
69
+ }
70
+ }
71
+ export const STATE_FILENAME = 'skills-sync-state.json';
72
+ function expandHome(p) {
73
+ return p.replace(/^~(?=\/|$)/, os.homedir());
74
+ }
75
+ export function defaultStoreDir() {
76
+ return expandHome('~/.gsk/skills-store');
77
+ }
78
+ export function defaultMountDir() {
79
+ return expandHome('~/.opencode/skills');
80
+ }
81
+ /** Load the persisted `{slug: sha}` digest map. Both unparseable JSON AND a
82
+ * valid-JSON-but-wrong-shape file self-heal to `{}` — a dirty state file
83
+ * forces a full re-diff on the next sync rather than risk skipping a change
84
+ * against a state we can't trust. The shape check matters because the server
85
+ * verb rejects the WHOLE sync (`bad state digest map`) on a single bad key,
86
+ * so without validating here a corrupt map would wedge every sync forever;
87
+ * resetting to `{}` lets it recover on the next run. */
88
+ export function loadState(storeDir) {
89
+ try {
90
+ const raw = fs.readFileSync(path.join(storeDir, STATE_FILENAME), 'utf8');
91
+ const parsed = JSON.parse(raw);
92
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
93
+ const obj = parsed;
94
+ const wellShaped = Object.entries(obj).every(([k, v]) => SLUG_RE.test(k) && typeof v === 'string');
95
+ if (wellShaped)
96
+ return obj;
97
+ }
98
+ }
99
+ catch {
100
+ // absent or unparseable → treated as empty state
101
+ }
102
+ return {};
103
+ }
104
+ /** True when `<slug>`'s files are downloaded on disk. The STORE dir is the
105
+ * single source of truth for "materialized" — NOT the mount symlink. The mount
106
+ * is a best-effort, separately-reconciled concern (see `reconcileMount`): on a
107
+ * filesystem that can't hold a symlink (Windows without Developer Mode,
108
+ * exFAT/FAT/SMB — `symlinkSync` throws EPERM/ENOSYS) the mount is never created,
109
+ * yet the store IS. Keying materialization on the mount would then read the
110
+ * slug as "not materialized" on every `unchanged` re-sync, drop its sha, and
111
+ * re-download the whole store forever. `writeSlugAtomic` renames the tree into
112
+ * place in one step, so a present store dir is a complete one. A hand-deleted
113
+ * store dir (store missing) still reads false → the caller drops the sha and
114
+ * the next sync re-fetches (the F3 self-heal). */
115
+ function isSlugMaterialized(storeDir, slug) {
116
+ return fs.existsSync(path.join(storeDir, slug));
117
+ }
118
+ /** Best-effort reconcile of the mount symlink for an already-stored slug, run
119
+ * every sync (never touches the store, so it can't trigger a re-download):
120
+ * - `'mounted'` — the symlink now points at (or already pointed at) the
121
+ * live store dir.
122
+ * - `'blocked'` — a REAL dir/file occupies the mount path; the builtin /
123
+ * user content wins and we never clobber it (the module's core deviation
124
+ * from setup.sh). A benign, terminal state — not retried into a symlink.
125
+ * - `'unsupported'` — `symlinkSync` threw (filesystem can't hold a symlink),
126
+ * or the mount path can't be stat'd (a non-ENOENT fault). The store is
127
+ * intact; the caller keeps the sha but reports the slug not-mounted.
128
+ * The whole body is guarded: the initial `lstatSync` can itself throw a
129
+ * non-ENOENT error (e.g. ENOTDIR when `mountDir` is a file), which must be
130
+ * caught here rather than escape the batch before `saveState`. */
131
+ function reconcileMount(storeDir, mountDir, slug) {
132
+ const linkPath = path.join(mountDir, slug);
133
+ const targetPath = path.join(storeDir, slug);
134
+ try {
135
+ const stat = fs.lstatSync(linkPath, { throwIfNoEntry: false });
136
+ if (stat && !stat.isSymbolicLink())
137
+ return 'blocked';
138
+ if (stat &&
139
+ path.resolve(mountDir, fs.readlinkSync(linkPath)) === targetPath) {
140
+ return 'mounted'; // already our symlink → no churn
141
+ }
142
+ fs.mkdirSync(mountDir, { recursive: true });
143
+ if (stat)
144
+ fs.unlinkSync(linkPath); // stale / foreign symlink → repoint
145
+ fs.symlinkSync(targetPath, linkPath, 'dir');
146
+ return 'mounted';
147
+ }
148
+ catch {
149
+ return 'unsupported';
150
+ }
151
+ }
152
+ /** Human-facing reason a stored slug isn't mounted, per `reconcileMount`. */
153
+ function mountFailureMessage(slug, result) {
154
+ const why = result === 'blocked'
155
+ ? 'a real directory occupies the mount path (builtin/user content wins)'
156
+ : 'the filesystem does not support symlinks (Windows without Developer Mode, exFAT/FAT/SMB)';
157
+ return `skills sync: ${slug} stored but not mounted — ${why}`;
158
+ }
159
+ /** Sweep orphan `.tmp-<slug>-<pid>` staging dirs left by a killed run. Each
160
+ * `writeSlugAtomic` only clears its OWN pid's tmp dir, so a process killed
161
+ * mid-write leaks one; this best-effort sweep runs once per apply. Guarded to
162
+ * the exact tmp-dir shape so it can never touch a real skill dir. */
163
+ function sweepStaleTmpDirs(storeDir) {
164
+ let entries;
165
+ try {
166
+ entries = fs.readdirSync(storeDir);
167
+ }
168
+ catch {
169
+ return;
170
+ }
171
+ const tmpRe = /^\.tmp-[A-Za-z0-9][A-Za-z0-9_-]*-\d+$/;
172
+ for (const name of entries) {
173
+ if (!tmpRe.test(name))
174
+ continue;
175
+ try {
176
+ fs.rmSync(path.join(storeDir, name), { recursive: true, force: true });
177
+ }
178
+ catch {
179
+ // best-effort — a concurrent writer or locked dir just stays
180
+ }
181
+ }
182
+ }
183
+ function saveState(storeDir, state) {
184
+ fs.mkdirSync(storeDir, { recursive: true });
185
+ fs.writeFileSync(path.join(storeDir, STATE_FILENAME), JSON.stringify(state, null, 2) + '\n');
186
+ }
187
+ /**
188
+ * Write one `update` entry's files into `<storeDir>/<slug>` atomically: build
189
+ * the tree in a sibling `.tmp-` dir first, then remove any existing target
190
+ * and `renameSync` the tmp dir over it. A crash or crafted traversal path
191
+ * partway through the write leaves the tmp dir orphaned but the previous
192
+ * (or absent) target untouched — never a half-written skill on disk.
193
+ */
194
+ function writeSlugAtomic(storeDir, slug, files) {
195
+ const tmpDir = path.join(storeDir, `.tmp-${slug}-${process.pid}`);
196
+ fs.rmSync(tmpDir, { recursive: true, force: true });
197
+ fs.mkdirSync(tmpDir, { recursive: true });
198
+ try {
199
+ for (const file of files) {
200
+ const target = safeJoin(tmpDir, file.path);
201
+ fs.mkdirSync(path.dirname(target), { recursive: true });
202
+ fs.writeFileSync(target, Buffer.from(file.content_base64, 'base64'));
203
+ }
204
+ const finalDir = path.join(storeDir, slug);
205
+ fs.rmSync(finalDir, { recursive: true, force: true });
206
+ fs.renameSync(tmpDir, finalDir);
207
+ }
208
+ catch (e) {
209
+ fs.rmSync(tmpDir, { recursive: true, force: true });
210
+ throw e;
211
+ }
212
+ }
213
+ /** Remove a slug's store dir and mount symlink. Only unlinks the symlink
214
+ * when it actually points into `storeDir` — a repointed or foreign symlink
215
+ * (or a real directory) is left alone rather than risk deleting user data. */
216
+ function removeSlug(storeDir, mountDir, slug) {
217
+ const linkPath = path.join(mountDir, slug);
218
+ const stat = fs.lstatSync(linkPath, { throwIfNoEntry: false });
219
+ if (stat?.isSymbolicLink()) {
220
+ const resolved = path.resolve(mountDir, fs.readlinkSync(linkPath));
221
+ const storeSlugDir = path.join(storeDir, slug);
222
+ if (resolved === storeSlugDir) {
223
+ fs.unlinkSync(linkPath);
224
+ }
225
+ }
226
+ fs.rmSync(path.join(storeDir, slug), { recursive: true, force: true });
227
+ }
228
+ /**
229
+ * Apply one `skills_sync` response to disk. Never called in `--check` mode
230
+ * (Task 6's caller diffs against `loadState` output itself for that path).
231
+ */
232
+ export function applySyncResponse(data, opts = {}) {
233
+ // Absolutize (mirrors resolvePullDest in skills-pull.ts): a relative
234
+ // storeDir/mountDir would otherwise reach safeJoin's containment check as
235
+ // a relative `dest` while its computed `target` is always absolute
236
+ // (path.resolve), so `target.startsWith(dest + sep)` could never match
237
+ // and every `update` entry would be rejected as an unsafe path.
238
+ const storeDir = path.resolve(expandHome(opts.storeDir ?? defaultStoreDir()));
239
+ const mountDir = path.resolve(expandHome(opts.mountDir ?? defaultMountDir()));
240
+ fs.mkdirSync(storeDir, { recursive: true });
241
+ sweepStaleTmpDirs(storeDir);
242
+ const state = loadState(storeDir);
243
+ const applied = [];
244
+ const removed = [];
245
+ const failed = [];
246
+ for (const entry of data.skills) {
247
+ if (!SLUG_RE.test(entry.slug)) {
248
+ failed.push(entry.slug);
249
+ continue;
250
+ }
251
+ if (entry.action === 'update') {
252
+ try {
253
+ writeSlugAtomic(storeDir, entry.slug, entry.files ?? []);
254
+ }
255
+ catch (e) {
256
+ logError(`skills sync: ${entry.slug} failed: ${e instanceof Error ? e.message : String(e)}`);
257
+ failed.push(entry.slug);
258
+ continue;
259
+ }
260
+ // Record the synced sha the moment the STORE write succeeds — the content
261
+ // is materialized and sha-addressed regardless of whether the mount step
262
+ // then succeeds. The mount is reconciled separately (best-effort); its
263
+ // failure must never drop the sha, or the server would re-send this slug
264
+ // every sync (on a symlink-unsupported filesystem that re-downloads
265
+ // everything each run, and a large re-sent skill sorting early could
266
+ // starve later slugs out of the byte budget until the batch guard bailed).
267
+ if (entry.sha)
268
+ state[entry.slug] = entry.sha;
269
+ const mount = reconcileMount(storeDir, mountDir, entry.slug);
270
+ if (mount === 'mounted') {
271
+ applied.push(entry.slug);
272
+ }
273
+ else {
274
+ // Store is materialized and the sha recorded (no re-download); only the
275
+ // mount could not be made. Surface it once for this content change.
276
+ logError(mountFailureMessage(entry.slug, mount));
277
+ failed.push(entry.slug);
278
+ }
279
+ }
280
+ else if (entry.action === 'unchanged') {
281
+ // The server said "unchanged" only because our digest matched — but the
282
+ // store may be gone from disk (dir hand-deleted, or a state file restored
283
+ // from backup without its store). Trust the STORE, not the state file:
284
+ // carry the sha forward only when the store dir is present; otherwise DROP
285
+ // it so the next sync sees a digest miss and re-fetches (self-heal). The
286
+ // mount symlink is NOT part of this decision — see isSlugMaterialized.
287
+ if (entry.sha && isSlugMaterialized(storeDir, entry.slug)) {
288
+ state[entry.slug] = entry.sha;
289
+ // Retry the mount every sync (a transient/absent mount can recover),
290
+ // but never let it drop the sha or re-download. A real dir winning the
291
+ // mount ('blocked') is benign and self-heals to exit 0; a
292
+ // symlink-unsupported filesystem ('unsupported') keeps the slug failed
293
+ // (persistent exit 4 — the store is downloaded but can't be mounted).
294
+ if (reconcileMount(storeDir, mountDir, entry.slug) === 'unsupported') {
295
+ logError(mountFailureMessage(entry.slug, 'unsupported'));
296
+ failed.push(entry.slug);
297
+ }
298
+ }
299
+ else {
300
+ delete state[entry.slug];
301
+ }
302
+ }
303
+ else if (entry.action === 'removed') {
304
+ try {
305
+ removeSlug(storeDir, mountDir, entry.slug);
306
+ delete state[entry.slug];
307
+ removed.push(entry.slug);
308
+ }
309
+ catch (e) {
310
+ // Keep the state entry so the next sync retries this removal —
311
+ // a failed delete here (e.g. a locked store dir) must not be
312
+ // reported as done, and the batch's already-applied updates
313
+ // still need saveState() below to land.
314
+ logError(`skills sync: ${entry.slug} failed: ${e instanceof Error ? e.message : String(e)}`);
315
+ failed.push(entry.slug);
316
+ }
317
+ }
318
+ else if (entry.action === 'error') {
319
+ // Surface the server's per-slug code (NOT_FOUND / TOO_LARGE) like the
320
+ // update/removed branches log their failures — otherwise a server-side
321
+ // sync error was silently swallowed into the failed count with no reason.
322
+ logError(`skills sync: ${entry.slug} failed${entry.code ? ` (${entry.code})` : ''}`);
323
+ failed.push(entry.slug);
324
+ }
325
+ }
326
+ saveState(storeDir, state);
327
+ return { state, applied, removed, failed };
328
+ }
329
+ //# sourceMappingURL=skills-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skills-sync.js","sourceRoot":"","sources":["../../src/commands/skills-sync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAA;AACxB,OAAO,KAAK,EAAE,MAAM,IAAI,CAAA;AACxB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAA;AAE5B,OAAO,EAAE,KAAK,IAAI,QAAQ,EAAE,MAAM,cAAc,CAAA;AAChD,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAqCpD;;;;;;;;;;;GAWG;AACH,MAAM,OAAO,cAAc;IACR,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAA;IAC5B,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAA;IAC5B,OAAO,GAAG,IAAI,GAAG,EAAU,CAAA;IAC3B,KAAK,GAAG,IAAI,GAAG,EAAe,CAAA;IAE/C,+EAA+E;IAC/E,GAAG,CAAC,MAAuB,EAAE,IAAoB;QAC/C,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,OAAO;YAAE,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;QACvE,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,OAAO;YAAE,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;QACvE,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,MAAM;YAAE,IAAI,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,CAAC,CAAA;QACrE,KAAK,MAAM,GAAG,IAAI,IAAI;YAAE,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,CAAA;IACvD,CAAC;IAEO,UAAU,CAAC,IAAY,EAAE,IAAiB;QAChD,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;QAC1B,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;QAC1B,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;QACzB,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;IAChB,CAAC;IAED,IAAI,YAAY;QACd,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAA;IAC3B,CAAC;IACD,IAAI,YAAY;QACd,OAAO,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAA;IAC3B,CAAC;IACD,IAAI,WAAW;QACb,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,CAAA;IAC1B,CAAC;IAED,2DAA2D;IAC3D,IAAI,MAAM;QACR,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;IACxC,CAAC;CACF;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,wBAAwB,CAAA;AAEtD,SAAS,UAAU,CAAC,CAAS;IAC3B,OAAO,CAAC,CAAC,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;AAC9C,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,OAAO,UAAU,CAAC,qBAAqB,CAAC,CAAA;AAC1C,CAAC;AAED,MAAM,UAAU,eAAe;IAC7B,OAAO,UAAU,CAAC,oBAAoB,CAAC,CAAA;AACzC,CAAC;AAED;;;;;;wDAMwD;AACxD,MAAM,UAAU,SAAS,CAAC,QAAgB;IACxC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,cAAc,CAAC,EAAE,MAAM,CAAC,CAAA;QACxE,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QACvC,IAAI,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACnE,MAAM,GAAG,GAAG,MAAiC,CAAA;YAC7C,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,KAAK,CAC1C,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,CACrD,CAAA;YACD,IAAI,UAAU;gBAAE,OAAO,GAA6B,CAAA;QACtD,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,iDAAiD;IACnD,CAAC;IACD,OAAO,EAAE,CAAA;AACX,CAAC;AAED;;;;;;;;;;kDAUkD;AAClD,SAAS,kBAAkB,CAAC,QAAgB,EAAE,IAAY;IACxD,OAAO,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAA;AACjD,CAAC;AAKD;;;;;;;;;;;;kEAYkE;AAClE,SAAS,cAAc,CACrB,QAAgB,EAChB,QAAgB,EAChB,IAAY;IAEZ,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;IAC1C,MAAM,UAAU,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;IAC5C,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,EAAE,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC,CAAA;QAC9D,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE;YAAE,OAAO,SAAS,CAAA;QACpD,IACE,IAAI;YACJ,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,KAAK,UAAU,EAChE,CAAC;YACD,OAAO,SAAS,CAAA,CAAC,iCAAiC;QACpD,CAAC;QACD,EAAE,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QAC3C,IAAI,IAAI;YAAE,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAA,CAAC,oCAAoC;QACtE,EAAE,CAAC,WAAW,CAAC,UAAU,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAA;QAC3C,OAAO,SAAS,CAAA;IAClB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,aAAa,CAAA;IACtB,CAAC;AACH,CAAC;AAED,6EAA6E;AAC7E,SAAS,mBAAmB,CAC1B,IAAY,EACZ,MAAuC;IAEvC,MAAM,GAAG,GACP,MAAM,KAAK,SAAS;QAClB,CAAC,CAAC,sEAAsE;QACxE,CAAC,CAAC,0FAA0F,CAAA;IAChG,OAAO,gBAAgB,IAAI,6BAA6B,GAAG,EAAE,CAAA;AAC/D,CAAC;AAED;;;qEAGqE;AACrE,SAAS,iBAAiB,CAAC,QAAgB;IACzC,IAAI,OAAiB,CAAA;IACrB,IAAI,CAAC;QACH,OAAO,GAAG,EAAE,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAA;IACpC,CAAC;IAAC,MAAM,CAAC;QACP,OAAM;IACR,CAAC;IACD,MAAM,KAAK,GAAG,uCAAuC,CAAA;IACrD,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;QAC3B,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,SAAQ;QAC/B,IAAI,CAAC;YACH,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;QACxE,CAAC;QAAC,MAAM,CAAC;YACP,6DAA6D;QAC/D,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB,EAAE,KAA6B;IAChE,EAAE,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC3C,EAAE,CAAC,aAAa,CACd,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,cAAc,CAAC,EACnC,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,CACtC,CAAA;AACH,CAAC;AAED;;;;;;GAMG;AACH,SAAS,eAAe,CACtB,QAAgB,EAChB,IAAY,EACZ,KAAsB;IAEtB,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,QAAQ,IAAI,IAAI,OAAO,CAAC,GAAG,EAAE,CAAC,CAAA;IACjE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;IACnD,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IACzC,IAAI,CAAC;QACH,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;YAC1C,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;YACvD,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,QAAQ,CAAC,CAAC,CAAA;QACtE,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;QAC1C,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;QACrD,EAAE,CAAC,UAAU,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;IACjC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;QACnD,MAAM,CAAC,CAAA;IACT,CAAC;AACH,CAAC;AAED;;8EAE8E;AAC9E,SAAS,UAAU,CAAC,QAAgB,EAAE,QAAgB,EAAE,IAAY;IAClE,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;IAC1C,MAAM,IAAI,GAAG,EAAE,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,CAAC,CAAA;IAC9D,IAAI,IAAI,EAAE,cAAc,EAAE,EAAE,CAAC;QAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,CAAA;QAClE,MAAM,YAAY,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;QAC9C,IAAI,QAAQ,KAAK,YAAY,EAAE,CAAC;YAC9B,EAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAA;QACzB,CAAC;IACH,CAAC;IACD,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;AACxE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAC/B,IAAsB,EACtB,OAAoB,EAAE;IAEtB,qEAAqE;IACrE,0EAA0E;IAC1E,mEAAmE;IACnE,uEAAuE;IACvE,gEAAgE;IAChE,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,IAAI,eAAe,EAAE,CAAC,CAAC,CAAA;IAC7E,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,IAAI,eAAe,EAAE,CAAC,CAAC,CAAA;IAC7E,EAAE,CAAC,SAAS,CAAC,QAAQ,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC3C,iBAAiB,CAAC,QAAQ,CAAC,CAAA;IAE3B,MAAM,KAAK,GAAG,SAAS,CAAC,QAAQ,CAAC,CAAA;IACjC,MAAM,OAAO,GAAa,EAAE,CAAA;IAC5B,MAAM,OAAO,GAAa,EAAE,CAAA;IAC5B,MAAM,MAAM,GAAa,EAAE,CAAA;IAE3B,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAChC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YAC9B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;YACvB,SAAQ;QACV,CAAC;QACD,IAAI,KAAK,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YAC9B,IAAI,CAAC;gBACH,eAAe,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,KAAK,IAAI,EAAE,CAAC,CAAA;YAC1D,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,QAAQ,CACN,gBAAgB,KAAK,CAAC,IAAI,YAAY,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CACnF,CAAA;gBACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;gBACvB,SAAQ;YACV,CAAC;YACD,0EAA0E;YAC1E,yEAAyE;YACzE,uEAAuE;YACvE,yEAAyE;YACzE,oEAAoE;YACpE,qEAAqE;YACrE,2EAA2E;YAC3E,IAAI,KAAK,CAAC,GAAG;gBAAE,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,GAAG,CAAA;YAC5C,MAAM,KAAK,GAAG,cAAc,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,CAAA;YAC5D,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACxB,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;YAC1B,CAAC;iBAAM,CAAC;gBACN,wEAAwE;gBACxE,oEAAoE;gBACpE,QAAQ,CAAC,mBAAmB,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC,CAAA;gBAChD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;YACzB,CAAC;QACH,CAAC;aAAM,IAAI,KAAK,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;YACxC,wEAAwE;YACxE,0EAA0E;YAC1E,uEAAuE;YACvE,2EAA2E;YAC3E,yEAAyE;YACzE,uEAAuE;YACvE,IAAI,KAAK,CAAC,GAAG,IAAI,kBAAkB,CAAC,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC1D,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,GAAG,CAAA;gBAC7B,qEAAqE;gBACrE,uEAAuE;gBACvE,0DAA0D;gBAC1D,uEAAuE;gBACvE,sEAAsE;gBACtE,IAAI,cAAc,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,KAAK,aAAa,EAAE,CAAC;oBACrE,QAAQ,CAAC,mBAAmB,CAAC,KAAK,CAAC,IAAI,EAAE,aAAa,CAAC,CAAC,CAAA;oBACxD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;gBACzB,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,OAAO,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;YAC1B,CAAC;QACH,CAAC;aAAM,IAAI,KAAK,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YACtC,IAAI,CAAC;gBACH,UAAU,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,CAAC,IAAI,CAAC,CAAA;gBAC1C,OAAO,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;gBACxB,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;YAC1B,CAAC;YAAC,OAAO,CAAC,EAAE,CAAC;gBACX,+DAA+D;gBAC/D,6DAA6D;gBAC7D,4DAA4D;gBAC5D,wCAAwC;gBACxC,QAAQ,CACN,gBAAgB,KAAK,CAAC,IAAI,YAAY,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CACnF,CAAA;gBACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;YACzB,CAAC;QACH,CAAC;aAAM,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO,EAAE,CAAC;YACpC,sEAAsE;YACtE,uEAAuE;YACvE,0EAA0E;YAC1E,QAAQ,CACN,gBAAgB,KAAK,CAAC,IAAI,UAAU,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,KAAK,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAC3E,CAAA;YACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;QACzB,CAAC;IACH,CAAC;IAED,SAAS,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAA;IAC1B,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CAAA;AAC5C,CAAC"}
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { Command } from 'commander';
22
22
  import * as fs from 'fs';
23
23
  import * as http from 'http';
24
24
  import * as https from 'https';
25
+ import * as os from 'os';
25
26
  import * as pathModule from 'path';
26
27
  import { createRequire } from 'module';
27
28
  import { spawn } from 'child_process';
@@ -30,7 +31,7 @@ import { setDebugEnabled, setOutputFormat, debug, info, error as logError, outpu
30
31
  import { loadConfigFile, saveConfigFile, getConfigPath, loadToolsCache, saveToolsCache, setConfigPathOverride, } from './config.js';
31
32
  import { checkForUpdates } from './updater.js';
32
33
  import { registerDesignCommand } from './commands/design.js';
33
- import { registerMeshCommand, getMeshInvocation, runMesh } from './commands/mesh.js';
34
+ import { registerMeshCommand, getMeshInvocation, runMesh, } from './commands/mesh.js';
34
35
  import { executeCloneUrl, normalizeCloneUrlForGit, } from './commands/sb-git-clone.js';
35
36
  const require = createRequire(import.meta.url);
36
37
  const { version: VERSION } = require('../package.json');
@@ -74,6 +75,12 @@ const program = new Command();
74
75
  program
75
76
  .name('gsk')
76
77
  .description('Genspark Tool CLI - Search, crawl, analyze, and generate media')
78
+ // Agents drive this CLI blind (no schema in context): on a bad invocation,
79
+ // print the full usage + a did-you-mean hint so the caller can self-correct
80
+ // on the next attempt instead of burning a round on `--help`. Subcommands
81
+ // inherit both settings via Commander's copyInheritedSettings.
82
+ .showHelpAfterError()
83
+ .showSuggestionAfterError()
77
84
  .version(VERSION, '-v, --version')
78
85
  .option('--api-key <key>', 'API key (or set GSK_API_KEY env var)')
79
86
  .option('--base-url <url>', `Base URL for API (default: ${DEFAULT_BASE_URL})`, DEFAULT_BASE_URL)
@@ -451,6 +458,16 @@ function extractFileUrl(data, keys) {
451
458
  function resolveVdProjectId() {
452
459
  return process.env.GSK_VD_PROJECT_ID || fileConfig.vd_project_id;
453
460
  }
461
+ /**
462
+ * Expand a leading `~` to the user's home directory. `skills-sync.ts`
463
+ * applies this same expansion internally on the paths it defaults to, but
464
+ * a user-supplied `--store-dir`/`--mount-dir` needs it applied here too —
465
+ * `loadState()` is called from this file before the sync response comes
466
+ * back, and it does no expansion of its own.
467
+ */
468
+ function expandHome(p) {
469
+ return p.replace(/^~(?=\/|$)/, os.homedir());
470
+ }
454
471
  /**
455
472
  * Register a dynamic tool command from a server-provided schema.
456
473
  *
@@ -506,8 +523,15 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
506
523
  if (name === tool.cli.primary_arg)
507
524
  continue;
508
525
  // For create_task, make params optional so --acp mode works without them.
509
- // Validation of required params happens in the action handler.
510
- const isRequired = tool.name === 'create_task' ? false : required.includes(name);
526
+ // Validation of required params happens in the action handler. Likewise
527
+ // skills_save's `files` is server-required but CLI-populated (from the
528
+ // [path] directory collector, never typed by hand) — a Commander
529
+ // requiredOption would block every invocation before the action handler
530
+ // (and its collector) ever runs.
531
+ const isRequired = tool.name === 'create_task' ||
532
+ (tool.name === 'skills_save' && name === 'files')
533
+ ? false
534
+ : required.includes(name);
511
535
  const paramType = Array.isArray(param.type) ? param.type[0] : param.type;
512
536
  // Single-char aliases are short flags (-n); multi-char aliases must be
513
537
  // long flags (--page) — commander treats `-page` as a literal short flag,
@@ -594,6 +618,27 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
594
618
  if (tool.name === 'sb-git') {
595
619
  cmd.option('--execute [dest]', 'For action=clone-url: run `git clone` locally to [dest] (default: ./<repo>) instead of just printing the snippet');
596
620
  }
621
+ // For skills pull: `--out [dir]` writes the returned skill files to disk.
622
+ // Client-side because an HTTP/JSON tool can't materialise a tree on the
623
+ // user's machine — same shape as sb-git's `--execute`. Default dest is
624
+ // ./<slug>. The option is CLI-only and is stripped before the API call.
625
+ if (tool.name === 'skills_pull') {
626
+ cmd.option('--out [dir]', 'Directory to write the skill files into (default: ./<slug>)');
627
+ }
628
+ // For skills sync: the digest-diff materializer loop lives entirely on
629
+ // the client (Task 5's skills-sync.ts), so these are CLI-only knobs —
630
+ // stripped before the API call, never forwarded to the server.
631
+ if (tool.name === 'skills_sync') {
632
+ cmd.option('--check', 'report differences without writing');
633
+ cmd.option('--mount-dir <dir>', 'symlink mount directory (default ~/.opencode/skills)');
634
+ cmd.option('--store-dir <dir>', 'content store directory (default ~/.gsk/skills-store)');
635
+ }
636
+ // For skills save: the server's `files` param is an inline array with no
637
+ // `primary_arg`, so add our own positional for the local directory to
638
+ // read — same shape as `skills pull <ref> --out <dir>` in reverse.
639
+ if (tool.name === 'skills_save') {
640
+ cmd.argument('[path]', 'Local skill directory to save (default: current directory)');
641
+ }
597
642
  // Generic action handler
598
643
  cmd.action(async (primaryArgValue, opts) => {
599
644
  // Commander passes opts as second arg when there's an argument,
@@ -738,6 +783,56 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
738
783
  }
739
784
  delete args.execute;
740
785
  }
786
+ // skills pull --out is client-side only; never forward. `pull` always
787
+ // materialises to disk (default ./<slug>); --out just overrides where.
788
+ let skillsPullOut;
789
+ if (tool.name === 'skills_pull') {
790
+ const outVal = args.out;
791
+ skillsPullOut =
792
+ typeof outVal === 'string' && outVal.length > 0 ? outVal : undefined;
793
+ delete args.out;
794
+ }
795
+ // skills sync: --check/--mount-dir/--store-dir are CLI-only. The
796
+ // digest `state` sent to the server is always computed here (from
797
+ // the on-disk state file the materializer loop owns), never
798
+ // accepted as user input.
799
+ let skillsSyncCheck = false;
800
+ let skillsSyncStoreDir = '';
801
+ let skillsSyncMountDir = '';
802
+ if (tool.name === 'skills_sync') {
803
+ const { loadState, defaultStoreDir, defaultMountDir } = await import('./commands/skills-sync.js');
804
+ skillsSyncCheck = !!args.check;
805
+ skillsSyncStoreDir = args.storeDir
806
+ ? expandHome(args.storeDir)
807
+ : defaultStoreDir();
808
+ skillsSyncMountDir = args.mountDir
809
+ ? expandHome(args.mountDir)
810
+ : defaultMountDir();
811
+ delete args.check;
812
+ delete args.storeDir;
813
+ delete args.mountDir;
814
+ args.state = JSON.stringify(loadState(skillsSyncStoreDir));
815
+ }
816
+ // skills save: read the local directory (default cwd) and ship its
817
+ // files inline — the inverse of `skills pull`. Client-side because
818
+ // only the local machine can walk a directory tree.
819
+ if (tool.name === 'skills_save') {
820
+ const { collectSkillDir } = await import('./commands/skills-save.js');
821
+ const dir = pathModule.resolve(primaryArgValue || '.');
822
+ let collected;
823
+ try {
824
+ collected = collectSkillDir(dir);
825
+ }
826
+ catch (e) {
827
+ logError(`skills save: ${e.message}`);
828
+ process.exit(1);
829
+ }
830
+ if (collected.sidecarOwner) {
831
+ info(`Warning: ${dir} carries provenance for ${collected.sidecarOwner} — ` +
832
+ `saving will fork this skill into your own catalog.`);
833
+ }
834
+ args.files = collected.files;
835
+ }
741
836
  // aidrive: handle --local_file for the upload action.
742
837
  // Stream the file directly to AI Drive via the dedicated tool_cli
743
838
  // endpoint (POST /api/tool_cli/aidrive/upload) — no blob middleman.
@@ -809,7 +904,7 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
809
904
  const after = normalizeCloneUrlForGit(before);
810
905
  data.url = after;
811
906
  if (Array.isArray(data.commands)) {
812
- data.commands = data.commands.map((cmd) => typeof cmd === 'string' ? cmd.replace(before, after) : cmd);
907
+ data.commands = data.commands.map(cmd => typeof cmd === 'string' ? cmd.replace(before, after) : cmd);
813
908
  }
814
909
  }
815
910
  }
@@ -833,6 +928,96 @@ function registerToolCommand(parentProgram, tool, clientFactory, vdProjectIdDefa
833
928
  process.exit(code);
834
929
  return;
835
930
  }
931
+ // skills pull: decode the returned file rows + write the skill tree to
932
+ // disk (default ./<slug>, or --out <dir>). Client-side because the
933
+ // backend returns JSON, not a filesystem. Pull always materialises;
934
+ // the base64 blobs are dropped from the echoed result below.
935
+ if (tool.name === 'skills_pull' &&
936
+ result.status === 'ok' &&
937
+ result.data &&
938
+ typeof result.data === 'object') {
939
+ const { writeSkillFiles } = await import('./commands/skills-pull.js');
940
+ const code = writeSkillFiles(result.data, skillsPullOut);
941
+ if (code !== 0)
942
+ process.exit(code);
943
+ // Drop the (large, base64) file blobs from the echoed result — the
944
+ // user asked to write to disk, not to dump bytes to stdout.
945
+ const data = result.data;
946
+ delete data.files;
947
+ output(result);
948
+ return;
949
+ }
950
+ // skills sync: `--check` reports a diff without writing. Otherwise
951
+ // materialize the response via applySyncResponse (Task 5), looping
952
+ // while the server signals a truncated bundle (`complete: false`) —
953
+ // only the local machine holds the digest state across calls and
954
+ // can write the resulting file tree / symlinks.
955
+ if (tool.name === 'skills_sync' &&
956
+ result.status === 'ok' &&
957
+ result.data &&
958
+ typeof result.data === 'object') {
959
+ if (skillsSyncCheck) {
960
+ const data = result.data;
961
+ for (const entry of data.skills) {
962
+ const suffix = entry.code ? ` (${entry.code})` : '';
963
+ info(`${entry.slug}: ${entry.action}${suffix}`);
964
+ }
965
+ const dirty = data.skills.some(entry => entry.action !== 'unchanged');
966
+ for (const entry of data.skills)
967
+ delete entry.files;
968
+ output(result);
969
+ if (dirty)
970
+ process.exit(3);
971
+ return;
972
+ }
973
+ const { applySyncResponse, SyncAggregator } = await import('./commands/skills-sync.js');
974
+ const syncOpts = {
975
+ storeDir: skillsSyncStoreDir,
976
+ mountDir: skillsSyncMountDir,
977
+ };
978
+ let current = result;
979
+ // Fold every batch's outcome into deduped totals (see SyncAggregator):
980
+ // a server `error` row re-appears each batch, so concatenating would
981
+ // multi-count it and the summary + exit code would lie.
982
+ const agg = new SyncAggregator();
983
+ let iterations = 0;
984
+ for (;;) {
985
+ const data = current.data;
986
+ const applyResult = applySyncResponse(data, syncOpts);
987
+ agg.add(applyResult, current.data.skills);
988
+ if (data.complete !== false)
989
+ break;
990
+ iterations += 1;
991
+ if (iterations >= 20) {
992
+ logError('skills sync: exceeded 20 batches without completing — aborting');
993
+ process.exit(1);
994
+ }
995
+ current = await client.executeTool('skills_sync', {
996
+ state: JSON.stringify(applyResult.state),
997
+ });
998
+ if (current.status !== 'ok' || !current.data) {
999
+ logError(`skills sync: batch ${iterations} failed — ${current.message}`);
1000
+ process.exit(1);
1001
+ }
1002
+ }
1003
+ info(`synced ${agg.appliedCount} updated / ${agg.removedCount} removed / ${agg.failedCount} failed`);
1004
+ // Emit the aggregated (all-batch, deduped) skill rows rather than only
1005
+ // the final batch's, stripping the base64 file blobs first.
1006
+ const aggregatedSkills = agg.skills;
1007
+ for (const entry of aggregatedSkills)
1008
+ delete entry.files;
1009
+ current.data.skills = aggregatedSkills;
1010
+ output(current);
1011
+ // A boot script runs `sync` every startup and keys off the exit
1012
+ // code; a per-slug failure (mount refused, write error, server
1013
+ // `error` row) must not read as success. Exit 4 (distinct from 3 =
1014
+ // `--check` found a diff, and 1 = the sync loop itself aborted) so
1015
+ // the caller can tell "some skills didn't materialize" from a clean
1016
+ // run. Exit 0 stays reserved for fully-clean.
1017
+ if (agg.failedCount > 0)
1018
+ process.exit(4);
1019
+ return;
1020
+ }
836
1021
  // If -o was specified and tool succeeded, download the output file.
837
1022
  // Skip for create_task — it has its own export logic below.
838
1023
  if (tool.name !== 'create_task' &&